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:
jeff
2026-09-10 16:59:36 +08:00
committed by GitHub
29 changed files with 532 additions and 116 deletions
+39 -6
View File
@@ -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
+9
View File
@@ -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.
+6
View File
@@ -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);
});
+1 -1
View File
@@ -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', () => ({
+9 -7
View File
@@ -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'),
);
});
+3 -1
View File
@@ -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',
};
});
+187
View File
@@ -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']);
});
});
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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,
-2
View File
@@ -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,
-2
View File
@@ -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';
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+14 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+27
View File
@@ -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
View File
@@ -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
View File
@@ -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]);