mirror of
https://github.com/Tencent/teamai-cli.git
synced 2026-10-02 03:14:40 +08:00
Merge pull request #426 from jeff-r2026/feat/375-multi-project-p1p2
feat(#375): multi-project management (P1+P2) — project dimension + learnings isolation
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
# Design: multi-project management — `project` as a dimension orthogonal to `role`
|
||||
|
||||
> Status: **proposed** (this doc ships with the first implementation PR for issue #375).
|
||||
> Phasing: P1+P2 in one PR, P3 separate, P4 docs. See "Phasing" below.
|
||||
|
||||
## Problem
|
||||
|
||||
One team repo often serves several projects, but resource distribution today has
|
||||
only two knobs:
|
||||
|
||||
| Mechanism | Carrier | Granularity | Controlled by |
|
||||
|---|---|---|---|
|
||||
| roles | `manifest/roles.yaml` | namespace (directory-level) | team admin |
|
||||
| tags | `tags.yaml` | single skill / rule | team tagging + personal subscription |
|
||||
|
||||
Three concrete problems appear at multi-project scale (all verifiable in current code):
|
||||
|
||||
- **P1 — semantics collapsed to one dimension.** `resolveRoleResourceNamespaces()`
|
||||
(`src/roles.ts:130`) makes `role` carry both *job function* and *resource bundle*.
|
||||
Expressing "HAI dev", "HAI PM", "billing dev" forces one role per
|
||||
`project × function` pair → N projects × M functions = N×M roles, and the
|
||||
manifest becomes unmaintainable.
|
||||
- **P2 — learnings have zero isolation (the painful one).** `src/roles.ts:13-15`
|
||||
documents that the learnings namespace is ignored; `teamai contribute` writes
|
||||
flat into `learnings/` (`src/contribute.ts`); the index builder uses the
|
||||
**non-recursive** `collectFlatMdEntries` (`src/utils/search-index.ts:549`), so
|
||||
subdirectories are never scanned. Result: project A's learnings surface in
|
||||
project B members' `teamai recall`, and recall signal-to-noise degrades linearly
|
||||
with project count. **`project` scope does not fix this** — two directories clone
|
||||
the *same* team repo, so `learnings/` stays one flat pile.
|
||||
- **P3 — member registration is a placeholder.** `MemberConfigSchema`
|
||||
(`src/types.ts:268`) has a `role?` field, but `init.ts` writes the member file
|
||||
only when it doesn't yet exist (`isNewMember`, `src/init.ts:1177`) and records
|
||||
neither role nor project. The team side cannot answer "who is on project X".
|
||||
|
||||
**Why tags aren't enough.** Tags are a personal local subscription (stored as
|
||||
`subscribedTags` in `config.yaml`, `src/types.ts:316`): invisible and
|
||||
unqueryable team-side, scoped to skill/rule only (not learnings/claudemd/docs),
|
||||
with no "membership" semantics. Tags express "I'm interested in a topic", not
|
||||
"I belong to this project".
|
||||
|
||||
## This is NOT issue #374's `project`
|
||||
|
||||
The two concepts share a word and must not be conflated in code:
|
||||
|
||||
| | #374 project | #375 project (this doc) |
|
||||
|---|---|---|
|
||||
| what it is | a **working directory** (path slug) | a team-defined **logical project** (manifest id) |
|
||||
| decided by | path → slug, pure function | admin, in `manifest/projects.yaml` |
|
||||
| solves | *where* machine-local data lives | *who* team knowledge is distributed to |
|
||||
| example | `-Users-x-work-hai` | `hai-inference` |
|
||||
|
||||
They compose orthogonally. After #374, this doc's field lives at
|
||||
`~/.teamai/projects/<path-slug>/config.yaml` as `projects: [<logical-id>]`; one
|
||||
path-slug maps to 0..N logical projects.
|
||||
|
||||
## Dependency on #374
|
||||
|
||||
The main path requires `cwd → project root` to resolve correctly in subdirectories
|
||||
and git worktrees. #374 found that pre-P0 `detectProjectConfig()` only inspected
|
||||
the cwd's own layer, silently falling back to user scope inside a worktree. **#374
|
||||
P0 already fixed this** (`detectProjectConfig()` now retries at the git
|
||||
`workspaceRoot`; `resolveAnchors()` exists — see `docs/designs/data-directory-layout.md`),
|
||||
and P0/P1-1/P1-2 are merged to `main`. The distribution feature in this doc does
|
||||
not depend on the remaining #374 phases (P1-3 auto-migration, P2 self-slimming,
|
||||
P3), which relocate data rather than distribute knowledge.
|
||||
|
||||
## Solution: `project` as a second, orthogonal dimension
|
||||
|
||||
New `manifest/projects.yaml`, sibling to `roles.yaml`, neither referencing the other:
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
projects:
|
||||
- id: hai-inference
|
||||
name: HAI Inference Platform
|
||||
resources:
|
||||
knowledge: [hai-inference]
|
||||
skills: [hai-inference]
|
||||
learnings: [hai-inference] # makes the learnings namespace actually take effect
|
||||
```
|
||||
|
||||
Directory layout reuses the existing namespace convention, adding one learnings layer:
|
||||
|
||||
```text
|
||||
team-repo/
|
||||
manifest/ roles.yaml projects.yaml
|
||||
skills/ common/ hai-inference/ billing/
|
||||
claudemd/ common/ hai-inference/ billing/
|
||||
learnings/
|
||||
team-general-2026-03-15.md # root = shared with the whole team (unchanged)
|
||||
hai-inference/ # NEW: project-private learnings
|
||||
billing/
|
||||
```
|
||||
|
||||
**Key invariant: `.md` at the `learnings/` root is always visible to everyone.**
|
||||
This is both the backward-compatibility pivot (today every learning is at the
|
||||
root → zero migration) and the natural home for cross-project shared experience.
|
||||
|
||||
Namespace resolution becomes a union:
|
||||
|
||||
```
|
||||
activeNamespaces = resolveRole(primaryRole, additionalRoles)
|
||||
∪ resolveProjects(activeProjects)
|
||||
```
|
||||
|
||||
`roles.yaml`'s `learnings` field **stays ignored** — the learnings namespace is
|
||||
provided *only* by projects, otherwise P1's semantic confusion returns.
|
||||
|
||||
**`role` and `project` are orthogonal, not competing — there is no priority
|
||||
override between them.** They live on different planes: a "HAI dev" legitimately
|
||||
needs generic dev skills (from `role`) *plus* HAI-specific knowledge (from
|
||||
`project`), so the resolver takes the **union**, never one-overrides-the-other.
|
||||
Concretely:
|
||||
|
||||
- **learnings** — `project` alone owns this dimension (`role` contributes nothing),
|
||||
so "project wins" is already a hard invariant here, with nothing to override.
|
||||
- **skills / knowledge** — the only place the two dimensions could "cross" is a
|
||||
**same-named** resource in a role namespace and a project namespace. That case
|
||||
is already a hard error today (`src/pull.ts:204` `Duplicate skill ... found in
|
||||
active namespaces`), resolved by the **admin** disambiguating names in the
|
||||
manifest — deliberately **not** by a runtime priority rule. A well-formed
|
||||
manifest keeps role and project namespaces non-overlapping, so the crossing is
|
||||
eliminated at the source rather than arbitrated at pull time.
|
||||
|
||||
Implementers should therefore **not** add any project-over-role precedence logic:
|
||||
the union + existing duplicate-guard is the whole model.
|
||||
|
||||
### Data model
|
||||
|
||||
`src/projects.ts` (new), mirroring `src/roles.ts`:
|
||||
|
||||
- `ProjectResourceNamespacesSchema` = `{ knowledge, skills, learnings }` (all
|
||||
`string[]`; here `learnings` is **active**, unlike in roles).
|
||||
- `ProjectSchema` = `{ id, name, description?, resources }`.
|
||||
- `ProjectsManifestSchema` = `{ version, projects: Project[] }` (may be **empty**,
|
||||
unlike roles' `.min(1)` — a repo can define projects without requiring them).
|
||||
- `loadProjectsManifest(repoPath)` returns `null` when
|
||||
`manifest/projects.yaml` is absent (roles throws; projects is optional).
|
||||
- `resolveProjectResourceNamespaces({ manifest, activeProjects })` →
|
||||
`{ knowledge, skills, learnings }`, dedup within each type.
|
||||
|
||||
`ResourceNamespaces` (`src/roles.ts:34`) gains a `learnings` key:
|
||||
`Record<'knowledge' | 'skills' | 'learnings', string[]>`. This is a type-level
|
||||
change that ripples into `pull.ts` and `search-index.ts` (see below). Role
|
||||
resolution keeps `learnings: []`; only project resolution populates it.
|
||||
|
||||
### Entry point: follows the working directory, no join/leave
|
||||
|
||||
Project identity is set by the working directory, exactly like `--role`:
|
||||
|
||||
```bash
|
||||
cd ~/work/hai-inference && teamai init <team-repo> --project hai-inference
|
||||
cd ~/work/billing && teamai init <team-repo> --project billing
|
||||
```
|
||||
|
||||
There is deliberately **no `projects join/leave`** command. The tags analogy that
|
||||
suggested it does not hold: tags express a personal preference with no external
|
||||
basis and need an explicit toggle; a project has an external basis (cwd) and is
|
||||
already known at `init` time. Recorded here so it isn't re-proposed.
|
||||
|
||||
`teamai projects set/list/members` are kept as low-frequency after-the-fact
|
||||
correction/query, mirroring `teamai roles set` relative to `init --role`
|
||||
(registered in `src/index.ts` next to the `roles` command at `src/index.ts:206`).
|
||||
|
||||
### Two `projects[]`, two deliberately-different semantics
|
||||
|
||||
| Location | Contents | Semantics |
|
||||
|---|---|---|
|
||||
| `<project>/config.yaml` (`LocalConfig.projects`) | projects active in this directory | **overwrite** |
|
||||
| `members/<user>.yaml` (`MemberConfig.projects`) | every project I've participated in | **append + dedup** |
|
||||
|
||||
Running `init` in two directories naturally lists two projects on the roster,
|
||||
while each directory syncs only its own. This requires changing the
|
||||
`isNewMember`-gated write at `src/init.ts:1177` (currently "write only if the file
|
||||
doesn't exist") to always **merge** project membership into the existing file.
|
||||
|
||||
`LocalConfig.projects` is an **array**: monorepos (one repo, multiple sub-projects)
|
||||
and cross-project functions (platform/infra people who need multi-project
|
||||
experience) both need it, without affecting the single-project main path.
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
| Scenario | Behavior |
|
||||
|---|---|
|
||||
| no `manifest/projects.yaml` | identical to today; every project code path short-circuits (`loadProjectsManifest` → `null`) |
|
||||
| manifest present, directory activates no project | role namespaces + `learnings/` root only |
|
||||
| old `members/<user>.yaml` / `config.yaml` without `projects` | parsed as `[]`, no error |
|
||||
| existing flat `learnings/*.md` | all stay at root = shared with everyone, **zero migration** |
|
||||
|
||||
## Open questions — resolved
|
||||
|
||||
- **Q1: auto-activate when the manifest has exactly one project?** Roles auto-select
|
||||
the sole role (`src/init.ts:85`). Projects **do not** auto-activate: a role is
|
||||
"you must have one", a project is "you may belong to none" — an infra member may
|
||||
need only `common`, and auto-activation would push project-private learnings to
|
||||
them, recreating P2. (Small change to relax later if teams turn out to be
|
||||
one-repo-one-project in practice.)
|
||||
- **Q2: explicit `learnings/shared/` vs "root = shared"?** Keep **root = shared**:
|
||||
zero migration outweighs the slightly uneven directory listing.
|
||||
|
||||
## Affected surface
|
||||
|
||||
**New:** `src/projects.ts`, `src/projects-cmd.ts`, and their unit tests.
|
||||
|
||||
**Modified:**
|
||||
- `src/types.ts` — `MemberConfigSchema` + `LocalConfigSchema` each gain `projects`.
|
||||
- `src/roles.ts` — `ResourceNamespaces` gains the `learnings` key.
|
||||
- `src/pull.ts` — merge role ∪ project namespaces (`src/pull.ts:135`); filter
|
||||
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/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`.
|
||||
- `src/init.ts` — `--project` flag + append member registration (`src/init.ts:1177`).
|
||||
- `src/bootstrap.ts`, `src/members.ts`, `src/index.ts` — wiring.
|
||||
|
||||
**Docs:** README (bilingual) + usage-guide (bilingual) per the CLAUDE.md sync rule.
|
||||
|
||||
## Phasing
|
||||
|
||||
| Phase | Scope |
|
||||
|---|---|
|
||||
| P1 | `projects.ts` + manifest + `LocalConfig.projects` + pull filtering of skills/rules/claudemd |
|
||||
| P2 | learnings namespace + namespace-aware index collector + contribute landing + pull cleanup |
|
||||
| P3 | member append-registration + query + `projects set/members` + push `--project` |
|
||||
| P4 | bilingual docs |
|
||||
|
||||
**P1 and P2 ship as one PR.** They share the namespace-resolution change; splitting
|
||||
them leaves an awkward intermediate state — projects isolated but learnings still
|
||||
cross-talking — which is exactly the most painful half (P2). P3 is a separate PR.
|
||||
|
||||
## Relationship to other issues
|
||||
|
||||
- **Depends on #374** (subdirectory/worktree `cwd → project root`; P0 satisfies it).
|
||||
- **Aligned with #341 (Go management backend).** #341's model is
|
||||
`Organization → Team → Project` with per-layer resource override and project
|
||||
member roles; the `project` semantics match. `projects.yaml` is the declarative
|
||||
representation the backend can later import/export. This doc deliberately omits
|
||||
Organization/Team layers: in Git mode a team repo *is* one Team, and the extra
|
||||
levels have no carrier.
|
||||
|
||||
### Explicitly out of scope
|
||||
|
||||
`teamai projects join/leave`; Organization/Team hierarchy; auto-activation of a
|
||||
lone project; migrating existing flat learnings into a `shared/` subdirectory.
|
||||
|
||||
## End-to-end test plan (real CLI, per CLAUDE.md — type-check/unit tests don't count)
|
||||
|
||||
1. **No manifest → unchanged.** Repo without `projects.yaml`: `init`/`pull`/`recall`
|
||||
behave exactly as today (regression baseline).
|
||||
2. **Admin authoring.** `teamai projects init` / edit `projects.yaml` with two
|
||||
projects → `teamai projects list` shows both.
|
||||
3. **Per-directory activation.** `init --project hai-inference` in dir A and
|
||||
`init --project billing` in dir B → each `config.yaml` has its own `projects`.
|
||||
4. **Skill/rule/claudemd isolation.** After `pull`, dir A has only
|
||||
`common` + `hai-inference` resources; dir B has only `common` + `billing`.
|
||||
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.
|
||||
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/<user>.yaml` lists
|
||||
both projects (append+dedup), while each dir syncs only its own.
|
||||
9. **Backward-compat roster/config.** An old member file / config without
|
||||
`projects` parses without error and is upgraded on next `init`.
|
||||
10. **`projects set/members`.** After-the-fact `teamai projects set` corrects the
|
||||
active project; `teamai projects members hai-inference` lists its members.
|
||||
+47
-1
@@ -144,8 +144,54 @@ teamai init https://github.com/yourorg/yourrepo --scope project --role hai_dev -
|
||||
| `--inherit-user-scope` | Project scope only: also sync safe user resources and search user knowledge |
|
||||
| `--no-inherit-user-scope` | Disable previously configured user-scope inheritance for this project |
|
||||
| `--role <id>` | Directly specify the primary role, skipping the interactive role prompt |
|
||||
| `--project <ids>` | Active logical project(s) from `manifest/projects.yaml` (comma-separated). Scopes which project resources and learnings this directory syncs. See [Multi-project](#multi-project-project-as-a-dimension-orthogonal-to-role) below |
|
||||
| `--force` | Overwrite existing config, skipping confirmation prompts |
|
||||
|
||||
#### Multi-project: `project` as a dimension orthogonal to `role`
|
||||
|
||||
When one team repo serves several projects, `project` is a second dispatch
|
||||
dimension alongside `role`, declared by the admin in `manifest/projects.yaml`.
|
||||
`role` answers "what is my job function"; `project` answers "which project this
|
||||
directory belongs to". They are orthogonal and additive — a member gets the
|
||||
**union** of their role namespaces and their active project namespaces (there is
|
||||
no override between the two).
|
||||
|
||||
Project identity follows the working directory, exactly like `--role`:
|
||||
|
||||
```bash
|
||||
cd ~/work/hai-inference && teamai init <team-repo> --project hai-inference
|
||||
cd ~/work/billing && teamai init <team-repo> --project billing
|
||||
```
|
||||
|
||||
Each directory then syncs only its own project's skills/rules/CLAUDE.md and
|
||||
learnings. Key points:
|
||||
|
||||
- **Learnings isolation.** `learnings/` at the repo root is shared with the whole
|
||||
team; a project's private learnings live under `learnings/<project-id>/` and
|
||||
only surface in `teamai recall` for members of that project. A directory with
|
||||
no active project sees the shared root only.
|
||||
- **Not auto-activated.** Unlike a lone role, a lone project is not auto-selected
|
||||
— a member may legitimately belong to no project (they still get `common` and
|
||||
the shared learnings root).
|
||||
- **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.
|
||||
|
||||
`manifest/projects.yaml` example:
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
projects:
|
||||
- id: hai-inference
|
||||
name: HAI Inference
|
||||
resources:
|
||||
knowledge: [hai-inference]
|
||||
skills: [hai-inference]
|
||||
learnings: [hai-inference]
|
||||
```
|
||||
|
||||
Example local config:
|
||||
|
||||
```yaml
|
||||
@@ -327,7 +373,7 @@ teamai pull --dry-run # Dry run, no actual changes
|
||||
|
||||
> Project scope is isolated by default. When the current working directory contains a project-scope `.teamai/config.yaml`, `pull` processes that project and skips user scope unless the local config has `inheritUserScope: true`; in that case it first refreshes the safe user-resource channel. Without a project config in the current directory, `pull` processes user scope. User `env`, MCP definitions, sources, reporting, and writes remain isolated in project mode. Hooks are the one exception: a project scope's hooks are injected into your **HOME** tool settings (`~/.claude/settings.json`, …), not `<projectRoot>`, because the built-in hooks gate on the `cwd` handed to `hook-dispatch` and `~/.claude` always exists so the "installed tool" gate passes (see the Hooks section). Self single-repo mode keeps its hooks in the business repo so they travel on clone.
|
||||
|
||||
With role-based skills enabled, `pull`'s skill sync source becomes the contents of `skills/<namespace>/`, expanded according to `primaryRole + additionalRoles` and flattened into each local AI tool's skills directory. `rules/`, `docs/`, and `learnings/` keep their original global sync behavior.
|
||||
With role-based skills enabled, `pull`'s skill sync source becomes the contents of `skills/<namespace>/`, expanded according to `primaryRole + additionalRoles` and flattened into each local AI tool's skills directory. `rules/` and `docs/` keep their original sync behavior. `learnings/` at the root is shared with everyone, while `learnings/<project-id>/` subdirectories sync only for the directory's active projects (see [Multi-project](#multi-project-project-as-a-dimension-orthogonal-to-role)).
|
||||
|
||||
### Team packages
|
||||
|
||||
|
||||
@@ -143,8 +143,48 @@ teamai init https://github.com/yourorg/yourrepo --scope project --role hai_dev -
|
||||
| `--inherit-user-scope` | 仅 project scope:同时同步安全的 user 资源并检索 user 知识 |
|
||||
| `--no-inherit-user-scope` | 关闭当前项目先前配置的 user scope 继承 |
|
||||
| `--role <id>` | 直接指定 primaryRole,跳过角色交互选择 |
|
||||
| `--project <ids>` | 从 `manifest/projects.yaml` 激活的逻辑项目(逗号分隔)。决定本目录同步哪些项目的资源与 learnings。详见下方 [多项目](#多项目project-作为与-role-正交的维度) |
|
||||
| `--force` | 覆盖已有配置,跳过确认提示 |
|
||||
|
||||
#### 多项目:`project` 作为与 `role` 正交的维度
|
||||
|
||||
当一个团队仓库承载多个项目时,`project` 是与 `role` 平级的第二个分发维度,由
|
||||
admin 在 `manifest/projects.yaml` 中声明。`role` 回答「我的职能是什么」,`project`
|
||||
回答「这个目录属于哪个项目」。两者正交且相加 —— 成员得到的是其 role namespace
|
||||
与激活 project namespace 的**并集**(两者之间没有覆盖关系)。
|
||||
|
||||
项目身份跟着工作目录走,与 `--role` 完全同一个模式:
|
||||
|
||||
```bash
|
||||
cd ~/work/hai-inference && teamai init <team-repo> --project hai-inference
|
||||
cd ~/work/billing && teamai init <team-repo> --project billing
|
||||
```
|
||||
|
||||
此后每个目录只同步自己项目的 skills/rules/CLAUDE.md 与 learnings。要点:
|
||||
|
||||
- **learnings 隔离。** 仓库 `learnings/` 根目录对全团队共享;项目私有经验放在
|
||||
`learnings/<project-id>/` 子目录下,只对该项目成员的 `teamai recall` 可见。
|
||||
未激活任何项目的目录只能看到共享的根目录。
|
||||
- **不自动激活。** 与「唯一 role 会被自动选中」不同,唯一的 project 不会自动选中
|
||||
—— 成员可以不属于任何项目(仍能获得 `common` 与共享的 learnings 根)。
|
||||
- **向后兼容。** 没有 `manifest/projects.yaml` 的仓库行为与之前完全一致;现存扁平
|
||||
的 `learnings/*.md` 继续对所有人共享(零迁移)。
|
||||
- **`teamai contribute`** 在恰好激活一个项目时,把经验落到该项目子目录,否则落到
|
||||
共享的根目录。
|
||||
|
||||
`manifest/projects.yaml` 示例:
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
projects:
|
||||
- id: hai-inference
|
||||
name: HAI Inference
|
||||
resources:
|
||||
knowledge: [hai-inference]
|
||||
skills: [hai-inference]
|
||||
learnings: [hai-inference]
|
||||
```
|
||||
|
||||
本地配置示例:
|
||||
|
||||
```yaml
|
||||
@@ -326,7 +366,7 @@ teamai pull --dry-run # 试运行,不实际修改
|
||||
|
||||
> Project scope 默认与 user scope 隔离。当前工作目录包含 project scope 的 `.teamai/config.yaml` 时,`pull` 会处理该项目并跳过 user scope;仅当本地配置包含 `inheritUserScope: true` 时,才会先刷新安全的 user 资源通道。当前目录没有 project 配置时,`pull` 处理 user scope。project 模式下,user 的 `env`、MCP 定义、sources、reporting 和写入行为仍保持隔离。hooks 是唯一例外:project scope 的 hooks 会注入到你的 **HOME** 工具设置(`~/.claude/settings.json` 等),而非 `<projectRoot>`——因为内置 hooks 依据传给 `hook-dispatch` 的 `cwd` 门控,且 `~/.claude` 恒存在、能通过「已安装工具」门槛(详见 Hooks 章节)。self 单仓模式则把 hooks 保留在业务仓库里,随 clone 传播。
|
||||
|
||||
启用角色化 skills 后,`pull` 的 skills 同步来源会变成 `skills/<namespace>/` 中的内容,按 `primaryRole + additionalRoles` 展开对应的 namespace,拍平安装到本地各 AI 工具 skills 目录。`rules/`、`docs/`、`learnings/` 仍然保持原有全局同步逻辑。
|
||||
启用角色化 skills 后,`pull` 的 skills 同步来源会变成 `skills/<namespace>/` 中的内容,按 `primaryRole + additionalRoles` 展开对应的 namespace,拍平安装到本地各 AI 工具 skills 目录。`rules/`、`docs/` 仍然保持原有同步逻辑。`learnings/` 根目录对所有人共享,而 `learnings/<project-id>/` 子目录只对本目录激活的项目同步(见 [多项目](#多项目project-作为与-role-正交的维度))。
|
||||
|
||||
### 团队包
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import fse from 'fs-extra';
|
||||
|
||||
vi.mock('../utils/logger.js', () => ({
|
||||
log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() },
|
||||
}));
|
||||
|
||||
import { buildIndex, loadIndex } from '../utils/search-index.js';
|
||||
|
||||
describe('buildIndex — learnings namespace isolation', () => {
|
||||
let tmpDir: string;
|
||||
let learningsDir: string;
|
||||
let indexPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-learn-ns-'));
|
||||
learningsDir = path.join(tmpDir, 'learnings');
|
||||
indexPath = path.join(tmpDir, 'search-index.json');
|
||||
// Root-level shared learning
|
||||
await fse.ensureDir(learningsDir);
|
||||
await fse.writeFile(path.join(learningsDir, 'shared.md'), '---\ntitle: shared root learning\n---\nshared body');
|
||||
// Project-private learnings under subdirectories
|
||||
await fse.ensureDir(path.join(learningsDir, 'hai-inference'));
|
||||
await fse.writeFile(path.join(learningsDir, 'hai-inference', 'deploy.md'), '---\ntitle: hai deploy note\n---\nhai body');
|
||||
await fse.ensureDir(path.join(learningsDir, 'billing'));
|
||||
await fse.writeFile(path.join(learningsDir, 'billing', 'invoice.md'), '---\ntitle: billing invoice note\n---\nbilling body');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fse.remove(tmpDir);
|
||||
});
|
||||
|
||||
const titles = async (): Promise<string[]> => {
|
||||
const index = await loadIndex(indexPath);
|
||||
return (index?.entries ?? []).map((e) => e.title).sort();
|
||||
};
|
||||
|
||||
it('indexes only the shared root when no namespaces are active', async () => {
|
||||
await buildIndex({ learningsDir, learningsNamespaces: [], indexPath });
|
||||
expect(await titles()).toEqual(['shared root learning']);
|
||||
});
|
||||
|
||||
it('includes an active project subdir plus the shared root, excluding other projects', async () => {
|
||||
await buildIndex({ learningsDir, learningsNamespaces: ['hai-inference'], indexPath });
|
||||
expect(await titles()).toEqual(['hai deploy note', 'shared root learning']);
|
||||
});
|
||||
|
||||
it('includes multiple active projects', async () => {
|
||||
await buildIndex({ learningsDir, learningsNamespaces: ['hai-inference', 'billing'], indexPath });
|
||||
expect(await titles()).toEqual(['billing invoice note', 'hai deploy note', 'shared root learning']);
|
||||
});
|
||||
|
||||
it('root-only (undefined namespaces) matches legacy flat behavior', async () => {
|
||||
await buildIndex({ learningsDir, indexPath });
|
||||
expect(await titles()).toEqual(['shared root learning']);
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,16 @@ describe('savePendingLearning', () => {
|
||||
const written = fs.readFileSync(path.join(pendingDir, filename), 'utf-8');
|
||||
expect(written).toBe(content);
|
||||
});
|
||||
|
||||
it('preserves a namespace subdirectory in relPath (PR #426 P1 regression)', async () => {
|
||||
const relPath = path.join('alpha-notes', 'session-2026-01-01-abc123.md');
|
||||
const content = '# Project-private learning';
|
||||
await savePendingLearning(repoPath, relPath, content);
|
||||
|
||||
const pendingDir = pendingLearningsDir(repoPath);
|
||||
const written = fs.readFileSync(path.join(pendingDir, relPath), 'utf-8');
|
||||
expect(written).toBe(content);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flushPendingLearnings', () => {
|
||||
@@ -140,6 +150,25 @@ describe('flushPendingLearnings', () => {
|
||||
expect(remaining.length).toBe(2);
|
||||
});
|
||||
|
||||
it('re-pushes a namespaced pending learning into its subdir (PR #426 P1 regression)', async () => {
|
||||
const relPath = path.join('alpha-notes', 'notes-2026-01-01-xyz.md');
|
||||
fs.mkdirSync(path.join(pendingDir, 'alpha-notes'), { recursive: true });
|
||||
fs.writeFileSync(path.join(pendingDir, relPath), '# Alpha private', 'utf-8');
|
||||
|
||||
vi.mocked(pushLearningToOrigin).mockResolvedValueOnce(true);
|
||||
|
||||
const result = await flushPendingLearnings(repoPath, 'alice');
|
||||
|
||||
expect(result).toBe(1);
|
||||
// pushed with the namespace-preserving relPath, not the bare filename
|
||||
expect(pushLearningToOrigin).toHaveBeenCalledWith(repoPath, relPath, expect.any(String));
|
||||
// landed under learnings/alpha-notes/, NOT the shared root
|
||||
expect(fs.existsSync(path.join(repoPath, 'learnings', 'alpha-notes', 'notes-2026-01-01-xyz.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(repoPath, 'learnings', 'notes-2026-01-01-xyz.md'))).toBe(false);
|
||||
// pending copy removed after success
|
||||
expect(fs.existsSync(path.join(pendingDir, relPath))).toBe(false);
|
||||
});
|
||||
|
||||
it('skips entries starting with "."', async () => {
|
||||
fs.mkdirSync(pendingDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(pendingDir, '.DS_Store'), 'binary garbage', 'utf-8');
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
loadProjectsManifest,
|
||||
saveProjectsManifest,
|
||||
findProject,
|
||||
listProjectIds,
|
||||
resolveProjectResourceNamespaces,
|
||||
resolveActiveLearningsNamespaces,
|
||||
mergeNamespaces,
|
||||
} from '../projects.js';
|
||||
import type { ProjectsManifest } from '../projects.js';
|
||||
import type { ResourceNamespaces } from '../roles.js';
|
||||
|
||||
function writeManifest(content: string): string {
|
||||
const repoDir = mkdtempSync(path.join(os.tmpdir(), 'teamai-projects-'));
|
||||
const manifestDir = path.join(repoDir, 'manifest');
|
||||
mkdirSync(manifestDir, { recursive: true });
|
||||
writeFileSync(path.join(manifestDir, 'projects.yaml'), content, 'utf-8');
|
||||
return repoDir;
|
||||
}
|
||||
|
||||
describe('loadProjectsManifest', () => {
|
||||
it('returns null when the manifest is absent (projects are optional)', async () => {
|
||||
const repoDir = mkdtempSync(path.join(os.tmpdir(), 'teamai-noproj-'));
|
||||
try {
|
||||
expect(await loadProjectsManifest(repoDir)).toBeNull();
|
||||
} finally {
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('parses a valid manifest with learnings as an active dimension', async () => {
|
||||
const repoDir = writeManifest(`
|
||||
version: 1
|
||||
projects:
|
||||
- id: hai-inference
|
||||
name: HAI Inference
|
||||
resources:
|
||||
knowledge: [hai-inference]
|
||||
skills: [hai-inference]
|
||||
learnings: [hai-inference]
|
||||
`);
|
||||
try {
|
||||
const m = await loadProjectsManifest(repoDir);
|
||||
expect(m).not.toBeNull();
|
||||
expect(listProjectIds(m!)).toEqual(['hai-inference']);
|
||||
expect(findProject(m!, 'hai-inference')?.resources.learnings).toEqual(['hai-inference']);
|
||||
} finally {
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts an empty projects list (unlike roles)', async () => {
|
||||
const repoDir = writeManifest(`version: 1\nprojects: []\n`);
|
||||
try {
|
||||
const m = await loadProjectsManifest(repoDir);
|
||||
expect(m!.projects).toEqual([]);
|
||||
} finally {
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults missing resource arrays to empty', async () => {
|
||||
const repoDir = writeManifest(`
|
||||
version: 1
|
||||
projects:
|
||||
- id: billing
|
||||
resources:
|
||||
skills: [billing]
|
||||
`);
|
||||
try {
|
||||
const m = await loadProjectsManifest(repoDir);
|
||||
const p = findProject(m!, 'billing')!;
|
||||
expect(p.resources.skills).toEqual(['billing']);
|
||||
expect(p.resources.knowledge).toEqual([]);
|
||||
expect(p.resources.learnings).toEqual([]);
|
||||
} finally {
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects duplicate project ids', async () => {
|
||||
const repoDir = writeManifest(`
|
||||
version: 1
|
||||
projects:
|
||||
- id: dup
|
||||
resources: { skills: [a] }
|
||||
- id: dup
|
||||
resources: { skills: [b] }
|
||||
`);
|
||||
try {
|
||||
await expect(loadProjectsManifest(repoDir)).rejects.toThrow(/duplicate project id/i);
|
||||
} finally {
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unknown resource types', async () => {
|
||||
const repoDir = writeManifest(`
|
||||
version: 1
|
||||
projects:
|
||||
- id: x
|
||||
resources: { bogus: [a] }
|
||||
`);
|
||||
try {
|
||||
await expect(loadProjectsManifest(repoDir)).rejects.toThrow(/unknown resource type/i);
|
||||
} finally {
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a project id that is not a safe path segment (traversal guard)', async () => {
|
||||
for (const badId of ['../evil', 'a/b', '..', 'x\\y']) {
|
||||
const repoDir = writeManifest(`
|
||||
version: 1
|
||||
projects:
|
||||
- id: "${badId}"
|
||||
resources: { skills: [a] }
|
||||
`);
|
||||
try {
|
||||
await expect(loadProjectsManifest(repoDir)).rejects.toThrow();
|
||||
} finally {
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('round-trips through save', async () => {
|
||||
const repoDir = mkdtempSync(path.join(os.tmpdir(), 'teamai-projsave-'));
|
||||
try {
|
||||
const manifest: ProjectsManifest = {
|
||||
version: 1,
|
||||
projects: [
|
||||
{ id: 'a', name: 'A', description: '', resources: { knowledge: ['a'], skills: ['a'], learnings: ['a'] } },
|
||||
],
|
||||
};
|
||||
await saveProjectsManifest(repoDir, manifest);
|
||||
const loaded = await loadProjectsManifest(repoDir);
|
||||
expect(loaded).toEqual(manifest);
|
||||
} finally {
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProjectResourceNamespaces', () => {
|
||||
const manifest: ProjectsManifest = {
|
||||
version: 1,
|
||||
projects: [
|
||||
{ id: 'hai', name: '', description: '', resources: { knowledge: ['common', 'hai'], skills: ['common', 'hai'], learnings: ['hai'] } },
|
||||
{ id: 'billing', name: '', description: '', resources: { knowledge: ['common', 'billing'], skills: ['billing'], learnings: ['billing'] } },
|
||||
],
|
||||
};
|
||||
|
||||
it('resolves a single active project', () => {
|
||||
expect(resolveProjectResourceNamespaces({ manifest, activeProjects: ['hai'] })).toEqual({
|
||||
knowledge: ['common', 'hai'],
|
||||
skills: ['common', 'hai'],
|
||||
learnings: ['hai'],
|
||||
});
|
||||
});
|
||||
|
||||
it('unions and dedupes across multiple active projects', () => {
|
||||
expect(resolveProjectResourceNamespaces({ manifest, activeProjects: ['hai', 'billing'] })).toEqual({
|
||||
knowledge: ['common', 'hai', 'billing'],
|
||||
skills: ['common', 'hai', 'billing'],
|
||||
learnings: ['hai', 'billing'],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty sets for no active projects', () => {
|
||||
expect(resolveProjectResourceNamespaces({ manifest, activeProjects: [] })).toEqual({
|
||||
knowledge: [],
|
||||
skills: [],
|
||||
learnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on an unknown active project', () => {
|
||||
expect(() => resolveProjectResourceNamespaces({ manifest, activeProjects: ['nope'] })).toThrow(/unknown project/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeNamespaces', () => {
|
||||
const role: ResourceNamespaces = { knowledge: ['common', 'dev'], skills: ['common', 'dev'], learnings: [] };
|
||||
const project = { knowledge: ['common', 'hai'], skills: ['hai'], learnings: ['hai'] };
|
||||
|
||||
it('unions role and project on knowledge/skills and takes learnings from project only', () => {
|
||||
expect(mergeNamespaces(role, project)).toEqual({
|
||||
knowledge: ['common', 'dev', 'hai'],
|
||||
skills: ['common', 'dev', 'hai'],
|
||||
learnings: ['hai'],
|
||||
});
|
||||
});
|
||||
|
||||
it('is a no-op union when project contributes nothing', () => {
|
||||
expect(mergeNamespaces(role, { knowledge: [], skills: [], learnings: [] })).toEqual({
|
||||
knowledge: ['common', 'dev'],
|
||||
skills: ['common', 'dev'],
|
||||
learnings: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveActiveLearningsNamespaces', () => {
|
||||
it('maps project id to its manifest learnings namespace (id may differ from namespace)', async () => {
|
||||
// Regression for PR #426 review P2: contribute must route by the manifest
|
||||
// learnings namespace (alpha-notes), NOT the raw project id (alpha).
|
||||
const repoDir = writeManifest(`
|
||||
version: 1
|
||||
projects:
|
||||
- id: alpha
|
||||
resources:
|
||||
learnings: [alpha-notes]
|
||||
`);
|
||||
try {
|
||||
expect(await resolveActiveLearningsNamespaces(repoDir, ['alpha'])).toEqual(['alpha-notes']);
|
||||
} finally {
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns [] with no active project, no manifest, or no learnings namespace', async () => {
|
||||
const noManifest = mkdtempSync(path.join(os.tmpdir(), 'teamai-noman-'));
|
||||
const noLearnings = writeManifest(`
|
||||
version: 1
|
||||
projects:
|
||||
- id: beta
|
||||
resources:
|
||||
skills: [beta]
|
||||
`);
|
||||
try {
|
||||
expect(await resolveActiveLearningsNamespaces(noManifest, ['x'])).toEqual([]);
|
||||
expect(await resolveActiveLearningsNamespaces(noLearnings, [])).toEqual([]);
|
||||
expect(await resolveActiveLearningsNamespaces(noLearnings, ['beta'])).toEqual([]);
|
||||
} finally {
|
||||
rmSync(noManifest, { recursive: true, force: true });
|
||||
rmSync(noLearnings, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -355,7 +355,7 @@ describe('pull role-aware sync and cleanup', () => {
|
||||
|
||||
await expect(scanRoleAwareSkills(
|
||||
localConfig,
|
||||
{ knowledge: ['common', 'hai'], skills: ['common', 'hai'] },
|
||||
{ knowledge: ['common', 'hai'], skills: ['common', 'hai'], learnings: [] },
|
||||
)).rejects.toThrow(/Duplicate skill "shared-skill"/);
|
||||
});
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ describe('resolveRoleResourceNamespaces', () => {
|
||||
expect(resolveRoleResourceNamespaces({ manifest, primaryRole: 'hai', additionalRoles: [] })).toEqual({
|
||||
knowledge: ['common', 'hai'],
|
||||
skills: ['common', 'hai'],
|
||||
learnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,6 +159,7 @@ describe('resolveRoleResourceNamespaces', () => {
|
||||
expect(resolveRoleResourceNamespaces({ manifest, primaryRole: 'hai', additionalRoles: ['pm', 'thpc'] })).toEqual({
|
||||
knowledge: ['common', 'hai', 'pm', 'thpc'],
|
||||
skills: ['common', 'hai', 'pm', 'thpc'],
|
||||
learnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,6 +167,7 @@ describe('resolveRoleResourceNamespaces', () => {
|
||||
expect(resolveRoleResourceNamespaces({ manifest, primaryRole: 'hai', additionalRoles: ['pm', 'hai'] })).toEqual({
|
||||
knowledge: ['common', 'hai', 'pm'],
|
||||
skills: ['common', 'hai', 'pm'],
|
||||
learnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+56
-8
@@ -9,6 +9,7 @@ import { ensureDir, pathExists } from './utils/fs.js';
|
||||
import { log, spinner } from './utils/logger.js';
|
||||
import { markContributed } from './contribute-check.js';
|
||||
import { savePendingLearning } from './utils/pending-learnings.js';
|
||||
import { isSafeNamespaceSegment, resolveActiveLearningsNamespaces } from './projects.js';
|
||||
import type { GlobalOptions, LocalConfig } from './types.js';
|
||||
import { LEARNINGS_LOCAL_DIR, getDataHome } from './types.js';
|
||||
|
||||
@@ -18,6 +19,35 @@ import { LEARNINGS_LOCAL_DIR, getDataHome } from './types.js';
|
||||
* `recall` only picks it up after the next `teamai pull` rebuilds the index (#85).
|
||||
* Mirrors the per-scope indexing pull.ts does after syncing learnings.
|
||||
*/
|
||||
/**
|
||||
* Decide which learnings subdirectory a contribution lands in — resolved from
|
||||
* the manifest's `resources.learnings`, the SAME mapping `pull` indexes by (NOT
|
||||
* the raw project id, which the schema allows to differ). Async because it reads
|
||||
* the manifest.
|
||||
*
|
||||
* - Exactly one active learnings namespace → that namespace's subdir (isolated).
|
||||
* - 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.
|
||||
*/
|
||||
async function resolveLearningsSubdir(localConfig: LocalConfig): Promise<string> {
|
||||
const namespaces = await resolveActiveLearningsNamespaces(
|
||||
localConfig.repo.localPath,
|
||||
localConfig.projects ?? [],
|
||||
);
|
||||
const sub = namespaces.length === 1 ? namespaces[0] : '';
|
||||
// Defense-in-depth: the namespace is a path component here. It is validated at
|
||||
// the manifest boundary, but refuse anything that isn't a safe single segment
|
||||
// rather than let it escape the learnings/ directory.
|
||||
if (sub && !isSafeNamespaceSegment(sub)) {
|
||||
throw new Error(`Invalid learnings namespace "${sub}": must not contain path separators or '..'`);
|
||||
}
|
||||
return sub;
|
||||
}
|
||||
|
||||
async function rebuildIndexAfterContribute(localConfig: LocalConfig): Promise<void> {
|
||||
const repoPath = localConfig.repo.localPath;
|
||||
const learningsRepoDir = path.join(repoPath, 'learnings');
|
||||
@@ -46,6 +76,9 @@ async function rebuildIndexAfterContribute(localConfig: LocalConfig): Promise<vo
|
||||
const { buildIndex } = await import('./utils/search-index.js');
|
||||
await buildIndex({
|
||||
learningsDir: effectiveLearningsDir,
|
||||
// Manifest-resolved namespaces — MUST match what pull indexes by, or a
|
||||
// contribute-time rebuild drops the project's other learnings from recall.
|
||||
learningsNamespaces: await resolveActiveLearningsNamespaces(repoPath, localConfig.projects ?? []),
|
||||
docsDir: (await pathExists(docsRepoDir)) ? docsRepoDir : undefined,
|
||||
rulesDir: (await pathExists(rulesRepoDir)) ? rulesRepoDir : undefined,
|
||||
skillsDir: (await pathExists(skillsRepoDir)) ? skillsRepoDir : undefined,
|
||||
@@ -139,7 +172,9 @@ export async function contribute(
|
||||
|
||||
if (options.dryRun) {
|
||||
const filename = generateFilename(options.title);
|
||||
log.info(`[dry-run] Would push: learnings/${filename} (${content.length} bytes)`);
|
||||
const subdir = await resolveLearningsSubdir(localConfig);
|
||||
const relPath = subdir ? path.posix.join(subdir, filename) : filename;
|
||||
log.info(`[dry-run] Would push: learnings/${relPath} (${content.length} bytes)`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -152,10 +187,16 @@ export async function contribute(
|
||||
|
||||
const pushSpin = spinner('Contributing session knowledge...').start();
|
||||
const filename = generateFilename(options.title);
|
||||
// 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 = await resolveLearningsSubdir(localConfig);
|
||||
const relPath = learningsSubdir ? path.posix.join(learningsSubdir, filename) : filename;
|
||||
|
||||
try {
|
||||
// Prepare destination
|
||||
const aiDocsDir = path.join(repoPath, 'learnings');
|
||||
const aiDocsDir = learningsSubdir
|
||||
? path.join(repoPath, 'learnings', learningsSubdir)
|
||||
: path.join(repoPath, 'learnings');
|
||||
await ensureDir(aiDocsDir);
|
||||
const destPath = path.join(aiDocsDir, filename);
|
||||
|
||||
@@ -182,12 +223,12 @@ export async function contribute(
|
||||
// event loop (and hanging the CLI) after the work is done.
|
||||
const commitMsg = `[teamai] Contribute session knowledge from ${username}`;
|
||||
await withTimeout(
|
||||
pushRepoDirectly(repoPath, commitMsg, [`learnings/${filename}`]),
|
||||
pushRepoDirectly(repoPath, commitMsg, [`learnings/${relPath}`]),
|
||||
10_000,
|
||||
'Push timeout (10s)',
|
||||
);
|
||||
|
||||
pushSpin.succeed(`Contributed: learnings/${filename}`);
|
||||
pushSpin.succeed(`Contributed: learnings/${relPath}`);
|
||||
|
||||
// Mark session as contributed (dedup for contribute-check)
|
||||
const sessionId = options.sessionId || process.env.CLAUDE_SESSION_ID || '';
|
||||
@@ -201,7 +242,7 @@ export async function contribute(
|
||||
// later pullRepo realign (reset --hard) cannot discard it, and retry on the
|
||||
// next pull.
|
||||
try {
|
||||
await savePendingLearning(repoPath, filename, content);
|
||||
await savePendingLearning(repoPath, relPath, content);
|
||||
pushSpin.warn(`Saved locally (push failed: ${(e as Error).message}). Will retry on the next pull.`);
|
||||
} catch {
|
||||
pushSpin.fail(`Contribution failed: ${(e as Error).message}`);
|
||||
@@ -227,7 +268,12 @@ async function contributeSelf(
|
||||
): Promise<void> {
|
||||
const username = localConfig.username;
|
||||
const filename = generateFilename(options.title);
|
||||
const relPath = `learnings/${filename}`;
|
||||
// Route into the active project's learnings namespace subdir (manifest-resolved,
|
||||
// same mapping pull/recall use), else the shared root — mirroring non-self mode.
|
||||
const selfSubdir = await resolveLearningsSubdir(localConfig);
|
||||
const relPath = selfSubdir
|
||||
? `learnings/${selfSubdir}/${filename}`
|
||||
: `learnings/${filename}`;
|
||||
const commitMsg = `[teamai] Contribute session knowledge from ${username}`;
|
||||
const spin = spinner('Contributing session knowledge...').start();
|
||||
|
||||
@@ -239,8 +285,9 @@ async function contributeSelf(
|
||||
|
||||
await withKnowledgeWorktree(localConfig, async (wtConfig) => {
|
||||
const wtRepo = wtConfig.repo.localPath;
|
||||
await ensureDir(path.join(wtRepo, 'learnings'));
|
||||
await fs.promises.writeFile(path.join(wtRepo, relPath), content, 'utf-8');
|
||||
const destAbs = path.join(wtRepo, relPath);
|
||||
await ensureDir(path.dirname(destAbs));
|
||||
await fs.promises.writeFile(destAbs, content, 'utf-8');
|
||||
|
||||
// Mirror the worktree's learnings into the machine-local dir + rebuild the
|
||||
// index so recall sees this contribution immediately — without touching the
|
||||
@@ -280,6 +327,7 @@ async function contributeSelf(
|
||||
const { buildIndex } = await import('./utils/search-index.js');
|
||||
await buildIndex({
|
||||
learningsDir: await pathExists(LEARNINGS_LOCAL_DIR) ? LEARNINGS_LOCAL_DIR : undefined,
|
||||
learningsNamespaces: await resolveActiveLearningsNamespaces(repoPath, localConfig.projects ?? []),
|
||||
docsDir: await pathExists(docsDir) ? docsDir : undefined,
|
||||
rulesDir: await pathExists(rulesDir) ? rulesDir : undefined,
|
||||
skillsDir: await pathExists(skillsDir) ? skillsDir : undefined,
|
||||
|
||||
@@ -32,6 +32,7 @@ program
|
||||
.option('--inherit-user-scope', 'In project scope, also sync safe user-scope resources and search its knowledge')
|
||||
.option('--no-inherit-user-scope', 'Disable user-scope inheritance for this project')
|
||||
.option('--role <id>', 'Primary role ID (e.g. hai_dev) for non-interactive setup')
|
||||
.option('--project <ids>', 'Active logical project(s) from manifest/projects.yaml (comma-separated); scopes which project resources and learnings this directory syncs')
|
||||
// Non-variadic + a collecting coercer: repeatable (`--agent a --agent b`) and
|
||||
// comma-separated (`--agent a,b`, split later by normalizeAgentList) both work,
|
||||
// WITHOUT the greedy `<name...>` variadic that would swallow the `[repo]`
|
||||
|
||||
+56
-1
@@ -20,6 +20,7 @@ import {
|
||||
} from './types.js';
|
||||
import { getUserHome } from './utils/home.js';
|
||||
import { describeRoles, loadRolesManifest } from './roles.js';
|
||||
import { loadProjectsManifest, listProjectIds } from './projects.js';
|
||||
import { askQuestion, askConfirmation, askSelection, closePrompt } from './utils/prompt.js';
|
||||
import {
|
||||
normalizeAgentList,
|
||||
@@ -112,6 +113,48 @@ async function promptForRoleProfile(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active logical projects for this directory from the `--project`
|
||||
* flag. Non-interactive and non-auto: a lone project is NOT auto-activated (a
|
||||
* member may legitimately belong to no project — see issue #375 Q1). Accepts a
|
||||
* comma-separated list. Returns `{ projects: [] }` when no flag and no manifest,
|
||||
* so behavior is unchanged for teams without project partitioning.
|
||||
*/
|
||||
async function resolveActiveProjects(
|
||||
repoPath: string,
|
||||
projectFlag?: string,
|
||||
): Promise<Pick<LocalConfig, 'projects'>> {
|
||||
const requested = (projectFlag ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (requested.length === 0) {
|
||||
return { projects: [] };
|
||||
}
|
||||
|
||||
const manifest = await loadProjectsManifest(repoPath);
|
||||
if (!manifest) {
|
||||
throw new Error(
|
||||
`--project given but no projects manifest (manifest/projects.yaml) exists in the team repo.`,
|
||||
);
|
||||
}
|
||||
|
||||
const validIds = new Set(listProjectIds(manifest));
|
||||
for (const id of requested) {
|
||||
if (!validIds.has(id)) {
|
||||
throw new Error(
|
||||
`Unknown project "${id}". Available projects: ${[...validIds].join(', ') || '(none)'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Dedupe while preserving order.
|
||||
const seen = new Set<string>();
|
||||
const projects = requested.filter((id) => (seen.has(id) ? false : (seen.add(id), true)));
|
||||
return { projects };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve init install scope from `--scope` / default.
|
||||
*
|
||||
@@ -236,7 +279,7 @@ async function isInsideGitRepo(dir: string): Promise<boolean> {
|
||||
*/
|
||||
export async function initHttp(
|
||||
url: string,
|
||||
options: GlobalOptions & { scope?: string; role?: string; agent?: string | string[]; force?: boolean; token?: string; inheritUserScope?: boolean },
|
||||
options: GlobalOptions & { scope?: string; role?: string; project?: string; agent?: string | string[]; force?: boolean; token?: string; inheritUserScope?: boolean },
|
||||
): Promise<void> {
|
||||
const { resolveApiKey, saveApiKey, getApiKeyPath } = await import('./api-key.js');
|
||||
|
||||
@@ -338,6 +381,7 @@ export async function initHttp(
|
||||
log.debug(`Role selection skipped: ${msg}`);
|
||||
}
|
||||
}
|
||||
Object.assign(localConfig, await resolveActiveProjects(localPath, options.project));
|
||||
|
||||
// Persist --agent into enabledAgents (additive across runs)
|
||||
const requestedAgents = normalizeAgentList(options.agent);
|
||||
@@ -609,6 +653,7 @@ export async function initSelfRepo(options: GlobalOptions & {
|
||||
repo?: string;
|
||||
repoPositional?: string;
|
||||
role?: string;
|
||||
project?: string;
|
||||
agent?: string | string[];
|
||||
force?: boolean;
|
||||
inheritUserScope?: boolean;
|
||||
@@ -744,6 +789,7 @@ export async function initSelfRepo(options: GlobalOptions & {
|
||||
log.debug(`Role selection skipped: ${msg}`);
|
||||
}
|
||||
}
|
||||
Object.assign(localConfig, await resolveActiveProjects(localPath, options.project));
|
||||
// Which AI tools to set up in this repo (create skills dir + inject hooks +
|
||||
// commit their settings.json). Resolved from --agent, else HOME detection
|
||||
// (non-interactive), else an interactive picker. Written to enabledAgents,
|
||||
@@ -903,6 +949,7 @@ export async function init(options: GlobalOptions & {
|
||||
repoPositional?: string;
|
||||
scope?: string;
|
||||
role?: string;
|
||||
project?: string;
|
||||
agent?: string | string[];
|
||||
force?: boolean;
|
||||
http?: string;
|
||||
@@ -1265,6 +1312,14 @@ export async function init(options: GlobalOptions & {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Object.assign(localConfig, await resolveActiveProjects(localPath, options.project));
|
||||
} catch (error) {
|
||||
// A bad --project is a user error on the main init path: fail loudly.
|
||||
log.error((error as Error).message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Persist --agent into enabledAgents (additive across runs)
|
||||
const requestedAgents = normalizeAgentList(options.agent);
|
||||
if (requestedAgents.length > 0) {
|
||||
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
import path from 'node:path';
|
||||
import YAML from 'yaml';
|
||||
import { z } from 'zod';
|
||||
import { readFileSafe, ensureDir, writeFile } from './utils/fs.js';
|
||||
import type { ResourceNamespaces } from './roles.js';
|
||||
|
||||
/**
|
||||
* Project resource types. Unlike roles, `learnings` is an ACTIVE dimension here:
|
||||
* projects are the only carrier of learnings-namespace isolation (roles ignore it
|
||||
* on purpose — see src/roles.ts). knowledge/skills mirror the role convention.
|
||||
*/
|
||||
const PROJECT_RESOURCE_TYPES = ['knowledge', 'skills', 'learnings'] as const;
|
||||
|
||||
export type ProjectResourceType = typeof PROJECT_RESOURCE_TYPES[number];
|
||||
|
||||
const ProjectResourceNamespacesSchema = z.object({
|
||||
knowledge: z.array(z.string().min(1)).default([]),
|
||||
skills: z.array(z.string().min(1)).default([]),
|
||||
learnings: z.array(z.string().min(1)).default([]),
|
||||
});
|
||||
|
||||
/**
|
||||
* A project id becomes a path component (skills/<id>/, learnings/<id>/), so it
|
||||
* must never contain a path separator or `..`. Enforced here at the manifest
|
||||
* boundary; use-sites that read ids from other sources (e.g. a hand-edited
|
||||
* config.yaml `projects` field) additionally guard via `isSafeNamespaceSegment`.
|
||||
*/
|
||||
const SAFE_ID = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
/** True if `seg` is safe to use as a single path segment (no separators, no `..`). */
|
||||
export function isSafeNamespaceSegment(seg: string): boolean {
|
||||
return SAFE_ID.test(seg) && seg !== '.' && seg !== '..';
|
||||
}
|
||||
|
||||
const ProjectSchema = z.object({
|
||||
id: z.string().min(1).refine((v) => isSafeNamespaceSegment(v), {
|
||||
message: "project id must be a single path segment (letters, digits, '.', '_', '-'; no '/', '\\\\', or '..')",
|
||||
}),
|
||||
name: z.string().default(''),
|
||||
description: z.string().default(''),
|
||||
resources: ProjectResourceNamespacesSchema,
|
||||
});
|
||||
|
||||
const ProjectsManifestSchema = z.object({
|
||||
version: z.number(),
|
||||
// Unlike roles (`.min(1)`), a repo may define zero projects — a team without
|
||||
// project partitioning simply has no projects.yaml, and an empty list is valid.
|
||||
projects: z.array(ProjectSchema).default([]),
|
||||
});
|
||||
|
||||
export type TeamProject = z.infer<typeof ProjectSchema>;
|
||||
export type ProjectsManifest = z.infer<typeof ProjectsManifestSchema>;
|
||||
|
||||
function validateManifestShape(raw: unknown): ProjectsManifest {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new Error('Invalid projects manifest: expected an object');
|
||||
}
|
||||
|
||||
const candidate = raw as Record<string, unknown>;
|
||||
const projects = candidate.projects;
|
||||
if (projects !== undefined && !Array.isArray(projects)) {
|
||||
throw new Error('Invalid projects manifest: projects must be an array');
|
||||
}
|
||||
|
||||
for (const project of projects ?? []) {
|
||||
if (!project || typeof project !== 'object') {
|
||||
throw new Error('Invalid projects manifest: every project must be an object');
|
||||
}
|
||||
|
||||
const resources = (project as Record<string, unknown>).resources;
|
||||
if (resources !== undefined && (typeof resources !== 'object' || Array.isArray(resources))) {
|
||||
throw new Error(`Invalid projects manifest: project ${(project as Record<string, unknown>).id ?? '<unknown>'} has invalid resources`);
|
||||
}
|
||||
|
||||
if (resources) {
|
||||
const ALLOWED_RESOURCE_KEYS = new Set<string>(PROJECT_RESOURCE_TYPES);
|
||||
for (const key of Object.keys(resources)) {
|
||||
if (!ALLOWED_RESOURCE_KEYS.has(key)) {
|
||||
throw new Error(`Invalid projects manifest: unknown resource type "${key}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const manifest = ProjectsManifestSchema.parse(raw);
|
||||
const ids = new Set<string>();
|
||||
for (const project of manifest.projects) {
|
||||
if (ids.has(project.id)) {
|
||||
throw new Error(`Invalid projects manifest: duplicate project id "${project.id}"`);
|
||||
}
|
||||
ids.add(project.id);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the projects manifest. Returns `null` when the file is absent — projects
|
||||
* are optional (a team without partitioning has no projects.yaml), so every
|
||||
* project code path short-circuits on `null` and behaves exactly as before.
|
||||
*/
|
||||
export async function loadProjectsManifest(repoPath: string): Promise<ProjectsManifest | null> {
|
||||
const manifestPath = path.join(repoPath, 'manifest', 'projects.yaml');
|
||||
const content = await readFileSafe(manifestPath);
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = YAML.parse(content);
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid projects manifest YAML: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return validateManifestShape(raw);
|
||||
}
|
||||
|
||||
export async function saveProjectsManifest(repoPath: string, manifest: ProjectsManifest): Promise<void> {
|
||||
// Re-validate before writing to prevent persisting invalid manifests
|
||||
validateManifestShape(manifest);
|
||||
|
||||
const manifestDir = path.join(repoPath, 'manifest');
|
||||
const manifestPath = path.join(manifestDir, 'projects.yaml');
|
||||
await ensureDir(manifestDir);
|
||||
await writeFile(manifestPath, YAML.stringify(manifest));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a project by id without throwing. Returns undefined if not found.
|
||||
*/
|
||||
export function findProject(manifest: ProjectsManifest, projectId: string): TeamProject | undefined {
|
||||
return manifest.projects.find((candidate) => candidate.id === projectId);
|
||||
}
|
||||
|
||||
export function listProjectIds(manifest: ProjectsManifest): string[] {
|
||||
return manifest.projects.map((project) => project.id);
|
||||
}
|
||||
|
||||
export function describeProjects(projects: Array<Pick<TeamProject, 'id' | 'name' | 'description'>>): string[] {
|
||||
return projects.map((project) => {
|
||||
const label = project.name || project.id;
|
||||
return project.description ? `${label}: ${project.description}` : label;
|
||||
});
|
||||
}
|
||||
|
||||
function getProjectOrThrow(manifest: ProjectsManifest, projectId: string): TeamProject {
|
||||
const project = findProject(manifest, projectId);
|
||||
if (!project) {
|
||||
throw new Error(`Unknown project "${projectId}". Valid projects: ${listProjectIds(manifest).join(', ')}`);
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the resource namespaces contributed by the given active projects, as a
|
||||
* deduped union across all three resource types (knowledge/skills/learnings).
|
||||
*
|
||||
* This is the ONLY source of learnings namespaces. Roles never contribute them.
|
||||
* The caller unions the result with `resolveRoleResourceNamespaces(...)` on the
|
||||
* knowledge/skills axes; there is no priority override between the two dimensions
|
||||
* (same-named resources across a role and a project namespace are an admin-side
|
||||
* duplicate error, not a runtime precedence decision).
|
||||
*/
|
||||
export function resolveProjectResourceNamespaces(input: {
|
||||
manifest: ProjectsManifest;
|
||||
activeProjects: string[];
|
||||
}): Record<ProjectResourceType, string[]> {
|
||||
const resolved = input.activeProjects.map((id) => getProjectOrThrow(input.manifest, id));
|
||||
|
||||
const namespaces: Record<ProjectResourceType, string[]> = {
|
||||
knowledge: [],
|
||||
skills: [],
|
||||
learnings: [],
|
||||
};
|
||||
|
||||
for (const type of PROJECT_RESOURCE_TYPES) {
|
||||
const seen = new Set<string>();
|
||||
for (const project of resolved) {
|
||||
for (const namespace of project.resources[type]) {
|
||||
if (seen.has(namespace)) continue;
|
||||
seen.add(namespace);
|
||||
namespaces[type].push(namespace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return namespaces;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active **learnings** namespaces for a directory, from the manifest
|
||||
* — the SAME source `pull` uses. This is the canonical mapping from active
|
||||
* project ids to learnings subdirectories: a project's learnings namespace is
|
||||
* `resources.learnings`, which the schema allows to differ from the project id
|
||||
* (e.g. project `alpha` → `learnings: [alpha-notes]`). `contribute` must route
|
||||
* and index through this, not through the raw project id, or its landing point
|
||||
* and post-contribute index diverge from what `pull` syncs.
|
||||
*
|
||||
* Returns `[]` when there is no manifest, no active project, or the active
|
||||
* projects declare no learnings namespace (→ contribution lands at the shared root).
|
||||
*/
|
||||
export async function resolveActiveLearningsNamespaces(
|
||||
repoPath: string,
|
||||
activeProjects: string[],
|
||||
): Promise<string[]> {
|
||||
if (activeProjects.length === 0) return [];
|
||||
const manifest = await loadProjectsManifest(repoPath);
|
||||
if (!manifest) return [];
|
||||
try {
|
||||
return resolveProjectResourceNamespaces({ manifest, activeProjects }).learnings;
|
||||
} catch {
|
||||
// Unknown active project id, etc. — degrade to shared root rather than throw.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge role and project namespaces into the final active set. knowledge/skills
|
||||
* are the deduped union of both dimensions; learnings comes from projects only.
|
||||
* There is deliberately no priority override — see the module-level note.
|
||||
*/
|
||||
export function mergeNamespaces(
|
||||
roleNamespaces: ResourceNamespaces,
|
||||
projectNamespaces: Record<ProjectResourceType, string[]>,
|
||||
): ResourceNamespaces {
|
||||
const dedupe = (a: string[], b: string[]): string[] => {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const ns of [...a, ...b]) {
|
||||
if (seen.has(ns)) continue;
|
||||
seen.add(ns);
|
||||
out.push(ns);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
return {
|
||||
knowledge: dedupe(roleNamespaces.knowledge, projectNamespaces.knowledge),
|
||||
skills: dedupe(roleNamespaces.skills, projectNamespaces.skills),
|
||||
// Roles never contribute learnings; this is effectively the project set.
|
||||
learnings: dedupe(roleNamespaces.learnings, projectNamespaces.learnings),
|
||||
};
|
||||
}
|
||||
+107
-27
@@ -5,7 +5,7 @@ import { requireInit, loadState, saveState, detectProjectConfig, loadLocalConfig
|
||||
import { pullRepo, getHeadRev } from './utils/git.js';
|
||||
import { flushPendingLearnings } from './utils/pending-learnings.js';
|
||||
import { log, spinner } from './utils/logger.js';
|
||||
import { pathExists, remove, listFiles, listDirs, readFileSafe } from './utils/fs.js';
|
||||
import { pathExists, remove, listFiles, listDirs, listFilesRecursive, readFileSafe } from './utils/fs.js';
|
||||
import { injectClaudeMdSection } from './utils/claudemd.js';
|
||||
import { getHandler, RulesHandler, DocsHandler, EnvHandler } from './resources/index.js';
|
||||
import { ResourceHandler } from './resources/base.js';
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from './types.js';
|
||||
import type { CultureFrontmatter } from './types.js';
|
||||
import { loadRolesManifest, resolveRoleResourceNamespaces, type ResourceNamespaces } from './roles.js';
|
||||
import { loadProjectsManifest, resolveProjectResourceNamespaces, mergeNamespaces } from './projects.js';
|
||||
import { getUserHome } from './utils/home.js';
|
||||
import { acquireLock, releaseLock } from './update.js';
|
||||
|
||||
@@ -120,32 +121,67 @@ async function refreshTeamRepo(
|
||||
}
|
||||
|
||||
async function buildRolePullContext(localConfig: LocalConfig): Promise<RolePullContext | null> {
|
||||
if (!localConfig.primaryRole) return null;
|
||||
const activeProjects = localConfig.projects ?? [];
|
||||
const hasRole = !!localConfig.primaryRole;
|
||||
const hasProjects = activeProjects.length > 0;
|
||||
// No role and no active project → nothing to filter by (unchanged behavior).
|
||||
if (!hasRole && !hasProjects) return null;
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = await loadRolesManifest(localConfig.repo.localPath);
|
||||
} catch {
|
||||
log.warn('Could not load roles manifest. Skipping role-based filtering.');
|
||||
return null;
|
||||
// ── Role namespaces (optional) ──
|
||||
let roleNamespaces: ResourceNamespaces = { knowledge: [], skills: [], learnings: [] };
|
||||
let allRoleSkillNamespaces = new Set<string>();
|
||||
if (hasRole) {
|
||||
let rolesManifest;
|
||||
try {
|
||||
rolesManifest = await loadRolesManifest(localConfig.repo.localPath);
|
||||
} catch {
|
||||
log.warn('Could not load roles manifest. Skipping role-based filtering.');
|
||||
rolesManifest = null;
|
||||
}
|
||||
if (rolesManifest) {
|
||||
try {
|
||||
roleNamespaces = resolveRoleResourceNamespaces({
|
||||
manifest: rolesManifest,
|
||||
primaryRole: localConfig.primaryRole!,
|
||||
additionalRoles: localConfig.additionalRoles ?? [],
|
||||
});
|
||||
allRoleSkillNamespaces = new Set(rolesManifest.roles.flatMap((role) => role.resources.skills));
|
||||
} catch {
|
||||
log.warn(`Role "${localConfig.primaryRole}" not found in manifest. Falling back to unfiltered sync.`);
|
||||
log.warn('Run `teamai roles set <role>` to pick a valid role.');
|
||||
// A misconfigured role with no active project means we can't filter safely.
|
||||
if (!hasProjects) return null;
|
||||
}
|
||||
} else if (!hasProjects) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let activeNamespaces;
|
||||
try {
|
||||
activeNamespaces = resolveRoleResourceNamespaces({
|
||||
manifest,
|
||||
primaryRole: localConfig.primaryRole,
|
||||
additionalRoles: localConfig.additionalRoles ?? [],
|
||||
});
|
||||
} catch (e) {
|
||||
log.warn(`Role "${localConfig.primaryRole}" not found in manifest. Falling back to unfiltered sync.`);
|
||||
log.warn('Run `teamai roles set <role>` to pick a valid role.');
|
||||
return null;
|
||||
// ── Project namespaces (optional) ──
|
||||
let projectNamespaces = { knowledge: [] as string[], skills: [] as string[], learnings: [] as string[] };
|
||||
let allProjectSkillNamespaces = new Set<string>();
|
||||
if (hasProjects) {
|
||||
const projectsManifest = await loadProjectsManifest(localConfig.repo.localPath);
|
||||
if (!projectsManifest) {
|
||||
log.warn('Active projects configured but no projects manifest found. Skipping project-based filtering.');
|
||||
} else {
|
||||
try {
|
||||
projectNamespaces = resolveProjectResourceNamespaces({
|
||||
manifest: projectsManifest,
|
||||
activeProjects,
|
||||
});
|
||||
allProjectSkillNamespaces = new Set(projectsManifest.projects.flatMap((p) => p.resources.skills));
|
||||
} catch (e) {
|
||||
log.warn(`${(e as Error).message} Falling back to role-only filtering.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allSkillNamespaces = new Set(
|
||||
manifest.roles.flatMap((role) => role.resources.skills),
|
||||
);
|
||||
const activeNamespaces = mergeNamespaces(roleNamespaces, projectNamespaces);
|
||||
|
||||
// Skill activation set spans BOTH dimensions: a skill is inactive only if it
|
||||
// lives in a namespace that neither an active role nor an active project selects.
|
||||
const allSkillNamespaces = new Set<string>([...allRoleSkillNamespaces, ...allProjectSkillNamespaces]);
|
||||
const inactiveSkillNamespaces = [...allSkillNamespaces].filter((namespace) => !activeNamespaces.skills.includes(namespace));
|
||||
const activeSkillNames = new Set<string>();
|
||||
const inactiveSkillNames = new Set<string>();
|
||||
@@ -708,23 +744,66 @@ async function pullForScope(
|
||||
|
||||
// user scope: sync learnings to ~/.teamai/learnings/ (legacy behavior)
|
||||
// project scope: use learnings directly from repo
|
||||
//
|
||||
// Learnings namespace isolation: the flat root .md files are always shared;
|
||||
// project subdirectories are synced/indexed only when the active projects
|
||||
// select them. `activeLearningsNamespaces` is the set from role∪project
|
||||
// resolution (roles contribute none, so effectively the project set).
|
||||
const activeLearningsNamespaces = roleContext?.activeNamespaces.learnings ?? [];
|
||||
const activeLearningsSet = new Set(activeLearningsNamespaces);
|
||||
// Filter for fse.copy: keep the root and any file/dir whose top-level
|
||||
// segment (relative to learningsRepoDir) is either a root-level .md (shared)
|
||||
// or an active-project subdirectory. Everything else (inactive project dirs)
|
||||
// is excluded so no other project's private learnings land on this machine.
|
||||
const learningsCopyFilter = (src: string): boolean => {
|
||||
if (path.basename(src).startsWith('.')) return false;
|
||||
const rel = path.relative(learningsRepoDir, src);
|
||||
if (rel === '') return true; // the root dir itself
|
||||
const top = rel.split(path.sep)[0];
|
||||
// Root-level file (shared) → top has no further segments and is a file.
|
||||
if (!rel.includes(path.sep)) {
|
||||
// Could be a root-level .md (keep) or a subdirectory entry (keep only
|
||||
// if it's an active namespace dir; fse.copy will then recurse into it).
|
||||
return top.endsWith('.md') || activeLearningsSet.has(top);
|
||||
}
|
||||
// Nested path: keep only if under an active namespace.
|
||||
return activeLearningsSet.has(top);
|
||||
};
|
||||
const countLearnings = async (baseDir: string): Promise<number> => {
|
||||
// Count root-level shared .md + active-namespace .md only.
|
||||
let n = (await listFiles(baseDir)).filter((f) => f.endsWith('.md')).length;
|
||||
for (const ns of activeLearningsNamespaces) {
|
||||
const nsDir = path.join(baseDir, ns);
|
||||
if (await pathExists(nsDir)) {
|
||||
n += (await listFilesRecursive(nsDir)).filter((f) => f.endsWith('.md')).length;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
};
|
||||
let learningsCount = 0;
|
||||
let effectiveLearningsDir: string | undefined;
|
||||
if (localConfig.scope === 'user') {
|
||||
if (await pathExists(learningsRepoDir)) {
|
||||
// Remove any stale namespace subdirectories no longer active before
|
||||
// re-copying, so deactivating a project cleans up its local learnings.
|
||||
if (await pathExists(LEARNINGS_LOCAL_DIR)) {
|
||||
for (const existing of await listDirs(LEARNINGS_LOCAL_DIR)) {
|
||||
if (!activeLearningsSet.has(existing)) {
|
||||
await fse.remove(path.join(LEARNINGS_LOCAL_DIR, existing));
|
||||
}
|
||||
}
|
||||
}
|
||||
await fse.copy(learningsRepoDir, LEARNINGS_LOCAL_DIR, {
|
||||
overwrite: true,
|
||||
filter: (src: string) => !path.basename(src).startsWith('.'),
|
||||
filter: learningsCopyFilter,
|
||||
});
|
||||
const allFiles = await listFiles(learningsRepoDir);
|
||||
learningsCount = allFiles.filter((f) => f.endsWith('.md')).length;
|
||||
learningsCount = await countLearnings(learningsRepoDir);
|
||||
}
|
||||
effectiveLearningsDir = await pathExists(LEARNINGS_LOCAL_DIR) ? LEARNINGS_LOCAL_DIR : undefined;
|
||||
} else {
|
||||
effectiveLearningsDir = await pathExists(learningsRepoDir) ? learningsRepoDir : undefined;
|
||||
if (effectiveLearningsDir) {
|
||||
const allFiles = await listFiles(learningsRepoDir);
|
||||
learningsCount = allFiles.filter((f) => f.endsWith('.md')).length;
|
||||
learningsCount = await countLearnings(learningsRepoDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,6 +827,7 @@ async function pullForScope(
|
||||
const { buildIndex } = await import('./utils/search-index.js');
|
||||
const elapsed = await buildIndex({
|
||||
learningsDir: effectiveLearningsDir,
|
||||
learningsNamespaces: activeLearningsNamespaces,
|
||||
docsDir: await pathExists(docsRepoDir) ? docsRepoDir : undefined,
|
||||
rulesDir: await pathExists(rulesRepoDir) ? rulesRepoDir : undefined,
|
||||
skillsDir: await pathExists(skillsRepoDir) ? skillsRepoDir : undefined,
|
||||
|
||||
+16
-1
@@ -31,7 +31,19 @@ const RolesManifestSchema = z.object({
|
||||
|
||||
export type TeamRole = z.infer<typeof RoleSchema>;
|
||||
export type RolesManifest = z.infer<typeof RolesManifestSchema>;
|
||||
export type ResourceNamespaces = Record<RoleResourceType, string[]>;
|
||||
|
||||
/**
|
||||
* Active resource namespaces after resolving roles ∪ projects. `learnings` is
|
||||
* always present but only projects ever populate it (roles leave it empty — see
|
||||
* the note on RoleResourceNamespacesSchema). Kept as a superset of the role
|
||||
* resource types so role and project resolutions share one shape and can be
|
||||
* unioned directly.
|
||||
*/
|
||||
export type ResourceNamespaces = {
|
||||
knowledge: string[];
|
||||
skills: string[];
|
||||
learnings: string[];
|
||||
};
|
||||
|
||||
function validateManifestShape(raw: unknown): RolesManifest {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
@@ -140,6 +152,9 @@ export function resolveRoleResourceNamespaces(input: {
|
||||
const namespaces: ResourceNamespaces = {
|
||||
knowledge: [],
|
||||
skills: [],
|
||||
// Roles never contribute learnings namespaces; only projects do. Kept empty
|
||||
// so the shape matches project resolution for a clean union at the call site.
|
||||
learnings: [],
|
||||
};
|
||||
|
||||
for (const type of ROLE_RESOURCE_TYPES) {
|
||||
|
||||
@@ -303,6 +303,14 @@ export const MemberConfigSchema = z.object({
|
||||
displayName: z.string().default(''),
|
||||
registeredAt: z.string(),
|
||||
role: z.string().optional(),
|
||||
/**
|
||||
* Every logical project this member has participated in, across all their
|
||||
* working directories. Append + dedupe semantics (contrast LocalConfig.projects,
|
||||
* which is overwrite per-directory): running `init --project` in two directories
|
||||
* lists both here while each directory syncs only its own. Optional for
|
||||
* backward compatibility with member files written before this field existed.
|
||||
*/
|
||||
projects: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type MemberConfig = z.infer<typeof MemberConfigSchema>;
|
||||
@@ -340,6 +348,15 @@ export const LocalConfigSchema = z.object({
|
||||
scope: ScopeEnum.default('user'),
|
||||
primaryRole: z.string().min(1).optional(),
|
||||
additionalRoles: z.array(z.string()).default([]),
|
||||
/**
|
||||
* Logical projects (manifest ids from projects.yaml) active in THIS directory.
|
||||
* Overwrite semantics: the directory syncs exactly these projects' resources.
|
||||
* Distinct from #374's path-slug "project" (which decides where data lives).
|
||||
* Empty/absent means no project partitioning — role namespaces + shared
|
||||
* learnings root only. Optional (not defaulted) so existing configs and test
|
||||
* fixtures without the field remain valid; consumers treat absent as [].
|
||||
*/
|
||||
projects: z.array(z.string()).optional(),
|
||||
resourceProfileVersion: z.number().int().positive().optional(),
|
||||
/** Absolute path to project root; required when scope is 'project'. */
|
||||
projectRoot: z.string().optional(),
|
||||
|
||||
+4
-2
@@ -358,11 +358,13 @@ export async function pushRepoDirectly(localPath: string, message: string, files
|
||||
*/
|
||||
export async function pushLearningToOrigin(
|
||||
repoPath: string,
|
||||
filename: string,
|
||||
relPath: string,
|
||||
message: string,
|
||||
): Promise<boolean> {
|
||||
const git = createGit(repoPath);
|
||||
await git.add([`learnings/${filename}`]);
|
||||
// relPath is relative to learnings/ and may include a namespace subdirectory
|
||||
// (e.g. `alpha-notes/foo.md`); normalize to forward slashes for git.
|
||||
await git.add([`learnings/${relPath.split(path.sep).join('/')}`]);
|
||||
const status = await git.status();
|
||||
if (status.staged.length > 0) {
|
||||
await git.commit(message);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { ensureDir } from './fs.js';
|
||||
import { ensureDir, listFilesRecursive } from './fs.js';
|
||||
import { pushLearningToOrigin } from './git.js';
|
||||
import { withTimeout } from './async.js';
|
||||
import { log } from './logger.js';
|
||||
@@ -18,7 +18,11 @@ export function pendingLearningsDir(repoPath: string): string {
|
||||
* Persist a learning whose push failed, so the next pull can retry it.
|
||||
*
|
||||
* @param repoPath - Team-repo clone root.
|
||||
* @param filename - Learning file name (e.g. `foo-2026-01-01-ab12cd.md`).
|
||||
* @param relPath - Learning path RELATIVE to `learnings/` (e.g.
|
||||
* `alpha-notes/foo-2026-01-01-ab12cd.md` for a project-namespaced learning, or
|
||||
* `foo-....md` for a shared-root one). The namespace subdirectory is preserved
|
||||
* here and on retry, so a failed project contribution is never downgraded to a
|
||||
* shared-root learning.
|
||||
* @param content - Full learning file content.
|
||||
*
|
||||
* Precondition: repoPath is a dedicated team-repo clone root, NOT a single-repo
|
||||
@@ -27,12 +31,13 @@ export function pendingLearningsDir(repoPath: string): string {
|
||||
*/
|
||||
export async function savePendingLearning(
|
||||
repoPath: string,
|
||||
filename: string,
|
||||
relPath: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const dir = pendingLearningsDir(repoPath);
|
||||
await ensureDir(dir);
|
||||
await fs.promises.writeFile(path.join(dir, filename), content, 'utf-8');
|
||||
const dest = path.join(dir, relPath);
|
||||
await ensureDir(path.dirname(dest));
|
||||
await fs.promises.writeFile(dest, content, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,19 +57,24 @@ export async function savePendingLearning(
|
||||
*/
|
||||
export async function flushPendingLearnings(repoPath: string, username: string): Promise<number> {
|
||||
const dir = pendingLearningsDir(repoPath);
|
||||
let names: string[];
|
||||
let relPaths: string[];
|
||||
try {
|
||||
names = await fs.promises.readdir(dir);
|
||||
// Recurse: pending learnings may sit under a namespace subdirectory
|
||||
// (e.g. `alpha-notes/foo.md`), which must be preserved on retry.
|
||||
relPaths = await listFilesRecursive(dir);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let pushed = 0;
|
||||
for (const filename of names) {
|
||||
if (filename.startsWith('.')) {
|
||||
for (const relPath of relPaths) {
|
||||
if (relPath.split(path.sep).some((seg) => seg.startsWith('.'))) {
|
||||
continue;
|
||||
}
|
||||
const pendingPath = path.join(dir, filename);
|
||||
if (!relPath.endsWith('.md')) {
|
||||
continue;
|
||||
}
|
||||
const pendingPath = path.join(dir, relPath);
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.promises.readFile(pendingPath, 'utf-8');
|
||||
@@ -73,25 +83,25 @@ export async function flushPendingLearnings(repoPath: string, username: string):
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const destDir = path.join(repoPath, 'learnings');
|
||||
await ensureDir(destDir);
|
||||
await fs.promises.writeFile(path.join(destDir, filename), content, 'utf-8');
|
||||
const destPath = path.join(repoPath, 'learnings', relPath);
|
||||
await ensureDir(path.dirname(destPath));
|
||||
await fs.promises.writeFile(destPath, content, 'utf-8');
|
||||
const commitMsg = `[teamai] Contribute session knowledge from ${username}`;
|
||||
const confirmed = await withTimeout(
|
||||
pushLearningToOrigin(repoPath, filename, commitMsg),
|
||||
pushLearningToOrigin(repoPath, relPath, commitMsg),
|
||||
10_000,
|
||||
'Push timeout (10s)',
|
||||
);
|
||||
if (!confirmed) {
|
||||
// Push returned but the branch is still ahead of origin — do NOT drop
|
||||
// the durable backup; retry on the next pull.
|
||||
log.debug(`flushPendingLearnings: ${filename} not confirmed on origin, keeping backup`);
|
||||
log.debug(`flushPendingLearnings: ${relPath} not confirmed on origin, keeping backup`);
|
||||
break;
|
||||
}
|
||||
await fs.promises.rm(pendingPath, { force: true });
|
||||
pushed += 1;
|
||||
} catch (e) {
|
||||
log.debug(`flushPendingLearnings: retry deferred for ${filename}: ${(e as Error).message}`);
|
||||
log.debug(`flushPendingLearnings: retry deferred for ${relPath}: ${(e as Error).message}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,6 +449,45 @@ async function collectFlatMdEntries(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Namespace-aware learnings collector. Always indexes the flat `.md` files at the
|
||||
* `learnings/` root (shared with the whole team — the zero-migration invariant),
|
||||
* and additionally indexes `.md` files under each active-project subdirectory.
|
||||
* Any subdirectory NOT in `namespaces` is skipped, so a member never sees another
|
||||
* project's private learnings in recall.
|
||||
*
|
||||
* When `namespaces` is undefined the collector degrades to root-only, matching the
|
||||
* historical flat behavior for teams without a projects manifest.
|
||||
*/
|
||||
async function collectLearningsEntries(
|
||||
dir: string,
|
||||
namespaces: string[] | undefined,
|
||||
voteCounts: Map<string, number>,
|
||||
): Promise<SearchIndexEntry[]> {
|
||||
if (!await pathExists(dir)) return [];
|
||||
// Root-level .md = always shared.
|
||||
const out: SearchIndexEntry[] = await collectFlatMdEntries(dir, 'learnings', voteCounts);
|
||||
|
||||
for (const ns of namespaces ?? []) {
|
||||
// Defense-in-depth: a namespace is a path segment (learnings/<ns>/). Skip
|
||||
// anything that isn't a safe single segment so a hand-edited config can't
|
||||
// make the index scan outside the learnings directory. (Inlined rather than
|
||||
// importing from ../projects.js to keep this low-level util dependency-free.)
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(ns) || ns === '.' || ns === '..') continue;
|
||||
const nsDir = path.join(dir, ns);
|
||||
if (!await pathExists(nsDir)) continue;
|
||||
const files = await listFilesRecursive(nsDir);
|
||||
for (const rel of files) {
|
||||
if (!rel.endsWith('.md')) continue;
|
||||
// Prefix the id with the namespace so it stays unique against the root and
|
||||
// other namespaces (e.g. `hai-inference/deploy-note.md`).
|
||||
const e = await entryFromMdFile(path.join(nsDir, rel), path.join(ns, rel), 'learnings', voteCounts);
|
||||
if (e) out.push(e);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect entries from a recursive *.md directory (used for `docs` and
|
||||
* `rules`, which may have subdirectories like `rules/common/`).
|
||||
@@ -508,6 +547,14 @@ async function collectSkillEntries(
|
||||
/** Options for the multi-category build. */
|
||||
export interface BuildIndexOptions {
|
||||
learningsDir?: string;
|
||||
/**
|
||||
* Active learnings namespaces (project ids). When provided, the learnings
|
||||
* collector indexes the flat root `.md` files (always shared) PLUS the `.md`
|
||||
* files under each named subdirectory, and skips every other subdirectory —
|
||||
* so project-private learnings only surface for members of that project.
|
||||
* When undefined, only the flat root is indexed (legacy behavior).
|
||||
*/
|
||||
learningsNamespaces?: string[];
|
||||
docsDir?: string;
|
||||
rulesDir?: string;
|
||||
skillsDir?: string;
|
||||
@@ -546,7 +593,7 @@ export async function buildIndex(
|
||||
const entries: SearchIndexEntry[] = [];
|
||||
|
||||
if (opts.learningsDir) {
|
||||
entries.push(...await collectFlatMdEntries(opts.learningsDir, 'learnings', voteCounts));
|
||||
entries.push(...await collectLearningsEntries(opts.learningsDir, opts.learningsNamespaces, voteCounts));
|
||||
}
|
||||
if (opts.docsDir) {
|
||||
entries.push(...await collectRecursiveMdEntries(opts.docsDir, 'docs', voteCounts));
|
||||
|
||||
Reference in New Issue
Block a user