Skip to content

Commit b3f3dc4

Browse files
zhaoM0赵小落zhaomo01
authored
Prefer AGENTS.md over CLAUDE.md for project instructions (Gitlawb#439)
* Prefer AGENTS.md over CLAUDE.md for project instructions * fix: preserve CLAUDE.md fallback behavior * fix: isolate onboarding tests and preserve legacy init * fix: restore full fsOperations exports in test mock and align compact cwd * Fix onboarding test isolation and init migration guidance * Tighten init prompt coverage and onboarding copy * Handle nested project instruction paths consistently * Fix NEW_INIT feature gate for Bun build --------- Co-authored-by: 赵小落 <zhaoxiaoluo@zhaoxiaoluodeMac-mini.local> Co-authored-by: zhaomo01 <zhaomo01@baidu.com>
1 parent 2e0e14d commit b3f3dc4

18 files changed

Lines changed: 521 additions & 105 deletions

src/commands/commit-push-pr.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ function getPromptContent(
4545
<!-- CHANGELOG:END -->`
4646
let slackStep = `
4747
48-
5. After creating/updating the PR, check if the user's CLAUDE.md mentions posting to Slack channels. If it does, use ToolSearch to search for "slack send message" tools. If ToolSearch finds a Slack tool, ask the user if they'd like you to post the PR URL to the relevant Slack channel. Only post if the user confirms. If ToolSearch returns no results or errors, skip this step silently—do not mention the failure, do not attempt workarounds, and do not try alternative approaches.`
48+
5. After creating/updating the PR, check if the user's AGENTS.md or CLAUDE.md mentions posting to Slack channels. If it does, use ToolSearch to search for "slack send message" tools. If ToolSearch finds a Slack tool, ask the user if they'd like you to post the PR URL to the relevant Slack channel. Only post if the user confirms. If ToolSearch returns no results or errors, skip this step silently—do not mention the failure, do not attempt workarounds, and do not try alternative approaches.`
4949
if (process.env.USER_TYPE === 'ant' && isUndercover()) {
5050
prefix = getUndercoverInstructions() + '\n'
5151
reviewerArg = ''

src/commands/init.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { afterEach, expect, mock, test } from 'bun:test'
2+
3+
const originalClaudeCodeNewInit = process.env.CLAUDE_CODE_NEW_INIT
4+
5+
async function importInitCommand() {
6+
return (await import(`./init.ts?ts=${Date.now()}-${Math.random()}`)).default
7+
}
8+
9+
afterEach(() => {
10+
mock.restore()
11+
12+
if (originalClaudeCodeNewInit === undefined) {
13+
delete process.env.CLAUDE_CODE_NEW_INIT
14+
} else {
15+
process.env.CLAUDE_CODE_NEW_INIT = originalClaudeCodeNewInit
16+
}
17+
})
18+
19+
test('NEW_INIT prompt preserves existing root CLAUDE.md by default', async () => {
20+
process.env.CLAUDE_CODE_NEW_INIT = '1'
21+
22+
mock.module('../projectOnboardingState.js', () => ({
23+
maybeMarkProjectOnboardingComplete: () => {},
24+
}))
25+
mock.module('./initMode.js', () => ({
26+
isNewInitEnabled: () => true,
27+
}))
28+
29+
const command = await importInitCommand()
30+
const blocks = await command.getPromptForCommand()
31+
32+
expect(blocks).toHaveLength(1)
33+
expect(blocks[0]?.type).toBe('text')
34+
expect(String(blocks[0]?.text)).toContain(
35+
'checked-in root `CLAUDE.md` and does NOT already have a root `AGENTS.md`',
36+
)
37+
expect(String(blocks[0]?.text)).toContain(
38+
'do NOT silently create a second root instruction file',
39+
)
40+
expect(String(blocks[0]?.text)).toContain(
41+
'update the existing root `CLAUDE.md` in place by default',
42+
)
43+
})

src/commands/init.ts

Lines changed: 30 additions & 36 deletions
Large diffs are not rendered by default.

src/commands/initMode.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { feature } from 'bun:bundle'
2+
import { isEnvTruthy } from '../utils/envUtils.js'
3+
4+
export function isNewInitEnabled(): boolean {
5+
if (feature('NEW_INIT')) {
6+
return (
7+
process.env.USER_TYPE === 'ant' ||
8+
isEnvTruthy(process.env.CLAUDE_CODE_NEW_INIT)
9+
)
10+
}
11+
12+
return false
13+
}

src/components/memory/MemoryFileSelector.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { c as _c } from "react-compiler-runtime";
22
import { feature } from 'bun:bundle';
33
import chalk from 'chalk';
44
import { mkdir } from 'fs/promises';
5-
import { join } from 'path';
5+
import { basename, join } from 'path';
66
import * as React from 'react';
77
import { use, useEffect, useState } from 'react';
88
import { getOriginalCwd } from '../../bootstrap/state.js';
@@ -24,6 +24,7 @@ import { projectIsInGitRepo } from '../../utils/memory/versions.js';
2424
import { updateSettingsForSource } from '../../utils/settings/settings.js';
2525
import { Select } from '../CustomSelect/index.js';
2626
import { ListItem } from '../design-system/ListItem.js';
27+
import { getProjectMemoryPathForSelector } from './memoryFileSelectorPaths.js';
2728

2829
/* eslint-disable @typescript-eslint/no-require-imports */
2930
const teamMemPaths = feature('TEAMMEM') ? require('../../memdir/teamMemPaths.js') as typeof import('../../memdir/teamMemPaths.js') : null;
@@ -48,8 +49,10 @@ export function MemoryFileSelector(t0) {
4849
onCancel
4950
} = t0;
5051
const existingMemoryFiles = use(getMemoryFiles());
52+
const originalCwd = getOriginalCwd();
5153
const userMemoryPath = join(getClaudeConfigHomeDir(), "CLAUDE.md");
52-
const projectMemoryPath = join(getOriginalCwd(), "CLAUDE.md");
54+
const projectMemoryPath = getProjectMemoryPathForSelector(existingMemoryFiles, originalCwd);
55+
const projectMemoryFileName = basename(projectMemoryPath);
5356
const hasUserMemory = existingMemoryFiles.some(f => f.path === userMemoryPath);
5457
const hasProjectMemory = existingMemoryFiles.some(f_0 => f_0.path === projectMemoryPath);
5558
const allMemoryFiles = [...existingMemoryFiles.filter(_temp).map(_temp2), ...(hasUserMemory ? [] : [{
@@ -85,12 +88,12 @@ export function MemoryFileSelector(t0) {
8588
}
8689
}
8790
let description;
88-
const isGit = projectIsInGitRepo(getOriginalCwd());
91+
const isGit = projectIsInGitRepo(originalCwd);
8992
if (file.type === "User" && !file.isNested) {
9093
description = "Saved in ~/.claude/CLAUDE.md";
9194
} else {
9295
if (file.type === "Project" && !file.isNested && file.path === projectMemoryPath) {
93-
description = `${isGit ? "Checked in at" : "Saved in"} ./CLAUDE.md`;
96+
description = `${isGit ? "Checked in at" : "Saved in"} ./${projectMemoryFileName}`;
9497
} else {
9598
if (file.parent) {
9699
description = "@-imported";
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { join } from 'node:path'
3+
4+
import type { MemoryFileInfo } from '../../utils/claudemd.js'
5+
import { getProjectMemoryPathForSelector } from './memoryFileSelectorPaths.js'
6+
7+
function projectFile(path: string): MemoryFileInfo {
8+
return {
9+
path,
10+
type: 'Project',
11+
content: '',
12+
}
13+
}
14+
15+
describe('getProjectMemoryPathForSelector', () => {
16+
test('uses the loaded repo-level AGENTS.md from a nested cwd', () => {
17+
const repoDir = '/repo'
18+
const nestedDir = join(repoDir, 'packages', 'app')
19+
20+
expect(
21+
getProjectMemoryPathForSelector(
22+
[projectFile(join(repoDir, 'AGENTS.md'))],
23+
nestedDir,
24+
),
25+
).toBe(join(repoDir, 'AGENTS.md'))
26+
})
27+
28+
test('uses the loaded repo-level CLAUDE.md fallback from a nested cwd', () => {
29+
const repoDir = '/repo'
30+
const nestedDir = join(repoDir, 'packages', 'app')
31+
32+
expect(
33+
getProjectMemoryPathForSelector(
34+
[projectFile(join(repoDir, 'CLAUDE.md'))],
35+
nestedDir,
36+
),
37+
).toBe(join(repoDir, 'CLAUDE.md'))
38+
})
39+
40+
test('prefers the closest loaded ancestor instruction file', () => {
41+
const repoDir = '/repo'
42+
const nestedProjectDir = join(repoDir, 'packages', 'app')
43+
44+
expect(
45+
getProjectMemoryPathForSelector(
46+
[
47+
projectFile(join(repoDir, 'AGENTS.md')),
48+
projectFile(join(nestedProjectDir, 'CLAUDE.md')),
49+
],
50+
join(nestedProjectDir, 'src'),
51+
),
52+
).toBe(join(nestedProjectDir, 'CLAUDE.md'))
53+
})
54+
55+
test('defaults to a new AGENTS.md in the current cwd when no project file is loaded', () => {
56+
expect(getProjectMemoryPathForSelector([], '/repo/packages/app')).toBe(
57+
'/repo/packages/app/AGENTS.md',
58+
)
59+
})
60+
61+
test('ignores loaded project instruction files outside the current cwd ancestry', () => {
62+
expect(
63+
getProjectMemoryPathForSelector(
64+
[projectFile('/other-worktree/AGENTS.md')],
65+
'/repo/packages/app',
66+
),
67+
).toBe('/repo/packages/app/AGENTS.md')
68+
})
69+
})
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { basename, join } from 'path'
2+
3+
import type { MemoryFileInfo } from '../../utils/claudemd.js'
4+
import {
5+
findProjectInstructionFilePathInAncestors,
6+
isProjectInstructionFileName,
7+
PRIMARY_PROJECT_INSTRUCTION_FILE,
8+
} from '../../utils/projectInstructions.js'
9+
10+
function isLoadedProjectInstructionFile(file: MemoryFileInfo): boolean {
11+
return (
12+
file.type === 'Project' &&
13+
file.parent === undefined &&
14+
isProjectInstructionFileName(basename(file.path))
15+
)
16+
}
17+
18+
export function getProjectMemoryPathForSelector(
19+
existingMemoryFiles: MemoryFileInfo[],
20+
cwd: string,
21+
): string {
22+
const loadedProjectInstructionPaths = new Set(
23+
existingMemoryFiles
24+
.filter(isLoadedProjectInstructionFile)
25+
.map(file => file.path),
26+
)
27+
28+
return (
29+
findProjectInstructionFilePathInAncestors(
30+
cwd,
31+
path => loadedProjectInstructionPaths.has(path),
32+
) ?? join(cwd, PRIMARY_PROJECT_INSTRUCTION_FILE)
33+
)
34+
}

src/projectOnboardingState.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { afterEach, describe, expect, test } from 'bun:test'
2+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
6+
import {
7+
getSteps,
8+
isProjectOnboardingComplete,
9+
} from './projectOnboardingSteps.js'
10+
import { runWithCwdOverride } from './utils/cwd.js'
11+
12+
let tempDir: string | undefined
13+
14+
afterEach(async () => {
15+
if (tempDir) {
16+
await rm(tempDir, { recursive: true, force: true })
17+
tempDir = undefined
18+
}
19+
})
20+
21+
describe('project onboarding completion', () => {
22+
test('is incomplete when neither AGENTS.md nor CLAUDE.md exists', async () => {
23+
tempDir = await mkdtemp(join(tmpdir(), 'project-onboarding-'))
24+
25+
await runWithCwdOverride(tempDir, async () => {
26+
expect(isProjectOnboardingComplete()).toBe(false)
27+
expect(getSteps()[1]?.text).toContain('/init')
28+
expect(getSteps()[1]?.text).toContain('AGENTS.md')
29+
expect(getSteps()[1]?.text).toContain('CLAUDE.md')
30+
})
31+
})
32+
33+
test('is complete when only CLAUDE.md exists', async () => {
34+
tempDir = await mkdtemp(join(tmpdir(), 'project-onboarding-'))
35+
await writeFile(join(tempDir, 'CLAUDE.md'), '# CLAUDE.md\n')
36+
37+
await runWithCwdOverride(tempDir, async () => {
38+
expect(isProjectOnboardingComplete()).toBe(true)
39+
})
40+
})
41+
42+
test('is complete when only AGENTS.md exists', async () => {
43+
tempDir = await mkdtemp(join(tmpdir(), 'project-onboarding-'))
44+
await writeFile(join(tempDir, 'AGENTS.md'), '# AGENTS.md\n')
45+
46+
await runWithCwdOverride(tempDir, async () => {
47+
expect(isProjectOnboardingComplete()).toBe(true)
48+
})
49+
})
50+
51+
test('is complete from a nested cwd when repo instructions exist in an ancestor directory', async () => {
52+
tempDir = await mkdtemp(join(tmpdir(), 'project-onboarding-'))
53+
const nestedDir = join(tempDir, 'packages', 'app')
54+
await writeFile(join(tempDir, 'AGENTS.md'), '# AGENTS.md\n')
55+
await mkdir(nestedDir, { recursive: true })
56+
await writeFile(join(nestedDir, 'index.ts'), 'export {}\n')
57+
58+
await runWithCwdOverride(nestedDir, async () => {
59+
expect(isProjectOnboardingComplete()).toBe(true)
60+
})
61+
})
62+
})

src/projectOnboardingState.ts

Lines changed: 6 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,14 @@
11
import memoize from 'lodash-es/memoize.js'
2-
import { join } from 'path'
32
import {
43
getCurrentProjectConfig,
54
saveCurrentProjectConfig,
65
} from './utils/config.js'
7-
import { getCwd } from './utils/cwd.js'
8-
import { isDirEmpty } from './utils/file.js'
9-
import { getFsImplementation } from './utils/fsOperations.js'
10-
11-
export type Step = {
12-
key: string
13-
text: string
14-
isComplete: boolean
15-
isCompletable: boolean
16-
isEnabled: boolean
17-
}
18-
19-
export function getSteps(): Step[] {
20-
const hasClaudeMd = getFsImplementation().existsSync(
21-
join(getCwd(), 'CLAUDE.md'),
22-
)
23-
const isWorkspaceDirEmpty = isDirEmpty(getCwd())
24-
25-
return [
26-
{
27-
key: 'workspace',
28-
text: 'Ask Claude to create a new app or clone a repository',
29-
isComplete: false,
30-
isCompletable: true,
31-
isEnabled: isWorkspaceDirEmpty,
32-
},
33-
{
34-
key: 'claudemd',
35-
text: 'Run /init to create a CLAUDE.md file with instructions for Claude',
36-
isComplete: hasClaudeMd,
37-
isCompletable: true,
38-
isEnabled: !isWorkspaceDirEmpty,
39-
},
40-
]
41-
}
42-
43-
export function isProjectOnboardingComplete(): boolean {
44-
return getSteps()
45-
.filter(({ isCompletable, isEnabled }) => isCompletable && isEnabled)
46-
.every(({ isComplete }) => isComplete)
47-
}
6+
export {
7+
getSteps,
8+
isProjectOnboardingComplete,
9+
type Step,
10+
} from './projectOnboardingSteps.js'
11+
import { isProjectOnboardingComplete } from './projectOnboardingSteps.js'
4812

4913
export function maybeMarkProjectOnboardingComplete(): void {
5014
// Short-circuit on cached config — isProjectOnboardingComplete() hits

src/projectOnboardingSteps.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { getCwd } from './utils/cwd.js'
2+
import { isDirEmpty } from './utils/file.js'
3+
import { getFsImplementation } from './utils/fsOperations.js'
4+
import { findProjectInstructionFilePathInAncestors } from './utils/projectInstructions.js'
5+
6+
export type Step = {
7+
key: string
8+
text: string
9+
isComplete: boolean
10+
isCompletable: boolean
11+
isEnabled: boolean
12+
}
13+
14+
export function getSteps(): Step[] {
15+
const hasRepoInstructions =
16+
findProjectInstructionFilePathInAncestors(
17+
getCwd(),
18+
getFsImplementation().existsSync,
19+
) !== null
20+
const isWorkspaceDirEmpty = isDirEmpty(getCwd())
21+
22+
return [
23+
{
24+
key: 'workspace',
25+
text: 'Ask Claude to create a new app or clone a repository',
26+
isComplete: false,
27+
isCompletable: true,
28+
isEnabled: isWorkspaceDirEmpty,
29+
},
30+
{
31+
key: 'claudemd',
32+
text: 'Set up repo instructions (/init creates AGENTS.md or updates existing CLAUDE.md; either file counts)',
33+
isComplete: hasRepoInstructions,
34+
isCompletable: true,
35+
isEnabled: !isWorkspaceDirEmpty,
36+
},
37+
]
38+
}
39+
40+
export function isProjectOnboardingComplete(): boolean {
41+
return getSteps()
42+
.filter(({ isCompletable, isEnabled }) => isCompletable && isEnabled)
43+
.every(({ isComplete }) => isComplete)
44+
}

0 commit comments

Comments
 (0)