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:
jeff
2026-09-08 20:19:41 +08:00
committed by GitHub
18 changed files with 1271 additions and 59 deletions
+272
View File
@@ -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
View File
@@ -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
+41 -1
View File
@@ -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-正交的维度))。
### 团队包
+59
View File
@@ -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']);
});
});
+29
View File
@@ -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');
+244
View File
@@ -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 });
}
});
});
+1 -1
View File
@@ -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"/);
});
+3
View File
@@ -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
View File
@@ -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,
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+17
View File
@@ -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
View File
@@ -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);
+26 -16
View File
@@ -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;
}
}
+48 -1
View File
@@ -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));