Skip to content

feat(#375): multi-project management (P1+P2) — project dimension + learnings isolation - #426

Merged
jeff-r2026 merged 1 commit into
Tencent:mainfrom
jeff-r2026:feat/375-multi-project-p1p2
Sep 8, 2026
Merged

feat(#375): multi-project management (P1+P2) — project dimension + learnings isolation#426
jeff-r2026 merged 1 commit into
Tencent:mainfrom
jeff-r2026:feat/375-multi-project-p1p2

Conversation

@jeff-r2026

Copy link
Copy Markdown
Collaborator

What & why

Implements issue #375 (P1+P2): one team repo serving multiple projects. Adds a project dimension orthogonal to role, declared by the admin in manifest/projects.yaml, controlling which team knowledge (skills / rules / CLAUDE.md / learnings) a directory syncs.

This is not #374's project (a path slug deciding where machine data lives). This PR's project is a logical, admin-defined id deciding who knowledge reaches. Depends on #374 P0 (subdirectory/worktree cwd → project root), already merged.

Solves three problems, all verified in code:

  • P1role was one-dimensional (resolveRoleResourceNamespaces), forcing N×M roles for N projects × M functions.
  • P2 (the painful one) — learnings had zero isolation: the namespace was ignored, the index collector was non-recursive, contribute wrote flat. Project A's learnings polluted project B's recall.
  • (P3 — member roster registration + projects set/members + push --project — ships as a separate PR.)

Design

  • role and project are orthogonal, unioned, no priority override: activeNamespaces = resolveRole(...) ∪ resolveProjects(...). Learnings namespaces come only from projects.
  • learnings/ root = shared with the whole team (zero-migration pivot); project-private learnings live under learnings/<project-id>/.
  • A lone project is not auto-activated (issue Proposal: 一个团队仓库承载多个项目(project 作为与 role 正交的维度) #375 Q1) — a member may belong to no project.
  • Backward compatible: no projects.yaml → behaves exactly as today; existing flat learnings stay shared.

Full design doc: docs/designs/multi-project-management.md.

Changes

New: src/projects.ts (manifest model + resolveProjectResourceNamespaces + mergeNamespaces + isSafeNamespaceSegment), projects.test.ts, learnings-namespace.test.ts.

Modified: roles.ts (ResourceNamespaces gains learnings), pull.ts (role∪project filtering + namespace-aware learnings sync/cleanup), search-index.ts (namespace-aware learnings collector), contribute.ts (learnings landing), init.ts (--project flag → LocalConfig.projects), types.ts (LocalConfig.projects + MemberConfig.projects), index.ts (register --project). Bilingual docs: docs/usage-guide.md / .zh-CN.md.

Security: two Path Traversal findings from automated review addressed with defense-in-depth — project id is validated as a safe path segment both at the manifest boundary (Zod refine on ProjectSchema.id) and at the use-sites (contribute + search-index), since config.yaml's projects field can be hand-edited.

Test plan

tsc --noEmit clean · npm run build OK · 2686 unit tests pass (single-threaded; parallel mode has a pre-existing fs-fixture flakiness unrelated to this change).

Real-CLI end-to-end — two providers, both green

Ran the built CLI against a real remote for each provider: seed a team repo (roles + projects manifest, skills split into common/hai-inference/billing namespaces, learnings split into root + per-project subdirs), then init --project in two business dirs, pull, and verify isolation. Repos created and deleted afterward.

Check TGit (git.woa.com) GitLab (self-hosted CE 16.11, Docker)
init --project hai-inferencecommon-skill + hai-skill, no billing
init --project billingcommon-skill + billing-skill, no hai
work-hai recall index = [hai private, shared root], no billing
work-billing recall index = [billing private, shared root], no hai
config.yaml projects written ([hai-inference] / [billing])
clone + member registration + PR/MR push
no --project (backward compat) → common-skill + shared-root learning only

Two independent providers confirm P1+P2 is provider-agnostic. The P2 learnings isolation — a project's private learnings never surfacing in another project's recall, while the shared root is visible to both — is verified end-to-end on both.

Closes #375 (P1+P2; P3 tracked separately).

@jeff-r2026 jeff-r2026 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

本地验证发现以下两个问题。验证基于 a3a2c7b:npm run build 成功,使用构建后的真实 CLI、隔离 HOME 和本地 bare Git 远端复现,未依赖 mock。

Comment thread src/contribute.ts
// Route into an active-project subdir when there is exactly one, else the
// shared root. `relPath` is the repo-relative learnings path used everywhere.
const learningsSubdir = resolveLearningsSubdir(localConfig);
const relPath = learningsSubdir ? path.posix.join(learningsSubdir, filename) : filename;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] 失败重试需要保留 learning 的项目 namespace

这里引入了项目相对路径 relPath,但 catch 分支仍调用 savePendingLearning(repoPath, filename, content)。后续 flushPendingLearnings 只按文件名重推到 learnings/ 根目录,把项目 learning 变成全队共享内容。

本地真实 CLI 复现:

  1. 配置 projects: [alpha],manifest 中 alpha.resources.learnings: [alpha]
  2. 本地 bare 远端添加退出码为 1 的 pre-receive hook,执行 teamai contribute --file note.md --title alpha-private,输出 Saved locally。
  3. 移除拒绝 hook,执行 teamai pull
  4. git ls-tree -r --name-only main 显示远端同时存在 learnings/alpha/<filename>.mdlearnings/<filename>.md;索引也包含这两份内容。

根目录文件会进入其他项目的 recall,破坏本 PR 的项目隔离。请让 pending 持久化及重试完整保留 namespace,避免失败恢复时降级为共享根目录。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复(commit 91df7f0)。失败重试现在完整保留 namespace,不再降级为共享根目录。

改动:

  • savePendingLearning(repoPath, relPath, content) 的第二参改为相对 learnings/ 的路径(可含 namespace 子目录,如 alpha-notes/foo.md),存到 pending-learnings/<relPath>,保留子目录结构。
  • flushPendingLearnings 改为递归遍历 pending 目录,按相对路径重推,写回 learnings/<relPath>(不再拍平到根)。
  • pushLearningToOrigin 参数改为 relPath 语义,git add learnings/<relPath>
  • contribute catch 分支传 relPath 而非 filename

这样失败-重试后,项目私有 learning 仍落在 learnings/<namespace>/,不会进入其他项目的 recall。

单测:pending-learnings.test.ts 加了两条回归——savePendingLearning 保留子目录、flushPendingLearnings 按 namespace relPath 重推到 learnings/alpha-notes/(断言不落根目录)。

Comment thread src/contribute.ts Outdated
const { buildIndex } = await import('./utils/search-index.js');
await buildIndex({
learningsDir: effectiveLearningsDir,
learningsNamespaces: localConfig.projects ?? [],

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 贡献路径和贡献后索引应使用 manifest 的 learning namespace

这里直接将 localConfig.projects(项目 ID)作为 namespace,resolveLearningsSubdir 也直接使用项目 ID;但 pull 使用的是 manifest 中 resources.learnings 的解析结果。schema 允许两者不同,因此贡献和拉取会操作不同的目录。

本地真实 CLI 复现:

  1. 配置 projects: [alpha],manifest 中 alpha.resources.learnings: [alpha-notes],添加 learnings/alpha-notes/existing.md
  2. 执行 teamai pull:索引包含 existing.md。
  3. 执行 teamai contribute --file note.md --title mapped-contribution:贡献成功,但文件写到 learnings/alpha/;贡献后重建索引使 existing.md 消失。
  4. 再执行 teamai pull:existing.md 恢复,新贡献从索引消失(文件仍在远端的 alpha 目录)。

请让贡献后的索引使用与 pull 相同的 manifest 解析结果,并让贡献落盘遵循该映射;至少单项目、单 learning namespace 的合法配置应保持一致。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复(commit 91df7f0)。根因确实是贡献侧用了项目 ID 而非 manifest 的 learnings namespace。

改动:

  • 新增 resolveActiveLearningsNamespaces(repoPath, activeProjects),从 manifest 的 resources.learnings 解析——与 pull 完全同源。
  • resolveLearningsSubdir 改为 async 并走该解析结果(不再用原始 project ID);rebuildIndexAfterContributelearningsNamespaces 也改用它,保证贡献后重建索引与 pull 一致。

真机验证(TGit,project id=alphaalpha.resources.learnings=[alpha-notes],预置 learnings/alpha-notes/existing.md):

  • teamai contribute 落点 = learnings/alpha-notes/mapped-contribution-*.md(不再是 alpha/)。
  • 贡献后索引 = [alpha notes/mapped contribution, existing alpha note] —— existing.md 不再消失,新贡献进入索引。
  • 远端 tree 仅 learnings/alpha-notes/ 下两文件,无 learnings/alpha/

单测:projects.test.ts 加了 resolveActiveLearningsNamespaces 的 id≠namespace 映射用例。

… isolation

- src/projects.ts: projects manifest model + resolveProjectResourceNamespaces + mergeNamespaces
- roles.ts: ResourceNamespaces gains learnings key (projects-only)
- pull.ts: role∪project namespace filtering; namespace-aware learnings sync+cleanup
- search-index.ts: namespace-aware learnings collector (root shared + active subdirs)
- contribute.ts: learnings landing (single active project subdir, else root)
- init.ts: --project flag → LocalConfig.projects (non-auto, Q1)
- types.ts: LocalConfig.projects + MemberConfig.projects
- tests: projects.test.ts, learnings-namespace.test.ts + roles/tombstone fixups
- docs/designs/multi-project-management.md
@jeff-r2026
jeff-r2026 force-pushed the feat/375-multi-project-p1p2 branch from a3a2c7b to 91df7f0 Compare September 7, 2026 09:12
@jeff-r2026

Copy link
Copy Markdown
Collaborator Author

LGTM

@jeff-r2026
jeff-r2026 merged commit 3c08d78 into Tencent:main Sep 8, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Proposal: 一个团队仓库承载多个项目(project 作为与 role 正交的维度)

1 participant