diff --git a/docs/designs/multi-project-management.md b/docs/designs/multi-project-management.md index 5468a39f5..ed5dfd37b 100644 --- a/docs/designs/multi-project-management.md +++ b/docs/designs/multi-project-management.md @@ -537,7 +537,10 @@ as "changed by you (kept by pull)". skills/rules/claudemd by the union; namespace-aware learnings sync + cleanup (`src/pull.ts:687-745`, which today copies the whole flat `learnings/`). - `src/push.ts` — `--project` landing point. -- `src/contribute.ts` — landing-point priority (active project namespace → root). +- `src/contribute.ts` — explicit `--namespace` must belong to the active projects' + learnings namespaces; otherwise default to the only active namespace or the + shared root. Persist the chosen relative path in the existing pending queue + so retries keep their destination. `projects list` shows the default and choices. - `src/utils/search-index.ts` — a namespace-aware learnings collector (root + active project subdirs), replacing the flat `collectFlatMdEntries` call at `src/utils/search-index.ts:549`. @@ -604,8 +607,11 @@ works. 5. **Learnings isolation (P2 core).** A learning contributed under `hai-inference` does **not** appear in dir B's `teamai recall`; a root-level learning appears in both. -6. **Contribute landing.** `teamai contribute` in dir A lands under - `learnings/hai-inference/`; with no active project it lands at the root. +6. **Contribute landing.** `teamai contribute` defaults to the only active + learnings namespace, or the root when there are none or several. With + `--namespace`, it accepts only an active learnings namespace (not necessarily + a project id); unavailable paths are refused before queueing. Preview and + offline retry retain the selected destination. 7. **Namespace-aware index.** `teamai recall` in dir A scans root + `hai-inference/` subdir (proves the flat→recursive collector change). 8. **Member roster append.** `init` in both dirs → `members/.yaml` lists diff --git a/docs/usage-guide.md b/docs/usage-guide.md index bb55cb526..50792d163 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -323,8 +323,10 @@ explicit `--project` skips the picker. - **Backward compatible.** A repo without `manifest/projects.yaml` behaves exactly 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. +- **`teamai contribute`** defaults to `learnings//` when the active + projects resolve to exactly one learnings namespace, otherwise to the shared + root. Pass `--namespace ` to choose one of those active namespaces; + `teamai projects list` shows the default destination and accepted namespaces. `manifest/projects.yaml` example: @@ -1387,8 +1389,16 @@ You can also specify a file manually: ```bash teamai contribute --file /tmp/session.md teamai contribute --file /tmp/session.md --scope project +teamai contribute --file /tmp/session.md --namespace payments ``` +`--namespace` accepts only the selected scope's active learnings namespaces from +`manifest/projects.yaml`, which can differ from project ids. An unavailable or +unsafe namespace is rejected before the learning is queued. Without the flag, +the default above is unchanged; when several namespaces are active the command +lists them and explains how to choose one. `--dry-run` previews the selected +path without writing, and an offline contribution keeps that path when retried. + #### Turning the hint off Teams that route knowledge sharing through their own review flow (for example, a personal retrospective that opens ordinary PRs) can switch the hint off without touching the rest of the Stop hook — update checks, votes sync, and dashboard reporting keep running. Same two-tier pattern as recall: @@ -1924,7 +1934,7 @@ If core graph extraction or writing fails, the import reports an error without m With `--dry-run`, `--from-repo` and `--from-repo-list` read each repo's target commit with `git ls-remote`, print `Would import / at ` and whether the local cache is current, and stop there: nothing is cloned or fetched into the cache, the import lock is not taken, and no AI step runs. With `--incremental` and a cache containing `LAST_SYNC`, the preview queries that cache's current branch at its configured origin, matching the real fetch/reset. Full-clone previews, including a missing cache or `LAST_SYNC`, follow remote HEAD. If the cached branch was deleted remotely, a non-pruning wildcard fetch retains its cached origin ref; incremental preview uses that retained commit too. Pruning, an explicit deleted-branch fetch refspec, or a missing cached origin ref still selects the full-clone fallback. Other cached-branch query failures warn and preview the full-clone fallback. With `--output`, the preview reports the same `teamwiki/evidence/code/` destination beside the output file as a real import. -`--from-mr` publishes its learning the way `teamai contribute` does, on the `teamai-learnings` branch: under `learnings//` when exactly one active project declares a learnings namespace, otherwise at the shared `learnings/` root. If that fails, the learning stays queued on this machine and the next `teamai pull` publishes it; when a learnings checkout teamai refuses stopped it, no pull can until you deal with that checkout as the message says. +`--from-mr` publishes its learning the way `teamai contribute` does by default, on the `teamai-learnings` branch: under `learnings//` when the active projects resolve to exactly one learnings namespace, otherwise at the shared `learnings/` root. If that fails, the learning stays queued on this machine and the next `teamai pull` publishes it; when a learnings checkout teamai refuses stopped it, no pull can until you deal with that checkout as the message says. When the draft overlaps existing learnings, from the shared root or your active projects' namespaces, the command names them (`Possible duplicate: this learning overlaps N existing learning(s): .`), with `--all` too. It is a notice only: nothing is marked or replaced. When `manifest/projects.yaml` cannot be read, the check compares the shared root only and says so. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index d44a34643..3014fe15b 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -279,8 +279,9 @@ cd ~/work/billing && teamai init --project billing `teamai projects set all` 走的是字面 id,仍能单独激活它。 - **向后兼容。** 没有 `manifest/projects.yaml` 的仓库行为与之前完全一致;现存扁平 的 `learnings/*.md` 继续对所有人共享(零迁移)。 -- **`teamai contribute`** 在恰好激活一个项目时,把经验落到该项目子目录,否则落到 - 共享的根目录。 +- **`teamai contribute`** 在激活项目合计解析出恰好一个 learnings namespace 时, + 默认写入 `learnings//`,否则写入共享根目录。可用 `--namespace ` + 指定其中一个活跃 namespace;`teamai projects list` 会显示默认落点和允许的选项。 `manifest/projects.yaml` 示例: @@ -1245,8 +1246,14 @@ Consider running `/teamai share what this session taught me` to summarize what y ```bash teamai contribute --file /tmp/session.md teamai contribute --file /tmp/session.md --scope project +teamai contribute --file /tmp/session.md --namespace payments ``` +`--namespace` 只接受所选 scope 在 `manifest/projects.yaml` 中声明的活跃 learnings +namespace,它不一定等于项目 id。不可用或不安全的 namespace 会在经验入队前被拒绝。 +不传该参数时保持上述默认行为;存在多个 namespace 时,命令会列出它们并提示如何选择。 +`--dry-run` 只预览选中的路径,不写入;离线贡献在重试发布时仍保留该路径。 + #### 关闭提醒 如果团队通过自己的评审流程沉淀知识(例如个人复盘后提交普通 PR),可以只关闭这条提醒,Stop hook 的其余功能(更新检查、votes 同步、dashboard 上报)照常运行。配置方式与 recall 相同,分两层: @@ -1769,7 +1776,7 @@ teamai import --from-repo https://github.com/org/repo --skip-enrich 加 `--dry-run` 时,`--from-repo` 与 `--from-repo-list` 用 `git ls-remote` 读取每个仓库的目标提交,打印 `Would import / at ` 以及本地缓存是否最新,然后停止:不会 clone 或 fetch 到缓存,不会获取导入锁,也不会运行任何 AI 步骤。使用 `--incremental` 且缓存含 `LAST_SYNC` 时,预览会查询该缓存配置的 origin 上当前分支的提交,与真实 fetch/reset 一致。完整克隆预览(包括缺少缓存或 `LAST_SYNC`)跟随远端 HEAD。若缓存分支已从远端删除,不执行 prune 的通配 fetch 会保留缓存 origin 引用,增量预览也使用该保留提交。启用 prune、显式 fetch 已删除分支或缺少缓存 origin 引用时,仍预览完整克隆回退。其他缓存分支查询失败时,预览会警告并预览完整克隆回退。 指定 `--output` 时,预览会显示与真实导入相同的输出文件旁 `teamwiki/evidence/code/` 目标目录。 -`--from-mr` 与 `teamai contribute` 一样,把提取的经验发布到 `teamai-learnings` 分支:恰好一个激活项目声明了 learnings namespace 时放在 `learnings//` 下,否则放在共享的 `learnings/` 根目录。发布失败时,经验留在本机队列中,下次 `teamai pull` 会发布它;若阻止发布的是 teamai 拒绝使用的 learnings 检出,则在你按提示处理该检出之前,任何 pull 都无法发布它。 +`--from-mr` 与 `teamai contribute` 的默认行为一样,把提取的经验发布到 `teamai-learnings` 分支:激活项目合计解析出恰好一个 learnings namespace 时放在 `learnings//` 下,否则放在共享的 `learnings/` 根目录。发布失败时,经验留在本机队列中,下次 `teamai pull` 会发布它;若阻止发布的是 teamai 拒绝使用的 learnings 检出,则在你按提示处理该检出之前,任何 pull 都无法发布它。 如果草稿与已有经验(共享根目录或当前激活项目的 namespace 中的)高度重叠,命令会列出这些文件(`Possible duplicate: this learning overlaps N existing learning(s): .`),使用 `--all` 时同样如此。这只是提示:不会标记或替换任何已有经验。`manifest/projects.yaml` 无法读取时,只与共享根目录比较,并给出提示。 diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index 94d1b73d1..1031dd830 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -295,9 +295,10 @@ Generated: do not edit by hand. Regenerate with ## contribute -- `teamai contribute` — Contribute session knowledge to team repo +- `teamai contribute` — Contribute session knowledge to team repo; projects list shows the default destination and allowed namespaces - `--file ` — Path to the contribution document - `--title ` — Title for the contribution document + - `--namespace <ns>` — Write to an active learnings namespace (default: the only active namespace, or the shared root when none or several are active) - `--session-id <id>` — Session ID for dedup tracking - `--scope <scope>` — Target scope: user or project diff --git a/src/__tests__/e2e/contribute-namespace.test.ts b/src/__tests__/e2e/contribute-namespace.test.ts new file mode 100644 index 000000000..030d1c9ed --- /dev/null +++ b/src/__tests__/e2e/contribute-namespace.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import YAML from 'yaml'; + +const cli = fileURLToPath(new URL('../../../dist/index.js', import.meta.url)); +let sandbox: string; +let home: string; +let project: string; +let origin: string; +let clone: string; +let configPath: string; +let note: string; +let env: NodeJS.ProcessEnv; + +function git(args: string[], cwd = sandbox): string { + return execFileSync('git', args, { cwd, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +} + +function run(args: string[]) { + const result = spawnSync(process.execPath, [cli, ...args], { + cwd: project, env, encoding: 'utf8', timeout: 30_000, + }); + if (result.error) throw result.error; + return { code: result.status, output: `${result.stdout}${result.stderr}` }; +} + +function writeYaml(file: string, value: unknown): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, YAML.stringify(value)); +} + +function setProjects(projects: string[]): void { + const config = YAML.parse(fs.readFileSync(configPath, 'utf8')); + writeYaml(configPath, { ...config, projects }); +} + +// Includes directory creation as well as byte changes, including the queue, +// index, config, locks and git refs in this isolated machine. +function snapshot(dir = sandbox, ignoreDebugLog = false): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).flatMap((entry) => { + const file = path.join(dir, entry.name); + const relative = path.relative(sandbox, file); + // Real command errors use the ordinary diagnostic log; it is not a learning write. + if (ignoreDebugLog && file === path.join(home, '.teamai', 'debug.log')) return []; + return entry.isDirectory() + ? [`${relative}/`, ...snapshot(file, ignoreDebugLog)] + : [`${relative}:${createHash('sha256').update(fs.readFileSync(file)).digest('hex')}`]; + }); +} + +beforeEach(() => { + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-namespace-e2e-')); + home = path.join(sandbox, 'home'); + project = path.join(sandbox, 'project'); + origin = path.join(sandbox, 'origin.git'); + clone = path.join(project, '.teamai', 'team-repo'); + configPath = path.join(project, '.teamai', 'config.yaml'); + const seed = path.join(sandbox, 'seed'); + fs.mkdirSync(path.join(home, '.teamai'), { recursive: true }); + fs.mkdirSync(seed); + fs.mkdirSync(project); + env = { + ...process.env, + HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: path.join(home, '.config'), + GIT_CONFIG_GLOBAL: path.join(home, '.gitconfig'), GIT_CONFIG_NOSYSTEM: '1', + GIT_AUTHOR_NAME: 'member', GIT_AUTHOR_EMAIL: 'member@example.invalid', + GIT_COMMITTER_NAME: 'member', GIT_COMMITTER_EMAIL: 'member@example.invalid', + GIT_TERMINAL_PROMPT: '0', FORCE_COLOR: '0', + }; + writeYaml(path.join(seed, 'teamai.yaml'), { + team: 'namespace-fixture', repo: origin, provider: 'git', usageReport: false, + }); + writeYaml(path.join(seed, 'manifest', 'projects.yaml'), { + version: 1, + projects: [ + { id: 'svc-a', resources: { learnings: ['svc-a', 'payments'] } }, + { id: 'alpha', resources: { learnings: ['alpha-notes'] } }, + ], + }); + git(['init', '-q', '-b', 'main'], seed); + git(['add', '.'], seed); + git(['commit', '-qm', 'fixture'], seed); + git(['clone', '-q', '--bare', seed, origin]); + git(['clone', '-q', origin, clone]); + writeYaml(configPath, { + repo: { localPath: clone, remote: origin, kind: 'git' }, + username: 'member', scope: 'project', projectRoot: project, + updatePolicy: 'skip', additionalRoles: [], projects: ['svc-a'], enabledAgents: ['claude'], + }); + note = path.join(sandbox, 'note.md'); + fs.writeFileSync(note, '# Narwhal contract\n\nNarwhal payments require a stable retry key.\n'); +}); + +afterEach(() => fs.rmSync(sandbox, { recursive: true, force: true })); + +describe('contribute --namespace through the built CLI (#916)', () => { + it('publishes to the chosen namespace and immediately recalls the published file', () => { + const result = run(['contribute', '--file', note, '--title', 'narwhal', '--namespace', 'payments']); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain('Contributed: learnings/payments/narwhal-'); + const files = git(['ls-tree', '-r', '--name-only', 'teamai-learnings'], origin).split('\n'); + expect(files.filter((f) => f.endsWith('.md'))).toEqual([expect.stringMatching(/^learnings\/payments\/narwhal-/)]); + const recalled = run(['recall', 'narwhal']); + expect(recalled.code, recalled.output).toBe(0); + const file = recalled.output.match(/^File: (.+)$/m)?.[1]; + expect(file, recalled.output).toBeDefined(); + expect(file).toContain(`${path.sep}payments${path.sep}`); + expect(fs.readFileSync(file!, 'utf8')).toContain('stable retry key'); + expect(file).not.toContain('pending-learnings'); + }); + + it.each([ + { projects: ['svc-a'], destination: 'learnings/ (shared root)', allowed: 'svc-a, payments', prefix: 'learnings/narwhal-' }, + { projects: ['alpha'], destination: 'learnings/alpha-notes/', allowed: 'alpha-notes', prefix: 'learnings/alpha-notes/narwhal-' }, + { projects: [], destination: 'learnings/ (shared root)', allowed: '(none)', prefix: 'learnings/narwhal-' }, + ])('preserves the default and lists its choices for $projects', ({ projects, destination, allowed, prefix }) => { + setProjects(projects); + const before = snapshot(); + const listed = run(['projects', 'list']); + expect(listed.code, listed.output).toBe(0); + expect(listed.output).toContain(`Contributes to: ${destination} --namespace accepts: ${allowed}`); + expect(snapshot()).toEqual(before); + const result = run(['contribute', '--file', note, '--title', 'narwhal']); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain(`Contributed: ${prefix}`); + expect(git(['ls-tree', '-r', '--name-only', 'teamai-learnings'], origin)).toContain(prefix); + if (projects[0] === 'svc-a') expect(result.output).toContain('pass --namespace <ns>: svc-a, payments'); + }); + + it.each(['project', 'user'])('previews the selected %s namespace without filesystem writes', (scope) => { + if (scope === 'user') { + const config = YAML.parse(fs.readFileSync(configPath, 'utf8')); + writeYaml(path.join(home, '.teamai', 'config.yaml'), { ...config, scope: 'user', projectRoot: undefined }); + setProjects(['alpha']); // An explicit user scope must not use this project's choices. + } + const before = snapshot(); + const result = run(['contribute', '--scope', scope, '--file', note, '--namespace', 'payments', '--dry-run']); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain('[dry-run] Would push: learnings/payments/'); + expect(snapshot()).toEqual(before); + }); + + it.each(['alpha-notes', '../escape', 'a/b', 'a\\b', ''])('rejects the unavailable namespace %j before writing', (namespace) => { + const before = snapshot(sandbox, true); + const result = run(['contribute', '--file', note, '--namespace', namespace]); + expect(result.code, result.output).toBe(1); + expect(result.output).toContain('Allowed learnings namespaces: svc-a, payments'); + expect(snapshot(sandbox, true)).toEqual(before); + }); + + it('keeps a team without a projects manifest at the shared root and rejects explicit namespaces', () => { + fs.rmSync(path.join(clone, 'manifest', 'projects.yaml')); + const before = snapshot(sandbox, true); + expect(run(['projects', 'list']).output).toContain('Contributes to: learnings/ (shared root) --namespace accepts: (none)'); + const result = run(['contribute', '--file', note, '--namespace', 'payments']); + expect(result.code, result.output).toBe(1); + expect(result.output).toContain('Allowed learnings namespaces: (none)'); + const preview = run(['contribute', '--file', note, '--title', 'narwhal', '--dry-run']); + expect(preview.code, preview.output).toBe(0); + expect(preview.output).toContain('Would push: learnings/narwhal-'); + expect(snapshot(sandbox, true)).toEqual(before); + }); + + it('retains the chosen namespace in the offline queue when a later contribution publishes it', () => { + fs.renameSync(origin, `${origin}.offline`); + const queued = run(['contribute', '--file', note, '--title', 'narwhal', '--namespace', 'payments']); + expect(queued.code, queued.output).toBe(0); + expect(queued.output).toContain('Saved locally'); + const pending = path.join(project, '.teamai', 'pending-learnings', 'payments'); + expect(fs.readdirSync(pending).some((file) => file.startsWith('narwhal-'))).toBe(true); + fs.renameSync(`${origin}.offline`, origin); + const retry = run(['contribute', '--file', note, '--title', 'shared']); + expect(retry.code, retry.output).toBe(0); + expect(retry.output).toContain('Contributed: learnings/shared-'); + const files = git(['ls-tree', '-r', '--name-only', 'teamai-learnings'], origin); + expect(files).toContain('learnings/payments/narwhal-'); + expect(files).toContain('learnings/shared-'); + }); +}); diff --git a/src/contribute.ts b/src/contribute.ts index 952b01193..107f743cb 100644 --- a/src/contribute.ts +++ b/src/contribute.ts @@ -24,9 +24,8 @@ import { getProjectSearchIndexPath, isSelfMode } from './types.js'; * - Zero (no project, or the active projects declare no learnings namespace) → * the shared root (empty string). * - Multiple active learnings namespaces → the shared root, because the - * contribution's ownership is ambiguous; a member on several projects can still - * target one explicitly by contributing from that project's directory. This - * favors the safe default (visible to all) over silently guessing a namespace. + * contribution's ownership is ambiguous. `contribute --namespace` lets the + * member choose one of these namespaces without changing this default. */ export async function resolveLearningsSubdir(localConfig: LocalConfig): Promise<string> { const namespaces = await resolveActiveLearningsNamespaces( @@ -146,11 +145,11 @@ export function generateFilename(title?: string): string { * dealt with. */ export async function contribute( - options: GlobalOptions & { file?: string; title?: string; sessionId?: string; scope?: string }, + options: GlobalOptions & { file?: string; title?: string; sessionId?: string; scope?: string; namespace?: string }, ): Promise<void> { // Validate file if (!options.file) { - log.error('Usage: teamai contribute --file <path> [--title <title>]'); + log.error('Usage: teamai contribute --file <path> [--title <title>] [--namespace <ns>]'); return; } @@ -188,9 +187,22 @@ export async function contribute( const username = localConfig.username; const filename = generateFilename(options.title); - // Route into an active-project subdir when there is exactly one, else the - // shared root. `relPath` is the learnings-relative path used everywhere. - const learningsSubdir = await resolveLearningsSubdir(localConfig); + const namespaces = await resolveActiveLearningsNamespaces( + localConfig.repo.localPath, + localConfig.projects ?? [], + ); + // Validate before saving the queue, creating worktrees or rebuilding indexes. + if (options.namespace !== undefined && + (!isSafeNamespaceSegment(options.namespace) || !namespaces.includes(options.namespace))) { + log.error(`Cannot contribute to namespace "${options.namespace}". Allowed learnings namespaces: ${namespaces.join(', ') || '(none)'}.`); + process.exitCode = 1; + return; + } + const learningsSubdir = options.namespace ?? (namespaces.length === 1 ? namespaces[0] : ''); + if (options.namespace === undefined && namespaces.length > 1) { + log.info(`Contributing to learnings/ (shared root). To choose a namespace, pass --namespace <ns>: ${namespaces.join(', ')}.`); + } + // Keep the chosen path in the queue so retries publish to the same namespace. const relPath = learningsSubdir ? path.posix.join(learningsSubdir, filename) : filename; if (options.dryRun) { diff --git a/src/index.ts b/src/index.ts index d16bbf6c0..a2bd68ed0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1081,9 +1081,10 @@ program program .command('contribute') - .description('Contribute session knowledge to team repo') + .description('Contribute session knowledge to team repo; projects list shows the default destination and allowed namespaces') .option('--file <path>', 'Path to the contribution document') .option('--title <title>', 'Title for the contribution document') + .option('--namespace <ns>', 'Write to an active learnings namespace (default: the only active namespace, or the shared root when none or several are active)') .option('--session-id <id>', 'Session ID for dedup tracking') .option('--scope <scope>', 'Target scope: user or project') .action(async (cmdOpts) => { diff --git a/src/projects-cmd.ts b/src/projects-cmd.ts index b5b1776bc..80b54c909 100644 --- a/src/projects-cmd.ts +++ b/src/projects-cmd.ts @@ -15,6 +15,7 @@ import { listProjectIds, unknownProjectMessage, PROJECT_RESOURCE_TYPES, + resolveActiveLearningsNamespaces, } from './projects.js'; import type { ProjectsManifest, TeamProject } from './projects.js'; import { runManifestEdit, pushManifestChange } from './manifest-edit.js'; @@ -48,9 +49,13 @@ export async function projectsList(_options: GlobalOptions): Promise<void> { const repoPath = localConfig.repo.localPath; const manifest = await loadProjectsManifest(repoPath); + const namespaces = await resolveActiveLearningsNamespaces(repoPath, localConfig.projects ?? []); + const destination = namespaces.length === 1 ? `learnings/${namespaces[0]}/` : 'learnings/ (shared root)'; + const contributionTarget = `Contributes to: ${destination} --namespace accepts: ${namespaces.join(', ') || '(none)'}`; if (!manifest) { log.info('This team repo defines no projects (no manifest/projects.yaml).'); log.info('Projects are optional — resources fall back to roles + shared learnings.'); + console.log(contributionTarget); return; } @@ -76,6 +81,7 @@ export async function projectsList(_options: GlobalOptions): Promise<void> { } else { console.log('No active projects in this directory. Run `teamai projects set <id>` to set them.'); } + console.log(contributionTarget); } // ─── projects set ───────────────────────────────────────