From 04eaf7b40a02b21ecc82483381d6490721a1e874 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Mon, 21 Sep 2026 20:52:22 +0200 Subject: [PATCH 01/37] feat(skill): serve packaged skill content from the CLI Add `teamai skill get [--full] [--all]` and `teamai skill path [name]`, so an agent can read built-in skill content that always matches the installed CLI version instead of a copy deployed into its skills directory. `get` prints SKILL.md byte for byte, frontmatter included, with {SKILL_DIR} resolved to the absolute packaged directory so documented script invocations run as-is. `--full` appends references/ and templates/, walked recursively and sorted by relative path, because our references nest one level deeper than the flat layout agent-browser assumes. Content goes to stdout and every diagnostic to stderr, so the output stays byte-exact when piped. An unknown flag warns and continues; an unknown name is fatal, since acting on the wrong skill is worse than a retry. `skill list` gains the served catalog and `--json`; `skill show` resolves packaged skills before the installed-agent fallback, which is what keeps it working once the deployed unit becomes a stub. Legacy directory names resolve as aliases. Refs #678 --- src/__tests__/skill-content.test.ts | 271 +++++++++++++++++++++++ src/index.ts | 32 ++- src/skill-cmd.ts | 64 +++++- src/skill-content.ts | 328 ++++++++++++++++++++++++++++ 4 files changed, 682 insertions(+), 13 deletions(-) create mode 100644 src/__tests__/skill-content.test.ts create mode 100644 src/skill-content.ts diff --git a/src/__tests__/skill-content.test.ts b/src/__tests__/skill-content.test.ts new file mode 100644 index 00000000..f88a7ffb --- /dev/null +++ b/src/__tests__/skill-content.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + SKILL_DIR_PLACEHOLDER, + listServableSkills, + packagedSkillRoots, + renderSkill, + resolvePackagedSkill, + skillCatalog, + skillGet, + skillPath, +} from '../skill-content.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); + +/** Build a throwaway package layout: /skills and /skill-data. */ +function makeRoots(): { tmp: string; deployRoot: string; dataRoot: string } { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-skill-content-')); + return { + tmp, + deployRoot: path.join(tmp, 'skills'), + dataRoot: path.join(tmp, 'skill-data'), + }; +} + +function writeSkill(root: string, name: string, body: string, files: Record = {}): string { + const dir = path.join(root, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'SKILL.md'), body); + for (const [relative, content] of Object.entries(files)) { + const target = path.join(dir, relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); + } + return dir; +} + +describe('packaged skill discovery', () => { + let roots: ReturnType; + + beforeEach(() => { + roots = makeRoots(); + }); + + afterEach(() => { + fs.rmSync(roots.tmp, { recursive: true, force: true }); + }); + + it('serves skill-data/ when it exists', async () => { + writeSkill(roots.deployRoot, 'teamai', '# stub\n'); + writeSkill(roots.dataRoot, 'core', '# core\n'); + writeSkill(roots.dataRoot, 'wiki', '# wiki\n'); + + const servable = await listServableSkills(roots); + expect(servable.map((s) => s.name)).toEqual(['core', 'wiki']); + expect(servable.every((s) => s.deployed)).toBe(false); + }); + + it('falls back to skills/ before the content moves', async () => { + writeSkill(roots.deployRoot, 'teamai', '# hub\n'); + + const servable = await listServableSkills(roots); + expect(servable.map((s) => s.name)).toEqual(['teamai']); + expect(servable[0].deployed).toBe(true); + }); + + it('keeps the deployed stub reachable by its exact name', async () => { + writeSkill(roots.deployRoot, 'teamai', '# stub\n'); + writeSkill(roots.dataRoot, 'core', '# core\n'); + + const stub = await resolvePackagedSkill('teamai', roots); + expect(stub?.dir).toBe(path.join(roots.deployRoot, 'teamai')); + expect(stub?.deployed).toBe(true); + }); + + it('resolves legacy directory names as aliases', async () => { + writeSkill(roots.dataRoot, 'team-wiki-codebase', '# wiki\n'); + writeSkill(roots.dataRoot, 'teamai-share-learnings', '# share\n'); + + for (const alias of ['wiki', 'codebase']) { + expect((await resolvePackagedSkill(alias, roots))?.name).toBe('team-wiki-codebase'); + } + for (const alias of ['share', 'learning', 'learnings']) { + expect((await resolvePackagedSkill(alias, roots))?.name).toBe('teamai-share-learnings'); + } + expect(await resolvePackagedSkill('nope', roots)).toBeNull(); + }); + + it('ignores directories without SKILL.md and dotfiles', async () => { + fs.mkdirSync(path.join(roots.dataRoot, 'empty'), { recursive: true }); + fs.mkdirSync(path.join(roots.dataRoot, '.hidden'), { recursive: true }); + fs.writeFileSync(path.join(roots.dataRoot, '.hidden', 'SKILL.md'), '# no\n'); + writeSkill(roots.dataRoot, 'core', '# core\n'); + + expect((await listServableSkills(roots)).map((s) => s.name)).toEqual(['core']); + }); +}); + +describe('renderSkill', () => { + let roots: ReturnType; + + beforeEach(() => { + roots = makeRoots(); + }); + + afterEach(() => { + fs.rmSync(roots.tmp, { recursive: true, force: true }); + }); + + it('prints SKILL.md unchanged, frontmatter included', async () => { + const body = '---\nname: core\ndescription: d\n---\n\n# core\n\nbody text\n'; + writeSkill(roots.dataRoot, 'core', body); + + const skill = await resolvePackagedSkill('core', roots); + expect(await renderSkill(skill!)).toBe(body); + }); + + it('appends references/ then templates/, recursively, sorted by relative path', async () => { + writeSkill(roots.dataRoot, 'wiki', '# wiki\n', { + 'references/methodology/phase1.md': 'phase one\n', + 'references/methodology/phase0.md': 'phase zero\n', + 'references/agents/kb.md': 'kb agent\n', + 'templates/report.md': 'report\n', + }); + + const skill = await resolvePackagedSkill('wiki', roots); + const out = await renderSkill(skill!, { full: true }); + + expect(out).toBe( + '# wiki\n' + + '\n--- references/agents/kb.md ---\n\nkb agent\n' + + '\n--- references/methodology/phase0.md ---\n\nphase zero\n' + + '\n--- references/methodology/phase1.md ---\n\nphase one\n' + + '\n--- templates/report.md ---\n\nreport\n', + ); + }); + + it('resolves {SKILL_DIR} to the packaged directory, in the body and in references', async () => { + writeSkill(roots.dataRoot, 'wiki', `run python3 ${SKILL_DIR_PLACEHOLDER}/scripts/scan_repo.py\n`, { + 'references/howto.md': `see ${SKILL_DIR_PLACEHOLDER}/scripts/\n`, + }); + + const skill = await resolvePackagedSkill('wiki', roots); + const out = await renderSkill(skill!, { full: true }); + + expect(out).not.toContain(SKILL_DIR_PLACEHOLDER); + expect(out).toContain(`python3 ${skill!.dir}/scripts/scan_repo.py`); + expect(out).toContain(`see ${skill!.dir}/scripts/`); + }); + + it('adds a trailing newline to files that lack one', async () => { + writeSkill(roots.dataRoot, 'core', '# core'); + const skill = await resolvePackagedSkill('core', roots); + expect(await renderSkill(skill!)).toBe('# core\n'); + }); +}); + +describe('skillCatalog', () => { + it('reports name, description and path for each served skill', async () => { + const roots = makeRoots(); + try { + writeSkill(roots.dataRoot, 'core', '---\nname: core\ndescription: Daily sync\n---\n\n# core\n'); + const catalog = await skillCatalog(roots); + expect(catalog).toEqual([ + { name: 'core', description: 'Daily sync', path: path.join(roots.dataRoot, 'core'), deployed: false }, + ]); + } finally { + fs.rmSync(roots.tmp, { recursive: true, force: true }); + } + }); +}); + +describe('teamai skill get / path against the shipped package', () => { + let stdout: string; + let stderr: string; + const restore: Array<() => void> = []; + + beforeEach(() => { + stdout = ''; + stderr = ''; + process.exitCode = undefined; + + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + stdout += args.join(' ') + '\n'; + }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + stderr += args.join(' ') + '\n'; + }); + restore.push(() => writeSpy.mockRestore(), () => logSpy.mockRestore(), () => errorSpy.mockRestore()); + }); + + afterEach(() => { + while (restore.length > 0) restore.pop()?.(); + process.exitCode = undefined; + }); + + it('resolves its roots inside the package', () => { + const roots = packagedSkillRoots(); + expect(roots.deployRoot).toBe(path.join(ROOT, 'skills')); + expect(roots.dataRoot).toBe(path.join(ROOT, 'skill-data')); + }); + + it('prints a shipped skill byte for byte', async () => { + const [first] = await listServableSkills(); + await skillGet([first.name]); + + expect(process.exitCode).toBeUndefined(); + expect(stderr).toBe(''); + expect(stdout).toBe(fs.readFileSync(path.join(first.dir, 'SKILL.md'), 'utf8')); + }); + + it('fails on an unknown name without writing to stdout', async () => { + await skillGet(['no-such-skill']); + + expect(process.exitCode).toBe(1); + expect(stdout).toBe(''); + expect(stderr).toContain('Skill not found: no-such-skill'); + expect(stderr).toContain('Available:'); + }); + + it('warns about an unknown flag and still serves the skill', async () => { + const [first] = await listServableSkills(); + await skillGet(['--bogus', first.name]); + + expect(process.exitCode).toBeUndefined(); + expect(stderr).toContain('Unknown flag ignored: --bogus'); + expect(stdout).toBe(fs.readFileSync(path.join(first.dir, 'SKILL.md'), 'utf8')); + }); + + it('fails when no name is left after dropping flags', async () => { + await skillGet(['--full']); + + expect(process.exitCode).toBe(1); + expect(stdout).toBe(''); + expect(stderr).toContain('No skill name provided'); + }); + + it('separates multiple skills and serves them all with --all', async () => { + const servable = await listServableSkills(); + await skillGet([], { all: true }); + + const expected = (await Promise.all(servable.map((skill) => renderSkill(skill)))).join('\n---\n\n'); + expect(process.exitCode).toBeUndefined(); + expect(stdout).toBe(expected); + }); + + it('prints the packaged directory, and the roots when no name is given', async () => { + const [first] = await listServableSkills(); + await skillPath(first.name); + expect(stdout.trim()).toBe(first.dir); + expect(fs.existsSync(path.join(stdout.trim(), 'SKILL.md'))).toBe(true); + + stdout = ''; + await skillPath(); + expect(stdout.trim().split('\n')).toContain(path.join(ROOT, 'skills')); + }); + + it('fails on an unknown name for path too', async () => { + await skillPath('no-such-skill'); + expect(process.exitCode).toBe(1); + expect(stdout).toBe(''); + expect(stderr).toContain('Skill not found: no-such-skill'); + }); +}); diff --git a/src/index.ts b/src/index.ts index 87ef267d..9e42e56b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -156,17 +156,39 @@ const skillCmd = program .description('List and inspect skills (default: list all skills across repo + installed agents)') .action(async () => { const globalOpts = program.opts() as GlobalOptions; - const { list } = await import('./status.js'); - await list('skills', { ...globalOpts, source: 'all' }); + const { skillList } = await import('./skill-cmd.js'); + await skillList(globalOpts); }); skillCmd .command('list') .description('List all skills (alias for: teamai list skills --source all)') - .action(async () => { + .option('--json', 'Output the CLI-served built-in skill catalog as JSON') + .action(async (cmdOpts) => { const globalOpts = program.opts() as GlobalOptions; - const { list } = await import('./status.js'); - await list('skills', { ...globalOpts, source: 'all' }); + const { skillList } = await import('./skill-cmd.js'); + await skillList({ ...globalOpts, ...cmdOpts }); + }); + +skillCmd + .command('get ') + .description('Print built-in skill content served by the installed CLI') + .option('--full', 'Append the skill\'s references/ and templates/ files') + .option('--all', 'Print every skill the CLI serves') + // A hallucinated flag should cost a warning, not a failed command: unknown + // options fall through to the action, which reports and ignores them. + .allowUnknownOption() + .action(async (names: string[], cmdOpts) => { + const { skillGet } = await import('./skill-content.js'); + await skillGet(names, { full: cmdOpts.full, all: cmdOpts.all }); + }); + +skillCmd + .command('path [name]') + .description('Print the packaged directory of a built-in skill (for scripts and templates)') + .action(async (name: string | undefined) => { + const { skillPath } = await import('./skill-content.js'); + await skillPath(name); }); skillCmd diff --git a/src/skill-cmd.ts b/src/skill-cmd.ts index 485aa2f5..a8b11363 100644 --- a/src/skill-cmd.ts +++ b/src/skill-cmd.ts @@ -13,6 +13,7 @@ import { type SkillSource, } from './agent-skills.js'; import { detectInstalledAgents, type ResolvedAgent } from './known-agents.js'; +import { resolvePackagedSkill, skillCatalog } from './skill-content.js'; import type { GlobalOptions, LocalConfig } from './types.js'; const DESCRIPTION_MAX = 160; @@ -22,7 +23,7 @@ interface ResolvedSkill { /** Path used to read SKILL.md, contributors and description. */ primaryPath: string; /** Where the primary copy was discovered. */ - primaryOrigin: 'team' | 'agent'; + primaryOrigin: 'team' | 'agent' | 'builtin'; /** Optional namespace if found in the team repo. */ namespace?: string; } @@ -48,19 +49,20 @@ export async function skillShow(name: string, options: GlobalOptions): Promise { + const catalog = await skillCatalog(); + + if (options.json) { + console.log(JSON.stringify({ skills: catalog }, null, 2)); + return; + } + + const { list } = await import('./status.js'); + await list('skills', { ...options, source: 'all' }); + + console.log('=== BUILT-IN SKILLS (served by the CLI) ==='); + console.log(''); + if (catalog.length === 0) { + console.log(' (none — the installed package ships no skill content)'); + } else { + for (const entry of catalog) { + console.log(` ${entry.name}`); + console.log(` ${truncate(entry.description, DESCRIPTION_MAX) || '(no description)'}`); + console.log(` teamai skill get ${entry.name}`); + } + } + console.log(''); +} + async function locateSkill( name: string, localConfig: LocalConfig, @@ -101,7 +132,15 @@ async function locateSkill( } } - // 3. First installed agent that has the skill + // 3. Built-in skill served by the CLI (including legacy-name aliases). + // Resolved before the agent fallback: under the discovery-stub model the + // agent directory holds a stub, not the content this command describes. + const packaged = await resolvePackagedSkill(name); + if (packaged) { + return { name: packaged.name, primaryPath: packaged.dir, primaryOrigin: 'builtin' }; + } + + // 4. First installed agent that has the skill for (const agent of agents) { if (!agent.installed) continue; const candidate = path.join(agent.absoluteSkillsPath, name); @@ -128,6 +167,12 @@ async function collectInstalledAgents( return matches; } +const PRIMARY_PATH_LABEL: Record = { + team: 'Repo path ', + agent: 'Source path', + builtin: 'Package path', +}; + interface SkillCard { name: string; source: SkillSource; @@ -136,7 +181,7 @@ interface SkillCard { contributors: string[]; tags: string[]; primaryPath: string; - primaryOrigin: 'team' | 'agent'; + primaryOrigin: ResolvedSkill['primaryOrigin']; installedIn: Array<{ agent: ResolvedAgent; path: string }>; } @@ -155,7 +200,10 @@ function printSkillCard(card: SkillCard): void { console.log(` Description : ${card.description || '(none)'}`); console.log(` Contributors : ${card.contributors.length > 0 ? card.contributors.join(', ') : '(none)'}`); console.log(` Tags : ${card.tags.length > 0 ? card.tags.join(', ') : '(none)'}`); - console.log(` ${card.primaryOrigin === 'team' ? 'Repo path ' : 'Source path'} : ${card.primaryPath}/`); + console.log(` ${PRIMARY_PATH_LABEL[card.primaryOrigin]} : ${card.primaryPath}/`); + if (card.primaryOrigin === 'builtin') { + console.log(` Read it with : teamai skill get ${card.name}`); + } if (card.installedIn.length === 0) { console.log(' Installed in : (not installed in any agent yet)'); diff --git a/src/skill-content.ts b/src/skill-content.ts new file mode 100644 index 00000000..7009deb3 --- /dev/null +++ b/src/skill-content.ts @@ -0,0 +1,328 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import chalk from 'chalk'; +import { pathExists } from './utils/fs.js'; +import { readSkillDescription } from './agent-skills.js'; + +// ─── CLI-served skill content ──────────────────────────── +// +// Built-in skill bodies ship inside the npm package and are +// printed on demand instead of being copied into every agent +// skills directory. What the agent reads therefore always +// matches the installed CLI version. +// +// npm package +// skills//SKILL.md deployed to agents (discovery stub) +// skill-data//SKILL.md never deployed, printed by `teamai skill get` +// +// Output discipline, mirrored from agent-browser: skill content +// goes to stdout untouched, every diagnostic goes to stderr, so a +// piped `teamai skill get > SKILL.md` stays byte-exact. +// log.warn()/log.info() write to stdout outside hook mode, so this +// module writes its diagnostics with console.error directly. +// + +/** Placeholder replaced with the absolute skill directory when content is printed. */ +export const SKILL_DIR_PLACEHOLDER = '{SKILL_DIR}'; + +/** Directories inside a skill whose files `--full` appends, in this order. */ +const SUPPLEMENTARY_DIRS = ['references', 'templates'] as const; + +const SKILL_MD = 'SKILL.md'; + +/** + * Alternative names accepted by `skill get` / `skill path`. + * + * Legacy directory names are kept as aliases so that documentation, + * muscle memory and older team guides keep resolving after the content + * moves under skill-data/. + */ +const SKILL_ALIASES: Readonly> = { + core: 'teamai', + default: 'teamai', + wiki: 'team-wiki-codebase', + codebase: 'team-wiki-codebase', + share: 'teamai-share-learnings', + learning: 'teamai-share-learnings', + learnings: 'teamai-share-learnings', +}; + +/** A skill directory that ships inside the npm package. */ +export interface PackagedSkill { + name: string; + /** Absolute path of the skill directory. */ + dir: string; + /** True when this copy is the unit deployed into agent skills directories. */ + deployed: boolean; +} + +/** The two packaged roots: deployable units and CLI-served content. */ +export interface PackagedSkillRoots { + /** `skills/` — what `deployBuiltinSkills` copies into agents. */ + deployRoot: string; + /** `skill-data/` — never deployed, printed on demand. */ + dataRoot: string; +} + +/** + * Locate the packaged roots relative to this module. + * + * `realpathSync` first: a global `npm i -g` install exposes the CLI through a + * symlinked bin, and without resolving it `..` can land outside the package. + */ +export function packagedSkillRoots(): PackagedSkillRoots { + const modulePath = fileURLToPath(import.meta.url); + let moduleDir: string; + try { + moduleDir = path.dirname(fs.realpathSync(modulePath)); + } catch { + moduleDir = path.dirname(modulePath); + } + const packageRoot = path.join(moduleDir, '..'); + return { + deployRoot: path.join(packageRoot, 'skills'), + dataRoot: path.join(packageRoot, 'skill-data'), + }; +} + +async function readSkillDirs(root: string, deployed: boolean): Promise { + let entries: string[]; + try { + entries = await fs.promises.readdir(root); + } catch { + return []; + } + + const skills: PackagedSkill[] = []; + for (const entry of entries.sort()) { + if (entry.startsWith('.')) continue; + const dir = path.join(root, entry); + if (await pathExists(path.join(dir, SKILL_MD))) { + skills.push({ name: entry, dir, deployed }); + } + } + return skills; +} + +/** + * Skills the CLI serves: everything under skill-data/, or — before the content + * moves there — everything under skills/. + */ +export async function listServableSkills(roots: PackagedSkillRoots = packagedSkillRoots()): Promise { + const served = await readSkillDirs(roots.dataRoot, false); + if (served.length > 0) return served; + return readSkillDirs(roots.deployRoot, true); +} + +/** + * Resolve a name or alias to a packaged skill. Servable content wins over the + * deployed stub, which stays reachable by its exact name for debugging. + */ +export async function resolvePackagedSkill( + name: string, + roots: PackagedSkillRoots = packagedSkillRoots(), +): Promise { + const servable = await listServableSkills(roots); + const deployed = await readSkillDirs(roots.deployRoot, true); + const candidates = [...servable, ...deployed.filter((s) => !servable.some((v) => v.name === s.name))]; + + const direct = candidates.find((s) => s.name === name); + if (direct) return direct; + + const aliased = SKILL_ALIASES[name]; + if (aliased) { + const match = candidates.find((s) => s.name === aliased); + if (match) return match; + } + return null; +} + +async function collectSupplementaryFiles(skillDir: string): Promise> { + const files: Array<{ relativePath: string; content: string }> = []; + + for (const dirName of SUPPLEMENTARY_DIRS) { + const root = path.join(skillDir, dirName); + if (!(await pathExists(root))) continue; + + // Recursive: our references/ nest (references/methodology/, references/agents/), + // so a single-level scan would silently serve an incomplete skill. + const walk = async (dir: string): Promise => { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + const found: string[] = []; + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + found.push(...(await walk(full))); + } else if (entry.isFile()) { + found.push(full); + } + } + return found; + }; + + const absolutePaths = await walk(root); + const relativePaths = absolutePaths + .map((p) => path.relative(skillDir, p).split(path.sep).join('/')) + .sort(); + + for (const relativePath of relativePaths) { + files.push({ + relativePath, + content: await fs.promises.readFile(path.join(skillDir, relativePath), 'utf8'), + }); + } + } + + return files; +} + +function withTrailingNewline(text: string): string { + return text.endsWith('\n') ? text : `${text}\n`; +} + +/** + * Render a packaged skill exactly as the agent should read it: the raw + * SKILL.md including frontmatter, with {SKILL_DIR} resolved to the absolute + * packaged directory so that documented script invocations can be run as-is. + */ +export async function renderSkill(skill: PackagedSkill, options: { full?: boolean } = {}): Promise { + const resolve = (text: string): string => text.split(SKILL_DIR_PLACEHOLDER).join(skill.dir); + + let out = withTrailingNewline(resolve(await fs.promises.readFile(path.join(skill.dir, SKILL_MD), 'utf8'))); + + if (options.full) { + for (const file of await collectSupplementaryFiles(skill.dir)) { + out += `\n--- ${file.relativePath} ---\n\n`; + out += withTrailingNewline(resolve(file.content)); + } + } + + return out; +} + +/** Diagnostics never share stdout with skill content. */ +function diagnostic(line: string): void { + console.error(line); +} + +function notFound(name: string, available: PackagedSkill[]): void { + diagnostic(`${chalk.red('✖')} Skill not found: ${name}`); + diagnostic(` Available: ${available.map((s) => s.name).join(', ')}`); + diagnostic(' Run `teamai skill list` to see what the installed CLI serves.'); + process.exitCode = 1; +} + +function rootsMissing(): void { + diagnostic(`${chalk.red('✖')} Packaged skill content not found.`); + diagnostic(' The installed teamai-cli package looks incomplete; reinstall with `npm i -g teamai-cli`.'); + process.exitCode = 1; +} + +export interface SkillGetOptions { + full?: boolean; + all?: boolean; +} + +/** + * `teamai skill get [--full] [--all]` — print version-matched skill + * content to stdout. + */ +export async function skillGet(names: string[], options: SkillGetOptions = {}): Promise { + const roots = packagedSkillRoots(); + const servable = await listServableSkills(roots); + + if (servable.length === 0) { + rootsMissing(); + return; + } + + // An unknown flag is forgiven — a hallucinated flag should not cost the agent a + // round-trip — but an unknown name is fatal: the agent would act on the wrong + // instructions. Commander hands unknown options through as operands here. + const requested: string[] = []; + for (const name of names) { + if (name.startsWith('-')) { + diagnostic(`${chalk.yellow('⚠')} Unknown flag ignored: ${name}`); + continue; + } + requested.push(name); + } + + const targets: PackagedSkill[] = []; + if (options.all) { + targets.push(...servable); + } else { + for (const name of requested) { + const skill = await resolvePackagedSkill(name, roots); + if (!skill) { + notFound(name, servable); + return; + } + targets.push(skill); + } + } + + if (targets.length === 0) { + diagnostic(`${chalk.red('✖')} No skill name provided. Usage: teamai skill get [--full]`); + diagnostic(` Available: ${servable.map((s) => s.name).join(', ')}`); + process.exitCode = 1; + return; + } + + const rendered: string[] = []; + for (const skill of targets) { + rendered.push(await renderSkill(skill, { full: options.full })); + } + process.stdout.write(rendered.join('\n---\n\n')); +} + +/** + * `teamai skill path [name]` — print the packaged directory, for agents that + * read files directly or need to run the scripts a skill ships. + */ +export async function skillPath(name?: string): Promise { + const roots = packagedSkillRoots(); + + if (!name) { + let printed = false; + for (const root of [roots.deployRoot, roots.dataRoot]) { + if (await pathExists(root)) { + console.log(root); + printed = true; + } + } + if (!printed) rootsMissing(); + return; + } + + const skill = await resolvePackagedSkill(name, roots); + if (!skill) { + notFound(name, await listServableSkills(roots)); + return; + } + console.log(skill.dir); +} + +/** One catalog entry, as `teamai skill list --json` reports it. */ +export interface SkillCatalogEntry { + name: string; + description: string; + path: string; + deployed: boolean; +} + +export async function skillCatalog(roots: PackagedSkillRoots = packagedSkillRoots()): Promise { + const skills = await listServableSkills(roots); + const entries: SkillCatalogEntry[] = []; + for (const skill of skills) { + entries.push({ + name: skill.name, + description: await readSkillDescription(path.join(skill.dir, SKILL_MD)), + path: skill.dir, + deployed: skill.deployed, + }); + } + return entries; +} From c1ca0ac764c5a3d87aa630e8a52befd805013346 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Mon, 21 Sep 2026 21:01:39 +0200 Subject: [PATCH 02/37] refactor(skills): move content to skill-data and deploy a single stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents now receive one file: `skills/teamai/SKILL.md`, a discovery stub of about 2 KB whose description carries the triggers of every workflow and whose body holds the commands that load them. The workflow content moves to skill-data/{core,share,wiki}, which is never deployed and is printed by `teamai skill get`. Before this, `deployBuiltinSkills` copied three whole trees — 176 KB — into every installed agent on every pull, so the text an agent read could disagree with the CLI it documented until the member ran a pull, and a machine with ten agents held ten copies. skills/ keeps its meaning ("everything here is deployed"), which is what lets BUILTIN_SKILL_NAMES collapse to one name. The stub is copied verbatim: no ensureSkillFrontmatter on the way out, so a deployed copy that differs from the packaged one is a bug rather than a variant. Recall no longer gates deployment, since the stub routes to every workflow; the run-time gate for share lands with the pruning pass. Uninstall learns the legacy directory names, which it would otherwise leave behind on every machine that upgraded. "skill-data" is added to package.json files, with a test that asserts it through `npm pack`: without that entry every test still passes against the repo and `skill get` serves nothing once installed from the registry. Refs #678 --- package.json | 1 + skill-data/core/SKILL.md | 152 +++++++++++++++++ .../core}/references/join-member.md | 0 .../core}/references/manage-admin.md | 0 .../core}/references/provider-tgit.md | 0 .../core}/references/setup-admin.md | 0 .../core}/references/troubleshooting.md | 0 .../core}/references/uninstall.md | 0 .../share}/SKILL.md | 0 .../share}/references/contribute-member.md | 0 .../wiki}/README.md | 0 .../wiki}/SKILL.md | 0 .../references/agents/graph-rag-agent.md | 0 .../references/agents/kb-doc-generator.md | 0 .../methodology/phase0-collection.md | 0 .../methodology/phase1-reverse-engineering.md | 0 .../methodology/phase2-document-types.md | 0 .../methodology/phase3-ai-enhancement.md | 0 .../references/methodology/phase4-quality.md | 0 .../references/templates/project-overview.md | 0 .../wiki}/scripts/scan_repo.py | 0 .../wiki}/scripts/validate_kb.py | 0 skills/teamai/SKILL.md | 155 +++--------------- .../e2e/codebase-extract-cli.test.ts | 6 +- .../e2e/enabled-agents-whitelist.test.ts | 4 +- src/__tests__/pull-skip-sync.test.ts | 4 +- src/__tests__/push-namespace-e2e.test.ts | 9 +- src/__tests__/recall-toggle.test.ts | 9 +- src/__tests__/skill-content.test.ts | 36 +++- src/__tests__/skip-uninstalled-tools.test.ts | 52 +++--- .../team-wiki-codebase-skill.test.ts | 4 +- src/__tests__/uninstall.test.ts | 9 +- src/builtin-skills.ts | 81 ++++----- src/skill-content.ts | 13 +- src/uninstall.ts | 5 +- 35 files changed, 316 insertions(+), 224 deletions(-) create mode 100644 skill-data/core/SKILL.md rename {skills/teamai => skill-data/core}/references/join-member.md (100%) rename {skills/teamai => skill-data/core}/references/manage-admin.md (100%) rename {skills/teamai => skill-data/core}/references/provider-tgit.md (100%) rename {skills/teamai => skill-data/core}/references/setup-admin.md (100%) rename {skills/teamai => skill-data/core}/references/troubleshooting.md (100%) rename {skills/teamai => skill-data/core}/references/uninstall.md (100%) rename {skills/teamai-share-learnings => skill-data/share}/SKILL.md (100%) rename {skills/teamai => skill-data/share}/references/contribute-member.md (100%) rename {skills/team-wiki-codebase => skill-data/wiki}/README.md (100%) rename {skills/team-wiki-codebase => skill-data/wiki}/SKILL.md (100%) rename {skills/team-wiki-codebase => skill-data/wiki}/references/agents/graph-rag-agent.md (100%) rename {skills/team-wiki-codebase => skill-data/wiki}/references/agents/kb-doc-generator.md (100%) rename {skills/team-wiki-codebase => skill-data/wiki}/references/methodology/phase0-collection.md (100%) rename {skills/team-wiki-codebase => skill-data/wiki}/references/methodology/phase1-reverse-engineering.md (100%) rename {skills/team-wiki-codebase => skill-data/wiki}/references/methodology/phase2-document-types.md (100%) rename {skills/team-wiki-codebase => skill-data/wiki}/references/methodology/phase3-ai-enhancement.md (100%) rename {skills/team-wiki-codebase => skill-data/wiki}/references/methodology/phase4-quality.md (100%) rename {skills/team-wiki-codebase => skill-data/wiki}/references/templates/project-overview.md (100%) rename {skills/team-wiki-codebase => skill-data/wiki}/scripts/scan_repo.py (100%) rename {skills/team-wiki-codebase => skill-data/wiki}/scripts/validate_kb.py (100%) diff --git a/package.json b/package.json index 739df072..73f4c37f 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "files": [ "dist/**/*.js", "skills", + "skill-data", "agents", "README.md", "CHANGELOG.md", diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md new file mode 100644 index 00000000..5efb645d --- /dev/null +++ b/skill-data/core/SKILL.md @@ -0,0 +1,152 @@ +--- +name: teamai +description: >- + Guide for TeamAI — the CLI that syncs a team's AI skills, rules, docs, and env + across AI coding tools (set up, join, manage, contribute, uninstall). Invoke + ONLY when the user explicitly runs `/teamai`. Do NOT auto-trigger from ordinary + conversation, even if words like "team", "skill", or "sync" appear. +--- + +# TeamAI — Team AI Skills & Rules Sync + +You are guiding a user through TeamAI. **They may not know Git.** You run the +commands; they only make choices when you ask. Follow the steps literally — +do not skip, reorder, or invent commands. + +## STEP 0 — Progressive disclosure (do this first, every time) + +Look at what the user typed after `/teamai`. + +**If they gave NO scenario** (bare `/teamai`, or only greetings/no task): +print the menu below **exactly**, then **STOP and wait**. Take no other action — +do not run any command, do not read any reference file yet. + +``` +teamai — Team AI Skills & Rules Sync + +Usage examples (copy one to get started): + + 🏗️ Admin — set up a new team repo: + /teamai Help me set up TeamAI for my team from scratch + + 🤝 Member — join an existing team: + /teamai Help me join my team's TeamAI, repo URL is https://... + + 🔧 Admin — daily management (publish & update skills, rules, MCP, env): + /teamai I already have TeamAI set up, help me manage it + + 📊 Anyone — open the team dashboard: + /teamai Open the TeamAI dashboard + + 💡 Member — share a skill with the team (just ask in plain language): + /teamai Share this skill with my team + + 🗑️ Anyone — remove TeamAI from this machine: + /teamai Uninstall TeamAI +``` + +> **Sharing a session's learnings is automatic — not a menu choice.** TeamAI +> prompts on its own at the end of a session that produced something worth sharing, +> and the **`teamai-share-learnings`** skill takes over. The user does not invoke +> `/teamai` for it. (Only appears when the admin left team sharing enabled — on by +> default.) + +**If they DID describe a scenario**, match it to one row of the table below, +then open that reference file and follow it step by step. + +| The user wants to… | Load this reference | +|-----------------------------------------------------|------------------------------------------| +| Set up TeamAI for a team from scratch (create repo) | `references/setup-admin.md` | +| Join their team (with or without a repo URL) | `references/join-member.md` | +| Manage a team: publish/update skills, rules, MCP, env, invite members | `references/manage-admin.md` | +| Share / publish a skill with the team ("share this xxx skill") — any member, not just admins | `references/contribute-member.md` | +| Open the team dashboard (web UI) | run `teamai dashboard` (see cheat sheet) | +| Remove / uninstall TeamAI from this machine | `references/uninstall.md` | + +> **Sharing session learnings is automatic, via a separate skill — do not route it +> here.** TeamAI prompts on its own at the end of a session worth sharing, and the +> **`teamai-share-learnings`** skill summarizes the session and runs +> `teamai contribute`. The user does not ask for it through `/teamai`. (Only when +> the admin left team sharing on — the default.) `contribute-member.md` here is for +> a member **publishing a reusable skill** on request ("share this xxx skill with +> my team"). + +Choosing between "set up" and "join": a user **setting up a new team** becomes its +admin and creates the repo; a user **joining an existing team** needs a repo URL +from their admin. If someone wants to join but has no URL, that is still the +**join** flow — `join-member.md` tells them to ask their admin for it. Do **not** +send a would-be member to the setup/create-repo flow just because they lack a URL. + +If the request is ambiguous (e.g. "help me with teamai" with no direction), +ask ONE short question to pick a row, then proceed. When something breaks at any +step, load `references/troubleshooting.md`. + +## Global rules (apply to every scenario) + +1. **Reply in the user's language — including every example and hand-off blurb.** + Answer in whatever language the user used to invoke the skill (Chinese in → + Chinese out, English in → English out, and so on), for the whole conversation. + This applies to **everything you write**, not just prose: the reference files + below are written in English, but any ready-made sentence they hand you — the + invite line you give an admin to forward to members, the one-line explanations, + the "what's next" summary — **must be translated into the user's language before + you show it.** Do not paste an English example at a Chinese-speaking user. + *Only* commands, flags, URLs, file paths, and code identifiers stay verbatim + (never translate `teamai pull`, `--scope user`, `/teamai`, a repo URL, etc.). + Example: for a Chinese user, the member-invite line becomes + `/teamai 帮我加入团队的 TeamAI,仓库地址是 https://...`, not the English form. +2. **Never teach Git.** Do not mention branches, commits, clone, or push/pull of + Git itself. TeamAI hides all of that. The user thinks in terms of "my team's + skills", not repositories. +3. **Always use a full URL** for the team repo (e.g. + `https://github.com/yourorg/yourrepo`). Never use the `owner/repo` short form. +4. **You run the commands.** Only pause to ask the user when you need a web login, + a value only they know, or a genuine either/or choice. Show each command before + you run it, in one short line. +5. **Detect the current AI tool first.** TeamAI behaves differently per host. Note + which tool this conversation is running in (Claude Code, Cursor, CodeBuddy, + WorkBuddy, ChatGPT App, Codex, OpenCode, Kiro, Gemini CLI, …). When you reopen a + session, use the name of **this** tool — do not assume Claude Code or Cursor. + Some hosts need extra manual steps for hooks — see + `references/troubleshooting.md` ("Agent-specific caveats"). +6. **Prerequisite:** Node.js ≥ 20. Install once with `npm install -g teamai-cli` + and verify with `teamai --version`. +7. **Finish with `teamai doctor`.** Every setup/onboarding flow ends by running + `teamai doctor` and resolving whatever it reports before you call it done. +8. **After init, resources appear on the NEXT session.** `teamai init` injects a + session-start hook that auto-runs `teamai pull`. It is normal that the skills/ + rules directories are empty right after init — they fill in when the user opens + a fresh session in this tool. To sync immediately, run `teamai pull`. +9. **Don't limit which AI tools get set up — cover all of them by default.** Unless + the user explicitly says "only install to Claude Code" (or names specific + tools), do **not** pass `--agent` to restrict the install. Let `teamai init` set + up **every AI tool already installed on the machine** (omitting `--agent` gives + an interactive picker; select all detected tools, or the user's stated subset). + **After init, report which agents were set up** — tell the user, in their + language, exactly which tools will now auto-start TeamAI (and which detected + tools were skipped and why, e.g. Codex trust-gate / CodeBuddy design). Verify + the real per-tool result with `teamai doctor` / `teamai hooks list`. + +## Command cheat sheet (ground truth — do not invent flags) + +```bash +teamai init # Set up / join a team (configure provider, clone, register) +teamai init --scope user # Install for the whole machine instead of just this project +teamai pull # Sync team resources into local AI tools now +teamai push # Publish your local skills/rules/docs to the team +teamai doctor # Diagnose configuration and hook problems +teamai status # Show local vs team differences +teamai list # List resources (skills|rules|docs|env|agents|hooks|mcp) +teamai members # See team members (subcommand: teamai members list) +teamai roles # Manage roles / resource namespaces +teamai projects # Manage multiple projects from one repo (list|set|members) +teamai packages # Install team-declared npm packages & Claude plugins +teamai env # Manage shared team environment variables +teamai dashboard # Open the AI coding session dashboard (web UI, default port 3721) +teamai contribute --file

--title # Contribute a knowledge doc (usually via the teamai-share-learnings skill) +``` + +Anything not in this cheat sheet: check `teamai --help` before using it. +Do **not** guess flags (for example, there is no member-invite flag in the CLI — +inviting a member is done on the Git platform's website; see +`references/manage-admin.md`). diff --git a/skills/teamai/references/join-member.md b/skill-data/core/references/join-member.md similarity index 100% rename from skills/teamai/references/join-member.md rename to skill-data/core/references/join-member.md diff --git a/skills/teamai/references/manage-admin.md b/skill-data/core/references/manage-admin.md similarity index 100% rename from skills/teamai/references/manage-admin.md rename to skill-data/core/references/manage-admin.md diff --git a/skills/teamai/references/provider-tgit.md b/skill-data/core/references/provider-tgit.md similarity index 100% rename from skills/teamai/references/provider-tgit.md rename to skill-data/core/references/provider-tgit.md diff --git a/skills/teamai/references/setup-admin.md b/skill-data/core/references/setup-admin.md similarity index 100% rename from skills/teamai/references/setup-admin.md rename to skill-data/core/references/setup-admin.md diff --git a/skills/teamai/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md similarity index 100% rename from skills/teamai/references/troubleshooting.md rename to skill-data/core/references/troubleshooting.md diff --git a/skills/teamai/references/uninstall.md b/skill-data/core/references/uninstall.md similarity index 100% rename from skills/teamai/references/uninstall.md rename to skill-data/core/references/uninstall.md diff --git a/skills/teamai-share-learnings/SKILL.md b/skill-data/share/SKILL.md similarity index 100% rename from skills/teamai-share-learnings/SKILL.md rename to skill-data/share/SKILL.md diff --git a/skills/teamai/references/contribute-member.md b/skill-data/share/references/contribute-member.md similarity index 100% rename from skills/teamai/references/contribute-member.md rename to skill-data/share/references/contribute-member.md diff --git a/skills/team-wiki-codebase/README.md b/skill-data/wiki/README.md similarity index 100% rename from skills/team-wiki-codebase/README.md rename to skill-data/wiki/README.md diff --git a/skills/team-wiki-codebase/SKILL.md b/skill-data/wiki/SKILL.md similarity index 100% rename from skills/team-wiki-codebase/SKILL.md rename to skill-data/wiki/SKILL.md diff --git a/skills/team-wiki-codebase/references/agents/graph-rag-agent.md b/skill-data/wiki/references/agents/graph-rag-agent.md similarity index 100% rename from skills/team-wiki-codebase/references/agents/graph-rag-agent.md rename to skill-data/wiki/references/agents/graph-rag-agent.md diff --git a/skills/team-wiki-codebase/references/agents/kb-doc-generator.md b/skill-data/wiki/references/agents/kb-doc-generator.md similarity index 100% rename from skills/team-wiki-codebase/references/agents/kb-doc-generator.md rename to skill-data/wiki/references/agents/kb-doc-generator.md diff --git a/skills/team-wiki-codebase/references/methodology/phase0-collection.md b/skill-data/wiki/references/methodology/phase0-collection.md similarity index 100% rename from skills/team-wiki-codebase/references/methodology/phase0-collection.md rename to skill-data/wiki/references/methodology/phase0-collection.md diff --git a/skills/team-wiki-codebase/references/methodology/phase1-reverse-engineering.md b/skill-data/wiki/references/methodology/phase1-reverse-engineering.md similarity index 100% rename from skills/team-wiki-codebase/references/methodology/phase1-reverse-engineering.md rename to skill-data/wiki/references/methodology/phase1-reverse-engineering.md diff --git a/skills/team-wiki-codebase/references/methodology/phase2-document-types.md b/skill-data/wiki/references/methodology/phase2-document-types.md similarity index 100% rename from skills/team-wiki-codebase/references/methodology/phase2-document-types.md rename to skill-data/wiki/references/methodology/phase2-document-types.md diff --git a/skills/team-wiki-codebase/references/methodology/phase3-ai-enhancement.md b/skill-data/wiki/references/methodology/phase3-ai-enhancement.md similarity index 100% rename from skills/team-wiki-codebase/references/methodology/phase3-ai-enhancement.md rename to skill-data/wiki/references/methodology/phase3-ai-enhancement.md diff --git a/skills/team-wiki-codebase/references/methodology/phase4-quality.md b/skill-data/wiki/references/methodology/phase4-quality.md similarity index 100% rename from skills/team-wiki-codebase/references/methodology/phase4-quality.md rename to skill-data/wiki/references/methodology/phase4-quality.md diff --git a/skills/team-wiki-codebase/references/templates/project-overview.md b/skill-data/wiki/references/templates/project-overview.md similarity index 100% rename from skills/team-wiki-codebase/references/templates/project-overview.md rename to skill-data/wiki/references/templates/project-overview.md diff --git a/skills/team-wiki-codebase/scripts/scan_repo.py b/skill-data/wiki/scripts/scan_repo.py similarity index 100% rename from skills/team-wiki-codebase/scripts/scan_repo.py rename to skill-data/wiki/scripts/scan_repo.py diff --git a/skills/team-wiki-codebase/scripts/validate_kb.py b/skill-data/wiki/scripts/validate_kb.py similarity index 100% rename from skills/team-wiki-codebase/scripts/validate_kb.py rename to skill-data/wiki/scripts/validate_kb.py diff --git a/skills/teamai/SKILL.md b/skills/teamai/SKILL.md index 7bd1ec87..747b5965 100644 --- a/skills/teamai/SKILL.md +++ b/skills/teamai/SKILL.md @@ -1,146 +1,43 @@ --- name: teamai description: >- - Guide for TeamAI — the CLI that syncs a team's AI skills, rules, docs, and env - across AI coding tools (set up, join, manage, contribute, uninstall). Invoke - ONLY when the user explicitly runs `/teamai`. Do NOT auto-trigger from ordinary - conversation, even if words like "team", "skill", or "sync" appear. + Make every team AI native — TeamAI syncs a team's AI skills, rules, docs and env across AI coding + tools. Use when the task operates on team-shared AI configuration or team knowledge: setting up a + team repo, joining one, managing members, syncing with pull or push, or checking team status. + Also use to build or query a codebase knowledge base for a large multi-repo project (架构分析, + 架构逆向, 代码知识库, code-to-knowledge, architecture wiki), and to share what a session taught you + back to the team (分享 Session 经验, contribute a learning, share this with my team), including + after a friction reminder. Triggers include "set up teamai", "join the team repo", "sync team + skills", "team wiki", "share what I learned", and running /teamai. Talking about a team needs no + skill; operating on what the team shares does. +allowed-tools: Bash(teamai:*), Bash(npx teamai-cli:*) --- -# TeamAI — Team AI Skills & Rules Sync +# teamai -You are guiding a user through TeamAI. **They may not know Git.** You run the -commands; they only make choices when you ask. Follow the steps literally — -do not skip, reorder, or invent commands. +Make every team AI native — one shared foundation for the skills, rules, docs and env a team works with. -## STEP 0 — Progressive disclosure (do this first, every time) +Install: `npm i -g teamai-cli` (Node.js >= 20) -Look at what the user typed after `/teamai`. +## Start here -**If they gave NO scenario** (bare `/teamai`, or only greetings/no task): -print the menu below **exactly**, then **STOP and wait**. Take no other action — -do not run any command, do not read any reference file yet. +This file is a discovery stub, not the usage guide. Load the workflow from the CLI before running anything, so the instructions match the installed version: +```bash +teamai skill get core # daily work: pull, push, status, doctor, command reference +teamai skill get core --full # adds troubleshooting ``` -teamai — Team AI Skills & Rules Sync - -Usage examples (copy one to get started): - - 🏗️ Admin — set up a new team repo: - /teamai Help me set up TeamAI for my team from scratch - - 🤝 Member — join an existing team: - /teamai Help me join my team's TeamAI, repo URL is https://... - - 🔧 Admin — daily management (publish & update skills, rules, MCP, env): - /teamai I already have TeamAI set up, help me manage it - - 📊 Anyone — open the team dashboard: - /teamai Open the TeamAI dashboard - - 💡 Member — share a skill with the team (just ask in plain language): - /teamai Share this skill with my team - - 🗑️ Anyone — remove TeamAI from this machine: - /teamai Uninstall TeamAI -``` - -**If they DID describe a scenario**, match it to one row of the table below, -then open that reference file and follow it step by step. - -| The user wants to… | Load this reference | -|-----------------------------------------------------|------------------------------------------| -| Set up TeamAI for a team from scratch (create repo) | `references/setup-admin.md` | -| Join their team (with or without a repo URL) | `references/join-member.md` | -| Manage a team: publish/update skills, rules, MCP, env, invite members | `references/manage-admin.md` | -| Share / publish a skill with the team ("share this xxx skill") — any member, not just admins | `references/contribute-member.md` | -| Open the team dashboard (web UI) | run `teamai dashboard` (see cheat sheet) | -| Remove / uninstall TeamAI from this machine | `references/uninstall.md` | - -> **Sharing session *learnings* is automatic — not a menu choice, and not routed -> here.** TeamAI prompts on its own at the end of a session worth sharing, and the -> separate **`teamai-share-learnings`** skill summarizes it and runs -> `teamai contribute`. The user never asks for it through `/teamai`. (Only when the -> admin left team sharing on — the default.) The `contribute-member.md` row above is -> a *different* task: a member **publishing a reusable skill** on request ("share -> this xxx skill with my team"). -Choosing between "set up" and "join": a user **setting up a new team** becomes its -admin and creates the repo; a user **joining an existing team** needs a repo URL -from their admin. If someone wants to join but has no URL, that is still the -**join** flow — `join-member.md` tells them to ask their admin for it. Do **not** -send a would-be member to the setup/create-repo flow just because they lack a URL. +The CLI serves skill content that always matches the installed version, so instructions never go stale. The content in this stub cannot change between releases, which is why it just points at `skill get`. -If the request is ambiguous (e.g. "help me with teamai" with no direction), -ask ONE short question to pick a row, then proceed. When something breaks at any -step, load `references/troubleshooting.md`. - -## Global rules (apply to every scenario) - -1. **Reply in the user's language — including every example and hand-off blurb.** - Answer in whatever language the user used to invoke the skill (Chinese in → - Chinese out, English in → English out, and so on), for the whole conversation. - This applies to **everything you write**, not just prose: the reference files - below are written in English, but any ready-made sentence they hand you — the - invite line you give an admin to forward to members, the one-line explanations, - the "what's next" summary — **must be translated into the user's language before - you show it.** Do not paste an English example at a Chinese-speaking user. - *Only* commands, flags, URLs, file paths, and code identifiers stay verbatim - (never translate `teamai pull`, `--scope user`, `/teamai`, a repo URL, etc.). - Example: for a Chinese user, the member-invite line becomes - `/teamai 帮我加入团队的 TeamAI,仓库地址是 https://...`, not the English form. -2. **Never teach Git.** Do not mention branches, commits, clone, or push/pull of - Git itself. TeamAI hides all of that. The user thinks in terms of "my team's - skills", not repositories. -3. **Always use a full URL** for the team repo (e.g. - `https://github.com/yourorg/yourrepo`). Never use the `owner/repo` short form. -4. **You run the commands.** Only pause to ask the user when you need a web login, - a value only they know, or a genuine either/or choice. Show each command before - you run it, in one short line. -5. **Detect the current AI tool first.** TeamAI behaves differently per host. Note - which tool this conversation is running in (Claude Code, Cursor, CodeBuddy, - WorkBuddy, ChatGPT App, Codex, OpenCode, Kiro, Gemini CLI, …). When you reopen a - session, use the name of **this** tool — do not assume Claude Code or Cursor. - Some hosts need extra manual steps for hooks — see - `references/troubleshooting.md` ("Agent-specific caveats"). -6. **Prerequisite:** Node.js ≥ 20. Install once with `npm install -g teamai-cli` - and verify with `teamai --version`. -7. **Finish with `teamai doctor`.** Every setup/onboarding flow ends by running - `teamai doctor` and resolving whatever it reports before you call it done. -8. **After init, resources appear on the NEXT session.** `teamai init` injects a - session-start hook that auto-runs `teamai pull`. It is normal that the skills/ - rules directories are empty right after init — they fill in when the user opens - a fresh session in this tool. To sync immediately, run `teamai pull`. -9. **Don't limit which AI tools get set up — cover all of them by default.** Unless - the user explicitly says "only install to Claude Code" (or names specific - tools), do **not** pass `--agent` to restrict the install. Let `teamai init` set - up **every AI tool already installed on the machine** (omitting `--agent` gives - an interactive picker; select all detected tools, or the user's stated subset). - **After init, report which agents were set up** — tell the user, in their - language, exactly which tools will now auto-start TeamAI (and which detected - tools were skipped and why, e.g. Codex trust-gate / CodeBuddy design). Verify - the real per-tool result with `teamai doctor` / `teamai hooks list`. - -## Command cheat sheet (ground truth — do not invent flags) +## Specialized workflows ```bash -teamai init # Set up / join a team (configure provider, clone, register) -teamai init --scope user # Install for the whole machine instead of just this project -teamai pull # Sync team resources into local AI tools now -teamai push # Publish your local skills/rules/docs to the team -teamai doctor # Diagnose configuration and hook problems -teamai status # Show local vs team differences -teamai list # List resources (skills|rules|docs|env|agents|hooks|mcp) -teamai members # See team members (subcommand: teamai members list) -teamai roles # Manage roles / resource namespaces -teamai projects # Manage multiple projects from one repo (list|set|members) -teamai packages # Install team-declared npm packages & Claude plugins -teamai env # Manage shared team environment variables -teamai dashboard # Open the AI coding session dashboard (web UI, default port 3721) -teamai contribute --file

--title # Contribute a knowledge doc (usually via the teamai-share-learnings skill) +teamai skill get setup # day 0: create a team repo (admin) or join one (member), manage, uninstall +teamai skill get wiki # large multi-repo codebase: architecture reverse-engineering and knowledge base +teamai skill get share # turn what this session taught you into a team learning ``` -Anything not in this cheat sheet: check `teamai --help` before using it. -Do **not** guess flags (for example, there is no member-invite flag in the CLI — -inviting a member is done on the Git platform's website; see -`references/manage-admin.md`). +A friction reminder at the end of a turn means `teamai skill get share`. + +`teamai skill list` shows everything the installed version serves. `teamai skill path ` prints the directory holding a skill's scripts and templates. diff --git a/src/__tests__/e2e/codebase-extract-cli.test.ts b/src/__tests__/e2e/codebase-extract-cli.test.ts index 5e531b90..de66be38 100644 --- a/src/__tests__/e2e/codebase-extract-cli.test.ts +++ b/src/__tests__/e2e/codebase-extract-cli.test.ts @@ -82,7 +82,7 @@ describe('teamai codebase extract CLI (issue #360 slice 1)', () => { const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-local-workflow-')); const caller = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-workflow-caller-')); try { - const skill = fs.readFileSync(path.join(ROOT, 'skills/team-wiki-codebase/SKILL.md'), 'utf8'); + const skill = fs.readFileSync(path.join(ROOT, 'skill-data/wiki/SKILL.md'), 'utf8'); const commands = [...skill.matchAll(/`(teamai codebase [^`]+)`/g)].map(match => match[1]); const refresh = commands.find(command => command.includes('--incremental')); const lint = commands.find(command => command.includes('--lint')); @@ -147,7 +147,7 @@ describe('teamai codebase reconcile CLI (issue #360 slice 2)', () => { const help = await runCLI(['codebase', '--help']); expect(help.code, help.output).toBe(0); expect(help.stdout).toContain('--reconcile'); - const skill = fs.readFileSync(path.join(ROOT, 'skills/team-wiki-codebase/SKILL.md'), 'utf8'); + const skill = fs.readFileSync(path.join(ROOT, 'skill-data/wiki/SKILL.md'), 'utf8'); const command = [...skill.matchAll(/`(teamai codebase [^`]+)`/g)] .map(match => match[1]) .find(candidate => candidate.includes('--reconcile')); @@ -294,7 +294,7 @@ describe('teamai codebase deep-enrich CLI (issue #360 slice 3)', () => { expect(help.code, help.output).toBe(0); expect(help.stdout).toContain('--deep-enrich'); - const skill = fs.readFileSync(path.join(ROOT, 'skills/team-wiki-codebase/SKILL.md'), 'utf8'); + const skill = fs.readFileSync(path.join(ROOT, 'skill-data/wiki/SKILL.md'), 'utf8'); const command = [...skill.matchAll(/`(teamai codebase [^`]+)`/g)] .map(match => match[1]) .find(candidate => candidate.includes('--deep-enrich')); diff --git a/src/__tests__/e2e/enabled-agents-whitelist.test.ts b/src/__tests__/e2e/enabled-agents-whitelist.test.ts index 622dc445..652b6f27 100644 --- a/src/__tests__/e2e/enabled-agents-whitelist.test.ts +++ b/src/__tests__/e2e/enabled-agents-whitelist.test.ts @@ -132,10 +132,10 @@ describe('enabledAgents whitelist on real CLI pull (#510)', () => { expect(first.output).not.toContain('Already synced'); expect(fs.existsSync(path.join(homeDir, '.workbuddy', 'skills', 'team-skill', 'SKILL.md'))).toBe(true); - expect(fs.existsSync(path.join(homeDir, '.workbuddy', 'skills', 'team-wiki-codebase', 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(homeDir, '.workbuddy', 'skills', 'teamai', 'SKILL.md'))).toBe(true); expect(fs.existsSync(path.join(homeDir, '.hermes', 'skills', 'team-skill'))).toBe(false); - expect(fs.existsSync(path.join(homeDir, '.hermes', 'skills', 'team-wiki-codebase'))).toBe(false); + expect(fs.existsSync(path.join(homeDir, '.hermes', 'skills', 'teamai'))).toBe(false); expect(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'team-skill'))).toBe(false); expect(fs.existsSync(path.join(homeDir, '.codebuddy', 'skills', 'team-skill'))).toBe(false); diff --git a/src/__tests__/pull-skip-sync.test.ts b/src/__tests__/pull-skip-sync.test.ts index 90f2de22..bbb7e235 100644 --- a/src/__tests__/pull-skip-sync.test.ts +++ b/src/__tests__/pull-skip-sync.test.ts @@ -1178,8 +1178,8 @@ describe('enabledAgents whitelist on pull inject, skip-sync, and cleanup (#510)' expect(log.success).toHaveBeenCalledWith( expect.stringContaining('Already synced at abc1234, skipping'), ); - expect(await fse.pathExists(path.join(homeDir, '.workbuddy/skills/team-wiki-codebase/SKILL.md'))).toBe(true); - expect(await fse.pathExists(path.join(homeDir, '.hermes/skills/team-wiki-codebase'))).toBe(false); + expect(await fse.pathExists(path.join(homeDir, '.workbuddy/skills/teamai/SKILL.md'))).toBe(true); + expect(await fse.pathExists(path.join(homeDir, '.hermes/skills/teamai'))).toBe(false); }); it('does not delete leftover copies on out-of-whitelist tools', async () => { diff --git a/src/__tests__/push-namespace-e2e.test.ts b/src/__tests__/push-namespace-e2e.test.ts index 787608b8..0811b50d 100644 --- a/src/__tests__/push-namespace-e2e.test.ts +++ b/src/__tests__/push-namespace-e2e.test.ts @@ -365,14 +365,13 @@ describe('push places new rules and agents in a namespace (issue #649)', () => { // The root copy is the author's own, placed under rules/fe-know/. Without // the placedRules redirect in the pre-push sync it stayed at v1, read as a - // local modification, and reverted the teammate's update. (The pull above - // also installs the CLI's built-in `teamai` skill, which this run does - // push — the assertion is about the rule.) + // local modification, and reverted the teammate's update. The built-in + // `teamai` skill the pull installed is CLI-owned and not scanned (#730), + // so once the rule is synced there is nothing left to push. expect(result.output).not.toContain('[rules] my-rule'); + expect(result.output).toContain('No new or modified resources to push'); expect(fs.readFileSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'), 'utf8')) .toContain('Teammate v2'); - const { branch } = branchFiles(fixture); - expect(git(['show', `${branch}:rules/fe-know/my-rule.md`], fixture.remote)).toContain('Teammate v2'); expect(git(['show', 'main:rules/fe-know/my-rule.md'], fixture.remote)).toContain('Teammate v2'); }, 60_000); diff --git a/src/__tests__/recall-toggle.test.ts b/src/__tests__/recall-toggle.test.ts index 8b18ccda..b0ba73fc 100644 --- a/src/__tests__/recall-toggle.test.ts +++ b/src/__tests__/recall-toggle.test.ts @@ -139,7 +139,7 @@ describe('recall toggle native agent cleanup', () => { await expect(fse.pathExists(path.join( copilotHome, 'skills', - 'teamai-share-learnings', + 'teamai', 'SKILL.md', ))).resolves.toBe(true); @@ -156,11 +156,14 @@ describe('recall toggle native agent cleanup', () => { 'agents', 'teamai-recall.agent.md', ))).resolves.toBe(false); + // The deployed stub routes to every workflow, recall-dependent or not, so + // disabling recall no longer removes a skill directory. await expect(fse.pathExists(path.join( copilotHome, 'skills', - 'teamai-share-learnings', - ))).resolves.toBe(false); + 'teamai', + 'SKILL.md', + ))).resolves.toBe(true); }); }); diff --git a/src/__tests__/skill-content.test.ts b/src/__tests__/skill-content.test.ts index f88a7ffb..31c9ad9f 100644 --- a/src/__tests__/skill-content.test.ts +++ b/src/__tests__/skill-content.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -77,15 +78,17 @@ describe('packaged skill discovery', () => { }); it('resolves legacy directory names as aliases', async () => { - writeSkill(roots.dataRoot, 'team-wiki-codebase', '# wiki\n'); - writeSkill(roots.dataRoot, 'teamai-share-learnings', '# share\n'); + writeSkill(roots.dataRoot, 'core', '# core\n'); + writeSkill(roots.dataRoot, 'wiki', '# wiki\n'); + writeSkill(roots.dataRoot, 'share', '# share\n'); - for (const alias of ['wiki', 'codebase']) { - expect((await resolvePackagedSkill(alias, roots))?.name).toBe('team-wiki-codebase'); + for (const alias of ['wiki', 'codebase', 'team-wiki-codebase']) { + expect((await resolvePackagedSkill(alias, roots))?.name, alias).toBe('wiki'); } - for (const alias of ['share', 'learning', 'learnings']) { - expect((await resolvePackagedSkill(alias, roots))?.name).toBe('teamai-share-learnings'); + for (const alias of ['share', 'learning', 'learnings', 'teamai-share-learnings']) { + expect((await resolvePackagedSkill(alias, roots))?.name, alias).toBe('share'); } + expect((await resolvePackagedSkill('default', roots))?.name).toBe('core'); expect(await resolvePackagedSkill('nope', roots)).toBeNull(); }); @@ -269,3 +272,24 @@ describe('teamai skill get / path against the shipped package', () => { expect(stderr).toContain('Skill not found: no-such-skill'); }); }); + +describe('npm package contents', () => { + // The whole design fails silently when skill-data/ is missing from + // package.json "files": every test above still passes against the repo, and + // `skill get` serves nothing at all once installed from the registry. + it('ships both the deployed stub and the served content', () => { + const packed = execFileSync('npm', ['pack', '--dry-run', '--json'], { + cwd: ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const files = (JSON.parse(packed) as Array<{ files: Array<{ path: string }> }>)[0] + .files.map((f) => f.path); + + expect(files).toContain('skills/teamai/SKILL.md'); + for (const skill of ['core', 'share', 'wiki']) { + expect(files.some((f) => f.startsWith(`skill-data/${skill}/`)), skill).toBe(true); + } + expect(files).toContain('skill-data/wiki/scripts/scan_repo.py'); + }, 60_000); +}); diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index f4ac0fc8..a2ca7503 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -1,10 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import path from 'node:path'; import os from 'node:os'; +import { fileURLToPath } from 'node:url'; import fse from 'fs-extra'; -vi.mock('../config.js', async (importOriginal) => ({ - ...(await importOriginal()), +const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); + +vi.mock('../config.js', () => ({ requireInit: vi.fn(), loadState: vi.fn(), saveState: vi.fn(), @@ -480,11 +482,11 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { expect(deployed).toBeGreaterThan(0); expect(await fse.pathExists(path.join( homeDir, - '.claude/skills/team-wiki-codebase/SKILL.md', + '.claude/skills/teamai/SKILL.md', ))).toBe(true); }); - it('should recursively deploy nested built-in skill files', async () => { + it('deploys the discovery stub only, never the packaged content', async () => { const { deployBuiltinSkills } = await import('../builtin-skills.js'); const teamConfig = { @@ -513,12 +515,18 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { }; const deployed = await deployBuiltinSkills(teamConfig, localConfig); - const skillDir = path.join(homeDir, '.claude/skills/team-wiki-codebase'); + const skillsDir = path.join(homeDir, '.claude/skills'); + const stubDir = path.join(skillsDir, 'teamai'); expect(deployed).toBeGreaterThan(0); - expect(await fse.pathExists(path.join(skillDir, 'SKILL.md'))).toBe(true); - expect(await fse.pathExists(path.join(skillDir, 'references/methodology/phase0-collection.md'))).toBe(true); - expect(await fse.pathExists(path.join(skillDir, 'scripts/scan_repo.py'))).toBe(true); + expect(await fse.pathExists(path.join(stubDir, 'SKILL.md'))).toBe(true); + // The stub is the whole deployed unit: one file, no references, no scripts. + expect(await fse.readdir(stubDir)).toEqual(['SKILL.md']); + expect(await fse.readdir(skillsDir)).toEqual(['teamai']); + // ...and it is the packaged file verbatim, so a diff means a bug. + expect(await fse.readFile(path.join(stubDir, 'SKILL.md'), 'utf8')).toBe( + await fse.readFile(path.join(PACKAGE_ROOT, 'skills/teamai/SKILL.md'), 'utf8'), + ); }); it('deploys built-in skills to OpenCode user scope under .config/opencode/skills', async () => { @@ -559,11 +567,11 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { expect(deployed).toBeGreaterThan(0); // Written to the user-scope path, NOT the project-scope .opencode/skills. - expect(await fse.pathExists(path.join(homeDir, '.config/opencode/skills/team-wiki-codebase/SKILL.md'))).toBe(true); + expect(await fse.pathExists(path.join(homeDir, '.config/opencode/skills/teamai/SKILL.md'))).toBe(true); expect(await fse.pathExists(path.join(homeDir, '.opencode'))).toBe(false); }); - it('should still deploy team-wiki-codebase when recall is disabled (skipRecall)', async () => { + it('still deploys the stub when recall is disabled (skipRecall)', async () => { const { deployBuiltinSkills } = await import('../builtin-skills.js'); const teamConfig = { @@ -594,18 +602,16 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { const deployed = await deployBuiltinSkills(teamConfig, localConfig, { skipRecall: true }); expect(deployed).toBeGreaterThan(0); - expect(await fse.pathExists(path.join(homeDir, '.claude/skills/team-wiki-codebase/SKILL.md'))).toBe(true); - const wikiEnrichFile = path.join( - homeDir, - '.claude/skills/team-wiki-codebase/references/methodology/phase3-ai-enhancement.md', - ); - expect(await fse.pathExists(wikiEnrichFile)).toBe(true); - expect(await fse.pathExists(path.join(homeDir, '.claude/skills/teamai-share-learnings/SKILL.md'))).toBe(false); + // The stub routes to every workflow, so recall no longer gates deployment: + // `teamai skill get share` decides at run time whether recall is on. + expect(await fse.pathExists(path.join(homeDir, '.claude/skills/teamai/SKILL.md'))).toBe(true); + expect(await fse.pathExists(path.join(homeDir, '.claude/skills/team-wiki-codebase'))).toBe(false); + expect(await fse.pathExists(path.join(homeDir, '.claude/skills/teamai-share-learnings'))).toBe(false); }); it('deploys a built-in Codex skill to its existing shared location', async () => { const { deployBuiltinSkills } = await import('../builtin-skills.js'); - const sharedSkill = path.join(homeDir, '.agents', 'skills', 'team-wiki-codebase'); + const sharedSkill = path.join(homeDir, '.agents', 'skills', 'teamai'); await fse.ensureDir(path.join(homeDir, '.codex')); await fse.ensureDir(sharedSkill); @@ -629,7 +635,7 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { await deployBuiltinSkills(teamConfig, localConfig); expect(await fse.pathExists(path.join(sharedSkill, 'SKILL.md'))).toBe(true); - expect(await fse.pathExists(path.join(homeDir, '.codex', 'skills', 'team-wiki-codebase'))).toBe(false); + expect(await fse.pathExists(path.join(homeDir, '.codex', 'skills', 'teamai'))).toBe(false); }); }); @@ -686,8 +692,8 @@ describe('deployBuiltinSkills — enabledAgents whitelist (#510)', () => { const deployed = await deployBuiltinSkills(teamConfig(), localConfig(['workbuddy'])); expect(deployed).toBeGreaterThan(0); - expect(await fse.pathExists(path.join(homeDir, '.workbuddy/skills/team-wiki-codebase/SKILL.md'))).toBe(true); - expect(await fse.pathExists(path.join(homeDir, '.hermes/skills/team-wiki-codebase'))).toBe(false); + expect(await fse.pathExists(path.join(homeDir, '.workbuddy/skills/teamai/SKILL.md'))).toBe(true); + expect(await fse.pathExists(path.join(homeDir, '.hermes/skills/teamai'))).toBe(false); expect(await fse.pathExists(path.join(homeDir, '.hermes/skills'))).toBe(false); }); @@ -696,7 +702,7 @@ describe('deployBuiltinSkills — enabledAgents whitelist (#510)', () => { const deployed = await deployBuiltinSkills(teamConfig(), localConfig()); expect(deployed).toBeGreaterThan(0); - expect(await fse.pathExists(path.join(homeDir, '.workbuddy/skills/team-wiki-codebase/SKILL.md'))).toBe(true); - expect(await fse.pathExists(path.join(homeDir, '.hermes/skills/team-wiki-codebase/SKILL.md'))).toBe(true); + expect(await fse.pathExists(path.join(homeDir, '.workbuddy/skills/teamai/SKILL.md'))).toBe(true); + expect(await fse.pathExists(path.join(homeDir, '.hermes/skills/teamai/SKILL.md'))).toBe(true); }); }); diff --git a/src/__tests__/team-wiki-codebase-skill.test.ts b/src/__tests__/team-wiki-codebase-skill.test.ts index df9184ca..f364682f 100644 --- a/src/__tests__/team-wiki-codebase-skill.test.ts +++ b/src/__tests__/team-wiki-codebase-skill.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); -const SKILL_DIR = path.join(ROOT, 'skills', 'team-wiki-codebase'); +const SKILL_DIR = path.join(ROOT, 'skill-data', 'wiki'); const SKILL_FILES = [ path.join(SKILL_DIR, 'SKILL.md'), @@ -20,7 +20,7 @@ const FORBIDDEN_REQUIRED_COMMANDS = [ 'team-wiki refresh', ] as const; -describe('team-wiki-codebase builtin skill (issue #360 slice 1)', () => { +describe('wiki builtin skill content (issue #360 slice 1)', () => { it('ships the packaged skill files', () => { for (const file of SKILL_FILES) { expect(fs.existsSync(file), file).toBe(true); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 26cc0e4c..3d7eac3c 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -120,6 +120,8 @@ async function setupFixture(tmpDir: string) { await fse.writeFile(path.join(homeDir, '.claude', 'rules', 'teamai-recall.md'), '# Recall Rule'); await fse.ensureDir(path.join(homeDir, '.claude', 'agents')); await fse.writeFile(path.join(homeDir, '.claude', 'agents', 'teamai-recall.md'), '# Recall Agent'); + await fse.ensureDir(path.join(homeDir, '.claude', 'skills', 'teamai')); + await fse.writeFile(path.join(homeDir, '.claude', 'skills', 'teamai', 'SKILL.md'), '# teamai stub'); await fse.ensureDir(path.join(homeDir, '.claude', 'skills', 'teamai-share-learnings')); await fse.writeFile(path.join(homeDir, '.claude', 'skills', 'teamai-share-learnings', 'SKILL.md'), '# Share Learnings'); await fse.ensureDir(path.join(homeDir, '.claude', 'skills', 'team-wiki-codebase')); @@ -978,7 +980,9 @@ describe('uninstall', () => { expect(await fse.pathExists(path.join(homeDir, '.claude', 'agents', 'teamai-recall.md'))).toBe(false); expect(await fse.pathExists(path.join(homeDir, '.claude', 'rules', 'teamai-recall.md'))).toBe(false); expect(await fse.pathExists(codexRecallAgent)).toBe(false); - // Built-in skills removed + // Built-in skills removed: the deployed stub, and the directories earlier + // releases left behind. + expect(await fse.pathExists(path.join(homeDir, '.claude', 'skills', 'teamai'))).toBe(false); expect(await fse.pathExists(path.join(homeDir, '.claude', 'skills', 'teamai-share-learnings'))).toBe(false); expect(await fse.pathExists(path.join(homeDir, '.claude', 'skills', 'team-wiki-codebase'))).toBe(false); // User's own skill still preserved @@ -1329,6 +1333,7 @@ describe('uninstall', () => { }); // Remove all teamai resources from claude so it has zero teamai presence await fse.remove(path.join(homeDir, '.claude', 'skills', 'team-skill')); + await fse.remove(path.join(homeDir, '.claude', 'skills', 'teamai')); await fse.remove(path.join(homeDir, '.claude', 'skills', 'teamai-share-learnings')); await fse.remove(path.join(homeDir, '.claude', 'skills', 'team-wiki-codebase')); await fse.remove(path.join(homeDir, '.claude', 'rules', 'team-rule.md')); @@ -1393,6 +1398,7 @@ describe('uninstall', () => { hooks: { SessionStart: [{ matcher: '*', hooks: [{ type: 'command', command: 'echo hi' }] }] }, }); await fse.remove(path.join(homeDir, '.claude', 'skills', 'team-skill')); + await fse.remove(path.join(homeDir, '.claude', 'skills', 'teamai')); await fse.remove(path.join(homeDir, '.claude', 'skills', 'teamai-share-learnings')); await fse.remove(path.join(homeDir, '.claude', 'skills', 'team-wiki-codebase')); await fse.remove(path.join(homeDir, '.claude', 'rules', 'team-rule.md')); @@ -1447,6 +1453,7 @@ describe('uninstall', () => { }); // Also remove all other teamai resources so nothing triggers cleanup. await fse.remove(path.join(homeDir, '.claude', 'skills', 'team-skill')); + await fse.remove(path.join(homeDir, '.claude', 'skills', 'teamai')); await fse.remove(path.join(homeDir, '.claude', 'skills', 'teamai-share-learnings')); await fse.remove(path.join(homeDir, '.claude', 'skills', 'team-wiki-codebase')); await fse.remove(path.join(homeDir, '.claude', 'rules', 'team-rule.md')); diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index 136b641d..494beeaf 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -1,47 +1,53 @@ import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import fse from 'fs-extra'; import { pathExists } from './utils/fs.js'; import { log } from './utils/logger.js'; import type { TeamaiConfig, LocalConfig } from './types.js'; import { resolveToolBaseDir, isAgentExcluded, scopedToolPaths } from './types.js'; import { isToolInstalledForConfig, ResourceHandler } from './resources/base.js'; -import { ensureSkillFrontmatter, resolveSkillDestination } from './resources/skills.js'; +import { resolveSkillDestination } from './resources/skills.js'; import { getUserHome } from './utils/home.js'; +import { packagedSkillRoots } from './skill-content.js'; // ─── Built-in skills deployment ────────────────────────── // -// CLI ships with built-in skills (e.g. teamai-contribute). -// These are bundled in the npm package under skills/. -// On each `teamai pull`, we copy them to local AI tool -// skill directories so they're always available and -// stay in sync with the CLI version. +// The CLI ships one deployable skill: the `teamai` discovery +// stub under skills/. On each `teamai pull` its SKILL.md is +// copied to local AI tool skill directories. The workflow +// content it points at is never copied — it lives under +// skill-data/ and is printed by `teamai skill get`, so what +// the agent reads always matches the installed CLI version. // // npm package -// skills/teamai-contribute/SKILL.md +// skills/teamai/SKILL.md (about 2 KB) // │ // ▼ (teamai pull / teamai init) -// ~/.claude/skills/teamai-contribute/SKILL.md -// ~/.claude-internal/skills/teamai-contribute/SKILL.md -// ~/.codex-internal/skills/teamai-contribute/SKILL.md -// ~/.cursor/skills/teamai-contribute/SKILL.md +// ~/.claude/skills/teamai/SKILL.md +// ~/.codex-internal/skills/teamai/SKILL.md +// ~/.cursor/skills/teamai/SKILL.md // ... // +// skill-data/{core,setup,wiki,share}/ never copied +// /** - * Get the path to the built-in skills directory bundled with the CLI. - * Resolves relative to the dist/ directory where the compiled CLI lives. + * Names of CLI built-in skills. Used by push to exclude them from team repo + * push, by pull cleanup, and by uninstall. */ -function getBuiltinSkillsDir(): string { - // __dirname equivalent for ESM: import.meta.url → file path → parent - const distDir = path.dirname(fileURLToPath(import.meta.url)); - // skills/ is at package root, dist/ is one level down - return path.join(distDir, '..', 'skills'); -} +export const BUILTIN_SKILL_NAMES = new Set(['teamai']); -/** Names of CLI built-in skills. Used by push to exclude them from team repo push. */ -export const BUILTIN_SKILL_NAMES = new Set(['teamai-share-learnings', 'team-wiki-codebase', 'teamai-workflow', 'teamai-import']); +/** + * Built-in skill directories earlier releases deployed, kept only so that pull + * can remove them from agent skills directories. Retire this set once 0.23.x is + * no longer in the field. + */ +export const LEGACY_BUILTIN_SKILL_NAMES = new Set([ + 'teamai-share-learnings', + 'team-wiki-codebase', + 'teamai-workflow', + 'teamai-import', +]); /** * Built-in skills that depend on recall being enabled. Skipped when recall is disabled. @@ -52,33 +58,30 @@ export const BUILTIN_SKILL_NAMES = new Set(['teamai-share-learnings', 'team-wiki */ export const RECALL_DEPENDENT_SKILLS = new Set(['teamai-share-learnings']); -async function copyBuiltinSkillDir(srcDir: string, destDir: string): Promise { - await fse.copy(srcDir, destDir, { - overwrite: true, - filter: (srcPath: string) => !path.basename(srcPath).startsWith('.'), - }); -} - /** * Deploy CLI built-in skills to all configured AI tool skill directories. * - * Copies each skill directory from the npm package's skills/ folder - * to every tool's skills path defined in teamai.yaml. + * Copies the SKILL.md of each skill in the npm package's skills/ folder to + * every tool's skills path defined in teamai.yaml. Only that one file: the + * deployed unit is a discovery stub, and its workflow content is served by + * `teamai skill get` from skill-data/. + * + * The stub is written verbatim — no frontmatter repair on the way out, so a + * deployed copy that differs from the packaged one is a bug, not a variant. * * Silently skips if: * - Built-in skills directory doesn't exist (dev environment without build) * - A tool's skills directory is not configured */ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig?: LocalConfig, options?: { reportingOnly?: boolean; skipRecall?: boolean }): Promise { - // Reporting-only HTTP mode has no team repo to write to, so the team-repo- - // dependent built-in skill (teamai-share-learnings) is non-functional there. - // Skip built-in skills entirely. + // Reporting-only HTTP mode has no team repo to write to, so the workflows the + // stub routes to are non-functional there. Skip built-in skills entirely. if (options?.reportingOnly) { - log.debug('Reporting-only mode (no team repo): skipping built-in skills (teamai-share-learnings)'); + log.debug('Reporting-only mode (no team repo): skipping built-in skills'); return 0; } - const builtinDir = getBuiltinSkillsDir(); + const builtinDir = packagedSkillRoots().deployRoot; if (!await pathExists(builtinDir)) { log.debug('No built-in skills directory found, skipping deployment'); @@ -126,10 +129,8 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? const destDir = await resolveSkillDestination(tool, toolPath.skills, baseDir, skillName, srcDir); try { - await copyBuiltinSkillDir(srcDir, destDir); - - // Ensure SKILL.md has proper YAML frontmatter (name + description) - await ensureSkillFrontmatter(destDir, skillName); + await fse.ensureDir(destDir); + await fse.copy(path.join(srcDir, 'SKILL.md'), path.join(destDir, 'SKILL.md'), { overwrite: true }); deployed++; } catch (e) { diff --git a/src/skill-content.ts b/src/skill-content.ts index 7009deb3..81223804 100644 --- a/src/skill-content.ts +++ b/src/skill-content.ts @@ -39,13 +39,12 @@ const SKILL_MD = 'SKILL.md'; * moves under skill-data/. */ const SKILL_ALIASES: Readonly> = { - core: 'teamai', - default: 'teamai', - wiki: 'team-wiki-codebase', - codebase: 'team-wiki-codebase', - share: 'teamai-share-learnings', - learning: 'teamai-share-learnings', - learnings: 'teamai-share-learnings', + default: 'core', + codebase: 'wiki', + 'team-wiki-codebase': 'wiki', + learning: 'share', + learnings: 'share', + 'teamai-share-learnings': 'share', }; /** A skill directory that ships inside the npm package. */ diff --git a/src/uninstall.ts b/src/uninstall.ts index 69781ef7..f1a24379 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -38,7 +38,7 @@ import { agentStemFromFilename } from './resources/agent-format.js'; import { resolveDocsDestination } from './resources/docs.js'; import { listTeamAgentDirs } from './resources/agents.js'; import { BUILTIN_AGENT_NAMES } from './builtin-agents.js'; -import { BUILTIN_SKILL_NAMES } from './builtin-skills.js'; +import { BUILTIN_SKILL_NAMES, LEGACY_BUILTIN_SKILL_NAMES } from './builtin-skills.js'; import { pathExists, readFileSafe, @@ -417,6 +417,9 @@ async function buildRemovalPlan( const repoPath = localConfig.repo.localPath; const teamSkillNames = await collectTeamSkillNames(repoPath); for (const name of BUILTIN_SKILL_NAMES) teamSkillNames.add(name); + // Directories earlier releases deployed: uninstall would otherwise leave the + // pre-stub skill trees behind on any machine that upgraded. + for (const name of LEGACY_BUILTIN_SKILL_NAMES) teamSkillNames.add(name); const teamRuleNames = await collectTeamRuleNames(repoPath); for (const name of BUILTIN_RULE_NAMES) teamRuleNames.add(name); const teamAgentNames = await collectTeamAgentNames(repoPath); From 6b2f4fcf75d45389e514ecdd28347267c28700c7 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Mon, 21 Sep 2026 21:37:24 +0200 Subject: [PATCH 03/37] fix(skills): repair stale commands, broken refs and frontmatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit of the three builtin skills found 60 defects. This fixes the ones that survive the move to skill-data, and splits the two skills that were carrying more than one job. Stale CLI surface. The wiki skill advertised `teamai extract graph`, a command that has never existed. The hand-written "ground truth" cheat sheet in the teamai skill omitted 19 real commands while telling the agent that anything missing from it could be checked with `--help` — which fails for the flags `--help` hides. The cheat sheet is replaced by `skill-data/core/references/commands.md`, rendered from the CLI's own command table, with hidden flags marked as such. Two tests guard it: one regenerates the file and diffs, the other resolves every `teamai …` string written anywhere in skill-data against the command table and fails on an unknown command or flag. That second test is the one that would have caught e151d43, 1ca43ac, 8bb0548 and 2ddb546 before they shipped; it carries a case proving it catches `teamai extract graph`. Paths. Everything the skills told an agent to read or execute assumed the skill sat in the agent's own directory: `python3 scripts/scan_repo.py` from a cwd that is the target repo, references cited by bare filename in two different conventions, methodology paths handed to sub-agents inside input packets. All of them now go through {SKILL_DIR}, which `skill get` resolves. The README template nobody referenced is wired into the step that writes the knowledge-base README. Frontmatter. None of the three skills declared allowed-tools, so the first command of every flow hit a permission prompt. The wiki skill kept its trigger words and prerequisites inside the description text; both move into the body. Splits. `core` keeps what a daily user needs and `setup` takes day 0 and the repo lifecycle, so the common path no longer carries ~500 lines of repo creation. The wiki skill's phase procedures move into references/phases/, taking its SKILL.md from 38.7 KB — larger than agent-browser's entire core — to 17 KB with an index that says when to load each phase. One contradiction is resolved in the author's text: the share skill mandated that every generated document be written in Chinese, against global rule 1 ("reply in the user's language") and this repo's own English rule. It now follows rule 1. Refs #678 --- skill-data/core/SKILL.md | 149 ++-- skill-data/core/references/commands.md | 391 ++++++++++ skill-data/core/references/troubleshooting.md | 2 +- skill-data/setup/SKILL.md | 73 ++ .../{core => setup}/references/join-member.md | 17 +- .../references/manage-admin.md | 6 +- .../{core => setup}/references/setup-admin.md | 12 +- .../{core => setup}/references/uninstall.md | 0 skill-data/share/SKILL.md | 28 +- skill-data/wiki/SKILL.md | 667 +----------------- .../wiki/references/agents/graph-rag-agent.md | 2 +- .../methodology/phase2-document-types.md | 2 +- .../references/methodology/phase4-quality.md | 4 +- .../{README.md => references/overview.md} | 0 .../phases/k1-reverse-engineering.md | 118 ++++ .../wiki/references/phases/k2-documents.md | 68 ++ .../wiki/references/phases/k3-ai-native.md | 121 ++++ .../wiki/references/phases/k4-quality.md | 190 +++++ .../wiki/references/phases/phase0-init.md | 112 +++ src/__tests__/commands-reference.test.ts | 22 + src/__tests__/skill-commands-exist.test.ts | 132 ++++ src/__tests__/skill-content.test.ts | 7 +- .../team-wiki-codebase-skill.test.ts | 8 +- src/commands-reference.ts | 71 ++ src/index.ts | 12 +- src/skill-content.ts | 2 + 26 files changed, 1456 insertions(+), 760 deletions(-) create mode 100644 skill-data/core/references/commands.md create mode 100644 skill-data/setup/SKILL.md rename skill-data/{core => setup}/references/join-member.md (90%) rename skill-data/{core => setup}/references/manage-admin.md (94%) rename skill-data/{core => setup}/references/setup-admin.md (96%) rename skill-data/{core => setup}/references/uninstall.md (100%) rename skill-data/wiki/{README.md => references/overview.md} (100%) create mode 100644 skill-data/wiki/references/phases/k1-reverse-engineering.md create mode 100644 skill-data/wiki/references/phases/k2-documents.md create mode 100644 skill-data/wiki/references/phases/k3-ai-native.md create mode 100644 skill-data/wiki/references/phases/k4-quality.md create mode 100644 skill-data/wiki/references/phases/phase0-init.md create mode 100644 src/__tests__/commands-reference.test.ts create mode 100644 src/__tests__/skill-commands-exist.test.ts create mode 100644 src/commands-reference.ts diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index 5efb645d..e46edc37 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -1,25 +1,24 @@ --- -name: teamai +name: core description: >- - Guide for TeamAI — the CLI that syncs a team's AI skills, rules, docs, and env - across AI coding tools (set up, join, manage, contribute, uninstall). Invoke - ONLY when the user explicitly runs `/teamai`. Do NOT auto-trigger from ordinary - conversation, even if words like "team", "skill", or "sync" appear. + TeamAI daily workflow: route a /teamai request, sync with pull and push, inspect status, + diagnose with doctor, and reach the specialized workflows. Loaded by the teamai discovery stub. +allowed-tools: Bash(teamai:*), Bash(npx teamai-cli:*) --- -# TeamAI — Team AI Skills & Rules Sync +# teamai — daily workflow You are guiding a user through TeamAI. **They may not know Git.** You run the commands; they only make choices when you ask. Follow the steps literally — do not skip, reorder, or invent commands. -## STEP 0 — Progressive disclosure (do this first, every time) +## Start here Look at what the user typed after `/teamai`. **If they gave NO scenario** (bare `/teamai`, or only greetings/no task): print the menu below **exactly**, then **STOP and wait**. Take no other action — -do not run any command, do not read any reference file yet. +do not run any command, do not load another skill yet. ``` teamai — Team AI Skills & Rules Sync @@ -45,108 +44,66 @@ Usage examples (copy one to get started): /teamai Uninstall TeamAI ``` -> **Sharing a session's learnings is automatic — not a menu choice.** TeamAI -> prompts on its own at the end of a session that produced something worth sharing, -> and the **`teamai-share-learnings`** skill takes over. The user does not invoke -> `/teamai` for it. (Only appears when the admin left team sharing enabled — on by -> default.) - -**If they DID describe a scenario**, match it to one row of the table below, -then open that reference file and follow it step by step. - -| The user wants to… | Load this reference | -|-----------------------------------------------------|------------------------------------------| -| Set up TeamAI for a team from scratch (create repo) | `references/setup-admin.md` | -| Join their team (with or without a repo URL) | `references/join-member.md` | -| Manage a team: publish/update skills, rules, MCP, env, invite members | `references/manage-admin.md` | -| Share / publish a skill with the team ("share this xxx skill") — any member, not just admins | `references/contribute-member.md` | -| Open the team dashboard (web UI) | run `teamai dashboard` (see cheat sheet) | -| Remove / uninstall TeamAI from this machine | `references/uninstall.md` | - -> **Sharing session learnings is automatic, via a separate skill — do not route it -> here.** TeamAI prompts on its own at the end of a session worth sharing, and the -> **`teamai-share-learnings`** skill summarizes the session and runs -> `teamai contribute`. The user does not ask for it through `/teamai`. (Only when -> the admin left team sharing on — the default.) `contribute-member.md` here is for -> a member **publishing a reusable skill** on request ("share this xxx skill with -> my team"). - -Choosing between "set up" and "join": a user **setting up a new team** becomes its -admin and creates the repo; a user **joining an existing team** needs a repo URL -from their admin. If someone wants to join but has no URL, that is still the -**join** flow — `join-member.md` tells them to ask their admin for it. Do **not** -send a would-be member to the setup/create-repo flow just because they lack a URL. +**If they DID describe a scenario**, match it to one row and follow what it loads. + +| The user wants to… | Load this | +|-----------------------------------------------------------------------|------------------------------------------------| +| Set up a team from scratch, join a team, manage one, or uninstall | `teamai skill get setup` | +| Share or publish a skill, a doc, or what this session taught them | `teamai skill get share` | +| Understand a large multi-repo codebase, build an architecture wiki | `teamai skill get wiki` | +| Sync now, see differences, diagnose | `teamai pull` · `teamai status` · `teamai doctor` | +| Open the team dashboard | `teamai dashboard` — it starts a local server (default port 3721); give the user the URL | +| Something broke | `{SKILL_DIR}/references/troubleshooting.md` | If the request is ambiguous (e.g. "help me with teamai" with no direction), -ask ONE short question to pick a row, then proceed. When something breaks at any -step, load `references/troubleshooting.md`. +ask ONE short question to pick a row, then proceed. + +Sharing a session's learnings needs no menu choice: TeamAI prompts on its own at +the end of a session that produced something worth sharing, and that prompt means +`teamai skill get share`. (Only when the admin left team sharing enabled — the default.) -## Global rules (apply to every scenario) +## Global rules 1. **Reply in the user's language — including every example and hand-off blurb.** - Answer in whatever language the user used to invoke the skill (Chinese in → - Chinese out, English in → English out, and so on), for the whole conversation. - This applies to **everything you write**, not just prose: the reference files - below are written in English, but any ready-made sentence they hand you — the - invite line you give an admin to forward to members, the one-line explanations, - the "what's next" summary — **must be translated into the user's language before - you show it.** Do not paste an English example at a Chinese-speaking user. - *Only* commands, flags, URLs, file paths, and code identifiers stay verbatim - (never translate `teamai pull`, `--scope user`, `/teamai`, a repo URL, etc.). - Example: for a Chinese user, the member-invite line becomes - `/teamai 帮我加入团队的 TeamAI,仓库地址是 https://...`, not the English form. + Answer in whatever language the user used, for the whole conversation. This + applies to **everything you write**: the invite line you give an admin to + forward, the one-line explanations, the "what's next" summary — all of it is + translated before you show it. *Only* commands, flags, URLs, file paths and + code identifiers stay verbatim (never translate `teamai pull`, `--scope user`, + `/teamai`, a repo URL). 2. **Never teach Git.** Do not mention branches, commits, clone, or push/pull of Git itself. TeamAI hides all of that. The user thinks in terms of "my team's skills", not repositories. -3. **Always use a full URL** for the team repo (e.g. - `https://github.com/yourorg/yourrepo`). Never use the `owner/repo` short form. -4. **You run the commands.** Only pause to ask the user when you need a web login, +3. **You run the commands.** Only pause to ask the user when you need a web login, a value only they know, or a genuine either/or choice. Show each command before you run it, in one short line. -5. **Detect the current AI tool first.** TeamAI behaves differently per host. Note +4. **Detect the current AI tool first.** TeamAI behaves differently per host. Note which tool this conversation is running in (Claude Code, Cursor, CodeBuddy, WorkBuddy, ChatGPT App, Codex, OpenCode, Kiro, Gemini CLI, …). When you reopen a session, use the name of **this** tool — do not assume Claude Code or Cursor. - Some hosts need extra manual steps for hooks — see - `references/troubleshooting.md` ("Agent-specific caveats"). -6. **Prerequisite:** Node.js ≥ 20. Install once with `npm install -g teamai-cli` - and verify with `teamai --version`. -7. **Finish with `teamai doctor`.** Every setup/onboarding flow ends by running - `teamai doctor` and resolving whatever it reports before you call it done. -8. **After init, resources appear on the NEXT session.** `teamai init` injects a - session-start hook that auto-runs `teamai pull`. It is normal that the skills/ - rules directories are empty right after init — they fill in when the user opens - a fresh session in this tool. To sync immediately, run `teamai pull`. -9. **Don't limit which AI tools get set up — cover all of them by default.** Unless - the user explicitly says "only install to Claude Code" (or names specific - tools), do **not** pass `--agent` to restrict the install. Let `teamai init` set - up **every AI tool already installed on the machine** (omitting `--agent` gives - an interactive picker; select all detected tools, or the user's stated subset). - **After init, report which agents were set up** — tell the user, in their - language, exactly which tools will now auto-start TeamAI (and which detected - tools were skipped and why, e.g. Codex trust-gate / CodeBuddy design). Verify - the real per-tool result with `teamai doctor` / `teamai hooks list`. - -## Command cheat sheet (ground truth — do not invent flags) + Some hosts need extra manual steps for hooks — see the troubleshooting + reference ("Agent-specific caveats"). + +## Daily commands ```bash -teamai init # Set up / join a team (configure provider, clone, register) -teamai init --scope user # Install for the whole machine instead of just this project -teamai pull # Sync team resources into local AI tools now -teamai push # Publish your local skills/rules/docs to the team -teamai doctor # Diagnose configuration and hook problems -teamai status # Show local vs team differences -teamai list # List resources (skills|rules|docs|env|agents|hooks|mcp) -teamai members # See team members (subcommand: teamai members list) -teamai roles # Manage roles / resource namespaces -teamai projects # Manage multiple projects from one repo (list|set|members) -teamai packages # Install team-declared npm packages & Claude plugins -teamai env # Manage shared team environment variables -teamai dashboard # Open the AI coding session dashboard (web UI, default port 3721) -teamai contribute --file

--title # Contribute a knowledge doc (usually via the teamai-share-learnings skill) +teamai pull # Sync team resources into local AI tools now +teamai push # Publish your local skills/rules/docs to the team +teamai status # Show local vs team differences +teamai doctor # Diagnose configuration and hook problems +teamai list # List resources (skills|rules|docs|env|agents|hooks|mcp) +teamai recall # Search what the team has already learned ``` -Anything not in this cheat sheet: check `teamai --help` before using it. -Do **not** guess flags (for example, there is no member-invite flag in the CLI — -inviting a member is done on the Git platform's website; see -`references/manage-admin.md`). +Every other command, every flag, and the flags `--help` hides live in the +generated reference below. Read it instead of guessing a flag. + +## References + +| File | When to load it | +|---|---| +| `{SKILL_DIR}/references/commands.md` | Before using any command not in the daily list, or any flag. Generated from the CLI's own command table, so it cannot drift. | +| `{SKILL_DIR}/references/troubleshooting.md` | A command fails, a hook does not fire, or a host needs manual steps. | + +`teamai skill get core --full` prints this skill with both references appended +(about 26 KB). Load a single file above when you only need one. diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md new file mode 100644 index 00000000..67a166df --- /dev/null +++ b/skill-data/core/references/commands.md @@ -0,0 +1,391 @@ +# teamai command reference + +Every command the installed CLI accepts, rendered from its own command table. +Flags marked `(hidden)` work but are absent from `--help`, so treat this file — +not `--help` — as the complete list. + +Generated: do not edit by hand. Regenerate with +`npx vitest run commands-reference -u` after changing a command or a flag. + + +## Global options + +- `-V, --version` — output the version number +- `--dry-run` — Preview mode, no changes made +- `-v, --verbose` — Verbose output + +## init + +- `teamai init [repo]` — Initialize teamai (configure Git provider, clone repo, register member) + - `--repo ` — Team repo (alias of the positional argument) + - `--http ` — Git-free HTTP team repo (read-only consumer; only needs an API key) + - `--self` — Single-repo mode: the current git repo is the team repo (equivalent to `teamai init .`). Knowledge lives on main under .teamai/; reports go to the teamai-reports orphan branch. + - `--token ` — API key for HTTP team repo / status reporting (stored 0600, never committed). Also reads TEAMAI_API_TOKEN. + - `--scope ` — Install scope: project (default, /.teamai + /.claude) or user (~/.teamai + ~/.claude) + - `--inherit-user-scope` — In project scope, also sync safe user-scope resources and search its knowledge + - `--no-inherit-user-scope` — Disable user-scope inheritance for this project + - `--role ` — Primary role ID (e.g. hai_dev) for non-interactive setup + - `--project ` — Active logical project(s) from manifest/projects.yaml (comma-separated); scopes which project resources and learnings this directory syncs. Pass "all" to activate every project the manifest declares (a snapshot taken now) + - `--agent ` — AI tools to set up (e.g. claude, codex, cursor, codebuddy, workbuddy, dsh). Repeatable or comma-separated. In single-repo mode, selects which tool dirs to create; omit for an interactive picker. Additive on repeated runs. + - `--force` — Overwrite existing config without confirmation + +## push + +- `teamai push` — Push local resources to team repo + - `--all` — Push all without confirmation + - `--skill ` — Push a specific skill by path (e.g., ~/.claude/skills/hai/my-skill or skills/hai_dev/my-skill) + - `--role ` — Namespace for new skills, rules and agents (skills//, rules//, agents//) + - `--project ` — Target a project: each new resource goes to that project's namespace for its own type — skills, knowledge for rules, agents (from manifest/projects.yaml) + +## pull + +- `teamai pull` — Pull team resources and inject into local AI tools + - `--silent` — Silent mode (for hooks) + - `--force` — Force full sync even if repo is unchanged + +## status + +- `teamai status` — Show local vs team repo diff + - `--all` — List every project data partition under ~/.teamai/projects (flags stale/orphan ones) + +## list + +- `teamai list [type]` — List resources (skills|rules|docs|env|agents|hooks|mcp). For skills, --source local/all also scans installed AI agent skill directories. + - `--source ` — Where to look for skills: repo | local | all + - `--agent ` — Filter local agents by id (only applies to skills) + - `--reveal` — Show env values in plaintext (default: masked) + +## skill + +- `teamai skill` — List and inspect skills (default: list all skills across repo + installed agents) + - `teamai skill list` — List all skills (alias for: teamai list skills --source all) + - `--json` — Output the CLI-served built-in skill catalog as JSON + - `teamai skill get ` — Print built-in skill content served by the installed CLI + - `--full` — Append the skill's references/ and templates/ files + - `--all` — Print every skill the CLI serves + - `teamai skill path [name]` — Print the packaged directory of a built-in skill (for scripts and templates) + - `teamai skill show ` — Show skill metadata: source / contributors / installed agents / description + - `teamai skill exclude` — Manage per-user skill exclusion (skip sync without affecting team repo) + - `teamai skill exclude list` — List excluded skills + - `teamai skill exclude add ` — Add skill(s) to the exclude list + - `teamai skill exclude remove ` — Remove skill(s) from the exclude list + +## members + +- `teamai members` — Manage team members + - `teamai members list` — List team members + +## remove + +- `teamai remove ` — Remove resource(s) from team repo and all local AI tools (type: skills|rules|agents|mcp) + - `--force` — Skip confirmation prompt + +## packages + +- `teamai packages [target]` — Install team npm packages and Claude plugins declared in teamai.yaml + - `-g, --global` — Install an npm target globally (for CLI tools) + - `--registry ` — Use a specific npm registry for this target + - `--npm` — Treat an ambiguous target as an npm package + - `--claude` — Treat the target as a Claude plugin + - `teamai packages install [target]` — Install team npm packages and Claude plugins declared in teamai.yaml + - `-g, --global` — Install an npm target globally (for CLI tools) + - `--registry ` — Use a specific npm registry for this target + - `--npm` — Treat an ambiguous target as an npm package + - `--claude` — Treat the target as a Claude plugin + +## doctor + +- `teamai doctor` — Diagnose configuration issues + - `--json` — Output the report as JSON (suitable for CI) + +## roles + +- `teamai roles` — Manage team roles and resource namespaces + - `teamai roles init` — Create a roles manifest for the team repo (admin) + - `teamai roles list` — List all defined roles and your current role + - `teamai roles set ` — Set your primary role (updates local config) + - `--add ` — Additional roles to include + - `teamai roles add ` — Add a new role to the manifest (admin) + - `--namespaces ` — Comma-separated resource namespaces (e.g. common,hai) + - `-d, --description ` — Description for the role + - `teamai roles remove ` — Remove a role from the manifest (admin) + - `teamai roles update ` — Update a role in the manifest (admin) + - `--add-namespaces ` — Comma-separated namespaces to add + - `--remove-namespaces ` — Comma-separated namespaces to remove + - `-d, --description ` — New description for the role + +## projects + +- `teamai projects` — Manage multi-project resource distribution (orthogonal to roles) + - `teamai projects list` — List defined projects and the ones active in this directory + - `teamai projects set [ids]` — Set the projects active in this directory (comma-separated or repeated; empty to clear) + - `teamai projects members ` — List members registered for a project + +## tags + +- `teamai tags` — Manage tag-based skill/rule filtering + - `teamai tags list` — List all available tags and subscription status + - `teamai tags subscribe ` — Subscribe to tags (only matching skills/rules will be synced) + - `teamai tags unsubscribe ` — Unsubscribe from tags + - `teamai tags add ` — Add tags to a skill or rule in tags.yaml (admin) + + Resource type: "skills" or "rules" + Name of the skill or rule (directory name) + One or more tags to add + + Examples: + $ teamai tags add skills hai-deploy hai infra + $ teamai tags add rules common-coding-style coding best-practices + + - `teamai tags remove ` — Remove tags from a skill or rule in tags.yaml (admin) + + Resource type: "skills" or "rules" + Name of the skill or rule (directory name) + One or more tags to remove + + Examples: + $ teamai tags remove skills hai-deploy infra + $ teamai tags remove rules common-coding-style best-practices + + +## source + +- `teamai source` — Manage cross-team skill sources + - `teamai source add ` — Add a cross-team source repo + - `--name ` — Alias for this source + - `teamai source remove ` — Remove a source and clean up its skills + - `teamai source add-http ` — Add an HTTP source (report/sync/ack) alongside a git main repo + - `--token ` — API token for the HTTP endpoint (stored 0600, never committed) + - `--force` — Overwrite an existing HTTP source config + - `teamai source remove-http` — Remove the HTTP source and clean up its resources + - `teamai source reconcile-plugins` — Run plugin reconcile worker (called internally by session_start hook) + - `teamai source list` — List all configured sources + - `teamai source browse ` — Browse public skills from a source + +## update + +- `teamai update` — Check for updates and upgrade teamai CLI + - `--check` — Only check if an update is available, do not install + +## uninstall + +- `teamai uninstall` — Remove all teamai-managed resources and hooks from this machine + - `--force` — Skip confirmation prompt + - `--agent ` — Only uninstall this agent's resources; shared resources go only if it is the last tool + +## env + +- `teamai env` — Manage team environment variables + - `--reveal` — Show env variable values in plaintext (default: masked) + - `teamai env list` — List team environment variables + - `--reveal` — Show env variable values in plaintext (default: masked) + - `teamai env add ` — Add or update a team environment variable + - `-d, --description ` — Description for the variable + - `teamai env remove ` — Remove a team environment variable + +## hooks + +- `teamai hooks` — Manage teamai hooks in AI tool settings + - `teamai hooks list` — List hook install status + effective built-in (A) and team (B) hooks + - `teamai hooks inject` — Inject teamai hooks into all AI tool settings + - `--silent` — Silent mode (suppress success message) + - `teamai hooks remove` — Remove teamai hooks from all AI tool settings + +## mcp + +- `teamai mcp` — Manage team MCP servers across AI tools + - `teamai mcp list` — List team MCP servers and their per-tool install status + - `teamai mcp inject` — Inject team MCP servers into all AI tool configs + - `--dry-run` — Show what would change without writing + - `--force` — Overwrite servers that collide with user-owned entries + - `teamai mcp remove` — Remove all teamai-managed MCP servers from AI tool configs + +## webhook + +- `teamai webhook` — Manage webhook integrations for team notifications + - `teamai webhook list` — List configured webhook endpoints + - `teamai webhook test` — Send test event to webhook endpoints + - `--url ` — Test specific endpoint URL + +## track + +- `teamai track [toolName] [toolInput]` — Track a tool usage event (called by PostToolUse hook) + - `--stdin` — Read hook data from STDIN (Claude Code hook format) + - `--tool ` — Tool identifier for usage attribution (e.g. claude, claude-internal) + +## track-slash + +- `teamai track-slash` — Track a slash command usage (called by UserPromptSubmit hook) + - `--stdin` — Read hook data from STDIN + - `--tool ` — Tool identifier for usage attribution (e.g. claude, claude-internal) + +## stats + +- `teamai stats` — Show local skill usage statistics + - `--by-repo` — Break usage down per repository + - `--by-time` — Show activity by hour of day + +## session + +- `teamai session` — Record and inspect coding-session summaries + - `teamai session save` — Record a privacy-scrubbed summary of a coding session to a local monthly log + - `--session-id ` — Session to record (default: most recent, or $CLAUDE_SESSION_ID) + - `--push` — Also push the summary to the team repo (feeds `teamai digest`) + - `--force` — Push even if the session is not flagged as valuable + - `--include-prompt` — Include the redacted first-prompt line in the pushed summary (default: off) + - `--scope ` — Config scope for --push: user | project (default: auto-detect) + +## digest + +- `teamai digest` — Generate weekly team activity digest + +## dashboard + +- `teamai dashboard` — Start the AI coding session dashboard (Web UI) + - `-p, --port ` — Port number + +## dashboard-report + +- `teamai dashboard-report` — Report session state to dashboard (called by hooks) + - `--stdin` — Read hook data from STDIN + - `--tool ` — Tool identifier (e.g. claude, claude-internal) + +## hook-dispatch + +- `teamai hook-dispatch ` — Unified hook dispatcher — handles all teamai hooks for a given event in one process + - `--stdin` — Read hook data from STDIN (accepted for forward compat, always reads STDIN) + - `--tool ` — Tool identifier (e.g. codebuddy, workbuddy, claude) + - `--matcher ` — Hook matcher for PostToolUse (e.g. Skill, Bash) + - `--bg-only` — Internal: run only fire-and-forget background handlers (used by the detached child) + - `--stdin-file ` — Internal: read the hook payload from this file instead of STDIN + +## bind-project + +- `teamai bind-project` — Bind the current workspace to a ClawPro project for HTTP local-agent sync + - `--project-id ` — Project ID from /projects/mine + - `--skip` — Mark current workspace as skipped (never prompt again) + +## contribute-check + +- `teamai contribute-check` — Check if session qualifies for contribution (called by PostToolUse hook) + - `--stdin` — Read hook data from STDIN + - `--tool ` — Tool identifier (e.g. claude, claude-internal) + +## contribute + +- `teamai contribute` — Contribute session knowledge to team repo + - `--file ` — Path to the contribution document + - `--title ` — Title for the contribution document + - `--session-id <id>` — Session ID for dedup tracking + - `--scope <scope>` — Target scope: user or project + +## recall + +- `teamai recall [query]` — Search team learnings knowledge base + - `--depth <level>` — Recall depth: route (entry-points only) | context (module-level, default) | lookup (full graph traversal) + - `--check` — Relevance precheck only: print RELEVANT/NOT_RELEVANT + top score; no file reads, no upvote + - `teamai recall disable` — Disable automatic knowledge-base recall + - `teamai recall enable` — Enable automatic knowledge-base recall + - `teamai recall status` — Show recall feature status + - `teamai recall feedback` — Record manual feedback for a recalled document + - `--positive <docId>` — Upvote a document (marks as actually useful) + - `--negative <docId>` — Record negative signal for a document + - `teamai recall maintenance` — Automatic maintenance of team knowledge base + - `--prune` — Remove low-confidence learnings + - `--threshold <n>` — Confidence threshold for pruning (default 0.15) + - `--archive` — Move to archive/ instead of deleting + - `--confidence-writeback` — Update frontmatter confidence scores + - `--update-quality` — Find stale docs/rules/skills and suggest updates + - `--dry-run` — Show what would be done without making changes + - `teamai recall promote [learningId]` — Promote a high-confidence learning to formal knowledge (docs/skills/rules) + - `--category <cat>` — Target category: skills | rules | docs + - `--dry-run` — Show what would be done without making changes + +## todowrite-hint + +- `teamai todowrite-hint` — Remind the agent to invoke teamai-recall when TodoWrite is used (PostToolUse hook) + - `--stdin` — Read hook data from STDIN + - `--tool <name>` — Source AI tool (claude / codebuddy / cursor) + +## import + +- `teamai import` — Import knowledge from local directories, remote repos, organizations, MRs, or iWiki + - `--dir <path>` — Extract code knowledge from a local directory (same as --from-repo but no clone) + - `--from-claude` (hidden) — Scan Claude/Cursor rule directories (~/.claude/rules, ~/.cursor/rules) + - `--from-mr <url>` — Extract learning from merged MR/PR and trigger incremental teamwiki update + - `--from-iwiki <space-id-or-url>` — Import documents from iWiki Space ID or page URL (requires TAI_PAT_TOKEN) + - `--resume` (hidden) — Resume an interrupted import session + - `--all` — Accept all suggestions without interactive confirmation + - `--output <path>` (hidden) — Write drafts to this directory instead of pushing to team repo + - `--from-repo <url>` — Clone a remote repo and generate per-repo codebase summary + - `--ssh` (hidden) — Force SSH clone even if HTTPS token is available + - `--domain <name>` (hidden) — Skip AI recommendation and assign repo to this domain explicitly + - `--from-repo-list <path>` — Batch import repos from a YAML whitelist + - `--concurrency <n>` (hidden) — Concurrent repos for --from-repo-list (default 3) + - `--incremental` — Use cached clone with fetch+reset (with --from-repo or --from-repo-list) + - `--skip-enrich` — Skip AI enrichment (only clone + extract + graph, no LLM calls) + - `--from-org <org>` — List repos under an org and generate a repo whitelist + - `--max-repos <n>` (hidden) — Cap on repos pulled from --from-org (default 200) + - `--exclude-archived` (hidden) — Exclude archived repos from --from-org (default true) + - `--include-pattern <re>` (hidden) — Regex to include repos by full name (used with --from-org) + - `--exclude-pattern <re>` (hidden) — Regex to exclude repos by full name (used with --from-org) + - `--skip-import` (hidden) — Only write drafts; skip the actual --from-repo-list run + - `--iwiki-dual` (hidden) — Enable dual-output mode for --from-iwiki (write codebase sections in addition to learning) + - `--require-review` (hidden) — Defer codebase section writes to .teamai/pending-review.jsonl for human review + - `--cache-status` — Show import cache status (repos cached, disk usage) + - `--cache-gc` — Garbage-collect stale import cache entries + - `--json` — Output cache status or GC result as JSON + - `--max-bytes <n>` (hidden) — Override capacity cap for --cache-gc + - `--stale-days <n>` (hidden) — Threshold for stale-eviction in days (default 30) + +## mr-hint + +- `teamai mr-hint` — Hint AI about recently merged but un-imported MRs (SessionStart hook) + - `--stdin` — Read hook data from STDIN + - `--tool <name>` — Source AI tool (claude / codebuddy / cursor) + +## codebase + +- `teamai codebase` — Inspect and maintain team-codebase outputs + - `--extract [path]` — Extract code knowledge and build graph from source + - `--incremental` (hidden) — Only re-extract changed files (requires prior manifest) + - `--project <name>` (hidden) — Project slug for --extract (defaults to directory name) and required for --deep-enrich + - `--max-files <n>` (hidden) — Max source files to scan (default: 200) + - `--upgrade-wiki` (hidden) — Migrate docs/team-codebase/ to teamwiki/ graph format + - `--lint` — Run global consistency lint over the teamwiki knowledge graph + - `--reconcile` — Reconcile product and code knowledge in teamwiki + - `--deep-enrich` — Generate deep knowledge docs from extracted evidence + - `--fix` (hidden) — Deprecated: teamwiki lint has no autofix; runs lint in report-only mode + - `--status` — Show knowledge-base git baseline (headSha / repoUrl / branch) + - `--severity <level>` (hidden) — Minimum severity to report: high|medium|low|info + - `--json` — Output report as JSON (suitable for CI) + - `--output <path>` (hidden) — Custom teamwiki output root directory + +## review + +- `teamai review [id]` — Inspect and process .teamai/pending-review.jsonl items + - `--apply` — Apply the change for the given id (only for codebase-section) + - `--reject` — Reject the given id without applying + - `--reason <msg>` — Reason for reject + - `--all-apply` — Apply all items at or below --max-risk + - `--max-risk <level>` — Risk ceiling for --all-apply: high|medium|low (default medium) + - `--json` — Machine-readable output + +## ci + +- `teamai ci` — CI pipeline integration commands + - `teamai ci extract-mr` — Extract knowledge from MR/PR and post as comment or write to team repo + - `--url <url>` — MR/PR web URL + - `--mode <mode>` — Operation mode: comment | write | both + - `--team-repo <path>` — Team knowledge repo path (required for write mode) + - `--comment-marker <marker>` — HTML comment anchor for idempotent updates + - `--write-mode <mode>` — Write strategy: direct | pending-review + - `--output <dir>` — Write artifacts to directory + - `--individual-comments` — Post each suggestion as separate comment with reaction/resolve support + +## deep-enrich + +- `teamai deep-enrich` — Run deep AI knowledge generation for an imported repo + - `--project <slug>` — Project slug (directory name in evidence/code/) + - `--wiki-root <path>` — Teamwiki root path + - `--max-modules <n>` — Max modules to process (cost control) diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index d4f87ac5..af2ec6ad 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -115,7 +115,7 @@ session and verify with `teamai pull` + `teamai list`. The sandbox **does not add hooks automatically** after `teamai init`. The user must **manually edit the config file to register the hook** so auto-sync works. Walk them through opening the tool's config and adding the TeamAI session-start -hook entry; if unsure of the exact config, run `teamai doctor` and `teamai hooks` +hook entry; if unsure of the exact config, run `teamai doctor` and `teamai hooks list` to see what should be present, then have them replicate it. Until then, they can sync with a manual `teamai pull`. diff --git a/skill-data/setup/SKILL.md b/skill-data/setup/SKILL.md new file mode 100644 index 00000000..623541f5 --- /dev/null +++ b/skill-data/setup/SKILL.md @@ -0,0 +1,73 @@ +--- +name: setup +description: >- + TeamAI day 0 and repo lifecycle: create a team repo as admin, join an existing team as a member, + manage members, roles, MCP and env, and uninstall. Loaded on demand by the teamai discovery stub. +allowed-tools: Bash(teamai:*), Bash(npx teamai-cli:*) +--- + +# teamai — setup and lifecycle + +You run the commands; the user only makes choices when you ask. **They may not +know Git** — never explain branches, commits or clones. + +## Before anything + +```bash +node --version # must be >= 20 +teamai --version # install once with: npm install -g teamai-cli +``` + +Then pick the flow. A user **setting up a new team** becomes its admin and creates +the repo; a user **joining an existing team** needs a repo URL from their admin. A +would-be member without a URL is still the **join** flow — `join-member.md` tells +them how to ask for it. Do not send them to the create-repo flow because the URL +is missing. + +| The user wants to… | Load this | +|-----------------------------------------------------------------------------|---------------------------------------------| +| Set up TeamAI for a team from scratch (create the repo) | `{SKILL_DIR}/references/setup-admin.md` | +| Join their team, with or without a repo URL | `{SKILL_DIR}/references/join-member.md` | +| Publish or update skills, rules, MCP, env; invite members; manage roles | `{SKILL_DIR}/references/manage-admin.md` | +| Remove TeamAI from this machine | `{SKILL_DIR}/references/uninstall.md` | +| Publish one skill or contribute a doc | `teamai skill get share` | +| Anything that breaks along the way | `teamai skill get core --full` (troubleshooting) | + +Supported Git providers are Tencent TGit (工蜂), GitHub, GitLab and CNB; +`setup-admin.md` carries the detection probe, the sign-in and create-repo URLs, +and the per-provider caveats. + +## Rules for these flows + +1. **Always use a full URL** for the team repo (e.g. + `https://github.com/yourorg/yourrepo`). Never the `owner/repo` short form. +2. **Don't limit which AI tools get set up — cover all of them by default.** + Unless the user names specific tools, do **not** pass `--agent` to restrict the + install. Let `teamai init` set up every AI tool already installed (omitting + `--agent` gives an interactive picker; select all detected tools). **After init, + report which agents were set up** — in the user's language, which tools now + auto-start TeamAI, and which detected tools were skipped and why (e.g. Codex + trust-gate, CodeBuddy design). Verify the real per-tool result with + `teamai doctor` and `teamai hooks list`. +3. **After init, resources appear on the NEXT session.** `teamai init` injects a + session-start hook that auto-runs `teamai pull`. Empty skills/rules directories + right after init are normal; they fill in when the user opens a fresh session in + this tool. To sync immediately, run `teamai pull`. +4. **Finish with `teamai doctor`.** Every setup or onboarding flow ends by running + it and resolving what it reports before you call the job done. + +Every command and flag, including the ones `--help` hides, is listed in +`teamai skill get core --full` under `references/commands.md`. Do not guess a flag: +there is no member-invite flag, for instance — inviting happens on the Git +platform's website, as `manage-admin.md` describes. + +## References + +| File | When to load it | +|---|---| +| `{SKILL_DIR}/references/setup-admin.md` | Creating a team repo: provider detection, auth, repo creation, first push. | +| `{SKILL_DIR}/references/join-member.md` | Joining an existing team from a repo URL. | +| `{SKILL_DIR}/references/manage-admin.md` | Day-to-day admin: publishing resources, roles, projects, MCP, env, members. | +| `{SKILL_DIR}/references/uninstall.md` | Removing TeamAI from a machine or from one agent. | + +`teamai skill get setup --full` prints this skill with all four appended (about 31 KB). diff --git a/skill-data/core/references/join-member.md b/skill-data/setup/references/join-member.md similarity index 90% rename from skill-data/core/references/join-member.md rename to skill-data/setup/references/join-member.md index 5a2929f9..12ea9a1a 100644 --- a/skill-data/core/references/join-member.md +++ b/skill-data/setup/references/join-member.md @@ -19,7 +19,7 @@ platforms, and — most importantly — **do not create a new repo.** Tell the u *"Ask your team's TeamAI admin for the repository URL, then come back and paste it here."* A member without a repo URL cannot continue; creating one would fork the team into a second, empty repo. (Setting up a brand-new team repo is the admin -flow — see `setup-admin.md` — not this one.) +flow — see `{SKILL_DIR}/references/setup-admin.md` — not this one.) ## Step 1 — Install and verify @@ -41,10 +41,9 @@ Match the login to the URL's host (do NOT create a second repo): - **`git.woa.com/...`** (Tencent TGit / 工蜂) → **you run both the `gf` install and the `gf … auth login`** (never tell the user to run them). Follow - `provider-tgit.md` ("Log in"); the user's only action is approving the login URL - in their browser / iOA. No `GITLAB_URL` needed. (No headless shortcut: - `TGIT_TOKEN` is REST-API-only and cannot clone, so the login has to be run once - on the machine.) + `{SKILL_DIR}/references/provider-tgit.md` ("Log in"); the user's only action is + approving the login URL in their browser / iOA. No `GITLAB_URL` needed. (Headless + only: pre-set `TGIT_TOKEN`.) - **`cnb.cool/...`** → install the CNB CLI, then authorize, in this order: 1. `npm install -g @cnbcool/cnb-cli` 2. `cnb login` — have the user approve it in the browser (OAuth2 device flow); @@ -98,7 +97,8 @@ teamai hooks list # per-tool: which AI tools actually got the hooks Fix anything `doctor` reports. **Don't trust the "Hooks injected into all AI tool settings" message alone** — it prints even for tools where nothing was written; `teamai doctor` / `teamai hooks list` show the real per-tool status. If it flags -hook problems, load `troubleshooting.md` ("Which tools actually get hooks"). +hook problems, load the troubleshooting reference (`teamai skill get core --full`), +section "Which tools actually get hooks". ## Step 6 — Confirm the skills actually arrived @@ -123,8 +123,7 @@ tool names only, on a separate branch of that same repo.) ## Agent-specific note If this conversation is running in **ChatGPT App** or **WorkBuddy**, the hooks -that drive auto-sync need an extra manual step — load `troubleshooting.md` -("Agent-specific caveats") and walk the user through it before finishing. +that drive auto-sync need an extra manual step — load the troubleshooting reference (`teamai skill get core --full`), section "Agent-specific caveats" and walk the user through it before finishing. ## If something is denied @@ -146,7 +145,7 @@ Summarize the outcome **in the user's own language** (global rule 1). Cover: 3. **They can also contribute a skill — just ask in plain language.** A member does not need to be an admin to publish a skill. They tell TeamAI something like *"share this xxx skill with my team"* / *"把这个 xxx skill 分享给团队"*, and you - run the publish for them (see `contribute-member.md`). + run the publish for them (see the share skill, `teamai skill get share --full`). 4. **How to leave — via the skill, not raw commands.** They can remove TeamAI any time by re-invoking the skill; you'll run it for them: `/teamai 卸载` / `/teamai Uninstall TeamAI`. diff --git a/skill-data/core/references/manage-admin.md b/skill-data/setup/references/manage-admin.md similarity index 94% rename from skill-data/core/references/manage-admin.md rename to skill-data/setup/references/manage-admin.md index 60e2f87b..9223a24c 100644 --- a/skill-data/core/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -74,7 +74,7 @@ repo per project: ```bash teamai projects list # projects defined + the ones active in this directory -teamai projects set <id> # set the active project(s) for this directory +teamai projects set [ids...] # set the active project(s) for this directory teamai projects members <id> # who is registered on a project ``` @@ -114,7 +114,7 @@ teamai env remove <KEY> # remove ## When sync fails Run `teamai doctor` first. If it reports hook or path problems, load -`troubleshooting.md`. Have the affected member reopen their session; if their tool +the troubleshooting reference (`teamai skill get core --full`). Have the affected member reopen their session; if their tool has no session-start hook, they run `teamai pull` manually. ## Capture a lesson learned @@ -124,7 +124,7 @@ worth sharing, TeamAI prompts the member and the dedicated **`teamai-share-learnings`** skill summarizes the session and runs `teamai contribute`. Nobody has to invoke it by hand. (Publishing a **reusable skill** someone authored is a different task — any member -can do it, see `contribute-member.md`.) +can do it, see the share skill, `teamai skill get share --full`.) ### Turn the sharing prompt on or off (admin) diff --git a/skill-data/core/references/setup-admin.md b/skill-data/setup/references/setup-admin.md similarity index 96% rename from skill-data/core/references/setup-admin.md rename to skill-data/setup/references/setup-admin.md index f75ad458..b13d865c 100644 --- a/skill-data/core/references/setup-admin.md +++ b/skill-data/setup/references/setup-admin.md @@ -56,7 +56,7 @@ curl -sSf -m 3 -o /dev/null https://cnb.cool && echo "cnb: OK" || echo " - **Exactly one reachable** → use that one. - **Several reachable** → list them (TGit first when present) and let the user pick. - **None reachable** → stop. Tell the user to ask their own admin for a ready-made - repo URL, then switch to `join-member.md`. + repo URL, then switch to `{SKILL_DIR}/references/join-member.md`. Choose by **account + reachability only — never by region**. @@ -200,7 +200,7 @@ Claude Code"). Omitting `--agent` gives an interactive picker — select **every tool already installed** on the machine. Then **report back which agents were set up**, in the user's language: name the tools that will now auto-start TeamAI, and any detected tool that was skipped and why (e.g. Codex trust-gate, -CodeBuddy/WorkBuddy by design — see `troubleshooting.md`). +CodeBuddy/WorkBuddy by design — see the troubleshooting reference, `teamai skill get core --full`). ## Step 6 — Verify with doctor @@ -215,8 +215,8 @@ Resolve everything `doctor` flags before continuing. it prints even for tools where nothing was written. `teamai doctor` / `teamai hooks list` show the real per-tool status. Only the tool you set up (e.g. `claude`) is expected to show hooks installed; others are skipped by design or not yet supported, -which is normal. Full table in `troubleshooting.md` ("Which tools actually get -hooks"). +which is normal. Full table in the troubleshooting reference +(`teamai skill get core --full`), section "Which tools actually get hooks". ## Step 7 — Grant members repo access (required before they can join) @@ -269,7 +269,7 @@ day-to-day work — they can keep letting the AI run things for them: - To manage the team later, they run: `/teamai 我已经装好了,帮我管理` (Chinese) / `/teamai I already have TeamAI set up, help me manage it` (English) — this loads - the daily-management flow (`manage-admin.md`): publishing skills, inviting + the daily-management flow (`{SKILL_DIR}/references/manage-admin.md`): publishing skills, inviting members, roles / packages / env. - To share a reusable skill with the team, they run: `/teamai 把这个 xxx skill 分享给团队` (Chinese) / @@ -295,4 +295,4 @@ One line, in their language: *"That removes the hooks and synced resources from your machine; your team repo on the website is untouched — you can rejoin any time with `/teamai` and the repo URL."* -(If they ask right now, load `uninstall.md` and run it for them.) +(If they ask right now, load `{SKILL_DIR}/references/uninstall.md` and run it for them.) diff --git a/skill-data/core/references/uninstall.md b/skill-data/setup/references/uninstall.md similarity index 100% rename from skill-data/core/references/uninstall.md rename to skill-data/setup/references/uninstall.md diff --git a/skill-data/share/SKILL.md b/skill-data/share/SKILL.md index 664bb522..c417e861 100644 --- a/skill-data/share/SKILL.md +++ b/skill-data/share/SKILL.md @@ -1,13 +1,19 @@ --- -name: teamai-share-learnings -description: "Contribute — 分享 Session 经验到团队知识库" +name: share +description: >- + Turn a session into a team learning: summarize what was solved, discovered or worked around, + and publish it to the team knowledge base with `teamai contribute`. Also publishes a reusable + skill or a knowledge doc on request. 分享 Session 经验到团队知识库。Loaded on demand by the + teamai discovery stub, and by the friction reminder that ends a session worth sharing. +allowed-tools: Bash(teamai:*), Bash(npx teamai-cli:*) --- # Contribute — 分享 Session 经验到团队知识库 总结本次 AI 编码 session 中学到的经验,推送到团队知识库。 -**【重要】所有生成的文档必须使用中文撰写。** +**文档使用用户本次会话所用的语言撰写**(中文提问 → 中文文档,英文提问 → 英文文档)。 +命令、参数、URL、路径和代码标识符保持原样。 ## When to Use @@ -19,7 +25,7 @@ description: "Contribute — 分享 Session 经验到团队知识库" ## How It Works 1. **总结**:回顾本次 session 的工具使用、解决的问题、发现的模式 -2. **生成文档**:用中文撰写 Markdown 文档,涵盖: +2. **生成文档**:撰写 Markdown 文档(语言同上),涵盖: - 任务/问题是什么 - 关键决策及原因 - 解决方案、变通方法或发现的模式 @@ -85,3 +91,17 @@ teamai contribute --file /tmp/session-summary.md --title "K8s pod 启动超时 - The document is pushed to the team repo's `teamai-learnings` branch, under `learnings/`, with no pull request - Team members will see it on their next `teamai pull` - Keep summaries concise and actionable — this is a knowledge base, not a diary + +## Publishing a reusable skill instead + +A member asking to publish a skill ("share this xxx skill with my team") is a +different flow: see `{SKILL_DIR}/references/contribute-member.md`. This file is for +turning a *session* into a learning. + +## References + +| File | When to load it | +|---|---| +| `{SKILL_DIR}/references/contribute-member.md` | The user wants to publish a skill, rule or doc they already have, rather than a session summary. | + +`teamai skill get share --full` prints this skill with that reference appended. diff --git a/skill-data/wiki/SKILL.md b/skill-data/wiki/SKILL.md index bf752c43..431bceba 100644 --- a/skill-data/wiki/SKILL.md +++ b/skill-data/wiki/SKILL.md @@ -1,20 +1,18 @@ --- -name: team-wiki-codebase -description: | - 让 AI 真正理解大型代码库。针对多仓库、多微服务、迭代多年的项目,通过架构逆向 + Graph RAG 图谱 + CLI 多语言 AST, - 将海量代码压缩为结构化知识库——每条结论可回溯代码行,每条关系有置信度标注。 - - 适用场景:项目有 10+ 仓库或微服务,AI 直接读代码无法全局理解、回答不准确、token 开销大。 - - 产出:组件设计文档 × N + 架构总览 + 桥梁文档 + Graph RAG 图谱(G1~G9) + _manifest.json + teamai extract graph (teamwiki/)。 - - Trigger: team-wiki-codebase, code-to-knowledge, 代码知识库, 架构分析, 架构逆向 - Prerequisites: 可访问的源码目录(支持多仓库);本 skill 目录下 `references/` 与 `scripts/` +name: wiki +description: >- + 让 AI 真正理解大型代码库:对多仓库、多微服务、迭代多年的项目做架构逆向 + Graph RAG 图谱 + 多语言 AST, + 把海量代码压缩成结构化知识库,每条结论可回溯代码行,每条关系有置信度标注。适用于 10+ 仓库或微服务、 + AI 直接读代码无法全局理解的项目。Triggers: 架构分析, 架构逆向, 代码知识库, code-to-knowledge, + architecture wiki, large multi-repo codebase. Loaded on demand by the teamai discovery stub. +allowed-tools: Bash(teamai:*), Bash(npx teamai-cli:*), Bash(python3:*) --- # team-wiki-codebase — 大型代码库 AI 认知工程 -> 方法论与脚本位于本 skill 的 `references/`、`scripts/`(`teamai pull` 后出现在 `.cursor/skills/team-wiki-codebase/` 或 `.codebuddy/skills/team-wiki-codebase/`)。人类可读概览见 [README.md](./README.md)。 +> 前置条件:可访问的源码目录(支持多仓库)、Python 3、已安装的 teamai CLI。 +> 方法论、子 agent 提示词、模板与脚本随 CLI 一起分发,运行 `teamai skill path wiki` 获取它们的绝对路径; +> 本文中的 `{SKILL_DIR}` 就是该路径。 > Phase 0 结构基线使用 `teamai codebase --extract`。TeamAI does not ship a separate team-wiki CLI. No extra plugin is required. **解决什么问题**:大型项目(10+ 仓库、数十微服务、迭代多年)让 AI 无法全局理解——上下文窗口装不下所有代码,组件关系散落各处,业务规则隐藏在深层调用链中。直接让 AI 读代码,既慢(海量 token)又不准(缺乏全局视角)。 @@ -23,11 +21,13 @@ description: | ## 使用方式 +用户用自然语言说明模式,或直接说“做代码库知识库”: + ``` -/team-wiki-codebase # 默认:Standard(单 session 核心路径) -/team-wiki-codebase --deep # Deep:完整 K1~K4 + G1~G9 -/team-wiki-codebase --update # 增量更新已有 knowledge/ -/team-wiki-codebase continue # 从 _review/progress.json 断点继续 +默认 Standard:单 session 核心路径 +--deep 完整 K1~K4 + G1~G9 +--update 增量更新已有 knowledge/ +continue 从 _review/progress.json 断点继续 ``` --- @@ -36,8 +36,8 @@ description: | | Agent | 文件 | 启动时机 | |-------|------|---------| -| 知识库文档生成 Agent | `references/agents/kb-doc-generator.md` | Phase K2 每批组件 | -| Graph RAG Agent | `references/agents/graph-rag-agent.md` | Phase K3 | +| 知识库文档生成 Agent | `{SKILL_DIR}/references/agents/kb-doc-generator.md` | Phase K2 每批组件 | +| Graph RAG Agent | `{SKILL_DIR}/references/agents/graph-rag-agent.md` | Phase K3 | **主 Agent 职责**:流程编排、确认点管理、progress.json 维护、质量报告汇总。 @@ -203,623 +203,32 @@ Step 7:组件级 diff(处理新增/删除仓库或组件) --- -## Phase 0:初始化 - -一次性向用户询问以下信息(**同一条消息,不分步骤**): - -1. **项目所有代码仓库路径**(用户把整个项目涉及的所有仓库地址列出来): - - 格式:每行一个绝对路径,或逗号分隔 - - 示例: - ``` - /path/to/api-gateway - /path/to/order-service - /path/to/user-service - /path/to/common-lib - ``` - - 说明:这是最关键的一步。大型项目的代码散布在多个仓库中,必须**全部提供**才能构建完整的架构认知。遗漏仓库 = 知识库盲区。 -2. **项目名称**(用于文档命名,如 "CVM"、"电商平台") -3. **产品文档来源**(可选,提供则生成 Type-5/6 桥梁文档): - - API 文档目录路径 - - 使用限制 / FAQ 文档路径 -4. **输出路径**(默认:第一个仓库的父目录下的 `knowledge/`) - -**Step 0A:仓库清单整理** - -收到用户提供的仓库列表后,构建仓库清单: - -``` -FOR 每个用户提供的路径: - 1. 验证路径存在且可访问 - 2. 检测是否为 git 仓库(是否有 .git 目录) - 3. 检测主要语言(按文件扩展名分布) - 4. 统计代码规模(文件数 + 估算行数) - 5. 记录 git commit SHA + tag - -结果写入 _review/repo-manifest.json: -{ - "repos": [ - { - "path": "/absolute/path/to/repo-a", - "name": "repo-a", - "language": "go", - "files": 320, - "lines_estimate": 45000, - "commit": "abc123", - "tag": "v1.2.0", - "accessible": true - }, - ... - ], - "total_repos": N, - "inaccessible": ["path/to/repo-x(权限不足)"] -} -``` - -展示给用户确认: -``` -已识别 {N} 个仓库: - ✅ repo-a (Go, ~45K 行) - ✅ repo-b (Python, ~12K 行) - ✅ repo-c (Go, ~28K 行) - ❌ repo-x (路径不存在或无法访问) - -总计: ~{N}K 行代码,{N} 个仓库 -确认无误后回复"继续",或补充遗漏的仓库。 -``` - -**Step 0B:自动检测主要语言**(按仓库列表汇总,不阻断流程): -``` -检测方法:汇总所有仓库的文件扩展名分布 - .go 文件占比最高 → language: "go" - .py 文件占比最高 → language: "python" - .java 文件占比最高 → language: "java" - .ts/.js 文件占比最高 → language: "typescript" - .rs 文件占比最高 → language: "rust" - 多语言混合(无明显主导) → language: "mixed" -备注:language 字段用于接口扫描时选择 grep 模式(详见 Phase K1 Step 5) -``` - -**Step 0C:记录基准版本**: -```bash -# 对每个仓库分别记录 -FOR repo in repos: - git -C <repo.path> rev-parse HEAD 2>/dev/null - git -C <repo.path> describe --tags --always 2>/dev/null -``` -写入 `_review/metadata.json`: -```json -{ - "project_name": "CVM", - "scan_time": "<ISO8601>", - "repos": [ - {"name": "repo-a", "commit": "<sha>", "tag": "<tag>"}, - {"name": "repo-b", "commit": "<sha>", "tag": "<tag>"} - ] -} -``` - -**Step 0D:CLI 结构基线(每个代码仓库,推荐)** - -在 K1 深读之前,用 TeamAI 提取可证据化的 import/call 结构边(Python/Go/TS 等,`code-ast`)并与 regex 基线合并(`code-heuristic`): - -```bash -# For each repo. Writes <repo>/teamwiki/ (evidence pages + .indices/graph-index.json). -# Existing flags only: --extract [path], optional --project <slug>, optional --incremental. -teamai codebase --extract <repo_abs_path> --project <project_slug> -``` - -- Output: `teamwiki/evidence/code/<project>/` pages; `teamwiki/.indices/graph-index.json` (structural edges). -- K1/K2/K3 写 `_manifest.json` 的 `edges[]` 时:**优先引用** extract 的 `code-ast` 边 + `evidenceRefs`(`path:line`),Agent 推断标 `INFERRED`/`AMBIGUOUS`。 -- After Phase K3, skip any extra graph compile / merge step that is not a `teamai` command. TeamAI does not ship a separate team-wiki CLI. Continue with this skill using `teamai` and the files under this skill directory. No extra plugin is required. - -写入初始 progress.json(current_phase: "phase0_done"),进入 **Phase K1**。 - ---- - -## Phase K1:架构逆向与源材料采集 - -**方法论**:`references/methodology/phase0-collection.md` + `references/methodology/phase1-reverse-engineering.md` - -### Step 1:可选运行扫描脚本(推荐) - -```bash -python3 scripts/scan_repo.py <project_root> --depth 2 --top 10 -``` -输出:文件统计 + 关键文件发现报告 + 语言分布。 - -### Step 2:关键文件提取 - -按优先级扫描(详见 phase0-collection.md): -- **P0 必须**:入口文件、路由/Handler、流程编排配置、Proto/IDL -- **P1 重要**:数据库 Schema(DDL)、常量/错误码定义 -- **P2 增强**:配置文件、测试文件(理解预期行为) - -### Step 3:架构逆向(详见 phase1-reverse-engineering.md) - -- 自底向上分层:叶子节点(DB/MQ) → 中间节点(编排/调度) → 根节点(API入口) -- 三层穿透追踪:对核心 API ≥5 条完成 API入口→编排层→服务执行层 全链路追踪 -- 构建 N×N 组件关系矩阵(标注通信方式:RPC/MQ/DB) - -### Step 4:生成架构分析报告 - -写入 `_review/k1-architecture-map.md`: - -```markdown -## 架构分层(≥4层) -| 层级 | 组件列表 | 核心职责 | 代码仓库 | - -## 组件清单 -| 组件名 | 架构层级 | **所属仓库** | 语言 | 核心度(P0/P1/P2) | 入口文件 | **接口校验类型** | - -接口校验类型取值(在确认点①请用户核对此列): - - `HTTP` → API 接入层,有 HTTP/gRPC 路由注册,需做接口数对账 - - `MQ` → 消息处理层,有 MQ Consumer/Exchange 声明,以 Topic 数做基准 - - `RPC` → 内部服务层,有 .proto / .thrift / IDL 文件,以 Method 数做基准 - - `NONE` → 调度/执行/数据层,无对外接口,不做接口数校验 - -## N×N 组件通信矩阵 -(值:RPC/MQ/DB/—,标注置信度 [E]EXTRACTED/[I]INFERRED/[A]AMBIGUOUS) - -## 核心调用链路(≥5条) -(格式:API(file:line) → 编排层(config:line) → 服务层(handler:line) → DB(table)) - -## 术语表 -| 内部术语 | 外部/产品术语 | 说明 | - -## 不确定项(供人工确认) -(标注 [A] 的关系和推断,说明不确定原因) -(接口校验类型不确定的组件,标注 [?] 等用户在确认点①明确) -``` - -### Step 5:接口清单扫描(按校验类型分别执行) - -**仅对 k1-architecture-map.md 中接口校验类型 ≠ NONE 的组件执行**: - -``` -FOR 每个 接口校验类型 = HTTP 的组件: - 执行 grep 扫描: - Go: grep -rn "\.GET\|\.POST\|\.PUT\|\.DELETE\|router\.Handle\|@handler" <component_dir> - Python: grep -rn "@app\.route\|@router\.\|APIRouter\|include_router" <component_dir> - 记录:组件名 → HTTP接口数 N(SCAN_CONFIDENCE: HIGH/MEDIUM) - -FOR 每个 接口校验类型 = MQ 的组件: - 执行 grep 扫描: - grep -rn "Exchange\|Queue\|Topic\|consumer\|subscribe\|@KafkaListener" <component_dir> - 记录:组件名 → MQ Topic/Queue 数 N - -FOR 每个 接口校验类型 = RPC 的组件: - 解析 .proto / .thrift 文件: - find <component_dir> -name "*.proto" -o -name "*.thrift" | xargs grep "^rpc\|^service" - 记录:组件名 → RPC Method 数 N -``` - -结果写入 `_review/interface-inventory.json`: -```json -{ - "ComponentA": {"type": "HTTP", "count": 13, "confidence": "HIGH"}, - "ComponentB": {"type": "MQ", "count": 5, "confidence": "MEDIUM"}, - "ComponentC": {"type": "RPC", "count": 8, "confidence": "HIGH"}, - "ComponentD": {"type": "NONE", "count": 0, "confidence": "—"} -} -``` - -**完成后**:更新 `current_phase` 为 `"phasek1_waiting_confirm"`。 - -**⛔ 确认点①** — 等待用户明确回复,不得自动进入下一阶段。 - -展示给用户: -``` -架构分析完成。 - -组件清单(共 N 个): - P0 核心: [列表] - P1 重要: [列表] - P2 辅助: [列表] - -接口扫描结果(供校验用): - HTTP 接口:ComponentA 13个, ComponentB 7个 - MQ Topic: ComponentC 5个 - RPC Method:ComponentD 8个 - 无接口组件:ComponentE, ComponentF, ... - -AMBIGUOUS 关系(请明确): - - ComponentX → ComponentY 的通信方式不确定 - -请确认(直接编辑 k1-architecture-map.md 后回复"继续"): - 1. 架构分层和 P0/P1/P2 标注是否正确? - 2. 每个组件的接口校验类型(HTTP/MQ/RPC/NONE)是否准确? - 3. 接口扫描数量是否合理?明显偏少说明有遗漏,偏多可能扫到了测试文件。 -``` - -确认后:更新 `"phasek1_confirmed"` → Phase K2。 - ---- - -## Phase K2:文档生成(分批并行 + 中间质量确认) - -**方法论**:`references/methodology/phase2-document-types.md` - -### 生成顺序(依赖链驱动,底层先写) - -``` -批次1: 数据层 + 基础执行层 Type-4 组件文档 ← 并行 -批次2: 资源/调度层 Type-4 组件文档 ← 并行 -批次3: 消息/服务层 Type-4 组件文档 ← 并行 -批次4: API入口层 Type-4 组件文档 ← 并行 - ⛔ 确认点② ← 人工抽查组件文档质量 -批次5: 架构总览层 (Type-1 + Type-2 + Type-3) ← 串行(依赖上层全部完成) -批次6: 桥梁文档 (Type-5 + Type-6 + Type-7) ← 串行(依赖产品文档) -批次7: 知识增强 (Type-8: 反模式/RPC契约/排障) ← 串行 -``` - -### 每批执行流程 - -读取 `references/agents/kb-doc-generator.md`,拼装输入包并启动: - -``` -component_list: 本批次组件/文档类型列表 -architecture_map: _review/k1-architecture-map.md 完整内容 -repos: _review/repo-manifest.json 中的仓库列表 -service_map: progress.json 中的 service_map -output_dir: <Phase 0> -project_name: <Phase 0> -product_docs_dir: <Phase 0,可为空> -methodology_dir: references/methodology/ -completed_docs: kb_progress.components_done(断点恢复跳过) -parallel_mode: true(批次1~4)/ false(批次5~7) -``` - -每批完成后: -- 将完成组件追加到 `kb_progress.components_done` -- 累加 `accuracy_stats`(从 Agent 返回的自校验摘要中提取) -- 更新 `current_phase` 为 `"phasek2_batch_N"` -- 展示本批次 token 消耗和 `[UNVERIFIED]` 统计 - -### ⛔ 确认点②(批次1~4完成后) - -展示给用户: -``` -已生成 {N} 份组件设计文档。准确性统计: - 总声明数: {N} | 已验证: {N} | [UNVERIFIED]: {N}({X}%) - AMBIGUOUS 关系: {N} 条 - -请抽查 2~3 份文档(建议选最复杂的组件): - 路径:<output_dir>/XX_<组件名>设计说明.md - -确认要点: - 1. AI 快速理解表的代码入口是否精确到函数名? - 2. 核心流程描述是否与代码实际一致? - 3. [UNVERIFIED] 比例是否可接受?(建议 <15%) - -如发现系统性问题,请描述,我将调整策略后重新生成。 -``` - -更新 `current_phase` 为 `"phasek2_waiting_confirm"`。 -用户确认后更新为 `"phasek2_confirmed"`,继续批次5~7。 - -### 全部批次完成后 - -写入 `_review/k2-doc-list.md`(文档清单:路径 + 规模KB + [UNVERIFIED]数 + 生成时间)。 -更新 `current_phase` 为 `"phasek2_done"` → Phase K3。 - ---- - -## Phase K3:AI-Native 增强 + 图谱文档集 - -**方法论**:`references/methodology/phase3-ai-enhancement.md` - -### Step 1:AI-Native 元素注入 - -对所有已生成文档补充(如 Phase K2 的 Agent 未完整添加): - -| 元素 | 要求 | 适用范围 | -|------|------|---------| -| `search-anchor` | 5~15 个关键词,标题后第一行 | 所有文档 | -| AI 快速理解表 | 10 维度,紧跟标题 | 所有 Type-4 组件文档 | -| 双向链接 | 组件↔主架构,桥梁↔组件 | 所有文档 | -| 检索路由规则 | 4条分流规则 + 4级优先级 | 仅技术架构总览 | -| QA 对 | 10~20 个高频问题+答案引用 | 仅技术架构总览第9章 | - -### Step 2:Graph RAG 图谱文档集 - -读取 `references/agents/graph-rag-agent.md`,拼装输入包并启动: - -``` -all_kb_docs_dir: <output_dir> -architecture_map: _review/k1-architecture-map.md -doc_list: _review/k2-doc-list.md -project_name: <Phase 0> -output_dir: <output_dir>/graph/ -methodology_file: references/methodology/phase2-document-types.md -``` +## 阶段流程(按需加载) -生成 G1~G9(每条关系强制置信度三态标注): +每个阶段的完整步骤在独立文件中,轮到该阶段时再加载,不要一次性读完: -| 图谱文档 | 解决的问题 | 置信度要求 | -|---------|---------|-----------| -| G1 组件依赖关系矩阵 | "谁依赖 X?" | EXTRACTED 来自文档明确描述 | -| G2 调用链路全景 + 状态机 + 约束矩阵 | "API 经过哪些模块?" | 调用链 EXTRACTED,推断依赖 INFERRED | -| G3 数据流与存储依赖图 | "数据存哪里?" | 读写关系 EXTRACTED | -| G4 错误码组件映射表 | "错误码是哪个模块的?" | EXTRACTED | -| G5 跨组件交互场景手册(≥10个时序图) | "配额检查怎么做?" | 时序 EXTRACTED,边界 INFERRED | -| G6 知识图谱三元组(≥100条) | "A 间接依赖谁?" | 每条标 E/I/A + 分值 | -| G7 架构风险与影响面分析 | "X 挂了影响多大?" | 直接依赖 EXTRACTED,间接 INFERRED | -| G8 核心配置参数索引 | "怎么改 XX 配置?" | EXTRACTED 来自配置文件 | -| G9 业务规则约束矩阵 + AI 推理决策树 | "能不能做 XX?" | 规则 EXTRACTED,推断 INFERRED | +| 阶段 | 文件 | 内容 | +|---|---|---| +| Phase 0 | `{SKILL_DIR}/references/phases/phase0-init.md` | 初始化、`teamai codebase --extract` 结构基线、仓库清单 | +| Phase K1 | `{SKILL_DIR}/references/phases/k1-reverse-engineering.md` | 架构逆向与源材料采集、扫描脚本、架构分析报告 | +| Phase K2 | `{SKILL_DIR}/references/phases/k2-documents.md` | 文档生成(分批并行 + 中间质量确认) | +| Phase K3 | `{SKILL_DIR}/references/phases/k3-ai-native.md` | AI-Native 增强 + Graph RAG 图谱文档集 | +| Phase K4 | `{SKILL_DIR}/references/phases/k4-quality.md` | 质量评估、校验脚本、质量报告 | -同时生成 `<output_dir>/graph/README.md`(索引 + 按问题类型查找表 + 检索路由建议)。 - -### Step 3:跨文档一致性校验 - -**Graph RAG Agent 完成后,主 Agent 自行执行此步骤(不委托给子 Agent)。** - -目的:检测组件文档之间的矛盾描述,防止"A 说调用 B 用 RPC,B 说被 A 用 MQ 调用"这类不一致。 - -``` -Step 3A:构建"声称矩阵" - - 对每份 Type-4 组件文档,从**两个层面**提取关系声称: - - 层面1:AI 快速理解表中的"上游组件"和"下游组件"字段 - 层面2:正文中的接口设计章节、核心流程章节中的调用描述 - - 如果层面1和层面2对同一关系描述不一致 → 首先记录为"文档内矛盾"(比表头和正文优先级更高的问题) - - 提取示例: - 组件X.md 表头声称: X→Y(RPC), X→Z(MQ) - 组件X.md 正文声称: X→Z(HTTP) ← 与表头矛盾! - 组件Y.md 表头声称: Y←X(RPC), Y→Z(DB) - 组件Z.md 表头声称: Z←X(HTTP), Z←Y(DB) - -Step 3B:交叉比对 - - FOR 每对组件 (A, B): - IF A.md 声称 "A→B 用 RPC" AND B.md 声称 "B←A 用 MQ": - → 记录矛盾: "A→B 通信方式不一致: A说RPC, B说MQ" - IF A.md 声称 "A→B" BUT B.md 未提到 "被A调用": - → 记录缺失: "A声称调用B,但B的文档未提及被A调用" - IF G1矩阵中的关系 与 组件文档声称不一致: - → 记录偏差: "G1矩阵说A→B(RPC),但A的文档说A→B(MQ)" - -Step 3C:生成一致性报告 - - 写入 `_review/k3-consistency-check.md`: - - ```markdown - # 跨文档一致性校验报告 - - ## 矛盾项(必须修复) - | 组件A | 组件B | A的描述 | B的描述 | 矛盾类型 | - |-------|-------|---------|---------|---------| - | X | Z | X→Z(MQ) | Z←X(HTTP) | 通信方式不一致 | - - ## 缺失项(建议补充) - | 声称方 | 被引用方 | 声称内容 | 缺失 | - |--------|---------|---------|------| - | A | B | A→B(RPC) | B的文档未提及被A调用 | - - ## G1矩阵偏差(建议对齐) - | G1矩阵 | 组件文档 | 偏差 | - - ## 统计 - - 矛盾项: N 处(❌ 需修复) - - 缺失项: N 处(⚠️ 建议补充) - - G1偏差: N 处(⚠️ 需对齐) - - 一致关系: N 条(✅) - - 一致率: X% - ``` - -Step 3D:自动修复(仅限明确情况) - - IF 矛盾项 > 0: - FOR 每个矛盾项: - 回溯代码验证:用 Grep 查找实际的调用方式(如 rpc.Call / mq.Publish) - IF 能明确正确方 → 修复错误方文档中的描述 + 更新 G1 矩阵 - IF 无法明确 → 标记为 AMBIGUOUS,留待用户在确认点确认 - 修复后重新统计一致率 - - IF 矛盾项 = 0: - → 跳过修复,直接进入 Phase K4 -``` - -**完成后**:更新 `current_phase` 为 `"phasek3_done"` → Phase K4。 - ---- +方法论背景(可选,写文档时参考):`{SKILL_DIR}/references/methodology/`; +子 agent 提示词:`{SKILL_DIR}/references/agents/`; +知识库 README 模板:`{SKILL_DIR}/references/templates/project-overview.md`。 -## Phase K4:知识库质量评估与报告 +人类可读概览(非执行用):`{SKILL_DIR}/references/overview.md`。 -**方法论**:`references/methodology/phase4-quality.md` - -### Step 1:自动校验 - -```bash -python3 scripts/validate_kb.py <output_dir> -``` - -输出(**必须完整展示,不得只展示通过项**): -``` -链接完整性: ✅/❌ N 个死链接 -search-anchor: ✅/⚠️ 覆盖率 N/M (X%) -AI 快速理解表: ✅/⚠️ 覆盖率 N/M (X%) -双向链接: ✅/⚠️ 覆盖率 N/M (X%) -README 索引: ✅/⚠️ 收录率 N/M (X%) -``` - -### Step 2:准确性审计 - -从 `accuracy_stats` 汇总全库可信度,同时从 `interface_coverage` 汇总接口覆盖情况: - -``` -【内容准确性】 -总声明数: N 条(业务规则 + 接口描述 + 关系) -已验证(有代码引用): N 条 (X%) -[UNVERIFIED]: N 条 (X%) -AMBIGUOUS 关系: N 条 (X%) - -【接口覆盖率】(仅统计 HTTP/MQ/RPC 类型组件,NONE 类型不计入) -HTTP 接口: 文档记录 M 个 / 扫描基准 N 个 = X% -MQ Topic: 文档记录 M 个 / 扫描基准 N 个 = X% -RPC Method: 文档记录 M 个 / 扫描基准 N 个 = X% -综合覆盖率: X% 目标 ≥ 90% - -⚠️ 接口缺口清单(文档记录 < 扫描基准 的组件): - - ComponentA: 文档记录 8 个,扫描基准 13 个,缺口 5 个 → 建议补充 -``` - -⚠️ 需人工确认清单:([UNVERIFIED] > 20% 的文档 + 接口缺口组件 + AMBIGUOUS 关系) - -### Step 3:RAG 检索抽检 - -按 `phase4-quality.md §RAG检索测试用例` 测试 7 类问题各 1 个(详见方法论),记录命中率。 - -### Step 4:AI 端到端验证(E2E Validation) - -**核心思路**:用知识库回答一组标准化问题,然后**回溯代码验证答案正确性**,检测知识库是否能让 AI 给出正确答案。 - -``` -Step 4A:生成标准验证问题集(自动,基于已有文档) - - **优先使用用户提供的外部验证集**: - IF 用户在 Phase 0 或此时提供了验证问题列表(3~10 个真实业务问题): - → 优先使用用户问题作为验证集(标注来源: USER) - → 自动补充至 10~15 题(标注来源: AUTO) - ELSE: - → 全部自动生成(标注来源: AUTO) - - > 用户提供的问题更有价值,因为 AI 自己出题容易考自己已知的领域, - > 真正的盲区(AI 没理解但没意识到的)只有外部问题才能测到。 - - 从 k1-architecture-map.md 和 k2-doc-list.md 自动生成 10~15 个验证问题: - - 问题类型分布(至少覆盖以下 5 类): - - ┌────────────────────────────────────────────────────────────────────┐ - │ 类型1:组件职责(3题) │ - │ 模式:"<组件名> 的核心职责是什么?代码入口在哪?" │ - │ 验证方式:答案中的函数名/文件名必须在代码中存在 │ - │ │ - │ 类型2:调用关系(3题) │ - │ 模式:"<组件A> 和 <组件B> 之间是什么关系?通过什么方式通信?" │ - │ 验证方式:答案与 G1 矩阵 + 代码实际 import/call 一致 │ - │ │ - │ 类型3:操作约束(2题) │ - │ 模式:"在 <状态X> 下能否执行 <操作Y>?" │ - │ 验证方式:答案与 G9 约束矩阵 + 代码中的状态检查一致 │ - │ │ - │ 类型4:数据流向(2题) │ - │ 模式:"<操作Z> 最终会写入哪些表/队列?" │ - │ 验证方式:答案与 G3 数据流 + 代码实际 SQL/MQ 操作一致 │ - │ │ - │ 类型5:错误排查(2题) │ - │ 模式:"错误码 <XXX> 是什么意思?在哪个组件产生?" │ - │ 验证方式:答案与 G4 错误码映射 + 代码中的错误定义一致 │ - │ │ - │ 类型6(可选):认知边界测试(2题) │ - │ 模式:故意问知识库不覆盖的内容(如第三方 SDK 内部、历史架构变迁) │ - │ 验证方式:AI 应回答"超出知识库覆盖范围"而非幻觉 │ - └────────────────────────────────────────────────────────────────────┘ - -Step 4B:用知识库回答(模拟 AI 使用场景) - - FOR 每个验证问题: - 1. 假设只能读知识库文档,不能直接读代码 - 2. 按检索路由规则,找到对应文档 - 3. 从文档中提取答案 - -Step 4C:代码回溯验证 - - FOR 每个答案: - 1. 用 Grep/Read 直接在代码中验证关键声明 - 2. 判定结果: - ✅ CORRECT — 答案与代码一致 - ⚠️ PARTIAL — 答案部分正确,有遗漏或不精确 - ❌ INCORRECT — 答案与代码矛盾 - 🔇 BOUNDARY_OK — 认知边界问题,正确拒绝回答(仅类型6) - 🔇 BOUNDARY_FAIL — 认知边界问题,错误地给出了答案(仅类型6) - -Step 4D:写入验证报告 - - 追加到 k4-quality-report.md 的 ## AI 端到端验证 章节: - - | 问题 | 类型 | 检索文档 | AI答案摘要 | 代码验证 | 结果 | - |------|------|---------|-----------|---------|------| - | Aurora 核心职责? | 组件职责 | 03_Aurora设计说明.md | 调度编排... | scheduler.go:42 | ✅ | - | A→B 通信方式? | 调用关系 | G1矩阵 | RPC | import rpc_client | ✅ | - | 状态X下能否操作Y? | 操作约束 | G9矩阵 | 不能 | check_state.go:88 | ✅ | - | 第三方SDK内部? | 认知边界 | — | 超出范围 | — | 🔇 OK | - - 统计: - CORRECT: N/M (X%) - PARTIAL: N/M (X%) - INCORRECT: N/M (X%) — ❌ 每个 INCORRECT 必须列出具体矛盾点 - BOUNDARY_OK: N/N - BOUNDARY_FAIL: N/N - - E2E 准确率 = (CORRECT + BOUNDARY_OK) / 总题数 - 目标: ≥ 80% -``` - -**如果 E2E 准确率 < 80%**:在质量报告"建议"章节列出需要改进的文档和具体问题。 - -### Step 5:生成质量报告 - -写入 `_review/k4-quality-report.md`: - -```markdown -# 知识库质量报告 - -## 概览 -- 代码基准:<commit SHA> (<tag>) -- 生成时间:<ISO8601> -- 文档总数:N 份(Type-1~8: N份,图谱G1~G9: 9份) - -## 准确性 -| 指标 | 数值 | 状态 | -| 总声明数 | N | — | -| 有代码引用 | N (X%) | ✅/❌ | -| [UNVERIFIED] | N (X%) | ✅/<15% / ⚠️15~25% / ❌>25% | -| AMBIGUOUS关系 | N | ✅/⚠️ | - -## 结构质量(validate_kb.py 输出) -(完整展示,不隐藏任何数字) - -## 跨文档一致性(k3-consistency-check.md 摘要) -| 指标 | 数值 | 状态 | -| 矛盾项 | N | ✅=0 / ❌>0 | -| 缺失引用 | N | ⚠️ | -| G1偏差 | N | ⚠️ | -| 一致率 | X% | 目标≥95% | - -## RAG 检索抽检 -| 测试问题 | 期望命中 | 实际命中 | 结果 | - -## AI 端到端验证 -| 指标 | 数值 | 状态 | -| CORRECT | N/M (X%) | — | -| PARTIAL | N/M (X%) | ⚠️ | -| INCORRECT | N/M (X%) | ❌ | -| BOUNDARY_OK | N/N | ✅ | -| E2E 准确率 | X% | 目标≥80% | - -INCORRECT 详情: -(每个 INCORRECT 的具体矛盾点和改进建议) - -## 待人工确认清单 -([UNVERIFIED] 超标文档 + AMBIGUOUS 关系 + 矛盾项 + 死链接) - -## 建议 -(基于一致性校验 + E2E 验证的改进方向) -``` - -**完成后**:更新 `current_phase` 为 `"completed"`,流程结束。 - ---- +`teamai skill get wiki --full` 一次性打印全部参考文件(约 100 KB),仅在需要通读时使用。 ## 输出目录结构 ``` <output_dir>/ ├── README.md ← 知识库索引 + 检索路由规则 + 认知边界声明(AI 专用) +│ 起手用模板:cp {SKILL_DIR}/references/templates/project-overview.md <output_dir>/README.md ├── {项目名} 技术架构.md ← [Type-1] 架构总览(目标 ≤80KB,超过则自动拆分) ├── {项目名} 技术架构-核心链路.md ← [Type-1b] 仅当 Type-1 超 80KB 时拆出 ├── {项目名} 技术架构-AI元数据.md ← [Type-1c] 仅当 Type-1 超 80KB 时拆出 @@ -894,12 +303,8 @@ _review/ ← 过程文件(不入知识库) | 产品文档入图 | Skip. Same English note as above. | | 产品↔代码桥接 | Use `teamai codebase --reconcile --output <repo>` after product pages and extracted code pages are under `<repo>/teamwiki/`. Prefix with `teamai --dry-run` to preview without updating the graph. | | 一键刷新 | Use `teamai codebase --extract <repo> --project <slug> --incremental`, reusing the Phase 0 repository path and project slug even when running from another directory. Do not look for another CLI. | -| 质量评估 | Use `scripts/validate_kb.py` and `teamai codebase --lint --output <repo>` to check `<repo>/teamwiki/` (`--output` takes the repository root, not the `teamwiki/` directory). Skip any extra evaluate binary. | - -**路径约定**(本 skill 安装后): +| 质量评估 | Use `python3 {SKILL_DIR}/scripts/validate_kb.py <output_dir>` and `teamai codebase --lint --output <repo>` to check `<repo>/teamwiki/` (`--output` takes the repository root, not the `teamwiki/` directory). Skip any extra evaluate binary. | -- 方法论:`references/methodology/*.md`(相对本 skill 目录) -- Agent:`references/agents/kb-doc-generator.md`、`references/agents/graph-rag-agent.md` -- 脚本:`scripts/scan_repo.py`、`scripts/validate_kb.py` +**路径约定**:`{SKILL_DIR}` 是 `teamai skill path wiki` 打印的目录,方法论在 `{SKILL_DIR}/references/methodology/`,子 agent 提示词在 `{SKILL_DIR}/references/agents/`,脚本在 `{SKILL_DIR}/scripts/`。 -所有流程在本 skill(`references/`、`scripts/`)与 `teamai` CLI 内完成。No extra plugin is required. +所有流程在 `teamai skill get wiki` 提供的内容与 `teamai` CLI 内完成。No extra plugin is required. diff --git a/skill-data/wiki/references/agents/graph-rag-agent.md b/skill-data/wiki/references/agents/graph-rag-agent.md index e896eed2..458e3f84 100644 --- a/skill-data/wiki/references/agents/graph-rag-agent.md +++ b/skill-data/wiki/references/agents/graph-rag-agent.md @@ -14,7 +14,7 @@ architecture_map: _review/k1-architecture-map.md 完整内容 doc_list: _review/k2-doc-list.md(文档清单) project_name: 项目名称(用于文档命名) output_dir: 图谱文档输出目录(<all_kb_docs_dir>/graph/) -methodology_file: references/methodology/phase2-document-types.md §Type-9 内容 +methodology_file: {SKILL_DIR}/references/methodology/phase2-document-types.md §Type-9 内容 ``` ## 执行步骤 diff --git a/skill-data/wiki/references/methodology/phase2-document-types.md b/skill-data/wiki/references/methodology/phase2-document-types.md index fddd0ac3..2308347d 100644 --- a/skill-data/wiki/references/methodology/phase2-document-types.md +++ b/skill-data/wiki/references/methodology/phase2-document-types.md @@ -95,7 +95,7 @@ ### 从代码生成的步骤 -> 详细执行规范见 `references/agents/kb-doc-generator.md`,此处仅列概要: +> 详细执行规范见 `{SKILL_DIR}/references/agents/kb-doc-generator.md`,此处仅列概要: > 1. 代码结构扫描(Glob → Grep → Read 三步法,按语言自适应) > 2. 信息提取(10 维度:核心职责/架构层级/上下游/代码入口/核心机制/数据流向/技术栈/数据模型/配置项/定时任务) > 3. 文档组装(按上述模板章节顺序) diff --git a/skill-data/wiki/references/methodology/phase4-quality.md b/skill-data/wiki/references/methodology/phase4-quality.md index a28d3e3f..7b68eaa0 100644 --- a/skill-data/wiki/references/methodology/phase4-quality.md +++ b/skill-data/wiki/references/methodology/phase4-quality.md @@ -1,6 +1,6 @@ # Phase 4: 质量评估与迭代优化 -> 辅助工具: `scripts/validate_kb.py` — 自动校验链接完整性、anchor 覆盖率、AI 快速理解表覆盖率、双向链接、README 索引收录率 +> 辅助工具: `python3 {SKILL_DIR}/scripts/validate_kb.py <output_dir>` — 自动校验链接完整性、anchor 覆盖率、AI 快速理解表覆盖率、双向链接、README 索引收录率 ## 五维评估模型 @@ -65,7 +65,7 @@ | 问题 | 修复方法 | |------|---------| -| 死链接 | 全局 grep `](` 链接,或运行 `scripts/validate_kb.py` | +| 死链接 | 全局 grep `](` 链接,或运行 `python3 {SKILL_DIR}/scripts/validate_kb.py <output_dir>` | | 术语不一致 | 建立术语表全局替换 | | 代码入口过时 | 定期与代码仓库 diff | | 约束值过时 | 定期与产品文档交叉比对 | diff --git a/skill-data/wiki/README.md b/skill-data/wiki/references/overview.md similarity index 100% rename from skill-data/wiki/README.md rename to skill-data/wiki/references/overview.md diff --git a/skill-data/wiki/references/phases/k1-reverse-engineering.md b/skill-data/wiki/references/phases/k1-reverse-engineering.md new file mode 100644 index 00000000..7333b942 --- /dev/null +++ b/skill-data/wiki/references/phases/k1-reverse-engineering.md @@ -0,0 +1,118 @@ +## Phase K1:架构逆向与源材料采集 + +**方法论**:`{SKILL_DIR}/references/methodology/phase0-collection.md` + `{SKILL_DIR}/references/methodology/phase1-reverse-engineering.md` + +### Step 1:可选运行扫描脚本(推荐) + +```bash +python3 {SKILL_DIR}/scripts/scan_repo.py <project_root> --depth 2 --top 10 +``` +输出:文件统计 + 关键文件发现报告 + 语言分布。 + +### Step 2:关键文件提取 + +按优先级扫描(详见 phase0-collection.md): +- **P0 必须**:入口文件、路由/Handler、流程编排配置、Proto/IDL +- **P1 重要**:数据库 Schema(DDL)、常量/错误码定义 +- **P2 增强**:配置文件、测试文件(理解预期行为) + +### Step 3:架构逆向(详见 phase1-reverse-engineering.md) + +- 自底向上分层:叶子节点(DB/MQ) → 中间节点(编排/调度) → 根节点(API入口) +- 三层穿透追踪:对核心 API ≥5 条完成 API入口→编排层→服务执行层 全链路追踪 +- 构建 N×N 组件关系矩阵(标注通信方式:RPC/MQ/DB) + +### Step 4:生成架构分析报告 + +写入 `_review/k1-architecture-map.md`: + +```markdown +## 架构分层(≥4层) +| 层级 | 组件列表 | 核心职责 | 代码仓库 | + +## 组件清单 +| 组件名 | 架构层级 | **所属仓库** | 语言 | 核心度(P0/P1/P2) | 入口文件 | **接口校验类型** | + +接口校验类型取值(在确认点①请用户核对此列): + - `HTTP` → API 接入层,有 HTTP/gRPC 路由注册,需做接口数对账 + - `MQ` → 消息处理层,有 MQ Consumer/Exchange 声明,以 Topic 数做基准 + - `RPC` → 内部服务层,有 .proto / .thrift / IDL 文件,以 Method 数做基准 + - `NONE` → 调度/执行/数据层,无对外接口,不做接口数校验 + +## N×N 组件通信矩阵 +(值:RPC/MQ/DB/—,标注置信度 [E]EXTRACTED/[I]INFERRED/[A]AMBIGUOUS) + +## 核心调用链路(≥5条) +(格式:API(file:line) → 编排层(config:line) → 服务层(handler:line) → DB(table)) + +## 术语表 +| 内部术语 | 外部/产品术语 | 说明 | + +## 不确定项(供人工确认) +(标注 [A] 的关系和推断,说明不确定原因) +(接口校验类型不确定的组件,标注 [?] 等用户在确认点①明确) +``` + +### Step 5:接口清单扫描(按校验类型分别执行) + +**仅对 k1-architecture-map.md 中接口校验类型 ≠ NONE 的组件执行**: + +``` +FOR 每个 接口校验类型 = HTTP 的组件: + 执行 grep 扫描: + Go: grep -rn "\.GET\|\.POST\|\.PUT\|\.DELETE\|router\.Handle\|@handler" <component_dir> + Python: grep -rn "@app\.route\|@router\.\|APIRouter\|include_router" <component_dir> + 记录:组件名 → HTTP接口数 N(SCAN_CONFIDENCE: HIGH/MEDIUM) + +FOR 每个 接口校验类型 = MQ 的组件: + 执行 grep 扫描: + grep -rn "Exchange\|Queue\|Topic\|consumer\|subscribe\|@KafkaListener" <component_dir> + 记录:组件名 → MQ Topic/Queue 数 N + +FOR 每个 接口校验类型 = RPC 的组件: + 解析 .proto / .thrift 文件: + find <component_dir> -name "*.proto" -o -name "*.thrift" | xargs grep "^rpc\|^service" + 记录:组件名 → RPC Method 数 N +``` + +结果写入 `_review/interface-inventory.json`: +```json +{ + "ComponentA": {"type": "HTTP", "count": 13, "confidence": "HIGH"}, + "ComponentB": {"type": "MQ", "count": 5, "confidence": "MEDIUM"}, + "ComponentC": {"type": "RPC", "count": 8, "confidence": "HIGH"}, + "ComponentD": {"type": "NONE", "count": 0, "confidence": "—"} +} +``` + +**完成后**:更新 `current_phase` 为 `"phasek1_waiting_confirm"`。 + +**⛔ 确认点①** — 等待用户明确回复,不得自动进入下一阶段。 + +展示给用户: +``` +架构分析完成。 + +组件清单(共 N 个): + P0 核心: [列表] + P1 重要: [列表] + P2 辅助: [列表] + +接口扫描结果(供校验用): + HTTP 接口:ComponentA 13个, ComponentB 7个 + MQ Topic: ComponentC 5个 + RPC Method:ComponentD 8个 + 无接口组件:ComponentE, ComponentF, ... + +AMBIGUOUS 关系(请明确): + - ComponentX → ComponentY 的通信方式不确定 + +请确认(直接编辑 k1-architecture-map.md 后回复"继续"): + 1. 架构分层和 P0/P1/P2 标注是否正确? + 2. 每个组件的接口校验类型(HTTP/MQ/RPC/NONE)是否准确? + 3. 接口扫描数量是否合理?明显偏少说明有遗漏,偏多可能扫到了测试文件。 +``` + +确认后:更新 `"phasek1_confirmed"` → Phase K2。 + +--- diff --git a/skill-data/wiki/references/phases/k2-documents.md b/skill-data/wiki/references/phases/k2-documents.md new file mode 100644 index 00000000..43e9628e --- /dev/null +++ b/skill-data/wiki/references/phases/k2-documents.md @@ -0,0 +1,68 @@ +## Phase K2:文档生成(分批并行 + 中间质量确认) + +**方法论**:`{SKILL_DIR}/references/methodology/phase2-document-types.md` + +### 生成顺序(依赖链驱动,底层先写) + +``` +批次1: 数据层 + 基础执行层 Type-4 组件文档 ← 并行 +批次2: 资源/调度层 Type-4 组件文档 ← 并行 +批次3: 消息/服务层 Type-4 组件文档 ← 并行 +批次4: API入口层 Type-4 组件文档 ← 并行 + ⛔ 确认点② ← 人工抽查组件文档质量 +批次5: 架构总览层 (Type-1 + Type-2 + Type-3) ← 串行(依赖上层全部完成) +批次6: 桥梁文档 (Type-5 + Type-6 + Type-7) ← 串行(依赖产品文档) +批次7: 知识增强 (Type-8: 反模式/RPC契约/排障) ← 串行 +``` + +### 每批执行流程 + +读取 `{SKILL_DIR}/references/agents/kb-doc-generator.md`,拼装输入包并启动: + +``` +component_list: 本批次组件/文档类型列表 +architecture_map: _review/k1-architecture-map.md 完整内容 +repos: _review/repo-manifest.json 中的仓库列表 +service_map: progress.json 中的 service_map +output_dir: <Phase 0> +project_name: <Phase 0> +product_docs_dir: <Phase 0,可为空> +methodology_dir: {SKILL_DIR}/references/methodology/ +completed_docs: kb_progress.components_done(断点恢复跳过) +parallel_mode: true(批次1~4)/ false(批次5~7) +``` + +每批完成后: +- 将完成组件追加到 `kb_progress.components_done` +- 累加 `accuracy_stats`(从 Agent 返回的自校验摘要中提取) +- 更新 `current_phase` 为 `"phasek2_batch_N"` +- 展示本批次 token 消耗和 `[UNVERIFIED]` 统计 + +### ⛔ 确认点②(批次1~4完成后) + +展示给用户: +``` +已生成 {N} 份组件设计文档。准确性统计: + 总声明数: {N} | 已验证: {N} | [UNVERIFIED]: {N}({X}%) + AMBIGUOUS 关系: {N} 条 + +请抽查 2~3 份文档(建议选最复杂的组件): + 路径:<output_dir>/XX_<组件名>设计说明.md + +确认要点: + 1. AI 快速理解表的代码入口是否精确到函数名? + 2. 核心流程描述是否与代码实际一致? + 3. [UNVERIFIED] 比例是否可接受?(建议 <15%) + +如发现系统性问题,请描述,我将调整策略后重新生成。 +``` + +更新 `current_phase` 为 `"phasek2_waiting_confirm"`。 +用户确认后更新为 `"phasek2_confirmed"`,继续批次5~7。 + +### 全部批次完成后 + +写入 `_review/k2-doc-list.md`(文档清单:路径 + 规模KB + [UNVERIFIED]数 + 生成时间)。 +更新 `current_phase` 为 `"phasek2_done"` → Phase K3。 + +--- diff --git a/skill-data/wiki/references/phases/k3-ai-native.md b/skill-data/wiki/references/phases/k3-ai-native.md new file mode 100644 index 00000000..2f4fcea4 --- /dev/null +++ b/skill-data/wiki/references/phases/k3-ai-native.md @@ -0,0 +1,121 @@ +## Phase K3:AI-Native 增强 + 图谱文档集 + +**方法论**:`{SKILL_DIR}/references/methodology/phase3-ai-enhancement.md` + +### Step 1:AI-Native 元素注入 + +对所有已生成文档补充(如 Phase K2 的 Agent 未完整添加): + +| 元素 | 要求 | 适用范围 | +|------|------|---------| +| `search-anchor` | 5~15 个关键词,标题后第一行 | 所有文档 | +| AI 快速理解表 | 10 维度,紧跟标题 | 所有 Type-4 组件文档 | +| 双向链接 | 组件↔主架构,桥梁↔组件 | 所有文档 | +| 检索路由规则 | 4条分流规则 + 4级优先级 | 仅技术架构总览 | +| QA 对 | 10~20 个高频问题+答案引用 | 仅技术架构总览第9章 | + +### Step 2:Graph RAG 图谱文档集 + +读取 `{SKILL_DIR}/references/agents/graph-rag-agent.md`,拼装输入包并启动: + +``` +all_kb_docs_dir: <output_dir> +architecture_map: _review/k1-architecture-map.md +doc_list: _review/k2-doc-list.md +project_name: <Phase 0> +output_dir: <output_dir>/graph/ +methodology_file: {SKILL_DIR}/references/methodology/phase2-document-types.md +``` + +生成 G1~G9(每条关系强制置信度三态标注): + +| 图谱文档 | 解决的问题 | 置信度要求 | +|---------|---------|-----------| +| G1 组件依赖关系矩阵 | "谁依赖 X?" | EXTRACTED 来自文档明确描述 | +| G2 调用链路全景 + 状态机 + 约束矩阵 | "API 经过哪些模块?" | 调用链 EXTRACTED,推断依赖 INFERRED | +| G3 数据流与存储依赖图 | "数据存哪里?" | 读写关系 EXTRACTED | +| G4 错误码组件映射表 | "错误码是哪个模块的?" | EXTRACTED | +| G5 跨组件交互场景手册(≥10个时序图) | "配额检查怎么做?" | 时序 EXTRACTED,边界 INFERRED | +| G6 知识图谱三元组(≥100条) | "A 间接依赖谁?" | 每条标 E/I/A + 分值 | +| G7 架构风险与影响面分析 | "X 挂了影响多大?" | 直接依赖 EXTRACTED,间接 INFERRED | +| G8 核心配置参数索引 | "怎么改 XX 配置?" | EXTRACTED 来自配置文件 | +| G9 业务规则约束矩阵 + AI 推理决策树 | "能不能做 XX?" | 规则 EXTRACTED,推断 INFERRED | + +同时生成 `<output_dir>/graph/README.md`(索引 + 按问题类型查找表 + 检索路由建议)。 + +### Step 3:跨文档一致性校验 + +**Graph RAG Agent 完成后,主 Agent 自行执行此步骤(不委托给子 Agent)。** + +目的:检测组件文档之间的矛盾描述,防止"A 说调用 B 用 RPC,B 说被 A 用 MQ 调用"这类不一致。 + +``` +Step 3A:构建"声称矩阵" + + 对每份 Type-4 组件文档,从**两个层面**提取关系声称: + + 层面1:AI 快速理解表中的"上游组件"和"下游组件"字段 + 层面2:正文中的接口设计章节、核心流程章节中的调用描述 + + 如果层面1和层面2对同一关系描述不一致 → 首先记录为"文档内矛盾"(比表头和正文优先级更高的问题) + + 提取示例: + 组件X.md 表头声称: X→Y(RPC), X→Z(MQ) + 组件X.md 正文声称: X→Z(HTTP) ← 与表头矛盾! + 组件Y.md 表头声称: Y←X(RPC), Y→Z(DB) + 组件Z.md 表头声称: Z←X(HTTP), Z←Y(DB) + +Step 3B:交叉比对 + + FOR 每对组件 (A, B): + IF A.md 声称 "A→B 用 RPC" AND B.md 声称 "B←A 用 MQ": + → 记录矛盾: "A→B 通信方式不一致: A说RPC, B说MQ" + IF A.md 声称 "A→B" BUT B.md 未提到 "被A调用": + → 记录缺失: "A声称调用B,但B的文档未提及被A调用" + IF G1矩阵中的关系 与 组件文档声称不一致: + → 记录偏差: "G1矩阵说A→B(RPC),但A的文档说A→B(MQ)" + +Step 3C:生成一致性报告 + + 写入 `_review/k3-consistency-check.md`: + + ```markdown + # 跨文档一致性校验报告 + + ## 矛盾项(必须修复) + | 组件A | 组件B | A的描述 | B的描述 | 矛盾类型 | + |-------|-------|---------|---------|---------| + | X | Z | X→Z(MQ) | Z←X(HTTP) | 通信方式不一致 | + + ## 缺失项(建议补充) + | 声称方 | 被引用方 | 声称内容 | 缺失 | + |--------|---------|---------|------| + | A | B | A→B(RPC) | B的文档未提及被A调用 | + + ## G1矩阵偏差(建议对齐) + | G1矩阵 | 组件文档 | 偏差 | + + ## 统计 + - 矛盾项: N 处(❌ 需修复) + - 缺失项: N 处(⚠️ 建议补充) + - G1偏差: N 处(⚠️ 需对齐) + - 一致关系: N 条(✅) + - 一致率: X% + ``` + +Step 3D:自动修复(仅限明确情况) + + IF 矛盾项 > 0: + FOR 每个矛盾项: + 回溯代码验证:用 Grep 查找实际的调用方式(如 rpc.Call / mq.Publish) + IF 能明确正确方 → 修复错误方文档中的描述 + 更新 G1 矩阵 + IF 无法明确 → 标记为 AMBIGUOUS,留待用户在确认点确认 + 修复后重新统计一致率 + + IF 矛盾项 = 0: + → 跳过修复,直接进入 Phase K4 +``` + +**完成后**:更新 `current_phase` 为 `"phasek3_done"` → Phase K4。 + +--- diff --git a/skill-data/wiki/references/phases/k4-quality.md b/skill-data/wiki/references/phases/k4-quality.md new file mode 100644 index 00000000..de4e76c5 --- /dev/null +++ b/skill-data/wiki/references/phases/k4-quality.md @@ -0,0 +1,190 @@ +## Phase K4:知识库质量评估与报告 + +**方法论**:`{SKILL_DIR}/references/methodology/phase4-quality.md` + +### Step 1:自动校验 + +```bash +python3 {SKILL_DIR}/scripts/validate_kb.py <output_dir> --verbose +``` + +`--verbose` 打印每一项的明细(缺失的 anchor、死链接的具体位置),这正是下面要求的完整展示。 + +输出(**必须完整展示,不得只展示通过项**): +``` +链接完整性: ✅/❌ N 个死链接 +search-anchor: ✅/⚠️ 覆盖率 N/M (X%) +AI 快速理解表: ✅/⚠️ 覆盖率 N/M (X%) +双向链接: ✅/⚠️ 覆盖率 N/M (X%) +README 索引: ✅/⚠️ 收录率 N/M (X%) +``` + +### Step 2:准确性审计 + +从 `accuracy_stats` 汇总全库可信度,同时从 `interface_coverage` 汇总接口覆盖情况: + +``` +【内容准确性】 +总声明数: N 条(业务规则 + 接口描述 + 关系) +已验证(有代码引用): N 条 (X%) +[UNVERIFIED]: N 条 (X%) +AMBIGUOUS 关系: N 条 (X%) + +【接口覆盖率】(仅统计 HTTP/MQ/RPC 类型组件,NONE 类型不计入) +HTTP 接口: 文档记录 M 个 / 扫描基准 N 个 = X% +MQ Topic: 文档记录 M 个 / 扫描基准 N 个 = X% +RPC Method: 文档记录 M 个 / 扫描基准 N 个 = X% +综合覆盖率: X% 目标 ≥ 90% + +⚠️ 接口缺口清单(文档记录 < 扫描基准 的组件): + - ComponentA: 文档记录 8 个,扫描基准 13 个,缺口 5 个 → 建议补充 +``` + +⚠️ 需人工确认清单:([UNVERIFIED] > 20% 的文档 + 接口缺口组件 + AMBIGUOUS 关系) + +### Step 3:RAG 检索抽检 + +按 `phase4-quality.md §RAG检索测试用例` 测试 7 类问题各 1 个(详见方法论),记录命中率。 + +### Step 4:AI 端到端验证(E2E Validation) + +**核心思路**:用知识库回答一组标准化问题,然后**回溯代码验证答案正确性**,检测知识库是否能让 AI 给出正确答案。 + +``` +Step 4A:生成标准验证问题集(自动,基于已有文档) + + **优先使用用户提供的外部验证集**: + IF 用户在 Phase 0 或此时提供了验证问题列表(3~10 个真实业务问题): + → 优先使用用户问题作为验证集(标注来源: USER) + → 自动补充至 10~15 题(标注来源: AUTO) + ELSE: + → 全部自动生成(标注来源: AUTO) + + > 用户提供的问题更有价值,因为 AI 自己出题容易考自己已知的领域, + > 真正的盲区(AI 没理解但没意识到的)只有外部问题才能测到。 + + 从 k1-architecture-map.md 和 k2-doc-list.md 自动生成 10~15 个验证问题: + + 问题类型分布(至少覆盖以下 5 类): + + ┌────────────────────────────────────────────────────────────────────┐ + │ 类型1:组件职责(3题) │ + │ 模式:"<组件名> 的核心职责是什么?代码入口在哪?" │ + │ 验证方式:答案中的函数名/文件名必须在代码中存在 │ + │ │ + │ 类型2:调用关系(3题) │ + │ 模式:"<组件A> 和 <组件B> 之间是什么关系?通过什么方式通信?" │ + │ 验证方式:答案与 G1 矩阵 + 代码实际 import/call 一致 │ + │ │ + │ 类型3:操作约束(2题) │ + │ 模式:"在 <状态X> 下能否执行 <操作Y>?" │ + │ 验证方式:答案与 G9 约束矩阵 + 代码中的状态检查一致 │ + │ │ + │ 类型4:数据流向(2题) │ + │ 模式:"<操作Z> 最终会写入哪些表/队列?" │ + │ 验证方式:答案与 G3 数据流 + 代码实际 SQL/MQ 操作一致 │ + │ │ + │ 类型5:错误排查(2题) │ + │ 模式:"错误码 <XXX> 是什么意思?在哪个组件产生?" │ + │ 验证方式:答案与 G4 错误码映射 + 代码中的错误定义一致 │ + │ │ + │ 类型6(可选):认知边界测试(2题) │ + │ 模式:故意问知识库不覆盖的内容(如第三方 SDK 内部、历史架构变迁) │ + │ 验证方式:AI 应回答"超出知识库覆盖范围"而非幻觉 │ + └────────────────────────────────────────────────────────────────────┘ + +Step 4B:用知识库回答(模拟 AI 使用场景) + + FOR 每个验证问题: + 1. 假设只能读知识库文档,不能直接读代码 + 2. 按检索路由规则,找到对应文档 + 3. 从文档中提取答案 + +Step 4C:代码回溯验证 + + FOR 每个答案: + 1. 用 Grep/Read 直接在代码中验证关键声明 + 2. 判定结果: + ✅ CORRECT — 答案与代码一致 + ⚠️ PARTIAL — 答案部分正确,有遗漏或不精确 + ❌ INCORRECT — 答案与代码矛盾 + 🔇 BOUNDARY_OK — 认知边界问题,正确拒绝回答(仅类型6) + 🔇 BOUNDARY_FAIL — 认知边界问题,错误地给出了答案(仅类型6) + +Step 4D:写入验证报告 + + 追加到 k4-quality-report.md 的 ## AI 端到端验证 章节: + + | 问题 | 类型 | 检索文档 | AI答案摘要 | 代码验证 | 结果 | + |------|------|---------|-----------|---------|------| + | Aurora 核心职责? | 组件职责 | 03_Aurora设计说明.md | 调度编排... | scheduler.go:42 | ✅ | + | A→B 通信方式? | 调用关系 | G1矩阵 | RPC | import rpc_client | ✅ | + | 状态X下能否操作Y? | 操作约束 | G9矩阵 | 不能 | check_state.go:88 | ✅ | + | 第三方SDK内部? | 认知边界 | — | 超出范围 | — | 🔇 OK | + + 统计: + CORRECT: N/M (X%) + PARTIAL: N/M (X%) + INCORRECT: N/M (X%) — ❌ 每个 INCORRECT 必须列出具体矛盾点 + BOUNDARY_OK: N/N + BOUNDARY_FAIL: N/N + + E2E 准确率 = (CORRECT + BOUNDARY_OK) / 总题数 + 目标: ≥ 80% +``` + +**如果 E2E 准确率 < 80%**:在质量报告"建议"章节列出需要改进的文档和具体问题。 + +### Step 5:生成质量报告 + +写入 `_review/k4-quality-report.md`: + +```markdown +# 知识库质量报告 + +## 概览 +- 代码基准:<commit SHA> (<tag>) +- 生成时间:<ISO8601> +- 文档总数:N 份(Type-1~8: N份,图谱G1~G9: 9份) + +## 准确性 +| 指标 | 数值 | 状态 | +| 总声明数 | N | — | +| 有代码引用 | N (X%) | ✅/❌ | +| [UNVERIFIED] | N (X%) | ✅/<15% / ⚠️15~25% / ❌>25% | +| AMBIGUOUS关系 | N | ✅/⚠️ | + +## 结构质量(validate_kb.py 输出) +(完整展示,不隐藏任何数字) + +## 跨文档一致性(k3-consistency-check.md 摘要) +| 指标 | 数值 | 状态 | +| 矛盾项 | N | ✅=0 / ❌>0 | +| 缺失引用 | N | ⚠️ | +| G1偏差 | N | ⚠️ | +| 一致率 | X% | 目标≥95% | + +## RAG 检索抽检 +| 测试问题 | 期望命中 | 实际命中 | 结果 | + +## AI 端到端验证 +| 指标 | 数值 | 状态 | +| CORRECT | N/M (X%) | — | +| PARTIAL | N/M (X%) | ⚠️ | +| INCORRECT | N/M (X%) | ❌ | +| BOUNDARY_OK | N/N | ✅ | +| E2E 准确率 | X% | 目标≥80% | + +INCORRECT 详情: +(每个 INCORRECT 的具体矛盾点和改进建议) + +## 待人工确认清单 +([UNVERIFIED] 超标文档 + AMBIGUOUS 关系 + 矛盾项 + 死链接) + +## 建议 +(基于一致性校验 + E2E 验证的改进方向) +``` + +**完成后**:更新 `current_phase` 为 `"completed"`,流程结束。 + +--- diff --git a/skill-data/wiki/references/phases/phase0-init.md b/skill-data/wiki/references/phases/phase0-init.md new file mode 100644 index 00000000..d06ba62e --- /dev/null +++ b/skill-data/wiki/references/phases/phase0-init.md @@ -0,0 +1,112 @@ +## Phase 0:初始化 + +一次性向用户询问以下信息(**同一条消息,不分步骤**): + +1. **项目所有代码仓库路径**(用户把整个项目涉及的所有仓库地址列出来): + - 格式:每行一个绝对路径,或逗号分隔 + - 示例: + ``` + /path/to/api-gateway + /path/to/order-service + /path/to/user-service + /path/to/common-lib + ``` + - 说明:这是最关键的一步。大型项目的代码散布在多个仓库中,必须**全部提供**才能构建完整的架构认知。遗漏仓库 = 知识库盲区。 +2. **项目名称**(用于文档命名,如 "CVM"、"电商平台") +3. **产品文档来源**(可选,提供则生成 Type-5/6 桥梁文档): + - API 文档目录路径 + - 使用限制 / FAQ 文档路径 +4. **输出路径**(默认:第一个仓库的父目录下的 `knowledge/`) + +**Step 0A:仓库清单整理** + +收到用户提供的仓库列表后,构建仓库清单: + +``` +FOR 每个用户提供的路径: + 1. 验证路径存在且可访问 + 2. 检测是否为 git 仓库(是否有 .git 目录) + 3. 检测主要语言(按文件扩展名分布) + 4. 统计代码规模(文件数 + 估算行数) + 5. 记录 git commit SHA + tag + +结果写入 _review/repo-manifest.json: +{ + "repos": [ + { + "path": "/absolute/path/to/repo-a", + "name": "repo-a", + "language": "go", + "files": 320, + "lines_estimate": 45000, + "commit": "abc123", + "tag": "v1.2.0", + "accessible": true + }, + ... + ], + "total_repos": N, + "inaccessible": ["path/to/repo-x(权限不足)"] +} +``` + +展示给用户确认: +``` +已识别 {N} 个仓库: + ✅ repo-a (Go, ~45K 行) + ✅ repo-b (Python, ~12K 行) + ✅ repo-c (Go, ~28K 行) + ❌ repo-x (路径不存在或无法访问) + +总计: ~{N}K 行代码,{N} 个仓库 +确认无误后回复"继续",或补充遗漏的仓库。 +``` + +**Step 0B:自动检测主要语言**(按仓库列表汇总,不阻断流程): +``` +检测方法:汇总所有仓库的文件扩展名分布 + .go 文件占比最高 → language: "go" + .py 文件占比最高 → language: "python" + .java 文件占比最高 → language: "java" + .ts/.js 文件占比最高 → language: "typescript" + .rs 文件占比最高 → language: "rust" + 多语言混合(无明显主导) → language: "mixed" +备注:language 字段用于接口扫描时选择 grep 模式(详见 Phase K1 Step 5) +``` + +**Step 0C:记录基准版本**: +```bash +# 对每个仓库分别记录 +FOR repo in repos: + git -C <repo.path> rev-parse HEAD 2>/dev/null + git -C <repo.path> describe --tags --always 2>/dev/null +``` +写入 `_review/metadata.json`: +```json +{ + "project_name": "CVM", + "scan_time": "<ISO8601>", + "repos": [ + {"name": "repo-a", "commit": "<sha>", "tag": "<tag>"}, + {"name": "repo-b", "commit": "<sha>", "tag": "<tag>"} + ] +} +``` + +**Step 0D:CLI 结构基线(每个代码仓库,推荐)** + +在 K1 深读之前,用 TeamAI 提取可证据化的 import/call 结构边(Python/Go/TS 等,`code-ast`)并与 regex 基线合并(`code-heuristic`): + +```bash +# For each repo. Writes <repo>/teamwiki/ (evidence pages + .indices/graph-index.json). +# Existing flags only: --extract [path], optional --project <slug>, optional --incremental. +teamai codebase --extract <repo_abs_path> --project <project_slug> +``` + +- Output: `teamwiki/evidence/code/<project>/` pages; `teamwiki/.indices/graph-index.json` (structural edges). +- K1/K2/K3 写 `_manifest.json` 的 `edges[]` 时:**优先引用** extract 的 `code-ast` 边 + `evidenceRefs`(`path:line`),Agent 推断标 `INFERRED`/`AMBIGUOUS`。 +- After Phase K3, skip any extra graph compile / merge step that is not a `teamai` command. TeamAI does not ship a separate team-wiki CLI. Continue with this skill using `teamai` and the files under this skill directory. No extra plugin is required. + +写入初始 progress.json(current_phase: "phase0_done"),进入 **Phase K1**。 + +--- diff --git a/src/__tests__/commands-reference.test.ts b/src/__tests__/commands-reference.test.ts new file mode 100644 index 00000000..6b780547 --- /dev/null +++ b/src/__tests__/commands-reference.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { renderCommandsReference, COMMANDS_REFERENCE_PATH } from '../commands-reference.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); + +describe('generated command reference', () => { + it('matches the CLI command table', async () => { + // Guard the CLI entry so importing it yields the command table instead of + // parsing this test run's argv. + process.env.TEAMAI_COMMAND_TABLE_ONLY = '1'; + const { program } = await import('../index.js'); + + // Regenerate with `npx vitest run commands-reference -u` when a command, + // subcommand or flag changes — the skill must not document a CLI that no + // longer exists. + await expect(renderCommandsReference(program)).toMatchFileSnapshot( + path.join(ROOT, COMMANDS_REFERENCE_PATH), + ); + }); +}); diff --git a/src/__tests__/skill-commands-exist.test.ts b/src/__tests__/skill-commands-exist.test.ts new file mode 100644 index 00000000..0fee19ad --- /dev/null +++ b/src/__tests__/skill-commands-exist.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Command } from 'commander'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const SKILL_DATA = path.join(ROOT, 'skill-data'); + +/** Every `teamai …` invocation written in the served skill content. */ +interface Invocation { + file: string; + line: number; + text: string; +} + +function markdownFiles(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return markdownFiles(full); + return entry.isFile() && entry.name.endsWith('.md') ? [full] : []; + }); +} + +function collectInvocations(): Invocation[] { + const found: Invocation[] = []; + for (const file of markdownFiles(SKILL_DATA)) { + const relative = path.relative(ROOT, file); + const lines = fs.readFileSync(file, 'utf8').split('\n'); + + // A description sentence naming the CLI is prose, not an invocation. + let start = 0; + if (lines[0] === '---') { + const close = lines.indexOf('---', 1); + if (close > 0) start = close + 1; + } + + lines.slice(start).forEach((line, offset) => { + // Inside backticks, or a bare command line inside a fenced block. + const spans = [...line.matchAll(/`([^`]*)`/g)].map((m) => m[1]); + if (/^\s*teamai\s/.test(line)) spans.push(line.trim()); + for (const span of spans) { + const text = span.trim().replace(/\s+#.*$/, ''); + // The token after `teamai` has to look like a command name; prose such + // as "teamai — Team AI …" or "teamai …" is a mention, not a call. + if (!/^teamai\s+(-{1,2}[a-z]|[a-z][a-z-]*(\s|$))/.test(text)) continue; + found.push({ file: relative, line: start + offset + 1, text }); + } + }); + } + return found; +} + +/** Walk the command chain an invocation names, and return the command it lands on. */ +function resolveCommand(program: Command, tokens: string[]): { command: Command; rest: string[] } { + let command = program; + let index = 0; + while (index < tokens.length) { + const next = (command.commands as Command[]).find( + (c) => c.name() === tokens[index] || c.aliases().includes(tokens[index]), + ); + if (!next) break; + command = next; + index += 1; + } + return { command, rest: tokens.slice(index) }; +} + +function knownFlags(command: Command, program: Command): Set<string> { + const flags = new Set<string>(['--help', '-h']); + for (const option of [...command.options, ...program.options]) { + if (option.long) flags.add(option.long); + if (option.short) flags.add(option.short); + } + return flags; +} + +function validate(program: Command, invocations: Invocation[]): string[] { + const problems: string[] = []; + + for (const invocation of invocations) { + const tokens = invocation.text.split(/\s+/).slice(1).filter(Boolean); + if (tokens.length === 0) continue; + + const { command, rest } = resolveCommand(program, tokens); + const where = `${invocation.file}:${invocation.line} ${invocation.text}`; + + if (command === program && !tokens[0].startsWith('-')) { + problems.push(`${where} → unknown command "${tokens[0]}"`); + continue; + } + + const flags = knownFlags(command, program); + for (const token of rest) { + if (!token.startsWith('-') || token === '-') continue; + const flag = token.split('=')[0]; + // Placeholders and prose inside an example are not flags to resolve. + if (/[<>[\]{}"']/.test(flag)) continue; + if (!flags.has(flag)) { + problems.push(`${where} → unknown flag "${flag}" for \`teamai ${command.name()}\``); + } + } + } + + return problems; +} + +describe('commands named by the served skill content', () => { + it('all exist in the CLI command table', async () => { + process.env.TEAMAI_COMMAND_TABLE_ONLY = '1'; + const { program } = await import('../index.js'); + + const problems = validate(program, collectInvocations()); + expect(problems, `\n${problems.join('\n')}\n`).toEqual([]); + }); + + it('catches the drift it exists to catch', async () => { + process.env.TEAMAI_COMMAND_TABLE_ONLY = '1'; + const { program } = await import('../index.js'); + + // `teamai extract graph` is the command the wiki skill advertised until this + // change (issue #678, defect D1); the flag is invented. + const problems = validate(program, [ + { file: 'synthetic.md', line: 1, text: 'teamai extract graph' }, + { file: 'synthetic.md', line: 2, text: 'teamai codebase --no-such-flag' }, + ]); + + expect(problems).toHaveLength(2); + expect(problems[0]).toContain('unknown command "extract"'); + expect(problems[1]).toContain('unknown flag "--no-such-flag"'); + }); +}); diff --git a/src/__tests__/skill-content.test.ts b/src/__tests__/skill-content.test.ts index 31c9ad9f..9b723c0f 100644 --- a/src/__tests__/skill-content.test.ts +++ b/src/__tests__/skill-content.test.ts @@ -210,13 +210,14 @@ describe('teamai skill get / path against the shipped package', () => { expect(roots.dataRoot).toBe(path.join(ROOT, 'skill-data')); }); - it('prints a shipped skill byte for byte', async () => { + it('prints a shipped skill byte for byte, bar the resolved {SKILL_DIR}', async () => { const [first] = await listServableSkills(); await skillGet([first.name]); + const raw = fs.readFileSync(path.join(first.dir, 'SKILL.md'), 'utf8'); expect(process.exitCode).toBeUndefined(); expect(stderr).toBe(''); - expect(stdout).toBe(fs.readFileSync(path.join(first.dir, 'SKILL.md'), 'utf8')); + expect(stdout).toBe(raw.split(SKILL_DIR_PLACEHOLDER).join(first.dir)); }); it('fails on an unknown name without writing to stdout', async () => { @@ -234,7 +235,7 @@ describe('teamai skill get / path against the shipped package', () => { expect(process.exitCode).toBeUndefined(); expect(stderr).toContain('Unknown flag ignored: --bogus'); - expect(stdout).toBe(fs.readFileSync(path.join(first.dir, 'SKILL.md'), 'utf8')); + expect(stdout).toBe(await renderSkill(first)); }); it('fails when no name is left after dropping flags', async () => { diff --git a/src/__tests__/team-wiki-codebase-skill.test.ts b/src/__tests__/team-wiki-codebase-skill.test.ts index f364682f..58eab2fe 100644 --- a/src/__tests__/team-wiki-codebase-skill.test.ts +++ b/src/__tests__/team-wiki-codebase-skill.test.ts @@ -8,10 +8,14 @@ const SKILL_DIR = path.join(ROOT, 'skill-data', 'wiki'); const SKILL_FILES = [ path.join(SKILL_DIR, 'SKILL.md'), - path.join(SKILL_DIR, 'README.md'), + path.join(SKILL_DIR, 'references', 'overview.md'), + path.join(SKILL_DIR, 'references', 'phases', 'phase0-init.md'), path.join(SKILL_DIR, 'references', 'methodology', 'phase0-collection.md'), ] as const; +/** Phase 0's procedure moved out of SKILL.md into its own reference (#678). */ +const PHASE0_FILES = SKILL_FILES.filter((f) => !f.endsWith(path.join('wiki', 'SKILL.md'))); + const FORBIDDEN_REQUIRED_COMMANDS = [ 'team-wiki compile code', 'team-wiki reconcile', @@ -28,7 +32,7 @@ describe('wiki builtin skill content (issue #360 slice 1)', () => { }); it('tells Phase 0 to run teamai codebase --extract', () => { - for (const file of SKILL_FILES) { + for (const file of PHASE0_FILES) { const text = fs.readFileSync(file, 'utf8'); expect(text, file).toContain('teamai codebase --extract'); } diff --git a/src/commands-reference.ts b/src/commands-reference.ts new file mode 100644 index 00000000..194ee37b --- /dev/null +++ b/src/commands-reference.ts @@ -0,0 +1,71 @@ +import type { Command, Option } from 'commander'; + +// ─── Generated command reference ───────────────────────── +// +// The `core` skill used to carry a hand-written cheat sheet +// labelled "ground truth". It drifted four times (e151d43, +// 1ca43ac, 8bb0548, 2ddb546), each time after a command +// changed under it. +// +// The command table is the only real ground truth, so the +// reference is rendered from it and checked by a test that +// regenerates and diffs. Adding a command without updating +// the reference now fails the build. +// + +/** Where the rendered reference is written, relative to the package root. */ +export const COMMANDS_REFERENCE_PATH = 'skill-data/core/references/commands.md'; + +const HEADER = `# teamai command reference + +Every command the installed CLI accepts, rendered from its own command table. +Flags marked \`(hidden)\` work but are absent from \`--help\`, so treat this file — +not \`--help\` — as the complete list. + +Generated: do not edit by hand. Regenerate with +\`npx vitest run commands-reference -u\` after changing a command or a flag. +`; + +function renderOption(option: Option): string { + const hidden = option.hidden ? ' (hidden)' : ''; + const description = option.description ? ` — ${option.description}` : ''; + return ` - \`${option.flags}\`${hidden}${description}`; +} + +function visibleOptions(command: Command): Option[] { + // `-h, --help` is on every command and says nothing about the command. + return command.options.filter((option) => option.long !== '--help'); +} + +function renderCommand(command: Command, parents: string[]): string[] { + const path = [...parents, command.name()]; + const args = command.registeredArguments.map((a) => (a.required ? `<${a.name()}>` : `[${a.name()}]`)); + const usage = ['teamai', ...path, ...args].join(' '); + + const lines: string[] = []; + const description = command.description(); + lines.push(`- \`${usage}\`${description ? ` — ${description}` : ''}`); + for (const option of visibleOptions(command)) { + lines.push(renderOption(option)); + } + for (const sub of command.commands as Command[]) { + lines.push(...renderCommand(sub, path).map((line) => ` ${line}`)); + } + return lines; +} + +/** Render the whole command table as the markdown the `core` skill serves. */ +export function renderCommandsReference(program: Command): string { + const sections: string[] = [HEADER]; + + const globalOptions = visibleOptions(program); + if (globalOptions.length > 0) { + sections.push(['## Global options', '', ...globalOptions.map(renderOption).map((l) => l.slice(2))].join('\n')); + } + + for (const command of program.commands as Command[]) { + sections.push([`## ${command.name()}`, '', ...renderCommand(command, [])].join('\n')); + } + + return sections.join('\n\n') + '\n'; +} diff --git a/src/index.ts b/src/index.ts index 9e42e56b..71273c6b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1249,4 +1249,14 @@ async function publishMaintenance(localConfig: LocalConfig, message: string): Pr } } -program.parse(); +/** + * The command table doubles as the source of truth for the generated skill + * command reference (skill-data/core/references/commands.md). Importing this + * module with TEAMAI_COMMAND_TABLE_ONLY set yields `program` without running + * the CLI. + */ +export { program }; + +if (!process.env.TEAMAI_COMMAND_TABLE_ONLY) { + program.parse(); +} diff --git a/src/skill-content.ts b/src/skill-content.ts index 81223804..19fc6cf7 100644 --- a/src/skill-content.ts +++ b/src/skill-content.ts @@ -40,6 +40,8 @@ const SKILL_MD = 'SKILL.md'; */ const SKILL_ALIASES: Readonly<Record<string, string>> = { default: 'core', + onboarding: 'setup', + join: 'setup', codebase: 'wiki', 'team-wiki-codebase': 'wiki', learning: 'share', From d6b0ac040128a6cb1465ea6b9034735a32416efe Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Mon, 21 Sep 2026 21:43:03 +0200 Subject: [PATCH 04/37] feat(pull): prune legacy builtin skill directories, gate recall at run time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrading the CLI used to leave the pre-stub trees in place: cleanup skips builtin names, and nothing else knew about them, so `team-wiki-codebase` and `teamai-share-learnings` would sit in every agent directory on the machine forever. Deployment now removes them first, in both the configured skills path and Codex's shared `.agents/skills`. Unconditional, because those trees were overwritten on every pull, so no local edit ever survived in them. Recall moves from deploy time to run time. Before, `skipRecall` decided whether the share skill reached the agent at all; with one stub routing to everything, there is no directory to withhold, so `teamai skill get share` checks instead and says what to enable. `--all` is exempt: an inventory dump is not an attempt to run the workflow. With no team config to consult the gate fails open — a fresh machine reading the docs gets the content rather than a refusal it cannot act on. deployBuiltinSkills drops its `skipRecall` option rather than keeping one that no longer decides anything, and recall-toggle stops deleting a skill directory it no longer owns. Refs #678 --- src/__tests__/init.test.ts | 5 +- src/__tests__/skill-recall-gate.test.ts | 83 ++++++++++++++++++++ src/__tests__/skip-uninstalled-tools.test.ts | 13 ++- src/builtin-skills.ts | 35 ++++++--- src/init.ts | 3 +- src/pull.ts | 5 +- src/recall-toggle.ts | 13 +-- src/resources/skills.ts | 2 +- src/skill-cmd.ts | 5 +- src/skill-content.ts | 39 +++++++++ 10 files changed, 169 insertions(+), 34 deletions(-) create mode 100644 src/__tests__/skill-recall-gate.test.ts diff --git a/src/__tests__/init.test.ts b/src/__tests__/init.test.ts index 12cc057b..77719dc7 100644 --- a/src/__tests__/init.test.ts +++ b/src/__tests__/init.test.ts @@ -561,7 +561,7 @@ describe('init', () => { }); describe('deploys built-in skills after init', () => { - it('calls deployBuiltinSkills with teamConfig and skipRecall when loadTeamConfig returns non-null', async () => { + it('calls deployBuiltinSkills with teamConfig when loadTeamConfig returns non-null', async () => { let cloneDone = false; pathExistsFn = (p: string) => { if (p === localPath) return cloneDone; @@ -592,10 +592,11 @@ describe('init', () => { await init({ repo: 'https://git.woa.com/HyperAI/teamai-test.git', scope: 'user' }); expect(mockDeployBuiltinSkills).toHaveBeenCalled(); + // No recall option: one stub deploys for everyone, and `teamai skill get + // share` is where recall is checked (#678). expect(mockDeployBuiltinSkills).toHaveBeenCalledWith( expect.objectContaining({ team: expect.any(String) }), expect.anything(), - expect.objectContaining({ skipRecall: expect.any(Boolean) }), ); }); }); diff --git a/src/__tests__/skill-recall-gate.test.ts b/src/__tests__/skill-recall-gate.test.ts new file mode 100644 index 00000000..24147354 --- /dev/null +++ b/src/__tests__/skill-recall-gate.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const autoDetectInit = vi.fn(); +vi.mock('../config.js', () => ({ autoDetectInit })); + +import { blockedByRecall, skillGet } from '../skill-content.js'; + +/** + * Recall used to be decided when deploying: the share skill simply was not + * copied into the agent. One deployed stub routes to every workflow, so the + * decision moved to the moment the agent asks for the content (#678). + */ +describe('recall gate on served skills', () => { + let stderr: string; + let stdout: string; + const restore: Array<() => void> = []; + + beforeEach(() => { + stderr = ''; + stdout = ''; + process.exitCode = undefined; + autoDetectInit.mockReset(); + + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + stderr += args.join(' ') + '\n'; + }); + restore.push(() => writeSpy.mockRestore(), () => errorSpy.mockRestore()); + }); + + afterEach(() => { + while (restore.length > 0) restore.pop()?.(); + process.exitCode = undefined; + }); + + const withRecall = (enabled: boolean): void => { + autoDetectInit.mockResolvedValue({ + localConfig: { recallEnabled: enabled }, + teamConfig: { sharing: { recall: { enabled } } }, + }); + }; + + it('blocks share when recall is disabled, and says what to turn on', async () => { + withRecall(false); + + expect(await blockedByRecall('share')).toBe(true); + + await skillGet(['share']); + expect(process.exitCode).toBe(1); + expect(stdout).toBe(''); + expect(stderr).toContain('share needs recall'); + expect(stderr).toContain('teamai recall enable'); + }); + + it('serves share when recall is enabled', async () => { + withRecall(true); + + expect(await blockedByRecall('share')).toBe(false); + + await skillGet(['share']); + expect(process.exitCode).toBeUndefined(); + expect(stdout).toContain('name: share'); + }); + + it('never gates the skills that do not depend on recall', async () => { + withRecall(false); + + for (const name of ['core', 'setup', 'wiki']) { + expect(await blockedByRecall(name), name).toBe(false); + } + }); + + it('fails open when there is no team config to consult', async () => { + autoDetectInit.mockRejectedValue(new Error('not initialized')); + + // A fresh machine reading the docs gets the content, not a refusal it + // cannot act on. + expect(await blockedByRecall('share')).toBe(false); + }); +}); diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index a2ca7503..14ab396a 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -571,7 +571,7 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { expect(await fse.pathExists(path.join(homeDir, '.opencode'))).toBe(false); }); - it('still deploys the stub when recall is disabled (skipRecall)', async () => { + it('deploys the stub regardless of recall, and prunes the legacy directories', async () => { const { deployBuiltinSkills } = await import('../builtin-skills.js'); const teamConfig = { @@ -599,11 +599,18 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { scope: 'user' as const, }; - const deployed = await deployBuiltinSkills(teamConfig, localConfig, { skipRecall: true }); + // Pre-stub releases left these behind in every agent directory. + await fse.ensureDir(path.join(homeDir, '.claude/skills/team-wiki-codebase/references')); + await fse.writeFile(path.join(homeDir, '.claude/skills/team-wiki-codebase/SKILL.md'), '# old'); + await fse.ensureDir(path.join(homeDir, '.claude/skills/teamai-share-learnings')); + await fse.writeFile(path.join(homeDir, '.claude/skills/teamai-share-learnings/SKILL.md'), '# old'); + + const deployed = await deployBuiltinSkills(teamConfig, localConfig); expect(deployed).toBeGreaterThan(0); // The stub routes to every workflow, so recall no longer gates deployment: - // `teamai skill get share` decides at run time whether recall is on. + // `teamai skill get share` decides at run time whether recall is on, and the + // directories earlier releases deployed are removed on the way. expect(await fse.pathExists(path.join(homeDir, '.claude/skills/teamai/SKILL.md'))).toBe(true); expect(await fse.pathExists(path.join(homeDir, '.claude/skills/team-wiki-codebase'))).toBe(false); expect(await fse.pathExists(path.join(homeDir, '.claude/skills/teamai-share-learnings'))).toBe(false); diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index 494beeaf..c9c89413 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -1,12 +1,12 @@ import fs from 'node:fs'; import path from 'node:path'; import fse from 'fs-extra'; -import { pathExists } from './utils/fs.js'; +import { pathExists, remove } from './utils/fs.js'; import { log } from './utils/logger.js'; import type { TeamaiConfig, LocalConfig } from './types.js'; import { resolveToolBaseDir, isAgentExcluded, scopedToolPaths } from './types.js'; import { isToolInstalledForConfig, ResourceHandler } from './resources/base.js'; -import { resolveSkillDestination } from './resources/skills.js'; +import { resolveSkillDestination, SHARED_AGENT_SKILLS_PATH } from './resources/skills.js'; import { getUserHome } from './utils/home.js'; import { packagedSkillRoots } from './skill-content.js'; @@ -50,13 +50,29 @@ export const LEGACY_BUILTIN_SKILL_NAMES = new Set([ ]); /** - * Built-in skills that depend on recall being enabled. Skipped when recall is disabled. + * Remove the skill directories earlier releases deployed. * - * Only teamai-share-learnings belongs here: it contributes learnings back to the - * team repo, which is meaningful only when recall is on. team-wiki-codebase is a - * knowledge-base generator and does not depend on recall, so it must always deploy. + * Unconditional: those trees were overwritten on every pull (`overwrite: true`), + * so no local edit ever survived in them, and leaving them behind costs every + * agent on the machine the context they were deployed to save. */ -export const RECALL_DEPENDENT_SKILLS = new Set(['teamai-share-learnings']); +async function pruneLegacyBuiltinSkills(tool: string, configuredSkillsPath: string, baseDir: string): Promise<void> { + for (const legacyName of LEGACY_BUILTIN_SKILL_NAMES) { + const candidates = [ + path.join(baseDir, configuredSkillsPath, legacyName), + path.join(baseDir, SHARED_AGENT_SKILLS_PATH, legacyName), + ]; + for (const dir of candidates) { + if (!await pathExists(dir)) continue; + try { + await remove(dir); + log.debug(`Removed legacy built-in skill ${legacyName} from ${tool} (${dir})`); + } catch (e) { + log.debug(`Could not remove legacy built-in skill ${legacyName} from ${tool}: ${(e as Error).message}`); + } + } + } +} /** * Deploy CLI built-in skills to all configured AI tool skill directories. @@ -73,7 +89,7 @@ export const RECALL_DEPENDENT_SKILLS = new Set(['teamai-share-learnings']); * - Built-in skills directory doesn't exist (dev environment without build) * - A tool's skills directory is not configured */ -export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig?: LocalConfig, options?: { reportingOnly?: boolean; skipRecall?: boolean }): Promise<number> { +export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig?: LocalConfig, options?: { reportingOnly?: boolean }): Promise<number> { // Reporting-only HTTP mode has no team repo to write to, so the workflows the // stub routes to are non-functional there. Skip built-in skills entirely. if (options?.reportingOnly) { @@ -98,7 +114,6 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? // Filter to directories that contain SKILL.md const skillNames: string[] = []; for (const entry of entries) { - if (options?.skipRecall && RECALL_DEPENDENT_SKILLS.has(entry)) continue; const skillMd = path.join(builtinDir, entry, 'SKILL.md'); if (await pathExists(skillMd)) { skillNames.push(entry); @@ -124,6 +139,8 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? } if (localConfig && isAgentExcluded(localConfig, tool)) continue; + await pruneLegacyBuiltinSkills(tool, toolPath.skills, baseDir); + for (const skillName of skillNames) { const srcDir = path.join(builtinDir, skillName); const destDir = await resolveSkillDestination(tool, toolPath.skills, baseDir, skillName, srcDir); diff --git a/src/init.ts b/src/init.ts index 4ebfce97..d76fc8bd 100644 --- a/src/init.ts +++ b/src/init.ts @@ -1622,8 +1622,7 @@ export async function init(options: GlobalOptions & { // is available in the IDE right after init, without waiting for first pull. try { const { deployBuiltinSkills } = await import('./builtin-skills.js'); - const skipRecall = !isRecallEnabled(localConfig, reloadedTeamConfig); - const deployed = await deployBuiltinSkills(reloadedTeamConfig, localConfig, { skipRecall }); + const deployed = await deployBuiltinSkills(reloadedTeamConfig, localConfig); if (deployed > 0) { log.debug(`Deployed ${deployed} built-in skill(s)`); } diff --git a/src/pull.ts b/src/pull.ts index f0627bad..37584353 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -1021,7 +1021,7 @@ async function pullForScope( const skipRecall = !isRecallEnabled(localConfig, freshConfig); try { const { deployBuiltinAgents } = await import('./builtin-agents.js'); await deployBuiltinAgents(freshConfig, localConfig, { skipRecall }); } catch {} try { const { deployBuiltinRules } = await import('./builtin-rules.js'); await deployBuiltinRules(freshConfig, localConfig, { skipRecall }); } catch {} - try { const { deployBuiltinSkills } = await import('./builtin-skills.js'); await deployBuiltinSkills(freshConfig, localConfig, { reportingOnly, skipRecall }); } catch {} + try { const { deployBuiltinSkills } = await import('./builtin-skills.js'); await deployBuiltinSkills(freshConfig, localConfig, { reportingOnly }); } catch {} // Refresh managed culture/shared-instruction blocks as well. A CLI // upgrade may add a new target file while the team repo SHA and tool // target set remain unchanged. @@ -1295,8 +1295,7 @@ async function pullForScope( if (!options.dryRun) { try { const { deployBuiltinSkills } = await import('./builtin-skills.js'); - const skipRecallForSkills = !isRecallEnabled(localConfig, freshConfig); - const deployed = await deployBuiltinSkills(freshConfig, localConfig, { reportingOnly, skipRecall: skipRecallForSkills }); + const deployed = await deployBuiltinSkills(freshConfig, localConfig, { reportingOnly }); if (deployed > 0) { log.debug(`[${scopeLabel}] Deployed ${deployed} built-in skill(s)`); } diff --git a/src/recall-toggle.ts b/src/recall-toggle.ts index d2fbd51a..4f5a14b5 100644 --- a/src/recall-toggle.ts +++ b/src/recall-toggle.ts @@ -9,7 +9,6 @@ import { type ToolName, } from './resources/agent-format.js'; import { ruleFileExtensionForTool } from './resources/rule-format.js'; -import { RECALL_DEPENDENT_SKILLS } from './builtin-skills.js'; import { resolveToolBaseDir, isRecallEnabled, @@ -54,16 +53,6 @@ async function removeRecallArtifacts(teamConfig: TeamaiConfig, localConfig: Loca } } - // Remove recall-dependent built-in skills - if (toolPath.skills) { - for (const skillName of RECALL_DEPENDENT_SKILLS) { - const skillDir = path.join(baseDir, toolPath.skills, skillName); - if (await pathExists(skillDir)) { - await remove(skillDir); - log.debug(`Removed recall skill ${skillName} from ${tool}`); - } - } - } // Remove recall block from CLAUDE.md if (toolPath.claudemd) { @@ -95,7 +84,7 @@ async function deployRecallArtifacts(teamConfig: TeamaiConfig, localConfig: Loca await deployBuiltinRules(teamConfig, localConfig, { skipRecall: false }); await deployBuiltinAgents(teamConfig, localConfig, { skipRecall: false }); - await deployBuiltinSkills(teamConfig, localConfig, { skipRecall: false }); + await deployBuiltinSkills(teamConfig, localConfig); // Inject recall rules block into CLAUDE.md for Tier-1 tools const { injectClaudeMdSection } = await import('./utils/claudemd.js'); diff --git a/src/resources/skills.ts b/src/resources/skills.ts index cdde62f6..77b152c3 100644 --- a/src/resources/skills.ts +++ b/src/resources/skills.ts @@ -16,7 +16,7 @@ import { splitFrontmatter, stringifyFrontmatter } from '../utils/frontmatter.js' const CONTRIBUTORS_FILE = 'CONTRIBUTORS'; const SKILL_MD = 'SKILL.md'; const CODEX_TOOL = 'codex'; -const SHARED_AGENT_SKILLS_PATH = '.agents/skills'; +export const SHARED_AGENT_SKILLS_PATH = '.agents/skills'; /** Prefer Codex's shared skill when that skill already lives there. */ export async function resolveSkillDestination( diff --git a/src/skill-cmd.ts b/src/skill-cmd.ts index a8b11363..a38daed3 100644 --- a/src/skill-cmd.ts +++ b/src/skill-cmd.ts @@ -13,7 +13,7 @@ import { type SkillSource, } from './agent-skills.js'; import { detectInstalledAgents, type ResolvedAgent } from './known-agents.js'; -import { resolvePackagedSkill, skillCatalog } from './skill-content.js'; +import { blockedByRecall, resolvePackagedSkill, skillCatalog } from './skill-content.js'; import type { GlobalOptions, LocalConfig } from './types.js'; const DESCRIPTION_MAX = 160; @@ -100,7 +100,8 @@ export async function skillList(options: GlobalOptions & { json?: boolean }): Pr console.log(' (none — the installed package ships no skill content)'); } else { for (const entry of catalog) { - console.log(` ${entry.name}`); + const blocked = await blockedByRecall(entry.name); + console.log(` ${entry.name}${blocked ? ' (needs recall — teamai recall enable)' : ''}`); console.log(` ${truncate(entry.description, DESCRIPTION_MAX) || '(no description)'}`); console.log(` teamai skill get ${entry.name}`); } diff --git a/src/skill-content.ts b/src/skill-content.ts index 19fc6cf7..aac89baa 100644 --- a/src/skill-content.ts +++ b/src/skill-content.ts @@ -49,6 +49,37 @@ const SKILL_ALIASES: Readonly<Record<string, string>> = { 'teamai-share-learnings': 'share', }; +/** + * Served skills that need recall to be on. + * + * `share` publishes a session's learnings into the team's learnings branch, + * which is meaningful only when recall is enabled. Before the discovery stub the + * gate was in deployment — the skill was simply absent. One stub routes to every + * workflow, so the gate moved here, where the command can also say what to turn + * on. + */ +const RECALL_DEPENDENT_SKILLS = new Set(['share']); + +/** + * Whether recall being off makes this skill unusable right now. + * + * Fails open: a machine with no team config (a fresh install reading the docs) + * gets the content rather than a refusal it cannot act on. + */ +export async function blockedByRecall(name: string): Promise<boolean> { + if (!RECALL_DEPENDENT_SKILLS.has(name)) return false; + try { + const [{ autoDetectInit }, { isRecallEnabled }] = await Promise.all([ + import('./config.js'), + import('./types.js'), + ]); + const { localConfig, teamConfig } = await autoDetectInit(); + return !isRecallEnabled(localConfig, teamConfig); + } catch { + return false; + } +} + /** A skill directory that ships inside the npm package. */ export interface PackagedSkill { name: string; @@ -253,6 +284,8 @@ export async function skillGet(names: string[], options: SkillGetOptions = {}): const targets: PackagedSkill[] = []; if (options.all) { + // An inventory dump is not an attempt to run a workflow, so the recall gate + // stays out of it; asking for the skill by name is what hits the gate. targets.push(...servable); } else { for (const name of requested) { @@ -261,6 +294,12 @@ export async function skillGet(names: string[], options: SkillGetOptions = {}): notFound(name, servable); return; } + if (await blockedByRecall(skill.name)) { + diagnostic(`${chalk.red('✖')} ${skill.name} needs recall, which is disabled for this team.`); + diagnostic(' Turn it on with `teamai recall enable`, or ask your team admin to enable sharing.'); + process.exitCode = 1; + return; + } targets.push(skill); } } From 916b514d87e58f3948a9db01dec9c4c849104fc8 Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Mon, 21 Sep 2026 21:46:57 +0200 Subject: [PATCH 05/37] docs: align the nudge and the guides with CLI-served skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/teamai-share-learnings` was never a slash command of its own — it existed because the directory was installed. The Stop-hook nudge now names `/teamai` and carries `teamai skill get share` literally, so an agent can act on it without having to infer the intent from the conversation. The five READMEs and both usage guides follow. Both guides gain the `skill get` / `skill path` commands and a short section on why built-in skills are served rather than copied. `docs/designs/skill-serving.md` records the contracts that are easy to break later: byte-for-byte output, {SKILL_DIR} substitution, stdout/stderr discipline, recursive `--full`, the run-time recall gate, the three drift guards, and when to retire LEGACY_BUILTIN_SKILL_NAMES and the long-name aliases. AGENTS.md and CLAUDE.md gain the rule that keeps this from rotting: skill-data is treated like documentation, a behaviour change updates the affected skill, commands.md is regenerated rather than edited, and new workflows go under skill-data instead of into the stub. Refs #678 --- AGENTS.md | 3 + CLAUDE.md | 3 + docs/designs/git-native-memory.md | 2 +- docs/designs/skill-serving.md | 99 ++++++++++++++++++++++ docs/product-overview.md | 4 +- docs/product-overview.zh-CN.md | 4 +- docs/usage-guide.md | 23 ++++- docs/usage-guide.zh-CN.md | 21 ++++- src/__tests__/codex-stop-hint.test.ts | 2 +- src/__tests__/contribute-check-e2e.test.ts | 4 +- src/contribute-check.ts | 4 +- src/pull.ts | 2 +- src/types.ts | 2 +- 13 files changed, 153 insertions(+), 20 deletions(-) create mode 100644 docs/designs/skill-serving.md diff --git a/AGENTS.md b/AGENTS.md index 7a9df7b0..d159c371 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,9 @@ TypeScript, Node 20+, tsup (ESM), Vitest. Commands: `npm run build`, `npx tsc -- - CLI user-facing output must be English. No Chinese in production code. Tests assert English output. - Keep bilingual docs in sync (`README` / `*.zh-CN.md`, `docs/usage-guide.*`). Behavior changes must update every affected doc (including `docs/designs/`); grep old wording before opening the PR. - **README 精简**:尽量少改动 README,保持简洁。确需改动时,所有语言版本(`README.md` 及全部 `README.*.md`,改前先 `ls README*` 确认清单)必须全部改完并保持一致。 +- **`skill-data/` 与文档同等对待**:那是 agent 真正读到的内容。行为变更必须同步更新受影响的 skill(`core` / `setup` / `wiki` / `share`),并在 PR 前 grep 旧措辞。 +- `skill-data/core/references/commands.md` 由 Commander 命令表生成,改动命令或 flag 后运行 `npx vitest run commands-reference -u` 重新生成。 +- 部署到 agent 的只有 `skills/teamai/SKILL.md`(发现入口),保持与版本无关:新增工作流是在 `skill-data/` 下加目录 + 在 stub 里加一行,不要把内容写进 stub。 - **奥卡姆剃刀**:避免过早添加新 CLI 命令;非必要不加;优先复用或扩展现有命令与选项。 ## PR 前测试 diff --git a/CLAUDE.md b/CLAUDE.md index 6f21a2fd..e93ec1fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,9 @@ TypeScript, Node 20+, tsup (ESM), Vitest. Commands: `npm run build`, `npx tsc -- - CLI user-facing output must be English. No Chinese in production code. Tests assert English output. - Keep bilingual docs in sync (`README` / `*.zh-CN.md`, `docs/usage-guide.*`). Behavior changes must update every affected doc (including `docs/designs/`); grep old wording before opening the PR. - **README 精简**:尽量少改动 README,保持简洁。确需改动时,所有语言版本(`README.md` 及全部 `README.*.md`,改前先 `ls README*` 确认清单)必须全部改完并保持一致。 +- **`skill-data/` 与文档同等对待**:那是 agent 真正读到的内容。行为变更必须同步更新受影响的 skill(`core` / `setup` / `wiki` / `share`),并在 PR 前 grep 旧措辞。 +- `skill-data/core/references/commands.md` 由 Commander 命令表生成,改动命令或 flag 后运行 `npx vitest run commands-reference -u` 重新生成。 +- 部署到 agent 的只有 `skills/teamai/SKILL.md`(发现入口),保持与版本无关:新增工作流是在 `skill-data/` 下加目录 + 在 stub 里加一行,不要把内容写进 stub。 - **奥卡姆剃刀**:避免过早添加新 CLI 命令;非必要不加;优先复用或扩展现有命令与选项。 ## PR 前测试 diff --git a/docs/designs/git-native-memory.md b/docs/designs/git-native-memory.md index de2b2945..d9b9d1fe 100644 --- a/docs/designs/git-native-memory.md +++ b/docs/designs/git-native-memory.md @@ -75,7 +75,7 @@ 1. `src/types.ts` — LearningDoc, SearchIndex types 2. `src/utils/search-index.ts` — buildIndex(), loadIndex(), search() with Intl.Segmenter 3. `src/pull.ts` — syncLearnings() step + index rebuild -4. `skills/teamai-share-learnings/SKILL.md` — frontmatter 标准化 +4. `skill-data/share/SKILL.md` — frontmatter 标准化(由 `teamai skill get share` 提供,不再部署到各 agent) 5. Tests: index build, search, CJK, edge cases ### Phase 2: Recall + Voting diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md new file mode 100644 index 00000000..e28bee9f --- /dev/null +++ b/docs/designs/skill-serving.md @@ -0,0 +1,99 @@ +# Serving built-in skill content from the CLI + +Issue: [#678](https://github.com/Tencent/teamai-cli/issues/678). Shipped in 0.23.0. + +## The problem + +`deployBuiltinSkills` copied three whole skill directories — 176 KB — into every +installed agent's skills directory, on `init`, on `pull` and on a recall toggle. +Nothing else redeployed them, so `npm i -g teamai-cli@latest` left the previous +content in place until the member ran a pull. Four commits exist only to +re-align deployed text after a command changed (`e151d43`, `1ca43ac`, `8bb0548`, +`2ddb546`), and `skills/team-wiki-codebase/SKILL.md` alone was 38 705 bytes — +roughly 10k tokens read on every activation, before the agent opened a single +reference file. + +## The shape + +One deployable unit, everything else served on demand. The pattern is +`vercel-labs/agent-browser`'s, verified against its published 0.38.1 package. + +```text +npm package +├── skills/ +│ └── teamai/SKILL.md the only unit deployed into agents (~2 KB) +└── skill-data/ never deployed; printed by `teamai skill get` + ├── core/ daily sync, routing, generated command reference + ├── setup/ day 0 and repo lifecycle + ├── wiki/ codebase knowledge base, incl. scripts/ + └── share/ session learnings +``` + +`skills/` keeps the invariant "everything here is deployed", which is what lets +`BUILTIN_SKILL_NAMES` hold a single name instead of a list of guards. + +What an agent reads, and when: + +```text +session start stub frontmatter description ~1.3 KB always in context +task matches stub body ~0.9 KB holds the commands +`teamai skill get core` daily workflow ~5.7 KB on demand +`… core --full` + troubleshooting + commands.md ~32 KB on demand +`… setup` / `wiki` / `share` on demand +``` + +## Contracts worth keeping + +- **`skill get` prints the file byte for byte**, frontmatter included, with no + banner. The only transformation is `{SKILL_DIR}`, replaced with the absolute + packaged directory, so a documented `python3 {SKILL_DIR}/scripts/scan_repo.py` + runs as written. `agent-browser` leaves that placeholder unsubstituted; an + agent copying such a line literally fails, which is why we resolve it. +- **Content on stdout, diagnostics on stderr.** An unknown flag warns and the + command continues — a hallucinated flag should not cost a round trip. An + unknown *name* is fatal: acting on the wrong instructions is worse than a + retry. +- **`--full` walks `references/` and `templates/` recursively**, sorted by + relative path. Our references nest (`references/methodology/`, + `references/phases/`); a single-level scan would serve an incomplete skill. +- **Nothing repairs the deployed stub.** `ensureSkillFrontmatter` is not called + on it, so deployed and packaged bytes are identical and a diff means a bug. +- **Recall is decided at run time**, inside `skill get`, not by withholding a + directory at deploy time. With no team config to consult it fails open. + +## Drift guards + +Two tests, both in the unit suite: + +- `commands-reference.test.ts` renders `skill-data/core/references/commands.md` + from the Commander table and diffs it. Regenerate with + `npx vitest run commands-reference -u`. +- `skill-commands-exist.test.ts` resolves every `teamai …` string written + anywhere under `skill-data/` against that same table, and fails on an unknown + command or flag. It carries a case proving it catches `teamai extract graph`, + the command the wiki skill advertised for four releases. + +A third, in `skill-content.test.ts`, asserts through `npm pack` that both +`skills/` and `skill-data/` are in the published tarball. Without it, a missing +`package.json` "files" entry passes every other test and serves nothing once +installed. + +## Migration + +`LEGACY_BUILTIN_SKILL_NAMES` (`src/builtin-skills.ts`) names the directories +earlier releases deployed: `team-wiki-codebase`, `teamai-share-learnings`, and +the two that were only ever guards, `teamai-workflow` and `teamai-import`. +Deployment removes them from every installed agent, in the configured skills +path and in Codex's shared `.agents/skills`. The removal is unconditional +because those trees were overwritten with `overwrite: true` on every pull, so no +local edit ever survived in them. + +**Retire that set once 0.23.x is no longer in the field.** The short names +(`wiki`, `share`) are the canonical ones; the long names survive as aliases in +`SKILL_ALIASES` (`src/skill-content.ts`) for documentation and muscle memory, +and can be dropped on the same schedule. + +`/teamai-share-learnings` was never a deployed slash command in its own right — +it existed because the directory was installed. The Stop-hook nudge now names +`/teamai` and carries `teamai skill get share` literally, so an agent can act on +it even without inferring the intent. diff --git a/docs/product-overview.md b/docs/product-overview.md index ac33bec1..5b4fb1ae 100644 --- a/docs/product-overview.md +++ b/docs/product-overview.md @@ -115,10 +115,10 @@ When a session ends, the Stop hook scores it by **friction** — signals that th Task: Fix duplicate project-level Hook injection -Consider running /teamai-share-learnings to summarize what you learned and share it with your team. +Consider running /teamai to summarize what you learned and share it with your team (or run `teamai skill get share`). ``` -The hint names the non-zero friction signals that triggered it and, when available, includes a redacted, single-line summary of the first task. The `/teamai-share-learnings` skill summarizes the session and pushes a learning document directly to the team repo. Each session is prompted at most once. Teams can switch the hint off with `sharing.contributeHint.enabled: false` in `teamai.yaml` (members: `contributeHintEnabled` in local config) while keeping the rest of the Stop hook. +The hint names the non-zero friction signals that triggered it and, when available, includes a redacted, single-line summary of the first task. The `share` workflow (`teamai skill get share`) summarizes the session and pushes a learning document directly to the team repo. Each session is prompted at most once. Teams can switch the hint off with `sharing.contributeHint.enabled: false` in `teamai.yaml` (members: `contributeHintEnabled` in local config) while keeping the rest of the Stop hook. ### Team Knowledge Recall diff --git a/docs/product-overview.zh-CN.md b/docs/product-overview.zh-CN.md index f3396d89..ac4fc187 100644 --- a/docs/product-overview.zh-CN.md +++ b/docs/product-overview.zh-CN.md @@ -115,10 +115,10 @@ Session 结束时,Stop hook 按**摩擦信号**对 session 评分——这些 Task: Fix duplicate project-level Hook injection -Consider running /teamai-share-learnings to summarize what you learned and share it with your team. +Consider running /teamai to summarize what you learned and share it with your team (or run `teamai skill get share`). ``` -提示会列出实际触发它的非零摩擦信号;如果能取得首个任务摘要,还会在脱敏、单行化后附上任务上下文。`/teamai-share-learnings` skill 自动总结 session 经验并推送到团队仓库。每个 session 最多提示一次。团队可在 `teamai.yaml` 设置 `sharing.contributeHint.enabled: false` 关闭该提示(成员可用本地配置 `contributeHintEnabled` 覆盖),Stop hook 的其余功能不受影响。 +提示会列出实际触发它的非零摩擦信号;如果能取得首个任务摘要,还会在脱敏、单行化后附上任务上下文。`share` 工作流(`teamai skill get share`)自动总结 session 经验并推送到团队仓库。每个 session 最多提示一次。团队可在 `teamai.yaml` 设置 `sharing.contributeHint.enabled: false` 关闭该提示(成员可用本地配置 `contributeHintEnabled` 覆盖),Stop hook 的其余功能不受影响。 ### 团队知识检索 diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 35f53d26..4a2b33f6 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -457,8 +457,23 @@ teamai list env --reveal # Show env values in plaintext (default: mas teamai skill # Equivalent to teamai list skills --source all teamai skill show hai-deploy-test # View a single skill's source / contributor / install locations / description summary + +teamai skill list --json # The built-in skills the installed CLI serves, machine-readable +teamai skill get core # Print a built-in workflow: core | setup | wiki | share +teamai skill get wiki --full # ...with its references and templates appended +teamai skill path wiki # The packaged directory, for the scripts a skill ships ``` +#### Built-in skills are served, not copied + +Agents receive one file from the CLI: `~/.<tool>/skills/teamai/SKILL.md`, a ~2 KB +discovery stub. The workflows it routes to (`core`, `setup`, `wiki`, `share`) stay +inside the npm package and are printed by `teamai skill get`, so what an agent reads +always matches the installed CLI version — `npm i -g teamai-cli@latest` is enough, with +no pull needed for the content to be current. Older releases copied the whole tree into +every agent directory; `teamai pull` removes those leftovers. The legacy names still +resolve: `teamai skill get team-wiki-codebase` serves `wiki`. + --- ## Day-to-Day Use @@ -896,10 +911,10 @@ The AI tracks your coding sessions via Hooks. When a session ends (the Stop hook Task: Fix duplicate project-level Hook injection -Consider running /teamai-share-learnings to summarize what you learned and share it with your team. +Consider running /teamai to summarize what you learned and share it with your team (or run `teamai skill get share`). ``` -The reminder lists the non-zero friction signals that triggered it. When the first task is available, it also includes a redacted, single-line task summary so you can decide whether the session is worth sharing. Using the built-in `/teamai-share-learnings` skill, the AI will automatically summarize the session's learnings and contribute them to the team knowledge base. Each session is prompted at most once. +The reminder lists the non-zero friction signals that triggered it. When the first task is available, it also includes a redacted, single-line task summary so you can decide whether the session is worth sharing. Using the built-in `share` workflow (`teamai skill get share`), the AI will automatically summarize the session's learnings and contribute them to the team knowledge base. Each session is prompted at most once. For the Codex family (`codex`, `codex-internal`, `tcodex`), the Stop hook saves contribution and knowledge-reference reminders for the next UserPromptSubmit in the same session. It does not force an extra agent turn. Contribution reminders are delivered once and discarded if you contribute before the next prompt. @@ -920,7 +935,7 @@ Teams that route knowledge sharing through their own review flow (for example, a | User override | `~/.teamai/config.yaml` | `contributeHintEnabled` | `true` / `false`, takes priority over the team default | | Environment variable | shell | `TEAMAI_CONTRIBUTE_HINT_DISABLED=1` | Force-disables the hint (emergency kill switch) | -Only the nudge is affected: friction scoring, `teamai contribute --file`, and `/teamai-share-learnings` keep working when invoked manually. +Only the nudge is affected: friction scoring, `teamai contribute --file`, and `/teamai` keep working when invoked manually. ### Searching knowledge @@ -1841,7 +1856,7 @@ sharing: coAuthor: enabled: false # optional; strip AI-tool commit trailers team-wide contributeHint: - enabled: true # optional; false = no /teamai-share-learnings nudge after high-friction sessions + enabled: true # optional; false = no /teamai nudge after high-friction sessions intervention: correctionKeywords: [] # optional; extra course-correction words merged with the built-in zh/en/ja list webhooks: # optional; notify external endpoints on team events (see "Webhook notifications") diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index d0fa0ee4..21c7d031 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -434,8 +434,21 @@ teamai list env --reveal # 明文显示 env(默认脱敏) teamai skill # 等价于 teamai list skills --source all teamai skill show hai-deploy-test # 看单个 skill 的来源 / 贡献者 / 安装位置 / 描述摘要 + +teamai skill list --json # 当前 CLI 提供的内置 skill 清单(机器可读) +teamai skill get core # 打印内置工作流:core | setup | wiki | share +teamai skill get wiki --full # 同时附上该 skill 的 references 与 templates +teamai skill path wiki # 打印打包目录,用于运行 skill 自带的脚本 ``` +#### 内置 skill 由 CLI 提供,不再复制 + +每个 agent 只收到一个文件:`~/.<tool>/skills/teamai/SKILL.md`,约 2 KB 的发现入口(stub)。 +它指向的工作流(`core`、`setup`、`wiki`、`share`)保留在 npm 包内,由 `teamai skill get` 按需打印, +因此 agent 读到的内容始终与已安装的 CLI 版本一致——`npm i -g teamai-cli@latest` 之后无需 `teamai pull` +内容就是最新的。旧版本会把整棵目录复制到每个 agent 下,`teamai pull` 会清除这些残留。 +旧名字仍然可用:`teamai skill get team-wiki-codebase` 等价于 `wiki`。 + --- ## 日常使用 @@ -868,10 +881,10 @@ AI 通过 Hooks 追踪你的编码会话。当会话结束时(Stop hook), Task: Fix duplicate project-level Hook injection -Consider running /teamai-share-learnings to summarize what you learned and share it with your team. +Consider running /teamai to summarize what you learned and share it with your team (or run `teamai skill get share`). ``` -提醒会列出实际触发它的非零摩擦信号;如果能取得首个任务,还会附上脱敏、单行化后的任务摘要,便于判断本次 session 是否值得分享。使用内置 skill `/teamai-share-learnings`,AI 会自动总结本次 session 经验并贡献到团队知识库。每个 session 最多提示一次。 +提醒会列出实际触发它的非零摩擦信号;如果能取得首个任务,还会附上脱敏、单行化后的任务摘要,便于判断本次 session 是否值得分享。使用内置 skill `/teamai`,AI 会自动总结本次 session 经验并贡献到团队知识库。每个 session 最多提示一次。 在 Codex 系列(`codex`、`codex-internal`、`tcodex`)中,Stop hook 会暂存贡献和知识引用提醒,在同一会话的下一次 UserPromptSubmit 交付,不会强制开启额外一轮。贡献提醒只交付一次;若下一次输入前已经贡献,则丢弃该提醒。 @@ -892,7 +905,7 @@ teamai contribute --file /tmp/session.md --scope project | 用户覆盖 | `~/.teamai/config.yaml` | `contributeHintEnabled` | `true` / `false`,优先级高于团队默认 | | 环境变量 | shell | `TEAMAI_CONTRIBUTE_HINT_DISABLED=1` | 强制关闭提醒(紧急开关) | -只影响提醒本身:摩擦评分、`teamai contribute --file` 和手动调用 `/teamai-share-learnings` 不受影响。 +只影响提醒本身:摩擦评分、`teamai contribute --file` 和手动调用 `/teamai` 不受影响。 ### 搜索知识 @@ -1788,7 +1801,7 @@ sharing: coAuthor: enabled: false # 可选,为全团队去除 AI 工具提交尾注 contributeHint: - enabled: true # 可选,false = 高摩擦 session 结束后不再提示 /teamai-share-learnings + enabled: true # 可选,false = 高摩擦 session 结束后不再提示 /teamai intervention: correctionKeywords: [] # 可选,额外的纠偏词,与内置中/英/日列表合并 webhooks: # 可选,在团队事件发生时通知外部端点(见"Webhook 通知") diff --git a/src/__tests__/codex-stop-hint.test.ts b/src/__tests__/codex-stop-hint.test.ts index f2aa4a89..da14ca64 100644 --- a/src/__tests__/codex-stop-hint.test.ts +++ b/src/__tests__/codex-stop-hint.test.ts @@ -39,7 +39,7 @@ describe('Codex Stop hint handoff with persisted session state', () => { expect(await stop.execute(stdin, 'codex')).toBeNull(); const state = await readContributeState(stdin.session_id); expect(state.hinted).toBe(true); - expect(state.pendingHint).toContain('teamai-share-learnings'); + expect(state.pendingHint).toContain('teamai skill get share'); expect(await stop.execute(stdin, 'codex')).toBeNull(); expect((await readContributeState(stdin.session_id)).pendingHint).toBe(state.pendingHint); expect(JSON.parse((await prompt.execute(stdin, 'codex'))!)).toEqual({ diff --git a/src/__tests__/contribute-check-e2e.test.ts b/src/__tests__/contribute-check-e2e.test.ts index 94e0e8b5..9b1261ba 100644 --- a/src/__tests__/contribute-check-e2e.test.ts +++ b/src/__tests__/contribute-check-e2e.test.ts @@ -162,7 +162,7 @@ describe('contribute-check E2E', () => { expect(result.stdout).toBe(''); const state = readSessionState(tmpHome, SESSION_ID)!; expect(state.hinted).toBe(true); - expect(state.pendingHint).toContain('teamai-share-learnings'); + expect(state.pendingHint).toContain('teamai skill get share'); const repeated = await runContributeCheck(tmpHome, makeStdinPayload(SESSION_ID), 'codex'); expect(repeated.stdout).toBe(''); expect(readSessionState(tmpHome, SESSION_ID)!.pendingHint).toBe(state.pendingHint); @@ -194,7 +194,7 @@ describe('contribute-check E2E', () => { expect(parsed.hookSpecificOutput.additionalContext).not.toContain(RAW_GITHUB_TOKEN); expect(parsed.hookSpecificOutput.additionalContext).not.toContain('50 tool calls'); expect(parsed.hookSpecificOutput.additionalContext).not.toContain('7 different tools'); - expect(parsed.hookSpecificOutput.additionalContext).toContain('/teamai-share-learnings'); + expect(parsed.hookSpecificOutput.additionalContext).toContain('/teamai'); expect(parsed.stopReason).toBeUndefined(); // The real CLI persists hinted=true, so a repeated Stop hook is silent. diff --git a/src/contribute-check.ts b/src/contribute-check.ts index b1d2ac06..b6a8fbd9 100644 --- a/src/contribute-check.ts +++ b/src/contribute-check.ts @@ -493,8 +493,8 @@ function buildHint({ friction, promptSummary, isKnowledgeGap }: HintContext): st } const task = promptSummary ? `\n\nTask: ${promptSummary}` : ''; const action = isKnowledgeGap - ? 'Consider running /teamai-share-learnings to summarize what you learned, share it with your team, and fill the knowledge gap.' - : 'Consider running /teamai-share-learnings to summarize what you learned and share it with your team.'; + ? 'Consider running /teamai to summarize what you learned, share it with your team, and fill the knowledge gap (or run `teamai skill get share`).' + : 'Consider running /teamai to summarize what you learned and share it with your team (or run `teamai skill get share`).'; return `${headline}${task}\n\n${action}`; } diff --git a/src/pull.ts b/src/pull.ts index 37584353..ab064a84 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -758,7 +758,7 @@ async function pullForScope( const pullSpin = spinner(`[${scopeLabel}] Pulling team repo...`).start(); let currentRev: string | null = null; // Reporting-only HTTP endpoints have no team repo to write to, so the - // team-repo-dependent built-in skill (teamai-share-learnings) is useless + // team-repo-dependent built-in workflows the stub routes to are useless // there and must not be injected. let reportingOnly = false; // A failed submodule update holds the rev back below so the next pull diff --git a/src/types.ts b/src/types.ts index 6dffa604..fe20b7cc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -86,7 +86,7 @@ export const SharingConfigSchema = z.object({ // isContributeHintEnabled() for the resolved view. contributeHint: z.object({ /** Team default: whether the Stop hook nudges members to run - * /teamai-share-learnings after a high-friction session. Teams that route + * /teamai after a high-friction session. Teams that route * knowledge sharing through their own review flow can turn the nudge off * without disabling the rest of the Stop hook (update check, votes sync, * dashboard reporting). */ From 42d241c9cee09d6abcb5e33647aed827d526f3c9 Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Mon, 21 Sep 2026 21:57:39 +0200 Subject: [PATCH 06/37] fix(skills): apply standards review findings `teamai init` printed "Built-in skills (e.g. team-wiki-codebase) are ready to use in your IDE now" seconds after deployment deleted that very directory. The message now names the teamai skill and how it loads its workflows. Two comments carrying the same stale name follow. The five READMEs said different things: only the English one named the share workflow and its command. All five now do. `collectSupplementaryFiles` hand-rolled a recursive walk that `listFilesRecursive` already does, including the ignore list that skips `.pyc` and `__pycache__` next to the wiki's Python scripts. It calls the helper instead. `listServableSkills` drops its fallback to `skills/`: a package without `skill-data/` is broken, and serving the stub as if it were the content hides that from the one error message built to report it. Tests drop six non-null assertions for a helper that throws, per the repo's rule against moving a compile-time error to run time. AGENTS.md and CLAUDE.md record the exemption the branch created: skill content printed by `skill get` keeps the language its author wrote it in, while the command's own prompts, errors and listings stay English. Refs #678 --- AGENTS.md | 1 + CLAUDE.md | 1 + src/__tests__/skill-content.test.ts | 37 ++++++++++++++++---------- src/init.ts | 7 ++--- src/skill-content.ts | 40 +++++++---------------------- src/wiki-engine/manifest-schema.ts | 5 ++-- 6 files changed, 41 insertions(+), 50 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d159c371..b4c2fc01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ TypeScript, Node 20+, tsup (ESM), Vitest. Commands: `npm run build`, `npx tsc -- ## Rules - CLI user-facing output must be English. No Chinese in production code. Tests assert English output. + 例外:`teamai skill get` 打印的 `skill-data/` 内容是文档,保留作者书写的语言;命令自身的提示、错误与 `skill list` 输出仍然必须是英文。 - Keep bilingual docs in sync (`README` / `*.zh-CN.md`, `docs/usage-guide.*`). Behavior changes must update every affected doc (including `docs/designs/`); grep old wording before opening the PR. - **README 精简**:尽量少改动 README,保持简洁。确需改动时,所有语言版本(`README.md` 及全部 `README.*.md`,改前先 `ls README*` 确认清单)必须全部改完并保持一致。 - **`skill-data/` 与文档同等对待**:那是 agent 真正读到的内容。行为变更必须同步更新受影响的 skill(`core` / `setup` / `wiki` / `share`),并在 PR 前 grep 旧措辞。 diff --git a/CLAUDE.md b/CLAUDE.md index e93ec1fd..c27ab8e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,7 @@ TypeScript, Node 20+, tsup (ESM), Vitest. Commands: `npm run build`, `npx tsc -- ## Rules - CLI user-facing output must be English. No Chinese in production code. Tests assert English output. + 例外:`teamai skill get` 打印的 `skill-data/` 内容是文档,保留作者书写的语言;命令自身的提示、错误与 `skill list` 输出仍然必须是英文。 - Keep bilingual docs in sync (`README` / `*.zh-CN.md`, `docs/usage-guide.*`). Behavior changes must update every affected doc (including `docs/designs/`); grep old wording before opening the PR. - **README 精简**:尽量少改动 README,保持简洁。确需改动时,所有语言版本(`README.md` 及全部 `README.*.md`,改前先 `ls README*` 确认清单)必须全部改完并保持一致。 - **`skill-data/` 与文档同等对待**:那是 agent 真正读到的内容。行为变更必须同步更新受影响的 skill(`core` / `setup` / `wiki` / `share`),并在 PR 前 grep 旧措辞。 diff --git a/src/__tests__/skill-content.test.ts b/src/__tests__/skill-content.test.ts index 9b723c0f..1d234579 100644 --- a/src/__tests__/skill-content.test.ts +++ b/src/__tests__/skill-content.test.ts @@ -13,6 +13,8 @@ import { skillCatalog, skillGet, skillPath, + type PackagedSkill, + type PackagedSkillRoots, } from '../skill-content.js'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); @@ -39,6 +41,13 @@ function writeSkill(root: string, name: string, body: string, files: Record<stri return dir; } +/** Resolve or fail the test, so the assertions below need no non-null operator. */ +async function mustResolve(name: string, roots: PackagedSkillRoots): Promise<PackagedSkill> { + const skill = await resolvePackagedSkill(name, roots); + if (!skill) throw new Error(`fixture skill not found: ${name}`); + return skill; +} + describe('packaged skill discovery', () => { let roots: ReturnType<typeof makeRoots>; @@ -60,12 +69,12 @@ describe('packaged skill discovery', () => { expect(servable.every((s) => s.deployed)).toBe(false); }); - it('falls back to skills/ before the content moves', async () => { + it('serves nothing when only the deployed stub is packaged', async () => { writeSkill(roots.deployRoot, 'teamai', '# hub\n'); - const servable = await listServableSkills(roots); - expect(servable.map((s) => s.name)).toEqual(['teamai']); - expect(servable[0].deployed).toBe(true); + // A package without skill-data is broken, not a fallback to serving stubs: + // `skill get` reports it and says to reinstall. + expect(await listServableSkills(roots)).toEqual([]); }); it('keeps the deployed stub reachable by its exact name', async () => { @@ -117,8 +126,8 @@ describe('renderSkill', () => { const body = '---\nname: core\ndescription: d\n---\n\n# core\n\nbody text\n'; writeSkill(roots.dataRoot, 'core', body); - const skill = await resolvePackagedSkill('core', roots); - expect(await renderSkill(skill!)).toBe(body); + const skill = await mustResolve('core', roots); + expect(await renderSkill(skill)).toBe(body); }); it('appends references/ then templates/, recursively, sorted by relative path', async () => { @@ -129,8 +138,8 @@ describe('renderSkill', () => { 'templates/report.md': 'report\n', }); - const skill = await resolvePackagedSkill('wiki', roots); - const out = await renderSkill(skill!, { full: true }); + const skill = await mustResolve('wiki', roots); + const out = await renderSkill(skill, { full: true }); expect(out).toBe( '# wiki\n' + @@ -146,18 +155,18 @@ describe('renderSkill', () => { 'references/howto.md': `see ${SKILL_DIR_PLACEHOLDER}/scripts/\n`, }); - const skill = await resolvePackagedSkill('wiki', roots); - const out = await renderSkill(skill!, { full: true }); + const skill = await mustResolve('wiki', roots); + const out = await renderSkill(skill, { full: true }); expect(out).not.toContain(SKILL_DIR_PLACEHOLDER); - expect(out).toContain(`python3 ${skill!.dir}/scripts/scan_repo.py`); - expect(out).toContain(`see ${skill!.dir}/scripts/`); + expect(out).toContain(`python3 ${skill.dir}/scripts/scan_repo.py`); + expect(out).toContain(`see ${skill.dir}/scripts/`); }); it('adds a trailing newline to files that lack one', async () => { writeSkill(roots.dataRoot, 'core', '# core'); - const skill = await resolvePackagedSkill('core', roots); - expect(await renderSkill(skill!)).toBe('# core\n'); + const skill = await mustResolve('core', roots); + expect(await renderSkill(skill)).toBe('# core\n'); }); }); diff --git a/src/init.ts b/src/init.ts index d76fc8bd..01a51323 100644 --- a/src/init.ts +++ b/src/init.ts @@ -1618,8 +1618,9 @@ export async function init(options: GlobalOptions & { const filterAgents = requestedAgents.length > 0 ? requestedAgents : undefined; await reconcileTeamHooksForConfig(reloadedTeamConfig, localConfig, { filterAgents }); - // Step 7.5: Deploy CLI built-in skills immediately so team-wiki-codebase - // is available in the IDE right after init, without waiting for first pull. + // Step 7.5: Deploy the built-in discovery stub immediately so the teamai + // skill is available in the IDE right after init, without waiting for the + // first pull. Its workflows are served by `teamai skill get`. try { const { deployBuiltinSkills } = await import('./builtin-skills.js'); const deployed = await deployBuiltinSkills(reloadedTeamConfig, localConfig); @@ -1632,7 +1633,7 @@ export async function init(options: GlobalOptions & { } log.success('teamai initialized successfully!'); - log.info('Built-in skills (e.g. team-wiki-codebase) are ready to use in your IDE now.'); + log.info('The built-in teamai skill is ready in your IDE; it loads its workflows with `teamai skill get`.'); log.info('Skills, rules, env and docs auto-sync on each session start when the selected agent has active TeamAI hooks.'); log.info('Run `teamai status` to check current config.'); diff --git a/src/skill-content.ts b/src/skill-content.ts index aac89baa..b169df64 100644 --- a/src/skill-content.ts +++ b/src/skill-content.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import chalk from 'chalk'; -import { pathExists } from './utils/fs.js'; +import { listFilesRecursive, pathExists } from './utils/fs.js'; import { readSkillDescription } from './agent-skills.js'; // ─── CLI-served skill content ──────────────────────────── @@ -137,14 +137,9 @@ async function readSkillDirs(root: string, deployed: boolean): Promise<PackagedS return skills; } -/** - * Skills the CLI serves: everything under skill-data/, or — before the content - * moves there — everything under skills/. - */ +/** Skills the CLI serves on demand: everything under skill-data/. */ export async function listServableSkills(roots: PackagedSkillRoots = packagedSkillRoots()): Promise<PackagedSkill[]> { - const served = await readSkillDirs(roots.dataRoot, false); - if (served.length > 0) return served; - return readSkillDirs(roots.deployRoot, true); + return readSkillDirs(roots.dataRoot, false); } /** @@ -174,29 +169,12 @@ async function collectSupplementaryFiles(skillDir: string): Promise<Array<{ rela const files: Array<{ relativePath: string; content: string }> = []; for (const dirName of SUPPLEMENTARY_DIRS) { - const root = path.join(skillDir, dirName); - if (!(await pathExists(root))) continue; - - // Recursive: our references/ nest (references/methodology/, references/agents/), - // so a single-level scan would silently serve an incomplete skill. - const walk = async (dir: string): Promise<string[]> => { - const entries = await fs.promises.readdir(dir, { withFileTypes: true }); - const found: string[] = []; - for (const entry of entries) { - if (entry.name.startsWith('.')) continue; - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - found.push(...(await walk(full))); - } else if (entry.isFile()) { - found.push(full); - } - } - return found; - }; - - const absolutePaths = await walk(root); - const relativePaths = absolutePaths - .map((p) => path.relative(skillDir, p).split(path.sep).join('/')) + // listFilesRecursive walks nested directories and skips .pyc, __pycache__ and + // the rest of the repo's ignore list, which matters for the wiki's scripts/. + // Our references nest (references/methodology/, references/phases/), so a + // single-level scan would serve an incomplete skill. + const relativePaths = (await listFilesRecursive(path.join(skillDir, dirName))) + .map((relative) => `${dirName}/${relative}`) .sort(); for (const relativePath of relativePaths) { diff --git a/src/wiki-engine/manifest-schema.ts b/src/wiki-engine/manifest-schema.ts index ac0f3b9e..a0d5647b 100644 --- a/src/wiki-engine/manifest-schema.ts +++ b/src/wiki-engine/manifest-schema.ts @@ -1,8 +1,9 @@ /** * Codebase output manifest schema definitions. * - * The manifest is the contract between AI compilers (e.g. team-wiki-codebase - * Skill) and the deterministic Node-side compiler (`compileFromManifest`). + * The manifest is the contract between AI compilers (e.g. the `wiki` skill, + * served by `teamai skill get wiki`) and the deterministic Node-side compiler + * (`compileFromManifest`). * * Two versions are supported: * From 03be8985c943b692fdc77046c5ed991419667875 Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Mon, 21 Sep 2026 22:02:32 +0200 Subject: [PATCH 07/37] fix(skills): apply spec review findings Upgrading left the old references in place. Releases before the stub deployed `skills/teamai/` with six reference files beside SKILL.md, and `teamai` is not a legacy name to prune, so copying one file over that directory kept ~39 KB of pre-stub instructions next to the new stub for good. Deployment now clears everything the deployed unit does not contain before writing it, and a test seeds the old layout to prove it. Pruning reached neither reporting-only teams nor the Codex shared directory in any test. The prune now runs before the reporting-only return, so a team that switched to reporting-only still loses the stale trees, and the Codex `.agents/skills` path is covered by a test. Excluded agents stay untouched, as the enabledAgents whitelist documents. The served content still routed to skills that no longer exist: five mentions of `teamai-share-learnings` and one `/team-wiki-codebase --update`, which is the rule this branch itself added being broken on arrival. One second-hop path inside a sub-agent input packet was still relative. The share skill's document template, frontmatter table and tag taxonomy move to `references/doc-template.md`, taking the always-read body from 3 701 to 2 471 bytes. Two tests now assert what nothing guarded: every served skill's frontmatter name matches its directory and declares allowed-tools. A new e2e file runs the built CLI the way an agent does: every listed skill is servable and byte-identical bar the resolved placeholder, the wiki scripts run from the directory `skill path` prints, an unknown name exits 1 with empty stdout, a hallucinated flag warns and still serves, and `--full` appends the nested references in sorted order. Refs #678 --- skill-data/setup/references/join-member.md | 2 +- skill-data/setup/references/manage-admin.md | 2 +- skill-data/share/SKILL.md | 47 +-------- .../share/references/contribute-member.md | 6 +- skill-data/share/references/doc-template.md | 43 ++++++++ skill-data/wiki/SKILL.md | 2 +- .../references/agents/kb-doc-generator.md | 2 +- skills/teamai/SKILL.md | 4 +- src/__tests__/e2e/skill-serving-cli.test.ts | 97 +++++++++++++++++++ src/__tests__/skill-content.test.ts | 20 ++++ src/__tests__/skip-uninstalled-tools.test.ts | 69 +++++++++++++ src/builtin-skills.ts | 25 ++++- 12 files changed, 263 insertions(+), 56 deletions(-) create mode 100644 skill-data/share/references/doc-template.md create mode 100644 src/__tests__/e2e/skill-serving-cli.test.ts diff --git a/skill-data/setup/references/join-member.md b/skill-data/setup/references/join-member.md index 12ea9a1a..1d708819 100644 --- a/skill-data/setup/references/join-member.md +++ b/skill-data/setup/references/join-member.md @@ -138,7 +138,7 @@ Summarize the outcome **in the user's own language** (global rule 1). Cover: tool keeps their team skills up to date; no commands needed. 2. **Sharing a session learning is automatic — no command to remember.** When a session produced something worth sharing, TeamAI **prompts them on its own** (at - the end of the session) and the `teamai-share-learnings` skill takes over to + the end of the session) and the `share` workflow (`teamai skill get share`) takes over to summarize and contribute it. They do **not** invoke `/teamai` for this. (This prompt only appears if the admin left team sharing enabled — it is on by default; the admin can turn it off in `teamai.yaml`.) diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index 9223a24c..241a643a 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -121,7 +121,7 @@ has no session-start hook, they run `teamai pull` manually. Turning a tricky fix into team knowledge is **automatic**: at the end of a session worth sharing, TeamAI prompts the member and the dedicated -**`teamai-share-learnings`** skill summarizes the session and runs +`share` workflow (`teamai skill get share`) summarizes the session and runs `teamai contribute`. Nobody has to invoke it by hand. (Publishing a **reusable skill** someone authored is a different task — any member can do it, see the share skill, `teamai skill get share --full`.) diff --git a/skill-data/share/SKILL.md b/skill-data/share/SKILL.md index c417e861..eda1152f 100644 --- a/skill-data/share/SKILL.md +++ b/skill-data/share/SKILL.md @@ -36,47 +36,9 @@ allowed-tools: Bash(teamai:*), Bash(npx teamai-cli:*) ## Document Template -**【必须】文档必须包含 YAML frontmatter,用于搜索索引和知识发现。** - -```markdown ---- -title: "<简短标题,描述核心问题或发现>" -author: <username> -date: <YYYY-MM-DD> -tags: [tag1, tag2, tag3] ---- - -## 背景 -在做什么?遇到了什么问题? - -## 解决方案 -怎么解决的?关键步骤是什么? - -## 经验总结 -- 经验 1 -- 经验 2 - -## 相关 Skills -- skill-name-1 -- skill-name-2 -``` - -### Frontmatter 字段说明 - -| 字段 | 必须 | 说明 | 示例 | -|------|------|------|------| -| title | ✅ | 简短标题(<60 字符) | "K8s Pod OOM 排查指南" | -| author | ✅ | 贡献者用户名 | jeffyxu | -| date | ✅ | 日期 YYYY-MM-DD | 2026-03-28 | -| tags | ✅ | 2-5 个关键标签 | [k8s, oom, troubleshooting] | - -### Tags 选择建议 - -从以下类别中选择 2-5 个: -- **技术栈**: python, typescript, go, k8s, docker, sglang, cuda -- **问题类型**: troubleshooting, performance, deployment, config, api -- **模式**: workflow, pattern, tool-usage, best-practice -- **场景**: debugging, testing, monitoring, security +Copy the template, the frontmatter field table and the tag taxonomy from +`{SKILL_DIR}/references/doc-template.md`. The frontmatter is required: it is what +makes the document searchable. ## Example @@ -102,6 +64,7 @@ turning a *session* into a learning. | File | When to load it | |---|---| +| `{SKILL_DIR}/references/doc-template.md` | Writing the learning document: template, frontmatter fields, tag taxonomy. | | `{SKILL_DIR}/references/contribute-member.md` | The user wants to publish a skill, rule or doc they already have, rather than a session summary. | -`teamai skill get share --full` prints this skill with that reference appended. +`teamai skill get share --full` prints this skill with both references appended. diff --git a/skill-data/share/references/contribute-member.md b/skill-data/share/references/contribute-member.md index ccf8bd9b..c69baee9 100644 --- a/skill-data/share/references/contribute-member.md +++ b/skill-data/share/references/contribute-member.md @@ -9,7 +9,7 @@ team"* / *"把这个 xxx skill 分享给团队"* — then you run the publish fo - **A learning** (a lesson, a gotcha, how you solved something) → this is **automatic**: TeamAI prompts at the end of a session worth sharing and the - dedicated **`teamai-share-learnings`** skill takes over (it summarizes the + dedicated `share` workflow (`teamai skill get share`) takes over (it summarizes the session and runs `teamai contribute`). The user does not come through this flow for it. (Step A below is only a manual fallback for when that skill isn't available.) @@ -18,7 +18,7 @@ team"* / *"把这个 xxx skill 分享给团队"* — then you run the publish fo ## Step A — Contribute a learning by hand (fallback only) -> Prefer the **`teamai-share-learnings`** skill. Use these manual steps only if it +> Prefer the `share` workflow (`teamai skill get share`). Use these manual steps only if it > is unavailable in the current tool. 1. Write a short Markdown doc that captures the lesson. Keep it concrete and @@ -54,7 +54,7 @@ The doc lands in the team's `learnings/` and appears for teammates on their next `teamai pull`. It is also searchable via `teamai recall`. > Tip: if there is a dedicated learnings skill available in this tool -> (`teamai-share-learnings`), you can use it to auto-summarize the current session +> (`teamai skill get share`), you can use it to auto-summarize the current session > instead of writing the doc by hand. ## Step B — Contribute a reusable skill diff --git a/skill-data/share/references/doc-template.md b/skill-data/share/references/doc-template.md new file mode 100644 index 00000000..30134824 --- /dev/null +++ b/skill-data/share/references/doc-template.md @@ -0,0 +1,43 @@ +# Learning document template + +**【必须】文档必须包含 YAML frontmatter,用于搜索索引和知识发现。** + +```markdown +--- +title: "<简短标题,描述核心问题或发现>" +author: <username> +date: <YYYY-MM-DD> +tags: [tag1, tag2, tag3] +--- + +## 背景 +在做什么?遇到了什么问题? + +## 解决方案 +怎么解决的?关键步骤是什么? + +## 经验总结 +- 经验 1 +- 经验 2 + +## 相关 Skills +- skill-name-1 +- skill-name-2 +``` + +### Frontmatter 字段说明 + +| 字段 | 必须 | 说明 | 示例 | +|------|------|------|------| +| title | ✅ | 简短标题(<60 字符) | "K8s Pod OOM 排查指南" | +| author | ✅ | 贡献者用户名 | jeffyxu | +| date | ✅ | 日期 YYYY-MM-DD | 2026-03-28 | +| tags | ✅ | 2-5 个关键标签 | [k8s, oom, troubleshooting] | + +### Tags 选择建议 + +从以下类别中选择 2-5 个: +- **技术栈**: python, typescript, go, k8s, docker, sglang, cuda +- **问题类型**: troubleshooting, performance, deployment, config, api +- **模式**: workflow, pattern, tool-usage, best-practice +- **场景**: debugging, testing, monitoring, security diff --git a/skill-data/wiki/SKILL.md b/skill-data/wiki/SKILL.md index 431bceba..583b758f 100644 --- a/skill-data/wiki/SKILL.md +++ b/skill-data/wiki/SKILL.md @@ -80,7 +80,7 @@ Step 3:根据 current_phase 跳转: ## Update 模式(增量更新) -**触发**:`/team-wiki-codebase --update` 或「增量更新」。 +**触发**:用户要求「增量更新」,或在本 skill 中指定 `--update` 模式。 **前提**:已有 completed 状态的 progress.json。 ``` diff --git a/skill-data/wiki/references/agents/kb-doc-generator.md b/skill-data/wiki/references/agents/kb-doc-generator.md index 67ebade0..22ed3d7d 100644 --- a/skill-data/wiki/references/agents/kb-doc-generator.md +++ b/skill-data/wiki/references/agents/kb-doc-generator.md @@ -17,7 +17,7 @@ service_map: 服务名→仓库映射表(用于跨仓库追踪调用链 output_dir: 知识库输出根目录 project_name: 项目名称(用于文档命名,如 "CVM") product_docs_dir: 产品文档目录(可为空,空则跳过产品约束提取) -methodology_dir: references/methodology/ 目录路径 +methodology_dir: {SKILL_DIR}/references/methodology/ 目录路径 completed_docs: 已完成的文档列表(断点恢复时跳过) parallel_mode: true | false(默认 true;Type-4 组件文档并行,Type-1~3/5~8 串行) ``` diff --git a/skills/teamai/SKILL.md b/skills/teamai/SKILL.md index 747b5965..ae0e3cd7 100644 --- a/skills/teamai/SKILL.md +++ b/skills/teamai/SKILL.md @@ -24,8 +24,8 @@ Install: `npm i -g teamai-cli` (Node.js >= 20) This file is a discovery stub, not the usage guide. Load the workflow from the CLI before running anything, so the instructions match the installed version: ```bash -teamai skill get core # daily work: pull, push, status, doctor, command reference -teamai skill get core --full # adds troubleshooting +teamai skill get core # daily work: routing, pull, push, status, doctor +teamai skill get core --full # adds the full command reference and troubleshooting ``` The CLI serves skill content that always matches the installed version, so instructions never go stale. The content in this stub cannot change between releases, which is why it just points at `skill get`. diff --git a/src/__tests__/e2e/skill-serving-cli.test.ts b/src/__tests__/e2e/skill-serving-cli.test.ts new file mode 100644 index 00000000..27a87626 --- /dev/null +++ b/src/__tests__/e2e/skill-serving-cli.test.ts @@ -0,0 +1,97 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const CLI = path.join(ROOT, 'dist', 'index.js'); + +/** + * The built CLI, run the way an agent runs it: through a shell, reading stdout. + * The unit tests call the functions; this proves the packaged binary resolves + * its own content and keeps stdout clean. + */ +describe('teamai skill get / path CLI (e2e)', () => { + let home: string; + + function run(...args: string[]) { + return spawnSync(process.execPath, [CLI, ...args], { + cwd: home, + env: { ...process.env, HOME: home, USERPROFILE: home, FORCE_COLOR: '0' }, + encoding: 'utf8', + }); + } + + beforeAll(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-skill-serving-e2e-')); + }); + + afterAll(() => { + fs.rmSync(home, { recursive: true, force: true }); + }); + + it('serves every skill it lists', () => { + const listed = run('skill', 'list', '--json'); + expect(listed.status).toBe(0); + + const catalog = JSON.parse(listed.stdout) as { skills: Array<{ name: string; path: string }> }; + expect(catalog.skills.map((s) => s.name)).toEqual(['core', 'setup', 'share', 'wiki']); + + for (const skill of catalog.skills) { + const got = run('skill', 'get', skill.name); + expect(got.status, skill.name).toBe(0); + expect(got.stderr, skill.name).toBe(''); + // Byte-identical to the packaged file, bar the resolved placeholder. + const raw = fs.readFileSync(path.join(skill.path, 'SKILL.md'), 'utf8'); + expect(got.stdout, skill.name).toBe(raw.split('{SKILL_DIR}').join(skill.path)); + expect(got.stdout, skill.name).not.toContain('{SKILL_DIR}'); + } + }); + + it('runs the wiki scripts from the directory it prints', () => { + const printed = run('skill', 'path', 'wiki'); + expect(printed.status).toBe(0); + + const dir = printed.stdout.trim(); + for (const script of ['scan_repo.py', 'validate_kb.py']) { + const scriptPath = path.join(dir, 'scripts', script); + expect(fs.existsSync(scriptPath), scriptPath).toBe(true); + + const help = spawnSync('python3', [scriptPath, '--help'], { encoding: 'utf8' }); + // A machine without python3 cannot run them; the path is what we assert there. + if (help.error) continue; + expect(help.status, script).toBe(0); + expect(help.stdout, script).toContain('usage:'); + } + }); + + it('keeps content on stdout and diagnostics on stderr', () => { + const unknown = run('skill', 'get', 'no-such-skill'); + expect(unknown.status).toBe(1); + expect(unknown.stdout).toBe(''); + expect(unknown.stderr).toContain('Skill not found: no-such-skill'); + + const hallucinatedFlag = run('skill', 'get', 'core', '--not-a-flag'); + expect(hallucinatedFlag.status).toBe(0); + expect(hallucinatedFlag.stderr).toContain('Unknown flag ignored: --not-a-flag'); + expect(hallucinatedFlag.stdout).toContain('name: core'); + + const legacyName = run('skill', 'get', 'team-wiki-codebase'); + expect(legacyName.status).toBe(0); + expect(legacyName.stdout).toContain('name: wiki'); + }); + + it('appends the nested references with --full', () => { + const full = run('skill', 'get', 'wiki', '--full'); + expect(full.status).toBe(0); + + const separators = full.stdout.split('\n').filter((line) => line.startsWith('--- ')); + expect(separators).toContain('--- references/methodology/phase0-collection.md ---'); + expect(separators).toContain('--- references/phases/phase0-init.md ---'); + // Sorted by relative path, references before templates. + expect([...separators].sort()).toEqual(separators); + expect(full.stdout.length).toBeGreaterThan(run('skill', 'get', 'wiki').stdout.length); + }); +}); diff --git a/src/__tests__/skill-content.test.ts b/src/__tests__/skill-content.test.ts index 1d234579..5aade751 100644 --- a/src/__tests__/skill-content.test.ts +++ b/src/__tests__/skill-content.test.ts @@ -283,6 +283,26 @@ describe('teamai skill get / path against the shipped package', () => { }); }); +describe('the shipped skill-data content', () => { + it('names every skill after its directory, and declares allowed-tools', async () => { + for (const skill of await listServableSkills()) { + const text = fs.readFileSync(path.join(skill.dir, 'SKILL.md'), 'utf8'); + // A frontmatter name that disagrees with the directory makes the skill + // undiscoverable for the agent and unresolvable for `skill get`. + expect(text, skill.name).toMatch(new RegExp(`^name: ${skill.name}$`, 'm')); + // Content loaded as text inherits no permissions, so each skill declares + // the commands it tells the agent to run. + expect(text, skill.name).toMatch(/^allowed-tools: .*Bash\(teamai:\*\)/m); + } + }); + + it('keeps the deployed stub declaring its own name and tools', () => { + const stub = fs.readFileSync(path.join(ROOT, 'skills/teamai/SKILL.md'), 'utf8'); + expect(stub).toMatch(/^name: teamai$/m); + expect(stub).toMatch(/^allowed-tools: Bash\(teamai:\*\), Bash\(npx teamai-cli:\*\)$/m); + }); +}); + describe('npm package contents', () => { // The whole design fails silently when skill-data/ is missing from // package.json "files": every test above still passes against the repo, and diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index 14ab396a..66b7004f 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -616,6 +616,75 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { expect(await fse.pathExists(path.join(homeDir, '.claude/skills/teamai-share-learnings'))).toBe(false); }); + it('removes the references an earlier release deployed beside the stub', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + const teamConfig = { + team: 'test', + description: '', + repo: 'https://git.woa.com/test/repo.git', + provider: 'tgit' as const, + reviewers: [], + sharing: { skills: {}, rules: { enforced: [] }, docs: { localDir: '' }, env: { injectShellProfile: true } }, + toolPaths: { claude: { skills: '.claude/skills' } }, + }; + const localConfig = { + repo: { localPath: path.join(tmpDir, 'repo'), remote: 'https://git.woa.com/test/repo.git' }, + username: 'testuser', + updatePolicy: 'auto' as const, + additionalRoles: [], + scope: 'user' as const, + }; + + // What `teamai pull` wrote before the stub: the same directory name, with a + // references tree the new deployment does not ship. + const stubDir = path.join(homeDir, '.claude/skills/teamai'); + await fse.ensureDir(path.join(stubDir, 'references')); + await fse.writeFile(path.join(stubDir, 'SKILL.md'), '# old body'); + await fse.writeFile(path.join(stubDir, 'references/setup-admin.md'), '# old reference'); + + await deployBuiltinSkills(teamConfig, localConfig); + + expect(await fse.readdir(stubDir)).toEqual(['SKILL.md']); + expect(await fse.readFile(path.join(stubDir, 'SKILL.md'), 'utf8')).toBe( + await fse.readFile(path.join(PACKAGE_ROOT, 'skills/teamai/SKILL.md'), 'utf8'), + ); + }); + + it('prunes legacy skills from the Codex shared directory, and without deploying in reporting-only mode', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + await fse.ensureDir(path.join(homeDir, '.codex')); + const sharedLegacy = path.join(homeDir, '.agents/skills/team-wiki-codebase'); + await fse.ensureDir(sharedLegacy); + await fse.writeFile(path.join(sharedLegacy, 'SKILL.md'), '# old'); + + const teamConfig = { + team: 'test', + description: '', + repo: 'https://example.test/team.git', + provider: 'git' as const, + reviewers: [], + sharing: { skills: {}, rules: { enforced: [] }, docs: { localDir: '' }, env: { injectShellProfile: true } }, + toolPaths: { codex: { skills: '.codex/skills' } }, + }; + const localConfig = { + repo: { localPath: path.join(tmpDir, 'repo'), remote: 'https://example.test/team.git' }, + username: 'testuser', + updatePolicy: 'auto' as const, + additionalRoles: [], + scope: 'user' as const, + }; + + // Reporting-only teams have nowhere to publish, so nothing is deployed — + // but a team that switched to it would otherwise keep the legacy trees. + const deployed = await deployBuiltinSkills(teamConfig, localConfig, { reportingOnly: true }); + + expect(deployed).toBe(0); + expect(await fse.pathExists(sharedLegacy)).toBe(false); + expect(await fse.pathExists(path.join(homeDir, '.agents/skills/teamai'))).toBe(false); + }); + it('deploys a built-in Codex skill to its existing shared location', async () => { const { deployBuiltinSkills } = await import('../builtin-skills.js'); const sharedSkill = path.join(homeDir, '.agents', 'skills', 'teamai'); diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index c9c89413..cae473a5 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -91,10 +91,12 @@ async function pruneLegacyBuiltinSkills(tool: string, configuredSkillsPath: stri */ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig?: LocalConfig, options?: { reportingOnly?: boolean }): Promise<number> { // Reporting-only HTTP mode has no team repo to write to, so the workflows the - // stub routes to are non-functional there. Skip built-in skills entirely. - if (options?.reportingOnly) { - log.debug('Reporting-only mode (no team repo): skipping built-in skills'); - return 0; + // stub routes to are non-functional there. Nothing is deployed, but the + // directories earlier releases left behind are still removed: a team that + // switched to reporting-only would otherwise keep them for good. + const deploy = !options?.reportingOnly; + if (!deploy) { + log.debug('Reporting-only mode (no team repo): pruning legacy built-in skills without deploying'); } const builtinDir = packagedSkillRoots().deployRoot; @@ -120,7 +122,7 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? } } - if (skillNames.length === 0) return 0; + if (deploy && skillNames.length === 0) return 0; const defaultBaseDir = getUserHome(); let deployed = 0; @@ -137,9 +139,13 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? log.debug(`Skipping built-in skill deployment for ${tool}: tool not installed`); continue; } + // An excluded agent is neither written to nor deleted from (usage-guide: + // "the enabledAgents whitelist also gates CLI built-in skills"), so its + // legacy directories are left alone too. if (localConfig && isAgentExcluded(localConfig, tool)) continue; await pruneLegacyBuiltinSkills(tool, toolPath.skills, baseDir); + if (!deploy) continue; for (const skillName of skillNames) { const srcDir = path.join(builtinDir, skillName); @@ -147,6 +153,15 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? try { await fse.ensureDir(destDir); + // Releases before the discovery stub deployed this same directory with a + // references/ tree beside SKILL.md. Copying one file over it would leave + // ~39 KB of pre-stub instructions in place forever, so everything the + // deployed unit does not contain goes first. + for (const entry of await fs.promises.readdir(destDir)) { + if (entry === 'SKILL.md') continue; + await remove(path.join(destDir, entry)); + log.debug(`Removed stale built-in skill file ${skillName}/${entry} from ${tool}`); + } await fse.copy(path.join(srcDir, 'SKILL.md'), path.join(destDir, 'SKILL.md'), { overwrite: true }); deployed++; From 61fbde3b55a6d7342523875b2d176de168b005a4 Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Tue, 22 Sep 2026 07:14:34 +0200 Subject: [PATCH 08/37] fix(skills): prune only directories the CLI owned, gate every content path on recall - LEGACY_BUILTIN_SKILL_NAMES drops teamai-workflow and teamai-import: they were reserved in the old guard set but never packaged, so a directory by either name is the user's own skill. Test: user-created skills with those names survive pull. - The recall gate now covers skill get --all (blocked skill skipped, named on stderr), skill path (refused) and skill list --json (blockedByRecall, path null). skill list reads the flag from the catalog instead of re-checking. - skill get [names...]: the positional is optional so --all is reachable from the real CLI; Commander used to fail with 'missing required argument'. Covered by the skill-serving e2e. - commands-reference renders Commander's variadic marker (<names...>); the snapshot is regenerated. --- docs/designs/skill-serving.md | 13 ++++-- skill-data/core/references/commands.md | 20 ++++----- src/__tests__/e2e/skill-serving-cli.test.ts | 12 +++++ src/__tests__/skill-content.test.ts | 10 ++++- src/__tests__/skill-recall-gate.test.ts | 42 ++++++++++++++++- src/__tests__/skip-uninstalled-tools.test.ts | 9 ++++ src/builtin-skills.ts | 7 ++- src/commands-reference.ts | 5 ++- src/index.ts | 7 +-- src/skill-cmd.ts | 5 +-- src/skill-content.ts | 47 +++++++++++++++----- 11 files changed, 140 insertions(+), 37 deletions(-) diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index e28bee9f..9f6f73f1 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -58,8 +58,12 @@ task matches stub body ~0.9 KB holds the c `references/phases/`); a single-level scan would serve an incomplete skill. - **Nothing repairs the deployed stub.** `ensureSkillFrontmatter` is not called on it, so deployed and packaged bytes are identical and a diff means a bug. -- **Recall is decided at run time**, inside `skill get`, not by withholding a - directory at deploy time. With no team config to consult it fails open. +- **Recall is decided at run time**, not by withholding a directory at deploy + time, and it holds on every path that hands out content or a location: + `skill get <name>` refuses, `skill get --all` leaves the skill out and says so + on stderr, `skill path <name>` refuses, and `skill list --json` reports + `blockedByRecall: true` with `path: null`. With no team config to consult it + fails open. ## Drift guards @@ -81,8 +85,9 @@ installed. ## Migration `LEGACY_BUILTIN_SKILL_NAMES` (`src/builtin-skills.ts`) names the directories -earlier releases deployed: `team-wiki-codebase`, `teamai-share-learnings`, and -the two that were only ever guards, `teamai-workflow` and `teamai-import`. +earlier releases deployed: `team-wiki-codebase` and `teamai-share-learnings`. +`teamai-workflow` and `teamai-import` sat in the old guard set but were never +packaged, so they are not in it: a directory by either name is the user's own. Deployment removes them from every installed agent, in the configured skills path and in Codex's shared `.agents/skills`. The removal is unconditional because those trees were overwritten with `overwrite: true` on every pull, so no diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index 67a166df..5f059971 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -60,15 +60,15 @@ Generated: do not edit by hand. Regenerate with - `teamai skill` — List and inspect skills (default: list all skills across repo + installed agents) - `teamai skill list` — List all skills (alias for: teamai list skills --source all) - `--json` — Output the CLI-served built-in skill catalog as JSON - - `teamai skill get <names>` — Print built-in skill content served by the installed CLI + - `teamai skill get [names...]` — Print built-in skill content served by the installed CLI - `--full` — Append the skill's references/ and templates/ files - `--all` — Print every skill the CLI serves - `teamai skill path [name]` — Print the packaged directory of a built-in skill (for scripts and templates) - `teamai skill show <name>` — Show skill metadata: source / contributors / installed agents / description - `teamai skill exclude` — Manage per-user skill exclusion (skip sync without affecting team repo) - `teamai skill exclude list` — List excluded skills - - `teamai skill exclude add <skills>` — Add skill(s) to the exclude list - - `teamai skill exclude remove <skills>` — Remove skill(s) from the exclude list + - `teamai skill exclude add <skills...>` — Add skill(s) to the exclude list + - `teamai skill exclude remove <skills...>` — Remove skill(s) from the exclude list ## members @@ -77,7 +77,7 @@ Generated: do not edit by hand. Regenerate with ## remove -- `teamai remove <type> <names>` — Remove resource(s) from team repo and all local AI tools (type: skills|rules|agents|mcp) +- `teamai remove <type> <names...>` — Remove resource(s) from team repo and all local AI tools (type: skills|rules|agents|mcp) - `--force` — Skip confirmation prompt ## packages @@ -118,16 +118,16 @@ Generated: do not edit by hand. Regenerate with - `teamai projects` — Manage multi-project resource distribution (orthogonal to roles) - `teamai projects list` — List defined projects and the ones active in this directory - - `teamai projects set [ids]` — Set the projects active in this directory (comma-separated or repeated; empty to clear) + - `teamai projects set [ids...]` — Set the projects active in this directory (comma-separated or repeated; empty to clear) - `teamai projects members <id>` — List members registered for a project ## tags - `teamai tags` — Manage tag-based skill/rule filtering - `teamai tags list` — List all available tags and subscription status - - `teamai tags subscribe <tags>` — Subscribe to tags (only matching skills/rules will be synced) - - `teamai tags unsubscribe <tags>` — Unsubscribe from tags - - `teamai tags add <type> <name> <tags>` — Add tags to a skill or rule in tags.yaml (admin) + - `teamai tags subscribe <tags...>` — Subscribe to tags (only matching skills/rules will be synced) + - `teamai tags unsubscribe <tags...>` — Unsubscribe from tags + - `teamai tags add <type> <name> <tags...>` — Add tags to a skill or rule in tags.yaml (admin) <type> Resource type: "skills" or "rules" <name> Name of the skill or rule (directory name) @@ -137,7 +137,7 @@ Generated: do not edit by hand. Regenerate with $ teamai tags add skills hai-deploy hai infra $ teamai tags add rules common-coding-style coding best-practices - - `teamai tags remove <type> <name> <tags>` — Remove tags from a skill or rule in tags.yaml (admin) + - `teamai tags remove <type> <name> <tags...>` — Remove tags from a skill or rule in tags.yaml (admin) <type> Resource type: "skills" or "rules" <name> Name of the skill or rule (directory name) @@ -281,7 +281,7 @@ Generated: do not edit by hand. Regenerate with ## recall -- `teamai recall [query]` — Search team learnings knowledge base +- `teamai recall [query...]` — Search team learnings knowledge base - `--depth <level>` — Recall depth: route (entry-points only) | context (module-level, default) | lookup (full graph traversal) - `--check` — Relevance precheck only: print RELEVANT/NOT_RELEVANT + top score; no file reads, no upvote - `teamai recall disable` — Disable automatic knowledge-base recall diff --git a/src/__tests__/e2e/skill-serving-cli.test.ts b/src/__tests__/e2e/skill-serving-cli.test.ts index 27a87626..effc8d5d 100644 --- a/src/__tests__/e2e/skill-serving-cli.test.ts +++ b/src/__tests__/e2e/skill-serving-cli.test.ts @@ -50,6 +50,18 @@ describe('teamai skill get / path CLI (e2e)', () => { } }); + it('serves every skill with --all and no name, and fails with neither', () => { + const all = run('skill', 'get', '--all'); + expect(all.status, all.stderr).toBe(0); + // No team config in this HOME, so the recall gate fails open and all four are served. + expect(all.stdout.match(/^name: /gm)).toHaveLength(4); + + const none = run('skill', 'get'); + expect(none.status).toBe(1); + expect(none.stdout).toBe(''); + expect(none.stderr).toContain('No skill name provided'); + }); + it('runs the wiki scripts from the directory it prints', () => { const printed = run('skill', 'path', 'wiki'); expect(printed.status).toBe(0); diff --git a/src/__tests__/skill-content.test.ts b/src/__tests__/skill-content.test.ts index 5aade751..e46d5a74 100644 --- a/src/__tests__/skill-content.test.ts +++ b/src/__tests__/skill-content.test.ts @@ -6,6 +6,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { SKILL_DIR_PLACEHOLDER, + blockedByRecall, listServableSkills, packagedSkillRoots, renderSkill, @@ -177,7 +178,7 @@ describe('skillCatalog', () => { writeSkill(roots.dataRoot, 'core', '---\nname: core\ndescription: Daily sync\n---\n\n# core\n'); const catalog = await skillCatalog(roots); expect(catalog).toEqual([ - { name: 'core', description: 'Daily sync', path: path.join(roots.dataRoot, 'core'), deployed: false }, + { name: 'core', description: 'Daily sync', path: path.join(roots.dataRoot, 'core'), deployed: false, blockedByRecall: false }, ]); } finally { fs.rmSync(roots.tmp, { recursive: true, force: true }); @@ -256,7 +257,12 @@ describe('teamai skill get / path against the shipped package', () => { }); it('separates multiple skills and serves them all with --all', async () => { - const servable = await listServableSkills(); + // The recall gate applies to --all too (covered in skill-recall-gate.test); + // this run's team config decides whether share is in the dump. + const servable: PackagedSkill[] = []; + for (const skill of await listServableSkills()) { + if (!await blockedByRecall(skill.name)) servable.push(skill); + } await skillGet([], { all: true }); const expected = (await Promise.all(servable.map((skill) => renderSkill(skill)))).join('\n---\n\n'); diff --git a/src/__tests__/skill-recall-gate.test.ts b/src/__tests__/skill-recall-gate.test.ts index 24147354..29d425cb 100644 --- a/src/__tests__/skill-recall-gate.test.ts +++ b/src/__tests__/skill-recall-gate.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; const autoDetectInit = vi.fn(); vi.mock('../config.js', () => ({ autoDetectInit })); -import { blockedByRecall, skillGet } from '../skill-content.js'; +import { blockedByRecall, skillCatalog, skillGet, skillPath } from '../skill-content.js'; /** * Recall used to be decided when deploying: the share skill simply was not @@ -25,10 +25,13 @@ describe('recall gate on served skills', () => { stdout += String(chunk); return true; }); + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + stdout += args.join(' ') + '\n'; + }); const errorSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { stderr += args.join(' ') + '\n'; }); - restore.push(() => writeSpy.mockRestore(), () => errorSpy.mockRestore()); + restore.push(() => writeSpy.mockRestore(), () => logSpy.mockRestore(), () => errorSpy.mockRestore()); }); afterEach(() => { @@ -65,6 +68,41 @@ describe('recall gate on served skills', () => { expect(stdout).toContain('name: share'); }); + it('leaves share out of --all when recall is disabled, and says so on stderr', async () => { + withRecall(false); + + await skillGet([], { all: true }); + expect(process.exitCode).toBeUndefined(); + expect(stdout).toContain('name: core'); + expect(stdout).toContain('name: wiki'); + expect(stdout).not.toContain('name: share'); + expect(stderr).toContain('Skipped share'); + expect(stderr).toContain('teamai recall enable'); + }); + + it('withholds the share directory from skill path and the catalog when recall is disabled', async () => { + withRecall(false); + + await skillPath('share'); + expect(process.exitCode).toBe(1); + expect(stdout).toBe(''); + expect(stderr).toContain('share needs recall'); + + const share = (await skillCatalog()).find((entry) => entry.name === 'share'); + expect(share).toMatchObject({ blockedByRecall: true, path: null }); + }); + + it('serves the share directory through skill path and the catalog when recall is enabled', async () => { + withRecall(true); + + await skillPath('share'); + expect(process.exitCode).toBeUndefined(); + expect(stdout.trim()).toMatch(/skill-data[\\/]share$/); + + const share = (await skillCatalog()).find((entry) => entry.name === 'share'); + expect(share).toMatchObject({ blockedByRecall: false, path: stdout.trim() }); + }); + it('never gates the skills that do not depend on recall', async () => { withRecall(false); diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index 66b7004f..de4caaa9 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -604,6 +604,12 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { await fse.writeFile(path.join(homeDir, '.claude/skills/team-wiki-codebase/SKILL.md'), '# old'); await fse.ensureDir(path.join(homeDir, '.claude/skills/teamai-share-learnings')); await fse.writeFile(path.join(homeDir, '.claude/skills/teamai-share-learnings/SKILL.md'), '# old'); + // These two names were reserved in the old guard set but never packaged, so + // a directory by either name is the user's own skill. + for (const userSkill of ['teamai-workflow', 'teamai-import']) { + await fse.ensureDir(path.join(homeDir, `.claude/skills/${userSkill}`)); + await fse.writeFile(path.join(homeDir, `.claude/skills/${userSkill}/SKILL.md`), '# mine'); + } const deployed = await deployBuiltinSkills(teamConfig, localConfig); @@ -614,6 +620,9 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { expect(await fse.pathExists(path.join(homeDir, '.claude/skills/teamai/SKILL.md'))).toBe(true); expect(await fse.pathExists(path.join(homeDir, '.claude/skills/team-wiki-codebase'))).toBe(false); expect(await fse.pathExists(path.join(homeDir, '.claude/skills/teamai-share-learnings'))).toBe(false); + for (const userSkill of ['teamai-workflow', 'teamai-import']) { + expect(await fse.readFile(path.join(homeDir, `.claude/skills/${userSkill}/SKILL.md`), 'utf8'), userSkill).toBe('# mine'); + } }); it('removes the references an earlier release deployed beside the stub', async () => { diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index cae473a5..c01c6afa 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -41,12 +41,15 @@ export const BUILTIN_SKILL_NAMES = new Set(['teamai']); * Built-in skill directories earlier releases deployed, kept only so that pull * can remove them from agent skills directories. Retire this set once 0.23.x is * no longer in the field. + * + * Only names the CLI actually wrote belong here. `teamai-workflow` and + * `teamai-import` were reserved in the old BUILTIN_SKILL_NAMES guard but never + * packaged, so a directory by either name is a user's own skill and must not be + * removed. */ export const LEGACY_BUILTIN_SKILL_NAMES = new Set([ 'teamai-share-learnings', 'team-wiki-codebase', - 'teamai-workflow', - 'teamai-import', ]); /** diff --git a/src/commands-reference.ts b/src/commands-reference.ts index 194ee37b..ca745c3d 100644 --- a/src/commands-reference.ts +++ b/src/commands-reference.ts @@ -39,7 +39,10 @@ function visibleOptions(command: Command): Option[] { function renderCommand(command: Command, parents: string[]): string[] { const path = [...parents, command.name()]; - const args = command.registeredArguments.map((a) => (a.required ? `<${a.name()}>` : `[${a.name()}]`)); + const args = command.registeredArguments.map((a) => { + const name = a.variadic ? `${a.name()}...` : a.name(); + return a.required ? `<${name}>` : `[${name}]`; + }); const usage = ['teamai', ...path, ...args].join(' '); const lines: string[] = []; diff --git a/src/index.ts b/src/index.ts index 71273c6b..46c82f87 100644 --- a/src/index.ts +++ b/src/index.ts @@ -171,16 +171,17 @@ skillCmd }); skillCmd - .command('get <names...>') + // Optional so that `--all` needs no name; the action fails when both are missing. + .command('get [names...]') .description('Print built-in skill content served by the installed CLI') .option('--full', 'Append the skill\'s references/ and templates/ files') .option('--all', 'Print every skill the CLI serves') // A hallucinated flag should cost a warning, not a failed command: unknown // options fall through to the action, which reports and ignores them. .allowUnknownOption() - .action(async (names: string[], cmdOpts) => { + .action(async (names: string[] | undefined, cmdOpts) => { const { skillGet } = await import('./skill-content.js'); - await skillGet(names, { full: cmdOpts.full, all: cmdOpts.all }); + await skillGet(names ?? [], { full: cmdOpts.full, all: cmdOpts.all }); }); skillCmd diff --git a/src/skill-cmd.ts b/src/skill-cmd.ts index a38daed3..a260feab 100644 --- a/src/skill-cmd.ts +++ b/src/skill-cmd.ts @@ -13,7 +13,7 @@ import { type SkillSource, } from './agent-skills.js'; import { detectInstalledAgents, type ResolvedAgent } from './known-agents.js'; -import { blockedByRecall, resolvePackagedSkill, skillCatalog } from './skill-content.js'; +import { resolvePackagedSkill, skillCatalog } from './skill-content.js'; import type { GlobalOptions, LocalConfig } from './types.js'; const DESCRIPTION_MAX = 160; @@ -100,8 +100,7 @@ export async function skillList(options: GlobalOptions & { json?: boolean }): Pr console.log(' (none — the installed package ships no skill content)'); } else { for (const entry of catalog) { - const blocked = await blockedByRecall(entry.name); - console.log(` ${entry.name}${blocked ? ' (needs recall — teamai recall enable)' : ''}`); + console.log(` ${entry.name}${entry.blockedByRecall ? ' (needs recall — teamai recall enable)' : ''}`); console.log(` ${truncate(entry.description, DESCRIPTION_MAX) || '(no description)'}`); console.log(` teamai skill get ${entry.name}`); } diff --git a/src/skill-content.ts b/src/skill-content.ts index b169df64..ed3bf95e 100644 --- a/src/skill-content.ts +++ b/src/skill-content.ts @@ -230,6 +230,17 @@ function rootsMissing(): void { process.exitCode = 1; } +/** + * Refuse a skill the recall gate blocks. Every path that hands out a skill's + * content or its directory goes through here, so the gate that replaced the + * old deployment restriction cannot be sidestepped by asking differently. + */ +function refuseBlockedByRecall(name: string): void { + diagnostic(`${chalk.red('✖')} ${name} needs recall, which is disabled for this team.`); + diagnostic(' Turn it on with `teamai recall enable`, or ask your team admin to enable sharing.'); + process.exitCode = 1; +} + export interface SkillGetOptions { full?: boolean; all?: boolean; @@ -262,9 +273,15 @@ export async function skillGet(names: string[], options: SkillGetOptions = {}): const targets: PackagedSkill[] = []; if (options.all) { - // An inventory dump is not an attempt to run a workflow, so the recall gate - // stays out of it; asking for the skill by name is what hits the gate. - targets.push(...servable); + // The gate holds for the inventory dump too: a blocked skill is left out + // and named on stderr, the rest is still served. + for (const skill of servable) { + if (await blockedByRecall(skill.name)) { + diagnostic(`${chalk.yellow('⚠')} Skipped ${skill.name}: needs recall, which is disabled for this team (teamai recall enable).`); + continue; + } + targets.push(skill); + } } else { for (const name of requested) { const skill = await resolvePackagedSkill(name, roots); @@ -273,9 +290,7 @@ export async function skillGet(names: string[], options: SkillGetOptions = {}): return; } if (await blockedByRecall(skill.name)) { - diagnostic(`${chalk.red('✖')} ${skill.name} needs recall, which is disabled for this team.`); - diagnostic(' Turn it on with `teamai recall enable`, or ask your team admin to enable sharing.'); - process.exitCode = 1; + refuseBlockedByRecall(skill.name); return; } targets.push(skill); @@ -283,7 +298,7 @@ export async function skillGet(names: string[], options: SkillGetOptions = {}): } if (targets.length === 0) { - diagnostic(`${chalk.red('✖')} No skill name provided. Usage: teamai skill get <name> [--full]`); + diagnostic(`${chalk.red('✖')} No skill name provided. Usage: teamai skill get <name> [--full], or --all`); diagnostic(` Available: ${servable.map((s) => s.name).join(', ')}`); process.exitCode = 1; return; @@ -320,26 +335,38 @@ export async function skillPath(name?: string): Promise<void> { notFound(name, await listServableSkills(roots)); return; } + if (await blockedByRecall(skill.name)) { + refuseBlockedByRecall(skill.name); + return; + } console.log(skill.dir); } -/** One catalog entry, as `teamai skill list --json` reports it. */ +/** + * One catalog entry, as `teamai skill list --json` reports it. + * + * A skill the recall gate blocks is still listed, so the agent learns it exists + * and what to turn on, but its directory is withheld like `skill path` does. + */ export interface SkillCatalogEntry { name: string; description: string; - path: string; + path: string | null; deployed: boolean; + blockedByRecall: boolean; } export async function skillCatalog(roots: PackagedSkillRoots = packagedSkillRoots()): Promise<SkillCatalogEntry[]> { const skills = await listServableSkills(roots); const entries: SkillCatalogEntry[] = []; for (const skill of skills) { + const blocked = await blockedByRecall(skill.name); entries.push({ name: skill.name, description: await readSkillDescription(path.join(skill.dir, SKILL_MD)), - path: skill.dir, + path: blocked ? null : skill.dir, deployed: skill.deployed, + blockedByRecall: blocked, }); } return entries; From b019895344a5ff2ae38c19d5c42dc8888c56f22e Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Tue, 22 Sep 2026 07:29:40 +0200 Subject: [PATCH 09/37] test(skills): drive the recall gate through the real CLI, guard the stub description budget - skill-serving e2e: a HOME with a team whose recall is off; skill get share, --all, skill path share and skill list --json each withhold share, and all serve it after recall enable. The earlier HOME has no team config and fails open, so the gate was never exercised through dist/index.js. - skill-content test: the stub description stays within 1024 characters. - skill-commands-exist also scans the deployed stub. - Docs and PR lead with content versioned with the CLI; the size numbers are measured (stub description 0.8 KB, body 1.3 KB; --full 32/36/115 KB). - Content audit against origin/main: every file has a counterpart. Fixes: {SKILL_DIR} defined in core/setup/share where the references are listed, the wiki overview draws the served layout, team-wiki-codebase kept as a trigger word in the stub description. --- docs/designs/skill-serving.md | 31 +++++--- docs/usage-guide.md | 17 +++-- docs/usage-guide.zh-CN.md | 12 +-- skill-data/core/SKILL.md | 4 +- skill-data/setup/SKILL.md | 4 +- skill-data/share/SKILL.md | 2 + skill-data/wiki/SKILL.md | 2 +- skill-data/wiki/references/overview.md | 33 +++++---- skills/teamai/SKILL.md | 2 +- src/__tests__/e2e/skill-serving-cli.test.ts | 82 +++++++++++++++++++++ src/__tests__/skill-commands-exist.test.ts | 4 +- src/__tests__/skill-content.test.ts | 9 +++ 12 files changed, 158 insertions(+), 44 deletions(-) diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index 9f6f73f1..6dd32969 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -4,17 +4,28 @@ Issue: [#678](https://github.com/Tencent/teamai-cli/issues/678). Shipped in 0.23 ## The problem -`deployBuiltinSkills` copied three whole skill directories — 176 KB — into every -installed agent's skills directory, on `init`, on `pull` and on a recall toggle. -Nothing else redeployed them, so `npm i -g teamai-cli@latest` left the previous -content in place until the member ran a pull. Four commits exist only to -re-align deployed text after a command changed (`e151d43`, `1ca43ac`, `8bb0548`, -`2ddb546`), and `skills/team-wiki-codebase/SKILL.md` alone was 38 705 bytes — -roughly 10k tokens read on every activation, before the agent opened a single -reference file. +The built-in skills describe the CLI, but they did not travel with it. +`deployBuiltinSkills` copied three whole skill directories into every installed +agent's skills directory on `init`, on `pull` and on a recall toggle, and nothing +else touched them. After `npm i -g teamai-cli@latest` the agent kept reading the +previous release's instructions until the member happened to run a pull, and a +machine with several agents could hold several different versions at once. Four +commits exist only to re-align deployed text after a command changed (`e151d43`, +`1ca43ac`, `8bb0548`, `2ddb546`), and every one of them needed a pull on every +machine to take effect. + +The copies were also large — 176 KB per agent, with +`skills/team-wiki-codebase/SKILL.md` alone at 38 705 bytes read in full on every +activation — but that is the secondary cost. The primary one is that the agent's +instructions and the binary they describe were versioned separately. ## The shape +**The skill content is versioned with the CLI.** It ships inside the npm package +and is printed by the installed binary, so `teamai skill get core` on version X +prints version X's instructions, byte for byte, with no pull in between. +Upgrading the CLI is the update; there is nothing else to sync. + One deployable unit, everything else served on demand. The pattern is `vercel-labs/agent-browser`'s, verified against its published 0.38.1 package. @@ -35,8 +46,8 @@ npm package What an agent reads, and when: ```text -session start stub frontmatter description ~1.3 KB always in context -task matches stub body ~0.9 KB holds the commands +session start stub frontmatter (description) ~0.9 KB always in context +task matches stub body ~1.3 KB holds the commands `teamai skill get core` daily workflow ~5.7 KB on demand `… core --full` + troubleshooting + commands.md ~32 KB on demand `… setup` / `wiki` / `share` on demand diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 4a2b33f6..bf9ab2f2 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -464,14 +464,15 @@ teamai skill get wiki --full # ...with its references and templates appen teamai skill path wiki # The packaged directory, for the scripts a skill ships ``` -#### Built-in skills are served, not copied - -Agents receive one file from the CLI: `~/.<tool>/skills/teamai/SKILL.md`, a ~2 KB -discovery stub. The workflows it routes to (`core`, `setup`, `wiki`, `share`) stay -inside the npm package and are printed by `teamai skill get`, so what an agent reads -always matches the installed CLI version — `npm i -g teamai-cli@latest` is enough, with -no pull needed for the content to be current. Older releases copied the whole tree into -every agent directory; `teamai pull` removes those leftovers. The legacy names still +#### Built-in skills are versioned with the CLI + +The built-in workflows (`core`, `setup`, `wiki`, `share`) ship inside the npm package +and are printed by the installed binary with `teamai skill get`, so what an agent reads +always matches the CLI version it is running — `npm i -g teamai-cli@latest` is the +update, with no pull needed for the content to be current. Agents receive a single file +from the CLI, `~/.<tool>/skills/teamai/SKILL.md`, a small discovery stub that points at +those commands. Older releases copied the whole tree into every agent directory, where it +went stale between pulls; `teamai pull` removes those leftovers. The legacy names still resolve: `teamai skill get team-wiki-codebase` serves `wiki`. --- diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 21c7d031..fe11671b 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -441,13 +441,13 @@ teamai skill get wiki --full # 同时附上该 skill 的 references 与 t teamai skill path wiki # 打印打包目录,用于运行 skill 自带的脚本 ``` -#### 内置 skill 由 CLI 提供,不再复制 +#### 内置 skill 随 CLI 一起版本化 -每个 agent 只收到一个文件:`~/.<tool>/skills/teamai/SKILL.md`,约 2 KB 的发现入口(stub)。 -它指向的工作流(`core`、`setup`、`wiki`、`share`)保留在 npm 包内,由 `teamai skill get` 按需打印, -因此 agent 读到的内容始终与已安装的 CLI 版本一致——`npm i -g teamai-cli@latest` 之后无需 `teamai pull` -内容就是最新的。旧版本会把整棵目录复制到每个 agent 下,`teamai pull` 会清除这些残留。 -旧名字仍然可用:`teamai skill get team-wiki-codebase` 等价于 `wiki`。 +内置工作流(`core`、`setup`、`wiki`、`share`)随 npm 包一起发布,由已安装的 CLI 通过 `teamai skill get` +按需打印,因此 agent 读到的内容始终与正在运行的 CLI 版本一致——`npm i -g teamai-cli@latest` 本身就是更新, +无需 `teamai pull` 内容就是最新的。每个 agent 只收到一个文件:`~/.<tool>/skills/teamai/SKILL.md`, +一个指向这些命令的小型发现入口(stub)。旧版本会把整棵目录复制到每个 agent 下,两次 pull 之间内容会过时; +`teamai pull` 会清除这些残留。旧名字仍然可用:`teamai skill get team-wiki-codebase` 等价于 `wiki`。 --- diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index e46edc37..e2e8ab93 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -100,10 +100,12 @@ generated reference below. Read it instead of guessing a flag. ## References +In the files below, `{SKILL_DIR}` is the directory `teamai skill path core` prints. + | File | When to load it | |---|---| | `{SKILL_DIR}/references/commands.md` | Before using any command not in the daily list, or any flag. Generated from the CLI's own command table, so it cannot drift. | | `{SKILL_DIR}/references/troubleshooting.md` | A command fails, a hook does not fire, or a host needs manual steps. | `teamai skill get core --full` prints this skill with both references appended -(about 26 KB). Load a single file above when you only need one. +(about 32 KB). Load a single file above when you only need one. diff --git a/skill-data/setup/SKILL.md b/skill-data/setup/SKILL.md index 623541f5..85a134b4 100644 --- a/skill-data/setup/SKILL.md +++ b/skill-data/setup/SKILL.md @@ -63,6 +63,8 @@ platform's website, as `manage-admin.md` describes. ## References +In the files below, `{SKILL_DIR}` is the directory `teamai skill path setup` prints. + | File | When to load it | |---|---| | `{SKILL_DIR}/references/setup-admin.md` | Creating a team repo: provider detection, auth, repo creation, first push. | @@ -70,4 +72,4 @@ platform's website, as `manage-admin.md` describes. | `{SKILL_DIR}/references/manage-admin.md` | Day-to-day admin: publishing resources, roles, projects, MCP, env, members. | | `{SKILL_DIR}/references/uninstall.md` | Removing TeamAI from a machine or from one agent. | -`teamai skill get setup --full` prints this skill with all four appended (about 31 KB). +`teamai skill get setup --full` prints this skill with all four appended (about 36 KB). diff --git a/skill-data/share/SKILL.md b/skill-data/share/SKILL.md index eda1152f..f129bb66 100644 --- a/skill-data/share/SKILL.md +++ b/skill-data/share/SKILL.md @@ -62,6 +62,8 @@ turning a *session* into a learning. ## References +In the files below, `{SKILL_DIR}` is the directory `teamai skill path share` prints. + | File | When to load it | |---|---| | `{SKILL_DIR}/references/doc-template.md` | Writing the learning document: template, frontmatter fields, tag taxonomy. | diff --git a/skill-data/wiki/SKILL.md b/skill-data/wiki/SKILL.md index 583b758f..f15e0656 100644 --- a/skill-data/wiki/SKILL.md +++ b/skill-data/wiki/SKILL.md @@ -221,7 +221,7 @@ Step 7:组件级 diff(处理新增/删除仓库或组件) 人类可读概览(非执行用):`{SKILL_DIR}/references/overview.md`。 -`teamai skill get wiki --full` 一次性打印全部参考文件(约 100 KB),仅在需要通读时使用。 +`teamai skill get wiki --full` 一次性打印全部参考文件(约 115 KB),仅在需要通读时使用。 ## 输出目录结构 diff --git a/skill-data/wiki/references/overview.md b/skill-data/wiki/references/overview.md index 861b4402..21ded62a 100644 --- a/skill-data/wiki/references/overview.md +++ b/skill-data/wiki/references/overview.md @@ -87,25 +87,28 @@ Phase K4 → 质量评估: ## 文件结构 +以下文件随 CLI 一起发布;`teamai skill path wiki` 打印其所在目录(本文中的 `{SKILL_DIR}`)。 + ``` -team-wiki-codebase/ -├── SKILL.md ← 主执行指令(AI 加载) -├── README.md ← 本文件 +{SKILL_DIR}/ +├── SKILL.md ← 主执行指令(`teamai skill get wiki`) ├── scripts/ │ ├── scan_repo.py ← 仓库扫描辅助工具 │ └── validate_kb.py ← 知识库质量校验工具 -└── references/ - ├── agents/ - │ ├── kb-doc-generator.md ← Type-1~8 文档生成专职 Agent - │ └── graph-rag-agent.md ← G1~G9 图谱文档专职 Agent - ├── methodology/ - │ ├── phase0-collection.md ← 源材料采集方法 - │ ├── phase1-reverse-engineering.md ← 架构逆向工程方法 - │ ├── phase2-document-types.md ← 九大文档类型规范与质量标准 - │ ├── phase3-ai-enhancement.md ← AI-Native 增强方法 - │ └── phase4-quality.md ← 质量评估 Checklist - └── templates/ - └── project-overview.md ← 知识库 README 模板(含认知边界声明) +├── references/ +│ ├── overview.md ← 本文件 +│ ├── agents/ +│ │ ├── kb-doc-generator.md ← Type-1~8 文档生成专职 Agent +│ │ └── graph-rag-agent.md ← G1~G9 图谱文档专职 Agent +│ ├── methodology/ +│ │ ├── phase0-collection.md ← 源材料采集方法 +│ │ ├── phase1-reverse-engineering.md ← 架构逆向工程方法 +│ │ ├── phase2-document-types.md ← 九大文档类型规范与质量标准 +│ │ ├── phase3-ai-enhancement.md ← AI-Native 增强方法 +│ │ └── phase4-quality.md ← 质量评估 Checklist +│ ├── phases/ ← 各 Phase 的执行步骤 +│ └── templates/ +│ └── project-overview.md ← 知识库 README 模板(含认知边界声明) ``` --- diff --git a/skills/teamai/SKILL.md b/skills/teamai/SKILL.md index ae0e3cd7..61f9ba95 100644 --- a/skills/teamai/SKILL.md +++ b/skills/teamai/SKILL.md @@ -5,7 +5,7 @@ description: >- tools. Use when the task operates on team-shared AI configuration or team knowledge: setting up a team repo, joining one, managing members, syncing with pull or push, or checking team status. Also use to build or query a codebase knowledge base for a large multi-repo project (架构分析, - 架构逆向, 代码知识库, code-to-knowledge, architecture wiki), and to share what a session taught you + 架构逆向, 代码知识库, code-to-knowledge, team-wiki-codebase, architecture wiki), and to share what a session taught you back to the team (分享 Session 经验, contribute a learning, share this with my team), including after a friction reminder. Triggers include "set up teamai", "join the team repo", "sync team skills", "team wiki", "share what I learned", and running /teamai. Talking about a team needs no diff --git a/src/__tests__/e2e/skill-serving-cli.test.ts b/src/__tests__/e2e/skill-serving-cli.test.ts index effc8d5d..eed83d49 100644 --- a/src/__tests__/e2e/skill-serving-cli.test.ts +++ b/src/__tests__/e2e/skill-serving-cli.test.ts @@ -107,3 +107,85 @@ describe('teamai skill get / path CLI (e2e)', () => { expect(full.stdout.length).toBeGreaterThan(run('skill', 'get', 'wiki').stdout.length); }); }); + +/** + * The gate the deployment restriction became. The HOME above has no team + * config, so every call there fails open; this one carries a team whose recall + * is off (the default for a fresh team), the case a member actually hits. + */ +describe('teamai skill recall gate CLI (e2e)', () => { + let home: string; + + function run(...args: string[]) { + return spawnSync(process.execPath, [CLI, ...args], { + cwd: home, + env: { ...process.env, HOME: home, USERPROFILE: home, FORCE_COLOR: '0' }, + encoding: 'utf8', + }); + } + + beforeAll(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-skill-recall-e2e-')); + const repo = path.join(home, '.teamai', 'team-repo'); + fs.mkdirSync(repo, { recursive: true }); + fs.writeFileSync(path.join(repo, 'teamai.yaml'), [ + 'team: recall-gate-e2e', + `repo: ${repo}`, + 'provider: git', + 'usageReport: false', + 'sharing:', + ' env:', + ' injectShellProfile: false', + ].join('\n')); + fs.writeFileSync(path.join(home, '.teamai', 'config.yaml'), [ + 'repo:', + ` localPath: ${repo}`, + ` remote: ${repo}`, + 'username: e2e-user', + 'updatePolicy: skip', + 'scope: user', + ].join('\n')); + }); + + afterAll(() => { + fs.rmSync(home, { recursive: true, force: true }); + }); + + it('withholds share on every content path while recall is off, and serves it once enabled', () => { + const status = run('recall', 'status'); + expect(status.stdout, status.stderr).toContain('Recall: disabled'); + + const byName = run('skill', 'get', 'share'); + expect(byName.status).toBe(1); + expect(byName.stdout).toBe(''); + expect(byName.stderr).toContain('share needs recall'); + expect(byName.stderr).toContain('teamai recall enable'); + + const all = run('skill', 'get', '--all'); + expect(all.status, all.stderr).toBe(0); + expect(all.stdout.match(/^name: /gm)).toEqual(['name: ', 'name: ', 'name: ']); + expect(all.stdout).not.toContain('name: share'); + expect(all.stderr).toContain('Skipped share'); + + const dir = run('skill', 'path', 'share'); + expect(dir.status).toBe(1); + expect(dir.stdout).toBe(''); + expect(dir.stderr).toContain('share needs recall'); + + const listed = run('skill', 'list', '--json'); + expect(listed.status).toBe(0); + const catalog = JSON.parse(listed.stdout) as { skills: Array<{ name: string; path: string | null; blockedByRecall: boolean }> }; + expect(catalog.skills.find((s) => s.name === 'share')).toMatchObject({ blockedByRecall: true, path: null }); + expect(catalog.skills.filter((s) => s.name !== 'share').every((s) => !s.blockedByRecall && s.path !== null)).toBe(true); + + const enable = run('recall', 'enable'); + expect(enable.status, enable.stderr).toBe(0); + + expect(run('skill', 'get', 'share').stdout).toContain('name: share'); + expect(run('skill', 'get', '--all').stdout.match(/^name: /gm)).toHaveLength(4); + const servedDir = run('skill', 'path', 'share').stdout.trim(); + expect(fs.existsSync(path.join(servedDir, 'SKILL.md'))).toBe(true); + const after = JSON.parse(run('skill', 'list', '--json').stdout) as { skills: Array<{ name: string; path: string | null; blockedByRecall: boolean }> }; + expect(after.skills.find((s) => s.name === 'share')).toMatchObject({ blockedByRecall: false, path: servedDir }); + }); +}); diff --git a/src/__tests__/skill-commands-exist.test.ts b/src/__tests__/skill-commands-exist.test.ts index 0fee19ad..437ada89 100644 --- a/src/__tests__/skill-commands-exist.test.ts +++ b/src/__tests__/skill-commands-exist.test.ts @@ -6,6 +6,8 @@ import type { Command } from 'commander'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); const SKILL_DATA = path.join(ROOT, 'skill-data'); +/** The deployed stub is the one file agents always hold, so its commands are checked too. */ +const DEPLOYED_STUB = path.join(ROOT, 'skills', 'teamai', 'SKILL.md'); /** Every `teamai …` invocation written in the served skill content. */ interface Invocation { @@ -24,7 +26,7 @@ function markdownFiles(dir: string): string[] { function collectInvocations(): Invocation[] { const found: Invocation[] = []; - for (const file of markdownFiles(SKILL_DATA)) { + for (const file of [...markdownFiles(SKILL_DATA), DEPLOYED_STUB]) { const relative = path.relative(ROOT, file); const lines = fs.readFileSync(file, 'utf8').split('\n'); diff --git a/src/__tests__/skill-content.test.ts b/src/__tests__/skill-content.test.ts index e46d5a74..30c287ab 100644 --- a/src/__tests__/skill-content.test.ts +++ b/src/__tests__/skill-content.test.ts @@ -17,6 +17,7 @@ import { type PackagedSkill, type PackagedSkillRoots, } from '../skill-content.js'; +import { readSkillDescription } from '../agent-skills.js'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); @@ -307,6 +308,14 @@ describe('the shipped skill-data content', () => { expect(stub).toMatch(/^name: teamai$/m); expect(stub).toMatch(/^allowed-tools: Bash\(teamai:\*\), Bash\(npx teamai-cli:\*\)$/m); }); + + it('keeps the stub description within the 1024-character budget agents load it under', async () => { + // With one deployed skill, this description is the only text an agent sees + // at selection time, and hosts cap it at 1024 characters. + const description = await readSkillDescription(path.join(ROOT, 'skills/teamai/SKILL.md')); + expect(description.length).toBeGreaterThan(0); + expect(description.length).toBeLessThanOrEqual(1024); + }); }); describe('npm package contents', () => { From eea67a02d4ab9d75c1147a67bed6f42ce7468d3b Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Tue, 22 Sep 2026 07:56:44 +0200 Subject: [PATCH 10/37] fix(skills): gate skill show on recall, prune Codex's shared dir only from Codex Review follow-up on #699. - `skill show <served skill>` refuses a recall-blocked skill with the same message and exit code as `skill get` / `skill path`; it printed the directory those two withhold. - A skill resolved from skill-data/ is classified `[builtin]` directly. BUILTIN_SKILL_NAMES only knows the deployed stub, so `skill show core` reported `[local-only]` beside a package path. - pruneLegacyBuiltinSkills reaches `.agents/skills` only on Codex's own pass. Another enabled tool's pass deleted Codex's legacy copies while Codex was excluded, against the enabledAgents guarantee. - The share skill and its references are written in English; the generated document still follows the session's language. The AGENTS.md exception for Chinese skill-data output is dropped. --- AGENTS.md | 1 - CLAUDE.md | 1 - docs/designs/skill-serving.md | 5 +- skill-data/share/SKILL.md | 35 ++++++------- .../share/references/contribute-member.md | 2 +- skill-data/share/references/doc-template.md | 49 ++++++++++--------- src/__tests__/e2e/skill-serving-cli.test.ts | 11 +++++ src/__tests__/skill-show.test.ts | 28 +++++++++++ src/__tests__/skip-uninstalled-tools.test.ts | 36 ++++++++++++++ src/builtin-skills.ts | 14 +++--- src/resources/skills.ts | 2 +- src/skill-cmd.ts | 20 ++++++-- 12 files changed, 148 insertions(+), 56 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b4c2fc01..d159c371 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,6 @@ TypeScript, Node 20+, tsup (ESM), Vitest. Commands: `npm run build`, `npx tsc -- ## Rules - CLI user-facing output must be English. No Chinese in production code. Tests assert English output. - 例外:`teamai skill get` 打印的 `skill-data/` 内容是文档,保留作者书写的语言;命令自身的提示、错误与 `skill list` 输出仍然必须是英文。 - Keep bilingual docs in sync (`README` / `*.zh-CN.md`, `docs/usage-guide.*`). Behavior changes must update every affected doc (including `docs/designs/`); grep old wording before opening the PR. - **README 精简**:尽量少改动 README,保持简洁。确需改动时,所有语言版本(`README.md` 及全部 `README.*.md`,改前先 `ls README*` 确认清单)必须全部改完并保持一致。 - **`skill-data/` 与文档同等对待**:那是 agent 真正读到的内容。行为变更必须同步更新受影响的 skill(`core` / `setup` / `wiki` / `share`),并在 PR 前 grep 旧措辞。 diff --git a/CLAUDE.md b/CLAUDE.md index c27ab8e4..e93ec1fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,6 @@ TypeScript, Node 20+, tsup (ESM), Vitest. Commands: `npm run build`, `npx tsc -- ## Rules - CLI user-facing output must be English. No Chinese in production code. Tests assert English output. - 例外:`teamai skill get` 打印的 `skill-data/` 内容是文档,保留作者书写的语言;命令自身的提示、错误与 `skill list` 输出仍然必须是英文。 - Keep bilingual docs in sync (`README` / `*.zh-CN.md`, `docs/usage-guide.*`). Behavior changes must update every affected doc (including `docs/designs/`); grep old wording before opening the PR. - **README 精简**:尽量少改动 README,保持简洁。确需改动时,所有语言版本(`README.md` 及全部 `README.*.md`,改前先 `ls README*` 确认清单)必须全部改完并保持一致。 - **`skill-data/` 与文档同等对待**:那是 agent 真正读到的内容。行为变更必须同步更新受影响的 skill(`core` / `setup` / `wiki` / `share`),并在 PR 前 grep 旧措辞。 diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index 6dd32969..02bb1da9 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -99,8 +99,9 @@ installed. earlier releases deployed: `team-wiki-codebase` and `teamai-share-learnings`. `teamai-workflow` and `teamai-import` sat in the old guard set but were never packaged, so they are not in it: a directory by either name is the user's own. -Deployment removes them from every installed agent, in the configured skills -path and in Codex's shared `.agents/skills`. The removal is unconditional +Deployment removes them from every installed, non-excluded agent, in its +configured skills path; Codex's pass also covers the shared `.agents/skills`, +which no other tool's pass touches. The removal is unconditional because those trees were overwritten with `overwrite: true` on every pull, so no local edit ever survived in them. diff --git a/skill-data/share/SKILL.md b/skill-data/share/SKILL.md index f129bb66..afda9b9c 100644 --- a/skill-data/share/SKILL.md +++ b/skill-data/share/SKILL.md @@ -3,17 +3,18 @@ name: share description: >- Turn a session into a team learning: summarize what was solved, discovered or worked around, and publish it to the team knowledge base with `teamai contribute`. Also publishes a reusable - skill or a knowledge doc on request. 分享 Session 经验到团队知识库。Loaded on demand by the - teamai discovery stub, and by the friction reminder that ends a session worth sharing. + skill or a knowledge doc on request. Loaded on demand by the teamai discovery stub, and by + the friction reminder that ends a session worth sharing. allowed-tools: Bash(teamai:*), Bash(npx teamai-cli:*) --- -# Contribute — 分享 Session 经验到团队知识库 +# Contribute — share what a session taught you with the team -总结本次 AI 编码 session 中学到的经验,推送到团队知识库。 +Summarize what this AI coding session taught you and push it to the team knowledge base. -**文档使用用户本次会话所用的语言撰写**(中文提问 → 中文文档,英文提问 → 英文文档)。 -命令、参数、URL、路径和代码标识符保持原样。 +**Write the document in the language the user used in this session** (a Chinese conversation +gets a Chinese document, an English one an English document). Commands, flags, URLs, paths and +code identifiers stay as they are. ## When to Use @@ -24,15 +25,15 @@ allowed-tools: Bash(teamai:*), Bash(npx teamai-cli:*) ## How It Works -1. **总结**:回顾本次 session 的工具使用、解决的问题、发现的模式 -2. **生成文档**:撰写 Markdown 文档(语言同上),涵盖: - - 任务/问题是什么 - - 关键决策及原因 - - 解决方案、变通方法或发现的模式 - - 哪些工具/skill 特别有用 - - 踩坑点和注意事项 -3. **保存临时文件**:写入临时文件 -4. **推送到团队**:运行 `teamai contribute --file <path> --title "<title>"` +1. **Summarize**: review the tools used, the problems solved and the patterns found in this session +2. **Write the document**: a Markdown document (language as above) covering: + - What the task or problem was + - The key decisions and why they were made + - The solution, workaround or pattern discovered + - Which tools or skills proved especially useful + - Pitfalls and things to watch out for +3. **Save it**: write the document to a temporary file +4. **Push it to the team**: run `teamai contribute --file <path> --title "<title>"` ## Document Template @@ -43,8 +44,8 @@ makes the document searchable. ## Example ```bash -# AI 生成总结文档到 /tmp/session-summary.md 后 -teamai contribute --file /tmp/session-summary.md --title "K8s pod 启动超时排查" +# After writing the summary to /tmp/session-summary.md +teamai contribute --file /tmp/session-summary.md --title "Debugging K8s pod startup timeouts" ``` ## Important diff --git a/skill-data/share/references/contribute-member.md b/skill-data/share/references/contribute-member.md index c69baee9..1a73bfd0 100644 --- a/skill-data/share/references/contribute-member.md +++ b/skill-data/share/references/contribute-member.md @@ -3,7 +3,7 @@ Goal: the user turns something they built into team knowledge everyone can pull. **Any member can do this — you do not need to be an admin.** The usual entry point is the user just asking in plain language, e.g. *"share this xxx skill with my -team"* / *"把这个 xxx skill 分享给团队"* — then you run the publish for them. +team"*, in whatever language they work in — then you run the publish for them. ## Which kind of contribution? diff --git a/skill-data/share/references/doc-template.md b/skill-data/share/references/doc-template.md index 30134824..4e3c0c0b 100644 --- a/skill-data/share/references/doc-template.md +++ b/skill-data/share/references/doc-template.md @@ -1,43 +1,44 @@ # Learning document template -**【必须】文档必须包含 YAML frontmatter,用于搜索索引和知识发现。** +**Required: the document must start with YAML frontmatter.** It feeds the search index +and is how other members discover the learning. ```markdown --- -title: "<简短标题,描述核心问题或发现>" +title: "<short title naming the core problem or finding>" author: <username> date: <YYYY-MM-DD> tags: [tag1, tag2, tag3] --- -## 背景 -在做什么?遇到了什么问题? +## Context +What were you doing? What problem did you hit? -## 解决方案 -怎么解决的?关键步骤是什么? +## Solution +How did you solve it? What were the key steps? -## 经验总结 -- 经验 1 -- 经验 2 +## Lessons +- Lesson 1 +- Lesson 2 -## 相关 Skills +## Related Skills - skill-name-1 - skill-name-2 ``` -### Frontmatter 字段说明 +### Frontmatter fields -| 字段 | 必须 | 说明 | 示例 | +| Field | Required | Meaning | Example | |------|------|------|------| -| title | ✅ | 简短标题(<60 字符) | "K8s Pod OOM 排查指南" | -| author | ✅ | 贡献者用户名 | jeffyxu | -| date | ✅ | 日期 YYYY-MM-DD | 2026-03-28 | -| tags | ✅ | 2-5 个关键标签 | [k8s, oom, troubleshooting] | - -### Tags 选择建议 - -从以下类别中选择 2-5 个: -- **技术栈**: python, typescript, go, k8s, docker, sglang, cuda -- **问题类型**: troubleshooting, performance, deployment, config, api -- **模式**: workflow, pattern, tool-usage, best-practice -- **场景**: debugging, testing, monitoring, security +| title | yes | Short title (under 60 characters) | "Diagnosing K8s Pod OOM kills" | +| author | yes | Contributor's username | jeffyxu | +| date | yes | Date as YYYY-MM-DD | 2026-03-28 | +| tags | yes | 2-5 key tags | [k8s, oom, troubleshooting] | + +### Choosing tags + +Pick 2-5 from these categories: +- **Stack**: python, typescript, go, k8s, docker, sglang, cuda +- **Problem type**: troubleshooting, performance, deployment, config, api +- **Pattern**: workflow, pattern, tool-usage, best-practice +- **Scenario**: debugging, testing, monitoring, security diff --git a/src/__tests__/e2e/skill-serving-cli.test.ts b/src/__tests__/e2e/skill-serving-cli.test.ts index eed83d49..22ef22fa 100644 --- a/src/__tests__/e2e/skill-serving-cli.test.ts +++ b/src/__tests__/e2e/skill-serving-cli.test.ts @@ -172,6 +172,16 @@ describe('teamai skill recall gate CLI (e2e)', () => { expect(dir.stdout).toBe(''); expect(dir.stderr).toContain('share needs recall'); + const shown = run('skill', 'show', 'share'); + expect(shown.status).toBe(1); + expect(shown.stdout).not.toContain('skill: share'); + expect(shown.stdout).not.toContain('skill-data'); + + const core = run('skill', 'show', 'core'); + expect(core.status, core.stderr).toBe(0); + expect(core.stdout).toContain('Source : [builtin]'); + expect(core.stdout).toContain('Read it with : teamai skill get core'); + const listed = run('skill', 'list', '--json'); expect(listed.status).toBe(0); const catalog = JSON.parse(listed.stdout) as { skills: Array<{ name: string; path: string | null; blockedByRecall: boolean }> }; @@ -185,6 +195,7 @@ describe('teamai skill recall gate CLI (e2e)', () => { expect(run('skill', 'get', '--all').stdout.match(/^name: /gm)).toHaveLength(4); const servedDir = run('skill', 'path', 'share').stdout.trim(); expect(fs.existsSync(path.join(servedDir, 'SKILL.md'))).toBe(true); + expect(run('skill', 'show', 'share').stdout).toContain(`Package path : ${servedDir}/`); const after = JSON.parse(run('skill', 'list', '--json').stdout) as { skills: Array<{ name: string; path: string | null; blockedByRecall: boolean }> }; expect(after.skills.find((s) => s.name === 'share')).toMatchObject({ blockedByRecall: false, path: servedDir }); }); diff --git a/src/__tests__/skill-show.test.ts b/src/__tests__/skill-show.test.ts index 706a7c66..b089b790 100644 --- a/src/__tests__/skill-show.test.ts +++ b/src/__tests__/skill-show.test.ts @@ -147,6 +147,34 @@ describe('skillShow locator', () => { expect(text).toContain('claude'); }); + it('classifies a skill served from the package as builtin', async () => { + // Only the deployed stub is in BUILTIN_SKILL_NAMES; the served workflows + // are built in by where they were found, not by name. + const lines = await runSkillShow('core', fx); + const text = lines.join('\n'); + expect(text).toContain('Source : [builtin]'); + expect(text).toContain('Read it with : teamai skill get core'); + expect(text).not.toContain('[local-only]'); + }); + + it('refuses share while recall is disabled, like skill get and skill path do', async () => { + // The fixture's team has no recall setting, so it is off by default. + const lines = await runSkillShow('share', fx); + expect(process.exitCode).toBe(1); + expect(lines.find((l) => l.includes('skill: share'))).toBeUndefined(); + expect(lines.join('\n')).not.toContain('skill-data'); + process.exitCode = 0; + }); + + it('shows share once recall is enabled', async () => { + fx.localConfig.recallEnabled = true; + const lines = await runSkillShow('share', fx); + const text = lines.join('\n'); + expect(process.exitCode).toBe(0); + expect(text).toContain('skill: share'); + expect(text).toContain('Source : [builtin]'); + }); + it('exits with non-zero code when skill not found', async () => { process.exitCode = 0; const lines = await runSkillShow('does-not-exist', fx); diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index de4caaa9..c47ac694 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -694,6 +694,42 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { expect(await fse.pathExists(path.join(homeDir, '.agents/skills/teamai'))).toBe(false); }); + it('leaves the Codex shared directory alone when another tool prunes and Codex is excluded', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + await fse.ensureDir(path.join(homeDir, '.claude')); + await fse.ensureDir(path.join(homeDir, '.codex')); + const sharedLegacy = path.join(homeDir, '.agents/skills/team-wiki-codebase'); + await fse.ensureDir(sharedLegacy); + await fse.writeFile(path.join(sharedLegacy, 'SKILL.md'), '# codex copy'); + + const teamConfig = { + team: 'test', + description: '', + repo: 'https://example.test/team.git', + provider: 'git' as const, + reviewers: [], + sharing: { skills: {}, rules: { enforced: [] }, docs: { localDir: '' }, env: { injectShellProfile: true } }, + toolPaths: { claude: { skills: '.claude/skills' }, codex: { skills: '.codex/skills' } }, + }; + const localConfig = { + repo: { localPath: path.join(tmpDir, 'repo'), remote: 'https://example.test/team.git' }, + username: 'testuser', + updatePolicy: 'auto' as const, + additionalRoles: [], + scope: 'user' as const, + enabledAgents: ['claude'], + }; + + const deployed = await deployBuiltinSkills(teamConfig, localConfig); + + // .agents/skills is Codex's; the whitelist says Codex is neither written to + // nor deleted from, and Claude's pass must not reach it on Codex's behalf. + expect(deployed).toBe(1); + expect(await fse.pathExists(path.join(homeDir, '.claude/skills/teamai/SKILL.md'))).toBe(true); + expect(await fse.readFile(path.join(sharedLegacy, 'SKILL.md'), 'utf8')).toBe('# codex copy'); + }); + it('deploys a built-in Codex skill to its existing shared location', async () => { const { deployBuiltinSkills } = await import('../builtin-skills.js'); const sharedSkill = path.join(homeDir, '.agents', 'skills', 'teamai'); diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index c01c6afa..1ea50bb4 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -6,7 +6,7 @@ import { log } from './utils/logger.js'; import type { TeamaiConfig, LocalConfig } from './types.js'; import { resolveToolBaseDir, isAgentExcluded, scopedToolPaths } from './types.js'; import { isToolInstalledForConfig, ResourceHandler } from './resources/base.js'; -import { resolveSkillDestination, SHARED_AGENT_SKILLS_PATH } from './resources/skills.js'; +import { CODEX_TOOL, resolveSkillDestination, SHARED_AGENT_SKILLS_PATH } from './resources/skills.js'; import { getUserHome } from './utils/home.js'; import { packagedSkillRoots } from './skill-content.js'; @@ -60,12 +60,14 @@ export const LEGACY_BUILTIN_SKILL_NAMES = new Set([ * agent on the machine the context they were deployed to save. */ async function pruneLegacyBuiltinSkills(tool: string, configuredSkillsPath: string, baseDir: string): Promise<void> { + // The shared .agents/skills directory belongs to Codex alone. Reaching it from + // another tool's pass would delete Codex's copies while Codex is excluded or + // not installed, which the enabledAgents whitelist rules out. + const skillRoots = [configuredSkillsPath]; + if (tool === CODEX_TOOL) skillRoots.push(SHARED_AGENT_SKILLS_PATH); for (const legacyName of LEGACY_BUILTIN_SKILL_NAMES) { - const candidates = [ - path.join(baseDir, configuredSkillsPath, legacyName), - path.join(baseDir, SHARED_AGENT_SKILLS_PATH, legacyName), - ]; - for (const dir of candidates) { + for (const root of skillRoots) { + const dir = path.join(baseDir, root, legacyName); if (!await pathExists(dir)) continue; try { await remove(dir); diff --git a/src/resources/skills.ts b/src/resources/skills.ts index 77b152c3..74fde282 100644 --- a/src/resources/skills.ts +++ b/src/resources/skills.ts @@ -15,7 +15,7 @@ import { splitFrontmatter, stringifyFrontmatter } from '../utils/frontmatter.js' /** File name used to track who has contributed (pushed) a skill. */ const CONTRIBUTORS_FILE = 'CONTRIBUTORS'; const SKILL_MD = 'SKILL.md'; -const CODEX_TOOL = 'codex'; +export const CODEX_TOOL = 'codex'; export const SHARED_AGENT_SKILLS_PATH = '.agents/skills'; /** Prefer Codex's shared skill when that skill already lives there. */ diff --git a/src/skill-cmd.ts b/src/skill-cmd.ts index a260feab..e6e55372 100644 --- a/src/skill-cmd.ts +++ b/src/skill-cmd.ts @@ -13,7 +13,7 @@ import { type SkillSource, } from './agent-skills.js'; import { detectInstalledAgents, type ResolvedAgent } from './known-agents.js'; -import { resolvePackagedSkill, skillCatalog } from './skill-content.js'; +import { blockedByRecall, resolvePackagedSkill, skillCatalog } from './skill-content.js'; import type { GlobalOptions, LocalConfig } from './types.js'; const DESCRIPTION_MAX = 160; @@ -50,8 +50,22 @@ export async function skillShow(name: string, options: GlobalOptions): Promise<v } const resolvedName = resolved.name; - const ctx = await buildClassifyContext(localConfig); - const source = classifySkill(resolvedName, ctx); + + // The recall gate holds here too: `skill get` and `skill path` withhold the + // share workflow while recall is off, and the card would otherwise print the + // very directory they refuse. + if (resolved.primaryOrigin === 'builtin' && await blockedByRecall(resolvedName)) { + log.error(`${resolvedName} needs recall, which is disabled for this team.`); + log.dim('Turn it on with `teamai recall enable`, or ask your team admin to enable sharing.'); + process.exitCode = 1; + return; + } + + // A skill served from the package is built in by construction; BUILTIN_SKILL_NAMES + // only knows the deployed stub, so classifying by name would call `core` local-only. + const source: SkillSource = resolved.primaryOrigin === 'builtin' + ? { kind: 'builtin' } + : classifySkill(resolvedName, await buildClassifyContext(localConfig)); const description = truncate(await readSkillDescription(path.join(resolved.primaryPath, 'SKILL.md')), DESCRIPTION_MAX); const contributors = await SkillsHandler.readContributors(resolved.primaryPath); From 2639c557de45ba10096a44ed7c3927dda292b665 Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Tue, 22 Sep 2026 08:17:59 +0200 Subject: [PATCH 11/37] fix(skills): one resolver for served skills, legacy names kept out of push, wiki in English Review follow-up on #699. - resolveServableSkill is the only way to obtain a PackagedSkill outside skill-content.ts; it returns `blocked` instead of the skill, so `get`, `path`, `list` and `show` inherit the recall gate by construction. - push never offers `team-wiki-codebase` / `teamai-share-learnings` as new user skills: between the upgrade and the first pull they are still on disk (isCliOwnedSkillName). - `recall disable` removes the legacy `teamai-share-learnings` directory again (LEGACY_RECALL_SKILL_NAMES), skipping excluded agents. - `skill list` prints the packaged catalog before `teamai init`, with a hint for the team half, instead of failing on the team listing. - skill-data/wiki (SKILL.md, 14 references, 2 scripts) translated to English. Generated document names follow one glossary; validate_kb.py still recognises headings of knowledge bases built by the previous release, matched by code point so the source stays ASCII. --- docs/designs/skill-serving.md | 18 +- skill-data/wiki/SKILL.md | 314 ++++++------ .../wiki/references/agents/graph-rag-agent.md | 450 +++++++++--------- .../references/agents/kb-doc-generator.md | 398 ++++++++-------- .../methodology/phase0-collection.md | 78 +-- .../methodology/phase1-reverse-engineering.md | 134 +++--- .../methodology/phase2-document-types.md | 420 ++++++++-------- .../methodology/phase3-ai-enhancement.md | 214 ++++----- .../references/methodology/phase4-quality.md | 362 +++++++------- skill-data/wiki/references/overview.md | 168 +++---- .../phases/k1-reverse-engineering.md | 138 +++--- .../wiki/references/phases/k2-documents.md | 84 ++-- .../wiki/references/phases/k3-ai-native.md | 144 +++--- .../wiki/references/phases/k4-quality.md | 234 ++++----- .../wiki/references/phases/phase0-init.md | 94 ++-- .../references/templates/project-overview.md | 200 ++++---- skill-data/wiki/scripts/scan_repo.py | 104 ++-- skill-data/wiki/scripts/validate_kb.py | 130 ++--- src/__tests__/recall-toggle.test.ts | 23 + src/__tests__/skill-content.test.ts | 31 +- .../skill-list-uninitialized.test.ts | 46 ++ src/__tests__/skill-recall-gate.test.ts | 12 +- src/__tests__/skills.test.ts | 19 + src/builtin-skills.ts | 25 +- src/recall-toggle.ts | 8 + src/resources/skills.ts | 4 +- src/skill-cmd.ts | 65 ++- src/skill-content.ts | 89 +++- 28 files changed, 2110 insertions(+), 1896 deletions(-) create mode 100644 src/__tests__/skill-list-uninitialized.test.ts diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index 02bb1da9..5af6bf01 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -72,9 +72,16 @@ task matches stub body ~1.3 KB holds the c - **Recall is decided at run time**, not by withholding a directory at deploy time, and it holds on every path that hands out content or a location: `skill get <name>` refuses, `skill get --all` leaves the skill out and says so - on stderr, `skill path <name>` refuses, and `skill list --json` reports - `blockedByRecall: true` with `path: null`. With no team config to consult it - fails open. + on stderr, `skill path <name>` and `skill show <name>` refuse, and + `skill list --json` reports `blockedByRecall: true` with `path: null`. With no + team config to consult it fails open. The gate lives in one place: + `resolveServableSkill` (`src/skill-content.ts`) is the only way to obtain a + packaged skill outside that module, and it returns `blocked` instead of the + skill, so a command cannot print a directory it never received. +- **`skill list` needs no team.** The human-readable listing prints the packaged + catalog even before `teamai init`, with a hint for the team half, so a fresh + machine can discover what the installed CLI serves the way `skill get` lets it. + ## Drift guards @@ -105,6 +112,11 @@ which no other tool's pass touches. The removal is unconditional because those trees were overwritten with `overwrite: true` on every pull, so no local edit ever survived in them. +Between the upgrade and that first pull the legacy trees are still on disk, so +two other commands know the names too: `push` never offers them as new user +skills (`isCliOwnedSkillName`), and `recall disable` still removes +`teamai-share-learnings` (`LEGACY_RECALL_SKILL_NAMES`), as it did before the stub. + **Retire that set once 0.23.x is no longer in the field.** The short names (`wiki`, `share`) are the canonical ones; the long names survive as aliases in `SKILL_ALIASES` (`src/skill-content.ts`) for documentation and muscle memory, diff --git a/skill-data/wiki/SKILL.md b/skill-data/wiki/SKILL.md index f15e0656..f5af4dd1 100644 --- a/skill-data/wiki/SKILL.md +++ b/skill-data/wiki/SKILL.md @@ -1,119 +1,123 @@ --- name: wiki description: >- - 让 AI 真正理解大型代码库:对多仓库、多微服务、迭代多年的项目做架构逆向 + Graph RAG 图谱 + 多语言 AST, - 把海量代码压缩成结构化知识库,每条结论可回溯代码行,每条关系有置信度标注。适用于 10+ 仓库或微服务、 - AI 直接读代码无法全局理解的项目。Triggers: 架构分析, 架构逆向, 代码知识库, code-to-knowledge, - architecture wiki, large multi-repo codebase. Loaded on demand by the teamai discovery stub. + Make AI truly understand large codebases: for multi-repository, multi-microservice projects + that have evolved over years, run architecture reverse-engineering + a Graph RAG graph + + multi-language AST to compress a huge codebase into a structured knowledge base, where every + conclusion traces back to a code line and every relation carries a confidence label. Suited to + projects with 10+ repositories or microservices that AI cannot understand globally by reading + the code directly. Triggers: architecture analysis, architecture reverse-engineering, + codebase knowledge base, code-to-knowledge, architecture wiki, large multi-repo codebase. + Loaded on demand by the teamai discovery stub. allowed-tools: Bash(teamai:*), Bash(npx teamai-cli:*), Bash(python3:*) --- -# team-wiki-codebase — 大型代码库 AI 认知工程 +# team-wiki-codebase: AI cognition engineering for large codebases -> 前置条件:可访问的源码目录(支持多仓库)、Python 3、已安装的 teamai CLI。 -> 方法论、子 agent 提示词、模板与脚本随 CLI 一起分发,运行 `teamai skill path wiki` 获取它们的绝对路径; -> 本文中的 `{SKILL_DIR}` 就是该路径。 -> Phase 0 结构基线使用 `teamai codebase --extract`。TeamAI does not ship a separate team-wiki CLI. No extra plugin is required. +> Prerequisites: an accessible source directory (multiple repositories supported), Python 3, and an installed teamai CLI. +> The methodology, sub-agent prompts, templates and scripts ship with the CLI. Run `teamai skill path wiki` to get their absolute path; +> `{SKILL_DIR}` in this document refers to that path. +> The Phase 0 structural baseline uses `teamai codebase --extract`. TeamAI does not ship a separate team-wiki CLI. No extra plugin is required. -**解决什么问题**:大型项目(10+ 仓库、数十微服务、迭代多年)让 AI 无法全局理解——上下文窗口装不下所有代码,组件关系散落各处,业务规则隐藏在深层调用链中。直接让 AI 读代码,既慢(海量 token)又不准(缺乏全局视角)。 +**The problem**: large projects (10+ repositories, dozens of microservices, years of iteration) defeat global understanding by AI. The context window cannot hold all the code, component relations are scattered everywhere, and business rules hide deep in call chains. Letting AI read the code directly is both slow (huge token counts) and inaccurate (no global view). -**怎么解决**:通过架构逆向工程,将海量代码系统化压缩为**结构化、可验证、AI-Native** 的深度知识库——每个结论可回溯到代码行,每条关系有置信度标注,每次更新有增量校验。AI 读知识库而非读源码,用约 **1/50 的 token** 获得全局架构认知。 +**The solution**: use architecture reverse-engineering to systematically compress a huge codebase into a **structured, verifiable, AI-Native** deep knowledge base. Every conclusion traces back to a code line, every relation carries a confidence label, and every update is incrementally verified. AI reads the knowledge base instead of the source, and gains global architecture awareness for about **1/50 of the tokens**. -## 使用方式 +## Usage -用户用自然语言说明模式,或直接说“做代码库知识库”: +The user states the mode in natural language, or simply says "build a codebase knowledge base": ``` -默认 Standard:单 session 核心路径 ---deep 完整 K1~K4 + G1~G9 ---update 增量更新已有 knowledge/ -continue 从 _review/progress.json 断点继续 +default Standard: single-session core path +--deep Full K1~K4 + G1~G9 +--update Incremental update of an existing knowledge/ +continue Resume from the _review/progress.json checkpoint ``` --- -## Agent 架构 +## Agent architecture -| Agent | 文件 | 启动时机 | +| Agent | File | When started | |-------|------|---------| -| 知识库文档生成 Agent | `{SKILL_DIR}/references/agents/kb-doc-generator.md` | Phase K2 每批组件 | +| Knowledge base document generator Agent | `{SKILL_DIR}/references/agents/kb-doc-generator.md` | Phase K2, every component batch | | Graph RAG Agent | `{SKILL_DIR}/references/agents/graph-rag-agent.md` | Phase K3 | -**主 Agent 职责**:流程编排、确认点管理、progress.json 维护、质量报告汇总。 +**Main agent responsibilities**: workflow orchestration, confirmation point management, progress.json maintenance, quality report aggregation. --- -## 入口判断 +## Entry decision -**每次激活时必须先执行此判断。** +**This decision must run first on every activation.** ``` -IF 用户输入包含 "--update" 或 "增量更新": - → Update 模式 -ELSE IF 用户输入包含 "continue" 或 "继续": - → Continue 模式 +IF the user input contains "--update" or "incremental update": + → Update mode +ELSE IF the user input contains "continue" or "resume": + → Continue mode ELSE: - → 检查用户指定目录下是否有 _review/progress.json - IF 存在 → 告知状态,等待"继续上次"或"重新开始" - ELSE → Phase 0 + → Check whether _review/progress.json exists under the user-specified directory + IF it exists → report the state, wait for "resume last run" or "start over" + ELSE → Phase 0 ``` --- -## Continue 模式 +## Continue mode ``` -Step 1:定位 progress.json -Step 2:读取解析,展示恢复摘要 -Step 3:根据 current_phase 跳转: +Step 1: Locate progress.json +Step 2: Read and parse it, show a resume summary +Step 3: Jump according to current_phase: "phase0_done" → Phase K1 - "phasek1_waiting_confirm" → 展示 k1-architecture-map.md,等待确认① + "phasek1_waiting_confirm" → Show k1-architecture-map.md, wait for confirmation ① "phasek1_confirmed" → Phase K2 - "phasek2_batch_N" → Phase K2 第 N 批继续(跳过已完成) - "phasek2_waiting_confirm" → 等待确认② + "phasek2_batch_N" → Continue Phase K2 from batch N (skip completed ones) + "phasek2_waiting_confirm" → Wait for confirmation ② "phasek2_confirmed" → Phase K3 "phasek3_done" → Phase K4 - "phasek4_done"/"completed" → 告知完成,询问是否 --update 或重跑某组件 + "phasek4_done"/"completed" → Report completion, ask whether to --update or rerun a component ``` --- -## Update 模式(增量更新) +## Update mode (incremental update) -**触发**:用户要求「增量更新」,或在本 skill 中指定 `--update` 模式。 -**前提**:已有 completed 状态的 progress.json。 +**Trigger**: the user asks for an "incremental update", or specifies the `--update` mode in this skill. +**Precondition**: an existing progress.json in the completed state. ``` -Step 1:读取 progress.json,获取 file_hash_cache -Step 2:扫描 project_root,计算各文件当前 SHA256 -Step 3:对比 hash,分类:新增 / 修改 / 删除 -Step 4:展示变更摘要,等待用户确认: +Step 1: Read progress.json, get file_hash_cache +Step 2: Scan project_root, compute the current SHA256 of every file +Step 3: Compare hashes, classify: added / modified / deleted +Step 4: Show the change summary, wait for user confirmation: ┌────────────────────────────────────┐ - │ 变更摘要 │ - │ 新增: N 个文件 │ - │ 修改: N 个文件(含 Aurora.py 等) │ - │ 删除: N 个文件 │ - │ 受影响组件: [列表] │ - │ 受影响图谱文档: G1/G2/G6/G7 │ + │ Change summary │ + │ Added: N files │ + │ Modified: N files (incl. Aurora.py)│ + │ Deleted: N files │ + │ Affected components: [list] │ + │ Affected graph documents: G1/G2/G6/G7 │ └────────────────────────────────────┘ -Step 5:仅重跑受影响范围: - - Phase K2:重新生成受影响组件的 Type-4 文档(覆盖写入) - - Phase K3 局部:更新涉及变更组件的图谱文档(G1/G2/G6/G7) - - Phase K4:重新运行 validate_kb.py -Step 6:更新 file_hash_cache + metadata.json commit SHA -Step 7:组件级 diff(处理新增/删除仓库或组件) - IF repos 列表与上次不同: - 新增的仓库 → 对新仓库执行完整 K1 扫描,补充到组件清单,生成 Type-4 文档 - 删除的仓库 → 对应组件文档顶部加 `⚠️ [DEPRECATED] 此组件对应仓库已移除` - → 更新 k1-architecture-map.md 的组件清单 - → 更新 G1 矩阵(移除已删除组件的行列,新增新组件行列) +Step 5: Rerun only the affected scope: + - Phase K2: regenerate the Type-4 documents of affected components (overwrite) + - Phase K3 partial: update the graph documents that involve changed components (G1/G2/G6/G7) + - Phase K4: rerun validate_kb.py +Step 6: Update file_hash_cache + the metadata.json commit SHA +Step 7: Component-level diff (handle added/removed repositories or components) + IF the repos list differs from last time: + Added repositories → run a full K1 scan on the new repository, add it to the component inventory, generate Type-4 documents + Removed repositories → prepend `⚠️ [DEPRECATED] The repository for this component has been removed` to the component document + → Update the component inventory in k1-architecture-map.md + → Update the G1 matrix (remove rows/columns of removed components, add rows/columns for new ones) ``` --- -## progress.json 规范 +## progress.json specification -**路径**:`<output_dir>/../_review/progress.json` +**Path**: `<output_dir>/../_review/progress.json` ```json { @@ -130,7 +134,7 @@ Step 7:组件级 diff(处理新增/删除仓库或组件) "confirmed_phases": ["phase0", "phasek1"], "service_map": { - "描述": "Phase K1 Step 3 构建的服务名→仓库映射表", + "description": "Service name → repository map built in Phase K1 Step 3", "ServiceA": {"repo": "repo-a", "entry": "cmd/serviceA/main.go"}, "ServiceB": {"repo": "repo-b", "entry": "app/main.py"} }, @@ -154,13 +158,13 @@ Step 7:组件级 diff(处理新增/删除仓库或组件) }, "interface_coverage": { - "描述": "接口数量对账结果,由 Phase K2 自校验填充", + "description": "Interface count reconciliation, filled by the Phase K2 self-check", "ComponentA": {"type": "HTTP", "scanned": 13, "documented": 0, "gap": 13}, "ComponentB": {"type": "MQ", "scanned": 5, "documented": 0, "gap": 5} }, "consistency_check": { - "描述": "Phase K3 Step 3 跨文档一致性校验结果", + "description": "Cross-document consistency check result from Phase K3 Step 3", "contradictions": 0, "missing_refs": 0, "g1_deviations": 0, @@ -168,7 +172,7 @@ Step 7:组件级 diff(处理新增/删除仓库或组件) }, "e2e_validation": { - "描述": "Phase K4 Step 4 AI 端到端验证结果", + "description": "AI end-to-end validation result from Phase K4 Step 4", "total_questions": 0, "correct": 0, "partial": 0, @@ -184,127 +188,127 @@ Step 7:组件级 diff(处理新增/删除仓库或组件) } ``` -> `accuracy_stats` 在每批 Phase K2 完成后累加,是知识库可信度的全局指标。 +> `accuracy_stats` accumulates after every Phase K2 batch and is the global trust indicator of the knowledge base. --- -## 核心原则(准确性优先) +## Core principles (accuracy first) -1. **代码为唯一事实来源**:每个结论必须有代码文件:行号 作为证据,无法验证的标 `[UNVERIFIED]` -2. **置信度三态强制**:图谱中每条关系标 `EXTRACTED(1.0)` / `INFERRED(0.6~0.9)` / `AMBIGUOUS(0.1~0.3)`;禁止凭空发明,禁止用 0.5 默认值 -3. **两级准确性验证**:Phase K2 每份文档生成后立即自校验;Phase K4 全库质量检验 -4. **人在回路两次确认**:架构理解(K①)和组件文档质量(K②)必须人工确认,防止系统性错误扩散 -5. **并行生成 + 断点续传**:Type-4 组件文档并行分发(同一消息发出所有 Agent calls);每批持久化 progress.json -6. **Token 精简**:`Glob → Grep → Read` 三步法,禁止全量目录扫描 -7. **诚实审计**:`[UNVERIFIED]` 不得隐藏;质量数字完整展示;不确定用 AMBIGUOUS 不删除 -8. **认知边界声明**:知识库 README 必须明确声明覆盖范围和不覆盖范围,让 AI 知道何时应该说"不确定" -9. **跨文档一致性**:Phase K3 强制交叉比对组件间关系描述,矛盾项必须修复后才计入"一致" -10. **端到端可验证**:Phase K4 用标准化问题测试知识库实际回答能力,E2E 准确率目标 ≥ 80% +1. **Code is the single source of truth**: every conclusion must cite a code file:line as evidence; anything unverifiable is marked `[UNVERIFIED]` +2. **Three-state confidence is mandatory**: every relation in the graph is labelled `EXTRACTED(1.0)` / `INFERRED(0.6~0.9)` / `AMBIGUOUS(0.1~0.3)`; no invention out of thin air, no 0.5 default +3. **Two-level accuracy verification**: Phase K2 self-checks every document right after generation; Phase K4 verifies the whole knowledge base +4. **Two human-in-the-loop confirmations**: architecture understanding (K①) and component document quality (K②) must be confirmed by a human to stop systematic errors from spreading +5. **Parallel generation + resume from checkpoint**: Type-4 component documents are dispatched in parallel (all Agent calls in the same message); progress.json is persisted after every batch +6. **Token economy**: the `Glob → Grep → Read` three-step method; full directory scans are forbidden +7. **Honest auditing**: `[UNVERIFIED]` must not be hidden; quality numbers are shown in full; when unsure, mark AMBIGUOUS instead of deleting +8. **Cognitive boundary declaration**: the knowledge base README must state explicitly what is covered and what is not, so AI knows when to say "not sure" +9. **Cross-document consistency**: Phase K3 must cross-check relation descriptions between components; contradictions count as "consistent" only after they are fixed +10. **End-to-end verifiable**: Phase K4 tests the knowledge base's actual answering ability with standardised questions; E2E accuracy target ≥ 80% --- -## 阶段流程(按需加载) +## Phase workflow (loaded on demand) -每个阶段的完整步骤在独立文件中,轮到该阶段时再加载,不要一次性读完: +The full steps of each phase live in separate files. Load a file when its phase comes up; do not read them all at once: -| 阶段 | 文件 | 内容 | +| Phase | File | Content | |---|---|---| -| Phase 0 | `{SKILL_DIR}/references/phases/phase0-init.md` | 初始化、`teamai codebase --extract` 结构基线、仓库清单 | -| Phase K1 | `{SKILL_DIR}/references/phases/k1-reverse-engineering.md` | 架构逆向与源材料采集、扫描脚本、架构分析报告 | -| Phase K2 | `{SKILL_DIR}/references/phases/k2-documents.md` | 文档生成(分批并行 + 中间质量确认) | -| Phase K3 | `{SKILL_DIR}/references/phases/k3-ai-native.md` | AI-Native 增强 + Graph RAG 图谱文档集 | -| Phase K4 | `{SKILL_DIR}/references/phases/k4-quality.md` | 质量评估、校验脚本、质量报告 | +| Phase 0 | `{SKILL_DIR}/references/phases/phase0-init.md` | Initialisation, `teamai codebase --extract` structural baseline, repository inventory | +| Phase K1 | `{SKILL_DIR}/references/phases/k1-reverse-engineering.md` | Architecture reverse-engineering and source material collection, scan script, architecture analysis report | +| Phase K2 | `{SKILL_DIR}/references/phases/k2-documents.md` | Document generation (parallel batches + intermediate quality confirmation) | +| Phase K3 | `{SKILL_DIR}/references/phases/k3-ai-native.md` | AI-Native enhancement + Graph RAG graph document set | +| Phase K4 | `{SKILL_DIR}/references/phases/k4-quality.md` | Quality assessment, validation script, quality report | -方法论背景(可选,写文档时参考):`{SKILL_DIR}/references/methodology/`; -子 agent 提示词:`{SKILL_DIR}/references/agents/`; -知识库 README 模板:`{SKILL_DIR}/references/templates/project-overview.md`。 +Methodology background (optional, for reference while writing documents): `{SKILL_DIR}/references/methodology/`; +sub-agent prompts: `{SKILL_DIR}/references/agents/`; +knowledge base README template: `{SKILL_DIR}/references/templates/project-overview.md`. -人类可读概览(非执行用):`{SKILL_DIR}/references/overview.md`。 +Human-readable overview (not for execution): `{SKILL_DIR}/references/overview.md`. -`teamai skill get wiki --full` 一次性打印全部参考文件(约 115 KB),仅在需要通读时使用。 +`teamai skill get wiki --full` prints every reference file in one go (about 115 KB). Use it only when you need to read everything. -## 输出目录结构 +## Output directory layout ``` <output_dir>/ -├── README.md ← 知识库索引 + 检索路由规则 + 认知边界声明(AI 专用) -│ 起手用模板:cp {SKILL_DIR}/references/templates/project-overview.md <output_dir>/README.md -├── {项目名} 技术架构.md ← [Type-1] 架构总览(目标 ≤80KB,超过则自动拆分) -├── {项目名} 技术架构-核心链路.md ← [Type-1b] 仅当 Type-1 超 80KB 时拆出 -├── {项目名} 技术架构-AI元数据.md ← [Type-1c] 仅当 Type-1 超 80KB 时拆出 -├── {项目名} 业务架构.md ← [Type-2] 产品能力 + 生命周期 ~70KB -├── {项目名} 部署架构.md ← [Type-3] 部署拓扑 ~40KB -├── XX_{组件名}设计说明.md × N ← [Type-4] 每份 20~100KB -├── XX_{项目名}核心API产品代码映射.md ← [Type-5] 仅有产品文档时生成 -├── XX_{项目名}产品规则速查表.md ← [Type-6] -├── XX_{项目名}业务开发规范SOP.md ← [Type-7] -├── {知识增强文档} × N ← [Type-8] 反模式/RPC契约/排障/知识文库 -└── graph/ ← [Type-9] Graph RAG 图谱文档集 - ├── README.md ← 图谱索引 + 按问题类型查找 - ├── G1_{项目名}组件依赖关系矩阵.md - ├── G2_{项目名}组件调用链路全景.md - ├── G3_{项目名}数据流与存储依赖图.md - ├── G4_{项目名}错误码组件映射表.md - ├── G5_{项目名}跨组件交互场景手册.md - ├── G6_{项目名}知识图谱三元组.md - ├── G7_{项目名}架构风险与影响面分析.md - ├── G8_{项目名}核心配置参数索引.md - └── G9_{项目名}业务规则约束矩阵.md - -_review/ ← 过程文件(不入知识库) -├── progress.json ← 断点续传 + 增量更新状态 -├── metadata.json ← 代码基准版本 -├── interface-inventory.json ← 接口扫描基准(Phase K1 Step 5) -├── k1-architecture-map.md ← 架构逆向结果(用户确认过) -├── k2-doc-list.md ← 文档清单 + 准确性统计 -├── k3-consistency-check.md ← 跨文档一致性校验报告(Phase K3 Step 3) -└── k4-quality-report.md ← 质量报告(含 E2E 验证结果) +├── README.md ← Knowledge base index + retrieval routing rules + cognitive boundary declaration (for AI) +│ Start from the template: cp {SKILL_DIR}/references/templates/project-overview.md <output_dir>/README.md +├── {project_name} Technical Architecture.md ← [Type-1] Architecture overview (target ≤80KB, split automatically when larger) +├── {project_name} Technical Architecture-Core Call Chains.md ← [Type-1b] Split out only when Type-1 exceeds 80KB +├── {project_name} Technical Architecture-AI Metadata.md ← [Type-1c] Split out only when Type-1 exceeds 80KB +├── {project_name} Business Architecture.md ← [Type-2] Product capabilities + lifecycle ~70KB +├── {project_name} Deployment Architecture.md ← [Type-3] Deployment topology ~40KB +├── XX_{component}_Design.md × N ← [Type-4] 20~100KB each +├── XX_{project_name}_Core_API_Product_Code_Mapping.md ← [Type-5] Generated only when product docs exist +├── XX_{project_name}_Product_Rules_Cheat_Sheet.md ← [Type-6] +├── XX_{project_name}_Business_Development_SOP.md ← [Type-7] +├── {knowledge_enhancement_doc} × N ← [Type-8] Anti-patterns / RPC contracts / troubleshooting / knowledge library +└── graph/ ← [Type-9] Graph RAG graph document set + ├── README.md ← Graph index + lookup by question type + ├── G1_{project_name}_Component_Dependency_Matrix.md + ├── G2_{project_name}_Component_Call_Chain_Overview.md + ├── G3_{project_name}_Data_Flow_and_Storage_Dependencies.md + ├── G4_{project_name}_Error_Code_Component_Map.md + ├── G5_{project_name}_Cross_Component_Interaction_Scenarios.md + ├── G6_{project_name}_Knowledge_Graph_Triples.md + ├── G7_{project_name}_Architecture_Risks_and_Impact_Analysis.md + ├── G8_{project_name}_Core_Config_Parameter_Index.md + └── G9_{project_name}_Business_Rule_Constraint_Matrix.md + +_review/ ← Process files (not part of the knowledge base) +├── progress.json ← Resume-from-checkpoint + incremental update state +├── metadata.json ← Code baseline version +├── interface-inventory.json ← Interface scan baseline (Phase K1 Step 5) +├── k1-architecture-map.md ← Architecture reverse-engineering result (confirmed by the user) +├── k2-doc-list.md ← Document inventory + accuracy statistics +├── k3-consistency-check.md ← Cross-document consistency check report (Phase K3 Step 3) +└── k4-quality-report.md ← Quality report (incl. E2E validation results) ``` --- -## 阶段间控制 +## Control between phases -| 用户回复 | 行为 | +| User reply | Behaviour | |---------|------| -| "继续" / "continue" / "ok" | 进入下一阶段 | -| "停止" / "stop" | 停止,已生成文件保持可用 | -| 直接描述问题 | 调整后重新确认,再继续 | -| 直接编辑文件后回复"继续" | 以修改后文件内容为准继续 | +| "continue" / "go on" / "ok" | Enter the next phase | +| "stop" | Stop; files generated so far stay usable | +| Describes a problem directly | Adjust, reconfirm, then continue | +| Edits files directly and then replies "continue" | Continue based on the edited file contents | --- -## 约束 +## Constraints -- **主 Agent 不执行代码分析**:全部由专职 Agent 完成;启动前必须先 Read 对应 agent 文件 -- **严禁冗余输出**:生成文件直接 Write,禁止先在对话中打印完整内容 -- **组件文档命名**:`XX_{组件名}设计说明.md`(XX 为两位数编号,按依赖链顺序分配,底层组件编号小) -- **无产品文档时**:Type-5/6 可跳过或将约束值标注为 `[PRODUCT_DOC_MISSING]`,不得推测 -- **并行模式**:Type-4 批次必须同一消息并发发出所有 Agent calls;串行批次顺序执行 +- **The main agent does no code analysis**: all of it is done by dedicated Agents; Read the corresponding agent file before starting one +- **No redundant output**: Write generated files directly; never print the full content in the conversation first +- **Component document naming**: `XX_{component}_Design.md` (XX is a two-digit number assigned in dependency-chain order, lower layers get lower numbers) +- **When no product docs exist**: Type-5/6 may be skipped, or constraint values marked `[PRODUCT_DOC_MISSING]`; never guess +- **Parallel mode**: a Type-4 batch must send all Agent calls concurrently in the same message; serial batches run in order -### 诚实审计规则(Honesty Rules) +### Honesty Rules -- **禁止凭空发明**:图谱每条关系必须有组件文档明确依据,不得基于名称猜测 -- **置信度不得伪造**:EXTRACTED=1.0,INFERRED 按证据强度 0.4~0.9,AMBIGUOUS 0.1~0.3;禁用 0.5 默认值 -- **[UNVERIFIED] 不得隐藏**:超过 20% 则文档顶部加可见警告 -- **质量数字完整展示**:validate_kb.py 输出不得只展示通过项 -- **token 成本透明**:每批完成后展示读取文件数和估计 token 消耗 -- **不确定优先 AMBIGUOUS**:宁可标注待确认,也不删除或假装确定 +- **No invention out of thin air**: every relation in the graph must have an explicit basis in a component document; never guess from names +- **Confidence must not be faked**: EXTRACTED=1.0, INFERRED 0.4~0.9 by evidence strength, AMBIGUOUS 0.1~0.3; the 0.5 default is banned +- **[UNVERIFIED] must not be hidden**: above 20%, add a visible warning at the top of the document +- **Quality numbers shown in full**: validate_kb.py output must not show only the passing items +- **Token cost transparency**: after every batch, show the number of files read and the estimated token consumption +- **When unsure, prefer AMBIGUOUS**: better to mark as pending confirmation than to delete or pretend certainty --- -## 与 TeamAI CLI 的配合(必读) +## Working with the TeamAI CLI (must read) -| 阶段 | 命令 / 路径 | +| Phase | Command / path | |------|-------------| -| Phase 0 结构基线 | `teamai codebase --extract <repo> --project <slug>`(writes `<repo>/teamwiki/`) | +| Phase 0 structural baseline | `teamai codebase --extract <repo> --project <slug>` (writes `<repo>/teamwiki/`) | | Deep knowledge | Use `teamai codebase --deep-enrich --project <slug> --output <repo>` after extract has written `teamwiki/evidence/code/<slug>/`. `--output` is the repository root, not the `teamwiki/` directory. Prefix with `teamai --dry-run` to preview without writing. TeamAI does not ship a separate team-wiki CLI. No extra plugin is required. | -| K3 后编译进 wiki | Skip. TeamAI does not ship a separate team-wiki CLI. Continue with this skill using `teamai` and the files under this skill directory. No extra plugin is required. | -| 产品文档入图 | Skip. Same English note as above. | -| 产品↔代码桥接 | Use `teamai codebase --reconcile --output <repo>` after product pages and extracted code pages are under `<repo>/teamwiki/`. Prefix with `teamai --dry-run` to preview without updating the graph. | -| 一键刷新 | Use `teamai codebase --extract <repo> --project <slug> --incremental`, reusing the Phase 0 repository path and project slug even when running from another directory. Do not look for another CLI. | -| 质量评估 | Use `python3 {SKILL_DIR}/scripts/validate_kb.py <output_dir>` and `teamai codebase --lint --output <repo>` to check `<repo>/teamwiki/` (`--output` takes the repository root, not the `teamwiki/` directory). Skip any extra evaluate binary. | +| Compile into the wiki after K3 | Skip. TeamAI does not ship a separate team-wiki CLI. Continue with this skill using `teamai` and the files under this skill directory. No extra plugin is required. | +| Product docs into the graph | Skip. Same English note as above. | +| Product ↔ code bridging | Use `teamai codebase --reconcile --output <repo>` after product pages and extracted code pages are under `<repo>/teamwiki/`. Prefix with `teamai --dry-run` to preview without updating the graph. | +| One-shot refresh | Use `teamai codebase --extract <repo> --project <slug> --incremental`, reusing the Phase 0 repository path and project slug even when running from another directory. Do not look for another CLI. | +| Quality assessment | Use `python3 {SKILL_DIR}/scripts/validate_kb.py <output_dir>` and `teamai codebase --lint --output <repo>` to check `<repo>/teamwiki/` (`--output` takes the repository root, not the `teamwiki/` directory). Skip any extra evaluate binary. | -**路径约定**:`{SKILL_DIR}` 是 `teamai skill path wiki` 打印的目录,方法论在 `{SKILL_DIR}/references/methodology/`,子 agent 提示词在 `{SKILL_DIR}/references/agents/`,脚本在 `{SKILL_DIR}/scripts/`。 +**Path convention**: `{SKILL_DIR}` is the directory printed by `teamai skill path wiki`. The methodology is in `{SKILL_DIR}/references/methodology/`, sub-agent prompts in `{SKILL_DIR}/references/agents/`, and scripts in `{SKILL_DIR}/scripts/`. -所有流程在 `teamai skill get wiki` 提供的内容与 `teamai` CLI 内完成。No extra plugin is required. +The whole workflow runs within the content served by `teamai skill get wiki` and the `teamai` CLI. No extra plugin is required. diff --git a/skill-data/wiki/references/agents/graph-rag-agent.md b/skill-data/wiki/references/agents/graph-rag-agent.md index 458e3f84..b6130fa0 100644 --- a/skill-data/wiki/references/agents/graph-rag-agent.md +++ b/skill-data/wiki/references/agents/graph-rag-agent.md @@ -1,344 +1,344 @@ # Graph RAG Agent -## 职责 +## Responsibility -从已生成的知识库组件文档中抽取跨组件关系信息,生成结构化图谱文档集(G1~G9),解决 RAG 检索在"跨组件关系查询"场景下的信息分散问题。 +Extract cross-component relationship information from the generated knowledge base component documents and produce a structured graph document set (G1~G9), solving the information-scattering problem RAG retrieval faces in "cross-component relationship query" scenarios. -**此 Agent 在 Phase K3 中被主 Agent 单次串行启动。** +**This agent is started once, serially, by the main agent in Phase K3.** -## 输入包 +## Input package ``` -all_kb_docs_dir: 知识库输出根目录(包含所有 Type-1~8 文档) -architecture_map: _review/k1-architecture-map.md 完整内容 -doc_list: _review/k2-doc-list.md(文档清单) -project_name: 项目名称(用于文档命名) -output_dir: 图谱文档输出目录(<all_kb_docs_dir>/graph/) -methodology_file: {SKILL_DIR}/references/methodology/phase2-document-types.md §Type-9 内容 +all_kb_docs_dir: knowledge base output root directory (contains all Type-1~8 documents) +architecture_map: full content of _review/k1-architecture-map.md +doc_list: _review/k2-doc-list.md (document list) +project_name: project name (used for document naming) +output_dir: graph document output directory (<all_kb_docs_dir>/graph/) +methodology_file: {SKILL_DIR}/references/methodology/phase2-document-types.md, §Type-9 content ``` -## 执行步骤 +## Execution steps -### Step 1:关系抽取 +### Step 1: Relationship extraction -扫描 `all_kb_docs_dir` 下所有组件文档(Type-4),从 AI 快速理解表和正文中提取: +Scan all component documents (Type-4) under `all_kb_docs_dir` and extract from the AI Quick Reference table and the body: ``` -扫描维度: -├── 调用关系 (上游组件→本组件, 本组件→下游组件, 通信方式) -├── 存储依赖 (读写了哪些 DB/Redis/MQ) -├── 消息拓扑 (发布/消费的 Exchange/Topic/Queue/RoutingKey) -├── 状态流转 (操作→起始状态→中间状态→终态, 状态字段值) -├── 约束条件 (操作→前置状态要求→硬件约束→计费约束→配额) -├── 配置映射 (配置项→影响行为→变更风险) -└── 错误码归属 (错误码段→组件→排查方向) +Scan dimensions: +├── Call relationships (upstream component -> this component, this component -> downstream component, communication method) +├── Storage dependencies (which DB/Redis/MQ are read/written) +├── Message topology (published/consumed Exchange/Topic/Queue/RoutingKey) +├── State transitions (operation -> start state -> intermediate state -> final state, state field values) +├── Constraints (operation -> state prerequisites -> hardware constraints -> billing constraints -> quota) +├── Config mapping (config item -> affected behavior -> change risk) +└── Error code ownership (error code range -> component -> troubleshooting direction) ``` -**置信度三态标注**(每条关系/三元组必须标注,不得省略): +**Three-state confidence labelling** (every relationship/triple must be labelled, no omissions): -| 标签 | 含义 | 来源依据 | 置信度分值 | +| Label | Meaning | Evidence basis | Confidence score | |------|------|---------|-----------| -| `EXTRACTED` | 组件文档中明确描述的关系(如"上游组件: Aurora(RPC)")| 代码/文档显式记录 | 1.0 | -| `INFERRED` | 合理推断的关系(如架构图中隐含的依赖链)| 结构性证据 + 合理推断 | 0.6~0.9 | -| `AMBIGUOUS` | 存在不确定性的关系,需人工确认 | 弱证据或相互矛盾 | 0.1~0.3 | +| `EXTRACTED` | Relationship explicitly described in a component document (e.g. "Upstream component: Aurora(RPC)") | Explicitly recorded in code/docs | 1.0 | +| `INFERRED` | Reasonably inferred relationship (e.g. a dependency chain implied by an architecture diagram) | Structural evidence + reasonable inference | 0.6~0.9 | +| `AMBIGUOUS` | Uncertain relationship, needs manual confirmation | Weak or contradictory evidence | 0.1~0.3 | -> ⚠️ **禁止用 0.5 作为默认分值**。每条关系都要独立评估:有直接代码引用的 INFERRED 用 0.8~0.9,仅靠命名推断的用 0.6~0.7,真正模糊的才用 AMBIGUOUS。 +> ⚠️ **Never use 0.5 as a default score**. Evaluate every relationship independently: INFERRED with a direct code reference gets 0.8~0.9, inference based only on naming gets 0.6~0.7, and only genuinely unclear cases use AMBIGUOUS. -构建中间数据结构(内存,不写文件): -- `relations[]`:(from, to, protocol, scenario, **confidence: EXTRACTED|INFERRED|AMBIGUOUS**, **confidence_score: 0.1~1.0**) -- `state_transitions[]`:(entity, from_state, to_state, trigger_op, state_field_value, **confidence**, **confidence_score**) -- `constraints[]`:(operation, state_req, hardware_req, billing_req, quota_req, **confidence**, **confidence_score**) -- `config_items[]`:(key, default, component, behavior, change_risk, effect_mode) -- `error_codes[]`:(code_range, component, meaning, debug_direction) -- `triples[]`:(subject, predicate, object, protocol, scenario, **confidence: EXTRACTED|INFERRED|AMBIGUOUS**, **confidence_score: 0.1~1.0**) +Build intermediate data structures (in memory, do not write files): +- `relations[]`: (from, to, protocol, scenario, **confidence: EXTRACTED|INFERRED|AMBIGUOUS**, **confidence_score: 0.1~1.0**) +- `state_transitions[]`: (entity, from_state, to_state, trigger_op, state_field_value, **confidence**, **confidence_score**) +- `constraints[]`: (operation, state_req, hardware_req, billing_req, quota_req, **confidence**, **confidence_score**) +- `config_items[]`: (key, default, component, behavior, change_risk, effect_mode) +- `error_codes[]`: (code_range, component, meaning, debug_direction) +- `triples[]`: (subject, predicate, object, protocol, scenario, **confidence: EXTRACTED|INFERRED|AMBIGUOUS**, **confidence_score: 0.1~1.0**) -### Step 2:逐份生成图谱文档 +### Step 2: Generate graph documents one by one -按顺序生成 G1~G9(串行,每份完成后立即 Write): +Generate G1~G9 in order (serially, Write each one as soon as it is complete): --- -#### G1:组件依赖关系矩阵 +#### G1: Component Dependency Matrix ```markdown -# {project_name} 组件依赖关系矩阵 -<!-- search-anchor: 组件依赖, 依赖矩阵, 通信方式, 调用关系 --> -## 🤖 AI 快速理解要点 -| 文档定位 | 解决"谁依赖 X?X 依赖谁?"的检索问题 | -| 核心价值 | N×N 通信矩阵 + 正向/反向依赖索引 | -| 使用场景 | 变更影响评估、服务依赖梳理、架构重构规划 | +# {project_name} Component Dependency Matrix +<!-- search-anchor: component dependencies, dependency matrix, communication method, call relationships --> +## 🤖 AI Quick Reference +| Document scope | Answers the retrieval question "who depends on X? what does X depend on?" | +| Core value | N×N communication matrix + forward/reverse dependency index | +| Use cases | Change impact assessment, service dependency review, architecture refactoring planning | -## N×N 组件通信矩阵 -(行:调用方,列:被调方,值:`RPC`/`MQ`/`DB`/`—`,括号内标注置信度标签) -示例:`RPC[E]` = EXTRACTED,`MQ[I:0.8]` = INFERRED 0.8,`RPC[A]` = AMBIGUOUS +## N×N component communication matrix +(rows: caller, columns: callee, values: `RPC`/`MQ`/`DB`/`—`, confidence label in brackets) +Example: `RPC[E]` = EXTRACTED, `MQ[I:0.8]` = INFERRED 0.8, `RPC[A]` = AMBIGUOUS -## 正向依赖索引(A 依赖谁) -| 组件 | 依赖组件 | 通信方式 | 置信度 | 典型场景 | +## Forward dependency index (what A depends on) +| Component | Depends on | Communication method | Confidence | Typical scenario | -## 反向依赖索引(谁依赖 A) -| 组件 | 被依赖来自 | 通信方式 | 置信度 | 典型场景 | +## Reverse dependency index (who depends on A) +| Component | Depended on by | Communication method | Confidence | Typical scenario | -## 外部服务依赖 -| 外部服务 | 被哪些组件依赖 | 通信方式 | 置信度 | 降级策略 | +## External service dependencies +| External service | Depended on by which components | Communication method | Confidence | Degradation strategy | -## 置信度统计 -| 标签 | 条数 | 说明 | +## Confidence statistics +| Label | Count | Notes | |------|------|------| -| EXTRACTED | N | 来自代码/文档直接描述 | -| INFERRED | N | 合理推断,标注分值 0.6~0.9 | -| AMBIGUOUS | N | 不确定,需人工确认 | +| EXTRACTED | N | Directly described in code/docs | +| INFERRED | N | Reasonable inference, scored 0.6~0.9 | +| AMBIGUOUS | N | Uncertain, needs manual confirmation | ``` --- -#### G2:组件调用链路全景 + 状态机 +#### G2: Component Call Chain Overview + state machines ```markdown -# {project_name} 组件调用链路全景与状态机 -<!-- search-anchor: 调用链路, 状态机, 端到端链路, API链路 --> -## 🤖 AI 快速理解要点 -| 文档定位 | 解决"API X 经过哪些模块?实体状态如何流转?"的检索问题 | -| 核心价值 | 核心API端到端链路 + 完整状态机 + 操作-状态约束矩阵 | +# {project_name} Component Call Chain Overview and State Machines +<!-- search-anchor: call chain, state machine, end-to-end chain, API chain --> +## 🤖 AI Quick Reference +| Document scope | Answers the retrieval question "which modules does API X pass through? how do entity states transition?" | +| Core value | End-to-end chains of core APIs + complete state machines + operation-state constraint matrix | -## 核心 API 端到端调用链路 -(对每个核心 API,用标准调用链格式 + mermaid 时序图) +## Core API end-to-end call chains +(for each core API, use the standard call chain format + a mermaid sequence diagram) -## 核心实体完整状态机 -(mermaid stateDiagram-v2,标注状态字段值和触发操作) +## Complete state machines of core entities +(mermaid stateDiagram-v2, annotated with state field values and triggering operations) -## 操作-状态约束速查矩阵 -| 操作 \ 当前状态 | 状态A | 状态B | ... | -(✅ 允许 / ❌ 禁止 / ⚠️ 有条件) +## Operation-state constraint quick matrix +| Operation \ Current state | State A | State B | ... | +(✅ allowed / ❌ forbidden / ⚠️ conditional) -## AI 状态判断推理规则 -(mermaid graph TD 决策树) +## AI state-judgement reasoning rules +(mermaid graph TD decision tree) ``` --- -#### G3:数据流与存储依赖图 +#### G3: Data Flow and Storage Dependencies ```markdown -# {project_name} 数据流与存储依赖图 -<!-- search-anchor: 数据流, 存储依赖, MQ拓扑, 缓存 --> -## 存储系统依赖矩阵 -| 组件 | MySQL | Redis | MQ | 对象存储 | 其他 | +# {project_name} Data Flow and Storage Dependencies +<!-- search-anchor: data flow, storage dependencies, MQ topology, cache --> +## Storage system dependency matrix +| Component | MySQL | Redis | MQ | Object storage | Other | -## MQ 队列拓扑 -| Exchange/Topic | Routing Key | 生产者 | 消费者 | 消息含义 | +## MQ queue topology +| Exchange/Topic | Routing Key | Producer | Consumer | Message meaning | -## 缓存策略矩阵 -| 组件 | 缓存键模式 | 过期时间 | 失效策略 | +## Cache strategy matrix +| Component | Cache key pattern | TTL | Invalidation strategy | ``` --- -#### G4:错误码组件映射表 +#### G4: Error Code Component Map ```markdown -# {project_name} 错误码组件映射表 -<!-- search-anchor: 错误码, 错误映射, InvalidParameter --> -## 错误码段分配 -| 错误码范围/前缀 | 归属组件 | 含义范围 | +# {project_name} Error Code Component Map +<!-- search-anchor: error code, error mapping, InvalidParameter --> +## Error code range allocation +| Error code range/prefix | Owning component | Meaning scope | -## 外部→内部错误码映射 -| 外部错误码 | 内部组件 | 内部含义 | 排查方向 | +## External -> internal error code mapping +| External error code | Internal component | Internal meaning | Troubleshooting direction | ``` --- -#### G5:跨组件交互场景手册 +#### G5: Cross-Component Interaction Scenarios -对每个核心业务场景,生成: +For each core business scenario, generate: ```markdown -## 场景N:{场景名称} -<!-- 典型场景:创建/删除/修改资源、配额检查、计费、状态变更等 --> +## Scenario N: {scenario name} +<!-- typical scenarios: create/delete/modify resources, quota checks, billing, state changes, etc. --> ```mermaid sequenceDiagram actor User - participant A as {组件A} - participant B as {组件B} + participant A as {ComponentA} + participant B as {ComponentB} ... ``` -**正常流程**:步骤描述 -**异常处理**:各异常分支 +**Normal flow**: step descriptions +**Exception handling**: each exception branch ``` -要求:≥10 个场景,覆盖主要写操作和关键读操作。 +Requirement: >=10 scenarios, covering the main write operations and key read operations. --- -#### G6:知识图谱三元组 +#### G6: Knowledge Graph Triples ```markdown -# {project_name} 知识图谱三元组 -<!-- search-anchor: 知识图谱, 三元组, 多跳推理 --> +# {project_name} Knowledge Graph Triples +<!-- search-anchor: knowledge graph, triples, multi-hop reasoning --> -## Ontology 定义 -### 实体类型: Service, Handler, Config, Table, Queue, API, ErrorCode -### 关系类型: CALLS, PUBLISHES, CONSUMES, READS, WRITES, CONFIGURES, MAPS_TO +## Ontology definition +### Entity types: Service, Handler, Config, Table, Queue, API, ErrorCode +### Relationship types: CALLS, PUBLISHES, CONSUMES, READS, WRITES, CONFIGURES, MAPS_TO -## 显式三元组(≥100条) +## Explicit triples (>=100) | Subject | Predicate | Object | Protocol/Scenario | Confidence | Score | -> 每条三元组的 Confidence 必须是 `EXTRACTED` / `INFERRED` / `AMBIGUOUS`,Score 不得省略,不得用 0.5 作默认值。 +> Every triple's Confidence must be `EXTRACTED` / `INFERRED` / `AMBIGUOUS`; Score must not be omitted and must not default to 0.5. -## 多跳依赖路径索引 -| 查询模式 | 路径示例 | -| "A 最终写入哪些表?" | A→(CALLS)→B→(WRITES)→Table | +## Multi-hop dependency path index +| Query pattern | Example path | +| "Which tables does A ultimately write to?" | A→(CALLS)→B→(WRITES)→Table | -## 反向可达索引 -| 目标节点 | 可达路径 | +## Reverse reachability index +| Target node | Reachable paths | ``` --- -#### G7:架构风险与影响面分析 +#### G7: Architecture Risks and Impact Analysis ```markdown -# {project_name} 架构风险与影响面分析 -<!-- search-anchor: 架构风险, 爆炸半径, 影响面 --> -## 组件风险等级总表 -| 组件 | 风险等级 | 爆炸半径 | 备注 | -(🔴高/🟡中/🟢低) - -## 关键组件爆炸半径分析(≥3个高风险组件) -组件 X 故障时的影响链路分析 - -## 关键路径与瓶颈识别 -## 聚类分析(哪些组件形成强耦合簇) -## 变更风险评估矩阵 +# {project_name} Architecture Risks and Impact Analysis +<!-- search-anchor: architecture risk, blast radius, impact surface --> +## Component risk level summary +| Component | Risk level | Blast radius | Notes | +(🔴 high / 🟡 medium / 🟢 low) + +## Blast radius analysis of key components (>=3 high-risk components) +Impact chain analysis when component X fails + +## Critical paths and bottleneck identification +## Cluster analysis (which components form tightly coupled clusters) +## Change risk assessment matrix ``` --- -#### G8:核心配置参数索引 +#### G8: Core Config Parameter Index ```markdown -# {project_name} 核心配置参数索引 -<!-- search-anchor: 配置参数, 配置索引, 配置变更 --> -## 分层配置架构图(mermaid) - -## 各层配置参数表 -| 配置项 | 所属组件 | 默认值 | 影响行为 | 变更风险 | 生效方式 | -(变更风险: 🟢低/🟡中/🔴高;生效方式: 热生效/需重启) - -## 配置变更影响面速查 -| 变更类型 | 影响范围 | 生效方式 | 回滚策略 | - -## AI 回答"怎么修改 XX 配置"时必须同时告知: -1. 配置文件位置 -2. 影响范围 -3. 生效方式 -4. 回滚策略 -5. 变更风险 -6. 是否需要灰度 +# {project_name} Core Config Parameter Index +<!-- search-anchor: config parameters, config index, config changes --> +## Layered configuration architecture diagram (mermaid) + +## Config parameter tables per layer +| Config item | Owning component | Default | Affected behavior | Change risk | Effect mode | +(change risk: 🟢 low / 🟡 medium / 🔴 high; effect mode: hot reload / restart required) + +## Config change impact quick reference +| Change type | Impact scope | Effect mode | Rollback strategy | + +## When answering "how do I change config XX", the AI must always state: +1. Config file location +2. Impact scope +3. Effect mode +4. Rollback strategy +5. Change risk +6. Whether a canary rollout is needed ``` --- -#### G9:业务规则约束矩阵 +#### G9: Business Rule Constraint Matrix ```markdown -# {project_name} 业务规则约束矩阵 -<!-- search-anchor: 业务规则, 约束矩阵, 操作约束, AI推理 --> -## 操作前置条件矩阵 -| 操作 | 状态要求 | 硬件约束 | 计费约束 | 配额约束 | 其他约束 | +# {project_name} Business Rule Constraint Matrix +<!-- search-anchor: business rules, constraint matrix, operation constraints, AI reasoning --> +## Operation precondition matrix +| Operation | State requirement | Hardware constraint | Billing constraint | Quota constraint | Other constraints | -## 约束决策树(mermaid graph TD) -(覆盖主要操作的多层约束检查流程) +## Constraint decision tree (mermaid graph TD) +(covers the multi-layer constraint check flow of the main operations) -## 特殊实例类型约束汇总 -| 实例/资源类型 | 限制操作 | 原因 | -(✅允许 / ❌禁止 / ⚠️有条件) +## Special instance type constraint summary +| Instance/resource type | Restricted operations | Reason | +(✅ allowed / ❌ forbidden / ⚠️ conditional) -## AI 推理规则速查 -(mermaid 流程图:AI 判断"某操作能否执行"时的逐层检查顺序) +## AI reasoning rules quick reference +(mermaid flowchart: the layer-by-layer check order the AI follows when judging "can operation X be performed") ``` --- -### Step 3:生成图谱目录 README +### Step 3: Generate the graph directory README -写入 `{output_dir}/README.md`: +Write to `{output_dir}/README.md`: ```markdown -# {project_name} 图谱文档集 (Graph RAG) -<!-- search-anchor: 图谱文档, Graph RAG, 关系索引 --> - -## 与主文档体系的关系 -(图谱文档不替代组件文档,而是提供关系视角的结构化索引) - -## 文档目录 -| 文件 | 大小 | 核心内容 | - -## 按问题类型查找 -| 问题类型 | 示例问题 | 查找文档 | -| 依赖关系 | "谁依赖 X?" | G1 组件依赖关系矩阵 | -| 调用链路 | "API X 经过哪些模块?" | G2 调用链路全景 | -| 数据位置 | "数据存在哪里?" | G3 数据流与存储依赖图 | -| 错误排查 | "错误码 XXX 是哪个模块的?" | G4 错误码组件映射表 | -| 场景手册 | "配额检查的完整流程?" | G5 跨组件交互场景手册 | -| 多跳推理 | "A 间接依赖谁?" | G6 知识图谱三元组 | -| 风险评估 | "X 挂了影响多大?" | G7 架构风险与影响面 | -| 配置修改 | "怎么修改 XX 配置?" | G8 核心配置参数索引 | -| 操作约束 | "能不能做 XX?" | G9 业务规则约束矩阵 | - -## 检索路由规则建议 -(关键词 → 优先检索文档) - -## 维护说明 -(组件文档更新后需同步更新图谱文档的时机和范围) +# {project_name} Graph Document Set (Graph RAG) +<!-- search-anchor: graph documents, Graph RAG, relationship index --> + +## Relationship to the main document system +(graph documents do not replace component documents; they provide a structured index from the relationship perspective) + +## Document directory +| File | Size | Core content | + +## Look up by question type +| Question type | Example question | Document to consult | +| Dependencies | "Who depends on X?" | G1 Component Dependency Matrix | +| Call chains | "Which modules does API X pass through?" | G2 Call Chain Overview | +| Data location | "Where is the data stored?" | G3 Data Flow and Storage Dependencies | +| Error troubleshooting | "Which module does error code XXX belong to?" | G4 Error Code Component Map | +| Scenario handbook | "What is the full quota check flow?" | G5 Cross-Component Interaction Scenarios | +| Multi-hop reasoning | "What does A indirectly depend on?" | G6 Knowledge Graph Triples | +| Risk assessment | "How big is the impact if X goes down?" | G7 Architecture Risks and Impact Analysis | +| Config changes | "How do I change config XX?" | G8 Core Config Parameter Index | +| Operation constraints | "Can I do XX?" | G9 Business Rule Constraint Matrix | + +## Suggested retrieval routing rules +(keyword -> document to search first) + +## Maintenance notes +(when and how far graph documents must be updated after component documents change) ``` -### Step 4:返回摘要 +### Step 4: Return summary ``` -Graph RAG 生成完成: -生成文档: G1~G9 共 9 份 + README - - G1_组件依赖关系矩阵.md: {N}KB,{N}个组件,{N}条关系 - 置信度: EXTRACTED {N} / INFERRED {N} / AMBIGUOUS {N} - - G2_组件调用链路全景.md: {N}KB,{N}条调用链,状态机{N}个状态 - - G3_数据流与存储依赖图.md: {N}KB - - G4_错误码组件映射表.md: {N}KB,{N}段错误码 - - G5_跨组件交互场景手册.md: {N}KB,{N}个场景时序图 - - G6_知识图谱三元组.md: {N}KB,{N}条三元组 - 置信度: EXTRACTED {N} / INFERRED {N} / AMBIGUOUS {N} - - G7_架构风险与影响面分析.md: {N}KB - - G8_核心配置参数索引.md: {N}KB,{N}个配置项 - - G9_业务规则约束矩阵.md: {N}KB -AMBIGUOUS 条目汇总(需人工确认): {N} 处 - - 示例: "Aurora→Compute 通信方式不确定(文档未明确)[A:0.2]" -发现问题: {问题 或 "无"} - -⚠️ 主 Agent 请注意:Graph RAG 完成后,请立即执行 Phase K3 Step 3(跨文档一致性校验)。 +Graph RAG generation complete: +Generated documents: G1~G9, 9 in total + README + - G1_Component_Dependency_Matrix.md: {N}KB, {N} components, {N} relationships + Confidence: EXTRACTED {N} / INFERRED {N} / AMBIGUOUS {N} + - G2_Component_Call_Chain_Overview.md: {N}KB, {N} call chains, {N} state machine states + - G3_Data_Flow_and_Storage_Dependencies.md: {N}KB + - G4_Error_Code_Component_Map.md: {N}KB, {N} error code ranges + - G5_Cross_Component_Interaction_Scenarios.md: {N}KB, {N} scenario sequence diagrams + - G6_Knowledge_Graph_Triples.md: {N}KB, {N} triples + Confidence: EXTRACTED {N} / INFERRED {N} / AMBIGUOUS {N} + - G7_Architecture_Risks_and_Impact_Analysis.md: {N}KB + - G8_Core_Config_Parameter_Index.md: {N}KB, {N} config items + - G9_Business_Rule_Constraint_Matrix.md: {N}KB +AMBIGUOUS entries summary (need manual confirmation): {N} places + - Example: "Aurora→Compute communication method uncertain (not specified in docs) [A:0.2]" +Issues found: {issues or "none"} + +⚠️ Note to the main agent: once Graph RAG is complete, immediately run Phase K3 Step 3 (cross-document consistency check). ``` -## 输出 +## Output ``` <output_dir>/README.md -<output_dir>/G1_{project_name}组件依赖关系矩阵.md -<output_dir>/G2_{project_name}组件调用链路全景.md -<output_dir>/G3_{project_name}数据流与存储依赖图.md -<output_dir>/G4_{project_name}错误码组件映射表.md -<output_dir>/G5_{project_name}跨组件交互场景手册.md -<output_dir>/G6_{project_name}知识图谱三元组.md -<output_dir>/G7_{project_name}架构风险与影响面分析.md -<output_dir>/G8_{project_name}核心配置参数索引.md -<output_dir>/G9_{project_name}业务规则约束矩阵.md -返回摘要字符串 +<output_dir>/G1_{project_name}_Component_Dependency_Matrix.md +<output_dir>/G2_{project_name}_Component_Call_Chain_Overview.md +<output_dir>/G3_{project_name}_Data_Flow_and_Storage_Dependencies.md +<output_dir>/G4_{project_name}_Error_Code_Component_Map.md +<output_dir>/G5_{project_name}_Cross_Component_Interaction_Scenarios.md +<output_dir>/G6_{project_name}_Knowledge_Graph_Triples.md +<output_dir>/G7_{project_name}_Architecture_Risks_and_Impact_Analysis.md +<output_dir>/G8_{project_name}_Core_Config_Parameter_Index.md +<output_dir>/G9_{project_name}_Business_Rule_Constraint_Matrix.md +Returned summary string ``` -## 约束 - -- **关系抽取以组件文档为唯一来源**:不直接读原始代码,防止与 Phase K2 产出不一致 -- **置信度三态强制**:每条关系/三元组必须标注 `EXTRACTED`/`INFERRED`/`AMBIGUOUS`,不得省略 -- **禁止用 0.5 作置信度默认值**:每条关系独立评估分值;INFERRED 直接结构证据 0.8~0.9,命名推断 0.6~0.7,弱证据 0.4~0.5;AMBIGUOUS 用 0.1~0.3 -- **禁止凭空发明关系**:若组件文档无依据,宁可标 AMBIGUOUS 也不捏造 EXTRACTED -- **每份图谱文档必须有 AI 快速理解要点表** -- **每份图谱文档必须有 search-anchor** -- **图谱文档不替代组件文档**:只提供关系视角的结构化索引 -- **状态机必须使用 mermaid stateDiagram-v2** -- **约束决策树必须使用 mermaid graph TD** -- **三元组必须遵循 (Subject, Predicate, Object, Confidence, Score) 格式** -- **操作-状态约束必须是 ✅/❌/⚠️ 矩阵格式** +## Constraints + +- **Component documents are the sole source for relationship extraction**: do not read the raw code directly, to avoid inconsistency with the Phase K2 output +- **Three-state confidence is mandatory**: every relationship/triple must be labelled `EXTRACTED`/`INFERRED`/`AMBIGUOUS`, no omissions +- **Never use 0.5 as the default confidence**: score every relationship independently; INFERRED with direct structural evidence 0.8~0.9, naming-based inference 0.6~0.7, weak evidence 0.4~0.5; AMBIGUOUS uses 0.1~0.3 +- **Never invent relationships**: if the component documents provide no basis, label it AMBIGUOUS rather than fabricating EXTRACTED +- **Every graph document must have an AI Quick Reference table** +- **Every graph document must have a search-anchor** +- **Graph documents do not replace component documents**: they only provide a structured index from the relationship perspective +- **State machines must use mermaid stateDiagram-v2** +- **Constraint decision trees must use mermaid graph TD** +- **Triples must follow the (Subject, Predicate, Object, Confidence, Score) format** +- **Operation-state constraints must be in ✅/❌/⚠️ matrix format** diff --git a/skill-data/wiki/references/agents/kb-doc-generator.md b/skill-data/wiki/references/agents/kb-doc-generator.md index 22ed3d7d..d02d19df 100644 --- a/skill-data/wiki/references/agents/kb-doc-generator.md +++ b/skill-data/wiki/references/agents/kb-doc-generator.md @@ -1,323 +1,323 @@ -# 知识库文档生成 Agent +# Knowledge Base Document Generator Agent -## 职责 +## Responsibility -为指定批次的组件/文档类型生成知识库文档,严格遵循九大文档类型规范,确保代码可回溯、AI 快速理解表完整、双向链接织网。 +Generate knowledge base documents for the assigned batch of components/document types, strictly following the nine document type specifications, ensuring code traceability, complete AI Quick Reference tables, and a web of bidirectional links. -**此 Agent 在 Phase K2 中被主 Agent 逐批启动,支持并行子 Agent 分发模式。** +**This agent is started batch by batch by the main agent in Phase K2 and supports the parallel sub-agent dispatch mode.** -## 输入包 +## Input package ``` -component_list: 本批次待生成的组件名或文档类型列表 - 例如: ["Aurora", "Frame", "CCDB", "Dispatcher"] 或 ["Type-1", "Type-2", "Type-3"] -architecture_map: _review/k1-architecture-map.md 完整内容 -repos: 仓库列表([{name, path, language}]),替代旧的 project_root -service_map: 服务名→仓库映射表(用于跨仓库追踪调用链) -output_dir: 知识库输出根目录 -project_name: 项目名称(用于文档命名,如 "CVM") -product_docs_dir: 产品文档目录(可为空,空则跳过产品约束提取) -methodology_dir: {SKILL_DIR}/references/methodology/ 目录路径 -completed_docs: 已完成的文档列表(断点恢复时跳过) -parallel_mode: true | false(默认 true;Type-4 组件文档并行,Type-1~3/5~8 串行) +component_list: list of component names or document types to generate in this batch + e.g. ["Aurora", "Frame", "CCDB", "Dispatcher"] or ["Type-1", "Type-2", "Type-3"] +architecture_map: full content of _review/k1-architecture-map.md +repos: repository list ([{name, path, language}]), replaces the old project_root +service_map: service name -> repository map (used to trace call chains across repositories) +output_dir: knowledge base output root directory +project_name: project name (used for document naming, e.g. "CVM") +product_docs_dir: product documentation directory (may be empty; if empty, skip product constraint extraction) +methodology_dir: {SKILL_DIR}/references/methodology/ directory path +completed_docs: list of already completed documents (skipped when resuming from checkpoint) +parallel_mode: true | false (default true; Type-4 component documents in parallel, Type-1~3/5~8 serially) ``` -## 执行步骤 +## Execution steps -### Step 0:加载方法论 +### Step 0: Load the methodology -读取 `{methodology_dir}/phase2-document-types.md`,加载对应文档类型的模板和生成规则。 +Read `{methodology_dir}/phase2-document-types.md` and load the templates and generation rules for the relevant document types. -### Step 1:断点检查 +### Step 1: Checkpoint check -检查 `completed_docs` 列表,从 `component_list` 中移除已完成项,得到 `pending_list`。 +Check the `completed_docs` list, remove completed items from `component_list`, and obtain `pending_list`. -若 `pending_list` 为空,直接返回"全部已完成"摘要,不做任何操作。 +If `pending_list` is empty, return an "all completed" summary immediately and perform no other action. -### Step 2:分发策略决策 +### Step 2: Dispatch strategy decision ``` -IF component_list 全为 Type-4 组件文档 AND parallel_mode = true: - → 并行模式(Step 2A) -ELSE(Type-1/2/3/5/6/7/8 或 parallel_mode = false): - → 串行模式(Step 2B) +IF component_list consists only of Type-4 component documents AND parallel_mode = true: + → parallel mode (Step 2A) +ELSE (Type-1/2/3/5/6/7/8 or parallel_mode = false): + → serial mode (Step 2B) ``` -### Step 2A:并行模式(Type-4 组件文档) +### Step 2A: Parallel mode (Type-4 component documents) -**MANDATORY:必须使用 Agent tool,禁止一个个顺序处理。** +**MANDATORY: you must use the Agent tool; processing components one by one in sequence is forbidden.** -**Step 2A-1:分块** +**Step 2A-1: Chunking** -将 `pending_list` 分成若干块,每块 **3~5 个组件**(组件文档较大,不超过 5 个避免上下文溢出)。 -- 优先把同一架构层的组件放同一块(减少跨层代码读取竞争) -- 已完成的跳过(断点恢复) +Split `pending_list` into chunks of **3~5 components** each (component documents are large; do not exceed 5 to avoid context overflow). +- Prefer placing components from the same architecture layer in the same chunk (reduces cross-layer code reading contention) +- Skip completed ones (resume from checkpoint) -**Step 2A-2:同一条消息并发启动所有子 Agent** +**Step 2A-2: Start all sub-agents concurrently in a single message** -**在同一次回复中发出所有 Agent tool 调用**。这是并行的唯一方式——分开多次调用则退化为串行。 +**Issue all Agent tool calls in the same reply**. This is the only way to run in parallel; issuing them in separate calls degrades to serial execution. -示例(3块并发): +Example (3 chunks concurrently): ``` [Agent tool call 1: chunk ["Aurora", "Frame"], subagent_type="general-purpose"] [Agent tool call 2: chunk ["CCDB", "VSResource"], subagent_type="general-purpose"] [Agent tool call 3: chunk ["Dispatcher", "Compute"], subagent_type="general-purpose"] ``` -每个子 Agent 接收以下 prompt(替换 CHUNK_COMPONENTS、CHUNK_NUM、TOTAL_CHUNKS): +Each sub-agent receives the following prompt (replace CHUNK_COMPONENTS, CHUNK_NUM, TOTAL_CHUNKS): ``` -你是 team-wiki-codebase 的组件文档生成子 Agent。 -为以下组件生成知识库文档(chunk CHUNK_NUM / TOTAL_CHUNKS): +You are the component document generation sub-agent of team-wiki-codebase. +Generate knowledge base documents for the following components (chunk CHUNK_NUM / TOTAL_CHUNKS): CHUNK_COMPONENTS -架构参考(精简版,仅含本 chunk 相关组件及其直接上下游): +Architecture reference (condensed; only the components in this chunk and their direct upstream/downstream): RELEVANT_COMPONENTS_TABLE -(格式:| 组件名 | 架构层级 | 所属仓库 | 语言 | 上游 | 下游 | 入口文件 |) +(format: | Component | Architecture layer | Repository | Language | Upstream | Downstream | Entry file |) -服务映射表(用于跨仓库追踪): +Service map (for cross-repository tracing): SERVICE_MAP_RELEVANT_ENTRIES -项目信息: -- repos: REPO_LIST(仅列路径,不列详情) +Project information: +- repos: REPO_LIST (paths only, no details) - output_dir: OUTPUT_DIR - project_name: PROJECT_NAME -- product_docs_dir: PRODUCT_DOCS_DIR(空则跳过产品约束) +- product_docs_dir: PRODUCT_DOCS_DIR (if empty, skip product constraints) -方法论路径: METHODOLOGY_DIR/phase2-document-types.md +Methodology path: METHODOLOGY_DIR/phase2-document-types.md -对每个组件执行: -1. 使用 Glob→Grep→Read 三步法扫描代码(参见 kb-doc-generator.md §Step 2:代码结构扫描规范) -2. 提取:核心职责/架构层级/上下游/代码入口/核心机制/数据流向/技术栈/数据模型/配置项 -3. 生成符合 Type-4 模板的文档,Write 到 OUTPUT_DIR/XX_组件名设计说明.md -4. 自校验(见下方 Checklist) -5. 将完成的组件名写入 OUTPUT_DIR/../_review/_chunk_done_CHUNK_NUM.txt(每行一个) +For each component: +1. Scan the code with the Glob→Grep→Read three-step method (see kb-doc-generator.md §Step 2: Code structure scanning rules) +2. Extract: core responsibility / architecture layer / upstream and downstream / code entry / core mechanisms / data flow / tech stack / data model / config items +3. Generate a document that follows the Type-4 template and Write it to OUTPUT_DIR/XX_{component}_Design.md +4. Self-check (see the Checklist below) +5. Append each completed component name to OUTPUT_DIR/../_review/_chunk_done_CHUNK_NUM.txt (one per line) -自校验 Checklist(每份文档生成后): -- [ ] AI 快速理解表 10 维度全部填写且具体(非泛泛描述)? -- [ ] "代码入口"精确到函数名(不是仅文件名)? -- [ ] search-anchor 有 5~15 个关键词? -- [ ] 包含指向主架构文档的双向链接? -- [ ] 无法回溯的内容已标注 [UNVERIFIED]? -- [ ] 无空占位章节? +Self-check Checklist (after each document is generated): +- [ ] All 10 dimensions of the AI Quick Reference table filled in and specific (not generic descriptions)? +- [ ] "Code entry" precise to the function name (not just the file name)? +- [ ] search-anchor has 5~15 keywords? +- [ ] Contains a bidirectional link to the main architecture document? +- [ ] Content that cannot be traced is marked [UNVERIFIED]? +- [ ] No empty placeholder sections? -[UNVERIFIED] 超过 20% → 文档顶部加 ⚠️ 低可信度警告。 +[UNVERIFIED] above 20% → add a ⚠️ low-confidence warning at the top of the document. -无法生成的组件写入 OUTPUT_DIR/../_review/_chunk_failed_CHUNK_NUM.txt 并注明原因。 +Write components that could not be generated to OUTPUT_DIR/../_review/_chunk_failed_CHUNK_NUM.txt with the reason. ``` -**Step 2A-3:等待并收集结果** +**Step 2A-3: Wait and collect results** -等待所有子 Agent 完成后: -- 检查 `_chunk_done_N.txt` 文件确认完成情况 -- 若某块 `_chunk_done_N.txt` 不存在,打印警告:`chunk N 可能未完成,检查子 Agent 是否以 general-purpose 类型运行` -- 若超过半数块失败,停止并告知用户重新运行 -- 将所有已完成组件合并到 `progress.json` 的 `kb_progress.components_done` -- 清理临时文件:`rm -f _review/_chunk_done_*.txt _review/_chunk_failed_*.txt` +After all sub-agents finish: +- Check the `_chunk_done_N.txt` files to confirm completion status +- If `_chunk_done_N.txt` is missing for a chunk, print a warning: `chunk N may not have completed; check whether the sub-agent ran as the general-purpose type` +- If more than half of the chunks failed, stop and tell the user to rerun +- Merge all completed components into `kb_progress.components_done` in `progress.json` +- Clean up temporary files: `rm -f _review/_chunk_done_*.txt _review/_chunk_failed_*.txt` -### Step 2B:串行模式(Type-1~3/5~8) +### Step 2B: Serial mode (Type-1~3/5~8) -对 `pending_list` 中每个文档类型**顺序执行**(这些文档类型相互依赖,必须串行): +For each document type in `pending_list`, execute **in sequence** (these document types depend on each other and must be serial): -#### 2B-1:代码结构扫描规范 +#### 2B-1: Code structure scanning rules -使用 `Glob → Grep → Read` 三步法(**按组件所属仓库的语言自适应**): +Use the `Glob → Grep → Read` three-step method (**adapt to the language of the component's repository**): ``` -1. Glob:找到组件对应仓库的入口文件(按语言选择模式) +1. Glob: find the entry files of the component's repository (choose the pattern by language) Go: main.go / cmd/*/main.go Python: main.py / app.py / manage.py / wsgi.py Java: *Application.java / *Bootstrap.java / src/main/java/**/Main*.java TypeScript: app.ts / index.ts / main.ts / server.ts Rust: main.rs / src/main.rs -2. Grep:定位核心 Handler/Router(按语言+框架选择模式) +2. Grep: locate the core Handlers/Routers (choose the pattern by language + framework) Go: grep -rn 'func.*Handler\|\.GET\|\.POST\|router\.\|@handler' <dir> Python: grep -rn '@app\.\|@router\.\|def.*view\|APIRouter\|include_router' <dir> Java: grep -rn '@RestController\|@Controller\|@Service\|@GetMapping\|@PostMapping\|@RequestMapping' <dir> TypeScript: grep -rn 'app\.get\|app\.post\|router\.\|@Get\|@Post\|@Controller' <dir> Rust: grep -rn '\.route\|\.get\|\.post\|#\[get\|#\[post\|async fn' <dir> - ⚠️ 排除测试文件:--exclude='*_test.*' --exclude='test_*' --exclude='*_mock.*' + ⚠️ Exclude test files: --exclude='*_test.*' --exclude='test_*' --exclude='*_mock.*' -3. Read:读取核心文件(按 architecture_map 中的目录价值分级) - - ⭐⭐⭐ 必读:业务逻辑层、核心配置文件、DDL - - ⭐⭐ 参考:服务上下文初始化、配置文件 - - ⭐ 可跳过:纯绑定层(通常只是参数透传) - - ✗ 禁止:自动生成文件(*.pb.go, *_gen.go, *_generated.*, node_modules/, target/, build/) +3. Read: read the core files (by the directory value rating in architecture_map) + - ⭐⭐⭐ Must read: business logic layer, core config files, DDL + - ⭐⭐ Reference: service context initialisation, config files + - ⭐ Skippable: pure binding layers (usually just parameter pass-through) + - ✗ Forbidden: generated files (*.pb.go, *_gen.go, *_generated.*, node_modules/, target/, build/) ``` -提取信息(**全部必须有代码文件:行号引用,不得推断**): -- 核心职责(一句话,≤30字) -- 架构层级和上下游组件(通信方式:RPC/MQ/DB) -- 代码入口(文件名 → 核心函数名) -- 核心机制(最重要的1~2个技术机制) -- 数据流向(从哪来 → 经过什么 → 到哪去) -- 技术栈(语言 + 框架 + 中间件) -- 数据模型(涉及的表名 + DDL 关键字段) -- 核心流程(时序图所需的步骤) -- 配置项(配置键 + 默认值 + 影响范围) -- 定时任务(如有) -- 监控指标(如有) +Extract the following information (**everything must cite a code file:line, no inference**): +- Core responsibility (one sentence, <=30 words) +- Architecture layer and upstream/downstream components (communication method: RPC/MQ/DB) +- Code entry (file name -> core function name) +- Core mechanisms (the 1~2 most important technical mechanisms) +- Data flow (where from -> what it passes through -> where to) +- Tech stack (language + framework + middleware) +- Data model (tables involved + key DDL fields) +- Core flows (the steps needed for sequence diagrams) +- Config items (config key + default value + impact scope) +- Scheduled tasks (if any) +- Monitoring metrics (if any) -无法从代码中找到的内容标注 `[UNVERIFIED]`,不得推断。 +Mark content that cannot be found in the code as `[UNVERIFIED]`; do not infer. -#### 2B-2:产品文档提取(Type-5/6/7,或有 product_docs_dir 时) +#### 2B-2: Product documentation extraction (Type-5/6/7, or when product_docs_dir is set) -若 `product_docs_dir` 非空: +If `product_docs_dir` is not empty: ``` -扫描维度(来自 phase2-document-types.md §Type-5 桥梁文档生成方法): -├── 数量限制(批量上限、配额、最大值) -├── 类型约束(枚举值、互斥关系) -├── 状态前置条件 -├── 计费规则 -├── 安全约束 -└── 兼容性约束 +Scan dimensions (from phase2-document-types.md §Type-5 bridge document generation method): +├── Quantity limits (batch caps, quotas, maximums) +├── Type constraints (enum values, mutual exclusions) +├── State preconditions +├── Billing rules +├── Security constraints +└── Compatibility constraints ``` -将每个产品约束追踪到代码校验位置(`if len() > N` 的具体文件:行号)。 +Trace every product constraint to its validation location in the code (the exact file:line of the `if len() > N`). -#### 2B-3:文档生成 +#### 2B-3: Document generation -按照 `phase2-document-types.md` 中对应类型的模板生成文档。 +Generate documents following the template for the corresponding type in `phase2-document-types.md`. -**Type-4 组件文档必须包含(按顺序)**: +**Type-4 component documents must contain (in order)**: ```markdown -# {组件名} 内部设计说明 -<!-- search-anchor: {中文名}, {英文名}, {缩写}, {同义词}, {常见搜索词} --> -> 项目: {project_name} | 代码仓库: {仓库URL} | 架构层级: {层级} -> 在整体架构中的位置: [📘 {project_name} 技术架构 - 4.X {组件名}](./{project_name} 技术架构.md#4x-组件名) +# {component} Internal Design +<!-- search-anchor: {full name}, {short name}, {abbreviation}, {synonyms}, {common search terms} --> +> Project: {project_name} | Repository: {repo URL} | Architecture layer: {layer} +> Position in the overall architecture: [📘 {project_name} Technical Architecture - 4.X {component}](./{project_name} Technical Architecture.md#4x-component) -## 🤖 AI 快速理解要点 -| 维度 | 关键信息 | +## 🤖 AI Quick Reference +| Dimension | Key information | |------|---------| -| **核心职责** | {≤30字,具体} | -| **架构层级** | {层级名} → {角色} | -| **上游组件** | {组件A(RPC)}, {组件B(MQ)} | -| **下游组件** | {组件C(RPC)}, {组件D(DB)} | -| **代码入口** | `{文件名}` → `{核心函数名}()` | -| **核心机制** | {机制1};{机制2} | -| **互斥控制** | {并发控制方式,如"分布式锁 key: xx"} | -| **数据流向** | {来源} → {处理} → {去向} | -| **技术栈** | {语言} + {框架} + {中间件} | -| **定时任务** | {N个定时任务,或"无"} | +| **Core responsibility** | {<=30 words, specific} | +| **Architecture layer** | {layer name} → {role} | +| **Upstream components** | {ComponentA(RPC)}, {ComponentB(MQ)} | +| **Downstream components** | {ComponentC(RPC)}, {ComponentD(DB)} | +| **Code entry** | `{file name}` → `{core function name}()` | +| **Core mechanisms** | {mechanism 1}; {mechanism 2} | +| **Mutual exclusion** | {concurrency control method, e.g. "distributed lock key: xx"} | +| **Data flow** | {source} → {processing} → {destination} | +| **Tech stack** | {language} + {framework} + {middleware} | +| **Scheduled tasks** | {N scheduled tasks, or "none"} | -## 📋 项目概述 -(核心职责编号列表 + ASCII 架构定位图) +## 📋 Project Overview +(numbered list of core responsibilities + ASCII architecture position diagram) -## 🏗️ 架构设计 -(ASCII 架构图 + 核心子模块说明 + 核心函数签名) +## 🏗️ Architecture Design +(ASCII architecture diagram + core sub-module descriptions + core function signatures) -## 📊 数据模型 -(SQL DDL 含注释 + 数据流向图) +## 📊 Data Model +(SQL DDL with comments + data flow diagram) -## 🔌 接口设计 -(对外/对内接口表 + 错误码定义) +## 🔌 Interface Design +(external/internal interface tables + error code definitions) -## ⚙️ 核心流程 -(mermaid 时序图 + 步骤说明 + 异常处理) +## ⚙️ Core Flows +(mermaid sequence diagrams + step descriptions + exception handling) -## 🔧 配置说明 -(配置项 / 默认值 / 说明 / 影响范围) +## 🔧 Configuration +(config item / default value / description / impact scope) -## 📈 监控与告警 +## 📈 Monitoring and Alerting -## 🐛 常见问题与排障 +## 🐛 Common Issues and Troubleshooting -## 📝 文档更新记录 -### v1.0 ({日期}) -- ✅ **新增**: 初始版本 -> 代码基准:{commit_sha} ({tag}) +## 📝 Document Change Log +### v1.0 ({date}) +- ✅ **Added**: initial version +> Code baseline: {commit_sha} ({tag}) ``` -**所有文档 Write 到 `output_dir` 下,禁止先在对话中打印完整内容再写文件。** +**Write all documents under `output_dir`; printing the full content in the conversation before writing the file is forbidden.** -### Step 3:自校验(准确性验证 + 接口对账) +### Step 3: Self-check (accuracy verification + interface reconciliation) -每份文档生成后执行,**不得跳过**: +Run after each document is generated; **must not be skipped**: -**结构完整性**: -- [ ] AI 快速理解表 10 个维度全部填写,且每个维度都是具体信息(不是"见下文")? -- [ ] "代码入口"精确到函数名(`文件名:行号 → 函数名()`)? -- [ ] search-anchor 有 5~15 个关键词,包含中英文名和同义词? -- [ ] 包含指向主架构文档的双向链接? -- [ ] 无空占位章节(没有内容的章节直接删除)? +**Structural completeness**: +- [ ] All 10 dimensions of the AI Quick Reference table filled in, each with specific information (not "see below")? +- [ ] "Code entry" precise to the function name (`file name:line → function()`)? +- [ ] search-anchor has 5~15 keywords, including full and short names and synonyms? +- [ ] Contains a bidirectional link to the main architecture document? +- [ ] No empty placeholder sections (delete sections with no content)? -**接口对账**(仅对 architecture_map 中接口校验类型 ≠ NONE 的组件执行): +**Interface reconciliation** (only for components whose interface verification type in architecture_map is not NONE): -从 `_review/interface-inventory.json` 读取该组件的扫描基准数 `scanned`,统计文档中实际记录的接口数 `documented`: +Read the component's scanned baseline count `scanned` from `_review/interface-inventory.json` and count the interfaces actually recorded in the document as `documented`: ``` -HTTP 类型: 统计文档 ## 接口设计 节中列出的路由数 -MQ 类型: 统计文档中明确记录的 Topic/Queue/Exchange 数 -RPC 类型: 统计文档中列出的 RPC Method 数 +HTTP type: count the routes listed in the document's ## Interface Design section +MQ type: count the Topics/Queues/Exchanges explicitly recorded in the document +RPC type: count the RPC Methods listed in the document ``` -计算差异:`gap = scanned - documented` +Compute the difference: `gap = scanned - documented` -处理规则: -- `gap = 0` → ✅ 接口覆盖完整 -- `0 < gap ≤ 20%` → ⚠️ 少量缺口,在文档末尾加 `<!-- INTERFACE_GAP: 疑似遗漏 N 个接口 -->` -- `gap > 20%` → ❌ 标记 `[INTERFACE_GAP]`,在摘要中注明,建议补充后重跑 +Handling rules: +- `gap = 0` → ✅ interface coverage complete +- `0 < gap <= 20%` → ⚠️ minor gap, append `<!-- INTERFACE_GAP: N interfaces possibly missing -->` at the end of the document +- `gap > 20%` → ❌ mark `[INTERFACE_GAP]`, note it in the summary, recommend supplementing and rerunning -更新 `progress.json` 中该组件的 `interface_coverage.documented` 字段。 +Update the component's `interface_coverage.documented` field in `progress.json`. -**准确性统计**(每份文档单独统计,返回给主 Agent 汇总): +**Accuracy statistics** (computed per document and returned to the main agent for aggregation): ``` -统计方法: - total_claims = 业务规则条数 + 核心流程步骤数 + 接口描述条数 + 配置项条数 - verified = 其中有 file:line 引用的条数 - unverified = 标注了 [UNVERIFIED] 的条数 +Method: + total_claims = business rule count + core flow step count + interface description count + config item count + verified = those with a file:line reference + unverified = those marked [UNVERIFIED] ratio = unverified / total_claims ``` -处理规则: -- `ratio > 20%` → 文档顶部加 `⚠️ 低可信度警告:{unverified}/{total_claims} 项无法回溯到代码` -- `ratio > 40%` → 摘要中标记 **[HIGH_UNVERIFIED]**,建议人工重点确认 +Handling rules: +- `ratio > 20%` → add `⚠️ Low-confidence warning: {unverified}/{total_claims} items cannot be traced to code` at the top of the document +- `ratio > 40%` → mark **[HIGH_UNVERIFIED]** in the summary and recommend focused manual confirmation -### Step 4:返回摘要 +### Step 4: Return summary -返回给主 Agent(主 Agent 将数据累加到 progress.json 的 `accuracy_stats` 和 `interface_coverage`): +Return to the main agent (the main agent accumulates the data into `accuracy_stats` and `interface_coverage` in progress.json): ``` -批次完成摘要: -读取文件: {N} 个(估计 token 消耗: ~{N}k) -生成文档: {N} 份 +Batch completion summary: +Files read: {N} (estimated token usage: ~{N}k) +Documents generated: {N} -准确性统计: - 总声明数: {N} | 已验证: {N} | [UNVERIFIED]: {N} ({X}%) +Accuracy statistics: + Total claims: {N} | Verified: {N} | [UNVERIFIED]: {N} ({X}%) -接口对账(仅有接口的组件): - ComponentA [HTTP]: 文档 {M} / 基准 {N} = {X}% ✅/⚠️/❌ - ComponentB [MQ]: 文档 {M} / 基准 {N} = {X}% ✅/⚠️/❌ +Interface reconciliation (components with interfaces only): + ComponentA [HTTP]: documented {M} / baseline {N} = {X}% ✅/⚠️/❌ + ComponentB [MQ]: documented {M} / baseline {N} = {X}% ✅/⚠️/❌ -逐文档明细: - - {组件名}设计说明.md: {N}KB,声明{N}条,[UNVERIFIED]{N}条({X}%) [HIGH_UNVERIFIED/INTERFACE_GAP 如适用] +Per-document details: + - {component}_Design.md: {N}KB, {N} claims, [UNVERIFIED] {N} ({X}%) [HIGH_UNVERIFIED/INTERFACE_GAP if applicable] -跳过(已完成): {N} 份 -发现问题: {问题描述 或 "无"} +Skipped (already completed): {N} +Issues found: {issue description or "none"} ``` -## 输出 +## Output ``` -<output_dir>/XX_{组件名}设计说明.md ← Type-4 组件文档 -<output_dir>/{project_name} 技术架构.md ← Type-1(如本批次包含) -<output_dir>/{project_name} 业务架构.md ← Type-2 -<output_dir>/{project_name} 部署架构.md ← Type-3 -<output_dir>/XX_{project_name}核心API产品代码映射.md ← Type-5 -<output_dir>/XX_{project_name}产品规则速查表.md ← Type-6 -<output_dir>/XX_{project_name}业务开发规范SOP.md ← Type-7 -<output_dir>/{知识增强文档}.md ← Type-8 -返回摘要字符串 +<output_dir>/XX_{component}_Design.md ← Type-4 component document +<output_dir>/{project_name} Technical Architecture.md ← Type-1 (if included in this batch) +<output_dir>/{project_name} Business Architecture.md ← Type-2 +<output_dir>/{project_name} Deployment Architecture.md ← Type-3 +<output_dir>/XX_{project_name}_Core_API_Product_Code_Mapping.md ← Type-5 +<output_dir>/XX_{project_name}_Product_Rules_Cheat_Sheet.md ← Type-6 +<output_dir>/XX_{project_name}_Business_Development_SOP.md ← Type-7 +<output_dir>/{knowledge_enhancement_doc}.md ← Type-8 +Returned summary string ``` -## 约束 +## Constraints -- **代码为真**:所有描述必须有代码文件引用,不可验证内容必须标注 `[UNVERIFIED]` -- **模板强制**:生成每类文件前必须先读取对应章节的模板 -- **严禁空文档**:没有实质内容则不创建文件 -- **严禁冗余输出**:直接 Write 文件,不在对话中打印完整内容 -- **命名规范**:组件文档用 `XX_{组件名}设计说明.md`,XX 按依赖链顺序分配(底层组件编号小) -- **API 未提供时**:Type-5/6 可跳过产品约束映射,将约束值标注为 `[PRODUCT_DOC_MISSING]` +- **Code is the truth**: every description must cite a code file; unverifiable content must be marked `[UNVERIFIED]` +- **Templates are mandatory**: read the template for the corresponding section before generating each file type +- **No empty documents**: do not create a file without substantive content +- **No redundant output**: Write files directly; do not print the full content in the conversation +- **Naming convention**: component documents use `XX_{component}_Design.md`; XX is assigned in dependency-chain order (lower-layer components get smaller numbers) +- **When no API is provided**: Type-5/6 may skip the product constraint mapping and mark constraint values as `[PRODUCT_DOC_MISSING]` diff --git a/skill-data/wiki/references/methodology/phase0-collection.md b/skill-data/wiki/references/methodology/phase0-collection.md index cf240913..f4139dfa 100644 --- a/skill-data/wiki/references/methodology/phase0-collection.md +++ b/skill-data/wiki/references/methodology/phase0-collection.md @@ -1,54 +1,54 @@ -# Phase 0: 源材料采集与预处理 +# Phase 0: Source Material Collection and Preprocessing -## 仓库发现与分类 +## Repository Discovery and Classification -从入口仓库出发,递归发现所有相关仓库: +Starting from the entry repository, recursively discover all related repositories: -1. **依赖分析**: 解析项目依赖文件(如 `requirements.txt`, `package.json`, `pom.xml`, `Cargo.toml`, `go.mod` 等,按检测到的语言选择) -2. **配置引用**: 解析流程编排配置中引用的模块名 → 仓库映射 -3. **RPC 服务发现**: 从服务注册配置提取服务名 → 仓库映射 -4. **按架构层级分类**: API接入层 / 流程引擎层 / 服务执行层 / 资源调度层 / 数据适配层 / 基础执行层 -5. **标记核心度**: 根据代码行数、被依赖数、Handler 数量计算优先级 +1. **Dependency analysis**: parse project dependency files (such as `requirements.txt`, `package.json`, `pom.xml`, `Cargo.toml`, `go.mod`, chosen by the detected language) +2. **Configuration references**: parse module names referenced in workflow orchestration configs → repository mapping +3. **RPC service discovery**: extract service names from service registry configs → repository mapping +4. **Classify by architecture layer**: API access layer / workflow engine layer / service execution layer / resource scheduling layer / data adapter layer / base execution layer +5. **Mark core-ness**: compute priority from lines of code, number of dependents, and Handler count -## 关键文件提取清单 +## Key File Extraction Checklist -| 文件类型 | 匹配模式 | 提取目的 | +| File type | Match pattern | Extraction purpose | |---------|---------|---------| -| **入口文件** | `main.py`, `main.go`, `cmd/*/main.go`, `app.ts` | 服务启动方式和初始化流程 | -| **路由/Handler** | `handler.*`, `router.*`, `controller.*` | API 接口和消息处理入口 | -| **配置文件** | `*config*.*`, `conf/`, `*.yaml`, `*.toml` | 流程编排、参数配置 | -| **Proto/IDL** | `*.proto`, `*.thrift`, `*schema*` | RPC 接口契约和数据结构 | -| **数据库操作** | `*db*.*`, `*dao*.*`, `*model*.*`, `*repository*.*` | 数据模型和表结构 | -| **常量/错误码** | `*const*`, `*error*`, `*code*`, `*enum*` | 错误码体系和业务常量 | -| **测试文件** | `*_test.*`, `test_*.*` | 预期行为和边界条件 | +| **Entry files** | `main.py`, `main.go`, `cmd/*/main.go`, `app.ts` | Service startup and initialization flow | +| **Routes/Handlers** | `handler.*`, `router.*`, `controller.*` | API endpoints and message handling entry points | +| **Config files** | `*config*.*`, `conf/`, `*.yaml`, `*.toml` | Workflow orchestration, parameter configuration | +| **Proto/IDL** | `*.proto`, `*.thrift`, `*schema*` | RPC interface contracts and data structures | +| **Database operations** | `*db*.*`, `*dao*.*`, `*model*.*`, `*repository*.*` | Data models and table schemas | +| **Constants/error codes** | `*const*`, `*error*`, `*code*`, `*enum*` | Error code system and business constants | +| **Test files** | `*_test.*`, `test_*.*` | Expected behavior and edge conditions | -## 构建代码知识图谱 +## Building the Code Knowledge Graph -在正式生成文档前,构建代码知识图谱作为中间表示: +Before generating documents, build a code knowledge graph as an intermediate representation: -**节点类型**: `[Service]` / `[Handler]` / `[Config]` / `[Table]` / `[Queue]` / `[API]` / `[ErrorCode]` +**Node types**: `[Service]` / `[Handler]` / `[Config]` / `[Table]` / `[Queue]` / `[API]` / `[ErrorCode]` -**边类型**: `[CALLS]`(同步RPC/HTTP) / `[PUBLISHES]`(异步MQ) / `[CONSUMES]`(MQ消费) / `[READS]`(DB读) / `[WRITES]`(DB写) / `[CONFIGURES]`(配置驱动) / `[MAPS_TO]`(产品→代码) +**Edge types**: `[CALLS]` (synchronous RPC/HTTP) / `[PUBLISHES]` (asynchronous MQ) / `[CONSUMES]` (MQ consumption) / `[READS]` (DB read) / `[WRITES]` (DB write) / `[CONFIGURES]` (config-driven) / `[MAPS_TO]` (product → code) -**构建方法**(按可用性排序): -1. **`teamai codebase --extract`** — Tree-sitter 结构边(**TS/JS/Python/Go** 等)+ 多语言 heuristic 事实页(writes `teamwiki/`) -2. Grep + Read(Agent K1/K2)— 补充动态路由、配置驱动调用 -3. 解析编排配置 → 模块→命令映射 -4. 解析 Proto/IDL/DDL → 数据结构和表关系(结构化文件,可精确解析) -5. MQ 拓扑推断 → Exchange/Topic/Queue/Routing Key -6. API 映射 → 外部 API 名称 → 内部 Handler 入口 +**Construction methods** (ordered by availability): +1. **`teamai codebase --extract`**: Tree-sitter structural edges (**TS/JS/Python/Go** and more) + multi-language heuristic fact pages (writes `teamwiki/`) +2. Grep + Read (Agent K1/K2): supplement dynamic routes and config-driven calls +3. Parse orchestration configs → module → command mapping +4. Parse Proto/IDL/DDL → data structures and table relationships (structured files, can be parsed precisely) +5. MQ topology inference → Exchange/Topic/Queue/Routing Key +6. API mapping → external API name → internal Handler entry point -> `code-ast` 对相对 import 可产出 `DEPENDS_ON` 边;包级/动态调用仍可能遗漏,标 `[UNVERIFIED]` 或 `AMBIGUOUS`。 -> AST 结果优先于 heuristic。There is no separate capabilities doc in this package; use `teamai codebase --extract` output under `teamwiki/`. +> `code-ast` can produce `DEPENDS_ON` edges for relative imports; package-level and dynamic calls may still be missed, mark them `[UNVERIFIED]` or `AMBIGUOUS`. +> AST results take precedence over heuristics. There is no separate capabilities doc in this package; use `teamai codebase --extract` output under `teamwiki/`. -## 输入源优先级 +## Input Source Priority -| 优先级 | 输入源 | 具体内容 | 产出文档类型 | +| Priority | Input source | Specific content | Output document types | |--------|--------|---------|------------| -| **P0 必须** | 代码仓库 | 目录结构、入口文件、配置、Proto | Type-1,4 | -| **P0 必须** | 流程编排配置 | workflow_config / 状态机 | Type-1,4,5 | -| **P0 必须** | 产品 API 文档 | 接口参数、错误码 | Type-5,6 | -| **P1 重要** | 数据库 Schema | DDL、表结构 | Type-4 | -| **P1 重要** | 产品使用文档 | 使用限制、FAQ | Type-6,8a | -| **P2 增强** | Git 历史 | Commit/MR 记录 | Type-8b | -| **P2 增强** | 故障记录 | 事故报告 | Type-8d | +| **P0 required** | Code repositories | Directory structure, entry files, configs, Proto | Type-1,4 | +| **P0 required** | Workflow orchestration configs | workflow_config / state machines | Type-1,4,5 | +| **P0 required** | Product API docs | Interface parameters, error codes | Type-5,6 | +| **P1 important** | Database schema | DDL, table schemas | Type-4 | +| **P1 important** | Product usage docs | Usage limits, FAQ | Type-6,8a | +| **P2 enhancement** | Git history | Commit/MR records | Type-8b | +| **P2 enhancement** | Incident records | Incident reports | Type-8d | diff --git a/skill-data/wiki/references/methodology/phase1-reverse-engineering.md b/skill-data/wiki/references/methodology/phase1-reverse-engineering.md index 969c25cb..a3923530 100644 --- a/skill-data/wiki/references/methodology/phase1-reverse-engineering.md +++ b/skill-data/wiki/references/methodology/phase1-reverse-engineering.md @@ -1,89 +1,89 @@ -# Phase 1: 架构逆向工程 — 从代码到架构认知 +# Phase 1: Architecture Reverse-Engineering, From Code to Architectural Understanding -## 1. 自底向上分层法 +## 1. Bottom-Up Layering Method ``` -Step 1: 识别"叶子节点" — 直接操作基础设施 - ├── 数据库操作 (MySQL/PostgreSQL/Redis/MongoDB) - ├── 消息队列操作 (RabbitMQ/Kafka/RocketMQ) - ├── 外部系统调用 (第三方 API / 底层驱动) - └── 文件/对象存储操作 (S3/OSS/COS) - -Step 2: 识别"中间节点" — 编排和路由 - ├── 消息路由框架 (消费者路由分发) - ├── 任务调度器 (定时任务/延迟任务) - ├── 流程编排引擎 (Workflow/Saga/状态机) - └── 资源调度器 (负载均衡/资源分配) - -Step 3: 识别"根节点" — 外部入口 - ├── API 网关 / HTTP Handler / gRPC Server - ├── 定时任务入口 (Cron/Scheduler) - └── 事件监听入口 (Webhook/EventBus) - -Step 4: 按调用方向分层 - 外部入口 → 流程编排 → 服务执行 → 资源调度 → 数据操作 → 基础设施 +Step 1: Identify "leaf nodes" that operate directly on infrastructure + ├── Database operations (MySQL/PostgreSQL/Redis/MongoDB) + ├── Message queue operations (RabbitMQ/Kafka/RocketMQ) + ├── External system calls (third-party APIs / low-level drivers) + └── File/object storage operations (S3/OSS/COS) + +Step 2: Identify "intermediate nodes" that orchestrate and route + ├── Message routing frameworks (consumer routing and dispatch) + ├── Task schedulers (cron jobs / delayed tasks) + ├── Workflow orchestration engines (Workflow/Saga/state machines) + └── Resource schedulers (load balancing / resource allocation) + +Step 3: Identify "root nodes", the external entry points + ├── API gateway / HTTP Handler / gRPC Server + ├── Scheduled task entry points (Cron/Scheduler) + └── Event listener entry points (Webhook/EventBus) + +Step 4: Layer by call direction + External entry → workflow orchestration → service execution → resource scheduling → data operations → infrastructure ``` -### 分层判定规则 +### Layer Assignment Rules -| 判定特征 | 所属层级 | 典型代码模式 | +| Distinguishing feature | Layer | Typical code pattern | |---------|---------|-------------| -| HTTP/gRPC Server 启动 | API 接入层 | `http.ListenAndServe()`, `grpc.NewServer()` | -| 参数校验 + 鉴权 + 限流 | API 接入层 | `validate()`, `auth()`, `rateLimit()` | -| 流程步骤配置和状态机 | 流程引擎层 | `workflow_config`, `state_machine` | -| MQ 消费 + Handler 路由 | 服务执行层 | `channel.consume()`, `handler.dispatch()` | -| 调度算法 (Filter/Score) | 资源调度层 | `filter()`, `score()`, `schedule()` | -| DB CRUD + 缓存操作 | 数据适配层 | `db.query()`, `redis.get()` | -| 底层系统调用/驱动 | 基础执行层 | `exec()`, `syscall.*`, `driver.*` | +| HTTP/gRPC Server startup | API access layer | `http.ListenAndServe()`, `grpc.NewServer()` | +| Parameter validation + auth + rate limiting | API access layer | `validate()`, `auth()`, `rateLimit()` | +| Workflow step configs and state machines | Workflow engine layer | `workflow_config`, `state_machine` | +| MQ consumption + Handler routing | Service execution layer | `channel.consume()`, `handler.dispatch()` | +| Scheduling algorithms (Filter/Score) | Resource scheduling layer | `filter()`, `score()`, `schedule()` | +| DB CRUD + cache operations | Data adapter layer | `db.query()`, `redis.get()` | +| Low-level system calls/drivers | Base execution layer | `exec()`, `syscall.*`, `driver.*` | -## 2. 三层穿透追踪法(核心方法论) +## 2. Three-Layer Penetration Tracing (Core Methodology) -对任何用户可见 API 操作,完成三层穿透追踪: +For every user-visible API operation, complete a three-layer penetration trace: ``` -Layer 1: API 入口层 - ├── 定位 Handler 函数 - ├── 提取参数校验逻辑 - ├── 识别硬编码默认值和白名单 - └── 确定下游调用方式 (同步RPC / 异步MQ) - -Layer 2: 流程编排层 - ├── 查找流程配置 (workflow_config / saga_config) - ├── 解析步骤序列 (步骤名/执行模块/回滚模块/超时/重试) - ├── 标注每步的执行模块和回滚模块 - └── 确定步骤间的数据传递方式 - -Layer 3: 服务执行层 - ├── 追踪每个步骤的具体 Handler 实现 - ├── 识别数据库操作和状态变更 - ├── 标注外部系统调用 - └── 确定最终执行结果的回调路径 - -输出: 完整调用链时序图 + 状态流转图 + 数据流向图 +Layer 1: API entry layer + ├── Locate the Handler function + ├── Extract parameter validation logic + ├── Identify hard-coded defaults and whitelists + └── Determine the downstream call style (synchronous RPC / asynchronous MQ) + +Layer 2: Workflow orchestration layer + ├── Find the workflow config (workflow_config / saga_config) + ├── Parse the step sequence (step name / execution module / rollback module / timeout / retry) + ├── Annotate the execution module and rollback module of each step + └── Determine how data is passed between steps + +Layer 3: Service execution layer + ├── Trace the concrete Handler implementation of each step + ├── Identify database operations and state changes + ├── Annotate external system calls + └── Determine the callback path of the final execution result + +Output: complete call chain sequence diagram + state transition diagram + data flow diagram ``` -### 调用链文档化标准格式 +### Standard Format for Documenting Call Chains ``` -[API名称](代码入口: {仓库}/{路径}/{文件}) - → 参数校验 + 鉴权限流 - → [前置检查]: {检查内容} - → RPC/MQ → [编排层] ({配置文件}: {操作名}) - → [服务层] ({配置文件}: {flow_name}) - → [{步骤1模块}] {步骤1命令} ({具体说明}) - → [{步骤2模块}] {步骤2命令} ({具体说明}) +[API name](code entry: {repo}/{path}/{file}) + → parameter validation + auth and rate limiting + → [pre-checks]: {check content} + → RPC/MQ → [orchestration layer] ({config file}: {operation name}) + → [service layer] ({config file}: {flow_name}) + → [{step 1 module}] {step 1 command} ({details}) + → [{step 2 module}] {step 2 command} ({details}) → ... - → 回调 [编排层] + → callback to [orchestration layer] ``` -## 3. 组件关系矩阵 +## 3. Component Relationship Matrix -构建 N×N 关系矩阵,标注通信方式: +Build an N×N relationship matrix annotated with the communication style: -| 调用方 ↓ / 被调方 → | 组件A | 组件B | 组件C | +| Caller ↓ / Callee → | ComponentA | ComponentB | ComponentC | |---------------------|-------|-------|-------| -| **组件A** | — | RPC | MQ | -| **组件B** | — | — | DB | -| **组件C** | RPC | MQ | — | +| **ComponentA** | — | RPC | MQ | +| **ComponentB** | — | — | DB | +| **ComponentC** | RPC | MQ | — | -标注: `RPC`(同步) / `MQ`(异步) / `DB`(共享数据库) / `—`(无直接通信) +Legend: `RPC` (synchronous) / `MQ` (asynchronous) / `DB` (shared database) / `—` (no direct communication) diff --git a/skill-data/wiki/references/methodology/phase2-document-types.md b/skill-data/wiki/references/methodology/phase2-document-types.md index 2308347d..28068e0b 100644 --- a/skill-data/wiki/references/methodology/phase2-document-types.md +++ b/skill-data/wiki/references/methodology/phase2-document-types.md @@ -1,341 +1,341 @@ -# Phase 2: 九大文档类型生成规范与模板 +# Phase 2: Generation Specs and Templates for the Nine Document Types -## Type-1: 技术架构总览 +## Type-1: Technical Architecture Overview -**规模**: ~200KB | **数量**: 1 份 +**Size**: ~200KB | **Count**: 1 -### 必备章节 +### Required Sections ``` -读者导航指南 (按角色推荐阅读路径) -知识库检索路由指引 (AI 专用,4条分流规则+4级优先级) -1. 架构概述 (30秒快速理解表、整体架构图ASCII、组件关系矩阵) -2. 三维架构视图 (逻辑/数据/部署) -3. 核心链路 ⭐ (每条核心API的完整时序图+调用链) -4. 核心组件详解 (每组件概述+表格) -5. 配置管理与服务发现 -6. 数据模型与存储架构 ⭐ -7. 高可用与技术架构 -8. 架构演进与设计决策 -9. AI 研发知识库规范 ⭐ (元数据QA/全局状态机/MQ拓扑/调度引擎/跨层追踪) -附录: 代码仓库/术语表/代码入口索引/错误码 +Reader navigation guide (recommended reading paths by role) +Knowledge base retrieval routing guide (AI only, 4 routing rules + 4 priority levels) +1. Architecture overview (30-second quick reference table, overall ASCII architecture diagram, component relationship matrix) +2. Three-dimensional architecture views (logical/data/deployment) +3. Core call chains ⭐ (complete sequence diagram + call chain for every core API) +4. Core components in detail (overview + table per component) +5. Configuration management and service discovery +6. Data model and storage architecture ⭐ +7. High availability and technical architecture +8. Architecture evolution and design decisions +9. AI development knowledge base spec ⭐ (metadata QA / global state machine / MQ topology / scheduling engine / cross-layer tracing) +Appendix: code repositories / glossary / code entry index / error codes ``` -### 生成规则 -- T1-R01: 必须包含读者导航指南 -- T1-R02: 必须包含 AI 检索路由规则 -- T1-R03: 核心链路必须有时序图 -- T1-R04: 组件表必须包含代码仓库列 -- T1-R05: 术语表必须包含内外部映射 -- T1-R06: 必须有 AI 专用第 9 章 -- T1-R07: 架构图使用 ASCII Art +### Generation Rules +- T1-R01: must include a reader navigation guide +- T1-R02: must include AI retrieval routing rules +- T1-R03: core call chains must have sequence diagrams +- T1-R04: the component table must include a code repository column +- T1-R05: the glossary must include external-to-internal mappings +- T1-R06: must have an AI-only chapter 9 +- T1-R07: architecture diagrams use ASCII Art --- -## Type-2: 业务架构文档 +## Type-2: Business Architecture Document -**规模**: ~70KB | **数量**: 1 份 +**Size**: ~70KB | **Count**: 1 ``` -1. 产品能力矩阵 (能力域/子能力/对应API/计费影响) -2. 计费模型详解 (模式对比/状态机/退费续费规则) -3. 核心实体生命周期 (完整状态机/各状态允许操作/互斥规则) -4. 核心业务流程 (用户视角时序图+前置条件+异常处理) -5. 产品规格体系 (命名规则/规格与底层资源映射) +1. Product capability matrix (capability domain / sub-capability / corresponding API / billing impact) +2. Billing model in detail (mode comparison / state machine / refund and renewal rules) +3. Core entity lifecycle (complete state machine / operations allowed per state / mutual exclusion rules) +4. Core business flows (user-perspective sequence diagram + preconditions + exception handling) +5. Product specification system (naming rules / mapping from specs to underlying resources) ``` --- -## Type-3: 部署架构文档 +## Type-3: Deployment Architecture Document -**规模**: ~40KB | **数量**: 1 份 +**Size**: ~40KB | **Count**: 1 ``` -1. 分层部署架构图 -2. 服务部署矩阵 (服务名/部署方式/实例数/资源配置/依赖) -3. 环境配置 (生产/测试/差异对照) -4. 部署流程与变更管理 +1. Layered deployment architecture diagram +2. Service deployment matrix (service name / deployment method / instance count / resource config / dependencies) +3. Environment configuration (production / test / difference comparison) +4. Deployment process and change management ``` --- -## Type-4: 组件设计文档(核心产出) +## Type-4: Component Design Document (Core Output) -**规模**: 20~100KB/份 | **数量**: N 份(每组件一份) +**Size**: 20~100KB each | **Count**: N (one per component) -### 标准模板 +### Standard Template ``` -# {组件名} 内部设计说明 -<!-- search-anchor: 组件名, 别名, 核心关键词 --> -> 项目名称 / 版本 / 代码仓库 / 代码规模 -> 在整体架构中的位置: [📘 链接到主架构文档] - -## 🤖 AI 快速理解要点 -(10 维度结构化摘要,详细定义见 [phase3-ai-enhancement.md §1](phase3-ai-enhancement.md)) - -## 📋 项目概述 (核心职责+在架构中的位置) -## 🏗️ 架构设计 (ASCII架构图+核心子模块,函数签名) -## 📊 数据模型 (SQL DDL带注释+数据流向图) -## 🔌 接口设计 (对外接口表+对内接口+错误码) -## ⚙️ 核心流程 (时序图+步骤说明+异常处理) -## 🔧 配置说明 (配置项/默认值/说明/影响范围) -## 📈 监控与告警 -## 🐛 常见问题与排障 +# {component} Internal Design +<!-- search-anchor: component name, aliases, core keywords --> +> Project name / version / code repository / code size +> Position in the overall architecture: [📘 link to the Technical Architecture document] + +## 🤖 AI Quick Reference +(10-dimension structured summary, detailed definition in [phase3-ai-enhancement.md §1](phase3-ai-enhancement.md)) + +## 📋 Project Overview (core responsibilities + position in the architecture) +## 🏗️ Architecture Design (ASCII architecture diagram + core sub-modules, function signatures) +## 📊 Data Model (SQL DDL with comments + data flow diagram) +## 🔌 Interface Design (external interface table + internal interfaces + error codes) +## ⚙️ Core Flows (sequence diagram + step descriptions + exception handling) +## 🔧 Configuration (config item / default / description / impact scope) +## 📈 Monitoring and Alerting +## 🐛 Common Issues and Troubleshooting ``` -### 生成规则 -- T4-R01: 必须有 AI 快速理解表 -- T4-R02: 必须有双向链接到主架构文档 -- T4-R03: 核心函数必须列出签名 -- T4-R04: SQL DDL 必须包含注释 -- T4-R05: 配置项必须标注影响范围 -- T4-R06: 架构图使用 ASCII Art -- T4-R07: 代码入口必须精确到函数名 +### Generation Rules +- T4-R01: must have an AI Quick Reference table +- T4-R02: must have bidirectional links to the Technical Architecture document +- T4-R03: core functions must list their signatures +- T4-R04: SQL DDL must include comments +- T4-R05: config items must state their impact scope +- T4-R06: architecture diagrams use ASCII Art +- T4-R07: code entries must be precise to the function name -### 从代码生成的步骤 +### Steps for Generating from Code -> 详细执行规范见 `{SKILL_DIR}/references/agents/kb-doc-generator.md`,此处仅列概要: -> 1. 代码结构扫描(Glob → Grep → Read 三步法,按语言自适应) -> 2. 信息提取(10 维度:核心职责/架构层级/上下游/代码入口/核心机制/数据流向/技术栈/数据模型/配置项/定时任务) -> 3. 文档组装(按上述模板章节顺序) -> 4. 自校验(准确性统计 + 接口对账) +> The detailed execution spec is in `{SKILL_DIR}/references/agents/kb-doc-generator.md`; only the outline is listed here: +> 1. Code structure scan (three-step Glob → Grep → Read, adapted per language) +> 2. Information extraction (10 dimensions: core responsibilities / architecture layer / upstream and downstream / code entries / core mechanisms / data flow / tech stack / data model / config items / scheduled tasks) +> 3. Document assembly (in the section order of the template above) +> 4. Self-check (accuracy statistics + interface reconciliation) --- -## Type-5: 产品-代码映射(桥梁文档) +## Type-5: Product-to-Code Mapping (Bridge Document) -### 每个核心 API 一节 +### One Section per Core API ``` -### N.1 用户意图 (一句话) -### N.2 产品约束 (约束项/约束值/影响组件/校验位置) -### N.3 用户可见状态流转 (ASCII图+内部状态映射) -### N.4 内部调用链路 (标准格式精确到代码文件) -### N.5 写代码时必须考虑的 (硬性约束编号列表) -### N.6 错误码与内部异常映射 (外部码/内部组件/含义) +### N.1 User intent (one sentence) +### N.2 Product constraints (constraint / value / affected components / validation location) +### N.3 User-visible state transitions (ASCII diagram + internal state mapping) +### N.4 Internal call chain (standard format, precise to the code file) +### N.5 Must-consider items when writing code (numbered list of hard constraints) +### N.6 Error codes and internal exception mapping (external code / internal component / meaning) ``` -### 生成规则 -- T5-R01: 约束表必须标注"影响的组件"和"校验位置" -- T5-R02: 调用链必须精确到代码文件路径 -- T5-R03: 状态流转必须标注内部状态码映射 -- T5-R04: "写代码时必须考虑的"是强制章节 -- T5-R05: 错误码映射必须包含内部组件归属 +### Generation Rules +- T5-R01: the constraint table must state the "affected components" and "validation location" +- T5-R02: call chains must be precise to the code file path +- T5-R03: state transitions must be annotated with the internal state code mapping +- T5-R04: "Must-consider items when writing code" is a mandatory section +- T5-R05: error code mappings must include the owning internal component -### 桥梁文档生成方法(3 Step) +### Bridge Document Generation Method (3 Steps) -**Step 1: 提取产品约束** — 从产品文档中提取所有影响代码实现的约束: +**Step 1: Extract product constraints**. From the product docs, extract every constraint that affects the code implementation: ``` -扫描维度: -├── 数量限制 (批量上限、配额、最大值) -├── 类型约束 (枚举值、互斥关系) -├── 状态前置条件 (操作前资源必须处于什么状态) -├── 计费规则 (不同计费模式的差异处理) -├── 安全约束 (鉴权、加密、脱敏) -└── 兼容性约束 (类型兼容、版本兼容、地域限制) +Scan dimensions: +├── Quantity limits (batch caps, quotas, maximums) +├── Type constraints (enum values, mutual exclusion) +├── State preconditions (what state a resource must be in before an operation) +├── Billing rules (different handling per billing mode) +├── Security constraints (auth, encryption, data masking) +└── Compatibility constraints (type compatibility, version compatibility, regional limits) ``` -**Step 2: 映射到代码位置** — 对每个产品约束,追踪到代码中的具体校验位置: +**Step 2: Map to code locations**. For each product constraint, trace to the concrete validation location in the code: ``` -产品约束: "{API名} 批量上限 N" - ↓ 追踪 -代码位置: {API网关组件} → {文件路径} → validate_params() - ↓ 确认 -校验方式: if len(resource_ids) > N: raise InvalidParameterValue +Product constraint: "{API name} batch cap N" + ↓ trace +Code location: {API gateway component} → {file path} → validate_params() + ↓ confirm +Validation: if len(resource_ids) > N: raise InvalidParameterValue ``` -**Step 3: 构建映射表** — 将上述信息组装为标准的产品-代码映射表(见 Type-5 模板)。 +**Step 3: Build the mapping table**. Assemble the information above into the standard product-to-code mapping table (see the Type-5 template). -**桥梁文档质量标准**: +**Bridge document quality criteria**: -| 质量维度 | 标准 | 检查方法 | +| Quality dimension | Standard | Check method | |---------|------|---------| -| **完整性** | 所有核心 API 都有映射 | 对照 API 列表逐一检查 | -| **精确性** | 代码路径精确到文件和函数 | 实际打开代码验证 | -| **一致性** | 约束值与产品文档一致 | 交叉比对产品文档 | -| **时效性** | 与最新代码版本同步 | 定期 diff 检查 | +| **Completeness** | Every core API has a mapping | Check one by one against the API list | +| **Precision** | Code paths are precise to file and function | Open the code and verify | +| **Consistency** | Constraint values match the product docs | Cross-check against the product docs | +| **Freshness** | In sync with the latest code version | Periodic diff check | --- -## Type-6: 产品规则速查表 +## Type-6: Product Rules Cheat Sheet ``` -## N. {规则类别} -| 规则 | 约束值 | 影响的组件 | 校验位置 | 来源文档 | +## N. {rule category} +| Rule | Constraint value | Affected components | Validation location | Source document | -## 状态与操作互斥规则 -| 当前状态 | 允许的操作 | 禁止的操作 | +## State and Operation Mutual Exclusion Rules +| Current state | Allowed operations | Forbidden operations | ``` -- T6-R01: 每条规则必须标注"影响的组件" -- T6-R02: 约束值必须是精确数字 -- T6-R03: 必须有"来源文档"列 -- T6-R04: 状态互斥规则必须是完整矩阵 +- T6-R01: every rule must state the "affected components" +- T6-R02: constraint values must be exact numbers +- T6-R03: must have a "source document" column +- T6-R04: state mutual exclusion rules must be a complete matrix --- -## Type-7: 业务开发规范 SOP +## Type-7: Business Development SOP ``` -1. 为什么需要标准代码模板 (野生代码问题) -2. 核心规约 (绝不向外暴露底层错误/Context一传到底/参数前置校验) -3. 标准 Handler 代码模板 (可直接复制,标注"AI 编码铁律") -4. 错误码映射对照表 (场景描述用AI思考逻辑/推荐错误码/Message) -5. AI 评审 CheckList (可机器校验) +1. Why a standard code template is needed (the problem of unmanaged code) +2. Core conventions (never expose low-level errors externally / pass Context all the way down / validate parameters up front) +3. Standard Handler code template (copy-ready, annotated with "AI coding iron rules") +4. Error code mapping table (scenario described in AI reasoning terms / recommended error code / Message) +5. AI review checklist (machine-checkable) ``` -- T7-R01: 代码模板必须可直接复制运行 -- T7-R02: 每个关键注释标注"AI 编码铁律" -- T7-R03: 错误码表用"AI的思考逻辑"作为场景描述 +- T7-R01: code templates must be directly copyable and runnable +- T7-R02: every key comment is annotated with "AI coding iron rule" +- T7-R03: the error code table uses "the AI's reasoning" as the scenario description --- -## Type-8: 知识增强文档 +## Type-8: Knowledge Enhancement Documents -### Type-8a: 产品知识文库 -标注 `type: bridge`,表格对比易混淆概念,含"代码传参示例"和"架构与业务影响"列。 +### Type-8a: Product Knowledge Library +Marked `type: bridge`; tables compare easily confused concepts and include "code parameter example" and "architecture and business impact" columns. -### Type-8b: 反模式与踩坑指南 -五段式:**触发场景→错误表现→根因分析→正确做法→关联组件** -概览表标注编号/分类/严重程度(P0致命/P1严重/P2重要)/关联组件。 +### Type-8b: Anti-Patterns and Pitfalls Guide +Five-part structure: **trigger scenario → faulty behavior → root cause analysis → correct approach → related components** +The overview table records number / category / severity (P0 fatal / P1 severe / P2 important) / related components. -### Type-8c: RPC 接口契约 -struct 定义含序列化 Tag + 必填/选填标注 + AI 编码契约要求。 +### Type-8c: RPC Interface Contracts +Struct definitions with serialization tags + required/optional markers + AI coding contract requirements. -### Type-8d: 排障案例记录 (Memorix) -结构:问题现象→排查过程(Step N)→根因定位→修复方案→经验总结→关联文档。 +### Type-8d: Troubleshooting Case Records (Memorix) +Structure: symptom → investigation process (Step N) → root cause → fix → lessons learned → related documents. --- -## Type-9: 图谱文档集(Graph RAG) +## Type-9: Graph Document Set (Graph RAG) -**规模**: 10~30KB/份 | **数量**: 5~10 份 | **目录**: `graph/` +**Size**: 10~30KB each | **Count**: 5~10 | **Directory**: `graph/` -> 将散落在 N 份组件文档中的**跨组件关系信息**抽取为结构化索引,解决 RAG 检索在关系查询场景下的"信息分散"问题。 +> Extracts the **cross-component relationship information** scattered across N component documents into a structured index, solving the "scattered information" problem RAG retrieval hits on relationship queries. -### 图谱文档类型清单 +### Graph Document Type List -| 编号 | 文档名 | 核心内容 | 解决的检索痛点 | +| ID | Document name | Core content | Retrieval pain point solved | |------|--------|---------|--------------| -| G1 | 组件依赖关系矩阵 | N×N 通信矩阵 + 正向/反向依赖索引 + 外部服务依赖 | "谁依赖 X?" 需遍历所有文档 | -| G2 | 组件调用链路全景 | 核心 API 端到端链路 + 读写分离机制 + **完整状态机流转图** + 操作-状态约束矩阵 | "API 经过哪些模块?" 信息分散 | -| G3 | 数据流与存储依赖图 | 存储依赖矩阵 + MQ 队列拓扑 + 缓存策略 | "数据存在哪里?" | -| G4 | 错误码组件映射表 | 错误码段分配 + 外部→内部映射 | "错误码是哪个模块的?" | -| G5 | 跨组件交互场景手册 | ≥10 个场景的 mermaid 时序图 + 异常处理 | "配额检查怎么做的?" | -| G6 | 知识图谱三元组 | (S, P, O) 三元组 + 多跳依赖路径索引 | "A 间接依赖谁?" | -| G7 | 架构风险与影响面分析 | 爆炸半径 + 聚类分析 + 关键路径/瓶颈 | "X 挂了影响多大?" | -| G8 | **核心配置参数索引** | 分层配置项→行为影响映射 + 变更影响面速查 | "怎么修改 XX 配置?" | -| G9 | **业务规则约束矩阵** | 操作前置条件 + 硬件/迁移/计费约束 + AI 推理决策树 | "能不能做 XX?" | - -### 图谱文档生成规则 - -- T9-R01: 每份图谱文档必须有 `🤖 AI 快速理解要点` 表 -- T9-R02: 每份图谱文档必须有 `<!-- search-anchor: ... -->` 锚点 -- T9-R03: 图谱目录必须有 `README.md` 索引,含"按问题类型查找"表和"检索路由规则建议" -- T9-R04: 状态机必须使用 mermaid `stateDiagram-v2` 格式 -- T9-R05: 约束决策树必须使用 mermaid `graph TD` 格式 -- T9-R06: 操作-状态约束必须是 ✅/❌ 矩阵格式 -- T9-R07: 配置参数必须标注"影响行为"、"变更风险"(🟢低/🟡中/🔴高)、"生效方式"(热生效/需重启) -- T9-R08: 业务规则约束必须包含 AI 推理检查流程(mermaid 流程图) -- T9-R09: 三元组必须遵循 (Subject, Predicate, Object) 标准格式 -- T9-R10: 图谱文档**不替代**组件文档,而是提供**关系视角的结构化索引** - -### 图谱文档生成方法 - -**Step 1: 关系抽取** — 从 N 份组件文档中提取跨组件关系: +| G1 | Component Dependency Matrix | N×N communication matrix + forward/reverse dependency index + external service dependencies | "Who depends on X?" requires traversing every document | +| G2 | Component Call Chain Overview | End-to-end core API chains + read/write separation mechanism + **complete state machine diagram** + operation-state constraint matrix | "Which modules does the API pass through?" information is scattered | +| G3 | Data Flow and Storage Dependencies | Storage dependency matrix + MQ queue topology + cache strategy | "Where is the data stored?" | +| G4 | Error Code Component Map | Error code range allocation + external → internal mapping | "Which module owns this error code?" | +| G5 | Cross-Component Interaction Scenarios | mermaid sequence diagrams for ≥10 scenarios + exception handling | "How is the quota check done?" | +| G6 | Knowledge Graph Triples | (S, P, O) triples + multi-hop dependency path index | "Who does A depend on indirectly?" | +| G7 | Architecture Risks and Impact Analysis | Blast radius + cluster analysis + critical paths/bottlenecks | "How big is the impact if X goes down?" | +| G8 | **Core Config Parameter Index** | Layered config item → behavior impact mapping + change impact surface quick lookup | "How do I change config XX?" | +| G9 | **Business Rule Constraint Matrix** | Operation preconditions + hardware/migration/billing constraints + AI reasoning decision tree | "Can XX be done?" | + +### Graph Document Generation Rules + +- T9-R01: every graph document must have a `🤖 AI Quick Reference` table +- T9-R02: every graph document must have a `<!-- search-anchor: ... -->` anchor +- T9-R03: the graph directory must have a `README.md` index with a "lookup by question type" table and "retrieval routing rule suggestions" +- T9-R04: state machines must use the mermaid `stateDiagram-v2` format +- T9-R05: constraint decision trees must use the mermaid `graph TD` format +- T9-R06: operation-state constraints must be in ✅/❌ matrix format +- T9-R07: config parameters must state "behavior impact", "change risk" (🟢 low / 🟡 medium / 🔴 high), and "activation" (hot reload / restart required) +- T9-R08: business rule constraints must include an AI reasoning check flow (mermaid flowchart) +- T9-R09: triples must follow the standard (Subject, Predicate, Object) format +- T9-R10: graph documents **do not replace** component documents; they provide a **structured index from the relationship perspective** + +### Graph Document Generation Method + +**Step 1: Relationship extraction**. Extract cross-component relationships from the N component documents: ``` -扫描维度: -├── 调用关系 (A calls B, 协议, 场景) -├── 数据依赖 (A reads/writes B, 数据内容) -├── 消息拓扑 (A publishes_to/consumes_from Queue) -├── 状态流转 (操作 → 起始状态 → 中间状态 → 终态) -├── 约束条件 (操作 → 前置条件 → 硬件/计费/配额约束) -├── 配置映射 (配置项 → 影响行为 → 变更风险) -└── 错误码归属 (错误码段 → 组件 → 排查方向) +Scan dimensions: +├── Call relationships (A calls B, protocol, scenario) +├── Data dependencies (A reads/writes B, data content) +├── Message topology (A publishes_to/consumes_from Queue) +├── State transitions (operation → initial state → intermediate state → final state) +├── Constraints (operation → preconditions → hardware/billing/quota constraints) +├── Config mapping (config item → behavior impact → change risk) +└── Error code ownership (error code range → component → investigation direction) ``` -**Step 2: 结构化建模** — 将抽取的关系转化为标准格式: +**Step 2: Structured modeling**. Convert the extracted relationships into standard formats: ``` -关系矩阵 → N×N 表格 -调用链路 → 端到端文本链路 + mermaid 时序图 -状态机 → mermaid stateDiagram-v2 -约束规则 → 决策树(mermaid graph TD) + 汇总表 -配置索引 → 分层表格(配置项/默认值/影响行为/变更风险/生效方式) -三元组 → (Subject, Predicate, Object, Protocol, Scenario) 表格 +Relationship matrix → N×N table +Call chains → end-to-end text chain + mermaid sequence diagram +State machine → mermaid stateDiagram-v2 +Constraint rules → decision tree (mermaid graph TD) + summary table +Config index → layered table (config item / default / behavior impact / change risk / activation) +Triples → (Subject, Predicate, Object, Protocol, Scenario) table ``` -**Step 3: 索引织网** — 建立图谱文档间的交叉引用和检索路由: +**Step 3: Index weaving**. Build cross-references and retrieval routing between the graph documents: ``` README.md: -├── 文档目录表 (文件/大小/核心内容) -├── 按问题类型查找表 (问题类型/示例/查找文档) -└── 检索路由规则建议 (关键词→优先检索文档) +├── Document directory table (file / size / core content) +├── Lookup-by-question-type table (question type / example / document to consult) +└── Retrieval routing rule suggestions (keyword → document to search first) ``` -### 关键模板 +### Key Templates -#### 状态机流转图模板 +#### State Machine Diagram Template ```markdown -## 实例状态机完整流转图 +## Complete Instance State Machine Diagram -### 核心状态流转图 +### Core State Transition Diagram ​```mermaid stateDiagram-v2 [*] --> PENDING: CreateAction - PENDING --> RUNNING: 创建成功 (flag: 2→1) + PENDING --> RUNNING: creation succeeded (flag: 2→1) RUNNING --> STOPPING: StopAction (flag: 1→8) - STOPPING --> STOPPED: 关机成功 (flag: 8→3) + STOPPING --> STOPPED: shutdown succeeded (flag: 8→3) ... ​``` -### 操作-状态约束速查矩阵 -| 操作 \ 当前状态 | RUNNING | STOPPED | PENDING | ... | +### Operation-State Constraint Quick Lookup Matrix +| Operation \ Current state | RUNNING | STOPPED | PENDING | ... | |---------------|:-------:|:-------:|:-------:|:---:| | **Start** | ❌ | ✅ | ❌ | ... | | **Stop** | ✅ | ❌ | ❌ | ... | ``` -#### 业务规则约束矩阵模板 +#### Business Rule Constraint Matrix Template ```markdown -## 操作前置条件矩阵 -| 操作 | 状态要求 | 硬件约束 | 计费约束 | 配额约束 | 其他约束 | +## Operation Precondition Matrix +| Operation | State requirement | Hardware constraint | Billing constraint | Quota constraint | Other constraints | -## 迁移约束决策树 +## Migration Constraint Decision Tree ​```mermaid graph TD - A[迁移请求] --> B{硬件约束1?} - B -->|是| C["❌ 禁止"] - B -->|否| D{硬件约束2?} + A[Migration request] --> B{Hardware constraint 1?} + B -->|Yes| C["❌ Forbidden"] + B -->|No| D{Hardware constraint 2?} ... ​``` -## AI 推理规则速查 +## AI Reasoning Rules Quick Lookup ​```mermaid graph TD - A["用户问:能否执行 XX?"] --> B["Step 1: 状态检查"] - B --> B1{"查操作-状态约束矩阵"} - B1 -->|❌| Z1["不能,状态不支持"] - B1 -->|✅| C["Step 2: 类型检查"] + A["User asks: can XX be executed?"] --> B["Step 1: state check"] + B --> B1{"Look up the operation-state constraint matrix"} + B1 -->|❌| Z1["No, the state does not allow it"] + B1 -->|✅| C["Step 2: type check"] ... ​``` ``` -#### 配置参数索引模板 +#### Config Parameter Index Template ```markdown -## {组件层}配置参数 -| 配置项 | 默认值 | 影响行为 | 变更风险 | 生效方式 | +## {component layer} Config Parameters +| Config item | Default | Behavior impact | Change risk | Activation | |--------|--------|---------|---------|---------| -| `config.key` | value | 描述 | 🟢低/🟡中/🔴高 | 热生效/需重启 | +| `config.key` | value | description | 🟢 low / 🟡 medium / 🔴 high | hot reload / restart required | -## 配置变更影响面速查 -| 变更类型 | 影响范围 | 生效方式 | 回滚策略 | 变更风险 | +## Config Change Impact Surface Quick Lookup +| Change type | Impact scope | Activation | Rollback strategy | Change risk | ``` diff --git a/skill-data/wiki/references/methodology/phase3-ai-enhancement.md b/skill-data/wiki/references/methodology/phase3-ai-enhancement.md index 8ebd799b..ac0c8841 100644 --- a/skill-data/wiki/references/methodology/phase3-ai-enhancement.md +++ b/skill-data/wiki/references/methodology/phase3-ai-enhancement.md @@ -1,164 +1,164 @@ -# Phase 3: AI-Native 增强 — 让知识库对 AI 可理解 +# Phase 3: AI-Native Enhancement, Making the Knowledge Base Understandable to AI -## 1. AI 快速理解表(每份组件文档必备) +## 1. AI Quick Reference Table (required in every component document) -RAG 检索返回的 chunk 通常是文档片段。AI 快速理解表确保无论检索到文档哪个部分,AI 都能在表头获得组件全局上下文。 +The chunk returned by RAG retrieval is usually a fragment of a document. The AI Quick Reference table ensures that no matter which part of the document is retrieved, the AI gets the component's global context from the table at the top. ```markdown -## 🤖 AI 快速理解要点 +## 🤖 AI Quick Reference -| 维度 | 关键信息 | +| Dimension | Key Information | |------|---------| -| **核心职责** | {一句话,不超过 30 字} | -| **架构层级** | {所属层级} → {在层级中的角色} | -| **上游组件** | {组件名(通信方式)} | -| **下游组件** | {组件名(通信方式)} | -| **代码入口** | {入口文件} → {核心函数} | -| **核心机制** | {最重要的 1-2 个技术机制} | -| **互斥控制** | {并发控制方式} | -| **数据流向** | {从哪来 → 经过什么 → 到哪去} | -| **技术栈** | {语言 + 框架 + 中间件} | -| **定时任务** | {N 个定时任务(简述核心任务)} | +| **Core Responsibility** | {one sentence, no more than 30 words} | +| **Architecture Layer** | {layer it belongs to} → {role within that layer} | +| **Upstream Components** | {component (communication method)} | +| **Downstream Components** | {component (communication method)} | +| **Code Entry Point** | {entry file} → {core function} | +| **Core Mechanism** | {the 1-2 most important technical mechanisms} | +| **Mutual Exclusion** | {concurrency control method} | +| **Data Flow** | {where it comes from → what it passes through → where it goes} | +| **Tech Stack** | {language + framework + middleware} | +| **Scheduled Jobs** | {N scheduled jobs (brief description of the core ones)} | ``` -规则: -- 每个维度必须是**具体的**,不能泛泛描述 -- "代码入口"精确到 `文件名 → 函数名` -- "上下游组件"必须标注通信方式 (RPC/MQ/DB) -- 表格放在文档最前面(紧跟标题之后) +Rules: +- Every dimension must be **concrete**, never a generic description +- "Code Entry Point" is precise down to `file name → function name` +- "Upstream/Downstream Components" must state the communication method (RPC/MQ/DB) +- The table goes at the very top of the document (immediately after the title) -## 2. 检索路由规则(主架构文档必备) +## 2. Retrieval Routing Rules (required in the main architecture document) -防止 RAG 检索内外部文档"串台": +Prevents RAG retrieval from "cross-talk" between internal and external documents: ```markdown -## 知识库检索路由指引(AI 专用) - -### 文档分类总览 -| 分类 | 目录位置 | 文档数量 | 内容性质 | -| 【内部·桥梁】产品-代码映射 | ... | N 份 | 核心API意图→约束→链路 | -| 【内部】组件设计文档 | ... | N 份 | 架构设计、代码入口 | -| 【外部】产品 API 文档 | ... | N 份 | 官网 API 参考 | - -### 检索路由规则 -规则 1 — 内部架构优先: 涉及组件名/内部概念 → 仅检索内部文档 -规则 2 — 外部文档适用: 涉及 API 参数/产品限制 → 检索外部文档 -规则 3 — 混合查询: 同时涉及 → 优先内部,辅以外部 -规则 4 — 写代码前先查约束: 必须先检索桥梁文档 - -### 文档优先级 -| 一级(核心) | 产品-代码映射 + 规则速查表 | 写代码前必查 | -| 二级(架构) | 组件设计文档 + 主架构文档 | 理解内部实现 | -| 三级(业务) | 业务架构 + 核心链路 | 理解业务流程 | -| 四级(备查) | 外部 API 原始文档 | 仅在上述不能回答时 | +## Knowledge Base Retrieval Routing Guide (AI only) + +### Document Category Overview +| Category | Directory | Document Count | Content Nature | +| [Internal, Bridge] Product-Code Mapping | ... | N docs | Core API intent → constraints → call chain | +| [Internal] Component Design Documents | ... | N docs | Architecture design, code entry points | +| [External] Product API Documentation | ... | N docs | Official API reference | + +### Retrieval Routing Rules +Rule 1, internal architecture first: involves component names / internal concepts → search internal documents only +Rule 2, external documents apply: involves API parameters / product limits → search external documents +Rule 3, mixed queries: involves both → internal first, supplemented by external +Rule 4, check constraints before writing code: the bridge documents must be searched first + +### Document Priority +| Level 1 (core) | Product-Code Mapping + Rules Cheat Sheet | Must check before writing code | +| Level 2 (architecture) | Component Design Documents + main architecture document | Understand internal implementation | +| Level 3 (business) | Business architecture + core call chains | Understand business flows | +| Level 4 (reference) | Raw external API documentation | Only when the above cannot answer | ``` -## 3. Search Anchor(语义检索锚点) +## 3. Search Anchor (semantic retrieval anchor) -每份文档标题下方添加: +Add below the title of every document: ```html -<!-- search-anchor: 关键词1, 关键词2, 同义词, 英文术语, 中文术语 --> +<!-- search-anchor: keyword1, keyword2, synonym, English term, Chinese term --> ``` -- 包含: 中文名、英文名、缩写、同义词、常见搜索词 -- 数量: 5~15 个 -- 示例: `<!-- search-anchor: RPC契约, Schema, 接口契约, Protobuf, IDL -->` +- Include: Chinese name, English name, abbreviations, synonyms, common search terms +- Count: 5~15 +- Example: `<!-- search-anchor: RPC contract, Schema, interface contract, Protobuf, IDL -->` -## 4. 双向链接织网 +## 4. Bidirectional Link Weaving ```markdown -# 组件文档 → 主架构文档 -> 在整体架构中的位置: [📘 主架构文档 - 4.5 {组件名}](./主架构文档.md#45-组件名) +# Component document → main architecture document +> Position in the overall architecture: [📘 Technical Architecture - 4.5 {component}](./{project_name} Technical Architecture.md#45-component) -# 主架构文档 → 组件文档 -详见 [{组件名}设计说明](./XX_{组件名}设计说明.md) +# Main architecture document → component document +See [{component} Design](./XX_{component}_Design.md) -# 桥梁文档 → 组件文档 -| [{组件名}](./XX_{组件名}设计说明.md) | 入参校验层 | +# Bridge document → component document +| [{component}](./XX_{component}_Design.md) | Input validation layer | ``` -织网规则: -1. 每份组件文档 ≥ 1 个链接指向主架构文档 -2. 主架构文档每个组件提及处有链接指向组件文档 -3. 桥梁文档中提到的每个组件有链接 -4. 反模式文档的"关联组件"有链接 +Weaving rules: +1. Every component document has ≥ 1 link pointing to the main architecture document +2. Every mention of a component in the main architecture document links to the component document +3. Every component mentioned in a bridge document has a link +4. The "Related Components" of anti-pattern documents have links -## 5. QA 对生成(AI 元数据层) +## 5. QA Pair Generation (AI metadata layer) -在主架构文档 AI 专用章节预置高频 QA 对(10~20 个): +Pre-populate high-frequency QA pairs (10~20) in the AI-only section of the main architecture document: ```markdown -- **Q: 核心实体的状态机是如何定义的?** - A: 见 `3.7 实体完整状态机` 及 `9.2.1 全局状态一致性映射表`。 +- **Q: How is the state machine of the core entity defined?** + A: See `3.7 Complete Entity State Machine` and `9.2.1 Global State Consistency Mapping Table`. -- **Q: 流程步骤配置在哪里?异常如何补偿回滚?** - A: 采用 N 级编排。宏观流程在 {配置文件1},细粒度步骤在 {配置文件2}。 +- **Q: Where are the workflow steps configured? How are exceptions compensated and rolled back?** + A: N-level orchestration is used. Macro flows are in {config file 1}, fine-grained steps in {config file 2}. -- **Q: 消息队列的拓扑和路由规则?** - A: 见 `9.3.1 MQ 路由拓扑`。核心 Exchange/Topic 包括 {列表}。 +- **Q: What are the message queue topology and routing rules?** + A: See `9.3.1 MQ Routing Topology`. Core Exchanges/Topics include {list}. -- **Q: 资源互斥(加锁)规范?** - A: 见 `9.4.4 分布式锁与幂等规范`。使用 {锁方案}。 +- **Q: What is the resource mutual exclusion (locking) convention?** + A: See `9.4.4 Distributed Locking and Idempotency Conventions`. {lock scheme} is used. ``` -每个 A 必须包含具体的章节/文档引用。 +Every A must include a concrete section / document reference. -## 6. 图谱文档 AI 增强规范 +## 6. Graph Document AI Enhancement Spec -图谱文档是 AI-Native 知识库的**关系索引层**,专门解决 RAG 在"跨组件关系查询"场景下的检索失败问题。 +Graph documents are the **relationship index layer** of an AI-Native knowledge base. They specifically solve retrieval failures of RAG in "cross-component relationship query" scenarios. -### 6.1 图谱文档 README 必备结构 +### 6.1 Required Structure of the Graph Document README ```markdown -# 图谱文档集 (Graph RAG) -## 与主文档体系的关系 (三层定位表) -## 文档目录 (文件/大小/核心内容) -## 按问题类型查找 (问题类型/示例/查找文档) -## 检索路由规则建议 (关键词→优先检索文档) -## 维护说明 +# Graph Document Set (Graph RAG) +## Relationship to the Main Document System (three-layer positioning table) +## Document Index (file / size / core content) +## Lookup by Question Type (question type / example / document to consult) +## Suggested Retrieval Routing Rules (keyword → document to search first) +## Maintenance Notes ``` -### 6.2 图谱文档 AI 快速理解表 +### 6.2 Graph Document AI Quick Reference Table -每份图谱文档必须在标题后紧跟: +Every graph document must have this immediately after the title: ```markdown -## 🤖 AI 快速理解要点 -| 维度 | 关键信息 | +## 🤖 AI Quick Reference +| Dimension | Key Information | |------|---------| -| **文档定位** | {一句话定位} | -| **核心价值** | {AI 用这份文档能做什么} | -| **覆盖范围** | {覆盖了哪些实体/关系} | -| **使用场景** | {典型问题示例} | -| **与状态机的关系** | {如适用:状态机解决X,本文档解决Y} | +| **Document Positioning** | {one-sentence positioning} | +| **Core Value** | {what the AI can do with this document} | +| **Coverage** | {which entities / relationships are covered} | +| **Usage Scenarios** | {typical example questions} | +| **Relationship to the State Machine** | {if applicable: the state machine solves X, this document solves Y} | ``` -### 6.3 AI 推理规则嵌入 +### 6.3 Embedded AI Reasoning Rules -对于约束类图谱文档,必须嵌入 AI 推理决策流程: +Constraint-type graph documents must embed the AI reasoning decision flow: ```markdown -## AI 推理规则速查 -> AI 判断"某操作能否执行"时,按以下优先级逐层检查: - -1. **状态检查** → 查操作-状态约束矩阵 -2. **类型检查** → 查特殊实例类型约束汇总 -3. **硬件检查** → 查硬件约束详表 -4. **计费检查** → 查计费约束详表 -5. **配额检查** → 查产品规则速查表 -6. **互斥检查** → 是否有进行中的操作 +## AI Reasoning Rules Quick Reference +> When the AI decides "whether an operation can be executed", check layer by layer in this priority order: + +1. **State check** → consult the operation-state constraint matrix +2. **Type check** → consult the special instance type constraint summary +3. **Hardware check** → consult the detailed hardware constraint table +4. **Billing check** → consult the detailed billing constraint table +5. **Quota check** → consult the product rules cheat sheet +6. **Mutual exclusion check** → is there an operation in progress ``` -### 6.4 配置变更检查清单 +### 6.4 Configuration Change Checklist -对于配置类图谱文档,AI 回答"怎么修改 XX 配置"时必须同时告知: +For configuration-type graph documents, when the AI answers "how do I change configuration XX" it must also state: ``` -1. 配置文件位置 — 在哪个文件/仓库中 -2. 影响范围 — 全地域还是单地域/单机 -3. 生效方式 — 热生效还是需要重启 -4. 回滚策略 — 如何快速回滚 -5. 变更风险 — 🟢低 / 🟡中 / 🔴高 -6. 灰度建议 — 是否需要灰度发布 +1. Config file location: which file / repository it lives in +2. Impact scope: all regions, or a single region / single machine +3. Activation method: hot reload, or restart required +4. Rollback strategy: how to roll back quickly +5. Change risk: 🟢 low / 🟡 medium / 🔴 high +6. Canary recommendation: whether a canary release is needed ``` diff --git a/skill-data/wiki/references/methodology/phase4-quality.md b/skill-data/wiki/references/methodology/phase4-quality.md index 7b68eaa0..4ac8f592 100644 --- a/skill-data/wiki/references/methodology/phase4-quality.md +++ b/skill-data/wiki/references/methodology/phase4-quality.md @@ -1,232 +1,232 @@ -# Phase 4: 质量评估与迭代优化 +# Phase 4: Quality Assessment and Iterative Improvement -> 辅助工具: `python3 {SKILL_DIR}/scripts/validate_kb.py <output_dir>` — 自动校验链接完整性、anchor 覆盖率、AI 快速理解表覆盖率、双向链接、README 索引收录率 +> Helper tool: `python3 {SKILL_DIR}/scripts/validate_kb.py <output_dir>` automatically checks link integrity, anchor coverage, AI Quick Reference table coverage, bidirectional links, and README index inclusion rate -## 五维评估模型 +## Five-Dimension Assessment Model -| 维度 | 权重 | 达标标准 | +| Dimension | Weight | Passing standard | |------|------|---------| -| **覆盖率** | 25% | ≥ 90% 核心组件有文档 | -| **深度** | 25% | ≥ 80% 代码入口可直接定位 | -| **一致性** | 20% | 0 死链接,0 矛盾描述 | -| **AI 可用性** | 20% | RAG 检索准确率 ≥ 85% | -| **时效性** | 10% | 核心文档更新滞后 ≤ 30 天 | +| **Coverage** | 25% | ≥ 90% of core components are documented | +| **Depth** | 25% | ≥ 80% of code entries can be located directly | +| **Consistency** | 20% | 0 dead links, 0 contradictory descriptions | +| **AI usability** | 20% | RAG retrieval accuracy ≥ 85% | +| **Freshness** | 10% | Core document update lag ≤ 30 days | -## 覆盖率检查 +## Coverage Check ``` -□ 每个代码仓库有对应的组件设计文档? -□ 每个核心 API 有产品-代码映射? -□ 每个数据表在某份文档中有 Schema 说明? -□ 每个 MQ Exchange/Topic/Queue 在拓扑图中标注? -□ 每个错误码在映射表中? -□ 每个配置项在配置说明中? -□ 每个定时任务在某份文档中说明? +□ Does every code repository have a corresponding component design document? +□ Does every core API have a product-to-code mapping? +□ Does every data table have a schema description in some document? +□ Is every MQ Exchange/Topic/Queue marked in the topology diagram? +□ Is every error code in the mapping table? +□ Is every config item in the configuration description? +□ Is every scheduled task described in some document? ``` -## RAG 检索测试用例 +## RAG Retrieval Test Cases -| 测试类型 | 示例问题 | 期望命中 | +| Test type | Example question | Expected hit | |---------|---------|---------| -| 组件定位 | "{组件名}的代码入口在哪?" | 组件设计文档 | -| 流程追踪 | "{API名}的内部调用链路?" | 产品-代码映射 | -| 约束查询 | "{操作}的批量上限?" | 规则速查表 | -| 状态查询 | "处于{状态}时可执行什么操作?" | 状态互斥规则 | -| 错误排查 | "遇到{错误码}怎么排查?" | 反模式/排障记录 | -| 代码生成 | "写一个{功能}的 Handler" | SOP + 接口契约 | -| 概念辨析 | "{A}和{B}区别?" | 产品知识文库 | +| Component location | "Where is the code entry of {component}?" | Component design document | +| Flow tracing | "What is the internal call chain of {API name}?" | Product-to-code mapping | +| Constraint query | "What is the batch cap of {operation}?" | Rules cheat sheet | +| State query | "Which operations can be executed in state {state}?" | State mutual exclusion rules | +| Error investigation | "How do I investigate {error code}?" | Anti-patterns / troubleshooting records | +| Code generation | "Write a Handler for {feature}" | SOP + interface contracts | +| Concept disambiguation | "What is the difference between {A} and {B}?" | Product knowledge library | -## 增量更新触发表 +## Incremental Update Trigger Table -| 触发条件 | 更新动作 | +| Trigger condition | Update action | |---------|---------| -| 新增代码仓库 | 生成 Type-4 组件文档 | -| API 接口变更 | 更新 Type-5 映射 + Type-6 速查表 | -| 新增产品功能 | 更新 Type-2 业务架构 + Type-8a 知识文库 | -| 线上故障 | 新增 Type-8d 排障记录 + 更新 Type-8b 反模式 | -| 架构重构 | 更新 Type-1 架构总览 + 受影响 Type-4 | -| 配置变更 | 更新对应组件文档的配置章节 | +| New code repository | Generate a Type-4 component document | +| API interface change | Update the Type-5 mapping + Type-6 cheat sheet | +| New product feature | Update the Type-2 business architecture + Type-8a knowledge library | +| Production incident | Add a Type-8d troubleshooting record + update Type-8b anti-patterns | +| Architecture refactoring | Update the Type-1 architecture overview + affected Type-4 documents | +| Config change | Update the configuration section of the corresponding component document | -## 版本管理规范 +## Version Management Convention -每份文档底部维护变更记录: +Maintain a change log at the bottom of every document: ```markdown -## 📝 文档更新记录 +## 📝 Document Change Log ### vX.Y (YYYY-MM-DD) -- ✅ **新增**: {新增内容描述} -- ✅ **修复**: {修复内容描述} -- ✅ **更新**: {更新内容描述} -- ⚠️ **废弃**: {废弃内容描述} +- ✅ **Added**: {description of added content} +- ✅ **Fixed**: {description of fixed content} +- ✅ **Updated**: {description of updated content} +- ⚠️ **Deprecated**: {description of deprecated content} ``` -## 常见质量问题修复 +## Fixing Common Quality Issues -| 问题 | 修复方法 | +| Issue | Fix method | |------|---------| -| 死链接 | 全局 grep `](` 链接,或运行 `python3 {SKILL_DIR}/scripts/validate_kb.py <output_dir>` | -| 术语不一致 | 建立术语表全局替换 | -| 代码入口过时 | 定期与代码仓库 diff | -| 约束值过时 | 定期与产品文档交叉比对 | -| AI 检索失败 | 补充 search-anchor 关键词 | -| 文档孤岛 | 补充双向链接 | +| Dead links | Grep `](` links globally, or run `python3 {SKILL_DIR}/scripts/validate_kb.py <output_dir>` | +| Inconsistent terminology | Build a glossary and replace globally | +| Outdated code entries | Diff against the code repositories periodically | +| Outdated constraint values | Cross-check against the product docs periodically | +| AI retrieval failures | Add search-anchor keywords | +| Isolated documents | Add bidirectional links | --- -## 完整生成流水线 Checklist +## Complete Generation Pipeline Checklist -### Phase 0 Checklist: 源材料采集 +### Phase 0 Checklist: Source Material Collection ``` -□ 所有核心代码仓库已克隆 -□ 产品 API 文档已采集 (接口名/入参/出参/错误码) -□ 产品使用文档已采集 (使用限制/FAQ/计费说明) -□ 数据库 Schema 已提取 (DDL/表结构) -□ 流程编排配置已提取 (workflow_config 等) -□ Proto/IDL 文件已提取 -□ 错误码定义已提取 +□ All core code repositories cloned +□ Product API docs collected (interface name / inputs / outputs / error codes) +□ Product usage docs collected (usage limits / FAQ / billing description) +□ Database schema extracted (DDL / table schemas) +□ Workflow orchestration configs extracted (workflow_config etc.) +□ Proto/IDL files extracted +□ Error code definitions extracted ``` -### Phase 1 Checklist: 架构逆向工程 +### Phase 1 Checklist: Architecture Reverse-Engineering ``` -□ 代码知识图谱已构建 (节点+边) -□ 架构分层已确定 (≥4 层) -□ 组件关系矩阵已构建 (N×N) -□ 核心调用链已追踪 (≥5 条核心 API) -□ MQ 拓扑已推断 (Exchange/Topic/Queue/Routing Key) -□ 数据库 ER 模型已构建 -□ 术语表已整理 (内外部映射) +□ Code knowledge graph built (nodes + edges) +□ Architecture layers determined (≥4 layers) +□ Component relationship matrix built (N×N) +□ Core call chains traced (≥5 core APIs) +□ MQ topology inferred (Exchange/Topic/Queue/Routing Key) +□ Database ER model built +□ Glossary compiled (external-to-internal mappings) ``` -### Phase 2 Checklist: 文档生成 +### Phase 2 Checklist: Document Generation ``` -□ [Type-1] 技术架构总览文档 (1份) - □ 包含读者导航指南 - □ 包含 AI 检索路由规则 - □ 包含核心链路时序图 (≥5 条) - □ 包含组件关系矩阵 - □ 包含 AI 专用第 9 章 - □ 包含术语表 - -□ [Type-2] 业务架构文档 (1份) - □ 包含产品能力矩阵 - □ 包含计费模型(如适用) - □ 包含核心实体生命周期状态机 - -□ [Type-3] 部署架构文档 (1份) - □ 包含服务部署矩阵 - □ 包含环境配置 - -□ [Type-4] 组件设计文档 (N份) - □ 每份包含 AI 快速理解表 - □ 每份包含双向链接 - □ 每份包含代码入口 (精确到函数) - □ 每份包含架构图 (ASCII Art) - □ 每份包含核心流程说明 - -□ [Type-5] 产品-代码映射文档 - □ 覆盖所有核心 API - □ 每个 API 包含约束表 - □ 每个 API 包含调用链路 - □ 每个 API 包含错误码映射 - -□ [Type-6] 产品规则速查表 - □ 覆盖所有规则类别 - □ 约束值精确 - □ 包含状态互斥矩阵 - -□ [Type-7] 业务开发规范 SOP - □ 包含可运行的代码模板 - □ 包含错误码对照表 - □ 包含 AI 评审 CheckList - -□ [Type-8] 知识增强文档 - □ [8a] 产品知识文库 (概念辨析) - □ [8b] 反模式与踩坑指南 - □ [8c] RPC 接口契约 - □ [8d] 排障案例记录 +□ [Type-1] Technical architecture overview document (1) + □ Includes the reader navigation guide + □ Includes AI retrieval routing rules + □ Includes core call chain sequence diagrams (≥5) + □ Includes the component relationship matrix + □ Includes the AI-only chapter 9 + □ Includes the glossary + +□ [Type-2] Business architecture document (1) + □ Includes the product capability matrix + □ Includes the billing model (if applicable) + □ Includes the core entity lifecycle state machine + +□ [Type-3] Deployment architecture document (1) + □ Includes the service deployment matrix + □ Includes environment configuration + +□ [Type-4] Component design documents (N) + □ Each includes an AI Quick Reference table + □ Each includes bidirectional links + □ Each includes code entries (precise to the function) + □ Each includes an architecture diagram (ASCII Art) + □ Each includes core flow descriptions + +□ [Type-5] Product-to-code mapping document + □ Covers all core APIs + □ Each API includes a constraint table + □ Each API includes a call chain + □ Each API includes an error code mapping + +□ [Type-6] Product rules cheat sheet + □ Covers all rule categories + □ Constraint values are exact + □ Includes the state mutual exclusion matrix + +□ [Type-7] Business development SOP + □ Includes runnable code templates + □ Includes the error code mapping table + □ Includes the AI review checklist + +□ [Type-8] Knowledge enhancement documents + □ [8a] Product knowledge library (concept disambiguation) + □ [8b] Anti-patterns and pitfalls guide + □ [8c] RPC interface contracts + □ [8d] Troubleshooting case records ``` -### Phase 3 Checklist: AI-Native 增强 +### Phase 3 Checklist: AI-Native Enhancement ``` -□ 所有组件文档包含 AI 快速理解表 -□ 主架构文档包含检索路由规则 -□ 所有文档包含 search-anchor -□ 双向链接网络完整 (0 死链接) -□ QA 对已生成 (10~20 个) -□ 文档优先级已定义 +□ All component documents include an AI Quick Reference table +□ The Technical Architecture document includes retrieval routing rules +□ All documents include a search-anchor +□ Bidirectional link network complete (0 dead links) +□ QA pairs generated (10~20) +□ Document priorities defined ``` -### Phase 3b Checklist: 图谱文档集 (Graph RAG) +### Phase 3b Checklist: Graph Document Set (Graph RAG) ``` -□ [G1] 组件依赖关系矩阵 - □ N×N 通信矩阵完整 - □ 正向/反向依赖索引 - □ 外部服务依赖 - -□ [G2] 组件调用链路全景 + 状态机 - □ 核心 API 端到端链路 (读+写) - □ 完整 mermaid 状态机流转图 - □ 核心状态字段值流转路径表(如有内部状态码) - □ 用户可见状态↔内部状态映射关系(如有多层状态) - □ 操作-状态约束速查矩阵 (✅/❌) - □ AI 状态判断推理规则 - -□ [G3] 数据流与存储依赖图 - □ 存储系统依赖矩阵 - □ MQ 队列拓扑 - □ 缓存策略矩阵 - -□ [G4] 错误码组件映射表 - □ 错误码段分配表 - □ 外部→内部错误码映射 - -□ [G5] 跨组件交互场景手册 - □ ≥10 个场景的 mermaid 时序图 - □ 每个场景有异常处理 - -□ [G6] 知识图谱三元组 - □ Ontology 定义 (实体类型+关系类型) - □ 显式三元组 ≥100 条 - □ 多跳依赖路径索引 - □ 反向可达索引 - -□ [G7] 架构风险与影响面分析 - □ 组件风险等级总表 - □ 爆炸半径分析 (≥3 个关键组件) - □ 聚类分析 - □ 变更风险评估矩阵 - -□ [G8] 核心配置参数索引 - □ 分层配置架构图 (mermaid) - □ 每层配置参数表 (配置项/默认值/影响行为/变更风险/生效方式) - □ 配置变更影响面速查矩阵 - -□ [G9] 业务规则约束矩阵 - □ 操作前置条件矩阵 - □ 硬件约束详表 - □ 迁移约束决策树 (mermaid) - □ 计费约束详表 - □ 特殊实例类型约束汇总 (✅/❌/⚠️) - □ AI 推理规则速查 (mermaid 流程图) - -□ 图谱目录 README.md 索引完整 - □ 按问题类型查找表 - □ 检索路由规则建议 +□ [G1] Component Dependency Matrix + □ N×N communication matrix complete + □ Forward/reverse dependency index + □ External service dependencies + +□ [G2] Component Call Chain Overview + state machine + □ End-to-end core API chains (read + write) + □ Complete mermaid state machine diagram + □ Core state field value transition path table (if internal state codes exist) + □ User-visible state ↔ internal state mapping (if multi-layer states exist) + □ Operation-state constraint quick lookup matrix (✅/❌) + □ AI state reasoning rules + +□ [G3] Data Flow and Storage Dependencies + □ Storage system dependency matrix + □ MQ queue topology + □ Cache strategy matrix + +□ [G4] Error Code Component Map + □ Error code range allocation table + □ External → internal error code mapping + +□ [G5] Cross-Component Interaction Scenarios + □ mermaid sequence diagrams for ≥10 scenarios + □ Every scenario has exception handling + +□ [G6] Knowledge Graph Triples + □ Ontology definition (entity types + relationship types) + □ ≥100 explicit triples + □ Multi-hop dependency path index + □ Reverse reachability index + +□ [G7] Architecture Risks and Impact Analysis + □ Component risk level summary table + □ Blast radius analysis (≥3 key components) + □ Cluster analysis + □ Change risk assessment matrix + +□ [G8] Core Config Parameter Index + □ Layered config architecture diagram (mermaid) + □ Config parameter table per layer (config item / default / behavior impact / change risk / activation) + □ Config change impact surface quick lookup matrix + +□ [G9] Business Rule Constraint Matrix + □ Operation precondition matrix + □ Detailed hardware constraint table + □ Migration constraint decision tree (mermaid) + □ Detailed billing constraint table + □ Special instance type constraint summary (✅/❌/⚠️) + □ AI reasoning rules quick lookup (mermaid flowchart) + +□ Graph directory README.md index complete + □ Lookup-by-question-type table + □ Retrieval routing rule suggestions ``` -### Phase 4 Checklist: 质量评估 +### Phase 4 Checklist: Quality Assessment ``` -□ 覆盖率 ≥ 90% -□ 代码入口精确度 ≥ 80% -□ 死链接 = 0 (运行 validate_kb.py 确认) -□ RAG 检索准确率 ≥ 85% -□ 核心文档更新滞后 ≤ 30 天 -□ 术语一致性检查通过 +□ Coverage ≥ 90% +□ Code entry precision ≥ 80% +□ Dead links = 0 (confirm by running validate_kb.py) +□ RAG retrieval accuracy ≥ 85% +□ Core document update lag ≤ 30 days +□ Terminology consistency check passed ``` diff --git a/skill-data/wiki/references/overview.md b/skill-data/wiki/references/overview.md index 21ded62a..27a2d38e 100644 --- a/skill-data/wiki/references/overview.md +++ b/skill-data/wiki/references/overview.md @@ -1,124 +1,124 @@ -# team-wiki-codebase — 大型代码库 AI 认知工程 +# team-wiki-codebase: AI cognition engineering for large codebases -> TeamAI builtin skill:方法论、脚本与 Agent 规范随 `teamai pull` / `teamai init` 部署到项目的 `.codebuddy/`、`.cursor/` 等目录。TeamAI does not ship a separate team-wiki CLI. No extra plugin is required. +> TeamAI builtin skill: the methodology, scripts and Agent specifications are deployed with `teamai pull` / `teamai init` into the project's `.codebuddy/`, `.cursor/` and similar directories. TeamAI does not ship a separate team-wiki CLI. No extra plugin is required. -## 为什么需要这个 skill +## Why this skill exists -大型项目的 AI 理解困境: +The AI comprehension problem of large projects: -| 痛点 | 具体表现 | +| Pain point | Symptom | |------|---------| -| **上下文装不下** | 10+ 仓库、数十万行代码,远超 AI 上下文窗口 | -| **关系看不清** | 微服务间的 RPC/MQ/DB 依赖散落在各仓库,没有全局视图 | -| **规则记不住** | 业务约束、状态机、配置参数隐藏在深层调用链中 | -| **回答不准确** | AI 只看到局部代码,缺乏全局架构认知,容易幻觉 | -| **token 消耗大** | 每次提问都要重新读大量源码,效率极低 | +| **Context does not fit** | 10+ repositories and hundreds of thousands of lines of code, far beyond the AI context window | +| **Relations are unclear** | RPC/MQ/DB dependencies between microservices are scattered across repositories with no global view | +| **Rules are not remembered** | Business constraints, state machines and config parameters hide deep in call chains | +| **Answers are inaccurate** | AI sees only local code, lacks global architecture awareness, and hallucinates easily | +| **High token consumption** | Every question re-reads large amounts of source, which is very inefficient | -## 怎么解决 +## How it is solved -通过架构逆向工程,将海量代码**压缩为结构化知识库**: +Architecture reverse-engineering **compresses the huge codebase into a structured knowledge base**: -- 每个结论有代码 `文件:行号` 作为证据 -- 每条组件关系有置信度标注(`EXTRACTED` / `INFERRED` / `AMBIGUOUS`) -- 每次生成后有准确性统计,超标自动警告 -- AI 读知识库而非读源码,**约 1/50 的 token 消耗**获得全局架构认知 -- Phase 0 可用 `teamai codebase --extract` 生成可证据化的结构边(TS/JS/Python/Go AST + 多语言 heuristic) -- 提取后可用 `teamai codebase --deep-enrich --project <slug> --output <repo>` 生成确定性图谱文档(G1/G2/G3)与深度知识;无需单独的 team-wiki CLI +- Every conclusion has a code `file:line` as evidence +- Every component relation carries a confidence label (`EXTRACTED` / `INFERRED` / `AMBIGUOUS`) +- Every generation run produces accuracy statistics, with automatic warnings when thresholds are exceeded +- AI reads the knowledge base instead of the source and gains global architecture awareness for **about 1/50 of the tokens** +- In Phase 0, `teamai codebase --extract` can generate evidence-backed structural edges (TS/JS/Python/Go AST + multi-language heuristics) +- After extraction, `teamai codebase --deep-enrich --project <slug> --output <repo>` can generate deterministic graph documents (G1/G2/G3) and deep knowledge; no separate team-wiki CLI is needed --- -## 产出体系 +## Deliverables ``` <output_dir>/ -├── README.md ← 检索路由指引(AI 专用) -├── {项目名} 技术架构.md ← 系统全貌,~200KB -├── {项目名} 业务架构.md ← 产品能力 + 生命周期 -├── {项目名} 部署架构.md ← 部署拓扑 -├── XX_{组件名}设计说明.md × N ← 每组件一份,含 AI 快速理解表 -├── XX_{项目名}核心API产品代码映射.md ← 产品约束→代码位置 桥梁文档 -├── XX_{项目名}产品规则速查表.md -├── XX_{项目名}业务开发规范SOP.md -├── {反模式/RPC契约/排障记录} × N -├── _manifest.json ← 机器可读 manifest(供后续图谱合并) -└── graph/ ← Graph RAG 图谱文档集 - ├── G1 组件依赖关系矩阵 - ├── G2 调用链路全景 + 状态机 - ├── G3 数据流与存储依赖图 - ├── G4 错误码组件映射表 - ├── G5 跨组件交互场景手册(≥10个时序图) - ├── G6 知识图谱三元组(≥100条,含置信度) - ├── G7 架构风险与影响面分析 - ├── G8 核心配置参数索引 - └── G9 业务规则约束矩阵 + AI 推理决策树 +├── README.md ← Retrieval routing guide (for AI) +├── {project_name} Technical Architecture.md ← Whole-system view, ~200KB +├── {project_name} Business Architecture.md ← Product capabilities + lifecycle +├── {project_name} Deployment Architecture.md ← Deployment topology +├── XX_{component}_Design.md × N ← One per component, with the AI Quick Reference table +├── XX_{project_name}_Core_API_Product_Code_Mapping.md ← Product constraint → code location bridge document +├── XX_{project_name}_Product_Rules_Cheat_Sheet.md +├── XX_{project_name}_Business_Development_SOP.md +├── {anti-patterns / RPC contracts / troubleshooting notes} × N +├── _manifest.json ← Machine-readable manifest (for later graph merging) +└── graph/ ← Graph RAG graph document set + ├── G1 Component dependency matrix + ├── G2 Call chain overview + state machines + ├── G3 Data flow and storage dependencies + ├── G4 Error code component map + ├── G5 Cross-component interaction scenarios (≥10 sequence diagrams) + ├── G6 Knowledge graph triples (≥100 entries, with confidence) + ├── G7 Architecture risks and impact analysis + ├── G8 Core config parameter index + └── G9 Business rule constraint matrix + AI reasoning decision tree ``` --- -## 执行流程 +## Execution flow ``` -Phase 0 → 初始化:收集路径、项目名、产品文档来源;可选 CLI ast+heuristic 结构基线 - -Phase K1 → 架构逆向:关键文件提取 → 分层分析 → 组件关系矩阵 - ⛔ 确认点① 架构理解确认 - -Phase K2 → 文档生成(分批并行): - 批次1~4: Type-4 组件文档(并行子 Agent 分发) - ⛔ 确认点② 文档质量抽查 - 批次5~7: 架构总览 + 桥梁文档 + 知识增强 - -Phase K3 → AI-Native 增强: - search-anchor + 双向链接 + 检索路由规则 - Graph RAG 图谱文档集 G1~G9(置信度三态标注) - -Phase K4 → 质量评估: - validate_kb.py 自动检验 - 全库准确性审计([UNVERIFIED] 统计 + 接口覆盖率) - 跨文档一致性校验(矛盾检测 + 自动修复) - RAG 检索抽检(7类问题) - AI 端到端验证(10~15 个标准问题 + 代码回溯) - 生成质量报告 +Phase 0 → Initialisation: collect paths, project name, product doc sources; optional CLI ast+heuristic structural baseline + +Phase K1 → Architecture reverse-engineering: key file extraction → layered analysis → component relation matrix + ⛔ Confirmation point ① Architecture understanding + +Phase K2 → Document generation (parallel batches): + Batches 1~4: Type-4 component documents (dispatched to parallel sub-agents) + ⛔ Confirmation point ② Document quality spot check + Batches 5~7: architecture overview + bridge documents + knowledge enhancement + +Phase K3 → AI-Native enhancement: + search-anchor + bidirectional links + retrieval routing rules + Graph RAG graph document set G1~G9 (three-state confidence labels) + +Phase K4 → Quality assessment: + validate_kb.py automatic checks + Whole-base accuracy audit ([UNVERIFIED] statistics + interface coverage) + Cross-document consistency check (contradiction detection + automatic fixes) + RAG retrieval spot check (7 question types) + AI end-to-end validation (10~15 standard questions + code trace-back) + Quality report generation ``` -支持 `--update` 增量更新(基于文件 hash 缓存,只重跑变更组件)。 +Supports `--update` incremental updates (based on a file hash cache, rerunning only changed components). --- -## 文件结构 +## File structure -以下文件随 CLI 一起发布;`teamai skill path wiki` 打印其所在目录(本文中的 `{SKILL_DIR}`)。 +The files below ship with the CLI; `teamai skill path wiki` prints the directory that contains them (`{SKILL_DIR}` in this document). ``` {SKILL_DIR}/ -├── SKILL.md ← 主执行指令(`teamai skill get wiki`) +├── SKILL.md ← Main execution instructions (`teamai skill get wiki`) ├── scripts/ -│ ├── scan_repo.py ← 仓库扫描辅助工具 -│ └── validate_kb.py ← 知识库质量校验工具 +│ ├── scan_repo.py ← Repository scan helper +│ └── validate_kb.py ← Knowledge base quality validation tool ├── references/ -│ ├── overview.md ← 本文件 +│ ├── overview.md ← This file │ ├── agents/ -│ │ ├── kb-doc-generator.md ← Type-1~8 文档生成专职 Agent -│ │ └── graph-rag-agent.md ← G1~G9 图谱文档专职 Agent +│ │ ├── kb-doc-generator.md ← Dedicated Agent for Type-1~8 document generation +│ │ └── graph-rag-agent.md ← Dedicated Agent for G1~G9 graph documents │ ├── methodology/ -│ │ ├── phase0-collection.md ← 源材料采集方法 -│ │ ├── phase1-reverse-engineering.md ← 架构逆向工程方法 -│ │ ├── phase2-document-types.md ← 九大文档类型规范与质量标准 -│ │ ├── phase3-ai-enhancement.md ← AI-Native 增强方法 -│ │ └── phase4-quality.md ← 质量评估 Checklist -│ ├── phases/ ← 各 Phase 的执行步骤 +│ │ ├── phase0-collection.md ← Source material collection method +│ │ ├── phase1-reverse-engineering.md ← Architecture reverse-engineering method +│ │ ├── phase2-document-types.md ← Specification and quality standards of the nine document types +│ │ ├── phase3-ai-enhancement.md ← AI-Native enhancement method +│ │ └── phase4-quality.md ← Quality assessment checklist +│ ├── phases/ ← Execution steps of each Phase │ └── templates/ -│ └── project-overview.md ← 知识库 README 模板(含认知边界声明) +│ └── project-overview.md ← Knowledge base README template (with cognitive boundary declaration) ``` --- -## 质量标准 +## Quality standards -| 维度 | 达标标准 | +| Dimension | Passing standard | |------|---------| -| 覆盖率 | ≥90% P0 核心组件有文档 | -| 准确性 | [UNVERIFIED] < 15% | -| 结构质量 | 死链接=0,search-anchor 覆盖率≥95% | -| AI 可用性 | RAG 检索抽检准确率≥85% | -| 关系可信度 | AMBIGUOUS 关系 < 10%,全部列入待确认清单 | +| Coverage | ≥90% of P0 core components have documents | +| Accuracy | [UNVERIFIED] < 15% | +| Structural quality | Dead links = 0, search-anchor coverage ≥95% | +| AI usability | RAG retrieval spot check accuracy ≥85% | +| Relation trustworthiness | AMBIGUOUS relations < 10%, all listed for confirmation | diff --git a/skill-data/wiki/references/phases/k1-reverse-engineering.md b/skill-data/wiki/references/phases/k1-reverse-engineering.md index 7333b942..8746fc41 100644 --- a/skill-data/wiki/references/phases/k1-reverse-engineering.md +++ b/skill-data/wiki/references/phases/k1-reverse-engineering.md @@ -1,81 +1,81 @@ -## Phase K1:架构逆向与源材料采集 +## Phase K1: Architecture Reverse-Engineering and Source Material Collection -**方法论**:`{SKILL_DIR}/references/methodology/phase0-collection.md` + `{SKILL_DIR}/references/methodology/phase1-reverse-engineering.md` +**Methodology**: `{SKILL_DIR}/references/methodology/phase0-collection.md` + `{SKILL_DIR}/references/methodology/phase1-reverse-engineering.md` -### Step 1:可选运行扫描脚本(推荐) +### Step 1: Optionally run the scan script (recommended) ```bash python3 {SKILL_DIR}/scripts/scan_repo.py <project_root> --depth 2 --top 10 ``` -输出:文件统计 + 关键文件发现报告 + 语言分布。 +Output: file statistics + key file discovery report + language distribution. -### Step 2:关键文件提取 +### Step 2: Key file extraction -按优先级扫描(详见 phase0-collection.md): -- **P0 必须**:入口文件、路由/Handler、流程编排配置、Proto/IDL -- **P1 重要**:数据库 Schema(DDL)、常量/错误码定义 -- **P2 增强**:配置文件、测试文件(理解预期行为) +Scan by priority (see phase0-collection.md for details): +- **P0 required**: entry files, routes/handlers, workflow orchestration config, Proto/IDL +- **P1 important**: database schema (DDL), constant / error code definitions +- **P2 enhancement**: config files, test files (to understand expected behaviour) -### Step 3:架构逆向(详见 phase1-reverse-engineering.md) +### Step 3: Architecture reverse-engineering (see phase1-reverse-engineering.md for details) -- 自底向上分层:叶子节点(DB/MQ) → 中间节点(编排/调度) → 根节点(API入口) -- 三层穿透追踪:对核心 API ≥5 条完成 API入口→编排层→服务执行层 全链路追踪 -- 构建 N×N 组件关系矩阵(标注通信方式:RPC/MQ/DB) +- Bottom-up layering: leaf nodes (DB/MQ) → intermediate nodes (orchestration/scheduling) → root nodes (API entry points) +- Three-layer penetration tracing: for ≥5 core APIs, complete the full call chain trace API entry → orchestration layer → service execution layer +- Build the N×N component relationship matrix (annotate the communication method: RPC/MQ/DB) -### Step 4:生成架构分析报告 +### Step 4: Generate the architecture analysis report -写入 `_review/k1-architecture-map.md`: +Write to `_review/k1-architecture-map.md`: ```markdown -## 架构分层(≥4层) -| 层级 | 组件列表 | 核心职责 | 代码仓库 | +## Architecture Layers (≥4 layers) +| Layer | Components | Core Responsibility | Code Repository | -## 组件清单 -| 组件名 | 架构层级 | **所属仓库** | 语言 | 核心度(P0/P1/P2) | 入口文件 | **接口校验类型** | +## Component Inventory +| Component | Architecture Layer | **Repository** | Language | Criticality (P0/P1/P2) | Entry File | **Interface Check Type** | -接口校验类型取值(在确认点①请用户核对此列): - - `HTTP` → API 接入层,有 HTTP/gRPC 路由注册,需做接口数对账 - - `MQ` → 消息处理层,有 MQ Consumer/Exchange 声明,以 Topic 数做基准 - - `RPC` → 内部服务层,有 .proto / .thrift / IDL 文件,以 Method 数做基准 - - `NONE` → 调度/执行/数据层,无对外接口,不做接口数校验 +Interface check type values (ask the user to verify this column at confirmation point ①): + - `HTTP` → API access layer, has HTTP/gRPC route registrations, requires interface count reconciliation + - `MQ` → message processing layer, has MQ Consumer/Exchange declarations, Topic count is the baseline + - `RPC` → internal service layer, has .proto / .thrift / IDL files, Method count is the baseline + - `NONE` → scheduling / execution / data layer, no external interface, no interface count check -## N×N 组件通信矩阵 -(值:RPC/MQ/DB/—,标注置信度 [E]EXTRACTED/[I]INFERRED/[A]AMBIGUOUS) +## N×N Component Communication Matrix +(values: RPC/MQ/DB/—, annotated with confidence [E]EXTRACTED/[I]INFERRED/[A]AMBIGUOUS) -## 核心调用链路(≥5条) -(格式:API(file:line) → 编排层(config:line) → 服务层(handler:line) → DB(table)) +## Core Call Chains (≥5) +(format: API(file:line) → orchestration layer(config:line) → service layer(handler:line) → DB(table)) -## 术语表 -| 内部术语 | 外部/产品术语 | 说明 | +## Glossary +| Internal Term | External / Product Term | Notes | -## 不确定项(供人工确认) -(标注 [A] 的关系和推断,说明不确定原因) -(接口校验类型不确定的组件,标注 [?] 等用户在确认点①明确) +## Uncertain Items (for manual confirmation) +(relationships and inferences marked [A], with the reason for the uncertainty) +(components whose interface check type is uncertain, marked [?], to be clarified by the user at confirmation point ①) ``` -### Step 5:接口清单扫描(按校验类型分别执行) +### Step 5: Interface inventory scan (run separately per check type) -**仅对 k1-architecture-map.md 中接口校验类型 ≠ NONE 的组件执行**: +**Run only for components whose interface check type in k1-architecture-map.md is ≠ NONE**: ``` -FOR 每个 接口校验类型 = HTTP 的组件: - 执行 grep 扫描: +FOR each component with interface check type = HTTP: + Run a grep scan: Go: grep -rn "\.GET\|\.POST\|\.PUT\|\.DELETE\|router\.Handle\|@handler" <component_dir> Python: grep -rn "@app\.route\|@router\.\|APIRouter\|include_router" <component_dir> - 记录:组件名 → HTTP接口数 N(SCAN_CONFIDENCE: HIGH/MEDIUM) + Record: component → HTTP interface count N (SCAN_CONFIDENCE: HIGH/MEDIUM) -FOR 每个 接口校验类型 = MQ 的组件: - 执行 grep 扫描: +FOR each component with interface check type = MQ: + Run a grep scan: grep -rn "Exchange\|Queue\|Topic\|consumer\|subscribe\|@KafkaListener" <component_dir> - 记录:组件名 → MQ Topic/Queue 数 N + Record: component → MQ Topic/Queue count N -FOR 每个 接口校验类型 = RPC 的组件: - 解析 .proto / .thrift 文件: +FOR each component with interface check type = RPC: + Parse the .proto / .thrift files: find <component_dir> -name "*.proto" -o -name "*.thrift" | xargs grep "^rpc\|^service" - 记录:组件名 → RPC Method 数 N + Record: component → RPC Method count N ``` -结果写入 `_review/interface-inventory.json`: +Write the results to `_review/interface-inventory.json`: ```json { "ComponentA": {"type": "HTTP", "count": 13, "confidence": "HIGH"}, @@ -85,34 +85,34 @@ FOR 每个 接口校验类型 = RPC 的组件: } ``` -**完成后**:更新 `current_phase` 为 `"phasek1_waiting_confirm"`。 +**When done**: update `current_phase` to `"phasek1_waiting_confirm"`. -**⛔ 确认点①** — 等待用户明确回复,不得自动进入下一阶段。 +**⛔ Confirmation point ①**: wait for an explicit reply from the user. Do not proceed to the next phase automatically. -展示给用户: +Show the user: ``` -架构分析完成。 - -组件清单(共 N 个): - P0 核心: [列表] - P1 重要: [列表] - P2 辅助: [列表] - -接口扫描结果(供校验用): - HTTP 接口:ComponentA 13个, ComponentB 7个 - MQ Topic: ComponentC 5个 - RPC Method:ComponentD 8个 - 无接口组件:ComponentE, ComponentF, ... - -AMBIGUOUS 关系(请明确): - - ComponentX → ComponentY 的通信方式不确定 - -请确认(直接编辑 k1-architecture-map.md 后回复"继续"): - 1. 架构分层和 P0/P1/P2 标注是否正确? - 2. 每个组件的接口校验类型(HTTP/MQ/RPC/NONE)是否准确? - 3. 接口扫描数量是否合理?明显偏少说明有遗漏,偏多可能扫到了测试文件。 +Architecture analysis complete. + +Component inventory (N in total): + P0 core: [list] + P1 important: [list] + P2 auxiliary: [list] + +Interface scan results (for verification): + HTTP interfaces: ComponentA 13, ComponentB 7 + MQ Topics: ComponentC 5 + RPC Methods: ComponentD 8 + Components without interfaces: ComponentE, ComponentF, ... + +AMBIGUOUS relationships (please clarify): + - The communication method of ComponentX → ComponentY is uncertain + +Please confirm (edit k1-architecture-map.md directly, then reply "continue"): + 1. Are the architecture layers and P0/P1/P2 annotations correct? + 2. Is the interface check type (HTTP/MQ/RPC/NONE) of every component accurate? + 3. Are the interface scan counts reasonable? Clearly too few means something was missed; too many may mean test files were scanned. ``` -确认后:更新 `"phasek1_confirmed"` → Phase K2。 +After confirmation: update to `"phasek1_confirmed"` → Phase K2. --- diff --git a/skill-data/wiki/references/phases/k2-documents.md b/skill-data/wiki/references/phases/k2-documents.md index 43e9628e..5edaa159 100644 --- a/skill-data/wiki/references/phases/k2-documents.md +++ b/skill-data/wiki/references/phases/k2-documents.md @@ -1,68 +1,68 @@ -## Phase K2:文档生成(分批并行 + 中间质量确认) +## Phase K2: Document Generation (batched parallel runs + mid-way quality confirmation) -**方法论**:`{SKILL_DIR}/references/methodology/phase2-document-types.md` +**Methodology**: `{SKILL_DIR}/references/methodology/phase2-document-types.md` -### 生成顺序(依赖链驱动,底层先写) +### Generation order (dependency-chain driven, lower layers first) ``` -批次1: 数据层 + 基础执行层 Type-4 组件文档 ← 并行 -批次2: 资源/调度层 Type-4 组件文档 ← 并行 -批次3: 消息/服务层 Type-4 组件文档 ← 并行 -批次4: API入口层 Type-4 组件文档 ← 并行 - ⛔ 确认点② ← 人工抽查组件文档质量 -批次5: 架构总览层 (Type-1 + Type-2 + Type-3) ← 串行(依赖上层全部完成) -批次6: 桥梁文档 (Type-5 + Type-6 + Type-7) ← 串行(依赖产品文档) -批次7: 知识增强 (Type-8: 反模式/RPC契约/排障) ← 串行 +Batch 1: data layer + basic execution layer Type-4 component documents ← parallel +Batch 2: resource / scheduling layer Type-4 component documents ← parallel +Batch 3: messaging / service layer Type-4 component documents ← parallel +Batch 4: API entry layer Type-4 component documents ← parallel + ⛔ Confirmation point ② ← manual spot check of component document quality +Batch 5: architecture overview layer (Type-1 + Type-2 + Type-3) ← serial (depends on all layers above being complete) +Batch 6: bridge documents (Type-5 + Type-6 + Type-7) ← serial (depends on product documentation) +Batch 7: knowledge enhancement (Type-8: anti-patterns / RPC contracts / troubleshooting) ← serial ``` -### 每批执行流程 +### Execution flow for each batch -读取 `{SKILL_DIR}/references/agents/kb-doc-generator.md`,拼装输入包并启动: +Read `{SKILL_DIR}/references/agents/kb-doc-generator.md`, assemble the input package and launch: ``` -component_list: 本批次组件/文档类型列表 -architecture_map: _review/k1-architecture-map.md 完整内容 -repos: _review/repo-manifest.json 中的仓库列表 -service_map: progress.json 中的 service_map +component_list: list of components / document types for this batch +architecture_map: full content of _review/k1-architecture-map.md +repos: repository list from _review/repo-manifest.json +service_map: service_map from progress.json output_dir: <Phase 0> project_name: <Phase 0> -product_docs_dir: <Phase 0,可为空> +product_docs_dir: <Phase 0, may be empty> methodology_dir: {SKILL_DIR}/references/methodology/ -completed_docs: kb_progress.components_done(断点恢复跳过) -parallel_mode: true(批次1~4)/ false(批次5~7) +completed_docs: kb_progress.components_done (skipped on resume from checkpoint) +parallel_mode: true (batches 1~4) / false (batches 5~7) ``` -每批完成后: -- 将完成组件追加到 `kb_progress.components_done` -- 累加 `accuracy_stats`(从 Agent 返回的自校验摘要中提取) -- 更新 `current_phase` 为 `"phasek2_batch_N"` -- 展示本批次 token 消耗和 `[UNVERIFIED]` 统计 +After each batch completes: +- Append the completed components to `kb_progress.components_done` +- Accumulate `accuracy_stats` (extracted from the self-check summary returned by the Agent) +- Update `current_phase` to `"phasek2_batch_N"` +- Show the token consumption and `[UNVERIFIED]` statistics for this batch -### ⛔ 确认点②(批次1~4完成后) +### ⛔ Confirmation point ② (after batches 1~4 complete) -展示给用户: +Show the user: ``` -已生成 {N} 份组件设计文档。准确性统计: - 总声明数: {N} | 已验证: {N} | [UNVERIFIED]: {N}({X}%) - AMBIGUOUS 关系: {N} 条 +{N} component design documents generated. Accuracy statistics: + Total claims: {N} | Verified: {N} | [UNVERIFIED]: {N} ({X}%) + AMBIGUOUS relationships: {N} -请抽查 2~3 份文档(建议选最复杂的组件): - 路径:<output_dir>/XX_<组件名>设计说明.md +Please spot-check 2~3 documents (the most complex components are recommended): + Path: <output_dir>/XX_<component>_Design.md -确认要点: - 1. AI 快速理解表的代码入口是否精确到函数名? - 2. 核心流程描述是否与代码实际一致? - 3. [UNVERIFIED] 比例是否可接受?(建议 <15%) +Points to confirm: + 1. Is the code entry point in the AI Quick Reference table precise down to the function name? + 2. Does the core flow description match the actual code? + 3. Is the [UNVERIFIED] ratio acceptable? (<15% recommended) -如发现系统性问题,请描述,我将调整策略后重新生成。 +If you find a systematic problem, describe it and I will adjust the strategy and regenerate. ``` -更新 `current_phase` 为 `"phasek2_waiting_confirm"`。 -用户确认后更新为 `"phasek2_confirmed"`,继续批次5~7。 +Update `current_phase` to `"phasek2_waiting_confirm"`. +After the user confirms, update to `"phasek2_confirmed"` and continue with batches 5~7. -### 全部批次完成后 +### After all batches complete -写入 `_review/k2-doc-list.md`(文档清单:路径 + 规模KB + [UNVERIFIED]数 + 生成时间)。 -更新 `current_phase` 为 `"phasek2_done"` → Phase K3。 +Write `_review/k2-doc-list.md` (document list: path + size in KB + [UNVERIFIED] count + generation time). +Update `current_phase` to `"phasek2_done"` → Phase K3. --- diff --git a/skill-data/wiki/references/phases/k3-ai-native.md b/skill-data/wiki/references/phases/k3-ai-native.md index 2f4fcea4..5bb43f91 100644 --- a/skill-data/wiki/references/phases/k3-ai-native.md +++ b/skill-data/wiki/references/phases/k3-ai-native.md @@ -1,22 +1,22 @@ -## Phase K3:AI-Native 增强 + 图谱文档集 +## Phase K3: AI-Native Enhancement + Graph Document Set -**方法论**:`{SKILL_DIR}/references/methodology/phase3-ai-enhancement.md` +**Methodology**: `{SKILL_DIR}/references/methodology/phase3-ai-enhancement.md` -### Step 1:AI-Native 元素注入 +### Step 1: Inject AI-Native elements -对所有已生成文档补充(如 Phase K2 的 Agent 未完整添加): +Add to all generated documents (where the Phase K2 Agent did not add them completely): -| 元素 | 要求 | 适用范围 | +| Element | Requirement | Scope | |------|------|---------| -| `search-anchor` | 5~15 个关键词,标题后第一行 | 所有文档 | -| AI 快速理解表 | 10 维度,紧跟标题 | 所有 Type-4 组件文档 | -| 双向链接 | 组件↔主架构,桥梁↔组件 | 所有文档 | -| 检索路由规则 | 4条分流规则 + 4级优先级 | 仅技术架构总览 | -| QA 对 | 10~20 个高频问题+答案引用 | 仅技术架构总览第9章 | +| `search-anchor` | 5~15 keywords, first line after the title | All documents | +| AI Quick Reference table | 10 dimensions, immediately after the title | All Type-4 component documents | +| Bidirectional links | component ↔ main architecture, bridge ↔ component | All documents | +| Retrieval routing rules | 4 routing rules + 4 priority levels | Technical architecture overview only | +| QA pairs | 10~20 high-frequency questions + answer references | Chapter 9 of the technical architecture overview only | -### Step 2:Graph RAG 图谱文档集 +### Step 2: Graph RAG graph document set -读取 `{SKILL_DIR}/references/agents/graph-rag-agent.md`,拼装输入包并启动: +Read `{SKILL_DIR}/references/agents/graph-rag-agent.md`, assemble the input package and launch: ``` all_kb_docs_dir: <output_dir> @@ -27,95 +27,95 @@ output_dir: <output_dir>/graph/ methodology_file: {SKILL_DIR}/references/methodology/phase2-document-types.md ``` -生成 G1~G9(每条关系强制置信度三态标注): +Generate G1~G9 (every relationship carries a mandatory three-state confidence annotation): -| 图谱文档 | 解决的问题 | 置信度要求 | +| Graph Document | Question Solved | Confidence Requirement | |---------|---------|-----------| -| G1 组件依赖关系矩阵 | "谁依赖 X?" | EXTRACTED 来自文档明确描述 | -| G2 调用链路全景 + 状态机 + 约束矩阵 | "API 经过哪些模块?" | 调用链 EXTRACTED,推断依赖 INFERRED | -| G3 数据流与存储依赖图 | "数据存哪里?" | 读写关系 EXTRACTED | -| G4 错误码组件映射表 | "错误码是哪个模块的?" | EXTRACTED | -| G5 跨组件交互场景手册(≥10个时序图) | "配额检查怎么做?" | 时序 EXTRACTED,边界 INFERRED | -| G6 知识图谱三元组(≥100条) | "A 间接依赖谁?" | 每条标 E/I/A + 分值 | -| G7 架构风险与影响面分析 | "X 挂了影响多大?" | 直接依赖 EXTRACTED,间接 INFERRED | -| G8 核心配置参数索引 | "怎么改 XX 配置?" | EXTRACTED 来自配置文件 | -| G9 业务规则约束矩阵 + AI 推理决策树 | "能不能做 XX?" | 规则 EXTRACTED,推断 INFERRED | +| G1 Component Dependency Matrix | "Who depends on X?" | EXTRACTED from explicit document descriptions | +| G2 Call Chain Overview + state machine + constraint matrix | "Which modules does an API pass through?" | call chains EXTRACTED, inferred dependencies INFERRED | +| G3 Data Flow and Storage Dependencies | "Where is the data stored?" | read/write relationships EXTRACTED | +| G4 Error Code Component Map | "Which module does this error code belong to?" | EXTRACTED | +| G5 Cross-Component Interaction Scenarios (≥10 sequence diagrams) | "How is the quota check done?" | sequences EXTRACTED, boundaries INFERRED | +| G6 Knowledge Graph Triples (≥100) | "Who does A depend on indirectly?" | every triple marked E/I/A + score | +| G7 Architecture Risks and Impact Analysis | "How big is the impact if X goes down?" | direct dependencies EXTRACTED, indirect INFERRED | +| G8 Core Config Parameter Index | "How do I change configuration XX?" | EXTRACTED from config files | +| G9 Business Rule Constraint Matrix + AI reasoning decision tree | "Can I do XX?" | rules EXTRACTED, inferences INFERRED | -同时生成 `<output_dir>/graph/README.md`(索引 + 按问题类型查找表 + 检索路由建议)。 +Also generate `<output_dir>/graph/README.md` (index + lookup-by-question-type table + retrieval routing suggestions). -### Step 3:跨文档一致性校验 +### Step 3: Cross-document consistency check -**Graph RAG Agent 完成后,主 Agent 自行执行此步骤(不委托给子 Agent)。** +**After the Graph RAG Agent finishes, the main agent performs this step itself (do not delegate to a sub-agent).** -目的:检测组件文档之间的矛盾描述,防止"A 说调用 B 用 RPC,B 说被 A 用 MQ 调用"这类不一致。 +Purpose: detect contradictory descriptions between component documents, preventing inconsistencies such as "A says it calls B over RPC, B says it is called by A over MQ". ``` -Step 3A:构建"声称矩阵" +Step 3A: Build the "claim matrix" - 对每份 Type-4 组件文档,从**两个层面**提取关系声称: + For every Type-4 component document, extract relationship claims from **two levels**: - 层面1:AI 快速理解表中的"上游组件"和"下游组件"字段 - 层面2:正文中的接口设计章节、核心流程章节中的调用描述 + Level 1: the "Upstream Components" and "Downstream Components" fields of the AI Quick Reference table + Level 2: call descriptions in the interface design and core flow sections of the body - 如果层面1和层面2对同一关系描述不一致 → 首先记录为"文档内矛盾"(比表头和正文优先级更高的问题) + If level 1 and level 2 describe the same relationship differently → first record it as an "intra-document contradiction" (a higher-priority problem than header vs body) - 提取示例: - 组件X.md 表头声称: X→Y(RPC), X→Z(MQ) - 组件X.md 正文声称: X→Z(HTTP) ← 与表头矛盾! - 组件Y.md 表头声称: Y←X(RPC), Y→Z(DB) - 组件Z.md 表头声称: Z←X(HTTP), Z←Y(DB) + Extraction example: + ComponentX.md header claims: X→Y(RPC), X→Z(MQ) + ComponentX.md body claims: X→Z(HTTP) ← contradicts the header! + ComponentY.md header claims: Y←X(RPC), Y→Z(DB) + ComponentZ.md header claims: Z←X(HTTP), Z←Y(DB) -Step 3B:交叉比对 +Step 3B: Cross-compare - FOR 每对组件 (A, B): - IF A.md 声称 "A→B 用 RPC" AND B.md 声称 "B←A 用 MQ": - → 记录矛盾: "A→B 通信方式不一致: A说RPC, B说MQ" - IF A.md 声称 "A→B" BUT B.md 未提到 "被A调用": - → 记录缺失: "A声称调用B,但B的文档未提及被A调用" - IF G1矩阵中的关系 与 组件文档声称不一致: - → 记录偏差: "G1矩阵说A→B(RPC),但A的文档说A→B(MQ)" + FOR each pair of components (A, B): + IF A.md claims "A→B over RPC" AND B.md claims "B←A over MQ": + → record contradiction: "A→B communication method inconsistent: A says RPC, B says MQ" + IF A.md claims "A→B" BUT B.md does not mention "called by A": + → record omission: "A claims to call B, but B's document does not mention being called by A" + IF a relationship in the G1 matrix differs from the component document claims: + → record deviation: "G1 matrix says A→B(RPC), but A's document says A→B(MQ)" -Step 3C:生成一致性报告 +Step 3C: Generate the consistency report - 写入 `_review/k3-consistency-check.md`: + Write to `_review/k3-consistency-check.md`: ```markdown - # 跨文档一致性校验报告 + # Cross-Document Consistency Check Report - ## 矛盾项(必须修复) - | 组件A | 组件B | A的描述 | B的描述 | 矛盾类型 | + ## Contradictions (must fix) + | Component A | Component B | A's Description | B's Description | Contradiction Type | |-------|-------|---------|---------|---------| - | X | Z | X→Z(MQ) | Z←X(HTTP) | 通信方式不一致 | + | X | Z | X→Z(MQ) | Z←X(HTTP) | Communication method inconsistent | - ## 缺失项(建议补充) - | 声称方 | 被引用方 | 声称内容 | 缺失 | + ## Omissions (recommended additions) + | Claimant | Referenced | Claim | Omission | |--------|---------|---------|------| - | A | B | A→B(RPC) | B的文档未提及被A调用 | + | A | B | A→B(RPC) | B's document does not mention being called by A | - ## G1矩阵偏差(建议对齐) - | G1矩阵 | 组件文档 | 偏差 | + ## G1 Matrix Deviations (recommended alignment) + | G1 Matrix | Component Document | Deviation | - ## 统计 - - 矛盾项: N 处(❌ 需修复) - - 缺失项: N 处(⚠️ 建议补充) - - G1偏差: N 处(⚠️ 需对齐) - - 一致关系: N 条(✅) - - 一致率: X% + ## Statistics + - Contradictions: N (❌ must fix) + - Omissions: N (⚠️ recommended additions) + - G1 deviations: N (⚠️ need alignment) + - Consistent relationships: N (✅) + - Consistency rate: X% ``` -Step 3D:自动修复(仅限明确情况) +Step 3D: Automatic fixes (unambiguous cases only) - IF 矛盾项 > 0: - FOR 每个矛盾项: - 回溯代码验证:用 Grep 查找实际的调用方式(如 rpc.Call / mq.Publish) - IF 能明确正确方 → 修复错误方文档中的描述 + 更新 G1 矩阵 - IF 无法明确 → 标记为 AMBIGUOUS,留待用户在确认点确认 - 修复后重新统计一致率 + IF contradictions > 0: + FOR each contradiction: + Trace back to the code: use Grep to find the actual call method (e.g. rpc.Call / mq.Publish) + IF the correct side can be determined → fix the description in the wrong side's document + update the G1 matrix + IF it cannot be determined → mark as AMBIGUOUS, leave for the user to confirm at the confirmation point + Recompute the consistency rate after fixing - IF 矛盾项 = 0: - → 跳过修复,直接进入 Phase K4 + IF contradictions = 0: + → skip fixing, go straight to Phase K4 ``` -**完成后**:更新 `current_phase` 为 `"phasek3_done"` → Phase K4。 +**When done**: update `current_phase` to `"phasek3_done"` → Phase K4. --- diff --git a/skill-data/wiki/references/phases/k4-quality.md b/skill-data/wiki/references/phases/k4-quality.md index de4e76c5..cc1272c6 100644 --- a/skill-data/wiki/references/phases/k4-quality.md +++ b/skill-data/wiki/references/phases/k4-quality.md @@ -1,190 +1,190 @@ -## Phase K4:知识库质量评估与报告 +## Phase K4: Knowledge Base Quality Assessment and Report -**方法论**:`{SKILL_DIR}/references/methodology/phase4-quality.md` +**Methodology**: `{SKILL_DIR}/references/methodology/phase4-quality.md` -### Step 1:自动校验 +### Step 1: Automated validation ```bash python3 {SKILL_DIR}/scripts/validate_kb.py <output_dir> --verbose ``` -`--verbose` 打印每一项的明细(缺失的 anchor、死链接的具体位置),这正是下面要求的完整展示。 +`--verbose` prints the details of every item (missing anchors, the exact location of dead links). This is exactly the full output required below. -输出(**必须完整展示,不得只展示通过项**): +Output (**must be shown in full, not only the passing items**): ``` -链接完整性: ✅/❌ N 个死链接 -search-anchor: ✅/⚠️ 覆盖率 N/M (X%) -AI 快速理解表: ✅/⚠️ 覆盖率 N/M (X%) -双向链接: ✅/⚠️ 覆盖率 N/M (X%) -README 索引: ✅/⚠️ 收录率 N/M (X%) +Link integrity: ✅/❌ N dead links +search-anchor: ✅/⚠️ coverage N/M (X%) +AI Quick Reference table: ✅/⚠️ coverage N/M (X%) +Bidirectional links: ✅/⚠️ coverage N/M (X%) +README index: ✅/⚠️ inclusion rate N/M (X%) ``` -### Step 2:准确性审计 +### Step 2: Accuracy audit -从 `accuracy_stats` 汇总全库可信度,同时从 `interface_coverage` 汇总接口覆盖情况: +Aggregate the credibility of the whole knowledge base from `accuracy_stats`, and the interface coverage from `interface_coverage`: ``` -【内容准确性】 -总声明数: N 条(业务规则 + 接口描述 + 关系) -已验证(有代码引用): N 条 (X%) -[UNVERIFIED]: N 条 (X%) -AMBIGUOUS 关系: N 条 (X%) - -【接口覆盖率】(仅统计 HTTP/MQ/RPC 类型组件,NONE 类型不计入) -HTTP 接口: 文档记录 M 个 / 扫描基准 N 个 = X% -MQ Topic: 文档记录 M 个 / 扫描基准 N 个 = X% -RPC Method: 文档记录 M 个 / 扫描基准 N 个 = X% -综合覆盖率: X% 目标 ≥ 90% - -⚠️ 接口缺口清单(文档记录 < 扫描基准 的组件): - - ComponentA: 文档记录 8 个,扫描基准 13 个,缺口 5 个 → 建议补充 +[Content accuracy] +Total claims: N (business rules + interface descriptions + relationships) +Verified (with code reference): N (X%) +[UNVERIFIED]: N (X%) +AMBIGUOUS relationships: N (X%) + +[Interface coverage] (only HTTP/MQ/RPC type components are counted, NONE type is excluded) +HTTP interfaces: documented M / scan baseline N = X% +MQ Topics: documented M / scan baseline N = X% +RPC Methods: documented M / scan baseline N = X% +Overall coverage: X% target ≥ 90% + +⚠️ Interface gap list (components where documented < scan baseline): + - ComponentA: documented 8, scan baseline 13, gap 5 → recommend adding ``` -⚠️ 需人工确认清单:([UNVERIFIED] > 20% 的文档 + 接口缺口组件 + AMBIGUOUS 关系) +⚠️ Manual confirmation list: (documents with [UNVERIFIED] > 20% + components with interface gaps + AMBIGUOUS relationships) -### Step 3:RAG 检索抽检 +### Step 3: RAG retrieval spot check -按 `phase4-quality.md §RAG检索测试用例` 测试 7 类问题各 1 个(详见方法论),记录命中率。 +Following `phase4-quality.md §RAG Retrieval Test Cases`, test 1 question from each of the 7 question types (see the methodology for details) and record the hit rate. -### Step 4:AI 端到端验证(E2E Validation) +### Step 4: AI end-to-end validation (E2E Validation) -**核心思路**:用知识库回答一组标准化问题,然后**回溯代码验证答案正确性**,检测知识库是否能让 AI 给出正确答案。 +**Core idea**: answer a set of standardised questions using the knowledge base, then **trace back to the code to verify the answers**, to detect whether the knowledge base enables the AI to give correct answers. ``` -Step 4A:生成标准验证问题集(自动,基于已有文档) +Step 4A: Generate the standard validation question set (automatic, based on existing documents) - **优先使用用户提供的外部验证集**: - IF 用户在 Phase 0 或此时提供了验证问题列表(3~10 个真实业务问题): - → 优先使用用户问题作为验证集(标注来源: USER) - → 自动补充至 10~15 题(标注来源: AUTO) + **Prefer an external validation set provided by the user**: + IF the user provided a list of validation questions (3~10 real business questions) in Phase 0 or now: + → use the user's questions as the validation set first (source: USER) + → top up automatically to 10~15 questions (source: AUTO) ELSE: - → 全部自动生成(标注来源: AUTO) + → generate all automatically (source: AUTO) - > 用户提供的问题更有价值,因为 AI 自己出题容易考自己已知的领域, - > 真正的盲区(AI 没理解但没意识到的)只有外部问题才能测到。 + > User-provided questions are more valuable, because when the AI writes its own questions it tends to test areas it already knows, + > and the real blind spots (things the AI did not understand and is unaware of) can only be found by external questions. - 从 k1-architecture-map.md 和 k2-doc-list.md 自动生成 10~15 个验证问题: + Automatically generate 10~15 validation questions from k1-architecture-map.md and k2-doc-list.md: - 问题类型分布(至少覆盖以下 5 类): + Question type distribution (cover at least the following 5 types): ┌────────────────────────────────────────────────────────────────────┐ - │ 类型1:组件职责(3题) │ - │ 模式:"<组件名> 的核心职责是什么?代码入口在哪?" │ - │ 验证方式:答案中的函数名/文件名必须在代码中存在 │ + │ Type 1: component responsibility (3 questions) │ + │ Pattern: "What is the core responsibility of <component>? Where is the code entry point?" │ + │ Verification: the function / file names in the answer must exist in the code │ │ │ - │ 类型2:调用关系(3题) │ - │ 模式:"<组件A> 和 <组件B> 之间是什么关系?通过什么方式通信?" │ - │ 验证方式:答案与 G1 矩阵 + 代码实际 import/call 一致 │ + │ Type 2: call relationships (3 questions) │ + │ Pattern: "What is the relationship between <component A> and <component B>? How do they communicate?" │ + │ Verification: the answer matches the G1 matrix + the actual imports / calls in the code │ │ │ - │ 类型3:操作约束(2题) │ - │ 模式:"在 <状态X> 下能否执行 <操作Y>?" │ - │ 验证方式:答案与 G9 约束矩阵 + 代码中的状态检查一致 │ + │ Type 3: operation constraints (2 questions) │ + │ Pattern: "Can <operation Y> be executed in <state X>?" │ + │ Verification: the answer matches the G9 constraint matrix + the state checks in the code │ │ │ - │ 类型4:数据流向(2题) │ - │ 模式:"<操作Z> 最终会写入哪些表/队列?" │ - │ 验证方式:答案与 G3 数据流 + 代码实际 SQL/MQ 操作一致 │ + │ Type 4: data flow (2 questions) │ + │ Pattern: "Which tables / queues does <operation Z> ultimately write to?" │ + │ Verification: the answer matches the G3 data flow + the actual SQL / MQ operations in the code │ │ │ - │ 类型5:错误排查(2题) │ - │ 模式:"错误码 <XXX> 是什么意思?在哪个组件产生?" │ - │ 验证方式:答案与 G4 错误码映射 + 代码中的错误定义一致 │ + │ Type 5: error troubleshooting (2 questions) │ + │ Pattern: "What does error code <XXX> mean? Which component produces it?" │ + │ Verification: the answer matches the G4 error code map + the error definitions in the code │ │ │ - │ 类型6(可选):认知边界测试(2题) │ - │ 模式:故意问知识库不覆盖的内容(如第三方 SDK 内部、历史架构变迁) │ - │ 验证方式:AI 应回答"超出知识库覆盖范围"而非幻觉 │ + │ Type 6 (optional): knowledge boundary test (2 questions) │ + │ Pattern: deliberately ask about content the knowledge base does not cover (e.g. third-party SDK internals, historical architecture changes) │ + │ Verification: the AI should answer "outside the knowledge base coverage" rather than hallucinate │ └────────────────────────────────────────────────────────────────────┘ -Step 4B:用知识库回答(模拟 AI 使用场景) +Step 4B: Answer using the knowledge base (simulating the AI usage scenario) - FOR 每个验证问题: - 1. 假设只能读知识库文档,不能直接读代码 - 2. 按检索路由规则,找到对应文档 - 3. 从文档中提取答案 + FOR each validation question: + 1. Assume only the knowledge base documents can be read, not the code directly + 2. Find the relevant document following the retrieval routing rules + 3. Extract the answer from the document -Step 4C:代码回溯验证 +Step 4C: Code trace-back verification - FOR 每个答案: - 1. 用 Grep/Read 直接在代码中验证关键声明 - 2. 判定结果: - ✅ CORRECT — 答案与代码一致 - ⚠️ PARTIAL — 答案部分正确,有遗漏或不精确 - ❌ INCORRECT — 答案与代码矛盾 - 🔇 BOUNDARY_OK — 认知边界问题,正确拒绝回答(仅类型6) - 🔇 BOUNDARY_FAIL — 认知边界问题,错误地给出了答案(仅类型6) + FOR each answer: + 1. Verify the key claims directly in the code with Grep/Read + 2. Judge the result: + ✅ CORRECT : the answer matches the code + ⚠️ PARTIAL : the answer is partially correct, with omissions or imprecision + ❌ INCORRECT : the answer contradicts the code + 🔇 BOUNDARY_OK : knowledge boundary question, correctly declined to answer (type 6 only) + 🔇 BOUNDARY_FAIL : knowledge boundary question, wrongly gave an answer (type 6 only) -Step 4D:写入验证报告 +Step 4D: Write the validation report - 追加到 k4-quality-report.md 的 ## AI 端到端验证 章节: + Append to the ## AI End-to-End Validation section of k4-quality-report.md: - | 问题 | 类型 | 检索文档 | AI答案摘要 | 代码验证 | 结果 | + | Question | Type | Retrieved Document | AI Answer Summary | Code Verification | Result | |------|------|---------|-----------|---------|------| - | Aurora 核心职责? | 组件职责 | 03_Aurora设计说明.md | 调度编排... | scheduler.go:42 | ✅ | - | A→B 通信方式? | 调用关系 | G1矩阵 | RPC | import rpc_client | ✅ | - | 状态X下能否操作Y? | 操作约束 | G9矩阵 | 不能 | check_state.go:88 | ✅ | - | 第三方SDK内部? | 认知边界 | — | 超出范围 | — | 🔇 OK | + | Core responsibility of Aurora? | Component responsibility | 03_Aurora_Design.md | Scheduling orchestration... | scheduler.go:42 | ✅ | + | A→B communication method? | Call relationship | G1 matrix | RPC | import rpc_client | ✅ | + | Can operation Y run in state X? | Operation constraint | G9 matrix | No | check_state.go:88 | ✅ | + | Third-party SDK internals? | Knowledge boundary | — | Out of scope | — | 🔇 OK | - 统计: + Statistics: CORRECT: N/M (X%) PARTIAL: N/M (X%) - INCORRECT: N/M (X%) — ❌ 每个 INCORRECT 必须列出具体矛盾点 + INCORRECT: N/M (X%), ❌ every INCORRECT must list the specific contradiction BOUNDARY_OK: N/N BOUNDARY_FAIL: N/N - E2E 准确率 = (CORRECT + BOUNDARY_OK) / 总题数 - 目标: ≥ 80% + E2E accuracy = (CORRECT + BOUNDARY_OK) / total questions + Target: ≥ 80% ``` -**如果 E2E 准确率 < 80%**:在质量报告"建议"章节列出需要改进的文档和具体问题。 +**If E2E accuracy < 80%**: list the documents that need improvement and the specific problems in the "Recommendations" section of the quality report. -### Step 5:生成质量报告 +### Step 5: Generate the quality report -写入 `_review/k4-quality-report.md`: +Write to `_review/k4-quality-report.md`: ```markdown -# 知识库质量报告 +# Knowledge Base Quality Report -## 概览 -- 代码基准:<commit SHA> (<tag>) -- 生成时间:<ISO8601> -- 文档总数:N 份(Type-1~8: N份,图谱G1~G9: 9份) +## Overview +- Code baseline: <commit SHA> (<tag>) +- Generated at: <ISO8601> +- Total documents: N (Type-1~8: N, graph G1~G9: 9) -## 准确性 -| 指标 | 数值 | 状态 | -| 总声明数 | N | — | -| 有代码引用 | N (X%) | ✅/❌ | +## Accuracy +| Metric | Value | Status | +| Total claims | N | — | +| With code reference | N (X%) | ✅/❌ | | [UNVERIFIED] | N (X%) | ✅/<15% / ⚠️15~25% / ❌>25% | -| AMBIGUOUS关系 | N | ✅/⚠️ | +| AMBIGUOUS relationships | N | ✅/⚠️ | -## 结构质量(validate_kb.py 输出) -(完整展示,不隐藏任何数字) +## Structural Quality (validate_kb.py output) +(shown in full, no numbers hidden) -## 跨文档一致性(k3-consistency-check.md 摘要) -| 指标 | 数值 | 状态 | -| 矛盾项 | N | ✅=0 / ❌>0 | -| 缺失引用 | N | ⚠️ | -| G1偏差 | N | ⚠️ | -| 一致率 | X% | 目标≥95% | +## Cross-Document Consistency (summary of k3-consistency-check.md) +| Metric | Value | Status | +| Contradictions | N | ✅=0 / ❌>0 | +| Missing references | N | ⚠️ | +| G1 deviations | N | ⚠️ | +| Consistency rate | X% | target ≥95% | -## RAG 检索抽检 -| 测试问题 | 期望命中 | 实际命中 | 结果 | +## RAG Retrieval Spot Check +| Test Question | Expected Hit | Actual Hit | Result | -## AI 端到端验证 -| 指标 | 数值 | 状态 | +## AI End-to-End Validation +| Metric | Value | Status | | CORRECT | N/M (X%) | — | | PARTIAL | N/M (X%) | ⚠️ | | INCORRECT | N/M (X%) | ❌ | | BOUNDARY_OK | N/N | ✅ | -| E2E 准确率 | X% | 目标≥80% | +| E2E accuracy | X% | target ≥80% | -INCORRECT 详情: -(每个 INCORRECT 的具体矛盾点和改进建议) +INCORRECT details: +(the specific contradiction and improvement suggestion for every INCORRECT) -## 待人工确认清单 -([UNVERIFIED] 超标文档 + AMBIGUOUS 关系 + 矛盾项 + 死链接) +## Manual Confirmation List +([UNVERIFIED] over-threshold documents + AMBIGUOUS relationships + contradictions + dead links) -## 建议 -(基于一致性校验 + E2E 验证的改进方向) +## Recommendations +(improvement directions based on the consistency check + E2E validation) ``` -**完成后**:更新 `current_phase` 为 `"completed"`,流程结束。 +**When done**: update `current_phase` to `"completed"`. The workflow ends. --- diff --git a/skill-data/wiki/references/phases/phase0-init.md b/skill-data/wiki/references/phases/phase0-init.md index d06ba62e..19867096 100644 --- a/skill-data/wiki/references/phases/phase0-init.md +++ b/skill-data/wiki/references/phases/phase0-init.md @@ -1,36 +1,36 @@ -## Phase 0:初始化 +## Phase 0: Initialisation -一次性向用户询问以下信息(**同一条消息,不分步骤**): +Ask the user for all of the following in one go (**a single message, not step by step**): -1. **项目所有代码仓库路径**(用户把整个项目涉及的所有仓库地址列出来): - - 格式:每行一个绝对路径,或逗号分隔 - - 示例: +1. **Paths of all code repositories of the project** (the user lists every repository the project involves): + - Format: one absolute path per line, or comma-separated + - Example: ``` /path/to/api-gateway /path/to/order-service /path/to/user-service /path/to/common-lib ``` - - 说明:这是最关键的一步。大型项目的代码散布在多个仓库中,必须**全部提供**才能构建完整的架构认知。遗漏仓库 = 知识库盲区。 -2. **项目名称**(用于文档命名,如 "CVM"、"电商平台") -3. **产品文档来源**(可选,提供则生成 Type-5/6 桥梁文档): - - API 文档目录路径 - - 使用限制 / FAQ 文档路径 -4. **输出路径**(默认:第一个仓库的父目录下的 `knowledge/`) + - Note: this is the most critical step. The code of a large project is spread across many repositories, and **all of them** must be provided to build complete architecture awareness. A missing repository = a blind spot in the knowledge base. +2. **Project name** (used in document names, e.g. "CVM", "E-commerce Platform") +3. **Product documentation sources** (optional; when provided, the Type-5/6 bridge documents are generated): + - API documentation directory path + - Usage limits / FAQ document path +4. **Output path** (default: `knowledge/` under the parent directory of the first repository) -**Step 0A:仓库清单整理** +**Step 0A: Repository inventory** -收到用户提供的仓库列表后,构建仓库清单: +After receiving the user's repository list, build the repository inventory: ``` -FOR 每个用户提供的路径: - 1. 验证路径存在且可访问 - 2. 检测是否为 git 仓库(是否有 .git 目录) - 3. 检测主要语言(按文件扩展名分布) - 4. 统计代码规模(文件数 + 估算行数) - 5. 记录 git commit SHA + tag - -结果写入 _review/repo-manifest.json: +FOR each path provided by the user: + 1. Verify the path exists and is accessible + 2. Detect whether it is a git repository (has a .git directory) + 3. Detect the primary language (by file extension distribution) + 4. Measure code size (file count + estimated line count) + 5. Record the git commit SHA + tag + +Write the result to _review/repo-manifest.json: { "repos": [ { @@ -46,42 +46,42 @@ FOR 每个用户提供的路径: ... ], "total_repos": N, - "inaccessible": ["path/to/repo-x(权限不足)"] + "inaccessible": ["path/to/repo-x (permission denied)"] } ``` -展示给用户确认: +Show it to the user for confirmation: ``` -已识别 {N} 个仓库: - ✅ repo-a (Go, ~45K 行) - ✅ repo-b (Python, ~12K 行) - ✅ repo-c (Go, ~28K 行) - ❌ repo-x (路径不存在或无法访问) - -总计: ~{N}K 行代码,{N} 个仓库 -确认无误后回复"继续",或补充遗漏的仓库。 +Identified {N} repositories: + ✅ repo-a (Go, ~45K lines) + ✅ repo-b (Python, ~12K lines) + ✅ repo-c (Go, ~28K lines) + ❌ repo-x (path does not exist or is not accessible) + +Total: ~{N}K lines of code, {N} repositories +Reply "continue" if this is correct, or add the missing repositories. ``` -**Step 0B:自动检测主要语言**(按仓库列表汇总,不阻断流程): +**Step 0B: Auto-detect the primary language** (aggregated over the repository list, does not block the flow): ``` -检测方法:汇总所有仓库的文件扩展名分布 - .go 文件占比最高 → language: "go" - .py 文件占比最高 → language: "python" - .java 文件占比最高 → language: "java" - .ts/.js 文件占比最高 → language: "typescript" - .rs 文件占比最高 → language: "rust" - 多语言混合(无明显主导) → language: "mixed" -备注:language 字段用于接口扫描时选择 grep 模式(详见 Phase K1 Step 5) +Detection method: aggregate the file extension distribution of all repositories + .go files dominate → language: "go" + .py files dominate → language: "python" + .java files dominate → language: "java" + .ts/.js files dominate → language: "typescript" + .rs files dominate → language: "rust" + Mixed languages (no clear majority) → language: "mixed" +Note: the language field selects the grep patterns for the interface scan (see Phase K1 Step 5) ``` -**Step 0C:记录基准版本**: +**Step 0C: Record the baseline version**: ```bash -# 对每个仓库分别记录 +# Record each repository separately FOR repo in repos: git -C <repo.path> rev-parse HEAD 2>/dev/null git -C <repo.path> describe --tags --always 2>/dev/null ``` -写入 `_review/metadata.json`: +Write to `_review/metadata.json`: ```json { "project_name": "CVM", @@ -93,9 +93,9 @@ FOR repo in repos: } ``` -**Step 0D:CLI 结构基线(每个代码仓库,推荐)** +**Step 0D: CLI structural baseline (per code repository, recommended)** -在 K1 深读之前,用 TeamAI 提取可证据化的 import/call 结构边(Python/Go/TS 等,`code-ast`)并与 regex 基线合并(`code-heuristic`): +Before the K1 deep read, use TeamAI to extract evidence-backed import/call structural edges (Python/Go/TS etc., `code-ast`) and merge them with the regex baseline (`code-heuristic`): ```bash # For each repo. Writes <repo>/teamwiki/ (evidence pages + .indices/graph-index.json). @@ -104,9 +104,9 @@ teamai codebase --extract <repo_abs_path> --project <project_slug> ``` - Output: `teamwiki/evidence/code/<project>/` pages; `teamwiki/.indices/graph-index.json` (structural edges). -- K1/K2/K3 写 `_manifest.json` 的 `edges[]` 时:**优先引用** extract 的 `code-ast` 边 + `evidenceRefs`(`path:line`),Agent 推断标 `INFERRED`/`AMBIGUOUS`。 +- When K1/K2/K3 write `edges[]` in `_manifest.json`: **prefer citing** the `code-ast` edges from extract + their `evidenceRefs` (`path:line`); label Agent inferences `INFERRED`/`AMBIGUOUS`. - After Phase K3, skip any extra graph compile / merge step that is not a `teamai` command. TeamAI does not ship a separate team-wiki CLI. Continue with this skill using `teamai` and the files under this skill directory. No extra plugin is required. -写入初始 progress.json(current_phase: "phase0_done"),进入 **Phase K1**。 +Write the initial progress.json (current_phase: "phase0_done") and enter **Phase K1**. --- diff --git a/skill-data/wiki/references/templates/project-overview.md b/skill-data/wiki/references/templates/project-overview.md index 04dfd19c..f6d54ead 100644 --- a/skill-data/wiki/references/templates/project-overview.md +++ b/skill-data/wiki/references/templates/project-overview.md @@ -1,148 +1,148 @@ -# 知识库总览模板 +# Knowledge base overview template -> 用于生成 `<output_dir>/README.md`,在 Phase K2 批次5 生成(知识库顶层索引)。 +> Used to generate `<output_dir>/README.md`, produced in Phase K2 batch 5 (the top-level index of the knowledge base). ```markdown -# <项目名称> — 深度知识库 -<!-- search-anchor: <项目名称>, <项目英文名>, 知识库, 架构总览, 快速导航, 组件文档, Graph RAG, 图谱 --> +# <Project name>: Deep Knowledge Base +<!-- search-anchor: <project name>, <project English name>, knowledge base, architecture overview, quick navigation, component documents, Graph RAG, graph --> -> **AI 读取指引**:本目录是 AI-Native 知识库。请先阅读本文件了解全局和认知边界, -> 再按检索路由规则进入对应文档查阅详情。**禁止一次性读取整个知识库目录。** +> **AI reading guide**: this directory is an AI-Native knowledge base. Read this file first for the global picture and the cognitive boundaries, +> then follow the retrieval routing rules into the relevant document for details. **Never read the whole knowledge base directory at once.** -## 🤖 知识库检索路由指引(AI 专用) +## 🤖 Knowledge Base Retrieval Routing Guide (for AI) -### 按问题类型快速导航 +### Quick navigation by question type -| 我想了解… | 应该读… | 路径 | +| I want to know... | Read... | Path | |---------|---------|------| -| 系统整体架构和分层 | 技术架构文档 | `./{项目名} 技术架构.md` | -| 某个组件的设计和实现 | 组件设计说明 | `./XX_{组件名}设计说明.md` | -| 组件之间的依赖关系 | G1 依赖矩阵 | `./graph/G1_*.md` | -| 某个 API 经过哪些模块 | G2 调用链路全景 | `./graph/G2_*.md` | -| 数据存在哪里、MQ 拓扑 | G3 数据流 | `./graph/G3_*.md` | -| 错误码是哪个模块的 | G4 错误码映射 | `./graph/G4_*.md` | -| 某个业务场景的完整流程 | G5 交互场景手册 | `./graph/G5_*.md` | -| A 间接依赖谁(多跳查询) | G6 知识图谱三元组 | `./graph/G6_*.md` | -| X 组件挂了影响多大 | G7 风险分析 | `./graph/G7_*.md` | -| 怎么修改某个配置 | G8 配置参数索引 | `./graph/G8_*.md` | -| 某个操作能不能执行 | G9 业务规则约束 | `./graph/G9_*.md` | -| 产品约束→代码位置映射 | 核心API映射文档 | `./XX_*产品代码映射.md` | -| 业务开发 SOP | 业务开发规范 | `./XX_*业务开发规范SOP.md` | - -### 检索规则 - -- **规则 1 — 先读索引后深入**:遇到不确定的组件,先读本文件找到正确路径,再深入组件文档 -- **规则 2 — 组件内部问题查组件文档**:核心机制、代码入口、数据模型 → `XX_{组件名}设计说明.md` -- **规则 3 — 跨组件关系问题查图谱**:依赖矩阵、调用链路、影响面 → `graph/` 目录 -- **规则 4 — 操作可行性问题查 G9**:约束矩阵 + 决策树 → `graph/G9_*.md` -- **规则 5 — `[UNVERIFIED]` 标注的内容不可用于代码生成**,需先人工确认 -- **规则 6 — `AMBIGUOUS` 关系不可用于变更影响评估**,需先明确 +| Overall system architecture and layering | Technical architecture document | `./{project_name} Technical Architecture.md` | +| Design and implementation of a component | Component design document | `./XX_{component}_Design.md` | +| Dependencies between components | G1 dependency matrix | `./graph/G1_*.md` | +| Which modules an API passes through | G2 call chain overview | `./graph/G2_*.md` | +| Where data lives, MQ topology | G3 data flow | `./graph/G3_*.md` | +| Which module an error code belongs to | G4 error code map | `./graph/G4_*.md` | +| The full flow of a business scenario | G5 interaction scenarios | `./graph/G5_*.md` | +| Who A depends on indirectly (multi-hop query) | G6 knowledge graph triples | `./graph/G6_*.md` | +| Blast radius if component X goes down | G7 risk analysis | `./graph/G7_*.md` | +| How to change a configuration | G8 config parameter index | `./graph/G8_*.md` | +| Whether an operation is allowed | G9 business rule constraints | `./graph/G9_*.md` | +| Product constraint → code location mapping | Core API mapping document | `./XX_*_Core_API_Product_Code_Mapping.md` | +| Business development SOP | Business development guidelines | `./XX_*_Business_Development_SOP.md` | + +### Retrieval rules + +- **Rule 1, index first, then dig in**: for an unfamiliar component, read this file first to find the right path, then go into the component document +- **Rule 2, component-internal questions go to the component document**: core mechanisms, code entry points, data models → `XX_{component}_Design.md` +- **Rule 3, cross-component relation questions go to the graph**: dependency matrix, call chains, impact surface → the `graph/` directory +- **Rule 4, operation feasibility questions go to G9**: constraint matrix + decision tree → `graph/G9_*.md` +- **Rule 5, content marked `[UNVERIFIED]` must not be used for code generation** until confirmed by a human +- **Rule 6, `AMBIGUOUS` relations must not be used for change impact assessment** until clarified --- -## 🚧 认知边界声明(AI 必读) +## 🚧 Cognitive Boundary Declaration (AI must read) -> 本节声明此知识库**不知道什么**。AI 在回答问题时,如果涉及以下范围, -> **必须主动告知用户"此信息超出知识库覆盖范围,建议查看源代码/产品文档/联系团队"**, -> 而不是尝试推断或幻觉。 +> This section declares what this knowledge base **does not know**. When a question touches the areas below, the AI +> **must proactively tell the user "this information is outside the knowledge base coverage; check the source code / product docs / contact the team"** +> instead of trying to infer or hallucinate. -### 覆盖范围 +### Coverage -| 维度 | 覆盖 | 说明 | +| Dimension | Coverage | Notes | |------|------|------| -| 代码基准 | `<commit SHA>` (`<tag>`) | 此版本**之后**的变更不在覆盖范围内 | -| 生成时间 | `<YYYY-MM-DDTHH:MM:SSZ>` | 知识库与代码的时间锚点 | -| 核心组件(P0) | <P0组件列表> | 文档深度最高,接口级覆盖 | -| 重要组件(P1) | <P1组件列表> | 文档深度中等,核心机制覆盖 | -| 辅助组件(P2) | <P2组件列表> | 文档深度有限,仅架构层面 | +| Code baseline | `<commit SHA>` (`<tag>`) | Changes **after** this version are not covered | +| Generated at | `<YYYY-MM-DDTHH:MM:SSZ>` | Time anchor between the knowledge base and the code | +| Core components (P0) | <P0 component list> | Deepest documentation, interface-level coverage | +| Important components (P1) | <P1 component list> | Medium documentation depth, core mechanisms covered | +| Auxiliary components (P2) | <P2 component list> | Limited documentation depth, architecture level only | -### 明确不覆盖(AI 不应尝试回答) +### Explicitly not covered (AI should not attempt to answer) -| 领域 | 原因 | +| Area | Reason | |------|------| -| 第三方 SDK/库内部实现 | 知识库只记录调用方式,不涉及第三方源码 | -| 运维/部署细节(ansible/k8s 配置) | 超出代码知识库范围,需查阅运维文档 | -| 非代码产出(UI 设计、产品 PRD 原文) | 仅 Type-5/6 桥梁文档有产品约束映射 | -| 历史架构变迁 | 仅反映当前代码基准版本的架构 | -| 性能基准数据 | 知识库不包含压测数据 | -| <项目特定不覆盖项> | <原因> | +| Internals of third-party SDKs/libraries | The knowledge base records only how they are called, not third-party source | +| Operations/deployment details (ansible/k8s config) | Outside the scope of a codebase knowledge base; consult the operations docs | +| Non-code deliverables (UI design, original product PRDs) | Only the Type-5/6 bridge documents map product constraints | +| Historical architecture evolution | Only the architecture of the current code baseline is reflected | +| Performance benchmark data | The knowledge base contains no load-test data | +| <project-specific uncovered items> | <reason> | -### 低可信度区域(AI 回答时需额外警告) +### Low-confidence areas (extra warning needed when answering) -| 区域 | 原因 | 建议 | +| Area | Reason | Recommendation | |------|------|------| -| P2 辅助组件的内部细节 | 文档深度有限 | 引用时加"基于有限文档分析" | -| `[UNVERIFIED]` 标注内容 | 无法回溯到代码 | 必须告知用户"此信息未经代码验证" | -| `AMBIGUOUS` 关系 | 置信度 < 0.3 | 必须告知用户"此关系存在不确定性" | -| 产品文档缺失时的 Type-5/6 | 无产品文档输入 | 标注 `[PRODUCT_DOC_MISSING]` | +| Internal details of P2 auxiliary components | Limited documentation depth | Add "based on limited documentation analysis" when citing | +| Content marked `[UNVERIFIED]` | Cannot be traced back to code | Must tell the user "this information is not verified against code" | +| `AMBIGUOUS` relations | Confidence < 0.3 | Must tell the user "this relation is uncertain" | +| Type-5/6 when product docs are missing | No product doc input | Marked `[PRODUCT_DOC_MISSING]` | -### 知识库更新说明 +### Knowledge base update notes -- **增量更新**:使用 `code-to-knowledge --update` 可仅更新变更文件对应的文档 -- **全量重建**:代码发生大规模重构时建议全量重建 -- **上次更新**:`<ISO8601>` +- **Incremental update**: `code-to-knowledge --update` updates only the documents of changed files +- **Full rebuild**: recommended after large-scale code refactoring +- **Last updated**: `<ISO8601>` --- -## 项目简介 +## Project introduction -<!-- 1-3 句话:项目背景、核心业务目标、主要用户 --> +<!-- 1-3 sentences: project background, core business goals, main users --> -## 技术栈 +## Tech stack -| 类别 | 技术 | 说明 | +| Category | Technology | Notes | |------|------|------| -| 语言 | Go / Python | ... | -| 框架 | go-zero / FastAPI | ... | -| 数据库 | MySQL / PostgreSQL | ... | -| 缓存 | Redis | ... | -| 消息队列 | Kafka / RabbitMQ | (如有) | +| Language | Go / Python | ... | +| Framework | go-zero / FastAPI | ... | +| Database | MySQL / PostgreSQL | ... | +| Cache | Redis | ... | +| Message queue | Kafka / RabbitMQ | (if any) | -## 知识库文档索引 +## Knowledge base document index -### 架构层文档 -| 文档 | 类型 | 规模 | 说明 | +### Architecture-level documents +| Document | Type | Size | Notes | |------|------|------|------| -| {项目名} 技术架构.md | Type-1 | ~200KB | 架构总览 | -| {项目名} 业务架构.md | Type-2 | ~70KB | 产品能力+生命周期 | -| {项目名} 部署架构.md | Type-3 | ~40KB | 部署拓扑 | +| {project_name} Technical Architecture.md | Type-1 | ~200KB | Architecture overview | +| {project_name} Business Architecture.md | Type-2 | ~70KB | Product capabilities + lifecycle | +| {project_name} Deployment Architecture.md | Type-3 | ~40KB | Deployment topology | -### 组件设计文档 -| 编号 | 组件 | 架构层 | 核心度 | 规模 | +### Component design documents +| No. | Component | Layer | Priority | Size | |------|------|--------|--------|------| -| 01 | <组件名> | <层级> | P0 | ~NKB | +| 01 | <component> | <layer> | P0 | ~NKB | -### 桥梁文档(有产品文档时生成) -| 文档 | 类型 | 说明 | +### Bridge documents (generated when product docs exist) +| Document | Type | Notes | |------|------|------| -| 核心API产品代码映射 | Type-5 | 产品约束→代码位置 | -| 产品规则速查表 | Type-6 | 使用限制/FAQ→代码 | -| 业务开发规范SOP | Type-7 | 开发/变更操作规范 | +| Core API Product Code Mapping | Type-5 | Product constraint → code location | +| Product Rules Cheat Sheet | Type-6 | Usage limits / FAQ → code | +| Business Development SOP | Type-7 | Development / change operation guidelines | -### 图谱文档集(Graph RAG) -| 文档 | 用途 | 规模 | +### Graph document set (Graph RAG) +| Document | Purpose | Size | |------|------|------| -| G1~G9 | 跨组件关系索引 | 详见 `graph/README.md` | +| G1~G9 | Cross-component relation index | See `graph/README.md` | -## 知识库质量概览 +## Knowledge base quality overview -| 指标 | 数值 | 状态 | +| Metric | Value | Status | |------|------|------| -| 文档总数 | N 份 | — | -| 内容准确率(有代码引用) | X% | ✅/⚠️ | -| [UNVERIFIED] 比例 | X% | 目标<15% | -| 接口覆盖率(非 NONE 组件) | X% | 目标≥90% | -| AMBIGUOUS 关系数 | N 条 | 需人工确认 | +| Total documents | N | - | +| Content accuracy (with code references) | X% | ✅/⚠️ | +| [UNVERIFIED] ratio | X% | Target <15% | +| Interface coverage (non-NONE components) | X% | Target ≥90% | +| AMBIGUOUS relation count | N | Needs human confirmation | -> 详细质量报告见 `_review/k4-quality-report.md` +> See `_review/k4-quality-report.md` for the detailed quality report -## 代码基准版本 +## Code baseline version -> ⚠️ 本知识库基于以下版本代码生成,代码演进后请运行 `code-to-knowledge --update` 增量更新。 +> ⚠️ This knowledge base was generated from the code version below. After the code evolves, run `code-to-knowledge --update` for an incremental update. -- **Commit**:`<git commit SHA>` -- **Tag**:`<tag 或 "无 tag">` -- **生成时间**:`<YYYY-MM-DDTHH:MM:SSZ>` +- **Commit**: `<git commit SHA>` +- **Tag**: `<tag or "no tag">` +- **Generated at**: `<YYYY-MM-DDTHH:MM:SSZ>` -> 版本信息来源:`_review/metadata.json` +> Version information source: `_review/metadata.json` ``` diff --git a/skill-data/wiki/scripts/scan_repo.py b/skill-data/wiki/scripts/scan_repo.py index b75ad28c..13e54c22 100644 --- a/skill-data/wiki/scripts/scan_repo.py +++ b/skill-data/wiki/scripts/scan_repo.py @@ -1,14 +1,14 @@ #!/usr/bin/env python3 """ -scan_repo.py — 代码仓库结构扫描与统计工具 +scan_repo.py: repository structure scan and statistics tool -用途: Phase 0 源材料采集阶段,快速扫描目标仓库/目录,输出: - 1. 目录结构树(2层深度) - 2. 代码统计(语言分布、文件数、总行数) - 3. 关键文件发现(入口文件、配置文件、Proto/IDL、错误码定义) - 4. 代码热点(文件行数 Top 20) +Purpose: in the Phase 0 source material collection stage, quickly scan the target repository/directory and print: + 1. Directory tree (2 levels deep) + 2. Code statistics (language distribution, file count, total lines) + 3. Key file discovery (entry files, config files, Proto/IDL, error code definitions) + 4. Code hotspots (top 20 files by line count) -使用方式: +Usage: python3 scan_repo.py /path/to/repo python3 scan_repo.py /path/to/repo --depth 3 --top 30 """ @@ -19,38 +19,38 @@ from pathlib import Path from collections import defaultdict, Counter -# 关键文件匹配模式 +# Key file match patterns KEY_FILE_PATTERNS = { - "入口文件": [ + "Entry files": [ "main.py", "main.go", "app.py", "app.ts", "app.js", "server.py", "server.go", "wsgi.py", "manage.py", "cmd/*/main.go", "index.ts", "index.js", ], - "路由/Handler": [ + "Routes/Handlers": [ "*handler*", "*router*", "*controller*", "*dispatch*", "*route*", "*api.*", "*endpoint*", ], - "配置文件": [ + "Config files": [ "*.yaml", "*.yml", "*.toml", "*.ini", "*.conf", "*config*", "*.env", "*.env.*", ], "Proto/IDL": [ "*.proto", "*.thrift", "*.graphql", "*schema*", ], - "数据库/模型": [ + "Database/Models": [ "*model*", "*dao*", "*repository*", "*migration*", "*schema*", "*.sql", "*db*", ], - "常量/错误码": [ + "Constants/Error codes": [ "*const*", "*constant*", "*error*", "*code*", "*enum*", "*define*", "*exception*", ], - "测试文件": [ + "Test files": [ "*_test.*", "test_*", "*.spec.*", "*_spec.*", ], } -# 语言扩展名映射 +# Language extension map LANG_MAP = { ".py": "Python", ".go": "Go", ".js": "JavaScript", ".ts": "TypeScript", ".java": "Java", ".rs": "Rust", ".rb": "Ruby", ".php": "PHP", @@ -61,7 +61,7 @@ ".json": "JSON", ".xml": "XML", ".md": "Markdown", } -# 忽略目录 +# Ignored directories IGNORE_DIRS = { ".git", ".svn", "node_modules", "__pycache__", ".tox", ".mypy_cache", "venv", ".venv", "env", ".env", "vendor", "dist", "build", @@ -85,28 +85,28 @@ def count_lines(filepath: Path) -> int: def match_pattern(filename: str, pattern: str) -> bool: - """简单的通配符匹配""" + """Simple wildcard match""" import fnmatch return fnmatch.fnmatch(filename.lower(), pattern.lower()) def scan_repository(repo_path: Path, depth: int = 2, top_n: int = 20): - """扫描仓库,返回统计结果""" + """Scan the repository and return the statistics""" all_files = [] - lang_stats = Counter() # 语言 -> (文件数, 行数) + lang_stats = Counter() # language -> (file count, line count) lang_lines = Counter() key_files = defaultdict(list) dir_tree = [] - # 遍历文件 + # Walk the files for root, dirs, files in os.walk(repo_path): rel_root = Path(root).relative_to(repo_path) - # 忽略目录 + # Skip ignored directories dirs[:] = [d for d in dirs if d not in IGNORE_DIRS and not d.endswith(".egg-info")] - # 目录树(限制深度) + # Directory tree (depth-limited) level = len(rel_root.parts) if level <= depth: indent = " " * level @@ -124,13 +124,13 @@ def scan_repository(repo_path: Path, depth: int = 2, top_n: int = 20): all_files.append((rel_path, ext, lines)) - # 语言统计 + # Language statistics lang = LANG_MAP.get(ext) if lang: lang_stats[lang] += 1 lang_lines[lang] += lines - # 关键文件匹配 + # Key file matching for category, patterns in KEY_FILE_PATTERNS.items(): for pattern in patterns: if match_pattern(fname, pattern): @@ -141,35 +141,35 @@ def scan_repository(repo_path: Path, depth: int = 2, top_n: int = 20): def print_report(repo_path: Path, all_files, lang_stats, lang_lines, key_files, dir_tree, top_n: int): - """输出扫描报告""" + """Print the scan report""" total_files = len(all_files) total_lines = sum(f[2] for f in all_files) print("=" * 70) - print(f" 代码仓库扫描报告: {repo_path.name}") - print(f" 路径: {repo_path}") + print(f" Repository scan report: {repo_path.name}") + print(f" Path: {repo_path}") print("=" * 70) - # 1. 基本统计 - print(f"\n## 1. 基本统计\n") - print(f"| 指标 | 数值 |") + # 1. Basic statistics + print(f"\n## 1. Basic statistics\n") + print(f"| Metric | Value |") print(f"|------|------|") - print(f"| 总文件数 | {total_files} |") - print(f"| 总代码行数 | {total_lines:,} |") - print(f"| 语言种类 | {len(lang_stats)} |") + print(f"| Total files | {total_files} |") + print(f"| Total lines of code | {total_lines:,} |") + print(f"| Languages | {len(lang_stats)} |") - # 2. 语言分布 - print(f"\n## 2. 语言分布\n") - print(f"| 语言 | 文件数 | 代码行数 | 占比 |") + # 2. Language distribution + print(f"\n## 2. Language distribution\n") + print(f"| Language | Files | Lines | Share |") print(f"|------|--------|---------|------|") for lang, count in lang_stats.most_common(15): lines = lang_lines[lang] pct = f"{lines / total_lines * 100:.1f}%" if total_lines > 0 else "0%" print(f"| {lang} | {count} | {lines:,} | {pct} |") - # 3. 目录结构 - print(f"\n## 3. 目录结构(前 30 行)\n") + # 3. Directory structure + print(f"\n## 3. Directory structure (first 30 lines)\n") print("```") for line in dir_tree[:30]: print(line) @@ -177,41 +177,41 @@ def print_report(repo_path: Path, all_files, lang_stats, lang_lines, key_files, print(f" ... ({len(dir_tree) - 30} more directories)") print("```") - # 4. 关键文件发现 - print(f"\n## 4. 关键文件发现\n") + # 4. Key file discovery + print(f"\n## 4. Key file discovery\n") for category, files in key_files.items(): if files: - print(f"\n### {category} ({len(files)} 个)\n") - # 去重并排序 + print(f"\n### {category} ({len(files)} files)\n") + # Deduplicate and sort seen = set() for fpath, lines in sorted(files, key=lambda x: -x[1])[:10]: if fpath not in seen: seen.add(fpath) - print(f"- `{fpath}` ({lines:,} 行)") + print(f"- `{fpath}` ({lines:,} lines)") - # 5. 代码热点 - print(f"\n## 5. 代码热点 (Top {top_n})\n") - print(f"| 排名 | 文件 | 行数 |") + # 5. Code hotspots + print(f"\n## 5. Code hotspots (Top {top_n})\n") + print(f"| Rank | File | Lines |") print(f"|------|------|------|") sorted_files = sorted(all_files, key=lambda x: -x[2]) for i, (fpath, ext, lines) in enumerate(sorted_files[:top_n], 1): print(f"| {i} | `{fpath}` | {lines:,} |") print(f"\n{'=' * 70}") - print(f" 扫描完成。共 {total_files} 个文件,{total_lines:,} 行代码。") + print(f" Scan complete. {total_files} files, {total_lines:,} lines of code.") print(f"{'=' * 70}") def main(): - parser = argparse.ArgumentParser(description="代码仓库结构扫描与统计工具") - parser.add_argument("repo_path", help="要扫描的仓库/目录路径") - parser.add_argument("--depth", type=int, default=2, help="目录树深度 (默认 2)") - parser.add_argument("--top", type=int, default=20, help="代码热点 Top N (默认 20)") + parser = argparse.ArgumentParser(description="Repository structure scan and statistics tool") + parser.add_argument("repo_path", help="Path of the repository/directory to scan") + parser.add_argument("--depth", type=int, default=2, help="Directory tree depth (default 2)") + parser.add_argument("--top", type=int, default=20, help="Code hotspots top N (default 20)") args = parser.parse_args() repo_path = Path(args.repo_path).resolve() if not repo_path.is_dir(): - print(f"错误: {repo_path} 不是有效目录", file=sys.stderr) + print(f"Error: {repo_path} is not a valid directory", file=sys.stderr) sys.exit(1) all_files, lang_stats, lang_lines, key_files, dir_tree = scan_repository( diff --git a/skill-data/wiki/scripts/validate_kb.py b/skill-data/wiki/scripts/validate_kb.py index 22ac3d72..06bbe3fb 100644 --- a/skill-data/wiki/scripts/validate_kb.py +++ b/skill-data/wiki/scripts/validate_kb.py @@ -1,15 +1,15 @@ #!/usr/bin/env python3 """ -validate_kb.py — 知识库质量校验工具 +validate_kb.py: knowledge base quality validation tool -用途: Phase 4 质量评估阶段,自动校验已生成知识库的: - 1. 链接完整性(检测死链接) - 2. search-anchor 覆盖率 - 3. AI 快速理解表覆盖率 - 4. 双向链接完整性 - 5. README 索引收录率 +Purpose: in the Phase 4 quality assessment stage, automatically check the generated knowledge base for: + 1. Link integrity (dead link detection) + 2. search-anchor coverage + 3. AI Quick Reference table coverage + 4. Bidirectional link integrity + 5. README index coverage -使用方式: +Usage: python3 validate_kb.py /path/to/knowledge-base-dir python3 validate_kb.py /path/to/knowledge-base-dir --verbose """ @@ -21,18 +21,24 @@ from pathlib import Path from collections import defaultdict -# Markdown 链接正则: [text](path) 或 [text](path#anchor) +# Markdown link regex: [text](path) or [text](path#anchor) LINK_PATTERN = re.compile(r'\[([^\]]*)\]\(([^)]+)\)') -# search-anchor 正则 +# search-anchor regex ANCHOR_PATTERN = re.compile(r'<!--\s*search-anchor\s*:(.*?)-->', re.DOTALL) -# AI 快速理解表正则 -AI_TABLE_PATTERN = re.compile(r'##\s*🤖\s*AI\s*快速理解', re.IGNORECASE) -# 双向链接: 链接回主架构/技术架构文档 -BACK_LINK_PATTERN = re.compile(r'\[📘.*(?:主架构|技术架构)|在整体架构中的位置', re.IGNORECASE) +# AI Quick Reference table regex. Matches both the current English heading and the +# legacy Chinese heading so knowledge bases built with earlier releases still validate. +# Legacy knowledge bases carry the Chinese heading; matched by code point so the source stays ASCII-only. +AI_TABLE_PATTERN = re.compile(r'##\s*🤖\s*AI\s*(?:Quick\s*Reference|\u5feb\u901f\u7406\u89e3)', re.IGNORECASE) +# Bidirectional link: a link back to the main / technical architecture document. +# Bilingual for the same reason as AI_TABLE_PATTERN. +BACK_LINK_PATTERN = re.compile( + r'\[📘.*(?:Technical\s*Architecture|\u4e3b\u67b6\u6784|\u6280\u672f\u67b6\u6784)|Position in the overall architecture|\u5728\u6574\u4f53\u67b6\u6784\u4e2d\u7684\u4f4d\u7f6e', + re.IGNORECASE, +) def find_md_files(kb_dir: Path) -> list: - """查找所有 .md 文件""" + """Find all .md files""" md_files = [] for root, dirs, files in os.walk(kb_dir): dirs[:] = [d for d in dirs if not d.startswith('.')] @@ -43,27 +49,27 @@ def find_md_files(kb_dir: Path) -> list: def check_links(md_file: Path, kb_dir: Path) -> list: - """检查文件中的链接是否有效""" + """Check that the links in the file resolve""" broken = [] try: content = md_file.read_text(encoding='utf-8', errors='ignore') except OSError: - return [("READ_ERROR", str(md_file), "无法读取文件")] + return [("READ_ERROR", str(md_file), "cannot read file")] for match in LINK_PATTERN.finditer(content): link_text = match.group(1) link_target = match.group(2) - # 跳过外部链接和锚点链接 + # Skip external links and anchor-only links if link_target.startswith(('http://', 'https://', 'mailto:', '#')): continue - # 分离路径和锚点 + # Split path and anchor path_part = link_target.split('#')[0] if not path_part: continue - # 解析相对路径 + # Resolve the relative path target_path = (md_file.parent / path_part).resolve() if not target_path.exists(): rel = str(md_file.relative_to(kb_dir)) @@ -73,7 +79,7 @@ def check_links(md_file: Path, kb_dir: Path) -> list: def check_anchor(md_file: Path) -> bool: - """检查文件是否包含 search-anchor""" + """Check whether the file contains a search-anchor""" try: content = md_file.read_text(encoding='utf-8', errors='ignore') return bool(ANCHOR_PATTERN.search(content)) @@ -82,7 +88,7 @@ def check_anchor(md_file: Path) -> bool: def check_ai_table(md_file: Path) -> bool: - """检查文件是否包含 AI 快速理解表""" + """Check whether the file contains the AI Quick Reference table""" try: content = md_file.read_text(encoding='utf-8', errors='ignore') return bool(AI_TABLE_PATTERN.search(content)) @@ -91,7 +97,7 @@ def check_ai_table(md_file: Path) -> bool: def check_back_link(md_file: Path) -> bool: - """检查组件文档是否有链接回主架构文档""" + """Check whether the component document links back to the main architecture document""" try: content = md_file.read_text(encoding='utf-8', errors='ignore') return bool(BACK_LINK_PATTERN.search(content)) @@ -100,7 +106,7 @@ def check_back_link(md_file: Path) -> bool: def check_readme_coverage(kb_dir: Path, md_files: list) -> tuple: - """检查 README 是否收录了所有 .md 文件""" + """Check whether the README indexes every .md file""" readme_path = kb_dir / "README.md" if not readme_path.exists(): return [], md_files @@ -112,7 +118,7 @@ def check_readme_coverage(kb_dir: Path, md_files: list) -> tuple: for f in md_files: if f.name == "README.md": continue - # 检查 README 中是否提到了这个文件 + # Check whether the README mentions this file fname_no_ext = f.stem if fname_no_ext in readme_content or f.name in readme_content: covered.append(f) @@ -123,106 +129,106 @@ def check_readme_coverage(kb_dir: Path, md_files: list) -> tuple: def main(): - parser = argparse.ArgumentParser(description="知识库质量校验工具") - parser.add_argument("kb_dir", help="知识库目录路径") - parser.add_argument("--verbose", "-v", action="store_true", help="输出详细信息") + parser = argparse.ArgumentParser(description="Knowledge base quality validation tool") + parser.add_argument("kb_dir", help="Path of the knowledge base directory") + parser.add_argument("--verbose", "-v", action="store_true", help="Print details") args = parser.parse_args() kb_dir = Path(args.kb_dir).resolve() if not kb_dir.is_dir(): - print(f"错误: {kb_dir} 不是有效目录", file=sys.stderr) + print(f"Error: {kb_dir} is not a valid directory", file=sys.stderr) sys.exit(1) md_files = find_md_files(kb_dir) if not md_files: - print(f"警告: {kb_dir} 中未找到任何 .md 文件") + print(f"Warning: no .md files found in {kb_dir}") sys.exit(0) - # 过滤出组件设计文档(以数字编号开头的文件) + # Filter the component design documents (files starting with a number) component_docs = [f for f in md_files if re.match(r'^\d+_', f.name)] print("=" * 70) - print(f" 知识库质量校验报告") - print(f" 目录: {kb_dir}") - print(f" 文件数: {len(md_files)} 个 .md 文件 (其中 {len(component_docs)} 个组件文档)") + print(f" Knowledge base quality validation report") + print(f" Directory: {kb_dir}") + print(f" Files: {len(md_files)} .md files ({len(component_docs)} component documents)") print("=" * 70) total_score = 0 max_score = 0 - # 1. 链接完整性 - print(f"\n## 1. 链接完整性检查\n") + # 1. Link integrity + print(f"\n## 1. Link integrity check\n") all_broken = [] for f in md_files: broken = check_links(f, kb_dir) all_broken.extend(broken) if all_broken: - print(f"❌ 发现 {len(all_broken)} 个死链接:") + print(f"❌ Found {len(all_broken)} dead links:") for src, target, text in all_broken[:20]: print(f" {src} → [{text}]({target})") if len(all_broken) > 20: - print(f" ... 还有 {len(all_broken) - 20} 个") + print(f" ... and {len(all_broken) - 20} more") else: - print(f"✅ 所有链接有效 (检查了 {len(md_files)} 个文件)") + print(f"✅ All links valid ({len(md_files)} files checked)") total_score += 20 max_score += 20 - # 2. search-anchor 覆盖率 - print(f"\n## 2. Search-Anchor 覆盖率\n") + # 2. search-anchor coverage + print(f"\n## 2. Search-Anchor coverage\n") has_anchor = sum(1 for f in md_files if check_anchor(f)) anchor_pct = has_anchor / len(md_files) * 100 if md_files else 0 - print(f"{'✅' if anchor_pct >= 80 else '⚠️'} {has_anchor}/{len(md_files)} 个文件有 search-anchor ({anchor_pct:.0f}%)") + print(f"{'✅' if anchor_pct >= 80 else '⚠️'} {has_anchor}/{len(md_files)} files have a search-anchor ({anchor_pct:.0f}%)") if args.verbose: for f in md_files: if not check_anchor(f): - print(f" 缺失: {f.relative_to(kb_dir)}") + print(f" Missing: {f.relative_to(kb_dir)}") if anchor_pct >= 80: total_score += 20 elif anchor_pct >= 50: total_score += 10 max_score += 20 - # 3. AI 快速理解表覆盖率(仅检查组件文档) - print(f"\n## 3. AI 快速理解表覆盖率 (组件文档)\n") + # 3. AI Quick Reference table coverage (component documents only) + print(f"\n## 3. AI Quick Reference table coverage (component documents)\n") if component_docs: has_ai_table = sum(1 for f in component_docs if check_ai_table(f)) ai_pct = has_ai_table / len(component_docs) * 100 - print(f"{'✅' if ai_pct >= 90 else '⚠️'} {has_ai_table}/{len(component_docs)} 个组件文档有 AI 快速理解表 ({ai_pct:.0f}%)") + print(f"{'✅' if ai_pct >= 90 else '⚠️'} {has_ai_table}/{len(component_docs)} component documents have an AI Quick Reference table ({ai_pct:.0f}%)") if args.verbose: for f in component_docs: if not check_ai_table(f): - print(f" 缺失: {f.relative_to(kb_dir)}") + print(f" Missing: {f.relative_to(kb_dir)}") if ai_pct >= 90: total_score += 20 elif ai_pct >= 60: total_score += 10 else: - print("⚠️ 未发现编号开头的组件文档") + print("⚠️ No numbered component documents found") max_score += 20 - # 4. 双向链接检查(组件文档是否链接回主架构) - print(f"\n## 4. 双向链接检查 (组件→主架构)\n") + # 4. Bidirectional link check (component documents link back to the main architecture) + print(f"\n## 4. Bidirectional link check (component → main architecture)\n") if component_docs: has_back = sum(1 for f in component_docs if check_back_link(f)) back_pct = has_back / len(component_docs) * 100 - print(f"{'✅' if back_pct >= 90 else '⚠️'} {has_back}/{len(component_docs)} 个组件文档有回链到主架构 ({back_pct:.0f}%)") + print(f"{'✅' if back_pct >= 90 else '⚠️'} {has_back}/{len(component_docs)} component documents link back to the main architecture ({back_pct:.0f}%)") if back_pct >= 90: total_score += 20 elif back_pct >= 60: total_score += 10 else: - print("⚠️ 未发现编号开头的组件文档") + print("⚠️ No numbered component documents found") max_score += 20 - # 5. README 索引覆盖率 - print(f"\n## 5. README 索引覆盖率\n") + # 5. README index coverage + print(f"\n## 5. README index coverage\n") covered, uncovered = check_readme_coverage(kb_dir, md_files) if (kb_dir / "README.md").exists(): cover_pct = len(covered) / (len(covered) + len(uncovered)) * 100 if (covered or uncovered) else 100 - print(f"{'✅' if cover_pct >= 90 else '⚠️'} README 收录了 {len(covered)}/{len(covered)+len(uncovered)} 个文档 ({cover_pct:.0f}%)") + print(f"{'✅' if cover_pct >= 90 else '⚠️'} README indexes {len(covered)}/{len(covered)+len(uncovered)} documents ({cover_pct:.0f}%)") if uncovered and args.verbose: - print(" 未收录:") + print(" Not indexed:") for f in uncovered[:10]: print(f" {f.relative_to(kb_dir)}") if cover_pct >= 90: @@ -230,19 +236,19 @@ def main(): elif cover_pct >= 60: total_score += 10 else: - print("❌ 未找到 README.md") + print("❌ README.md not found") max_score += 20 - # 总结 + # Summary final_pct = total_score / max_score * 100 if max_score else 0 print(f"\n{'=' * 70}") - print(f" 综合评分: {total_score}/{max_score} ({final_pct:.0f}%)") + print(f" Overall score: {total_score}/{max_score} ({final_pct:.0f}%)") if final_pct >= 90: - print(f" 评级: ✅ 优秀 — 知识库质量达标") + print(f" Rating: ✅ Excellent. The knowledge base meets the quality bar") elif final_pct >= 70: - print(f" 评级: ⚠️ 良好 — 建议修复上述问题") + print(f" Rating: ⚠️ Good. Fixing the issues above is recommended") else: - print(f" 评级: ❌ 需改进 — 存在较多质量问题") + print(f" Rating: ❌ Needs improvement. There are many quality issues") print(f"{'=' * 70}") diff --git a/src/__tests__/recall-toggle.test.ts b/src/__tests__/recall-toggle.test.ts index b0ba73fc..0cee9773 100644 --- a/src/__tests__/recall-toggle.test.ts +++ b/src/__tests__/recall-toggle.test.ts @@ -85,6 +85,29 @@ describe('recall toggle native agent cleanup', () => { expect(await fse.pathExists(legacyMarkdownAgent)).toBe(false); }); + it('disable removes the legacy share skill an earlier release deployed, and nothing beside it', async () => { + const { localConfig, teamConfig } = await mockAutoDetectInit(); + mockAutoDetectInit.mockResolvedValue({ + localConfig, + teamConfig: { ...teamConfig, toolPaths: { codex: { agents: '.codex/agents', skills: '.codex/skills' } } }, + }); + const skillsDir = path.join(homeDir, '.codex', 'skills'); + for (const name of ['teamai-share-learnings', 'team-wiki-codebase', 'teamai', 'my-own']) { + await fse.ensureDir(path.join(skillsDir, name)); + await fse.writeFile(path.join(skillsDir, name, 'SKILL.md'), `# ${name}`); + } + + await recallDisable({}); + + // Upgrade, then `recall disable` before the first pull: the old share + // workflow must not stay discoverable. The stub and the user's skills are + // not recall artifacts; the wiki tree is pull's to remove. + expect(await fse.pathExists(path.join(skillsDir, 'teamai-share-learnings'))).toBe(false); + for (const kept of ['team-wiki-codebase', 'teamai', 'my-own']) { + expect(await fse.pathExists(path.join(skillsDir, kept, 'SKILL.md')), kept).toBe(true); + } + }); + it('disable preserves non-agent files that only share the recall stem', async () => { const backup = path.join(homeDir, '.codex', 'agents', 'teamai-recall.backup'); await fse.writeFile(backup, 'user backup'); diff --git a/src/__tests__/skill-content.test.ts b/src/__tests__/skill-content.test.ts index 30c287ab..4d2d4ae3 100644 --- a/src/__tests__/skill-content.test.ts +++ b/src/__tests__/skill-content.test.ts @@ -6,11 +6,10 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { SKILL_DIR_PLACEHOLDER, - blockedByRecall, listServableSkills, packagedSkillRoots, renderSkill, - resolvePackagedSkill, + resolveServableSkill, skillCatalog, skillGet, skillPath, @@ -45,9 +44,16 @@ function writeSkill(root: string, name: string, body: string, files: Record<stri /** Resolve or fail the test, so the assertions below need no non-null operator. */ async function mustResolve(name: string, roots: PackagedSkillRoots): Promise<PackagedSkill> { - const skill = await resolvePackagedSkill(name, roots); - if (!skill) throw new Error(`fixture skill not found: ${name}`); - return skill; + const resolved = await resolveServableSkill(name, roots); + if (resolved.kind !== 'found') throw new Error(`fixture skill not found: ${name} (${resolved.kind})`); + return resolved.skill; +} + +/** The name a resolution lands on, or null: what the alias assertions compare. */ +async function resolvedName(name: string, roots: PackagedSkillRoots): Promise<string | null> { + const resolved = await resolveServableSkill(name, roots); + if (resolved.kind === 'not-found') return null; + return resolved.kind === 'found' ? resolved.skill.name : resolved.name; } describe('packaged skill discovery', () => { @@ -83,8 +89,8 @@ describe('packaged skill discovery', () => { writeSkill(roots.deployRoot, 'teamai', '# stub\n'); writeSkill(roots.dataRoot, 'core', '# core\n'); - const stub = await resolvePackagedSkill('teamai', roots); - expect(stub?.dir).toBe(path.join(roots.deployRoot, 'teamai')); + const stub = await mustResolve('teamai', roots); + expect(stub.dir).toBe(path.join(roots.deployRoot, 'teamai')); expect(stub?.deployed).toBe(true); }); @@ -94,13 +100,13 @@ describe('packaged skill discovery', () => { writeSkill(roots.dataRoot, 'share', '# share\n'); for (const alias of ['wiki', 'codebase', 'team-wiki-codebase']) { - expect((await resolvePackagedSkill(alias, roots))?.name, alias).toBe('wiki'); + expect(await resolvedName(alias, roots), alias).toBe('wiki'); } for (const alias of ['share', 'learning', 'learnings', 'teamai-share-learnings']) { - expect((await resolvePackagedSkill(alias, roots))?.name, alias).toBe('share'); + expect(await resolvedName(alias, roots), alias).toBe('share'); } - expect((await resolvePackagedSkill('default', roots))?.name).toBe('core'); - expect(await resolvePackagedSkill('nope', roots)).toBeNull(); + expect(await resolvedName('default', roots)).toBe('core'); + expect(await resolvedName('nope', roots)).toBeNull(); }); it('ignores directories without SKILL.md and dotfiles', async () => { @@ -262,7 +268,8 @@ describe('teamai skill get / path against the shipped package', () => { // this run's team config decides whether share is in the dump. const servable: PackagedSkill[] = []; for (const skill of await listServableSkills()) { - if (!await blockedByRecall(skill.name)) servable.push(skill); + const resolved = await resolveServableSkill(skill.name); + if (resolved.kind === 'found') servable.push(resolved.skill); } await skillGet([], { all: true }); diff --git a/src/__tests__/skill-list-uninitialized.test.ts b/src/__tests__/skill-list-uninitialized.test.ts new file mode 100644 index 00000000..28ab0ef0 --- /dev/null +++ b/src/__tests__/skill-list-uninitialized.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { autoDetectInit, logDim } = vi.hoisted(() => ({ autoDetectInit: vi.fn(), logDim: vi.fn() })); +vi.mock('../config.js', () => ({ autoDetectInit })); +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: logDim }, +})); + +import { skillList } from '../skill-cmd.js'; + +/** + * `skill get` serves the packaged content on a machine with no team; the + * human-readable `skill list` must let that machine discover it too, instead of + * failing on the team listing it prints first. + */ +describe('teamai skill list before init', () => { + let stdout: string; + let logSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + stdout = ''; + autoDetectInit.mockReset(); + logDim.mockReset(); + logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + stdout += args.join(' ') + '\n'; + }); + }); + + afterEach(() => { + logSpy.mockRestore(); + process.exitCode = undefined; + }); + + it('prints the packaged catalog and says what to run for the rest', async () => { + autoDetectInit.mockRejectedValue(new Error('teamai is not initialized. Run `teamai init` first.')); + + await skillList({}); + + expect(process.exitCode).toBeUndefined(); + expect(stdout).toContain('=== BUILT-IN SKILLS (served by the CLI) ==='); + for (const name of ['core', 'setup', 'share', 'wiki']) { + expect(stdout).toContain(`teamai skill get ${name}`); + } + expect(logDim).toHaveBeenCalledWith(expect.stringContaining('teamai init')); + }); +}); diff --git a/src/__tests__/skill-recall-gate.test.ts b/src/__tests__/skill-recall-gate.test.ts index 29d425cb..cc39cbcd 100644 --- a/src/__tests__/skill-recall-gate.test.ts +++ b/src/__tests__/skill-recall-gate.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; const autoDetectInit = vi.fn(); vi.mock('../config.js', () => ({ autoDetectInit })); -import { blockedByRecall, skillCatalog, skillGet, skillPath } from '../skill-content.js'; +import { resolveServableSkill, skillCatalog, skillGet, skillPath } from '../skill-content.js'; /** * Recall used to be decided when deploying: the share skill simply was not @@ -49,7 +49,9 @@ describe('recall gate on served skills', () => { it('blocks share when recall is disabled, and says what to turn on', async () => { withRecall(false); - expect(await blockedByRecall('share')).toBe(true); + expect(await resolveServableSkill('share')).toEqual({ kind: 'blocked', name: 'share', reason: 'recall' }); + // Aliases land on the same gate: the name in the refusal is the canonical one. + expect(await resolveServableSkill('teamai-share-learnings')).toMatchObject({ kind: 'blocked', name: 'share' }); await skillGet(['share']); expect(process.exitCode).toBe(1); @@ -61,7 +63,7 @@ describe('recall gate on served skills', () => { it('serves share when recall is enabled', async () => { withRecall(true); - expect(await blockedByRecall('share')).toBe(false); + expect(await resolveServableSkill('share')).toMatchObject({ kind: 'found', skill: { name: 'share' } }); await skillGet(['share']); expect(process.exitCode).toBeUndefined(); @@ -107,7 +109,7 @@ describe('recall gate on served skills', () => { withRecall(false); for (const name of ['core', 'setup', 'wiki']) { - expect(await blockedByRecall(name), name).toBe(false); + expect((await resolveServableSkill(name)).kind, name).toBe('found'); } }); @@ -116,6 +118,6 @@ describe('recall gate on served skills', () => { // A fresh machine reading the docs gets the content, not a refusal it // cannot act on. - expect(await blockedByRecall('share')).toBe(false); + expect((await resolveServableSkill('share')).kind).toBe('found'); }); }); diff --git a/src/__tests__/skills.test.ts b/src/__tests__/skills.test.ts index b46fe087..75b5ec9a 100644 --- a/src/__tests__/skills.test.ts +++ b/src/__tests__/skills.test.ts @@ -217,6 +217,25 @@ scope: 'user', expect(names).not.toContain('ignored-skill'); }); + it('never offers the directories earlier releases deployed as new skills to push', async () => { + // A member who runs `teamai push --all` after upgrading but before their + // next pull still has the legacy trees on disk; they are the CLI's, not theirs. + for (const legacy of ['team-wiki-codebase', 'teamai-share-learnings', 'teamai']) { + const dir = path.join(homeDir, '.claude/skills', legacy); + await fse.ensureDir(dir); + await fse.writeFile(path.join(dir, 'SKILL.md'), '# packaged by an earlier release'); + } + const mine = path.join(homeDir, '.claude/skills', 'teamai-workflow'); + await fse.ensureDir(mine); + await fse.writeFile(path.join(mine, 'SKILL.md'), '# mine'); + + const names = (await handler.scanLocalForPush(teamConfig, localConfig)).map((i) => i.name); + expect(names).not.toContain('team-wiki-codebase'); + expect(names).not.toContain('teamai-share-learnings'); + expect(names).not.toContain('teamai'); + expect(names).toContain('teamai-workflow'); + }); + it('should detect both new and modified skills together', async () => { // Modified const teamSkillDir = path.join(localConfig.repo.localPath, 'skills', 'existing'); diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index 1ea50bb4..c86a9b16 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -52,6 +52,22 @@ export const LEGACY_BUILTIN_SKILL_NAMES = new Set([ 'team-wiki-codebase', ]); +/** + * The legacy directory that depended on recall. `teamai recall disable` still + * removes it, as it did before the stub, so a member who upgrades and disables + * recall before their next pull is not left with the old share workflow. + */ +export const LEGACY_RECALL_SKILL_NAMES = new Set(['teamai-share-learnings']); + +/** + * Whether a skill directory by this name is the CLI's, current or legacy, and + * therefore never a user's own to push. A member who runs `teamai push --all` + * after upgrading but before pulling still has the legacy trees on disk. + */ +export function isCliOwnedSkillName(name: string): boolean { + return BUILTIN_SKILL_NAMES.has(name) || LEGACY_BUILTIN_SKILL_NAMES.has(name); +} + /** * Remove the skill directories earlier releases deployed. * @@ -59,13 +75,18 @@ export const LEGACY_BUILTIN_SKILL_NAMES = new Set([ * so no local edit ever survived in them, and leaving them behind costs every * agent on the machine the context they were deployed to save. */ -async function pruneLegacyBuiltinSkills(tool: string, configuredSkillsPath: string, baseDir: string): Promise<void> { +export async function pruneLegacyBuiltinSkills( + tool: string, + configuredSkillsPath: string, + baseDir: string, + names: ReadonlySet<string> = LEGACY_BUILTIN_SKILL_NAMES, +): Promise<void> { // The shared .agents/skills directory belongs to Codex alone. Reaching it from // another tool's pass would delete Codex's copies while Codex is excluded or // not installed, which the enabledAgents whitelist rules out. const skillRoots = [configuredSkillsPath]; if (tool === CODEX_TOOL) skillRoots.push(SHARED_AGENT_SKILLS_PATH); - for (const legacyName of LEGACY_BUILTIN_SKILL_NAMES) { + for (const legacyName of names) { for (const root of skillRoots) { const dir = path.join(baseDir, root, legacyName); if (!await pathExists(dir)) continue; diff --git a/src/recall-toggle.ts b/src/recall-toggle.ts index 4f5a14b5..5d28ce65 100644 --- a/src/recall-toggle.ts +++ b/src/recall-toggle.ts @@ -9,6 +9,7 @@ import { type ToolName, } from './resources/agent-format.js'; import { ruleFileExtensionForTool } from './resources/rule-format.js'; +import { LEGACY_RECALL_SKILL_NAMES, pruneLegacyBuiltinSkills } from './builtin-skills.js'; import { resolveToolBaseDir, isRecallEnabled, @@ -37,6 +38,13 @@ async function removeRecallArtifacts(teamConfig: TeamaiConfig, localConfig: Loca } } + // Remove the legacy recall skill an earlier release deployed. The served + // `share` workflow is gated at run time, but a member who upgrades and + // disables recall before pulling still has the old directory. + if (toolPath.skills && !isAgentExcluded(localConfig, tool)) { + await pruneLegacyBuiltinSkills(tool, toolPath.skills, baseDir, LEGACY_RECALL_SKILL_NAMES); + } + // Remove recall agent file if (toolPath.agents) { const agentsDir = path.join(baseDir, toolPath.agents); diff --git a/src/resources/skills.ts b/src/resources/skills.ts index 74fde282..8fcf8593 100644 --- a/src/resources/skills.ts +++ b/src/resources/skills.ts @@ -5,7 +5,7 @@ import type { ResourceItem, ResourceItemStatus, DeliveryTarget, TeamaiConfig, Lo import { getPushignorePath, isAgentExcluded, resolveToolBaseDir, scopedToolPaths } from '../types.js'; import { listDirs, listFilesRecursive, pathExists, copyDir, remove, pruneEmptyDirs, dirContentEqual, dirTeamSubsetEqual, getDirLatestMtime, readFileSafe, writeFile } from '../utils/fs.js'; import { log } from '../utils/logger.js'; -import { BUILTIN_SKILL_NAMES } from '../builtin-skills.js'; +import { isCliOwnedSkillName } from '../builtin-skills.js'; import { resolveOpenclawWorkspaceDir } from '../openclaw-hooks.js'; import { getHermesHome } from '../hermes-home.js'; import { loadRolesManifest, resolveRoleResourceNamespaces } from '../roles.js'; @@ -440,7 +440,7 @@ export class SkillsHandler extends ResourceHandler { if (tombstones.has(dir)) continue; if (pushIgnoredSkills.has(dir)) continue; if (blockedSkills.has(dir)) continue; // Skip skills in non-allowed namespaces - if (BUILTIN_SKILL_NAMES.has(dir)) continue; // Skip CLI built-in skills + if (isCliOwnedSkillName(dir)) continue; // Skip CLI built-in skills, current and legacy if (sourceSkillNames.has(dir)) continue; // Skip cross-team source skills if (teamSkills.has(dir)) { diff --git a/src/skill-cmd.ts b/src/skill-cmd.ts index e6e55372..ee4761f7 100644 --- a/src/skill-cmd.ts +++ b/src/skill-cmd.ts @@ -13,12 +13,13 @@ import { type SkillSource, } from './agent-skills.js'; import { detectInstalledAgents, type ResolvedAgent } from './known-agents.js'; -import { blockedByRecall, resolvePackagedSkill, skillCatalog } from './skill-content.js'; +import { recallBlockMessage, resolveServableSkill, skillCatalog } from './skill-content.js'; import type { GlobalOptions, LocalConfig } from './types.js'; const DESCRIPTION_MAX = 160; interface ResolvedSkill { + kind: 'found'; name: string; /** Path used to read SKILL.md, contributors and description. */ primaryPath: string; @@ -28,6 +29,14 @@ interface ResolvedSkill { namespace?: string; } +/** A packaged skill the recall gate withholds; there is no path to print. */ +interface BlockedSkill { + kind: 'blocked'; + name: string; +} + +type LocatedSkill = ResolvedSkill | BlockedSkill; + /** * `teamai skill show <name>` — print metadata about a single * skill: source classification, contributors, namespace, tags @@ -41,25 +50,25 @@ export async function skillShow(name: string, options: GlobalOptions): Promise<v const { localConfig, teamConfig } = await autoDetectInit(); const agents = await detectInstalledAgents(localConfig, teamConfig); - const resolved = await locateSkill(name, localConfig, agents); - if (!resolved) { + const located = await locateSkill(name, localConfig, agents); + if (!located) { log.error(`Skill "${name}" not found in team repo or any installed agent.`); log.dim('Try `teamai list --source all` to see available skills.'); process.exitCode = 1; return; } - - const resolvedName = resolved.name; - - // The recall gate holds here too: `skill get` and `skill path` withhold the - // share workflow while recall is off, and the card would otherwise print the - // very directory they refuse. - if (resolved.primaryOrigin === 'builtin' && await blockedByRecall(resolvedName)) { - log.error(`${resolvedName} needs recall, which is disabled for this team.`); - log.dim('Turn it on with `teamai recall enable`, or ask your team admin to enable sharing.'); + // The resolver never hands out a blocked skill, so there is no directory to + // print here even by accident; only the refusal is left to do. + if (located.kind === 'blocked') { + const { headline, hint } = recallBlockMessage(located.name); + log.error(headline); + log.dim(hint); process.exitCode = 1; return; } + const resolved: ResolvedSkill = located; + + const resolvedName = resolved.name; // A skill served from the package is built in by construction; BUILTIN_SKILL_NAMES // only knows the deployed stub, so classifying by name would call `core` local-only. @@ -105,8 +114,21 @@ export async function skillList(options: GlobalOptions & { json?: boolean }): Pr return; } - const { list } = await import('./status.js'); - await list('skills', { ...options, source: 'all' }); + // The packaged catalog needs no team: a machine that has not run `teamai init` + // still gets to discover what the installed CLI serves, like `skill get` does. + let initialized = true; + try { + await autoDetectInit(); + } catch { + initialized = false; + } + if (initialized) { + const { list } = await import('./status.js'); + await list('skills', { ...options, source: 'all' }); + } else { + log.dim('Not initialized: run `teamai init` to list team and installed skills.'); + console.log(''); + } console.log('=== BUILT-IN SKILLS (served by the CLI) ==='); console.log(''); @@ -126,13 +148,13 @@ async function locateSkill( name: string, localConfig: LocalConfig, agents: ResolvedAgent[], -): Promise<ResolvedSkill | null> { +): Promise<LocatedSkill | null> { const teamSkillsDir = path.join(localConfig.repo.localPath, 'skills'); // 1. Flat layout in team repo const flat = path.join(teamSkillsDir, name); if (await pathExists(path.join(flat, 'SKILL.md'))) { - return { name, primaryPath: flat, primaryOrigin: 'team' }; + return { kind: 'found', name, primaryPath: flat, primaryOrigin: 'team' }; } // 2. Namespaced layout in team repo @@ -141,7 +163,7 @@ async function locateSkill( for (const ns of namespaces) { const candidate = path.join(teamSkillsDir, ns, name); if (await pathExists(path.join(candidate, 'SKILL.md'))) { - return { name, primaryPath: candidate, primaryOrigin: 'team', namespace: ns }; + return { kind: 'found', name, primaryPath: candidate, primaryOrigin: 'team', namespace: ns }; } } } @@ -149,9 +171,10 @@ async function locateSkill( // 3. Built-in skill served by the CLI (including legacy-name aliases). // Resolved before the agent fallback: under the discovery-stub model the // agent directory holds a stub, not the content this command describes. - const packaged = await resolvePackagedSkill(name); - if (packaged) { - return { name: packaged.name, primaryPath: packaged.dir, primaryOrigin: 'builtin' }; + const served = await resolveServableSkill(name); + if (served.kind === 'blocked') return { kind: 'blocked', name: served.name }; + if (served.kind === 'found') { + return { kind: 'found', name: served.skill.name, primaryPath: served.skill.dir, primaryOrigin: 'builtin' }; } // 4. First installed agent that has the skill @@ -159,7 +182,7 @@ async function locateSkill( if (!agent.installed) continue; const candidate = path.join(agent.absoluteSkillsPath, name); if (await pathExists(path.join(candidate, 'SKILL.md'))) { - return { name, primaryPath: candidate, primaryOrigin: 'agent' }; + return { kind: 'found', name, primaryPath: candidate, primaryOrigin: 'agent' }; } } diff --git a/src/skill-content.ts b/src/skill-content.ts index ed3bf95e..ee60ff20 100644 --- a/src/skill-content.ts +++ b/src/skill-content.ts @@ -66,7 +66,7 @@ const RECALL_DEPENDENT_SKILLS = new Set(['share']); * Fails open: a machine with no team config (a fresh install reading the docs) * gets the content rather than a refusal it cannot act on. */ -export async function blockedByRecall(name: string): Promise<boolean> { +async function blockedByRecall(name: string): Promise<boolean> { if (!RECALL_DEPENDENT_SKILLS.has(name)) return false; try { const [{ autoDetectInit }, { isRecallEnabled }] = await Promise.all([ @@ -146,7 +146,7 @@ export async function listServableSkills(roots: PackagedSkillRoots = packagedSki * Resolve a name or alias to a packaged skill. Servable content wins over the * deployed stub, which stays reachable by its exact name for debugging. */ -export async function resolvePackagedSkill( +async function resolvePackagedSkill( name: string, roots: PackagedSkillRoots = packagedSkillRoots(), ): Promise<PackagedSkill | null> { @@ -165,6 +165,41 @@ export async function resolvePackagedSkill( return null; } +/** + * The outcome of asking for a skill by name. `blocked` carries the same + * information as `found`, minus the skill: a caller cannot print a directory it + * never received. + */ +export type ServableSkillResolution = + | { kind: 'found'; skill: PackagedSkill } + | { kind: 'blocked'; name: string; reason: 'recall' } + | { kind: 'not-found'; name: string }; + +/** + * The only way to obtain a packaged skill outside this module. + * + * The recall gate is applied here, once, so every command that hands out a + * skill's content or its directory (`get`, `path`, `list`, `show`) inherits it + * by construction instead of remembering to check. + */ +export async function resolveServableSkill( + name: string, + roots: PackagedSkillRoots = packagedSkillRoots(), +): Promise<ServableSkillResolution> { + const skill = await resolvePackagedSkill(name, roots); + if (!skill) return { kind: 'not-found', name }; + if (await blockedByRecall(skill.name)) return { kind: 'blocked', name: skill.name, reason: 'recall' }; + return { kind: 'found', skill }; +} + +/** The two lines every command prints for a recall-blocked skill. */ +export function recallBlockMessage(name: string): { headline: string; hint: string } { + return { + headline: `${name} needs recall, which is disabled for this team.`, + hint: 'Turn it on with `teamai recall enable`, or ask your team admin to enable sharing.', + }; +} + async function collectSupplementaryFiles(skillDir: string): Promise<Array<{ relativePath: string; content: string }>> { const files: Array<{ relativePath: string; content: string }> = []; @@ -236,8 +271,9 @@ function rootsMissing(): void { * old deployment restriction cannot be sidestepped by asking differently. */ function refuseBlockedByRecall(name: string): void { - diagnostic(`${chalk.red('✖')} ${name} needs recall, which is disabled for this team.`); - diagnostic(' Turn it on with `teamai recall enable`, or ask your team admin to enable sharing.'); + const { headline, hint } = recallBlockMessage(name); + diagnostic(`${chalk.red('✖')} ${headline}`); + diagnostic(` ${hint}`); process.exitCode = 1; } @@ -275,25 +311,26 @@ export async function skillGet(names: string[], options: SkillGetOptions = {}): if (options.all) { // The gate holds for the inventory dump too: a blocked skill is left out // and named on stderr, the rest is still served. - for (const skill of servable) { - if (await blockedByRecall(skill.name)) { - diagnostic(`${chalk.yellow('⚠')} Skipped ${skill.name}: needs recall, which is disabled for this team (teamai recall enable).`); + for (const listed of servable) { + const resolved = await resolveServableSkill(listed.name, roots); + if (resolved.kind !== 'found') { + diagnostic(`${chalk.yellow('⚠')} Skipped ${listed.name}: needs recall, which is disabled for this team (teamai recall enable).`); continue; } - targets.push(skill); + targets.push(resolved.skill); } } else { for (const name of requested) { - const skill = await resolvePackagedSkill(name, roots); - if (!skill) { + const resolved = await resolveServableSkill(name, roots); + if (resolved.kind === 'not-found') { notFound(name, servable); return; } - if (await blockedByRecall(skill.name)) { - refuseBlockedByRecall(skill.name); + if (resolved.kind === 'blocked') { + refuseBlockedByRecall(resolved.name); return; } - targets.push(skill); + targets.push(resolved.skill); } } @@ -330,16 +367,22 @@ export async function skillPath(name?: string): Promise<void> { return; } - const skill = await resolvePackagedSkill(name, roots); - if (!skill) { - notFound(name, await listServableSkills(roots)); - return; - } - if (await blockedByRecall(skill.name)) { - refuseBlockedByRecall(skill.name); - return; + const resolved = await resolveServableSkill(name, roots); + switch (resolved.kind) { + case 'not-found': + notFound(name, await listServableSkills(roots)); + return; + case 'blocked': + refuseBlockedByRecall(resolved.name); + return; + case 'found': + console.log(resolved.skill.dir); + return; + default: { + const exhaustive: never = resolved; + throw new Error(`Unhandled resolution ${String(exhaustive)}`); + } } - console.log(skill.dir); } /** @@ -360,7 +403,7 @@ export async function skillCatalog(roots: PackagedSkillRoots = packagedSkillRoot const skills = await listServableSkills(roots); const entries: SkillCatalogEntry[] = []; for (const skill of skills) { - const blocked = await blockedByRecall(skill.name); + const blocked = (await resolveServableSkill(skill.name, roots)).kind === 'blocked'; entries.push({ name: skill.name, description: await readSkillDescription(path.join(skill.dir, SKILL_MD)), From ee25129975563a06a3542d1353d56f8ea24bb1db Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Tue, 22 Sep 2026 08:40:46 +0200 Subject: [PATCH 12/37] fix(skills): prune only files the CLI packaged, let local skills win by name Review follow-up on #699. - PACKAGED_SKILL_FILES lists every file a release ever wrote under skills/, as the union of `git ls-tree -r <tag> -- skills/` over all 91 tags. The prune removes those paths and the directories they leave empty; a file a member added is kept, its directory with it, and pull says which and why. The stub directory loses its six known references by name instead of "everything that is not SKILL.md". Python bytecode of a script we shipped counts as ours, so a __pycache__ does not strand the tree. - locateSkill searches the team repo, then installed agents, then the package. A directory a member created under `codebase`, `default`, `learning` or `share` is the skill they asked about, and the recall gate does not apply to it. - A guard test fails when a file ships under skills/ without being recorded in the manifest, which a later migration would otherwise leave behind. --- docs/designs/skill-serving.md | 25 +++- src/__tests__/skill-show.test.ts | 18 +++ src/__tests__/skip-uninstalled-tools.test.ts | 98 +++++++++++++++ src/builtin-skills.ts | 126 +++++++++++++++++-- src/skill-cmd.ts | 23 ++-- 5 files changed, 265 insertions(+), 25 deletions(-) diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index 5af6bf01..430dc824 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -78,6 +78,11 @@ task matches stub body ~1.3 KB holds the c `resolveServableSkill` (`src/skill-content.ts`) is the only way to obtain a packaged skill outside that module, and it returns `blocked` instead of the skill, so a command cannot print a directory it never received. +- **A member's own skill outranks a packaged name.** `locateSkill` searches the + team repo, then the installed agents, then the package. `codebase`, `default`, + `learning` and `share` are ordinary names: a directory a member created under + one of them is the skill they are asking about, and the recall gate does not + apply to it. - **`skill list` needs no team.** The human-readable listing prints the packaged catalog even before `teamai init`, with a hint for the team half, so a fresh machine can discover what the installed CLI serves the way `skill get` lets it. @@ -108,9 +113,23 @@ earlier releases deployed: `team-wiki-codebase` and `teamai-share-learnings`. packaged, so they are not in it: a directory by either name is the user's own. Deployment removes them from every installed, non-excluded agent, in its configured skills path; Codex's pass also covers the shared `.agents/skills`, -which no other tool's pass touches. The removal is unconditional -because those trees were overwritten with `overwrite: true` on every pull, so no -local edit ever survived in them. +which no other tool's pass touches. + +**It removes only the files those releases packaged.** `PACKAGED_SKILL_FILES` +lists them, built as the union of `git ls-tree -r <tag> -- skills/` over every +tag, so each path is provably the CLI's. Those files were overwritten with +`overwrite: true` on every pull and no local edit ever survived in one; a file a +member added beside them was never touched by the old deployment and is not ours +to delete now. Directories left empty go; a directory still holding a member's +file is kept, and `pull` says which one and why. Python bytecode of a script we +shipped counts as ours, so a `__pycache__` left by running the wiki scripts does +not strand the tree. The same rule governs the stub directory: the six +`teamai/references/*.md` a pre-stub release wrote are removed by name, not by +"everything that is not SKILL.md". + +`teamai-wiki` (0.13.0, 0.16.x) is deliberately not in the set. It predates the +trees this migration is about, and widening a destructive set belongs in its own +change. Between the upgrade and that first pull the legacy trees are still on disk, so two other commands know the names too: `push` never offers them as new user diff --git a/src/__tests__/skill-show.test.ts b/src/__tests__/skill-show.test.ts index b089b790..e3624562 100644 --- a/src/__tests__/skill-show.test.ts +++ b/src/__tests__/skill-show.test.ts @@ -147,6 +147,24 @@ describe('skillShow locator', () => { expect(text).toContain('claude'); }); + it("prefers a member's own skill over a packaged name or alias", async () => { + // `codebase` aliases the wiki skill and `share` is served by the CLI, but a + // directory a member created under either name is the skill they mean. + const claudeSkillsDir = path.join(fx.homeDir, '.claude', 'skills'); + await fse.ensureDir(claudeSkillsDir); + await makeSkill(claudeSkillsDir, 'codebase', 'my own codebase notes'); + await makeSkill(claudeSkillsDir, 'share', 'my own sharing helper'); + + for (const [name, description] of [['codebase', 'my own codebase notes'], ['share', 'my own sharing helper']]) { + const text = (await runSkillShow(name, fx)).join('\n'); + expect(text, name).toContain(description); + expect(text, name).toContain('[local-only]'); + expect(text, name).not.toContain('skill-data'); + // `share` is recall-gated in the package; a member's own skill is not. + expect(process.exitCode, name).toBe(0); + } + }); + it('classifies a skill served from the package as builtin', async () => { // Only the deployed stub is in BUILTIN_SKILL_NAMES; the served workflows // are built in by where they were found, not by name. diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index c47ac694..7d7e877a 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -694,6 +694,104 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { expect(await fse.pathExists(path.join(homeDir, '.agents/skills/teamai'))).toBe(false); }); + it('records every file the package still ships, so the prune keeps proving ownership', async () => { + const { PACKAGED_SKILL_FILES } = await import('../builtin-skills.js'); + + const shipped: string[] = []; + const walk = async (dir: string, prefix: string): Promise<void> => { + for (const entry of await fse.readdir(dir, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) await walk(path.join(dir, entry.name), relative); + else shipped.push(relative); + } + }; + await walk(path.join(PACKAGE_ROOT, 'skills'), ''); + + // A packaged file missing from the manifest is one a later migration would + // leave behind on every machine, which no other test would notice. + for (const relative of shipped) { + const [skillName, ...rest] = relative.split('/'); + expect(PACKAGED_SKILL_FILES.get(skillName), relative).toContain(rest.join('/')); + } + }); + + it('removes the packaged files from a legacy directory but keeps what the member added', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + const teamConfig = { + team: 'test', + description: '', + repo: 'https://example.test/team.git', + provider: 'git' as const, + reviewers: [], + sharing: { skills: {}, rules: { enforced: [] }, docs: { localDir: '' }, env: { injectShellProfile: true } }, + toolPaths: { claude: { skills: '.claude/skills' } }, + }; + const localConfig = { + repo: { localPath: path.join(tmpDir, 'repo'), remote: 'https://example.test/team.git' }, + username: 'testuser', + updatePolicy: 'auto' as const, + additionalRoles: [], + scope: 'user' as const, + }; + + const wiki = path.join(homeDir, '.claude/skills/team-wiki-codebase'); + // What the release packaged… + for (const packaged of ['SKILL.md', 'README.md', 'references/methodology/phase0-collection.md', 'scripts/scan_repo.py']) { + await fse.ensureDir(path.join(wiki, path.dirname(packaged))); + await fse.writeFile(path.join(wiki, packaged), '# packaged'); + } + // …and what the member put beside it, which `overwrite: true` never deleted. + await fse.writeFile(path.join(wiki, 'references/methodology/my-notes.md'), '# mine'); + await fse.ensureDir(path.join(wiki, 'scripts/__pycache__')); + await fse.writeFile(path.join(wiki, 'scripts/__pycache__/scan_repo.cpython-311.pyc'), 'bytecode'); + + await deployBuiltinSkills(teamConfig, localConfig); + + expect(await fse.pathExists(path.join(wiki, 'SKILL.md'))).toBe(false); + expect(await fse.pathExists(path.join(wiki, 'README.md'))).toBe(false); + expect(await fse.pathExists(path.join(wiki, 'references/methodology/phase0-collection.md'))).toBe(false); + // Bytecode of a script we shipped is ours, so it does not keep the tree alive. + expect(await fse.pathExists(path.join(wiki, 'scripts'))).toBe(false); + // The member's file, and only it, survives. + expect(await fse.readFile(path.join(wiki, 'references/methodology/my-notes.md'), 'utf8')).toBe('# mine'); + }); + + it('keeps a file the member added beside the deployed stub', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + const teamConfig = { + team: 'test', + description: '', + repo: 'https://example.test/team.git', + provider: 'git' as const, + reviewers: [], + sharing: { skills: {}, rules: { enforced: [] }, docs: { localDir: '' }, env: { injectShellProfile: true } }, + toolPaths: { claude: { skills: '.claude/skills' } }, + }; + const localConfig = { + repo: { localPath: path.join(tmpDir, 'repo'), remote: 'https://example.test/team.git' }, + username: 'testuser', + updatePolicy: 'auto' as const, + additionalRoles: [], + scope: 'user' as const, + }; + + const stubDir = path.join(homeDir, '.claude/skills/teamai'); + await fse.ensureDir(path.join(stubDir, 'references')); + await fse.writeFile(path.join(stubDir, 'SKILL.md'), '# old body'); + await fse.writeFile(path.join(stubDir, 'references/setup-admin.md'), '# old reference'); + await fse.writeFile(path.join(stubDir, 'references/team-playbook.md'), '# mine'); + + await deployBuiltinSkills(teamConfig, localConfig); + + expect(await fse.pathExists(path.join(stubDir, 'references/setup-admin.md'))).toBe(false); + expect(await fse.readFile(path.join(stubDir, 'references/team-playbook.md'), 'utf8')).toBe('# mine'); + expect(await fse.readFile(path.join(stubDir, 'SKILL.md'), 'utf8')).toBe( + await fse.readFile(path.join(PACKAGE_ROOT, 'skills/teamai/SKILL.md'), 'utf8'), + ); + }); + it('leaves the Codex shared directory alone when another tool prunes and Codex is excluded', async () => { const { deployBuiltinSkills } = await import('../builtin-skills.js'); diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index c86a9b16..4976024d 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -68,12 +68,111 @@ export function isCliOwnedSkillName(name: string): boolean { return BUILTIN_SKILL_NAMES.has(name) || LEGACY_BUILTIN_SKILL_NAMES.has(name); } +/** + * Every file a release ever packaged under `skills/`, by directory name. + * + * Built as the union of `git ls-tree -r <tag> -- skills/` over all 91 tags, so a + * path listed here was written by the CLI and is ours to remove. Deployment + * copied these trees with `overwrite: true` and never deleted anything, so a + * file that is *not* listed here was put there by the member and survives. + * + * `teamai-wiki` (0.13.0, 0.16.x) is deliberately absent: it predates the trees + * this migration is about, and widening a destructive set is its own change. + */ +export const PACKAGED_SKILL_FILES: ReadonlyMap<string, readonly string[]> = new Map([ + ['teamai', [ + 'SKILL.md', + 'references/contribute-member.md', + 'references/join-member.md', + 'references/manage-admin.md', + 'references/setup-admin.md', + 'references/troubleshooting.md', + 'references/uninstall.md', + ]], + ['teamai-share-learnings', ['SKILL.md']], + ['team-wiki-codebase', [ + 'SKILL.md', + 'README.md', + 'references/agents/graph-rag-agent.md', + 'references/agents/kb-doc-generator.md', + 'references/methodology/phase0-collection.md', + 'references/methodology/phase1-reverse-engineering.md', + 'references/methodology/phase2-document-types.md', + 'references/methodology/phase3-ai-enhancement.md', + 'references/methodology/phase4-quality.md', + 'references/templates/project-overview.md', + 'scripts/scan_repo.py', + 'scripts/validate_kb.py', + ]], +]); + +/** + * Python bytecode cache of a script we shipped. Compiler output of our own + * files, so it carries nothing a member wrote and does not make a directory + * theirs. + */ +function isDerivedArtifact(relativePath: string): boolean { + return relativePath.endsWith('.pyc') || relativePath.split('/').includes('__pycache__'); +} + +/** Every file under `dir`, as paths relative to it. Symlinks count as files. */ +async function walkFiles(dir: string, prefix = ''): Promise<string[]> { + const found: string[] = []; + for (const entry of await fs.promises.readdir(dir, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + found.push(...await walkFiles(path.join(dir, entry.name), relative)); + } else { + found.push(relative); + } + } + return found; +} + +/** Remove `dir` and every directory under it that holds nothing. */ +async function removeEmptyDirs(dir: string): Promise<void> { + let entries; + try { + entries = await fs.promises.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.isDirectory()) await removeEmptyDirs(path.join(dir, entry.name)); + } + // Fails when something is left, which is the point: that something is the + // member's, and their directory stays. + try { await fs.promises.rmdir(dir); } catch { /* not empty */ } +} + +/** + * Remove from `dir` the files the CLI put there, then the directories that end + * up empty. Returns false when the member has files of their own in there, so + * the caller can say the directory was kept. + */ +async function removeOwnedFiles(dir: string, owned: readonly string[]): Promise<boolean> { + const ownedPaths = new Set(owned); + let foreign = 0; + + for (const relative of await walkFiles(dir)) { + if (!ownedPaths.has(relative) && !isDerivedArtifact(relative)) { + foreign++; + continue; + } + await remove(path.join(dir, relative)); + } + await removeEmptyDirs(dir); + + return foreign === 0; +} + /** * Remove the skill directories earlier releases deployed. * - * Unconditional: those trees were overwritten on every pull (`overwrite: true`), - * so no local edit ever survived in them, and leaving them behind costs every - * agent on the machine the context they were deployed to save. + * Only the files those releases packaged: each was overwritten on every pull + * (`overwrite: true`), so no local edit ever survived in one, while a file the + * member added beside them was never touched and is not ours to delete. A + * directory that still holds such a file is kept, and the member is told. */ export async function pruneLegacyBuiltinSkills( tool: string, @@ -91,8 +190,12 @@ export async function pruneLegacyBuiltinSkills( const dir = path.join(baseDir, root, legacyName); if (!await pathExists(dir)) continue; try { - await remove(dir); - log.debug(`Removed legacy built-in skill ${legacyName} from ${tool} (${dir})`); + const removedWhole = await removeOwnedFiles(dir, PACKAGED_SKILL_FILES.get(legacyName) ?? []); + if (removedWhole) { + log.debug(`Removed legacy built-in skill ${legacyName} from ${tool} (${dir})`); + } else { + log.warn(`Kept "${legacyName}" (${tool}): ${dir} holds files TeamAI did not put there. The packaged files were removed; delete the rest yourself once you have saved what you need.`); + } } catch (e) { log.debug(`Could not remove legacy built-in skill ${legacyName} from ${tool}: ${(e as Error).message}`); } @@ -178,16 +281,15 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? const destDir = await resolveSkillDestination(tool, toolPath.skills, baseDir, skillName, srcDir); try { - await fse.ensureDir(destDir); // Releases before the discovery stub deployed this same directory with a // references/ tree beside SKILL.md. Copying one file over it would leave - // ~39 KB of pre-stub instructions in place forever, so everything the - // deployed unit does not contain goes first. - for (const entry of await fs.promises.readdir(destDir)) { - if (entry === 'SKILL.md') continue; - await remove(path.join(destDir, entry)); - log.debug(`Removed stale built-in skill file ${skillName}/${entry} from ${tool}`); + // ~39 KB of pre-stub instructions in place forever, so the files those + // releases wrote go first — and only those: a file a member added here + // is theirs, and the old deployment never deleted it either. + if (await pathExists(destDir)) { + await removeOwnedFiles(destDir, PACKAGED_SKILL_FILES.get(skillName) ?? []); } + await fse.ensureDir(destDir); await fse.copy(path.join(srcDir, 'SKILL.md'), path.join(destDir, 'SKILL.md'), { overwrite: true }); deployed++; diff --git a/src/skill-cmd.ts b/src/skill-cmd.ts index ee4761f7..885eef0a 100644 --- a/src/skill-cmd.ts +++ b/src/skill-cmd.ts @@ -168,16 +168,10 @@ async function locateSkill( } } - // 3. Built-in skill served by the CLI (including legacy-name aliases). - // Resolved before the agent fallback: under the discovery-stub model the - // agent directory holds a stub, not the content this command describes. - const served = await resolveServableSkill(name); - if (served.kind === 'blocked') return { kind: 'blocked', name: served.name }; - if (served.kind === 'found') { - return { kind: 'found', name: served.skill.name, primaryPath: served.skill.dir, primaryOrigin: 'builtin' }; - } - - // 4. First installed agent that has the skill + // 3. First installed agent that has the skill. Ahead of the packaged content + // on purpose: `codebase`, `default`, `learning` and `share` are ordinary + // names, and a directory a member created under one of them is the skill + // they are asking about, not the built-in it happens to alias. for (const agent of agents) { if (!agent.installed) continue; const candidate = path.join(agent.absoluteSkillsPath, name); @@ -186,6 +180,15 @@ async function locateSkill( } } + // 4. Built-in skill served by the CLI, including legacy-name aliases. Last, + // so it answers for the names nothing on this machine claims: `core` and + // `wiki` live in the package, and the agent directory holds only the stub. + const served = await resolveServableSkill(name); + if (served.kind === 'blocked') return { kind: 'blocked', name: served.name }; + if (served.kind === 'found') { + return { kind: 'found', name: served.skill.name, primaryPath: served.skill.dir, primaryOrigin: 'builtin' }; + } + return null; } From 32b4bf3ae02a746f9b0fd8e430e6461b986a6bee Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Tue, 22 Sep 2026 09:30:03 +0200 Subject: [PATCH 13/37] fix(skills): close the last recall bypass, uninstall Codex's shared stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #699. - `skill path` takes a name, always. The argument-less form printed the `skill-data/` root, and `<root>/share/SKILL.md` is readable from there — the content the gate withholds one command over. - uninstall discovers skills in Codex's shared `.agents/skills` root, where resolveSkillDestination puts the stub whenever the skill already lives there. Without it, uninstall reported success and left it behind. Codex only, as the legacy prune already does. - core/SKILL.md said team sharing is enabled by default; getRecallSharing defaults it to false. It now says recall is off by default and names `teamai recall enable`. --- docs/designs/skill-serving.md | 10 ++++++ skill-data/core/SKILL.md | 4 ++- skill-data/core/references/commands.md | 2 +- src/__tests__/e2e/skill-serving-cli.test.ts | 7 ++++ src/__tests__/skill-content.test.ts | 6 +--- src/__tests__/uninstall.test.ts | 36 +++++++++++++++++++++ src/index.ts | 4 +-- src/skill-content.ts | 20 ++++-------- src/uninstall.ts | 6 ++++ 9 files changed, 72 insertions(+), 23 deletions(-) diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index 430dc824..ce5090a1 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -78,6 +78,10 @@ task matches stub body ~1.3 KB holds the c `resolveServableSkill` (`src/skill-content.ts`) is the only way to obtain a packaged skill outside that module, and it returns `blocked` instead of the skill, so a command cannot print a directory it never received. +- **`skill path` takes a name, always.** Printing the `skill-data/` root would + hand out the parent of every served skill, and `<root>/share/SKILL.md` is + readable from there — the content the gate withholds one command over. There is + no argument-less form to close that way around. - **A member's own skill outranks a packaged name.** `locateSkill` searches the team repo, then the installed agents, then the package. `codebase`, `default`, `learning` and `share` are ordinary names: a directory a member created under @@ -115,6 +119,12 @@ Deployment removes them from every installed, non-excluded agent, in its configured skills path; Codex's pass also covers the shared `.agents/skills`, which no other tool's pass touches. +Codex's shared root is on the removal side of three commands now, because +`resolveSkillDestination` puts the stub there whenever the skill already lives +there: the legacy prune, `recall disable`, and `uninstall`, whose skill discovery +adds `.agents/skills` for `codex` alone. Without it, an uninstall reported +success while leaving `~/.agents/skills/teamai` behind. + **It removes only the files those releases packaged.** `PACKAGED_SKILL_FILES` lists them, built as the union of `git ls-tree -r <tag> -- skills/` over every tag, so each path is provably the CLI's. Those files were overwritten with diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index e2e8ab93..fb475e1d 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -60,7 +60,9 @@ ask ONE short question to pick a row, then proceed. Sharing a session's learnings needs no menu choice: TeamAI prompts on its own at the end of a session that produced something worth sharing, and that prompt means -`teamai skill get share`. (Only when the admin left team sharing enabled — the default.) +`teamai skill get share`. (Only when recall is on for the team; it is off by +default, and `teamai skill get share` says so and names `teamai recall enable` +when it is not.) ## Global rules diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index 5f059971..47e123e2 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -63,7 +63,7 @@ Generated: do not edit by hand. Regenerate with - `teamai skill get [names...]` — Print built-in skill content served by the installed CLI - `--full` — Append the skill's references/ and templates/ files - `--all` — Print every skill the CLI serves - - `teamai skill path [name]` — Print the packaged directory of a built-in skill (for scripts and templates) + - `teamai skill path <name>` — Print the packaged directory of a built-in skill (for scripts and templates) - `teamai skill show <name>` — Show skill metadata: source / contributors / installed agents / description - `teamai skill exclude` — Manage per-user skill exclusion (skip sync without affecting team repo) - `teamai skill exclude list` — List excluded skills diff --git a/src/__tests__/e2e/skill-serving-cli.test.ts b/src/__tests__/e2e/skill-serving-cli.test.ts index 22ef22fa..aeeb98d4 100644 --- a/src/__tests__/e2e/skill-serving-cli.test.ts +++ b/src/__tests__/e2e/skill-serving-cli.test.ts @@ -62,6 +62,13 @@ describe('teamai skill get / path CLI (e2e)', () => { expect(none.stderr).toContain('No skill name provided'); }); + it('refuses skill path without a name, so the skill-data root is never printed', () => { + const bare = run('skill', 'path'); + expect(bare.status).toBe(1); + expect(bare.stdout).toBe(''); + expect(bare.stderr).toContain("missing required argument 'name'"); + }); + it('runs the wiki scripts from the directory it prints', () => { const printed = run('skill', 'path', 'wiki'); expect(printed.status).toBe(0); diff --git a/src/__tests__/skill-content.test.ts b/src/__tests__/skill-content.test.ts index 4d2d4ae3..47333e24 100644 --- a/src/__tests__/skill-content.test.ts +++ b/src/__tests__/skill-content.test.ts @@ -278,15 +278,11 @@ describe('teamai skill get / path against the shipped package', () => { expect(stdout).toBe(expected); }); - it('prints the packaged directory, and the roots when no name is given', async () => { + it('prints the packaged directory of the named skill', async () => { const [first] = await listServableSkills(); await skillPath(first.name); expect(stdout.trim()).toBe(first.dir); expect(fs.existsSync(path.join(stdout.trim(), 'SKILL.md'))).toBe(true); - - stdout = ''; - await skillPath(); - expect(stdout.trim().split('\n')).toContain(path.join(ROOT, 'skills')); }); it('fails on an unknown name for path too', async () => { diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 3d7eac3c..01adfdc3 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -989,6 +989,42 @@ describe('uninstall', () => { expect(await fse.pathExists(path.join(homeDir, '.claude', 'skills', 'my-own-skill'))).toBe(true); }); + it('removes the stub Codex kept in the shared .agents/skills root, and nothing else there', async () => { + const { homeDir, repoPath } = await setupFixture(tmpDir); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + + // resolveSkillDestination writes Codex's copy here whenever the skill + // already lives in the shared root, so this is where the stub ends up on a + // machine that has ever had one. + const sharedStub = path.join(homeDir, '.agents', 'skills', 'teamai'); + await fse.ensureDir(sharedStub); + await fse.writeFile(path.join(sharedStub, 'SKILL.md'), '# TeamAI\n'); + const sharedUserSkill = path.join(homeDir, '.agents', 'skills', 'my-own-skill'); + await fse.ensureDir(sharedUserSkill); + await fse.writeFile(path.join(sharedUserSkill, 'SKILL.md'), '# Mine\n'); + + const teamConfig = makeTeamConfig({ + toolPaths: { + claude: { + skills: '.claude/skills', + rules: '.claude/rules', + settings: '.claude/settings.json', + claudemd: '.claude/CLAUDE.md', + agents: '.claude/agents', + }, + codex: { skills: '.codex/skills' }, + }, + }); + const localConfig = makeLocalConfig(homeDir, repoPath); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true }); + + expect(await fse.pathExists(sharedStub)).toBe(false); + expect(await fse.pathExists(sharedUserSkill)).toBe(true); + }); + it('清理 CLAUDE.md 中所有 teamai section(culture/claudemd/recall-rules)', async () => { const { homeDir, repoPath } = await setupFixture(tmpDir); vi.stubEnv('HOME', homeDir); diff --git a/src/index.ts b/src/index.ts index 46c82f87..2a6d6d61 100644 --- a/src/index.ts +++ b/src/index.ts @@ -185,9 +185,9 @@ skillCmd }); skillCmd - .command('path [name]') + .command('path <name>') .description('Print the packaged directory of a built-in skill (for scripts and templates)') - .action(async (name: string | undefined) => { + .action(async (name: string) => { const { skillPath } = await import('./skill-content.js'); await skillPath(name); }); diff --git a/src/skill-content.ts b/src/skill-content.ts index ee60ff20..1b69cc62 100644 --- a/src/skill-content.ts +++ b/src/skill-content.ts @@ -349,24 +349,16 @@ export async function skillGet(names: string[], options: SkillGetOptions = {}): } /** - * `teamai skill path [name]` — print the packaged directory, for agents that + * `teamai skill path <name>` — print the packaged directory, for agents that * read files directly or need to run the scripts a skill ships. + * + * A name is required. Printing the `skill-data/` root instead would hand out the + * parent of every served skill, and reading `<root>/share/SKILL.md` from there + * is exactly the content the recall gate withholds one command over. */ -export async function skillPath(name?: string): Promise<void> { +export async function skillPath(name: string): Promise<void> { const roots = packagedSkillRoots(); - if (!name) { - let printed = false; - for (const root of [roots.deployRoot, roots.dataRoot]) { - if (await pathExists(root)) { - console.log(root); - printed = true; - } - } - if (!printed) rootsMissing(); - return; - } - const resolved = await resolveServableSkill(name, roots); switch (resolved.kind) { case 'not-found': diff --git a/src/uninstall.ts b/src/uninstall.ts index f1a24379..882e9aa1 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -39,6 +39,7 @@ import { resolveDocsDestination } from './resources/docs.js'; import { listTeamAgentDirs } from './resources/agents.js'; import { BUILTIN_AGENT_NAMES } from './builtin-agents.js'; import { BUILTIN_SKILL_NAMES, LEGACY_BUILTIN_SKILL_NAMES } from './builtin-skills.js'; +import { CODEX_TOOL, SHARED_AGENT_SKILLS_PATH } from './resources/skills.js'; import { pathExists, readFileSafe, @@ -349,6 +350,11 @@ async function discoverToolResources( const workspaceDir = await resolveOpenclawWorkspaceDir(); if (workspaceDir) skillRoots.add(path.join(workspaceDir, 'skills')); } + // `resolveSkillDestination` writes Codex's copy into the shared + // .agents/skills root whenever that skill already lives there, so uninstall + // must look where deployment could have put it — the legacy prune already + // does. Codex only: another tool's pass must not reach into it. + if (tool === CODEX_TOOL) skillRoots.add(path.join(baseDir, SHARED_AGENT_SKILLS_PATH)); for (const skillsDir of skillRoots) { if (await pathExists(skillsDir)) { const dirs = await listDirs(skillsDir); From 5578d4094f2839ec71e22f50e424dc5eee6bb139 Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Tue, 22 Sep 2026 10:00:01 +0200 Subject: [PATCH 14/37] fix(skills): uninstall by the same ownership rule as pull, quote {SKILL_DIR} Review follow-up on #699. - uninstall removed a CLI-owned skill directory whole, undoing one command over the guarantee pull makes. It now removes the PACKAGED_SKILL_FILES paths through the same removeOwnedFiles, keeps a directory holding a file the member added, says which one, and tells the confirmation prompt so it no longer promises a directory it will keep. A team-repo skill is synced whole and still goes whole. - Served shell commands quote the placeholder: `python3 "{SKILL_DIR}/..."`. Unquoted, an install path with a space ("Program Files", "Application Support", a Windows path through Bash) splits into two arguments and the documented invocation fails. A test fails on an unquoted occurrence after any command word, in SKILL.md or any reference. - wiki/references/overview.md said the methodology, scripts and agent specs are deployed into agent directories. They are not: only the stub is, and the rest is served from the installed CLI. --- docs/designs/skill-serving.md | 7 +++ skill-data/wiki/SKILL.md | 4 +- .../references/methodology/phase4-quality.md | 4 +- skill-data/wiki/references/overview.md | 2 +- .../phases/k1-reverse-engineering.md | 2 +- .../wiki/references/phases/k4-quality.md | 2 +- src/__tests__/skill-content.test.ts | 28 ++++++++++ src/__tests__/uninstall.test.ts | 51 +++++++++++++++++++ src/builtin-skills.ts | 6 ++- src/uninstall.ts | 42 ++++++++++++--- 10 files changed, 134 insertions(+), 14 deletions(-) diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index ce5090a1..dd8eed94 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -125,6 +125,13 @@ there: the legacy prune, `recall disable`, and `uninstall`, whose skill discover adds `.agents/skills` for `codex` alone. Without it, an uninstall reported success while leaving `~/.agents/skills/teamai` behind. +**`uninstall` deletes a CLI-owned directory by the same rule.** A team-repo skill +is synced whole, so uninstall removes the whole directory. A CLI-owned one is +not: deployment writes only `PACKAGED_SKILL_FILES` and never touched a file the +member added beside them, so uninstall removes those same paths through +`removeOwnedFiles` and keeps the rest, saying which directory it kept. Deleting +the directory there would undo, one command over, the guarantee pull makes. + **It removes only the files those releases packaged.** `PACKAGED_SKILL_FILES` lists them, built as the union of `git ls-tree -r <tag> -- skills/` over every tag, so each path is provably the CLI's. Those files were overwritten with diff --git a/skill-data/wiki/SKILL.md b/skill-data/wiki/SKILL.md index f5af4dd1..22f8c899 100644 --- a/skill-data/wiki/SKILL.md +++ b/skill-data/wiki/SKILL.md @@ -232,7 +232,7 @@ Human-readable overview (not for execution): `{SKILL_DIR}/references/overview.md ``` <output_dir>/ ├── README.md ← Knowledge base index + retrieval routing rules + cognitive boundary declaration (for AI) -│ Start from the template: cp {SKILL_DIR}/references/templates/project-overview.md <output_dir>/README.md +│ Start from the template: cp "{SKILL_DIR}/references/templates/project-overview.md" <output_dir>/README.md ├── {project_name} Technical Architecture.md ← [Type-1] Architecture overview (target ≤80KB, split automatically when larger) ├── {project_name} Technical Architecture-Core Call Chains.md ← [Type-1b] Split out only when Type-1 exceeds 80KB ├── {project_name} Technical Architecture-AI Metadata.md ← [Type-1c] Split out only when Type-1 exceeds 80KB @@ -307,7 +307,7 @@ _review/ ← Process files (not part of the knowl | Product docs into the graph | Skip. Same English note as above. | | Product ↔ code bridging | Use `teamai codebase --reconcile --output <repo>` after product pages and extracted code pages are under `<repo>/teamwiki/`. Prefix with `teamai --dry-run` to preview without updating the graph. | | One-shot refresh | Use `teamai codebase --extract <repo> --project <slug> --incremental`, reusing the Phase 0 repository path and project slug even when running from another directory. Do not look for another CLI. | -| Quality assessment | Use `python3 {SKILL_DIR}/scripts/validate_kb.py <output_dir>` and `teamai codebase --lint --output <repo>` to check `<repo>/teamwiki/` (`--output` takes the repository root, not the `teamwiki/` directory). Skip any extra evaluate binary. | +| Quality assessment | Use `python3 "{SKILL_DIR}/scripts/validate_kb.py" <output_dir>` and `teamai codebase --lint --output <repo>` to check `<repo>/teamwiki/` (`--output` takes the repository root, not the `teamwiki/` directory). Skip any extra evaluate binary. | **Path convention**: `{SKILL_DIR}` is the directory printed by `teamai skill path wiki`. The methodology is in `{SKILL_DIR}/references/methodology/`, sub-agent prompts in `{SKILL_DIR}/references/agents/`, and scripts in `{SKILL_DIR}/scripts/`. diff --git a/skill-data/wiki/references/methodology/phase4-quality.md b/skill-data/wiki/references/methodology/phase4-quality.md index 4ac8f592..c22fd657 100644 --- a/skill-data/wiki/references/methodology/phase4-quality.md +++ b/skill-data/wiki/references/methodology/phase4-quality.md @@ -1,6 +1,6 @@ # Phase 4: Quality Assessment and Iterative Improvement -> Helper tool: `python3 {SKILL_DIR}/scripts/validate_kb.py <output_dir>` automatically checks link integrity, anchor coverage, AI Quick Reference table coverage, bidirectional links, and README index inclusion rate +> Helper tool: `python3 "{SKILL_DIR}/scripts/validate_kb.py" <output_dir>` automatically checks link integrity, anchor coverage, AI Quick Reference table coverage, bidirectional links, and README index inclusion rate ## Five-Dimension Assessment Model @@ -65,7 +65,7 @@ Maintain a change log at the bottom of every document: | Issue | Fix method | |------|---------| -| Dead links | Grep `](` links globally, or run `python3 {SKILL_DIR}/scripts/validate_kb.py <output_dir>` | +| Dead links | Grep `](` links globally, or run `python3 "{SKILL_DIR}/scripts/validate_kb.py" <output_dir>` | | Inconsistent terminology | Build a glossary and replace globally | | Outdated code entries | Diff against the code repositories periodically | | Outdated constraint values | Cross-check against the product docs periodically | diff --git a/skill-data/wiki/references/overview.md b/skill-data/wiki/references/overview.md index 27a2d38e..1ae3dd65 100644 --- a/skill-data/wiki/references/overview.md +++ b/skill-data/wiki/references/overview.md @@ -1,6 +1,6 @@ # team-wiki-codebase: AI cognition engineering for large codebases -> TeamAI builtin skill: the methodology, scripts and Agent specifications are deployed with `teamai pull` / `teamai init` into the project's `.codebuddy/`, `.cursor/` and similar directories. TeamAI does not ship a separate team-wiki CLI. No extra plugin is required. +> TeamAI built-in skill. The methodology, scripts and agent specifications are **not** copied into `.claude/`, `.codebuddy/`, `.cursor/` or any other agent directory: they ship inside the installed CLI and are served on demand by `teamai skill get wiki` (`--full` for the references too). What an agent reads therefore always matches the CLI it is running. `teamai skill path wiki` prints the directory that holds the scripts and templates, for the commands below that run them. TeamAI ships no separate team-wiki CLI, and no extra plugin is required. ## Why this skill exists diff --git a/skill-data/wiki/references/phases/k1-reverse-engineering.md b/skill-data/wiki/references/phases/k1-reverse-engineering.md index 8746fc41..5db48bb8 100644 --- a/skill-data/wiki/references/phases/k1-reverse-engineering.md +++ b/skill-data/wiki/references/phases/k1-reverse-engineering.md @@ -5,7 +5,7 @@ ### Step 1: Optionally run the scan script (recommended) ```bash -python3 {SKILL_DIR}/scripts/scan_repo.py <project_root> --depth 2 --top 10 +python3 "{SKILL_DIR}/scripts/scan_repo.py" <project_root> --depth 2 --top 10 ``` Output: file statistics + key file discovery report + language distribution. diff --git a/skill-data/wiki/references/phases/k4-quality.md b/skill-data/wiki/references/phases/k4-quality.md index cc1272c6..a2037b9d 100644 --- a/skill-data/wiki/references/phases/k4-quality.md +++ b/skill-data/wiki/references/phases/k4-quality.md @@ -5,7 +5,7 @@ ### Step 1: Automated validation ```bash -python3 {SKILL_DIR}/scripts/validate_kb.py <output_dir> --verbose +python3 "{SKILL_DIR}/scripts/validate_kb.py" <output_dir> --verbose ``` `--verbose` prints the details of every item (missing anchors, the exact location of dead links). This is exactly the full output required below. diff --git a/src/__tests__/skill-content.test.ts b/src/__tests__/skill-content.test.ts index 47333e24..7ce726b0 100644 --- a/src/__tests__/skill-content.test.ts +++ b/src/__tests__/skill-content.test.ts @@ -17,6 +17,7 @@ import { type PackagedSkillRoots, } from '../skill-content.js'; import { readSkillDescription } from '../agent-skills.js'; +import { listFilesRecursive } from '../utils/fs.js'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); @@ -312,6 +313,33 @@ describe('the shipped skill-data content', () => { expect(stub).toMatch(/^allowed-tools: Bash\(teamai:\*\), Bash\(npx teamai-cli:\*\)$/m); }); + it('quotes {SKILL_DIR} in every command it tells the agent to run', async () => { + // The placeholder resolves to the install path, which can hold a space + // ("Program Files", "~/Library/Application Support", a user's full name) or + // be a Windows path used through Bash. An unquoted occurrence in a command + // line splits into two arguments there and the documented invocation fails. + const offenders: string[] = []; + for (const skill of await listServableSkills()) { + const files = [ + 'SKILL.md', + ...(await listFilesRecursive(path.join(skill.dir, 'references'))).map((f) => `references/${f}`), + ]; + for (const relative of files) { + if (!relative.endsWith('.md')) continue; + const text = fs.readFileSync(path.join(skill.dir, relative), 'utf8'); + text.split('\n').forEach((line, i) => { + // A command word followed by the bare placeholder: `python3 {SKILL_DIR}/…`. + // Prose and reference tables name the path without running it, and a + // quoted occurrence is already correct. + if (/(?:^|[`\s(])(?:python3?|node|bash|sh|cp|mv|cat|ls|rm)\s+\{SKILL_DIR\}/.test(line)) { + offenders.push(`${skill.name}/${relative}:${i + 1}: ${line.trim()}`); + } + }); + } + } + expect(offenders).toEqual([]); + }); + it('keeps the stub description within the 1024-character budget agents load it under', async () => { // With one deployed skill, this description is the only text an agent sees // at selection time, and hosts cap it at 1024 characters. diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 01adfdc3..d591d0ab 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -989,6 +989,57 @@ describe('uninstall', () => { expect(await fse.pathExists(path.join(homeDir, '.claude', 'skills', 'my-own-skill'))).toBe(true); }); + it('removes only the packaged files from a CLI-owned skill dir, keeping what the member added', async () => { + const { homeDir, repoPath } = await setupFixture(tmpDir); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + + // A machine that upgraded through the pre-stub releases: the packaged trees, + // with the member's own files mixed into them. Deployment never wrote those + // files and never deleted them, so uninstall must not either. + const skills = path.join(homeDir, '.claude', 'skills'); + const stub = path.join(skills, 'teamai'); + await fse.outputFile(path.join(stub, 'SKILL.md'), '# stub\n'); + await fse.outputFile(path.join(stub, 'references', 'setup-admin.md'), '# packaged\n'); + await fse.outputFile(path.join(stub, 'references', 'team-playbook.md'), '# mine\n'); + + const legacy = path.join(skills, 'team-wiki-codebase'); + await fse.outputFile(path.join(legacy, 'SKILL.md'), '# packaged\n'); + await fse.outputFile(path.join(legacy, 'scripts', 'scan_repo.py'), '# packaged\n'); + await fse.outputFile(path.join(legacy, 'references', 'methodology', 'my-notes.md'), '# mine\n'); + + // Nothing of the member's in this one, so it goes whole. + const legacyShare = path.join(skills, 'teamai-share-learnings'); + await fse.outputFile(path.join(legacyShare, 'SKILL.md'), '# packaged\n'); + + const teamConfig = makeTeamConfig({ + toolPaths: { + claude: { + skills: '.claude/skills', + rules: '.claude/rules', + settings: '.claude/settings.json', + claudemd: '.claude/CLAUDE.md', + agents: '.claude/agents', + }, + }, + }); + const localConfig = makeLocalConfig(homeDir, repoPath); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true }); + + // The member's files, and the directories holding them, survive. + expect(await fse.pathExists(path.join(stub, 'references', 'team-playbook.md'))).toBe(true); + expect(await fse.pathExists(path.join(legacy, 'references', 'methodology', 'my-notes.md'))).toBe(true); + // Everything TeamAI packaged is gone. + expect(await fse.pathExists(path.join(stub, 'SKILL.md'))).toBe(false); + expect(await fse.pathExists(path.join(stub, 'references', 'setup-admin.md'))).toBe(false); + expect(await fse.pathExists(path.join(legacy, 'SKILL.md'))).toBe(false); + expect(await fse.pathExists(path.join(legacy, 'scripts'))).toBe(false); + // A directory with nothing of the member's in it still goes whole. + expect(await fse.pathExists(legacyShare)).toBe(false); + }); + it('removes the stub Codex kept in the shared .agents/skills root, and nothing else there', async () => { const { homeDir, repoPath } = await setupFixture(tmpDir); vi.stubEnv('HOME', homeDir); diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index 4976024d..4ee56b10 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -149,8 +149,12 @@ async function removeEmptyDirs(dir: string): Promise<void> { * Remove from `dir` the files the CLI put there, then the directories that end * up empty. Returns false when the member has files of their own in there, so * the caller can say the directory was kept. + * + * Exported because uninstall must delete a CLI-owned skill directory by the same + * rule pull does: a file a member added beside our packaged ones was never ours + * to write and is not ours to remove, whichever command is doing the removing. */ -async function removeOwnedFiles(dir: string, owned: readonly string[]): Promise<boolean> { +export async function removeOwnedFiles(dir: string, owned: readonly string[]): Promise<boolean> { const ownedPaths = new Set(owned); let foreign = 0; diff --git a/src/uninstall.ts b/src/uninstall.ts index 882e9aa1..e2288565 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -38,7 +38,13 @@ import { agentStemFromFilename } from './resources/agent-format.js'; import { resolveDocsDestination } from './resources/docs.js'; import { listTeamAgentDirs } from './resources/agents.js'; import { BUILTIN_AGENT_NAMES } from './builtin-agents.js'; -import { BUILTIN_SKILL_NAMES, LEGACY_BUILTIN_SKILL_NAMES } from './builtin-skills.js'; +import { + BUILTIN_SKILL_NAMES, + LEGACY_BUILTIN_SKILL_NAMES, + PACKAGED_SKILL_FILES, + isCliOwnedSkillName, + removeOwnedFiles, +} from './builtin-skills.js'; import { CODEX_TOOL, SHARED_AGENT_SKILLS_PATH } from './resources/skills.js'; import { pathExists, @@ -677,7 +683,12 @@ function printSummary(plan: RemovalPlan, agentFilter?: string): void { if (plan.skillDirs.length > 0) { console.log(` Skills (${plan.skillDirs.length} directories):`); for (const skillDir of plan.skillDirs) { - console.log(` ${skillDir}`); + // A CLI-owned directory loses the files TeamAI packaged, not whatever the + // member added beside them, so the prompt must not promise the directory. + const suffix = isCliOwnedSkillName(path.basename(skillDir)) + ? ' (TeamAI-packaged files only; anything you added stays)' + : ''; + console.log(` ${skillDir}${suffix}`); } console.log(''); } @@ -838,16 +849,35 @@ async function executeRemoval(plan: RemovalPlan): Promise<void> { } } - // (c) Remove synced skills + // (c) Remove synced skills. + // + // A team-repo skill is synced whole, so the whole directory goes. A CLI-owned + // one is not: deployment writes only the files in PACKAGED_SKILL_FILES and + // never touched a file a member added beside them, so uninstall removes those + // same paths and keeps the rest — the same rule pull applies, for the same + // reason. Deleting the directory here would undo the guarantee one command over. + let removedSkillDirs = 0; + const keptSkillDirs: string[] = []; for (const skillDir of plan.skillDirs) { try { - await remove(skillDir); + const name = path.basename(skillDir); + if (isCliOwnedSkillName(name)) { + const removedWhole = await removeOwnedFiles(skillDir, PACKAGED_SKILL_FILES.get(name) ?? []); + if (removedWhole) removedSkillDirs++; + else keptSkillDirs.push(skillDir); + } else { + await remove(skillDir); + removedSkillDirs++; + } } catch (e) { log.warn(`Failed to remove skill ${skillDir}: ${(e as Error).message}`); } } - if (plan.skillDirs.length > 0) { - log.success(`Removed ${plan.skillDirs.length} skill directories`); + if (removedSkillDirs > 0) { + log.success(`Removed ${removedSkillDirs} skill directories`); + } + for (const skillDir of keptSkillDirs) { + log.warn(`Kept ${skillDir}: it holds files TeamAI did not put there. The packaged files were removed; delete the rest yourself once you have saved what you need.`); } // (d) Remove synced rules From 3ade92d9312ad7e88fe3039a82a73509023161f0 Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Tue, 22 Sep 2026 12:17:01 +0200 Subject: [PATCH 15/37] fix(skills): deploy the stub in reporting-only mode, drop the stale list alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #699. - Reporting-only HTTP pull pruned the legacy trees and deployed nothing, so a member on an HTTP team came out of the upgrade with no built-in entry point at all. The skip predates CLI-served content: it existed because the only deployable unit then needed a team repo. The stub does not — its workflows are printed by the installed binary, and `skill get wiki` is a local knowledge-base generator that never touches a repo. The stub now deploys in every mode, and `reportingOnly` goes with the branch it gated: nothing else read it. - `teamai skill list` called itself an alias for `teamai list skills --source all`. It has not been one since it started printing the CLI-served catalog underneath. Both descriptions, the generated command reference and both usage guides now say what it does. Refs #678 --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/core/references/commands.md | 4 ++-- src/__tests__/skip-uninstalled-tools.test.ts | 14 +++++++------ src/builtin-skills.ts | 21 +++++++++----------- src/index.ts | 4 ++-- src/pull.ts | 20 +++++++------------ 7 files changed, 30 insertions(+), 37 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index bf9ab2f2..2acd9501 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -455,7 +455,7 @@ teamai list --source local # Skills under each installed agent teamai list --agent claude --verbose teamai list env --reveal # Show env values in plaintext (default: masked) -teamai skill # Equivalent to teamai list skills --source all +teamai skill # teamai list skills --source all, then the CLI-served built-in catalog teamai skill show hai-deploy-test # View a single skill's source / contributor / install locations / description summary teamai skill list --json # The built-in skills the installed CLI serves, machine-readable diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index fe11671b..440ff1bd 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -432,7 +432,7 @@ teamai list --source local # 各已安装 agent 下的 skills teamai list --agent claude --verbose teamai list env --reveal # 明文显示 env(默认脱敏) -teamai skill # 等价于 teamai list skills --source all +teamai skill # 先输出 teamai list skills --source all,再列出 CLI 内置 skill 目录 teamai skill show hai-deploy-test # 看单个 skill 的来源 / 贡献者 / 安装位置 / 描述摘要 teamai skill list --json # 当前 CLI 提供的内置 skill 清单(机器可读) diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index 47e123e2..a41f5bc8 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -57,8 +57,8 @@ Generated: do not edit by hand. Regenerate with ## skill -- `teamai skill` — List and inspect skills (default: list all skills across repo + installed agents) - - `teamai skill list` — List all skills (alias for: teamai list skills --source all) +- `teamai skill` — List and inspect skills (default: repo + installed agents, then the CLI-served catalog) + - `teamai skill list` — List team and installed skills, then the built-in catalog the CLI serves - `--json` — Output the CLI-served built-in skill catalog as JSON - `teamai skill get [names...]` — Print built-in skill content served by the installed CLI - `--full` — Append the skill's references/ and templates/ files diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index 7d7e877a..1e7b43c4 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -660,7 +660,7 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { ); }); - it('prunes legacy skills from the Codex shared directory, and without deploying in reporting-only mode', async () => { + it('prunes legacy skills from the Codex shared directory and deploys the stub beside them', async () => { const { deployBuiltinSkills } = await import('../builtin-skills.js'); await fse.ensureDir(path.join(homeDir, '.codex')); @@ -685,13 +685,15 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { scope: 'user' as const, }; - // Reporting-only teams have nowhere to publish, so nothing is deployed — - // but a team that switched to it would otherwise keep the legacy trees. - const deployed = await deployBuiltinSkills(teamConfig, localConfig, { reportingOnly: true }); + const deployed = await deployBuiltinSkills(teamConfig, localConfig); - expect(deployed).toBe(0); + expect(deployed).toBe(1); expect(await fse.pathExists(sharedLegacy)).toBe(false); - expect(await fse.pathExists(path.join(homeDir, '.agents/skills/teamai'))).toBe(false); + // Codex reads .codex/skills; the shared .agents/skills is where its legacy + // copies live, and the prune is the only thing that reaches in there. + expect(await fse.readFile(path.join(homeDir, '.codex/skills/teamai/SKILL.md'), 'utf8')).toBe( + await fse.readFile(path.join(PACKAGE_ROOT, 'skills/teamai/SKILL.md'), 'utf8'), + ); }); it('records every file the package still ships, so the prune keeps proving ownership', async () => { diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index 4ee56b10..e4be24b1 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -218,20 +218,18 @@ export async function pruneLegacyBuiltinSkills( * The stub is written verbatim — no frontmatter repair on the way out, so a * deployed copy that differs from the packaged one is a bug, not a variant. * + * Reporting-only HTTP teams get the stub too. The release before this one had + * nothing to deploy there that worked without a team repo, so it deployed + * nothing; the stub's content is served by the installed CLI, and `skill get + * wiki` — a local knowledge-base generator — needs no repo at all. Skipping it + * while still pruning the legacy trees would leave those members with no + * discoverable entry point at all. + * * Silently skips if: * - Built-in skills directory doesn't exist (dev environment without build) * - A tool's skills directory is not configured */ -export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig?: LocalConfig, options?: { reportingOnly?: boolean }): Promise<number> { - // Reporting-only HTTP mode has no team repo to write to, so the workflows the - // stub routes to are non-functional there. Nothing is deployed, but the - // directories earlier releases left behind are still removed: a team that - // switched to reporting-only would otherwise keep them for good. - const deploy = !options?.reportingOnly; - if (!deploy) { - log.debug('Reporting-only mode (no team repo): pruning legacy built-in skills without deploying'); - } - +export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig?: LocalConfig): Promise<number> { const builtinDir = packagedSkillRoots().deployRoot; if (!await pathExists(builtinDir)) { @@ -255,7 +253,7 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? } } - if (deploy && skillNames.length === 0) return 0; + if (skillNames.length === 0) return 0; const defaultBaseDir = getUserHome(); let deployed = 0; @@ -278,7 +276,6 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? if (localConfig && isAgentExcluded(localConfig, tool)) continue; await pruneLegacyBuiltinSkills(tool, toolPath.skills, baseDir); - if (!deploy) continue; for (const skillName of skillNames) { const srcDir = path.join(builtinDir, skillName); diff --git a/src/index.ts b/src/index.ts index 2a6d6d61..8838ff0e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -153,7 +153,7 @@ program const skillCmd = program .command('skill') - .description('List and inspect skills (default: list all skills across repo + installed agents)') + .description('List and inspect skills (default: repo + installed agents, then the CLI-served catalog)') .action(async () => { const globalOpts = program.opts() as GlobalOptions; const { skillList } = await import('./skill-cmd.js'); @@ -162,7 +162,7 @@ const skillCmd = program skillCmd .command('list') - .description('List all skills (alias for: teamai list skills --source all)') + .description('List team and installed skills, then the built-in catalog the CLI serves') .option('--json', 'Output the CLI-served built-in skill catalog as JSON') .action(async (cmdOpts) => { const globalOpts = program.opts() as GlobalOptions; diff --git a/src/pull.ts b/src/pull.ts index ab064a84..eabdd31e 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -70,8 +70,7 @@ export interface RolePullContext { * * - git: `git pull` into localPath; version = current HEAD rev. * - http: nothing to clone — skills/rules/CLAUDE.md are delivered per-session via - * report/sync/ack (the local-agent bypass), not a repo snapshot. The - * `reportingOnly` flag tells the deploy step to skip git-tree sync. + * report/sync/ack (the local-agent bypass), not a repo snapshot. * * Returns a display label and the opaque version string used as the * incremental-sync cache key (state.lastPullRev). `version` is null only when @@ -85,7 +84,7 @@ export interface RolePullContext { */ async function refreshTeamRepo( localConfig: LocalConfig, -): Promise<{ label: string; version: string | null; reportingOnly: boolean; submodulesFailed: boolean; submodulesChanged: boolean }> { +): Promise<{ label: string; version: string | null; submodulesFailed: boolean; submodulesChanged: boolean }> { if (localConfig.repo.kind === 'http') { const { resolveApiKey } = await import('./api-key.js'); const apiKey = resolveApiKey(); @@ -94,7 +93,7 @@ async function refreshTeamRepo( } // HTTP backends deliver resources through report/sync (own hook handler), // so there is no repo tree to pull here. - return { label: 'HTTP (report/sync delivery)', version: null, reportingOnly: true, submodulesFailed: false, submodulesChanged: false }; + return { label: 'HTTP (report/sync delivery)', version: null, submodulesFailed: false, submodulesChanged: false }; } if (localConfig.repo.kind === 'self') { @@ -118,7 +117,7 @@ async function refreshTeamRepo( } catch { version = null; } - return { label: 'single-repo (knowledge on main)', version, reportingOnly: false, submodulesFailed: false, submodulesChanged: false }; + return { label: 'single-repo (knowledge on main)', version, submodulesFailed: false, submodulesChanged: false }; } // The shared team clone is mutated here (git pull + flushPendingLearnings' @@ -193,7 +192,7 @@ async function refreshTeamRepo( log.warn(`Submodule update failed for ${localConfig.repo.localPath}: ${(e as Error).message}`); } - return { label: result, version, reportingOnly: false, submodulesFailed, submodulesChanged }; + return { label: result, version, submodulesFailed, submodulesChanged }; } /** teamai.yaml `usageReport: false` — per-repo opt-out of stat commits. */ @@ -757,10 +756,6 @@ async function pullForScope( // Step 1: refresh team repo (git pull, or HTTP /repo materialization) const pullSpin = spinner(`[${scopeLabel}] Pulling team repo...`).start(); let currentRev: string | null = null; - // Reporting-only HTTP endpoints have no team repo to write to, so the - // team-repo-dependent built-in workflows the stub routes to are useless - // there and must not be injected. - let reportingOnly = false; // A failed submodule update holds the rev back below so the next pull // retries (see refreshTeamRepo). let submodulesFailed = false; @@ -770,7 +765,6 @@ async function pullForScope( try { const refresh = await refreshTeamRepo(localConfig); currentRev = refresh.version; - reportingOnly = refresh.reportingOnly; submodulesFailed = refresh.submodulesFailed; submodulesChanged = refresh.submodulesChanged; pullSpin.succeed(`[${scopeLabel}] Team repo: ${refresh.label}`); @@ -1021,7 +1015,7 @@ async function pullForScope( const skipRecall = !isRecallEnabled(localConfig, freshConfig); try { const { deployBuiltinAgents } = await import('./builtin-agents.js'); await deployBuiltinAgents(freshConfig, localConfig, { skipRecall }); } catch {} try { const { deployBuiltinRules } = await import('./builtin-rules.js'); await deployBuiltinRules(freshConfig, localConfig, { skipRecall }); } catch {} - try { const { deployBuiltinSkills } = await import('./builtin-skills.js'); await deployBuiltinSkills(freshConfig, localConfig, { reportingOnly }); } catch {} + try { const { deployBuiltinSkills } = await import('./builtin-skills.js'); await deployBuiltinSkills(freshConfig, localConfig); } catch {} // Refresh managed culture/shared-instruction blocks as well. A CLI // upgrade may add a new target file while the team repo SHA and tool // target set remain unchanged. @@ -1295,7 +1289,7 @@ async function pullForScope( if (!options.dryRun) { try { const { deployBuiltinSkills } = await import('./builtin-skills.js'); - const deployed = await deployBuiltinSkills(freshConfig, localConfig, { reportingOnly }); + const deployed = await deployBuiltinSkills(freshConfig, localConfig); if (deployed > 0) { log.debug(`[${scopeLabel}] Deployed ${deployed} built-in skill(s)`); } From f605fbade3756ae9bb1e0971f92f2dd02629d04b Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Tue, 22 Sep 2026 14:10:48 +0200 Subject: [PATCH 16/37] fix(skills): carry the TGit provider guide into the served setup skill #724 landed `skills/teamai/references/provider-tgit.md` and repointed setup-admin.md and join-member.md at it. Rebasing onto that left the new file in a tree this branch no longer deploys, and the pointers in bare `provider-tgit.md` form the served skills do not use. - move it to `skill-data/setup/references/`, beside the two files that cite it, so `teamai skill get setup --full` serves it - rewrite every pointer to it as `{SKILL_DIR}/references/provider-tgit.md` - list it in the setup skill's reference table - add `references/provider-tgit.md` to PACKAGED_SKILL_FILES, so the prune removes it from members who pulled a release that shipped it --- skill-data/setup/SKILL.md | 8 +++++--- .../{core => setup}/references/provider-tgit.md | 5 +++-- skill-data/setup/references/setup-admin.md | 16 ++++++++-------- src/builtin-skills.ts | 7 +++++-- 4 files changed, 21 insertions(+), 15 deletions(-) rename skill-data/{core => setup}/references/provider-tgit.md (94%) diff --git a/skill-data/setup/SKILL.md b/skill-data/setup/SKILL.md index 85a134b4..01721191 100644 --- a/skill-data/setup/SKILL.md +++ b/skill-data/setup/SKILL.md @@ -34,8 +34,9 @@ is missing. | Anything that breaks along the way | `teamai skill get core --full` (troubleshooting) | Supported Git providers are Tencent TGit (工蜂), GitHub, GitLab and CNB; -`setup-admin.md` carries the detection probe, the sign-in and create-repo URLs, -and the per-provider caveats. +`{SKILL_DIR}/references/setup-admin.md` carries the detection probe, the sign-in +and create-repo URLs, and the per-provider caveats, and points at +`{SKILL_DIR}/references/provider-tgit.md` for everything TGit-specific. ## Rules for these flows @@ -71,5 +72,6 @@ In the files below, `{SKILL_DIR}` is the directory `teamai skill path setup` pri | `{SKILL_DIR}/references/join-member.md` | Joining an existing team from a repo URL. | | `{SKILL_DIR}/references/manage-admin.md` | Day-to-day admin: publishing resources, roles, projects, MCP, env, members. | | `{SKILL_DIR}/references/uninstall.md` | Removing TeamAI from a machine or from one agent. | +| `{SKILL_DIR}/references/provider-tgit.md` | Tencent TGit (工蜂): reachability probe, `gf` install and login, repo creation on init. | -`teamai skill get setup --full` prints this skill with all four appended (about 36 KB). +`teamai skill get setup --full` prints this skill with all five appended. diff --git a/skill-data/core/references/provider-tgit.md b/skill-data/setup/references/provider-tgit.md similarity index 94% rename from skill-data/core/references/provider-tgit.md rename to skill-data/setup/references/provider-tgit.md index 21114e89..ec7e70ff 100644 --- a/skill-data/core/references/provider-tgit.md +++ b/skill-data/setup/references/provider-tgit.md @@ -2,8 +2,9 @@ git.woa.com is **Tencent-internal only**. TeamAI supports it natively as the `tgit` provider — it recognizes the host on its own, so you **never** set `GITLAB_URL`. -Both `setup-admin.md` and `join-member.md` point here for the reachability probe -and the `gf` login; follow the relevant section for whichever flow you are in. +Both `{SKILL_DIR}/references/setup-admin.md` and +`{SKILL_DIR}/references/join-member.md` point here for the reachability probe and +the `gf` login; follow the relevant section for whichever flow you are in. ## Probe reachability (setup flow only) diff --git a/skill-data/setup/references/setup-admin.md b/skill-data/setup/references/setup-admin.md index b13d865c..7a90edef 100644 --- a/skill-data/setup/references/setup-admin.md +++ b/skill-data/setup/references/setup-admin.md @@ -25,8 +25,8 @@ through these sub-steps **in order**: ### 2a — Ask which platform they know **Tencent-internal first:** before asking, probe whether TGit (工蜂) is reachable -on this machine — see `provider-tgit.md` ("Probe reachability") for the one-line -`x-env: tgit` check. If it says `tgit: OK`, **list Tencent TGit (工蜂) first** and +on this machine — see `{SKILL_DIR}/references/provider-tgit.md` ("Probe +reachability") for the one-line `x-env: tgit` check. If it says `tgit: OK`, **list Tencent TGit (工蜂) first** and prefer it. Then ask: *"Have you heard of / do you have an account on any of these — Tencent TGit (工蜂), GitHub, GitLab, or CNB (cnb.cool)?"* @@ -42,7 +42,7 @@ If they name one, use that platform and go to sub-step 2c. Test which sites this network can actually reach (probe each, ~3s timeout each). The TGit probe checks the `x-env: tgit` header, not just reachability (see -`provider-tgit.md`); the others just check reachability: +`{SKILL_DIR}/references/provider-tgit.md`); the others just check reachability: ```bash curl -sS -m 3 -D - -o /dev/null https://git.woa.com 2>/dev/null | grep -qi '^x-env:[[:space:]]*tgit' && echo "tgit: OK" || echo "tgit: unreachable" @@ -74,7 +74,7 @@ the repository, then continue to the next step: > **Tencent TGit (工蜂):** don't send the user to the browser to create the repo — > prefer letting `teamai init` create it via the API in Step 5. See -> `provider-tgit.md` ("When you `teamai init` on TGit"). +> `{SKILL_DIR}/references/provider-tgit.md` ("When you `teamai init` on TGit"). Tell the user to sign in, create an **empty** repo (suggested name `TeamAi-<team-name>`), and give you the resulting repo URL. Explain in one @@ -93,8 +93,8 @@ platform's CLI credentials. Have the user complete the matching CLI login: ### Tencent TGit (工蜂) -See `provider-tgit.md` ("Log in") — you install `gf` and run `gf auth login` -yourself; the user only approves in the browser / iOA. No `GITLAB_URL` needed. +See `{SKILL_DIR}/references/provider-tgit.md` ("Log in") — you install `gf` and +run `gf auth login` yourself; the user only approves in the browser / iOA. No `GITLAB_URL` needed. Then return here for Step 4. ### CNB — install the CLI, authorize, then read the repo (in this order) @@ -173,8 +173,8 @@ teamai init https://<platform>/<org>/<repo-name> --scope user If the repo does not exist yet, `init` offers to create it — accept the prompt. - **Tencent TGit (工蜂):** `gf` and login are already done, so init creates the - repo via the API when it's missing — see `provider-tgit.md` ("When you - `teamai init` on TGit"). + repo via the API when it's missing — see + `{SKILL_DIR}/references/provider-tgit.md` ("When you `teamai init` on TGit"). - **CNB caveat:** a `cnb login` token **cannot create** an org or repo — that is exactly why the CNB flow has the user create the repo on the website first (Step 2c). If the org/repo is still missing here, `init` prints web links diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index e4be24b1..5a69ce2c 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -71,8 +71,10 @@ export function isCliOwnedSkillName(name: string): boolean { /** * Every file a release ever packaged under `skills/`, by directory name. * - * Built as the union of `git ls-tree -r <tag> -- skills/` over all 91 tags, so a - * path listed here was written by the CLI and is ours to remove. Deployment + * Built as the union of `git ls-tree -r <tag> -- skills/` over all 91 tags, plus + * `references/provider-tgit.md`, which main carries unreleased and the next + * release therefore ships. Every path listed here is written by the CLI and is + * ours to remove. Deployment * copied these trees with `overwrite: true` and never deleted anything, so a * file that is *not* listed here was put there by the member and survives. * @@ -85,6 +87,7 @@ export const PACKAGED_SKILL_FILES: ReadonlyMap<string, readonly string[]> = new 'references/contribute-member.md', 'references/join-member.md', 'references/manage-admin.md', + 'references/provider-tgit.md', 'references/setup-admin.md', 'references/troubleshooting.md', 'references/uninstall.md', From d4fe1188b2da9a3c0102cb55e87cb3311cbf4da8 Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Tue, 22 Sep 2026 14:40:54 +0200 Subject: [PATCH 17/37] fix(skills): make the blocked catalog entry unrepresentable, drop unsafe casts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings from the standards axis, plus the doc half of the prune count. - `SkillCatalogEntry` allowed `{blockedByRecall: true, path: '/…'}`, an invariant `skillCatalog` then upheld by hand. Split it on `blockedByRecall`, so the withheld directory is a type error rather than a review catch. Both variants keep the `path` key, so the `skill list --json` shape is unchanged. - `command.commands as Command[]` stripped commander's `readonly` in three places. `for…of` and `.find` need no cast. - `docs/designs/skill-serving.md` still said the prune removes six `teamai/references/*.md`; provider-tgit.md makes it seven. --- docs/designs/skill-serving.md | 5 +++-- src/__tests__/skill-commands-exist.test.ts | 2 +- src/commands-reference.ts | 4 ++-- src/skill-content.ts | 22 +++++++++++++++------- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index dd8eed94..abcb291a 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -134,13 +134,14 @@ the directory there would undo, one command over, the guarantee pull makes. **It removes only the files those releases packaged.** `PACKAGED_SKILL_FILES` lists them, built as the union of `git ls-tree -r <tag> -- skills/` over every -tag, so each path is provably the CLI's. Those files were overwritten with +tag, plus `references/provider-tgit.md`, which #724 put on `main` unreleased and +the next release therefore ships, so each path is provably the CLI's. Those files were overwritten with `overwrite: true` on every pull and no local edit ever survived in one; a file a member added beside them was never touched by the old deployment and is not ours to delete now. Directories left empty go; a directory still holding a member's file is kept, and `pull` says which one and why. Python bytecode of a script we shipped counts as ours, so a `__pycache__` left by running the wiki scripts does -not strand the tree. The same rule governs the stub directory: the six +not strand the tree. The same rule governs the stub directory: the seven `teamai/references/*.md` a pre-stub release wrote are removed by name, not by "everything that is not SKILL.md". diff --git a/src/__tests__/skill-commands-exist.test.ts b/src/__tests__/skill-commands-exist.test.ts index 437ada89..8a232732 100644 --- a/src/__tests__/skill-commands-exist.test.ts +++ b/src/__tests__/skill-commands-exist.test.ts @@ -58,7 +58,7 @@ function resolveCommand(program: Command, tokens: string[]): { command: Command; let command = program; let index = 0; while (index < tokens.length) { - const next = (command.commands as Command[]).find( + const next = command.commands.find( (c) => c.name() === tokens[index] || c.aliases().includes(tokens[index]), ); if (!next) break; diff --git a/src/commands-reference.ts b/src/commands-reference.ts index ca745c3d..c6810aeb 100644 --- a/src/commands-reference.ts +++ b/src/commands-reference.ts @@ -51,7 +51,7 @@ function renderCommand(command: Command, parents: string[]): string[] { for (const option of visibleOptions(command)) { lines.push(renderOption(option)); } - for (const sub of command.commands as Command[]) { + for (const sub of command.commands) { lines.push(...renderCommand(sub, path).map((line) => ` ${line}`)); } return lines; @@ -66,7 +66,7 @@ export function renderCommandsReference(program: Command): string { sections.push(['## Global options', '', ...globalOptions.map(renderOption).map((l) => l.slice(2))].join('\n')); } - for (const command of program.commands as Command[]) { + for (const command of program.commands) { sections.push([`## ${command.name()}`, '', ...renderCommand(command, [])].join('\n')); } diff --git a/src/skill-content.ts b/src/skill-content.ts index 1b69cc62..190220fb 100644 --- a/src/skill-content.ts +++ b/src/skill-content.ts @@ -383,26 +383,34 @@ export async function skillPath(name: string): Promise<void> { * A skill the recall gate blocks is still listed, so the agent learns it exists * and what to turn on, but its directory is withheld like `skill path` does. */ -export interface SkillCatalogEntry { +interface SkillCatalogEntryFields { name: string; description: string; - path: string | null; deployed: boolean; - blockedByRecall: boolean; } +/** + * `blockedByRecall` carries the directory with it: a blocked entry has no path + * to report, and a served one always has. Both variants keep the `path` key so + * the JSON shape does not change with the gate. + */ +export type SkillCatalogEntry = + | (SkillCatalogEntryFields & { blockedByRecall: false; path: string }) + | (SkillCatalogEntryFields & { blockedByRecall: true; path: null }); + export async function skillCatalog(roots: PackagedSkillRoots = packagedSkillRoots()): Promise<SkillCatalogEntry[]> { const skills = await listServableSkills(roots); const entries: SkillCatalogEntry[] = []; for (const skill of skills) { const blocked = (await resolveServableSkill(skill.name, roots)).kind === 'blocked'; - entries.push({ + const fields: SkillCatalogEntryFields = { name: skill.name, description: await readSkillDescription(path.join(skill.dir, SKILL_MD)), - path: blocked ? null : skill.dir, deployed: skill.deployed, - blockedByRecall: blocked, - }); + }; + entries.push(blocked + ? { ...fields, blockedByRecall: true, path: null } + : { ...fields, blockedByRecall: false, path: skill.dir }); } return entries; } From cb85fbb8664b376708afa0fd6cde9b3d1b56c2e0 Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Tue, 22 Sep 2026 14:45:36 +0200 Subject: [PATCH 18/37] fix(skills): keep publishing a skill reachable when recall is off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publishing a skill is `teamai push --skill`, which never consulted recall (`src/push.ts` names it nowhere). On main the flow shipped in the teamai skill, ungated. Moving `contribute-member.md` under `share` put it behind the recall gate, so with recall off — a new team's default — the core routing table sent the agent to `teamai skill get share`, which exits 1 and tells it to enable recall. Wrong advice for a flow recall does not touch, and no other path to the instructions. Move the file to `core`, the skill that already owns `push`, and split the routing row so publishing and session learnings stop sharing one destination. The gate itself is right and stays: learnings do need recall. `share/SKILL.md` already called this "a different flow"; now it points at `teamai skill get core --full` instead of at its own references. PACKAGED_SKILL_FILES is unchanged: the legacy path a pre-stub release wrote is still `teamai/references/contribute-member.md`. --- docs/designs/skill-serving.md | 2 +- skill-data/core/SKILL.md | 8 +++++--- .../{share => core}/references/contribute-member.md | 0 skill-data/share/SKILL.md | 8 ++++---- 4 files changed, 10 insertions(+), 8 deletions(-) rename skill-data/{share => core}/references/contribute-member.md (100%) diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index abcb291a..9b7fff8d 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -34,7 +34,7 @@ npm package ├── skills/ │ └── teamai/SKILL.md the only unit deployed into agents (~2 KB) └── skill-data/ never deployed; printed by `teamai skill get` - ├── core/ daily sync, routing, generated command reference + ├── core/ daily sync, routing, publishing a skill, command reference ├── setup/ day 0 and repo lifecycle ├── wiki/ codebase knowledge base, incl. scripts/ └── share/ session learnings diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index fb475e1d..f74008c2 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -49,7 +49,8 @@ Usage examples (copy one to get started): | The user wants to… | Load this | |-----------------------------------------------------------------------|------------------------------------------------| | Set up a team from scratch, join a team, manage one, or uninstall | `teamai skill get setup` | -| Share or publish a skill, a doc, or what this session taught them | `teamai skill get share` | +| Publish a skill, rule or doc they already have | `{SKILL_DIR}/references/contribute-member.md` | +| Share what this session taught them | `teamai skill get share` | | Understand a large multi-repo codebase, build an architecture wiki | `teamai skill get wiki` | | Sync now, see differences, diagnose | `teamai pull` · `teamai status` · `teamai doctor` | | Open the team dashboard | `teamai dashboard` — it starts a local server (default port 3721); give the user the URL | @@ -108,6 +109,7 @@ In the files below, `{SKILL_DIR}` is the directory `teamai skill path core` prin |---|---| | `{SKILL_DIR}/references/commands.md` | Before using any command not in the daily list, or any flag. Generated from the CLI's own command table, so it cannot drift. | | `{SKILL_DIR}/references/troubleshooting.md` | A command fails, a hook does not fire, or a host needs manual steps. | +| `{SKILL_DIR}/references/contribute-member.md` | A member wants to publish a skill, rule or doc they already have. Any member can, not just admins. | -`teamai skill get core --full` prints this skill with both references appended -(about 32 KB). Load a single file above when you only need one. +`teamai skill get core --full` prints this skill with all three references +appended. Load a single file above when you only need one. diff --git a/skill-data/share/references/contribute-member.md b/skill-data/core/references/contribute-member.md similarity index 100% rename from skill-data/share/references/contribute-member.md rename to skill-data/core/references/contribute-member.md diff --git a/skill-data/share/SKILL.md b/skill-data/share/SKILL.md index afda9b9c..38205454 100644 --- a/skill-data/share/SKILL.md +++ b/skill-data/share/SKILL.md @@ -58,8 +58,9 @@ teamai contribute --file /tmp/session-summary.md --title "Debugging K8s pod star ## Publishing a reusable skill instead A member asking to publish a skill ("share this xxx skill with my team") is a -different flow: see `{SKILL_DIR}/references/contribute-member.md`. This file is for -turning a *session* into a learning. +different flow, and it does not need recall: it lives in the `core` skill, as +`teamai skill get core --full` under `references/contribute-member.md`. This file +is for turning a *session* into a learning. ## References @@ -68,6 +69,5 @@ In the files below, `{SKILL_DIR}` is the directory `teamai skill path share` pri | File | When to load it | |---|---| | `{SKILL_DIR}/references/doc-template.md` | Writing the learning document: template, frontmatter fields, tag taxonomy. | -| `{SKILL_DIR}/references/contribute-member.md` | The user wants to publish a skill, rule or doc they already have, rather than a session summary. | -`teamai skill get share --full` prints this skill with both references appended. +`teamai skill get share --full` prints this skill with its reference appended. From fa61cc1820a6cbe42863b286133d921233dd6bb1 Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Tue, 22 Sep 2026 15:11:28 +0200 Subject: [PATCH 19/37] fix(skills): back up what the prune removes, so no edit is a one-way door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: `removeOwnedFiles` proves ownership by pathname and deletes without reading the file, so a member's edit goes with it. For a path the current package still ships that changes nothing: the old deployment overwrote it with `overwrite: true` on the same three triggers, so the edit died either way, at the same moment. The case the objection gets right is a path a retired release shipped and the package no longer does — the overwrite never reached it, so the edit did survive, and the prune is the first thing to remove it. Copy every pruned file to `~/.teamai/removed-skills/<date>/<tool>/<skill>/` before removing it. Outside every agent directory, so nothing reads it back as a skill. Verifying contents against a hash of each released version was the other way out, and it is worse: anything not byte-identical is then kept, so one CRLF checkout on Windows — a platform this project supports — leaves the whole 176 KB in place and reports success. Backing up gives the same guarantee without betting the migration on byte equality. Uninstall keeps deleting outright: there the member asked for the files to go. --- docs/designs/skill-serving.md | 7 ++- src/__tests__/skip-uninstalled-tools.test.ts | 46 ++++++++++++++++++++ src/builtin-skills.ts | 42 +++++++++++++++--- 3 files changed, 88 insertions(+), 7 deletions(-) diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index 9b7fff8d..8a8df3a3 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -138,7 +138,12 @@ tag, plus `references/provider-tgit.md`, which #724 put on `main` unreleased and the next release therefore ships, so each path is provably the CLI's. Those files were overwritten with `overwrite: true` on every pull and no local edit ever survived in one; a file a member added beside them was never touched by the old deployment and is not ours -to delete now. Directories left empty go; a directory still holding a member's +to delete now. One case the overwrite never reached: a path a retired release +shipped and the current package no longer does just sat there, so an edit to it +did survive. Ownership is proven by pathname, not by contents, so the prune +cannot tell that file from ours — it copies everything it removes to +`~/.teamai/removed-skills/<date>/<tool>/<skill>/` first, and the migration stops +being a one-way door for any of them. Directories left empty go; a directory still holding a member's file is kept, and `pull` says which one and why. Python bytecode of a script we shipped counts as ours, so a `__pycache__` left by running the wiki scripts does not strand the tree. The same rule governs the stub directory: the seven diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index 1e7b43c4..e46517c2 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -625,6 +625,52 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { } }); + it('parks a copy of every file it prunes, so a member who edited one can get it back', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + const teamConfig = { + team: 'test', + description: '', + repo: 'https://git.woa.com/test/repo.git', + provider: 'tgit' as const, + reviewers: [], + sharing: { + skills: {}, + rules: { enforced: [] }, + docs: { localDir: '' }, + env: { injectShellProfile: true }, + }, + toolPaths: { + claude: { skills: '.claude/skills' }, + }, + }; + + const localConfig = { + repo: { localPath: path.join(tmpDir, 'repo'), remote: 'https://git.woa.com/test/repo.git' }, + username: 'testuser', + updatePolicy: 'auto' as const, + additionalRoles: [], + scope: 'user' as const, + }; + + // Ownership is proven by pathname, so this file is pruned even though the + // member edited it. A retired release's path is never overwritten by the + // deployment either, which is what makes the backup the only way back. + const wiki = path.join(homeDir, '.claude/skills/team-wiki-codebase'); + await fse.ensureDir(path.join(wiki, 'references/methodology')); + await fse.writeFile(path.join(wiki, 'SKILL.md'), '# edited by the member'); + await fse.writeFile(path.join(wiki, 'references/methodology/phase0-collection.md'), '# my notes'); + + await deployBuiltinSkills(teamConfig, localConfig); + + expect(await fse.pathExists(wiki)).toBe(false); + + const stamp = new Date().toISOString().slice(0, 10); + const backup = path.join(homeDir, '.teamai/removed-skills', stamp, 'claude/team-wiki-codebase'); + expect(await fse.readFile(path.join(backup, 'SKILL.md'), 'utf8')).toBe('# edited by the member'); + expect(await fse.readFile(path.join(backup, 'references/methodology/phase0-collection.md'), 'utf8')).toBe('# my notes'); + }); + it('removes the references an earlier release deployed beside the stub', async () => { const { deployBuiltinSkills } = await import('../builtin-skills.js'); diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index 5a69ce2c..48800343 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -157,7 +157,11 @@ async function removeEmptyDirs(dir: string): Promise<void> { * rule pull does: a file a member added beside our packaged ones was never ours * to write and is not ours to remove, whichever command is doing the removing. */ -export async function removeOwnedFiles(dir: string, owned: readonly string[]): Promise<boolean> { +export async function removeOwnedFiles( + dir: string, + owned: readonly string[], + backupDir?: string, +): Promise<boolean> { const ownedPaths = new Set(owned); let foreign = 0; @@ -166,13 +170,38 @@ export async function removeOwnedFiles(dir: string, owned: readonly string[]): P foreign++; continue; } - await remove(path.join(dir, relative)); + const file = path.join(dir, relative); + // Ownership is proven by pathname, not by contents: a member who edited one + // of our files in place still has that edit in there. The old deployment + // overwrote it on the next pull, so nothing was preserved either way, but a + // path a retired release shipped and the current package no longer does was + // never overwritten. Park a copy before removing so no version of that is a + // one-way door. + if (backupDir) { + try { + await fse.copy(file, path.join(backupDir, relative), { overwrite: true }); + } catch (e) { + log.debug(`Could not back up ${file}: ${(e as Error).message}`); + } + } + await remove(file); } await removeEmptyDirs(dir); return foreign === 0; } +/** The day's backup root. One per run keeps a re-run from multiplying copies. */ +const PRUNE_STAMP = new Date().toISOString().slice(0, 10); + +/** + * Where the prune parks what it removes: outside every agent directory, so no + * agent reads it back as a skill, and under the member's own `~/.teamai`. + */ +function skillBackupDir(baseDir: string, tool: string, skillName: string): string { + return path.join(baseDir, '.teamai', 'removed-skills', PRUNE_STAMP, tool, skillName); +} + /** * Remove the skill directories earlier releases deployed. * @@ -197,11 +226,12 @@ export async function pruneLegacyBuiltinSkills( const dir = path.join(baseDir, root, legacyName); if (!await pathExists(dir)) continue; try { - const removedWhole = await removeOwnedFiles(dir, PACKAGED_SKILL_FILES.get(legacyName) ?? []); + const backupDir = skillBackupDir(baseDir, tool, legacyName); + const removedWhole = await removeOwnedFiles(dir, PACKAGED_SKILL_FILES.get(legacyName) ?? [], backupDir); if (removedWhole) { - log.debug(`Removed legacy built-in skill ${legacyName} from ${tool} (${dir})`); + log.debug(`Removed legacy built-in skill ${legacyName} from ${tool} (${dir}); a copy is in ${backupDir}`); } else { - log.warn(`Kept "${legacyName}" (${tool}): ${dir} holds files TeamAI did not put there. The packaged files were removed; delete the rest yourself once you have saved what you need.`); + log.warn(`Kept "${legacyName}" (${tool}): ${dir} holds files TeamAI did not put there. The packaged files were removed (a copy is in ${backupDir}); delete the rest yourself once you have saved what you need.`); } } catch (e) { log.debug(`Could not remove legacy built-in skill ${legacyName} from ${tool}: ${(e as Error).message}`); @@ -291,7 +321,7 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? // releases wrote go first — and only those: a file a member added here // is theirs, and the old deployment never deleted it either. if (await pathExists(destDir)) { - await removeOwnedFiles(destDir, PACKAGED_SKILL_FILES.get(skillName) ?? []); + await removeOwnedFiles(destDir, PACKAGED_SKILL_FILES.get(skillName) ?? [], skillBackupDir(baseDir, tool, skillName)); } await fse.ensureDir(destDir); await fse.copy(path.join(srcDir, 'SKILL.md'), path.join(destDir, 'SKILL.md'), { overwrite: true }); From 5a0dff0965943832bc6ce2efd8f9185fab1e4de1 Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Tue, 22 Sep 2026 15:22:06 +0200 Subject: [PATCH 20/37] fix(skills): let no backup failure authorise a delete, give each root its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two holes in the backup the previous commit added, both reported in review. The copy's failure was swallowed at debug level and the delete went ahead regardless, so a full disk or a read-only home turned the migration back into the data loss the backup exists to prevent — and the log still named a backup directory that held nothing. A file whose copy fails is now kept, counted, and named at warn level; `removeOwnedFiles` returns what happened instead of a bare boolean, and only a run that copied something names the directory. The backup path was `<date>/<tool>/<skill>` with `overwrite: true`, so the second copy of a name silently replaced the first. Codex prunes the same skill from `.codex/skills` and the shared `.agents/skills`, and two pulls share a date. The path now carries a per-run id and the skill root, and the copy refuses to overwrite rather than clobbering a copy it cannot replace. Tests cover both: a file where the backup tree must start makes every copy fail, and the two Codex roots land in separate directories. Each fails against the previous commit. --- docs/designs/skill-serving.md | 8 +- src/__tests__/skip-uninstalled-tools.test.ts | 81 +++++++++++++++++++- src/builtin-skills.ts | 68 ++++++++++++---- src/uninstall.ts | 3 +- 4 files changed, 139 insertions(+), 21 deletions(-) diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index 8a8df3a3..3f1c6b65 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -142,8 +142,12 @@ to delete now. One case the overwrite never reached: a path a retired release shipped and the current package no longer does just sat there, so an edit to it did survive. Ownership is proven by pathname, not by contents, so the prune cannot tell that file from ours — it copies everything it removes to -`~/.teamai/removed-skills/<date>/<tool>/<skill>/` first, and the migration stops -being a one-way door for any of them. Directories left empty go; a directory still holding a member's +`~/.teamai/removed-skills/<run>/<tool>/<skill-root>/<skill>/` first, and the +migration stops being a one-way door for any of them. A file whose copy fails is +kept rather than removed: a backup that did not happen must not authorise the +delete. The path carries the run and the skill root because neither is unique on +its own — two pulls land on the same day, and Codex prunes the same skill name +from both `.codex/skills` and the shared `.agents/skills`. Directories left empty go; a directory still holding a member's file is kept, and `pull` says which one and why. Python bytecode of a script we shipped counts as ours, so a `__pycache__` left by running the wiki scripts does not strand the tree. The same rule governs the stub directory: the seven diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index e46517c2..46762e0f 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -6,6 +6,45 @@ import fse from 'fs-extra'; const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +/** The team config the prune tests share; pass toolPaths to change which tool runs. */ +function legacyPruneTeamConfig(toolPaths: Record<string, { skills: string }> = { claude: { skills: '.claude/skills' } }) { + return { + team: 'test', + description: '', + repo: 'https://git.woa.com/test/repo.git', + provider: 'tgit' as const, + reviewers: [], + sharing: { + skills: {}, + rules: { enforced: [] }, + docs: { localDir: '' }, + env: { injectShellProfile: true }, + }, + toolPaths, + }; +} + +function legacyPruneLocalConfig(tmpDir: string) { + return { + repo: { localPath: path.join(tmpDir, 'repo'), remote: 'https://git.woa.com/test/repo.git' }, + username: 'testuser', + updatePolicy: 'auto' as const, + additionalRoles: [], + scope: 'user' as const, + }; +} + +/** + * The one backup root this run created. Its name carries a timestamp, so the + * test reads it back instead of reconstructing it and racing the clock. + */ +async function onlyRunDir(homeDir: string): Promise<string> { + const root = path.join(homeDir, '.teamai/removed-skills'); + const runs = await fse.readdir(root); + expect(runs).toHaveLength(1); + return path.join(root, runs[0]); +} + vi.mock('../config.js', () => ({ requireInit: vi.fn(), loadState: vi.fn(), @@ -665,12 +704,50 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { expect(await fse.pathExists(wiki)).toBe(false); - const stamp = new Date().toISOString().slice(0, 10); - const backup = path.join(homeDir, '.teamai/removed-skills', stamp, 'claude/team-wiki-codebase'); + const backup = path.join(await onlyRunDir(homeDir), 'claude/.claude-skills/team-wiki-codebase'); expect(await fse.readFile(path.join(backup, 'SKILL.md'), 'utf8')).toBe('# edited by the member'); expect(await fse.readFile(path.join(backup, 'references/methodology/phase0-collection.md'), 'utf8')).toBe('# my notes'); }); + it('keeps a file it could not back up, instead of deleting it anyway', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + const teamConfig = legacyPruneTeamConfig(); + const localConfig = legacyPruneLocalConfig(tmpDir); + + const wiki = path.join(homeDir, '.claude/skills/team-wiki-codebase'); + await fse.ensureDir(wiki); + await fse.writeFile(path.join(wiki, 'SKILL.md'), '# edited by the member'); + + // A file where the backup tree has to start: every copy under it fails, the + // way a full disk or a read-only home would. + await fse.ensureDir(path.join(homeDir, '.teamai')); + await fse.writeFile(path.join(homeDir, '.teamai/removed-skills'), 'not a directory'); + + await deployBuiltinSkills(teamConfig, localConfig); + + expect(await fse.readFile(path.join(wiki, 'SKILL.md'), 'utf8')).toBe('# edited by the member'); + }); + + it('gives each skill root its own backup, so the two Codex copies do not overwrite each other', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + const teamConfig = legacyPruneTeamConfig({ codex: { skills: '.codex/skills' } }); + const localConfig = legacyPruneLocalConfig(tmpDir); + + // Codex prunes its own root and the shared one; same skill name, different files. + for (const [root, body] of [['.codex/skills', '# from codex'], ['.agents/skills', '# from shared']]) { + await fse.ensureDir(path.join(homeDir, root, 'team-wiki-codebase')); + await fse.writeFile(path.join(homeDir, root, 'team-wiki-codebase/SKILL.md'), body); + } + + await deployBuiltinSkills(teamConfig, localConfig); + + const run = await onlyRunDir(homeDir); + expect(await fse.readFile(path.join(run, 'codex/.codex-skills/team-wiki-codebase/SKILL.md'), 'utf8')).toBe('# from codex'); + expect(await fse.readFile(path.join(run, 'codex/.agents-skills/team-wiki-codebase/SKILL.md'), 'utf8')).toBe('# from shared'); + }); + it('removes the references an earlier release deployed beside the stub', async () => { const { deployBuiltinSkills } = await import('../builtin-skills.js'); diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index 48800343..426cf19a 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -157,17 +157,31 @@ async function removeEmptyDirs(dir: string): Promise<void> { * rule pull does: a file a member added beside our packaged ones was never ours * to write and is not ours to remove, whichever command is doing the removing. */ +export interface PruneResult { + /** Files left in place because the member, not the CLI, put them there. */ + foreign: number; + /** Files left in place because their backup could not be written, and why. */ + unbackedUp: { file: string; error: string }[]; + /** Whether anything was actually copied, so a log only names a backup that exists. */ + backedUp: number; +} + +/** True when the directory is gone: nothing of the member's, nothing unsaved. */ +export function prunedWhole(result: PruneResult): boolean { + return result.foreign === 0 && result.unbackedUp.length === 0; +} + export async function removeOwnedFiles( dir: string, owned: readonly string[], backupDir?: string, -): Promise<boolean> { +): Promise<PruneResult> { const ownedPaths = new Set(owned); - let foreign = 0; + const result: PruneResult = { foreign: 0, unbackedUp: [], backedUp: 0 }; for (const relative of await walkFiles(dir)) { if (!ownedPaths.has(relative) && !isDerivedArtifact(relative)) { - foreign++; + result.foreign++; continue; } const file = path.join(dir, relative); @@ -179,27 +193,42 @@ export async function removeOwnedFiles( // one-way door. if (backupDir) { try { - await fse.copy(file, path.join(backupDir, relative), { overwrite: true }); + // `errorOnExist` turns a colliding path into a failure rather than a + // silent overwrite: a lost copy would be the data loss this exists to + // prevent, wearing the log line of a success. + await fse.copy(file, path.join(backupDir, relative), { overwrite: false, errorOnExist: true }); + result.backedUp++; } catch (e) { - log.debug(`Could not back up ${file}: ${(e as Error).message}`); + // A full disk, a read-only home, a colliding copy. Keep the file: a + // backup that did not happen must not authorise the delete. + result.unbackedUp.push({ file, error: (e as Error).message }); + continue; } } await remove(file); } await removeEmptyDirs(dir); - return foreign === 0; + return result; } -/** The day's backup root. One per run keeps a re-run from multiplying copies. */ -const PRUNE_STAMP = new Date().toISOString().slice(0, 10); +/** + * One backup root per process run. A date alone collides: two pulls on the same + * day would have the second overwrite the first's copies. + */ +const PRUNE_RUN_ID = `${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}`; /** * Where the prune parks what it removes: outside every agent directory, so no * agent reads it back as a skill, and under the member's own `~/.teamai`. + * + * The skill root is part of the path because one tool can prune the same skill + * name from two roots — Codex reads `.codex/skills` and the shared + * `.agents/skills` — and those two copies are different files. */ -function skillBackupDir(baseDir: string, tool: string, skillName: string): string { - return path.join(baseDir, '.teamai', 'removed-skills', PRUNE_STAMP, tool, skillName); +function skillBackupDir(baseDir: string, tool: string, skillRoot: string, skillName: string): string { + const rootSlug = skillRoot.replace(/[\\/:]+/g, '-').replace(/^-+/, ''); + return path.join(baseDir, '.teamai', 'removed-skills', PRUNE_RUN_ID, tool, rootSlug, skillName); } /** @@ -226,12 +255,15 @@ export async function pruneLegacyBuiltinSkills( const dir = path.join(baseDir, root, legacyName); if (!await pathExists(dir)) continue; try { - const backupDir = skillBackupDir(baseDir, tool, legacyName); - const removedWhole = await removeOwnedFiles(dir, PACKAGED_SKILL_FILES.get(legacyName) ?? [], backupDir); - if (removedWhole) { - log.debug(`Removed legacy built-in skill ${legacyName} from ${tool} (${dir}); a copy is in ${backupDir}`); + const backupDir = skillBackupDir(baseDir, tool, root, legacyName); + const result = await removeOwnedFiles(dir, PACKAGED_SKILL_FILES.get(legacyName) ?? [], backupDir); + const saved = result.backedUp > 0 ? `; a copy is in ${backupDir}` : ''; + if (prunedWhole(result)) { + log.debug(`Removed legacy built-in skill ${legacyName} from ${tool} (${dir})${saved}`); + } else if (result.unbackedUp.length > 0) { + log.warn(`Kept "${legacyName}" (${tool}): ${result.unbackedUp.length} file(s) in ${dir} could not be backed up, so they were not removed. First: ${result.unbackedUp[0].file} — ${result.unbackedUp[0].error}`); } else { - log.warn(`Kept "${legacyName}" (${tool}): ${dir} holds files TeamAI did not put there. The packaged files were removed (a copy is in ${backupDir}); delete the rest yourself once you have saved what you need.`); + log.warn(`Kept "${legacyName}" (${tool}): ${dir} holds files TeamAI did not put there. The packaged files were removed${saved}; delete the rest yourself once you have saved what you need.`); } } catch (e) { log.debug(`Could not remove legacy built-in skill ${legacyName} from ${tool}: ${(e as Error).message}`); @@ -321,7 +353,11 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? // releases wrote go first — and only those: a file a member added here // is theirs, and the old deployment never deleted it either. if (await pathExists(destDir)) { - await removeOwnedFiles(destDir, PACKAGED_SKILL_FILES.get(skillName) ?? [], skillBackupDir(baseDir, tool, skillName)); + const backupDir = skillBackupDir(baseDir, tool, path.relative(baseDir, destDir), skillName); + const result = await removeOwnedFiles(destDir, PACKAGED_SKILL_FILES.get(skillName) ?? [], backupDir); + if (result.unbackedUp.length > 0) { + log.warn(`Kept ${result.unbackedUp.length} file(s) under ${destDir}: their backup could not be written, so they were not removed. First: ${result.unbackedUp[0].file} — ${result.unbackedUp[0].error}`); + } } await fse.ensureDir(destDir); await fse.copy(path.join(srcDir, 'SKILL.md'), path.join(destDir, 'SKILL.md'), { overwrite: true }); diff --git a/src/uninstall.ts b/src/uninstall.ts index e2288565..0d560e60 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -43,6 +43,7 @@ import { LEGACY_BUILTIN_SKILL_NAMES, PACKAGED_SKILL_FILES, isCliOwnedSkillName, + prunedWhole, removeOwnedFiles, } from './builtin-skills.js'; import { CODEX_TOOL, SHARED_AGENT_SKILLS_PATH } from './resources/skills.js'; @@ -862,7 +863,7 @@ async function executeRemoval(plan: RemovalPlan): Promise<void> { try { const name = path.basename(skillDir); if (isCliOwnedSkillName(name)) { - const removedWhole = await removeOwnedFiles(skillDir, PACKAGED_SKILL_FILES.get(name) ?? []); + const removedWhole = prunedWhole(await removeOwnedFiles(skillDir, PACKAGED_SKILL_FILES.get(name) ?? [])); if (removedWhole) removedSkillDirs++; else keptSkillDirs.push(skillDir); } else { From 5b494c10577fdfc3fb24039c2891509a542b0ddd Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Tue, 22 Sep 2026 15:47:23 +0200 Subject: [PATCH 21/37] fix(skills): stop at a symlinked root, archive only what is retired Three review findings, all in the prune. A symlinked skill directory was walked through. `readdir` follows the link, every path under it matches a packaged name, and the delete lands in someone else's checkout. Ownership now stops at the link: the root is lstat'd, a symlink is refused, and link and target are left alone. The stub directory was pruned against the full historical file list, which includes the SKILL.md written one line later. Deployment runs on every session start, unchanged revision included, so that archived an identical copy per session forever. Only paths this release no longer ships are archived now. Backups were written under the tool's base directory, which under project scope is the repo root, so they landed in the working tree outside the generated .teamai/.gitignore. They go to the machine's home. Also: `skill show <packaged>` resolved the team before the package, so it failed on a machine that never ran `teamai init` for content that needs no team. Packaged names resolve first and print without the team-dependent fields. --- docs/designs/skill-serving.md | 7 ++++- src/__tests__/skip-uninstalled-tools.test.ts | 30 ++++++++++++++++++ src/builtin-skills.ts | 32 ++++++++++++++----- src/skill-cmd.ts | 33 ++++++++++++++++++-- 4 files changed, 92 insertions(+), 10 deletions(-) diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index 3f1c6b65..9dc246e0 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -143,7 +143,12 @@ shipped and the current package no longer does just sat there, so an edit to it did survive. Ownership is proven by pathname, not by contents, so the prune cannot tell that file from ours — it copies everything it removes to `~/.teamai/removed-skills/<run>/<tool>/<skill-root>/<skill>/` first, and the -migration stops being a one-way door for any of them. A file whose copy fails is +migration stops being a one-way door for any of them. That path is the machine's +home, never the tool's base directory, which under project scope is the repo +root. Only *retired* paths are archived: the stub is rewritten on every session +start, so archiving it would file an identical copy per session forever. A +symlinked skill root is refused outright, link and target untouched: everything +under it matches our names, and none of it is ours. A file whose copy fails is kept rather than removed: a backup that did not happen must not authorise the delete. The path carries the run and the skill root because neither is unique on its own — two pulls land on the same day, and Codex prunes the same skill name diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index 46762e0f..a3f5cdb9 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -729,6 +729,36 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { expect(await fse.readFile(path.join(wiki, 'SKILL.md'), 'utf8')).toBe('# edited by the member'); }); + it('never walks through a symlinked skill root, so it cannot delete the link target', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + // A shared checkout the member linked in. Every path under it matches ours + // by name, so following the link would delete files we never wrote. + const shared = path.join(tmpDir, 'shared-skills/team-wiki-codebase'); + await fse.ensureDir(shared); + await fse.writeFile(path.join(shared, 'SKILL.md'), '# someone else\'s'); + + await fse.ensureDir(path.join(homeDir, '.claude/skills')); + await fse.symlink(shared, path.join(homeDir, '.claude/skills/team-wiki-codebase'), 'dir'); + + await deployBuiltinSkills(legacyPruneTeamConfig(), legacyPruneLocalConfig(tmpDir)); + + expect(await fse.readFile(path.join(shared, 'SKILL.md'), 'utf8')).toBe('# someone else\'s'); + expect(await fse.pathExists(path.join(homeDir, '.claude/skills/team-wiki-codebase'))).toBe(true); + }); + + it('archives nothing when there is nothing retired to archive', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + // Deployment runs on every session start, unchanged revision included. The + // stub it rewrites is shipped now, not retired, so it must not be archived + // once per session for the life of the install. + await deployBuiltinSkills(legacyPruneTeamConfig(), legacyPruneLocalConfig(tmpDir)); + await deployBuiltinSkills(legacyPruneTeamConfig(), legacyPruneLocalConfig(tmpDir)); + + expect(await fse.pathExists(path.join(homeDir, '.teamai/removed-skills'))).toBe(false); + }); + it('gives each skill root its own backup, so the two Codex copies do not overwrite each other', async () => { const { deployBuiltinSkills } = await import('../builtin-skills.js'); diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index 426cf19a..91679d73 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -158,6 +158,8 @@ async function removeEmptyDirs(dir: string): Promise<void> { * to write and is not ours to remove, whichever command is doing the removing. */ export interface PruneResult { + /** True when the root is a symlink, so nothing under it was read or removed. */ + skippedSymlink: boolean; /** Files left in place because the member, not the CLI, put them there. */ foreign: number; /** Files left in place because their backup could not be written, and why. */ @@ -168,7 +170,7 @@ export interface PruneResult { /** True when the directory is gone: nothing of the member's, nothing unsaved. */ export function prunedWhole(result: PruneResult): boolean { - return result.foreign === 0 && result.unbackedUp.length === 0; + return !result.skippedSymlink && result.foreign === 0 && result.unbackedUp.length === 0; } export async function removeOwnedFiles( @@ -177,7 +179,16 @@ export async function removeOwnedFiles( backupDir?: string, ): Promise<PruneResult> { const ownedPaths = new Set(owned); - const result: PruneResult = { foreign: 0, unbackedUp: [], backedUp: 0 }; + const result: PruneResult = { skippedSymlink: false, foreign: 0, unbackedUp: [], backedUp: 0 }; + + // A symlinked skill root points at files we never wrote — a shared checkout, + // a dotfiles repo. `readdir` follows it and every path under it would match + // ours by name, so the walk would delete someone else's files through the + // link. Ownership stops at the link. + if ((await fs.promises.lstat(dir)).isSymbolicLink()) { + result.skippedSymlink = true; + return result; + } for (const relative of await walkFiles(dir)) { if (!ownedPaths.has(relative) && !isDerivedArtifact(relative)) { @@ -226,9 +237,11 @@ const PRUNE_RUN_ID = `${new Date().toISOString().replace(/[:.]/g, '-')}-${proces * name from two roots — Codex reads `.codex/skills` and the shared * `.agents/skills` — and those two copies are different files. */ -function skillBackupDir(baseDir: string, tool: string, skillRoot: string, skillName: string): string { +function skillBackupDir(tool: string, skillRoot: string, skillName: string): string { const rootSlug = skillRoot.replace(/[\\/:]+/g, '-').replace(/^-+/, ''); - return path.join(baseDir, '.teamai', 'removed-skills', PRUNE_RUN_ID, tool, rootSlug, skillName); + // Machine data, not project data: under project scope `baseDir` is the repo + // root, where a backup per session start would show up as a dirty tree. + return path.join(getUserHome(), '.teamai', 'removed-skills', PRUNE_RUN_ID, tool, rootSlug, skillName); } /** @@ -255,7 +268,7 @@ export async function pruneLegacyBuiltinSkills( const dir = path.join(baseDir, root, legacyName); if (!await pathExists(dir)) continue; try { - const backupDir = skillBackupDir(baseDir, tool, root, legacyName); + const backupDir = skillBackupDir(tool, root, legacyName); const result = await removeOwnedFiles(dir, PACKAGED_SKILL_FILES.get(legacyName) ?? [], backupDir); const saved = result.backedUp > 0 ? `; a copy is in ${backupDir}` : ''; if (prunedWhole(result)) { @@ -353,8 +366,13 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? // releases wrote go first — and only those: a file a member added here // is theirs, and the old deployment never deleted it either. if (await pathExists(destDir)) { - const backupDir = skillBackupDir(baseDir, tool, path.relative(baseDir, destDir), skillName); - const result = await removeOwnedFiles(destDir, PACKAGED_SKILL_FILES.get(skillName) ?? [], backupDir); + // Only the paths this release no longer ships. `SKILL.md` is written + // one line below, so pruning it would archive an identical copy on + // every session start and never reach a file worth keeping. + const shippedNow = new Set(await walkFiles(srcDir)); + const retired = (PACKAGED_SKILL_FILES.get(skillName) ?? []).filter((p) => !shippedNow.has(p)); + const backupDir = skillBackupDir(tool, path.relative(baseDir, destDir), skillName); + const result = await removeOwnedFiles(destDir, retired, backupDir); if (result.unbackedUp.length > 0) { log.warn(`Kept ${result.unbackedUp.length} file(s) under ${destDir}: their backup could not be written, so they were not removed. First: ${result.unbackedUp[0].file} — ${result.unbackedUp[0].error}`); } diff --git a/src/skill-cmd.ts b/src/skill-cmd.ts index 885eef0a..7f989d9d 100644 --- a/src/skill-cmd.ts +++ b/src/skill-cmd.ts @@ -14,7 +14,7 @@ import { } from './agent-skills.js'; import { detectInstalledAgents, type ResolvedAgent } from './known-agents.js'; import { recallBlockMessage, resolveServableSkill, skillCatalog } from './skill-content.js'; -import type { GlobalOptions, LocalConfig } from './types.js'; +import type { GlobalOptions, LocalConfig, TeamaiConfig } from './types.js'; const DESCRIPTION_MAX = 160; @@ -47,7 +47,36 @@ type LocatedSkill = ResolvedSkill | BlockedSkill; * we print under "Repo path" or "Installed in". */ export async function skillShow(name: string, options: GlobalOptions): Promise<void> { - const { localConfig, teamConfig } = await autoDetectInit(); + // A packaged skill needs no team: it ships with the CLI. Resolving it first + // keeps `teamai skill show core` working on a machine that has never run + // `teamai init`, where autoDetectInit has nothing to find. + const packaged = await resolveServableSkill(name); + let init: { localConfig: LocalConfig; teamConfig: TeamaiConfig }; + try { + init = await autoDetectInit(); + } catch (e) { + if (packaged.kind === 'blocked') { + const { headline, hint } = recallBlockMessage(packaged.name); + log.error(headline); + log.dim(hint); + process.exitCode = 1; + return; + } + if (packaged.kind !== 'found') throw e; + printSkillCard({ + name: packaged.skill.name, + source: { kind: 'builtin' }, + description: truncate(await readSkillDescription(path.join(packaged.skill.dir, 'SKILL.md')), DESCRIPTION_MAX), + contributors: [], + tags: [], + primaryPath: packaged.skill.dir, + primaryOrigin: 'builtin', + installedIn: [], + }); + log.dim('No team is set up on this machine, so contributors, tags and installed agents are not shown.'); + return; + } + const { localConfig, teamConfig } = init; const agents = await detectInstalledAgents(localConfig, teamConfig); const located = await locateSkill(name, localConfig, agents); From d1e814562b763b5d641399b18964b5619d6434e2 Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Tue, 22 Sep 2026 16:12:11 +0200 Subject: [PATCH 22/37] fix(skills): stop at the first link above a skill dir, report a half prune MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from a self-review run before pushing, plus the two from the last review round. The symlink guard was one level too low. It lstat'd the skill directory, so the common shape — `~/.claude/skills` itself linked at a dotfiles checkout — walked straight through: every directory under the link is real. The guard now walks each component below the tool's base directory and stops at the first link, which covers the prune and the stub write with one check. Components at or above the base are not checked: a home directory under a link is ordinary, and refusing there would disable deployment on those machines. The symlink branch borrowed the foreign-files message, so a member was told "delete the rest yourself" about a directory nothing had touched. Following that destroys what the guard just protected. It has its own sentence now, in pull and in uninstall. `remove()` was not fail-closed the way the backup is: a read-only parent left the tree half-pruned under a debug line, and a `walkFiles` that threw returned success. Both are recorded in `notRemoved` and reported. The backup path gained the base directory: `inheritUserScope` deploys the user base and then the project base in one process, same tool, same root, same skill name, and `errorOnExist` turned that collision into files the second pass could neither archive nor prune. Docs corrected against the code: the archive path, the tag count (98, not 91, and `teamai-wiki` is excluded), the version line, and the size table. --- docs/designs/skill-serving.md | 34 ++++-- src/__tests__/skip-uninstalled-tools.test.ts | 42 ++++++- src/builtin-skills.ts | 111 +++++++++++++++---- src/uninstall.ts | 19 +++- 4 files changed, 169 insertions(+), 37 deletions(-) diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index 9dc246e0..eb11a1ba 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -1,6 +1,6 @@ # Serving built-in skill content from the CLI -Issue: [#678](https://github.com/Tencent/teamai-cli/issues/678). Shipped in 0.23.0. +Issue: [#678](https://github.com/Tencent/teamai-cli/issues/678). Unreleased; the package is at 0.22.0. ## The problem @@ -46,11 +46,13 @@ npm package What an agent reads, and when: ```text -session start stub frontmatter (description) ~0.9 KB always in context -task matches stub body ~1.3 KB holds the commands -`teamai skill get core` daily workflow ~5.7 KB on demand -`… core --full` + troubleshooting + commands.md ~32 KB on demand -`… setup` / `wiki` / `share` on demand +session start stub frontmatter (description) 875 B always in context +task matches stub body 1 408 B holds the commands +`teamai skill get core` daily workflow 6 383 B on demand +`… core --full` + commands.md, contribute-member, + troubleshooting 36 213 B on demand +`… setup` / `wiki` 5 528 B / 19 102 B on demand +`… setup --full` / `… wiki --full` 38 231 B / 132 754 B on demand ``` ## Contracts worth keeping @@ -132,23 +134,31 @@ member added beside them, so uninstall removes those same paths through `removeOwnedFiles` and keeps the rest, saying which directory it kept. Deleting the directory there would undo, one command over, the guarantee pull makes. +Pull's archive is deliberately not applied there: pull runs on an upgrade the +member did not ask anything to be removed by, while uninstall is them asking for +all of it to go. Leaving copies behind would be the thing they ran it to avoid. + **It removes only the files those releases packaged.** `PACKAGED_SKILL_FILES` -lists them, built as the union of `git ls-tree -r <tag> -- skills/` over every -tag, plus `references/provider-tgit.md`, which #724 put on `main` unreleased and -the next release therefore ships, so each path is provably the CLI's. Those files were overwritten with +lists them, built as the union of `git ls-tree -r <tag> -- skills/` over all 98 +tags, minus `teamai-wiki` (see below), plus `references/provider-tgit.md`, which +#724 put on `main` unreleased and the next release therefore ships. Every path +in it is provably the CLI's. Those files were overwritten with `overwrite: true` on every pull and no local edit ever survived in one; a file a member added beside them was never touched by the old deployment and is not ours to delete now. One case the overwrite never reached: a path a retired release shipped and the current package no longer does just sat there, so an edit to it did survive. Ownership is proven by pathname, not by contents, so the prune cannot tell that file from ours — it copies everything it removes to -`~/.teamai/removed-skills/<run>/<tool>/<skill-root>/<skill>/` first, and the +`~/.teamai/removed-skills/<run>/<base>/<tool>/<skill-root>/<skill>/` first, and the migration stops being a one-way door for any of them. That path is the machine's home, never the tool's base directory, which under project scope is the repo root. Only *retired* paths are archived: the stub is rewritten on every session start, so archiving it would file an identical copy per session forever. A -symlinked skill root is refused outright, link and target untouched: everything -under it matches our names, and none of it is ours. A file whose copy fails is +link anywhere between the tool's base directory and the skill directory is +refused outright — neither pruned nor written through, link and target +untouched: everything under it matches our names, and none of it is ours. The +`<base>` segment is there because `inheritUserScope` deploys the user base and +then the project base in one process, with the same tool, root and skill name. A file whose copy fails is kept rather than removed: a backup that did not happen must not authorise the delete. The path carries the run and the skill root because neither is unique on its own — two pulls land on the same day, and Codex prunes the same skill name diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index a3f5cdb9..d3a40ca5 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -42,7 +42,11 @@ async function onlyRunDir(homeDir: string): Promise<string> { const root = path.join(homeDir, '.teamai/removed-skills'); const runs = await fse.readdir(root); expect(runs).toHaveLength(1); - return path.join(root, runs[0]); + // Below the run comes the base directory the deploy targeted, keyed by a + // digest so two scopes in one process cannot land on the same path. + const bases = await fse.readdir(path.join(root, runs[0])); + expect(bases).toHaveLength(1); + return path.join(root, runs[0], bases[0]); } vi.mock('../config.js', () => ({ @@ -747,6 +751,42 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { expect(await fse.pathExists(path.join(homeDir, '.claude/skills/team-wiki-codebase'))).toBe(true); }); + it('stops at a link above the skill directory, not just at the skill directory', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + // The common shape: the member links their whole skills root at a dotfiles + // checkout. Every directory under it is real, so checking the leaf alone + // sees nothing and the walk deletes files in the checkout. + const dotfiles = path.join(tmpDir, 'dotfiles/skills'); + await fse.ensureDir(path.join(dotfiles, 'team-wiki-codebase')); + await fse.writeFile(path.join(dotfiles, 'team-wiki-codebase/SKILL.md'), '# theirs'); + + await fse.ensureDir(path.join(homeDir, '.claude')); + await fse.symlink(dotfiles, path.join(homeDir, '.claude/skills'), 'dir'); + + await deployBuiltinSkills(legacyPruneTeamConfig(), legacyPruneLocalConfig(tmpDir)); + + expect(await fse.readFile(path.join(dotfiles, 'team-wiki-codebase/SKILL.md'), 'utf8')).toBe('# theirs'); + expect(await fse.pathExists(path.join(dotfiles, 'teamai/SKILL.md'))).toBe(false); + }); + + it('does not write the stub through a symlinked destination', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + const outside = path.join(tmpDir, 'outside/teamai'); + await fse.ensureDir(outside); + await fse.writeFile(path.join(outside, 'SKILL.md'), '# not ours'); + + await fse.ensureDir(path.join(homeDir, '.claude/skills')); + await fse.symlink(outside, path.join(homeDir, '.claude/skills/teamai'), 'dir'); + + await deployBuiltinSkills(legacyPruneTeamConfig(), legacyPruneLocalConfig(tmpDir)); + + // The prune refuses to walk the link; the copy must refuse to write through + // it too, or the guarantee stops one line short of where it is claimed. + expect(await fse.readFile(path.join(outside, 'SKILL.md'), 'utf8')).toBe('# not ours'); + }); + it('archives nothing when there is nothing retired to archive', async () => { const { deployBuiltinSkills } = await import('../builtin-skills.js'); diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index 91679d73..240b62c7 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { createHash } from 'node:crypto'; import path from 'node:path'; import fse from 'fs-extra'; import { pathExists, remove } from './utils/fs.js'; @@ -71,10 +72,10 @@ export function isCliOwnedSkillName(name: string): boolean { /** * Every file a release ever packaged under `skills/`, by directory name. * - * Built as the union of `git ls-tree -r <tag> -- skills/` over all 91 tags, plus - * `references/provider-tgit.md`, which main carries unreleased and the next - * release therefore ships. Every path listed here is written by the CLI and is - * ours to remove. Deployment + * Built as the union of `git ls-tree -r <tag> -- skills/` over all 98 tags, + * minus `teamai-wiki` (below), plus `references/provider-tgit.md`, which main + * carries unreleased and the next release therefore ships. Every path listed + * here is written by the CLI and is ours to remove. Deployment * copied these trees with `overwrite: true` and never deleted anything, so a * file that is *not* listed here was put there by the member and survives. * @@ -158,39 +159,57 @@ async function removeEmptyDirs(dir: string): Promise<void> { * to write and is not ours to remove, whichever command is doing the removing. */ export interface PruneResult { - /** True when the root is a symlink, so nothing under it was read or removed. */ + /** True when a link sits between the base and the root: nothing was touched. */ skippedSymlink: boolean; /** Files left in place because the member, not the CLI, put them there. */ foreign: number; /** Files left in place because their backup could not be written, and why. */ unbackedUp: { file: string; error: string }[]; + /** Files archived but not deleted, leaving the tree half-pruned, and why. */ + notRemoved: { file: string; error: string }[]; /** Whether anything was actually copied, so a log only names a backup that exists. */ backedUp: number; } /** True when the directory is gone: nothing of the member's, nothing unsaved. */ export function prunedWhole(result: PruneResult): boolean { - return !result.skippedSymlink && result.foreign === 0 && result.unbackedUp.length === 0; + return !result.skippedSymlink + && result.foreign === 0 + && result.unbackedUp.length === 0 + && result.notRemoved.length === 0; } export async function removeOwnedFiles( dir: string, owned: readonly string[], backupDir?: string, + baseDir?: string, ): Promise<PruneResult> { const ownedPaths = new Set(owned); - const result: PruneResult = { skippedSymlink: false, foreign: 0, unbackedUp: [], backedUp: 0 }; + const result: PruneResult = { + skippedSymlink: false, foreign: 0, unbackedUp: [], notRemoved: [], backedUp: 0, + }; - // A symlinked skill root points at files we never wrote — a shared checkout, - // a dotfiles repo. `readdir` follows it and every path under it would match - // ours by name, so the walk would delete someone else's files through the - // link. Ownership stops at the link. - if ((await fs.promises.lstat(dir)).isSymbolicLink()) { + // A link anywhere between the base directory and this one points at files we + // never wrote — a shared checkout, a dotfiles repo. `readdir` follows it and + // every path under it matches ours by name, so the walk would delete someone + // else's files through the link. Ownership stops at the first link. + if (baseDir ? await crossesSymlink(baseDir, dir) : (await fs.promises.lstat(dir)).isSymbolicLink()) { result.skippedSymlink = true; return result; } - for (const relative of await walkFiles(dir)) { + let entries: string[]; + try { + entries = await walkFiles(dir); + } catch (e) { + // An unreadable subdirectory. Reporting it is the point: a silent return + // leaves the tree in place while `pull` says it succeeded. + result.notRemoved.push({ file: dir, error: (e as Error).message }); + return result; + } + + for (const relative of entries) { if (!ownedPaths.has(relative) && !isDerivedArtifact(relative)) { result.foreign++; continue; @@ -216,13 +235,44 @@ export async function removeOwnedFiles( continue; } } - await remove(file); + try { + await remove(file); + } catch (e) { + // A read-only parent. The archive holds the copy, but the original stays, + // so the tree is half-pruned: say so rather than let a debug line carry it. + result.notRemoved.push({ file, error: (e as Error).message }); + } } await removeEmptyDirs(dir); return result; } +/** + * True when any path component between `baseDir` and `target` is a symlink. + * + * Checking `target` alone is not enough: a member who links `~/.claude/skills` + * at a dotfiles checkout leaves every skill directory under it a real + * directory, so `lstat` on one says nothing. Components at or above `baseDir` + * are not checked — a home directory that itself sits under a link is ordinary, + * and refusing there would disable deployment for those machines. + */ +async function crossesSymlink(baseDir: string, target: string): Promise<boolean> { + const relative = path.relative(baseDir, target); + if (relative.startsWith('..') || path.isAbsolute(relative)) return true; + + let walked = baseDir; + for (const segment of relative.split(path.sep).filter(Boolean)) { + walked = path.join(walked, segment); + try { + if ((await fs.promises.lstat(walked)).isSymbolicLink()) return true; + } catch { + return false; // does not exist yet: nothing to walk through + } + } + return false; +} + /** * One backup root per process run. A date alone collides: two pulls on the same * day would have the second overwrite the first's copies. @@ -237,11 +287,16 @@ const PRUNE_RUN_ID = `${new Date().toISOString().replace(/[:.]/g, '-')}-${proces * name from two roots — Codex reads `.codex/skills` and the shared * `.agents/skills` — and those two copies are different files. */ -function skillBackupDir(tool: string, skillRoot: string, skillName: string): string { +function skillBackupDir(baseDir: string, tool: string, skillRoot: string, skillName: string): string { const rootSlug = skillRoot.replace(/[\\/:]+/g, '-').replace(/^-+/, ''); + // `inheritUserScope` deploys to the user base and then the project base in one + // process, with the same tool, root and skill name. Without the base in the + // path the second pass collides with the first, and `errorOnExist` turns that + // into files it can neither archive nor prune. + const baseSlug = `${path.basename(baseDir) || 'root'}-${createHash('sha256').update(baseDir).digest('hex').slice(0, 8)}`; // Machine data, not project data: under project scope `baseDir` is the repo // root, where a backup per session start would show up as a dirty tree. - return path.join(getUserHome(), '.teamai', 'removed-skills', PRUNE_RUN_ID, tool, rootSlug, skillName); + return path.join(getUserHome(), '.teamai', 'removed-skills', PRUNE_RUN_ID, baseSlug, tool, rootSlug, skillName); } /** @@ -268,13 +323,19 @@ export async function pruneLegacyBuiltinSkills( const dir = path.join(baseDir, root, legacyName); if (!await pathExists(dir)) continue; try { - const backupDir = skillBackupDir(tool, root, legacyName); - const result = await removeOwnedFiles(dir, PACKAGED_SKILL_FILES.get(legacyName) ?? [], backupDir); + const backupDir = skillBackupDir(baseDir, tool, root, legacyName); + const result = await removeOwnedFiles(dir, PACKAGED_SKILL_FILES.get(legacyName) ?? [], backupDir, baseDir); const saved = result.backedUp > 0 ? `; a copy is in ${backupDir}` : ''; if (prunedWhole(result)) { log.debug(`Removed legacy built-in skill ${legacyName} from ${tool} (${dir})${saved}`); + } else if (result.skippedSymlink) { + // Never the "delete the rest yourself" sentence here: following it + // would destroy exactly what the guard just protected. + log.warn(`Skipped "${legacyName}" (${tool}): ${dir} is reached through a symlink, so TeamAI left it alone. Nothing was read, copied or removed.`); } else if (result.unbackedUp.length > 0) { log.warn(`Kept "${legacyName}" (${tool}): ${result.unbackedUp.length} file(s) in ${dir} could not be backed up, so they were not removed. First: ${result.unbackedUp[0].file} — ${result.unbackedUp[0].error}`); + } else if (result.notRemoved.length > 0) { + log.warn(`Partly removed "${legacyName}" (${tool}): ${result.notRemoved.length} file(s) in ${dir} were archived but could not be deleted. First: ${result.notRemoved[0].file} — ${result.notRemoved[0].error}`); } else { log.warn(`Kept "${legacyName}" (${tool}): ${dir} holds files TeamAI did not put there. The packaged files were removed${saved}; delete the rest yourself once you have saved what you need.`); } @@ -360,6 +421,13 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? const destDir = await resolveSkillDestination(tool, toolPath.skills, baseDir, skillName, srcDir); try { + // A symlinked destination points somewhere we do not own. Writing + // through it would put the stub outside the agent directory, which is + // the same reason the prune refuses to walk it. Neither step runs. + if (await crossesSymlink(baseDir, destDir)) { + log.warn(`Skipped ${skillName} (${tool}): ${destDir} is reached through a symlink, and TeamAI does not write through one. Remove the link to let the skill deploy.`); + continue; + } // Releases before the discovery stub deployed this same directory with a // references/ tree beside SKILL.md. Copying one file over it would leave // ~39 KB of pre-stub instructions in place forever, so the files those @@ -371,11 +439,14 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? // every session start and never reach a file worth keeping. const shippedNow = new Set(await walkFiles(srcDir)); const retired = (PACKAGED_SKILL_FILES.get(skillName) ?? []).filter((p) => !shippedNow.has(p)); - const backupDir = skillBackupDir(tool, path.relative(baseDir, destDir), skillName); - const result = await removeOwnedFiles(destDir, retired, backupDir); + const backupDir = skillBackupDir(baseDir, tool, path.relative(baseDir, destDir), skillName); + const result = await removeOwnedFiles(destDir, retired, backupDir, baseDir); if (result.unbackedUp.length > 0) { log.warn(`Kept ${result.unbackedUp.length} file(s) under ${destDir}: their backup could not be written, so they were not removed. First: ${result.unbackedUp[0].file} — ${result.unbackedUp[0].error}`); } + if (result.notRemoved.length > 0) { + log.warn(`Archived but could not delete ${result.notRemoved.length} file(s) under ${destDir}. First: ${result.notRemoved[0].file} — ${result.notRemoved[0].error}`); + } } await fse.ensureDir(destDir); await fse.copy(path.join(srcDir, 'SKILL.md'), path.join(destDir, 'SKILL.md'), { overwrite: true }); diff --git a/src/uninstall.ts b/src/uninstall.ts index 0d560e60..6c294217 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -855,16 +855,22 @@ async function executeRemoval(plan: RemovalPlan): Promise<void> { // A team-repo skill is synced whole, so the whole directory goes. A CLI-owned // one is not: deployment writes only the files in PACKAGED_SKILL_FILES and // never touched a file a member added beside them, so uninstall removes those - // same paths and keeps the rest — the same rule pull applies, for the same - // reason. Deleting the directory here would undo the guarantee one command over. + // same paths and keeps the rest — the same ownership rule pull applies. + // Deleting the directory here would undo the guarantee one command over. + // + // Pull's archive is deliberately not applied: there the member is upgrading + // and did not ask for anything to go, here they asked for all of it. Leaving + // copies behind would be the thing they ran the command to avoid. let removedSkillDirs = 0; const keptSkillDirs: string[] = []; + const linkedSkillDirs: string[] = []; for (const skillDir of plan.skillDirs) { try { const name = path.basename(skillDir); if (isCliOwnedSkillName(name)) { - const removedWhole = prunedWhole(await removeOwnedFiles(skillDir, PACKAGED_SKILL_FILES.get(name) ?? [])); - if (removedWhole) removedSkillDirs++; + const result = await removeOwnedFiles(skillDir, PACKAGED_SKILL_FILES.get(name) ?? []); + if (prunedWhole(result)) removedSkillDirs++; + else if (result.skippedSymlink) linkedSkillDirs.push(skillDir); else keptSkillDirs.push(skillDir); } else { await remove(skillDir); @@ -880,6 +886,11 @@ async function executeRemoval(plan: RemovalPlan): Promise<void> { for (const skillDir of keptSkillDirs) { log.warn(`Kept ${skillDir}: it holds files TeamAI did not put there. The packaged files were removed; delete the rest yourself once you have saved what you need.`); } + // A different reason, so a different sentence: nothing here was touched, and + // "delete the rest yourself" would send the member into the link target. + for (const skillDir of linkedSkillDirs) { + log.warn(`Kept ${skillDir}: it is a symlink, so TeamAI left it and whatever it points at alone.`); + } // (d) Remove synced rules for (const ruleFile of plan.ruleFiles) { From 12cf110a7d95be261cffd913e9aef7f4b4a15c70 Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Tue, 22 Sep 2026 17:24:20 +0200 Subject: [PATCH 23/37] fix(skills): route the nudge and skill publishing where they land, classify legacy names as ours The Stop-hook hint said "run /teamai", but bare /teamai prints the menu and stops, so following the primary suggestion never reached the share workflow. It now names an invocation the core skill routes to share, with the `teamai skill get share` fallback kept. The four docs that quote the hint follow. The setup skill sent "publish one skill" to `teamai skill get share`, which handles session learnings and is refused when recall is off (the default); reusable-skill publishing lives in core's contribute-member reference and needs no recall. The routing row and the two references that repeated it now point there. classifySkill checked BUILTIN_SKILL_NAMES alone, so until the first pull pruned them, team-wiki-codebase and teamai-share-learnings showed as [local-only]. It now uses isCliOwnedSkillName, the rule push and uninstall already apply. --- docs/product-overview.md | 2 +- docs/product-overview.zh-CN.md | 2 +- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/setup/SKILL.md | 2 +- skill-data/setup/references/join-member.md | 3 ++- skill-data/setup/references/manage-admin.md | 2 +- src/__tests__/agent-skills.test.ts | 7 +++++++ src/agent-skills.ts | 6 ++++-- src/contribute-check.ts | 4 ++-- 10 files changed, 21 insertions(+), 11 deletions(-) diff --git a/docs/product-overview.md b/docs/product-overview.md index 5b4fb1ae..687dd6e6 100644 --- a/docs/product-overview.md +++ b/docs/product-overview.md @@ -115,7 +115,7 @@ When a session ends, the Stop hook scores it by **friction** — signals that th Task: Fix duplicate project-level Hook injection -Consider running /teamai to summarize what you learned and share it with your team (or run `teamai skill get share`). +Consider running `/teamai share what this session taught me` to summarize what you learned and share it with your team (or run `teamai skill get share`). ``` The hint names the non-zero friction signals that triggered it and, when available, includes a redacted, single-line summary of the first task. The `share` workflow (`teamai skill get share`) summarizes the session and pushes a learning document directly to the team repo. Each session is prompted at most once. Teams can switch the hint off with `sharing.contributeHint.enabled: false` in `teamai.yaml` (members: `contributeHintEnabled` in local config) while keeping the rest of the Stop hook. diff --git a/docs/product-overview.zh-CN.md b/docs/product-overview.zh-CN.md index ac4fc187..ebfff162 100644 --- a/docs/product-overview.zh-CN.md +++ b/docs/product-overview.zh-CN.md @@ -115,7 +115,7 @@ Session 结束时,Stop hook 按**摩擦信号**对 session 评分——这些 Task: Fix duplicate project-level Hook injection -Consider running /teamai to summarize what you learned and share it with your team (or run `teamai skill get share`). +Consider running `/teamai share what this session taught me` to summarize what you learned and share it with your team (or run `teamai skill get share`). ``` 提示会列出实际触发它的非零摩擦信号;如果能取得首个任务摘要,还会在脱敏、单行化后附上任务上下文。`share` 工作流(`teamai skill get share`)自动总结 session 经验并推送到团队仓库。每个 session 最多提示一次。团队可在 `teamai.yaml` 设置 `sharing.contributeHint.enabled: false` 关闭该提示(成员可用本地配置 `contributeHintEnabled` 覆盖),Stop hook 的其余功能不受影响。 diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 2acd9501..df479089 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -912,7 +912,7 @@ The AI tracks your coding sessions via Hooks. When a session ends (the Stop hook Task: Fix duplicate project-level Hook injection -Consider running /teamai to summarize what you learned and share it with your team (or run `teamai skill get share`). +Consider running `/teamai share what this session taught me` to summarize what you learned and share it with your team (or run `teamai skill get share`). ``` The reminder lists the non-zero friction signals that triggered it. When the first task is available, it also includes a redacted, single-line task summary so you can decide whether the session is worth sharing. Using the built-in `share` workflow (`teamai skill get share`), the AI will automatically summarize the session's learnings and contribute them to the team knowledge base. Each session is prompted at most once. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 440ff1bd..cee03ba3 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -881,7 +881,7 @@ AI 通过 Hooks 追踪你的编码会话。当会话结束时(Stop hook), Task: Fix duplicate project-level Hook injection -Consider running /teamai to summarize what you learned and share it with your team (or run `teamai skill get share`). +Consider running `/teamai share what this session taught me` to summarize what you learned and share it with your team (or run `teamai skill get share`). ``` 提醒会列出实际触发它的非零摩擦信号;如果能取得首个任务,还会附上脱敏、单行化后的任务摘要,便于判断本次 session 是否值得分享。使用内置 skill `/teamai`,AI 会自动总结本次 session 经验并贡献到团队知识库。每个 session 最多提示一次。 diff --git a/skill-data/setup/SKILL.md b/skill-data/setup/SKILL.md index 01721191..90e293fa 100644 --- a/skill-data/setup/SKILL.md +++ b/skill-data/setup/SKILL.md @@ -30,7 +30,7 @@ is missing. | Join their team, with or without a repo URL | `{SKILL_DIR}/references/join-member.md` | | Publish or update skills, rules, MCP, env; invite members; manage roles | `{SKILL_DIR}/references/manage-admin.md` | | Remove TeamAI from this machine | `{SKILL_DIR}/references/uninstall.md` | -| Publish one skill or contribute a doc | `teamai skill get share` | +| Publish one skill or contribute a doc | `teamai skill get core --full` (contribute-member) | | Anything that breaks along the way | `teamai skill get core --full` (troubleshooting) | Supported Git providers are Tencent TGit (工蜂), GitHub, GitLab and CNB; diff --git a/skill-data/setup/references/join-member.md b/skill-data/setup/references/join-member.md index 1d708819..ec188ce9 100644 --- a/skill-data/setup/references/join-member.md +++ b/skill-data/setup/references/join-member.md @@ -145,7 +145,8 @@ Summarize the outcome **in the user's own language** (global rule 1). Cover: 3. **They can also contribute a skill — just ask in plain language.** A member does not need to be an admin to publish a skill. They tell TeamAI something like *"share this xxx skill with my team"* / *"把这个 xxx skill 分享给团队"*, and you - run the publish for them (see the share skill, `teamai skill get share --full`). + run the publish for them (see `references/contribute-member.md` in + `teamai skill get core --full`; it needs no recall). 4. **How to leave — via the skill, not raw commands.** They can remove TeamAI any time by re-invoking the skill; you'll run it for them: `/teamai 卸载` / `/teamai Uninstall TeamAI`. diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index 241a643a..3758ec02 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -124,7 +124,7 @@ worth sharing, TeamAI prompts the member and the dedicated `share` workflow (`teamai skill get share`) summarizes the session and runs `teamai contribute`. Nobody has to invoke it by hand. (Publishing a **reusable skill** someone authored is a different task — any member -can do it, see the share skill, `teamai skill get share --full`.) +can do it, see `references/contribute-member.md` in `teamai skill get core --full`.) ### Turn the sharing prompt on or off (admin) diff --git a/src/__tests__/agent-skills.test.ts b/src/__tests__/agent-skills.test.ts index baef666a..4c79c2b4 100644 --- a/src/__tests__/agent-skills.test.ts +++ b/src/__tests__/agent-skills.test.ts @@ -174,6 +174,13 @@ describe('classifySkill', () => { expect(formatSkillSource(cls)).toBe('[source:partner]'); }); + it('returns [builtin] for the names a pre-stub release deployed, until pull prunes them', async () => { + const ctx = await buildClassifyContext(fx.localConfig); + for (const legacy of ['team-wiki-codebase', 'teamai-share-learnings']) { + expect(classifySkill(legacy, ctx).kind).toBe('builtin'); + } + }); + it('returns [local-only] when skill is unknown to repo, sources and builtins', async () => { const ctx = await buildClassifyContext(fx.localConfig); const cls = classifySkill('only-local', ctx); diff --git a/src/agent-skills.ts b/src/agent-skills.ts index 71620dc5..b3bfa07a 100644 --- a/src/agent-skills.ts +++ b/src/agent-skills.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { listDirs, pathExists, readFileSafe } from './utils/fs.js'; import { detectInstalledAgents, type ResolvedAgent } from './known-agents.js'; -import { BUILTIN_SKILL_NAMES } from './builtin-skills.js'; +import { isCliOwnedSkillName } from './builtin-skills.js'; import type { LocalConfig, TeamaiConfig } from './types.js'; import { getUserHome } from './utils/home.js'; import { parseFrontmatter } from './utils/frontmatter.js'; @@ -107,7 +107,9 @@ async function collectTeamRepoSkills(repoPath: string): Promise<Map<string, { na /** Resolve a skill name to its source tag using the prebuilt context. */ export function classifySkill(name: string, ctx: ClassifyContext): SkillSource { - if (BUILTIN_SKILL_NAMES.has(name)) return { kind: 'builtin' }; + // Same rule as the push scan and uninstall: a name a pre-stub release deployed + // is ours until the next pull prunes it, not a member's local-only skill. + if (isCliOwnedSkillName(name)) return { kind: 'builtin' }; if (ctx.teamSkills.has(name)) { return { kind: 'team', namespace: ctx.teamSkills.get(name)?.namespace }; } diff --git a/src/contribute-check.ts b/src/contribute-check.ts index b6a8fbd9..1af35b69 100644 --- a/src/contribute-check.ts +++ b/src/contribute-check.ts @@ -493,8 +493,8 @@ function buildHint({ friction, promptSummary, isKnowledgeGap }: HintContext): st } const task = promptSummary ? `\n\nTask: ${promptSummary}` : ''; const action = isKnowledgeGap - ? 'Consider running /teamai to summarize what you learned, share it with your team, and fill the knowledge gap (or run `teamai skill get share`).' - : 'Consider running /teamai to summarize what you learned and share it with your team (or run `teamai skill get share`).'; + ? 'Consider running `/teamai share what this session taught me` to summarize what you learned, share it with your team, and fill the knowledge gap (or run `teamai skill get share`).' + : 'Consider running `/teamai share what this session taught me` to summarize what you learned and share it with your team (or run `teamai skill get share`).'; return `${headline}${task}\n\n${action}`; } From dda2e3729e6debf48850f4a566903dd36f2e0112 Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Tue, 22 Sep 2026 17:48:41 +0200 Subject: [PATCH 24/37] =?UTF-8?q?fix(skills):=20pre-push=20review=20?= =?UTF-8?q?=E2=80=94=20gate=20the=20nudge=20on=20recall,=20keep=20bytecode?= =?UTF-8?q?=20out=20of=20the=20tarball,=20report=20a=20failed=20uninstall?= =?UTF-8?q?=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review of the whole branch against #678, #730 and the design doc, run before pushing. What it found and what changed: - The Stop-hook share reminder was gated on the hint switch alone; recall is off by default and `teamai skill get share` refuses then, so the reminder pointed at a command that said no. It is withheld while recall is off, the same gate the workflow has; the served text about when the prompt appears now matches. - `npm pack` swept `skill-data/wiki/scripts/__pycache__` into the tarball once the e2e suite had run the scripts. Excluded in package.json "files", asserted absent in the tarball test, and the e2e run sets PYTHONDONTWRITEBYTECODE. - The `share` description still offered to publish reusable skills, the flow its own body sends to `core`; the sentence is gone. - `skill show <unknown>` before `teamai init` threw the init error as a stack trace; it prints the not-found line and exits 1. - The stub pre-approved every `teamai` command from the always-loaded unit; narrowed to `Bash(teamai skill:*)`, which is all it asks for (#678). - Six routing lines loaded `core --full` to reach one reference; they name the file under `$(teamai skill path core)/references/` instead. - `uninstall` reported a failed delete as "holds files TeamAI did not put there; the packaged files were removed", both false and the error unprinted. It names the file and the error; a test makes the stub directory read-only. - The stub directory archived under `<tool>/.claude-skills-teamai/teamai/` while the legacy trees used `<tool>/.claude-skills/<skill>/`; one layout now. - CHANGELOG entry; dead `isRecallEnabled` import; wiki heading still naming `team-wiki-codebase`; JSDoc on the wrong declaration; stale byte counts; the usage guides gain the recall refusal and the archive location; the design doc records the `--json` deviation, the legacy-name classification rule, the uninstall symlink scope and the fail-open wording. --- CHANGELOG.md | 2 ++ docs/designs/skill-serving.md | 30 ++++++++++++++----- docs/usage-guide.md | 8 ++++- docs/usage-guide.zh-CN.md | 9 ++++-- package.json | 2 ++ skill-data/setup/SKILL.md | 4 +-- skill-data/setup/references/join-member.md | 13 ++++---- skill-data/setup/references/manage-admin.md | 7 +++-- skill-data/setup/references/setup-admin.md | 4 +-- skill-data/share/SKILL.md | 9 +++--- skill-data/wiki/SKILL.md | 4 +-- .../references/agents/kb-doc-generator.md | 2 +- skill-data/wiki/references/overview.md | 2 +- skills/teamai/SKILL.md | 2 +- src/__tests__/e2e/skill-serving-cli.test.ts | 16 +++++++++- src/__tests__/hook-handlers.test.ts | 22 ++++++++++++-- src/__tests__/skill-content.test.ts | 7 ++++- src/__tests__/uninstall.test.ts | 26 ++++++++++++++++ src/builtin-skills.ts | 27 ++++++++++------- src/hook-handlers.ts | 8 +++-- src/init.ts | 1 - src/recall-toggle.ts | 1 - src/skill-cmd.ts | 7 ++++- src/skill-content.ts | 5 +++- src/uninstall.ts | 7 +++++ 25 files changed, 171 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0b7d2e3..91f959f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to this project will be documented in this file. See [standa ### ✨ Features +- Built-in skill content ships inside the npm package and is printed by the installed CLI: `teamai skill get <core|setup|wiki|share> [--full] [--all]`, `teamai skill path <name>` for the directory holding a skill's scripts, and `teamai skill list --json` for the catalog. Agents receive one file, `skills/teamai/SKILL.md`, a discovery stub that points at those commands, so what an agent reads always matches the CLI version it is running. `teamai pull` removes the `team-wiki-codebase`, `teamai-share-learnings` and `teamai/references/*.md` trees earlier releases copied into every agent directory, archiving each removed file under `~/.teamai/removed-skills/<run>/…` first and keeping any directory that holds a member's own file; `teamai uninstall` removes only the packaged files from CLI-owned skill directories by the same rule. `share` is served only while recall is on for the team, and the end-of-session share reminder is withheld until then too. The legacy names still resolve as aliases (for [#678](https://github.com/Tencent/teamai-cli/issues/678), [#730](https://github.com/Tencent/teamai-cli/issues/730)). + - Hooks, MCP servers and env variables can be scoped by logical project, the second membership axis they lacked. A `hooks/hooks.yaml` hook and an `mcp/mcp.yaml` server accept an optional `projects:` list beside `roles:`, and an `env/env.yaml` variable accepts both. An entry reaches a member when one of the projects its directory is bound to (`teamai projects set`) is listed; `projects: []` reaches nobody, and a directory bound to no project keeps receiving every entry, so nothing changes until a maintainer adds the key. The two axes compose as AND, the way `tools:` and `roles:` already do, so `roles: [frontend] projects: [checkout]` reaches frontend members of checkout rather than everyone on either. Rebinding with `teamai projects set` removes the previous project's entries on the next pull — for env that means the variable leaves `env.sh`, also on a pull that finds the team repo unchanged, so a machine upgrading from a CLI that ignored the keys drops a withheld variable without `--force`, and a refresh that cannot be written there is reported with the path and the way out rather than passing silently under `Already synced`; `teamai doctor` applies the same filter, so a variable correctly withheld is not reported as undelivered, while one that `env.sh` still exports after a rebind is reported until the next pull rewrites the file. An id that `manifest/projects.yaml` does not define produces one warning per pull, and so does a `projects:` key in a team with no projects manifest: the key still filters against the ids in the directory's `config.yaml`, but nothing can validate them. `teamai mcp list`, `teamai hooks list` and `teamai env list` show the restriction, and `pull` reports `Synced 1 of 3 env variable(s)` when scoping withheld some. This is what the keys exist to control: a team with five projects and three MCP servers each gave every member of a role fifteen server processes and fifteen tool lists in the context of every session (for [#668](https://github.com/Tencent/teamai-cli/issues/668)). - `teamai doctor` now checks what landed for every resource, not only skills and docs. `Rules delivered to <tool>` and `Agents delivered to <tool>` ask the resource handler where an item lands — a rule's filename and content change per tool, an agent's destination comes from its render and its `targets:` — and compare a delivered rule with the bytes the handler renders for that tool, so a `.mdc` whose `globs` drifted from the team rule's `paths:` is reported rather than passing on the presence of its frontmatter keys. An agent is compared with the bytes its render produces, so a copy left behind by an older spec is reported rather than counted as delivered. `Every team agent reaches a tool` names an agent that renders for no installed tool, and is reported whenever a tool is installed to receive agents, including when no agent renders anywhere. Two tools do not read a rules directory and get a check each: `Team rules are active in opencode` fails when `opencode.json` stops listing the glob that makes the delivered `.md` files load at all, and `Team rules are inlined in Hermes SOUL.md` compares the managed block of `SOUL.md` with what the team rules inline to. `MCP servers delivered to <tool>` compares each server the team resolves for a tool with the entry in that tool's own config — the entry, not the name, since reconciliation leaves an entry teamai does not own alone, so an unrelated server under a team name holds the key while the team's definition never arrives — and names any the reconcile skipped with its reason, so an unresolved `${VAR}` is reported with the variable instead of being mentioned once during a pull and never again. An `mcp.yaml` that does not parse is reported as `Team MCP servers can be read` rather than read as a team shipping no MCP at all. `Env variables injected in shell profile` stops at the marker comment no longer: it checks that `env/env.yaml` parses and declares its variables under `variables:` (an explicit `variables: []` is an empty configuration and fails nothing), that each reached `env.sh` with the declared value — read back through the generator's own inverse, so a multiline value quoted across several lines is matched rather than reported stale — and that the injected block would actually load it. The two expensive registries, rules and agents, are built for `teamai doctor` only, so the checks at the end of a pull keep their budget (for [#624](https://github.com/Tencent/teamai-cli/issues/624)). diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index eb11a1ba..729c7722 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -51,8 +51,8 @@ task matches stub body 1 408 B holds the c `teamai skill get core` daily workflow 6 383 B on demand `… core --full` + commands.md, contribute-member, troubleshooting 36 213 B on demand -`… setup` / `wiki` 5 528 B / 19 102 B on demand -`… setup --full` / `… wiki --full` 38 231 B / 132 754 B on demand +`… setup` / `wiki` 5 553 B / 19 088 B on demand +`… setup --full` / `… wiki --full` 38 571 B / 132 722 B on demand ``` ## Contracts worth keeping @@ -76,7 +76,10 @@ task matches stub body 1 408 B holds the c `skill get <name>` refuses, `skill get --all` leaves the skill out and says so on stderr, `skill path <name>` and `skill show <name>` refuse, and `skill list --json` reports `blockedByRecall: true` with `path: null`. With no - team config to consult it fails open. The gate lives in one place: + team config to consult — or one it cannot load — it fails open: a refusal the + member cannot act on is worse than serving the workflow. The Stop-hook share + reminder is gated the same way (`contributeHintAllowed`, `src/hook-handlers.ts`), + because it points at this command. The gate lives in one place: `resolveServableSkill` (`src/skill-content.ts`) is the only way to obtain a packaged skill outside that module, and it returns `blocked` instead of the skill, so a command cannot print a directory it never received. @@ -88,7 +91,15 @@ task matches stub body 1 408 B holds the c team repo, then the installed agents, then the package. `codebase`, `default`, `learning` and `share` are ordinary names: a directory a member created under one of them is the skill they are asking about, and the recall gate does not - apply to it. + apply to it. The two legacy directory names are the exception, by design: + `team-wiki-codebase` and `teamai-share-learnings` classify as `[builtin]` and + are skipped by the push scan by name alone (`isCliOwnedSkillName`), because a + tree with that name is one a pre-stub release wrote until the first pull has + pruned it. That rule retires with `LEGACY_BUILTIN_SKILL_NAMES`. +- **`skill get` has no `--json`.** #678 sketched one; the content is markdown for + an agent to read, and the machine-readable half is `skill list --json`. An + unknown flag on `skill get`, `--json` included, is warned about on stderr and + ignored, so the content still arrives. - **`skill list` needs no team.** The human-readable listing prints the packaged catalog even before `teamai init`, with a hint for the team half, so a fresh machine can discover what the installed CLI serves the way `skill get` lets it. @@ -156,7 +167,9 @@ root. Only *retired* paths are archived: the stub is rewritten on every session start, so archiving it would file an identical copy per session forever. A link anywhere between the tool's base directory and the skill directory is refused outright — neither pruned nor written through, link and target -untouched: everything under it matches our names, and none of it is ours. The +untouched: everything under it matches our names, and none of it is ours. That +walk-up check is pull's; `uninstall` has only the skill directory in hand and +checks that one, which is enough for the member who asked for the removal. The `<base>` segment is there because `inheritUserScope` deploys the user base and then the project base in one process, with the same tool, root and skill name. A file whose copy fails is kept rather than removed: a backup that did not happen must not authorise the @@ -185,5 +198,8 @@ and can be dropped on the same schedule. `/teamai-share-learnings` was never a deployed slash command in its own right — it existed because the directory was installed. The Stop-hook nudge now names -`/teamai` and carries `teamai skill get share` literally, so an agent can act on -it even without inferring the intent. +`/teamai share what this session taught me`, an invocation the core skill routes +to `share` (bare `/teamai` prints the menu and stops), and carries +`teamai skill get share` literally, so an agent can act on it even without +inferring the intent. It is withheld while recall is off, because that command +refuses then. diff --git a/docs/usage-guide.md b/docs/usage-guide.md index df479089..4304a4d9 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -472,7 +472,11 @@ always matches the CLI version it is running — `npm i -g teamai-cli@latest` is update, with no pull needed for the content to be current. Agents receive a single file from the CLI, `~/.<tool>/skills/teamai/SKILL.md`, a small discovery stub that points at those commands. Older releases copied the whole tree into every agent directory, where it -went stale between pulls; `teamai pull` removes those leftovers. The legacy names still +went stale between pulls; `teamai pull` removes those leftovers, keeping a copy of every +removed file under `~/.teamai/removed-skills/`, one directory per pull, so an edit you made +to one of them is not lost. A directory that also holds a file of your own is kept and named +in the pull output. `share` is served only while recall is on for the team: until +`teamai recall enable`, `teamai skill get share` refuses and says so. The legacy names still resolve: `teamai skill get team-wiki-codebase` serves `wiki`. --- @@ -938,6 +942,8 @@ Teams that route knowledge sharing through their own review flow (for example, a Only the nudge is affected: friction scoring, `teamai contribute --file`, and `/teamai` keep working when invoked manually. +The reminder is also withheld while recall is off for the team (the default until `teamai recall enable`): it points at the `share` workflow, and `teamai skill get share` refuses until recall is on. + ### Searching knowledge ```bash diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index cee03ba3..1f9ff93e 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -447,7 +447,10 @@ teamai skill path wiki # 打印打包目录,用于运行 skill 按需打印,因此 agent 读到的内容始终与正在运行的 CLI 版本一致——`npm i -g teamai-cli@latest` 本身就是更新, 无需 `teamai pull` 内容就是最新的。每个 agent 只收到一个文件:`~/.<tool>/skills/teamai/SKILL.md`, 一个指向这些命令的小型发现入口(stub)。旧版本会把整棵目录复制到每个 agent 下,两次 pull 之间内容会过时; -`teamai pull` 会清除这些残留。旧名字仍然可用:`teamai skill get team-wiki-codebase` 等价于 `wiki`。 +`teamai pull` 会清除这些残留,并把每个被删除的文件先复制到 `~/.teamai/removed-skills/` 下(每次 pull 一个目录), +你对其中文件的修改不会丢失;目录里若还有你自己的文件,则整个目录保留并在 pull 输出中点名。`share` 只在团队开启 +recall 后才会提供:`teamai recall enable` 之前,`teamai skill get share` 会拒绝并说明原因。旧名字仍然可用: +`teamai skill get team-wiki-codebase` 等价于 `wiki`。 --- @@ -884,7 +887,7 @@ Task: Fix duplicate project-level Hook injection Consider running `/teamai share what this session taught me` to summarize what you learned and share it with your team (or run `teamai skill get share`). ``` -提醒会列出实际触发它的非零摩擦信号;如果能取得首个任务,还会附上脱敏、单行化后的任务摘要,便于判断本次 session 是否值得分享。使用内置 skill `/teamai`,AI 会自动总结本次 session 经验并贡献到团队知识库。每个 session 最多提示一次。 +提醒会列出实际触发它的非零摩擦信号;如果能取得首个任务,还会附上脱敏、单行化后的任务摘要,便于判断本次 session 是否值得分享。使用内置 `share` 工作流(`teamai skill get share`),AI 会自动总结本次 session 经验并贡献到团队知识库。每个 session 最多提示一次。 在 Codex 系列(`codex`、`codex-internal`、`tcodex`)中,Stop hook 会暂存贡献和知识引用提醒,在同一会话的下一次 UserPromptSubmit 交付,不会强制开启额外一轮。贡献提醒只交付一次;若下一次输入前已经贡献,则丢弃该提醒。 @@ -907,6 +910,8 @@ teamai contribute --file /tmp/session.md --scope project 只影响提醒本身:摩擦评分、`teamai contribute --file` 和手动调用 `/teamai` 不受影响。 +团队未开启 recall 时(默认关闭,`teamai recall enable` 开启)也不会显示这条提醒:提醒指向 `share` 工作流,而 recall 关闭时 `teamai skill get share` 会拒绝执行。 + ### 搜索知识 ```bash diff --git a/package.json b/package.json index 73f4c37f..c05d9168 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "dist/**/*.js", "skills", "skill-data", + "!**/__pycache__", + "!**/*.pyc", "agents", "README.md", "CHANGELOG.md", diff --git a/skill-data/setup/SKILL.md b/skill-data/setup/SKILL.md index 90e293fa..a5a7a58c 100644 --- a/skill-data/setup/SKILL.md +++ b/skill-data/setup/SKILL.md @@ -30,8 +30,8 @@ is missing. | Join their team, with or without a repo URL | `{SKILL_DIR}/references/join-member.md` | | Publish or update skills, rules, MCP, env; invite members; manage roles | `{SKILL_DIR}/references/manage-admin.md` | | Remove TeamAI from this machine | `{SKILL_DIR}/references/uninstall.md` | -| Publish one skill or contribute a doc | `teamai skill get core --full` (contribute-member) | -| Anything that breaks along the way | `teamai skill get core --full` (troubleshooting) | +| Publish one skill or contribute a doc | `$(teamai skill path core)/references/contribute-member.md` | +| Anything that breaks along the way | `$(teamai skill path core)/references/troubleshooting.md` | Supported Git providers are Tencent TGit (工蜂), GitHub, GitLab and CNB; `{SKILL_DIR}/references/setup-admin.md` carries the detection probe, the sign-in diff --git a/skill-data/setup/references/join-member.md b/skill-data/setup/references/join-member.md index ec188ce9..4d757d1e 100644 --- a/skill-data/setup/references/join-member.md +++ b/skill-data/setup/references/join-member.md @@ -97,7 +97,7 @@ teamai hooks list # per-tool: which AI tools actually got the hooks Fix anything `doctor` reports. **Don't trust the "Hooks injected into all AI tool settings" message alone** — it prints even for tools where nothing was written; `teamai doctor` / `teamai hooks list` show the real per-tool status. If it flags -hook problems, load the troubleshooting reference (`teamai skill get core --full`), +hook problems, load the troubleshooting reference (`$(teamai skill path core)/references/troubleshooting.md`), section "Which tools actually get hooks". ## Step 6 — Confirm the skills actually arrived @@ -123,7 +123,7 @@ tool names only, on a separate branch of that same repo.) ## Agent-specific note If this conversation is running in **ChatGPT App** or **WorkBuddy**, the hooks -that drive auto-sync need an extra manual step — load the troubleshooting reference (`teamai skill get core --full`), section "Agent-specific caveats" and walk the user through it before finishing. +that drive auto-sync need an extra manual step — load the troubleshooting reference (`$(teamai skill path core)/references/troubleshooting.md`), section "Agent-specific caveats" and walk the user through it before finishing. ## If something is denied @@ -140,13 +140,14 @@ Summarize the outcome **in the user's own language** (global rule 1). Cover: session produced something worth sharing, TeamAI **prompts them on its own** (at the end of the session) and the `share` workflow (`teamai skill get share`) takes over to summarize and contribute it. They do **not** invoke `/teamai` for this. (This - prompt only appears if the admin left team sharing enabled — it is on by - default; the admin can turn it off in `teamai.yaml`.) + prompt only appears when recall is on for the team — it is off by default, + `teamai recall enable` turns it on — and the admin has not switched the + reminder off in `teamai.yaml`.) 3. **They can also contribute a skill — just ask in plain language.** A member does not need to be an admin to publish a skill. They tell TeamAI something like *"share this xxx skill with my team"* / *"把这个 xxx skill 分享给团队"*, and you - run the publish for them (see `references/contribute-member.md` in - `teamai skill get core --full`; it needs no recall). + run the publish for them (see `$(teamai skill path core)/references/contribute-member.md`; + it needs no recall). 4. **How to leave — via the skill, not raw commands.** They can remove TeamAI any time by re-invoking the skill; you'll run it for them: `/teamai 卸载` / `/teamai Uninstall TeamAI`. diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index 3758ec02..4a8faee9 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -114,17 +114,18 @@ teamai env remove <KEY> # remove ## When sync fails Run `teamai doctor` first. If it reports hook or path problems, load -the troubleshooting reference (`teamai skill get core --full`). Have the affected member reopen their session; if their tool +the troubleshooting reference (`$(teamai skill path core)/references/troubleshooting.md`). Have the affected member reopen their session; if their tool has no session-start hook, they run `teamai pull` manually. ## Capture a lesson learned -Turning a tricky fix into team knowledge is **automatic**: at the end of a session +Turning a tricky fix into team knowledge is **automatic** once recall is on for +the team (`teamai recall enable`; it is off by default): at the end of a session worth sharing, TeamAI prompts the member and the dedicated `share` workflow (`teamai skill get share`) summarizes the session and runs `teamai contribute`. Nobody has to invoke it by hand. (Publishing a **reusable skill** someone authored is a different task — any member -can do it, see `references/contribute-member.md` in `teamai skill get core --full`.) +can do it, see `$(teamai skill path core)/references/contribute-member.md`.) ### Turn the sharing prompt on or off (admin) diff --git a/skill-data/setup/references/setup-admin.md b/skill-data/setup/references/setup-admin.md index 7a90edef..1a5382a0 100644 --- a/skill-data/setup/references/setup-admin.md +++ b/skill-data/setup/references/setup-admin.md @@ -200,7 +200,7 @@ Claude Code"). Omitting `--agent` gives an interactive picker — select **every tool already installed** on the machine. Then **report back which agents were set up**, in the user's language: name the tools that will now auto-start TeamAI, and any detected tool that was skipped and why (e.g. Codex trust-gate, -CodeBuddy/WorkBuddy by design — see the troubleshooting reference, `teamai skill get core --full`). +CodeBuddy/WorkBuddy by design — see the troubleshooting reference, `$(teamai skill path core)/references/troubleshooting.md`). ## Step 6 — Verify with doctor @@ -216,7 +216,7 @@ it prints even for tools where nothing was written. `teamai doctor` / `teamai ho list` show the real per-tool status. Only the tool you set up (e.g. `claude`) is expected to show hooks installed; others are skipped by design or not yet supported, which is normal. Full table in the troubleshooting reference -(`teamai skill get core --full`), section "Which tools actually get hooks". +(`$(teamai skill path core)/references/troubleshooting.md`), section "Which tools actually get hooks". ## Step 7 — Grant members repo access (required before they can join) diff --git a/skill-data/share/SKILL.md b/skill-data/share/SKILL.md index 38205454..8e7dbf9c 100644 --- a/skill-data/share/SKILL.md +++ b/skill-data/share/SKILL.md @@ -2,9 +2,8 @@ name: share description: >- Turn a session into a team learning: summarize what was solved, discovered or worked around, - and publish it to the team knowledge base with `teamai contribute`. Also publishes a reusable - skill or a knowledge doc on request. Loaded on demand by the teamai discovery stub, and by - the friction reminder that ends a session worth sharing. + and publish it to the team knowledge base with `teamai contribute`. Loaded on demand by the + teamai discovery stub, and by the friction reminder that ends a session worth sharing. allowed-tools: Bash(teamai:*), Bash(npx teamai-cli:*) --- @@ -58,8 +57,8 @@ teamai contribute --file /tmp/session-summary.md --title "Debugging K8s pod star ## Publishing a reusable skill instead A member asking to publish a skill ("share this xxx skill with my team") is a -different flow, and it does not need recall: it lives in the `core` skill, as -`teamai skill get core --full` under `references/contribute-member.md`. This file +different flow, and it does not need recall: it lives in the `core` skill, at +`$(teamai skill path core)/references/contribute-member.md`. This file is for turning a *session* into a learning. ## References diff --git a/skill-data/wiki/SKILL.md b/skill-data/wiki/SKILL.md index 22f8c899..18c6eaa8 100644 --- a/skill-data/wiki/SKILL.md +++ b/skill-data/wiki/SKILL.md @@ -12,7 +12,7 @@ description: >- allowed-tools: Bash(teamai:*), Bash(npx teamai-cli:*), Bash(python3:*) --- -# team-wiki-codebase: AI cognition engineering for large codebases +# wiki: AI cognition engineering for large codebases > Prerequisites: an accessible source directory (multiple repositories supported), Python 3, and an installed teamai CLI. > The methodology, sub-agent prompts, templates and scripts ship with the CLI. Run `teamai skill path wiki` to get their absolute path; @@ -225,7 +225,7 @@ knowledge base README template: `{SKILL_DIR}/references/templates/project-overvi Human-readable overview (not for execution): `{SKILL_DIR}/references/overview.md`. -`teamai skill get wiki --full` prints every reference file in one go (about 115 KB). Use it only when you need to read everything. +`teamai skill get wiki --full` prints every reference file in one go (about 130 KB). Use it only when you need to read everything. ## Output directory layout diff --git a/skill-data/wiki/references/agents/kb-doc-generator.md b/skill-data/wiki/references/agents/kb-doc-generator.md index d02d19df..9a89c9c0 100644 --- a/skill-data/wiki/references/agents/kb-doc-generator.md +++ b/skill-data/wiki/references/agents/kb-doc-generator.md @@ -67,7 +67,7 @@ Example (3 chunks concurrently): Each sub-agent receives the following prompt (replace CHUNK_COMPONENTS, CHUNK_NUM, TOTAL_CHUNKS): ``` -You are the component document generation sub-agent of team-wiki-codebase. +You are the component document generation sub-agent of the wiki skill. Generate knowledge base documents for the following components (chunk CHUNK_NUM / TOTAL_CHUNKS): CHUNK_COMPONENTS diff --git a/skill-data/wiki/references/overview.md b/skill-data/wiki/references/overview.md index 1ae3dd65..26d2b236 100644 --- a/skill-data/wiki/references/overview.md +++ b/skill-data/wiki/references/overview.md @@ -1,4 +1,4 @@ -# team-wiki-codebase: AI cognition engineering for large codebases +# wiki: AI cognition engineering for large codebases > TeamAI built-in skill. The methodology, scripts and agent specifications are **not** copied into `.claude/`, `.codebuddy/`, `.cursor/` or any other agent directory: they ship inside the installed CLI and are served on demand by `teamai skill get wiki` (`--full` for the references too). What an agent reads therefore always matches the CLI it is running. `teamai skill path wiki` prints the directory that holds the scripts and templates, for the commands below that run them. TeamAI ships no separate team-wiki CLI, and no extra plugin is required. diff --git a/skills/teamai/SKILL.md b/skills/teamai/SKILL.md index 61f9ba95..658f6516 100644 --- a/skills/teamai/SKILL.md +++ b/skills/teamai/SKILL.md @@ -10,7 +10,7 @@ description: >- after a friction reminder. Triggers include "set up teamai", "join the team repo", "sync team skills", "team wiki", "share what I learned", and running /teamai. Talking about a team needs no skill; operating on what the team shares does. -allowed-tools: Bash(teamai:*), Bash(npx teamai-cli:*) +allowed-tools: Bash(teamai skill:*), Bash(npx teamai-cli skill:*) --- # teamai diff --git a/src/__tests__/e2e/skill-serving-cli.test.ts b/src/__tests__/e2e/skill-serving-cli.test.ts index aeeb98d4..ef20e719 100644 --- a/src/__tests__/e2e/skill-serving-cli.test.ts +++ b/src/__tests__/e2e/skill-serving-cli.test.ts @@ -78,7 +78,11 @@ describe('teamai skill get / path CLI (e2e)', () => { const scriptPath = path.join(dir, 'scripts', script); expect(fs.existsSync(scriptPath), scriptPath).toBe(true); - const help = spawnSync('python3', [scriptPath, '--help'], { encoding: 'utf8' }); + const help = spawnSync('python3', [scriptPath, '--help'], { + encoding: 'utf8', + // Do not leave bytecode in the packaged tree the test just proved ships. + env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' }, + }); // A machine without python3 cannot run them; the path is what we assert there. if (help.error) continue; expect(help.status, script).toBe(0); @@ -100,6 +104,16 @@ describe('teamai skill get / path CLI (e2e)', () => { const legacyName = run('skill', 'get', 'team-wiki-codebase'); expect(legacyName.status).toBe(0); expect(legacyName.stdout).toContain('name: wiki'); + + // Before `teamai init`, an unknown name is a not-found line, not the stack + // trace of the init error the team lookup would have thrown. + // `skill show` is a human command: the error is on stderr and the way out + // is a dim line on stdout, as on the initialised not-found path. + const shownUnknown = run('skill', 'show', 'no-such-skill'); + expect(shownUnknown.status).toBe(1); + expect(shownUnknown.stderr).toContain('not found among the skills the installed CLI serves'); + expect(shownUnknown.stdout).toContain('Run `teamai init` first'); + expect(shownUnknown.stdout + shownUnknown.stderr).not.toContain(' at '); }); it('appends the nested references with --full', () => { diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index a6a48bf9..d158ae27 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -77,7 +77,9 @@ vi.mock('../update.js', () => ({ const mockAutoDetectInit = vi.fn().mockResolvedValue({ localConfig: { repo: { localPath: '/tmp', remote: '' }, username: 'test', scope: 'user' }, - teamConfig: { team: 'test', repo: '', toolPaths: {} }, + // Recall on: the contribute hint routes to the share workflow, which is + // refused while recall is off, so the hint is withheld there too. + teamConfig: { team: 'test', repo: '', toolPaths: {}, sharing: { recall: { enabled: true } } }, }); vi.mock('../config.js', async (importOriginal) => ({ @@ -369,7 +371,7 @@ describe('hook-handlers registry', () => { )!.handler; mockAutoDetectInit.mockResolvedValueOnce({ localConfig: { repo: { localPath: '/tmp', remote: '' }, username: 'test', scope: 'user', contributeHintEnabled: true }, - teamConfig: { team: 'test', repo: '', toolPaths: {}, sharing: { contributeHint: { enabled: false } } }, + teamConfig: { team: 'test', repo: '', toolPaths: {}, sharing: { contributeHint: { enabled: false }, recall: { enabled: true } } }, }); mockContributeCheckForSession.mockResolvedValueOnce({ hint: '[teamai] do share' }); @@ -377,6 +379,22 @@ describe('hook-handlers registry', () => { expect(result).toContain('do share'); }); + it('contribute-check handler stays silent while recall is off, since `teamai skill get share` would refuse', async () => { + const registry = buildHandlerRegistry(); + const handler = registry.find( + (r) => r.event === 'stop' && r.handler.name === 'contribute-check', + )!.handler; + mockAutoDetectInit.mockResolvedValueOnce({ + localConfig: { repo: { localPath: '/tmp', remote: '' }, username: 'test', scope: 'user' }, + teamConfig: { team: 'test', repo: '', toolPaths: {} }, + }); + mockContributeCheckForSession.mockClear(); + + const result = await handler.execute({ session_id: 's3b', cwd: '/x' }, 'claude'); + expect(result).toBeNull(); + expect(mockContributeCheckForSession).not.toHaveBeenCalled(); + }); + it('contribute-check handler keeps hinting when config cannot be loaded', async () => { const registry = buildHandlerRegistry(); const handler = registry.find( diff --git a/src/__tests__/skill-content.test.ts b/src/__tests__/skill-content.test.ts index 7ce726b0..a92f5dad 100644 --- a/src/__tests__/skill-content.test.ts +++ b/src/__tests__/skill-content.test.ts @@ -310,7 +310,9 @@ describe('the shipped skill-data content', () => { it('keeps the deployed stub declaring its own name and tools', () => { const stub = fs.readFileSync(path.join(ROOT, 'skills/teamai/SKILL.md'), 'utf8'); expect(stub).toMatch(/^name: teamai$/m); - expect(stub).toMatch(/^allowed-tools: Bash\(teamai:\*\), Bash\(npx teamai-cli:\*\)$/m); + // The stub is the always-loaded unit, so it pre-approves only the read-only + // `teamai skill …` commands it asks for; the served skills grant the rest. + expect(stub).toMatch(/^allowed-tools: Bash\(teamai skill:\*\), Bash\(npx teamai-cli skill:\*\)$/m); }); it('quotes {SKILL_DIR} in every command it tells the agent to run', async () => { @@ -367,5 +369,8 @@ describe('npm package contents', () => { expect(files.some((f) => f.startsWith(`skill-data/${skill}/`)), skill).toBe(true); } expect(files).toContain('skill-data/wiki/scripts/scan_repo.py'); + // Running the wiki scripts (the e2e suite does) leaves __pycache__ beside + // them; "files" must not sweep interpreter bytecode into the package. + expect(files.filter((f) => f.endsWith('.pyc') || f.includes('__pycache__'))).toEqual([]); }, 60_000); }); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index d591d0ab..9101c796 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -1040,6 +1040,32 @@ describe('uninstall', () => { expect(await fse.pathExists(legacyShare)).toBe(false); }); + it('names the file and the error when a packaged file cannot be deleted, instead of calling the directory kept', async () => { + if (process.getuid?.() === 0) return; // root ignores directory permissions + const { homeDir, repoPath } = await setupFixture(tmpDir); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + const stubDir = path.join(homeDir, '.claude', 'skills', 'teamai'); + await fse.chmod(stubDir, 0o555); + + const localConfig = makeLocalConfig(homeDir, repoPath); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig: makeTeamConfig() }); + const { log } = await import('../utils/logger.js'); + try { + await uninstall({ force: true }); + } finally { + await fse.chmod(stubDir, 0o755); + } + + const warnings = (log.warn as ReturnType<typeof vi.fn>).mock.calls.map((c) => String(c[0])); + const about = warnings.filter((w) => w.includes(stubDir)); + expect(about).toHaveLength(1); + expect(about[0]).toContain('Could not delete packaged files under'); + expect(about[0]).toContain(path.join(stubDir, 'SKILL.md')); + expect(about[0]).not.toContain('did not put there'); + expect(await fse.pathExists(path.join(stubDir, 'SKILL.md'))).toBe(true); + }); + it('removes the stub Codex kept in the shared .agents/skills root, and nothing else there', async () => { const { homeDir, repoPath } = await setupFixture(tmpDir); vi.stubEnv('HOME', homeDir); diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index 240b62c7..1587cd1d 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -119,7 +119,11 @@ function isDerivedArtifact(relativePath: string): boolean { return relativePath.endsWith('.pyc') || relativePath.split('/').includes('__pycache__'); } -/** Every file under `dir`, as paths relative to it. Symlinks count as files. */ +/** + * Every file under `dir`, as paths relative to it. Symlinks count as files. + * Not the shared walker in utils/fs: that one skips `__pycache__`, and the prune + * has to see it to decide whether a directory is empty of the member's files. + */ async function walkFiles(dir: string, prefix = ''): Promise<string[]> { const found: string[] = []; for (const entry of await fs.promises.readdir(dir, { withFileTypes: true })) { @@ -149,15 +153,7 @@ async function removeEmptyDirs(dir: string): Promise<void> { try { await fs.promises.rmdir(dir); } catch { /* not empty */ } } -/** - * Remove from `dir` the files the CLI put there, then the directories that end - * up empty. Returns false when the member has files of their own in there, so - * the caller can say the directory was kept. - * - * Exported because uninstall must delete a CLI-owned skill directory by the same - * rule pull does: a file a member added beside our packaged ones was never ours - * to write and is not ours to remove, whichever command is doing the removing. - */ +/** What `removeOwnedFiles` did and did not do, for the caller to report. */ export interface PruneResult { /** True when a link sits between the base and the root: nothing was touched. */ skippedSymlink: boolean; @@ -179,6 +175,15 @@ export function prunedWhole(result: PruneResult): boolean { && result.notRemoved.length === 0; } +/** + * Remove from `dir` the files the CLI put there, then the directories that end + * up empty. Anything that stopped it — a member's own file, a failed backup, a + * failed delete, a link — is in the result, so the caller can say which. + * + * Exported because uninstall must delete a CLI-owned skill directory by the same + * rule pull does: a file a member added beside our packaged ones was never ours + * to write and is not ours to remove, whichever command is doing the removing. + */ export async function removeOwnedFiles( dir: string, owned: readonly string[], @@ -439,7 +444,7 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? // every session start and never reach a file worth keeping. const shippedNow = new Set(await walkFiles(srcDir)); const retired = (PACKAGED_SKILL_FILES.get(skillName) ?? []).filter((p) => !shippedNow.has(p)); - const backupDir = skillBackupDir(baseDir, tool, path.relative(baseDir, destDir), skillName); + const backupDir = skillBackupDir(baseDir, tool, path.relative(baseDir, path.dirname(destDir)), skillName); const result = await removeOwnedFiles(destDir, retired, backupDir, baseDir); if (result.unbackedUp.length > 0) { log.warn(`Kept ${result.unbackedUp.length} file(s) under ${destDir}: their backup could not be written, so they were not removed. First: ${result.unbackedUp[0].file} — ${result.unbackedUp[0].error}`); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index df1e45bb..9106f33c 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -235,13 +235,17 @@ const trackSlashHandler: HookHandler = { * hook run so a team can switch it off via teamai.yaml (or a member via local * config) without re-injecting hooks. Falls back to enabled when config can't * be read, preserving pre-toggle behavior for half-initialized installs. + * + * Recall gates it too: the hint routes to the `share` workflow, and + * `teamai skill get share` refuses while recall is off, so a nudge towards it + * would send the agent to a command that says no. */ async function contributeHintAllowed(): Promise<boolean> { - const { isContributeHintEnabled } = await import('./types.js'); + const { isContributeHintEnabled, isRecallEnabled } = await import('./types.js'); try { const { autoDetectInit } = await import('./config.js'); const { localConfig, teamConfig } = await autoDetectInit(); - return isContributeHintEnabled(localConfig, teamConfig); + return isContributeHintEnabled(localConfig, teamConfig) && isRecallEnabled(localConfig, teamConfig); } catch { return isContributeHintEnabled({}, {}); } diff --git a/src/init.ts b/src/init.ts index 01a51323..86e49446 100644 --- a/src/init.ts +++ b/src/init.ts @@ -17,7 +17,6 @@ import { type Scope, getTeamaiHome, getConfigPath, - isRecallEnabled, } from './types.js'; import { getUserHome } from './utils/home.js'; import { describeRoles, listRoleIds, loadRolesManifest } from './roles.js'; diff --git a/src/recall-toggle.ts b/src/recall-toggle.ts index 5d28ce65..e2d11158 100644 --- a/src/recall-toggle.ts +++ b/src/recall-toggle.ts @@ -61,7 +61,6 @@ async function removeRecallArtifacts(teamConfig: TeamaiConfig, localConfig: Loca } } - // Remove recall block from CLAUDE.md if (toolPath.claudemd) { const claudeMdPath = path.join(baseDir, toolPath.claudemd); diff --git a/src/skill-cmd.ts b/src/skill-cmd.ts index 7f989d9d..7da99e53 100644 --- a/src/skill-cmd.ts +++ b/src/skill-cmd.ts @@ -62,7 +62,12 @@ export async function skillShow(name: string, options: GlobalOptions): Promise<v process.exitCode = 1; return; } - if (packaged.kind !== 'found') throw e; + if (packaged.kind !== 'found') { + log.error(`Skill "${name}" not found among the skills the installed CLI serves.`); + log.dim('Run `teamai init` first to search the team repo and installed agents too.'); + process.exitCode = 1; + return; + } printSkillCard({ name: packaged.skill.name, source: { kind: 'builtin' }, diff --git a/src/skill-content.ts b/src/skill-content.ts index 190220fb..2ce41643 100644 --- a/src/skill-content.ts +++ b/src/skill-content.ts @@ -313,7 +313,10 @@ export async function skillGet(names: string[], options: SkillGetOptions = {}): // and named on stderr, the rest is still served. for (const listed of servable) { const resolved = await resolveServableSkill(listed.name, roots); - if (resolved.kind !== 'found') { + // The listing and the resolver read one catalog, so `not-found` cannot + // happen here; it is still its own branch so the message stays truthful. + if (resolved.kind === 'not-found') continue; + if (resolved.kind === 'blocked') { diagnostic(`${chalk.yellow('⚠')} Skipped ${listed.name}: needs recall, which is disabled for this team (teamai recall enable).`); continue; } diff --git a/src/uninstall.ts b/src/uninstall.ts index 6c294217..2d5cfc7d 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -864,6 +864,7 @@ async function executeRemoval(plan: RemovalPlan): Promise<void> { let removedSkillDirs = 0; const keptSkillDirs: string[] = []; const linkedSkillDirs: string[] = []; + const failedSkillDirs: { skillDir: string; first: { file: string; error: string } }[] = []; for (const skillDir of plan.skillDirs) { try { const name = path.basename(skillDir); @@ -871,6 +872,9 @@ async function executeRemoval(plan: RemovalPlan): Promise<void> { const result = await removeOwnedFiles(skillDir, PACKAGED_SKILL_FILES.get(name) ?? []); if (prunedWhole(result)) removedSkillDirs++; else if (result.skippedSymlink) linkedSkillDirs.push(skillDir); + // A delete that failed is not a member's file: say what happened, not + // "the packaged files were removed". + else if (result.notRemoved.length > 0) failedSkillDirs.push({ skillDir, first: result.notRemoved[0] }); else keptSkillDirs.push(skillDir); } else { await remove(skillDir); @@ -891,6 +895,9 @@ async function executeRemoval(plan: RemovalPlan): Promise<void> { for (const skillDir of linkedSkillDirs) { log.warn(`Kept ${skillDir}: it is a symlink, so TeamAI left it and whatever it points at alone.`); } + for (const { skillDir, first } of failedSkillDirs) { + log.warn(`Could not delete packaged files under ${skillDir}. First: ${first.file} — ${first.error}. Fix the permissions and run \`teamai uninstall\` again, or delete the directory yourself.`); + } // (d) Remove synced rules for (const ruleFile of plan.ruleFiles) { From ecc70dda1abe9cdb036f283b940faf77e3d98eed Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Tue, 22 Sep 2026 23:44:02 +0200 Subject: [PATCH 25/37] fix(skills): English-only served content, one link guard for every caller, withhold share from read-only sources The reviewer flagged Chinese in skills/ and skill-data/ a third time. Both reach the agent as CLI output, so the stub's trigger keywords, the paired sample invocations and the Chinese name for TGit go; the agent translates for the user. A test fails on CJK anywhere under either root. Pre-push review of the whole branch, and what changed: - uninstall walked through a linked ~/.claude/skills and deleted the packaged files inside the member's dotfiles checkout; pull refused the same layout. removeOwnedFiles now owns the guard, so pull, deploy and uninstall apply one check: the skills root and the skill directory. A linked ~/.claude (stow, chezmoi) is no longer refused, since every other resource writes through it and refusing left those machines on the pre-stub trees. - share was served to read-only HTTP teams, where its last step (teamai contribute) always fails; reportingOnly used to skip it. The serving gate carries a reason (recall | read-only) with its own message, and `skill list --json` reports it as `blockedBy`. - The bytecode rule claimed any file under any __pycache__; it now claims only the .pyc of a shipped script. - recall disable pruned the shared .agents/skills root for an uninstalled Codex; it has deployment's install gate now. - The source-team guard lost the legacy names when BUILTIN_SKILL_NAMES narrowed, so a source removal could delete a legacy tree wholesale. - Routing: the admin wrap-up and the stub still sent "share what I learned" to share without saying it needs recall, and the stub filed "share this with my team" (the publish-a-skill phrase) under share. recall enable is described as the per-machine override it is, next to the team key. - {SKILL_DIR} definitions now say how a reference file opened on its own spells the directory, since serving resolves the definition too. - skill show: packaged resolve only when init fails, aligned label, a served skill is "served by the CLI, not installed". - Docs: uninstall removes the archive with ~/.teamai; zh said the whole directory is kept; the product overview lacked the recall gate; the design doc's release, tag and byte figures were stale; CHANGELOG notes the language change of generated documents. --- CHANGELOG.md | 2 +- docs/designs/skill-serving.md | 54 +++++---- docs/product-overview.md | 2 +- docs/product-overview.zh-CN.md | 2 +- docs/usage-guide.md | 11 +- docs/usage-guide.zh-CN.md | 9 +- skill-data/core/SKILL.md | 8 +- .../core/references/contribute-member.md | 14 +-- skill-data/setup/SKILL.md | 6 +- skill-data/setup/references/join-member.md | 10 +- skill-data/setup/references/manage-admin.md | 5 +- skill-data/setup/references/provider-tgit.md | 4 +- skill-data/setup/references/setup-admin.md | 44 +++---- skill-data/share/SKILL.md | 2 +- skill-data/wiki/SKILL.md | 3 +- skills/teamai/SKILL.md | 13 ++- src/__tests__/commands-reference.test.ts | 4 +- src/__tests__/e2e/skill-serving-cli.test.ts | 12 +- src/__tests__/skill-commands-exist.test.ts | 6 +- src/__tests__/skill-content.test.ts | 17 ++- src/__tests__/skill-recall-gate.test.ts | 21 +++- src/__tests__/skip-uninstalled-tools.test.ts | 56 +++++++++ src/__tests__/uninstall.test.ts | 16 +++ src/builtin-skills.ts | 69 ++++++----- src/index.ts | 2 +- src/recall-toggle.ts | 5 +- src/skill-cmd.ts | 31 ++--- src/skill-content.ts | 108 +++++++++++------- src/source.ts | 7 +- src/types.ts | 4 +- src/uninstall.ts | 2 +- 31 files changed, 346 insertions(+), 203 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91f959f1..535083b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. See [standa ### ✨ Features -- Built-in skill content ships inside the npm package and is printed by the installed CLI: `teamai skill get <core|setup|wiki|share> [--full] [--all]`, `teamai skill path <name>` for the directory holding a skill's scripts, and `teamai skill list --json` for the catalog. Agents receive one file, `skills/teamai/SKILL.md`, a discovery stub that points at those commands, so what an agent reads always matches the CLI version it is running. `teamai pull` removes the `team-wiki-codebase`, `teamai-share-learnings` and `teamai/references/*.md` trees earlier releases copied into every agent directory, archiving each removed file under `~/.teamai/removed-skills/<run>/…` first and keeping any directory that holds a member's own file; `teamai uninstall` removes only the packaged files from CLI-owned skill directories by the same rule. `share` is served only while recall is on for the team, and the end-of-session share reminder is withheld until then too. The legacy names still resolve as aliases (for [#678](https://github.com/Tencent/teamai-cli/issues/678), [#730](https://github.com/Tencent/teamai-cli/issues/730)). +- Built-in skill content ships inside the npm package and is printed by the installed CLI: `teamai skill get <core|setup|wiki|share> [--full] [--all]`, `teamai skill path <name>` for the directory holding a skill's scripts, and `teamai skill list --json` for the catalog. Agents receive one file, `skills/teamai/SKILL.md`, a discovery stub that points at those commands, so what an agent reads always matches the CLI version it is running. `teamai pull` removes the `team-wiki-codebase`, `teamai-share-learnings` and `teamai/references/*.md` trees earlier releases copied into every agent directory, archiving each removed file under `~/.teamai/removed-skills/<run>/…` first and keeping any directory that holds a member's own file; `teamai uninstall` removes only the packaged files from CLI-owned skill directories by the same rule. `share` is served only while recall is on and the team source is writable (not a read-only HTTP one), and the end-of-session share reminder is withheld until then too. The served workflows are English; learning and knowledge-base documents are written in the user's language (previously always Chinese), and an existing knowledge base keeps its file names and headings. The legacy names still resolve as aliases (for [#678](https://github.com/Tencent/teamai-cli/issues/678), [#730](https://github.com/Tencent/teamai-cli/issues/730)). - Hooks, MCP servers and env variables can be scoped by logical project, the second membership axis they lacked. A `hooks/hooks.yaml` hook and an `mcp/mcp.yaml` server accept an optional `projects:` list beside `roles:`, and an `env/env.yaml` variable accepts both. An entry reaches a member when one of the projects its directory is bound to (`teamai projects set`) is listed; `projects: []` reaches nobody, and a directory bound to no project keeps receiving every entry, so nothing changes until a maintainer adds the key. The two axes compose as AND, the way `tools:` and `roles:` already do, so `roles: [frontend] projects: [checkout]` reaches frontend members of checkout rather than everyone on either. Rebinding with `teamai projects set` removes the previous project's entries on the next pull — for env that means the variable leaves `env.sh`, also on a pull that finds the team repo unchanged, so a machine upgrading from a CLI that ignored the keys drops a withheld variable without `--force`, and a refresh that cannot be written there is reported with the path and the way out rather than passing silently under `Already synced`; `teamai doctor` applies the same filter, so a variable correctly withheld is not reported as undelivered, while one that `env.sh` still exports after a rebind is reported until the next pull rewrites the file. An id that `manifest/projects.yaml` does not define produces one warning per pull, and so does a `projects:` key in a team with no projects manifest: the key still filters against the ids in the directory's `config.yaml`, but nothing can validate them. `teamai mcp list`, `teamai hooks list` and `teamai env list` show the restriction, and `pull` reports `Synced 1 of 3 env variable(s)` when scoping withheld some. This is what the keys exist to control: a team with five projects and three MCP servers each gave every member of a role fifteen server processes and fifteen tool lists in the context of every session (for [#668](https://github.com/Tencent/teamai-cli/issues/668)). diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index 729c7722..b4978a89 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -1,6 +1,6 @@ # Serving built-in skill content from the CLI -Issue: [#678](https://github.com/Tencent/teamai-cli/issues/678). Unreleased; the package is at 0.22.0. +Issue: [#678](https://github.com/Tencent/teamai-cli/issues/678). Unreleased; targets the release after 0.25.0. ## The problem @@ -46,15 +46,18 @@ npm package What an agent reads, and when: ```text -session start stub frontmatter (description) 875 B always in context -task matches stub body 1 408 B holds the commands -`teamai skill get core` daily workflow 6 383 B on demand +session start stub frontmatter (description) 1 005 B always in context +task matches stub body 1 524 B holds the commands +`teamai skill get core` daily workflow 6 533 B on demand `… core --full` + commands.md, contribute-member, - troubleshooting 36 213 B on demand -`… setup` / `wiki` 5 553 B / 19 088 B on demand -`… setup --full` / `… wiki --full` 38 571 B / 132 722 B on demand + troubleshooting 36 321 B on demand +`… setup` / `wiki` 5 620 B / 19 386 B on demand +`… setup --full` / `… wiki --full` 38 624 B / 133 020 B on demand ``` +Served sizes include the resolved `{SKILL_DIR}`, so they grow with the install +path (measured here from a 77-character one). + ## Contracts worth keeping - **`skill get` prints the file byte for byte**, frontmatter included, with no @@ -72,10 +75,13 @@ task matches stub body 1 408 B holds the c - **Nothing repairs the deployed stub.** `ensureSkillFrontmatter` is not called on it, so deployed and packaged bytes are identical and a diff means a bug. - **Recall is decided at run time**, not by withholding a directory at deploy - time, and it holds on every path that hands out content or a location: + time, and so is the read-only HTTP source that `reportingOnly` used to skip + `share` for (`teamai contribute` refuses there, so the workflow would fail at + its last step; `skill list --json` reports `blockedBy: "read-only"`). Both hold + on every path that hands out content or a location: `skill get <name>` refuses, `skill get --all` leaves the skill out and says so on stderr, `skill path <name>` and `skill show <name>` refuse, and - `skill list --json` reports `blockedByRecall: true` with `path: null`. With no + `skill list --json` reports `blockedBy: "recall"` with `path: null`. With no team config to consult — or one it cannot load — it fails open: a refusal the member cannot act on is worse than serving the workflow. The Stop-hook share reminder is gated the same way (`contributeHintAllowed`, `src/hook-handlers.ts`), @@ -83,10 +89,9 @@ task matches stub body 1 408 B holds the c `resolveServableSkill` (`src/skill-content.ts`) is the only way to obtain a packaged skill outside that module, and it returns `blocked` instead of the skill, so a command cannot print a directory it never received. -- **`skill path` takes a name, always.** Printing the `skill-data/` root would - hand out the parent of every served skill, and `<root>/share/SKILL.md` is - readable from there — the content the gate withholds one command over. There is - no argument-less form to close that way around. +- **`skill path` takes a name, always,** and a blocked name gets the same + refusal as `skill get`. The gate routes the agent away from a workflow that + cannot finish; it is not access control, since the files ship in the package. - **A member's own skill outranks a packaged name.** `locateSkill` searches the team repo, then the installed agents, then the package. `codebase`, `default`, `learning` and `share` are ordinary names: a directory a member created under @@ -120,7 +125,8 @@ Two tests, both in the unit suite: A third, in `skill-content.test.ts`, asserts through `npm pack` that both `skills/` and `skill-data/` are in the published tarball. Without it, a missing `package.json` "files" entry passes every other test and serves nothing once -installed. +installed. The same file fails on Chinese text under `skills/` or `skill-data/`: +both reach the agent as CLI output, which the repo keeps English. ## Migration @@ -150,9 +156,9 @@ member did not ask anything to be removed by, while uninstall is them asking for all of it to go. Leaving copies behind would be the thing they ran it to avoid. **It removes only the files those releases packaged.** `PACKAGED_SKILL_FILES` -lists them, built as the union of `git ls-tree -r <tag> -- skills/` over all 98 -tags, minus `teamai-wiki` (see below), plus `references/provider-tgit.md`, which -#724 put on `main` unreleased and the next release therefore ships. Every path +lists them, built as the union of `git ls-tree -r <tag> -- skills/` over all 99 +tags through v0.25.0, minus `teamai-wiki` (see below); `references/provider-tgit.md` +first shipped in 0.25.0. Every path in it is provably the CLI's. Those files were overwritten with `overwrite: true` on every pull and no local edit ever survived in one; a file a member added beside them was never touched by the old deployment and is not ours @@ -165,11 +171,13 @@ migration stops being a one-way door for any of them. That path is the machine's home, never the tool's base directory, which under project scope is the repo root. Only *retired* paths are archived: the stub is rewritten on every session start, so archiving it would file an identical copy per session forever. A -link anywhere between the tool's base directory and the skill directory is -refused outright — neither pruned nor written through, link and target -untouched: everything under it matches our names, and none of it is ours. That -walk-up check is pull's; `uninstall` has only the skill directory in hand and -checks that one, which is enough for the member who asked for the removal. The +linked skills root (`~/.claude/skills`) or skill directory is refused outright — +neither pruned nor written through, link and target untouched: everything under +it matches our names, and none of it is ours. Pull, deploy and `uninstall` apply +the same check. The agent directory and everything above it are not checked: a +linked `~/.claude` (stow, chezmoi) is ordinary, every other resource the sync +writes goes through it, and refusing there would leave those machines on the +pre-stub trees forever. The `<base>` segment is there because `inheritUserScope` deploys the user base and then the project base in one process, with the same tool, root and skill name. A file whose copy fails is kept rather than removed: a backup that did not happen must not authorise the @@ -191,7 +199,7 @@ two other commands know the names too: `push` never offers them as new user skills (`isCliOwnedSkillName`), and `recall disable` still removes `teamai-share-learnings` (`LEGACY_RECALL_SKILL_NAMES`), as it did before the stub. -**Retire that set once 0.23.x is no longer in the field.** The short names +**Retire that set once 0.25.x, the last release to deploy those trees, is no longer in the field.** The short names (`wiki`, `share`) are the canonical ones; the long names survive as aliases in `SKILL_ALIASES` (`src/skill-content.ts`) for documentation and muscle memory, and can be dropped on the same schedule. diff --git a/docs/product-overview.md b/docs/product-overview.md index 687dd6e6..6e1ead9b 100644 --- a/docs/product-overview.md +++ b/docs/product-overview.md @@ -118,7 +118,7 @@ Task: Fix duplicate project-level Hook injection Consider running `/teamai share what this session taught me` to summarize what you learned and share it with your team (or run `teamai skill get share`). ``` -The hint names the non-zero friction signals that triggered it and, when available, includes a redacted, single-line summary of the first task. The `share` workflow (`teamai skill get share`) summarizes the session and pushes a learning document directly to the team repo. Each session is prompted at most once. Teams can switch the hint off with `sharing.contributeHint.enabled: false` in `teamai.yaml` (members: `contributeHintEnabled` in local config) while keeping the rest of the Stop hook. +The hint names the non-zero friction signals that triggered it and, when available, includes a redacted, single-line summary of the first task. The `share` workflow (`teamai skill get share`) summarizes the session and pushes a learning document directly to the team repo. Each session is prompted at most once. Teams can switch the hint off with `sharing.contributeHint.enabled: false` in `teamai.yaml` (members: `contributeHintEnabled` in local config) while keeping the rest of the Stop hook. The hint also needs recall to be on (it is off by default), because the workflow it points at is served only then. ### Team Knowledge Recall diff --git a/docs/product-overview.zh-CN.md b/docs/product-overview.zh-CN.md index ebfff162..5a7faf0b 100644 --- a/docs/product-overview.zh-CN.md +++ b/docs/product-overview.zh-CN.md @@ -118,7 +118,7 @@ Task: Fix duplicate project-level Hook injection Consider running `/teamai share what this session taught me` to summarize what you learned and share it with your team (or run `teamai skill get share`). ``` -提示会列出实际触发它的非零摩擦信号;如果能取得首个任务摘要,还会在脱敏、单行化后附上任务上下文。`share` 工作流(`teamai skill get share`)自动总结 session 经验并推送到团队仓库。每个 session 最多提示一次。团队可在 `teamai.yaml` 设置 `sharing.contributeHint.enabled: false` 关闭该提示(成员可用本地配置 `contributeHintEnabled` 覆盖),Stop hook 的其余功能不受影响。 +提示会列出实际触发它的非零摩擦信号;如果能取得首个任务摘要,还会在脱敏、单行化后附上任务上下文。`share` 工作流(`teamai skill get share`)自动总结 session 经验并推送到团队仓库。每个 session 最多提示一次。团队可在 `teamai.yaml` 设置 `sharing.contributeHint.enabled: false` 关闭该提示(成员可用本地配置 `contributeHintEnabled` 覆盖),Stop hook 的其余功能不受影响。该提示还需要开启 recall(默认关闭),因为它指向的工作流只在 recall 开启时提供。 ### 团队知识检索 diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 4304a4d9..ca87d26a 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -474,9 +474,12 @@ from the CLI, `~/.<tool>/skills/teamai/SKILL.md`, a small discovery stub that po those commands. Older releases copied the whole tree into every agent directory, where it went stale between pulls; `teamai pull` removes those leftovers, keeping a copy of every removed file under `~/.teamai/removed-skills/`, one directory per pull, so an edit you made -to one of them is not lost. A directory that also holds a file of your own is kept and named -in the pull output. `share` is served only while recall is on for the team: until -`teamai recall enable`, `teamai skill get share` refuses and says so. The legacy names still +to one of them is not lost (until `teamai uninstall`, which removes `~/.teamai/` and this +archive with it). A directory that also holds a file of your own is kept, with only the +packaged files removed, and named in the pull output. `share` is served only while recall is +on (off by default; `sharing.recall.enabled: true` in `teamai.yaml` for the team, or +`teamai recall enable` for one machine): until then `teamai skill get share` refuses and says so. +It also refuses on a read-only HTTP source, where `teamai contribute` cannot write. The legacy names still resolve: `teamai skill get team-wiki-codebase` serves `wiki`. --- @@ -942,7 +945,7 @@ Teams that route knowledge sharing through their own review flow (for example, a Only the nudge is affected: friction scoring, `teamai contribute --file`, and `/teamai` keep working when invoked manually. -The reminder is also withheld while recall is off for the team (the default until `teamai recall enable`): it points at the `share` workflow, and `teamai skill get share` refuses until recall is on. +The reminder is also withheld while recall is off (the default until `sharing.recall.enabled: true` in `teamai.yaml`, or `teamai recall enable` on one machine): it points at the `share` workflow, and `teamai skill get share` refuses until recall is on. ### Searching knowledge diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 1f9ff93e..ccbe6783 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -448,8 +448,11 @@ teamai skill path wiki # 打印打包目录,用于运行 skill 无需 `teamai pull` 内容就是最新的。每个 agent 只收到一个文件:`~/.<tool>/skills/teamai/SKILL.md`, 一个指向这些命令的小型发现入口(stub)。旧版本会把整棵目录复制到每个 agent 下,两次 pull 之间内容会过时; `teamai pull` 会清除这些残留,并把每个被删除的文件先复制到 `~/.teamai/removed-skills/` 下(每次 pull 一个目录), -你对其中文件的修改不会丢失;目录里若还有你自己的文件,则整个目录保留并在 pull 输出中点名。`share` 只在团队开启 -recall 后才会提供:`teamai recall enable` 之前,`teamai skill get share` 会拒绝并说明原因。旧名字仍然可用: +你对其中文件的修改不会丢失(`teamai uninstall` 会删除 `~/.teamai/`,这份备份也随之删除);目录里若还有你自己的文件, +只删除其中的打包文件,保留该目录和你的文件,并在 pull 输出中点名。`share` 只在开启 recall 后才会提供(默认关闭; +团队在 `teamai.yaml` 设置 `sharing.recall.enabled: true`,或单台机器运行 `teamai recall enable`):在此之前, +`teamai skill get share` 会拒绝并说明原因。 +只读 HTTP 源上它同样会拒绝,因为 `teamai contribute` 无法写入。旧名字仍然可用: `teamai skill get team-wiki-codebase` 等价于 `wiki`。 --- @@ -910,7 +913,7 @@ teamai contribute --file /tmp/session.md --scope project 只影响提醒本身:摩擦评分、`teamai contribute --file` 和手动调用 `/teamai` 不受影响。 -团队未开启 recall 时(默认关闭,`teamai recall enable` 开启)也不会显示这条提醒:提醒指向 `share` 工作流,而 recall 关闭时 `teamai skill get share` 会拒绝执行。 +未开启 recall 时(默认关闭;团队在 `teamai.yaml` 设置 `sharing.recall.enabled: true`,或单台机器运行 `teamai recall enable`)也不会显示这条提醒:提醒指向 `share` 工作流,而 recall 关闭时 `teamai skill get share` 会拒绝执行。 ### 搜索知识 diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index f74008c2..5aa6681a 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -61,9 +61,9 @@ ask ONE short question to pick a row, then proceed. Sharing a session's learnings needs no menu choice: TeamAI prompts on its own at the end of a session that produced something worth sharing, and that prompt means -`teamai skill get share`. (Only when recall is on for the team; it is off by -default, and `teamai skill get share` says so and names `teamai recall enable` -when it is not.) +`teamai skill get share`. (Only when recall is on; it is off by default. The team turns it on with +`sharing.recall.enabled: true` in `teamai.yaml`, a member with `teamai recall enable`; +while it is off, `teamai skill get share` says so.) ## Global rules @@ -103,7 +103,7 @@ generated reference below. Read it instead of guessing a flag. ## References -In the files below, `{SKILL_DIR}` is the directory `teamai skill path core` prints. +In the files below, `{SKILL_DIR}` is the directory `teamai skill path core` prints; a reference file you open on its own writes that directory as `SKILL_DIR` in braces. | File | When to load it | |---|---| diff --git a/skill-data/core/references/contribute-member.md b/skill-data/core/references/contribute-member.md index 1a73bfd0..73590fde 100644 --- a/skill-data/core/references/contribute-member.md +++ b/skill-data/core/references/contribute-member.md @@ -8,18 +8,17 @@ team"*, in whatever language they work in — then you run the publish for them. ## Which kind of contribution? - **A learning** (a lesson, a gotcha, how you solved something) → this is - **automatic**: TeamAI prompts at the end of a session worth sharing and the + **automatic** once recall is on (off by default): TeamAI prompts at the end of a session worth sharing and the dedicated `share` workflow (`teamai skill get share`) takes over (it summarizes the session and runs `teamai contribute`). The user does not come through this flow - for it. (Step A below is only a manual fallback for when that skill isn't - available.) + for it. (Step A below is only a manual fallback for while recall is off.) - **A reusable skill** (a `SKILL.md` others invoke) → author the skill, then `teamai push` (Step B — the main purpose of this reference). ## Step A — Contribute a learning by hand (fallback only) -> Prefer the `share` workflow (`teamai skill get share`). Use these manual steps only if it -> is unavailable in the current tool. +> Prefer the `share` workflow (`teamai skill get share`). Use these manual steps only while +> it refuses because recall is off. 1. Write a short Markdown doc that captures the lesson. Keep it concrete and actionable — a knowledge base, not a diary. Include YAML frontmatter for search @@ -53,9 +52,8 @@ team"*, in whatever language they work in — then you run the publish for them. The doc lands in the team's `learnings/` and appears for teammates on their next `teamai pull`. It is also searchable via `teamai recall`. -> Tip: if there is a dedicated learnings skill available in this tool -> (`teamai skill get share`), you can use it to auto-summarize the current session -> instead of writing the doc by hand. +> Tip: while recall is on, `teamai skill get share` auto-summarizes the current +> session instead of you writing the doc by hand. ## Step B — Contribute a reusable skill diff --git a/skill-data/setup/SKILL.md b/skill-data/setup/SKILL.md index a5a7a58c..7173b8cb 100644 --- a/skill-data/setup/SKILL.md +++ b/skill-data/setup/SKILL.md @@ -33,7 +33,7 @@ is missing. | Publish one skill or contribute a doc | `$(teamai skill path core)/references/contribute-member.md` | | Anything that breaks along the way | `$(teamai skill path core)/references/troubleshooting.md` | -Supported Git providers are Tencent TGit (工蜂), GitHub, GitLab and CNB; +Supported Git providers are Tencent TGit, GitHub, GitLab and CNB; `{SKILL_DIR}/references/setup-admin.md` carries the detection probe, the sign-in and create-repo URLs, and the per-provider caveats, and points at `{SKILL_DIR}/references/provider-tgit.md` for everything TGit-specific. @@ -64,7 +64,7 @@ platform's website, as `manage-admin.md` describes. ## References -In the files below, `{SKILL_DIR}` is the directory `teamai skill path setup` prints. +In the files below, `{SKILL_DIR}` is the directory `teamai skill path setup` prints; a reference file you open on its own writes that directory as `SKILL_DIR` in braces. | File | When to load it | |---|---| @@ -72,6 +72,6 @@ In the files below, `{SKILL_DIR}` is the directory `teamai skill path setup` pri | `{SKILL_DIR}/references/join-member.md` | Joining an existing team from a repo URL. | | `{SKILL_DIR}/references/manage-admin.md` | Day-to-day admin: publishing resources, roles, projects, MCP, env, members. | | `{SKILL_DIR}/references/uninstall.md` | Removing TeamAI from a machine or from one agent. | -| `{SKILL_DIR}/references/provider-tgit.md` | Tencent TGit (工蜂): reachability probe, `gf` install and login, repo creation on init. | +| `{SKILL_DIR}/references/provider-tgit.md` | Tencent TGit: reachability probe, `gf` install and login, repo creation on init. | `teamai skill get setup --full` prints this skill with all five appended. diff --git a/skill-data/setup/references/join-member.md b/skill-data/setup/references/join-member.md index 4d757d1e..0228846b 100644 --- a/skill-data/setup/references/join-member.md +++ b/skill-data/setup/references/join-member.md @@ -39,7 +39,7 @@ If it fails, Node.js ≥ 20 is missing — have them install Node 20+ first. Match the login to the URL's host (do NOT create a second repo): -- **`git.woa.com/...`** (Tencent TGit / 工蜂) → **you run both the `gf` install and +- **`git.woa.com/...`** (Tencent TGit) → **you run both the `gf` install and the `gf … auth login`** (never tell the user to run them). Follow `{SKILL_DIR}/references/provider-tgit.md` ("Log in"); the user's only action is approving the login URL in their browser / iOA. No `GITLAB_URL` needed. (Headless @@ -140,16 +140,16 @@ Summarize the outcome **in the user's own language** (global rule 1). Cover: session produced something worth sharing, TeamAI **prompts them on its own** (at the end of the session) and the `share` workflow (`teamai skill get share`) takes over to summarize and contribute it. They do **not** invoke `/teamai` for this. (This - prompt only appears when recall is on for the team — it is off by default, - `teamai recall enable` turns it on — and the admin has not switched the + prompt only appears when recall is on — it is off by default; the admin turns it + on in `teamai.yaml` (`sharing.recall.enabled`), a member with `teamai recall enable` — and the admin has not switched the reminder off in `teamai.yaml`.) 3. **They can also contribute a skill — just ask in plain language.** A member does not need to be an admin to publish a skill. They tell TeamAI something like - *"share this xxx skill with my team"* / *"把这个 xxx skill 分享给团队"*, and you + *"share this xxx skill with my team"*, in their own language, and you run the publish for them (see `$(teamai skill path core)/references/contribute-member.md`; it needs no recall). 4. **How to leave — via the skill, not raw commands.** They can remove TeamAI any time by re-invoking the skill; you'll run it for them: - `/teamai 卸载` / `/teamai Uninstall TeamAI`. + `/teamai Uninstall TeamAI` (in their language; the `/teamai` prefix stays as-is). One line, in their language: *"This only removes things from your machine; the team repo stays — rejoin any time with `/teamai` and the repo URL."* diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index 4a8faee9..d28eaf44 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -120,7 +120,8 @@ has no session-start hook, they run `teamai pull` manually. ## Capture a lesson learned Turning a tricky fix into team knowledge is **automatic** once recall is on for -the team (`teamai recall enable`; it is off by default): at the end of a session +the team (`sharing.recall.enabled: true` in `teamai.yaml`, then `teamai push`; it is +off by default, and `teamai recall enable` turns it on for one machine only): at the end of a session worth sharing, TeamAI prompts the member and the dedicated `share` workflow (`teamai skill get share`) summarizes the session and runs `teamai contribute`. Nobody has to invoke it by hand. @@ -129,7 +130,7 @@ can do it, see `$(teamai skill path core)/references/contribute-member.md`.) ### Turn the sharing prompt on or off (admin) -The auto-share prompt is **on by default**. To disable it team-wide, set this in +The auto-share prompt is **on by default once recall is on**. To disable it team-wide, set this in `teamai.yaml` and `teamai push`: ```yaml diff --git a/skill-data/setup/references/provider-tgit.md b/skill-data/setup/references/provider-tgit.md index ec7e70ff..644124c8 100644 --- a/skill-data/setup/references/provider-tgit.md +++ b/skill-data/setup/references/provider-tgit.md @@ -1,4 +1,4 @@ -# Provider: Tencent TGit (工蜂) +# Provider: Tencent TGit git.woa.com is **Tencent-internal only**. TeamAI supports it natively as the `tgit` provider — it recognizes the host on its own, so you **never** set `GITLAB_URL`. @@ -10,7 +10,7 @@ the `gf` login; follow the relevant section for whichever flow you are in. When an admin is choosing a platform and hasn't named one, check whether this machine can reach TGit. A request to git.woa.com that returns the header -`x-env: tgit` means TGit (工蜂) is reachable — plain reachability is not enough, +`x-env: tgit` means TGit is reachable — plain reachability is not enough, the header is what confirms it: ```bash diff --git a/skill-data/setup/references/setup-admin.md b/skill-data/setup/references/setup-admin.md index 1a5382a0..dcaad4d9 100644 --- a/skill-data/setup/references/setup-admin.md +++ b/skill-data/setup/references/setup-admin.md @@ -24,13 +24,13 @@ through these sub-steps **in order**: ### 2a — Ask which platform they know -**Tencent-internal first:** before asking, probe whether TGit (工蜂) is reachable +**Tencent-internal first:** before asking, probe whether TGit is reachable on this machine — see `{SKILL_DIR}/references/provider-tgit.md` ("Probe -reachability") for the one-line `x-env: tgit` check. If it says `tgit: OK`, **list Tencent TGit (工蜂) first** and +reachability") for the one-line `x-env: tgit` check. If it says `tgit: OK`, **list Tencent TGit first** and prefer it. Then ask: *"Have you heard of / do you have an account on any of these — -Tencent TGit (工蜂), GitHub, GitLab, or CNB (cnb.cool)?"* +Tencent TGit, GitHub, GitLab, or CNB (cnb.cool)?"* -- **Tencent TGit (工蜂)** — https://git.woa.com (Tencent-internal only; shown +- **Tencent TGit** — https://git.woa.com (Tencent-internal only; shown first when the probe above says `tgit: OK`) - **GitHub** — https://github.com - **GitLab** — https://gitlab.com (or a self-hosted company GitLab) @@ -51,7 +51,7 @@ curl -sSf -m 3 -o /dev/null https://gitlab.com && echo "gitlab: OK" || echo "g curl -sSf -m 3 -o /dev/null https://cnb.cool && echo "cnb: OK" || echo "cnb: unreachable" ``` -- **TGit reachable (`tgit: OK`)** → prefer Tencent TGit (工蜂); it is the +- **TGit reachable (`tgit: OK`)** → prefer Tencent TGit; it is the Tencent-internal default. - **Exactly one reachable** → use that one. - **Several reachable** → list them (TGit first when present) and let the user pick. @@ -67,12 +67,12 @@ the repository, then continue to the next step: | Platform | Sign in / sign up | Create a new repo (do this) | |----------|------------------------------|------------------------------------| -| Tencent TGit (工蜂) | https://git.woa.com | https://git.woa.com/projects/new | +| Tencent TGit | https://git.woa.com | https://git.woa.com/projects/new | | GitHub | https://github.com/login | https://github.com/new | | GitLab | https://gitlab.com/users/sign_in | https://gitlab.com/projects/new | | CNB | https://cnb.cool | https://cnb.cool/new/repos (org first: https://cnb.cool/new/groups) | -> **Tencent TGit (工蜂):** don't send the user to the browser to create the repo — +> **Tencent TGit:** don't send the user to the browser to create the repo — > prefer letting `teamai init` create it via the API in Step 5. See > `{SKILL_DIR}/references/provider-tgit.md` ("When you `teamai init` on TGit"). @@ -91,7 +91,7 @@ computer only holds a synced copy — you never put business code in it."* Signing in on the website (Step 2c) is not enough — `teamai init` also needs the platform's CLI credentials. Have the user complete the matching CLI login: -### Tencent TGit (工蜂) +### Tencent TGit See `{SKILL_DIR}/references/provider-tgit.md` ("Log in") — you install `gf` and run `gf auth login` yourself; the user only approves in the browser / iOA. No `GITLAB_URL` needed. @@ -172,7 +172,7 @@ teamai init https://<platform>/<org>/<repo-name> --scope user If the repo does not exist yet, `init` offers to create it — accept the prompt. -- **Tencent TGit (工蜂):** `gf` and login are already done, so init creates the +- **Tencent TGit:** `gf` and login are already done, so init creates the repo via the API when it's missing — see `{SKILL_DIR}/references/provider-tgit.md` ("When you `teamai init` on TGit"). - **CNB caveat:** a `cnb login` token **cannot create** an org or repo — that is @@ -227,7 +227,7 @@ read/write access to it on the platform website**, or their `teamai init` / `pul Tell the admin (in their language) to add every member on the repo's website: -- **Tencent TGit (工蜂):** repo → 成员管理 / Members → add each member with at +- **Tencent TGit:** repo → Members → add each member with at least **Developer** (read/write) access. - **GitHub:** repo → Settings → Collaborators → add with **Write**. - **GitLab:** repo → Settings → Members → add with **Developer** or above. @@ -249,10 +249,8 @@ carries counts + tool names only, on a separate branch of that same repo.) 1. Give the user their **repo web URL** to share. 2. Give them a ready-to-forward invite line **written in their language**, with the - URL filled in. The `/teamai` prefix stays as-is; translate the rest. For a - Chinese-speaking user, that is: - `/teamai 帮我加入团队的 TeamAI,仓库地址是 <URL>` - (English user: `/teamai Help me join my team's TeamAI, repo URL is <URL>`.) + URL filled in. The `/teamai` prefix stays as-is; translate the rest: + `/teamai Help me join my team's TeamAI, repo URL is <URL>` Tell them to send the URL + this line to each member. 3. Remind them (in their language): **new resources appear only after opening a fresh session** in the AI tool. Right after init the skills folder may look @@ -266,19 +264,13 @@ The user may not be comfortable with the command line, so **don't just hand them list of `teamai …` commands.** Instead, point them back to *this skill* for day-to-day work — they can keep letting the AI run things for them: -- To manage the team later, they run: - `/teamai 我已经装好了,帮我管理` (Chinese) / - `/teamai I already have TeamAI set up, help me manage it` (English) — this loads +- To manage the team later, they run (in their language): + `/teamai I already have TeamAI set up, help me manage it` — this loads the daily-management flow (`{SKILL_DIR}/references/manage-admin.md`): publishing skills, inviting members, roles / packages / env. -- To share a reusable skill with the team, they run: - `/teamai 把这个 xxx skill 分享给团队` (Chinese) / - `/teamai Share this <skill-name> skill with my team` (English) — see - `contribute-member.md`. -- Sharing a **session's learnings** is **automatic** — do **not** send them to - `/teamai` for it. TeamAI prompts on its own at the end of a session worth - sharing, and the separate **`teamai-share-learnings`** skill takes over. (Only - when the admin left team sharing on — the default.) +- To share something they learned, once recall is on (off by default; turn it on + team-wide with `sharing.recall.enabled: true` in `teamai.yaml`, then `teamai push`): + `/teamai I want to contribute what I learned to my team`. Mention the underlying commands (`teamai push`, `teamai roles`, …) only as a note for users who *do* want them — the primary path is re-invoking `/teamai`. @@ -289,7 +281,7 @@ Finish by telling the user, **in their language**, that they can remove TeamAI a time — and that they don't need the command line to do it. They just re-invoke the skill and you'll handle it: -`/teamai 卸载` (Chinese) / `/teamai Uninstall TeamAI` (English) +`/teamai Uninstall TeamAI` (in their language; the `/teamai` prefix stays as-is) One line, in their language: *"That removes the hooks and synced resources from your machine; your team repo on the website is untouched — you can rejoin any time diff --git a/skill-data/share/SKILL.md b/skill-data/share/SKILL.md index 8e7dbf9c..5a8be62d 100644 --- a/skill-data/share/SKILL.md +++ b/skill-data/share/SKILL.md @@ -63,7 +63,7 @@ is for turning a *session* into a learning. ## References -In the files below, `{SKILL_DIR}` is the directory `teamai skill path share` prints. +In the files below, `{SKILL_DIR}` is the directory `teamai skill path share` prints; a reference file you open on its own writes that directory as `SKILL_DIR` in braces. | File | When to load it | |---|---| diff --git a/skill-data/wiki/SKILL.md b/skill-data/wiki/SKILL.md index 18c6eaa8..1c8a94fc 100644 --- a/skill-data/wiki/SKILL.md +++ b/skill-data/wiki/SKILL.md @@ -16,7 +16,8 @@ allowed-tools: Bash(teamai:*), Bash(npx teamai-cli:*), Bash(python3:*) > Prerequisites: an accessible source directory (multiple repositories supported), Python 3, and an installed teamai CLI. > The methodology, sub-agent prompts, templates and scripts ship with the CLI. Run `teamai skill path wiki` to get their absolute path; -> `{SKILL_DIR}` in this document refers to that path. +> `{SKILL_DIR}` in this document refers to that path; a reference file you open on its own writes that directory as `SKILL_DIR` in braces. +> Write documents in the language the user works in. When updating an existing knowledge base, keep its file names and headings; `validate_kb.py` accepts both the current English and the earlier Chinese headings. > The Phase 0 structural baseline uses `teamai codebase --extract`. TeamAI does not ship a separate team-wiki CLI. No extra plugin is required. **The problem**: large projects (10+ repositories, dozens of microservices, years of iteration) defeat global understanding by AI. The context window cannot hold all the code, component relations are scattered everywhere, and business rules hide deep in call chains. Letting AI read the code directly is both slow (huge token counts) and inaccurate (no global view). diff --git a/skills/teamai/SKILL.md b/skills/teamai/SKILL.md index 658f6516..36fdfc00 100644 --- a/skills/teamai/SKILL.md +++ b/skills/teamai/SKILL.md @@ -4,9 +4,10 @@ description: >- Make every team AI native — TeamAI syncs a team's AI skills, rules, docs and env across AI coding tools. Use when the task operates on team-shared AI configuration or team knowledge: setting up a team repo, joining one, managing members, syncing with pull or push, or checking team status. - Also use to build or query a codebase knowledge base for a large multi-repo project (架构分析, - 架构逆向, 代码知识库, code-to-knowledge, team-wiki-codebase, architecture wiki), and to share what a session taught you - back to the team (分享 Session 经验, contribute a learning, share this with my team), including + Also use to build or query a codebase knowledge base for a large multi-repo project (architecture + analysis, architecture reverse-engineering, code-to-knowledge, team-wiki-codebase, architecture wiki), + and to share what a session taught you back to the team (share session learnings, contribute a + learning, share what I learned with my team), including after a friction reminder. Triggers include "set up teamai", "join the team repo", "sync team skills", "team wiki", "share what I learned", and running /teamai. Talking about a team needs no skill; operating on what the team shares does. @@ -17,7 +18,7 @@ allowed-tools: Bash(teamai skill:*), Bash(npx teamai-cli skill:*) Make every team AI native — one shared foundation for the skills, rules, docs and env a team works with. -Install: `npm i -g teamai-cli` (Node.js >= 20) +Install: `npm i -g teamai-cli@latest` (Node.js >= 20). If `teamai skill get` is not recognised, the installed CLI predates it; upgrade the same way. ## Start here @@ -35,9 +36,11 @@ The CLI serves skill content that always matches the installed version, so instr ```bash teamai skill get setup # day 0: create a team repo (admin) or join one (member), manage, uninstall teamai skill get wiki # large multi-repo codebase: architecture reverse-engineering and knowledge base -teamai skill get share # turn what this session taught you into a team learning +teamai skill get share # turn what this session taught you into a team learning (needs recall on) ``` +Publishing a skill, rule or doc the user already has is in `core`; it needs no recall. + A friction reminder at the end of a turn means `teamai skill get share`. `teamai skill list` shows everything the installed version serves. `teamai skill path <name>` prints the directory holding a skill's scripts and templates. diff --git a/src/__tests__/commands-reference.test.ts b/src/__tests__/commands-reference.test.ts index 6b780547..4485bd79 100644 --- a/src/__tests__/commands-reference.test.ts +++ b/src/__tests__/commands-reference.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { renderCommandsReference, COMMANDS_REFERENCE_PATH } from '../commands-reference.js'; @@ -9,7 +9,7 @@ describe('generated command reference', () => { it('matches the CLI command table', async () => { // Guard the CLI entry so importing it yields the command table instead of // parsing this test run's argv. - process.env.TEAMAI_COMMAND_TABLE_ONLY = '1'; + vi.stubEnv('TEAMAI_COMMAND_TABLE_ONLY', '1'); const { program } = await import('../index.js'); // Regenerate with `npx vitest run commands-reference -u` when a command, diff --git a/src/__tests__/e2e/skill-serving-cli.test.ts b/src/__tests__/e2e/skill-serving-cli.test.ts index ef20e719..9a2b0af8 100644 --- a/src/__tests__/e2e/skill-serving-cli.test.ts +++ b/src/__tests__/e2e/skill-serving-cli.test.ts @@ -205,9 +205,9 @@ describe('teamai skill recall gate CLI (e2e)', () => { const listed = run('skill', 'list', '--json'); expect(listed.status).toBe(0); - const catalog = JSON.parse(listed.stdout) as { skills: Array<{ name: string; path: string | null; blockedByRecall: boolean }> }; - expect(catalog.skills.find((s) => s.name === 'share')).toMatchObject({ blockedByRecall: true, path: null }); - expect(catalog.skills.filter((s) => s.name !== 'share').every((s) => !s.blockedByRecall && s.path !== null)).toBe(true); + const catalog = JSON.parse(listed.stdout) as { skills: Array<{ name: string; path: string | null; blockedBy: string | null }> }; + expect(catalog.skills.find((s) => s.name === 'share')).toMatchObject({ blockedBy: 'recall', path: null }); + expect(catalog.skills.filter((s) => s.name !== 'share').every((s) => s.blockedBy === null && s.path !== null)).toBe(true); const enable = run('recall', 'enable'); expect(enable.status, enable.stderr).toBe(0); @@ -216,8 +216,8 @@ describe('teamai skill recall gate CLI (e2e)', () => { expect(run('skill', 'get', '--all').stdout.match(/^name: /gm)).toHaveLength(4); const servedDir = run('skill', 'path', 'share').stdout.trim(); expect(fs.existsSync(path.join(servedDir, 'SKILL.md'))).toBe(true); - expect(run('skill', 'show', 'share').stdout).toContain(`Package path : ${servedDir}/`); - const after = JSON.parse(run('skill', 'list', '--json').stdout) as { skills: Array<{ name: string; path: string | null; blockedByRecall: boolean }> }; - expect(after.skills.find((s) => s.name === 'share')).toMatchObject({ blockedByRecall: false, path: servedDir }); + expect(run('skill', 'show', 'share').stdout).toContain(`Package dir : ${servedDir}/`); + const after = JSON.parse(run('skill', 'list', '--json').stdout) as { skills: Array<{ name: string; path: string | null; blockedBy: string | null }> }; + expect(after.skills.find((s) => s.name === 'share')).toMatchObject({ blockedBy: null, path: servedDir }); }); }); diff --git a/src/__tests__/skill-commands-exist.test.ts b/src/__tests__/skill-commands-exist.test.ts index 8a232732..f4180f57 100644 --- a/src/__tests__/skill-commands-exist.test.ts +++ b/src/__tests__/skill-commands-exist.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -109,7 +109,7 @@ function validate(program: Command, invocations: Invocation[]): string[] { describe('commands named by the served skill content', () => { it('all exist in the CLI command table', async () => { - process.env.TEAMAI_COMMAND_TABLE_ONLY = '1'; + vi.stubEnv('TEAMAI_COMMAND_TABLE_ONLY', '1'); const { program } = await import('../index.js'); const problems = validate(program, collectInvocations()); @@ -117,7 +117,7 @@ describe('commands named by the served skill content', () => { }); it('catches the drift it exists to catch', async () => { - process.env.TEAMAI_COMMAND_TABLE_ONLY = '1'; + vi.stubEnv('TEAMAI_COMMAND_TABLE_ONLY', '1'); const { program } = await import('../index.js'); // `teamai extract graph` is the command the wiki skill advertised until this diff --git a/src/__tests__/skill-content.test.ts b/src/__tests__/skill-content.test.ts index a92f5dad..2347acda 100644 --- a/src/__tests__/skill-content.test.ts +++ b/src/__tests__/skill-content.test.ts @@ -186,7 +186,7 @@ describe('skillCatalog', () => { writeSkill(roots.dataRoot, 'core', '---\nname: core\ndescription: Daily sync\n---\n\n# core\n'); const catalog = await skillCatalog(roots); expect(catalog).toEqual([ - { name: 'core', description: 'Daily sync', path: path.join(roots.dataRoot, 'core'), deployed: false, blockedByRecall: false }, + { name: 'core', description: 'Daily sync', path: path.join(roots.dataRoot, 'core'), deployed: false, blockedBy: null }, ]); } finally { fs.rmSync(roots.tmp, { recursive: true, force: true }); @@ -342,6 +342,21 @@ describe('the shipped skill-data content', () => { expect(offenders).toEqual([]); }); + it('ships no Chinese text in the deployed stub or the served content', async () => { + // Both reach the agent as CLI output (`teamai skill get` prints skill-data/), + // which the repo rule keeps English; the agent translates for the user. + const offenders: string[] = []; + for (const root of ['skills', 'skill-data']) { + for (const relative of await listFilesRecursive(path.join(ROOT, root))) { + const text = fs.readFileSync(path.join(ROOT, root, relative), 'utf8'); + text.split('\n').forEach((line, i) => { + if (/[\u3000-\u303f\u3400-\u9fff\uf900-\ufaff\uff00-\uffef]/.test(line)) offenders.push(`${root}/${relative}:${i + 1}`); + }); + } + } + expect(offenders).toEqual([]); + }); + it('keeps the stub description within the 1024-character budget agents load it under', async () => { // With one deployed skill, this description is the only text an agent sees // at selection time, and hosts cap it at 1024 characters. diff --git a/src/__tests__/skill-recall-gate.test.ts b/src/__tests__/skill-recall-gate.test.ts index cc39cbcd..a0027877 100644 --- a/src/__tests__/skill-recall-gate.test.ts +++ b/src/__tests__/skill-recall-gate.test.ts @@ -91,7 +91,7 @@ describe('recall gate on served skills', () => { expect(stderr).toContain('share needs recall'); const share = (await skillCatalog()).find((entry) => entry.name === 'share'); - expect(share).toMatchObject({ blockedByRecall: true, path: null }); + expect(share).toMatchObject({ blockedBy: 'recall', path: null }); }); it('serves the share directory through skill path and the catalog when recall is enabled', async () => { @@ -102,7 +102,24 @@ describe('recall gate on served skills', () => { expect(stdout.trim()).toMatch(/skill-data[\\/]share$/); const share = (await skillCatalog()).find((entry) => entry.name === 'share'); - expect(share).toMatchObject({ blockedByRecall: false, path: stdout.trim() }); + expect(share).toMatchObject({ blockedBy: null, path: stdout.trim() }); + }); + + it('withholds share from a read-only HTTP team, whose `teamai contribute` always refuses', async () => { + // Recall on, so only the source decides: the workflow's last step would fail + // after the agent had written the whole learning. + autoDetectInit.mockResolvedValue({ + localConfig: { recallEnabled: true, repo: { kind: 'http', localPath: '/tmp', remote: '' } }, + teamConfig: { sharing: { recall: { enabled: true } } }, + }); + + expect(await resolveServableSkill('share')).toEqual({ kind: 'blocked', name: 'share', reason: 'read-only' }); + await skillGet(['share']); + expect(process.exitCode).toBe(1); + expect(stdout).toBe(''); + expect(stderr).toContain('read-only HTTP source'); + expect(stderr).not.toContain('teamai recall enable'); + expect((await skillCatalog()).find((entry) => entry.name === 'share')).toMatchObject({ blockedBy: 'read-only', path: null }); }); it('never gates the skills that do not depend on recall', async () => { diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index d3a40ca5..04968551 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; import fse from 'fs-extra'; +import { listFilesRecursive } from '../utils/fs.js'; const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); @@ -787,6 +788,61 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { expect(await fse.readFile(path.join(outside, 'SKILL.md'), 'utf8')).toBe('# not ours'); }); + it('deploys and prunes through a linked agent directory, the stow / chezmoi layout', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + // Every other resource the sync writes goes through a linked ~/.claude; the + // stub and the migration must not be the ones left behind on those machines. + const dotfiles = path.join(tmpDir, 'dotfiles/claude'); + await fse.ensureDir(path.join(dotfiles, 'skills/team-wiki-codebase')); + await fse.writeFile(path.join(dotfiles, 'skills/team-wiki-codebase/SKILL.md'), '# pre-stub'); + await fse.remove(path.join(homeDir, '.claude')); + await fse.symlink(dotfiles, path.join(homeDir, '.claude'), 'dir'); + + const deployed = await deployBuiltinSkills(legacyPruneTeamConfig(), legacyPruneLocalConfig(tmpDir)); + + expect(deployed).toBe(1); + expect(await fse.pathExists(path.join(dotfiles, 'skills/teamai/SKILL.md'))).toBe(true); + expect(await fse.pathExists(path.join(dotfiles, 'skills/team-wiki-codebase'))).toBe(false); + }); + + it('keeps a member\'s file under a __pycache__ that is not bytecode of a shipped script', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + const wiki = path.join(homeDir, '.claude/skills/team-wiki-codebase'); + await fse.ensureDir(path.join(wiki, 'scripts/__pycache__')); + await fse.ensureDir(path.join(wiki, 'notes/__pycache__')); + await fse.writeFile(path.join(wiki, 'SKILL.md'), '# packaged'); + await fse.writeFile(path.join(wiki, 'scripts/__pycache__/scan_repo.cpython-311.pyc'), 'bytecode'); + await fse.writeFile(path.join(wiki, 'notes/__pycache__/keep.txt'), '# mine'); + + await deployBuiltinSkills(legacyPruneTeamConfig(), legacyPruneLocalConfig(tmpDir)); + + expect(await fse.pathExists(path.join(wiki, 'scripts/__pycache__/scan_repo.cpython-311.pyc'))).toBe(false); + expect(await fse.readFile(path.join(wiki, 'notes/__pycache__/keep.txt'), 'utf8')).toBe('# mine'); + }); + + it('keeps both copies when the user and the project scope prune the same skill in one run', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + // `inheritUserScope`: user base, then project base, same tool, root and name. + const projectRoot = path.join(tmpDir, 'work/proj'); + const projectConfig = { ...legacyPruneLocalConfig(tmpDir), scope: 'project' as const, projectRoot }; + for (const [base, body] of [[homeDir, '# user copy'], [projectRoot, '# project copy']]) { + await fse.ensureDir(path.join(base, '.claude/skills/team-wiki-codebase')); + await fse.writeFile(path.join(base, '.claude/skills/team-wiki-codebase/SKILL.md'), body); + } + + await deployBuiltinSkills(legacyPruneTeamConfig(), legacyPruneLocalConfig(tmpDir)); + await deployBuiltinSkills(legacyPruneTeamConfig(), projectConfig); + + expect(await fse.pathExists(path.join(projectRoot, '.claude/skills/team-wiki-codebase'))).toBe(false); + const archived = (await listFilesRecursive(path.join(homeDir, '.teamai/removed-skills'))) + .filter((f) => f.endsWith('team-wiki-codebase/SKILL.md')); + const bodies = await Promise.all(archived.map((f) => fse.readFile(path.join(homeDir, '.teamai/removed-skills', f), 'utf8'))); + expect(bodies.sort()).toEqual(['# project copy', '# user copy']); + }); + it('archives nothing when there is nothing retired to archive', async () => { const { deployBuiltinSkills } = await import('../builtin-skills.js'); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 9101c796..64df0bda 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -1066,6 +1066,22 @@ describe('uninstall', () => { expect(await fse.pathExists(path.join(stubDir, 'SKILL.md'))).toBe(true); }); + it('does not delete through a linked skills root, the same line pull stops at', async () => { + const { homeDir, repoPath } = await setupFixture(tmpDir); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + // The member's dotfiles checkout, linked in as ~/.claude/skills. + const dotfiles = path.join(tmpDir, 'dotfiles-skills'); + await fse.move(path.join(homeDir, '.claude', 'skills'), dotfiles); + await fse.symlink(dotfiles, path.join(homeDir, '.claude', 'skills'), 'dir'); + + const localConfig = makeLocalConfig(homeDir, repoPath); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig: makeTeamConfig() }); + await uninstall({ force: true }); + + expect(await fse.pathExists(path.join(dotfiles, 'teamai', 'SKILL.md'))).toBe(true); + }); + it('removes the stub Codex kept in the shared .agents/skills root, and nothing else there', async () => { const { homeDir, repoPath } = await setupFixture(tmpDir); vi.stubEnv('HOME', homeDir); diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index 1587cd1d..18ceca6d 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -40,8 +40,8 @@ export const BUILTIN_SKILL_NAMES = new Set(['teamai']); /** * Built-in skill directories earlier releases deployed, kept only so that pull - * can remove them from agent skills directories. Retire this set once 0.23.x is - * no longer in the field. + * can remove them from agent skills directories. Retire this set once 0.25.x, + * the last release to deploy them, is no longer in the field. * * Only names the CLI actually wrote belong here. `teamai-workflow` and * `teamai-import` were reserved in the old BUILTIN_SKILL_NAMES guard but never @@ -72,9 +72,8 @@ export function isCliOwnedSkillName(name: string): boolean { /** * Every file a release ever packaged under `skills/`, by directory name. * - * Built as the union of `git ls-tree -r <tag> -- skills/` over all 98 tags, - * minus `teamai-wiki` (below), plus `references/provider-tgit.md`, which main - * carries unreleased and the next release therefore ships. Every path listed + * Built as the union of `git ls-tree -r <tag> -- skills/` over all 99 tags + * through v0.25.0, minus `teamai-wiki` (below). Every path listed * here is written by the CLI and is ours to remove. Deployment * copied these trees with `overwrite: true` and never deleted anything, so a * file that is *not* listed here was put there by the member and survives. @@ -111,12 +110,16 @@ export const PACKAGED_SKILL_FILES: ReadonlyMap<string, readonly string[]> = new ]); /** - * Python bytecode cache of a script we shipped. Compiler output of our own - * files, so it carries nothing a member wrote and does not make a directory - * theirs. + * Python bytecode cache of a script we shipped: `a/__pycache__/x.cpython-311.pyc` + * for an owned `a/x.py`. Compiler output of our own file, so it carries nothing + * a member wrote and does not make a directory theirs. Anything else under a + * `__pycache__` is not ours to remove. */ -function isDerivedArtifact(relativePath: string): boolean { - return relativePath.endsWith('.pyc') || relativePath.split('/').includes('__pycache__'); +function isDerivedArtifact(relativePath: string, owned: ReadonlySet<string>): boolean { + const parts = relativePath.split('/'); + if (parts.length < 2 || parts[parts.length - 2] !== '__pycache__' || !relativePath.endsWith('.pyc')) return false; + const stem = parts[parts.length - 1].split('.')[0]; + return owned.has([...parts.slice(0, -2), `${stem}.py`].join('/')); } /** @@ -155,7 +158,7 @@ async function removeEmptyDirs(dir: string): Promise<void> { /** What `removeOwnedFiles` did and did not do, for the caller to report. */ export interface PruneResult { - /** True when a link sits between the base and the root: nothing was touched. */ + /** True when the skills root or the skill directory is a link: nothing was touched. */ skippedSymlink: boolean; /** Files left in place because the member, not the CLI, put them there. */ foreign: number; @@ -188,18 +191,17 @@ export async function removeOwnedFiles( dir: string, owned: readonly string[], backupDir?: string, - baseDir?: string, ): Promise<PruneResult> { const ownedPaths = new Set(owned); const result: PruneResult = { skippedSymlink: false, foreign: 0, unbackedUp: [], notRemoved: [], backedUp: 0, }; - // A link anywhere between the base directory and this one points at files we - // never wrote — a shared checkout, a dotfiles repo. `readdir` follows it and - // every path under it matches ours by name, so the walk would delete someone - // else's files through the link. Ownership stops at the first link. - if (baseDir ? await crossesSymlink(baseDir, dir) : (await fs.promises.lstat(dir)).isSymbolicLink()) { + // A linked skills root or skill directory points at files we never wrote — a + // shared checkout, a dotfiles repo. `readdir` follows it and every path under + // it matches ours by name, so the walk would delete someone else's files + // through the link. Ownership stops at the first link. + if (await reachedThroughLink(dir)) { result.skippedSymlink = true; return result; } @@ -215,7 +217,7 @@ export async function removeOwnedFiles( } for (const relative of entries) { - if (!ownedPaths.has(relative) && !isDerivedArtifact(relative)) { + if (!ownedPaths.has(relative) && !isDerivedArtifact(relative, ownedPaths)) { result.foreign++; continue; } @@ -254,23 +256,20 @@ export async function removeOwnedFiles( } /** - * True when any path component between `baseDir` and `target` is a symlink. + * True when `skillDir` (`<agent dir>/<skills root>/<skill>`) or its skills root + * is a symlink. * - * Checking `target` alone is not enough: a member who links `~/.claude/skills` - * at a dotfiles checkout leaves every skill directory under it a real - * directory, so `lstat` on one says nothing. Components at or above `baseDir` - * are not checked — a home directory that itself sits under a link is ordinary, - * and refusing there would disable deployment for those machines. + * Checking the skill directory alone is not enough: a member who links + * `~/.claude/skills` at a dotfiles checkout leaves every skill directory under + * it a real directory, so `lstat` on one says nothing. The agent directory and + * everything above it are not checked: a linked `~/.claude` (stow, chezmoi) or + * home is ordinary, every other resource the sync writes goes through it too, + * and refusing there would leave those machines on the pre-stub trees forever. */ -async function crossesSymlink(baseDir: string, target: string): Promise<boolean> { - const relative = path.relative(baseDir, target); - if (relative.startsWith('..') || path.isAbsolute(relative)) return true; - - let walked = baseDir; - for (const segment of relative.split(path.sep).filter(Boolean)) { - walked = path.join(walked, segment); +async function reachedThroughLink(skillDir: string): Promise<boolean> { + for (const candidate of [path.dirname(skillDir), skillDir]) { try { - if ((await fs.promises.lstat(walked)).isSymbolicLink()) return true; + if ((await fs.promises.lstat(candidate)).isSymbolicLink()) return true; } catch { return false; // does not exist yet: nothing to walk through } @@ -329,7 +328,7 @@ export async function pruneLegacyBuiltinSkills( if (!await pathExists(dir)) continue; try { const backupDir = skillBackupDir(baseDir, tool, root, legacyName); - const result = await removeOwnedFiles(dir, PACKAGED_SKILL_FILES.get(legacyName) ?? [], backupDir, baseDir); + const result = await removeOwnedFiles(dir, PACKAGED_SKILL_FILES.get(legacyName) ?? [], backupDir); const saved = result.backedUp > 0 ? `; a copy is in ${backupDir}` : ''; if (prunedWhole(result)) { log.debug(`Removed legacy built-in skill ${legacyName} from ${tool} (${dir})${saved}`); @@ -429,7 +428,7 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? // A symlinked destination points somewhere we do not own. Writing // through it would put the stub outside the agent directory, which is // the same reason the prune refuses to walk it. Neither step runs. - if (await crossesSymlink(baseDir, destDir)) { + if (await reachedThroughLink(destDir)) { log.warn(`Skipped ${skillName} (${tool}): ${destDir} is reached through a symlink, and TeamAI does not write through one. Remove the link to let the skill deploy.`); continue; } @@ -445,7 +444,7 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? const shippedNow = new Set(await walkFiles(srcDir)); const retired = (PACKAGED_SKILL_FILES.get(skillName) ?? []).filter((p) => !shippedNow.has(p)); const backupDir = skillBackupDir(baseDir, tool, path.relative(baseDir, path.dirname(destDir)), skillName); - const result = await removeOwnedFiles(destDir, retired, backupDir, baseDir); + const result = await removeOwnedFiles(destDir, retired, backupDir); if (result.unbackedUp.length > 0) { log.warn(`Kept ${result.unbackedUp.length} file(s) under ${destDir}: their backup could not be written, so they were not removed. First: ${result.unbackedUp[0].file} — ${result.unbackedUp[0].error}`); } diff --git a/src/index.ts b/src/index.ts index 8838ff0e..ecc72097 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1254,7 +1254,7 @@ async function publishMaintenance(localConfig: LocalConfig, message: string): Pr * The command table doubles as the source of truth for the generated skill * command reference (skill-data/core/references/commands.md). Importing this * module with TEAMAI_COMMAND_TABLE_ONLY set yields `program` without running - * the CLI. + * the CLI. Test-only: the two tests that read the table set it. */ export { program }; diff --git a/src/recall-toggle.ts b/src/recall-toggle.ts index e2d11158..08f71751 100644 --- a/src/recall-toggle.ts +++ b/src/recall-toggle.ts @@ -41,7 +41,10 @@ async function removeRecallArtifacts(teamConfig: TeamaiConfig, localConfig: Loca // Remove the legacy recall skill an earlier release deployed. The served // `share` workflow is gated at run time, but a member who upgrades and // disables recall before pulling still has the old directory. - if (toolPath.skills && !isAgentExcluded(localConfig, tool)) { + // Same gates as deployment: an uninstalled Codex must not have the shared + // .agents/skills root pruned on its behalf. + if (toolPath.skills && !isAgentExcluded(localConfig, tool) + && await isToolInstalledForConfig(tool, toolPath.skills, localConfig)) { await pruneLegacyBuiltinSkills(tool, toolPath.skills, baseDir, LEGACY_RECALL_SKILL_NAMES); } diff --git a/src/skill-cmd.ts b/src/skill-cmd.ts index 7da99e53..3a437fe8 100644 --- a/src/skill-cmd.ts +++ b/src/skill-cmd.ts @@ -13,7 +13,7 @@ import { type SkillSource, } from './agent-skills.js'; import { detectInstalledAgents, type ResolvedAgent } from './known-agents.js'; -import { recallBlockMessage, resolveServableSkill, skillCatalog } from './skill-content.js'; +import { blockMessage, resolveServableSkill, skillCatalog, type SkillBlockReason } from './skill-content.js'; import type { GlobalOptions, LocalConfig, TeamaiConfig } from './types.js'; const DESCRIPTION_MAX = 160; @@ -29,10 +29,11 @@ interface ResolvedSkill { namespace?: string; } -/** A packaged skill the recall gate withholds; there is no path to print. */ +/** A packaged skill the serving gate withholds; there is no path to print. */ interface BlockedSkill { kind: 'blocked'; name: string; + reason: SkillBlockReason; } type LocatedSkill = ResolvedSkill | BlockedSkill; @@ -47,16 +48,15 @@ type LocatedSkill = ResolvedSkill | BlockedSkill; * we print under "Repo path" or "Installed in". */ export async function skillShow(name: string, options: GlobalOptions): Promise<void> { - // A packaged skill needs no team: it ships with the CLI. Resolving it first - // keeps `teamai skill show core` working on a machine that has never run - // `teamai init`, where autoDetectInit has nothing to find. - const packaged = await resolveServableSkill(name); let init: { localConfig: LocalConfig; teamConfig: TeamaiConfig }; try { init = await autoDetectInit(); - } catch (e) { + } catch { + // A packaged skill needs no team: it ships with the CLI, so `teamai skill + // show core` still works on a machine that has never run `teamai init`. + const packaged = await resolveServableSkill(name); if (packaged.kind === 'blocked') { - const { headline, hint } = recallBlockMessage(packaged.name); + const { headline, hint } = blockMessage(packaged.name, packaged.reason); log.error(headline); log.dim(hint); process.exitCode = 1; @@ -94,7 +94,7 @@ export async function skillShow(name: string, options: GlobalOptions): Promise<v // The resolver never hands out a blocked skill, so there is no directory to // print here even by accident; only the refusal is left to do. if (located.kind === 'blocked') { - const { headline, hint } = recallBlockMessage(located.name); + const { headline, hint } = blockMessage(located.name, located.reason); log.error(headline); log.dim(hint); process.exitCode = 1; @@ -170,7 +170,9 @@ export async function skillList(options: GlobalOptions & { json?: boolean }): Pr console.log(' (none — the installed package ships no skill content)'); } else { for (const entry of catalog) { - console.log(` ${entry.name}${entry.blockedByRecall ? ' (needs recall — teamai recall enable)' : ''}`); + const note = entry.blockedBy === 'recall' ? ' (needs recall — teamai recall enable)' + : entry.blockedBy === 'read-only' ? ' (not available on a read-only HTTP source)' : ''; + console.log(` ${entry.name}${note}`); console.log(` ${truncate(entry.description, DESCRIPTION_MAX) || '(no description)'}`); console.log(` teamai skill get ${entry.name}`); } @@ -218,7 +220,7 @@ async function locateSkill( // so it answers for the names nothing on this machine claims: `core` and // `wiki` live in the package, and the agent directory holds only the stub. const served = await resolveServableSkill(name); - if (served.kind === 'blocked') return { kind: 'blocked', name: served.name }; + if (served.kind === 'blocked') return { kind: 'blocked', name: served.name, reason: served.reason }; if (served.kind === 'found') { return { kind: 'found', name: served.skill.name, primaryPath: served.skill.dir, primaryOrigin: 'builtin' }; } @@ -244,7 +246,7 @@ async function collectInstalledAgents( const PRIMARY_PATH_LABEL: Record<ResolvedSkill['primaryOrigin'], string> = { team: 'Repo path ', agent: 'Source path', - builtin: 'Package path', + builtin: 'Package dir', }; interface SkillCard { @@ -280,7 +282,10 @@ function printSkillCard(card: SkillCard): void { } if (card.installedIn.length === 0) { - console.log(' Installed in : (not installed in any agent yet)'); + // A served skill is never copied into an agent, so "yet" would be false. + console.log(card.primaryOrigin === 'builtin' + ? ' Installed in : (served by the CLI, not installed)' + : ' Installed in : (not installed in any agent yet)'); } else { const first = card.installedIn[0]; console.log(` Installed in : ${first.agent.id} (${first.path})`); diff --git a/src/skill-content.ts b/src/skill-content.ts index 2ce41643..92809c8c 100644 --- a/src/skill-content.ts +++ b/src/skill-content.ts @@ -50,33 +50,40 @@ const SKILL_ALIASES: Readonly<Record<string, string>> = { }; /** - * Served skills that need recall to be on. + * Served skills that write to the team repo and need recall to be on. * * `share` publishes a session's learnings into the team's learnings branch, - * which is meaningful only when recall is enabled. Before the discovery stub the - * gate was in deployment — the skill was simply absent. One stub routes to every - * workflow, so the gate moved here, where the command can also say what to turn - * on. + * which is meaningful only when recall is enabled, and impossible against a + * read-only HTTP source. Before the discovery stub both gates were in + * deployment (`skipRecall`, `reportingOnly`) — the skill was simply absent. One + * stub routes to every workflow, so the gates moved here, where the command can + * also say why. */ const RECALL_DEPENDENT_SKILLS = new Set(['share']); +/** Why a served skill is withheld right now. */ +export type SkillBlockReason = 'recall' | 'read-only'; + /** - * Whether recall being off makes this skill unusable right now. + * What makes this skill unusable right now, or null. * * Fails open: a machine with no team config (a fresh install reading the docs) * gets the content rather than a refusal it cannot act on. */ -async function blockedByRecall(name: string): Promise<boolean> { - if (!RECALL_DEPENDENT_SKILLS.has(name)) return false; +async function blockReason(name: string): Promise<SkillBlockReason | null> { + if (!RECALL_DEPENDENT_SKILLS.has(name)) return null; try { const [{ autoDetectInit }, { isRecallEnabled }] = await Promise.all([ import('./config.js'), import('./types.js'), ]); const { localConfig, teamConfig } = await autoDetectInit(); - return !isRecallEnabled(localConfig, teamConfig); + // `teamai contribute` refuses a read-only source (read-only.ts), so the + // workflow would fail at its last step after the agent did all the work. + if (localConfig.repo?.kind === 'http') return 'read-only'; + return isRecallEnabled(localConfig, teamConfig) ? null : 'recall'; } catch { - return false; + return null; } } @@ -172,13 +179,13 @@ async function resolvePackagedSkill( */ export type ServableSkillResolution = | { kind: 'found'; skill: PackagedSkill } - | { kind: 'blocked'; name: string; reason: 'recall' } + | { kind: 'blocked'; name: string; reason: SkillBlockReason } | { kind: 'not-found'; name: string }; /** * The only way to obtain a packaged skill outside this module. * - * The recall gate is applied here, once, so every command that hands out a + * The recall and read-only gates are applied here, once, so every command that hands out a * skill's content or its directory (`get`, `path`, `list`, `show`) inherits it * by construction instead of remembering to check. */ @@ -188,16 +195,29 @@ export async function resolveServableSkill( ): Promise<ServableSkillResolution> { const skill = await resolvePackagedSkill(name, roots); if (!skill) return { kind: 'not-found', name }; - if (await blockedByRecall(skill.name)) return { kind: 'blocked', name: skill.name, reason: 'recall' }; + const reason = await blockReason(skill.name); + if (reason) return { kind: 'blocked', name: skill.name, reason }; return { kind: 'found', skill }; } -/** The two lines every command prints for a recall-blocked skill. */ -export function recallBlockMessage(name: string): { headline: string; hint: string } { - return { - headline: `${name} needs recall, which is disabled for this team.`, - hint: 'Turn it on with `teamai recall enable`, or ask your team admin to enable sharing.', - }; +/** The two lines every command prints for a blocked skill. */ +export function blockMessage(name: string, reason: SkillBlockReason): { headline: string; hint: string } { + switch (reason) { + case 'recall': + return { + headline: `${name} needs recall, which is disabled for this team.`, + hint: 'Turn it on with `teamai recall enable`, or ask your team admin to enable sharing.', + }; + case 'read-only': + return { + headline: `${name} is not available: this team uses a read-only HTTP source, so nothing can be contributed from here.`, + hint: 'Ask a team admin to add the learning to the team repo.', + }; + default: { + const exhaustive: never = reason; + throw new Error(`Unhandled block reason ${String(exhaustive)}`); + } + } } async function collectSupplementaryFiles(skillDir: string): Promise<Array<{ relativePath: string; content: string }>> { @@ -266,12 +286,13 @@ function rootsMissing(): void { } /** - * Refuse a skill the recall gate blocks. Every path that hands out a skill's - * content or its directory goes through here, so the gate that replaced the - * old deployment restriction cannot be sidestepped by asking differently. + * Refuse a blocked skill. Every command that hands out a skill's content or its + * directory goes through here, so an agent is routed away from a workflow that + * cannot finish whichever way it asks. A routing aid, not access control: the + * files ship in the npm package either way. */ -function refuseBlockedByRecall(name: string): void { - const { headline, hint } = recallBlockMessage(name); +function refuseBlocked(name: string, reason: SkillBlockReason): void { + const { headline, hint } = blockMessage(name, reason); diagnostic(`${chalk.red('✖')} ${headline}`); diagnostic(` ${hint}`); process.exitCode = 1; @@ -313,11 +334,13 @@ export async function skillGet(names: string[], options: SkillGetOptions = {}): // and named on stderr, the rest is still served. for (const listed of servable) { const resolved = await resolveServableSkill(listed.name, roots); - // The listing and the resolver read one catalog, so `not-found` cannot - // happen here; it is still its own branch so the message stays truthful. - if (resolved.kind === 'not-found') continue; - if (resolved.kind === 'blocked') { - diagnostic(`${chalk.yellow('⚠')} Skipped ${listed.name}: needs recall, which is disabled for this team (teamai recall enable).`); + // The listing and the resolver read one catalog, so a listed name is + // either found or blocked. + if (resolved.kind !== 'found') { + if (resolved.kind === 'blocked') { + const { headline, hint } = blockMessage(listed.name, resolved.reason); + diagnostic(`${chalk.yellow('⚠')} Skipped ${listed.name}. ${headline} ${hint}`); + } continue; } targets.push(resolved.skill); @@ -330,7 +353,7 @@ export async function skillGet(names: string[], options: SkillGetOptions = {}): return; } if (resolved.kind === 'blocked') { - refuseBlockedByRecall(resolved.name); + refuseBlocked(resolved.name, resolved.reason); return; } targets.push(resolved.skill); @@ -355,9 +378,8 @@ export async function skillGet(names: string[], options: SkillGetOptions = {}): * `teamai skill path <name>` — print the packaged directory, for agents that * read files directly or need to run the scripts a skill ships. * - * A name is required. Printing the `skill-data/` root instead would hand out the - * parent of every served skill, and reading `<root>/share/SKILL.md` from there - * is exactly the content the recall gate withholds one command over. + * A name is required, and a blocked skill gets the same refusal as `skill get`, + * so an agent asking for the directory is routed the same way. */ export async function skillPath(name: string): Promise<void> { const roots = packagedSkillRoots(); @@ -368,7 +390,7 @@ export async function skillPath(name: string): Promise<void> { notFound(name, await listServableSkills(roots)); return; case 'blocked': - refuseBlockedByRecall(resolved.name); + refuseBlocked(resolved.name, resolved.reason); return; case 'found': console.log(resolved.skill.dir); @@ -393,27 +415,27 @@ interface SkillCatalogEntryFields { } /** - * `blockedByRecall` carries the directory with it: a blocked entry has no path - * to report, and a served one always has. Both variants keep the `path` key so - * the JSON shape does not change with the gate. + * `blockedBy` carries the directory with it: a blocked entry has no path to + * report, and a served one always has. Both variants keep the `path` key so the + * JSON shape does not change with the gate. */ export type SkillCatalogEntry = - | (SkillCatalogEntryFields & { blockedByRecall: false; path: string }) - | (SkillCatalogEntryFields & { blockedByRecall: true; path: null }); + | (SkillCatalogEntryFields & { blockedBy: null; path: string }) + | (SkillCatalogEntryFields & { blockedBy: SkillBlockReason; path: null }); export async function skillCatalog(roots: PackagedSkillRoots = packagedSkillRoots()): Promise<SkillCatalogEntry[]> { const skills = await listServableSkills(roots); const entries: SkillCatalogEntry[] = []; for (const skill of skills) { - const blocked = (await resolveServableSkill(skill.name, roots)).kind === 'blocked'; + const resolved = await resolveServableSkill(skill.name, roots); const fields: SkillCatalogEntryFields = { name: skill.name, description: await readSkillDescription(path.join(skill.dir, SKILL_MD)), deployed: skill.deployed, }; - entries.push(blocked - ? { ...fields, blockedByRecall: true, path: null } - : { ...fields, blockedByRecall: false, path: skill.dir }); + entries.push(resolved.kind === 'blocked' + ? { ...fields, blockedBy: resolved.reason, path: null } + : { ...fields, blockedBy: null, path: skill.dir }); } return entries; } diff --git a/src/source.ts b/src/source.ts index a2cddb16..ddf083d3 100644 --- a/src/source.ts +++ b/src/source.ts @@ -19,7 +19,7 @@ import { import { getHandler } from './resources/index.js'; import { ResourceHandler } from './resources/base.js'; import { resolveSkillDestination } from './resources/skills.js'; -import { BUILTIN_SKILL_NAMES } from './builtin-skills.js'; +import { BUILTIN_SKILL_NAMES, LEGACY_BUILTIN_SKILL_NAMES } from './builtin-skills.js'; import { getUserHome } from './utils/home.js'; import { assertSafeResourceName, assertWithinRoot } from './utils/path-safety.js'; import type { @@ -623,8 +623,9 @@ async function getLocalTeamSkillNames(teamConfig: TeamaiConfig, localConfig: Loc const handler = getHandler('skills'); const items = await handler.scanTeamForPull(teamConfig, localConfig); const names = new Set(items.map((i) => i.name)); - // Also include builtin skills - for (const name of BUILTIN_SKILL_NAMES) { + // Also include builtin skills, legacy ones too: a source-team removal must not + // delete a legacy tree wholesale, which only pull's ownership rule may prune. + for (const name of [...BUILTIN_SKILL_NAMES, ...LEGACY_BUILTIN_SKILL_NAMES]) { names.add(name); } return names; diff --git a/src/types.ts b/src/types.ts index fe20b7cc..f1b8cade 100644 --- a/src/types.ts +++ b/src/types.ts @@ -85,8 +85,8 @@ export const SharingConfigSchema = z.object({ // Optional (not .default) so existing TeamaiConfig literals stay valid; use // isContributeHintEnabled() for the resolved view. contributeHint: z.object({ - /** Team default: whether the Stop hook nudges members to run - * /teamai after a high-friction session. Teams that route + /** Team default: whether the Stop hook nudges members towards the + * share workflow after a high-friction session. Teams that route * knowledge sharing through their own review flow can turn the nudge off * without disabling the rest of the Stop hook (update check, votes sync, * dashboard reporting). */ diff --git a/src/uninstall.ts b/src/uninstall.ts index 2d5cfc7d..afab5f57 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -893,7 +893,7 @@ async function executeRemoval(plan: RemovalPlan): Promise<void> { // A different reason, so a different sentence: nothing here was touched, and // "delete the rest yourself" would send the member into the link target. for (const skillDir of linkedSkillDirs) { - log.warn(`Kept ${skillDir}: it is a symlink, so TeamAI left it and whatever it points at alone.`); + log.warn(`Kept ${skillDir}: it is reached through a symlink, so TeamAI left it and whatever the link points at alone.`); } for (const { skillDir, first } of failedSkillDirs) { log.warn(`Could not delete packaged files under ${skillDir}. First: ${first.file} — ${first.error}. Fix the permissions and run \`teamai uninstall\` again, or delete the directory yourself.`); From b02932969ee6de71b2a98903aa10056f7b852ced Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Wed, 23 Sep 2026 00:00:01 +0200 Subject: [PATCH 26/37] fix(skills): check every path component below the base for a link, in uninstall too The previous commit narrowed the guard to the skills root and the skill directory, so a link at ~/.config or ~/.config/opencode was walked through: the prune could delete, and deploy write, inside a dotfiles checkout. The full walk from the tool's base directory is back, and removeOwnedFiles now requires the base, so uninstall applies it too; each skill directory in the uninstall plan carries the base its skills root hangs off. A member whose whole ~/.claude is a link keeps the pre-stub trees and gets the warning naming the path, as before the previous commit. Deleting through a link is the one thing the prune must never do. --- docs/designs/skill-serving.md | 18 +++++--- src/__tests__/skip-uninstalled-tools.test.ts | 25 ++++++----- src/__tests__/uninstall.test.ts | 15 +++++++ src/builtin-skills.ts | 46 +++++++++++--------- src/uninstall.ts | 32 +++++++++----- 5 files changed, 87 insertions(+), 49 deletions(-) diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index b4978a89..9854cbac 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -171,13 +171,17 @@ migration stops being a one-way door for any of them. That path is the machine's home, never the tool's base directory, which under project scope is the repo root. Only *retired* paths are archived: the stub is rewritten on every session start, so archiving it would file an identical copy per session forever. A -linked skills root (`~/.claude/skills`) or skill directory is refused outright — -neither pruned nor written through, link and target untouched: everything under -it matches our names, and none of it is ours. Pull, deploy and `uninstall` apply -the same check. The agent directory and everything above it are not checked: a -linked `~/.claude` (stow, chezmoi) is ordinary, every other resource the sync -writes goes through it, and refusing there would leave those machines on the -pre-stub trees forever. The +link on any component between the tool's base directory and the skill +directory — `~/.claude`, `~/.config/opencode`, `~/.claude/skills`, the skill +directory itself — is refused outright: neither pruned nor written through, link +and target untouched, since everything under it matches our names and none of it +is ours. Pull, deploy and `uninstall` apply the same check; uninstall carries +each skill directory's base for it. Components at or above the base are not +checked: a home directory under a link is ordinary. The cost is a member whose +whole `~/.claude` is a link (stow, chezmoi): the stub is not deployed and the +legacy trees stay, with a warning on each pull naming the path, until the link +is replaced by a directory. Deleting through a link is the one thing the prune +must never do, so that member is told rather than guessed for. The `<base>` segment is there because `inheritUserScope` deploys the user base and then the project base in one process, with the same tool, root and skill name. A file whose copy fails is kept rather than removed: a backup that did not happen must not authorise the diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index 04968551..5fadb139 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -788,22 +788,25 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { expect(await fse.readFile(path.join(outside, 'SKILL.md'), 'utf8')).toBe('# not ours'); }); - it('deploys and prunes through a linked agent directory, the stow / chezmoi layout', async () => { + it('stops at a link on any component below the base, not only the last two', async () => { const { deployBuiltinSkills } = await import('../builtin-skills.js'); - // Every other resource the sync writes goes through a linked ~/.claude; the - // stub and the migration must not be the ones left behind on those machines. - const dotfiles = path.join(tmpDir, 'dotfiles/claude'); + // `~/.config/opencode` linked at a dotfiles checkout: the skills root and + // the skill directory under it are real directories, the link is higher up. + const dotfiles = path.join(tmpDir, 'dotfiles/opencode'); await fse.ensureDir(path.join(dotfiles, 'skills/team-wiki-codebase')); - await fse.writeFile(path.join(dotfiles, 'skills/team-wiki-codebase/SKILL.md'), '# pre-stub'); - await fse.remove(path.join(homeDir, '.claude')); - await fse.symlink(dotfiles, path.join(homeDir, '.claude'), 'dir'); + await fse.writeFile(path.join(dotfiles, 'skills/team-wiki-codebase/SKILL.md'), '# theirs'); + await fse.ensureDir(path.join(homeDir, '.config')); + await fse.symlink(dotfiles, path.join(homeDir, '.config/opencode'), 'dir'); - const deployed = await deployBuiltinSkills(legacyPruneTeamConfig(), legacyPruneLocalConfig(tmpDir)); + const deployed = await deployBuiltinSkills( + legacyPruneTeamConfig({ opencode: { skills: '.config/opencode/skills' } }), + legacyPruneLocalConfig(tmpDir), + ); - expect(deployed).toBe(1); - expect(await fse.pathExists(path.join(dotfiles, 'skills/teamai/SKILL.md'))).toBe(true); - expect(await fse.pathExists(path.join(dotfiles, 'skills/team-wiki-codebase'))).toBe(false); + expect(deployed).toBe(0); + expect(await fse.readFile(path.join(dotfiles, 'skills/team-wiki-codebase/SKILL.md'), 'utf8')).toBe('# theirs'); + expect(await fse.pathExists(path.join(dotfiles, 'skills/teamai'))).toBe(false); }); it('keeps a member\'s file under a __pycache__ that is not bytecode of a shipped script', async () => { diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 64df0bda..10c93b42 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -1082,6 +1082,21 @@ describe('uninstall', () => { expect(await fse.pathExists(path.join(dotfiles, 'teamai', 'SKILL.md'))).toBe(true); }); + it('does not delete through a linked agent directory either, a link higher up the path', async () => { + const { homeDir, repoPath } = await setupFixture(tmpDir); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + const dotfiles = path.join(tmpDir, 'dotfiles-claude'); + await fse.move(path.join(homeDir, '.claude'), dotfiles); + await fse.symlink(dotfiles, path.join(homeDir, '.claude'), 'dir'); + + const localConfig = makeLocalConfig(homeDir, repoPath); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig: makeTeamConfig() }); + await uninstall({ force: true }); + + expect(await fse.pathExists(path.join(dotfiles, 'skills', 'teamai', 'SKILL.md'))).toBe(true); + }); + it('removes the stub Codex kept in the shared .agents/skills root, and nothing else there', async () => { const { homeDir, repoPath } = await setupFixture(tmpDir); vi.stubEnv('HOME', homeDir); diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index 18ceca6d..c92c6003 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -158,7 +158,7 @@ async function removeEmptyDirs(dir: string): Promise<void> { /** What `removeOwnedFiles` did and did not do, for the caller to report. */ export interface PruneResult { - /** True when the skills root or the skill directory is a link: nothing was touched. */ + /** True when a link sits between the base and the skill directory: nothing was touched. */ skippedSymlink: boolean; /** Files left in place because the member, not the CLI, put them there. */ foreign: number; @@ -190,6 +190,7 @@ export function prunedWhole(result: PruneResult): boolean { export async function removeOwnedFiles( dir: string, owned: readonly string[], + baseDir: string, backupDir?: string, ): Promise<PruneResult> { const ownedPaths = new Set(owned); @@ -197,11 +198,11 @@ export async function removeOwnedFiles( skippedSymlink: false, foreign: 0, unbackedUp: [], notRemoved: [], backedUp: 0, }; - // A linked skills root or skill directory points at files we never wrote — a - // shared checkout, a dotfiles repo. `readdir` follows it and every path under - // it matches ours by name, so the walk would delete someone else's files - // through the link. Ownership stops at the first link. - if (await reachedThroughLink(dir)) { + // A link anywhere between the base directory and this one points at files we + // never wrote — a shared checkout, a dotfiles repo. `readdir` follows it and + // every path under it matches ours by name, so the walk would delete someone + // else's files through the link. Ownership stops at the first link. + if (await crossesSymlink(baseDir, dir)) { result.skippedSymlink = true; return result; } @@ -256,20 +257,25 @@ export async function removeOwnedFiles( } /** - * True when `skillDir` (`<agent dir>/<skills root>/<skill>`) or its skills root - * is a symlink. + * True when any path component between `baseDir` and `target` is a symlink, or + * `target` is not under `baseDir` at all. * - * Checking the skill directory alone is not enough: a member who links - * `~/.claude/skills` at a dotfiles checkout leaves every skill directory under - * it a real directory, so `lstat` on one says nothing. The agent directory and - * everything above it are not checked: a linked `~/.claude` (stow, chezmoi) or - * home is ordinary, every other resource the sync writes goes through it too, - * and refusing there would leave those machines on the pre-stub trees forever. + * Checking `target` alone is not enough: a member who links `~/.claude/skills` + * — or `~/.config/opencode`, or `~/.claude` itself — at a dotfiles checkout + * leaves every skill directory under it a real directory, so `lstat` on one + * says nothing. Components at or above `baseDir` are not checked: a home + * directory that itself sits under a link is ordinary, and refusing there would + * disable deployment for those machines. */ -async function reachedThroughLink(skillDir: string): Promise<boolean> { - for (const candidate of [path.dirname(skillDir), skillDir]) { +async function crossesSymlink(baseDir: string, target: string): Promise<boolean> { + const relative = path.relative(baseDir, target); + if (relative.startsWith('..') || path.isAbsolute(relative)) return true; + + let walked = baseDir; + for (const segment of relative.split(path.sep).filter(Boolean)) { + walked = path.join(walked, segment); try { - if ((await fs.promises.lstat(candidate)).isSymbolicLink()) return true; + if ((await fs.promises.lstat(walked)).isSymbolicLink()) return true; } catch { return false; // does not exist yet: nothing to walk through } @@ -328,7 +334,7 @@ export async function pruneLegacyBuiltinSkills( if (!await pathExists(dir)) continue; try { const backupDir = skillBackupDir(baseDir, tool, root, legacyName); - const result = await removeOwnedFiles(dir, PACKAGED_SKILL_FILES.get(legacyName) ?? [], backupDir); + const result = await removeOwnedFiles(dir, PACKAGED_SKILL_FILES.get(legacyName) ?? [], baseDir, backupDir); const saved = result.backedUp > 0 ? `; a copy is in ${backupDir}` : ''; if (prunedWhole(result)) { log.debug(`Removed legacy built-in skill ${legacyName} from ${tool} (${dir})${saved}`); @@ -428,7 +434,7 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? // A symlinked destination points somewhere we do not own. Writing // through it would put the stub outside the agent directory, which is // the same reason the prune refuses to walk it. Neither step runs. - if (await reachedThroughLink(destDir)) { + if (await crossesSymlink(baseDir, destDir)) { log.warn(`Skipped ${skillName} (${tool}): ${destDir} is reached through a symlink, and TeamAI does not write through one. Remove the link to let the skill deploy.`); continue; } @@ -444,7 +450,7 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? const shippedNow = new Set(await walkFiles(srcDir)); const retired = (PACKAGED_SKILL_FILES.get(skillName) ?? []).filter((p) => !shippedNow.has(p)); const backupDir = skillBackupDir(baseDir, tool, path.relative(baseDir, path.dirname(destDir)), skillName); - const result = await removeOwnedFiles(destDir, retired, backupDir); + const result = await removeOwnedFiles(destDir, retired, baseDir, backupDir); if (result.unbackedUp.length > 0) { log.warn(`Kept ${result.unbackedUp.length} file(s) under ${destDir}: their backup could not be written, so they were not removed. First: ${result.unbackedUp[0].file} — ${result.unbackedUp[0].error}`); } diff --git a/src/uninstall.ts b/src/uninstall.ts index afab5f57..442a51f3 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -91,8 +91,11 @@ interface RemovalPlan { hookManifestPath: string; /** CLAUDE.md files with teamai rules blocks. */ claudeMdFiles: string[]; - /** Skill directories synced from team repo. */ - skillDirs: string[]; + /** + * Skill directories synced from team repo, each with the base directory its + * skills root hangs off: the prune refuses a link anywhere below that base. + */ + skillDirs: SkillDirEntry[]; /** Rule .md files synced from team repo (plus CLI built-in rules). */ ruleFiles: string[]; /** Built-in agent .md files deployed by the CLI (e.g. teamai-recall). */ @@ -116,6 +119,12 @@ interface RemovalPlan { } /** Per-tool findings collected during discovery (tool-specific resources only). */ +/** A skill directory to remove, and the base the link guard starts from. */ +interface SkillDirEntry { + dir: string; + baseDir: string; +} + interface ToolResources { hookFiles: Array<{ path: string; tool: string; manifestPath: string }>; openclawHookDirs: Array<{ hooksDir: string; tool: string }>; @@ -123,7 +132,7 @@ interface ToolResources { ompHookFile: string | null; dshHookFile: string | null; claudeMdFiles: string[]; - skillDirs: string[]; + skillDirs: SkillDirEntry[]; ruleFiles: string[]; agentFiles: string[]; } @@ -352,22 +361,23 @@ async function discoverToolResources( // (c) Skills — only those matching team repo if (toolPath.skills) { - const skillRoots = new Set([path.join(baseDir, toolPath.skills)]); + // Skills root → the base the link guard starts from. + const skillRoots = new Map([[path.join(baseDir, toolPath.skills), baseDir]]); if (tool === 'openclaw') { const workspaceDir = await resolveOpenclawWorkspaceDir(); - if (workspaceDir) skillRoots.add(path.join(workspaceDir, 'skills')); + if (workspaceDir) skillRoots.set(path.join(workspaceDir, 'skills'), workspaceDir); } // `resolveSkillDestination` writes Codex's copy into the shared // .agents/skills root whenever that skill already lives there, so uninstall // must look where deployment could have put it — the legacy prune already // does. Codex only: another tool's pass must not reach into it. - if (tool === CODEX_TOOL) skillRoots.add(path.join(baseDir, SHARED_AGENT_SKILLS_PATH)); - for (const skillsDir of skillRoots) { + if (tool === CODEX_TOOL) skillRoots.set(path.join(baseDir, SHARED_AGENT_SKILLS_PATH), baseDir); + for (const [skillsDir, rootBase] of skillRoots) { if (await pathExists(skillsDir)) { const dirs = await listDirs(skillsDir); for (const dir of dirs) { if (teamSkillNames.has(dir)) { - res.skillDirs.push(path.join(skillsDir, dir)); + res.skillDirs.push({ dir: path.join(skillsDir, dir), baseDir: rootBase }); } } } @@ -683,7 +693,7 @@ function printSummary(plan: RemovalPlan, agentFilter?: string): void { if (plan.skillDirs.length > 0) { console.log(` Skills (${plan.skillDirs.length} directories):`); - for (const skillDir of plan.skillDirs) { + for (const { dir: skillDir } of plan.skillDirs) { // A CLI-owned directory loses the files TeamAI packaged, not whatever the // member added beside them, so the prompt must not promise the directory. const suffix = isCliOwnedSkillName(path.basename(skillDir)) @@ -865,11 +875,11 @@ async function executeRemoval(plan: RemovalPlan): Promise<void> { const keptSkillDirs: string[] = []; const linkedSkillDirs: string[] = []; const failedSkillDirs: { skillDir: string; first: { file: string; error: string } }[] = []; - for (const skillDir of plan.skillDirs) { + for (const { dir: skillDir, baseDir } of plan.skillDirs) { try { const name = path.basename(skillDir); if (isCliOwnedSkillName(name)) { - const result = await removeOwnedFiles(skillDir, PACKAGED_SKILL_FILES.get(name) ?? []); + const result = await removeOwnedFiles(skillDir, PACKAGED_SKILL_FILES.get(name) ?? [], baseDir); if (prunedWhole(result)) removedSkillDirs++; else if (result.skippedSymlink) linkedSkillDirs.push(skillDir); // A delete that failed is not a member's file: say what happened, not From 94081b5e393d2dc2f49c2a5dc0368c67689f161d Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Wed, 23 Sep 2026 00:22:03 +0200 Subject: [PATCH 27/37] fix(skills): withhold the share hint on read-only sources, route legacy names through the gate contributeHintAllowed checked recall only. The dispatcher already drops this gitOnly handler for HTTP teams, but the gate now says so itself, so the reminder never points at a `share` that refuses as read-only wherever it runs. `skill show teamai-share-learnings` searched the agent directories before the package, so a legacy tree a pull had not pruned yet was shown with its path while the gate refused `share`. A legacy built-in name now skips the agent search and goes to the packaged skill and its gate; ordinary names and aliases such as `share` keep a member's own directory first. --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/hook-handlers.test.ts | 16 ++++++++++++++++ src/__tests__/skill-show.test.ts | 12 ++++++++++++ src/hook-handlers.ts | 12 ++++++++---- src/skill-cmd.ts | 8 ++++++-- 6 files changed, 44 insertions(+), 8 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index ca87d26a..fa21987c 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -945,7 +945,7 @@ Teams that route knowledge sharing through their own review flow (for example, a Only the nudge is affected: friction scoring, `teamai contribute --file`, and `/teamai` keep working when invoked manually. -The reminder is also withheld while recall is off (the default until `sharing.recall.enabled: true` in `teamai.yaml`, or `teamai recall enable` on one machine): it points at the `share` workflow, and `teamai skill get share` refuses until recall is on. +The reminder is also withheld while recall is off (the default until `sharing.recall.enabled: true` in `teamai.yaml`, or `teamai recall enable` on one machine): it points at the `share` workflow, and `teamai skill get share` refuses until recall is on. It never appears on a read-only HTTP source, where `share` refuses too. ### Searching knowledge diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index ccbe6783..4c47ef47 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -913,7 +913,7 @@ teamai contribute --file /tmp/session.md --scope project 只影响提醒本身:摩擦评分、`teamai contribute --file` 和手动调用 `/teamai` 不受影响。 -未开启 recall 时(默认关闭;团队在 `teamai.yaml` 设置 `sharing.recall.enabled: true`,或单台机器运行 `teamai recall enable`)也不会显示这条提醒:提醒指向 `share` 工作流,而 recall 关闭时 `teamai skill get share` 会拒绝执行。 +未开启 recall 时(默认关闭;团队在 `teamai.yaml` 设置 `sharing.recall.enabled: true`,或单台机器运行 `teamai recall enable`)也不会显示这条提醒:提醒指向 `share` 工作流,而 recall 关闭时 `teamai skill get share` 会拒绝执行。只读 HTTP 源上这条提醒也从不出现,因为 `share` 同样会拒绝。 ### 搜索知识 diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index d158ae27..d4d4fb68 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -395,6 +395,22 @@ describe('hook-handlers registry', () => { expect(mockContributeCheckForSession).not.toHaveBeenCalled(); }); + it('contribute-check handler stays silent on a read-only HTTP source even with recall on', async () => { + const registry = buildHandlerRegistry(); + const handler = registry.find( + (r) => r.event === 'stop' && r.handler.name === 'contribute-check', + )!.handler; + mockAutoDetectInit.mockResolvedValueOnce({ + localConfig: { repo: { kind: 'http', localPath: '/tmp', remote: '' }, username: 'test', scope: 'user' }, + teamConfig: { team: 'test', repo: '', toolPaths: {}, sharing: { recall: { enabled: true } } }, + }); + mockContributeCheckForSession.mockClear(); + + const result = await handler.execute({ session_id: 's3c', cwd: '/x' }, 'claude'); + expect(result).toBeNull(); + expect(mockContributeCheckForSession).not.toHaveBeenCalled(); + }); + it('contribute-check handler keeps hinting when config cannot be loaded', async () => { const registry = buildHandlerRegistry(); const handler = registry.find( diff --git a/src/__tests__/skill-show.test.ts b/src/__tests__/skill-show.test.ts index e3624562..abef0427 100644 --- a/src/__tests__/skill-show.test.ts +++ b/src/__tests__/skill-show.test.ts @@ -184,6 +184,18 @@ describe('skillShow locator', () => { process.exitCode = 0; }); + it('refuses a legacy share directory a pull has not pruned yet, instead of showing its path', async () => { + // A pre-stub release wrote this; it is the CLI's stale copy, not the + // member's skill, so the name must go through the packaged gate. + const claudeSkillsDir = path.join(fx.homeDir, '.claude', 'skills'); + await makeSkill(claudeSkillsDir, 'teamai-share-learnings', 'old share workflow'); + + const lines = await runSkillShow('teamai-share-learnings', fx); + expect(process.exitCode).toBe(1); + expect(lines.join('\n')).not.toContain(path.join(claudeSkillsDir, 'teamai-share-learnings')); + process.exitCode = 0; + }); + it('shows share once recall is enabled', async () => { fx.localConfig.recallEnabled = true; const lines = await runSkillShow('share', fx); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 9106f33c..16452eab 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -236,16 +236,20 @@ const trackSlashHandler: HookHandler = { * config) without re-injecting hooks. Falls back to enabled when config can't * be read, preserving pre-toggle behavior for half-initialized installs. * - * Recall gates it too: the hint routes to the `share` workflow, and - * `teamai skill get share` refuses while recall is off, so a nudge towards it - * would send the agent to a command that says no. + * Recall and a writable source gate it too: the hint routes to the `share` + * workflow, and `teamai skill get share` refuses while recall is off or the + * team source is read-only HTTP, so a nudge towards it would send the agent to + * a command that says no. The dispatcher already drops this `gitOnly` handler + * for HTTP teams; the check here keeps the gate the same wherever it is called. */ async function contributeHintAllowed(): Promise<boolean> { const { isContributeHintEnabled, isRecallEnabled } = await import('./types.js'); try { const { autoDetectInit } = await import('./config.js'); const { localConfig, teamConfig } = await autoDetectInit(); - return isContributeHintEnabled(localConfig, teamConfig) && isRecallEnabled(localConfig, teamConfig); + return localConfig.repo?.kind !== 'http' + && isContributeHintEnabled(localConfig, teamConfig) + && isRecallEnabled(localConfig, teamConfig); } catch { return isContributeHintEnabled({}, {}); } diff --git a/src/skill-cmd.ts b/src/skill-cmd.ts index 3a437fe8..5318a1aa 100644 --- a/src/skill-cmd.ts +++ b/src/skill-cmd.ts @@ -13,6 +13,7 @@ import { type SkillSource, } from './agent-skills.js'; import { detectInstalledAgents, type ResolvedAgent } from './known-agents.js'; +import { LEGACY_BUILTIN_SKILL_NAMES } from './builtin-skills.js'; import { blockMessage, resolveServableSkill, skillCatalog, type SkillBlockReason } from './skill-content.js'; import type { GlobalOptions, LocalConfig, TeamaiConfig } from './types.js'; @@ -207,8 +208,11 @@ async function locateSkill( // 3. First installed agent that has the skill. Ahead of the packaged content // on purpose: `codebase`, `default`, `learning` and `share` are ordinary // names, and a directory a member created under one of them is the skill - // they are asking about, not the built-in it happens to alias. - for (const agent of agents) { + // they are asking about, not the built-in it happens to alias. A legacy + // built-in name is the exception: that directory is a stale copy a + // pre-stub release wrote, so the name goes to the packaged skill and its + // gate, never to the leftover a pull has not pruned yet. + for (const agent of LEGACY_BUILTIN_SKILL_NAMES.has(name) ? [] : agents) { if (!agent.installed) continue; const candidate = path.join(agent.absoluteSkillsPath, name); if (await pathExists(path.join(candidate, 'SKILL.md'))) { From bed4c69340c1c32e5d6ef78ec225f91f33f28cfd Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Wed, 23 Sep 2026 00:41:21 +0200 Subject: [PATCH 28/37] fix(skills): deploy and prune built-ins where the tool keeps its skills deployBuiltinSkills joined baseDir with the configured skills path, while team-skill sync resolves the directory through skillsDirForTool: OpenClaw's workspace, and HERMES_HOME for Hermes. Those agents got the stub in a directory they never read and had their legacy trees pruned from the wrong place. Deploy, the legacy prune, recall disable and uninstall now resolve the same directory; the link guard starts at the tool's base directory when the skills directory sits under it, else at that directory's parent. --- docs/designs/skill-serving.md | 6 ++ docs/usage-guide.md | 3 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/skip-uninstalled-tools.test.ts | 25 +++++++ src/__tests__/uninstall.test.ts | 19 +++++ src/builtin-skills.ts | 77 +++++++++++++++----- src/recall-toggle.ts | 13 ++-- src/uninstall.ts | 13 +++- 8 files changed, 131 insertions(+), 27 deletions(-) diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index 9854cbac..721d4761 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -72,6 +72,12 @@ path (measured here from a 77-character one). - **`--full` walks `references/` and `templates/` recursively**, sorted by relative path. Our references nest (`references/methodology/`, `references/phases/`); a single-level scan would serve an incomplete skill. +- **The stub lands where team skills land.** Deploy, the legacy prune, + `recall disable` and `uninstall` resolve the skills directory through + `skillsDirForTool`, the resolver team-skill sync uses, so OpenClaw gets it in + its workspace and Hermes under `HERMES_HOME` rather than under a tool root + that agent never reads. The link guard walks from the tool's base directory + when the skills directory is under it, else from that directory's parent. - **Nothing repairs the deployed stub.** `ensureSkillFrontmatter` is not called on it, so deployed and packaged bytes are identical and a diff means a bug. - **Recall is decided at run time**, not by withholding a directory at deploy diff --git a/docs/usage-guide.md b/docs/usage-guide.md index fa21987c..38e471f7 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -470,7 +470,8 @@ The built-in workflows (`core`, `setup`, `wiki`, `share`) ship inside the npm pa and are printed by the installed binary with `teamai skill get`, so what an agent reads always matches the CLI version it is running — `npm i -g teamai-cli@latest` is the update, with no pull needed for the content to be current. Agents receive a single file -from the CLI, `~/.<tool>/skills/teamai/SKILL.md`, a small discovery stub that points at +from the CLI, `~/.<tool>/skills/teamai/SKILL.md` (or wherever that tool keeps team skills: OpenClaw's +workspace, `HERMES_HOME`), a small discovery stub that points at those commands. Older releases copied the whole tree into every agent directory, where it went stale between pulls; `teamai pull` removes those leftovers, keeping a copy of every removed file under `~/.teamai/removed-skills/`, one directory per pull, so an edit you made diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 4c47ef47..b71cf9c5 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -445,7 +445,7 @@ teamai skill path wiki # 打印打包目录,用于运行 skill 内置工作流(`core`、`setup`、`wiki`、`share`)随 npm 包一起发布,由已安装的 CLI 通过 `teamai skill get` 按需打印,因此 agent 读到的内容始终与正在运行的 CLI 版本一致——`npm i -g teamai-cli@latest` 本身就是更新, -无需 `teamai pull` 内容就是最新的。每个 agent 只收到一个文件:`~/.<tool>/skills/teamai/SKILL.md`, +无需 `teamai pull` 内容就是最新的。每个 agent 只收到一个文件:`~/.<tool>/skills/teamai/SKILL.md`(或该工具存放团队 skill 的位置:OpenClaw 的 workspace、`HERMES_HOME`), 一个指向这些命令的小型发现入口(stub)。旧版本会把整棵目录复制到每个 agent 下,两次 pull 之间内容会过时; `teamai pull` 会清除这些残留,并把每个被删除的文件先复制到 `~/.teamai/removed-skills/` 下(每次 pull 一个目录), 你对其中文件的修改不会丢失(`teamai uninstall` 会删除 `~/.teamai/`,这份备份也随之删除);目录里若还有你自己的文件, diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index 5fadb139..ec00cd85 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -809,6 +809,31 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { expect(await fse.pathExists(path.join(dotfiles, 'skills/teamai'))).toBe(false); }); + it('deploys the stub and prunes where team skills land for Hermes and OpenClaw, not under the tool root', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + // Hermes honours HERMES_HOME, which can live outside HOME; OpenClaw reads + // skills from its workspace. Team-skill sync already resolves both. + const hermesHome = path.join(tmpDir, 'elsewhere/hermes'); + vi.stubEnv('HERMES_HOME', hermesHome); + await fse.ensureDir(path.join(hermesHome, 'skills/team-wiki-codebase')); + await fse.writeFile(path.join(hermesHome, 'skills/team-wiki-codebase/SKILL.md'), '# pre-stub'); + const workspace = path.join(homeDir, '.openclaw/workspace'); + await fse.ensureDir(workspace); + + const deployed = await deployBuiltinSkills( + legacyPruneTeamConfig({ hermes: { skills: '.hermes/skills' }, openclaw: { skills: '.openclaw/skills' } }), + legacyPruneLocalConfig(tmpDir), + ); + + expect(deployed).toBe(2); + expect(await fse.pathExists(path.join(hermesHome, 'skills/teamai/SKILL.md'))).toBe(true); + expect(await fse.pathExists(path.join(hermesHome, 'skills/team-wiki-codebase'))).toBe(false); + expect(await fse.pathExists(path.join(workspace, 'skills/teamai/SKILL.md'))).toBe(true); + expect(await fse.pathExists(path.join(homeDir, '.hermes'))).toBe(false); + expect(await fse.pathExists(path.join(homeDir, '.openclaw/skills'))).toBe(false); + }); + it('keeps a member\'s file under a __pycache__ that is not bytecode of a shipped script', async () => { const { deployBuiltinSkills } = await import('../builtin-skills.js'); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 10c93b42..ea27796e 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -1097,6 +1097,25 @@ describe('uninstall', () => { expect(await fse.pathExists(path.join(dotfiles, 'skills', 'teamai', 'SKILL.md'))).toBe(true); }); + it('removes the stub where Hermes keeps skills, under HERMES_HOME outside the home directory', async () => { + const { homeDir, repoPath } = await setupFixture(tmpDir); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + const hermesHome = path.join(tmpDir, 'elsewhere', 'hermes'); + vi.stubEnv('HERMES_HOME', hermesHome); + const stub = path.join(hermesHome, 'skills', 'teamai', 'SKILL.md'); + await fse.ensureDir(path.dirname(stub)); + await fse.writeFile(stub, '# stub'); + + const localConfig = makeLocalConfig(homeDir, repoPath); + const teamConfig = makeTeamConfig(); + teamConfig.toolPaths.hermes = { skills: '.hermes/skills' }; + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + await uninstall({ force: true }); + + expect(await fse.pathExists(stub)).toBe(false); + }); + it('removes the stub Codex kept in the shared .agents/skills root, and nothing else there', async () => { const { homeDir, repoPath } = await setupFixture(tmpDir); vi.stubEnv('HOME', homeDir); diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index c92c6003..35bf7118 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -6,8 +6,8 @@ import { pathExists, remove } from './utils/fs.js'; import { log } from './utils/logger.js'; import type { TeamaiConfig, LocalConfig } from './types.js'; import { resolveToolBaseDir, isAgentExcluded, scopedToolPaths } from './types.js'; -import { isToolInstalledForConfig, ResourceHandler } from './resources/base.js'; -import { CODEX_TOOL, resolveSkillDestination, SHARED_AGENT_SKILLS_PATH } from './resources/skills.js'; +import { ResourceHandler } from './resources/base.js'; +import { CODEX_TOOL, resolveSkillDestination, SHARED_AGENT_SKILLS_PATH, skillsDirForTool, skillTargetForTool } from './resources/skills.js'; import { getUserHome } from './utils/home.js'; import { packagedSkillRoots } from './skill-content.js'; @@ -309,6 +309,49 @@ function skillBackupDir(baseDir: string, tool: string, skillRoot: string, skillN return path.join(getUserHome(), '.teamai', 'removed-skills', PRUNE_RUN_ID, baseSlug, tool, rootSlug, skillName); } +/** Where a tool keeps its skills on this machine, and where the link guard starts. */ +export interface BuiltinSkillsTarget { + skillsDir: string; + /** + * The tool's base directory when the skills directory sits under it, else + * the skills directory's parent: OpenClaw's workspace and a `HERMES_HOME` + * can live anywhere, and the guard must still walk every component the CLI + * did not choose. + */ + guardBase: string; +} + +/** + * The base the link guard walks down from: the tool's base directory when + * `skillsDir` sits under it, else `skillsDir`'s parent. + */ +export function skillsGuardBase(toolBaseDir: string, skillsDir: string): string { + const relative = path.relative(toolBaseDir, skillsDir); + const underBase = relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative); + return underBase ? toolBaseDir : path.dirname(skillsDir); +} + +/** + * The skills directory `tool` receives built-ins into, or null when it cannot + * receive them. The same resolver team-skill sync uses (`skillsDirForTool`), + * so the stub lands where every other skill does — OpenClaw's workspace, + * `HERMES_HOME` — and the prune looks where earlier releases wrote. + */ +export async function builtinSkillsTarget( + tool: string, + configuredSkillsPath: string, + localConfig?: LocalConfig, +): Promise<BuiltinSkillsTarget | null> { + if (!localConfig) { + const baseDir = getUserHome(); + if (!await ResourceHandler.isToolInstalled(configuredSkillsPath, baseDir)) return null; + return { skillsDir: path.join(baseDir, configuredSkillsPath), guardBase: baseDir }; + } + const skillsDir = await skillsDirForTool(tool, configuredSkillsPath, localConfig); + if (skillsDir === null) return null; + return { skillsDir, guardBase: skillsGuardBase(resolveToolBaseDir(tool, localConfig), skillsDir) }; +} + /** * Remove the skill directories earlier releases deployed. * @@ -319,22 +362,21 @@ function skillBackupDir(baseDir: string, tool: string, skillRoot: string, skillN */ export async function pruneLegacyBuiltinSkills( tool: string, - configuredSkillsPath: string, - baseDir: string, + { skillsDir, guardBase }: BuiltinSkillsTarget, names: ReadonlySet<string> = LEGACY_BUILTIN_SKILL_NAMES, ): Promise<void> { // The shared .agents/skills directory belongs to Codex alone. Reaching it from // another tool's pass would delete Codex's copies while Codex is excluded or // not installed, which the enabledAgents whitelist rules out. - const skillRoots = [configuredSkillsPath]; - if (tool === CODEX_TOOL) skillRoots.push(SHARED_AGENT_SKILLS_PATH); + const skillRoots = [skillsDir]; + if (tool === CODEX_TOOL) skillRoots.push(path.join(guardBase, SHARED_AGENT_SKILLS_PATH)); for (const legacyName of names) { for (const root of skillRoots) { - const dir = path.join(baseDir, root, legacyName); + const dir = path.join(root, legacyName); if (!await pathExists(dir)) continue; try { - const backupDir = skillBackupDir(baseDir, tool, root, legacyName); - const result = await removeOwnedFiles(dir, PACKAGED_SKILL_FILES.get(legacyName) ?? [], baseDir, backupDir); + const backupDir = skillBackupDir(guardBase, tool, path.relative(guardBase, root), legacyName); + const result = await removeOwnedFiles(dir, PACKAGED_SKILL_FILES.get(legacyName) ?? [], guardBase, backupDir); const saved = result.backedUp > 0 ? `; a copy is in ${backupDir}` : ''; if (prunedWhole(result)) { log.debug(`Removed legacy built-in skill ${legacyName} from ${tool} (${dir})${saved}`); @@ -404,31 +446,30 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? if (skillNames.length === 0) return 0; - const defaultBaseDir = getUserHome(); let deployed = 0; for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig ?? {}))) { if (!toolPath.skills) continue; - const baseDir = localConfig ? resolveToolBaseDir(tool, localConfig) : defaultBaseDir; - // Skip tools that are not installed - const installed = localConfig - ? await isToolInstalledForConfig(tool, toolPath.skills, localConfig) - : await ResourceHandler.isToolInstalled(toolPath.skills, baseDir); - if (!installed) { + // Skip tools that cannot receive skills: not installed, no workspace. + const target = await builtinSkillsTarget(tool, toolPath.skills, localConfig); + if (!target) { log.debug(`Skipping built-in skill deployment for ${tool}: tool not installed`); continue; } + const baseDir = target.guardBase; // An excluded agent is neither written to nor deleted from (usage-guide: // "the enabledAgents whitelist also gates CLI built-in skills"), so its // legacy directories are left alone too. if (localConfig && isAgentExcluded(localConfig, tool)) continue; - await pruneLegacyBuiltinSkills(tool, toolPath.skills, baseDir); + await pruneLegacyBuiltinSkills(tool, target); for (const skillName of skillNames) { const srcDir = path.join(builtinDir, skillName); - const destDir = await resolveSkillDestination(tool, toolPath.skills, baseDir, skillName, srcDir); + const destDir = localConfig + ? await skillTargetForTool(tool, toolPath.skills, localConfig, skillName, srcDir) ?? path.join(target.skillsDir, skillName) + : await resolveSkillDestination(tool, toolPath.skills, baseDir, skillName, srcDir); try { // A symlinked destination points somewhere we do not own. Writing diff --git a/src/recall-toggle.ts b/src/recall-toggle.ts index 08f71751..a2142ff2 100644 --- a/src/recall-toggle.ts +++ b/src/recall-toggle.ts @@ -9,7 +9,7 @@ import { type ToolName, } from './resources/agent-format.js'; import { ruleFileExtensionForTool } from './resources/rule-format.js'; -import { LEGACY_RECALL_SKILL_NAMES, pruneLegacyBuiltinSkills } from './builtin-skills.js'; +import { LEGACY_RECALL_SKILL_NAMES, builtinSkillsTarget, pruneLegacyBuiltinSkills } from './builtin-skills.js'; import { resolveToolBaseDir, isRecallEnabled, @@ -41,11 +41,12 @@ async function removeRecallArtifacts(teamConfig: TeamaiConfig, localConfig: Loca // Remove the legacy recall skill an earlier release deployed. The served // `share` workflow is gated at run time, but a member who upgrades and // disables recall before pulling still has the old directory. - // Same gates as deployment: an uninstalled Codex must not have the shared - // .agents/skills root pruned on its behalf. - if (toolPath.skills && !isAgentExcluded(localConfig, tool) - && await isToolInstalledForConfig(tool, toolPath.skills, localConfig)) { - await pruneLegacyBuiltinSkills(tool, toolPath.skills, baseDir, LEGACY_RECALL_SKILL_NAMES); + // Same resolver and gates as deployment: an uninstalled Codex must not have + // the shared .agents/skills root pruned on its behalf, and OpenClaw and + // Hermes are pruned where their skills actually live. + if (toolPath.skills && !isAgentExcluded(localConfig, tool)) { + const target = await builtinSkillsTarget(tool, toolPath.skills, localConfig); + if (target) await pruneLegacyBuiltinSkills(tool, target, LEGACY_RECALL_SKILL_NAMES); } // Remove recall agent file diff --git a/src/uninstall.ts b/src/uninstall.ts index 442a51f3..34de59cd 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -45,7 +45,9 @@ import { isCliOwnedSkillName, prunedWhole, removeOwnedFiles, + skillsGuardBase, } from './builtin-skills.js'; +import { getHermesHome } from './hermes-home.js'; import { CODEX_TOOL, SHARED_AGENT_SKILLS_PATH } from './resources/skills.js'; import { pathExists, @@ -363,9 +365,18 @@ async function discoverToolResources( if (toolPath.skills) { // Skills root → the base the link guard starts from. const skillRoots = new Map([[path.join(baseDir, toolPath.skills), baseDir]]); + // OpenClaw and Hermes receive skills where team sync and the stub put them + // (`skillsDirForTool`): the workspace, and HERMES_HOME. if (tool === 'openclaw') { const workspaceDir = await resolveOpenclawWorkspaceDir(); - if (workspaceDir) skillRoots.set(path.join(workspaceDir, 'skills'), workspaceDir); + if (workspaceDir) { + const workspaceSkills = path.join(workspaceDir, 'skills'); + skillRoots.set(workspaceSkills, skillsGuardBase(baseDir, workspaceSkills)); + } + } + if (tool === 'hermes') { + const hermesSkills = path.join(getHermesHome(), 'skills'); + skillRoots.set(hermesSkills, skillsGuardBase(baseDir, hermesSkills)); } // `resolveSkillDestination` writes Codex's copy into the shared // .agents/skills root whenever that skill already lives there, so uninstall From 067a020052623880b13c628324a0a0c33857fde2 Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Wed, 23 Sep 2026 01:04:31 +0200 Subject: [PATCH 29/37] fix(skills): check an external skills root for a link, keep config-load logs off stdout, drop inert allowed-tools - A skills directory outside the tool's base (HERMES_HOME, an OpenClaw workspace) had the guard start at the root itself, so a linked root was never checked. It starts one level above now, and a linked HERMES_HOME is refused like a linked ~/.claude. - The share gate loads the config, which can migrate it and report that with log.info on stdout: an upgrading machine got that line in `skill get` output and in `skill list --json`. Config loading reports on stderr for that call; setStderrOnly returns the previous mode so it can be restored. - `allowed-tools` in the served skills was printed as command output and never processed as skill metadata, so it granted nothing. Removed, and the test now fails if one comes back. Only the stub's line pre-approves. --- docs/designs/skill-serving.md | 8 ++++---- skill-data/core/SKILL.md | 1 - skill-data/setup/SKILL.md | 1 - skill-data/share/SKILL.md | 1 - skill-data/wiki/SKILL.md | 1 - src/__tests__/skill-content.test.ts | 12 ++++++----- src/__tests__/skill-recall-gate.test.ts | 19 ++++++++++++++++++ src/__tests__/skill-show.test.ts | 1 + src/__tests__/skip-uninstalled-tools.test.ts | 21 ++++++++++++++++++++ src/builtin-skills.ts | 13 +++++++----- src/skill-content.ts | 13 +++++++++++- src/utils/logger.ts | 7 +++++-- 12 files changed, 77 insertions(+), 21 deletions(-) diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index 721d4761..bea62647 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -48,11 +48,11 @@ What an agent reads, and when: ```text session start stub frontmatter (description) 1 005 B always in context task matches stub body 1 524 B holds the commands -`teamai skill get core` daily workflow 6 533 B on demand +`teamai skill get core` daily workflow 6 479 B on demand `… core --full` + commands.md, contribute-member, - troubleshooting 36 321 B on demand -`… setup` / `wiki` 5 620 B / 19 386 B on demand -`… setup --full` / `… wiki --full` 38 624 B / 133 020 B on demand + troubleshooting 36 267 B on demand +`… setup` / `wiki` 5 566 B / 19 315 B on demand +`… setup --full` / `… wiki --full` 38 570 B / 132 949 B on demand ``` Served sizes include the resolved `{SKILL_DIR}`, so they grow with the install diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index 5aa6681a..b9a02ca6 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -3,7 +3,6 @@ name: core description: >- TeamAI daily workflow: route a /teamai request, sync with pull and push, inspect status, diagnose with doctor, and reach the specialized workflows. Loaded by the teamai discovery stub. -allowed-tools: Bash(teamai:*), Bash(npx teamai-cli:*) --- # teamai — daily workflow diff --git a/skill-data/setup/SKILL.md b/skill-data/setup/SKILL.md index 7173b8cb..3996e67d 100644 --- a/skill-data/setup/SKILL.md +++ b/skill-data/setup/SKILL.md @@ -3,7 +3,6 @@ name: setup description: >- TeamAI day 0 and repo lifecycle: create a team repo as admin, join an existing team as a member, manage members, roles, MCP and env, and uninstall. Loaded on demand by the teamai discovery stub. -allowed-tools: Bash(teamai:*), Bash(npx teamai-cli:*) --- # teamai — setup and lifecycle diff --git a/skill-data/share/SKILL.md b/skill-data/share/SKILL.md index 5a8be62d..b9a9a884 100644 --- a/skill-data/share/SKILL.md +++ b/skill-data/share/SKILL.md @@ -4,7 +4,6 @@ description: >- Turn a session into a team learning: summarize what was solved, discovered or worked around, and publish it to the team knowledge base with `teamai contribute`. Loaded on demand by the teamai discovery stub, and by the friction reminder that ends a session worth sharing. -allowed-tools: Bash(teamai:*), Bash(npx teamai-cli:*) --- # Contribute — share what a session taught you with the team diff --git a/skill-data/wiki/SKILL.md b/skill-data/wiki/SKILL.md index 1c8a94fc..54869d91 100644 --- a/skill-data/wiki/SKILL.md +++ b/skill-data/wiki/SKILL.md @@ -9,7 +9,6 @@ description: >- the code directly. Triggers: architecture analysis, architecture reverse-engineering, codebase knowledge base, code-to-knowledge, architecture wiki, large multi-repo codebase. Loaded on demand by the teamai discovery stub. -allowed-tools: Bash(teamai:*), Bash(npx teamai-cli:*), Bash(python3:*) --- # wiki: AI cognition engineering for large codebases diff --git a/src/__tests__/skill-content.test.ts b/src/__tests__/skill-content.test.ts index 2347acda..99aaa02c 100644 --- a/src/__tests__/skill-content.test.ts +++ b/src/__tests__/skill-content.test.ts @@ -295,15 +295,16 @@ describe('teamai skill get / path against the shipped package', () => { }); describe('the shipped skill-data content', () => { - it('names every skill after its directory, and declares allowed-tools', async () => { + it('names every skill after its directory, and promises no permissions it cannot grant', async () => { for (const skill of await listServableSkills()) { const text = fs.readFileSync(path.join(skill.dir, 'SKILL.md'), 'utf8'); // A frontmatter name that disagrees with the directory makes the skill // undiscoverable for the agent and unresolvable for `skill get`. expect(text, skill.name).toMatch(new RegExp(`^name: ${skill.name}$`, 'm')); - // Content loaded as text inherits no permissions, so each skill declares - // the commands it tells the agent to run. - expect(text, skill.name).toMatch(/^allowed-tools: .*Bash\(teamai:\*\)/m); + // `skill get` prints this frontmatter as command output; the agent never + // processes it as skill metadata, so an `allowed-tools` line here would + // claim grants that do not happen. Only the deployed stub's counts. + expect(text, skill.name).not.toMatch(/^allowed-tools:/m); } }); @@ -311,7 +312,8 @@ describe('the shipped skill-data content', () => { const stub = fs.readFileSync(path.join(ROOT, 'skills/teamai/SKILL.md'), 'utf8'); expect(stub).toMatch(/^name: teamai$/m); // The stub is the always-loaded unit, so it pre-approves only the read-only - // `teamai skill …` commands it asks for; the served skills grant the rest. + // `teamai skill …` commands it asks for. Everything else a served workflow + // runs goes through the agent's own permission prompt. expect(stub).toMatch(/^allowed-tools: Bash\(teamai skill:\*\), Bash\(npx teamai-cli skill:\*\)$/m); }); diff --git a/src/__tests__/skill-recall-gate.test.ts b/src/__tests__/skill-recall-gate.test.ts index a0027877..32618ba5 100644 --- a/src/__tests__/skill-recall-gate.test.ts +++ b/src/__tests__/skill-recall-gate.test.ts @@ -122,6 +122,25 @@ describe('recall gate on served skills', () => { expect((await skillCatalog()).find((entry) => entry.name === 'share')).toMatchObject({ blockedBy: 'read-only', path: null }); }); + it('keeps a config-migration line off stdout, so the content and the JSON stay exact', async () => { + // Loading an upgraded config can migrate it and say so with log.info. + autoDetectInit.mockImplementation(async () => { + const { log } = await import('../utils/logger.js'); + log.info('Migrated legacy teamai config to default role profile: hai'); + return { localConfig: { recallEnabled: true }, teamConfig: { sharing: { recall: { enabled: true } } } }; + }); + + await skillGet(['share']); + expect(stdout.startsWith('---\nname: share')).toBe(true); + expect(stdout).not.toContain('Migrated legacy'); + expect(stderr).toContain('Migrated legacy'); + + stdout = ''; + const { skillList } = await import('../skill-cmd.js'); + await skillList({ json: true }); + expect(() => JSON.parse(stdout)).not.toThrow(); + }); + it('never gates the skills that do not depend on recall', async () => { withRecall(false); diff --git a/src/__tests__/skill-show.test.ts b/src/__tests__/skill-show.test.ts index abef0427..3d1b14c3 100644 --- a/src/__tests__/skill-show.test.ts +++ b/src/__tests__/skill-show.test.ts @@ -12,6 +12,7 @@ vi.mock('../utils/logger.js', () => ({ debug: vi.fn(), dim: vi.fn(), }, + setStderrOnly: vi.fn(() => false), })); import type { LocalConfig, TeamaiConfig } from '../types.js'; diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index ec00cd85..5c6c0540 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -834,6 +834,27 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { expect(await fse.pathExists(path.join(homeDir, '.openclaw/skills'))).toBe(false); }); + it('refuses a linked HERMES_HOME outside the home directory, the root itself not only what is under it', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + const target = path.join(tmpDir, 'dotfiles/hermes'); + await fse.ensureDir(path.join(target, 'skills/team-wiki-codebase')); + await fse.writeFile(path.join(target, 'skills/team-wiki-codebase/SKILL.md'), '# theirs'); + const hermesHome = path.join(tmpDir, 'elsewhere/hermes'); + await fse.ensureDir(path.dirname(hermesHome)); + await fse.symlink(target, hermesHome, 'dir'); + vi.stubEnv('HERMES_HOME', hermesHome); + + const deployed = await deployBuiltinSkills( + legacyPruneTeamConfig({ hermes: { skills: '.hermes/skills' } }), + legacyPruneLocalConfig(tmpDir), + ); + + expect(deployed).toBe(0); + expect(await fse.readFile(path.join(target, 'skills/team-wiki-codebase/SKILL.md'), 'utf8')).toBe('# theirs'); + expect(await fse.pathExists(path.join(target, 'skills/teamai'))).toBe(false); + }); + it('keeps a member\'s file under a __pycache__ that is not bytecode of a shipped script', async () => { const { deployBuiltinSkills } = await import('../builtin-skills.js'); diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index 35bf7118..4e6d30fd 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -314,21 +314,24 @@ export interface BuiltinSkillsTarget { skillsDir: string; /** * The tool's base directory when the skills directory sits under it, else - * the skills directory's parent: OpenClaw's workspace and a `HERMES_HOME` - * can live anywhere, and the guard must still walk every component the CLI - * did not choose. + * the parent of the configured root: OpenClaw's workspace and a + * `HERMES_HOME` can live anywhere, and the guard must still check that root + * and every component below it. */ guardBase: string; } /** * The base the link guard walks down from: the tool's base directory when - * `skillsDir` sits under it, else `skillsDir`'s parent. + * `skillsDir` sits under it. Otherwise the root was configured outside it + * (`HERMES_HOME`, an OpenClaw workspace), and the walk starts above that root + * so the root itself is checked too: a linked `HERMES_HOME` is refused like a + * linked `~/.claude`. */ export function skillsGuardBase(toolBaseDir: string, skillsDir: string): string { const relative = path.relative(toolBaseDir, skillsDir); const underBase = relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative); - return underBase ? toolBaseDir : path.dirname(skillsDir); + return underBase ? toolBaseDir : path.dirname(path.dirname(skillsDir)); } /** diff --git a/src/skill-content.ts b/src/skill-content.ts index 92809c8c..dbae692b 100644 --- a/src/skill-content.ts +++ b/src/skill-content.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import chalk from 'chalk'; import { listFilesRecursive, pathExists } from './utils/fs.js'; import { readSkillDescription } from './agent-skills.js'; +import { setStderrOnly } from './utils/logger.js'; // ─── CLI-served skill content ──────────────────────────── // @@ -77,7 +78,17 @@ async function blockReason(name: string): Promise<SkillBlockReason | null> { import('./config.js'), import('./types.js'), ]); - const { localConfig, teamConfig } = await autoDetectInit(); + // Loading the config can migrate it and say so with `log.info`. That line + // must not land in the skill content or the JSON these commands print on + // stdout, so config loading reports on stderr for this one call. + const previous = setStderrOnly(true); + let loaded: Awaited<ReturnType<typeof autoDetectInit>>; + try { + loaded = await autoDetectInit(); + } finally { + setStderrOnly(previous); + } + const { localConfig, teamConfig } = loaded; // `teamai contribute` refuses a read-only source (read-only.ts), so the // workflow would fail at its last step after the agent did all the work. if (localConfig.repo?.kind === 'http') return 'read-only'; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index b261c473..aba89b09 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -115,10 +115,13 @@ export function setSilent(s: boolean): void { /** * Route non-error log output to stderr. Used by hook-dispatch commands to - * keep stdout as a clean JSON channel for the AI tool. + * keep stdout as a clean JSON channel for the AI tool. Returns the previous + * mode, so a caller that needs it for one step can put it back. */ -export function setStderrOnly(s: boolean): void { +export function setStderrOnly(s: boolean): boolean { + const previous = stderrMode; stderrMode = s; + return previous; } /** Write a "non-error" log line. Goes to stderr in hook mode, stdout otherwise. */ From 899e35764eff3242094530a80c3440381a1ff328 Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Wed, 23 Sep 2026 01:28:25 +0200 Subject: [PATCH 30/37] fix(skills): walk the link guard from the scope root, so a linked COPILOT_HOME is refused skillsGuardBase started at the tool's base directory, which for Copilot in user scope is COPILOT_HOME, so the walk never checked whether COPILOT_HOME itself was a link, and pull and uninstall wrote and pruned through it. The guard now starts at the scope root (home, or the project root), where a link at or above is ordinary, in deploy, the legacy prune and uninstall alike; a root configured outside it still has the walk start just above that root. --- docs/designs/skill-serving.md | 11 ++++--- src/__tests__/skip-uninstalled-tools.test.ts | 22 +++++++++++++- src/__tests__/uninstall.test.ts | 18 +++++++++++ src/builtin-skills.ts | 32 +++++++++++--------- src/uninstall.ts | 15 ++++++--- 5 files changed, 73 insertions(+), 25 deletions(-) diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index bea62647..eb72f4c3 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -76,8 +76,9 @@ path (measured here from a 77-character one). `recall disable` and `uninstall` resolve the skills directory through `skillsDirForTool`, the resolver team-skill sync uses, so OpenClaw gets it in its workspace and Hermes under `HERMES_HOME` rather than under a tool root - that agent never reads. The link guard walks from the tool's base directory - when the skills directory is under it, else from that directory's parent. + that agent never reads. The link guard walks from the scope root (home, or + the project root) when the skills directory is under it, else from just above + the configured root, so that root is checked too. - **Nothing repairs the deployed stub.** `ensureSkillFrontmatter` is not called on it, so deployed and packaged bytes are identical and a diff means a bug. - **Recall is decided at run time**, not by withholding a directory at deploy @@ -177,9 +178,9 @@ migration stops being a one-way door for any of them. That path is the machine's home, never the tool's base directory, which under project scope is the repo root. Only *retired* paths are archived: the stub is rewritten on every session start, so archiving it would file an identical copy per session forever. A -link on any component between the tool's base directory and the skill -directory — `~/.claude`, `~/.config/opencode`, `~/.claude/skills`, the skill -directory itself — is refused outright: neither pruned nor written through, link +link on any component between the scope root (home, or the project root) and +the skill directory — `~/.claude`, `~/.config/opencode`, `~/.claude/skills`, +`COPILOT_HOME`, the skill directory itself — is refused outright: neither pruned nor written through, link and target untouched, since everything under it matches our names and none of it is ours. Pull, deploy and `uninstall` apply the same check; uninstall carries each skill directory's base for it. Components at or above the base are not diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index 5c6c0540..32212fe0 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -8,7 +8,9 @@ import { listFilesRecursive } from '../utils/fs.js'; const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); /** The team config the prune tests share; pass toolPaths to change which tool runs. */ -function legacyPruneTeamConfig(toolPaths: Record<string, { skills: string }> = { claude: { skills: '.claude/skills' } }) { +function legacyPruneTeamConfig( + toolPaths: Record<string, { skills: string; userScope?: { skills: string } }> = { claude: { skills: '.claude/skills' } }, +) { return { team: 'test', description: '', @@ -855,6 +857,24 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { expect(await fse.pathExists(path.join(target, 'skills/teamai'))).toBe(false); }); + it('refuses a linked COPILOT_HOME, which is Copilot\'s own base directory in user scope', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + const target = path.join(tmpDir, 'dotfiles/copilot'); + await fse.ensureDir(path.join(target, 'skills/team-wiki-codebase')); + await fse.writeFile(path.join(target, 'skills/team-wiki-codebase/SKILL.md'), '# theirs'); + await fse.symlink(target, path.join(homeDir, '.copilot'), 'dir'); + + const deployed = await deployBuiltinSkills( + legacyPruneTeamConfig({ copilot: { skills: '.github/skills', userScope: { skills: 'skills' } } }), + legacyPruneLocalConfig(tmpDir), + ); + + expect(deployed).toBe(0); + expect(await fse.readFile(path.join(target, 'skills/team-wiki-codebase/SKILL.md'), 'utf8')).toBe('# theirs'); + expect(await fse.pathExists(path.join(target, 'skills/teamai'))).toBe(false); + }); + it('keeps a member\'s file under a __pycache__ that is not bytecode of a shipped script', async () => { const { deployBuiltinSkills } = await import('../builtin-skills.js'); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index ea27796e..2a04e9a2 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -1097,6 +1097,24 @@ describe('uninstall', () => { expect(await fse.pathExists(path.join(dotfiles, 'skills', 'teamai', 'SKILL.md'))).toBe(true); }); + it('does not delete through a linked COPILOT_HOME, Copilot\'s own base directory in user scope', async () => { + const { homeDir, repoPath } = await setupFixture(tmpDir); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + const target = path.join(tmpDir, 'dotfiles-copilot'); + await fse.ensureDir(path.join(target, 'skills', 'teamai')); + await fse.writeFile(path.join(target, 'skills', 'teamai', 'SKILL.md'), '# stub in the checkout'); + await fse.symlink(target, path.join(homeDir, '.copilot'), 'dir'); + + const localConfig = makeLocalConfig(homeDir, repoPath); + const teamConfig = makeTeamConfig(); + teamConfig.toolPaths.copilot = { skills: '.github/skills', userScope: { skills: 'skills' } }; + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + await uninstall({ force: true }); + + expect(await fse.pathExists(path.join(target, 'skills', 'teamai', 'SKILL.md'))).toBe(true); + }); + it('removes the stub where Hermes keeps skills, under HERMES_HOME outside the home directory', async () => { const { homeDir, repoPath } = await setupFixture(tmpDir); vi.stubEnv('HOME', homeDir); diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index 4e6d30fd..c35def00 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -5,7 +5,7 @@ import fse from 'fs-extra'; import { pathExists, remove } from './utils/fs.js'; import { log } from './utils/logger.js'; import type { TeamaiConfig, LocalConfig } from './types.js'; -import { resolveToolBaseDir, isAgentExcluded, scopedToolPaths } from './types.js'; +import { resolveBaseDir, isAgentExcluded, scopedToolPaths } from './types.js'; import { ResourceHandler } from './resources/base.js'; import { CODEX_TOOL, resolveSkillDestination, SHARED_AGENT_SKILLS_PATH, skillsDirForTool, skillTargetForTool } from './resources/skills.js'; import { getUserHome } from './utils/home.js'; @@ -313,25 +313,27 @@ function skillBackupDir(baseDir: string, tool: string, skillRoot: string, skillN export interface BuiltinSkillsTarget { skillsDir: string; /** - * The tool's base directory when the skills directory sits under it, else - * the parent of the configured root: OpenClaw's workspace and a - * `HERMES_HOME` can live anywhere, and the guard must still check that root - * and every component below it. + * The scope root (home, or the project root) when the skills directory sits + * under it, else the parent of the configured root: `COPILOT_HOME`, + * `HERMES_HOME` and OpenClaw's workspace can live anywhere, and the guard + * must still check that root and every component below it. */ guardBase: string; } /** - * The base the link guard walks down from: the tool's base directory when - * `skillsDir` sits under it. Otherwise the root was configured outside it - * (`HERMES_HOME`, an OpenClaw workspace), and the walk starts above that root - * so the root itself is checked too: a linked `HERMES_HOME` is refused like a - * linked `~/.claude`. + * The base the link guard walks down from: the scope root — home, or the + * project root — when `skillsDir` sits under it, since a link at or above that + * is ordinary. Not the tool's base directory: for Copilot that is + * `COPILOT_HOME`, and starting there would never check whether `COPILOT_HOME` + * itself is a link. A root configured outside the scope root (`HERMES_HOME`, + * an OpenClaw workspace) has the walk start just above it, so that root is + * checked too. */ -export function skillsGuardBase(toolBaseDir: string, skillsDir: string): string { - const relative = path.relative(toolBaseDir, skillsDir); - const underBase = relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative); - return underBase ? toolBaseDir : path.dirname(path.dirname(skillsDir)); +export function skillsGuardBase(scopeRoot: string, skillsDir: string): string { + const relative = path.relative(scopeRoot, skillsDir); + const underRoot = relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative); + return underRoot ? scopeRoot : path.dirname(path.dirname(skillsDir)); } /** @@ -352,7 +354,7 @@ export async function builtinSkillsTarget( } const skillsDir = await skillsDirForTool(tool, configuredSkillsPath, localConfig); if (skillsDir === null) return null; - return { skillsDir, guardBase: skillsGuardBase(resolveToolBaseDir(tool, localConfig), skillsDir) }; + return { skillsDir, guardBase: skillsGuardBase(resolveBaseDir(localConfig), skillsDir) }; } /** diff --git a/src/uninstall.ts b/src/uninstall.ts index 34de59cd..e62d162b 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -254,6 +254,8 @@ async function discoverToolResources( tool: string, toolPath: TeamaiConfig['toolPaths'][string], baseDir: string, + /** Home, or the project root: where the skills link guard starts (`skillsGuardBase`). */ + scopeRoot: string, teamSkillNames: Set<string>, teamRuleNames: Set<string>, teamAgentNames: Set<string>, @@ -364,25 +366,29 @@ async function discoverToolResources( // (c) Skills — only those matching team repo if (toolPath.skills) { // Skills root → the base the link guard starts from. - const skillRoots = new Map([[path.join(baseDir, toolPath.skills), baseDir]]); + const configuredSkills = path.join(baseDir, toolPath.skills); + const skillRoots = new Map([[configuredSkills, skillsGuardBase(scopeRoot, configuredSkills)]]); // OpenClaw and Hermes receive skills where team sync and the stub put them // (`skillsDirForTool`): the workspace, and HERMES_HOME. if (tool === 'openclaw') { const workspaceDir = await resolveOpenclawWorkspaceDir(); if (workspaceDir) { const workspaceSkills = path.join(workspaceDir, 'skills'); - skillRoots.set(workspaceSkills, skillsGuardBase(baseDir, workspaceSkills)); + skillRoots.set(workspaceSkills, skillsGuardBase(scopeRoot, workspaceSkills)); } } if (tool === 'hermes') { const hermesSkills = path.join(getHermesHome(), 'skills'); - skillRoots.set(hermesSkills, skillsGuardBase(baseDir, hermesSkills)); + skillRoots.set(hermesSkills, skillsGuardBase(scopeRoot, hermesSkills)); } // `resolveSkillDestination` writes Codex's copy into the shared // .agents/skills root whenever that skill already lives there, so uninstall // must look where deployment could have put it — the legacy prune already // does. Codex only: another tool's pass must not reach into it. - if (tool === CODEX_TOOL) skillRoots.set(path.join(baseDir, SHARED_AGENT_SKILLS_PATH), baseDir); + if (tool === CODEX_TOOL) { + const sharedSkills = path.join(baseDir, SHARED_AGENT_SKILLS_PATH); + skillRoots.set(sharedSkills, skillsGuardBase(scopeRoot, sharedSkills)); + } for (const [skillsDir, rootBase] of skillRoots) { if (await pathExists(skillsDir)) { const dirs = await listDirs(skillsDir); @@ -499,6 +505,7 @@ async function buildRemovalPlan( tool, toolPath, resolveToolBaseDir(tool, localConfig), + resolveBaseDir(localConfig), teamSkillNames, teamRuleNames, teamAgentNames, From cba3c46a849f04d4d330df6aa1584c7df7cebf77 Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Wed, 23 Sep 2026 01:51:39 +0200 Subject: [PATCH 31/37] fix(skills): keep generated documents in Simplified Chinese, fail on a broken config, quote skill paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - share and wiki had moved generated learnings and knowledge-base documents from always Chinese to the session language. Serving the instructions from the CLI does not need that, so both say "Simplified Chinese" again, in an English instruction; the CHANGELOG entry follows. - skill show and skill list treated every autoDetectInit failure as "not initialized" and pointed at `teamai init`. requireInit now throws a tagged NotInitializedError; only that falls back to the packaged catalog, and a malformed or unreadable config propagates. - `$(teamai skill path …)/…` is word-split in a shell command like an unquoted {SKILL_DIR}; all ten occurrences are double-quoted and the quoting test covers the form. --- CHANGELOG.md | 2 +- skill-data/setup/SKILL.md | 4 ++-- skill-data/setup/references/join-member.md | 6 +++--- skill-data/setup/references/manage-admin.md | 4 ++-- skill-data/setup/references/setup-admin.md | 4 ++-- skill-data/share/SKILL.md | 7 +++---- skill-data/wiki/SKILL.md | 2 +- src/__tests__/skill-content.test.ts | 7 ++++++- .../skill-list-uninitialized.test.ts | 20 ++++++++++++++++--- src/config.ts | 13 ++++++++++-- src/skill-cmd.ts | 9 ++++++--- 11 files changed, 54 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 535083b3..4c03d83e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. See [standa ### ✨ Features -- Built-in skill content ships inside the npm package and is printed by the installed CLI: `teamai skill get <core|setup|wiki|share> [--full] [--all]`, `teamai skill path <name>` for the directory holding a skill's scripts, and `teamai skill list --json` for the catalog. Agents receive one file, `skills/teamai/SKILL.md`, a discovery stub that points at those commands, so what an agent reads always matches the CLI version it is running. `teamai pull` removes the `team-wiki-codebase`, `teamai-share-learnings` and `teamai/references/*.md` trees earlier releases copied into every agent directory, archiving each removed file under `~/.teamai/removed-skills/<run>/…` first and keeping any directory that holds a member's own file; `teamai uninstall` removes only the packaged files from CLI-owned skill directories by the same rule. `share` is served only while recall is on and the team source is writable (not a read-only HTTP one), and the end-of-session share reminder is withheld until then too. The served workflows are English; learning and knowledge-base documents are written in the user's language (previously always Chinese), and an existing knowledge base keeps its file names and headings. The legacy names still resolve as aliases (for [#678](https://github.com/Tencent/teamai-cli/issues/678), [#730](https://github.com/Tencent/teamai-cli/issues/730)). +- Built-in skill content ships inside the npm package and is printed by the installed CLI: `teamai skill get <core|setup|wiki|share> [--full] [--all]`, `teamai skill path <name>` for the directory holding a skill's scripts, and `teamai skill list --json` for the catalog. Agents receive one file, `skills/teamai/SKILL.md`, a discovery stub that points at those commands, so what an agent reads always matches the CLI version it is running. `teamai pull` removes the `team-wiki-codebase`, `teamai-share-learnings` and `teamai/references/*.md` trees earlier releases copied into every agent directory, archiving each removed file under `~/.teamai/removed-skills/<run>/…` first and keeping any directory that holds a member's own file; `teamai uninstall` removes only the packaged files from CLI-owned skill directories by the same rule. `share` is served only while recall is on and the team source is writable (not a read-only HTTP one), and the end-of-session share reminder is withheld until then too. The served workflows are English; learning and knowledge-base documents are still written in Simplified Chinese, and an existing knowledge base keeps its file names and headings. The legacy names still resolve as aliases (for [#678](https://github.com/Tencent/teamai-cli/issues/678), [#730](https://github.com/Tencent/teamai-cli/issues/730)). - Hooks, MCP servers and env variables can be scoped by logical project, the second membership axis they lacked. A `hooks/hooks.yaml` hook and an `mcp/mcp.yaml` server accept an optional `projects:` list beside `roles:`, and an `env/env.yaml` variable accepts both. An entry reaches a member when one of the projects its directory is bound to (`teamai projects set`) is listed; `projects: []` reaches nobody, and a directory bound to no project keeps receiving every entry, so nothing changes until a maintainer adds the key. The two axes compose as AND, the way `tools:` and `roles:` already do, so `roles: [frontend] projects: [checkout]` reaches frontend members of checkout rather than everyone on either. Rebinding with `teamai projects set` removes the previous project's entries on the next pull — for env that means the variable leaves `env.sh`, also on a pull that finds the team repo unchanged, so a machine upgrading from a CLI that ignored the keys drops a withheld variable without `--force`, and a refresh that cannot be written there is reported with the path and the way out rather than passing silently under `Already synced`; `teamai doctor` applies the same filter, so a variable correctly withheld is not reported as undelivered, while one that `env.sh` still exports after a rebind is reported until the next pull rewrites the file. An id that `manifest/projects.yaml` does not define produces one warning per pull, and so does a `projects:` key in a team with no projects manifest: the key still filters against the ids in the directory's `config.yaml`, but nothing can validate them. `teamai mcp list`, `teamai hooks list` and `teamai env list` show the restriction, and `pull` reports `Synced 1 of 3 env variable(s)` when scoping withheld some. This is what the keys exist to control: a team with five projects and three MCP servers each gave every member of a role fifteen server processes and fifteen tool lists in the context of every session (for [#668](https://github.com/Tencent/teamai-cli/issues/668)). diff --git a/skill-data/setup/SKILL.md b/skill-data/setup/SKILL.md index 3996e67d..a53633ec 100644 --- a/skill-data/setup/SKILL.md +++ b/skill-data/setup/SKILL.md @@ -29,8 +29,8 @@ is missing. | Join their team, with or without a repo URL | `{SKILL_DIR}/references/join-member.md` | | Publish or update skills, rules, MCP, env; invite members; manage roles | `{SKILL_DIR}/references/manage-admin.md` | | Remove TeamAI from this machine | `{SKILL_DIR}/references/uninstall.md` | -| Publish one skill or contribute a doc | `$(teamai skill path core)/references/contribute-member.md` | -| Anything that breaks along the way | `$(teamai skill path core)/references/troubleshooting.md` | +| Publish one skill or contribute a doc | `"$(teamai skill path core)/references/contribute-member.md"` | +| Anything that breaks along the way | `"$(teamai skill path core)/references/troubleshooting.md"` | Supported Git providers are Tencent TGit, GitHub, GitLab and CNB; `{SKILL_DIR}/references/setup-admin.md` carries the detection probe, the sign-in diff --git a/skill-data/setup/references/join-member.md b/skill-data/setup/references/join-member.md index 0228846b..54561b82 100644 --- a/skill-data/setup/references/join-member.md +++ b/skill-data/setup/references/join-member.md @@ -97,7 +97,7 @@ teamai hooks list # per-tool: which AI tools actually got the hooks Fix anything `doctor` reports. **Don't trust the "Hooks injected into all AI tool settings" message alone** — it prints even for tools where nothing was written; `teamai doctor` / `teamai hooks list` show the real per-tool status. If it flags -hook problems, load the troubleshooting reference (`$(teamai skill path core)/references/troubleshooting.md`), +hook problems, load the troubleshooting reference (`"$(teamai skill path core)/references/troubleshooting.md"`), section "Which tools actually get hooks". ## Step 6 — Confirm the skills actually arrived @@ -123,7 +123,7 @@ tool names only, on a separate branch of that same repo.) ## Agent-specific note If this conversation is running in **ChatGPT App** or **WorkBuddy**, the hooks -that drive auto-sync need an extra manual step — load the troubleshooting reference (`$(teamai skill path core)/references/troubleshooting.md`), section "Agent-specific caveats" and walk the user through it before finishing. +that drive auto-sync need an extra manual step — load the troubleshooting reference (`"$(teamai skill path core)/references/troubleshooting.md"`), section "Agent-specific caveats" and walk the user through it before finishing. ## If something is denied @@ -146,7 +146,7 @@ Summarize the outcome **in the user's own language** (global rule 1). Cover: 3. **They can also contribute a skill — just ask in plain language.** A member does not need to be an admin to publish a skill. They tell TeamAI something like *"share this xxx skill with my team"*, in their own language, and you - run the publish for them (see `$(teamai skill path core)/references/contribute-member.md`; + run the publish for them (see `"$(teamai skill path core)/references/contribute-member.md"`; it needs no recall). 4. **How to leave — via the skill, not raw commands.** They can remove TeamAI any time by re-invoking the skill; you'll run it for them: diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index d28eaf44..c8fe0ae3 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -114,7 +114,7 @@ teamai env remove <KEY> # remove ## When sync fails Run `teamai doctor` first. If it reports hook or path problems, load -the troubleshooting reference (`$(teamai skill path core)/references/troubleshooting.md`). Have the affected member reopen their session; if their tool +the troubleshooting reference (`"$(teamai skill path core)/references/troubleshooting.md"`). Have the affected member reopen their session; if their tool has no session-start hook, they run `teamai pull` manually. ## Capture a lesson learned @@ -126,7 +126,7 @@ worth sharing, TeamAI prompts the member and the dedicated `share` workflow (`teamai skill get share`) summarizes the session and runs `teamai contribute`. Nobody has to invoke it by hand. (Publishing a **reusable skill** someone authored is a different task — any member -can do it, see `$(teamai skill path core)/references/contribute-member.md`.) +can do it, see `"$(teamai skill path core)/references/contribute-member.md"`.) ### Turn the sharing prompt on or off (admin) diff --git a/skill-data/setup/references/setup-admin.md b/skill-data/setup/references/setup-admin.md index dcaad4d9..e837130d 100644 --- a/skill-data/setup/references/setup-admin.md +++ b/skill-data/setup/references/setup-admin.md @@ -200,7 +200,7 @@ Claude Code"). Omitting `--agent` gives an interactive picker — select **every tool already installed** on the machine. Then **report back which agents were set up**, in the user's language: name the tools that will now auto-start TeamAI, and any detected tool that was skipped and why (e.g. Codex trust-gate, -CodeBuddy/WorkBuddy by design — see the troubleshooting reference, `$(teamai skill path core)/references/troubleshooting.md`). +CodeBuddy/WorkBuddy by design — see the troubleshooting reference, `"$(teamai skill path core)/references/troubleshooting.md"`). ## Step 6 — Verify with doctor @@ -216,7 +216,7 @@ it prints even for tools where nothing was written. `teamai doctor` / `teamai ho list` show the real per-tool status. Only the tool you set up (e.g. `claude`) is expected to show hooks installed; others are skipped by design or not yet supported, which is normal. Full table in the troubleshooting reference -(`$(teamai skill path core)/references/troubleshooting.md`), section "Which tools actually get hooks". +(`"$(teamai skill path core)/references/troubleshooting.md"`), section "Which tools actually get hooks". ## Step 7 — Grant members repo access (required before they can join) diff --git a/skill-data/share/SKILL.md b/skill-data/share/SKILL.md index b9a9a884..12fb76c1 100644 --- a/skill-data/share/SKILL.md +++ b/skill-data/share/SKILL.md @@ -10,9 +10,8 @@ description: >- Summarize what this AI coding session taught you and push it to the team knowledge base. -**Write the document in the language the user used in this session** (a Chinese conversation -gets a Chinese document, an English one an English document). Commands, flags, URLs, paths and -code identifiers stay as they are. +**Write the document in Simplified Chinese**, as earlier releases required. Commands, flags, +URLs, paths and code identifiers stay as they are. ## When to Use @@ -57,7 +56,7 @@ teamai contribute --file /tmp/session-summary.md --title "Debugging K8s pod star A member asking to publish a skill ("share this xxx skill with my team") is a different flow, and it does not need recall: it lives in the `core` skill, at -`$(teamai skill path core)/references/contribute-member.md`. This file +`"$(teamai skill path core)/references/contribute-member.md"`. This file is for turning a *session* into a learning. ## References diff --git a/skill-data/wiki/SKILL.md b/skill-data/wiki/SKILL.md index 54869d91..861ebf15 100644 --- a/skill-data/wiki/SKILL.md +++ b/skill-data/wiki/SKILL.md @@ -16,7 +16,7 @@ description: >- > Prerequisites: an accessible source directory (multiple repositories supported), Python 3, and an installed teamai CLI. > The methodology, sub-agent prompts, templates and scripts ship with the CLI. Run `teamai skill path wiki` to get their absolute path; > `{SKILL_DIR}` in this document refers to that path; a reference file you open on its own writes that directory as `SKILL_DIR` in braces. -> Write documents in the language the user works in. When updating an existing knowledge base, keep its file names and headings; `validate_kb.py` accepts both the current English and the earlier Chinese headings. +> Write the knowledge-base documents in Simplified Chinese, as earlier releases did. When updating an existing knowledge base, keep its file names and headings; `validate_kb.py` accepts both the current English and the earlier Chinese headings. > The Phase 0 structural baseline uses `teamai codebase --extract`. TeamAI does not ship a separate team-wiki CLI. No extra plugin is required. **The problem**: large projects (10+ repositories, dozens of microservices, years of iteration) defeat global understanding by AI. The context window cannot hold all the code, component relations are scattered everywhere, and business rules hide deep in call chains. Letting AI read the code directly is both slow (huge token counts) and inaccurate (no global view). diff --git a/src/__tests__/skill-content.test.ts b/src/__tests__/skill-content.test.ts index 99aaa02c..15a8a661 100644 --- a/src/__tests__/skill-content.test.ts +++ b/src/__tests__/skill-content.test.ts @@ -317,7 +317,7 @@ describe('the shipped skill-data content', () => { expect(stub).toMatch(/^allowed-tools: Bash\(teamai skill:\*\), Bash\(npx teamai-cli skill:\*\)$/m); }); - it('quotes {SKILL_DIR} in every command it tells the agent to run', async () => { + it('quotes {SKILL_DIR} and $(teamai skill path …) in every command it tells the agent to run', async () => { // The placeholder resolves to the install path, which can hold a space // ("Program Files", "~/Library/Application Support", a user's full name) or // be a Windows path used through Bash. An unquoted occurrence in a command @@ -338,6 +338,11 @@ describe('the shipped skill-data content', () => { if (/(?:^|[`\s(])(?:python3?|node|bash|sh|cp|mv|cat|ls|rm)\s+\{SKILL_DIR\}/.test(line)) { offenders.push(`${skill.name}/${relative}:${i + 1}: ${line.trim()}`); } + // `$(teamai skill path …)` is word-split in a shell command just the + // same, so it is always written inside double quotes. + if (/(?<!")\$\(teamai skill path /.test(line)) { + offenders.push(`${skill.name}/${relative}:${i + 1}: ${line.trim()}`); + } }); } } diff --git a/src/__tests__/skill-list-uninitialized.test.ts b/src/__tests__/skill-list-uninitialized.test.ts index 28ab0ef0..d6b4384f 100644 --- a/src/__tests__/skill-list-uninitialized.test.ts +++ b/src/__tests__/skill-list-uninitialized.test.ts @@ -1,9 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { autoDetectInit, logDim } = vi.hoisted(() => ({ autoDetectInit: vi.fn(), logDim: vi.fn() })); -vi.mock('../config.js', () => ({ autoDetectInit })); +const { autoDetectInit, logDim, NotInitializedError } = vi.hoisted(() => ({ + autoDetectInit: vi.fn(), + logDim: vi.fn(), + NotInitializedError: class NotInitializedError extends Error {}, +})); +vi.mock('../config.js', () => ({ autoDetectInit, NotInitializedError })); vi.mock('../utils/logger.js', () => ({ log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: logDim }, + setStderrOnly: vi.fn(() => false), })); import { skillList } from '../skill-cmd.js'; @@ -32,7 +37,7 @@ describe('teamai skill list before init', () => { }); it('prints the packaged catalog and says what to run for the rest', async () => { - autoDetectInit.mockRejectedValue(new Error('teamai is not initialized. Run `teamai init` first.')); + autoDetectInit.mockRejectedValue(new NotInitializedError('teamai is not initialized. Run `teamai init` first.')); await skillList({}); @@ -43,4 +48,13 @@ describe('teamai skill list before init', () => { } expect(logDim).toHaveBeenCalledWith(expect.stringContaining('teamai init')); }); + + it('reports a broken config instead of calling the machine uninitialized', async () => { + // A config that exists but cannot be used is not "no team": telling the + // member to run `teamai init` would send them to re-init over a real setup. + autoDetectInit.mockRejectedValue(new Error('Team config (teamai.yaml) not found. Check your repo path.')); + + await expect(skillList({})).rejects.toThrow('Team config (teamai.yaml) not found'); + expect(logDim).not.toHaveBeenCalledWith(expect.stringContaining('Not initialized')); + }); }); diff --git a/src/config.ts b/src/config.ts index 9aeab136..75f8feae 100644 --- a/src/config.ts +++ b/src/config.ts @@ -119,13 +119,22 @@ export async function saveState(state: State): Promise<void> { await writeJson(expandHome(getUserStatePath()), state); } +/** + * No teamai config on this machine for the scope asked. Its own class so a + * command that can work without a team (the packaged skills) falls back on + * this and nothing else: a malformed config or an unreadable file still fails. + */ +export class NotInitializedError extends Error { + readonly name = 'NotInitializedError'; +} + /** * Require that teamai is initialized (local config exists) */ export async function requireInit(): Promise<{ localConfig: LocalConfig; teamConfig: TeamaiConfig }> { const localConfig = await loadLocalConfig(); if (!localConfig) { - throw new Error('teamai is not initialized. Run `teamai init` first.'); + throw new NotInitializedError('teamai is not initialized. Run `teamai init` first.'); } const teamConfig = await loadTeamConfig(localConfig.repo.localPath); if (!teamConfig) { @@ -410,7 +419,7 @@ export async function requireInitForScope( ): Promise<{ localConfig: LocalConfig; teamConfig: TeamaiConfig }> { const localConfig = await loadLocalConfigForScope(scope, projectRoot); if (!localConfig) { - throw new Error( + throw new NotInitializedError( scope === 'project' ? `teamai is not initialized in project scope at ${projectRoot}. Run \`teamai init\` first.` : 'teamai is not initialized. Run `teamai init` first.', diff --git a/src/skill-cmd.ts b/src/skill-cmd.ts index 5318a1aa..c4b8d3ef 100644 --- a/src/skill-cmd.ts +++ b/src/skill-cmd.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { autoDetectInit } from './config.js'; +import { autoDetectInit, NotInitializedError } from './config.js'; import { log } from './utils/logger.js'; import { listDirs, pathExists } from './utils/fs.js'; import { SkillsHandler } from './resources/skills.js'; @@ -52,9 +52,11 @@ export async function skillShow(name: string, options: GlobalOptions): Promise<v let init: { localConfig: LocalConfig; teamConfig: TeamaiConfig }; try { init = await autoDetectInit(); - } catch { + } catch (e) { // A packaged skill needs no team: it ships with the CLI, so `teamai skill // show core` still works on a machine that has never run `teamai init`. + // Only that case: a broken config is reported, not read as "no team". + if (!(e instanceof NotInitializedError)) throw e; const packaged = await resolveServableSkill(name); if (packaged.kind === 'blocked') { const { headline, hint } = blockMessage(packaged.name, packaged.reason); @@ -154,7 +156,8 @@ export async function skillList(options: GlobalOptions & { json?: boolean }): Pr let initialized = true; try { await autoDetectInit(); - } catch { + } catch (e) { + if (!(e instanceof NotInitializedError)) throw e; initialized = false; } if (initialized) { From 0e4df7181b60b8df11398fdd5174c0fed9420ffb Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Wed, 23 Sep 2026 02:18:15 +0200 Subject: [PATCH 32/37] fix(config): raise NotInitializedError only when the config file is missing loadLocalConfig returns null both for a missing file and for one that fails to parse, validate or migrate (it logs the reason). requireInit turned every null into NotInitializedError, so skill show and skill list still fell back to the packaged catalog and a `teamai init` hint on a broken config. Only an absent file is NotInitializedError now; an existing one that could not be used is an error naming its path, in requireInit and the user branch of requireInitForScope. Covered through the real loader and the built binary. --- src/__tests__/config-not-initialized.test.ts | 54 ++++++++++++++++++++ src/__tests__/e2e/skill-serving-cli.test.ts | 23 +++++++++ src/config.ts | 32 ++++++++---- 3 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 src/__tests__/config-not-initialized.test.ts diff --git a/src/__tests__/config-not-initialized.test.ts b/src/__tests__/config-not-initialized.test.ts new file mode 100644 index 00000000..53037333 --- /dev/null +++ b/src/__tests__/config-not-initialized.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() }, + setStderrOnly: vi.fn(() => false), +})); + +import { NotInitializedError, requireInit } from '../config.js'; + +/** + * `loadLocalConfig` returns null both for a missing file and for one it could + * not use. Commands that work without a team fall back on NotInitializedError + * alone, so only the missing file may produce it. + */ +describe('requireInit: missing config versus unreadable config', () => { + let home: string; + + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-config-init-')); + vi.stubEnv('HOME', home); + vi.stubEnv('USERPROFILE', home); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(home, { recursive: true, force: true }); + }); + + it('is NotInitializedError when there is no config file', async () => { + await expect(requireInit()).rejects.toBeInstanceOf(NotInitializedError); + }); + + it('names the file, and is not NotInitializedError, when the config exists but does not parse', async () => { + const configPath = path.join(home, '.teamai', 'config.yaml'); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, 'repo: [unclosed\n'); + + const error = await requireInit().catch((e: unknown) => e); + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(NotInitializedError); + expect(String(error)).toContain(configPath); + }); + + it('is not NotInitializedError when the config parses but fails validation', async () => { + const configPath = path.join(home, '.teamai', 'config.yaml'); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, 'username: 42\n'); + + await expect(requireInit()).rejects.not.toBeInstanceOf(NotInitializedError); + }); +}); diff --git a/src/__tests__/e2e/skill-serving-cli.test.ts b/src/__tests__/e2e/skill-serving-cli.test.ts index 9a2b0af8..2b6688e6 100644 --- a/src/__tests__/e2e/skill-serving-cli.test.ts +++ b/src/__tests__/e2e/skill-serving-cli.test.ts @@ -116,6 +116,29 @@ describe('teamai skill get / path CLI (e2e)', () => { expect(shownUnknown.stdout + shownUnknown.stderr).not.toContain(' at '); }); + it('reports an unreadable config instead of calling the machine uninitialized', () => { + // A config that exists but does not parse is not "no team": the packaged + // fallback and its `teamai init` hint are for a machine with no config. + const brokenHome = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-skill-broken-config-')); + try { + fs.mkdirSync(path.join(brokenHome, '.teamai'), { recursive: true }); + fs.writeFileSync(path.join(brokenHome, '.teamai', 'config.yaml'), 'repo: [unclosed\n'); + for (const args of [['skill', 'list'], ['skill', 'show', 'core']]) { + const result = spawnSync(process.execPath, [CLI, ...args], { + cwd: brokenHome, + env: { ...process.env, HOME: brokenHome, USERPROFILE: brokenHome, FORCE_COLOR: '0' }, + encoding: 'utf8', + }); + expect(result.status, args.join(' ')).not.toBe(0); + expect(result.stderr, args.join(' ')).toContain('could not be read'); + expect(result.stdout + result.stderr, args.join(' ')).not.toContain('Not initialized'); + expect(result.stdout + result.stderr, args.join(' ')).not.toContain('No team is set up'); + } + } finally { + fs.rmSync(brokenHome, { recursive: true, force: true }); + } + }); + it('appends the nested references with --full', () => { const full = run('skill', 'get', 'wiki', '--full'); expect(full.status).toBe(0); diff --git a/src/config.ts b/src/config.ts index 75f8feae..faa9b82b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -120,9 +120,10 @@ export async function saveState(state: State): Promise<void> { } /** - * No teamai config on this machine for the scope asked. Its own class so a - * command that can work without a team (the packaged skills) falls back on - * this and nothing else: a malformed config or an unreadable file still fails. + * No teamai config on this machine for the scope asked: the file does not + * exist. Its own class so a command that can work without a team (the packaged + * skills) falls back on this and nothing else. A config file that exists but + * cannot be parsed, validated or migrated is a plain Error naming its path. */ export class NotInitializedError extends Error { readonly name = 'NotInitializedError'; @@ -133,9 +134,7 @@ export class NotInitializedError extends Error { */ export async function requireInit(): Promise<{ localConfig: LocalConfig; teamConfig: TeamaiConfig }> { const localConfig = await loadLocalConfig(); - if (!localConfig) { - throw new NotInitializedError('teamai is not initialized. Run `teamai init` first.'); - } + if (!localConfig) return throwMissingOrInvalid(expandHome(getUserConfigPath())); const teamConfig = await loadTeamConfig(localConfig.repo.localPath); if (!teamConfig) { throw new Error('Team config (teamai.yaml) not found. Check your repo path.'); @@ -143,6 +142,18 @@ export async function requireInit(): Promise<{ localConfig: LocalConfig; teamCon return { localConfig, teamConfig }; } +/** + * The loaders return null both when the config file is absent and when it could + * not be used (they log the reason). Only the first is "not initialized"; + * telling a member with a broken config to re-init sends them over a real setup. + */ +async function throwMissingOrInvalid(configPath: string, notInitializedMessage = 'teamai is not initialized. Run `teamai init` first.'): Promise<never> { + if (await pathExists(configPath)) { + throw new Error(`The teamai config at ${configPath} could not be read (the reason is logged above). Fix the file, or move it aside and run \`teamai init\` to write a new one.`); + } + throw new NotInitializedError(notInitializedMessage); +} + // ─── Scope-aware config loading ───────────────────────── /** @@ -419,11 +430,10 @@ export async function requireInitForScope( ): Promise<{ localConfig: LocalConfig; teamConfig: TeamaiConfig }> { const localConfig = await loadLocalConfigForScope(scope, projectRoot); if (!localConfig) { - throw new NotInitializedError( - scope === 'project' - ? `teamai is not initialized in project scope at ${projectRoot}. Run \`teamai init\` first.` - : 'teamai is not initialized. Run `teamai init` first.', - ); + if (scope === 'project') { + throw new NotInitializedError(`teamai is not initialized in project scope at ${projectRoot}. Run \`teamai init\` first.`); + } + return throwMissingOrInvalid(expandHome(getConfigPath(scope, projectRoot))); } const teamConfig = await loadTeamConfig(localConfig.repo.localPath); if (!teamConfig) { From d19a10038b87b42595f7763009da5a1879a02ff6 Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Wed, 23 Sep 2026 02:47:07 +0200 Subject: [PATCH 33/37] fix(skills): prove ownership by content, not path alone; retire the second Codex copy - The legacy prune and uninstall removed any file at a path a release had packaged, so a member's edit, a skill of their own under an old name, or a root TeamAI never managed (toolPaths or HERMES_HOME moved) lost its files. A file is ours now only at a packaged path and with content a release shipped there: PACKAGED_SKILL_DIGESTS records the sha256 of every blob over all 99 tags through v0.25.0 and main before the stub, 37 versions across 21 paths. A skill-root SKILL.md is compared by its body, since releases before 0.17 shipped no frontmatter and the deploy of the day repaired it on disk. The current stub is ours by the packaged copy. Anything else stays. - Codex reads .codex/skills and the shared .agents/skills, and the stub goes to the shared one when a copy lives there; the copy an earlier release left in the other root kept its old SKILL.md and references. It is retired by the same ownership rule, archived first, and named when kept. - Tests mock the digest table with a stand-in for shipped content, and a test keeps the stand-in on the same paths as the real table. --- CHANGELOG.md | 2 +- docs/designs/skill-serving.md | 32 ++-- docs/usage-guide.md | 7 +- docs/usage-guide.zh-CN.md | 5 +- src/__tests__/helpers/shipped-skills.ts | 51 +++++++ src/__tests__/recall-toggle.test.ts | 6 +- src/__tests__/skill-content.test.ts | 10 ++ src/__tests__/skip-uninstalled-tools.test.ts | 140 ++++++++++++----- src/__tests__/uninstall.test.ts | 46 ++++-- src/builtin-skills.ts | 150 +++++++++++++------ src/packaged-skill-digests.ts | 41 +++++ src/uninstall.ts | 4 +- 12 files changed, 378 insertions(+), 116 deletions(-) create mode 100644 src/__tests__/helpers/shipped-skills.ts create mode 100644 src/packaged-skill-digests.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c03d83e..7594c6ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. See [standa ### ✨ Features -- Built-in skill content ships inside the npm package and is printed by the installed CLI: `teamai skill get <core|setup|wiki|share> [--full] [--all]`, `teamai skill path <name>` for the directory holding a skill's scripts, and `teamai skill list --json` for the catalog. Agents receive one file, `skills/teamai/SKILL.md`, a discovery stub that points at those commands, so what an agent reads always matches the CLI version it is running. `teamai pull` removes the `team-wiki-codebase`, `teamai-share-learnings` and `teamai/references/*.md` trees earlier releases copied into every agent directory, archiving each removed file under `~/.teamai/removed-skills/<run>/…` first and keeping any directory that holds a member's own file; `teamai uninstall` removes only the packaged files from CLI-owned skill directories by the same rule. `share` is served only while recall is on and the team source is writable (not a read-only HTTP one), and the end-of-session share reminder is withheld until then too. The served workflows are English; learning and knowledge-base documents are still written in Simplified Chinese, and an existing knowledge base keeps its file names and headings. The legacy names still resolve as aliases (for [#678](https://github.com/Tencent/teamai-cli/issues/678), [#730](https://github.com/Tencent/teamai-cli/issues/730)). +- Built-in skill content ships inside the npm package and is printed by the installed CLI: `teamai skill get <core|setup|wiki|share> [--full] [--all]`, `teamai skill path <name>` for the directory holding a skill's scripts, and `teamai skill list --json` for the catalog. Agents receive one file, `skills/teamai/SKILL.md`, a discovery stub that points at those commands, so what an agent reads always matches the CLI version it is running. `teamai pull` removes the `team-wiki-codebase`, `teamai-share-learnings` and `teamai/references/*.md` trees earlier releases copied into every agent directory, removing only files whose content a release shipped (an edited file, or a member's own skill under an old name, stays), archiving each removed file under `~/.teamai/removed-skills/<run>/…` first, and keeping any directory that holds a member's own file; `teamai uninstall` removes only the packaged files from CLI-owned skill directories by the same rule. `share` is served only while recall is on and the team source is writable (not a read-only HTTP one), and the end-of-session share reminder is withheld until then too. The served workflows are English; learning and knowledge-base documents are still written in Simplified Chinese, and an existing knowledge base keeps its file names and headings. The legacy names still resolve as aliases (for [#678](https://github.com/Tencent/teamai-cli/issues/678), [#730](https://github.com/Tencent/teamai-cli/issues/730)). - Hooks, MCP servers and env variables can be scoped by logical project, the second membership axis they lacked. A `hooks/hooks.yaml` hook and an `mcp/mcp.yaml` server accept an optional `projects:` list beside `roles:`, and an `env/env.yaml` variable accepts both. An entry reaches a member when one of the projects its directory is bound to (`teamai projects set`) is listed; `projects: []` reaches nobody, and a directory bound to no project keeps receiving every entry, so nothing changes until a maintainer adds the key. The two axes compose as AND, the way `tools:` and `roles:` already do, so `roles: [frontend] projects: [checkout]` reaches frontend members of checkout rather than everyone on either. Rebinding with `teamai projects set` removes the previous project's entries on the next pull — for env that means the variable leaves `env.sh`, also on a pull that finds the team repo unchanged, so a machine upgrading from a CLI that ignored the keys drops a withheld variable without `--force`, and a refresh that cannot be written there is reported with the path and the way out rather than passing silently under `Already synced`; `teamai doctor` applies the same filter, so a variable correctly withheld is not reported as undelivered, while one that `env.sh` still exports after a rebind is reported until the next pull rewrites the file. An id that `manifest/projects.yaml` does not define produces one warning per pull, and so does a `projects:` key in a team with no projects manifest: the key still filters against the ids in the directory's `config.yaml`, but nothing can validate them. `teamai mcp list`, `teamai hooks list` and `teamai env list` show the restriction, and `pull` reports `Synced 1 of 3 env variable(s)` when scoping withheld some. This is what the keys exist to control: a team with five projects and three MCP servers each gave every member of a role fifteen server processes and fifteen tool lists in the context of every session (for [#668](https://github.com/Tencent/teamai-cli/issues/668)). diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index eb72f4c3..a61af26c 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -162,19 +162,25 @@ Pull's archive is deliberately not applied there: pull runs on an upgrade the member did not ask anything to be removed by, while uninstall is them asking for all of it to go. Leaving copies behind would be the thing they ran it to avoid. -**It removes only the files those releases packaged.** `PACKAGED_SKILL_FILES` -lists them, built as the union of `git ls-tree -r <tag> -- skills/` over all 99 -tags through v0.25.0, minus `teamai-wiki` (see below); `references/provider-tgit.md` -first shipped in 0.25.0. Every path -in it is provably the CLI's. Those files were overwritten with -`overwrite: true` on every pull and no local edit ever survived in one; a file a -member added beside them was never touched by the old deployment and is not ours -to delete now. One case the overwrite never reached: a path a retired release -shipped and the current package no longer does just sat there, so an edit to it -did survive. Ownership is proven by pathname, not by contents, so the prune -cannot tell that file from ours — it copies everything it removes to -`~/.teamai/removed-skills/<run>/<base>/<tool>/<skill-root>/<skill>/` first, and the -migration stops being a one-way door for any of them. That path is the machine's +**It removes only the files those releases packaged, at the content they +packaged.** `PACKAGED_SKILL_DIGESTS` (`src/packaged-skill-digests.ts`) records the +sha256 of every blob `git ls-tree -r <ref> -- skills/` shows over all 99 tags +through v0.25.0 and `main` before the stub, minus `teamai-wiki` (see below): 37 +versions across 21 paths. A file is ours only at one of those paths *and* with one +of those digests; a skill-root `SKILL.md` is compared by its body without the +frontmatter block, because releases before 0.17 shipped none and the deploy of +the day repaired it on disk. The copies came from the npm tarball byte for byte, +so an unedited one matches. Anything else at a packaged path — an edit, a +member's own skill that uses a legacy name, a root TeamAI never managed because +`toolPaths` or `HERMES_HOME` moved — is the member's and stays, with its +directory. Checking the path alone would have deleted those. What is removed is +still copied first to +`~/.teamai/removed-skills/<run>/<base>/<tool>/<skill-root>/<skill>/`, so no +removal is a one-way door. Codex reads both `.codex/skills` and the shared `.agents/skills`, and the +stub goes to the shared one when a copy already lives there; the copy an earlier +release left in the other root is retired by the same rule +(`retireOtherCodexCopy`), so Codex never sees a stale `teamai` beside the current +one. That path is the machine's home, never the tool's base directory, which under project scope is the repo root. Only *retired* paths are archived: the stub is rewritten on every session start, so archiving it would file an identical copy per session forever. A diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 38e471f7..396d7cae 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -474,9 +474,10 @@ from the CLI, `~/.<tool>/skills/teamai/SKILL.md` (or wherever that tool keeps te workspace, `HERMES_HOME`), a small discovery stub that points at those commands. Older releases copied the whole tree into every agent directory, where it went stale between pulls; `teamai pull` removes those leftovers, keeping a copy of every -removed file under `~/.teamai/removed-skills/`, one directory per pull, so an edit you made -to one of them is not lost (until `teamai uninstall`, which removes `~/.teamai/` and this -archive with it). A directory that also holds a file of your own is kept, with only the +removed file under `~/.teamai/removed-skills/`, one directory per pull (until `teamai uninstall`, +which removes `~/.teamai/` and this archive with it). Only files whose content a release shipped +are removed: a packaged file you edited, or a skill of your own under one of the old names, is +yours and stays. A directory that also holds a file of your own is kept, with only the packaged files removed, and named in the pull output. `share` is served only while recall is on (off by default; `sharing.recall.enabled: true` in `teamai.yaml` for the team, or `teamai recall enable` for one machine): until then `teamai skill get share` refuses and says so. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index b71cf9c5..5110a624 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -447,8 +447,9 @@ teamai skill path wiki # 打印打包目录,用于运行 skill 按需打印,因此 agent 读到的内容始终与正在运行的 CLI 版本一致——`npm i -g teamai-cli@latest` 本身就是更新, 无需 `teamai pull` 内容就是最新的。每个 agent 只收到一个文件:`~/.<tool>/skills/teamai/SKILL.md`(或该工具存放团队 skill 的位置:OpenClaw 的 workspace、`HERMES_HOME`), 一个指向这些命令的小型发现入口(stub)。旧版本会把整棵目录复制到每个 agent 下,两次 pull 之间内容会过时; -`teamai pull` 会清除这些残留,并把每个被删除的文件先复制到 `~/.teamai/removed-skills/` 下(每次 pull 一个目录), -你对其中文件的修改不会丢失(`teamai uninstall` 会删除 `~/.teamai/`,这份备份也随之删除);目录里若还有你自己的文件, +`teamai pull` 会清除这些残留,并把每个被删除的文件先复制到 `~/.teamai/removed-skills/` 下(每次 pull 一个目录; +`teamai uninstall` 会删除 `~/.teamai/`,这份备份也随之删除)。只删除内容与某个发布版本完全一致的文件:你改过的打包文件, +或你自己用旧名字写的 skill,都属于你,会保留。目录里若还有你自己的文件, 只删除其中的打包文件,保留该目录和你的文件,并在 pull 输出中点名。`share` 只在开启 recall 后才会提供(默认关闭; 团队在 `teamai.yaml` 设置 `sharing.recall.enabled: true`,或单台机器运行 `teamai recall enable`):在此之前, `teamai skill get share` 会拒绝并说明原因。 diff --git a/src/__tests__/helpers/shipped-skills.ts b/src/__tests__/helpers/shipped-skills.ts new file mode 100644 index 00000000..8872ee35 --- /dev/null +++ b/src/__tests__/helpers/shipped-skills.ts @@ -0,0 +1,51 @@ +import { createHash } from 'node:crypto'; + +/** + * Stand-in content for "a file a release shipped", for tests that exercise the + * legacy prune without the real historical blobs. Pair it with + * `shippedSkillDigestsMock()` in a `vi.mock('../packaged-skill-digests.js')`: + * a file holding `shipped(skill, path)` is the CLI's, anything else at the same + * path is the member's. + */ +export function shipped(skill: string, relative: string): string { + return `# shipped ${skill}/${relative}\n`; +} + +const PATHS: Readonly<Record<string, readonly string[]>> = { + teamai: [ + 'SKILL.md', + 'references/contribute-member.md', + 'references/join-member.md', + 'references/manage-admin.md', + 'references/provider-tgit.md', + 'references/setup-admin.md', + 'references/troubleshooting.md', + 'references/uninstall.md', + ], + 'teamai-share-learnings': ['SKILL.md'], + 'team-wiki-codebase': [ + 'SKILL.md', + 'README.md', + 'references/agents/graph-rag-agent.md', + 'references/agents/kb-doc-generator.md', + 'references/methodology/phase0-collection.md', + 'references/methodology/phase1-reverse-engineering.md', + 'references/methodology/phase2-document-types.md', + 'references/methodology/phase3-ai-enhancement.md', + 'references/methodology/phase4-quality.md', + 'references/templates/project-overview.md', + 'scripts/scan_repo.py', + 'scripts/validate_kb.py', + ], +}; + +/** The module shape of `packaged-skill-digests.ts`, with `shipped()` as the only shipped version. */ +export function shippedSkillDigestsMock(): { PACKAGED_SKILL_DIGESTS: ReadonlyMap<string, ReadonlyMap<string, readonly string[]>> } { + const sha = (text: string): string => createHash('sha256').update(text).digest('hex'); + return { + PACKAGED_SKILL_DIGESTS: new Map(Object.entries(PATHS).map(([skill, paths]) => [ + skill, + new Map(paths.map((relative) => [relative, [sha(shipped(skill, relative))]])), + ])), + }; +} diff --git a/src/__tests__/recall-toggle.test.ts b/src/__tests__/recall-toggle.test.ts index 0cee9773..93f2c502 100644 --- a/src/__tests__/recall-toggle.test.ts +++ b/src/__tests__/recall-toggle.test.ts @@ -2,6 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import path from 'node:path'; import os from 'node:os'; import fse from 'fs-extra'; +import { shipped, shippedSkillDigestsMock } from './helpers/shipped-skills.js'; + +// A CLI-owned file is one whose content a release shipped; `shipped()` is it here. +vi.mock('../packaged-skill-digests.js', () => shippedSkillDigestsMock()); const mockAutoDetectInit = vi.fn(); const mockSaveLocalConfigForScope = vi.fn(); @@ -94,7 +98,7 @@ describe('recall toggle native agent cleanup', () => { const skillsDir = path.join(homeDir, '.codex', 'skills'); for (const name of ['teamai-share-learnings', 'team-wiki-codebase', 'teamai', 'my-own']) { await fse.ensureDir(path.join(skillsDir, name)); - await fse.writeFile(path.join(skillsDir, name, 'SKILL.md'), `# ${name}`); + await fse.writeFile(path.join(skillsDir, name, 'SKILL.md'), name === 'my-own' ? '# mine' : shipped(name, 'SKILL.md')); } await recallDisable({}); diff --git a/src/__tests__/skill-content.test.ts b/src/__tests__/skill-content.test.ts index 15a8a661..c3dd9596 100644 --- a/src/__tests__/skill-content.test.ts +++ b/src/__tests__/skill-content.test.ts @@ -364,6 +364,16 @@ describe('the shipped skill-data content', () => { expect(offenders).toEqual([]); }); + it('keeps the tests\' stand-in for shipped content on the paths the real digests record', async () => { + // The prune tests mock the digest table; a path they know and the real + // table does not (or the reverse) would test a prune that never runs. + const { PACKAGED_SKILL_DIGESTS } = await import('../packaged-skill-digests.js'); + const { shippedSkillDigestsMock } = await import('./helpers/shipped-skills.js'); + const paths = (table: ReadonlyMap<string, ReadonlyMap<string, readonly string[]>>) => + Object.fromEntries([...table].map(([skill, files]) => [skill, [...files.keys()].sort()])); + expect(paths(shippedSkillDigestsMock().PACKAGED_SKILL_DIGESTS)).toEqual(paths(PACKAGED_SKILL_DIGESTS)); + }); + it('keeps the stub description within the 1024-character budget agents load it under', async () => { // With one deployed skill, this description is the only text an agent sees // at selection time, and hosts cap it at 1024 characters. diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index 32212fe0..1b42e003 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -4,6 +4,7 @@ import os from 'node:os'; import { fileURLToPath } from 'node:url'; import fse from 'fs-extra'; import { listFilesRecursive } from '../utils/fs.js'; +import { shipped, shippedSkillDigestsMock } from './helpers/shipped-skills.js'; const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); @@ -52,6 +53,14 @@ async function onlyRunDir(homeDir: string): Promise<string> { return path.join(root, runs[0], bases[0]); } +// A file is the CLI's only at content a release shipped; `shipped()` stands in +// for that content, anything else at the same path is the member's. +vi.mock('../packaged-skill-digests.js', () => shippedSkillDigestsMock()); + +const WIKI_SKILL = shipped('team-wiki-codebase', 'SKILL.md'); +/** Shipped body under a distinguishing frontmatter block: still ours, told apart. */ +const wikiSkillTagged = (tag: string): string => `---\nname: ${tag}\n---\n${WIKI_SKILL}`; + vi.mock('../config.js', () => ({ requireInit: vi.fn(), loadState: vi.fn(), @@ -647,9 +656,9 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { // Pre-stub releases left these behind in every agent directory. await fse.ensureDir(path.join(homeDir, '.claude/skills/team-wiki-codebase/references')); - await fse.writeFile(path.join(homeDir, '.claude/skills/team-wiki-codebase/SKILL.md'), '# old'); + await fse.writeFile(path.join(homeDir, '.claude/skills/team-wiki-codebase/SKILL.md'), WIKI_SKILL); await fse.ensureDir(path.join(homeDir, '.claude/skills/teamai-share-learnings')); - await fse.writeFile(path.join(homeDir, '.claude/skills/teamai-share-learnings/SKILL.md'), '# old'); + await fse.writeFile(path.join(homeDir, '.claude/skills/teamai-share-learnings/SKILL.md'), shipped('teamai-share-learnings', 'SKILL.md')); // These two names were reserved in the old guard set but never packaged, so // a directory by either name is the user's own skill. for (const userSkill of ['teamai-workflow', 'teamai-import']) { @@ -671,7 +680,7 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { } }); - it('parks a copy of every file it prunes, so a member who edited one can get it back', async () => { + it('archives what it prunes, and keeps a packaged path whose content the member changed', async () => { const { deployBuiltinSkills } = await import('../builtin-skills.js'); const teamConfig = { @@ -699,21 +708,22 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { scope: 'user' as const, }; - // Ownership is proven by pathname, so this file is pruned even though the - // member edited it. A retired release's path is never overwritten by the - // deployment either, which is what makes the backup the only way back. + // A path a release shipped is ours only at content a release shipped there. + // The unedited SKILL.md goes, a copy archived first; the reference the + // member rewrote is theirs now and stays, and so does its directory. const wiki = path.join(homeDir, '.claude/skills/team-wiki-codebase'); await fse.ensureDir(path.join(wiki, 'references/methodology')); - await fse.writeFile(path.join(wiki, 'SKILL.md'), '# edited by the member'); + await fse.writeFile(path.join(wiki, 'SKILL.md'), WIKI_SKILL); await fse.writeFile(path.join(wiki, 'references/methodology/phase0-collection.md'), '# my notes'); await deployBuiltinSkills(teamConfig, localConfig); - expect(await fse.pathExists(wiki)).toBe(false); + expect(await fse.pathExists(path.join(wiki, 'SKILL.md'))).toBe(false); + expect(await fse.readFile(path.join(wiki, 'references/methodology/phase0-collection.md'), 'utf8')).toBe('# my notes'); const backup = path.join(await onlyRunDir(homeDir), 'claude/.claude-skills/team-wiki-codebase'); - expect(await fse.readFile(path.join(backup, 'SKILL.md'), 'utf8')).toBe('# edited by the member'); - expect(await fse.readFile(path.join(backup, 'references/methodology/phase0-collection.md'), 'utf8')).toBe('# my notes'); + expect(await fse.readFile(path.join(backup, 'SKILL.md'), 'utf8')).toBe(WIKI_SKILL); + expect(await fse.pathExists(path.join(backup, 'references/methodology/phase0-collection.md'))).toBe(false); }); it('keeps a file it could not back up, instead of deleting it anyway', async () => { @@ -724,7 +734,7 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { const wiki = path.join(homeDir, '.claude/skills/team-wiki-codebase'); await fse.ensureDir(wiki); - await fse.writeFile(path.join(wiki, 'SKILL.md'), '# edited by the member'); + await fse.writeFile(path.join(wiki, 'SKILL.md'), WIKI_SKILL); // A file where the backup tree has to start: every copy under it fails, the // way a full disk or a read-only home would. @@ -733,7 +743,7 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { await deployBuiltinSkills(teamConfig, localConfig); - expect(await fse.readFile(path.join(wiki, 'SKILL.md'), 'utf8')).toBe('# edited by the member'); + expect(await fse.readFile(path.join(wiki, 'SKILL.md'), 'utf8')).toBe(WIKI_SKILL); }); it('never walks through a symlinked skill root, so it cannot delete the link target', async () => { @@ -743,14 +753,14 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { // by name, so following the link would delete files we never wrote. const shared = path.join(tmpDir, 'shared-skills/team-wiki-codebase'); await fse.ensureDir(shared); - await fse.writeFile(path.join(shared, 'SKILL.md'), '# someone else\'s'); + await fse.writeFile(path.join(shared, 'SKILL.md'), WIKI_SKILL); await fse.ensureDir(path.join(homeDir, '.claude/skills')); await fse.symlink(shared, path.join(homeDir, '.claude/skills/team-wiki-codebase'), 'dir'); await deployBuiltinSkills(legacyPruneTeamConfig(), legacyPruneLocalConfig(tmpDir)); - expect(await fse.readFile(path.join(shared, 'SKILL.md'), 'utf8')).toBe('# someone else\'s'); + expect(await fse.readFile(path.join(shared, 'SKILL.md'), 'utf8')).toBe(WIKI_SKILL); expect(await fse.pathExists(path.join(homeDir, '.claude/skills/team-wiki-codebase'))).toBe(true); }); @@ -762,14 +772,14 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { // sees nothing and the walk deletes files in the checkout. const dotfiles = path.join(tmpDir, 'dotfiles/skills'); await fse.ensureDir(path.join(dotfiles, 'team-wiki-codebase')); - await fse.writeFile(path.join(dotfiles, 'team-wiki-codebase/SKILL.md'), '# theirs'); + await fse.writeFile(path.join(dotfiles, 'team-wiki-codebase/SKILL.md'), WIKI_SKILL); await fse.ensureDir(path.join(homeDir, '.claude')); await fse.symlink(dotfiles, path.join(homeDir, '.claude/skills'), 'dir'); await deployBuiltinSkills(legacyPruneTeamConfig(), legacyPruneLocalConfig(tmpDir)); - expect(await fse.readFile(path.join(dotfiles, 'team-wiki-codebase/SKILL.md'), 'utf8')).toBe('# theirs'); + expect(await fse.readFile(path.join(dotfiles, 'team-wiki-codebase/SKILL.md'), 'utf8')).toBe(WIKI_SKILL); expect(await fse.pathExists(path.join(dotfiles, 'teamai/SKILL.md'))).toBe(false); }); @@ -797,7 +807,7 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { // the skill directory under it are real directories, the link is higher up. const dotfiles = path.join(tmpDir, 'dotfiles/opencode'); await fse.ensureDir(path.join(dotfiles, 'skills/team-wiki-codebase')); - await fse.writeFile(path.join(dotfiles, 'skills/team-wiki-codebase/SKILL.md'), '# theirs'); + await fse.writeFile(path.join(dotfiles, 'skills/team-wiki-codebase/SKILL.md'), WIKI_SKILL); await fse.ensureDir(path.join(homeDir, '.config')); await fse.symlink(dotfiles, path.join(homeDir, '.config/opencode'), 'dir'); @@ -807,7 +817,7 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { ); expect(deployed).toBe(0); - expect(await fse.readFile(path.join(dotfiles, 'skills/team-wiki-codebase/SKILL.md'), 'utf8')).toBe('# theirs'); + expect(await fse.readFile(path.join(dotfiles, 'skills/team-wiki-codebase/SKILL.md'), 'utf8')).toBe(WIKI_SKILL); expect(await fse.pathExists(path.join(dotfiles, 'skills/teamai'))).toBe(false); }); @@ -819,7 +829,7 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { const hermesHome = path.join(tmpDir, 'elsewhere/hermes'); vi.stubEnv('HERMES_HOME', hermesHome); await fse.ensureDir(path.join(hermesHome, 'skills/team-wiki-codebase')); - await fse.writeFile(path.join(hermesHome, 'skills/team-wiki-codebase/SKILL.md'), '# pre-stub'); + await fse.writeFile(path.join(hermesHome, 'skills/team-wiki-codebase/SKILL.md'), WIKI_SKILL); const workspace = path.join(homeDir, '.openclaw/workspace'); await fse.ensureDir(workspace); @@ -841,7 +851,7 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { const target = path.join(tmpDir, 'dotfiles/hermes'); await fse.ensureDir(path.join(target, 'skills/team-wiki-codebase')); - await fse.writeFile(path.join(target, 'skills/team-wiki-codebase/SKILL.md'), '# theirs'); + await fse.writeFile(path.join(target, 'skills/team-wiki-codebase/SKILL.md'), WIKI_SKILL); const hermesHome = path.join(tmpDir, 'elsewhere/hermes'); await fse.ensureDir(path.dirname(hermesHome)); await fse.symlink(target, hermesHome, 'dir'); @@ -853,7 +863,7 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { ); expect(deployed).toBe(0); - expect(await fse.readFile(path.join(target, 'skills/team-wiki-codebase/SKILL.md'), 'utf8')).toBe('# theirs'); + expect(await fse.readFile(path.join(target, 'skills/team-wiki-codebase/SKILL.md'), 'utf8')).toBe(WIKI_SKILL); expect(await fse.pathExists(path.join(target, 'skills/teamai'))).toBe(false); }); @@ -862,7 +872,7 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { const target = path.join(tmpDir, 'dotfiles/copilot'); await fse.ensureDir(path.join(target, 'skills/team-wiki-codebase')); - await fse.writeFile(path.join(target, 'skills/team-wiki-codebase/SKILL.md'), '# theirs'); + await fse.writeFile(path.join(target, 'skills/team-wiki-codebase/SKILL.md'), WIKI_SKILL); await fse.symlink(target, path.join(homeDir, '.copilot'), 'dir'); const deployed = await deployBuiltinSkills( @@ -871,17 +881,73 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { ); expect(deployed).toBe(0); - expect(await fse.readFile(path.join(target, 'skills/team-wiki-codebase/SKILL.md'), 'utf8')).toBe('# theirs'); + expect(await fse.readFile(path.join(target, 'skills/team-wiki-codebase/SKILL.md'), 'utf8')).toBe(WIKI_SKILL); expect(await fse.pathExists(path.join(target, 'skills/teamai'))).toBe(false); }); + it('keeps a skill of the member\'s that only shares a packaged name, whatever its paths', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + // A root TeamAI never managed, or a skill the member wrote under the old + // name: every path matches a packaged one, no content matches a release. + const wiki = path.join(homeDir, '.claude/skills/team-wiki-codebase'); + await fse.ensureDir(path.join(wiki, 'scripts')); + await fse.writeFile(path.join(wiki, 'SKILL.md'), '---\nname: team-wiki-codebase\n---\n# my own wiki skill\n'); + await fse.writeFile(path.join(wiki, 'scripts/scan_repo.py'), 'print("mine")\n'); + + await deployBuiltinSkills(legacyPruneTeamConfig(), legacyPruneLocalConfig(tmpDir)); + + expect(await fse.readFile(path.join(wiki, 'SKILL.md'), 'utf8')).toContain('# my own wiki skill'); + expect(await fse.readFile(path.join(wiki, 'scripts/scan_repo.py'), 'utf8')).toBe('print("mine")\n'); + expect(await fse.pathExists(path.join(homeDir, '.teamai/removed-skills'))).toBe(false); + }); + + it('retires the second Codex copy of the stub, so Codex does not read a stale one beside it', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + // The resolver picks .agents/skills/teamai because it exists; the copy an + // earlier release left in .codex/skills would otherwise keep its old body. + await fse.ensureDir(path.join(homeDir, '.codex')); + const shared = path.join(homeDir, '.agents/skills/teamai'); + const configured = path.join(homeDir, '.codex/skills/teamai'); + await fse.ensureDir(shared); + await fse.writeFile(path.join(shared, 'SKILL.md'), shipped('teamai', 'SKILL.md')); + await fse.ensureDir(path.join(configured, 'references')); + await fse.writeFile(path.join(configured, 'SKILL.md'), shipped('teamai', 'SKILL.md')); + await fse.writeFile(path.join(configured, 'references/setup-admin.md'), shipped('teamai', 'references/setup-admin.md')); + + await deployBuiltinSkills(legacyPruneTeamConfig({ codex: { skills: '.codex/skills' } }), legacyPruneLocalConfig(tmpDir)); + + expect(await fse.readFile(path.join(shared, 'SKILL.md'), 'utf8')).toBe( + await fse.readFile(path.join(PACKAGE_ROOT, 'skills/teamai/SKILL.md'), 'utf8'), + ); + expect(await fse.pathExists(configured)).toBe(false); + }); + + it('keeps the second Codex copy when it holds a file TeamAI did not write', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + await fse.ensureDir(path.join(homeDir, '.codex')); + const shared = path.join(homeDir, '.agents/skills/teamai'); + const configured = path.join(homeDir, '.codex/skills/teamai'); + await fse.ensureDir(shared); + await fse.ensureDir(path.join(configured, 'references')); + await fse.writeFile(path.join(configured, 'SKILL.md'), shipped('teamai', 'SKILL.md')); + await fse.writeFile(path.join(configured, 'references/team-playbook.md'), '# mine'); + + await deployBuiltinSkills(legacyPruneTeamConfig({ codex: { skills: '.codex/skills' } }), legacyPruneLocalConfig(tmpDir)); + + expect(await fse.pathExists(path.join(configured, 'SKILL.md'))).toBe(false); + expect(await fse.readFile(path.join(configured, 'references/team-playbook.md'), 'utf8')).toBe('# mine'); + }); + it('keeps a member\'s file under a __pycache__ that is not bytecode of a shipped script', async () => { const { deployBuiltinSkills } = await import('../builtin-skills.js'); const wiki = path.join(homeDir, '.claude/skills/team-wiki-codebase'); await fse.ensureDir(path.join(wiki, 'scripts/__pycache__')); await fse.ensureDir(path.join(wiki, 'notes/__pycache__')); - await fse.writeFile(path.join(wiki, 'SKILL.md'), '# packaged'); + await fse.writeFile(path.join(wiki, 'SKILL.md'), WIKI_SKILL); await fse.writeFile(path.join(wiki, 'scripts/__pycache__/scan_repo.cpython-311.pyc'), 'bytecode'); await fse.writeFile(path.join(wiki, 'notes/__pycache__/keep.txt'), '# mine'); @@ -897,7 +963,7 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { // `inheritUserScope`: user base, then project base, same tool, root and name. const projectRoot = path.join(tmpDir, 'work/proj'); const projectConfig = { ...legacyPruneLocalConfig(tmpDir), scope: 'project' as const, projectRoot }; - for (const [base, body] of [[homeDir, '# user copy'], [projectRoot, '# project copy']]) { + for (const [base, body] of [[homeDir, wikiSkillTagged('user')], [projectRoot, wikiSkillTagged('project')]]) { await fse.ensureDir(path.join(base, '.claude/skills/team-wiki-codebase')); await fse.writeFile(path.join(base, '.claude/skills/team-wiki-codebase/SKILL.md'), body); } @@ -909,7 +975,7 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { const archived = (await listFilesRecursive(path.join(homeDir, '.teamai/removed-skills'))) .filter((f) => f.endsWith('team-wiki-codebase/SKILL.md')); const bodies = await Promise.all(archived.map((f) => fse.readFile(path.join(homeDir, '.teamai/removed-skills', f), 'utf8'))); - expect(bodies.sort()).toEqual(['# project copy', '# user copy']); + expect(bodies.sort()).toEqual([wikiSkillTagged('project'), wikiSkillTagged('user')]); }); it('archives nothing when there is nothing retired to archive', async () => { @@ -931,7 +997,7 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { const localConfig = legacyPruneLocalConfig(tmpDir); // Codex prunes its own root and the shared one; same skill name, different files. - for (const [root, body] of [['.codex/skills', '# from codex'], ['.agents/skills', '# from shared']]) { + for (const [root, body] of [['.codex/skills', wikiSkillTagged('codex')], ['.agents/skills', wikiSkillTagged('shared')]]) { await fse.ensureDir(path.join(homeDir, root, 'team-wiki-codebase')); await fse.writeFile(path.join(homeDir, root, 'team-wiki-codebase/SKILL.md'), body); } @@ -939,8 +1005,8 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { await deployBuiltinSkills(teamConfig, localConfig); const run = await onlyRunDir(homeDir); - expect(await fse.readFile(path.join(run, 'codex/.codex-skills/team-wiki-codebase/SKILL.md'), 'utf8')).toBe('# from codex'); - expect(await fse.readFile(path.join(run, 'codex/.agents-skills/team-wiki-codebase/SKILL.md'), 'utf8')).toBe('# from shared'); + expect(await fse.readFile(path.join(run, 'codex/.codex-skills/team-wiki-codebase/SKILL.md'), 'utf8')).toBe(wikiSkillTagged('codex')); + expect(await fse.readFile(path.join(run, 'codex/.agents-skills/team-wiki-codebase/SKILL.md'), 'utf8')).toBe(wikiSkillTagged('shared')); }); it('removes the references an earlier release deployed beside the stub', async () => { @@ -967,8 +1033,8 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { // references tree the new deployment does not ship. const stubDir = path.join(homeDir, '.claude/skills/teamai'); await fse.ensureDir(path.join(stubDir, 'references')); - await fse.writeFile(path.join(stubDir, 'SKILL.md'), '# old body'); - await fse.writeFile(path.join(stubDir, 'references/setup-admin.md'), '# old reference'); + await fse.writeFile(path.join(stubDir, 'SKILL.md'), shipped('teamai', 'SKILL.md')); + await fse.writeFile(path.join(stubDir, 'references/setup-admin.md'), shipped('teamai', 'references/setup-admin.md')); await deployBuiltinSkills(teamConfig, localConfig); @@ -984,7 +1050,7 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { await fse.ensureDir(path.join(homeDir, '.codex')); const sharedLegacy = path.join(homeDir, '.agents/skills/team-wiki-codebase'); await fse.ensureDir(sharedLegacy); - await fse.writeFile(path.join(sharedLegacy, 'SKILL.md'), '# old'); + await fse.writeFile(path.join(sharedLegacy, 'SKILL.md'), WIKI_SKILL); const teamConfig = { team: 'test', @@ -1059,7 +1125,7 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { // What the release packaged… for (const packaged of ['SKILL.md', 'README.md', 'references/methodology/phase0-collection.md', 'scripts/scan_repo.py']) { await fse.ensureDir(path.join(wiki, path.dirname(packaged))); - await fse.writeFile(path.join(wiki, packaged), '# packaged'); + await fse.writeFile(path.join(wiki, packaged), shipped('team-wiki-codebase', packaged)); } // …and what the member put beside it, which `overwrite: true` never deleted. await fse.writeFile(path.join(wiki, 'references/methodology/my-notes.md'), '# mine'); @@ -1099,8 +1165,8 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { const stubDir = path.join(homeDir, '.claude/skills/teamai'); await fse.ensureDir(path.join(stubDir, 'references')); - await fse.writeFile(path.join(stubDir, 'SKILL.md'), '# old body'); - await fse.writeFile(path.join(stubDir, 'references/setup-admin.md'), '# old reference'); + await fse.writeFile(path.join(stubDir, 'SKILL.md'), shipped('teamai', 'SKILL.md')); + await fse.writeFile(path.join(stubDir, 'references/setup-admin.md'), shipped('teamai', 'references/setup-admin.md')); await fse.writeFile(path.join(stubDir, 'references/team-playbook.md'), '# mine'); await deployBuiltinSkills(teamConfig, localConfig); @@ -1119,7 +1185,7 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { await fse.ensureDir(path.join(homeDir, '.codex')); const sharedLegacy = path.join(homeDir, '.agents/skills/team-wiki-codebase'); await fse.ensureDir(sharedLegacy); - await fse.writeFile(path.join(sharedLegacy, 'SKILL.md'), '# codex copy'); + await fse.writeFile(path.join(sharedLegacy, 'SKILL.md'), WIKI_SKILL); const teamConfig = { team: 'test', @@ -1145,7 +1211,7 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { // nor deleted from, and Claude's pass must not reach it on Codex's behalf. expect(deployed).toBe(1); expect(await fse.pathExists(path.join(homeDir, '.claude/skills/teamai/SKILL.md'))).toBe(true); - expect(await fse.readFile(path.join(sharedLegacy, 'SKILL.md'), 'utf8')).toBe('# codex copy'); + expect(await fse.readFile(path.join(sharedLegacy, 'SKILL.md'), 'utf8')).toBe(WIKI_SKILL); }); it('deploys a built-in Codex skill to its existing shared location', async () => { diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 2a04e9a2..b8f320ca 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -2,9 +2,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import path from 'node:path'; import os from 'node:os'; import fse from 'fs-extra'; +import { fileURLToPath } from 'node:url'; +import { shipped, shippedSkillDigestsMock } from './helpers/shipped-skills.js'; + +const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); // ─── Mocks ───────────────────────────────────────────── +// A CLI-owned file is one whose content a release shipped; `shipped()` is it here. +vi.mock('../packaged-skill-digests.js', () => shippedSkillDigestsMock()); + const mockAutoDetectInit = vi.fn(); const mockSaveLocalConfig = vi.fn(); const mockSaveLocalConfigForScope = vi.fn(); @@ -121,11 +128,11 @@ async function setupFixture(tmpDir: string) { await fse.ensureDir(path.join(homeDir, '.claude', 'agents')); await fse.writeFile(path.join(homeDir, '.claude', 'agents', 'teamai-recall.md'), '# Recall Agent'); await fse.ensureDir(path.join(homeDir, '.claude', 'skills', 'teamai')); - await fse.writeFile(path.join(homeDir, '.claude', 'skills', 'teamai', 'SKILL.md'), '# teamai stub'); + await fse.writeFile(path.join(homeDir, '.claude', 'skills', 'teamai', 'SKILL.md'), shipped('teamai', 'SKILL.md')); await fse.ensureDir(path.join(homeDir, '.claude', 'skills', 'teamai-share-learnings')); - await fse.writeFile(path.join(homeDir, '.claude', 'skills', 'teamai-share-learnings', 'SKILL.md'), '# Share Learnings'); + await fse.writeFile(path.join(homeDir, '.claude', 'skills', 'teamai-share-learnings', 'SKILL.md'), shipped('teamai-share-learnings', 'SKILL.md')); await fse.ensureDir(path.join(homeDir, '.claude', 'skills', 'team-wiki-codebase')); - await fse.writeFile(path.join(homeDir, '.claude', 'skills', 'team-wiki-codebase', 'SKILL.md'), '# Wiki Codebase'); + await fse.writeFile(path.join(homeDir, '.claude', 'skills', 'team-wiki-codebase', 'SKILL.md'), shipped('team-wiki-codebase', 'SKILL.md')); // Settings.json with hooks await fse.writeJson(path.join(homeDir, '.claude', 'settings.json'), { @@ -999,18 +1006,18 @@ describe('uninstall', () => { // files and never deleted them, so uninstall must not either. const skills = path.join(homeDir, '.claude', 'skills'); const stub = path.join(skills, 'teamai'); - await fse.outputFile(path.join(stub, 'SKILL.md'), '# stub\n'); - await fse.outputFile(path.join(stub, 'references', 'setup-admin.md'), '# packaged\n'); + await fse.outputFile(path.join(stub, 'SKILL.md'), shipped('teamai', 'SKILL.md')); + await fse.outputFile(path.join(stub, 'references', 'setup-admin.md'), shipped('teamai', 'references/setup-admin.md')); await fse.outputFile(path.join(stub, 'references', 'team-playbook.md'), '# mine\n'); const legacy = path.join(skills, 'team-wiki-codebase'); - await fse.outputFile(path.join(legacy, 'SKILL.md'), '# packaged\n'); - await fse.outputFile(path.join(legacy, 'scripts', 'scan_repo.py'), '# packaged\n'); + await fse.outputFile(path.join(legacy, 'SKILL.md'), shipped('team-wiki-codebase', 'SKILL.md')); + await fse.outputFile(path.join(legacy, 'scripts', 'scan_repo.py'), shipped('team-wiki-codebase', 'scripts/scan_repo.py')); await fse.outputFile(path.join(legacy, 'references', 'methodology', 'my-notes.md'), '# mine\n'); // Nothing of the member's in this one, so it goes whole. const legacyShare = path.join(skills, 'teamai-share-learnings'); - await fse.outputFile(path.join(legacyShare, 'SKILL.md'), '# packaged\n'); + await fse.outputFile(path.join(legacyShare, 'SKILL.md'), shipped('teamai-share-learnings', 'SKILL.md')); const teamConfig = makeTeamConfig({ toolPaths: { @@ -1066,6 +1073,23 @@ describe('uninstall', () => { expect(await fse.pathExists(path.join(stubDir, 'SKILL.md'))).toBe(true); }); + it('removes the stub the installed CLI deployed, byte for byte, and keeps a same-named skill of the member\'s', async () => { + const { homeDir, repoPath } = await setupFixture(tmpDir); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + const skills = path.join(homeDir, '.claude', 'skills'); + // What `teamai pull` writes now: the packaged stub, verbatim. + await fse.copy(path.join(PACKAGE_ROOT, 'skills', 'teamai', 'SKILL.md'), path.join(skills, 'teamai', 'SKILL.md')); + // A skill the member wrote under a legacy name: no content a release shipped. + await fse.outputFile(path.join(skills, 'team-wiki-codebase', 'SKILL.md'), '---\nname: team-wiki-codebase\n---\n# mine\n'); + + mockAutoDetectInit.mockResolvedValue({ localConfig: makeLocalConfig(homeDir, repoPath), teamConfig: makeTeamConfig() }); + await uninstall({ force: true }); + + expect(await fse.pathExists(path.join(skills, 'teamai'))).toBe(false); + expect(await fse.readFile(path.join(skills, 'team-wiki-codebase', 'SKILL.md'), 'utf8')).toContain('# mine'); + }); + it('does not delete through a linked skills root, the same line pull stops at', async () => { const { homeDir, repoPath } = await setupFixture(tmpDir); vi.stubEnv('HOME', homeDir); @@ -1103,7 +1127,7 @@ describe('uninstall', () => { vi.stubEnv('SHELL', '/bin/zsh'); const target = path.join(tmpDir, 'dotfiles-copilot'); await fse.ensureDir(path.join(target, 'skills', 'teamai')); - await fse.writeFile(path.join(target, 'skills', 'teamai', 'SKILL.md'), '# stub in the checkout'); + await fse.writeFile(path.join(target, 'skills', 'teamai', 'SKILL.md'), shipped('teamai', 'SKILL.md')); await fse.symlink(target, path.join(homeDir, '.copilot'), 'dir'); const localConfig = makeLocalConfig(homeDir, repoPath); @@ -1123,7 +1147,7 @@ describe('uninstall', () => { vi.stubEnv('HERMES_HOME', hermesHome); const stub = path.join(hermesHome, 'skills', 'teamai', 'SKILL.md'); await fse.ensureDir(path.dirname(stub)); - await fse.writeFile(stub, '# stub'); + await fse.writeFile(stub, shipped('teamai', 'SKILL.md')); const localConfig = makeLocalConfig(homeDir, repoPath); const teamConfig = makeTeamConfig(); @@ -1144,7 +1168,7 @@ describe('uninstall', () => { // machine that has ever had one. const sharedStub = path.join(homeDir, '.agents', 'skills', 'teamai'); await fse.ensureDir(sharedStub); - await fse.writeFile(path.join(sharedStub, 'SKILL.md'), '# TeamAI\n'); + await fse.writeFile(path.join(sharedStub, 'SKILL.md'), shipped('teamai', 'SKILL.md')); const sharedUserSkill = path.join(homeDir, '.agents', 'skills', 'my-own-skill'); await fse.ensureDir(sharedUserSkill); await fse.writeFile(path.join(sharedUserSkill, 'SKILL.md'), '# Mine\n'); diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index c35def00..8b80d81e 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -10,6 +10,7 @@ import { ResourceHandler } from './resources/base.js'; import { CODEX_TOOL, resolveSkillDestination, SHARED_AGENT_SKILLS_PATH, skillsDirForTool, skillTargetForTool } from './resources/skills.js'; import { getUserHome } from './utils/home.js'; import { packagedSkillRoots } from './skill-content.js'; +import { PACKAGED_SKILL_DIGESTS } from './packaged-skill-digests.js'; // ─── Built-in skills deployment ────────────────────────── // @@ -70,44 +71,63 @@ export function isCliOwnedSkillName(name: string): boolean { } /** - * Every file a release ever packaged under `skills/`, by directory name. - * - * Built as the union of `git ls-tree -r <tag> -- skills/` over all 99 tags - * through v0.25.0, minus `teamai-wiki` (below). Every path listed - * here is written by the CLI and is ours to remove. Deployment - * copied these trees with `overwrite: true` and never deleted anything, so a - * file that is *not* listed here was put there by the member and survives. + * Every file a release ever packaged under `skills/`, by directory name: the + * paths of PACKAGED_SKILL_DIGESTS. A path alone does not make a file ours — + * `removeOwnedFiles` also needs its content to match a shipped version — but + * the list is what the deploy prune reads to find paths the current package no + * longer ships. * * `teamai-wiki` (0.13.0, 0.16.x) is deliberately absent: it predates the trees * this migration is about, and widening a destructive set is its own change. */ -export const PACKAGED_SKILL_FILES: ReadonlyMap<string, readonly string[]> = new Map([ - ['teamai', [ - 'SKILL.md', - 'references/contribute-member.md', - 'references/join-member.md', - 'references/manage-admin.md', - 'references/provider-tgit.md', - 'references/setup-admin.md', - 'references/troubleshooting.md', - 'references/uninstall.md', - ]], - ['teamai-share-learnings', ['SKILL.md']], - ['team-wiki-codebase', [ - 'SKILL.md', - 'README.md', - 'references/agents/graph-rag-agent.md', - 'references/agents/kb-doc-generator.md', - 'references/methodology/phase0-collection.md', - 'references/methodology/phase1-reverse-engineering.md', - 'references/methodology/phase2-document-types.md', - 'references/methodology/phase3-ai-enhancement.md', - 'references/methodology/phase4-quality.md', - 'references/templates/project-overview.md', - 'scripts/scan_repo.py', - 'scripts/validate_kb.py', - ]], -]); +export const PACKAGED_SKILL_FILES: ReadonlyMap<string, readonly string[]> = new Map( + [...PACKAGED_SKILL_DIGESTS].map(([skill, files]) => [skill, [...files.keys()]]), +); + +const FRONTMATTER_BLOCK = /^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/; + +/** + * The digest PACKAGED_SKILL_DIGESTS records for a file: sha256 of its bytes, or, + * for a skill-root `SKILL.md`, of its body without the frontmatter block, which + * the deploy of earlier releases rewrote on disk when a field was missing. + */ +export function packagedSkillDigest(relativePath: string, content: Buffer): string { + const hashed = relativePath === 'SKILL.md' + ? Buffer.from(content.toString('utf8').replace(FRONTMATTER_BLOCK, '').replace(/^[\r\n]+/, ''), 'utf8') + : content; + return createHash('sha256').update(hashed).digest('hex'); +} + +/** Digests by path: what `removeOwnedFiles` may remove, and only at that content. */ +export type OwnedSkillFiles = ReadonlyMap<string, ReadonlySet<string>>; + +/** + * The files of `skillName` the CLI provably wrote — every version a release + * shipped, plus, for the stub, the one this package ships now — optionally + * narrowed to `paths`. + */ +export async function ownedSkillFiles(skillName: string, paths?: readonly string[]): Promise<OwnedSkillFiles> { + const owned = new Map<string, Set<string>>(); + for (const [relative, digests] of PACKAGED_SKILL_DIGESTS.get(skillName) ?? []) { + if (!paths || paths.includes(relative)) owned.set(relative, new Set(digests)); + } + if (BUILTIN_SKILL_NAMES.has(skillName) && (!paths || paths.includes('SKILL.md'))) { + const stubPath = path.join(packagedSkillRoots().deployRoot, skillName, 'SKILL.md'); + if (await pathExists(stubPath)) { + const digests = owned.get('SKILL.md') ?? new Set<string>(); + digests.add(packagedSkillDigest('SKILL.md', await fs.promises.readFile(stubPath))); + owned.set('SKILL.md', digests); + } + } + return owned; +} + +/** True when `file` sits at an owned path with content a release shipped there. */ +async function isOwnedFile(file: string, relative: string, owned: OwnedSkillFiles): Promise<boolean> { + const digests = owned.get(relative); + if (!digests) return false; + return digests.has(packagedSkillDigest(relative, await fs.promises.readFile(file))); +} /** * Python bytecode cache of a script we shipped: `a/__pycache__/x.cpython-311.pyc` @@ -189,11 +209,11 @@ export function prunedWhole(result: PruneResult): boolean { */ export async function removeOwnedFiles( dir: string, - owned: readonly string[], + owned: OwnedSkillFiles, baseDir: string, backupDir?: string, ): Promise<PruneResult> { - const ownedPaths = new Set(owned); + const ownedPaths = new Set(owned.keys()); const result: PruneResult = { skippedSymlink: false, foreign: 0, unbackedUp: [], notRemoved: [], backedUp: 0, }; @@ -218,17 +238,25 @@ export async function removeOwnedFiles( } for (const relative of entries) { - if (!ownedPaths.has(relative) && !isDerivedArtifact(relative, ownedPaths)) { + const file = path.join(dir, relative); + // Ours only at a path a release shipped *and* with content one of them + // shipped there. A member's edit, or a skill of their own that uses a + // packaged name under a root TeamAI never managed, fails the second test + // and stays, with its directory. + let owns: boolean; + try { + owns = await isOwnedFile(file, relative, owned) || isDerivedArtifact(relative, ownedPaths); + } catch (e) { + // Unreadable, so unprovable: kept, and said so. + result.notRemoved.push({ file, error: (e as Error).message }); + continue; + } + if (!owns) { result.foreign++; continue; } - const file = path.join(dir, relative); - // Ownership is proven by pathname, not by contents: a member who edited one - // of our files in place still has that edit in there. The old deployment - // overwrote it on the next pull, so nothing was preserved either way, but a - // path a retired release shipped and the current package no longer does was - // never overwritten. Park a copy before removing so no version of that is a - // one-way door. + // Content proves the CLI wrote the file; a copy still goes to the archive + // before the delete, so no removal is a one-way door. if (backupDir) { try { // `errorOnExist` turns a colliding path into a failure rather than a @@ -381,7 +409,7 @@ export async function pruneLegacyBuiltinSkills( if (!await pathExists(dir)) continue; try { const backupDir = skillBackupDir(guardBase, tool, path.relative(guardBase, root), legacyName); - const result = await removeOwnedFiles(dir, PACKAGED_SKILL_FILES.get(legacyName) ?? [], guardBase, backupDir); + const result = await removeOwnedFiles(dir, await ownedSkillFiles(legacyName), guardBase, backupDir); const saved = result.backedUp > 0 ? `; a copy is in ${backupDir}` : ''; if (prunedWhole(result)) { log.debug(`Removed legacy built-in skill ${legacyName} from ${tool} (${dir})${saved}`); @@ -403,6 +431,35 @@ export async function pruneLegacyBuiltinSkills( } } +/** + * Codex reads both `.codex/skills` and the shared `.agents/skills`, and the + * destination resolver picks the shared copy whenever one exists. The stub has + * just been written to one of them; a copy an earlier release left in the other + * would keep its old SKILL.md and references beside it, so Codex would see two + * `teamai` skills and one of them stale. That copy goes, by the same ownership + * rule as the rest: only files whose content a release shipped, archived first. + */ +async function retireOtherCodexCopy( + tool: string, + skillName: string, + deployedDir: string, + { skillsDir, guardBase }: BuiltinSkillsTarget, +): Promise<void> { + const candidates = [path.join(skillsDir, skillName), path.join(guardBase, SHARED_AGENT_SKILLS_PATH, skillName)]; + for (const other of candidates) { + if (path.resolve(other) === path.resolve(deployedDir) || !await pathExists(other)) continue; + const backupDir = skillBackupDir(guardBase, tool, path.relative(guardBase, path.dirname(other)), skillName); + const result = await removeOwnedFiles(other, await ownedSkillFiles(skillName), guardBase, backupDir); + if (prunedWhole(result)) { + log.debug(`Removed the second Codex copy of ${skillName} at ${other}; the stub is at ${deployedDir}`); + } else if (result.skippedSymlink) { + log.warn(`Kept ${other}: it is reached through a symlink, so TeamAI left it alone. Codex also reads the stub at ${deployedDir}.`); + } else { + log.warn(`Kept ${other}: it holds files TeamAI did not write, so Codex sees it beside the stub at ${deployedDir}. Remove it once you have saved what you need.`); + } + } +} + /** * Deploy CLI built-in skills to all configured AI tool skill directories. * @@ -496,7 +553,7 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? const shippedNow = new Set(await walkFiles(srcDir)); const retired = (PACKAGED_SKILL_FILES.get(skillName) ?? []).filter((p) => !shippedNow.has(p)); const backupDir = skillBackupDir(baseDir, tool, path.relative(baseDir, path.dirname(destDir)), skillName); - const result = await removeOwnedFiles(destDir, retired, baseDir, backupDir); + const result = await removeOwnedFiles(destDir, await ownedSkillFiles(skillName, retired), baseDir, backupDir); if (result.unbackedUp.length > 0) { log.warn(`Kept ${result.unbackedUp.length} file(s) under ${destDir}: their backup could not be written, so they were not removed. First: ${result.unbackedUp[0].file} — ${result.unbackedUp[0].error}`); } @@ -506,6 +563,7 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? } await fse.ensureDir(destDir); await fse.copy(path.join(srcDir, 'SKILL.md'), path.join(destDir, 'SKILL.md'), { overwrite: true }); + if (tool === CODEX_TOOL) await retireOtherCodexCopy(tool, skillName, destDir, target); deployed++; } catch (e) { diff --git a/src/packaged-skill-digests.ts b/src/packaged-skill-digests.ts new file mode 100644 index 00000000..b3f06e6b --- /dev/null +++ b/src/packaged-skill-digests.ts @@ -0,0 +1,41 @@ +/** + * sha256 of every file a release shipped under the CLI-owned skill trees, by + * skill and path relative to the skill directory. A skill-root `SKILL.md` is + * hashed without its frontmatter block (`packagedSkillDigest`): releases before + * 0.17 shipped it with none, and the deploy of the day repaired frontmatter on + * disk, so only the body is what the CLI provably wrote. + * + * Generated from `git ls-tree -r <ref> -- skills/` over all 99 tags + * through v0.25.0 plus origin/main before the stub (installs from `main`), + * minus `teamai-wiki` (see PACKAGED_SKILL_FILES). Do not edit by hand; a new + * release adds nothing here, since the package no longer ships these trees. + */ +export const PACKAGED_SKILL_DIGESTS: ReadonlyMap<string, ReadonlyMap<string, readonly string[]>> = new Map([ + ['teamai', new Map([ + ['SKILL.md', ['5d3c7629924db1a4b7feb17d75b0a94a804229a6bbee923ba50888a706f328cc', '7002b421f08803ab7c5858b8d69e0c5e37f5d6c42dd6c4ee411796e08d35f1d8']], + ['references/contribute-member.md', ['2a6a8c3eeee7424f79cb6d3f97d14f8588720f1d570028f5afdd12d6db458355']], + ['references/join-member.md', ['2f2f823675cea971b2a0360e7c6f090b2397e25702e60cce50bafb2b450ce8c3', '45e56c965fba5fe5f14cfb927c9b18032d19ebf98e524e7c709bcde16862b15a', 'ed2ab1c92680b3412e2ce60d5900f6baba0da896ad92158945ac6115ac939fc2']], + ['references/manage-admin.md', ['fd31d78724fb35d3bfecf606299c9e907f2a10da275bf06f840780c674584158']], + ['references/provider-tgit.md', ['df7faedb8beeafeb55a23c2b3d2b99d1421548cf4f8172ed7da0752ef6aeda39']], + ['references/setup-admin.md', ['4e58bae91bcb3831fd7d2dc0c9f086bb0e985f7d51dd7bc7e753bce1b380816e', 'dae12585f3003f1e6c347f51859de68c7af9baf11b84be63559cb782d122db7b', 'ff9996686f42cc7d7c2a09cb5f254dc0d8fb379fae29dab9046c971dcced543f']], + ['references/troubleshooting.md', ['78ad122c14f1c5081ef698c880c4a250a99eb7a6ccbb1b39674359e712261fc6']], + ['references/uninstall.md', ['10a97318e8bc6a94a0413e6d1b1b516b24ab8fd7f52d118cfc0d92c97394b892']], + ])], + ['teamai-share-learnings', new Map([ + ['SKILL.md', ['2bfdfa9c4f312424e06544fe104fbf5988cf5a6b3f2289215cbe89fb430d18c0', '7771ec3997b747e4e270818189a1f450cc7e7307cc14c15b42491a2f20e94494', 'a47169735ee710bb38c21fa72e8f647ca2078b1cfd538bad78e30a0806f179c5', 'de28d09f85099943339f3adff3f3e2f180099d18d2963655bb14ca4af4f98bea', 'e7ab73f2258e13b55c91bffdd07975b13fb7a34c84c81215340a8239432f358b']], + ])], + ['team-wiki-codebase', new Map([ + ['README.md', ['4e1ab336bfa6d78085572b4e0fc0e345bda0ec2be065279f189a8f2939f242b8', '82945615d4706b2c1b581d2b326c15536be0b8573472c359d1414690f0b2e804', 'd3c7312663caa8cefccd1034127fe7091384b8e603fcd04961f4f30a8ff1fe0e']], + ['SKILL.md', ['178164fbfd2724cbc581c7e8c16712e41c7928bb8dba7c9ad6b9a3ef50afac2f', '1f5fbdc46873e8baae340b6dfb4e289a74d3706b357db4c7b8eab41971ef95dc', '79f260dda754a5495e027643ae2bd0b3b70ea761b8ce297aee9a66a97ca43da7', 'b2ffc2996f415b5709bf3a75ac5b5d5de52ebb8e866506f5f00aa6730d55c269', 'eb268ad4bf653e77dead394141204d6a20881b0e5a6cb9d5cea33822c7ebd90c']], + ['references/agents/graph-rag-agent.md', ['d79e52cfec1e131877f7fa28bb14993b937fb643ed6778fde6bf569dbd0ba2d3']], + ['references/agents/kb-doc-generator.md', ['8dc1f1ef5e5d270223586567629b66333a07c42ae103ac5cdef6c755364587d5']], + ['references/methodology/phase0-collection.md', ['1061ff28e17aa290dc0942958bac0ea0844b3b830fabda3dd9e03ac32c02b0b0', '89caa5e6e5135b19e39ebf48224a31ae6ea80bbef84c7c2e4cf50b6391220cf6']], + ['references/methodology/phase1-reverse-engineering.md', ['9d709e09a30ca198020fe7f2470980a4d2f4889a685dc33f71c79f6433110a59']], + ['references/methodology/phase2-document-types.md', ['62eee3e3290cbc1a4a7c40bcac5e7d8f2100989b438726b867af448dfda67b7b']], + ['references/methodology/phase3-ai-enhancement.md', ['efe1536f1ea4a2ffeb9ab69409ce1cb172fe8a5b8efb583a46ed69ed976bc6d0']], + ['references/methodology/phase4-quality.md', ['a7b536ab120a8c4bc3fbd53256675309a399e53b4d0d202abd5df1b82c985754']], + ['references/templates/project-overview.md', ['296c15c827ae7798bf9f3112b84d1bdd69b0807bb80286cfc78f1a0d956e66ec']], + ['scripts/scan_repo.py', ['a941f3ac9a260c860cfeded26eb6c6f3d55cc5af748e1e5f673a94278c6a9c36']], + ['scripts/validate_kb.py', ['c6e08b03b80a60048374637b4de20afdd21b552892e915b8301efb368316522f']], + ])], +]); diff --git a/src/uninstall.ts b/src/uninstall.ts index e62d162b..e3796095 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -41,7 +41,7 @@ import { BUILTIN_AGENT_NAMES } from './builtin-agents.js'; import { BUILTIN_SKILL_NAMES, LEGACY_BUILTIN_SKILL_NAMES, - PACKAGED_SKILL_FILES, + ownedSkillFiles, isCliOwnedSkillName, prunedWhole, removeOwnedFiles, @@ -897,7 +897,7 @@ async function executeRemoval(plan: RemovalPlan): Promise<void> { try { const name = path.basename(skillDir); if (isCliOwnedSkillName(name)) { - const result = await removeOwnedFiles(skillDir, PACKAGED_SKILL_FILES.get(name) ?? [], baseDir); + const result = await removeOwnedFiles(skillDir, await ownedSkillFiles(name), baseDir); if (prunedWhole(result)) removedSkillDirs++; else if (result.skippedSymlink) linkedSkillDirs.push(skillDir); // A delete that failed is not a member's file: say what happened, not From 493cb63f2f02091834c22fe2c26270b9993878df Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Wed, 23 Sep 2026 07:23:25 +0200 Subject: [PATCH 34/37] chore(skills): carry main's skill edits into the served copies after the rebase #713 and #736 edited skills/teamai/references/*.md, which this branch moved to skill-data/setup/references/. Two hunks did not follow the move: - join-member.md: TGIT_TOKEN is REST-API-only and cannot clone (#713). - setup-admin.md: the /teamai share entry publishes a reusable skill; a session's learnings are automatic (#736), in English as the served text is. #739's partial config mock is restored in skip-uninstalled-tools.test.ts. --- skill-data/setup/references/join-member.md | 5 +++-- skill-data/setup/references/setup-admin.md | 11 ++++++++--- src/__tests__/skip-uninstalled-tools.test.ts | 3 ++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/skill-data/setup/references/join-member.md b/skill-data/setup/references/join-member.md index 54561b82..564b2a86 100644 --- a/skill-data/setup/references/join-member.md +++ b/skill-data/setup/references/join-member.md @@ -42,8 +42,9 @@ Match the login to the URL's host (do NOT create a second repo): - **`git.woa.com/...`** (Tencent TGit) → **you run both the `gf` install and the `gf … auth login`** (never tell the user to run them). Follow `{SKILL_DIR}/references/provider-tgit.md` ("Log in"); the user's only action is - approving the login URL in their browser / iOA. No `GITLAB_URL` needed. (Headless - only: pre-set `TGIT_TOKEN`.) + approving the login URL in their browser / iOA. No `GITLAB_URL` needed. (No headless + shortcut: `TGIT_TOKEN` is REST-API-only and cannot clone, so the login has to be run + once on the machine.) - **`cnb.cool/...`** → install the CNB CLI, then authorize, in this order: 1. `npm install -g @cnbcool/cnb-cli` 2. `cnb login` — have the user approve it in the browser (OAuth2 device flow); diff --git a/skill-data/setup/references/setup-admin.md b/skill-data/setup/references/setup-admin.md index e837130d..acabd7b9 100644 --- a/skill-data/setup/references/setup-admin.md +++ b/skill-data/setup/references/setup-admin.md @@ -268,9 +268,14 @@ day-to-day work — they can keep letting the AI run things for them: `/teamai I already have TeamAI set up, help me manage it` — this loads the daily-management flow (`{SKILL_DIR}/references/manage-admin.md`): publishing skills, inviting members, roles / packages / env. -- To share something they learned, once recall is on (off by default; turn it on - team-wide with `sharing.recall.enabled: true` in `teamai.yaml`, then `teamai push`): - `/teamai I want to contribute what I learned to my team`. +- To share a reusable skill with the team, they run (in their language): + `/teamai Share this <skill-name> skill with my team` — see + `"$(teamai skill path core)/references/contribute-member.md"`. +- Sharing a **session's learnings** needs no command from them: TeamAI prompts on + its own at the end of a session worth sharing, and the `share` workflow + (`teamai skill get share`) takes over. (Only once recall is on — off by default; + turn it on team-wide with `sharing.recall.enabled: true` in `teamai.yaml`, then + `teamai push`.) Mention the underlying commands (`teamai push`, `teamai roles`, …) only as a note for users who *do* want them — the primary path is re-invoking `/teamai`. diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index 1b42e003..fec640c6 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -61,7 +61,8 @@ const WIKI_SKILL = shipped('team-wiki-codebase', 'SKILL.md'); /** Shipped body under a distinguishing frontmatter block: still ours, told apart. */ const wikiSkillTagged = (tag: string): string => `---\nname: ${tag}\n---\n${WIKI_SKILL}`; -vi.mock('../config.js', () => ({ +vi.mock('../config.js', async (importOriginal) => ({ + ...(await importOriginal<typeof import('../config.js')>()), requireInit: vi.fn(), loadState: vi.fn(), saveState: vi.fn(), From a74e6d04148671de694d91e8bd2c342c3e6c91e3 Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Wed, 23 Sep 2026 07:33:10 +0200 Subject: [PATCH 35/37] fix(skills): deploy before pruning, block share on an unloadable config, drop hidden commands from the reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Legacy trees were pruned before the stub was written, so a refused or failed stub (a link, a read-only directory) left the agent with nothing to discover. They go only once the stub deployed for that agent. - The share gate failed open on any config error. Only a machine with no config (NotInitializedError) is served; a config that exists but cannot be loaded blocks with its own reason, `blockedBy: "config"`. - The KB template told agents to run `code-to-knowledge --update`, which does not exist; it names `teamai codebase --extract … --incremental`. - The generated command reference listed hidden hook plumbing (`track`, `contribute-check`, `todowrite-hint`, …). It renders what `--help` lists. - removeEmptyDirs swallowed every rmdir error, so a directory that stayed could be reported removed. Only "still holds something" is expected; any other failure is reported. --- docs/designs/skill-serving.md | 8 ++-- skill-data/core/references/commands.md | 44 ----------------- .../references/templates/project-overview.md | 4 +- src/__tests__/skill-recall-gate.test.ts | 21 ++++++++- src/__tests__/skill-show.test.ts | 3 +- src/__tests__/skip-uninstalled-tools.test.ts | 44 +++++++++++++++++ src/builtin-skills.ts | 47 ++++++++++++++----- src/commands-reference.ts | 13 ++++- src/skill-cmd.ts | 3 +- src/skill-content.ts | 18 +++++-- 10 files changed, 134 insertions(+), 71 deletions(-) diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index a61af26c..c4dedcdf 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -89,8 +89,10 @@ path (measured here from a 77-character one). `skill get <name>` refuses, `skill get --all` leaves the skill out and says so on stderr, `skill path <name>` and `skill show <name>` refuse, and `skill list --json` reports `blockedBy: "recall"` with `path: null`. With no - team config to consult — or one it cannot load — it fails open: a refusal the - member cannot act on is worse than serving the workflow. The Stop-hook share + config on the machine at all it fails open: a refusal a fresh install cannot act + on is worse than serving the workflow. A config that exists but cannot be loaded + blocks instead (`blockedBy: "config"`), since recall and the source are then + unknown and the workflow would fail at `teamai contribute`. The Stop-hook share reminder is gated the same way (`contributeHintAllowed`, `src/hook-handlers.ts`), because it points at this command. The gate lives in one place: `resolveServableSkill` (`src/skill-content.ts`) is the only way to obtain a @@ -176,7 +178,7 @@ member's own skill that uses a legacy name, a root TeamAI never managed because directory. Checking the path alone would have deleted those. What is removed is still copied first to `~/.teamai/removed-skills/<run>/<base>/<tool>/<skill-root>/<skill>/`, so no -removal is a one-way door. Codex reads both `.codex/skills` and the shared `.agents/skills`, and the +removal is a one-way door. The legacy trees go only after the stub deployed for that agent: pruning first and then failing to write the stub (a link, a read-only directory) would leave nothing to discover. Codex reads both `.codex/skills` and the shared `.agents/skills`, and the stub goes to the shared one when a copy already lives there; the copy an earlier release left in the other root is retired by the same rule (`retireOtherCodexCopy`), so Codex never sees a stale `teamai` beside the current diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index a41f5bc8..dd26b131 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -158,7 +158,6 @@ Generated: do not edit by hand. Regenerate with - `--token <key>` — API token for the HTTP endpoint (stored 0600, never committed) - `--force` — Overwrite an existing HTTP source config - `teamai source remove-http` — Remove the HTTP source and clean up its resources - - `teamai source reconcile-plugins` — Run plugin reconcile worker (called internally by session_start hook) - `teamai source list` — List all configured sources - `teamai source browse <name>` — Browse public skills from a source @@ -207,18 +206,6 @@ Generated: do not edit by hand. Regenerate with - `teamai webhook test` — Send test event to webhook endpoints - `--url <url>` — Test specific endpoint URL -## track - -- `teamai track [toolName] [toolInput]` — Track a tool usage event (called by PostToolUse hook) - - `--stdin` — Read hook data from STDIN (Claude Code hook format) - - `--tool <name>` — Tool identifier for usage attribution (e.g. claude, claude-internal) - -## track-slash - -- `teamai track-slash` — Track a slash command usage (called by UserPromptSubmit hook) - - `--stdin` — Read hook data from STDIN - - `--tool <name>` — Tool identifier for usage attribution (e.g. claude, claude-internal) - ## stats - `teamai stats` — Show local skill usage statistics @@ -244,12 +231,6 @@ Generated: do not edit by hand. Regenerate with - `teamai dashboard` — Start the AI coding session dashboard (Web UI) - `-p, --port <port>` — Port number -## dashboard-report - -- `teamai dashboard-report` — Report session state to dashboard (called by hooks) - - `--stdin` — Read hook data from STDIN - - `--tool <name>` — Tool identifier (e.g. claude, claude-internal) - ## hook-dispatch - `teamai hook-dispatch <event>` — Unified hook dispatcher — handles all teamai hooks for a given event in one process @@ -265,12 +246,6 @@ Generated: do not edit by hand. Regenerate with - `--project-id <id>` — Project ID from /projects/mine - `--skip` — Mark current workspace as skipped (never prompt again) -## contribute-check - -- `teamai contribute-check` — Check if session qualifies for contribution (called by PostToolUse hook) - - `--stdin` — Read hook data from STDIN - - `--tool <name>` — Tool identifier (e.g. claude, claude-internal) - ## contribute - `teamai contribute` — Contribute session knowledge to team repo @@ -301,12 +276,6 @@ Generated: do not edit by hand. Regenerate with - `--category <cat>` — Target category: skills | rules | docs - `--dry-run` — Show what would be done without making changes -## todowrite-hint - -- `teamai todowrite-hint` — Remind the agent to invoke teamai-recall when TodoWrite is used (PostToolUse hook) - - `--stdin` — Read hook data from STDIN - - `--tool <name>` — Source AI tool (claude / codebuddy / cursor) - ## import - `teamai import` — Import knowledge from local directories, remote repos, organizations, MRs, or iWiki @@ -338,12 +307,6 @@ Generated: do not edit by hand. Regenerate with - `--max-bytes <n>` (hidden) — Override capacity cap for --cache-gc - `--stale-days <n>` (hidden) — Threshold for stale-eviction in days (default 30) -## mr-hint - -- `teamai mr-hint` — Hint AI about recently merged but un-imported MRs (SessionStart hook) - - `--stdin` — Read hook data from STDIN - - `--tool <name>` — Source AI tool (claude / codebuddy / cursor) - ## codebase - `teamai codebase` — Inspect and maintain team-codebase outputs @@ -382,10 +345,3 @@ Generated: do not edit by hand. Regenerate with - `--write-mode <mode>` — Write strategy: direct | pending-review - `--output <dir>` — Write artifacts to directory - `--individual-comments` — Post each suggestion as separate comment with reaction/resolve support - -## deep-enrich - -- `teamai deep-enrich` — Run deep AI knowledge generation for an imported repo - - `--project <slug>` — Project slug (directory name in evidence/code/) - - `--wiki-root <path>` — Teamwiki root path - - `--max-modules <n>` — Max modules to process (cost control) diff --git a/skill-data/wiki/references/templates/project-overview.md b/skill-data/wiki/references/templates/project-overview.md index f6d54ead..653c1dbd 100644 --- a/skill-data/wiki/references/templates/project-overview.md +++ b/skill-data/wiki/references/templates/project-overview.md @@ -78,7 +78,7 @@ ### Knowledge base update notes -- **Incremental update**: `code-to-knowledge --update` updates only the documents of changed files +- **Incremental update**: `teamai codebase --extract <repo> --project <slug> --incremental` re-extracts only the changed files - **Full rebuild**: recommended after large-scale code refactoring - **Last updated**: `<ISO8601>` @@ -138,7 +138,7 @@ ## Code baseline version -> ⚠️ This knowledge base was generated from the code version below. After the code evolves, run `code-to-knowledge --update` for an incremental update. +> ⚠️ This knowledge base was generated from the code version below. After the code evolves, run `teamai codebase --extract <repo> --project <slug> --incremental` for an incremental update. - **Commit**: `<git commit SHA>` - **Tag**: `<tag or "no tag">` diff --git a/src/__tests__/skill-recall-gate.test.ts b/src/__tests__/skill-recall-gate.test.ts index 32618ba5..29864bd6 100644 --- a/src/__tests__/skill-recall-gate.test.ts +++ b/src/__tests__/skill-recall-gate.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; const autoDetectInit = vi.fn(); -vi.mock('../config.js', () => ({ autoDetectInit })); +vi.mock('../config.js', async (importOriginal) => ({ + ...(await importOriginal<typeof import('../config.js')>()), + autoDetectInit, +})); import { resolveServableSkill, skillCatalog, skillGet, skillPath } from '../skill-content.js'; @@ -150,10 +153,24 @@ describe('recall gate on served skills', () => { }); it('fails open when there is no team config to consult', async () => { - autoDetectInit.mockRejectedValue(new Error('not initialized')); + const { NotInitializedError } = await import('../config.js'); + autoDetectInit.mockRejectedValue(new NotInitializedError('teamai is not initialized. Run `teamai init` first.')); // A fresh machine reading the docs gets the content, not a refusal it // cannot act on. expect((await resolveServableSkill('share')).kind).toBe('found'); }); + + it('blocks share when a config exists but cannot be loaded, since recall and the source are then unknown', async () => { + autoDetectInit.mockRejectedValue(new Error('Team config (teamai.yaml) not found. Check your repo path.')); + + expect(await resolveServableSkill('share')).toEqual({ kind: 'blocked', name: 'share', reason: 'config' }); + await skillGet(['share']); + expect(process.exitCode).toBe(1); + expect(stdout).toBe(''); + expect(stderr).toContain('config on this machine could not be loaded'); + expect((await skillCatalog()).find((entry) => entry.name === 'share')).toMatchObject({ blockedBy: 'config', path: null }); + // Only share depends on the config; the rest is still served. + expect((await resolveServableSkill('core')).kind).toBe('found'); + }); }); diff --git a/src/__tests__/skill-show.test.ts b/src/__tests__/skill-show.test.ts index 3d1b14c3..616c6cfd 100644 --- a/src/__tests__/skill-show.test.ts +++ b/src/__tests__/skill-show.test.ts @@ -87,7 +87,8 @@ function captureLogs() { } async function runSkillShow(name: string, fx: Fixture): Promise<string[]> { - vi.doMock('../config.js', () => ({ + vi.doMock('../config.js', async (importOriginal) => ({ + ...(await importOriginal<typeof import('../config.js')>()), autoDetectInit: async () => ({ localConfig: fx.localConfig, teamConfig: fx.teamConfig }), })); const { skillShow } = await import('../skill-cmd.js'); diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index fec640c6..c089ff91 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -979,6 +979,50 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { expect(bodies.sort()).toEqual([wikiSkillTagged('project'), wikiSkillTagged('user')]); }); + it('keeps the legacy skills when the stub could not be deployed, so the agent keeps one to discover', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + // The stub destination is a link, so the stub is refused; pruning first + // would leave the agent with neither the old skills nor the new one. + const outside = path.join(tmpDir, 'outside/teamai'); + await fse.ensureDir(outside); + await fse.ensureDir(path.join(homeDir, '.claude/skills/team-wiki-codebase')); + await fse.writeFile(path.join(homeDir, '.claude/skills/team-wiki-codebase/SKILL.md'), WIKI_SKILL); + await fse.symlink(outside, path.join(homeDir, '.claude/skills/teamai'), 'dir'); + + const deployed = await deployBuiltinSkills(legacyPruneTeamConfig(), legacyPruneLocalConfig(tmpDir)); + + expect(deployed).toBe(0); + expect(await fse.readFile(path.join(homeDir, '.claude/skills/team-wiki-codebase/SKILL.md'), 'utf8')).toBe(WIKI_SKILL); + }); + + it('reports a legacy directory it emptied but could not remove, instead of calling it removed', async () => { + if (process.getuid?.() === 0) return; // root ignores directory permissions + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + const { log } = await import('../utils/logger.js'); + + // A locked skills root: the files inside the legacy directory can go, the + // directory itself cannot. The stub directory already exists, so the stub + // still deploys and the prune runs. + const skills = path.join(homeDir, '.claude/skills'); + await fse.ensureDir(path.join(skills, 'team-wiki-codebase')); + await fse.writeFile(path.join(skills, 'team-wiki-codebase/SKILL.md'), WIKI_SKILL); + await fse.ensureDir(path.join(skills, 'teamai')); + await fse.writeFile(path.join(skills, 'teamai/SKILL.md'), shipped('teamai', 'SKILL.md')); + (log.warn as ReturnType<typeof vi.fn>).mockClear(); + await fse.chmod(skills, 0o555); + try { + await deployBuiltinSkills(legacyPruneTeamConfig(), legacyPruneLocalConfig(tmpDir)); + } finally { + await fse.chmod(skills, 0o755); + } + + const warnings = (log.warn as ReturnType<typeof vi.fn>).mock.calls.map((c) => String(c[0])); + expect(warnings.filter((w) => w.includes('team-wiki-codebase') && w.includes('could not be deleted'))).toHaveLength(1); + // The stub's own directory is not empty, so it is not reported. + expect(warnings.filter((w) => w.includes(path.join(skills, 'teamai')))).toEqual([]); + }); + it('archives nothing when there is nothing retired to archive', async () => { const { deployBuiltinSkills } = await import('../builtin-skills.js'); diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index 8b80d81e..6b2ef26d 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -160,20 +160,36 @@ async function walkFiles(dir: string, prefix = ''): Promise<string[]> { return found; } -/** Remove `dir` and every directory under it that holds nothing. */ -async function removeEmptyDirs(dir: string): Promise<void> { +/** + * Remove `dir` and every directory under it that holds nothing. A directory + * that still has something in it stays, which is the point: that something is + * the member's. Any other failure (permissions, a busy mount) is returned, so + * the prune does not report a directory gone that is still there. + */ +async function removeEmptyDirs(dir: string): Promise<{ file: string; error: string }[]> { + const failures: { file: string; error: string }[] = []; let entries; try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); - } catch { - return; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') failures.push({ file: dir, error: (e as Error).message }); + return failures; } for (const entry of entries) { - if (entry.isDirectory()) await removeEmptyDirs(path.join(dir, entry.name)); + if (entry.isDirectory()) failures.push(...await removeEmptyDirs(path.join(dir, entry.name))); } - // Fails when something is left, which is the point: that something is the - // member's, and their directory stays. - try { await fs.promises.rmdir(dir); } catch { /* not empty */ } + try { + await fs.promises.rmdir(dir); + } catch (e) { + // Not empty is the expected outcome for a directory holding the member's + // files — and some platforms say EACCES for that under a read-only parent, + // so the directory's contents, not the error code, decide. + const code = (e as NodeJS.ErrnoException).code; + const stillHolds = code === 'ENOTEMPTY' || code === 'EEXIST' + || (await fs.promises.readdir(dir).catch(() => [])).length > 0; + if (!stillHolds) failures.push({ file: dir, error: (e as Error).message }); + } + return failures; } /** What `removeOwnedFiles` did and did not do, for the caller to report. */ @@ -279,7 +295,7 @@ export async function removeOwnedFiles( result.notRemoved.push({ file, error: (e as Error).message }); } } - await removeEmptyDirs(dir); + result.notRemoved.push(...await removeEmptyDirs(dir)); return result; } @@ -525,8 +541,7 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? // legacy directories are left alone too. if (localConfig && isAgentExcluded(localConfig, tool)) continue; - await pruneLegacyBuiltinSkills(tool, target); - + let deployedHere = 0; for (const skillName of skillNames) { const srcDir = path.join(builtinDir, skillName); const destDir = localConfig @@ -566,10 +581,20 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? if (tool === CODEX_TOOL) await retireOtherCodexCopy(tool, skillName, destDir, target); deployed++; + deployedHere++; } catch (e) { log.error(`Failed to deploy built-in skill ${skillName} to ${toolPath.skills}: ${(e as Error).message}`); } } + + // The legacy trees go only once their replacement is in place: pruning first + // and then failing to write the stub (a link, a read-only directory) would + // leave the agent with no discoverable TeamAI skill at all. + if (deployedHere === skillNames.length) { + await pruneLegacyBuiltinSkills(tool, target); + } else { + log.warn(`Kept the pre-stub skills for ${tool}: the new stub was not deployed there, so removing them would leave nothing to discover.`); + } } return deployed; diff --git a/src/commands-reference.ts b/src/commands-reference.ts index c6810aeb..fc4c451e 100644 --- a/src/commands-reference.ts +++ b/src/commands-reference.ts @@ -37,6 +37,15 @@ function visibleOptions(command: Command): Option[] { return command.options.filter((option) => option.long !== '--help'); } +/** + * Subcommands `--help` lists. Hidden ones (`track`, `contribute-check`, …) are + * hook plumbing the CLI calls itself; listing them would advertise them to the + * agent as supported commands. The implicit `help` entry says nothing. + */ +function visibleSubcommands(command: Command): Command[] { + return command.createHelp().visibleCommands(command).filter((sub) => sub.name() !== 'help'); +} + function renderCommand(command: Command, parents: string[]): string[] { const path = [...parents, command.name()]; const args = command.registeredArguments.map((a) => { @@ -51,7 +60,7 @@ function renderCommand(command: Command, parents: string[]): string[] { for (const option of visibleOptions(command)) { lines.push(renderOption(option)); } - for (const sub of command.commands) { + for (const sub of visibleSubcommands(command)) { lines.push(...renderCommand(sub, path).map((line) => ` ${line}`)); } return lines; @@ -66,7 +75,7 @@ export function renderCommandsReference(program: Command): string { sections.push(['## Global options', '', ...globalOptions.map(renderOption).map((l) => l.slice(2))].join('\n')); } - for (const command of program.commands) { + for (const command of visibleSubcommands(program)) { sections.push([`## ${command.name()}`, '', ...renderCommand(command, [])].join('\n')); } diff --git a/src/skill-cmd.ts b/src/skill-cmd.ts index c4b8d3ef..fe6786dd 100644 --- a/src/skill-cmd.ts +++ b/src/skill-cmd.ts @@ -175,7 +175,8 @@ export async function skillList(options: GlobalOptions & { json?: boolean }): Pr } else { for (const entry of catalog) { const note = entry.blockedBy === 'recall' ? ' (needs recall — teamai recall enable)' - : entry.blockedBy === 'read-only' ? ' (not available on a read-only HTTP source)' : ''; + : entry.blockedBy === 'read-only' ? ' (not available on a read-only HTTP source)' + : entry.blockedBy === 'config' ? ' (not available: the teamai config could not be loaded)' : ''; console.log(` ${entry.name}${note}`); console.log(` ${truncate(entry.description, DESCRIPTION_MAX) || '(no description)'}`); console.log(` teamai skill get ${entry.name}`); diff --git a/src/skill-content.ts b/src/skill-content.ts index dbae692b..971a6b99 100644 --- a/src/skill-content.ts +++ b/src/skill-content.ts @@ -63,16 +63,19 @@ const SKILL_ALIASES: Readonly<Record<string, string>> = { const RECALL_DEPENDENT_SKILLS = new Set(['share']); /** Why a served skill is withheld right now. */ -export type SkillBlockReason = 'recall' | 'read-only'; +export type SkillBlockReason = 'recall' | 'read-only' | 'config'; /** * What makes this skill unusable right now, or null. * - * Fails open: a machine with no team config (a fresh install reading the docs) - * gets the content rather than a refusal it cannot act on. + * Fails open only where there is no config at all: a fresh install reading + * the docs gets the content rather than a refusal it cannot act on. A config + * that exists but cannot be loaded blocks: whether recall is on, or the source + * writable, is then unknown, and the workflow would fail at `teamai contribute`. */ async function blockReason(name: string): Promise<SkillBlockReason | null> { if (!RECALL_DEPENDENT_SKILLS.has(name)) return null; + const { NotInitializedError } = await import('./config.js'); try { const [{ autoDetectInit }, { isRecallEnabled }] = await Promise.all([ import('./config.js'), @@ -93,8 +96,8 @@ async function blockReason(name: string): Promise<SkillBlockReason | null> { // workflow would fail at its last step after the agent did all the work. if (localConfig.repo?.kind === 'http') return 'read-only'; return isRecallEnabled(localConfig, teamConfig) ? null : 'recall'; - } catch { - return null; + } catch (e) { + return e instanceof NotInitializedError ? null : 'config'; } } @@ -224,6 +227,11 @@ export function blockMessage(name: string, reason: SkillBlockReason): { headline headline: `${name} is not available: this team uses a read-only HTTP source, so nothing can be contributed from here.`, hint: 'Ask a team admin to add the learning to the team repo.', }; + case 'config': + return { + headline: `${name} is not available: the teamai config on this machine could not be loaded, so whether it can contribute is unknown.`, + hint: 'Run `teamai doctor` to see what is wrong with it, then try again.', + }; default: { const exhaustive: never = reason; throw new Error(`Unhandled block reason ${String(exhaustive)}`); From 518dd78d5f8ec70225be7e1057592d59719e675c Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Wed, 23 Sep 2026 08:03:44 +0200 Subject: [PATCH 36/37] fix(skills): whole-file ownership, stub before its references, no side effects before the link guard Review of 327f9cd: - SKILL.md was compared by its body, so a member who changed only its frontmatter lost the file. Every release from 0.16.1 (the first whose deploy repaired frontmatter) shipped complete frontmatter, so what is on disk is what was shipped: digests are whole files now (42 versions over 100 tags and main). A link is never ours; bytecode is ours only beside a script proven ours by content, decided before anything is removed. - The stub dir's retired references were pruned before SKILL.md was copied; a failed copy left the old skill pointing at files that were gone. The stub is written first. - The Codex destination was resolved with the reconciliation that deletes a duplicate, before the link guard ran. It is resolved side-effect free; the other copy is handled under the guard by retireOtherCodexCopy, whose report now names a failed backup or delete as such. - A broken project config was skipped by detection, so the share gate answered with the user config. findUnreadableProjectConfig reports it via an optional sink on detection (no caller changes), and the gate blocks. The Stop-hook reminder is withheld on an unloadable config too. - init announced the stub as ready when nothing was deployed; hook-dispatch is hidden (hook plumbing), and the reference says it lists public commands; the design doc no longer says teamai-workflow/teamai-import are removed. --- docs/designs/skill-serving.md | 35 ++++--- skill-data/core/references/commands.md | 16 +-- skill-data/setup/SKILL.md | 2 +- src/__tests__/config-not-initialized.test.ts | 27 ++++- src/__tests__/helpers/shipped-skills.ts | 8 +- src/__tests__/hook-handlers.test.ts | 19 +++- src/__tests__/skill-recall-gate.test.ts | 14 +++ src/__tests__/skip-uninstalled-tools.test.ts | 80 ++++++++++++-- src/builtin-skills.ts | 104 ++++++++++--------- src/commands-reference.ts | 7 +- src/config.ts | 36 +++++-- src/hook-handlers.ts | 12 ++- src/index.ts | 2 +- src/init.ts | 16 ++- src/packaged-skill-digests.ts | 21 ++-- src/skill-content.ts | 5 +- 16 files changed, 284 insertions(+), 120 deletions(-) diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index c4dedcdf..7888ca16 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -92,7 +92,9 @@ path (measured here from a 77-character one). config on the machine at all it fails open: a refusal a fresh install cannot act on is worse than serving the workflow. A config that exists but cannot be loaded blocks instead (`blockedBy: "config"`), since recall and the source are then - unknown and the workflow would fail at `teamai contribute`. The Stop-hook share + unknown and the workflow would fail at `teamai contribute` — a project config + too, which detection alone would skip in favour of the user config + (`findUnreadableProjectConfig`). The Stop-hook reminder follows the same rule. The Stop-hook share reminder is gated the same way (`contributeHintAllowed`, `src/hook-handlers.ts`), because it points at this command. The gate lives in one place: `resolveServableSkill` (`src/skill-content.ts`) is the only way to obtain a @@ -141,11 +143,12 @@ both reach the agent as CLI output, which the repo keeps English. `LEGACY_BUILTIN_SKILL_NAMES` (`src/builtin-skills.ts`) names the directories earlier releases deployed: `team-wiki-codebase` and `teamai-share-learnings`. -`teamai-workflow` and `teamai-import` sat in the old guard set but were never -packaged, so they are not in it: a directory by either name is the user's own. -Deployment removes them from every installed, non-excluded agent, in its -configured skills path; Codex's pass also covers the shared `.agents/skills`, -which no other tool's pass touches. +Deployment removes those two, after the stub is in place, from every installed, +non-excluded agent, in its configured skills path; Codex's pass also covers the +shared `.agents/skills`, which no other tool's pass touches. `teamai-workflow` +and `teamai-import` sat in the old guard set but were never packaged, so they +are not in it: a directory by either name is the user's own and is never +touched. Codex's shared root is on the removal side of three commands now, because `resolveSkillDestination` puts the stub there whenever the skill already lives @@ -166,19 +169,25 @@ all of it to go. Leaving copies behind would be the thing they ran it to avoid. **It removes only the files those releases packaged, at the content they packaged.** `PACKAGED_SKILL_DIGESTS` (`src/packaged-skill-digests.ts`) records the -sha256 of every blob `git ls-tree -r <ref> -- skills/` shows over all 99 tags -through v0.25.0 and `main` before the stub, minus `teamai-wiki` (see below): 37 +sha256 of every blob `git ls-tree -r <ref> -- skills/` shows over all 100 tags +through v0.25.0 and `main` before the stub, minus `teamai-wiki` (see below): 42 versions across 21 paths. A file is ours only at one of those paths *and* with one -of those digests; a skill-root `SKILL.md` is compared by its body without the -frontmatter block, because releases before 0.17 shipped none and the deploy of -the day repaired it on disk. The copies came from the npm tarball byte for byte, -so an unedited one matches. Anything else at a packaged path — an edit, a +of those digests, whole file, frontmatter included: a member who changed only a +skill's description changed the skill. The deploy repaired frontmatter from +0.16.1 on, but every `SKILL.md` those releases shipped was already complete, and +the copies came from the npm tarball byte for byte, so an unedited one matches. +No release shipped a symlink, so a link is never ours, and bytecode is ours only +beside a script proven ours by content. Anything else at a packaged path — an edit, a member's own skill that uses a legacy name, a root TeamAI never managed because `toolPaths` or `HERMES_HOME` moved — is the member's and stays, with its directory. Checking the path alone would have deleted those. What is removed is still copied first to `~/.teamai/removed-skills/<run>/<base>/<tool>/<skill-root>/<skill>/`, so no -removal is a one-way door. The legacy trees go only after the stub deployed for that agent: pruning first and then failing to write the stub (a link, a read-only directory) would leave nothing to discover. Codex reads both `.codex/skills` and the shared `.agents/skills`, and the +removal is a one-way door. Order matters as much as ownership: the stub is +written first, then the references it no longer points at are pruned, and the +legacy trees go only once the stub deployed for that agent, so a stub that cannot +be written leaves a working old skill rather than a broken one. The destination +is resolved without side effects before the link guard runs. Codex reads both `.codex/skills` and the shared `.agents/skills`, and the stub goes to the shared one when a copy already lives there; the copy an earlier release left in the other root is retired by the same rule (`retireOtherCodexCopy`), so Codex never sees a stale `teamai` beside the current diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index dd26b131..8c0493af 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -1,8 +1,9 @@ # teamai command reference -Every command the installed CLI accepts, rendered from its own command table. -Flags marked `(hidden)` work but are absent from `--help`, so treat this file — -not `--help` — as the complete list. +Every public command the installed CLI accepts, rendered from its own command +table. Hidden commands are left out: they are hook plumbing the CLI runs itself, +never something to type. Flags marked `(hidden)` work but are absent from +`--help`, so treat this file — not `--help` — as the complete list of flags. Generated: do not edit by hand. Regenerate with `npx vitest run commands-reference -u` after changing a command or a flag. @@ -231,15 +232,6 @@ Generated: do not edit by hand. Regenerate with - `teamai dashboard` — Start the AI coding session dashboard (Web UI) - `-p, --port <port>` — Port number -## hook-dispatch - -- `teamai hook-dispatch <event>` — Unified hook dispatcher — handles all teamai hooks for a given event in one process - - `--stdin` — Read hook data from STDIN (accepted for forward compat, always reads STDIN) - - `--tool <name>` — Tool identifier (e.g. codebuddy, workbuddy, claude) - - `--matcher <matcher>` — Hook matcher for PostToolUse (e.g. Skill, Bash) - - `--bg-only` — Internal: run only fire-and-forget background handlers (used by the detached child) - - `--stdin-file <path>` — Internal: read the hook payload from this file instead of STDIN - ## bind-project - `teamai bind-project` — Bind the current workspace to a ClawPro project for HTTP local-agent sync diff --git a/skill-data/setup/SKILL.md b/skill-data/setup/SKILL.md index a53633ec..68313331 100644 --- a/skill-data/setup/SKILL.md +++ b/skill-data/setup/SKILL.md @@ -56,7 +56,7 @@ and create-repo URLs, and the per-provider caveats, and points at 4. **Finish with `teamai doctor`.** Every setup or onboarding flow ends by running it and resolving what it reports before you call the job done. -Every command and flag, including the ones `--help` hides, is listed in +Every public command and every flag, including the flags `--help` hides, is listed in `teamai skill get core --full` under `references/commands.md`. Do not guess a flag: there is no member-invite flag, for instance — inviting happens on the Git platform's website, as `manage-admin.md` describes. diff --git a/src/__tests__/config-not-initialized.test.ts b/src/__tests__/config-not-initialized.test.ts index 53037333..cf39d188 100644 --- a/src/__tests__/config-not-initialized.test.ts +++ b/src/__tests__/config-not-initialized.test.ts @@ -8,7 +8,7 @@ vi.mock('../utils/logger.js', () => ({ setStderrOnly: vi.fn(() => false), })); -import { NotInitializedError, requireInit } from '../config.js'; +import { NotInitializedError, findUnreadableProjectConfig, requireInit } from '../config.js'; /** * `loadLocalConfig` returns null both for a missing file and for one it could @@ -52,3 +52,28 @@ describe('requireInit: missing config versus unreadable config', () => { await expect(requireInit()).rejects.not.toBeInstanceOf(NotInitializedError); }); }); + +describe('findUnreadableProjectConfig', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-project-config-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('names a project config that exists but does not parse, which detection alone skips', async () => { + const configPath = path.join(dir, '.teamai', 'config.yaml'); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, 'repo: [unclosed\n'); + + expect(await findUnreadableProjectConfig(dir)).toContain(configPath); + }); + + it('is null when there is no project config at all', async () => { + expect(await findUnreadableProjectConfig(dir)).toBeNull(); + }); +}); + diff --git a/src/__tests__/helpers/shipped-skills.ts b/src/__tests__/helpers/shipped-skills.ts index 8872ee35..fa73c704 100644 --- a/src/__tests__/helpers/shipped-skills.ts +++ b/src/__tests__/helpers/shipped-skills.ts @@ -7,8 +7,8 @@ import { createHash } from 'node:crypto'; * a file holding `shipped(skill, path)` is the CLI's, anything else at the same * path is the member's. */ -export function shipped(skill: string, relative: string): string { - return `# shipped ${skill}/${relative}\n`; +export function shipped(skill: string, relative: string, release: 1 | 2 = 1): string { + return `# shipped ${skill}/${relative} (release ${release})\n`; } const PATHS: Readonly<Record<string, readonly string[]>> = { @@ -39,13 +39,13 @@ const PATHS: Readonly<Record<string, readonly string[]>> = { ], }; -/** The module shape of `packaged-skill-digests.ts`, with `shipped()` as the only shipped version. */ +/** The module shape of `packaged-skill-digests.ts`: two shipped releases of every path. */ export function shippedSkillDigestsMock(): { PACKAGED_SKILL_DIGESTS: ReadonlyMap<string, ReadonlyMap<string, readonly string[]>> } { const sha = (text: string): string => createHash('sha256').update(text).digest('hex'); return { PACKAGED_SKILL_DIGESTS: new Map(Object.entries(PATHS).map(([skill, paths]) => [ skill, - new Map(paths.map((relative) => [relative, [sha(shipped(skill, relative))]])), + new Map(paths.map((relative) => [relative, [sha(shipped(skill, relative, 1)), sha(shipped(skill, relative, 2))]])), ])), }; } diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index d4d4fb68..a0f12ab8 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -411,18 +411,33 @@ describe('hook-handlers registry', () => { expect(mockContributeCheckForSession).not.toHaveBeenCalled(); }); - it('contribute-check handler keeps hinting when config cannot be loaded', async () => { + it('contribute-check handler keeps hinting when there is no config at all', async () => { + const { NotInitializedError } = await import('../config.js'); const registry = buildHandlerRegistry(); const handler = registry.find( (r) => r.event === 'stop' && r.handler.name === 'contribute-check', )!.handler; - mockAutoDetectInit.mockRejectedValueOnce(new Error('not initialized')); + mockAutoDetectInit.mockRejectedValueOnce(new NotInitializedError('teamai is not initialized. Run `teamai init` first.')); mockContributeCheckForSession.mockResolvedValueOnce({ hint: '[teamai] do share' }); const result = await handler.execute({ session_id: 's5', cwd: '/x' }, 'claude'); expect(result).toContain('do share'); }); + it('contribute-check handler stays silent when a config exists but cannot be loaded', async () => { + const registry = buildHandlerRegistry(); + const handler = registry.find( + (r) => r.event === 'stop' && r.handler.name === 'contribute-check', + )!.handler; + // `teamai skill get share` refuses on such a config, so the nudge would lead nowhere. + mockAutoDetectInit.mockRejectedValueOnce(new Error('Team config (teamai.yaml) not found. Check your repo path.')); + mockContributeCheckForSession.mockClear(); + + const result = await handler.execute({ session_id: 's5b', cwd: '/x' }, 'claude'); + expect(result).toBeNull(); + expect(mockContributeCheckForSession).not.toHaveBeenCalled(); + }); + it('contribute-check handler obeys TEAMAI_CONTRIBUTE_HINT_DISABLED=1', async () => { const registry = buildHandlerRegistry(); const handler = registry.find( diff --git a/src/__tests__/skill-recall-gate.test.ts b/src/__tests__/skill-recall-gate.test.ts index 29864bd6..3fa9223c 100644 --- a/src/__tests__/skill-recall-gate.test.ts +++ b/src/__tests__/skill-recall-gate.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; const autoDetectInit = vi.fn(); +const findUnreadableProjectConfig = vi.fn(); vi.mock('../config.js', async (importOriginal) => ({ ...(await importOriginal<typeof import('../config.js')>()), autoDetectInit, + findUnreadableProjectConfig, })); import { resolveServableSkill, skillCatalog, skillGet, skillPath } from '../skill-content.js'; @@ -23,6 +25,8 @@ describe('recall gate on served skills', () => { stdout = ''; process.exitCode = undefined; autoDetectInit.mockReset(); + findUnreadableProjectConfig.mockReset(); + findUnreadableProjectConfig.mockResolvedValue(null); const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { stdout += String(chunk); @@ -173,4 +177,14 @@ describe('recall gate on served skills', () => { // Only share depends on the config; the rest is still served. expect((await resolveServableSkill('core')).kind).toBe('found'); }); + + it('blocks share when the project config is unreadable, instead of answering with the user config', async () => { + // Detection skips the broken project config; the user config it falls back + // to belongs to another team, with its own recall and source. + findUnreadableProjectConfig.mockResolvedValue('/work/proj/.teamai/config.yaml: bad indentation'); + withRecall(true); + + expect(await resolveServableSkill('share')).toEqual({ kind: 'blocked', name: 'share', reason: 'config' }); + expect(autoDetectInit).not.toHaveBeenCalled(); + }); }); diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index c089ff91..4cf5a3b6 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -58,8 +58,8 @@ async function onlyRunDir(homeDir: string): Promise<string> { vi.mock('../packaged-skill-digests.js', () => shippedSkillDigestsMock()); const WIKI_SKILL = shipped('team-wiki-codebase', 'SKILL.md'); -/** Shipped body under a distinguishing frontmatter block: still ours, told apart. */ -const wikiSkillTagged = (tag: string): string => `---\nname: ${tag}\n---\n${WIKI_SKILL}`; +/** Two releases' SKILL.md: both ours, and told apart. */ +const WIKI_SKILL_OTHER_RELEASE = shipped('team-wiki-codebase', 'SKILL.md', 2); vi.mock('../config.js', async (importOriginal) => ({ ...(await importOriginal<typeof import('../config.js')>()), @@ -949,6 +949,7 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { await fse.ensureDir(path.join(wiki, 'scripts/__pycache__')); await fse.ensureDir(path.join(wiki, 'notes/__pycache__')); await fse.writeFile(path.join(wiki, 'SKILL.md'), WIKI_SKILL); + await fse.writeFile(path.join(wiki, 'scripts/scan_repo.py'), shipped('team-wiki-codebase', 'scripts/scan_repo.py')); await fse.writeFile(path.join(wiki, 'scripts/__pycache__/scan_repo.cpython-311.pyc'), 'bytecode'); await fse.writeFile(path.join(wiki, 'notes/__pycache__/keep.txt'), '# mine'); @@ -964,7 +965,7 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { // `inheritUserScope`: user base, then project base, same tool, root and name. const projectRoot = path.join(tmpDir, 'work/proj'); const projectConfig = { ...legacyPruneLocalConfig(tmpDir), scope: 'project' as const, projectRoot }; - for (const [base, body] of [[homeDir, wikiSkillTagged('user')], [projectRoot, wikiSkillTagged('project')]]) { + for (const [base, body] of [[homeDir, WIKI_SKILL], [projectRoot, WIKI_SKILL_OTHER_RELEASE]]) { await fse.ensureDir(path.join(base, '.claude/skills/team-wiki-codebase')); await fse.writeFile(path.join(base, '.claude/skills/team-wiki-codebase/SKILL.md'), body); } @@ -976,7 +977,7 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { const archived = (await listFilesRecursive(path.join(homeDir, '.teamai/removed-skills'))) .filter((f) => f.endsWith('team-wiki-codebase/SKILL.md')); const bodies = await Promise.all(archived.map((f) => fse.readFile(path.join(homeDir, '.teamai/removed-skills', f), 'utf8'))); - expect(bodies.sort()).toEqual([wikiSkillTagged('project'), wikiSkillTagged('user')]); + expect(bodies.sort()).toEqual([WIKI_SKILL, WIKI_SKILL_OTHER_RELEASE].sort()); }); it('keeps the legacy skills when the stub could not be deployed, so the agent keeps one to discover', async () => { @@ -1023,6 +1024,71 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { expect(warnings.filter((w) => w.includes(path.join(skills, 'teamai')))).toEqual([]); }); + it('keeps a SKILL.md whose member changed only the frontmatter', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + // Same body a release shipped, a description of the member's: the skill is + // theirs now, so the whole file is what ownership is proven on. + const wiki = path.join(homeDir, '.claude/skills/team-wiki-codebase'); + const edited = `---\nname: team-wiki-codebase\ndescription: my wording\n---\n${WIKI_SKILL}`; + await fse.ensureDir(wiki); + await fse.writeFile(path.join(wiki, 'SKILL.md'), edited); + + await deployBuiltinSkills(legacyPruneTeamConfig(), legacyPruneLocalConfig(tmpDir)); + + expect(await fse.readFile(path.join(wiki, 'SKILL.md'), 'utf8')).toBe(edited); + }); + + it('keeps bytecode beside a script the member edited', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + const wiki = path.join(homeDir, '.claude/skills/team-wiki-codebase'); + await fse.ensureDir(path.join(wiki, 'scripts/__pycache__')); + await fse.writeFile(path.join(wiki, 'scripts/scan_repo.py'), 'print("my version")\n'); + await fse.writeFile(path.join(wiki, 'scripts/__pycache__/scan_repo.cpython-311.pyc'), 'bytecode of my version'); + + await deployBuiltinSkills(legacyPruneTeamConfig(), legacyPruneLocalConfig(tmpDir)); + + expect(await fse.pathExists(path.join(wiki, 'scripts/__pycache__/scan_repo.cpython-311.pyc'))).toBe(true); + }); + + it('keeps the old references when the stub cannot be written over the old SKILL.md', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + // Something the copy cannot replace sits where SKILL.md goes, so the stub + // is not written. Pruning the references first would leave the old skill + // pointing at files that are gone. + const stubDir = path.join(homeDir, '.claude/skills/teamai'); + await fse.ensureDir(path.join(stubDir, 'references')); + await fse.ensureDir(path.join(stubDir, 'SKILL.md')); + await fse.writeFile(path.join(stubDir, 'SKILL.md', 'keep'), '# blocks the copy'); + await fse.writeFile(path.join(stubDir, 'references/setup-admin.md'), shipped('teamai', 'references/setup-admin.md')); + + await deployBuiltinSkills(legacyPruneTeamConfig(), legacyPruneLocalConfig(tmpDir)); + + expect(await fse.readFile(path.join(stubDir, 'references/setup-admin.md'), 'utf8')).toBe(shipped('teamai', 'references/setup-admin.md')); + }); + + it('deletes nothing through a linked Codex skills root while resolving where the stub goes', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + // `.codex/skills` linked at a dotfiles checkout, holding a copy identical to + // the shared one and to the package: the resolver's reconciliation would + // delete it through the link before the guard ever ran. + const stub = await fse.readFile(path.join(PACKAGE_ROOT, 'skills/teamai/SKILL.md'), 'utf8'); + const dotfiles = path.join(tmpDir, 'dotfiles/codex-skills'); + await fse.ensureDir(path.join(dotfiles, 'teamai')); + await fse.writeFile(path.join(dotfiles, 'teamai/SKILL.md'), stub); + await fse.ensureDir(path.join(homeDir, '.codex')); + await fse.symlink(dotfiles, path.join(homeDir, '.codex/skills'), 'dir'); + await fse.ensureDir(path.join(homeDir, '.agents/skills/teamai')); + await fse.writeFile(path.join(homeDir, '.agents/skills/teamai/SKILL.md'), stub); + + await deployBuiltinSkills(legacyPruneTeamConfig({ codex: { skills: '.codex/skills' } }), legacyPruneLocalConfig(tmpDir)); + + expect(await fse.readFile(path.join(dotfiles, 'teamai/SKILL.md'), 'utf8')).toBe(stub); + }); + it('archives nothing when there is nothing retired to archive', async () => { const { deployBuiltinSkills } = await import('../builtin-skills.js'); @@ -1042,7 +1108,7 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { const localConfig = legacyPruneLocalConfig(tmpDir); // Codex prunes its own root and the shared one; same skill name, different files. - for (const [root, body] of [['.codex/skills', wikiSkillTagged('codex')], ['.agents/skills', wikiSkillTagged('shared')]]) { + for (const [root, body] of [['.codex/skills', WIKI_SKILL], ['.agents/skills', WIKI_SKILL_OTHER_RELEASE]]) { await fse.ensureDir(path.join(homeDir, root, 'team-wiki-codebase')); await fse.writeFile(path.join(homeDir, root, 'team-wiki-codebase/SKILL.md'), body); } @@ -1050,8 +1116,8 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { await deployBuiltinSkills(teamConfig, localConfig); const run = await onlyRunDir(homeDir); - expect(await fse.readFile(path.join(run, 'codex/.codex-skills/team-wiki-codebase/SKILL.md'), 'utf8')).toBe(wikiSkillTagged('codex')); - expect(await fse.readFile(path.join(run, 'codex/.agents-skills/team-wiki-codebase/SKILL.md'), 'utf8')).toBe(wikiSkillTagged('shared')); + expect(await fse.readFile(path.join(run, 'codex/.codex-skills/team-wiki-codebase/SKILL.md'), 'utf8')).toBe(WIKI_SKILL); + expect(await fse.readFile(path.join(run, 'codex/.agents-skills/team-wiki-codebase/SKILL.md'), 'utf8')).toBe(WIKI_SKILL_OTHER_RELEASE); }); it('removes the references an earlier release deployed beside the stub', async () => { diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index 6b2ef26d..99dcdbec 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -84,18 +84,13 @@ export const PACKAGED_SKILL_FILES: ReadonlyMap<string, readonly string[]> = new [...PACKAGED_SKILL_DIGESTS].map(([skill, files]) => [skill, [...files.keys()]]), ); -const FRONTMATTER_BLOCK = /^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/; - /** - * The digest PACKAGED_SKILL_DIGESTS records for a file: sha256 of its bytes, or, - * for a skill-root `SKILL.md`, of its body without the frontmatter block, which - * the deploy of earlier releases rewrote on disk when a field was missing. + * The digest PACKAGED_SKILL_DIGESTS records for a file: sha256 of its bytes. + * The whole file, frontmatter included: a member who changed only a skill's + * name, description or allowed-tools changed the skill, and it is theirs. */ -export function packagedSkillDigest(relativePath: string, content: Buffer): string { - const hashed = relativePath === 'SKILL.md' - ? Buffer.from(content.toString('utf8').replace(FRONTMATTER_BLOCK, '').replace(/^[\r\n]+/, ''), 'utf8') - : content; - return createHash('sha256').update(hashed).digest('hex'); +export function packagedSkillDigest(content: Buffer): string { + return createHash('sha256').update(content).digest('hex'); } /** Digests by path: what `removeOwnedFiles` may remove, and only at that content. */ @@ -115,7 +110,7 @@ export async function ownedSkillFiles(skillName: string, paths?: readonly string const stubPath = path.join(packagedSkillRoots().deployRoot, skillName, 'SKILL.md'); if (await pathExists(stubPath)) { const digests = owned.get('SKILL.md') ?? new Set<string>(); - digests.add(packagedSkillDigest('SKILL.md', await fs.promises.readFile(stubPath))); + digests.add(packagedSkillDigest(await fs.promises.readFile(stubPath))); owned.set('SKILL.md', digests); } } @@ -126,20 +121,21 @@ export async function ownedSkillFiles(skillName: string, paths?: readonly string async function isOwnedFile(file: string, relative: string, owned: OwnedSkillFiles): Promise<boolean> { const digests = owned.get(relative); if (!digests) return false; - return digests.has(packagedSkillDigest(relative, await fs.promises.readFile(file))); + return digests.has(packagedSkillDigest(await fs.promises.readFile(file))); } /** * Python bytecode cache of a script we shipped: `a/__pycache__/x.cpython-311.pyc` - * for an owned `a/x.py`. Compiler output of our own file, so it carries nothing - * a member wrote and does not make a directory theirs. Anything else under a - * `__pycache__` is not ours to remove. + * for an `a/x.py` present and proven ours by content. Compiler output of our + * own file, so it carries nothing a member wrote and does not make a directory + * theirs. Bytecode beside a member's edit of the script, or with no script at + * all, is theirs. */ -function isDerivedArtifact(relativePath: string, owned: ReadonlySet<string>): boolean { +function isDerivedArtifact(relativePath: string, provenScripts: ReadonlySet<string>): boolean { const parts = relativePath.split('/'); if (parts.length < 2 || parts[parts.length - 2] !== '__pycache__' || !relativePath.endsWith('.pyc')) return false; const stem = parts[parts.length - 1].split('.')[0]; - return owned.has([...parts.slice(0, -2), `${stem}.py`].join('/')); + return provenScripts.has([...parts.slice(0, -2), `${stem}.py`].join('/')); } /** @@ -229,7 +225,6 @@ export async function removeOwnedFiles( baseDir: string, backupDir?: string, ): Promise<PruneResult> { - const ownedPaths = new Set(owned.keys()); const result: PruneResult = { skippedSymlink: false, foreign: 0, unbackedUp: [], notRemoved: [], backedUp: 0, }; @@ -253,20 +248,29 @@ export async function removeOwnedFiles( return result; } + // Decide ownership before removing anything: bytecode is ours only beside a + // script proven ours, and that proof has to be taken while the script is + // still there. + const proven = new Set<string>(); for (const relative of entries) { const file = path.join(dir, relative); - // Ours only at a path a release shipped *and* with content one of them - // shipped there. A member's edit, or a skill of their own that uses a - // packaged name under a root TeamAI never managed, fails the second test - // and stays, with its directory. - let owns: boolean; try { - owns = await isOwnedFile(file, relative, owned) || isDerivedArtifact(relative, ownedPaths); + // Ours only at a path a release shipped *and* with content one of them + // shipped there. A member's edit, or a skill of their own that uses a + // packaged name under a root TeamAI never managed, fails the second test + // and stays, with its directory. No release shipped a symlink. + if (!(await fs.promises.lstat(file)).isSymbolicLink() && await isOwnedFile(file, relative, owned)) proven.add(relative); } catch (e) { // Unreadable, so unprovable: kept, and said so. result.notRemoved.push({ file, error: (e as Error).message }); - continue; } + } + + for (const relative of entries) { + const file = path.join(dir, relative); + if (result.notRemoved.some((failure) => failure.file === file)) continue; + const owns = proven.has(relative) + || (isDerivedArtifact(relative, proven) && !(await fs.promises.lstat(file)).isSymbolicLink()); if (!owns) { result.foreign++; continue; @@ -470,6 +474,10 @@ async function retireOtherCodexCopy( log.debug(`Removed the second Codex copy of ${skillName} at ${other}; the stub is at ${deployedDir}`); } else if (result.skippedSymlink) { log.warn(`Kept ${other}: it is reached through a symlink, so TeamAI left it alone. Codex also reads the stub at ${deployedDir}.`); + } else if (result.unbackedUp.length > 0) { + log.warn(`Kept ${result.unbackedUp.length} file(s) in ${other}: their backup could not be written, so they were not removed. First: ${result.unbackedUp[0].file} — ${result.unbackedUp[0].error}`); + } else if (result.notRemoved.length > 0) { + log.warn(`Could not finish removing ${other}: ${result.notRemoved.length} file(s) or directories stayed. First: ${result.notRemoved[0].file} — ${result.notRemoved[0].error}`); } else { log.warn(`Kept ${other}: it holds files TeamAI did not write, so Codex sees it beside the stub at ${deployedDir}. Remove it once you have saved what you need.`); } @@ -544,9 +552,13 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? let deployedHere = 0; for (const skillName of skillNames) { const srcDir = path.join(builtinDir, skillName); + // Resolved without a source path, so the resolver only answers where the + // skill lives and touches nothing: its Codex reconciliation deletes a + // duplicate, and nothing may be deleted before the link guard has run. + // The other Codex copy is dealt with below, under the guard. const destDir = localConfig - ? await skillTargetForTool(tool, toolPath.skills, localConfig, skillName, srcDir) ?? path.join(target.skillsDir, skillName) - : await resolveSkillDestination(tool, toolPath.skills, baseDir, skillName, srcDir); + ? await skillTargetForTool(tool, toolPath.skills, localConfig, skillName) ?? path.join(target.skillsDir, skillName) + : await resolveSkillDestination(tool, toolPath.skills, baseDir, skillName); try { // A symlinked destination points somewhere we do not own. Writing @@ -556,28 +568,26 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? log.warn(`Skipped ${skillName} (${tool}): ${destDir} is reached through a symlink, and TeamAI does not write through one. Remove the link to let the skill deploy.`); continue; } - // Releases before the discovery stub deployed this same directory with a - // references/ tree beside SKILL.md. Copying one file over it would leave - // ~39 KB of pre-stub instructions in place forever, so the files those - // releases wrote go first — and only those: a file a member added here - // is theirs, and the old deployment never deleted it either. - if (await pathExists(destDir)) { - // Only the paths this release no longer ships. `SKILL.md` is written - // one line below, so pruning it would archive an identical copy on - // every session start and never reach a file worth keeping. - const shippedNow = new Set(await walkFiles(srcDir)); - const retired = (PACKAGED_SKILL_FILES.get(skillName) ?? []).filter((p) => !shippedNow.has(p)); - const backupDir = skillBackupDir(baseDir, tool, path.relative(baseDir, path.dirname(destDir)), skillName); - const result = await removeOwnedFiles(destDir, await ownedSkillFiles(skillName, retired), baseDir, backupDir); - if (result.unbackedUp.length > 0) { - log.warn(`Kept ${result.unbackedUp.length} file(s) under ${destDir}: their backup could not be written, so they were not removed. First: ${result.unbackedUp[0].file} — ${result.unbackedUp[0].error}`); - } - if (result.notRemoved.length > 0) { - log.warn(`Archived but could not delete ${result.notRemoved.length} file(s) under ${destDir}. First: ${result.notRemoved[0].file} — ${result.notRemoved[0].error}`); - } - } + // The stub first: if it cannot be written, the pre-stub SKILL.md and the + // references it points at stay together, a working old skill rather + // than an old skill whose references are gone. await fse.ensureDir(destDir); await fse.copy(path.join(srcDir, 'SKILL.md'), path.join(destDir, 'SKILL.md'), { overwrite: true }); + // Releases before the discovery stub deployed this same directory with a + // references/ tree beside SKILL.md, ~39 KB of pre-stub instructions the + // new SKILL.md no longer points at. The files those releases wrote go — + // only the paths this release no longer ships, and only at content a + // release shipped: a file a member added or edited here is theirs. + const shippedNow = new Set(await walkFiles(srcDir)); + const retired = (PACKAGED_SKILL_FILES.get(skillName) ?? []).filter((p) => !shippedNow.has(p)); + const backupDir = skillBackupDir(baseDir, tool, path.relative(baseDir, path.dirname(destDir)), skillName); + const result = await removeOwnedFiles(destDir, await ownedSkillFiles(skillName, retired), baseDir, backupDir); + if (result.unbackedUp.length > 0) { + log.warn(`Kept ${result.unbackedUp.length} file(s) under ${destDir}: their backup could not be written, so they were not removed. First: ${result.unbackedUp[0].file} — ${result.unbackedUp[0].error}`); + } + if (result.notRemoved.length > 0) { + log.warn(`Archived but could not delete ${result.notRemoved.length} file(s) under ${destDir}. First: ${result.notRemoved[0].file} — ${result.notRemoved[0].error}`); + } if (tool === CODEX_TOOL) await retireOtherCodexCopy(tool, skillName, destDir, target); deployed++; diff --git a/src/commands-reference.ts b/src/commands-reference.ts index fc4c451e..9d2510ab 100644 --- a/src/commands-reference.ts +++ b/src/commands-reference.ts @@ -18,9 +18,10 @@ export const COMMANDS_REFERENCE_PATH = 'skill-data/core/references/commands.md'; const HEADER = `# teamai command reference -Every command the installed CLI accepts, rendered from its own command table. -Flags marked \`(hidden)\` work but are absent from \`--help\`, so treat this file — -not \`--help\` — as the complete list. +Every public command the installed CLI accepts, rendered from its own command +table. Hidden commands are left out: they are hook plumbing the CLI runs itself, +never something to type. Flags marked \`(hidden)\` work but are absent from +\`--help\`, so treat this file — not \`--help\` — as the complete list of flags. Generated: do not edit by hand. Regenerate with \`npx vitest run commands-reference -u\` after changing a command or a flag. diff --git a/src/config.ts b/src/config.ts index faa9b82b..bcf8fdcd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -292,7 +292,14 @@ export async function resolveDataHomeForScope(scope: Scope, projectRoot?: string return path.join(projectRoot, '.teamai'); } -export async function detectProjectConfig(cwd?: string): Promise<LocalConfig | null> { +/** + * Told about a project-scope config file that exists but cannot be used, which + * detection otherwise skips: `null` means "no project config here" to every + * caller that does not ask. + */ +export type UnreadableConfigSink = (configPath: string, error: string) => void; + +export async function detectProjectConfig(cwd?: string, onUnreadable?: UnreadableConfigSink): Promise<LocalConfig | null> { const dir = cwd ?? process.cwd(); // Resolve git anchors FIRST so the result never depends on which directory of @@ -313,23 +320,23 @@ export async function detectProjectConfig(cwd?: string): Promise<LocalConfig | n // `<basename>-<hash>` name; adoption renames it into the current name so // detection — and every command after it — keeps finding the config. const partitionDir = await resolvePartitionDir(anchors.projectAnchor); - const fromPartition = await readConfigFrom(partitionDir, anchors.workspaceRoot); + const fromPartition = await readConfigFrom(partitionDir, anchors.workspaceRoot, undefined, onUnreadable); if (fromPartition) return fromPartition; // 2. No partition config yet. A workspace that declares `mode: self` self-heals // on a fresh clone (issue #198): bootstrapSelfRepo now writes the machine // config into the PARTITION (P2), not <workspaceRoot>/.teamai. So run the // self-heal and, on success, read the config back FROM THE PARTITION. - const healed = await selfHealAndReadPartition(anchors.workspaceRoot, partitionDir); + const healed = await selfHealAndReadPartition(anchors.workspaceRoot, partitionDir, onUnreadable); if (healed) return healed; // 3. Otherwise read a legacy `<workspaceRoot>/.teamai` config directly — a // pre-P2 self install (or any un-migrated install) whose config still lives // in the repo. Double-read compat until migration relocates it. - return readConfigFrom(legacyDir, anchors.workspaceRoot); + return readConfigFrom(legacyDir, anchors.workspaceRoot, undefined, onUnreadable); } // Not a git repo: fall back to a legacy `.teamai` directly at `dir` (also runs // the self-heal bootstrap for a freshly-cloned single-repo project). - return readConfigFrom(path.join(dir, '.teamai'), dir, dir); + return readConfigFrom(path.join(dir, '.teamai'), dir, dir, onUnreadable); } /** @@ -357,6 +364,7 @@ export async function detectProjectConfig(cwd?: string): Promise<LocalConfig | n async function selfHealAndReadPartition( workspaceRoot: string, partitionDir: string, + onUnreadable?: UnreadableConfigSink, ): Promise<LocalConfig | null> { try { const { bootstrapSelfRepo } = await import('./bootstrap.js'); @@ -365,13 +373,14 @@ async function selfHealAndReadPartition( } catch { return null; } - return readConfigFrom(partitionDir, workspaceRoot); + return readConfigFrom(partitionDir, workspaceRoot, undefined, onUnreadable); } export async function readConfigFrom( dataHomeDir: string, projectRoot: string, selfHealRepoRoot?: string, + onUnreadable?: UnreadableConfigSink, ): Promise<LocalConfig | null> { const configPath = path.join(dataHomeDir, 'config.yaml'); if (!(await pathExists(configPath))) { @@ -414,11 +423,24 @@ export async function readConfigFrom( }; } return resolved; - } catch { + } catch (e) { + onUnreadable?.(configPath, (e as Error).message); return null; } } +/** + * The project-scope config under `cwd` that exists but cannot be parsed or + * validated, with the reason, or null. Detection skips such a file and falls + * back to the user config, which for a command that must know which team it + * serves means answering for the wrong one. + */ +export async function findUnreadableProjectConfig(cwd?: string): Promise<string | null> { + let problem: string | null = null; + const found = await detectProjectConfig(cwd, (configPath, error) => { problem ??= `${configPath}: ${error}`; }); + return found ? null : problem; +} + /** * Require init for a specific scope. * For 'user' scope, behaves like original requireInit. diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 16452eab..f28ee95c 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -233,8 +233,10 @@ const trackSlashHandler: HookHandler = { /** * Whether the share-learnings hint may be emitted at all. Resolved lazily per * hook run so a team can switch it off via teamai.yaml (or a member via local - * config) without re-injecting hooks. Falls back to enabled when config can't - * be read, preserving pre-toggle behavior for half-initialized installs. + * config) without re-injecting hooks. Falls back to enabled when there is no + * config at all, preserving pre-toggle behavior for half-initialized installs, + * where `teamai skill get share` serves too. A config that exists but cannot be + * loaded withholds it: `share` refuses there, so the nudge would lead nowhere. * * Recall and a writable source gate it too: the hint routes to the `share` * workflow, and `teamai skill get share` refuses while recall is off or the @@ -244,14 +246,14 @@ const trackSlashHandler: HookHandler = { */ async function contributeHintAllowed(): Promise<boolean> { const { isContributeHintEnabled, isRecallEnabled } = await import('./types.js'); + const { autoDetectInit, NotInitializedError } = await import('./config.js'); try { - const { autoDetectInit } = await import('./config.js'); const { localConfig, teamConfig } = await autoDetectInit(); return localConfig.repo?.kind !== 'http' && isContributeHintEnabled(localConfig, teamConfig) && isRecallEnabled(localConfig, teamConfig); - } catch { - return isContributeHintEnabled({}, {}); + } catch (e) { + return e instanceof NotInitializedError ? isContributeHintEnabled({}, {}) : false; } } diff --git a/src/index.ts b/src/index.ts index ecc72097..d6bc5076 100644 --- a/src/index.ts +++ b/src/index.ts @@ -807,7 +807,7 @@ program }); program - .command('hook-dispatch <event>') + .command('hook-dispatch <event>', { hidden: true }) .description('Unified hook dispatcher — handles all teamai hooks for a given event in one process') .option('--stdin', 'Read hook data from STDIN (accepted for forward compat, always reads STDIN)') .option('--tool <name>', 'Tool identifier (e.g. codebuddy, workbuddy, claude)') diff --git a/src/init.ts b/src/init.ts index 86e49446..e2e0efe1 100644 --- a/src/init.ts +++ b/src/init.ts @@ -1613,6 +1613,8 @@ export async function init(options: GlobalOptions & { // Step 7: Inject built-in + team hooks into AI tools const reloadedTeamConfig = await loadTeamConfig(localPath); + // Only a stub that actually landed is announced as ready in the IDE. + let stubDeployed = 0; if (reloadedTeamConfig) { const filterAgents = requestedAgents.length > 0 ? requestedAgents : undefined; await reconcileTeamHooksForConfig(reloadedTeamConfig, localConfig, { filterAgents }); @@ -1622,17 +1624,21 @@ export async function init(options: GlobalOptions & { // first pull. Its workflows are served by `teamai skill get`. try { const { deployBuiltinSkills } = await import('./builtin-skills.js'); - const deployed = await deployBuiltinSkills(reloadedTeamConfig, localConfig); - if (deployed > 0) { - log.debug(`Deployed ${deployed} built-in skill(s)`); + stubDeployed = await deployBuiltinSkills(reloadedTeamConfig, localConfig); + if (stubDeployed > 0) { + log.debug(`Deployed ${stubDeployed} built-in skill(s)`); } } catch (e) { - log.debug(`Built-in skills deployment skipped: ${(e as Error).message}`); + log.warn(`The built-in teamai skill was not deployed: ${(e as Error).message}`); } } log.success('teamai initialized successfully!'); - log.info('The built-in teamai skill is ready in your IDE; it loads its workflows with `teamai skill get`.'); + if (stubDeployed > 0) { + log.info('The built-in teamai skill is ready in your IDE; it loads its workflows with `teamai skill get`.'); + } else { + log.warn('The built-in teamai skill was not deployed to any AI tool (see the lines above); run `teamai pull` once the cause is fixed, or `teamai doctor` to see it.'); + } log.info('Skills, rules, env and docs auto-sync on each session start when the selected agent has active TeamAI hooks.'); log.info('Run `teamai status` to check current config.'); diff --git a/src/packaged-skill-digests.ts b/src/packaged-skill-digests.ts index b3f06e6b..42598815 100644 --- a/src/packaged-skill-digests.ts +++ b/src/packaged-skill-digests.ts @@ -1,32 +1,31 @@ /** * sha256 of every file a release shipped under the CLI-owned skill trees, by - * skill and path relative to the skill directory. A skill-root `SKILL.md` is - * hashed without its frontmatter block (`packagedSkillDigest`): releases before - * 0.17 shipped it with none, and the deploy of the day repaired frontmatter on - * disk, so only the body is what the CLI provably wrote. + * skill and path relative to the skill directory, whole file. The deploy + * repaired frontmatter from 0.16.1 on, but every SKILL.md those releases + * shipped was already complete, so what is on disk is what was shipped. * - * Generated from `git ls-tree -r <ref> -- skills/` over all 99 tags + * Generated from `git ls-tree -r <ref> -- skills/` over all 100 tags * through v0.25.0 plus origin/main before the stub (installs from `main`), * minus `teamai-wiki` (see PACKAGED_SKILL_FILES). Do not edit by hand; a new * release adds nothing here, since the package no longer ships these trees. */ export const PACKAGED_SKILL_DIGESTS: ReadonlyMap<string, ReadonlyMap<string, readonly string[]>> = new Map([ ['teamai', new Map([ - ['SKILL.md', ['5d3c7629924db1a4b7feb17d75b0a94a804229a6bbee923ba50888a706f328cc', '7002b421f08803ab7c5858b8d69e0c5e37f5d6c42dd6c4ee411796e08d35f1d8']], + ['SKILL.md', ['82a22b1c37531834ab5784f93f950f0a8840ac4e4aa422f1ef2dc03437f9ad66', '898fc8a74fec0ccba37b0a6c7a8a446d3dde97a486fc566340f80c259faf4211', 'a523cfc79aca5870f6f35ff639229777f4f506457666560ec21f57176cf7f78e']], ['references/contribute-member.md', ['2a6a8c3eeee7424f79cb6d3f97d14f8588720f1d570028f5afdd12d6db458355']], - ['references/join-member.md', ['2f2f823675cea971b2a0360e7c6f090b2397e25702e60cce50bafb2b450ce8c3', '45e56c965fba5fe5f14cfb927c9b18032d19ebf98e524e7c709bcde16862b15a', 'ed2ab1c92680b3412e2ce60d5900f6baba0da896ad92158945ac6115ac939fc2']], + ['references/join-member.md', ['2f2f823675cea971b2a0360e7c6f090b2397e25702e60cce50bafb2b450ce8c3', '45e56c965fba5fe5f14cfb927c9b18032d19ebf98e524e7c709bcde16862b15a', '9584d1f228930c01141dc47a19623c17b02df999b0bff19e104d1a18fc952fcb', 'ed2ab1c92680b3412e2ce60d5900f6baba0da896ad92158945ac6115ac939fc2']], ['references/manage-admin.md', ['fd31d78724fb35d3bfecf606299c9e907f2a10da275bf06f840780c674584158']], - ['references/provider-tgit.md', ['df7faedb8beeafeb55a23c2b3d2b99d1421548cf4f8172ed7da0752ef6aeda39']], - ['references/setup-admin.md', ['4e58bae91bcb3831fd7d2dc0c9f086bb0e985f7d51dd7bc7e753bce1b380816e', 'dae12585f3003f1e6c347f51859de68c7af9baf11b84be63559cb782d122db7b', 'ff9996686f42cc7d7c2a09cb5f254dc0d8fb379fae29dab9046c971dcced543f']], + ['references/provider-tgit.md', ['386a14715db132b5b34ab95a7586119b79a6795cbbbe776a4be455b1af7fa49f', 'df7faedb8beeafeb55a23c2b3d2b99d1421548cf4f8172ed7da0752ef6aeda39']], + ['references/setup-admin.md', ['4e58bae91bcb3831fd7d2dc0c9f086bb0e985f7d51dd7bc7e753bce1b380816e', '615fc60bab798125bc2b6340e87b3a1e5376b7d499cab5d884f9d78bab07154b', 'dae12585f3003f1e6c347f51859de68c7af9baf11b84be63559cb782d122db7b', 'ff9996686f42cc7d7c2a09cb5f254dc0d8fb379fae29dab9046c971dcced543f']], ['references/troubleshooting.md', ['78ad122c14f1c5081ef698c880c4a250a99eb7a6ccbb1b39674359e712261fc6']], ['references/uninstall.md', ['10a97318e8bc6a94a0413e6d1b1b516b24ab8fd7f52d118cfc0d92c97394b892']], ])], ['teamai-share-learnings', new Map([ - ['SKILL.md', ['2bfdfa9c4f312424e06544fe104fbf5988cf5a6b3f2289215cbe89fb430d18c0', '7771ec3997b747e4e270818189a1f450cc7e7307cc14c15b42491a2f20e94494', 'a47169735ee710bb38c21fa72e8f647ca2078b1cfd538bad78e30a0806f179c5', 'de28d09f85099943339f3adff3f3e2f180099d18d2963655bb14ca4af4f98bea', 'e7ab73f2258e13b55c91bffdd07975b13fb7a34c84c81215340a8239432f358b']], + ['SKILL.md', ['2bfdfa9c4f312424e06544fe104fbf5988cf5a6b3f2289215cbe89fb430d18c0', '676da346cf38c3d40d681a28bd2330abf825ec534ed774996362b3a91d20981a', '7771ec3997b747e4e270818189a1f450cc7e7307cc14c15b42491a2f20e94494', 'a47169735ee710bb38c21fa72e8f647ca2078b1cfd538bad78e30a0806f179c5', 'e7ab73f2258e13b55c91bffdd07975b13fb7a34c84c81215340a8239432f358b', 'f2d7c437520d8707182fbd3b4ca0dd453315d5210d924abb7dfa25aee651a94f']], ])], ['team-wiki-codebase', new Map([ ['README.md', ['4e1ab336bfa6d78085572b4e0fc0e345bda0ec2be065279f189a8f2939f242b8', '82945615d4706b2c1b581d2b326c15536be0b8573472c359d1414690f0b2e804', 'd3c7312663caa8cefccd1034127fe7091384b8e603fcd04961f4f30a8ff1fe0e']], - ['SKILL.md', ['178164fbfd2724cbc581c7e8c16712e41c7928bb8dba7c9ad6b9a3ef50afac2f', '1f5fbdc46873e8baae340b6dfb4e289a74d3706b357db4c7b8eab41971ef95dc', '79f260dda754a5495e027643ae2bd0b3b70ea761b8ce297aee9a66a97ca43da7', 'b2ffc2996f415b5709bf3a75ac5b5d5de52ebb8e866506f5f00aa6730d55c269', 'eb268ad4bf653e77dead394141204d6a20881b0e5a6cb9d5cea33822c7ebd90c']], + ['SKILL.md', ['47ebc8f3ac3f39551e96ef46ede14e3e076fe68acee3b431c5598b09df433904', '4d7e728e0c821404d760a63f994e6d0fe81519bd875e2d4297d178bffd292266', '4e6f1b937270e90cf53351e117cf8a4de19cbcca37b90603abe9532a9fe3a4c0', 'f6ebd80e036cc37dd049597c20bea5c28f267729d8c6af636ee5c61f60092af5', 'fad8ee99235ca195438dd4e52853d42c8f53b09bed4febd0f330039dee804907']], ['references/agents/graph-rag-agent.md', ['d79e52cfec1e131877f7fa28bb14993b937fb643ed6778fde6bf569dbd0ba2d3']], ['references/agents/kb-doc-generator.md', ['8dc1f1ef5e5d270223586567629b66333a07c42ae103ac5cdef6c755364587d5']], ['references/methodology/phase0-collection.md', ['1061ff28e17aa290dc0942958bac0ea0844b3b830fabda3dd9e03ac32c02b0b0', '89caa5e6e5135b19e39ebf48224a31ae6ea80bbef84c7c2e4cf50b6391220cf6']], diff --git a/src/skill-content.ts b/src/skill-content.ts index 971a6b99..99569b90 100644 --- a/src/skill-content.ts +++ b/src/skill-content.ts @@ -77,7 +77,7 @@ async function blockReason(name: string): Promise<SkillBlockReason | null> { if (!RECALL_DEPENDENT_SKILLS.has(name)) return null; const { NotInitializedError } = await import('./config.js'); try { - const [{ autoDetectInit }, { isRecallEnabled }] = await Promise.all([ + const [{ autoDetectInit, findUnreadableProjectConfig }, { isRecallEnabled }] = await Promise.all([ import('./config.js'), import('./types.js'), ]); @@ -87,6 +87,9 @@ async function blockReason(name: string): Promise<SkillBlockReason | null> { const previous = setStderrOnly(true); let loaded: Awaited<ReturnType<typeof autoDetectInit>>; try { + // A broken project config is skipped by detection, which would then + // answer with the user config: another team's recall and source. + if (await findUnreadableProjectConfig()) return 'config'; loaded = await autoDetectInit(); } finally { setStderrOnly(previous); From 5f5b2c14f874ca9118f5465b84cae6bc2c96f431 Mon Sep 17 00:00:00 2001 From: Saul Moro <saul.moro.gomez@gmail.com> Date: Wed, 23 Sep 2026 08:27:49 +0200 Subject: [PATCH 37/37] fix(config): report a broken higher-priority project config even when a fallback loads findUnreadableProjectConfig dropped a recorded error whenever detection went on to find a later candidate: a broken partition config followed by a valid legacy .teamai/ config returned null, and the share gate answered with the fallback's team. It now reports the first unreadable file regardless. An existing config file that is empty or cannot be read is reported to the sink too, instead of returning without a word. --- src/__tests__/config-not-initialized.test.ts | 42 +++++++++++++++++++- src/config.ts | 20 ++++++---- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/__tests__/config-not-initialized.test.ts b/src/__tests__/config-not-initialized.test.ts index cf39d188..6de26fb9 100644 --- a/src/__tests__/config-not-initialized.test.ts +++ b/src/__tests__/config-not-initialized.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import fs from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import fs, { realpathSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -8,7 +9,8 @@ vi.mock('../utils/logger.js', () => ({ setStderrOnly: vi.fn(() => false), })); -import { NotInitializedError, findUnreadableProjectConfig, requireInit } from '../config.js'; +import { NotInitializedError, detectProjectConfig, findUnreadableProjectConfig, requireInit } from '../config.js'; +import { projectDataHome } from '../utils/partition.js'; /** * `loadLocalConfig` returns null both for a missing file and for one it could @@ -75,5 +77,41 @@ describe('findUnreadableProjectConfig', () => { it('is null when there is no project config at all', async () => { expect(await findUnreadableProjectConfig(dir)).toBeNull(); }); + + it('names an empty project config, which detection alone also skips', async () => { + const configPath = path.join(dir, '.teamai', 'config.yaml'); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, ''); + + expect(await findUnreadableProjectConfig(dir)).toContain(configPath); + }); + + it('names a broken partition config even when the legacy .teamai/ config behind it loads', async () => { + // The partition is authoritative; detection skips it when broken and lands + // on the legacy config, which may belong to another team. + const home = path.join(dir, 'home'); + fs.mkdirSync(home); + vi.stubEnv('HOME', home); + vi.stubEnv('USERPROFILE', home); + try { + const repo = path.join(dir, 'repo'); + fs.mkdirSync(repo); + for (const args of [['init', '-q'], ['config', 'user.email', 't@e'], ['config', 'user.name', 'T'], ['commit', '--allow-empty', '-q', '-m', 'init']]) { + execFileSync('git', args, { cwd: repo, stdio: 'pipe' }); + } + const anchor = realpathSync(repo); + const partitionConfig = path.join(projectDataHome(anchor), 'config.yaml'); + fs.mkdirSync(path.dirname(partitionConfig), { recursive: true }); + fs.writeFileSync(partitionConfig, 'repo: [unclosed\n'); + fs.mkdirSync(path.join(repo, '.teamai')); + fs.writeFileSync(path.join(repo, '.teamai', 'config.yaml'), + `repo:\n localPath: ${path.join(repo, '.teamai', 'team-repo')}\n remote: https://example.com/other.git\nusername: t\nscope: project\n`); + + expect(await detectProjectConfig(repo)).not.toBeNull(); + expect(await findUnreadableProjectConfig(repo)).toContain(partitionConfig); + } finally { + vi.unstubAllEnvs(); + } + }); }); diff --git a/src/config.ts b/src/config.ts index bcf8fdcd..ad03deef 100644 --- a/src/config.ts +++ b/src/config.ts @@ -395,7 +395,11 @@ export async function readConfigFrom( if (!(await pathExists(configPath))) return null; } const content = await readFileSafe(configPath); - if (!content) return null; + if (!content) { + // The file exists (checked above) but gave nothing: unreadable or empty. + onUnreadable?.(configPath, 'the file is empty or could not be read'); + return null; + } try { const raw = YAML.parse(content); const config = LocalConfigSchema.parse(raw); @@ -430,15 +434,17 @@ export async function readConfigFrom( } /** - * The project-scope config under `cwd` that exists but cannot be parsed or - * validated, with the reason, or null. Detection skips such a file and falls - * back to the user config, which for a command that must know which team it - * serves means answering for the wrong one. + * The project-scope config under `cwd` that exists but cannot be read, parsed + * or validated, with the reason, or null. Detection skips such a file and falls + * back to the next candidate — a legacy `.teamai/`, then the user config — + * which for a command that must know which team it serves means answering for + * the wrong one. So a broken higher-priority file is reported even when a later + * candidate loads. */ export async function findUnreadableProjectConfig(cwd?: string): Promise<string | null> { let problem: string | null = null; - const found = await detectProjectConfig(cwd, (configPath, error) => { problem ??= `${configPath}: ${error}`; }); - return found ? null : problem; + await detectProjectConfig(cwd, (configPath, error) => { problem ??= `${configPath}: ${error}`; }); + return problem; } /**