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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,11 @@ explicit `--project` skips the picker.
as before; existing flat `learnings/*.md` stay shared with everyone (zero
migration).
- **`teamai contribute`** lands a learning under the active project's subdirectory
when exactly one project is active, otherwise at the shared root.
when exactly one project is active, otherwise at the shared root. When several
learnings namespaces are active, `--namespace <ns>` files it under one of them —
restricted to the namespaces this directory reads, so a learning never lands
where its author's `recall` would not find it. `teamai projects list` shows the
default destination and the accepted namespaces.

`manifest/projects.yaml` example:

Expand Down
4 changes: 3 additions & 1 deletion docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,9 @@ cd ~/work/billing && teamai init <team-repo> --project billing
- **向后兼容。** 没有 `manifest/projects.yaml` 的仓库行为与之前完全一致;现存扁平
的 `learnings/*.md` 继续对所有人共享(零迁移)。
- **`teamai contribute`** 在恰好激活一个项目时,把经验落到该项目子目录,否则落到
共享的根目录。
共享的根目录。激活了多个 learnings 命名空间时,`--namespace <ns>` 可以把经验落到
其中一个——只接受本目录会读取的命名空间,经验不会落到作者 `recall` 不到的地方。
`teamai projects list` 会显示默认落点与可选命名空间。

`manifest/projects.yaml` 示例:

Expand Down
1 change: 1 addition & 0 deletions skill-data/core/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ Generated: do not edit by hand. Regenerate with
- `--title <title>` — Title for the contribution document
- `--session-id <id>` — Session ID for dedup tracking
- `--scope <scope>` — Target scope: user or project
- `--namespace <ns>` — File the learning under this learnings namespace; must be one this directory reads (`teamai projects list` shows them)

## recall

Expand Down
3 changes: 3 additions & 0 deletions skill-data/share/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ URLs, paths and code identifiers stay as they are.
- 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>"`
— if `teamai projects list` shows several learnings namespaces for this
directory, pass `--namespace <ns>` (one it lists) to file the learning under
one of them instead of the shared root

## Document Template

Expand Down
216 changes: 216 additions & 0 deletions src/__tests__/contribute-namespace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';

// ─── contribute --namespace (#916) ──────────────────────────
// A directory that reads several learnings namespaces gets a way to choose
// one for a contribution, instead of always landing in the shared root.

function writeManifest(repoDir: string, learnings: Record<string, string[]>): void {
const projects = Object.entries(learnings)
.map(([id, ns]) => ` - id: ${id}\n resources: { learnings: [${ns.join(', ')}] }`)
.join('\n');
fs.mkdirSync(path.join(repoDir, 'manifest'), { recursive: true });
fs.writeFileSync(
path.join(repoDir, 'manifest', 'projects.yaml'),
`version: 1\nprojects:\n${projects}\n`,
'utf-8',
);
}

