From 54458b0806e2da4d8ff6ef5a82ca0b6af3e89cda Mon Sep 17 00:00:00 2001 From: unitypark Date: Tue, 18 Aug 2026 09:14:54 +0200 Subject: [PATCH] A repo with its own AGENTS.md gets specd's four rules, not a second rulebook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from a real onboarding: portal-app already had an AGENTS.md saying much of what the template says, and setup appended the whole template under it. Two statements of the same practice is worse than either — an agent follows whichever it reads first, and a reader cannot tell which is current. The template is a complete set of engineering rules: read the docs before implementing, cite what you relied on, update them in the same PR, never rewrite a delivered spec. A team that has written its own AGENTS.md has usually said all of that already, in its own words, and it is their repository. So there are now two renderings. With no existing file, the full document, as before — nothing to duplicate. With one, `renderAgentsSupplement`: the four things specd's machinery actually enforces, and where the knowledge base is. Approved specs and `specd spec pull`, the branch and PR-title shape the webhook matches, the as-built record, and knowledge/. Everything general is left to the team, and the block says so — where the two overlap, theirs wins. Thirty-six lines instead of fifty-eight against a typical file, and none of them a restatement. The fence, the in-place update on re-grounding, and the untouched original are all as they were. The open question and the setup PR body described the old behaviour and now describe this one. --- README.md | 7 ++- apps/web/lib/docs/concepts.ts | 2 +- packages/templates/src/agents-md.test.ts | 52 +++++++++++++---- packages/templates/src/agents-md.ts | 72 ++++++++++++++++++++++-- packages/templates/src/knowledge.ts | 16 ++++-- 5 files changed, 123 insertions(+), 26 deletions(-) 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