mirror of
https://github.com/Tencent/teamai-cli.git
synced 2026-10-02 03:14:40 +08:00
Merge pull request #464 from Tencent/worktree-issue-374-p3-functionize-constants
feat(data-layout): functionize module-load constants + add `status --all` (#374 P3)
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
# Design: teamai data directory layout — global home + per-project partitioning
|
||||
|
||||
> Status: **P0 + P1 + P2 implemented** (issue #374). P1 shipped as PRs #397 / #402 /
|
||||
> Status: **P0 + P1 + P2 + P3 implemented** (issue #374 complete). P1 shipped as PRs #397 / #402 /
|
||||
> #406 / #414 / #417 (partition routing) and #439 (P1-3 auto-migration). P2 (self
|
||||
> mode slimming) is below. P3 is a follow-up phase, tracked below.
|
||||
> mode slimming, #455) and P3 (constant functionization + `status --all`) are below.
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -237,11 +237,44 @@ too (git worktrees must live in the same repo; they anchor on `localPath`, not
|
||||
Acceptance: after slimming, `git status` is clean (the A1 data is physically gone,
|
||||
not merely ignored) and a teammate's fresh clone bootstraps into the partition.
|
||||
|
||||
## Follow-up phases (not in this PR)
|
||||
## P3 — constant functionization + `status --all` (implemented)
|
||||
|
||||
- **P3** — functionize module-load-time path constants (so tests that swap `$HOME`
|
||||
at runtime take effect), then assign A1/A2 ownership per the data-classification
|
||||
table. `status --all` across partitions.
|
||||
**Functionization.** A handful of top-level path constants were computed once at
|
||||
module import: `export const TEAMAI_HOME = path.join(getUserHome(), '.teamai')` and
|
||||
its derivatives (config/state/token/update-lock/session-logs/learnings/votes/
|
||||
search-index). Because they froze at import, a test that later swapped `HOME` never
|
||||
saw the new value — so `HOME`-based isolation silently failed (tests worked around
|
||||
it with `vi.resetModules()` or `vi.mock('../types.js')`). P3 converts them to
|
||||
call-time getters (`getTeamaiHomeDir()`, `getUserVotesDir()`, `getSessionLogsDir()`,
|
||||
…), matching the existing `getUserHome()` / `getDataHome()` pattern, so isolation
|
||||
just works. Seven consts that already had runtime getters and no live consumers
|
||||
(`TEAMAI_SOURCES_DIR`, `TEAMAI_USAGE_PATH`, `TEAMAI_KNOWN_SKILLS_PATH`,
|
||||
`TEAMAI_PUSHIGNORE_PATH`, `CONTRIBUTE_SESSIONS_DIR`, `DASHBOARD_EVENTS_DIR/PATH`)
|
||||
were removed.
|
||||
|
||||
**Functionization ≠ project-scoping.** All of these are class-A2 (machine-level):
|
||||
the getters still return `~/.teamai/...`, unchanged. The project-scoped equivalents
|
||||
already route through `getDataHome()`. The dashboard is likewise an A2 singleton
|
||||
(events carry `cwd`/`sessionId`); "two projects' events don't mix" is satisfied by
|
||||
`getEventsPath()` reading `HOME` at call time, not by per-project dirs.
|
||||
|
||||
**`anchor` on save.** Previously only migration wrote a partition's `anchor`
|
||||
reverse-lookup file, so freshly-init'd partitions had none. `saveLocalConfigForScope`
|
||||
now writes it whenever the config lands in a partition (via the shared
|
||||
`writeAnchorFile`), so every partition can be resolved back to its project.
|
||||
|
||||
**`status --all`.** Extends the existing `status` command with an `--all` flag that
|
||||
enumerates every partition under `~/.teamai/projects/` and marks each
|
||||
active / ORPHAN (project path gone → safe to delete) / unknown / corrupt. The
|
||||
verdict rests **only on the `anchor` file** — the shared project anchor the
|
||||
partition is keyed by. The config's businessRepoRoot/projectRoot is read purely as
|
||||
a display fallback: it is a persisted *workspace* path that may point at a linked
|
||||
worktree, so its disappearance does not prove the shared partition is orphaned. A
|
||||
partition with no anchor (e.g. one written before anchor-on-save) is therefore
|
||||
`unknown`, never ORPHAN — we never recommend deleting data we cannot confirm is
|
||||
dead. teamai never auto-collects orphans (a renamed or deleted project leaves its
|
||||
partition behind — a `gc` command is explicitly out of scope), so this is how a
|
||||
user finds partitions safe to `rm -rf` by hand.
|
||||
|
||||
### Explicitly out of scope
|
||||
|
||||
|
||||
@@ -548,8 +548,17 @@ Choose namespace [1-3] (default: 1 = common):
|
||||
|
||||
```bash
|
||||
teamai status # Current scope, last sync time, resource stats
|
||||
teamai status --all # List every project data partition under ~/.teamai/projects
|
||||
```
|
||||
|
||||
`--all` enumerates every project's machine-data partition and flags each as
|
||||
**active** (project still on disk), **ORPHAN** (project moved/deleted — its
|
||||
partition is safe to `rm -rf`), or **unknown** (no `anchor` file, so it cannot be
|
||||
confirmed orphaned — never recommended for deletion). The ORPHAN verdict rests
|
||||
only on the anchor, so a partition is never flagged for deletion on a hunch. teamai
|
||||
never garbage-collects orphans automatically, so this is how you find partitions to
|
||||
delete by hand.
|
||||
|
||||
### Role management
|
||||
|
||||
Roles control which skills each member sees. Admins define roles via `manifest/roles.yaml`; once a member selects their role, `pull` syncs skills from the matching namespace. Active tag subscriptions may additionally sync explicitly matching skills from other namespaces, but untagged skills in inactive namespaces are not included.
|
||||
|
||||
@@ -533,8 +533,14 @@ Choose namespace [1-3] (default: 1 = common):
|
||||
|
||||
```bash
|
||||
teamai status # 当前 scope、同步时间、资源统计
|
||||
teamai status --all # 列出 ~/.teamai/projects 下所有项目数据分区
|
||||
```
|
||||
|
||||
`--all` 会枚举每个项目的机器数据分区,并标注为 **active**(项目仍在磁盘上)、
|
||||
**ORPHAN**(项目已移动/删除——该分区可安全 `rm -rf`)或 **unknown**(无 `anchor`
|
||||
文件,无法确认是否孤儿——绝不建议删除)。ORPHAN 判定只依据 anchor,因此绝不会凭猜测
|
||||
把分区标记为可删除。teamai 从不自动回收孤儿分区,因此这是你找出可手动删除分区的方式。
|
||||
|
||||
### 角色管理
|
||||
|
||||
角色(Roles)控制每个成员看到哪些 skills。管理员通过 `manifest/roles.yaml` 定义角色,成员选择自己的角色后,pull 会同步对应 namespace 的 skills。启用标签订阅后,还可以额外同步其他 namespace 中显式匹配标签的 skills,但不会包含非活跃 namespace 中未打标签的 skills。
|
||||
|
||||
@@ -44,7 +44,7 @@ vi.mock('../utils/logger.js', () => ({
|
||||
}));
|
||||
|
||||
const { contribute } = await import('../contribute.js');
|
||||
const { LEARNINGS_LOCAL_DIR } = await import('../types.js');
|
||||
const { getUserLearningsDir } = await import('../types.js');
|
||||
|
||||
function git(args: string[], cwd: string) {
|
||||
execFileSync('git', args, { cwd, stdio: 'ignore' });
|
||||
@@ -54,7 +54,7 @@ describe('contributeSelf — machine-local learnings cache (issue #472)', () =>
|
||||
beforeEach(async () => {
|
||||
fs.rmSync(businessRoot, { recursive: true, force: true });
|
||||
fs.rmSync(remote, { recursive: true, force: true });
|
||||
fs.rmSync(LEARNINGS_LOCAL_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(getUserLearningsDir(), { recursive: true, force: true });
|
||||
|
||||
fs.mkdirSync(businessRoot, { recursive: true });
|
||||
git(['init', '--bare', remote], testRoot);
|
||||
@@ -72,10 +72,10 @@ describe('contributeSelf — machine-local learnings cache (issue #472)', () =>
|
||||
// Pre-existing cache content this contribution must never touch: another
|
||||
// project's shared root learning, plus a namespace directory unrelated to
|
||||
// this project's own (empty) namespace set.
|
||||
fs.mkdirSync(LEARNINGS_LOCAL_DIR, { recursive: true });
|
||||
fs.writeFileSync(path.join(LEARNINGS_LOCAL_DIR, 'other-team.md'), '# other team knowledge');
|
||||
fs.mkdirSync(path.join(LEARNINGS_LOCAL_DIR, 'other-namespace'), { recursive: true });
|
||||
fs.writeFileSync(path.join(LEARNINGS_LOCAL_DIR, 'other-namespace', 'note.md'), '# unrelated namespace');
|
||||
fs.mkdirSync(getUserLearningsDir(), { recursive: true });
|
||||
fs.writeFileSync(path.join(getUserLearningsDir(), 'other-team.md'), '# other team knowledge');
|
||||
fs.mkdirSync(path.join(getUserLearningsDir(), 'other-namespace'), { recursive: true });
|
||||
fs.writeFileSync(path.join(getUserLearningsDir(), 'other-namespace', 'note.md'), '# unrelated namespace');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -84,7 +84,7 @@ describe('contributeSelf — machine-local learnings cache (issue #472)', () =>
|
||||
});
|
||||
|
||||
function cacheFiles(): string[] {
|
||||
return fs.readdirSync(LEARNINGS_LOCAL_DIR).sort();
|
||||
return fs.readdirSync(getUserLearningsDir()).sort();
|
||||
}
|
||||
|
||||
function noteFile(text: string): string {
|
||||
@@ -96,8 +96,8 @@ describe('contributeSelf — machine-local learnings cache (issue #472)', () =>
|
||||
it('adds a new contribution without deleting unrelated cache entries', async () => {
|
||||
await contribute({ scope: 'project', title: 'first-pending', file: noteFile('first unique knowledge') });
|
||||
|
||||
expect(fs.existsSync(path.join(LEARNINGS_LOCAL_DIR, 'other-team.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(LEARNINGS_LOCAL_DIR, 'other-namespace', 'note.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(getUserLearningsDir(), 'other-team.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(getUserLearningsDir(), 'other-namespace', 'note.md'))).toBe(true);
|
||||
expect(cacheFiles().some((f) => f.startsWith('first-pending-'))).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ vi.mock('../utils/logger.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../types.js', () => ({
|
||||
TEAMAI_HOME: '/tmp/test-teamai-home',
|
||||
getTeamaiHomeDir: () => '/tmp/test-teamai-home',
|
||||
}));
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
|
||||
@@ -47,19 +47,21 @@ describe('getUserHome', () => {
|
||||
expect(resolveBaseDir(config)).toBe('C:\\Users\\alice');
|
||||
});
|
||||
|
||||
it('initializes exported user paths from USERPROFILE when HOME is unavailable', async () => {
|
||||
it('resolves user paths from USERPROFILE when HOME is unavailable (evaluated at call time)', async () => {
|
||||
// Path getters read the home dir at CALL time (issue #374 P3), so changing
|
||||
// the environment takes effect immediately — no vi.resetModules() dance the
|
||||
// old module-load constants needed.
|
||||
delete process.env.HOME;
|
||||
process.env.USERPROFILE = 'C:\\Users\\alice';
|
||||
vi.resetModules();
|
||||
|
||||
const paths = await import('../types.js');
|
||||
const { getTeamaiHomeDir, getUserConfigPath, getUserStatePath } = await import('../types.js');
|
||||
|
||||
expect(paths.TEAMAI_HOME).toBe(path.join('C:\\Users\\alice', '.teamai'));
|
||||
expect(paths.TEAMAI_CONFIG_PATH).toBe(
|
||||
expect(getTeamaiHomeDir()).toBe(path.join('C:\\Users\\alice', '.teamai'));
|
||||
expect(getUserConfigPath()).toBe(
|
||||
path.join('C:\\Users\\alice', '.teamai', 'config.yaml'),
|
||||
);
|
||||
expect(paths.TEAMAI_SOURCES_DIR).toBe(
|
||||
path.join('C:\\Users\\alice', '.teamai', 'sources'),
|
||||
expect(getUserStatePath()).toBe(
|
||||
path.join('C:\\Users\\alice', '.teamai', 'state.json'),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -165,7 +165,9 @@ vi.mock('../types.js', async (importOriginal) => {
|
||||
const original = await importOriginal() as Record<string, unknown>;
|
||||
return {
|
||||
...original,
|
||||
TEAMAI_HOME: '/tmp/test-teamai-home',
|
||||
// The machine home is now a runtime getter (issue #374 P3), so override the
|
||||
// getter instead of the removed TEAMAI_HOME const to isolate onto /tmp.
|
||||
getTeamaiHomeDir: () => '/tmp/test-teamai-home',
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fse from 'fs-extra';
|
||||
|
||||
// ─── issue #374 P3: constant functionization + anchor-on-save ───────────────
|
||||
|
||||
// Capture status --all output. Every line log.info/log.warn emits is collected
|
||||
// so the orphan/unknown verdict can be asserted.
|
||||
const logLines: string[] = [];
|
||||
vi.mock('../utils/logger.js', () => ({
|
||||
log: {
|
||||
info: (m: string) => { logLines.push(m); },
|
||||
warn: (m: string) => { logLines.push(m); },
|
||||
success: (m: string) => { logLines.push(m); },
|
||||
error: (m: string) => { logLines.push(m); },
|
||||
debug: () => {},
|
||||
dim: (m: string) => { logLines.push(m); },
|
||||
},
|
||||
spinner: () => ({
|
||||
start: () => ({ succeed() {}, fail() {}, warn() {}, info() {}, stop() {} }),
|
||||
}),
|
||||
}));
|
||||
|
||||
function git(cwd: string, ...args: string[]): void {
|
||||
execFileSync('git', args, { cwd, stdio: 'pipe' });
|
||||
}
|
||||
|
||||
let base: string, home: string;
|
||||
|
||||
beforeEach(() => {
|
||||
base = realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p3-')));
|
||||
home = path.join(base, 'home');
|
||||
fs.mkdirSync(home);
|
||||
vi.stubEnv('HOME', home);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
try { fs.rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
|
||||
});
|
||||
|
||||
describe('P3 path getters honor a runtime HOME change (no vi.resetModules)', () => {
|
||||
it('getTeamaiHomeDir / getUserVotesDir / getSessionLogsDir follow the current HOME', async () => {
|
||||
const types = await import('../types.js');
|
||||
// HOME was stubbed in beforeEach; the getters must reflect it at CALL time.
|
||||
expect(types.getTeamaiHomeDir()).toBe(path.join(home, '.teamai'));
|
||||
expect(types.getUserVotesDir()).toBe(path.join(home, '.teamai', 'votes'));
|
||||
expect(types.getSessionLogsDir()).toBe(path.join(home, '.teamai', 'session-logs'));
|
||||
|
||||
// Swap HOME again mid-test — a module-load const could never do this.
|
||||
const home2 = path.join(base, 'home2');
|
||||
fs.mkdirSync(home2);
|
||||
vi.stubEnv('HOME', home2);
|
||||
expect(types.getTeamaiHomeDir()).toBe(path.join(home2, '.teamai'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('P3 dashboard events isolate by HOME (two projects do not mix)', () => {
|
||||
it('appendEvent writes under the current HOME, so two HOMEs get separate logs', async () => {
|
||||
const { appendEvent } = await import('../dashboard-collector.js');
|
||||
const ev = (sid: string) => ({
|
||||
type: 'session_start', sessionId: sid, tool: 'claude', cwd: '/p', timestamp: new Date().toISOString(),
|
||||
}) as never;
|
||||
|
||||
const homeA = path.join(base, 'homeA');
|
||||
fs.mkdirSync(homeA);
|
||||
vi.stubEnv('HOME', homeA);
|
||||
await appendEvent(ev('A'));
|
||||
|
||||
const homeB = path.join(base, 'homeB');
|
||||
fs.mkdirSync(homeB);
|
||||
vi.stubEnv('HOME', homeB);
|
||||
await appendEvent(ev('B'));
|
||||
|
||||
const logA = fs.readFileSync(path.join(homeA, '.teamai', 'dashboard', 'events.jsonl'), 'utf-8');
|
||||
const logB = fs.readFileSync(path.join(homeB, '.teamai', 'dashboard', 'events.jsonl'), 'utf-8');
|
||||
expect(logA).toContain('"A"');
|
||||
expect(logA).not.toContain('"B"'); // project A's log has no project B event
|
||||
expect(logB).toContain('"B"');
|
||||
expect(logB).not.toContain('"A"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('P3 saveLocalConfigForScope writes an anchor into a partition', () => {
|
||||
it('a partitioned project config gets an anchor reverse-lookup file', async () => {
|
||||
const repo = path.join(base, 'repo');
|
||||
fs.mkdirSync(repo);
|
||||
git(repo, 'init', '-q');
|
||||
git(repo, 'config', 'user.email', 't@e.com');
|
||||
git(repo, 'config', 'user.name', 'T');
|
||||
git(repo, 'commit', '--allow-empty', '-q', '-m', 'init');
|
||||
const realRepo = realpathSync(repo);
|
||||
|
||||
const { saveLocalConfigForScope } = await import('../config.js');
|
||||
const { projectDataHome, readAnchorFile } = await import('../utils/partition.js');
|
||||
const cfg = {
|
||||
repo: { localPath: path.join(realRepo, '.teamai', 'team-repo'), remote: 'r', kind: 'git' },
|
||||
username: 'u', scope: 'project', projectRoot: realRepo,
|
||||
dataHome: projectDataHome(realRepo),
|
||||
} as never;
|
||||
|
||||
await saveLocalConfigForScope(cfg, 'project', realRepo);
|
||||
|
||||
const partition = projectDataHome(realRepo);
|
||||
expect(await fse.pathExists(path.join(partition, 'config.yaml'))).toBe(true);
|
||||
// The anchor file was written and points back at the project anchor (main checkout).
|
||||
expect(await readAnchorFile(partition)).toBe(realRepo);
|
||||
});
|
||||
|
||||
it('a user-scope config does NOT get an anchor (only partitions do)', async () => {
|
||||
const { saveLocalConfigForScope } = await import('../config.js');
|
||||
const cfg = {
|
||||
repo: { localPath: path.join(home, '.teamai', 'team-repo'), remote: 'r', kind: 'git' },
|
||||
username: 'u', scope: 'user',
|
||||
} as never;
|
||||
await saveLocalConfigForScope(cfg, 'user');
|
||||
expect(await fse.pathExists(path.join(home, '.teamai', 'anchor'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('P3 status --all orphan verdict rests on the anchor, not a persisted workspace path', () => {
|
||||
beforeEach(() => { logLines.length = 0; });
|
||||
|
||||
it('a pre-P3 partition (no anchor) whose config projectRoot points at a removed worktree is unknown, NOT orphan', async () => {
|
||||
const { projectDataHome } = await import('../utils/partition.js');
|
||||
// The shared project anchor (main checkout) still exists; the partition is
|
||||
// keyed by it. This is what detectProjectConfig(mainCheckout) would load.
|
||||
const mainCheckout = path.join(base, 'main-checkout');
|
||||
fs.mkdirSync(mainCheckout, { recursive: true });
|
||||
// A now-removed linked worktree, persisted in the pre-P3 config as projectRoot.
|
||||
const removedWorktree = path.join(base, 'gone-worktree'); // never created
|
||||
|
||||
const partition = projectDataHome(mainCheckout);
|
||||
fs.mkdirSync(partition, { recursive: true });
|
||||
// Pre-P3 config: NO anchor file was written; projectRoot points at the worktree.
|
||||
fs.writeFileSync(
|
||||
path.join(partition, 'config.yaml'),
|
||||
`repo:\n localPath: ${path.join(partition, 'team-repo')}\n remote: r\n kind: git\nusername: u\nscope: project\nprojectRoot: ${removedWorktree}\n`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const { status } = await import('../status.js');
|
||||
await status({ all: true } as never);
|
||||
|
||||
const out = logLines.join('\n');
|
||||
// The removed worktree path must NOT trigger an ORPHAN "safe to delete" verdict.
|
||||
expect(out).not.toContain('ORPHAN');
|
||||
expect(out).not.toContain('safe to delete');
|
||||
expect(out).toMatch(/unknown/);
|
||||
// No orphan summary / rm -rf hint should be printed.
|
||||
expect(out).not.toContain('rm -rf');
|
||||
});
|
||||
|
||||
it('a partition whose anchor path is gone IS flagged orphan', async () => {
|
||||
const { projectDataHome, writeAnchorFile } = await import('../utils/partition.js');
|
||||
const goneProject = path.join(base, 'deleted-project'); // never created
|
||||
const partition = projectDataHome(goneProject);
|
||||
fs.mkdirSync(partition, { recursive: true });
|
||||
await writeAnchorFile(partition, goneProject);
|
||||
|
||||
const { status } = await import('../status.js');
|
||||
await status({ all: true } as never);
|
||||
|
||||
const out = logLines.join('\n');
|
||||
expect(out).toContain('ORPHAN');
|
||||
expect(out).toContain('rm -rf');
|
||||
});
|
||||
|
||||
it('a partition whose anchor path still exists is active', async () => {
|
||||
const { projectDataHome, writeAnchorFile } = await import('../utils/partition.js');
|
||||
const liveProject = path.join(base, 'live-project');
|
||||
fs.mkdirSync(liveProject, { recursive: true });
|
||||
const partition = projectDataHome(liveProject);
|
||||
fs.mkdirSync(partition, { recursive: true });
|
||||
await writeAnchorFile(partition, liveProject);
|
||||
|
||||
const { status } = await import('../status.js');
|
||||
await status({ all: true } as never);
|
||||
|
||||
const out = logLines.join('\n');
|
||||
expect(out).toMatch(/\[active\]/);
|
||||
expect(out).not.toContain('ORPHAN');
|
||||
});
|
||||
});
|
||||
@@ -63,7 +63,7 @@ vi.mock('../update.js', () => ({
|
||||
|
||||
const { pull } = await import('../pull.js');
|
||||
const { loadLocalConfigForScope, loadTeamConfig } = await import('../config.js');
|
||||
const { LEARNINGS_LOCAL_DIR, SEARCH_INDEX_PATH } = await import('../types.js');
|
||||
const { getUserLearningsDir, getUserSearchIndexPath } = await import('../types.js');
|
||||
const { loadIndex } = await import('../utils/search-index.js');
|
||||
import type { LocalConfig, TeamaiConfig } from '../types.js';
|
||||
|
||||
@@ -94,8 +94,8 @@ describe('pull — user-scope learning deletion propagation (issue #458)', () =>
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await fse.remove(repoPath);
|
||||
await fse.remove(LEARNINGS_LOCAL_DIR);
|
||||
await fse.remove(SEARCH_INDEX_PATH);
|
||||
await fse.remove(getUserLearningsDir());
|
||||
await fse.remove(getUserSearchIndexPath());
|
||||
await fse.outputFile(path.join(repoPath, 'learnings', 'shared-a.md'), '---\ntitle: shared a\n---\n');
|
||||
await fse.outputFile(path.join(repoPath, 'learnings', 'shared-b.md'), '---\ntitle: shared b\n---\n');
|
||||
vi.mocked(loadLocalConfigForScope).mockResolvedValue(localConfig);
|
||||
@@ -109,13 +109,13 @@ describe('pull — user-scope learning deletion propagation (issue #458)', () =>
|
||||
|
||||
it('removes a shared Markdown file deleted upstream and drops it from the index', async () => {
|
||||
await pull({ silent: true });
|
||||
expect(await fse.pathExists(path.join(LEARNINGS_LOCAL_DIR, 'shared-b.md'))).toBe(true);
|
||||
expect(await fse.pathExists(path.join(getUserLearningsDir(), 'shared-b.md'))).toBe(true);
|
||||
|
||||
await fse.remove(path.join(repoPath, 'learnings', 'shared-b.md'));
|
||||
await pull({ silent: true, force: true });
|
||||
|
||||
expect(await fse.pathExists(path.join(LEARNINGS_LOCAL_DIR, 'shared-b.md'))).toBe(false);
|
||||
const index = await loadIndex(SEARCH_INDEX_PATH);
|
||||
expect(await fse.pathExists(path.join(getUserLearningsDir(), 'shared-b.md'))).toBe(false);
|
||||
const index = await loadIndex(getUserSearchIndexPath());
|
||||
expect(index?.entries.map((entry) => entry.title)).toEqual(['shared a']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ vi.mock('../utils/fs.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../types.js', () => ({
|
||||
TEAMAI_UPDATE_LOCK_PATH: '/tmp/test-update-lock',
|
||||
getUpdateLockPath: () => '/tmp/test-update-lock',
|
||||
}));
|
||||
|
||||
let readlineAnswer = 'n';
|
||||
|
||||
+25
-8
@@ -4,8 +4,8 @@ import {
|
||||
TeamaiConfigSchema,
|
||||
LocalConfigSchema,
|
||||
StateSchema,
|
||||
TEAMAI_CONFIG_PATH,
|
||||
TEAMAI_STATE_PATH,
|
||||
getUserConfigPath,
|
||||
getUserStatePath,
|
||||
type TeamaiConfig,
|
||||
type LocalConfig,
|
||||
type State,
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from './types.js';
|
||||
import { readFileSafe, readJson, writeFile, writeJson, expandHome, pathExists } from './utils/fs.js';
|
||||
import { resolveAnchors } from './utils/git.js';
|
||||
import { projectDataHome } from './utils/partition.js';
|
||||
import { projectDataHome, writeAnchorFile } from './utils/partition.js';
|
||||
import { log } from './utils/logger.js';
|
||||
import { loadRolesManifest } from './roles.js';
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function loadTeamConfig(repoPath: string): Promise<TeamaiConfig | n
|
||||
* Load the local config (~/.teamai/config.yaml)
|
||||
*/
|
||||
export async function loadLocalConfig(): Promise<LocalConfig | null> {
|
||||
const configPath = expandHome(TEAMAI_CONFIG_PATH);
|
||||
const configPath = expandHome(getUserConfigPath());
|
||||
const content = await readFileSafe(configPath);
|
||||
if (!content) return null;
|
||||
try {
|
||||
@@ -100,14 +100,14 @@ function serializeLocalConfig(config: LocalConfig): string {
|
||||
* Save the local config
|
||||
*/
|
||||
export async function saveLocalConfig(config: LocalConfig): Promise<void> {
|
||||
await writeFile(expandHome(TEAMAI_CONFIG_PATH), serializeLocalConfig(config));
|
||||
await writeFile(expandHome(getUserConfigPath()), serializeLocalConfig(config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the local state (~/.teamai/state.json)
|
||||
*/
|
||||
export async function loadState(): Promise<State> {
|
||||
const raw = await readJson<Record<string, unknown>>(expandHome(TEAMAI_STATE_PATH));
|
||||
const raw = await readJson<Record<string, unknown>>(expandHome(getUserStatePath()));
|
||||
if (!raw) return StateSchema.parse({});
|
||||
return StateSchema.parse(raw);
|
||||
}
|
||||
@@ -116,7 +116,7 @@ export async function loadState(): Promise<State> {
|
||||
* Save the local state
|
||||
*/
|
||||
export async function saveState(state: State): Promise<void> {
|
||||
await writeJson(expandHome(TEAMAI_STATE_PATH), state);
|
||||
await writeJson(expandHome(getUserStatePath()), state);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,8 +179,25 @@ export async function saveLocalConfigForScope(
|
||||
_scope?: Scope,
|
||||
_projectRoot?: string,
|
||||
): Promise<void> {
|
||||
const configPath = path.join(getDataHome(config), 'config.yaml');
|
||||
const dataHome = getDataHome(config);
|
||||
const configPath = path.join(dataHome, 'config.yaml');
|
||||
await writeFile(expandHome(configPath), serializeLocalConfig(config));
|
||||
// If the config lives in a project partition, drop the `anchor` reverse-lookup
|
||||
// file next to it (issue #374 P3). Previously only migration wrote it, so
|
||||
// freshly-init'd partitions had no anchor and `status --all` could not resolve
|
||||
// them back to a project path. Skip user scope (dataHome is ~/.teamai, not a
|
||||
// partition) and legacy in-repo data homes.
|
||||
try {
|
||||
if (config.scope === 'project' && config.projectRoot) {
|
||||
const partition = await resolveProjectDataHome(config.projectRoot);
|
||||
if (path.resolve(expandHome(dataHome)) === path.resolve(partition)) {
|
||||
const anchors = await resolveAnchors(config.projectRoot);
|
||||
if (anchors) await writeAnchorFile(partition, anchors.projectAnchor);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// anchor file is a best-effort convenience for status --all; never fail a save
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+7
-7
@@ -10,7 +10,7 @@ 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';
|
||||
import { getUserLearningsDir, getDataHome } from './types.js';
|
||||
import { addLearningToCache, mirrorLearnings } from './utils/learnings-mirror.js';
|
||||
|
||||
/**
|
||||
@@ -64,8 +64,8 @@ async function rebuildIndexAfterContribute(localConfig: LocalConfig): Promise<vo
|
||||
localConfig.projects ?? [],
|
||||
);
|
||||
if (localConfig.scope === 'user') {
|
||||
await mirrorLearnings(learningsRepoDir, LEARNINGS_LOCAL_DIR, activeLearningsNamespaces);
|
||||
effectiveLearningsDir = (await pathExists(LEARNINGS_LOCAL_DIR)) ? LEARNINGS_LOCAL_DIR : undefined;
|
||||
await mirrorLearnings(learningsRepoDir, getUserLearningsDir(), activeLearningsNamespaces);
|
||||
effectiveLearningsDir = (await pathExists(getUserLearningsDir())) ? getUserLearningsDir() : undefined;
|
||||
} else {
|
||||
effectiveLearningsDir = (await pathExists(learningsRepoDir)) ? learningsRepoDir : undefined;
|
||||
}
|
||||
@@ -255,7 +255,7 @@ export async function contribute(
|
||||
* PR from an isolated knowledge worktree instead of pushing to main directly.
|
||||
*
|
||||
* The user's active working tree is never written to. For immediate local recall,
|
||||
* we additively copy just the new file into the machine-local LEARNINGS_LOCAL_DIR
|
||||
* we additively copy just the new file into the machine-local getUserLearningsDir()
|
||||
* and index from there. The worktree is a disposable snapshot scoped to
|
||||
* origin/<default>, not the full cache's source of truth, so it must never drive
|
||||
* a deleting mirror there: doing so wiped out other projects' cached learnings
|
||||
@@ -301,7 +301,7 @@ async function contributeSelf(
|
||||
// worktree — withKnowledgeWorktree deletes wtRepo on teardown, and buildIndex
|
||||
// bakes absolute paths into search-index.json, so worktree paths would leave
|
||||
// recall printing `File: <deleted>` pointers. learnings come from the
|
||||
// persistent LEARNINGS_LOCAL_DIR mirror; votes from the reports worktree.
|
||||
// persistent getUserLearningsDir() mirror; votes from the reports worktree.
|
||||
// This matches the other index-build sites (pull.ts / recall.ts).
|
||||
try {
|
||||
const { pathExists } = await import('./utils/fs.js');
|
||||
@@ -309,7 +309,7 @@ async function contributeSelf(
|
||||
localConfig.repo.localPath,
|
||||
localConfig.projects ?? [],
|
||||
);
|
||||
await addLearningToCache(destAbs, LEARNINGS_LOCAL_DIR, relPath.slice('learnings/'.length));
|
||||
await addLearningToCache(destAbs, getUserLearningsDir(), relPath.slice('learnings/'.length));
|
||||
|
||||
const repoPath = localConfig.repo.localPath; // persistent active-tree .teamai
|
||||
const docsDir = path.join(repoPath, 'docs');
|
||||
@@ -327,7 +327,7 @@ async function contributeSelf(
|
||||
const teamaiHome = getDataHome(localConfig);
|
||||
const { buildIndex } = await import('./utils/search-index.js');
|
||||
await buildIndex({
|
||||
learningsDir: await pathExists(LEARNINGS_LOCAL_DIR) ? LEARNINGS_LOCAL_DIR : undefined,
|
||||
learningsDir: await pathExists(getUserLearningsDir()) ? getUserLearningsDir() : undefined,
|
||||
learningsNamespaces: activeLearningsNamespaces,
|
||||
docsDir: await pathExists(docsDir) ? docsDir : undefined,
|
||||
rulesDir: await pathExists(rulesDir) ? rulesDir : undefined,
|
||||
|
||||
@@ -10,8 +10,6 @@ import { resolveMonitorPid } from './pid-monitor.js';
|
||||
import { normalizeToolName } from './utils/tool-names.js';
|
||||
import { redactWithEnv } from './utils/redact.js';
|
||||
import {
|
||||
DASHBOARD_EVENTS_PATH,
|
||||
DASHBOARD_EVENTS_DIR,
|
||||
DASHBOARD_COMPACTION_THRESHOLD,
|
||||
DASHBOARD_IDLE_TIMEOUT_MS,
|
||||
DASHBOARD_STALE_TIMEOUT_MS,
|
||||
|
||||
@@ -7,8 +7,6 @@ import { readEvents, rebuildSessions, appendEvent } from './dashboard-collector.
|
||||
import { isProcessAlive } from './pid-monitor.js';
|
||||
import {
|
||||
DASHBOARD_DEFAULT_PORT,
|
||||
DASHBOARD_EVENTS_PATH,
|
||||
DASHBOARD_EVENTS_DIR,
|
||||
DASHBOARD_PID_CHECK_INTERVAL_MS,
|
||||
type DashboardEvent,
|
||||
} from './types.js';
|
||||
|
||||
@@ -300,8 +300,8 @@ const votesSyncHandler: HookHandler = {
|
||||
// autoDetectInit picks project scope when present (so self-mode configs are
|
||||
// honored), falling back to user scope otherwise.
|
||||
const { localConfig } = await autoDetectInit();
|
||||
const { VOTES_LOCAL_DIR } = await import('./types.js');
|
||||
const votesDir = VOTES_LOCAL_DIR;
|
||||
const { getUserVotesDir } = await import('./types.js');
|
||||
const votesDir = getUserVotesDir();
|
||||
const votePath = path.join(votesDir, `${localConfig.username}.yaml`);
|
||||
|
||||
// Only count upvotes for docs actually recalled this session, to avoid crediting hallucinated/distractor doc-ids
|
||||
|
||||
+3
-2
@@ -100,10 +100,11 @@ program
|
||||
program
|
||||
.command('status')
|
||||
.description('Show local vs team repo diff')
|
||||
.action(async () => {
|
||||
.option('--all', 'List every project data partition under ~/.teamai/projects (flags stale/orphan ones)')
|
||||
.action(async (cmdOpts) => {
|
||||
const globalOpts = program.opts() as GlobalOptions;
|
||||
const { status } = await import('./status.js');
|
||||
await status(globalOpts);
|
||||
await status({ ...globalOpts, ...cmdOpts });
|
||||
});
|
||||
|
||||
program
|
||||
|
||||
+4
-4
@@ -9,7 +9,7 @@ import { getProvider, detectProviderForInit, RepoNotFoundError } from './provide
|
||||
import { ensureDir, writeFile, pathExists, expandHome, readFileSafe, remove } from './utils/fs.js';
|
||||
import { log, spinner } from './utils/logger.js';
|
||||
import {
|
||||
TEAMAI_HOME,
|
||||
getTeamaiHomeDir,
|
||||
REPORTS_BRANCH,
|
||||
type GlobalOptions,
|
||||
type LocalConfig,
|
||||
@@ -397,7 +397,7 @@ export async function initHttp(
|
||||
if (scope === 'project') {
|
||||
await saveLocalConfigForScope(localConfig, scope, projectRoot);
|
||||
} else {
|
||||
await ensureDir(TEAMAI_HOME);
|
||||
await ensureDir(getTeamaiHomeDir());
|
||||
await saveLocalConfig(localConfig);
|
||||
}
|
||||
log.success(`Local config saved to ${teamaiHome}/config.yaml`);
|
||||
@@ -1412,9 +1412,9 @@ export async function init(options: GlobalOptions & {
|
||||
log.debug('Generated .teamai/.gitignore for project scope');
|
||||
}
|
||||
} else {
|
||||
await ensureDir(TEAMAI_HOME);
|
||||
await ensureDir(getTeamaiHomeDir());
|
||||
await saveLocalConfig(localConfig);
|
||||
log.success(`Local config saved to ${TEAMAI_HOME}/config.yaml`);
|
||||
log.success(`Local config saved to ${getTeamaiHomeDir()}/config.yaml`);
|
||||
}
|
||||
|
||||
// Step 6.5: Invalidate pull cache so next pull does full sync with cleanup
|
||||
|
||||
+2
-3
@@ -49,8 +49,7 @@ import { logHttpRequest, logHttpResponse } from './utils/http-log.js';
|
||||
import { reconcilePlugins, teardownAllPlugins, parseGetConfig, substituteVars, unresolvedPlaceholders, type ReconcileDeps, type PluginState } from './plugin-lifecycle.js';
|
||||
import {
|
||||
resolveBaseDir,
|
||||
TEAMAI_HOME,
|
||||
TEAMAI_TOKEN_PATH,
|
||||
getTokenPath,
|
||||
TEAMAI_CLAUDEMD_START,
|
||||
TEAMAI_CLAUDEMD_END,
|
||||
TeamaiConfigSchema,
|
||||
@@ -3257,7 +3256,7 @@ export async function initLocalAgentHttp(options: {
|
||||
await ensureDir(getLocalAgentHome());
|
||||
await saveLocalAgentConfig(config);
|
||||
if (options.token) {
|
||||
await writeTokenFile(TEAMAI_TOKEN_PATH, options.token);
|
||||
await writeTokenFile(getTokenPath(), options.token);
|
||||
}
|
||||
|
||||
const teamConfig = createLocalAgentTeamConfig(endpoint);
|
||||
|
||||
+3
-5
@@ -3,7 +3,7 @@ import fse from 'fs-extra';
|
||||
import YAML from 'yaml';
|
||||
import { LocalConfigSchema, SYNC_LOCK_FILENAME } from './types.js';
|
||||
import { resolveAnchors } from './utils/git.js';
|
||||
import { projectDataHome } from './utils/partition.js';
|
||||
import { projectDataHome, writeAnchorFile } from './utils/partition.js';
|
||||
import { realpath } from 'node:fs/promises';
|
||||
import { expandHome, pathExists, readFileSafe, remove, writeFile } from './utils/fs.js';
|
||||
import { acquireLock, releaseLock } from './update.js';
|
||||
@@ -283,10 +283,8 @@ export async function runMigration(
|
||||
await remove(partitionDir);
|
||||
await fse.rename(staging, partitionDir);
|
||||
|
||||
// 4. Write the anchor reverse-lookup file. The slug is a one-way sha256, so
|
||||
// the original projectAnchor is only recoverable from this file — which
|
||||
// lives inside the partition, off the workspace, preserving zero-residue.
|
||||
await writeFile(path.join(partitionDir, 'anchor'), `${anchor}\n`);
|
||||
// 4. Write the anchor reverse-lookup file (shared helper, also used by init).
|
||||
await writeAnchorFile(partitionDir, anchor);
|
||||
|
||||
// 5. Release the lock BEFORE renaming legacyDir away, so releaseLock finds
|
||||
// the lock at its original path and no live lock is buried in the backup.
|
||||
|
||||
@@ -4,12 +4,16 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathExists, ensureDir } from '../../utils/fs.js';
|
||||
import { log, spinner } from '../../utils/logger.js';
|
||||
import { TEAMAI_HOME } from '../../types.js';
|
||||
import { getTeamaiHomeDir } from '../../types.js';
|
||||
import { tgitFetch, tgitGitCloneUrl } from './rest-auth.js';
|
||||
|
||||
/** Path where gf CLI is installed */
|
||||
const GF_INSTALL_DIR = path.join(TEAMAI_HOME, 'gf');
|
||||
const GF_BIN_PATH = path.join(GF_INSTALL_DIR, 'gf', 'bin', 'gf');
|
||||
function gfInstallDir(): string {
|
||||
return path.join(getTeamaiHomeDir(), 'gf');
|
||||
}
|
||||
function gfBinPath(): string {
|
||||
return path.join(gfInstallDir(), 'gf', 'bin', 'gf');
|
||||
}
|
||||
|
||||
/** Download base URL for gf CLI tarballs */
|
||||
const GF_DOWNLOAD_BASE = 'http://mirrors.tencent.com/repository/generic/gongfeng-cli/files/channels/stable';
|
||||
@@ -72,11 +76,11 @@ export function gfExec(
|
||||
function getGfPath(): string {
|
||||
// Prefer our managed install
|
||||
try {
|
||||
const stat = execSync(`test -x "${GF_BIN_PATH}" && echo ok`, {
|
||||
const stat = execSync(`test -x "${gfBinPath()}" && echo ok`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
if (stat.trim() === 'ok') return GF_BIN_PATH;
|
||||
if (stat.trim() === 'ok') return gfBinPath();
|
||||
} catch {
|
||||
// not installed locally
|
||||
}
|
||||
@@ -149,22 +153,22 @@ export async function ensureGfInstalled(): Promise<void> {
|
||||
const spin = spinner('Installing gf CLI (工蜂命令行工具)...').start();
|
||||
|
||||
try {
|
||||
await ensureDir(GF_INSTALL_DIR);
|
||||
await ensureDir(gfInstallDir());
|
||||
|
||||
// Download and extract tarball
|
||||
execSync(
|
||||
`curl -fsSL "${url}" | tar xz -C "${GF_INSTALL_DIR}"`,
|
||||
`curl -fsSL "${url}" | tar xz -C "${gfInstallDir()}"`,
|
||||
{ stdio: ['pipe', 'pipe', 'pipe'], timeout: 120_000 },
|
||||
);
|
||||
|
||||
// Verify installation
|
||||
execSync(`test -x "${GF_BIN_PATH}"`, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
execSync(`test -x "${gfBinPath()}"`, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
spin.succeed(`gf CLI installed to ${GF_INSTALL_DIR}`);
|
||||
spin.succeed(`gf CLI installed to ${gfInstallDir()}`);
|
||||
} catch (e) {
|
||||
spin.fail(`Failed to install gf CLI: ${(e as Error).message}`);
|
||||
log.info(`You can install it manually from: ${url}`);
|
||||
log.info(`Extract to: ${GF_INSTALL_DIR}`);
|
||||
log.info(`Extract to: ${gfInstallDir()}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -13,7 +13,7 @@ import { loadTagsConfig, filterByTags } from './utils/tags.js';
|
||||
import { BUILTIN_SKILL_NAMES } from './builtin-skills.js';
|
||||
import type { GlobalOptions, ResourceType, ResourceItem, TeamaiConfig, LocalConfig, TagsConfig } from './types.js';
|
||||
import {
|
||||
LEARNINGS_LOCAL_DIR,
|
||||
getUserLearningsDir,
|
||||
TEAMAI_CULTURE_START,
|
||||
TEAMAI_CULTURE_END,
|
||||
TEAMAI_CLAUDEMD_START,
|
||||
@@ -866,13 +866,13 @@ async function pullForScope(
|
||||
if (localConfig.scope === 'user') {
|
||||
await mirrorLearnings(
|
||||
learningsRepoDir,
|
||||
LEARNINGS_LOCAL_DIR,
|
||||
getUserLearningsDir(),
|
||||
activeLearningsNamespaces,
|
||||
);
|
||||
if (await pathExists(learningsRepoDir)) {
|
||||
learningsCount = await countLearnings(learningsRepoDir);
|
||||
}
|
||||
effectiveLearningsDir = await pathExists(LEARNINGS_LOCAL_DIR) ? LEARNINGS_LOCAL_DIR : undefined;
|
||||
effectiveLearningsDir = await pathExists(getUserLearningsDir()) ? getUserLearningsDir() : undefined;
|
||||
} else {
|
||||
effectiveLearningsDir = await pathExists(learningsRepoDir) ? learningsRepoDir : undefined;
|
||||
if (effectiveLearningsDir) {
|
||||
|
||||
+4
-4
@@ -27,7 +27,7 @@ import {
|
||||
} from './session-collector.js';
|
||||
import { log, spinner } from './utils/logger.js';
|
||||
import { withTimeout } from './utils/async.js';
|
||||
import { SESSION_LOGS_LOCAL_DIR } from './types.js';
|
||||
import { getSessionLogsDir } from './types.js';
|
||||
import type { GlobalOptions, LocalConfig } from './types.js';
|
||||
|
||||
export interface SaveSessionOptions extends GlobalOptions {
|
||||
@@ -75,17 +75,17 @@ export async function saveSession(options: SaveSessionOptions): Promise<void> {
|
||||
log.info(
|
||||
`[dry-run] Would record session ${sessionId.slice(0, 8)} ` +
|
||||
`(${summary.toolTotal} tools, ${summary.interventionCount} interventions, ` +
|
||||
`valuable=${summary.valuable}) to ${SESSION_LOGS_LOCAL_DIR}/${monthKey(summary)}.md`,
|
||||
`valuable=${summary.valuable}) to ${getSessionLogsDir()}/${monthKey(summary)}.md`,
|
||||
);
|
||||
} else {
|
||||
// Local logs live on the user's own machine, so keep the redacted prompt line.
|
||||
const written = await appendMonthlyLog(SESSION_LOGS_LOCAL_DIR, summary, { includePrompt: true });
|
||||
const written = await appendMonthlyLog(getSessionLogsDir(), summary, { includePrompt: true });
|
||||
if (written) {
|
||||
log.info(`Recorded session to ${written}`);
|
||||
} else {
|
||||
log.info(`Session ${sessionId.slice(0, 8)} already recorded this month.`);
|
||||
}
|
||||
await pruneMonthlyLogs(SESSION_LOGS_LOCAL_DIR, new Date()).catch(() => []);
|
||||
await pruneMonthlyLogs(getSessionLogsDir(), new Date()).catch(() => []);
|
||||
}
|
||||
|
||||
if (!options.push) return;
|
||||
|
||||
+91
-1
@@ -16,7 +16,8 @@ import {
|
||||
truncate,
|
||||
type AgentSkillsView,
|
||||
} from './agent-skills.js';
|
||||
import { RESOURCE_TYPES, getDataHome, type GlobalOptions, type ResourceType } from './types.js';
|
||||
import { RESOURCE_TYPES, LocalConfigSchema, getDataHome, type GlobalOptions, type ResourceType } from './types.js';
|
||||
import { projectsRootDir, readAnchorFile, projectSlug } from './utils/partition.js';
|
||||
import { maskEnvValue } from './resources/env.js';
|
||||
import { parseTeamMcpServers } from './resources/mcp.js';
|
||||
import { parseHooksYaml } from './resources/hooks.js';
|
||||
@@ -32,6 +33,10 @@ export interface ListOptions extends GlobalOptions {
|
||||
}
|
||||
|
||||
export async function status(options: GlobalOptions): Promise<void> {
|
||||
if (options.all) {
|
||||
await statusAll();
|
||||
return;
|
||||
}
|
||||
// Auto-detect scope
|
||||
const { localConfig, teamConfig } = await autoDetectInit();
|
||||
const scopeLabel = localConfig.scope;
|
||||
@@ -140,6 +145,91 @@ export async function status(options: GlobalOptions): Promise<void> {
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* `teamai status --all` — enumerate every project data partition under
|
||||
* ~/.teamai/projects and flag the stale/orphan ones (issue #374 P3). teamai never
|
||||
* auto-collects orphans (a renamed/moved/deleted project leaves its partition
|
||||
* behind), so this is how a user finds partitions safe to delete by hand.
|
||||
*
|
||||
* The verdict rests on the `anchor` reverse-lookup file — the shared project
|
||||
* anchor this partition is keyed by. The persisted repo.businessRepoRoot /
|
||||
* projectRoot in config.yaml is read only as a DISPLAY fallback (an older
|
||||
* partition may predate anchor files); it is a workspace path that can point at a
|
||||
* linked worktree, so it must never drive the orphan verdict. We mark it:
|
||||
* - active : anchor exists on disk
|
||||
* - orphan : anchor is gone (project deleted/moved) → safe to delete
|
||||
* - unknown : no anchor → cannot confirm orphan (partition may still be active,
|
||||
* e.g. a pre-P3 partition still loaded by its main checkout)
|
||||
* - corrupt : the dir name does not match slug(anchor) → tampered/half-written
|
||||
*/
|
||||
async function statusAll(): Promise<void> {
|
||||
const root = projectsRootDir();
|
||||
const slugs = await listDirs(root);
|
||||
|
||||
console.log('');
|
||||
log.info(`Project data partitions (${root}):`);
|
||||
if (slugs.length === 0) {
|
||||
log.info(' (none — no project has been initialized or migrated on this machine)');
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
|
||||
let orphanCount = 0;
|
||||
for (const slug of slugs.sort()) {
|
||||
const partitionDir = path.join(root, slug);
|
||||
const anchor = await readAnchorFile(partitionDir);
|
||||
|
||||
// Recover the project path + read a bit of config for DISPLAY context. The
|
||||
// anchor is the trustworthy source; the config's businessRepoRoot/projectRoot
|
||||
// is only a display fallback (see the orphan-verdict note below).
|
||||
let projectPath = anchor;
|
||||
let scope: string | undefined;
|
||||
let kind: string | undefined;
|
||||
const cfgRaw = await readFileSafe(path.join(partitionDir, 'config.yaml'));
|
||||
if (cfgRaw) {
|
||||
try {
|
||||
const parsed = LocalConfigSchema.parse(YAML.parse(cfgRaw));
|
||||
scope = parsed.scope;
|
||||
kind = parsed.repo.kind;
|
||||
if (!projectPath) projectPath = parsed.repo.businessRepoRoot ?? parsed.projectRoot ?? null;
|
||||
} catch { /* unreadable config — leave fields undefined */ }
|
||||
}
|
||||
|
||||
// The orphan verdict must rest ONLY on the anchor — it is the shared project
|
||||
// anchor this partition is keyed by (projectSlug(anchor)). The config's
|
||||
// businessRepoRoot/projectRoot is a persisted *workspace* path that may point
|
||||
// at a linked worktree; its disappearance does NOT prove the shared partition
|
||||
// (still used by the main checkout) is orphaned. So without a trustworthy
|
||||
// anchor we never recommend deletion — classify as unknown.
|
||||
let state: string;
|
||||
if (!anchor) {
|
||||
state = projectPath
|
||||
? 'unknown — no anchor; cannot confirm orphan (partition may still be active)'
|
||||
: 'unknown (no anchor / project path)';
|
||||
} else if (!(await pathExists(anchor))) {
|
||||
state = 'ORPHAN — project path is gone, safe to delete';
|
||||
orphanCount++;
|
||||
} else if (projectSlug(anchor) !== slug) {
|
||||
state = 'corrupt — dir name does not match anchor';
|
||||
} else {
|
||||
state = 'active';
|
||||
}
|
||||
|
||||
const kindLabel = kind ? ` ${kind}` : '';
|
||||
log.info(` ${slug} [${state}]`);
|
||||
log.info(` project: ${projectPath ?? '(unresolved)'}${scope ? ` (${scope}${kindLabel})` : ''}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (orphanCount > 0) {
|
||||
log.warn(
|
||||
`${orphanCount} orphan partition(s) found. teamai never deletes them automatically; ` +
|
||||
`remove one with: rm -rf "${root}/<slug>"`,
|
||||
);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
export async function list(type: string | undefined, options: ListOptions): Promise<void> {
|
||||
// Auto-detect scope
|
||||
const { localConfig, teamConfig } = await autoDetectInit();
|
||||
|
||||
+3
-3
@@ -15,7 +15,7 @@ import { withTimeout } from './utils/async.js';
|
||||
import { writeFile, readFileSafe, ensureDir, pathExists, readJson, writeJson } from './utils/fs.js';
|
||||
import { log } from './utils/logger.js';
|
||||
import type { UserStats, UserInterventionStats, SessionMetrics, TokenUsage, DashboardEvent, LocalConfig } from './types.js';
|
||||
import { VOTES_LOCAL_DIR, emptyTokenUsage, addTokenUsage } from './types.js';
|
||||
import { getUserVotesDir, emptyTokenUsage, addTokenUsage } from './types.js';
|
||||
import { getUserHome } from './utils/home.js';
|
||||
import {
|
||||
aggregateDailySessions,
|
||||
@@ -478,9 +478,9 @@ export async function reportUsageToTeam(
|
||||
|
||||
// Always stage pending local votes (V2 delta-aware merge)
|
||||
try {
|
||||
if (await pathExists(VOTES_LOCAL_DIR)) {
|
||||
if (await pathExists(getUserVotesDir())) {
|
||||
const { syncVotesToTeam } = await import('./votes.js');
|
||||
const synced = await syncVotesToTeam(writeRoot, username, VOTES_LOCAL_DIR);
|
||||
const synced = await syncVotesToTeam(writeRoot, username, getUserVotesDir());
|
||||
if (synced) {
|
||||
filesToPush.push(`votes/${username}.yaml`);
|
||||
}
|
||||
|
||||
+60
-16
@@ -673,11 +673,34 @@ export interface GlobalOptions {
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────
|
||||
|
||||
export const TEAMAI_HOME = path.join(getUserHome(), '.teamai');
|
||||
export const TEAMAI_CONFIG_PATH = path.join(TEAMAI_HOME, 'config.yaml');
|
||||
export const TEAMAI_STATE_PATH = path.join(TEAMAI_HOME, 'state.json');
|
||||
export const TEAMAI_TOKEN_PATH = path.join(TEAMAI_HOME, 'token');
|
||||
export const TEAMAI_UPDATE_LOCK_PATH = path.join(TEAMAI_HOME, '.update-lock');
|
||||
// Machine-level (class A2) paths under ~/.teamai. These are getters, NOT
|
||||
// top-level `const`s: a `const path.join(getUserHome(), …)` is evaluated ONCE at
|
||||
// module import, so a test that later swaps the HOME env var never sees the new
|
||||
// value. Evaluating at call time (issue #374 P3) makes HOME isolation actually
|
||||
// work, and keeps a single source of truth for the user home. A2 means they stay
|
||||
// under ~/.teamai (functionizing is NOT project-scoping — the landing is
|
||||
// unchanged); the project-scoped equivalents already route through getDataHome().
|
||||
|
||||
/** The machine-level teamai home, `~/.teamai` (class A2). Evaluated at call time. */
|
||||
export function getTeamaiHomeDir(): string {
|
||||
return path.join(getUserHome(), '.teamai');
|
||||
}
|
||||
/** User-scope global config path, `~/.teamai/config.yaml`. Evaluated at call time. */
|
||||
export function getUserConfigPath(): string {
|
||||
return path.join(getTeamaiHomeDir(), 'config.yaml');
|
||||
}
|
||||
/** User-scope global state path, `~/.teamai/state.json`. Evaluated at call time. */
|
||||
export function getUserStatePath(): string {
|
||||
return path.join(getTeamaiHomeDir(), 'state.json');
|
||||
}
|
||||
/** API token path, `~/.teamai/token` (machine-level). Evaluated at call time. */
|
||||
export function getTokenPath(): string {
|
||||
return path.join(getTeamaiHomeDir(), 'token');
|
||||
}
|
||||
/** Self-update lock path, `~/.teamai/.update-lock`. Evaluated at call time. */
|
||||
export function getUpdateLockPath(): string {
|
||||
return path.join(getTeamaiHomeDir(), '.update-lock');
|
||||
}
|
||||
|
||||
export const RESOURCE_TYPES: ResourceType[] = ['skills', 'rules', 'docs', 'env', 'agents', 'hooks', 'mcp'];
|
||||
|
||||
@@ -722,14 +745,19 @@ export const TEAMAI_RECALL_RULES_END = '<!-- [teamai:recall-rules:end] -->';
|
||||
/** Regex for valid skill names: alphanumeric, hyphens, underscores, colons, dots. Max 200 chars. */
|
||||
export const SKILL_NAME_REGEX = /^[a-zA-Z0-9_\-:.]{1,200}$/;
|
||||
|
||||
export const TEAMAI_USAGE_PATH = `${TEAMAI_HOME}/usage.jsonl`;
|
||||
export const TEAMAI_KNOWN_SKILLS_PATH = `${TEAMAI_HOME}/known-skills.json`;
|
||||
export const TEAMAI_PUSHIGNORE_PATH = `${TEAMAI_HOME}/pushignore`;
|
||||
// TEAMAI_USAGE_PATH / TEAMAI_KNOWN_SKILLS_PATH / TEAMAI_PUSHIGNORE_PATH were
|
||||
// module-load consts with no live consumers — the code uses runtime getters
|
||||
// (usage-tracker.ts getUsagePath/getKnownSkillsPath, getPushignorePath below), so
|
||||
// they are removed here (issue #374 P3).
|
||||
|
||||
/**
|
||||
* Local monthly session logs (`teamai session save`). Kept in a dedicated dir —
|
||||
* not the sessions directory, which holds per-session contribute-state `.json`.
|
||||
* Evaluated at call time so HOME isolation works in tests (issue #374 P3).
|
||||
*/
|
||||
export const SESSION_LOGS_LOCAL_DIR = `${TEAMAI_HOME}/session-logs`;
|
||||
export function getSessionLogsDir(): string {
|
||||
return path.join(getTeamaiHomeDir(), 'session-logs');
|
||||
}
|
||||
|
||||
export interface UsageEvent {
|
||||
skill: string;
|
||||
@@ -983,8 +1011,11 @@ export interface DashboardSession {
|
||||
tokens: TokenUsage;
|
||||
}
|
||||
|
||||
export const DASHBOARD_EVENTS_DIR = `${TEAMAI_HOME}/dashboard`;
|
||||
export const DASHBOARD_EVENTS_PATH = `${DASHBOARD_EVENTS_DIR}/events.jsonl`;
|
||||
// DASHBOARD_EVENTS_DIR / DASHBOARD_EVENTS_PATH were module-load consts that no
|
||||
// code consumed — dashboard read/write go through runtime helpers that inline
|
||||
// getUserHome() (dashboard-collector.ts getEventsPath, dashboard.ts), so HOME
|
||||
// isolation already works there. Removed (issue #374 P3). The dashboard is an
|
||||
// A2 machine-level singleton keyed by event cwd/sessionId, not per-project.
|
||||
export const DASHBOARD_DEFAULT_PORT = 3721;
|
||||
/** Sessions with no activity for this long (ms) are marked idle */
|
||||
export const DASHBOARD_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
@@ -1157,8 +1188,9 @@ export const CONTRIBUTE_LOW_QUALITY_THRESHOLD = 5.0;
|
||||
/** Phase 2: git commit is neutral (no bonus, no penalty) */
|
||||
export const CONTRIBUTE_GIT_COMMIT_DOWNWEIGHT = 0;
|
||||
|
||||
/** Directory for per-session contribute state files */
|
||||
export const CONTRIBUTE_SESSIONS_DIR = `${TEAMAI_HOME}/sessions`;
|
||||
// CONTRIBUTE_SESSIONS_DIR was a module-load const with no live consumers — the
|
||||
// code uses contribute-check.ts getSessionPath() (inlines getUserHome()), so it
|
||||
// is removed here (issue #374 P3).
|
||||
|
||||
// ─── Learnings / Recall (Git-Native Memory) ──────────
|
||||
//
|
||||
@@ -1274,9 +1306,21 @@ export interface UserVotesV2 {
|
||||
deltas: Record<string, VoteDelta>;
|
||||
}
|
||||
|
||||
export const LEARNINGS_LOCAL_DIR = `${TEAMAI_HOME}/learnings`;
|
||||
export const SEARCH_INDEX_PATH = `${TEAMAI_HOME}/search-index.json`;
|
||||
export const VOTES_LOCAL_DIR = `${TEAMAI_HOME}/votes`;
|
||||
// User-scope (A2) learnings mirror / search index / votes. Getters, not consts,
|
||||
// so HOME isolation works in tests (issue #374 P3). The project-scope equivalents
|
||||
// route through getDataHome(); these remain the user-scope global landing.
|
||||
/** User-scope learnings mirror dir, `~/.teamai/learnings`. Evaluated at call time. */
|
||||
export function getUserLearningsDir(): string {
|
||||
return path.join(getTeamaiHomeDir(), 'learnings');
|
||||
}
|
||||
/** User-scope search index, `~/.teamai/search-index.json`. Evaluated at call time. */
|
||||
export function getUserSearchIndexPath(): string {
|
||||
return path.join(getTeamaiHomeDir(), 'search-index.json');
|
||||
}
|
||||
/** User-scope votes dir, `~/.teamai/votes`. Evaluated at call time. */
|
||||
export function getUserVotesDir(): string {
|
||||
return path.join(getTeamaiHomeDir(), 'votes');
|
||||
}
|
||||
|
||||
export const CultureCompanySchema = z.object({
|
||||
name: z.string(),
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@ import { loadState, saveState, loadLocalConfig, loadTeamConfig } from './config.
|
||||
import { resolveEffectiveUpdatePolicy } from './update-policy.js';
|
||||
import { log } from './utils/logger.js';
|
||||
import { expandHome, ensureDir } from './utils/fs.js';
|
||||
import { TEAMAI_UPDATE_LOCK_PATH } from './types.js';
|
||||
import { getUpdateLockPath } from './types.js';
|
||||
import { askConfirmation } from './utils/prompt.js';
|
||||
|
||||
// `getCurrentVersion` and `getCurrentPackageName` live in `./package-info.ts`
|
||||
@@ -253,7 +253,7 @@ async function acquireReclaimSentinel(sentinel: string, owner: string): Promise<
|
||||
* sentinel's dead-pid recovery bounds that.)
|
||||
*/
|
||||
export async function acquireLock(lockPath?: string): Promise<boolean> {
|
||||
const resolved = lockPath ?? expandHome(TEAMAI_UPDATE_LOCK_PATH);
|
||||
const resolved = lockPath ?? expandHome(getUpdateLockPath());
|
||||
const owner = randomUUID();
|
||||
const payload = JSON.stringify({
|
||||
pid: process.pid,
|
||||
@@ -310,7 +310,7 @@ export async function acquireLock(lockPath?: string): Promise<boolean> {
|
||||
* after ours went stale, so we leave the new holder's lock alone.
|
||||
*/
|
||||
export async function releaseLock(lockPath?: string): Promise<void> {
|
||||
const resolved = lockPath ?? expandHome(TEAMAI_UPDATE_LOCK_PATH);
|
||||
const resolved = lockPath ?? expandHome(getUpdateLockPath());
|
||||
const ourOwner = heldLockOwners.get(resolved);
|
||||
if (!ourOwner) return;
|
||||
try {
|
||||
|
||||
@@ -119,3 +119,30 @@ export function projectSlug(anchor: string): string {
|
||||
export function projectDataHome(anchor: string): string {
|
||||
return path.join(getUserHome(), '.teamai', 'projects', projectSlug(anchor));
|
||||
}
|
||||
|
||||
/** Root dir holding every project partition: `~/.teamai/projects`. */
|
||||
export function projectsRootDir(): string {
|
||||
return path.join(getUserHome(), '.teamai', 'projects');
|
||||
}
|
||||
|
||||
/**
|
||||
* The `anchor` reverse-lookup file inside a partition. The slug is a one-way
|
||||
* sha256, so this file is the ONLY way back to the original projectAnchor path;
|
||||
* it lives inside the partition (off the workspace), preserving zero-residue.
|
||||
* Written on both init and migration so every partition carries it (issue #374).
|
||||
*/
|
||||
export async function writeAnchorFile(partitionDir: string, anchor: string): Promise<void> {
|
||||
await fs.promises.mkdir(partitionDir, { recursive: true });
|
||||
await fs.promises.writeFile(path.join(partitionDir, 'anchor'), `${anchor}\n`, 'utf-8');
|
||||
}
|
||||
|
||||
/** Read a partition's `anchor` file (trimmed), or null when absent/unreadable. */
|
||||
export async function readAnchorFile(partitionDir: string): Promise<string | null> {
|
||||
try {
|
||||
const raw = await fs.promises.readFile(path.join(partitionDir, 'anchor'), 'utf-8');
|
||||
const trimmed = raw.trim();
|
||||
return trimmed || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+9
-8
@@ -13,9 +13,10 @@ import { loadIndex, buildIndex } from './utils/search-index.js';
|
||||
import { loadUserVotes } from './votes.js';
|
||||
import { detectProjectConfig, loadLocalConfig } from './config.js';
|
||||
import {
|
||||
VOTES_LOCAL_DIR,
|
||||
SEARCH_INDEX_PATH,
|
||||
LEARNINGS_LOCAL_DIR,
|
||||
getTeamaiHomeDir,
|
||||
getUserVotesDir,
|
||||
getUserSearchIndexPath,
|
||||
getUserLearningsDir,
|
||||
getKnowledgeDir,
|
||||
getReportsDir,
|
||||
getDataHome,
|
||||
@@ -165,7 +166,7 @@ export async function resolveVizRoot(opts: VizOptions): Promise<VizPaths> {
|
||||
const teamaiHome = useProjectScope ? getDataHome(config) : getTeamaiHome('user');
|
||||
const learningsDir = useProjectScope
|
||||
? path.join(knowledgeRoot, 'learnings')
|
||||
: LEARNINGS_LOCAL_DIR;
|
||||
: getUserLearningsDir();
|
||||
const source: VizSource = config.repo.kind === 'self'
|
||||
? { scope: 'local', label: 'Personal repo · your recalls only' }
|
||||
: { scope: 'team', label: 'Team repo · aggregated across the team' };
|
||||
@@ -181,14 +182,14 @@ export async function resolveVizRoot(opts: VizOptions): Promise<VizPaths> {
|
||||
}
|
||||
|
||||
// Pure local / no team repo configured: fall back to ~/.teamai.
|
||||
const teamaiHome = path.dirname(VOTES_LOCAL_DIR);
|
||||
const teamaiHome = getTeamaiHomeDir();
|
||||
return {
|
||||
root: teamaiHome,
|
||||
knowledgeRoot: teamaiHome,
|
||||
votesDir: VOTES_LOCAL_DIR,
|
||||
learningsDir: LEARNINGS_LOCAL_DIR,
|
||||
votesDir: getUserVotesDir(),
|
||||
learningsDir: getUserLearningsDir(),
|
||||
statsDir: path.join(teamaiHome, 'stats'),
|
||||
indexPath: SEARCH_INDEX_PATH,
|
||||
indexPath: getUserSearchIndexPath(),
|
||||
source: { scope: 'local', label: 'Local ~/.teamai · your recalls only' },
|
||||
};
|
||||
}
|
||||
|
||||
+2
-2
@@ -183,8 +183,8 @@ export async function recallFeedback(opts: { positive?: string; negative?: strin
|
||||
const { autoDetectInit } = await import('./config.js');
|
||||
const { localConfig } = await autoDetectInit();
|
||||
const { username } = localConfig;
|
||||
const { VOTES_LOCAL_DIR } = await import('./types.js');
|
||||
const votePath = path.join(VOTES_LOCAL_DIR, `${username}.yaml`);
|
||||
const { getUserVotesDir } = await import('./types.js');
|
||||
const votePath = path.join(getUserVotesDir(), `${username}.yaml`);
|
||||
|
||||
if (opts.positive) {
|
||||
await incrementUpvoted(votePath, [opts.positive]);
|
||||
|
||||
Reference in New Issue
Block a user