describe('resolveLearningsDestination (#916)', () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-contribute-dest-'));
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

function localConfigFor(projects: string[]) {
return {
repo: { localPath: tmpDir },
username: 'testuser',
projects,
} as never;
}

it('routes to the single active namespace without a flag', async () => {
writeManifest(tmpDir, { 'svc-a': ['svc-a'] });
const { resolveLearningsDestination } = await import('../contribute.js');
const dest = await resolveLearningsDestination(localConfigFor(['svc-a']));
expect(dest).toEqual({ subdir: 'svc-a', namespaces: ['svc-a'] });
});

it('falls back to the shared root when several namespaces are active', async () => {
writeManifest(tmpDir, { 'svc-a': ['svc-a'], payments: ['payments'] });
const { resolveLearningsDestination } = await import('../contribute.js');
const dest = await resolveLearningsDestination(localConfigFor(['svc-a', 'payments']));
expect(dest).toEqual({ subdir: '', namespaces: ['svc-a', 'payments'] });
});

it('routes a --namespace flag to the requested namespace', async () => {
writeManifest(tmpDir, { 'svc-a': ['svc-a'], payments: ['payments'] });
const { resolveLearningsDestination } = await import('../contribute.js');
const dest = await resolveLearningsDestination(localConfigFor(['svc-a', 'payments']), 'payments');
expect(dest).toEqual({ subdir: 'payments', namespaces: ['svc-a', 'payments'] });
});

it('accepts a namespace that differs from the project id', async () => {
writeManifest(tmpDir, { alpha: ['alpha-notes'] });
const { resolveLearningsDestination } = await import('../contribute.js');
const dest = await resolveLearningsDestination(localConfigFor(['alpha']), 'alpha-notes');
expect(dest).toEqual({ subdir: 'alpha-notes', namespaces: ['alpha-notes'] });
});

it('refuses a namespace this directory does not read, listing the valid ones', async () => {
writeManifest(tmpDir, { 'svc-a': ['svc-a'], payments: ['payments'] });
const { resolveLearningsDestination } = await import('../contribute.js');
await expect(
resolveLearningsDestination(localConfigFor(['svc-a', 'payments']), 'nope'),
).rejects.toThrow('Unknown learnings namespace "nope". Valid namespaces: svc-a, payments');
});

it('refuses any flag value when no namespace is active', async () => {
writeManifest(tmpDir, { 'svc-a': ['svc-a'] });
const { resolveLearningsDestination } = await import('../contribute.js');
await expect(
resolveLearningsDestination(localConfigFor([]), 'svc-a'),
).rejects.toThrow('reads no learnings namespace');
});
});

describe('contribute --namespace (#916)', () => {
let tmpDir: string;
let repoDir: string;
const originalHome = process.env.HOME;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-contribute-ns-'));
process.env.HOME = tmpDir;
// One level in: the queue sits NEXT TO the repo checkout, and a repo at
// the tmpDir root would put it in the shared OS temp dir.
repoDir = path.join(tmpDir, 'repo');
writeManifest(repoDir, { 'svc-a': ['svc-a'], payments: ['payments'] });
});

afterEach(() => {
process.env.HOME = originalHome;
fs.rmSync(tmpDir, { recursive: true, force: true });
});

function mockConfig(projects: string[]) {
// The pure-function tests above import contribute.js against the real
// config; drop the registry so this import re-resolves it to the mock.
vi.resetModules();
vi.doMock('../config.js', () => ({
requireInit: vi.fn().mockResolvedValue({
localConfig: {
repo: { localPath: repoDir, remote: 'https://example.com/team/repo.git', kind: 'git' },
username: 'testuser',
projects,
scope: 'user',
},
teamConfig: {},
}),
detectProjectConfig: vi.fn().mockResolvedValue(null),
}));
// The queue write checks the install's own config still names this install
// as the queue's owner; without it every write is refused as stale.
fs.mkdirSync(path.join(tmpDir, '.teamai'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.teamai', 'config.yaml'),
`repo:\n localPath: ${repoDir}\n remote: https://example.com/team/repo.git\n kind: git\nusername: testuser\nprojects: ${JSON.stringify(projects)}\n`,
'utf-8',
);
}

function contentFile(): string {
const file = path.join(tmpDir, 'notes.md');
fs.writeFileSync(file, '# Session Notes\nA payment routing insight.', 'utf-8');
return file;
}

// The queue sits next to the repo checkout, not inside it.
function queuedFiles(): string[] {
const queueDir = path.join(path.dirname(repoDir), 'pending-learnings');
if (!fs.existsSync(queueDir)) return [];
return fs.readdirSync(queueDir, { recursive: true }) as string[];
}

function mockPublishNothing(): void {
vi.doMock('../utils/learnings-publish.js', () => ({
publishQueuedLearnings: vi.fn().mockResolvedValue({
published: [], lastError: null, refused: false, installChanged: null,
}),
}));
}

it('files a learning under the requested namespace', async () => {
mockConfig(['svc-a', 'payments']);
mockPublishNothing();

const { contribute } = await import('../contribute.js');
await contribute({ file: contentFile(), title: 'Payments insight', namespace: 'payments' });

expect(queuedFiles().some((f) => f.includes(`payments${path.sep}`))).toBe(true);
vi.doUnmock('../config.js');
vi.doUnmock('../utils/learnings-publish.js');
});

it('refuses an unknown namespace without queueing anything', async () => {
mockConfig(['svc-a', 'payments']);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});

const { contribute } = await import('../contribute.js');
await contribute({ file: contentFile(), namespace: 'nope' });

// log.error prefixes its symbol; the message is the second argument.
expect(errorSpy).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining('Unknown learnings namespace "nope". Valid namespaces: svc-a, payments'),
);
expect(queuedFiles()).toEqual([]);

