feat(#375): multi-project management (P1+P2) — project dimension + learnings isolation - #426
Conversation
jeff-r2026
left a comment
There was a problem hiding this comment.
本地验证发现以下两个问题。验证基于 a3a2c7b:npm run build 成功,使用构建后的真实 CLI、隔离 HOME 和本地 bare Git 远端复现,未依赖 mock。
| // 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; |
There was a problem hiding this comment.
[P1] 失败重试需要保留 learning 的项目 namespace
这里引入了项目相对路径 relPath,但 catch 分支仍调用 savePendingLearning(repoPath, filename, content)。后续 flushPendingLearnings 只按文件名重推到 learnings/ 根目录,把项目 learning 变成全队共享内容。
本地真实 CLI 复现:
- 配置
projects: [alpha],manifest 中alpha.resources.learnings: [alpha]。 - 本地 bare 远端添加退出码为 1 的
pre-receivehook,执行teamai contribute --file note.md --title alpha-private,输出 Saved locally。 - 移除拒绝 hook,执行
teamai pull。 git ls-tree -r --name-only main显示远端同时存在learnings/alpha/<filename>.md和learnings/<filename>.md;索引也包含这两份内容。
根目录文件会进入其他项目的 recall,破坏本 PR 的项目隔离。请让 pending 持久化及重试完整保留 namespace,避免失败恢复时降级为共享根目录。
There was a problem hiding this comment.
已修复(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>。contributecatch 分支传relPath而非filename。
这样失败-重试后,项目私有 learning 仍落在 learnings/<namespace>/,不会进入其他项目的 recall。
单测:pending-learnings.test.ts 加了两条回归——savePendingLearning 保留子目录、flushPendingLearnings 按 namespace relPath 重推到 learnings/alpha-notes/(断言不落根目录)。
| const { buildIndex } = await import('./utils/search-index.js'); | ||
| await buildIndex({ | ||
| learningsDir: effectiveLearningsDir, | ||
| learningsNamespaces: localConfig.projects ?? [], |
There was a problem hiding this comment.
[P2] 贡献路径和贡献后索引应使用 manifest 的 learning namespace
这里直接将 localConfig.projects(项目 ID)作为 namespace,resolveLearningsSubdir 也直接使用项目 ID;但 pull 使用的是 manifest 中 resources.learnings 的解析结果。schema 允许两者不同,因此贡献和拉取会操作不同的目录。
本地真实 CLI 复现:
- 配置
projects: [alpha],manifest 中alpha.resources.learnings: [alpha-notes],添加learnings/alpha-notes/existing.md。 - 执行
teamai pull:索引包含 existing.md。 - 执行
teamai contribute --file note.md --title mapped-contribution:贡献成功,但文件写到learnings/alpha/;贡献后重建索引使 existing.md 消失。 - 再执行
teamai pull:existing.md 恢复,新贡献从索引消失(文件仍在远端的 alpha 目录)。
请让贡献后的索引使用与 pull 相同的 manifest 解析结果,并让贡献落盘遵循该映射;至少单项目、单 learning namespace 的合法配置应保持一致。
There was a problem hiding this comment.
已修复(commit 91df7f0)。根因确实是贡献侧用了项目 ID 而非 manifest 的 learnings namespace。
改动:
- 新增
resolveActiveLearningsNamespaces(repoPath, activeProjects),从 manifest 的resources.learnings解析——与pull完全同源。 resolveLearningsSubdir改为 async 并走该解析结果(不再用原始 project ID);rebuildIndexAfterContribute的learningsNamespaces也改用它,保证贡献后重建索引与 pull 一致。
真机验证(TGit,project id=alpha、alpha.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
a3a2c7b to
91df7f0
Compare
|
LGTM |
What & why
Implements issue #375 (P1+P2): one team repo serving multiple projects. Adds a
projectdimension orthogonal torole, declared by the admin inmanifest/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'sprojectis a logical, admin-defined id deciding who knowledge reaches. Depends on #374 P0 (subdirectory/worktreecwd → project root), already merged.Solves three problems, all verified in code:
rolewas one-dimensional (resolveRoleResourceNamespaces), forcing N×M roles for N projects × M functions.recall.projects set/members+push --project— ships as a separate PR.)Design
roleandprojectare 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 underlearnings/<project-id>/.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(ResourceNamespacesgainslearnings),pull.ts(role∪project filtering + namespace-aware learnings sync/cleanup),search-index.ts(namespace-aware learnings collector),contribute.ts(learnings landing),init.ts(--projectflag →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), sinceconfig.yaml'sprojectsfield can be hand-edited.Test plan
tsc --noEmitclean ·npm run buildOK · 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/billingnamespaces, learnings split into root + per-project subdirs), theninit --projectin two business dirs,pull, and verify isolation. Repos created and deleted afterward.init --project hai-inference→common-skill+hai-skill, no billinginit --project billing→common-skill+billing-skill, no hai[hai private, shared root], no billing[billing private, shared root], no haiconfig.yaml projectswritten ([hai-inference]/[billing])--project(backward compat) →common-skill+ shared-root learning onlyTwo 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).