diff --git a/README.md b/README.md index 9d7d95b..5ddb26d 100644 --- a/README.md +++ b/README.md @@ -277,9 +277,10 @@ through the stations — narrated click by click in they name; the judgement around them is drafted, and anything the scan could not ground says `UNVERIFIED`. The wizard does not pretend to know your architecture ([`knowledge/decisions/0015-onboarding-reads-the-repo-before-it-drafts.md`](knowledge/decisions/0015-onboarding-reads-the-repo-before-it-drafts.md)). - An `AGENTS.md` or `CLAUDE.md` you already have is **not rewritten** — specd's - rules are appended below yours behind a `` fence, which - a later grounding run updates in place. + An `AGENTS.md` or `CLAUDE.md` you already have is **not rewritten**, and not + restated: specd appends a short block with the four rules its own machinery + enforces, behind a `` fence a later grounding run updates + in place. Your own agreements are left to say everything else. 3. **Adopt** — merge the setup branch. Merging *is* the adoption signal; specd indexes `knowledge/` the moment the webhook lands (local mode has an "I merged it" button instead). diff --git a/apps/web/lib/docs/concepts.ts b/apps/web/lib/docs/concepts.ts index 25b180c..fcea294 100644 --- a/apps/web/lib/docs/concepts.ts +++ b/apps/web/lib/docs/concepts.ts @@ -171,7 +171,7 @@ export const CONCEPTS: DocCategory = { }, { k: 'p', - text: 'If the repository already has an `AGENTS.md` or `CLAUDE.md`, **nothing in it is rewritten.** Your file stays exactly as it is and specd\'s rules are appended below it, fenced by `` markers so a later grounding run updates only that block. Where the two sets disagree, yours came first and a human decides — the setup pull request says so, and `knowledge/open-questions.md` carries it as an item.', + text: 'If the repository already has an `AGENTS.md`, **nothing in it is rewritten** — and specd does not append a second set of engineering rules underneath. A team that wrote its own agreements has usually already said "read the docs first" and "docs ride the change"; restating that is worse than either copy, because an agent follows whichever it reads first. So specd appends a short block instead: the four rules its own machinery enforces, and where the knowledge base lives. It is fenced by `` markers, so a later grounding run updates only that block.', }, { k: 'ol', diff --git a/packages/templates/src/agents-md.test.ts b/packages/templates/src/agents-md.test.ts index 80bacf0..d23e620 100644 --- a/packages/templates/src/agents-md.test.ts +++ b/packages/templates/src/agents-md.test.ts @@ -5,9 +5,12 @@ import { mergeAgentsMd, mergeClaudeMd, renderAgentsMd, + renderAgentsSupplement, renderClaudeMd, } from './agents-md.js'; +const supplement = renderAgentsSupplement({ isPrimary: true, projectName: 'Acme' }); + const generated = renderAgentsMd({ repoName: 'acme/api', stack: { @@ -36,7 +39,7 @@ describe('mergeAgentsMd', () => { }); it('keeps every line of an existing file and appends the specd block', () => { - const merged = mergeAgentsMd(theirs, generated); + const merged = mergeAgentsMd(theirs, generated, supplement); for (const line of theirs.trim().split('\n')) { expect(merged).toContain(line); @@ -44,11 +47,39 @@ describe('mergeAgentsMd', () => { expect(merged).toContain(SPECD_BLOCK_BEGIN); expect(merged).toContain(SPECD_BLOCK_END); expect(merged.indexOf('Never touch')).toBeLessThan(merged.indexOf(SPECD_BLOCK_BEGIN)); - expect(merged).toContain('Before implementing ANYTHING'); + }); + + it('appends only what is specd\'s, not a second set of engineering rules', () => { + // A team that wrote its own AGENTS.md has usually said "read the docs + // first", "cite what you relied on", "docs ride the change" already, in + // its own words. Restating them under theirs is worse than either copy: an + // agent follows whichever it reads first. + const merged = mergeAgentsMd(theirs, generated, supplement); + + expect(merged).not.toContain('Before implementing ANYTHING'); + expect(merged).not.toContain('Knowledge first — no exceptions'); + expect(merged).not.toContain('Do not invent the answer'); + + // What survives is the machinery a team cannot already have described. + expect(merged).toContain('specd spec pull '); + expect(merged).toContain('spec/-'); + expect(merged).toContain('knowledge/specs/-.md'); + }); + + it('says the team\'s rules win, since only they can resolve an overlap', () => { + expect(mergeAgentsMd(theirs, generated, supplement)).toMatch( + /agreements above still stand.*yours win/s, + ); + }); + + it('still writes the whole document when the repo has no agreements to duplicate', () => { + // Nothing to talk over, so the full set of rules is the useful thing. + expect(mergeAgentsMd(null, generated, supplement)).toBe(generated); + expect(mergeAgentsMd('', generated, supplement)).toContain('Before implementing ANYTHING'); }); it('leaves the team the only H1 — the appended block is a section, not a rival document', () => { - const merged = mergeAgentsMd(theirs, generated); + const merged = mergeAgentsMd(theirs, generated, supplement); expect(merged.match(/^# /gm)).toHaveLength(1); }); @@ -56,23 +87,22 @@ describe('mergeAgentsMd', () => { // The fence is an HTML comment, and every markdown renderer hides those — // so on the pull request page a reader would see one continuous list of // rules with no idea which half arrived this morning. - const merged = mergeAgentsMd(theirs, generated); - expect(merged).toContain('## Working agreements added by specd'); - expect(merged).toContain('Your own rules above came'); + const merged = mergeAgentsMd(theirs, generated, supplement); + expect(merged).toContain('## specd — added by setup'); }); it('replaces its own block on a re-run instead of stacking a second copy', () => { - const first = mergeAgentsMd(theirs, generated); - const second = mergeAgentsMd(first, generated.replace('Stack:', 'Stack (rescanned):')); + const first = mergeAgentsMd(theirs, generated, supplement); + const second = mergeAgentsMd(first, generated, supplement.replace('specd spec pull', 'specd spec fetch')); expect(second.match(new RegExp(SPECD_BLOCK_BEGIN.slice(0, 20), 'g'))).toHaveLength(1); - expect(second).toContain('Stack (rescanned):'); + expect(second).toContain('specd spec fetch'); expect(second).toContain('Never touch `legacy/` without asking Priya.'); }); it('survives a half-deleted fence rather than appending underneath it', () => { const damaged = `${theirs}\n${SPECD_BLOCK_BEGIN}\n\nold rules someone cut the end off\n`; - const merged = mergeAgentsMd(damaged, generated); + const merged = mergeAgentsMd(damaged, generated, supplement); expect(merged).not.toContain('old rules someone cut the end off'); expect(merged).toContain('Never touch `legacy/` without asking Priya.'); @@ -83,7 +113,7 @@ describe('mergeAgentsMd', () => { // Nobody's writing is discarded here, so there is nothing to preserve — // and fencing specd's own output inside specd's own output reads as two // sets of rules where there is one. - const merged = mergeAgentsMd(generated, generated); + const merged = mergeAgentsMd(generated, generated, supplement); expect(merged).toBe(generated); expect(merged).not.toContain(SPECD_BLOCK_BEGIN); }); diff --git a/packages/templates/src/agents-md.ts b/packages/templates/src/agents-md.ts index 797fca1..91ebc85 100644 --- a/packages/templates/src/agents-md.ts +++ b/packages/templates/src/agents-md.ts @@ -93,6 +93,61 @@ spec_status · spec_pull · list_specs `; } +/** + * What specd adds to a repository that already has working agreements. + * + * The full `renderAgentsMd` document is a complete set of engineering rules — + * read the docs first, cite what you relied on, update them in the same PR — + * and a team that has written its own AGENTS.md has usually said most of that + * already, in its own words. Appending the whole thing under theirs produced + * two statements of the same practice, which is worse than either: an agent + * follows whichever it reads first, and a reader cannot tell which is current. + * + * So when there is an existing file, specd contributes only what is *its*: + * the four things its machinery actually enforces, and where the knowledge base + * lives. Everything general is left to the team, because they already said it + * and it is their repository. + */ +export function renderAgentsSupplement(input: { + isPrimary: boolean; + projectName: string; +}): string { + const { isPrimary, projectName } = input; + const asBuiltHome = isPrimary ? 'this repo' : `the primary repo of ${projectName}`; + + return `## specd — added by setup + +This repository is spec-driven with [specd](https://github.com/unitypark/specd). +**The agreements above still stand.** What follows is only the part specd's own +machinery depends on — where the two overlap, yours win and this block is the +one to correct. + +1. **Work arrives as an approved spec.** Fetch it with \`specd spec pull \`. + The server refuses to serve a spec no human has approved, so this is not a + convention you can route around. +2. **Branch \`spec/-\`, and title the pull request \`[] - \`.** + The merge webhook matches that shape back to the spec; a branch named + anything else is delivered work specd cannot record. +3. **The last task of a spec files its as-built copy** to + \`knowledge/specs/<ID>-<slug>.md\` in ${asBuiltHome}. That record is what + grounds the next spec. +4. **The knowledge base is \`knowledge/\`**, and it is what specs get drafted + and cited from. Start at [knowledge/README.md](knowledge/README.md); specd + re-indexes the directory whenever a change to it merges. + +## Wiring the knowledge tools + +Point any MCP-capable editor at specd. It serves this project's knowledge base +and its approved specs, read-only — approval stays a signed-in human in the app. + + { "mcpServers": { "specd": { "command": "specd", "args": ["mcp", "serve"] } } } + +Needs the specd CLI on your PATH and \`specd login\` once per machine. +Tools: search_knowledge · get_doc · verify_citation · knowledge_health · +spec_status · spec_pull · list_specs +`; +} + export function renderClaudeMd(): string { return `# CLAUDE.md @@ -137,7 +192,16 @@ const GENERATED_HEADING = '# Working agreements for AI agents (generated by spec * 3. Anything else is a team's own file. It survives verbatim, and the * generated rules land underneath it, fenced and labelled. */ -export function mergeAgentsMd(existing: string | null | undefined, generated: string): string { +export function mergeAgentsMd( + existing: string | null | undefined, + generated: string, + /** + * What to append when the repository already has agreements. Falls back to + * the whole document, which is what this used to do unconditionally and is + * still right when there is nothing to duplicate. + */ + supplement?: string, +): string { const current = (existing ?? '').trim(); if (!current) return generated; @@ -147,11 +211,7 @@ export function mergeAgentsMd(existing: string | null | undefined, generated: st // actually sees, and they are the half that says which rules are whose. const block = `${SPECD_BLOCK_BEGIN}\n\n` + - '## Working agreements added by specd\n\n' + - '_Generated by a setup run, and yours to edit. Your own rules above came\n' + - 'first: where the two disagree, they win and this block is the thing to\n' + - 'correct._\n\n' + - `${stripHeading(generated)}\n${SPECD_BLOCK_END}\n`; + `${stripHeading(supplement ?? generated)}\n${SPECD_BLOCK_END}\n`; const replaced = replaceFencedBlock(current, block); if (replaced) return replaced; diff --git a/packages/templates/src/knowledge.ts b/packages/templates/src/knowledge.ts index 8ce724a..c87398c 100644 --- a/packages/templates/src/knowledge.ts +++ b/packages/templates/src/knowledge.ts @@ -1,5 +1,5 @@ import { type DetectedStack, describeStack } from './stack.js'; -import { mergeAgentsMd, mergeClaudeMd } from './agents-md.js'; +import { mergeAgentsMd, mergeClaudeMd, renderAgentsSupplement } from './agents-md.js'; import { hasDataEvidence, hasIntegrationEvidence, @@ -634,8 +634,8 @@ export function renderOpenQuestions(input: DocContext): string { const merged = mergedAgentDocs(evidence); if (merged.length) { items.push({ - question: `This repo already had ${merged.join(' and ')} — read ${merged.length === 1 ? 'it' : 'them'} against the specd block appended below yours and reconcile anything that disagrees.`, - why: "Your rules were kept and specd's were added under a marked fence, so nothing was lost — but two rules that contradict each other are worse than either alone: an agent follows whichever it reads first.", + question: `This repo already had ${merged.join(' and ')} — check the short specd block appended below yours does not contradict what you already say.`, + why: "Your rules were kept untouched, and specd appended only the four things its own machinery enforces rather than a second set of engineering rules. Anything the two do both mention, yours wins — but only a human can say which is which.", doc: 'conventions.md', }); } @@ -1005,7 +1005,13 @@ export function renderScaffold(input: { const docs = scaffoldDocPaths(evidence); const ctx: DocContext = { repoName, projectName, stack, evidence, drafted, docs }; - const mergedAgents = mergeAgentsMd(existing?.agentsMd, agentsMd); + // A repository with its own agreements gets the short specd-specific block, + // not the whole document — see `renderAgentsSupplement`. + const mergedAgents = mergeAgentsMd( + existing?.agentsMd, + agentsMd, + renderAgentsSupplement({ isPrimary: input.isPrimary, projectName }), + ); const mergedClaude = mergeClaudeMd(existing?.claudeMd); const files: ScaffoldFile[] = [ @@ -1077,7 +1083,7 @@ export function renderSetupPrBody(input: { merged.length === 1 ? 'was' : 'were' } kept.** Nothing in ${ merged.length === 1 ? 'it' : 'them' - } was rewritten — specd's agreements are appended below yours, fenced by\n> \`<!-- specd:begin -->\` markers so a later setup run updates only that block.\n> Where the two sets disagree, yours came first and a human decides.` + } was rewritten. specd appended a short block — the four rules its own\n> machinery enforces, and where the knowledge base lives — rather than a second\n> set of engineering rules on top of yours. It is fenced by\n> \`<!-- specd:begin -->\` markers, so a later setup run updates only that block.` : ''; return `## specd setup — review me, then merge to adopt