Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
5fa6f84
fix(rules): inline rules without frontmatter, naming path-scoped globs
SaulMoro Sep 30, 2026
4ae7f0b
fix(rules): inline team rules into Codex AGENTS.md in project scope
SaulMoro Sep 30, 2026
57ba9af
Merge branch 'fix/codex-rules-agents-md' into t938/t1
SaulMoro Sep 30, 2026
f8375f1
fix(rules): render the Codex team-rules block with inlinedRulesText
SaulMoro Sep 30, 2026
247d494
test(rules): cover Codex AGENTS.md in user scope, culture, shared ins…
SaulMoro Sep 30, 2026
70d1640
fix(rules): remove Codex team rules and their old copies on uninstall
SaulMoro Sep 30, 2026
5e4a366
fix(doctor): check the team-rules block in Codex AGENTS.md
SaulMoro Sep 30, 2026
903ec52
fix(rules): remove the .codex/rules copies earlier pulls delivered
SaulMoro Sep 30, 2026
4a8a75b
Merge T5 (uninstall per block) into fix/codex-rules-agents-md
SaulMoro Sep 30, 2026
62bf475
Merge branch 'fix/codex-rules-agents-md' into t938/t4
SaulMoro Sep 30, 2026
2b061d3
Merge T2 (user scope, culture, shared instructions, recall) into fix/…
SaulMoro Sep 30, 2026
a039cfa
Merge T4 (reclaim old .codex/rules copies) into fix/codex-rules-agent…
SaulMoro Sep 30, 2026
c8ea5d7
docs(rules): document where Codex reads team rules (AGENTS.md)
SaulMoro Sep 30, 2026
2f52016
fix(rules): give Codex its AGENTS.md rules on an already-synced pull
SaulMoro Sep 30, 2026
428fef0
Merge T8 (docs) into fix/codex-rules-agents-md
SaulMoro Sep 30, 2026
914aa14
Merge branch 'fix/codex-rules-agents-md' into t938/t9
SaulMoro Sep 30, 2026
9ffdd7e
fix(doctor): tell a Codex member a plain pull restores the team-rules…
SaulMoro Sep 30, 2026
a194df0
fix(rules): give codex-internal and tcodex the codex instructions layout
SaulMoro Sep 30, 2026
bf9fe4b
Merge branch 'fix/codex-rules-agents-md' into t938/t7
SaulMoro Sep 30, 2026
a40652f
Merge T7 (codex-internal/tcodex, shared probe and block helper) into …
SaulMoro Sep 30, 2026
1ffcec6
fix(rules): keep an empty AGENTS.md that held no team-rules block
SaulMoro Sep 30, 2026
6fcd4ee
fix(doctor): pass the Codex team-rules check when no rule has a body
SaulMoro Sep 30, 2026
5949964
fix(doctor): a plain pull applies the Codex claudemd toolPaths fix
SaulMoro Sep 30, 2026
e6c8e0d
fix(uninstall): name the instruction file uninstall cleans
SaulMoro Sep 30, 2026
c997a04
refactor(recall): gate the recall block on writesInstructionBlock
SaulMoro Sep 30, 2026
9b40706
fix(rules): reclaim .codex/rules copies of rules the team removed
SaulMoro Sep 30, 2026
99dca26
test(rules): cover codex-internal and tcodex in reclaim, doctor and u…
SaulMoro Sep 30, 2026
2d91f6c
test(pull): cover the project-scope Codex fast path after an upgrade
SaulMoro Sep 30, 2026
53e7003
test(local-agent): pin the prompt sync into Codex's AGENTS.md
SaulMoro Sep 30, 2026
38b89eb
refactor(rules): move rulePaths into rule-format
SaulMoro Sep 30, 2026
2a1a00b
fix(doctor): ask nothing of a Codex entry that delivers no rules to it
SaulMoro Sep 30, 2026
9b27bd4
fix(rules): keep a teamai-recall.md the member edited since the last …
SaulMoro Oct 1, 2026
3e342e1
fix(rules): keep a member's empty AGENTS.md when the team-rules block…
SaulMoro Oct 1, 2026
aea692e
fix(rules): address review on uninstall, remove, doctor and markers
SaulMoro Oct 1, 2026
8be2dc9
refactor(rules): split legacy Codex rule-copy ownership out of the re…
SaulMoro Oct 1, 2026
7acca83
fix(uninstall): keep edited legacy Codex rule copies, remove removed …
SaulMoro Oct 1, 2026
f810cde
fix(rules): give Codex the team rules through its session-start hook
SaulMoro Oct 1, 2026
4d9c47d
Merge remote-tracking branch 'origin/main' into fix/pr-940-review-fin…
SaulMoro Oct 1, 2026
2675c5a
test(tool-roots): expect Codex's instruction file under CODEX_HOME
SaulMoro Oct 1, 2026
bf3c28e
fix(rules): keep Codex's project content out of AGENTS.md (#945)
SaulMoro Oct 1, 2026
4d192c4
fix(rules): keep tombstoned Codex copies without delivery records
SaulMoro Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ All notable changes to this project will be documented in this file. See [standa

### 🐛 Bug Fixes

- Team rules reach Codex, `codex-internal` and `tcodex`. Pull used to copy them to `.codex/rules/<name>.md`, which Codex does not read, and `teamai doctor` reported them delivered. In user scope they now go into a team-rules block of the tool's own `AGENTS.md` (`~/.codex/AGENTS.md`), beside the culture, shared-instructions and recall blocks. In a project the session-start hook adds the project's rules and those blocks to each session, and to a spawned subagent through a new `SubagentStart` entry, with `additionalContextLimit: 0` so Codex keeps them whole; pull leaves the project `AGENTS.md`, which other tools read too, unchanged. Pull removes the `.md` copies earlier pulls left in `.codex/rules/`, keeping an edited one with a warning. Pull and uninstall keep a removed rule's legacy copy unless it matches its recorded delivery hash; a missing record proves nothing about local edits. A path-scoped rule is rendered without frontmatter after an `Applies to files matching: <globs>` line, in Hermes' `SOUL.md` too. `teamai doctor` checks the user-scope block and, in a project, both hook entries' limit. The public Codex asks once to approve the changed teamai hooks (for [#938](https://github.com/Tencent/teamai-cli/issues/938)).
- `teamai pull` names the skills it removes because they are no longer delivered here, in one line, instead of a `debug` line calling them excluded and a summary that says `No resources to sync`. Picking a role or project, or an admin adding `manifest/projects.yaml`, takes root skills away, since the root `skills/` is then the tag catalog; the line then says that `teamai tags subscribe <tag>` brings one back. A namespace skill removed because its namespace is no longer active is named too. The usage guide and the multi-project design no longer say a member with no project still gets `common` or every root item (for [#911](https://github.com/Tencent/teamai-cli/issues/911)).
- `teamai pull` keeps the `teamai tags subscribe <tag>` recovery line when the skill directory it removes is byte-identical to an inactive namespace copy: that copy made the namespace cleanup phase remove the directory first, and the hint was lost, because pull inferred whether a root skill had left from which phase did the removing. The hint now follows the repo — it appears exactly when the team repo holds the removed skill at the root, the copy a tag delivers — so a namespace-only skill removed on deactivation is still named without the hint. The usage guide no longer says a member with no role gets no skills at all, in English or Chinese: root skills still arrive through a tag (review of [#917](https://github.com/Tencent/teamai-cli/pull/917)).
- `teamai pull` keeps a skill, rule or agent you changed since teamai delivered it instead of overwriting it, and names it: `Kept <path>: you changed it since teamai delivered it`, with a warning when the team version has changed since, which `teamai push` repeats for that copy, since the SessionStart pull is silent. Pull records the sha256 of what it writes at each path in the checkout's record, and the pre-push sync records its writes too, so a copy counts as changed only against that record; a skill counts as one copy, and files only you added do not count. The copies of other tools still update. `--force` keeps these copies, `--dry-run` prints `Would keep <path>`, and a copy of an item the team removed stays when you changed it. To take the team version, delete your copy and run `teamai pull --force`. There is no record before the first full pull on this version, or in a new worktree, so that pull overwrites as before. A file you added to a skill at a path another team version of it has is no longer named on every pull, and `teamai doctor` no longer fails a rules or agents check, or points at `teamai pull --force`, for a kept copy: it lists one as `changed by you (kept by pull)` beside any other problem (for [#822](https://github.com/Tencent/teamai-cli/issues/822)).
Expand Down
20 changes: 15 additions & 5 deletions docs/usage-guide.md

Large diffs are not rendered by default.

20 changes: 15 additions & 5 deletions docs/usage-guide.zh-CN.md

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion skill-data/setup/references/uninstall.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ machine** (all tools)?"*

- **Just this tool** → `--agent <tool>` (use the tool this conversation runs in,
e.g. `claude`). Shared resources are removed only if it is the last tool using
them.
them. An instructions file several tools read (the project `AGENTS.md` of Pi,
Hermes and WorkBuddy) is cleaned block by block: a teamai block stays while
a remaining tool on that file still writes it, so `--agent workbuddy` with Pi
still enabled removes the recall block and keeps the rest. A file teamai
created goes with its last block; one the user had before stays, even if empty.
- **Whole machine** → no `--agent` flag.

Reassure them (in their language): *"This only removes things from your computer.
Expand Down Expand Up @@ -51,6 +55,10 @@ and give it your team repo URL."*

## Notes

- Uninstall keeps edited legacy Codex rule copies. For a rule the team has
removed, it deletes the copy only if its hash matches the recorded delivery.
Without that record, it keeps the copy and names it in a warning. Save any
changes you need, then delete the copy manually.
- Do **not** delete the team repo on the Git platform — uninstall never touches it,
and neither should you.
- If the user only wants to stop auto-sync for one tool but keep TeamAI otherwise,
Expand Down
20 changes: 20 additions & 0 deletions src/__tests__/builtin-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,26 @@ describe('builtinHookDefs — unified built-in hook model', () => {
for (const tool of ['cursor', 'copilot', 'zcode', 'workbuddy']) expect(subagentStop(tool)).toEqual([]);
});

it('lets the Codex family\'s session-start hook pass the team rules whole (#938)', () => {
// Codex keeps only the start and end of additionalContext past 2,500 tokens; 0 turns that off.
const sessionStart = (tool: string) => builtinHookDefs(tool).find((d) => d.event === 'SessionStart');
for (const tool of ['codex', 'codex-internal', 'tcodex']) expect(sessionStart(tool)?.additionalContextLimit).toBe(0);
for (const tool of ['claude', 'cursor', 'copilot', 'codebuddy']) expect(sessionStart(tool)?.additionalContextLimit).toBeUndefined();
});

it('adds SubagentStart, with the same limit, only for the Codex family, whose fresh subagents fire no SessionStart (#938)', () => {
const subagentStart = (tool: string) => builtinHookDefs(tool).filter((d) => d.event === 'SubagentStart');
for (const tool of ['codex', 'codex-internal', 'tcodex']) {
expect(subagentStart(tool)).toEqual([expect.objectContaining({
key: 'Hook dispatch subagent-start',
matcher: '*',
command: expect.stringContaining(`hook-dispatch subagent-start --tool ${tool}`),
additionalContextLimit: 0,
})]);
}
for (const tool of ['claude', 'cursor', 'copilot', 'codebuddy', 'qoder', 'zcode']) expect(subagentStart(tool)).toEqual([]);
});

it('adds a lifecycle-complete SessionEnd hook only for Copilot', () => {
const defs = builtinHookDefs('copilot');
expect(defs).toHaveLength(7);
Expand Down
53 changes: 52 additions & 1 deletion src/__tests__/claudemd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import path from 'node:path';
import os from 'node:os';
import fse from 'fs-extra';
import { injectClaudeMdSection } from '../utils/claudemd.js';
import { injectClaudeMdSection, removeClaudeMdSection } from '../utils/claudemd.js';

const START = '<!-- [test:start] -->';
const END = '<!-- [test:end] -->';
Expand Down Expand Up @@ -75,3 +75,54 @@ describe('injectClaudeMdSection', () => {
expect(result).toContain(END);
});
});

describe('removeClaudeMdSection', () => {
let tmpDir: string;

beforeEach(async () => {
tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-claudemd-'));
});

afterEach(async () => {
await fse.remove(tmpDir);
});

it('with deleteIfEmpty, deletes a file injectClaudeMdSection created for the section', async () => {
const filePath = path.join(tmpDir, 'AGENTS.md');
await injectClaudeMdSection(filePath, START, END, makeBlock('x'));

expect(await removeClaudeMdSection(filePath, START, END, { deleteIfEmpty: true })).toBe(true);

expect(await fse.pathExists(filePath)).toBe(false);
});

it('with deleteIfEmpty, deletes a file injectClaudeMdSection created once its sections go one by one', async () => {
const filePath = path.join(tmpDir, 'AGENTS.md');
await injectClaudeMdSection(filePath, START, END, makeBlock('x'));
await injectClaudeMdSection(filePath, '<!-- other:start -->', '<!-- other:end -->', '<!-- other:start -->\ny\n<!-- other:end -->');

await removeClaudeMdSection(filePath, START, END, { deleteIfEmpty: true });
await removeClaudeMdSection(filePath, '<!-- other:start -->', '<!-- other:end -->', { deleteIfEmpty: true });

expect(await fse.pathExists(filePath)).toBe(false);
});

it('with deleteIfEmpty, gives back a member\'s empty file the section was appended to', async () => {
const filePath = path.join(tmpDir, 'AGENTS.md');
await fse.writeFile(filePath, '');
await injectClaudeMdSection(filePath, START, END, makeBlock('x'));

expect(await removeClaudeMdSection(filePath, START, END, { deleteIfEmpty: true })).toBe(true);

expect(await fse.readFile(filePath, 'utf8')).toBe('');
});

it('with deleteIfEmpty, leaves an empty file that has no section', async () => {
const filePath = path.join(tmpDir, 'AGENTS.md');
await fse.writeFile(filePath, '\n');

expect(await removeClaudeMdSection(filePath, START, END, { deleteIfEmpty: true })).toBe(false);

expect(await fse.readFile(filePath, 'utf8')).toBe('\n');
});
});
192 changes: 192 additions & 0 deletions src/__tests__/codex-hook-rules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
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 { buildHandlerRegistry, filterHandlersForConfig } from '../hook-handlers.js';
import type { LocalConfig } from '../types.js';

const CODEX_FAMILY = ['codex', 'codex-internal', 'tcodex'];

describe('Codex gets the project\'s rules and instruction blocks from its session-start hook (#938, #945)', () => {
let tmpDir: string;
let repoPath: string;
let localConfig: LocalConfig;

const registration = (event = 'session-start') =>
buildHandlerRegistry().find((r) => r.event === event && r.handler.name === 'team-rules');
const context = async (stdin: Record<string, unknown>, tool = 'codex'): Promise<string | null> => {
const event = stdin.hook_event_name === 'SubagentStart' ? 'subagent-start' : 'session-start';
const output = await registration(event)!.handler.execute(stdin, tool, localConfig);
if (output === null) return null;
const parsed = JSON.parse(output) as { hookSpecificOutput: { hookEventName: string; additionalContext: string } };
// Codex rejects a hookEventName that is not the event it ran.
expect(parsed.hookSpecificOutput.hookEventName).toBe(stdin.hook_event_name);
return parsed.hookSpecificOutput.additionalContext;
};

beforeEach(async () => {
tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-codex-hook-rules-'));
// No user scope unless a test writes ~/.teamai/config.yaml.
vi.stubEnv('HOME', path.join(tmpDir, 'home'));
repoPath = path.join(tmpDir, 'team-repo');
await fse.outputFile(path.join(repoPath, 'teamai.yaml'), 'team: test\nrepo: https://example.invalid/x/team.git\n');
await fse.outputFile(path.join(repoPath, 'rules', 'codeword.md'), 'The team codeword is PELICAN-42.\n');
await fse.outputFile(path.join(repoPath, 'rules', 'scoped.md'), '---\npaths:\n - "src/**"\n---\nPrefer named exports.\n');
localConfig = {
repo: { localPath: repoPath, remote: 'https://example.invalid/x/team.git' },
username: 'u',
additionalRoles: [],
scope: 'project',
projectRoot: path.join(tmpDir, 'project'),
} as unknown as LocalConfig;
});

afterEach(async () => {
vi.unstubAllEnvs();
await fse.remove(tmpDir);
});

/** A user-scope teamai install whose team repo holds `rules`. */
async function userScope(rules: Record<string, string>, extra = ''): Promise<void> {
const userRepo = path.join(tmpDir, 'user-team-repo');
await fse.outputFile(path.join(userRepo, 'teamai.yaml'), 'team: personal\nrepo: https://example.invalid/x/personal.git\n');
for (const [name, body] of Object.entries(rules)) await fse.outputFile(path.join(userRepo, 'rules', name), body);
await fse.outputFile(
path.join(tmpDir, 'home', '.teamai', 'config.yaml'),
`repo:\n localPath: ${userRepo}\n remote: https://example.invalid/x/personal.git\nusername: u\n${extra}`,
);
}

it.each(CODEX_FAMILY)('adds the team rules for %s at session start, a path-scoped rule after its globs', async (tool) => {
const text = await context({ hook_event_name: 'SessionStart', source: 'startup' }, tool);

expect(text).toContain('The team codeword is PELICAN-42.');
expect(text).toContain('Applies to files matching: src/**\nPrefer named exports.');
expect(text).not.toContain('paths:');
});

it('adds them again after a compaction or a clear, but not on resume, whose history already holds them', async () => {
for (const source of ['startup', 'clear', 'compact']) {
expect(await context({ hook_event_name: 'SessionStart', source })).toContain('PELICAN-42');
}
expect(await context({ hook_event_name: 'SessionStart', source: 'resume' })).toBeNull();
});

it('adds nothing from the user scope, which reaches Codex through its own AGENTS.md', async () => {
await userScope({ 'personal.md': 'The personal codeword is WREN-5.\n' });

const text = (await context({ hook_event_name: 'SessionStart', source: 'startup' }))!;

expect(text).toContain('PELICAN-42');
expect(text).not.toContain('WREN-5');
});

it('adds nothing when the session starts outside a teamai project', async () => {
localConfig = { ...localConfig, scope: 'user', projectRoot: undefined } as unknown as LocalConfig;

expect(await context({ hook_event_name: 'SessionStart', source: 'startup' })).toBeNull();
});

it('adds the project\'s culture, shared instructions and recall, without their file markers', async () => {
await fse.outputFile(path.join(repoPath, 'culture.md'), '---\ncompany:\n name: Acme\n---\n\nBe kind to teammates.\n');
await fse.outputFile(path.join(repoPath, 'claudemd', 'shared.md'), 'Shared team instructions.\n');
localConfig = { ...localConfig, recallEnabled: true } as LocalConfig;

const text = (await context({ hook_event_name: 'SessionStart', source: 'startup' }))!;

expect(text).toContain('Be kind to teammates.');
expect(text).toContain('Shared team instructions.');
expect(text).toContain('Team Knowledge Recall (teamai)');
expect(text).toContain('PELICAN-42');
expect(text).not.toContain('<!--');
});

it('adds no recall block when recall is off', async () => {
localConfig = { ...localConfig, recallEnabled: false } as LocalConfig;

expect(await context({ hook_event_name: 'SessionStart', source: 'startup' })).not.toContain('Team Knowledge Recall');
});

it('adds only the shared instructions of the member\'s namespaces', async () => {
await fse.outputFile(path.join(repoPath, 'manifest', 'projects.yaml'), `
version: 1
projects:
- id: alpha
resources: { knowledge: [alpha] }
- id: billing
resources: { knowledge: [billing] }
`);
await fse.outputFile(path.join(repoPath, 'claudemd', 'alpha', 'a.md'), 'Alpha instructions.\n');
await fse.outputFile(path.join(repoPath, 'claudemd', 'billing', 'b.md'), 'Billing instructions.\n');
localConfig = { ...localConfig, projects: ['alpha'] } as LocalConfig;

const text = await context({ hook_event_name: 'SessionStart', source: 'startup' });

expect(text).toContain('Alpha instructions.');
expect(text).not.toContain('Billing instructions.');
});

it('skips a block another tool already wrote into the project AGENTS.md, which Codex reads', async () => {
await fse.outputFile(path.join(repoPath, 'culture.md'), '---\ncompany:\n name: Acme\n---\n\nBe kind to teammates.\n');
await fse.outputFile(path.join(repoPath, 'claudemd', 'shared.md'), 'Shared team instructions.\n');
await fse.outputFile(
path.join(tmpDir, 'project', 'AGENTS.md'),
'# Notes\n\n<!-- [teamai:culture:start] -->\nBe kind to teammates.\n<!-- [teamai:culture:end] -->\n',
);

const text = (await context({ hook_event_name: 'SessionStart', source: 'startup' }))!;

expect(text).not.toContain('Be kind to teammates.');
expect(text).toContain('Shared team instructions.');
});

it('adds no rules from a scope that does not enable the tool', async () => {
await userScope({ 'personal.md': 'The personal codeword is WREN-5.\n' }, 'enabledAgents: [claude]\n');
localConfig = { ...localConfig, enabledAgents: ['claude'] } as LocalConfig;

expect(await context({ hook_event_name: 'SessionStart', source: 'startup' })).toBeNull();
});

it('adds them to a subagent Codex starts, as SubagentStart', async () => {
expect(await context({ hook_event_name: 'SubagentStart', agent_type: 'worker' })).toContain('PELICAN-42');
expect(registration('subagent-start')).toMatchObject({ matcher: '*', requiresConfig: true });
expect(registration('subagent-start')?.background).not.toBe(true);
});

it('adds nothing for a tool that reads its own rules directory', async () => {
expect(await context({ hook_event_name: 'SessionStart', source: 'startup' }, 'claude')).toBeNull();
});

it('adds only the rules of the member\'s projects', async () => {
await fse.outputFile(path.join(repoPath, 'manifest', 'projects.yaml'), `
version: 1
projects:
- id: alpha
resources: { knowledge: [alpha] }
- id: billing
resources: { knowledge: [billing] }
`);
await fse.outputFile(path.join(repoPath, 'rules', 'alpha', 'alpha-rule.md'), 'Alpha rule.\n');
await fse.outputFile(path.join(repoPath, 'rules', 'billing', 'billing-rule.md'), 'Billing rule.\n');
localConfig = { ...localConfig, projects: ['alpha'] } as LocalConfig;

const text = await context({ hook_event_name: 'SessionStart', source: 'startup' });

expect(text).toContain('Alpha rule.');
expect(text).not.toContain('Billing rule.');
});

it('adds nothing when no rule has a body', async () => {
await fse.remove(path.join(repoPath, 'rules'));
await fse.outputFile(path.join(repoPath, 'rules', 'empty.md'), '---\npaths:\n - "src/**"\n---\n');

expect(await context({ hook_event_name: 'SessionStart', source: 'startup' })).toBeNull();
});

it('runs in the foreground at session start, only where teamai is set up', () => {
expect(registration()).toMatchObject({ event: 'session-start', matcher: '*', requiresConfig: true });
expect(registration()?.background).not.toBe(true);
expect(filterHandlersForConfig(buildHandlerRegistry(), null).map((r) => r.handler.name)).not.toContain('team-rules');
});
});
Loading
Loading