diff --git a/.gitignore b/.gitignore index 8a5b6a4..7041ac7 100644 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,7 @@ docs/superpowers/ docs/designs/auto-update.md docs/designs/ci-pipeline.md docs/designs/knowledge-feed.md +docs/codebase.md +docs/llm-wiki.md roadmap_jael.md validation/ diff --git a/agents/teamai-recall.md b/agents/teamai-recall.md index 4a4bac2..5cf7d6d 100644 --- a/agents/teamai-recall.md +++ b/agents/teamai-recall.md @@ -22,10 +22,14 @@ upstream API"). Treat this as your query. ### Step 1 — Read the codebase manifest (optional but preferred) -If `~/.teamai/docs/codebase.md` exists, read it first. It lists the team's -repositories and their purposes. Extract a one-sentence repo-list summary -to prepend to your final output. If the file does not exist, **silently -skip** this step — never error out. +If `~/.teamai/docs/codebase.md` OR `docs/team-codebase/index.md` (in the +current project) exists, read it first. It lists the team's repositories +and their purposes. Extract a one-sentence repo-list summary to prepend to +your final output. If neither file exists, **silently skip** this step — +never error out. + +> Note: `teamai recall` already indexes team-codebase documents +> (repos/*.md), so Step 3 will return codebase knowledge matches directly. ### Step 2 — Extract keywords from the task description diff --git a/src/__tests__/auto-recall-quality.test.ts b/src/__tests__/auto-recall-quality.test.ts new file mode 100644 index 0000000..b2af01f --- /dev/null +++ b/src/__tests__/auto-recall-quality.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { readRecallQuality } from '../auto-recall.js'; + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-recall-quality-test-')); +} + +describe('readRecallQuality', () => { + let tmpDir: string; + const originalHome = process.env.HOME; + + beforeEach(() => { + tmpDir = makeTmpDir(); + process.env.HOME = tmpDir; + }); + + afterEach(() => { + process.env.HOME = originalHome; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns null when no cache exists', () => { + const result = readRecallQuality('nonexistent-session'); + expect(result).toBeNull(); + }); + + it('returns null when cache has zero hit and miss counts', () => { + const sessionId = 'zero-counts-session'; + const sessionsDir = path.join(tmpDir, '.teamai', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.writeFileSync( + path.join(sessionsDir, `${sessionId}-recall-cache.json`), + JSON.stringify({ + queries: [], + count: 0, + updatedAt: new Date().toISOString(), + topScore: 0, + hitCount: 0, + missCount: 0, + }), + 'utf-8', + ); + + const result = readRecallQuality(sessionId); + expect(result).toBeNull(); + }); + + it('returns quality data when hitCount > 0', () => { + const sessionId = 'hit-session'; + const sessionsDir = path.join(tmpDir, '.teamai', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.writeFileSync( + path.join(sessionsDir, `${sessionId}-recall-cache.json`), + JSON.stringify({ + queries: ['test'], + count: 1, + updatedAt: new Date().toISOString(), + topScore: 12.5, + hitCount: 2, + missCount: 1, + }), + 'utf-8', + ); + + const result = readRecallQuality(sessionId); + expect(result).toEqual({ topScore: 12.5, hitCount: 2, missCount: 1 }); + }); + + it('returns quality data when missCount > 0 and hitCount is 0', () => { + const sessionId = 'miss-only-session'; + const sessionsDir = path.join(tmpDir, '.teamai', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.writeFileSync( + path.join(sessionsDir, `${sessionId}-recall-cache.json`), + JSON.stringify({ + queries: ['q1'], + count: 1, + updatedAt: new Date().toISOString(), + topScore: 0, + hitCount: 0, + missCount: 3, + }), + 'utf-8', + ); + + const result = readRecallQuality(sessionId); + expect(result).toEqual({ topScore: 0, hitCount: 0, missCount: 3 }); + }); + + it('handles legacy cache format (missing quality fields) gracefully', () => { + const sessionId = 'legacy-session'; + const sessionsDir = path.join(tmpDir, '.teamai', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.writeFileSync( + path.join(sessionsDir, `${sessionId}-recall-cache.json`), + JSON.stringify({ + queries: ['old'], + count: 2, + updatedAt: new Date().toISOString(), + }), + 'utf-8', + ); + + const result = readRecallQuality(sessionId); + expect(result).toBeNull(); + }); + + it('returns null for expired cache (TTL exceeded)', () => { + const sessionId = 'expired-session'; + const sessionsDir = path.join(tmpDir, '.teamai', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + const expiredAt = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(); + fs.writeFileSync( + path.join(sessionsDir, `${sessionId}-recall-cache.json`), + JSON.stringify({ + queries: ['q1'], + count: 1, + updatedAt: expiredAt, + topScore: 8.0, + hitCount: 1, + missCount: 0, + }), + 'utf-8', + ); + + const result = readRecallQuality(sessionId); + expect(result).toBeNull(); + }); +}); diff --git a/src/__tests__/builtin-rules.test.ts b/src/__tests__/builtin-rules.test.ts index 432c735..ba4f630 100644 --- a/src/__tests__/builtin-rules.test.ts +++ b/src/__tests__/builtin-rules.test.ts @@ -25,11 +25,9 @@ describe('builtin-rules', () => { }); describe('deployBuiltinRules', () => { - it('should clean up legacy teamai-recall.md files', async () => { - // Arrange: create tool rules directories with legacy rule + it('should deploy teamai-recall.md rule to tool rules directory', async () => { const claudeRulesDir = path.join(tmpDir, '.claude', 'rules'); fs.mkdirSync(claudeRulesDir, { recursive: true }); - fs.writeFileSync(path.join(claudeRulesDir, 'teamai-recall.md'), 'old recall rule', 'utf-8'); const teamConfig = { toolPaths: { @@ -42,12 +40,14 @@ describe('builtin-rules', () => { }, } as any; - // Act const { deployBuiltinRules } = await import('../builtin-rules.js'); await deployBuiltinRules(teamConfig); - // Assert: legacy file is removed - expect(fs.existsSync(path.join(claudeRulesDir, 'teamai-recall.md'))).toBe(false); + const deployed = path.join(claudeRulesDir, 'teamai-recall.md'); + expect(fs.existsSync(deployed)).toBe(true); + const content = fs.readFileSync(deployed, 'utf-8'); + expect(content).toContain('Team Knowledge Recall'); + expect(content).toContain('teamai recall'); }); it('should skip tool directories that do not exist (tool not installed)', async () => { @@ -103,9 +103,9 @@ describe('builtin-rules', () => { }); describe('BUILTIN_RULE_NAMES', () => { - it('should be empty (no built-in rules deployed after recall rule removal)', async () => { + it('should contain teamai-recall', async () => { const { BUILTIN_RULE_NAMES } = await import('../builtin-rules.js'); - expect(BUILTIN_RULE_NAMES.size).toBe(0); + expect(BUILTIN_RULE_NAMES.has('teamai-recall')).toBe(true); }); }); }); diff --git a/src/__tests__/codebase-lint.test.ts b/src/__tests__/codebase-lint.test.ts index 494f9b2..ab08007 100644 --- a/src/__tests__/codebase-lint.test.ts +++ b/src/__tests__/codebase-lint.test.ts @@ -77,7 +77,7 @@ async function scaffold(opts: ScaffoldOptions): Promise { const fm = { title: 'Codebase 概览', lastUpdated: isoAgo(1), - source: path.join(os.homedir(), '.teamai', 'cache', 'repos', 'placeholder'), + source: opts.cwd, generator: 'teamai-cli', schemaVersion: 1, ...(opts.repoFrontmatter ?? {}), diff --git a/src/__tests__/contribute-check-phase2.test.ts b/src/__tests__/contribute-check-phase2.test.ts new file mode 100644 index 0000000..5e3c79f --- /dev/null +++ b/src/__tests__/contribute-check-phase2.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { applyPhase2Adjustments, hasGitCommitInSession, contributeCheckForSession, writeContributeState } from '../contribute-check.js'; +import { appendEvent } from '../dashboard-collector.js'; +import { + CONTRIBUTE_KNOWLEDGE_GAP_BONUS, + CONTRIBUTE_LOW_QUALITY_BONUS, + CONTRIBUTE_LOW_QUALITY_THRESHOLD, + CONTRIBUTE_GIT_COMMIT_DOWNWEIGHT, +} from '../types.js'; + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-p2-check-test-')); +} + +function writeRecallCache( + tmpDir: string, + sessionId: string, + data: object, +): void { + const sessionsDir = path.join(tmpDir, '.teamai', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.writeFileSync( + path.join(sessionsDir, `${sessionId}-recall-cache.json`), + JSON.stringify(data), + 'utf-8', + ); +} + +describe('applyPhase2Adjustments', () => { + let tmpDir: string; + const originalHome = process.env.HOME; + + beforeEach(() => { + tmpDir = makeTmpDir(); + process.env.HOME = tmpDir; + }); + + afterEach(() => { + process.env.HOME = originalHome; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns base score unchanged when no recall-cache exists', () => { + const result = applyPhase2Adjustments(40, 'no-cache-session'); + expect(result.score).toBe(40); + expect(result.isKnowledgeGap).toBe(false); + expect(result.hasGitCommit).toBe(false); + }); + + it('adds KNOWLEDGE_GAP_BONUS when all recalls missed', () => { + const sessionId = 'all-miss-session'; + writeRecallCache(tmpDir, sessionId, { + queries: ['q1'], + count: 1, + updatedAt: new Date().toISOString(), + topScore: 0, + hitCount: 0, + missCount: 3, + }); + + const result = applyPhase2Adjustments(30, sessionId); + expect(result.score).toBe(30 + CONTRIBUTE_KNOWLEDGE_GAP_BONUS); + expect(result.isKnowledgeGap).toBe(true); + }); + + it('adds LOW_QUALITY_BONUS when top score below threshold', () => { + const sessionId = 'low-quality-session'; + writeRecallCache(tmpDir, sessionId, { + queries: ['q1'], + count: 1, + updatedAt: new Date().toISOString(), + topScore: 3.0, + hitCount: 2, + missCount: 1, + }); + + const result = applyPhase2Adjustments(30, sessionId); + expect(result.score).toBe(30 + CONTRIBUTE_LOW_QUALITY_BONUS); + expect(result.isKnowledgeGap).toBe(true); + }); + + it('no bonus when recall quality is good (topScore >= threshold)', () => { + const sessionId = 'good-quality-session'; + writeRecallCache(tmpDir, sessionId, { + queries: ['q1'], + count: 1, + updatedAt: new Date().toISOString(), + topScore: 10.0, + hitCount: 3, + missCount: 0, + }); + + const result = applyPhase2Adjustments(30, sessionId); + expect(result.score).toBe(30); + expect(result.isKnowledgeGap).toBe(false); + }); + + it('does not apply git commit downweight without cwd parameter', () => { + const sessionId = 'no-cwd-session'; + writeRecallCache(tmpDir, sessionId, { + queries: ['q1'], + count: 1, + updatedAt: new Date().toISOString(), + topScore: 10.0, + hitCount: 2, + missCount: 0, + }); + + const result = applyPhase2Adjustments(50, sessionId); + expect(result.score).toBe(50); + expect(result.hasGitCommit).toBe(false); + }); + + it('score cannot go below 0 after adjustments', () => { + const sessionId = 'floor-session'; + writeRecallCache(tmpDir, sessionId, { + queries: ['q1'], + count: 1, + updatedAt: new Date().toISOString(), + topScore: 10.0, + hitCount: 2, + missCount: 0, + }); + + const gitRepo = path.resolve(__dirname, '../../'); + const veryOldStart = '2020-01-01T00:00:00Z'; + const result = applyPhase2Adjustments(5, sessionId, gitRepo, veryOldStart); + expect(result.score).toBe(0); + }); +}); + +describe('hasGitCommitInSession', () => { + it('returns false for non-git directory', () => { + const result = hasGitCommitInSession('/tmp', '2020-01-01T00:00:00Z'); + expect(result).toBe(false); + }); + + it('returns false for nonexistent directory', () => { + const result = hasGitCommitInSession('/tmp/nonexistent-dir-xyz', '2020-01-01T00:00:00Z'); + expect(result).toBe(false); + }); +}); + +describe('buildHint text differentiation', () => { + let tmpDir: string; + const originalHome = process.env.HOME; + + beforeEach(() => { + tmpDir = makeTmpDir(); + process.env.HOME = tmpDir; + }); + + afterEach(() => { + process.env.HOME = originalHome; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + async function seedHighScoreSession(sessionId: string): Promise { + const count = 50; + const now = Date.now(); + const tools = ['Bash', 'Read', 'Edit', 'Skill', 'Grep']; + for (let i = 0; i < count; i++) { + await appendEvent({ + type: 'tool_use', + sessionId, + tool: 'claude', + toolName: tools[i % tools.length], + timestamp: new Date(now - ((count - i) * 60 * 1000)).toISOString(), + }); + } + await appendEvent({ + type: 'prompt_submit', + sessionId, + tool: 'claude', + promptSummary: 'fix the error', + timestamp: new Date(now - 60 * 1000).toISOString(), + }); + } + + it('hint contains "知识库尚未覆盖" when knowledge gap detected', async () => { + const sessionId = 'knowledge-gap-hint-session'; + await seedHighScoreSession(sessionId); + writeRecallCache(tmpDir, sessionId, { + queries: ['q1'], + count: 1, + updatedAt: new Date().toISOString(), + topScore: 0, + hitCount: 0, + missCount: 5, + }); + + const { hint } = await contributeCheckForSession(sessionId); + expect(hint).not.toBeNull(); + expect(hint).toContain('知识库尚未覆盖'); + }); + + it('hint contains "内容丰富" when recall quality is good (no knowledge gap)', async () => { + const sessionId = 'good-recall-hint-session'; + await seedHighScoreSession(sessionId); + writeRecallCache(tmpDir, sessionId, { + queries: ['q1'], + count: 1, + updatedAt: new Date().toISOString(), + topScore: 15.0, + hitCount: 3, + missCount: 0, + }); + + const { hint } = await contributeCheckForSession(sessionId); + expect(hint).not.toBeNull(); + expect(hint).toContain('内容丰富'); + expect(hint).not.toContain('知识库尚未覆盖'); + }); +}); diff --git a/src/__tests__/gf-org.test.ts b/src/__tests__/gf-org.test.ts index a68badf..bafd2d9 100644 --- a/src/__tests__/gf-org.test.ts +++ b/src/__tests__/gf-org.test.ts @@ -114,6 +114,8 @@ describe('gfListOrgRepos', () => { }); it('404 — 抛 TGit group not found or no access', async () => { + // 策略 1(/groups//projects)和策略 2(/groups/)均返回 404 + mockFetch.mockResolvedValueOnce(makeResponse('Not Found', 404)); mockFetch.mockResolvedValueOnce(makeResponse('Not Found', 404)); await expect(gfListOrgRepos('nonexistent-group')).rejects.toThrow( diff --git a/src/__tests__/import-repo.test.ts b/src/__tests__/import-repo.test.ts index 7dbf28c..2e65158 100644 --- a/src/__tests__/import-repo.test.ts +++ b/src/__tests__/import-repo.test.ts @@ -32,6 +32,10 @@ vi.mock('../utils/prompt.js', () => ({ askConfirmation: vi.fn().mockResolvedValue(true), })); +vi.mock('../config.js', () => ({ + autoDetectInit: vi.fn().mockRejectedValue(new Error('not initialized in test')), +})); + // ─── Imports(after mocks)────────────────────────────── import { importFromRepo, buildRepoMetaFromPath } from '../import-repo.js'; diff --git a/src/aggregate.ts b/src/aggregate.ts index 73c8c85..a020116 100644 --- a/src/aggregate.ts +++ b/src/aggregate.ts @@ -48,23 +48,38 @@ async function parseRepoMd(filePath: string, slug: string): Promise : typeof fm.url === 'string' ? fm.url : ''; - // 仓库名:frontmatter.repo_name 或首个 # 标题 + // 仓库名:frontmatter.repo_name > 从 URL 提取 > slug let name = typeof fm.repo_name === 'string' ? fm.repo_name : ''; + if (!name && url) { + const repoMatch = url.match(/\/([^/]+?)(?:\.git)?$/); + if (repoMatch) name = repoMatch[1]; + } if (!name) { - const titleMatch = content.match(/^#\s+(.+)/m); - name = titleMatch ? titleMatch[1].trim() : slug; + name = slug; } const primaryLanguage = typeof fm.primary_language === 'string' ? fm.primary_language : 'N/A'; const lineCount = fm.line_count != null ? String(fm.line_count) : 'N/A'; const lastSynced = typeof fm.last_synced === 'string' ? fm.last_synced : typeof fm.generated_at === 'string' ? fm.generated_at + : typeof fm.lastUpdated === 'string' ? fm.lastUpdated : 'N/A'; - // 摘要:去掉标题行,取首段前 200 字 - const bodyWithoutTitle = content.replace(/^#[^\n]*\n/m, '').trim(); - const firstPara = bodyWithoutTitle.split(/\n\n+/)[0] ?? ''; - const excerpt = firstPara.slice(0, 200); + // 摘要:找到 ## 项目概述 章节的首个非空文本段落 + let excerpt = ''; + const overviewMatch = content.match(/## 项目概述\n+([\s\S]*?)(?=\n## |\n# |$)/); + if (overviewMatch) { + const overviewBody = overviewMatch[1]; + const lines = overviewBody.split('\n') + .filter(l => l.trim() && !l.trim().startsWith('')); + excerpt = lines.slice(0, 3).join(' ').slice(0, 200); + } + if (!excerpt) { + const bodyWithoutTitle = content.replace(/^#[^\n]*\n/m, '').trim(); + const paras = bodyWithoutTitle.split(/\n\n+/) + .filter(p => p.trim() && !p.trim().startsWith(' +\`\`\` + +如无相关命中则声明空列表:\`\` + +## 自动错误检索 + +当 Bash 命令执行报错时,teamai 的 auto-recall hook 会自动搜索团队知识库 +并将相关经验注入上下文。无需手动操作。 +`; + diff --git a/src/codebase.ts b/src/codebase.ts index 3901bc2..a5cd013 100644 --- a/src/codebase.ts +++ b/src/codebase.ts @@ -388,7 +388,19 @@ export async function generateCodebaseMd(opts: { const rawResult = await callClaude(prompt); // 剥离 AI 可能自行附加的 frontmatter,再 prepend 标准 frontmatter - const body = stripExistingFrontmatter(rawResult); + let body = stripExistingFrontmatter(rawResult); + + // 去除 AI 可能在首个标题前输出的过渡性文字(如"文件写入需要权限确认…") + const h1Idx = body.indexOf('# '); + const h2Idx = body.indexOf('## '); + const titleIdx = h1Idx >= 0 ? h1Idx : h2Idx; + if (titleIdx > 0) { + body = body.slice(titleIdx); + } else if (titleIdx < 0) { + // 完全没有标题,尝试去除明显的 AI 过渡文字行 + body = body.replace(/^.*(?:文件写入|请授权|权限确认|以下是生成的|完整内容|文档已准备|由于无法).*\n*/gm, '').trim(); + } + return buildFrontmatter(repoPath) + body; } diff --git a/src/contribute-check.ts b/src/contribute-check.ts index 16247ae..b20bd1a 100644 --- a/src/contribute-check.ts +++ b/src/contribute-check.ts @@ -1,13 +1,19 @@ import fs from 'node:fs'; import path from 'node:path'; +import { execFileSync } from 'node:child_process'; import { log } from './utils/logger.js'; import { readJson, writeJson, ensureDir } from './utils/fs.js'; import { readEvents } from './dashboard-collector.js'; +import { readRecallQuality } from './auto-recall.js'; import type { ContributeState, DashboardEvent } from './types.js'; import { CONTRIBUTE_SMART_THRESHOLD, CONTRIBUTE_BASE_THRESHOLD, CONTRIBUTE_SCORE_CACHE_MS, + CONTRIBUTE_KNOWLEDGE_GAP_BONUS, + CONTRIBUTE_LOW_QUALITY_BONUS, + CONTRIBUTE_LOW_QUALITY_THRESHOLD, + CONTRIBUTE_GIT_COMMIT_DOWNWEIGHT, } from './types.js'; // ─── Contribute check data flow (Stop hook) ──────────────── @@ -91,6 +97,9 @@ export async function readContributeState(sessionId: string): Promise 0; + } catch { + return false; + } +} + +/** + * Apply Phase 2 score adjustments based on recall quality and git commit status. + * Returns the adjusted score and metadata flags. + */ +export function applyPhase2Adjustments( + baseScore: number, + sessionId: string, + cwd?: string, + sessionStartIso?: string, +): { score: number; isKnowledgeGap: boolean; hasGitCommit: boolean } { + let score = baseScore; + let isKnowledgeGap = false; + let gitCommitDetected = false; + + const recallQuality = readRecallQuality(sessionId); + + if (recallQuality) { + const totalRecalls = recallQuality.hitCount + recallQuality.missCount; + if (totalRecalls > 0 && recallQuality.hitCount === 0) { + score += CONTRIBUTE_KNOWLEDGE_GAP_BONUS; + isKnowledgeGap = true; + } else if (recallQuality.topScore < CONTRIBUTE_LOW_QUALITY_THRESHOLD && recallQuality.hitCount > 0) { + score += CONTRIBUTE_LOW_QUALITY_BONUS; + isKnowledgeGap = true; + } + } + + if (cwd && sessionStartIso) { + gitCommitDetected = hasGitCommitInSession(cwd, sessionStartIso); + if (gitCommitDetected && recallQuality && recallQuality.hitCount > 0) { + score -= CONTRIBUTE_GIT_COMMIT_DOWNWEIGHT; + } + } + + return { score: Math.max(0, score), isKnowledgeGap, hasGitCommit: gitCommitDetected }; +} + /** Read STDIN and extract sessionId from hook JSON. */ -async function readStdinAndDeriveSession(): Promise<{ sessionId: string } | null> { +async function readStdinAndDeriveSession(): Promise<{ sessionId: string; cwd?: string } | null> { if (process.stdin.isTTY) return null; const chunks: Buffer[] = []; @@ -244,7 +312,8 @@ async function readStdinAndDeriveSession(): Promise<{ sessionId: string } | null (typeof hookData.session_id === 'string' && hookData.session_id) || process.env.CLAUDE_SESSION_ID || `pid-${process.ppid ?? process.pid}-${typeof hookData.cwd === 'string' ? hookData.cwd : process.cwd()}`; - return { sessionId }; + const cwd = typeof hookData.cwd === 'string' ? hookData.cwd : undefined; + return { sessionId, cwd }; } catch { return null; } @@ -261,7 +330,14 @@ function countUniqueTools(events: DashboardEvent[]): number { } /** Build the STDOUT hint string from pre-computed display values. */ -function buildHint(totalToolCalls: number, uniqueTools: number): string { +function buildHint(totalToolCalls: number, uniqueTools: number, isKnowledgeGap: boolean): string { + if (isKnowledgeGap) { + return [ + `[teamai] 本次 session 涉及知识库尚未覆盖的领域(${totalToolCalls} 次工具调用,${uniqueTools} 种不同工具)。`, + `建议运行 /teamai-share-learnings 将本次经验总结分享给团队,帮助填补知识库空白。`, + `下次遇到类似任务时,团队成员将直接受益于您的经验。`, + ].join(''); + } return [ `[teamai] 本次 session 内容丰富(${totalToolCalls} 次工具调用,${uniqueTools} 种不同工具)。`, `建议运行 /teamai-share-learnings 总结本次 session 的经验并分享给团队。`, @@ -298,7 +374,10 @@ function buildHint(totalToolCalls: number, uniqueTools: number): string { * * Returns the hint string (caller writes to stdout) or null if no hint. */ -export async function contributeCheckForSession(sessionId: string): Promise<{ hint: string | null }> { +export async function contributeCheckForSession( + sessionId: string, + cwd?: string, +): Promise<{ hint: string | null }> { const state = await readContributeState(sessionId); const now = Date.now(); @@ -328,6 +407,7 @@ export async function contributeCheckForSession(sessionId: string): Promise<{ hi let toolCount: number; let uniqueTools: number; let needsPersist: boolean; + let sessionStartIso: string | undefined; const cachedDisplayAvailable = cacheFresh @@ -340,6 +420,7 @@ export async function contributeCheckForSession(sessionId: string): Promise<{ hi score = state.smartScore!; toolCount = state.toolCount!; uniqueTools = state.uniqueTools!; + sessionStartIso = state.sessionStartIso; needsPersist = false; } else { const allEvents = await readEvents(); @@ -348,9 +429,23 @@ export async function contributeCheckForSession(sessionId: string): Promise<{ hi toolCount = countToolUseEvents(sessionEvents); uniqueTools = countUniqueTools(sessionEvents); needsPersist = true; + if (sessionEvents.length > 0) { + sessionStartIso = sessionEvents[0].timestamp; + } log.debug(`contribute-check: session ${sessionId.slice(0, 16)} smart score = ${score} (threshold: ${CONTRIBUTE_SMART_THRESHOLD})`); } + // Phase 2: apply knowledge gap + git commit adjustments + const phase2 = applyPhase2Adjustments(score, sessionId, cwd, sessionStartIso); + score = phase2.score; + const { isKnowledgeGap, hasGitCommit } = phase2; + if (isKnowledgeGap || hasGitCommit) { + needsPersist = true; + log.debug( + `contribute-check: phase2 adjustments applied (gap=${isKnowledgeGap}, commit=${hasGitCommit}, adjusted=${score})`, + ); + } + const willHint = score >= CONTRIBUTE_SMART_THRESHOLD; // Single write: re-read first to avoid clobbering parallel /contribute marks. @@ -363,9 +458,10 @@ export async function contributeCheckForSession(sessionId: string): Promise<{ hi toolCount, uniqueTools, lastEvaluated: needsPersist ? now : (latest.lastEvaluated ?? now), + sessionStartIso: sessionStartIso ?? latest.sessionStartIso, + isKnowledgeGap, + hasGitCommit, }; - // Only persist hinted=true; never write hinted=false (semantically same as - // undefined and would surprise tests / consumers expecting absence). if (latest.hinted || willHint) { updated.hinted = true; } @@ -377,7 +473,7 @@ export async function contributeCheckForSession(sessionId: string): Promise<{ hi return { hint: null }; } - return { hint: buildHint(toolCount, uniqueTools) }; + return { hint: buildHint(toolCount, uniqueTools, isKnowledgeGap) }; } /** @@ -398,7 +494,7 @@ export async function contributeCheck(toolArg?: string): Promise { return; } - const { hint } = await contributeCheckForSession(stdinData.sessionId); + const { hint } = await contributeCheckForSession(stdinData.sessionId, stdinData.cwd); if (hint !== null) { // Stop schema has no hookSpecificOutput; use stopReason process.stdout.write(JSON.stringify({ stopReason: hint })); diff --git a/src/contribute.ts b/src/contribute.ts index 08a8bd3..64b9cf3 100644 --- a/src/contribute.ts +++ b/src/contribute.ts @@ -96,13 +96,12 @@ export async function contribute( } const pushSpin = spinner('Contributing session knowledge...').start(); + const filename = generateFilename(options.title); try { // Prepare destination const aiDocsDir = path.join(repoPath, 'learnings'); await ensureDir(aiDocsDir); - - const filename = generateFilename(options.title); const destPath = path.join(aiDocsDir, filename); // Write file to repo @@ -139,7 +138,16 @@ export async function contribute( log.info(`Your session knowledge has been shared with the team.`); } catch (e) { - pushSpin.fail(`Contribution failed: ${(e as Error).message}`); - log.info('You can retry with: teamai contribute --file '); + // 确保文件至少被本地 commit(防止 resetToCleanMaster 丢失数据) + try { + const { execFileSync } = await import('node:child_process'); + const commitMsg = `[teamai] Contribute: ${options.title || 'session knowledge'}`; + execFileSync('git', ['add', `learnings/${filename}`], { cwd: repoPath, timeout: 5000 }); + execFileSync('git', ['commit', '-m', commitMsg], { cwd: repoPath, timeout: 5000 }); + pushSpin.warn(`已保存到本地(推送失败: ${(e as Error).message})。下次 pull 时将自动重试推送。`); + } catch { + pushSpin.fail(`Contribution failed: ${(e as Error).message}`); + log.info('You can retry with: teamai contribute --file '); + } } } diff --git a/src/hooks-cmd.ts b/src/hooks-cmd.ts index 24e5dc9..2a1876d 100644 --- a/src/hooks-cmd.ts +++ b/src/hooks-cmd.ts @@ -15,6 +15,12 @@ export async function hooksInject(options: GlobalOptions): Promise { const baseDir = resolveBaseDir(localConfig); await injectHooksToAllTools(teamConfig.toolPaths, baseDir); + // Project scope: also inject into user scope (~/) so hooks work from subdirectories + if (localConfig.scope === 'project') { + const userBaseDir = process.env.HOME ?? ''; + await injectHooksToAllTools(teamConfig.toolPaths, userBaseDir); + } + if (!options.silent) { log.success('Hooks injected into all AI tool settings'); } diff --git a/src/hooks.ts b/src/hooks.ts index ad0aaae..e40c5c2 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -302,7 +302,7 @@ function isTeamaiHookCommand(command: string): boolean { /** Known teamai command substrings used to identify teamai-managed hooks. */ const TEAMAI_COMMAND_MARKERS = [ - 'teamai pull', 'teamai update', 'teamai track', 'teamai dashboard', 'teamai contribute-check', 'teamai auto-recall', 'teamai todowrite-hint', 'teamai mr-hint', + 'teamai pull', 'teamai update', 'teamai track', 'teamai dashboard', 'teamai contribute-check', 'teamai auto-recall', 'teamai todowrite-hint', 'teamai mr-hint', 'teamai hook-dispatch', ]; /** diff --git a/src/import-org.ts b/src/import-org.ts index f000db8..be0ec08 100644 --- a/src/import-org.ts +++ b/src/import-org.ts @@ -101,6 +101,11 @@ function parseOrgInput(org: string): { providerName: string; orgPath: string } { return { providerName: getProviderFromUrl('').name, orgPath: trimmed }; } + // 纯数字 → TGit group ID(GitHub 不支持数字 org ID) + if (/^\d+$/.test(trimmed)) { + return { providerName: 'tgit', orgPath: trimmed }; + } + // 裸 org 名 const providerName = getProvider().name; return { providerName, orgPath: trimmed }; diff --git a/src/import-repo-list.ts b/src/import-repo-list.ts index c45388c..3b6ea05 100644 --- a/src/import-repo-list.ts +++ b/src/import-repo-list.ts @@ -1,4 +1,5 @@ // -*- coding: utf-8 -*- +import path from 'node:path'; import { loadRepoList } from './repo-list/store.js'; import { isOrgEntry, type RepoListEntry } from './repo-list/schema.js'; import { importFromRepo } from './import-repo.js'; @@ -148,8 +149,19 @@ export async function importFromRepoList( if (!skipAggregate && !dryRun) { try { const cwd = process.cwd(); - const paths = getTeamCodebasePaths(cwd, output); - const domains = await loadDomains(cwd); + // 优先使用 team-repo 路径读取 domains 和写入聚合产物 + let resolvedOutput = output; + let domainsBase = cwd; + if (!resolvedOutput) { + try { + const { autoDetectInit } = await import('./config.js'); + const { localConfig: lc } = await autoDetectInit(); + resolvedOutput = path.join(lc.repo.localPath, 'docs', 'team-codebase'); + domainsBase = lc.repo.localPath; + } catch { /* fallback to cwd */ } + } + const paths = getTeamCodebasePaths(cwd, resolvedOutput); + const domains = await loadDomains(domainsBase); await regenerateAggregate({ paths, domains }); aggregateGenerated = true; log.info(`聚合文件已生成:${paths.index}`); diff --git a/src/import-repo.ts b/src/import-repo.ts index c24976f..8fc0bf3 100644 --- a/src/import-repo.ts +++ b/src/import-repo.ts @@ -488,6 +488,17 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise log.info(`Clone/Fetch 完成: SHA=${cloneSha.slice(0, 8)}, branch=${cloneBranch}`); + // 2.5 SHA 未变化时跳过 AI 扫描(增量模式快速路径) + if (useIncremental && oldSha && cloneSha === oldSha) { + log.info(`[incremental] SHA 未变化 (${cloneSha.slice(0, 8)}),跳过 AI 扫描`); + await writeLastSync(cacheDir, cloneSha); + try { + await touchCacheEntry({ provider: providerName, owner, repo: repoName, lastSyncedSha: cloneSha }); + } catch {} + log.info(chalk.green(`✓ 仓库 ${owner}/${repoName} 无变化,跳过`)); + return; + } + // 3. 扫描生成 codebase.md log.info(`扫描仓库内容...`); let codebaseMd: string; @@ -498,9 +509,11 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise throw new Error(`codebase 扫描失败: ${err instanceof Error ? err.message : String(err)}`); } - // 4. 确定产物输出路径 + // 4. 确定产物输出路径(优先写入 team-repo/docs/team-codebase) + // 注:outputRoot 使用后续步骤 5 中 domainsBase 同源的 team-repo 路径 + // 这里先用临时值,待 domainsBase 确定后再修正 const outputRoot = output ?? path.join(process.cwd(), 'docs', 'team-codebase'); - const repoMdPath = path.join(outputRoot, 'repos', `${slug}.md`); + let repoMdPath = path.join(outputRoot, 'repos', `${slug}.md`); // path-safety:确保写入路径在 reposDir 内,防止 slug 含路径分隔符导致目录穿越 assertSafePath(repoMdPath, [path.join(outputRoot, 'repos')]); @@ -538,6 +551,14 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise toWrite = merged.mergedMd; } + // 注入 repo_url 到 frontmatter,供 aggregate 映射 domain + if (toWrite.startsWith('---\n') && !toWrite.includes('\nrepo_url:')) { + const fmEnd = toWrite.indexOf('\n---\n', 4); + if (fmEnd !== -1) { + toWrite = toWrite.slice(0, fmEnd) + `\nrepo_url: ${url}` + toWrite.slice(fmEnd); + } + } + if (dryRun) { console.log(chalk.yellow(`[dry-run] 产物路径: ${repoMdPath}`)); console.log(chalk.yellow('[dry-run] 产物预览(前 50 行):')); @@ -579,7 +600,27 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise // 5. 业务域推荐 const cwd = process.cwd(); - const existingDomains = await loadDomains(cwd); + // 当无 --output 时,domains.yaml 写入团队仓库(共享),否则写入 cwd + let domainsBase = cwd; + if (!output) { + try { + // 优先使用团队仓库路径(多人共享 domains.yaml) + const { autoDetectInit } = await import('./config.js'); + const { localConfig: lc } = await autoDetectInit(); + // 确认团队仓库的 .teamai/ 目录可访问 + const teamaiDir = path.join(lc.repo.localPath, '.teamai'); + await fs.ensureDir(teamaiDir); + domainsBase = lc.repo.localPath; + } catch { /* fallback: cwd */ } + } + const existingDomains = await loadDomains(domainsBase); + + // 修正产物路径:使用 domainsBase(team-repo)作为输出根 + if (!output && domainsBase !== cwd) { + const correctedRoot = path.join(domainsBase, 'docs', 'team-codebase'); + repoMdPath = path.join(correctedRoot, 'repos', `${slug}.md`); + assertSafePath(repoMdPath, [path.join(correctedRoot, 'repos')]); + } // 检查 url 是否已在其他域 const existingDomainName = findExistingDomain(existingDomains, url); @@ -588,7 +629,7 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise if (existingDomainName && !dryRun) { const newMeta = await buildRepoMetaFromPath(cacheDir, url, repoName); await detectDomainDrift({ - cwd, + cwd: domainsBase, url, newMeta, domains: existingDomains, @@ -604,6 +645,17 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise log.debug(`[cache-index] touchCacheEntry 失败(不阻塞主流程): ${String(touchErr)}`); } log.info(chalk.green(`✓ 仓库 ${owner}/${repoName} 增量同步完成`)); + // 增量同步后也更新聚合文件 + if (!dryRun) { + try { + const { regenerateAggregate } = await import('./aggregate.js'); + const { getTeamCodebasePaths } = await import('./utils/team-codebase-paths.js'); + const aggOutput = output ?? path.join(domainsBase, 'docs', 'team-codebase'); + const aggPaths = getTeamCodebasePaths(cwd, aggOutput); + const freshDomains = await loadDomains(domainsBase); + await regenerateAggregate({ paths: aggPaths, domains: freshDomains }); + } catch { /* 非关键路径 */ } + } return; } @@ -695,11 +747,11 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise return { ...domain, repos: [...domain.repos, newEntry] }; }); - await saveDomains(cwd, updatedDomains); + await saveDomains(domainsBase, updatedDomains); log.info(`已将仓库 ${repoName} 归入域「${finalDomainName}」`); // appendHistory - await appendHistory(cwd, { + await appendHistory(domainsBase, { ts: new Date().toISOString(), actor: historyActor, action: rejectReason ? 'reject' : 'accept', @@ -726,4 +778,17 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise } log.info(chalk.green(`✓ 仓库 ${owner}/${repoName} 导入完成`)); + + // 8. 更新聚合文件(domain-*.md + index.md) + if (!dryRun) { + try { + const { regenerateAggregate } = await import('./aggregate.js'); + const { getTeamCodebasePaths } = await import('./utils/team-codebase-paths.js'); + const aggOutput = output ?? path.join(domainsBase, 'docs', 'team-codebase'); + const aggPaths = getTeamCodebasePaths(cwd, aggOutput); + const freshDomains = await loadDomains(domainsBase); + await regenerateAggregate({ paths: aggPaths, domains: freshDomains }); + log.info(`聚合文件已更新`); + } catch { /* 非关键路径 */ } + } } diff --git a/src/import.ts b/src/import.ts index 1c3ad77..e137c17 100644 --- a/src/import.ts +++ b/src/import.ts @@ -28,8 +28,6 @@ interface ImportOptions extends GlobalOptions { fromMr?: string; /** iWiki Space ID 或页面 URL,用于批量导入 iWiki 文档 */ fromIwiki?: string; - /** 批量模式下最多扫描的 MR 数量(字符串,需 parseInt) */ - limit?: string; /** 是否恢复中断的导入会话 */ resume?: boolean; /** 是否导入全部候选(跳过交互确认) */ @@ -244,11 +242,14 @@ export async function importCmd(opts: ImportOptions): Promise { const classified = await classifyWithAI(candidates); const session = await interactiveReview(classified, { all: opts.all, resume: opts.resume }); const { localConfig } = await autoDetectInit(); - await pushAccepted(session, localConfig.repo.localPath, { + const { pushed } = await pushAccepted(session, localConfig.repo.localPath, { dryRun: opts.dryRun, outputDir: opts.output, }); log.success('导入完成'); + if (pushed > 0 && !opts.dryRun && !opts.output) { + log.info('文件已写入本地团队仓库,运行 `teamai push` 推送到远程仓库'); + } } else { // 默认:未指定来源,提示用户 log.info('请指定导入来源:--dir 、--from-claude、--workspace、--from-mr 或 --from-iwiki '); diff --git a/src/index.ts b/src/index.ts index 82d19b2..e2213a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -576,7 +576,6 @@ program .option('--workspace', 'Generate codebase.md from current git workspace') .option('--from-mr ', 'Extract learning and codebase suggestions from a merged MR/PR URL') .option('--from-iwiki ', 'Import documents from iWiki Space ID or page URL (requires TAI_PAT_TOKEN)') - .option('--limit ', 'Max number of recent merged MRs to scan (used with --from-mr batch mode)', '10') .option('--resume', 'Resume an interrupted import session') .option('--all', 'Accept all suggestions without interactive confirmation') .option('--output ', 'Write drafts to this directory instead of pushing to team repo') diff --git a/src/providers/tgit/gf-org.ts b/src/providers/tgit/gf-org.ts index 68b0b7f..e8c33c9 100644 --- a/src/providers/tgit/gf-org.ts +++ b/src/providers/tgit/gf-org.ts @@ -74,24 +74,27 @@ export async function gfListOrgRepos( }; const collected: OrgRepoInfo[] = []; + + // 策略 1: 尝试 /groups//projects 分页接口(标准 GitLab API) + let useProjectsEndpoint = true; let page = 1; - while (collected.length < maxRepos) { + while (useProjectsEndpoint && collected.length < maxRepos) { const url = `${TGIT_API_BASE}/groups/${encodedGroup}/projects?per_page=${perPage}&page=${page}`; - // redirect: 'manual' 防止跟随重定向到内网地址(SSRF) const resp = await fetch(url, { headers, redirect: 'manual' }); if (resp.status >= 300 && resp.status < 400) { throw new Error(`Unexpected redirect from TGit API: ${resp.status}`); } if (resp.status === 404) { - throw new Error(`TGit group ${group} not found or no access`); + // 工蜂部分版本不支持 /groups//projects,fallback 到策略 2 + useProjectsEndpoint = false; + break; } if (!resp.ok) { throw new Error(`TGit API HTTP ${resp.status}: ${await resp.text().catch(() => '')}`); } - // 流式读取响应体,限制最大 50 MB 防止 OOM const reader = resp.body?.getReader(); let received = 0; const chunks: Uint8Array[] = []; @@ -120,6 +123,49 @@ export async function gfListOrgRepos( page++; } + // 策略 2: 从 /groups/ 响应中提取内嵌 projects 数组(工蜂兼容) + if (!useProjectsEndpoint && collected.length === 0) { + const url = `${TGIT_API_BASE}/groups/${encodedGroup}`; + const resp = await fetch(url, { headers, redirect: 'manual' }); + + if (resp.status >= 300 && resp.status < 400) { + throw new Error(`Unexpected redirect from TGit API: ${resp.status}`); + } + if (resp.status === 404) { + throw new Error(`TGit group ${group} not found or no access`); + } + if (!resp.ok) { + throw new Error(`TGit API HTTP ${resp.status}: ${await resp.text().catch(() => '')}`); + } + + const reader = resp.body?.getReader(); + let received = 0; + const chunks: Uint8Array[] = []; + if (reader) { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + received += value.length; + if (received > MAX_RESPONSE_BYTES) { + await reader.cancel(); + throw new Error(`TGit API response exceeds ${MAX_RESPONSE_BYTES} bytes`); + } + chunks.push(value); + } + } + const bodyText = Buffer.concat(chunks).toString('utf-8'); + const groupData = JSON.parse(bodyText) as { projects?: TgitProjectApiItem[] }; + + if (groupData.projects && Array.isArray(groupData.projects)) { + for (const item of groupData.projects) { + collected.push(mapItem(item)); + if (collected.length >= maxRepos) break; + } + } else { + throw new Error(`TGit group ${group} not found or no access`); + } + } + log.debug(`gfListOrgRepos: ${group} 共 ${collected.length} 项`); return collected; } diff --git a/src/pull.ts b/src/pull.ts index ae18c90..6846ad0 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -257,6 +257,15 @@ async function pullForScope( const state = await loadStateForScope(localConfig.scope, localConfig.projectRoot); if (state.lastPullRev && state.lastPullRev === currentRev) { log.success(`[${scopeLabel}] Already synced at ${currentRev}, skipping`); + // 即使 repo 未变化,仍部署 CLI 内置资源(确保 CLI 升级后新版本 agent/rules 生效) + if (!options.dryRun) { + const cfg = await loadTeamConfig(localConfig.repo.localPath); + if (cfg) { + try { const { deployBuiltinAgents } = await import('./builtin-agents.js'); await deployBuiltinAgents(cfg, localConfig); } catch {} + try { const { deployBuiltinRules } = await import('./builtin-rules.js'); await deployBuiltinRules(cfg, localConfig); } catch {} + try { const { deployBuiltinSkills } = await import('./builtin-skills.js'); await deployBuiltinSkills(cfg, localConfig, { skipWiki: !isWikiEnabled() }); } catch {} + } + } return; } } catch { @@ -517,8 +526,8 @@ async function pullForScope( } // Step 3.5: Sync learnings and rebuild the multi-category search index - // (Phase 1: covers learnings + docs + rules + skills). user scope only. - if (!options.dryRun && localConfig.scope === 'user') { + // (Phase 1: covers learnings + docs + rules + skills). Both scopes supported. + if (!options.dryRun) { try { const learningsRepoDir = path.join(localConfig.repo.localPath, 'learnings'); const docsRepoDir = path.join(localConfig.repo.localPath, 'docs'); @@ -526,34 +535,54 @@ async function pullForScope( const skillsRepoDir = path.join(localConfig.repo.localPath, 'skills'); const votesDir = path.join(localConfig.repo.localPath, 'votes'); - // Always sync learnings to ~/.teamai/learnings/ when present (legacy behavior) + // user scope: sync learnings to ~/.teamai/learnings/ (legacy behavior) + // project scope: use learnings directly from repo let learningsCount = 0; - if (await pathExists(learningsRepoDir)) { - await fse.copy(learningsRepoDir, LEARNINGS_LOCAL_DIR, { - overwrite: true, - filter: (src: string) => !path.basename(src).startsWith('.'), - }); - const allFiles = await listFiles(learningsRepoDir); - learningsCount = allFiles.filter((f) => f.endsWith('.md')).length; + let effectiveLearningsDir: string | undefined; + if (localConfig.scope === 'user') { + if (await pathExists(learningsRepoDir)) { + await fse.copy(learningsRepoDir, LEARNINGS_LOCAL_DIR, { + overwrite: true, + filter: (src: string) => !path.basename(src).startsWith('.'), + }); + const allFiles = await listFiles(learningsRepoDir); + learningsCount = allFiles.filter((f) => f.endsWith('.md')).length; + } + effectiveLearningsDir = await pathExists(LEARNINGS_LOCAL_DIR) ? LEARNINGS_LOCAL_DIR : undefined; + } else { + effectiveLearningsDir = await pathExists(learningsRepoDir) ? learningsRepoDir : undefined; + if (effectiveLearningsDir) { + const allFiles = await listFiles(learningsRepoDir); + learningsCount = allFiles.filter((f) => f.endsWith('.md')).length; + } } - // Build the index when ANY of the four categories has content. Missing - // categories are silently skipped by the collectors. + // Build the index when ANY of the four categories has content. const hasAnySource = - await pathExists(LEARNINGS_LOCAL_DIR) || + effectiveLearningsDir || await pathExists(docsRepoDir) || await pathExists(rulesRepoDir) || await pathExists(skillsRepoDir); - if (hasAnySource) { + // Resolve codebase directory (project cwd or team repo) + const cwdCodebaseDir = path.join(process.cwd(), 'docs', 'team-codebase'); + const repoCodebaseDir = path.join(localConfig.repo.localPath, 'docs', 'team-codebase'); + const effectiveCodebaseDir = await pathExists(cwdCodebaseDir) ? cwdCodebaseDir + : await pathExists(repoCodebaseDir) ? repoCodebaseDir : undefined; + + if (hasAnySource || effectiveCodebaseDir) { const votesExist = await pathExists(votesDir); + const teamaiHome = getTeamaiHome(localConfig.scope, localConfig.projectRoot); + const indexPath = path.join(teamaiHome, 'search-index.json'); const { buildIndex } = await import('./utils/search-index.js'); const elapsed = await buildIndex({ - learningsDir: await pathExists(LEARNINGS_LOCAL_DIR) ? LEARNINGS_LOCAL_DIR : undefined, + learningsDir: effectiveLearningsDir, docsDir: await pathExists(docsRepoDir) ? docsRepoDir : undefined, rulesDir: await pathExists(rulesRepoDir) ? rulesRepoDir : undefined, skillsDir: await pathExists(skillsRepoDir) ? skillsRepoDir : undefined, + codebaseDir: effectiveCodebaseDir, votesDir: votesExist ? votesDir : undefined, + indexPath, }); if (learningsCount > 0) { log.success(`Synced ${learningsCount} learnings (index: ${elapsed}ms)`); @@ -566,6 +595,18 @@ async function pullForScope( } } + // Step 3.5b: Sync domains.yaml from team repo to local .teamai/ + if (!options.dryRun) { + try { + const teamDomainsPath = path.join(localConfig.repo.localPath, '.teamai', 'domains.yaml'); + if (await pathExists(teamDomainsPath)) { + const localDomainsDir = path.join(process.cwd(), '.teamai'); + await fse.ensureDir(localDomainsDir); + await fse.copy(teamDomainsPath, path.join(localDomainsDir, 'domains.yaml'), { overwrite: true }); + } + } catch { /* non-critical */ } + } + // Step 3.6: Inject team culture into CLAUDE.md if (!options.dryRun) { try { @@ -855,6 +896,21 @@ export function compileRecallRulesBlock(): string { 'of relevant team knowledge (skills, learnings, docs, rules) without', 'polluting this conversation with raw content.', '', + '**Important constraints on agent sequencing:**', + '1. Always invoke `teamai-recall` subagent **first and alone** — never', + ' launch it in parallel with Explore or other research agents.', + '2. After recall returns results, use Read to get full content of the', + ' returned files if you need more detail. Do NOT launch Explore agents', + ' to search for the same topics — recall results + Read is the complete', + ' workflow for accessing team knowledge.', + '3. Explore/research agents have their own scope and must NOT overlap', + ' with recall:', + ' - **recall subagent covers:** team learnings, codebase docs, skills,', + ' rules, and anything under `.teamai/`, `learnings/`, `docs/team-codebase/`.', + ' - **Explore agents cover:** navigating source code in the current', + ' working directory, and web search for external information.', + ' - Explore agents must never search paths covered by recall.', + '', '**After** completing the task, in your final reply you **MUST**', 'declare which knowledge entries were actually referenced, using an', 'HTML comment of the form:', diff --git a/src/recall.ts b/src/recall.ts index 69b5ab9..66e67e3 100644 --- a/src/recall.ts +++ b/src/recall.ts @@ -186,12 +186,17 @@ async function loadOrBuildScopeIndex( const docsDir = path.join(localConfig.repo.localPath, 'docs'); const rulesDir = path.join(localConfig.repo.localPath, 'rules'); const skillsDir = path.join(localConfig.repo.localPath, 'skills'); + const cwdCodebaseDir = path.join(process.cwd(), 'docs', 'team-codebase'); + const repoCodebaseDir = path.join(localConfig.repo.localPath, 'docs', 'team-codebase'); + const codebaseDir = await pathExists(cwdCodebaseDir) ? cwdCodebaseDir + : await pathExists(repoCodebaseDir) ? repoCodebaseDir : undefined; try { await buildIndex({ learningsDir: effectiveLearningsDir ?? undefined, docsDir: await pathExists(docsDir) ? docsDir : undefined, rulesDir: await pathExists(rulesDir) ? rulesDir : undefined, skillsDir: await pathExists(skillsDir) ? skillsDir : undefined, + codebaseDir, votesDir: votesExist ? votesDir : undefined, indexPath, }); diff --git a/src/types.ts b/src/types.ts index 7b604e7..b496515 100644 --- a/src/types.ts +++ b/src/types.ts @@ -402,6 +402,12 @@ export interface ContributeState { * Prevents repeated hints when Layer 2 cache is hit on subsequent Stop hooks. */ hinted?: boolean; + /** Phase 2: ISO timestamp of session start (for git commit detection in cache-hit path) */ + sessionStartIso?: string; + /** Phase 2: whether git commit was detected during this session */ + hasGitCommit?: boolean; + /** Phase 2: whether knowledge gap was detected (all recalls missed) */ + isKnowledgeGap?: boolean; } /** Layer 1 (fast-path) threshold: if toolCount < this, skip reading events.jsonl */ @@ -413,6 +419,18 @@ export const CONTRIBUTE_SMART_THRESHOLD = 35; /** Cache smart score for this many ms (6 hours) */ export const CONTRIBUTE_SCORE_CACHE_MS = 6 * 60 * 60 * 1000; +/** Phase 2: bonus when all recalls return zero results (knowledge gap) */ +export const CONTRIBUTE_KNOWLEDGE_GAP_BONUS = 20; + +/** Phase 2: bonus when recalls return results but top score is very low */ +export const CONTRIBUTE_LOW_QUALITY_BONUS = 10; + +/** Phase 2: threshold below which recall results are considered low quality */ +export const CONTRIBUTE_LOW_QUALITY_THRESHOLD = 5.0; + +/** Phase 2: score deduction when session has git commits and recall had hits */ +export const CONTRIBUTE_GIT_COMMIT_DOWNWEIGHT = 15; + /** Directory for per-session contribute state files */ export const CONTRIBUTE_SESSIONS_DIR = `${TEAMAI_HOME}/sessions`; diff --git a/src/utils/ai-client.ts b/src/utils/ai-client.ts index acd9edc..1c95eb8 100644 --- a/src/utils/ai-client.ts +++ b/src/utils/ai-client.ts @@ -9,8 +9,8 @@ const ALLOWED_CLI_CANDIDATES = [ /** CLI 探测超时(毫秒),防止 execFileSync 挂死。 */ const CLI_DETECT_TIMEOUT_MS = 5_000; -/** 默认 AI 调用超时时间(毫秒)。 */ -const DEFAULT_TIMEOUT_MS = 120_000; +/** 默认 AI 调用超时时间(毫秒)。仓库初始化等大文档生成场景需要较长时间。 */ +const DEFAULT_TIMEOUT_MS = 720_000; /** 默认并发数量上限。 */ const DEFAULT_CONCURRENCY = 3; diff --git a/src/utils/search-index.ts b/src/utils/search-index.ts index 46e35f7..17a0f15 100644 --- a/src/utils/search-index.ts +++ b/src/utils/search-index.ts @@ -487,6 +487,7 @@ export interface BuildIndexOptions { docsDir?: string; rulesDir?: string; skillsDir?: string; + codebaseDir?: string; votesDir?: string; indexPath?: string; } @@ -531,6 +532,9 @@ export async function buildIndex( if (opts.skillsDir) { entries.push(...await collectSkillEntries(opts.skillsDir, voteCounts)); } + if (opts.codebaseDir) { + entries.push(...await collectRecursiveMdEntries(opts.codebaseDir, 'docs', voteCounts)); + } // Build document-frequency map for IDF weighting. // Count how many *entries* contain each token (not raw term frequency). @@ -542,6 +546,15 @@ export async function buildIndex( } const elapsed = Date.now() - start; + + // Guard: don't overwrite a healthy index with a significantly smaller one + const targetPath = opts.indexPath ?? getSearchIndexPath(); + const existingIndex = await loadIndex(targetPath); + if (existingIndex && existingIndex.entries.length > 5 && entries.length < existingIndex.entries.length * 0.5) { + log.warn(`Index rebuild skipped: new index (${entries.length} entries) much smaller than existing (${existingIndex.entries.length})`); + return elapsed; + } + const index: SearchIndex = { version: SEARCH_INDEX_VERSION, builtAt: new Date().toISOString(), @@ -550,7 +563,7 @@ export async function buildIndex( df, }; - await writeJson(opts.indexPath ?? getSearchIndexPath(), index); + await writeJson(targetPath, index); if (elapsed > 2000) { log.warn(`Search index build took ${elapsed}ms — consider incremental updates for large knowledge bases`); @@ -656,7 +669,9 @@ export function search( } // Require at least one title or tag match to filter out body-only noise. - if (score > 0 && hasTitleOrTagMatch) { + // Codebase docs (from team-codebase/) lack tags, so allow body-only matches for them. + const isCodebaseDoc = entry.type === 'docs' && (entry.path ?? entry.filename ?? '').includes('team-codebase'); + if (score > 0 && (hasTitleOrTagMatch || isCodebaseDoc)) { // Vote bonus: +0.5 per vote, max 5 points (unchanged). score += Math.min(entry.votes * 0.5, 5);