errorSpy.mockRestore();
logSpy.mockRestore();
vi.doUnmock('../config.js');
});

it('hints at the namespaces when several are active and no flag is passed', async () => {
mockConfig(['svc-a', 'payments']);
mockPublishNothing();
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});

const { contribute } = await import('../contribute.js');
await contribute({ file: contentFile() });

expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining('several learnings namespaces (svc-a, payments)'),
);

logSpy.mockRestore();
vi.doUnmock('../config.js');
vi.doUnmock('../utils/learnings-publish.js');
});

it('shows the namespace in the dry-run path', async () => {
mockConfig(['svc-a', 'payments']);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});

const { contribute } = await import('../contribute.js');
await contribute({
file: contentFile(), title: 'Payments insight', namespace: 'payments', dryRun: true,
});

expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[dry-run] Would push: learnings/payments/'));
expect(queuedFiles()).toEqual([]);

logSpy.mockRestore();
vi.doUnmock('../config.js');
});
});
58 changes: 57 additions & 1 deletion src/__tests__/projects-cmd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const logMocks = vi.hoisted(() => ({
}));
vi.mock('../utils/logger.js', () => ({ log: logMocks }));

import { projectsAdd, projectsUpdate, projectsRemove } from '../projects-cmd.js';
import { projectsAdd, projectsUpdate, projectsRemove, projectsList } from '../projects-cmd.js';
import { autoDetectInit } from '../config.js';

describe('projects add / update / remove (#756)', () => {
Expand Down Expand Up @@ -262,3 +262,59 @@ describe('projects add / update / remove (#756)', () => {
});
});
});

describe('projects list destination (#916)', () => {
let repoDir: string;
const logLines = () => logSpy.mock.calls.map((c) => String(c[0])).join('\n');
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});

beforeEach(async () => {
repoDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-projects-list-'));
vi.clearAllMocks();
});

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

async function mockActive(projects: string[]): Promise<void> {
vi.mocked(autoDetectInit).mockResolvedValue({
localConfig: {
repo: { localPath: repoDir, remote: 'https://github.com/team/repo.git' },
username: 'member',
projects,
scope: 'user',
},
} as unknown as Awaited<ReturnType<typeof autoDetectInit>>);
await fse.ensureDir(path.join(repoDir, 'manifest'));
await fse.writeFile(path.join(repoDir, 'manifest', 'projects.yaml'), YAML.stringify({
version: 1,
projects: [
{ id: 'svc-a', resources: { learnings: ['svc-a'] } },
{ id: 'payments', resources: { learnings: ['payments'] } },
],
}));
}

it('names the single active namespace as the contribute destination', async () => {
await mockActive(['svc-a']);
await projectsList({});
expect(logLines()).toContain('Contribute destination for this directory: learnings/svc-a/');
});

it('shows the shared root and the --namespace escape hatch for several', async () => {
await mockActive(['svc-a', 'payments']);
await projectsList({});
const lines = logLines();
expect(lines).toContain('Contribute destination for this directory: learnings/ (shared root)');
expect(lines).toContain(
'Namespaces read here: svc-a, payments — pass `teamai contribute --namespace <ns>` to file under one.',
);
});

it('shows the shared root when no namespace is active', async () => {
await mockActive([]);
await projectsList({});
expect(logLines()).toContain('Contribute destination for this directory: learnings/ (shared root)');
});
});
Loading