Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!-- specd:begin -->` 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 `<!-- specd:begin -->` 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).
Expand Down
2 changes: 1 addition & 1 deletion apps/web/lib/docs/concepts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!-- specd:begin -->` 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 `<!-- specd:begin -->` markers, so a later grounding run updates only that block.',
},
{
k: 'ol',
Expand Down
52 changes: 41 additions & 11 deletions packages/templates/src/agents-md.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -36,43 +39,70 @@ 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);
}
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 <id>');
expect(merged).toContain('spec/<ID>-<slug>');
expect(merged).toContain('knowledge/specs/<ID>-<slug>.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);
});

it('marks whose rules are whose in text a renderer will actually show', () => {
// 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.');
Expand All @@ -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);
});
Expand Down
72 changes: 66 additions & 6 deletions packages/templates/src/agents-md.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>\`.
The server refuses to serve a spec no human has approved, so this is not a
convention you can route around.
2. **Branch \`spec/<ID>-<slug>\`, and title the pull request \`[<ID>] - <Title>\`.**
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

Expand Down Expand Up @@ -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;

Expand All @@ -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;
Expand Down
16 changes: 11 additions & 5 deletions packages/templates/src/knowledge.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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',
});
}
Expand Down Expand Up @@ -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[] = [
Expand Down Expand Up @@ -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
Expand Down