diff --git a/docs/explanation/established-projects-faq.md b/docs/explanation/established-projects-faq.md index 7b100e41e..6d701c32c 100644 --- a/docs/explanation/established-projects-faq.md +++ b/docs/explanation/established-projects-faq.md @@ -15,13 +15,13 @@ Quick answers to common questions about working on established projects with the ### Do I have to run document-project first? -`bmad-document-project` is deprecated — its replacement is [`bmad-project-context`](project-context.md), which builds a small verified context system (kernel + bundle) instead of generated documentation. Running it first is highly recommended, especially if: +`bmad-document-project` is deprecated — its replacement is [`bmad-project-context`](project-context.md), which writes a small verified block into your repo's `AGENTS.md` instead of generated documentation. Running it first is highly recommended, especially if: - No existing documentation - Documentation is outdated - AI agents need context about existing code -You can skip it if agents already load a maintained kernel, or you'll use other tools or techniques to aid discovery for the agent to build on an existing system. +You can skip it if your repo already has maintained agent instructions, or you'll use other tools or techniques to aid discovery for the agent to build on an existing system. ### What if I forget to run document-project? diff --git a/docs/explanation/project-context-theory.md b/docs/explanation/project-context-theory.md index c360e2694..c575f8fdc 100644 --- a/docs/explanation/project-context-theory.md +++ b/docs/explanation/project-context-theory.md @@ -1,61 +1,100 @@ --- title: "The Theory of Project Context" -description: Why bmad-project-context captures so little, what earns a place in the context system, and what is deliberately left out +description: Why bmad-project-context captures so little, what earns a place in a repository's agent instructions, and what is deliberately left out sidebar: order: 11 --- -`bmad-project-context` is built on an uncomfortable finding: most documentation written *for* AI agents makes them worse. This page explains the theory behind the skill — what it captures and why, and more importantly, what it deliberately refuses to capture. If you are coming from `bmad-document-project` or `bmad-generate-project-context`, the second half explains exactly what changed and why. +`bmad-project-context` is built on an uncomfortable finding: most documentation written *for* AI agents makes them worse. This page explains the theory behind the skill — what it captures and why, and more importantly, what it deliberately refuses to capture. If you are coming from `bmad-document-project` or `bmad-generate-project-context`, the last section explains exactly what changed. For what the skill *does* and how to run it, see [Project Context](project-context.md) and the [how-to guide](../how-to/project-context.md). -## The problem with generated documentation +## The line: derivable or not -Three findings drive the design: +Written context earns its cost only when it carries something the agent cannot derive by reading the repository. -1. **LLM-generated context documents measurably degrade agent performance** — lower correctness at higher cost. A generated overview is a paraphrase of the code, and a paraphrase is worse than the code: it drops detail, it drifts the moment the code changes, and the agent trusts it instead of looking. -2. **Every line of always-loaded context is paid for in every session.** A 2,000-line context file is not "thorough" — it is a tax on every future task, most of it spent on things the agent would have gotten right anyway. -3. **Wrong context is worse than no context.** An agent with no documentation explores and finds the truth. An agent with a stale document confidently follows it off a cliff. Staleness is not a cosmetic problem; it is the failure mode. +Two results from different literatures locate the same boundary. Separating code reasoning from documentation memorization across repository-level tasks, **code access delivers the dominant gains over documentation access** — a document describing how the system works loses to the source it describes. Running the inverse experiment — generating requirements *from* code — models prove unreliable at producing anything not already implemented. Current behavior is recoverable from source. **Intent, rationale, and what was deliberately rejected are not.** -The conclusion: the valuable set is the **minimum of non-derivable, verified truths** — everything an agent cannot learn by reading the code, and nothing it can. +So anything derivable is read live and never stored. A stored copy is a stale duplicate of something the agent reads more accurately first-hand, and it is charged on every single call. + +## Why most AGENTS.md files measure as worthless + +The repository instruction file is the most-studied artifact of 2026, and its record on task correctness is poor. Measured present versus absent: **no improvement in success rate, and +20% inference cost.** Replicated on real repositories, with failures traced to implementation skill gaps rather than missing repository knowledge. In one study at scale, **randomly generated rules matched expert-curated ones.** + +That is a damning result until you notice what those files contain. They overwhelmingly restate what the repository already holds — structure, stack, architecture summaries. The studies measured *derivable* written context, not written context in general. + +## Why a short index in the always-loaded file is the opposite result + +One controlled comparison ran four configurations against framework APIs absent from the model's training data: + +| Configuration | Pass rate | +|---|---| +| No documentation | 53% | +| Reusable skill, unaided | 53% | +| Same skill, with explicit instructions to invoke it | 79% | +| **Compressed documentation index in `AGENTS.md`** | **100%** | + +Same file format as the null results above. Opposite content: knowledge the model did not have, rather than a restatement of the repo. The index was 8KB, compressed from 40KB with no loss in performance. + +The other half of that result matters just as much. The unaided skill was **never invoked in 56% of cases**. Adding explicit instructions raised trigger rates above 95% and still capped at 79%, with outcomes swinging on subtle wording changes. + +**Conditional retrieval is unreliable**, and separate measurements agree. In an ablation over a 709-page wiki, agents skipped the index entirely and inferred page paths from the question rather than fetching it. + +The rule that reconciles all of it: **an index the agent must choose to fetch gets skipped; an index already in context does not.** Anything load-bearing goes in the always-loaded file. Pointers out of it must name a trigger the agent can *observe* — a path, a file type, a concrete task — never one it must judge or self-monitor. ## What earns a place -The test for every line is the **pruning test**: *would removing this line change agent behavior?* If an agent would do the right thing anyway — because the code shows it, or because it is the ecosystem default — the line is noise. What passes: +The test for every line is the **pruning test**: *would removing this line change agent behavior?* -- **Commands where the obvious guess fails.** `npm install`, never `npm ci`, because lockfiles are deliberately gitignored. An agent cannot derive "deliberately" from a missing file. -- **Conventions that differ from defaults.** Only the divergences. "Use conventional commits" earns a line; "write tests for new code" does not — the agent already believes that. -- **Landmines.** The docs folder that predates two migrations. The workflow that looks live but is broken. The two generations of config variables that must not be mixed. These are the facts whose absence produces confident, wrong work. -- **Decision rationale.** *Why* the architecture is shaped this way — the code shows the shape, never the reason. Decisions are born in `bmad-architecture`; they live here. -- **Org requirements and domain facts** that exist nowhere in the repo at all. - -Everything captured is **verified before it is written as truth**: mined claims are checked against code (the trust ladder — code and configs are ground truth, existing docs are untrusted until proven), then confirmed with a human. Every entry carries its trust status (`verified` or `generated`), its sources, and its verification date. A claim nobody confirmed is stored as `generated` — visible as inference, never laundered into fact. +- **What a config file cannot say about running the project.** The invocation itself lives in `package.json`, a `Makefile`, or CI config and is read from there. What does not live there is the correction: the root test script does nothing in this workspace, integration tests need a service up first, the suite is slow enough that you should iterate on single files, CI runs a check the test script does not. +- **Policy the code cannot express.** Frozen paths, generated files, branch rules, security and compliance requirements. Admitted by authority, not by discovery. +- **Conventions that differ from ecosystem defaults.** Only the divergences. An agent follows the norm unless told otherwise, so a fact nobody would get wrong by default is not worth a line. +- **Known pitfalls, from observed failure only.** A repository yields hundreds of trap-looking facts, and no property of the fact separates the few that cause real mistakes — that signal exists only in observed behavior. A surprising scan finding becomes a question, never a line. +- **Negative constraints over positive guidance**, which measured better, and which is why a prohibition here always names the permitted alternative. ## What is deliberately not captured -The negative space is the design. Each exclusion has a reason: +The negative space is the design. | Not captured | Why | |---|---| -| **What the code already says** | Agents read source better than they read summaries of source. A paraphrase adds a second copy that rots while the original stays true. | -| **Repo structure and file maps** | Structure changes with every commit — stored maps rot fastest of all. Agents derive structure fresh in seconds with the tools they already have. | -| **Overview and tour documents** | The classic generated deliverable, and the measured harm: long overviews add wasted exploration and misplaced confidence. The kernel's job is to change behavior, not to orient a reader. | -| **Ecosystem defaults** | An LLM already knows how a typical Node, Python, or Go project works. Restating defaults spends budget teaching the agent what it arrived knowing. | -| **History and edit narration** | "We removed X because…" is banned prose. Git and the memlog own history; entries state present truth only, and supersession is a dated frontmatter field, not a story. | -| **Unverified inference presented as fact** | Anything not confirmed stays marked `generated`. The trust field is the contract: `verified` asserts a human was in the loop. | -| **User-facing documentation** | Tutorials, setup guides, and reference sites serve human readers — a different artifact with different rules. The skill will flag user docs that have drifted (as a landmine: "distrust docs/ on these topics") but it does not write or replace them. | -| **Aspirational state** | What the system *should* become belongs in specs and architecture documents. Context describes what *is* — an agent acting on aspiration ships fiction. | +| **What the code already says** | Agents read source better than summaries of source. A paraphrase adds a second copy that rots while the original stays true. | +| **Repo structure and file maps** | Structure changes with every commit — stored maps rot fastest of all, and agents derive structure fresh in seconds. | +| **Overview and tour documents** | The classic generated deliverable, and the measured harm. The block's job is to change behavior, not to orient a reader. | +| **Ecosystem defaults** | An LLM already knows how a typical Node, Python, or Go project works. Restating them spends budget teaching the agent what it arrived knowing. | +| **Anything included for being interesting** | Interest is not evidence of need. This is the failure mode the skill exists to avoid. | +| **Style rules an agent should self-enforce** | That job belongs to a formatter, linter, hook, or CI check. The skill proposes the check instead, and a check that lands deletes its line. | +| **History and edit narration** | "We removed X because…" is banned prose. Git owns history; the block states present truth only. | +| **Aspirational state** | What the system *should* become belongs in specs. An agent acting on aspiration ships fiction. | -The result is small by design. A healthy kernel is a screenful; a healthy bundle for a real repo is a dozen small entries. Small projects need the kernel and nothing else — that outcome is success, not an unfinished job. +The result is small by design. When the evidence supports ten lines, ten lines is the deliverable. + +## Retirement runs the other way + +There is one rule that inverts the pruning instinct, and getting it wrong quietly destroys the best content in the file. + +**A policy or pitfall line retires only when the thing it guards is gone** — removed, or now mechanically enforced — **or when a human retires it.** Absence of recent failures is never grounds. A working rule erases its own evidence, and much of the value of the block is the failures that no longer happen. + +## Two altitudes, two artifacts + +One artifact cannot serve both coding and planning work. The material divides, and the halves barely overlap. + +**Implementation context** — constraints, commands, conventions, pitfalls — is a property of a **code repository**. It is verifiable against the code, executably. It goes stale on every commit. It is loaded on every session, so it must be tiny. That is what this skill owns. + +**Planning context** — rationale, rejected approaches, ownership, domain meaning, org standards — is a property of a **project or initiative**. It is traceable only to source documents. It goes stale on organizational time, in months rather than hours. It is consulted in bursts, not loaded continuously. That is a different capability, and it is coming separately. + +Trying to serve both from one file is what produced the two skills this one replaced. ## Context is a liability to be re-earned -The old model treated documentation as an asset: more coverage, more value. This skill treats context as a **liability that must keep proving itself**. Staleness sweeps check every claim's sources against the repo; the audit intent applies the pruning test to every line and ends with the context smaller or equal, never larger; entries that merely paraphrase readable code are deleted. When a claim's source disappears, the claim is fixed against the new reality or removed — never quietly re-pointed at a document that happens to still mention it. +The old model treated documentation as an asset: more coverage, more value. This skill treats context as a **liability that must keep proving itself.** Refresh re-checks every caveat and diffs deletions and renames against every line. Audit applies the pruning test and ends with the block smaller or equal, never larger. When a claim's source disappears, the claim is fixed against the new reality or removed — never quietly re-pointed at a document that happens to still mention it. + +Generating the first version is the cheap part. Keeping it true is where the value is, and it is why refresh and audit exist as first-class intents rather than a note in the documentation. ## Versus the two replaced skills -`bmad-document-project` scanned a brownfield repo and generated a documentation tree — overview, source tree, per-area deep dives. It embodied the asset model, and the evidence went against it: the output was large, unverified, stale on arrival, and precisely the kind of context that degrades agents. Its valid instinct — *understand the repo before working in it* — survives as the ingest scan, which now feeds verification instead of prose generation. Where it would have described the repo's structure, the new skill lets agents derive structure fresh; where it would have summarized code, the new skill writes nothing. +`bmad-document-project` scanned a brownfield repo and generated a documentation tree — overview, source tree, per-area deep dives. It embodied the asset model, and the evidence went against it: large, unverified, stale on arrival, and precisely the kind of context that degrades agents. Its valid instinct — *understand the repo before working in it* — survives as the discovery pass, which now feeds verification instead of prose generation. -`bmad-generate-project-context` had the right instinct — a single small rules file of unobvious, project-specific facts — and that instinct is now the whole architecture. What it lacked was everything around the file: no verification (its content was as trusted as its generation run was lucky), no trust marks, no staleness model, no maintenance loop, and no room for the *why* behind the rules. The kernel is its descendant, held to a measured budget; the bundle carries the rationale it had nowhere to put; ingest/audit keep both true over time. An existing `project-context.md` keeps loading and becomes a mining source on the next ingest. +`bmad-generate-project-context` had the right instinct: a single small rules file of unobvious, project-specific facts. That instinct is now the whole architecture. What it lacked was everything around the file — no verification, no maintenance loop, and no way to tell an inference from a confirmed fact. -The one-line version: the old skills wrote more documentation; this skill maintains less truth — and less, verified, wins. +The one-line version: the old skills wrote more documentation; this skill maintains less truth, and less, verified, wins. diff --git a/docs/explanation/project-context.md b/docs/explanation/project-context.md index b7cf24ff1..7fc9a7476 100644 --- a/docs/explanation/project-context.md +++ b/docs/explanation/project-context.md @@ -1,44 +1,55 @@ --- title: "Project Context" -description: How bmad-project-context curates the verified knowledge AI agents load — a small kernel plus a knowledge bundle +description: How bmad-project-context writes a repository's agent instructions — a small verified block in AGENTS.md sidebar: order: 10 --- -`bmad-project-context` owns everything the code can't tell an AI agent: why the architecture is shaped this way, which conventions are deliberate, what the org requires, which landmines a fresh session must know before touching anything. It maintains that knowledge as a small, verified context system instead of generated documentation. +`bmad-project-context` sets up a repository so AI agents work well in it. The output is a small verified block inside the repo's `AGENTS.md`: what the org requires, the commands that were actually run, the conventions where the obvious guess is wrong, and the mistakes agents keep making here. -The evidence behind the design is blunt: LLM-generated context docs measurably make agents *worse* (lower correctness at higher cost), and long overview documents add wasted exploration. What works is a tiny always-loaded file, small verified entries loaded on demand, and mechanical maps produced fresh on demand. So the skill curates the minimum non-derivable set and never describes what the code already says. For the full reasoning — including what is deliberately *not* captured and why — see [The Theory of Project Context](project-context-theory.md). +It is a conversation, not a generator. You bring the rules you want followed — governance, security, coding standards — and it discovers and verifies the rest. The human is in the loop for every write; there is no unattended mode. -## The two artifacts +For the full reasoning, including what is deliberately *not* captured and why, see [The Theory of Project Context](project-context-theory.md). -**The kernel** (`kernel.md`) is one small file loaded into every agent session — exact commands where the obvious guess fails, conventions that differ from ecosystem defaults, landmines, hard org requirements. It lives under an instruction budget (~150–200 instructions), is priority-ordered, and every line must pass the pruning test: *would removing this line change agent behavior?* Small projects need the kernel and nothing else. +## What goes in, and what doesn't -**The bundle** is a directory of small markdown entries behind the kernel — architecture rationale, the *why* behind conventions, domain facts, decision history. Each entry carries frontmatter with trust signals: `verified` (a human confirmed it, or it was path-checked with a human in the loop) or `generated` (inferred, unconfirmed — everything a headless run writes). `index.md` is the sole entry point; entries are loaded on demand, never wholesale. +The governing line is whether a fact can be derived by reading the repository. Agents read code more accurately than they read prose describing code, and a stored description is a stale duplicate charged on every call. So repo overviews, directory trees and tech-stack lists never enter. -Both live in your `project_knowledge` folder (the standard install setting, default `docs/`). A mechanics script (`context.py`) handles everything mechanical — validation, indexing, staleness sweeps, repo maps, cross-project resolution — so no agent ever guesses at mechanical facts. +What earns a line is what the code cannot say: -## Three intents +- **Policy** the org requires — frozen paths, generated files, branch rules, security and compliance. +- **What a config file cannot say about running the project** — the caveat, not the command. `pnpm test` is already in `package.json`; that the suite takes eleven minutes, or needs a service running first, is not. +- **Conventions that differ from ecosystem defaults**, because an agent follows the norm unless told otherwise. +- **Known pitfalls**, admitted only from observed failure — a lesson already recorded, the maintainer's recollection, a mistake fixed repeatedly in git history, or one the writing session made and caught. A trap-looking fact from a scan becomes a question, never a line. +- **Pointers** to where work lands, and to nested or linked files worth reading first. + +Every rule the skill applies is written out in `references/best-practices.md`, with the evidence behind it. The skill uses it to assess what your repo already has, and explains its reasoning back to you at the end. + +## Four intents | Intent | What it does | |--------|--------------| -| **Ingest** | Build or refresh the context. Brownfield: mine the repo and docs first, then ask only what's genuinely unknowable. Greenfield: seed from a spec or architecture doc, or a short interview. Refresh: diff against the last run — never start over. | -| **Query** | Answer a question from the bundle without loading all of it, with trust metadata attached. | -| **Audit** | Keep the set small and true: staleness sweeps, path verification, the pruning test. Context shrinks or holds — it never accretes. | +| **Setup** | The default. Assess what exists, ask what you bring, discover and verify the rest, show you the block, then write it. | +| **Refresh** | The same run against an existing block: re-run its commands, diff deletions and renames since the recorded commit, update what moved. | +| **Record** | Capture one observed agent mistake at the moment it happens. A recurring or costly one earns a line. | +| **Audit** | Re-verify and prune. The block ends smaller or equal, never larger. | ## How agents load it -On first run the skill asks your **placement** preference: +`AGENTS.md` at the repo root, which every major coding harness reads. BMad owns only the region between `` and ``; everything you write outside those markers is preserved byte for byte, and a refresh never touches it. -- **bmad** — the kernel loads through BMad customization arrays; your agent files are never touched. -- **agent-files** — the script maintains managed `` blocks in your root and nested `AGENTS.md` files, preserving everything around them. This is the default when there's no BMad install — the skill works standalone in any repo, with no framework at all. -- **both** — arrays plus agent files, kept in sync. +Monorepo components and nested repositories get their own file under the same rules, listed as pointers in the parent. A large rule set bounded to a directory belongs in a nested `AGENTS.md` there, where the harness attaches it by location. + +## Repo or home directory + +What this skill writes belongs committed to the repository — shared by the team, consistent across machines, versioned with the code it constrains. If you find the same rules repeating across every project, or they are your personal preferences rather than the team's, they belong in your agent's global configuration in your home directory instead. ## Interaction with architecture -Decisions are *born* in `bmad-architecture`; they *live* in project-context. The architecture spine is the premier ingest source, and if ingest surfaces a genuinely contested decision, the skill says it deserves `bmad-architecture` rather than quietly making the call. +Decisions are *born* in `bmad-architecture`. If a genuinely contested design decision surfaces here — real tradeoffs, multiple viable shapes — the skill says it deserves `bmad-architecture` rather than quietly making the call. ## Replaces two earlier skills :::note[Deprecated: bmad-document-project and bmad-generate-project-context] -Both earlier skills are deprecated and now forward here. `bmad-generate-project-context` produced a single `project-context.md`; `bmad-document-project` scanned a brownfield repo into documentation. Their trigger phrases still work, any existing `project-context.md` keeps loading (and becomes a mining source on the next ingest), and the ideas they carried — "capture unobvious rules only" — are now the whole architecture. +Both earlier skills are deprecated and now forward here. `bmad-generate-project-context` produced a single `project-context.md` — if you have one, setup offers to absorb its content rather than orphaning it. `bmad-document-project` scanned a brownfield repo into generated documentation, which is the approach the evidence went against; the deeper "explain this system and its rationale" material is a different altitude and is coming as its own capability. ::: diff --git a/docs/how-to/established-projects.md b/docs/how-to/established-projects.md index 18245cbe2..fe7d098ee 100644 --- a/docs/how-to/established-projects.md +++ b/docs/how-to/established-projects.md @@ -36,7 +36,7 @@ Run the project context skill: bmad-project-context ``` -It scans your codebase and any docs first (mechanically, via its script and parallel subagents), then asks you in short rounds only about what's genuinely unknowable — landmines, frozen areas, org requirements. You end up with a small always-loaded `kernel.md` plus a bundle of verified entries in your `project_knowledge` folder, instead of generated documentation volume. An existing bloated `docs/` folder is treated as a source to verify against code, not something to add to. +It reads what you already have and tells you how it measures up, asks what rules you want followed, then discovers and verifies the rest — running every command before writing it down. You end up with a small verified block in your repo's `AGENTS.md` instead of generated documentation volume. An existing hand-written file is a baseline it improves; a bloated `docs/` folder is a source to verify against code, not something to add to. [Learn more about project context](../explanation/project-context.md) diff --git a/docs/how-to/project-context.md b/docs/how-to/project-context.md index 356da0fc2..356d180fb 100644 --- a/docs/how-to/project-context.md +++ b/docs/how-to/project-context.md @@ -1,23 +1,24 @@ --- title: 'Manage Project Context' -description: Build and maintain your project's verified context system with bmad-project-context +description: Set up and maintain your repository's agent instructions with bmad-project-context sidebar: order: 8 --- -Use `bmad-project-context` to give every AI agent session the minimum verified, non-derivable knowledge it needs — a small always-loaded kernel plus a knowledge bundle — for a new project or an existing codebase, with or without a BMad install. +Use `bmad-project-context` to set up a repository so AI agents work well in it — for a new project or an existing codebase, with or without a BMad install. The output is a small verified block in your `AGENTS.md`. :::note[Prerequisites] -- BMad Method installed — or nothing at all: the skill also runs standalone in any repo (it bootstraps its own mechanics script on first run) +- BMad Method installed — or nothing at all: the skill also runs standalone in any repo ::: ## When to use this - You're starting AI-assisted work in an existing codebase (this is the brownfield on-ramp) -- You're starting a new project and want your stack, conventions, and constraints captured before implementation -- Agents keep making decisions that don't match your project -- Your context feels stale or bloated — run an audit +- You're starting a new project and want your standards followed from the first commit +- You have governance, security or style rules that agents need to respect +- Agents keep making the same mistake and you want it written down +- Your instructions feel stale or bloated — run an audit ## Step 1: Run it @@ -25,26 +26,48 @@ Use `bmad-project-context` to give every AI agent session the minimum verified, bmad-project-context ``` -Say what you want in plain language — "set up project context", "refresh the context", "audit our context" — and the skill routes itself (ingest is the default). On first run it confirms where the context lives (your `project_knowledge` folder) and asks how agents should load it: through BMad customization arrays, through managed blocks in your `AGENTS.md` files, or both. +Say what you want in plain language — "set up AGENTS.md", "refresh the context", "audit our context", "the agent keeps using the wrong test runner" — and the skill routes itself. Setup is the default. -## Step 2: Answer only what the code can't +Point it at a repo if you're not already in one. If the path resolves to more than one working tree, it asks which before writing anything. -For an existing codebase the skill scans first — code, configs, planning docs, any docs folders — and then asks in short rounds: confirmations of what it inferred (with evidence, so a confirm takes seconds), then only the genuinely unknowable things — landmines, frozen areas, org requirements. It never asks a question the code could answer. Anything you bring from outside the repo (org handbooks, wiki exports, an MCP knowledgebase) gets mined the same way — mention it when asked what sources you have. +## Step 2: Tell it what you bring -## Step 3: Review what exists +The first thing it does is read what's already there — `AGENTS.md`, `CLAUDE.md`, editor rule files, docs — and report back what's good, what's derivable filler, and what looks stale. A hand-written file is a baseline it improves, never something it discards. -You get a kernel (`kernel.md`) that stays under its instruction budget and a bundle of small entries, each marked `verified` (you confirmed it) or `generated` (inferred, unconfirmed). The `index.md` is script-generated. Nothing describes what the code already says — if an entry does, the audit deletes it. +Then it asks what rules you want followed regardless of what the repo does: governance, security and compliance requirements, coding standards, style guides, frozen areas. Bring outside documents too — org handbooks, wiki exports, an MCP knowledgebase. -Keep it healthy over time: +For a greenfield project that conversation is the whole content. For a working codebase it's the half no scan can reach. -- **Refresh** after real change — it diffs against the last run and never re-asks what you already settled -- **Audit** on demand — staleness sweep, path checks, and the pruning test; total size holds or shrinks -- **Query** — other skills (and you) can ask questions answered from the bundle with trust metadata attached +## Step 3: It verifies the rest + +It checks every path a line names, and reads your `package.json`, `Makefile` and CI config — not to copy the commands out, since an agent reads those directly, but to know what they already say so the block only carries what they don't. + +Then it asks what no scan could answer: what agents keep getting wrong here, what's off limits, what a domain term means, and which commands come with a catch. + +## Step 4: Approve the block + +You see the complete block before anything is written. Nothing lands without that. On approval it's spliced between the `` markers, and everything you wrote outside them is preserved byte for byte. + +It never commits. Changes stay in your working tree for you to review. + +At the end it tells you what went in, what was left out and why, and the reasoning behind both. + +## Keeping it healthy + +- **Refresh** after real change — re-checks that the caveats still hold, diffs deletions and renames since the recorded commit, updates what moved, and never re-asks what you already settled +- **Record** the moment an agent gets something wrong — that's the only admissible source for a pitfall line +- **Audit** on demand — re-verifies everything and prunes; the block ends smaller or equal, never larger + +A rule stays until the thing it guards is gone or you retire it. Nothing broke lately is never a reason to delete one — a working rule erases its own evidence. + +## Repo or home directory + +What this writes belongs committed to the repo, shared by the team. If the same rules keep repeating across all your projects, or they're your personal preferences, put those in your agent's global configuration in your home directory instead. ## Deprecated predecessors :::note[Looking for bmad-generate-project-context or bmad-document-project?] -Both are deprecated and forward here — their trigger phrases still work. An existing `project-context.md` keeps loading for backwards compatibility and becomes a mining source on your next ingest. +Both are deprecated and forward here — their trigger phrases still work. If you have an existing `project-context.md`, setup offers to absorb its content rather than orphaning it. ::: ## Next steps diff --git a/docs/reference/workflow-map.md b/docs/reference/workflow-map.md index bd48670da..4c14a6ac7 100644 --- a/docs/reference/workflow-map.md +++ b/docs/reference/workflow-map.md @@ -109,13 +109,13 @@ this structure, agents make inconsistent decisions. ### Project Context :::tip[Recommended] -Build your project context so AI agents follow your project's rules and preferences across all workflows: a small -always-loaded kernel plus a bundle of verified entries, maintained by `bmad-project-context`. Seed it from your -architecture at the end of planning, or mine it from an existing codebase at any time. +Set up your repo so AI agents follow your project's rules across all workflows: a small verified block in +`AGENTS.md`, maintained by `bmad-project-context`. Seed it from your architecture at the end of planning, or +discover it from an existing codebase at any time. ::: **How to create it:** -- Run `bmad-project-context` — greenfield (seeded from your spec or architecture) or brownfield (mined from the codebase, then confirmed with you). The earlier `bmad-generate-project-context` is deprecated and forwards there; an existing `project-context.md` keeps loading. +- Run `bmad-project-context` — greenfield (seeded from your spec or architecture) or brownfield (discovered from the codebase, verified, then confirmed with you). The earlier `bmad-generate-project-context` is deprecated and forwards there; an existing `project-context.md` is offered up for absorption. -[**Learn more about project-context.md**](../explanation/project-context.md) +[**Learn more about project context**](../explanation/project-context.md) diff --git a/src/bmm-skills/agents/bmad-agent-analyst/customize.toml b/src/bmm-skills/agents/bmad-agent-analyst/customize.toml index bca3b4b8f..6c570ff16 100644 --- a/src/bmm-skills/agents/bmad-agent-analyst/customize.toml +++ b/src/bmm-skills/agents/bmad-agent-analyst/customize.toml @@ -101,5 +101,5 @@ skill = "bmad-prfaq" [[agent.menu]] code = "PC" -description = "Curate the verified project context AI agents load — kernel + knowledge bundle (ingest, query, audit)" +description = "Set up or refresh this repo's agent instructions — verified commands, policy, conventions, pitfalls (setup, refresh, record, audit)" skill = "bmad-project-context" diff --git a/src/bmm-skills/module-help.csv b/src/bmm-skills/module-help.csv index 2cb466afb..f3abac003 100644 --- a/src/bmm-skills/module-help.csv +++ b/src/bmm-skills/module-help.csv @@ -1,6 +1,6 @@ module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs BMad Method,_meta,,,,,,,,,false,https://docs.bmad-method.org/llms.txt, -BMad Method,bmad-project-context,Project Context,PC,"Curate the verified project context AI agents load: a small always-loaded kernel plus a knowledge bundle. Ingest (brownfield or greenfield), query, and audit — replaces document-project and generate-project-context.",,,anytime,,,false,project_knowledge,kernel.md + context bundle +BMad Method,bmad-project-context,Project Context,PC,"Set up or refresh a repo's agent instructions so AI agents work well in it: verified commands, policy, conventions that differ from defaults, and known pitfalls. Setup, refresh, record, and audit — replaces document-project and generate-project-context.",,,anytime,,,false,repo root,AGENTS.md managed block BMad Method,bmad-build,Build,BD,Official Phase 4 implementation loop: clarify intent plan implement review and present.,,,ship,bmad-sprint-planning,bmad-code-review,true,implementation_artifacts,spec and project implementation BMad Method,bmad-spec,Spec,SPC,"Use to distill any intent input (brief, PRD, transcript, brain dump, design folder, mixed multi-source) into a succinct, no-fluff SPEC.md contract + companions that downstream work derives from. Locks the WHAT before the HOW. Works for software, game design, research, editorial, policy, business, anything intent-bearing. Validation mode also available.",,[path],anytime,,,false,{output_folder}/specs/spec-{slug},SPEC.md + companion files BMad Method,bmad-correct-course,Correct Course,CC,Navigate significant changes. May recommend start over update PRD redo architecture sprint planning or correct epics and stories.,,,anytime,,,false,planning_artifacts,change proposal diff --git a/src/bmm-skills/plan/bmad-document-project/SKILL.md b/src/bmm-skills/plan/bmad-document-project/SKILL.md index 3020774ad..bfe766570 100644 --- a/src/bmm-skills/plan/bmad-document-project/SKILL.md +++ b/src/bmm-skills/plan/bmad-document-project/SKILL.md @@ -5,6 +5,10 @@ description: 'Deprecated — forwards to bmad-project-context. Use when the user # DEPRECATED — forwards to bmad-project-context -Tell the user: this skill is deprecated — `bmad-project-context` now owns this job, and instead of generating documentation volume it curates a small verified context system (an always-loaded kernel plus a knowledge bundle). Invoke `bmad-project-context` next time. +Tell the user two things. -Then invoke `bmad-project-context` with **ingest** intent, forwarding the user's original request and any paths or documents they supplied, verbatim. It takes the workflow from here. +First: this skill is deprecated. Generating documentation volume about a codebase made agents worse, not better — agents read code more accurately than prose describing code, and the generated set was stale on arrival. `bmad-project-context` owns what remains useful: a small verified block in the repo's `AGENTS.md` carrying what the code cannot say — required policy, conventions that differ from defaults, what running the project takes that no config file states, and known pitfalls. + +Second, so they are not surprised by what they get: the deeper "explain this system, its rationale and its history" material is a different altitude and is not part of that block. It is coming as its own capability. If that is what they were after, say so plainly rather than producing a thin substitute. + +Then invoke `bmad-project-context` with **setup** intent, forwarding the user's original request and any paths or documents they supplied, verbatim. It takes the workflow from here. diff --git a/src/bmm-skills/plan/bmad-generate-project-context/SKILL.md b/src/bmm-skills/plan/bmad-generate-project-context/SKILL.md index 5fd0242e1..05ecccef5 100644 --- a/src/bmm-skills/plan/bmad-generate-project-context/SKILL.md +++ b/src/bmm-skills/plan/bmad-generate-project-context/SKILL.md @@ -5,6 +5,6 @@ description: 'Deprecated — forwards to bmad-project-context. Use when the user # DEPRECATED — forwards to bmad-project-context -Tell the user: this skill is deprecated — `bmad-project-context` now owns this job. Instead of one generated `project-context.md`, it curates a small verified context system (an always-loaded kernel plus a knowledge bundle); any existing `project-context.md` keeps loading and becomes a mining source. Invoke `bmad-project-context` next time. +Tell the user: this skill is deprecated — `bmad-project-context` now owns this job. Instead of one generated `project-context.md`, it writes a small verified block inside the repo's `AGENTS.md`, and any existing `project-context.md` is offered up for absorption rather than left orphaned. Invoke `bmad-project-context` next time. -Then invoke `bmad-project-context` with **ingest** intent, forwarding the user's original request and any inputs they supplied (architecture doc, spec, preferences), verbatim. It takes the workflow from here. +Then invoke `bmad-project-context` with **setup** intent, forwarding the user's original request and any inputs they supplied (architecture doc, spec, standards, preferences), verbatim. It takes the workflow from here. diff --git a/src/bmm-skills/plan/bmad-project-context/SKILL.md b/src/bmm-skills/plan/bmad-project-context/SKILL.md index 11b7edd66..b8cb804f1 100644 --- a/src/bmm-skills/plan/bmad-project-context/SKILL.md +++ b/src/bmm-skills/plan/bmad-project-context/SKILL.md @@ -1,74 +1,109 @@ --- name: bmad-project-context -description: 'Curate and maintain verified project context for AI agents. Use when the user says "project context", "document project", "generate project context", "refresh context", or "audit context"' +description: 'Set up or refresh agent instructions so AI agents work well in it. Use when the user says "project context", "set up AGENTS.md", "document this project", "refresh context", "audit context", wants to apply coding standards or governance to a repo, or wants to record a mistake agents keep making' --- # Overview -You are the curator of everything the code can't say. This skill builds and maintains a project's context system: a tiny always-loaded **kernel** and a **bundle** of small verified knowledge entries — architecture rationale, unobvious conventions, landmines, org requirements. The governing thesis, backed by measurement: generated documentation makes agents worse; a curated minimum of verified, non-derivable truths makes them better. So you curate the minimum non-derivable set and never describe what the code already says. +A conversation that produces a repository's agent instructions: a small verified block inside `AGENTS.md`. The user brings rules they want followed — governance, security, standards — and the repository supplies the rest, verified. -Works with a full BMad install or standalone in any repo with no framework at all. +Conversational always; the user approves every write. -**Args:** intent (`ingest` | `query` | `audit`); `--auto` for headless; a scope path to bound the run; placement (`bmad` | `agent-files` | `both`); a bundle-root override; extra source paths or URLs to mine. Supplied values are used directly and skip their questions. Script interface: `uv run {skill-root}/scripts/context.py --help`. +**Args:** intent (`setup` | `refresh` | `record` | `audit`); a target repo or path; extra source paths or URLs. Supplied values skip their questions. ## Resolution rules -- Bare paths and `{skill-root}` (e.g. `references/kernel-contract.md`) resolve from this skill's installed directory. +- Bare paths and `{skill-root}` (e.g. `references/best-practices.md`) resolve from this skill's installed directory. - `{project-root}` → the project working directory. +- **Target** → the repository being described, defaulting to `{project-root}`. If it resolves to more than one working tree, or to one the user cannot commit in, ask before writing. ## On Activation 1. Resolve customization: `uv run {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow`. On failure, read `{skill-root}/customize.toml` directly and use defaults. Execute `{workflow.activation_steps_prepend}`; treat `{workflow.persistent_facts}` entries as standing context (`file:` = paths/globs to load, others verbatim). -2. Mechanics: every mechanical fact comes from the script, never from guessing. If `{project-root}/_bmad/scripts/context.py` is missing (standalone repo), run `uv run {skill-root}/scripts/context.py bootstrap` once — it installs itself there. All later calls: `uv run {project-root}/_bmad/scripts/context.py ` (`--json` on any command for machine reads; `--help` for the full interface). -3. Config comes from one resolution, never hand-merged: `uv run {project-root}/_bmad/scripts/context.py config --json`. It delegates to the installed BMad resolver (`resolve_config.py`) when present and otherwise falls back through the legacy and standalone config files itself, so the script and this session can never disagree about paths. Read `{user_name}`, `{communication_language}` (use it every turn), `{document_output_language}`, `{project_knowledge}`, `{output_folder}` (standalone default `_bmad-output`), and `context_placement` from its output. -4. **First run** (no kernel at `{project_knowledge}/kernel.md`), interactive only: load `references/placement.md` and settle the bundle location and placement there. In auto mode: detect (BMad install → bmad, else agent-files), record `context_placement`, don't ask. -5. Init or resume the memlog at `{project_knowledge}/.memlog.md` (`uv run {project-root}/_bmad/scripts/memlog.py init --path ...` if absent; if present, read it once — it is the record of every prior run, and refresh diffs against it instead of starting over). If `memlog.py` itself is missing (standalone repo), append one-line typed entries to the same file directly — append-only, never rewritten. -6. Detect intent — **ingest** (build or refresh; the default), **query** (answer from the bundle), **audit** (shrink and re-verify) — and greet `{user_name}`. For interactive ingest, ask what they bring before anything scans: sources outside the repo (org handbooks, wiki or Notion exports, prior architecture docs, MCP knowledgebases) and any area to focus on — note the paths for subagent scanning, don't read them now; when a named source is huge, ask one bounding question rather than scanning it whole. Fold `{workflow.external_sources}` entries into the same source list. Auto mode skips the ask, scans what's discoverable, and logs that as an assumption. Execute `{workflow.activation_steps_append}`. +2. Config: if `{project-root}/_bmad` exists, `uv run {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root}` and read `{user_name}`, `{communication_language}` (use it every turn), `{output_folder}`. Standalone: skip. +3. **Load `references/best-practices.md` and `references/template.md` before anything else.** Every decision below is made against them. +4. Detect intent and greet `{user_name}`: **setup** (no block in the target — the default), **refresh** (a block exists), **record** (the user reports a mistake agents made), **audit** (re-verify and prune). Fold `{workflow.external_sources}` into the source list. Execute `{workflow.activation_steps_append}`. -## Engine disciplines — every intent, every mode +## Setup and Refresh Steps -- Every user decision, confirmed claim, rejected claim, and idea lands in the memlog the moment it happens — never batched for session end. -- **Orchestrate the scanning.** Discovery is yours to plan with whatever tools fit, but make good use of parallel subagents: they scan, returning claims with evidence paths and an inferred|needs-confirmation mark; you interrogate and decide, and never grind a large tree through your own context. +No writes until step 5! -## Writing rules — every kernel line, entry, and compass +### 1. Assess and report -- **Succinct to the point of discomfort.** Every sentence costs context in every future session. If a line can be shorter, it isn't done. -- **Present truth only.** State what *is*, never the story of the edit — "we removed X because..." is banned prose. Git and the memlog hold history; supersession is a dated frontmatter field. -- **No reference without a link.** Every mentioned decision, doc, file, or system carries a path, `[[project:entry]]` link, or URL a fresh context can follow. "As previously discussed" is banned. +Read `AGENTS.md`, harness or agent specific rule files, docs folders, and any notes carrying lessons. Report what exists and how it measures up, per `best-practices.md`. -## Ingest +If the target contains separable units — a workspace manifest listing members, or directories carrying their own build manifest — name them and ask whether this run covers the root only, all of them, or which. Absent that evidence, do not ask. Sibling repositories are not children; each is its own target, offered in turn. -The outcome: a kernel within its instruction budget and bundle entries for what earned depth — every claim verified (user-confirmed or path-checked, interactive only; auto mode writes the same content marked `generated`) before it's written as truth. Contracts govern the artifacts: load `references/kernel-contract.md` and `references/bundle-contract.md` before writing either. +### 2. Ask what they bring -**Brownfield:** discover the repo however you judge best, then fan out the source scan (trust ladder: code and configs are ground truth; planning docs next — an ARCHITECTURE-SPINE is the premier source; existing docs folders, org docs, and MCP knowledgebases are untrusted until verified against code). Then interrogate in chunked rounds per `references/interrogation-guide.md` — confirmations first, then only the genuinely unknowable. Never ask what a scan could answer. A bloated docs folder is a source to strip-mine, then recommend archiving. +Rules to follow regardless of what the repo does: governance, security and compliance, coding standards, style guides, frozen areas. Ask for outside documents too — handbooks, wikis, architecture docs, MCP knowledgebases. Note the paths; do not read them yet. -**Greenfield:** same pipeline seeded from a bmad-spec artifact or planning doc (or pure interview). If a genuinely contested decision surfaces — real tradeoffs, multiple viable shapes — say it deserves `bmad-architecture` rather than making the call: decisions are born there; they *live* here. +Greenfield: this is the whole content. Brownfield: it is the half no scan reaches. -**Refresh:** ingest with existing artifacts — read the memlog, run `sweep`, diff instead of restarting, never re-ask what a prior run settled. Sweep findings resolve against code, not prose: when a path a claim names is gone, the claim is updated to the new reality or removed/marked superseded — re-pointing its `sources` at documents that merely mention it is laundering, not verification. Total size must hold or shrink. +### 3. Discover and verify -**Scope:** whole repo or a component; in a monorepo, global truths go in the root kernel, component truths in that component's compass. After writing: `index`, then `validate` — its stats block is the measured budget check; an over-budget finding means cut, never raise. Under agent-files/both placement, `sync --dry-run` first and show which files it will touch (confirm on the session's first sync; auto mode skips the ask and logs the written list to the memlog), then `sync`. Close with a fresh-eyes polish pass: a subagent holding only the written artifacts and the two contract files — none of this conversation — returns proposed cuts and rewrites (line, which test it fails, replacement or delete); apply or override, logging overrides to the memlog. The writer who just heard every line justified cannot honestly run the pruning test on it. If subagents are unavailable, run the pass yourself against the contracts. Log the run's summary to the memlog and offer a face artifact. +Fan out with parallel subagents against what the sections need — executable config and CI for policy and for what they already state, tracked source for conventions and boundaries, targeted history for constraints whose reason must still hold. -## Query +`package.json`, a `Makefile`, `pyproject.toml`, and CI config are read to know what the block must not repeat. Their caveats come from the human in step 4. Path-check every claim naming a file. -Answer a question from the bundle without loading all of it: resolve through `index.md`, and return only the relevant entries with their trust metadata (`verified`/`generated`, sources, staleness — staleness read from `sweep --json`, never recomputed; field semantics in `references/bundle-contract.md`). Anything outside this repo — a `[[project:entry]]` link, a question about another project — goes through `resolve ` only, which returns a local path, SHA, and freshness; never crawl the filesystem or workspace for another project's context, because the same query must work when that project isn't checked out. Never dump the bundle. If the bundle can't answer, say so — don't improvise an answer the context doesn't hold. +Each child agreed in step 1 is scanned as its own scope, against its own manifests. + +### 4. Interview the gaps + +Only what no scan reaches: what agents keep getting wrong here, what is off limits, what a domain term means, why a constraint exists. + +- Never ask what a scan could answer. Asking the user to confirm a path-checked claim, or one a config file already states, is a defect. +- Ask recall questions, not review lists. Never hand the user a selection problem a scan created. +- A mistake this session made and caught is observed evidence — offer it. +- Batches of at most eight; fewer is better. A batch yielding nothing new means write. +- When the repo contradicts the user, show the evidence and ask. Never write the claim as given, never drop it silently. + +### 5. Show the block, then write it + +Compose against `template.md`. For each candidate, ask first whether a hook, lint rule, or CI check enforces it better than prose; if so propose the check, and the line becomes the fallback if they decline. + +**Show the complete block before writing it**, and every child block alongside it — one approval covers the set. On approval, splice between the markers, leaving everything outside them byte-identical. Fill each provenance line with today's date and the verified SHA. + +Where an instruction elsewhere contradicts the block in a way that changes behavior — a stale `CLAUDE.md` line, a retired command — propose the fix to that file. Two live contradictory instructions is a defect. + +Never commit. + +### 6. Close + +- What went in, and what was left out and why. +- Why, in the user's terms, from `best-practices.md` — why it is small, why what the repo already states stays out, why a pitfall line stays until its cause is gone. +- How it loads, and that other harness files can point at it. +- Maintenance: re-run after significant change, `record` the moment an agent gets something wrong, prefer a check over a new line. +- Rules repeating across their projects, or personal rather than the team's, belong in their global agent config. + +### Refresh + +Same steps, step 1 as a diff. Read the provenance line, re-verify every path and every caveat, and run `git log --diff-filter=DR --name-only` since the recorded SHA against every line — update or remove lines whose evidence is gone. Never re-ask what a prior run settled; the interview shrinks to what changed about how the team works. The block grows only on new evidence. + +### Greenfield + +Seeded from a spec or planning document, or interview alone. Commands that do not exist yet are written as explicit TODOs naming the decided stack, never a guessed invocation stated as fact, and verified on the first refresh after code exists. A genuinely contested design decision — real tradeoffs, multiple viable shapes — goes to `bmad-architecture`. + +### Migration + +If the target has a `project-context.md` from the retired skills, commonly under `{output_folder}`, read it in step 1 and offer to absorb its content. Do not delete it without agreement, and do not silently orphan it. + +## Record + +Capture one observed agent mistake as it happens — the only admissible source for a pitfall line. + +Take the task, the mistake, the correction, and its evidence. Check the block for a line already covering it. One occurrence is noted; a recurring or costly mistake earns a line now — write it, show the diff. If it is mechanically preventable, propose the hook, lint rule, or CI check instead. ## Audit -Keep the set small and true: run `validate` and `sweep` — sweep's `missing` list is the path-check for every claim naming a file, and validate's stats block measures the kernel budget — and apply the pruning test to every kernel line — *would removing this line change agent behavior?* If no, it goes. Entries that paraphrase readable code are deleted; unconfirmed `generated` entries are queued for confirmation. Load `references/bundle-contract.md` before mutating any entry — the frontmatter it acts on is defined there. Where an obeya is configured, propose batched promotion of `org-candidate` entries. Audit ends with the context smaller or equal, never larger — present proposed deletions for confirmation (interactive) before removing; in auto mode deletions proceed and every removal lands in the memlog as a typed entry. +Re-check every caveat, path-check every file, follow every pointer, and ask of every line whether removing it would change agent behavior. Check for contradictions with other instruction files. -## Modes +Failing lines move behind an observable trigger, get fixed, or are deleted — confirm deletions first. **A policy or pitfall line goes only when the thing it guards is gone or the user retires it; nothing failing lately is not grounds.** Audit ends smaller or equal. -Interactive is the default: the user is the oracle, in chunked rounds. **Auto mode** (headless, or on request) accepts inferences without confirmation — everything it writes, including path-checked claims, is marked `generated`, never `verified` (`verified` asserts a human was in the loop), and every assumption lands in the memlog. A headless invocation may supply intent (`ingest`|`query`|`audit`), a scope path, a placement, and a bundle root — supplied values are used directly; only genuinely absent ones are inferred, each inference logged as an `assumption`. When invoked headless: never ask; if intent is neither supplied nor inferable, halt with a `blocked` JSON status and `reason`. End with JSON: +## Children -```json -{"status": "complete", "intent": "ingest", "kernel": "docs/kernel.md", - "bundle": "docs/", "memlog": "docs/.memlog.md", "placement": "agent-files"} -``` +A component, nested repository, or extracted rules file gets its own file under the same shape when work keeps landing there and its truths do not belong at the parent level. Rules bounded to a directory go in a nested `AGENTS.md` there, attached by location. Use a linked file only when the trigger is not a path. -## Face artifacts +A chosen child that ends with nothing its parent does not already say gets no file. Say so and move on. -On request after any intent, generate a human-readable face of the context — always asking its purpose first so it fits (a slide deck for one subsystem, a website of everything, a service explainer). Faces are written outside the bundle (default `{output_folder}`), never indexed, never cited as a source, and regenerated rather than maintained: the organized, indexed markdown is the only source of truth. - -## Finalize - -Distill the memlog — every meaningful entry captured in an artifact or set aside as noise — confirm `validate` exits clean, tell the user what exists where (and what was *not* created, if kernel-only). When `AGENTS.md` carries the kernel, say plainly: if your harness doesn't auto-load `AGENTS.md`, make the context file it does load pull this one in (e.g. a `CLAUDE.md` containing `@AGENTS.md`). Then run `{workflow.on_complete}` if non-empty. +List every child in the parent's **Where things are** with one line and its path. Discovery never depends on the harness finding it. diff --git a/src/bmm-skills/plan/bmad-project-context/customize.toml b/src/bmm-skills/plan/bmad-project-context/customize.toml index dc3f2cdd4..bc9fe78ae 100644 --- a/src/bmm-skills/plan/bmad-project-context/customize.toml +++ b/src/bmm-skills/plan/bmad-project-context/customize.toml @@ -11,15 +11,14 @@ # --- Universal defaults --- activation_steps_prepend = [] activation_steps_append = [] -# Deliberately empty: the file this skill wants standing (the kernel) is only -# knowable after config resolves, and the usual **/project-context.md glob is -# this skill's own superseded output. Users append their own facts. +# Deliberately empty: this skill's own output (AGENTS.md) is loaded by the +# harness, not through this array. Users append their own standing facts. persistent_facts = [] on_complete = "" -# Standing outside-the-repo sources fed into every ingest fan-out at their -# trust-ladder rank (untrusted until verified against code). Append-only. -# Entries: "file:{project-root}/..." or "file:/abs/path" for docs, -# "skill:name" to consult a skill, plain text for a standing fact, +# Standing outside-the-repo sources offered at every setup/refresh run +# (untrusted until verified against the repo or user-confirmed). +# Append-only. Entries: "file:{project-root}/..." or "file:/abs/path" for +# docs, "skill:name" to consult a skill, plain text for a standing fact, # "tool:name" for an MCP knowledgebase. external_sources = [] diff --git a/src/bmm-skills/plan/bmad-project-context/evals/cases.json b/src/bmm-skills/plan/bmad-project-context/evals/cases.json deleted file mode 100644 index b91141834..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/cases.json +++ /dev/null @@ -1,345 +0,0 @@ -{ - "skill_name": "bmad-project-context", - "cases": [ - { - "id": "E1-brownfield-capture", - "input": "Run headless. Ingest project context for the repo at files/fixture-brownfield (treat it as the project root).", - "rubric": [ - "files/fixture-brownfield/context/kernel.md exists and contains the integer-cents money rule with a path or link (src/lib/money.ts or planning/decisions.md)", - "The kernel or a bundle entry captures the soft-delete rule (deleted_at filtering) and the Stripe webhook staging-replay idempotency landmine", - "The kernel captures that legacy/ is frozen and must not be modified", - "The test command is captured as pnpm test / vitest with a warning against jest syntax", - "The kernel does NOT state self-evident facts: no 'uses TypeScript', no 'built on Express', no layering overview like 'the API layer calls the service layer'", - "Every artifact written is marked generated (never verified) \u2014 this was an auto-mode run with no user confirmation", - "The kernel is small: at most ~40 content lines, ordered with commands/conventions/landmines style sections, no prose paragraphs", - "The transcript shows context.py invocations for mechanical facts (index and/or validate); index.md exists in files/fixture-brownfield/context/", - "Scanning stays bounded: either source mining is delegated to subagents returning claim lists, or (small repo) the main session reads sources in a few batched calls \u2014 it never file-by-file crawls a large tree in its own context. For this small fixture, batched direct reads pass." - ], - "files": [ - "files/fixture-brownfield/AGENTS.md", - "files/fixture-brownfield/_bmad/context.yaml", - "files/fixture-brownfield/docs/data-access.md", - "files/fixture-brownfield/docs/deploy.md", - "files/fixture-brownfield/docs/money.md", - "files/fixture-brownfield/docs/overview.md", - "files/fixture-brownfield/legacy/README.md", - "files/fixture-brownfield/package.json", - "files/fixture-brownfield/planning/decisions.md", - "files/fixture-brownfield/pnpm-lock.yaml", - "files/fixture-brownfield/src/lib/money.ts", - "files/fixture-brownfield/src/repos/orders.ts", - "files/fixture-brownfield/src/routes/webhooks.ts" - ], - "timeout": 900 - }, - { - "id": "E2-false-claim-rejection", - "input": "Run headless. Ingest project context for the repo at files/fixture-brownfield (treat it as the project root). The docs/ folder there is pre-existing documentation of unknown quality.", - "rubric": [ - "The false claim 'we use jest' from docs/overview.md does not appear as truth anywhere in kernel or bundle; the captured test framework is vitest", - "The false claim that prices are stored as floating-point dollars (docs/money.md) does not appear as truth; money is captured as integer cents", - "The stale Heroku deployment claim (docs/deploy.md) is not promoted into kernel or bundle", - "The true claim from docs/data-access.md (repository pattern in src/repos/) IS captured", - "The run recommends archiving or retiring the stale docs/ folder (in the final message, memlog, or an entry)", - "No claim sourced only from docs/ is marked verified" - ], - "files": [ - "files/fixture-brownfield/AGENTS.md", - "files/fixture-brownfield/_bmad/context.yaml", - "files/fixture-brownfield/docs/data-access.md", - "files/fixture-brownfield/docs/deploy.md", - "files/fixture-brownfield/docs/money.md", - "files/fixture-brownfield/docs/overview.md", - "files/fixture-brownfield/legacy/README.md", - "files/fixture-brownfield/package.json", - "files/fixture-brownfield/planning/decisions.md", - "files/fixture-brownfield/pnpm-lock.yaml", - "files/fixture-brownfield/src/lib/money.ts", - "files/fixture-brownfield/src/repos/orders.ts", - "files/fixture-brownfield/src/routes/webhooks.ts" - ], - "timeout": 900 - }, - { - "id": "E4-interrogation-discipline", - "state_prefix": "[Interactive brownfield ingest is underway on the repo at files/fixture-brownfield (the project root). The orchestrated scan of code, configs, planning/decisions.md, and the docs/ folder is complete and the claim list is assembled. The next step is the first interrogation round.]", - "input": "User said: \"go ahead with your questions.\"", - "rubric": [ - "The round contains at most 8 items", - "No question asks something derivable from the code: nothing like 'what test framework do you use' (package.json answers it) or 'what language is this' \u2014 mined claims are presented for confirmation instead", - "Confirmation items quote their evidence (a file path or doc claim) so the user can confirm in seconds", - "Unverifiable docs claims are surfaced as 'the docs say X, still true?' rather than stated as fact", - "The turn ends waiting for the user; no artifacts are written as verified before the user answers", - "No fixture file under files/fixture-brownfield/ is modified during the turn", - "At least one docs-vs-code contradiction (jest vs vitest, or float dollars vs integer cents) is surfaced in the round as a claim needing the user's confirmation or already refuted by code evidence" - ], - "files": [ - "files/fixture-brownfield/AGENTS.md", - "files/fixture-brownfield/_bmad/context.yaml", - "files/fixture-brownfield/docs/data-access.md", - "files/fixture-brownfield/docs/deploy.md", - "files/fixture-brownfield/docs/money.md", - "files/fixture-brownfield/docs/overview.md", - "files/fixture-brownfield/legacy/README.md", - "files/fixture-brownfield/package.json", - "files/fixture-brownfield/planning/decisions.md", - "files/fixture-brownfield/pnpm-lock.yaml", - "files/fixture-brownfield/src/lib/money.ts", - "files/fixture-brownfield/src/repos/orders.ts", - "files/fixture-brownfield/src/routes/webhooks.ts" - ], - "timeout": 600 - }, - { - "id": "E5-writing-rules", - "input": "Run headless. Ingest project context for the repo at files/fixture-brownfield (treat it as the project root).", - "rubric": [ - "No kernel line, entry, or compass contains drift narration: no 'we updated/removed/decided to change X because' storytelling anywhere in the written artifacts", - "Every reference to a file, decision, or doc inside written artifacts carries a followable path or link; no 'as previously discussed' or naming of unlocatable things", - "Entries are succinct: no entry body exceeds ~120 words; kernel lines are single-sentence imperatives", - "No entry paraphrases readable code (an entry restating what src/lib/money.ts plainly shows without adding the why would fail)", - "Frontmatter on every entry has type, title, description, and exactly one of generated/verified", - "Substance, not just restraint: the bundle captures at least three of the planted non-derivable truths (integer-cents why, soft-delete, webhook replay, legacy freeze) each with its reason \u2014 a near-empty bundle fails this", - "Links in any materialized AGENTS.md block resolve from that file's own location (no kernel-relative links copied verbatim to repo root)" - ], - "files": [ - "files/fixture-brownfield/AGENTS.md", - "files/fixture-brownfield/_bmad/context.yaml", - "files/fixture-brownfield/docs/data-access.md", - "files/fixture-brownfield/docs/deploy.md", - "files/fixture-brownfield/docs/money.md", - "files/fixture-brownfield/docs/overview.md", - "files/fixture-brownfield/legacy/README.md", - "files/fixture-brownfield/package.json", - "files/fixture-brownfield/planning/decisions.md", - "files/fixture-brownfield/pnpm-lock.yaml", - "files/fixture-brownfield/src/lib/money.ts", - "files/fixture-brownfield/src/repos/orders.ts", - "files/fixture-brownfield/src/routes/webhooks.ts" - ], - "timeout": 900 - }, - { - "id": "E8a-first-run-placement-ask", - "input": "Set up project context for the repo at files/fixture-brownfield (treat it as the project root).", - "rubric": [ - "The first-run flow confirms the bundle location and asks the placement question with the three options (bmad / agent-files / both) explained in plain language", - "A default is suggested based on detection rather than left as a bare menu", - "The recommendation to make the kernel load (persistent_facts via bmad-customize, or an agent-file pointer) is mentioned", - "The pre-existing AGENTS.md is not modified and no new AGENTS.md is created before the user answers", - "No altitude or filing machinery surfaces \u2014 no 'altitude', 'org-candidate', promotion, or org-level filing questions (this is a solo repo with no obeya configured). Asking whether the user has org docs as an ingest source is fine and expected.", - "The turn ends waiting for the user's placement answer" - ], - "files": [ - "files/fixture-brownfield/AGENTS.md", - "files/fixture-brownfield/_bmad/context.yaml", - "files/fixture-brownfield/docs/data-access.md", - "files/fixture-brownfield/docs/deploy.md", - "files/fixture-brownfield/docs/money.md", - "files/fixture-brownfield/docs/overview.md", - "files/fixture-brownfield/legacy/README.md", - "files/fixture-brownfield/package.json", - "files/fixture-brownfield/planning/decisions.md", - "files/fixture-brownfield/pnpm-lock.yaml", - "files/fixture-brownfield/src/lib/money.ts", - "files/fixture-brownfield/src/repos/orders.ts", - "files/fixture-brownfield/src/routes/webhooks.ts" - ], - "timeout": 600 - }, - { - "id": "E8b-bmad-placement-honored", - "state_prefix": "[First-run setup on the repo at files/fixture-brownfield (the project root). The skill asked the placement question and the user replied:]", - "input": "User said: \"bmad placement please \u2014 don't touch my agent files.\"", - "rubric": [ - "context_placement is recorded as bmad in the project config file", - "The pre-existing AGENTS.md at files/fixture-brownfield/AGENTS.md is byte-identical to the fixture, and no other AGENTS.md is created anywhere in the workspace", - "The user is told how the kernel will load under bmad placement (persistent_facts arrays / bmad-customize)", - "The sync command is not run (it refuses under bmad placement and the skill knows not to call it)", - "The run proceeds to a completed ingest: context/kernel.md exists and captures at least the integer-cents and webhook-replay truths" - ], - "files": [ - "files/fixture-brownfield/AGENTS.md", - "files/fixture-brownfield/_bmad/context.yaml", - "files/fixture-brownfield/docs/data-access.md", - "files/fixture-brownfield/docs/deploy.md", - "files/fixture-brownfield/docs/money.md", - "files/fixture-brownfield/docs/overview.md", - "files/fixture-brownfield/legacy/README.md", - "files/fixture-brownfield/package.json", - "files/fixture-brownfield/planning/decisions.md", - "files/fixture-brownfield/pnpm-lock.yaml", - "files/fixture-brownfield/src/lib/money.ts", - "files/fixture-brownfield/src/repos/orders.ts", - "files/fixture-brownfield/src/routes/webhooks.ts" - ], - "timeout": 900 - }, - { - "id": "E3-greenfield-seeding", - "input": "Run headless. Generate project context for the repo at files/fixture-greenfield (treat it as the project root). The spec is at files/fixture-greenfield/spec.md.", - "rubric": [ - "The kernel captures the decided stack (TypeScript/Fastify/Postgres), the vitest+testcontainers no-DB-mocking test approach, integer-cents money, and the club_id isolation constraint", - "The contested settlement-persistence decision (event sourcing vs CRUD) is NOT decided by the skill: it is flagged as deserving bmad-architecture, in the final message or memlog", - "The output is kernel-only or near-kernel-only: no bundle entries that merely restate spec text", - "All artifacts are marked generated (auto mode)" - ], - "files": ["files/fixture-greenfield/spec.md"], - "timeout": 900 - }, - { - "id": "E6-refresh-non-accretion", - "input": "Run headless. Refresh the project context for the repo at files/fixture-refresh (treat it as the project root). Context artifacts from a prior run exist at files/fixture-refresh/context/.", - "rubric": [ - "No written artifact still references src/lib/money.ts (the file is now src/lib/currency.ts): the integer-cents claim is either re-pointed to currency.ts or its entry removed", - "The repository-pattern convention (src/repos/ no longer exists in the code) is removed, marked superseded via frontmatter, or downgraded from verified \u2014 it is not left standing as verified truth", - "Total bundle size does not grow: combined bytes of kernel.md + entries + index.md in context/ is less than or equal to the fixture's originals", - "The memlog at context/.memlog.md gains entries recording this refresh diff (what changed and why), and prior entries are untouched", - "Nothing already settled in the prior memlog is re-derived from scratch or contradicted without evidence" - ], - "files": [ - "files/fixture-refresh/_bmad/context.yaml", - "files/fixture-refresh/context/.memlog.md", - "files/fixture-refresh/context/index.md", - "files/fixture-refresh/context/integer-cents.md", - "files/fixture-refresh/context/kernel.md", - "files/fixture-refresh/context/repository-pattern.md", - "files/fixture-refresh/package.json", - "files/fixture-refresh/planning/decisions.md", - "files/fixture-refresh/src/lib/currency.ts", - "files/fixture-refresh/src/routes/webhooks.ts" - ], - "timeout": 900 - }, - { - "id": "E7-query-contract", - "input": "Run headless. Query the project context (project root: files/fixture-workspace/proj-a): first, what are the webhook signing rules for calls to sibling-api? Second, what is our deploy cadence?", - "rubric": [ - "The answer states the HMAC-SHA256 X-Sig signing rule including the silent-drop behavior, sourced from sibling-api's webhook-conventions entry", - "The answer carries trust metadata: the entry's verified date is surfaced", - "Resolution goes through the mechanics (context.py resolve, or the index) rather than ad-hoc tree crawling across the workspace", - "The full bundle is not dumped: only the relevant entry content appears in the answer", - "The deploy-cadence question is answered with 'the bundle cannot answer this' (or equivalent) rather than an invented answer" - ], - "files": [ - "files/fixture-workspace/obeya/registry.yaml", - "files/fixture-workspace/proj-a/_bmad/context.yaml", - "files/fixture-workspace/proj-a/docs/kernel.md", - "files/fixture-workspace/proj-a/src/client.ts", - "files/fixture-workspace/sibling-api/docs/kernel.md", - "files/fixture-workspace/sibling-api/docs/webhook-conventions.md" - ], - "timeout": 600 - }, - { - "id": "E9-monorepo-scoping", - "input": "Run headless. Ingest project context for the monorepo at files/fixture-monorepo (treat it as the project root). Work is about to start in services/billing.", - "rubric": [ - "The root kernel contains only global truths (workspace import-boundary rule via @nimbus/contracts, pnpm workspace commands) \u2014 no billing- or web-specific conventions", - "Billing truths (idempotent handlers due to queue redelivery/nightly replay, integer cents) land in a compass with area services/billing, 25-35 lines, answering the five compass questions", - "The web no-default-exports convention does not leak into the root kernel or the billing compass", - "Compass frontmatter carries a correct area field so context.py compass services/billing/src/handlers.ts returns the billing compass" - ], - "files": [ - "files/fixture-monorepo/README.md", - "files/fixture-monorepo/apps/web/package.json", - "files/fixture-monorepo/apps/web/src/conventions.md", - "files/fixture-monorepo/package.json", - "files/fixture-monorepo/services/billing/package.json", - "files/fixture-monorepo/services/billing/src/handlers.ts" - ], - "timeout": 900 - }, - { - "id": "E10-standalone", - "input": "Run headless. Ingest project context for the repo at files/fixture-standalone (treat it as the project root). This repo has no BMad install.", - "rubric": [ - "The mechanics script is bootstrapped: the transcript shows the skill's bundled scripts/context.py bootstrap call, and _bmad/scripts/context.py exists under the project root afterward", - "Placement defaults to agent-files (detected, not asked \u2014 headless): an AGENTS.md is produced at the project root containing the managed block with the kernel content", - "Capture quality matches a BMad-install run: integer-cents rule and the planning/decisions.md rationale are captured, marked generated", - "The run completes without requiring any BMad module, config, or skill beyond this one" - ], - "files": [ - "files/fixture-standalone/package.json", - "files/fixture-standalone/planning/decisions.md", - "files/fixture-standalone/src/lib/money.ts" - ], - "timeout": 900 - }, - { - "id": "E11-durability", - "state_prefix": "[Interactive brownfield ingest on the repo at files/fixture-brownfield (the project root). The scan is done; round 1 presented 6 confirmations (vitest test command; integer-cents money; repository pattern; soft-delete filtering; webhook replay idempotency; legacy/ frozen) and the user replied:]", - "input": "User said: \"all six are correct. One more thing: never run database migrations from CI \u2014 they are applied manually during the release call.\"", - "rubric": [ - "Every one of the six confirmations lands in the memlog as its own entry during this turn, before or alongside artifact writing \u2014 not summarized in conversation only", - "The new migrations landmine is captured: memlog entry plus a kernel landmine line (or entry), written this turn", - "Kernel lines / entries for the confirmed claims are written to disk this turn (progressive writes), not deferred to a later finalize", - "The artifacts written are marked verified (the user confirmed them)", - "If a next round is posed, it contains only genuinely unknowable items \u2014 no re-asking anything just confirmed" - ], - "files": [ - "files/fixture-brownfield/AGENTS.md", - "files/fixture-brownfield/_bmad/context.yaml", - "files/fixture-brownfield/docs/data-access.md", - "files/fixture-brownfield/docs/deploy.md", - "files/fixture-brownfield/docs/money.md", - "files/fixture-brownfield/docs/overview.md", - "files/fixture-brownfield/legacy/README.md", - "files/fixture-brownfield/package.json", - "files/fixture-brownfield/planning/decisions.md", - "files/fixture-brownfield/pnpm-lock.yaml", - "files/fixture-brownfield/src/lib/money.ts", - "files/fixture-brownfield/src/repos/orders.ts", - "files/fixture-brownfield/src/routes/webhooks.ts" - ], - "timeout": 900 - }, - { - "id": "E12a-face-purpose-asked", - "state_prefix": "[Ingest just completed on the repo at files/fixture-refresh (the project root); kernel and bundle exist at context/. The skill offered a human-readable face artifact and the user replied:]", - "input": "User said: \"yes, make me one.\"", - "rubric": [ - "The skill asks what the face is for (its purpose/audience) before generating anything", - "No face artifact is written before the user answers, and nothing in context/ is modified", - "The turn ends waiting for the user" - ], - "files": [ - "files/fixture-refresh/_bmad/context.yaml", - "files/fixture-refresh/context/.memlog.md", - "files/fixture-refresh/context/index.md", - "files/fixture-refresh/context/integer-cents.md", - "files/fixture-refresh/context/kernel.md", - "files/fixture-refresh/context/repository-pattern.md", - "files/fixture-refresh/package.json", - "files/fixture-refresh/planning/decisions.md", - "files/fixture-refresh/src/lib/currency.ts", - "files/fixture-refresh/src/routes/webhooks.ts" - ], - "timeout": 600 - }, - { - "id": "E12b-face-generation", - "state_prefix": "[Ingest just completed on the repo at files/fixture-refresh (the project root); kernel and bundle exist at context/. The skill offered a face artifact, asked its purpose, and the user replied:]", - "input": "User said: \"a short slide deck to onboard new devs to how we handle money and webhooks.\"", - "rubric": [ - "A face artifact is generated and fits the stated purpose: slide-deck-shaped, focused on money handling and webhooks, drawn from the kernel/bundle content", - "The face lives outside context/ (the bundle root)", - "context/index.md does not reference the face; every knowledge entry, kernel.md, and index.md in context/ is byte-unchanged (the append-only .memlog.md may gain entries \u2014 that is prescribed behavior)", - "The face is presented as ephemeral/regenerable, not as a maintained source of truth" - ], - "files": [ - "files/fixture-refresh/_bmad/context.yaml", - "files/fixture-refresh/context/.memlog.md", - "files/fixture-refresh/context/index.md", - "files/fixture-refresh/context/integer-cents.md", - "files/fixture-refresh/context/kernel.md", - "files/fixture-refresh/context/repository-pattern.md", - "files/fixture-refresh/package.json", - "files/fixture-refresh/planning/decisions.md", - "files/fixture-refresh/src/lib/currency.ts", - "files/fixture-refresh/src/routes/webhooks.ts" - ], - "timeout": 900 - } - ] -} diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/AGENTS.md b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/AGENTS.md deleted file mode 100644 index 6b16020a4..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/AGENTS.md +++ /dev/null @@ -1,6 +0,0 @@ -# Wavecart Agents - -House rules maintained by the team — do not reformat this file. - -- Ask before adding new dependencies. -- PR titles follow conventional commits. diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/_bmad/context.yaml b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/_bmad/context.yaml deleted file mode 100644 index 9c1b17dd9..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/_bmad/context.yaml +++ /dev/null @@ -1,2 +0,0 @@ -project_name: wavecart -project_knowledge: context diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/docs/data-access.md b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/docs/data-access.md deleted file mode 100644 index 0ba3f2ab4..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/docs/data-access.md +++ /dev/null @@ -1,3 +0,0 @@ -# Data access -All database access goes through repository classes in src/repos/ — handlers never -use the pg client directly. diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/docs/deploy.md b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/docs/deploy.md deleted file mode 100644 index 01078a028..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/docs/deploy.md +++ /dev/null @@ -1,2 +0,0 @@ -# Deployment (updated 2023-04) -Deploys go through the Heroku pipeline defined in Procfile. See bin/deploy-heroku.sh. diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/docs/money.md b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/docs/money.md deleted file mode 100644 index 7096f333c..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/docs/money.md +++ /dev/null @@ -1,2 +0,0 @@ -# Money handling -Prices are stored as floating-point dollar amounts in the `price_dollars` column. diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/docs/overview.md b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/docs/overview.md deleted file mode 100644 index bd9ef28a1..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/docs/overview.md +++ /dev/null @@ -1,3 +0,0 @@ -# Wavecart Overview -Wavecart is a TypeScript e-commerce API built on Express. The API layer calls the -service layer, which calls the data layer. We use jest for testing. diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/legacy/README.md b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/legacy/README.md deleted file mode 100644 index 5b123c13d..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/legacy/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# legacy/ -PHP checkout being strangled out. Do not modify anything in this directory; changes -ship from src/ only. diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/package.json b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/package.json deleted file mode 100644 index adf79995c..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "wavecart", - "private": true, - "scripts": { - "build": "tsc -p .", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": { - "express": "^4.19.0", - "pg": "^8.11.0", - "stripe": "^14.0.0" - }, - "devDependencies": { - "typescript": "^5.4.0", - "vitest": "^1.6.0" - } -} diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/planning/decisions.md b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/planning/decisions.md deleted file mode 100644 index 4f80f2080..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/planning/decisions.md +++ /dev/null @@ -1,14 +0,0 @@ -# Architecture decisions — wavecart - -## Money is integer cents (2025-11) -Floating-point rounding produced $0.01 invoice mismatches in the Nov 2025 incident. -All amounts are integer cents end-to-end (`amountCents`); display conversion only in -src/lib/money.ts formatters. Rejected: decimal.js (bundle size), storing dollars. - -## Repository pattern for all DB access (2025-08) -All database access goes through classes in src/repos/. Route handlers never touch -the pg client directly. Reason: the soft-delete rule (deleted_at) must be applied in -exactly one layer. - -## Soft delete everywhere (2025-08) -Rows are never DELETEd; `deleted_at` is set. Every query must filter it. diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/pnpm-lock.yaml b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/pnpm-lock.yaml deleted file mode 100644 index 1c0676fed..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/pnpm-lock.yaml +++ /dev/null @@ -1,8 +0,0 @@ -lockfileVersion: "9.0" - -importers: - .: - dependencies: - express: - specifier: ^4.19.0 - version: 4.19.2 diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/src/lib/money.ts b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/src/lib/money.ts deleted file mode 100644 index 4a0cccfa5..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/src/lib/money.ts +++ /dev/null @@ -1,5 +0,0 @@ -// All amounts flow through here. Cents only — see planning/decisions.md. -export type Cents = number; -export const toDisplay = (amountCents: Cents, currency: string): string => - new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amountCents / 100); -export const addCents = (a: Cents, b: Cents): Cents => a + b; diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/src/repos/orders.ts b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/src/repos/orders.ts deleted file mode 100644 index 64770326f..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/src/repos/orders.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Pool } from "pg"; -// Every query in this codebase must filter deleted_at IS NULL — rows are soft-deleted, -// never removed. A naive SELECT returns ghosts. -export class OrderRepo { - constructor(private pool: Pool) {} - async byId(id: string) { - const r = await this.pool.query( - "SELECT * FROM orders WHERE id = $1 AND deleted_at IS NULL", [id]); - return r.rows[0]; - } -} diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/src/routes/webhooks.ts b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/src/routes/webhooks.ts deleted file mode 100644 index 37dd2ac21..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-brownfield/src/routes/webhooks.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { Request, Response } from "express"; -import { OrderRepo } from "../repos/orders"; -// Stripe webhooks: staging replays events every 6 hours. Handlers are idempotent by -// checking event.id against processed_events before acting. -export async function stripeWebhook(req: Request, res: Response) { - res.sendStatus(200); -} diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-greenfield/spec.md b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-greenfield/spec.md deleted file mode 100644 index 10755305e..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-greenfield/spec.md +++ /dev/null @@ -1,15 +0,0 @@ -# Spec — TrailLedger - -A small SaaS for hiking clubs to track shared trip expenses and settle balances. - -## Decided -- Stack: TypeScript, Fastify, Postgres. Frontend later; API first. -- Tests: vitest, colocated `*.test.ts`, no mocking of the database — tests run against - a throwaway Postgres via testcontainers. -- All money amounts are integer cents; currency is per-club, never mixed within a trip. -- Org constraint: every table carries `club_id`; no cross-club queries outside admin jobs. - -## Open -- Persistence shape for the settlement history: the team is split between event-sourcing - the ledger (auditability, replay) and a plain CRUD balance table (simplicity). Real - tradeoffs both ways; not yet decided. diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-monorepo/README.md b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-monorepo/README.md deleted file mode 100644 index d0b552d32..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-monorepo/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# nimbus -pnpm workspace monorepo. Global rule: packages never import across workspace -boundaries except through @nimbus/contracts — direct deep imports break the build -cache in non-obvious ways. diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-monorepo/apps/web/package.json b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-monorepo/apps/web/package.json deleted file mode 100644 index 1e949686a..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-monorepo/apps/web/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "@nimbus/web", - "scripts": { - "dev": "vite", - "test": "vitest run" - } -} diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-monorepo/apps/web/src/conventions.md b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-monorepo/apps/web/src/conventions.md deleted file mode 100644 index 13c617901..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-monorepo/apps/web/src/conventions.md +++ /dev/null @@ -1,2 +0,0 @@ -Web app convention: no default exports anywhere (breaks fast-refresh tooling); -components are named exports from index barrels. diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-monorepo/package.json b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-monorepo/package.json deleted file mode 100644 index ec75a9c0a..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-monorepo/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "nimbus", - "private": true, - "workspaces": [ - "apps/*", - "services/*" - ], - "scripts": { - "lint": "eslint .", - "test": "pnpm -r test" - } -} diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-monorepo/services/billing/package.json b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-monorepo/services/billing/package.json deleted file mode 100644 index ef31d516e..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-monorepo/services/billing/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "@nimbus/billing", - "scripts": { - "test": "vitest run --pool=forks" - } -} diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-monorepo/services/billing/src/handlers.ts b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-monorepo/services/billing/src/handlers.ts deleted file mode 100644 index 538ba8c7d..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-monorepo/services/billing/src/handlers.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Billing handlers must be idempotent: the queue redelivers on any 5xx, and staging -// replays the full day's events nightly. Amounts are integer cents (amountCents). -export async function chargeHandler(): Promise {} diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/context/.memlog.md b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/context/.memlog.md deleted file mode 100644 index 3767f9bed..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/context/.memlog.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -title: wavecart project context -updated: 2026-06-01T10:00 ---- - -- (event) ingest run 2026-06-01: mined package.json, planning/decisions.md, src; user confirmed 4 claims -- (decision) money integer cents confirmed by user; entry integer-cents.md written -- (decision) repository pattern confirmed by user; entry repository-pattern.md written -- (decision) webhook replay landmine confirmed; kernel line only, no entry needed diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/context/index.md b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/context/index.md deleted file mode 100644 index f48d56602..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/context/index.md +++ /dev/null @@ -1,4 +0,0 @@ - - -- [Integer-cents money handling](integer-cents.md) — Why all money is integer cents and what it replaced (decision, verified) -- [Repository pattern for DB access](repository-pattern.md) — All database access goes through src/repos classes (convention, verified) diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/context/integer-cents.md b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/context/integer-cents.md deleted file mode 100644 index ea6394c90..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/context/integer-cents.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -type: decision -title: Integer-cents money handling -description: Why all money is integer cents and what it replaced -tags: [money, correctness] -verified: 2026-06-01 -sources: [src/lib/money.ts] ---- -Floating-point rounding produced $0.01 invoice mismatches (incident 2025-11). -Amounts are integer cents end-to-end; display conversion only in src/lib/money.ts -formatters. Rejected: decimal.js (bundle size), storing dollars (migration risk). diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/context/kernel.md b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/context/kernel.md deleted file mode 100644 index 0dae66011..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/context/kernel.md +++ /dev/null @@ -1,8 +0,0 @@ -# Project Kernel — wavecart -## Commands -- Test: `pnpm test` (vitest — do NOT use jest syntax) -## Conventions that differ from defaults -- Money is always integer cents (`amountCents`), never floats — src/lib/money.ts -- All DB access through repositories in src/repos/ — never call the client directly -## Landmines -- Stripe webhooks replay in staging every 6h — handlers must be idempotent diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/context/repository-pattern.md b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/context/repository-pattern.md deleted file mode 100644 index 3bc3768b6..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/context/repository-pattern.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -type: convention -title: Repository pattern for DB access -description: All database access goes through src/repos classes -tags: [data-access] -verified: 2026-06-01 -sources: [src/repos/orders.ts] ---- -All database access goes through classes in src/repos/. Route handlers never touch -the pg client directly, so the soft-delete filter is applied in exactly one layer. diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/package.json b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/package.json deleted file mode 100644 index adf79995c..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "wavecart", - "private": true, - "scripts": { - "build": "tsc -p .", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": { - "express": "^4.19.0", - "pg": "^8.11.0", - "stripe": "^14.0.0" - }, - "devDependencies": { - "typescript": "^5.4.0", - "vitest": "^1.6.0" - } -} diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/planning/decisions.md b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/planning/decisions.md deleted file mode 100644 index 4f80f2080..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/planning/decisions.md +++ /dev/null @@ -1,14 +0,0 @@ -# Architecture decisions — wavecart - -## Money is integer cents (2025-11) -Floating-point rounding produced $0.01 invoice mismatches in the Nov 2025 incident. -All amounts are integer cents end-to-end (`amountCents`); display conversion only in -src/lib/money.ts formatters. Rejected: decimal.js (bundle size), storing dollars. - -## Repository pattern for all DB access (2025-08) -All database access goes through classes in src/repos/. Route handlers never touch -the pg client directly. Reason: the soft-delete rule (deleted_at) must be applied in -exactly one layer. - -## Soft delete everywhere (2025-08) -Rows are never DELETEd; `deleted_at` is set. Every query must filter it. diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/src/lib/currency.ts b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/src/lib/currency.ts deleted file mode 100644 index 4a0cccfa5..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/src/lib/currency.ts +++ /dev/null @@ -1,5 +0,0 @@ -// All amounts flow through here. Cents only — see planning/decisions.md. -export type Cents = number; -export const toDisplay = (amountCents: Cents, currency: string): string => - new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amountCents / 100); -export const addCents = (a: Cents, b: Cents): Cents => a + b; diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/src/routes/webhooks.ts b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/src/routes/webhooks.ts deleted file mode 100644 index 37dd2ac21..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-refresh/src/routes/webhooks.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { Request, Response } from "express"; -import { OrderRepo } from "../repos/orders"; -// Stripe webhooks: staging replays events every 6 hours. Handlers are idempotent by -// checking event.id against processed_events before acting. -export async function stripeWebhook(req: Request, res: Response) { - res.sendStatus(200); -} diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-standalone/package.json b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-standalone/package.json deleted file mode 100644 index adf79995c..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-standalone/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "wavecart", - "private": true, - "scripts": { - "build": "tsc -p .", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": { - "express": "^4.19.0", - "pg": "^8.11.0", - "stripe": "^14.0.0" - }, - "devDependencies": { - "typescript": "^5.4.0", - "vitest": "^1.6.0" - } -} diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-standalone/planning/decisions.md b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-standalone/planning/decisions.md deleted file mode 100644 index 4f80f2080..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-standalone/planning/decisions.md +++ /dev/null @@ -1,14 +0,0 @@ -# Architecture decisions — wavecart - -## Money is integer cents (2025-11) -Floating-point rounding produced $0.01 invoice mismatches in the Nov 2025 incident. -All amounts are integer cents end-to-end (`amountCents`); display conversion only in -src/lib/money.ts formatters. Rejected: decimal.js (bundle size), storing dollars. - -## Repository pattern for all DB access (2025-08) -All database access goes through classes in src/repos/. Route handlers never touch -the pg client directly. Reason: the soft-delete rule (deleted_at) must be applied in -exactly one layer. - -## Soft delete everywhere (2025-08) -Rows are never DELETEd; `deleted_at` is set. Every query must filter it. diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-standalone/src/lib/money.ts b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-standalone/src/lib/money.ts deleted file mode 100644 index 4a0cccfa5..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-standalone/src/lib/money.ts +++ /dev/null @@ -1,5 +0,0 @@ -// All amounts flow through here. Cents only — see planning/decisions.md. -export type Cents = number; -export const toDisplay = (amountCents: Cents, currency: string): string => - new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amountCents / 100); -export const addCents = (a: Cents, b: Cents): Cents => a + b; diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-workspace/obeya/registry.yaml b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-workspace/obeya/registry.yaml deleted file mode 100644 index 5a512457c..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-workspace/obeya/registry.yaml +++ /dev/null @@ -1,10 +0,0 @@ -bmad_obeya_registry: true -projects: - proj-a: - remote: https://example.invalid/proj-a.git - branch: main - context_root: docs - sibling-api: - remote: https://example.invalid/sibling-api.git - branch: main - context_root: docs diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-workspace/proj-a/docs/kernel.md b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-workspace/proj-a/docs/kernel.md deleted file mode 100644 index 62392dc9d..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-workspace/proj-a/docs/kernel.md +++ /dev/null @@ -1,3 +0,0 @@ -# Project Kernel — proj-a -## Conventions that differ from defaults -- Webhook payloads to sibling-api follow its signing rules — [[sibling-api:webhook-conventions]] diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-workspace/proj-a/src/client.ts b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-workspace/proj-a/src/client.ts deleted file mode 100644 index 5b835a2d6..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-workspace/proj-a/src/client.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Calls sibling-api. Webhook signing rules live in sibling-api's context bundle. -export const callSibling = async (): Promise => {}; diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-workspace/sibling-api/docs/kernel.md b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-workspace/sibling-api/docs/kernel.md deleted file mode 100644 index eadbc5f0f..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-workspace/sibling-api/docs/kernel.md +++ /dev/null @@ -1,3 +0,0 @@ -# Project Kernel — sibling-api -## Landmines -- Webhooks are HMAC-signed; unsigned calls are dropped silently — why: [webhook-conventions](webhook-conventions.md) diff --git a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-workspace/sibling-api/docs/webhook-conventions.md b/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-workspace/sibling-api/docs/webhook-conventions.md deleted file mode 100644 index 63295e1f5..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/files/fixture-workspace/sibling-api/docs/webhook-conventions.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -type: convention -title: webhook-conventions -description: HMAC signing rules for inbound webhooks -verified: 2026-07-01 -sources: [] ---- -All inbound webhooks carry an X-Sig HMAC-SHA256 header over the raw body. Unsigned -or mis-signed calls are dropped silently (no 4xx) to starve probe traffic. diff --git a/src/bmm-skills/plan/bmad-project-context/evals/triggers.json b/src/bmm-skills/plan/bmad-project-context/evals/triggers.json deleted file mode 100644 index e7b97b5b5..000000000 --- a/src/bmm-skills/plan/bmad-project-context/evals/triggers.json +++ /dev/null @@ -1,42 +0,0 @@ -[ - { - "query": "generate project context for this repo", - "should_trigger": true - }, - { - "query": "can you document this project so AI agents understand it", - "should_trigger": true - }, - { - "query": "refresh the project context, a lot has changed", - "should_trigger": true - }, - { - "query": "audit our project context, I think it's stale", - "should_trigger": true - }, - { - "query": "set up project context for our monorepo", - "should_trigger": true - }, - { - "query": "what does our project context say about how we handle money amounts", - "should_trigger": true - }, - { - "query": "write a product brief for my new app idea", - "should_trigger": false - }, - { - "query": "document my REST API endpoints for the public docs site", - "should_trigger": false - }, - { - "query": "help me design the architecture for a new service", - "should_trigger": false - }, - { - "query": "summarize this codebase for me real quick", - "should_trigger": false - } -] diff --git a/src/bmm-skills/plan/bmad-project-context/references/best-practices.md b/src/bmm-skills/plan/bmad-project-context/references/best-practices.md new file mode 100644 index 000000000..9da108c49 --- /dev/null +++ b/src/bmm-skills/plan/bmad-project-context/references/best-practices.md @@ -0,0 +1,65 @@ +# What belongs in a repo's agent instructions + +Rules for deciding what goes in the block, for judging what a repo already has, and for explaining both to the user. + +## The test + +Can an agent derive this by reading the repository? If yes, leave it out — a stored copy is a stale duplicate of something the agent reads more accurately first-hand, and it is charged on every session. Write down what the code cannot say. + +## Admit + +- **Policy the code cannot express** — branch rules, frozen and protected paths, generated files, secrets, security and compliance. Stated by a human or read off an enforcing config, never inferred. +- **What a config file cannot say about running the project** — the root test script does nothing in this workspace, integration tests need a service up first, the suite takes eleven minutes so iterate on single files, the `Makefile` is the real entry point and `package.json` is vestigial, CI runs a typecheck the test script does not. The invocation itself is already stated in `package.json`, `Makefile`, `pyproject.toml`, or CI config and does not earn a line — the correction or the caveat does. +- **Conventions that differ from ecosystem defaults.** An agent follows the norm unless told otherwise, so only the divergences earn a line. +- **Pitfalls with observed evidence** — a recorded lesson, the maintainer's recollection, the same mistake fixed repeatedly in history, or one this session made and caught. A repo yields hundreds of trap-looking facts and none of them predict real mistakes; only observed behavior does. A surprising scan finding is a question to ask, not a line to write. +- **Runtime behavior invisible from the repo** — replaying webhooks, lying health endpoints, environment quirks — once a human confirms it. +- **Entry points and pointers** to where work lands. + +Prefer prohibitions to advice, and name the permitted alternative in the same line. + +## Exclude + +| | Why | +|---|---| +| Repo overviews, directory trees, stack lists | Derived fresh, more accurately; stored copies rot | +| Anything included for being interesting | Interest is not need | +| Style rules an agent self-enforces | Belongs in a formatter, linter, hook, or CI check — propose the check instead | +| Platitudes | Already the default | +| Commands already stated in `package.json`, a `Makefile`, or CI config | Read from the source of truth; a copy drifts the moment a script is renamed | +| Pasted code, changelog content, fast-changing facts | Stale immediately | +| Aspirational state | Describe what is; intent belongs in specs | +| History and edit narration | Git holds it; state present truth | + +## Retire + +A policy or pitfall line goes only when the thing it guards is gone, or the user retires it. Nothing failing lately is not evidence — a working rule erases its own evidence. + +Every other line faces one question at each write: would removing it change agent behavior? If no, cut it. + +## Size + +Every line is paid in every session, and instruction-following degrades as the loaded set grows. Count what other always-loaded files add. Over budget means cut the weakest lines or move them behind a trigger — never raise the budget. Ten lines of evidence means ten lines. + +## Retrieval + +An index the agent must choose to fetch gets skipped; one already in context does not. Keep everything load-bearing in the block. A pointer out of it names a trigger the agent can observe — a path, a file type, a named task — never one it must judge ("when the task is complex") or track about itself ("before your first edit"). + +Rules bounded to a directory go in a nested `AGENTS.md` there, attached by location rather than by pointer. Use a linked file only when the trigger is not a path. + +## Maintain + +- Re-check that caveats still hold — a slow suite that got fast, a workaround for a bug that was fixed. +- Diff deletions and renames since the verified SHA against every line. +- Record provenance in the block so the next run knows what it is diffing from. +- Capture mistakes when they happen, not at review time. One occurrence is a note; recurrence earns a line. +- Route anything mechanically preventable to a hook, lint rule, or CI check. A check that lands deletes its line. + +## Repo or home directory + +This block belongs committed: shared by the team, consistent across machines, versioned with the code it constrains. + +Two things belong in the user's global agent config instead — rules repeating across all their projects, and personal preferences that are theirs rather than the team's. + +## Judging an existing file + +Report, in this order: what is derivable filler, what is unverifiable or stale, what is missing against the sections above, and what is already good. Keep recorded lessons by default — they are maintainer testimony, and are challenged only with evidence that the thing they name is gone or wrong. diff --git a/src/bmm-skills/plan/bmad-project-context/references/bundle-contract.md b/src/bmm-skills/plan/bmad-project-context/references/bundle-contract.md deleted file mode 100644 index 149e2ca88..000000000 --- a/src/bmm-skills/plan/bmad-project-context/references/bundle-contract.md +++ /dev/null @@ -1,48 +0,0 @@ -# Bundle Contract - -The bundle is the directory of small verified knowledge entries behind the kernel — depth on demand, never loaded wholesale. It lives at the bundle root: the resolved `{project_knowledge}` folder (the standard bmm config value; default `docs/`). That folder may already hold human docs — the bundle coexists with them, and the mechanics script manages only files bearing conformant frontmatter, never foreign files. - -## Layout - -```text -/ -├── kernel.md # the always-loaded file (kernel-contract.md governs it) -├── index.md # script-generated; sole entry point into the entries -├── .md ... # small knowledge entries, kebab-case descriptive names -└── compass/ - └── .md ... # one compass per subsystem that earned one -``` - -`index.md` is regenerated by `context.py index`, never hand-edited. If a foreign `index.md` (no generation marker) already exists there, the script refuses and names the fix — move the foreign file, or point `project_knowledge` at a clean folder. One line per entry: `- [title](file.md) — description (type, verified|generated)`. Repo maps (structure, symbols) are never stored here or anywhere — derive them fresh when needed; stored maps rot. - -## Entry shape - -Every entry answers a question the code can't. One concern per entry, a few sentences of body — an entry approaching a page is either two entries or too much. An entry that paraphrases readable code fails audit and is deleted. - -```markdown ---- -type: decision -title: Integer-cents money handling -description: Why all money is integer cents and what it replaced -tags: [money, correctness] -verified: 2026-08-01 -sources: [docs-archive/adr-014.md, src/lib/money.ts] ---- -Floating-point rounding produced $0.01 invoice mismatches (incident 2025-11). -Amounts are integer cents end-to-end; conversion to display happens only in -formatters. Rejected: decimal.js (bundle size), storing dollars (migration -risk). Related: [[payments-api:webhook-conventions]] -``` - -**Required frontmatter:** `type`, `title`, `description`, plus exactly one of `verified: ` (user-confirmed or path-checked — that is all it means) or `generated: ` (inferred, unconfirmed — all auto-mode output). **Optional:** `tags`, `sources` (paths/URLs the claim rests on), `status` (`superseded` retires an entry in place — supersession is this dated field, never narrated in prose), `stale_after: ` (for truths with a known shelf life). Common `type` values: `decision`, `convention`, `landmine`, `domain`, `org-requirement`, `reference` — the set is open; pick the plainest word. - -**Links:** plain markdown links between entries in the same bundle; `[[project:entry]]` for another project's bundle (resolved by `context.py resolve`). - -**Writing rules — every entry body:** succinct to the point of discomfort (if a sentence can be shorter, it isn't done); present truth only (never the story of the edit — "we removed X because..." is banned; supersession is the dated `status` field); no reference without a link (every named decision, doc, file, or system carries a path, `[[project:entry]]` link, or URL a fresh context can follow). - -**Altitude:** when (and only when) an obeya is configured, an entry that looks like a truth about more than this repo may carry `altitude: org-candidate`. It changes nothing about the entry; promotion is a batched pull from above at audit time, never a filing question during capture. - -## Compass files - -A subsystem earns a compass when work keeps landing there and its truths don't belong in the root kernel. 25–35 lines answering five questions in order: **what is this, who owns it, how do I run it, what's surprising, where do I go next.** Every path in it verified. Frontmatter adds `area: ` — that is how `context.py compass ` finds the nearest one. Compasses are never created wall-to-wall; coverage is demand-driven. - diff --git a/src/bmm-skills/plan/bmad-project-context/references/interrogation-guide.md b/src/bmm-skills/plan/bmad-project-context/references/interrogation-guide.md deleted file mode 100644 index 8c8c9f105..000000000 --- a/src/bmm-skills/plan/bmad-project-context/references/interrogation-guide.md +++ /dev/null @@ -1,11 +0,0 @@ -# Interrogation Guide - -How interactive ingest talks to the user. Load only for interactive ingest. - -The orchestrated scan has already produced the claim list; interrogation never opens the conversation. Never ask a question the code could answer — that is a defect, not a courtesy, and it is enforced by eval. - -Ask in chunked rounds of 5–8 — eight is a hard cap; surplus claims wait for the next round, however ready they feel. Round 1 is confirmations: inferred claims stated with their evidence so a confirm takes seconds ("Tests run via `pnpm test` (vitest) — correct?"), kernel-bound claims first; an unverifiable docs claim is surfaced as "the docs say X — still true?", never stated as fact. Round 2 is only what no scan can reach — landmines, frozen areas, org requirements, comment conventions, domain facts, the why behind surprising shapes; ask open and listen, because this round holds the irreplaceable material. - -Before writing, one soft gate: name in a line what will be captured and ask what's missing — a landmine, a frozen area, an org rule that hasn't come up. Users remember one more thing when given the exit, and this class of material is unrecoverable by any later scan. A round that yields nothing new after that is the signal to write, not to invent another round. - -Log every answer and every rejection (with its reason) to the memlog the moment it lands, and write confirmed kernel lines and entries progressively — an interrupted session loses only the round in flight, and refresh never re-asks. Out-of-scope gold the user volunteers is captured, never deflected. A genuinely contested decision — real tradeoffs, multiple viable shapes — goes to `bmad-architecture` with a `gap` logged, never decided here. diff --git a/src/bmm-skills/plan/bmad-project-context/references/kernel-contract.md b/src/bmm-skills/plan/bmad-project-context/references/kernel-contract.md deleted file mode 100644 index 44bae785e..000000000 --- a/src/bmm-skills/plan/bmad-project-context/references/kernel-contract.md +++ /dev/null @@ -1,49 +0,0 @@ -# Kernel Contract - -The kernel is one file — `kernel.md` at the bundle root — injected into every agent session in this project. It is the highest-cost real estate the skill manages: every line is paid in every future session. This contract governs every kernel write, in any intent or mode. - -## Hard rules - -- **Instruction budget: ~150–200 instructions, a ceiling not a target.** Instruction-following measurably decays past this range (IFScale). Count instructions, not lines — one line carrying three rules is three instructions. When the budget is threatened, the weakest line moves to a bundle entry or dies; the budget is never raised. `context.py validate` measures it. -- **Priority ordering.** Most load-bearing rules first — the rules whose violation costs the most. The file must degrade gracefully if truncated or skimmed: a reader who stops halfway got the half that matters most. -- **The pruning test.** *Would removing this line change agent behavior?* If no, the line is deleted. Applied to every line at every write and every audit. - -## What enters, what never does - -A kernel line earns its place only by being **non-derivable** (the agent cannot learn it from the code in reasonable time) and **behavior-changing** (an agent without it does something wrong). The reliable categories: exact commands where the obvious guess fails, conventions that differ from ecosystem defaults, landmines (frozen areas, replaying webhooks, lying health endpoints), and hard org requirements. - -Never enters, regardless of who asks: - -```text -✗ "This project uses TypeScript and React" ← skimmable from package.json -✗ "Write clean, well-tested code" ← LLM default; changes nothing -✗ "The API layer calls the service layer" ← visible in code; overview prose -``` - -## Shape - -Terse sections, imperative lines. The three writing rules bind every line: **succinct to the point of discomfort** (if a line can be shorter, it isn't done); **present truth only** (never the story of the edit — supersession is a dated frontmatter field, not a sentence); **no reference without a link** (every named decision, doc, file, or system carries a path, `[[project:entry]]` link, or URL a fresh context can follow). No prose paragraphs, no introduction, no summary. The target shape: - -```markdown -# Project Kernel — acme-billing -## Commands -- Test: `pnpm test` (vitest — do NOT use jest syntax) -- Single file: `pnpm test -- path/to/file` -## Conventions that differ from defaults -- Money is always integer cents (`amountCents`), never floats — src/lib/money.ts -- All DB access through repositories in src/repos/ — never call the client directly -- Errors: throw typed AppError subclasses; HTTP mapping only in middleware -## Landmines -- Stripe webhooks replay in staging every 6h — handlers must be idempotent -- `legacy/` is frozen: never modify; it is being strangled out -``` - -Section names adapt to the project; the example's three cover most repos. A line whose depth exceeds one sentence points at its bundle entry: `- Money is integer cents — why: [integer-cents](integer-cents.md)`. - -## Trust - -An interactive kernel holds only confirmed truths — each line was user-confirmed or path-verified during ingest. An auto-mode kernel carries `status: generated` in its frontmatter (the only frontmatter a kernel ever has) until a human session confirms it; confirmation removes the field. There is no per-line trust marking — the kernel is too small to need it, and markers cost budget. - -## Kernel-only is success - -A small project needs a kernel and nothing else. When ingest finds fewer than a handful of truths worth depth, say so and stop — manufacturing bundle entries to justify the machinery is exactly the volume failure this skill replaces. diff --git a/src/bmm-skills/plan/bmad-project-context/references/placement.md b/src/bmm-skills/plan/bmad-project-context/references/placement.md deleted file mode 100644 index 30c13119d..000000000 --- a/src/bmm-skills/plan/bmad-project-context/references/placement.md +++ /dev/null @@ -1,13 +0,0 @@ -# Placement — first run only - -How the kernel and compasses reach agents is the user's choice, asked once and changeable any time. Confirm the bundle location (`{project_knowledge}`) in the same breath. - -The three placements: - -- **bmad** — loaded via BMad customization arrays; agent files untouched. Recommend adding `file:{project_knowledge}/kernel.md` to `persistent_facts` in the workflows they use — offer to invoke `bmad-customize` to do it. The legacy `**/project-context.md` glob already in those arrays keeps outputs from the retired bmad-generate-project-context loading. -- **agent-files** — the script writes managed `` blocks into root and nested `AGENTS.md` files (`sync` command); surrounding content is never disturbed, and `sync --dry-run` previews every file a sync will touch. The only loading path without BMad — the standalone default. -- **both** — arrays plus agent files, kept in sync. - -Suggest by detection (BMad install present → bmad; none → agent-files), and record the answer as `context_placement` — the one key this skill ever writes into project config (standalone: `{project-root}/_bmad/context.yaml`), because `context.py sync` refuses to run without it. - -A written kernel is worthless until the user's harness actually loads it, and harnesses are too many and too varied to verify. So whenever `AGENTS.md` carries the kernel, the closing message must say plainly: if your harness does not auto-load `AGENTS.md`, make the context file it does load pull this one in (e.g. a `CLAUDE.md` containing `@AGENTS.md` for Claude Code). diff --git a/src/bmm-skills/plan/bmad-project-context/references/template.md b/src/bmm-skills/plan/bmad-project-context/references/template.md new file mode 100644 index 000000000..8a8108940 --- /dev/null +++ b/src/bmm-skills/plan/bmad-project-context/references/template.md @@ -0,0 +1,55 @@ +# Block shape + +Sections in this order. Omit any section with nothing that passes its rule — never write an empty one. Admission rules: `best-practices.md`. + +1. **Orientation** — three or four sentences: what this is, the stack, where planning and deeper docs live. +2. **Policy** — what the org requires. +3. **Where things are** — entry points, and pointers to children and linked files. +4. **Running and verifying** — only what `package.json`, a `Makefile`, or CI config does not already say. +5. **Conventions that differ from defaults** +6. **Known pitfalls** + +Terse imperative lines under plain headings. No prose beyond Orientation, no introduction, no summary. A bare fact appears only as the justification clause of an instruction — "Exclude `vendor/` from searches, it is 60% of tracked files", never "`vendor/` is 60% of tracked files". A prohibition names the alternative. At most two emphasis markers in the whole block. + +## Worked example + +````markdown + + + +## acme-billing + +Payment processing for Acme storefronts. TypeScript/Node, pnpm, Postgres. Planning lives in `docs/planning/`, tickets in Linear (ACME board). + +## Policy + +- Never push to main; PRs only, one approval. +- Never modify `legacy/` — frozen, being replaced. New work goes in `src/`. +- Never hand-edit `src/generated/` — run `pnpm codegen`. + +## Where things are + +- Webhook handling: `src/routes/webhooks.ts`; conventions in `docs/webhooks.md` +- Writing a migration? Read `docs/db-rules.md` first — ordering, transaction boundaries, pool limits. +- Billing service has its own guide: `services/billing/AGENTS.md` + +## Running and verifying + +- Run single test files while iterating; the full suite takes ~11 minutes. +- Integration tests need `docker compose up -d` first, and fail confusingly without it. +- CI also runs `pnpm typecheck`, which `pnpm test` does not cover. + +## Conventions that differ from defaults + +- Money is integer cents (`amountCents`), never floats — `src/lib/money.ts` +- All DB access goes through repositories in `src/repos/`; never call the client directly. + +## Known pitfalls + +- Stripe webhooks replay in staging every 6h — handlers must be idempotent. +- Use vitest matchers, not jest — agents repeatedly add jest syntax here. + + +```` + +Fill the provenance line with the real date and the commit SHA verified against. Refresh diffs from that SHA. diff --git a/src/bmm-skills/plan/bmad-project-context/scripts/context.py b/src/bmm-skills/plan/bmad-project-context/scripts/context.py deleted file mode 100644 index b6a1c5b26..000000000 --- a/src/bmm-skills/plan/bmad-project-context/scripts/context.py +++ /dev/null @@ -1,657 +0,0 @@ -#!/usr/bin/env python3 -# /// script -# requires-python = ">=3.10" -# /// -"""context.py — mechanics for bmad-project-context. - -One core runtime script (memlog.py lineage): everything mechanical about a project's -context bundle so no LLM ever guesses at mechanical facts. The bundle lives at the -project's `project_knowledge` folder (default `docs/`); the script manages only files -bearing conformant frontmatter and never touches foreign files. - -Commands (all accept --json): - validate [root] frontmatter + link + index check; exit 1 on findings - index [root] regenerate index.md (refuses to overwrite a foreign one) - sweep [root] [--today D] staleness report (stale_after passed; sources drifted) - resolve [--refresh] cross-project resolution: self > workspace > cache > remote - compass [root] nearest compass file covering a repo-relative path - sync materialize kernel/compass blocks into AGENTS.md files - (only under the agent-files/both placement) - bootstrap copy this script to {project-root}/_bmad/scripts/context.py -""" -import argparse -import datetime as dt -import json -import os -import re -import shutil -import subprocess -import sys -import tempfile -from pathlib import Path - -INDEX_MARKER = "" -BLOCK_START = "" -BLOCK_END = "" -REGISTRY_FILE = "registry.yaml" -REGISTRY_KEY = "bmad_obeya_registry" -DEFAULT_KNOWLEDGE = "docs" - - -def fail(msg, code=1): - print(msg, file=sys.stderr) - sys.exit(code) - - -def emit(data, as_json, human=""): - if as_json: - print(json.dumps(data)) - elif human: - print(human) - - -# ── config ─────────────────────────────────────────────────────────────────── -# One resolution, shared by every command. Delegates to the installed BMad -# resolver (resolve_config.py, four-layer TOML merge) whenever it is present so -# script and platform can never disagree; falls back to a native TOML merge, -# then legacy YAML files, then defaults. - -CONFIG_KEYS = ("project_name", "project_knowledge", "output_folder", - "context_placement", "obeya_remote", "user_name", - "communication_language", "document_output_language") -CONFIG_DEFAULTS = {"project_knowledge": DEFAULT_KNOWLEDGE, "output_folder": "_bmad-output"} -TOML_LAYERS = ("_bmad/config.toml", "_bmad/config.user.toml", - "_bmad/custom/config.toml", "_bmad/custom/config.user.toml") -YAML_LAYERS = ("_bmad/config.yaml", "_bmad/bmm/config.yaml", - "_bmad/bmm/config.user.yaml", "_bmad/context.yaml") - -try: - import tomllib -except ImportError: # Python 3.10: TOML layers skipped, YAML fallback still works - tomllib = None - - -def _installed_resolver_config(project_root: Path): - resolver = project_root / "_bmad" / "scripts" / "resolve_config.py" - if not (resolver.exists() and (project_root / "_bmad" / "config.toml").exists()): - return None - proc = subprocess.run( - [sys.executable, str(resolver), "--project-root", str(project_root)], - capture_output=True, text=True) - if proc.returncode != 0: - return None - try: - data = json.loads(proc.stdout) - except json.JSONDecodeError: - return None - return {k: v for k, v in data.items() if isinstance(v, (str, int, bool))} - - -def _toml_chain(project_root: Path): - if tomllib is None or not (project_root / TOML_LAYERS[0]).exists(): - return None - merged = {} - for rel in TOML_LAYERS: - f = project_root / rel - if not f.exists(): - continue - try: - with f.open("rb") as fh: - layer = tomllib.load(fh) - except (tomllib.TOMLDecodeError, OSError): - continue - merged.update({k: v for k, v in layer.items() if isinstance(v, (str, int, bool))}) - return merged - - -def _yaml_chain(project_root: Path): - merged = {} - for rel in YAML_LAYERS: # later layers win per key - f = project_root / rel - if not f.exists(): - continue - for line in f.read_text(encoding="utf-8").splitlines(): - m = re.match(r"^([A-Za-z_][\w-]*):\s*(.+?)\s*$", line) - if m: - merged[m.group(1)] = m.group(2).strip("'\"") - return merged - - -def resolve_full_config(project_root: Path) -> dict: - cfg = (_installed_resolver_config(project_root) - or _toml_chain(project_root) - or {}) - for k, v in _yaml_chain(project_root).items(): - cfg.setdefault(k, v) # YAML fills gaps (e.g. standalone context_placement), never overrides TOML - for k, v in CONFIG_DEFAULTS.items(): - cfg.setdefault(k, v) - return cfg - - -def bundle_root(project_root: Path, override: str | None, cfg: dict) -> Path: - raw = override or str(cfg.get("project_knowledge", DEFAULT_KNOWLEDGE)) - raw = raw.replace("{project-root}/", "").replace("{project-root}", "") - return (project_root / raw) if not Path(raw).is_absolute() else Path(raw) - - -def cmd_config(args, project_root, cfg, as_json): - out = {k: cfg.get(k) for k in CONFIG_KEYS} - out["bundle_root"] = str(bundle_root(project_root, None, cfg)) - emit(out, as_json, "\n".join(f"{k}: {v}" for k, v in out.items() if v is not None)) - - -# ── frontmatter ────────────────────────────────────────────────────────────── - -def parse_frontmatter(text: str): - """Returns (fields|None, error|None, body). Naive flat YAML subset.""" - if not text.startswith("---\n"): - return None, None, text - end = text.find("\n---\n", 4) - if end == -1: - return None, "unparseable frontmatter (no closing fence)", "" - fields = {} - for line in text[4:end].splitlines(): - if not line.strip() or line.startswith("#"): - continue - m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*?)\s*$", line) - if not m: - return None, "unparseable frontmatter", "" - key, val = m.group(1), m.group(2) - if val.startswith("[") and val.endswith("]"): - fields[key] = [v.strip().strip("'\"") for v in val[1:-1].split(",") if v.strip()] - else: - fields[key] = re.split(r"\s+#", val)[0].strip().strip("'\"") - return fields, None, text[end + 5:] - - -def load_entries(root: Path): - """Conformant entries at the bundle root: (path, fields|None, error|None, body).""" - out = [] - if not root.is_dir(): - return out - for f in sorted(root.glob("*.md")): - if f.name in ("kernel.md", "index.md"): - continue - fields, err, body = parse_frontmatter(f.read_text(encoding="utf-8")) - if fields is None and err is None: - continue # foreign file: no frontmatter - if fields is not None and "type" not in fields and "title" not in fields: - continue # foreign file: frontmatter but not ours - out.append((f, fields, err, body)) - return out - - -def load_compasses(root: Path): - out = [] - cdir = root / "compass" - if not cdir.is_dir(): - return out - for f in sorted(cdir.glob("*.md")): - fields, err, body = parse_frontmatter(f.read_text(encoding="utf-8")) - out.append((f, fields or {}, err, body)) - return out - - -def trust_of(fields: dict) -> str: - return "verified" if "verified" in fields else "generated" - - -def index_rows(entries): - rows = [] - for f, fields, err, _ in entries: - if err or not fields or "type" not in fields or "title" not in fields: - continue - rows.append(f"- [{fields['title']}]({f.name}) — {fields.get('description', '')} " - f"({fields['type']}, {trust_of(fields)})") - return rows - - -def render_index(entries) -> str: - return INDEX_MARKER + "\n\n" + "\n".join(index_rows(entries)) + "\n" - - -# ── validate ───────────────────────────────────────────────────────────────── - -def cmd_validate(args, project_root, cfg, as_json): - root = bundle_root(project_root, args.root, cfg) - findings = [] - entries = load_entries(root) - for f, fields, err, body in entries: - rel = f.name - if err: - findings.append({"file": rel, "issue": err}) - continue - for req in ("type", "title", "description"): - if req not in fields: - findings.append({"file": rel, "issue": f"missing required field: {req}"}) - has_v, has_g = "verified" in fields, "generated" in fields - if has_v == has_g: - findings.append({"file": rel, "issue": - "exactly one of verified/generated is required"}) - for m in re.finditer(r"\[[^\]]*\]\(([^)]+)\)", body): - target = m.group(1) - if target.startswith(("http://", "https://", "#")) or not target.endswith(".md"): - continue - if not (f.parent / target).exists(): - findings.append({"file": rel, "issue": f"dangling link: {target}"}) - for f, fields, err, _ in load_compasses(root): - rel = f"compass/{f.name}" - if err: - findings.append({"file": rel, "issue": err}) - elif "area" not in fields: - findings.append({"file": rel, "issue": "missing required field: area"}) - idx = root / "index.md" - listable = {f.name for f, fields, err, _ in entries - if not err and fields and "type" in fields and "title" in fields} - if not idx.exists(): - if listable: - findings.append({"file": "index.md", "issue": "index.md missing — run: index"}) - else: - text = idx.read_text(encoding="utf-8") - linked = set(re.findall(r"\]\(([^)]+\.md)\)", text)) - for name in sorted(listable - linked): - findings.append({"file": name, "issue": f"entry {name} missing from index.md"}) - for name in sorted(linked - listable): - findings.append({"file": "index.md", "issue": f"index row points to missing entry: {name}"}) - stats = {"kernel": {"lines": 0, "bullets": 0, "tokens": 0}, "entries": {}, "bundle_tokens": 0} - kernel = root / "kernel.md" - if kernel.exists(): - ktext = kernel.read_text(encoding="utf-8") - kfields, kerr, kbody = parse_frontmatter(ktext) - if kerr: - findings.append({"file": "kernel.md", "issue": kerr}) - elif kfields: - for key in kfields: - if key != "status": - findings.append({"file": "kernel.md", - "issue": f"kernel frontmatter key not allowed: {key}"}) - body = kbody or ktext - lines = [ln for ln in body.splitlines() if ln.strip()] - bullets = [ln for ln in lines if ln.lstrip().startswith("- ")] - stats["kernel"] = {"lines": len(lines), "bullets": len(bullets), - "tokens": int(len(body) / 4)} - if len(bullets) > 200 or len(lines) > 250: - findings.append({"file": "kernel.md", - "issue": f"kernel over instruction budget: {len(bullets)} bullets / " - f"{len(lines)} lines (ceiling ~200 instructions)"}) - total = stats["kernel"]["tokens"] - for f, fields, err, body in entries: - toks = int(len(body) / 4) - stats["entries"][f.name] = toks - total += toks - if toks > 400: - findings.append({"file": f.name, - "issue": f"entry ~{toks} tokens, approaching a page — " - f"split into two entries or cut"}) - stats["bundle_tokens"] = total - emit({"ok": not findings, "findings": findings, "stats": stats}, as_json, - "\n".join(f"{x['file']}: {x['issue']}" for x in findings) or "clean") - sys.exit(1 if findings else 0) - - -# ── index ──────────────────────────────────────────────────────────────────── - -def cmd_index(args, project_root, cfg, as_json): - root = bundle_root(project_root, args.root, cfg) - idx = root / "index.md" - first_line = (idx.read_text(encoding="utf-8").splitlines() or [""])[0] if idx.exists() else "" - if first_line and INDEX_MARKER not in first_line: - fail(f"refusing to overwrite foreign index.md at {idx} — move it, or point " - f"project_knowledge at a clean folder") - entries = load_entries(root) - content = render_index(entries) - if not idx.exists() or idx.read_text(encoding="utf-8") != content: - idx.write_text(content, encoding="utf-8") - emit({"ok": True, "entries": len(index_rows(entries)), "written": str(idx)}, as_json) - - -# ── sweep ──────────────────────────────────────────────────────────────────── - -def source_date(project_root: Path, source: str): - proc = subprocess.run(["git", "log", "-1", "--format=%cI", "--", source], - cwd=str(project_root), capture_output=True, text=True) - if proc.returncode == 0 and proc.stdout.strip(): - return proc.stdout.strip()[:10] - p = project_root / source - if p.exists(): - return dt.date.fromtimestamp(p.stat().st_mtime).isoformat() - return None - - -PATH_TOKEN = re.compile(r"`([^`\s]+/[^`\s]+)`") - - -def missing_body_paths(project_root: Path, text: str): - """Backticked repo-relative paths whose top directory exists but the file does not.""" - out = [] - for tok in PATH_TOKEN.findall(text): - tok = tok.strip().rstrip("/") - if "://" in tok or tok.startswith(("{", "<", "-", "~", "/")) or "*" in tok: - continue - first = tok.split("/")[0] - if (project_root / first).is_dir() and not (project_root / tok).exists(): - out.append(tok) - return out - - -def cmd_sweep(args, project_root, cfg, as_json): - root = bundle_root(project_root, args.root, cfg) - today = args.today or dt.date.today().isoformat() - stale, missing = [], [] - kernel = root / "kernel.md" - if kernel.exists(): - for tok in missing_body_paths(project_root, kernel.read_text(encoding="utf-8")): - missing.append({"file": "kernel.md", "reason": f"path `{tok}` does not exist"}) - for f, fields, err, body in load_entries(root): - if err or not fields: - continue - rel = f.name - if fields.get("stale_after") and str(fields["stale_after"]) < today: - stale.append({"file": rel, "reason": f"stale_after {fields['stale_after']} passed"}) - sources = fields.get("sources") or [] - for s in (sources if isinstance(sources, list) else [sources]): - if "://" in s: - continue - if not (project_root / s).exists(): - missing.append({"file": rel, "reason": f"source {s} does not exist"}) - elif fields.get("verified"): - changed = source_date(project_root, s) - if changed and changed > str(fields["verified"]): - stale.append({"file": rel, - "reason": f"source {s} changed {changed}, after verified {fields['verified']}"}) - for tok in missing_body_paths(project_root, body): - missing.append({"file": rel, "reason": f"path `{tok}` does not exist"}) - emit({"stale": stale, "missing": missing}, as_json, - "\n".join(f"{x['file']}: {x['reason']}" for x in stale + missing) or "clean") - - -# ── compass ────────────────────────────────────────────────────────────────── - -def nearest_compass(root: Path, target: str): - best, best_len = None, -1 - for f, fields, err, body in load_compasses(root): - area = str(fields.get("area", "")).rstrip("/") - if err or not area: - continue - if target == area or target.startswith(area + "/"): - if len(area) > best_len: - best, best_len = (f, fields, body), len(area) - return best - - -def cmd_compass(args, project_root, cfg, as_json): - root = bundle_root(project_root, args.root, cfg) - hit = nearest_compass(root, args.path.rstrip("/")) - if not hit: - emit({"path": None, "content": None}, as_json) - return - f, fields, body = hit - emit({"path": str(f), "area": fields.get("area"), "content": body}, as_json) - if not as_json: - sys.stdout.write(f.read_text(encoding="utf-8")) - - -# ── resolve ────────────────────────────────────────────────────────────────── - -def parse_registry(path: Path) -> dict: - """Minimal indent parser for the obeya registry: projects..: value.""" - projects, current = {}, None - in_projects = False - for line in path.read_text(encoding="utf-8").splitlines(): - if not line.strip() or line.strip().startswith("#"): - continue - indent = len(line) - len(line.lstrip()) - key, _, val = line.strip().partition(":") - val = val.strip().strip("'\"") - if indent == 0: - in_projects = key == "projects" - elif in_projects and indent == 2: - current = key - projects[current] = {} - elif in_projects and indent >= 4 and current: - projects[current][key] = val - return projects - - -def find_workspace(project_root: Path): - """Walk up for sibling checkouts and an obeya checkout. Returns (siblings, registry).""" - siblings, registry = {}, None - node = project_root.parent - for _ in range(6): - if not node or node == node.parent: - break - try: - children = [c for c in node.iterdir() if c.is_dir()] - except OSError: - break - for c in children: - reg = c / REGISTRY_FILE - if registry is None and reg.exists() and REGISTRY_KEY in reg.read_text(encoding="utf-8"): - registry = parse_registry(reg) - if c != project_root: - siblings.setdefault(c.name, c) - node = node.parent - return siblings, registry - - -def cache_dir() -> Path: - return Path(os.environ.get("BMAD_CONTEXT_CACHE", str(Path.home() / ".bmad" / "context-cache"))) - - -def cache_lookup(project: str): - pointer = cache_dir() / f"{project}.latest.json" - if pointer.exists(): - try: - meta = json.loads(pointer.read_text(encoding="utf-8")) - sha = meta["sha"] - except (json.JSONDecodeError, OSError, KeyError, TypeError): - return None - path = cache_dir() / f"{project}@{sha}" - if path.is_dir(): - return {"path": str(path), "sha": sha, - "fetched_at": meta.get("fetched_at"), "source": "cache"} - return None - - -def sparse_fetch(project: str, record: dict): - remote, branch = record["remote"], record.get("branch", "main") - context_root = record.get("context_root", DEFAULT_KNOWLEDGE) - with tempfile.TemporaryDirectory() as tmp: - clone = Path(tmp) / "clone" - proc = subprocess.run( - ["git", "clone", "-q", "--depth", "1", "--filter=blob:none", "--sparse", - "--branch", branch, remote, str(clone)], - capture_output=True, text=True) - if proc.returncode != 0: - return None, proc.stderr.strip() - subprocess.run(["git", "-C", str(clone), "sparse-checkout", "set", context_root], - capture_output=True, check=False) - sha = subprocess.run(["git", "-C", str(clone), "rev-parse", "HEAD"], - capture_output=True, text=True).stdout.strip() - src = clone / context_root - if not src.is_dir(): - return None, f"context root {context_root!r} not present in {remote}" - dest = cache_dir() / f"{project}@{sha}" - if dest.exists(): - shutil.rmtree(dest) - dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copytree(src, dest) - fetched_at = dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds") - pointer = cache_dir() / f"{project}.latest.json" - tmp_pointer = pointer.with_suffix(".json.tmp") - tmp_pointer.write_text(json.dumps({"sha": sha, "fetched_at": fetched_at}), encoding="utf-8") - os.replace(tmp_pointer, pointer) - return {"path": str(dest), "sha": sha, "fetched_at": fetched_at, - "source": "remote"}, None - - -def git_head(path: Path): - proc = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(path), - capture_output=True, text=True) - return proc.stdout.strip() if proc.returncode == 0 else None - - -def sibling_bundle(checkout: Path): - sib_cfg = resolve_full_config(checkout) - root = bundle_root(checkout, None, sib_cfg) - return root if (root / "kernel.md").exists() or (root / "index.md").exists() else None - - -def cmd_resolve(args, project_root, cfg, as_json): - project, _, entry = args.name.partition(":") - result = None - self_name = cfg.get("project_name") or project_root.name - if project == self_name: - result = {"path": str(bundle_root(project_root, None, cfg)), - "sha": git_head(project_root), "fetched_at": None, "source": "local"} - siblings, registry = (None, None) - if result is None: - siblings, registry = find_workspace(project_root) - checkout = siblings.get(project) - root = sibling_bundle(checkout) if checkout else None - if root: - result = {"path": str(root), "sha": git_head(checkout), - "fetched_at": None, "source": "local"} - if result is None and not args.refresh: - result = cache_lookup(project) - if result is None: - if registry is None and cfg.get("obeya_remote"): - fetched, _ = sparse_fetch("obeya", {"remote": cfg["obeya_remote"], - "context_root": "."}) - if fetched: - reg = Path(fetched["path"]) / REGISTRY_FILE - if reg.exists(): - registry = parse_registry(reg) - record = (registry or {}).get(project) - if record: - result, err = sparse_fetch(project, record) - if result is None: - cached = cache_lookup(project) - if cached: - cached["warning"] = f"remote unreachable ({err}); serving cache" - result = cached - if result is None: - fail(f"cannot resolve {project!r}: not this project, no workspace checkout, " - f"no cache, and no registry record") - if entry: - hit = next((p for p in Path(result["path"]).glob("*.md") - if p.stem == entry), None) - if not hit: - fail(f"entry {entry!r} not found in {project!r} bundle at {result['path']}") - result["path"] = str(hit) - emit(result, as_json, result["path"]) - - -# ── sync ───────────────────────────────────────────────────────────────────── - -def strip_frontmatter(text: str) -> str: - fields, err, body = parse_frontmatter(text) - return text if fields is None and err is None and not body else (body or text) - - -def rewrite_links(content: str, source_dir: Path, target_dir: Path) -> str: - """Re-anchor relative .md links so they resolve from the file the block lands in.""" - def repl(m): - text, target = m.group(1), m.group(2) - if target.startswith(("http://", "https://", "#", "/")) or not target.endswith(".md"): - return m.group(0) - resolved = (source_dir / target).resolve() - return f"[{text}]({Path(os.path.relpath(resolved, target_dir)).as_posix()})" - return re.sub(r"\[([^\]]*)\]\(([^)]+)\)", repl, content) - - -def apply_block(target: Path, content: str, dry: bool = False) -> bool: - block = f"{BLOCK_START}\n{content.rstrip()}\n{BLOCK_END}\n" - if target.exists(): - text = target.read_text(encoding="utf-8") - if BLOCK_START in text and BLOCK_END in text: - pre = text[:text.index(BLOCK_START)] - post = text[text.index(BLOCK_END) + len(BLOCK_END):].lstrip("\n") - new = pre + block + post - else: - new = text.rstrip("\n") + "\n\n" + block - else: - new = block - if target.exists() and target.read_text(encoding="utf-8") == new: - return False - if not dry: - target.write_text(new, encoding="utf-8") - return True - - -def cmd_sync(args, project_root, cfg, as_json): - placement = cfg.get("context_placement") - if placement not in ("agent-files", "both"): - fail(f"sync runs only under the agent-files or both placement " - f"(context_placement is {placement!r})") - root = bundle_root(project_root, None, cfg) - dry = getattr(args, "dry_run", False) - written = [] - kernel = root / "kernel.md" - if kernel.exists(): - content = rewrite_links(strip_frontmatter(kernel.read_text(encoding="utf-8")), - root, project_root) - if apply_block(project_root / "AGENTS.md", content, dry): - written.append(str(project_root / "AGENTS.md")) - for f, fields, err, body in load_compasses(root): - area = str(fields.get("area", "")).rstrip("/") - if err or not area: - continue - area_dir = project_root / area - if area_dir.is_dir(): - if apply_block(area_dir / "AGENTS.md", - rewrite_links(body, root / "compass", area_dir), dry): - written.append(str(area_dir / "AGENTS.md")) - emit({"written": written, "dry_run": dry}, as_json, "\n".join(written) or "up to date") - - -# ── bootstrap ──────────────────────────────────────────────────────────────── - -def cmd_bootstrap(args, project_root, cfg, as_json): - target = project_root / "_bmad" / "scripts" / "context.py" - target.parent.mkdir(parents=True, exist_ok=True) - self_bytes = Path(__file__).resolve().read_bytes() - if not target.exists() or target.read_bytes() != self_bytes: - target.write_bytes(self_bytes) - emit({"path": str(target)}, as_json, str(target)) - - -# ── main ───────────────────────────────────────────────────────────────────── - -def main(argv=None): - p = argparse.ArgumentParser(prog="context.py", description=__doc__) - p.add_argument("--json", action="store_true", dest="as_json") - p.add_argument("--project-root", default=".") - jp = argparse.ArgumentParser(add_help=False) # lets --json follow the subcommand too - jp.add_argument("--json", action="store_true", dest="as_json", default=argparse.SUPPRESS) - sub = p.add_subparsers(dest="command", required=True) - - s = sub.add_parser("validate", parents=[jp]) - s.add_argument("root", nargs="?") - s = sub.add_parser("index", parents=[jp]) - s.add_argument("root", nargs="?") - s = sub.add_parser("sweep", parents=[jp]) - s.add_argument("root", nargs="?") - s.add_argument("--today") - s = sub.add_parser("resolve", parents=[jp]) - s.add_argument("name") - s.add_argument("--refresh", action="store_true") - s = sub.add_parser("compass", parents=[jp]) - s.add_argument("path") - s.add_argument("root", nargs="?") - s = sub.add_parser("sync", parents=[jp]) - s.add_argument("--dry-run", action="store_true", dest="dry_run") - sub.add_parser("bootstrap", parents=[jp]) - sub.add_parser("config", parents=[jp]) - - args = p.parse_args(argv) - project_root = Path(args.project_root).resolve() - cfg = resolve_full_config(project_root) - {"validate": cmd_validate, "index": cmd_index, "sweep": cmd_sweep, - "resolve": cmd_resolve, "compass": cmd_compass, "sync": cmd_sync, - "bootstrap": cmd_bootstrap, "config": cmd_config}[args.command]( - args, project_root, cfg, args.as_json) - - -if __name__ == "__main__": - main() diff --git a/src/bmm-skills/plan/bmad-project-context/scripts/tests/test_context.py b/src/bmm-skills/plan/bmad-project-context/scripts/tests/test_context.py deleted file mode 100644 index 6a86bbd82..000000000 --- a/src/bmm-skills/plan/bmad-project-context/scripts/tests/test_context.py +++ /dev/null @@ -1,47 +0,0 @@ -# /// script -# requires-python = ">=3.10" -# dependencies = ["pytest>=8.0"] -# /// -"""Smoke tests for the skill's bundled context.py (the R10 bootstrap source). - -The full Layer-1 suite lives with the core script at src/scripts/tests/test_context.py; -this file only proves the bundled copy is intact and runnable on its own. -Run: uv run --with pytest pytest scripts/tests/test_context.py -""" -import json -import subprocess -import sys -from pathlib import Path - -CONTEXT_PY = Path(__file__).resolve().parent.parent / "context.py" - - -def run(args, cwd): - return subprocess.run([sys.executable, str(CONTEXT_PY), *args], - cwd=str(cwd), capture_output=True, text=True) - - -def test_bootstrap_installs_to_known_spot(tmp_path): - proc = run(["--json", "bootstrap"], tmp_path) - assert proc.returncode == 0, proc.stderr - target = tmp_path / "_bmad" / "scripts" / "context.py" - assert target.exists() - assert target.read_bytes() == CONTEXT_PY.read_bytes() - - -def test_index_validate_roundtrip(tmp_path): - docs = tmp_path / "docs" - docs.mkdir() - (docs / "kernel.md").write_text("# Project Kernel — smoke\n") - (docs / "rule.md").write_text( - "---\ntype: convention\ntitle: rule\ndescription: d\nverified: 2026-01-01\n---\nBody.\n") - assert run(["index"], tmp_path).returncode == 0 - proc = run(["--json", "validate"], tmp_path) - assert proc.returncode == 0 - assert json.loads(proc.stdout)["ok"] is True - - -def test_matches_core_script_when_in_repo(): - core = CONTEXT_PY.parents[5] / "scripts" / "context.py" - if core.exists(): # only meaningful inside the bmm source repo - assert core.read_bytes() == CONTEXT_PY.read_bytes() diff --git a/src/bmm-skills/ship/bmad-correct-course/SKILL.md b/src/bmm-skills/ship/bmad-correct-course/SKILL.md index f62b91780..38d1a514f 100644 --- a/src/bmm-skills/ship/bmad-correct-course/SKILL.md +++ b/src/bmm-skills/ship/bmad-correct-course/SKILL.md @@ -77,7 +77,7 @@ Activation is complete. If `activation_steps_prepend` or `activation_steps_appen | Architecture | `{planning_artifacts}/*architecture*.md` (whole) or `{planning_artifacts}/*architecture*/*.md` (sharded) | FULL_LOAD | | UX Design | `{planning_artifacts}/*ux*.md` (whole) or `{planning_artifacts}/*ux*/*.md` (sharded) | FULL_LOAD | | Spec | `{planning_artifacts}/*spec-*.md` (whole) | FULL_LOAD | -| Document Project | `{project_knowledge}/index.md` (sharded) | INDEX_GUIDED | +| Project Context | `AGENTS.md` in the affected repo (the `bmad:context` block) | FULL_LOAD | ## Execution @@ -95,12 +95,11 @@ Activation is complete. If `activation_steps_prepend` or `activation_steps_appen - Process the combined content as a single document 4. **Priority**: If both whole and sharded versions exist, use the whole document -**Discovery Process for INDEX_GUIDED documents (Document Project):** +**Discovery Process for Project Context:** -1. **Search for index file** - Look for `{project_knowledge}/index.md` -2. **If found**: Read the index to understand available documentation sections -3. **Selectively load sections** based on relevance to the change being analyzed — do NOT load everything, only sections that relate to the impacted areas -4. **This document is optional** — skip if `{project_knowledge}` does not exist (greenfield projects) +1. **Read `AGENTS.md`** in the repo the change affects — the block between the `bmad:context` markers carries the policy, frozen paths, and conventions a course correction must respect. +2. **Follow only the pointers that relate to the impacted areas** — nested component files or linked rule files listed under "Where things are". Do not load them all. +3. **This document is optional** — skip if the repo has no `AGENTS.md` (greenfield projects). **Fuzzy matching**: Be flexible with document names — users may use variations like `prd.md`, `bmm-prd.md`, `product-requirements.md`, etc. diff --git a/src/scripts/context.py b/src/scripts/context.py deleted file mode 100644 index b6a1c5b26..000000000 --- a/src/scripts/context.py +++ /dev/null @@ -1,657 +0,0 @@ -#!/usr/bin/env python3 -# /// script -# requires-python = ">=3.10" -# /// -"""context.py — mechanics for bmad-project-context. - -One core runtime script (memlog.py lineage): everything mechanical about a project's -context bundle so no LLM ever guesses at mechanical facts. The bundle lives at the -project's `project_knowledge` folder (default `docs/`); the script manages only files -bearing conformant frontmatter and never touches foreign files. - -Commands (all accept --json): - validate [root] frontmatter + link + index check; exit 1 on findings - index [root] regenerate index.md (refuses to overwrite a foreign one) - sweep [root] [--today D] staleness report (stale_after passed; sources drifted) - resolve [--refresh] cross-project resolution: self > workspace > cache > remote - compass [root] nearest compass file covering a repo-relative path - sync materialize kernel/compass blocks into AGENTS.md files - (only under the agent-files/both placement) - bootstrap copy this script to {project-root}/_bmad/scripts/context.py -""" -import argparse -import datetime as dt -import json -import os -import re -import shutil -import subprocess -import sys -import tempfile -from pathlib import Path - -INDEX_MARKER = "" -BLOCK_START = "" -BLOCK_END = "" -REGISTRY_FILE = "registry.yaml" -REGISTRY_KEY = "bmad_obeya_registry" -DEFAULT_KNOWLEDGE = "docs" - - -def fail(msg, code=1): - print(msg, file=sys.stderr) - sys.exit(code) - - -def emit(data, as_json, human=""): - if as_json: - print(json.dumps(data)) - elif human: - print(human) - - -# ── config ─────────────────────────────────────────────────────────────────── -# One resolution, shared by every command. Delegates to the installed BMad -# resolver (resolve_config.py, four-layer TOML merge) whenever it is present so -# script and platform can never disagree; falls back to a native TOML merge, -# then legacy YAML files, then defaults. - -CONFIG_KEYS = ("project_name", "project_knowledge", "output_folder", - "context_placement", "obeya_remote", "user_name", - "communication_language", "document_output_language") -CONFIG_DEFAULTS = {"project_knowledge": DEFAULT_KNOWLEDGE, "output_folder": "_bmad-output"} -TOML_LAYERS = ("_bmad/config.toml", "_bmad/config.user.toml", - "_bmad/custom/config.toml", "_bmad/custom/config.user.toml") -YAML_LAYERS = ("_bmad/config.yaml", "_bmad/bmm/config.yaml", - "_bmad/bmm/config.user.yaml", "_bmad/context.yaml") - -try: - import tomllib -except ImportError: # Python 3.10: TOML layers skipped, YAML fallback still works - tomllib = None - - -def _installed_resolver_config(project_root: Path): - resolver = project_root / "_bmad" / "scripts" / "resolve_config.py" - if not (resolver.exists() and (project_root / "_bmad" / "config.toml").exists()): - return None - proc = subprocess.run( - [sys.executable, str(resolver), "--project-root", str(project_root)], - capture_output=True, text=True) - if proc.returncode != 0: - return None - try: - data = json.loads(proc.stdout) - except json.JSONDecodeError: - return None - return {k: v for k, v in data.items() if isinstance(v, (str, int, bool))} - - -def _toml_chain(project_root: Path): - if tomllib is None or not (project_root / TOML_LAYERS[0]).exists(): - return None - merged = {} - for rel in TOML_LAYERS: - f = project_root / rel - if not f.exists(): - continue - try: - with f.open("rb") as fh: - layer = tomllib.load(fh) - except (tomllib.TOMLDecodeError, OSError): - continue - merged.update({k: v for k, v in layer.items() if isinstance(v, (str, int, bool))}) - return merged - - -def _yaml_chain(project_root: Path): - merged = {} - for rel in YAML_LAYERS: # later layers win per key - f = project_root / rel - if not f.exists(): - continue - for line in f.read_text(encoding="utf-8").splitlines(): - m = re.match(r"^([A-Za-z_][\w-]*):\s*(.+?)\s*$", line) - if m: - merged[m.group(1)] = m.group(2).strip("'\"") - return merged - - -def resolve_full_config(project_root: Path) -> dict: - cfg = (_installed_resolver_config(project_root) - or _toml_chain(project_root) - or {}) - for k, v in _yaml_chain(project_root).items(): - cfg.setdefault(k, v) # YAML fills gaps (e.g. standalone context_placement), never overrides TOML - for k, v in CONFIG_DEFAULTS.items(): - cfg.setdefault(k, v) - return cfg - - -def bundle_root(project_root: Path, override: str | None, cfg: dict) -> Path: - raw = override or str(cfg.get("project_knowledge", DEFAULT_KNOWLEDGE)) - raw = raw.replace("{project-root}/", "").replace("{project-root}", "") - return (project_root / raw) if not Path(raw).is_absolute() else Path(raw) - - -def cmd_config(args, project_root, cfg, as_json): - out = {k: cfg.get(k) for k in CONFIG_KEYS} - out["bundle_root"] = str(bundle_root(project_root, None, cfg)) - emit(out, as_json, "\n".join(f"{k}: {v}" for k, v in out.items() if v is not None)) - - -# ── frontmatter ────────────────────────────────────────────────────────────── - -def parse_frontmatter(text: str): - """Returns (fields|None, error|None, body). Naive flat YAML subset.""" - if not text.startswith("---\n"): - return None, None, text - end = text.find("\n---\n", 4) - if end == -1: - return None, "unparseable frontmatter (no closing fence)", "" - fields = {} - for line in text[4:end].splitlines(): - if not line.strip() or line.startswith("#"): - continue - m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*?)\s*$", line) - if not m: - return None, "unparseable frontmatter", "" - key, val = m.group(1), m.group(2) - if val.startswith("[") and val.endswith("]"): - fields[key] = [v.strip().strip("'\"") for v in val[1:-1].split(",") if v.strip()] - else: - fields[key] = re.split(r"\s+#", val)[0].strip().strip("'\"") - return fields, None, text[end + 5:] - - -def load_entries(root: Path): - """Conformant entries at the bundle root: (path, fields|None, error|None, body).""" - out = [] - if not root.is_dir(): - return out - for f in sorted(root.glob("*.md")): - if f.name in ("kernel.md", "index.md"): - continue - fields, err, body = parse_frontmatter(f.read_text(encoding="utf-8")) - if fields is None and err is None: - continue # foreign file: no frontmatter - if fields is not None and "type" not in fields and "title" not in fields: - continue # foreign file: frontmatter but not ours - out.append((f, fields, err, body)) - return out - - -def load_compasses(root: Path): - out = [] - cdir = root / "compass" - if not cdir.is_dir(): - return out - for f in sorted(cdir.glob("*.md")): - fields, err, body = parse_frontmatter(f.read_text(encoding="utf-8")) - out.append((f, fields or {}, err, body)) - return out - - -def trust_of(fields: dict) -> str: - return "verified" if "verified" in fields else "generated" - - -def index_rows(entries): - rows = [] - for f, fields, err, _ in entries: - if err or not fields or "type" not in fields or "title" not in fields: - continue - rows.append(f"- [{fields['title']}]({f.name}) — {fields.get('description', '')} " - f"({fields['type']}, {trust_of(fields)})") - return rows - - -def render_index(entries) -> str: - return INDEX_MARKER + "\n\n" + "\n".join(index_rows(entries)) + "\n" - - -# ── validate ───────────────────────────────────────────────────────────────── - -def cmd_validate(args, project_root, cfg, as_json): - root = bundle_root(project_root, args.root, cfg) - findings = [] - entries = load_entries(root) - for f, fields, err, body in entries: - rel = f.name - if err: - findings.append({"file": rel, "issue": err}) - continue - for req in ("type", "title", "description"): - if req not in fields: - findings.append({"file": rel, "issue": f"missing required field: {req}"}) - has_v, has_g = "verified" in fields, "generated" in fields - if has_v == has_g: - findings.append({"file": rel, "issue": - "exactly one of verified/generated is required"}) - for m in re.finditer(r"\[[^\]]*\]\(([^)]+)\)", body): - target = m.group(1) - if target.startswith(("http://", "https://", "#")) or not target.endswith(".md"): - continue - if not (f.parent / target).exists(): - findings.append({"file": rel, "issue": f"dangling link: {target}"}) - for f, fields, err, _ in load_compasses(root): - rel = f"compass/{f.name}" - if err: - findings.append({"file": rel, "issue": err}) - elif "area" not in fields: - findings.append({"file": rel, "issue": "missing required field: area"}) - idx = root / "index.md" - listable = {f.name for f, fields, err, _ in entries - if not err and fields and "type" in fields and "title" in fields} - if not idx.exists(): - if listable: - findings.append({"file": "index.md", "issue": "index.md missing — run: index"}) - else: - text = idx.read_text(encoding="utf-8") - linked = set(re.findall(r"\]\(([^)]+\.md)\)", text)) - for name in sorted(listable - linked): - findings.append({"file": name, "issue": f"entry {name} missing from index.md"}) - for name in sorted(linked - listable): - findings.append({"file": "index.md", "issue": f"index row points to missing entry: {name}"}) - stats = {"kernel": {"lines": 0, "bullets": 0, "tokens": 0}, "entries": {}, "bundle_tokens": 0} - kernel = root / "kernel.md" - if kernel.exists(): - ktext = kernel.read_text(encoding="utf-8") - kfields, kerr, kbody = parse_frontmatter(ktext) - if kerr: - findings.append({"file": "kernel.md", "issue": kerr}) - elif kfields: - for key in kfields: - if key != "status": - findings.append({"file": "kernel.md", - "issue": f"kernel frontmatter key not allowed: {key}"}) - body = kbody or ktext - lines = [ln for ln in body.splitlines() if ln.strip()] - bullets = [ln for ln in lines if ln.lstrip().startswith("- ")] - stats["kernel"] = {"lines": len(lines), "bullets": len(bullets), - "tokens": int(len(body) / 4)} - if len(bullets) > 200 or len(lines) > 250: - findings.append({"file": "kernel.md", - "issue": f"kernel over instruction budget: {len(bullets)} bullets / " - f"{len(lines)} lines (ceiling ~200 instructions)"}) - total = stats["kernel"]["tokens"] - for f, fields, err, body in entries: - toks = int(len(body) / 4) - stats["entries"][f.name] = toks - total += toks - if toks > 400: - findings.append({"file": f.name, - "issue": f"entry ~{toks} tokens, approaching a page — " - f"split into two entries or cut"}) - stats["bundle_tokens"] = total - emit({"ok": not findings, "findings": findings, "stats": stats}, as_json, - "\n".join(f"{x['file']}: {x['issue']}" for x in findings) or "clean") - sys.exit(1 if findings else 0) - - -# ── index ──────────────────────────────────────────────────────────────────── - -def cmd_index(args, project_root, cfg, as_json): - root = bundle_root(project_root, args.root, cfg) - idx = root / "index.md" - first_line = (idx.read_text(encoding="utf-8").splitlines() or [""])[0] if idx.exists() else "" - if first_line and INDEX_MARKER not in first_line: - fail(f"refusing to overwrite foreign index.md at {idx} — move it, or point " - f"project_knowledge at a clean folder") - entries = load_entries(root) - content = render_index(entries) - if not idx.exists() or idx.read_text(encoding="utf-8") != content: - idx.write_text(content, encoding="utf-8") - emit({"ok": True, "entries": len(index_rows(entries)), "written": str(idx)}, as_json) - - -# ── sweep ──────────────────────────────────────────────────────────────────── - -def source_date(project_root: Path, source: str): - proc = subprocess.run(["git", "log", "-1", "--format=%cI", "--", source], - cwd=str(project_root), capture_output=True, text=True) - if proc.returncode == 0 and proc.stdout.strip(): - return proc.stdout.strip()[:10] - p = project_root / source - if p.exists(): - return dt.date.fromtimestamp(p.stat().st_mtime).isoformat() - return None - - -PATH_TOKEN = re.compile(r"`([^`\s]+/[^`\s]+)`") - - -def missing_body_paths(project_root: Path, text: str): - """Backticked repo-relative paths whose top directory exists but the file does not.""" - out = [] - for tok in PATH_TOKEN.findall(text): - tok = tok.strip().rstrip("/") - if "://" in tok or tok.startswith(("{", "<", "-", "~", "/")) or "*" in tok: - continue - first = tok.split("/")[0] - if (project_root / first).is_dir() and not (project_root / tok).exists(): - out.append(tok) - return out - - -def cmd_sweep(args, project_root, cfg, as_json): - root = bundle_root(project_root, args.root, cfg) - today = args.today or dt.date.today().isoformat() - stale, missing = [], [] - kernel = root / "kernel.md" - if kernel.exists(): - for tok in missing_body_paths(project_root, kernel.read_text(encoding="utf-8")): - missing.append({"file": "kernel.md", "reason": f"path `{tok}` does not exist"}) - for f, fields, err, body in load_entries(root): - if err or not fields: - continue - rel = f.name - if fields.get("stale_after") and str(fields["stale_after"]) < today: - stale.append({"file": rel, "reason": f"stale_after {fields['stale_after']} passed"}) - sources = fields.get("sources") or [] - for s in (sources if isinstance(sources, list) else [sources]): - if "://" in s: - continue - if not (project_root / s).exists(): - missing.append({"file": rel, "reason": f"source {s} does not exist"}) - elif fields.get("verified"): - changed = source_date(project_root, s) - if changed and changed > str(fields["verified"]): - stale.append({"file": rel, - "reason": f"source {s} changed {changed}, after verified {fields['verified']}"}) - for tok in missing_body_paths(project_root, body): - missing.append({"file": rel, "reason": f"path `{tok}` does not exist"}) - emit({"stale": stale, "missing": missing}, as_json, - "\n".join(f"{x['file']}: {x['reason']}" for x in stale + missing) or "clean") - - -# ── compass ────────────────────────────────────────────────────────────────── - -def nearest_compass(root: Path, target: str): - best, best_len = None, -1 - for f, fields, err, body in load_compasses(root): - area = str(fields.get("area", "")).rstrip("/") - if err or not area: - continue - if target == area or target.startswith(area + "/"): - if len(area) > best_len: - best, best_len = (f, fields, body), len(area) - return best - - -def cmd_compass(args, project_root, cfg, as_json): - root = bundle_root(project_root, args.root, cfg) - hit = nearest_compass(root, args.path.rstrip("/")) - if not hit: - emit({"path": None, "content": None}, as_json) - return - f, fields, body = hit - emit({"path": str(f), "area": fields.get("area"), "content": body}, as_json) - if not as_json: - sys.stdout.write(f.read_text(encoding="utf-8")) - - -# ── resolve ────────────────────────────────────────────────────────────────── - -def parse_registry(path: Path) -> dict: - """Minimal indent parser for the obeya registry: projects..: value.""" - projects, current = {}, None - in_projects = False - for line in path.read_text(encoding="utf-8").splitlines(): - if not line.strip() or line.strip().startswith("#"): - continue - indent = len(line) - len(line.lstrip()) - key, _, val = line.strip().partition(":") - val = val.strip().strip("'\"") - if indent == 0: - in_projects = key == "projects" - elif in_projects and indent == 2: - current = key - projects[current] = {} - elif in_projects and indent >= 4 and current: - projects[current][key] = val - return projects - - -def find_workspace(project_root: Path): - """Walk up for sibling checkouts and an obeya checkout. Returns (siblings, registry).""" - siblings, registry = {}, None - node = project_root.parent - for _ in range(6): - if not node or node == node.parent: - break - try: - children = [c for c in node.iterdir() if c.is_dir()] - except OSError: - break - for c in children: - reg = c / REGISTRY_FILE - if registry is None and reg.exists() and REGISTRY_KEY in reg.read_text(encoding="utf-8"): - registry = parse_registry(reg) - if c != project_root: - siblings.setdefault(c.name, c) - node = node.parent - return siblings, registry - - -def cache_dir() -> Path: - return Path(os.environ.get("BMAD_CONTEXT_CACHE", str(Path.home() / ".bmad" / "context-cache"))) - - -def cache_lookup(project: str): - pointer = cache_dir() / f"{project}.latest.json" - if pointer.exists(): - try: - meta = json.loads(pointer.read_text(encoding="utf-8")) - sha = meta["sha"] - except (json.JSONDecodeError, OSError, KeyError, TypeError): - return None - path = cache_dir() / f"{project}@{sha}" - if path.is_dir(): - return {"path": str(path), "sha": sha, - "fetched_at": meta.get("fetched_at"), "source": "cache"} - return None - - -def sparse_fetch(project: str, record: dict): - remote, branch = record["remote"], record.get("branch", "main") - context_root = record.get("context_root", DEFAULT_KNOWLEDGE) - with tempfile.TemporaryDirectory() as tmp: - clone = Path(tmp) / "clone" - proc = subprocess.run( - ["git", "clone", "-q", "--depth", "1", "--filter=blob:none", "--sparse", - "--branch", branch, remote, str(clone)], - capture_output=True, text=True) - if proc.returncode != 0: - return None, proc.stderr.strip() - subprocess.run(["git", "-C", str(clone), "sparse-checkout", "set", context_root], - capture_output=True, check=False) - sha = subprocess.run(["git", "-C", str(clone), "rev-parse", "HEAD"], - capture_output=True, text=True).stdout.strip() - src = clone / context_root - if not src.is_dir(): - return None, f"context root {context_root!r} not present in {remote}" - dest = cache_dir() / f"{project}@{sha}" - if dest.exists(): - shutil.rmtree(dest) - dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copytree(src, dest) - fetched_at = dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds") - pointer = cache_dir() / f"{project}.latest.json" - tmp_pointer = pointer.with_suffix(".json.tmp") - tmp_pointer.write_text(json.dumps({"sha": sha, "fetched_at": fetched_at}), encoding="utf-8") - os.replace(tmp_pointer, pointer) - return {"path": str(dest), "sha": sha, "fetched_at": fetched_at, - "source": "remote"}, None - - -def git_head(path: Path): - proc = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(path), - capture_output=True, text=True) - return proc.stdout.strip() if proc.returncode == 0 else None - - -def sibling_bundle(checkout: Path): - sib_cfg = resolve_full_config(checkout) - root = bundle_root(checkout, None, sib_cfg) - return root if (root / "kernel.md").exists() or (root / "index.md").exists() else None - - -def cmd_resolve(args, project_root, cfg, as_json): - project, _, entry = args.name.partition(":") - result = None - self_name = cfg.get("project_name") or project_root.name - if project == self_name: - result = {"path": str(bundle_root(project_root, None, cfg)), - "sha": git_head(project_root), "fetched_at": None, "source": "local"} - siblings, registry = (None, None) - if result is None: - siblings, registry = find_workspace(project_root) - checkout = siblings.get(project) - root = sibling_bundle(checkout) if checkout else None - if root: - result = {"path": str(root), "sha": git_head(checkout), - "fetched_at": None, "source": "local"} - if result is None and not args.refresh: - result = cache_lookup(project) - if result is None: - if registry is None and cfg.get("obeya_remote"): - fetched, _ = sparse_fetch("obeya", {"remote": cfg["obeya_remote"], - "context_root": "."}) - if fetched: - reg = Path(fetched["path"]) / REGISTRY_FILE - if reg.exists(): - registry = parse_registry(reg) - record = (registry or {}).get(project) - if record: - result, err = sparse_fetch(project, record) - if result is None: - cached = cache_lookup(project) - if cached: - cached["warning"] = f"remote unreachable ({err}); serving cache" - result = cached - if result is None: - fail(f"cannot resolve {project!r}: not this project, no workspace checkout, " - f"no cache, and no registry record") - if entry: - hit = next((p for p in Path(result["path"]).glob("*.md") - if p.stem == entry), None) - if not hit: - fail(f"entry {entry!r} not found in {project!r} bundle at {result['path']}") - result["path"] = str(hit) - emit(result, as_json, result["path"]) - - -# ── sync ───────────────────────────────────────────────────────────────────── - -def strip_frontmatter(text: str) -> str: - fields, err, body = parse_frontmatter(text) - return text if fields is None and err is None and not body else (body or text) - - -def rewrite_links(content: str, source_dir: Path, target_dir: Path) -> str: - """Re-anchor relative .md links so they resolve from the file the block lands in.""" - def repl(m): - text, target = m.group(1), m.group(2) - if target.startswith(("http://", "https://", "#", "/")) or not target.endswith(".md"): - return m.group(0) - resolved = (source_dir / target).resolve() - return f"[{text}]({Path(os.path.relpath(resolved, target_dir)).as_posix()})" - return re.sub(r"\[([^\]]*)\]\(([^)]+)\)", repl, content) - - -def apply_block(target: Path, content: str, dry: bool = False) -> bool: - block = f"{BLOCK_START}\n{content.rstrip()}\n{BLOCK_END}\n" - if target.exists(): - text = target.read_text(encoding="utf-8") - if BLOCK_START in text and BLOCK_END in text: - pre = text[:text.index(BLOCK_START)] - post = text[text.index(BLOCK_END) + len(BLOCK_END):].lstrip("\n") - new = pre + block + post - else: - new = text.rstrip("\n") + "\n\n" + block - else: - new = block - if target.exists() and target.read_text(encoding="utf-8") == new: - return False - if not dry: - target.write_text(new, encoding="utf-8") - return True - - -def cmd_sync(args, project_root, cfg, as_json): - placement = cfg.get("context_placement") - if placement not in ("agent-files", "both"): - fail(f"sync runs only under the agent-files or both placement " - f"(context_placement is {placement!r})") - root = bundle_root(project_root, None, cfg) - dry = getattr(args, "dry_run", False) - written = [] - kernel = root / "kernel.md" - if kernel.exists(): - content = rewrite_links(strip_frontmatter(kernel.read_text(encoding="utf-8")), - root, project_root) - if apply_block(project_root / "AGENTS.md", content, dry): - written.append(str(project_root / "AGENTS.md")) - for f, fields, err, body in load_compasses(root): - area = str(fields.get("area", "")).rstrip("/") - if err or not area: - continue - area_dir = project_root / area - if area_dir.is_dir(): - if apply_block(area_dir / "AGENTS.md", - rewrite_links(body, root / "compass", area_dir), dry): - written.append(str(area_dir / "AGENTS.md")) - emit({"written": written, "dry_run": dry}, as_json, "\n".join(written) or "up to date") - - -# ── bootstrap ──────────────────────────────────────────────────────────────── - -def cmd_bootstrap(args, project_root, cfg, as_json): - target = project_root / "_bmad" / "scripts" / "context.py" - target.parent.mkdir(parents=True, exist_ok=True) - self_bytes = Path(__file__).resolve().read_bytes() - if not target.exists() or target.read_bytes() != self_bytes: - target.write_bytes(self_bytes) - emit({"path": str(target)}, as_json, str(target)) - - -# ── main ───────────────────────────────────────────────────────────────────── - -def main(argv=None): - p = argparse.ArgumentParser(prog="context.py", description=__doc__) - p.add_argument("--json", action="store_true", dest="as_json") - p.add_argument("--project-root", default=".") - jp = argparse.ArgumentParser(add_help=False) # lets --json follow the subcommand too - jp.add_argument("--json", action="store_true", dest="as_json", default=argparse.SUPPRESS) - sub = p.add_subparsers(dest="command", required=True) - - s = sub.add_parser("validate", parents=[jp]) - s.add_argument("root", nargs="?") - s = sub.add_parser("index", parents=[jp]) - s.add_argument("root", nargs="?") - s = sub.add_parser("sweep", parents=[jp]) - s.add_argument("root", nargs="?") - s.add_argument("--today") - s = sub.add_parser("resolve", parents=[jp]) - s.add_argument("name") - s.add_argument("--refresh", action="store_true") - s = sub.add_parser("compass", parents=[jp]) - s.add_argument("path") - s.add_argument("root", nargs="?") - s = sub.add_parser("sync", parents=[jp]) - s.add_argument("--dry-run", action="store_true", dest="dry_run") - sub.add_parser("bootstrap", parents=[jp]) - sub.add_parser("config", parents=[jp]) - - args = p.parse_args(argv) - project_root = Path(args.project_root).resolve() - cfg = resolve_full_config(project_root) - {"validate": cmd_validate, "index": cmd_index, "sweep": cmd_sweep, - "resolve": cmd_resolve, "compass": cmd_compass, "sync": cmd_sync, - "bootstrap": cmd_bootstrap, "config": cmd_config}[args.command]( - args, project_root, cfg, args.as_json) - - -if __name__ == "__main__": - main() diff --git a/src/scripts/tests/test_context.py b/src/scripts/tests/test_context.py deleted file mode 100644 index e5ca64983..000000000 --- a/src/scripts/tests/test_context.py +++ /dev/null @@ -1,572 +0,0 @@ -# /// script -# requires-python = ">=3.10" -# dependencies = ["pytest>=8.0"] -# /// -"""Layer-1 deterministic tests for context.py (bmad-project-context mechanics). - -Run: uv run --with pytest pytest src/scripts/tests/test_context.py - -Written against the CLI contract in the skill design (§13/§14): each command has a -fixture-driven case list. No LLM, no network — "remote" fixtures are local bare repos -reached via file:// URLs. -""" -import json -import os -import shutil -import subprocess -import sys -from pathlib import Path - -import pytest - -SCRIPTS_DIR = Path(__file__).resolve().parent.parent -CONTEXT_PY = SCRIPTS_DIR / "context.py" - -sys.path.insert(0, str(SCRIPTS_DIR)) - -INDEX_MARKER = "" -BLOCK_END = "" - - -def run(args, cwd, env_extra=None, check=False): - env = os.environ.copy() - if env_extra: - env.update(env_extra) - proc = subprocess.run( - [sys.executable, str(CONTEXT_PY), *args], - cwd=str(cwd), capture_output=True, text=True, env=env, - ) - if check and proc.returncode != 0: - raise AssertionError(f"context.py {args} failed rc={proc.returncode}\n{proc.stderr}") - return proc - - -def jrun(args, cwd, check=True): - proc = run(["--json", *args], cwd, check=check) - out = proc.stdout.strip() - return (json.loads(out) if out else {}), proc - - -def git(cwd, *args): - subprocess.run(["git", *args], cwd=str(cwd), capture_output=True, check=True) - - -def make_git_repo(path): - path.mkdir(parents=True, exist_ok=True) - git(path, "init", "-q", "-b", "main") - git(path, "config", "user.email", "t@t.t") - git(path, "config", "user.name", "t") - return path - - -ENTRY = """--- -type: {type} -title: {title} -description: {description} -{trust}: {date} -{extra}--- -{body} -""" - - -def write_entry(root, name, *, type="decision", title=None, description="d", - trust="verified", date="2026-01-01", extra="", body="Body text."): - root.mkdir(parents=True, exist_ok=True) - (root / f"{name}.md").write_text(ENTRY.format( - type=type, title=title or name, description=description, - trust=trust, date=date, extra=extra, body=body)) - - -def make_bundle(root, entries=("integer-cents", "repo-pattern")): - root.mkdir(parents=True, exist_ok=True) - (root / "kernel.md").write_text("# Project Kernel — fixture\n## Commands\n- Test: `pytest`\n") - for e in entries: - write_entry(root, e) - return root - - -@pytest.fixture -def proj(tmp_path): - """A project root with a bundle at docs/ and config naming it.""" - p = tmp_path / "proj" - p.mkdir() - make_bundle(p / "docs") - cfg = p / "_bmad" - cfg.mkdir() - (cfg / "context.yaml").write_text("project_name: proj\nproject_knowledge: docs\n") - return p - - -def indexed(proj_root): - run(["index"], proj_root, check=True) - return proj_root - - -# ── validate ───────────────────────────────────────────────────────────────── - -class TestValidate: - def test_clean_bundle_exit_0(self, proj): - indexed(proj) - proc = run(["validate"], proj) - assert proc.returncode == 0 - - def test_missing_type_caught(self, proj): - (proj / "docs" / "bad.md").write_text( - "---\ntitle: Bad\ndescription: d\nverified: 2026-01-01\n---\nBody.\n") - indexed(proj) - data, proc = jrun(["validate"], proj, check=False) - assert proc.returncode == 1 - assert any("type" in f["issue"] for f in data["findings"]) - - def test_unparseable_frontmatter_caught(self, proj): - (proj / "docs" / "broken.md").write_text("---\ntype: decision\nno closing fence\n") - data, proc = jrun(["validate"], proj, check=False) - assert proc.returncode == 1 - assert any("frontmatter" in f["issue"] for f in data["findings"]) - - def test_dangling_link_caught(self, proj): - write_entry(proj / "docs", "linker", body="See [gone](missing-entry.md).") - indexed(proj) - data, proc = jrun(["validate"], proj, check=False) - assert proc.returncode == 1 - assert any("missing-entry.md" in f["issue"] for f in data["findings"]) - - def test_entry_missing_from_index_caught(self, proj): - indexed(proj) - write_entry(proj / "docs", "orphan") - data, proc = jrun(["validate"], proj, check=False) - assert proc.returncode == 1 - assert any("orphan" in f["issue"] and "index" in f["issue"] for f in data["findings"]) - - def test_trust_field_required_and_exclusive(self, proj): - (proj / "docs" / "untrusted.md").write_text( - "---\ntype: decision\ntitle: U\ndescription: d\n---\nBody.\n") - write_entry(proj / "docs", "double", extra="generated: 2026-01-02\n") - indexed(proj) - data, proc = jrun(["validate"], proj, check=False) - issues = " | ".join(f["issue"] for f in data["findings"]) - assert "untrusted.md" in " ".join(f["file"] for f in data["findings"]) - assert "verified" in issues or "generated" in issues - - def test_foreign_files_ignored(self, proj): - (proj / "docs" / "human-notes.md").write_text("# Just some human doc\nNo frontmatter.\n") - indexed(proj) - proc = run(["validate"], proj) - assert proc.returncode == 0 - - -# ── index ──────────────────────────────────────────────────────────────────── - -class TestIndex: - def test_byte_identical_on_unchanged_bundle(self, proj): - indexed(proj) - first = (proj / "docs" / "index.md").read_bytes() - run(["index"], proj, check=True) - assert (proj / "docs" / "index.md").read_bytes() == first - - def test_row_format(self, proj): - indexed(proj) - text = (proj / "docs" / "index.md").read_text() - assert INDEX_MARKER in text - assert "- [integer-cents](integer-cents.md) — d (decision, verified)" in text - - def test_new_and_removed_entries_reflected(self, proj): - indexed(proj) - write_entry(proj / "docs", "newcomer", trust="generated", date="2026-02-01") - (proj / "docs" / "repo-pattern.md").unlink() - run(["index"], proj, check=True) - text = (proj / "docs" / "index.md").read_text() - assert "newcomer" in text and "(decision, generated)" in text - assert "repo-pattern" not in text - - def test_hand_edits_overwritten(self, proj): - indexed(proj) - idx = proj / "docs" / "index.md" - idx.write_text(idx.read_text() + "\n- hand-added row\n") - run(["index"], proj, check=True) - assert "hand-added" not in idx.read_text() - - def test_foreign_index_refused(self, proj): - (proj / "docs" / "index.md").write_text("# Docs site index\nHuman-owned.\n") - proc = run(["index"], proj) - assert proc.returncode != 0 - assert "index.md" in proc.stderr - assert (proj / "docs" / "index.md").read_text().startswith("# Docs site index") - - -# ── sweep ──────────────────────────────────────────────────────────────────── - -class TestSweep: - def test_clean_bundle_empty_report(self, proj): - indexed(proj) - data, proc = jrun(["sweep"], proj) - assert data["stale"] == [] - - def test_stale_after_passed_flagged(self, proj): - write_entry(proj / "docs", "expiring", extra="stale_after: 2026-06-01\n") - indexed(proj) - data, _ = jrun(["sweep", "--today", "2026-07-01"], proj) - assert any(s["file"].endswith("expiring.md") for s in data["stale"]) - data2, _ = jrun(["sweep", "--today", "2026-05-01"], proj) - assert not any(s["file"].endswith("expiring.md") for s in data2["stale"]) - - def test_sources_changed_after_verified_flagged(self, tmp_path): - p = make_git_repo(tmp_path / "gitproj") - (p / "_bmad").mkdir() - (p / "_bmad" / "context.yaml").write_text("project_name: gitproj\nproject_knowledge: docs\n") - (p / "src").mkdir() - (p / "src" / "money.py").write_text("CENTS = 1\n") - make_bundle(p / "docs", entries=()) - write_entry(p / "docs", "drifted", date="2020-01-01", - extra="sources: [src/money.py]\n") - write_entry(p / "docs", "fresh", date="2099-01-01", - extra="sources: [src/money.py]\n") - git(p, "add", "-A") - git(p, "commit", "-qm", "init") - run(["index"], p, check=True) - data, _ = jrun(["sweep"], p) - files = [s["file"] for s in data["stale"]] - assert any(f.endswith("drifted.md") for f in files) - assert not any(f.endswith("fresh.md") for f in files) - - -# ── compass ────────────────────────────────────────────────────────────────── - -class TestCompass: - def test_nearest_path_selection_with_nesting(self, proj): - cdir = proj / "docs" / "compass" - cdir.mkdir() - (cdir / "src.md").write_text( - "---\ntype: compass\ntitle: src\ndescription: d\narea: src\nverified: 2026-01-01\n---\nSRC COMPASS\n") - (cdir / "billing.md").write_text( - "---\ntype: compass\ntitle: billing\ndescription: d\narea: src/billing\nverified: 2026-01-01\n---\nBILLING COMPASS\n") - proc = run(["compass", "src/billing/handlers.py"], proj, check=True) - assert "BILLING COMPASS" in proc.stdout - proc2 = run(["compass", "src/other/x.py"], proj, check=True) - assert "SRC COMPASS" in proc2.stdout - - def test_no_compass_empty_exit_0(self, proj): - proc = run(["compass", "src/anything.py"], proj) - assert proc.returncode == 0 - assert proc.stdout.strip() == "" - - -# ── resolve ────────────────────────────────────────────────────────────────── - -def make_remote_project(tmp_path, name="payments-api"): - """A git project with a bundle, plus a bare clone acting as its remote.""" - src = make_git_repo(tmp_path / f"{name}-src") - make_bundle(src / "docs", entries=("webhook-conventions",)) - git(src, "add", "-A") - git(src, "commit", "-qm", "init") - bare = tmp_path / f"{name}.git" - subprocess.run(["git", "clone", "-q", "--bare", str(src), str(bare)], check=True, - capture_output=True) - return src, bare - - -def make_obeya(ws, registry: dict): - ob = ws / "obeya" - ob.mkdir(parents=True) - lines = ["bmad_obeya_registry: true", "projects:"] - for name, rec in registry.items(): - lines.append(f" {name}:") - for k, v in rec.items(): - lines.append(f" {k}: {v}") - (ob / "registry.yaml").write_text("\n".join(lines) + "\n") - return ob - - -class TestResolve: - @pytest.fixture - def ws(self, tmp_path): - """Workspace: proj-a (cwd), sibling checkout, obeya with a remote-only record.""" - ws = tmp_path / "ws" - a = ws / "proj-a" - a.mkdir(parents=True) - make_bundle(a / "docs") - (a / "_bmad").mkdir() - (a / "_bmad" / "context.yaml").write_text("project_name: proj-a\nproject_knowledge: docs\n") - sib = ws / "sibling-api" - make_bundle(sib / "docs", entries=("sib-entry",)) - _, bare = make_remote_project(tmp_path) - make_obeya(ws, { - "payments-api": {"remote": str(bare), "branch": "main", "context_root": "docs"}, - "sibling-api": {"remote": str(tmp_path / "nowhere.git"), "branch": "main", - "context_root": "docs"}, - }) - return ws - - def cache_env(self, tmp_path): - return {"BMAD_CONTEXT_CACHE": str(tmp_path / "cache")} - - def test_rung1_self(self, ws, tmp_path): - data, _ = jrun(["resolve", "proj-a"], ws / "proj-a") - assert data["source"] == "local" - assert Path(data["path"]) == ws / "proj-a" / "docs" - - def test_rung2_workspace_sibling_beats_remote(self, ws, tmp_path): - data, _ = jrun(["resolve", "sibling-api"], ws / "proj-a") - assert data["source"] == "local" - assert Path(data["path"]) == ws / "sibling-api" / "docs" - - def test_rung4_sparse_fetch_pins_sha(self, ws, tmp_path): - env = self.cache_env(tmp_path) - proc = run(["--json", "resolve", "payments-api"], ws / "proj-a", env_extra=env) - assert proc.returncode == 0, proc.stderr - data = json.loads(proc.stdout) - assert data["source"] == "remote" - assert len(data["sha"]) == 40 - fetched = Path(data["path"]) - assert (fetched / "kernel.md").exists() - assert f"payments-api@{data['sha']}" in str(fetched) - - def test_rung3_cache_hit_after_fetch(self, ws, tmp_path): - env = self.cache_env(tmp_path) - run(["--json", "resolve", "payments-api"], ws / "proj-a", env_extra=env, check=True) - proc = run(["--json", "resolve", "payments-api"], ws / "proj-a", env_extra=env) - data = json.loads(proc.stdout) - assert data["source"] == "cache" - - def test_offline_serves_cache_with_warning(self, ws, tmp_path): - env = self.cache_env(tmp_path) - run(["--json", "resolve", "payments-api"], ws / "proj-a", env_extra=env, check=True) - ob_reg = ws / "obeya" / "registry.yaml" - ob_reg.write_text(ob_reg.read_text().replace("payments-api.git", "gone.git")) - proc = run(["--json", "resolve", "payments-api", "--refresh"], ws / "proj-a", env_extra=env) - data = json.loads(proc.stdout) - assert data["source"] == "cache" - assert data.get("warning") - - def test_entry_level_resolution(self, ws, tmp_path): - data, _ = jrun(["resolve", "sibling-api:sib-entry"], ws / "proj-a") - assert data["path"].endswith("sib-entry.md") - - def test_unresolvable_nonzero_exit(self, ws, tmp_path): - proc = run(["resolve", "no-such-project"], ws / "proj-a", - env_extra=self.cache_env(tmp_path)) - assert proc.returncode != 0 - - def test_no_obeya_no_resolver(self, tmp_path): - lone = tmp_path / "lone" - lone.mkdir() - make_bundle(lone / "docs") - proc = run(["resolve", "anything-remote"], lone, - env_extra=self.cache_env(tmp_path)) - assert proc.returncode != 0 - - -# ── sync ───────────────────────────────────────────────────────────────────── - -class TestSync: - def set_placement(self, proj, value): - cfg = proj / "_bmad" / "context.yaml" - cfg.write_text(f"project_name: proj\nproject_knowledge: docs\ncontext_placement: {value}\n") - - def test_creates_agents_md_when_absent(self, proj): - self.set_placement(proj, "agent-files") - data, _ = jrun(["sync"], proj) - agents = proj / "AGENTS.md" - assert agents.exists() - text = agents.read_text() - assert BLOCK_START in text and BLOCK_END in text - assert "Project Kernel" in text - - def test_idempotent_second_run_zero_diff(self, proj): - self.set_placement(proj, "agent-files") - jrun(["sync"], proj) - first = (proj / "AGENTS.md").read_bytes() - jrun(["sync"], proj) - assert (proj / "AGENTS.md").read_bytes() == first - - def test_user_content_byte_preserved(self, proj): - self.set_placement(proj, "agent-files") - user_text = "# My Agents File\n\nMy own rules — do not touch.\n" - (proj / "AGENTS.md").write_text(user_text) - jrun(["sync"], proj) - text = (proj / "AGENTS.md").read_text() - assert user_text.rstrip("\n") in text - assert BLOCK_START in text - (proj / "docs" / "kernel.md").write_text("# Project Kernel — fixture\n- Updated rule\n") - jrun(["sync"], proj) - text2 = (proj / "AGENTS.md").read_text() - assert user_text.rstrip("\n") in text2 - assert "Updated rule" in text2 - assert text2.count(BLOCK_START) == 1 - - def test_compass_nested_agents_files(self, proj): - self.set_placement(proj, "agent-files") - cdir = proj / "docs" / "compass" - cdir.mkdir() - (cdir / "billing.md").write_text( - "---\ntype: compass\ntitle: billing\ndescription: d\narea: src/billing\nverified: 2026-01-01\n---\nBILLING COMPASS\n") - (proj / "src" / "billing").mkdir(parents=True) - jrun(["sync"], proj) - nested = proj / "src" / "billing" / "AGENTS.md" - assert nested.exists() and "BILLING COMPASS" in nested.read_text() - - def test_relative_links_rewritten_to_resolve_from_target(self, proj): - self.set_placement(proj, "agent-files") - (proj / "docs" / "kernel.md").write_text( - "# Project Kernel — fixture\n- Money is cents. Why: [integer-cents](integer-cents.md)\n") - cdir = proj / "docs" / "compass" - cdir.mkdir() - (cdir / "billing.md").write_text( - "---\ntype: compass\ntitle: billing\ndescription: d\narea: src/billing\nverified: 2026-01-01\n---\n" - "See [integer-cents](../integer-cents.md) and [kernel](../kernel.md).\n") - (proj / "src" / "billing").mkdir(parents=True) - jrun(["sync"], proj) - root_text = (proj / "AGENTS.md").read_text() - assert "(docs/integer-cents.md)" in root_text - assert "(integer-cents.md)" not in root_text.replace("docs/integer-cents.md", "") - nested = (proj / "src" / "billing" / "AGENTS.md").read_text() - assert "(../../docs/integer-cents.md)" in nested - assert "(../../docs/kernel.md)" in nested - - def test_refuses_under_bmad_placement(self, proj): - self.set_placement(proj, "bmad") - proc = run(["sync"], proj) - assert proc.returncode != 0 - assert not (proj / "AGENTS.md").exists() - - -# ── config ─────────────────────────────────────────────────────────────────── - -class TestConfig: - def test_central_toml_chain_with_user_override(self, tmp_path): - p = tmp_path / "proj" - (p / "_bmad").mkdir(parents=True) - (p / "_bmad" / "config.toml").write_text( - 'project_name = "proj"\nproject_knowledge = "docs"\noutput_folder = "_bmad-output"\n') - (p / "_bmad" / "config.user.toml").write_text('project_knowledge = "knowledge"\n') - make_bundle(p / "knowledge") - data, _ = jrun(["config"], p) - assert data["project_knowledge"] == "knowledge" - assert data["bundle_root"].endswith("knowledge") - - def test_all_commands_share_the_resolved_root(self, tmp_path): - p = tmp_path / "proj" - (p / "_bmad").mkdir(parents=True) - (p / "_bmad" / "config.toml").write_text('project_knowledge = "docs"\n') - (p / "_bmad" / "config.user.toml").write_text('project_knowledge = "knowledge"\n') - make_bundle(p / "knowledge") - run(["index"], p, check=True) - assert (p / "knowledge" / "index.md").exists() - assert not (p / "docs" / "index.md").exists() - proc = run(["validate"], p) - assert proc.returncode == 0 - - def test_standalone_yaml_fallback_and_defaults(self, proj, tmp_path): - data, _ = jrun(["config"], proj) - assert data["project_knowledge"] == "docs" - bare = tmp_path / "bare" - bare.mkdir() - data2, _ = jrun(["config"], bare) - assert data2["project_knowledge"] == "docs" - - def test_json_accepted_after_subcommand(self, proj): - indexed(proj) - proc = run(["validate", "--json"], proj) - assert proc.returncode == 0 - assert json.loads(proc.stdout)["ok"] is True - - -# ── sweep: missing sources ─────────────────────────────────────────────────── - -class TestSweepMissing: - def test_deleted_source_reported_missing(self, proj): - write_entry(proj / "docs", "ghost", extra="sources: [src/lib/deleted.ts]\n") - indexed(proj) - data, _ = jrun(["sweep"], proj) - assert any(m["file"].endswith("ghost.md") and "src/lib/deleted.ts" in m["reason"] - for m in data["missing"]) - assert data["stale"] == [] - - def test_existing_source_not_reported(self, proj): - (proj / "src" / "lib").mkdir(parents=True) - (proj / "src" / "lib" / "money.ts").write_text("x") - write_entry(proj / "docs", "solid", extra="sources: [src/lib/money.ts]\n") - indexed(proj) - data, _ = jrun(["sweep"], proj) - assert not any(m["file"].endswith("solid.md") for m in data["missing"]) - - def test_kernel_body_path_gone_reported(self, proj): - (proj / "src").mkdir(exist_ok=True) - (proj / "docs" / "kernel.md").write_text( - "# Project Kernel — fixture\n- Money helpers live in `src/lib/money.ts`\n") - data, _ = jrun(["sweep"], proj) - assert any("src/lib/money.ts" in m["reason"] for m in data["missing"]) - - -# ── validate: kernel and size stats ────────────────────────────────────────── - -class TestValidateStats: - def test_oversized_kernel_flagged(self, proj): - lines = ["# Project Kernel — fixture"] + [f"- rule {i}" for i in range(260)] - (proj / "docs" / "kernel.md").write_text("\n".join(lines) + "\n") - indexed(proj) - data, proc = jrun(["validate"], proj, check=False) - assert proc.returncode == 1 - assert any("budget" in f["issue"] for f in data["findings"]) - assert data["stats"]["kernel"]["bullets"] == 260 - - def test_unknown_kernel_frontmatter_flagged(self, proj): - (proj / "docs" / "kernel.md").write_text( - "---\nbogus_key: x\n---\n# Project Kernel — fixture\n- one rule\n") - indexed(proj) - data, proc = jrun(["validate"], proj, check=False) - assert proc.returncode == 1 - assert any("bogus_key" in f["issue"] for f in data["findings"]) - - def test_page_length_entry_flagged(self, proj): - write_entry(proj / "docs", "sprawl", body=("word " * 700).strip()) - indexed(proj) - data, proc = jrun(["validate"], proj, check=False) - assert proc.returncode == 1 - assert any("sprawl" in f["file"] and "entries" in f["issue"] for f in data["findings"]) - - def test_clean_bundle_still_exit_0_with_stats(self, proj): - indexed(proj) - data, proc = jrun(["validate"], proj) - assert proc.returncode == 0 - assert "kernel" in data["stats"] and "bundle_tokens" in data["stats"] - - -# ── sync --dry-run ─────────────────────────────────────────────────────────── - -class TestSyncDryRun: - def test_dry_run_reports_without_writing(self, proj): - cfg = proj / "_bmad" / "context.yaml" - cfg.write_text("project_name: proj\nproject_knowledge: docs\ncontext_placement: agent-files\n") - data, _ = jrun(["sync", "--dry-run"], proj) - assert any(w.endswith("AGENTS.md") for w in data["written"]) - assert data["dry_run"] is True - assert not (proj / "AGENTS.md").exists() - - -# ── bootstrap & packaging ──────────────────────────────────────────────────── - -class TestBootstrap: - def test_copies_self_to_known_spot(self, tmp_path): - lone = tmp_path / "lone" - lone.mkdir() - proc = run(["bootstrap"], lone, check=True) - target = lone / "_bmad" / "scripts" / "context.py" - assert target.exists() - assert target.read_bytes() == CONTEXT_PY.read_bytes() - - def test_idempotent(self, tmp_path): - lone = tmp_path / "lone" - lone.mkdir() - run(["bootstrap"], lone, check=True) - proc = run(["bootstrap"], lone) - assert proc.returncode == 0 - - -def test_skill_copy_in_sync_with_core_script(): - skill_copy = (SCRIPTS_DIR.parent / "bmm-skills" / "plan" / "bmad-project-context" - / "scripts" / "context.py") - assert skill_copy.exists(), "skill must ship scripts/context.py (bootstrap source, R10)" - assert skill_copy.read_bytes() == CONTEXT_PY.read_bytes(), ( - "skill scripts/context.py must be byte-identical to src/scripts/context.py") diff --git a/tools/validate-file-refs.js b/tools/validate-file-refs.js index afff30b51..88317fba3 100644 --- a/tools/validate-file-refs.js +++ b/tools/validate-file-refs.js @@ -83,7 +83,7 @@ function escapeTableCell(str) { const INSTALL_ONLY_PATHS = ['_config/', 'custom/', 'render/bmad-build/', 'render/bmad-build-auto/']; // Files that are generated at install time and don't exist in the source tree -const INSTALL_GENERATED_FILES = ['config.yaml', 'config.user.yaml', 'context.yaml']; +const INSTALL_GENERATED_FILES = ['config.yaml', 'config.user.yaml']; // Variables that indicate a path is not statically resolvable const UNRESOLVABLE_VARS = [