feat(lib): bundle MCP servers from .common/lib into factory/lib/

Copies source (no node_modules, dist, .tasks, .wiki, __pycache__) for:
- projects-meta-mcp v2.25.0 (TypeScript/Node)
- wiki-graph v0.3.1 (TypeScript/Node)
- interns-mcp v0.3.3 (Python/FastMCP)

.gitignore: exclude lib build artefacts (node_modules, dist, .venv, __pycache__, *.pyc)
bootstrap.ps1: add MCP build step — npm install+build for TS servers, venv+pip for Python

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 13:17:12 +03:00
parent 9eeae13e72
commit ca669d96e1
116 changed files with 25331 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
/**
* Cross-isolation-boundary backend for cross-project tasks/knowledge bus.
*
* The MCP server speaks to projects through a backend abstraction so the same
* write-tools (`tasks_create`, `tasks_update`, `tasks_close`, future
* `knowledge_ingest`) work against Gitea today and other hosting backends
* (GitHub, GitLab, plain local paths) when those land.
*
* The first implementation is `GiteaBackend` in `gitea.ts`.
*/
export interface RepoInfo {
name: string;
default_branch: string;
archived?: boolean;
}
/** A file fetched together with its blob SHA — needed to update via Contents API atomically. */
export interface FileWithSha {
content: string;
sha: string;
}
export interface CommitFileArgs {
user: string;
repo: string;
path: string;
branch: string;
/** UTF-8 string content. Backend handles base64-encoding for protocols that need it. */
content: string;
message: string;
/**
* Required for **updating** an existing file (Gitea Contents API uses it as
* an optimistic lock). Omit to **create** a new file.
*/
sha?: string;
authorName?: string;
authorEmail?: string;
}
export interface CommitResult {
/** SHA of the new file blob (returned by Gitea — survives across rewrites). */
fileSha: string;
/** SHA of the commit that introduced the change. */
commitSha: string;
}
export interface Backend {
listUserRepos(user: string): Promise<RepoInfo[]>;
/** Returns raw file content, or null on 404. Throws on other errors. */
getRawFile(user: string, repo: string, path: string, branch: string): Promise<string | null>;
/**
* Returns content + blob SHA, or null on 404. Throws on other errors. The
* blob SHA must be passed back into `commitFile` to update this file
* atomically (Gitea uses it as an optimistic lock).
*/
getFileWithSha(user: string, repo: string, path: string, branch: string): Promise<FileWithSha | null>;
/**
* Create or update a file. Pass `sha` to update an existing file
* (atomically); omit it to create a new file.
*/
commitFile(args: CommitFileArgs): Promise<CommitResult>;
}

View File

@@ -0,0 +1,86 @@
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
export const CACHE_SCHEMA_VERSION = 3;
export interface ActiveTask {
slug: string;
status: 'active' | 'paused' | 'blocked' | 'ready' | 'done';
next: string | null;
}
export interface ProjectStatus {
name: string;
default_branch: string;
fetched_at: string;
active_tasks: ActiveTask[];
all_tasks_count: number;
raw: string;
/** Raw .wiki/index.md, if the repo has one. Used by knowledge.ask_projects. */
wiki_index?: string;
}
export interface SyncError {
project: string;
reason: string;
}
export interface CacheFile {
/** Cache schema version. Bumped on breaking layout changes (v3 = `agenda` alias literal; v2 = qualified `<owner>/<repo>` ProjectStatus.name). */
schemaVersion: number;
synced_at: string;
synced_from: string;
machine: string;
projects: ProjectStatus[];
errors: SyncError[];
/** Number of Gitea repos skipped because they are archived. */
archived_skipped?: number;
}
export interface CacheInvalidationResult {
invalidated: boolean;
oldVersion: number | null;
}
export async function writeCache(file: string, data: CacheFile): Promise<void> {
await mkdir(dirname(file), { recursive: true });
const tmp = `${file}.tmp`;
await writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
try {
await rename(tmp, file);
} catch (err) {
await unlink(tmp).catch(() => undefined);
throw err;
}
}
export async function readCache(file: string): Promise<CacheFile | null> {
try {
const txt = await readFile(file, 'utf8');
const parsed = JSON.parse(txt) as Partial<CacheFile> & Record<string, unknown>;
if (parsed.schemaVersion !== CACHE_SCHEMA_VERSION) return null;
return parsed as CacheFile;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw err;
}
}
/**
* Inspect cache file without consuming it. Used by sync entry-point to decide
* whether to log a `cache_schema_invalidated` line before overwriting with a
* fresh-format cache.
*/
export async function detectCacheInvalidation(file: string): Promise<CacheInvalidationResult> {
try {
const txt = await readFile(file, 'utf8');
const parsed = JSON.parse(txt) as { schemaVersion?: unknown };
const v = typeof parsed.schemaVersion === 'number' ? parsed.schemaVersion : null;
return { invalidated: v !== CACHE_SCHEMA_VERSION, oldVersion: v };
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return { invalidated: false, oldVersion: null };
}
throw err;
}
}

View File

@@ -0,0 +1,227 @@
/**
* Pure claim-selection logic for `tasks_claim_next` (agent-orchestration).
*
* The MCP handler in `tools/tasks.ts` gathers ⚪-ready candidate tasks from one
* or more project boards, calls {@link selectClaimableTask} to pick the first
* eligible one (board order = priority, FIFO for MVP), then performs the atomic
* claim via a sha-CAS commit. All policy lives here so it is unit-testable
* without a backend.
*
* Enforcement order mirrors the design (decisions #6/#7):
* 1. claimable — task is ⚪ ready AND currently unowned
* 2. runtime — self-runtime ∈ task.runtimeAllowed (if the task restricts)
* 3. capabilities — task.requirements ⊆ self.capabilities
*
* Anti-self-review was removed: the gate keyed on poller identity (hostname:runtime:pid),
* but the poller is an orchestrator — impl and review are separate spawned sessions with
* clean context, so the gate was both semantically wrong and caused false-matches on slugs
* containing "-review-" in the middle (e.g. "relax-anti-self-review-gate"). Model-diversity
* ("don't let opus review opus") belongs in fleet-routing via runtimeAllowed, not here.
*
* Project-level `policy.toml` defaults (decision #7) are NOT applied here — that
* is `policy-toml-loader`'s job; until then a task with no `runtimeAllowed` is
* unrestricted.
*/
import { randomUUID } from 'node:crypto';
import type { ParsedTask } from './status-md.js';
export interface ClaimCandidate {
/** qualified `<owner>/<repo>` (or `agenda`) the task lives on */
project: string;
task: ParsedTask;
}
export interface SelectOpts {
/** self-runtime of the claiming agent (e.g. `claude-opus`) */
runtime?: string;
/** self-capabilities (matched against each task's `requirements`) */
capabilities?: string[];
/** claimer identity `<machine>:<runtime>:<session>` */
claimerOwner: string;
/** all parsed tasks per project — needed for the anti-self-review cross-ref */
boardTasks?: Record<string, ParsedTask[]>;
/**
* Optional rollout scope guard. When provided AND non-empty, only candidates
* whose qualified `<owner>/<repo>` project is in this list are eligible; every
* other candidate is skipped with reason `out-of-scope`. Absent or empty →
* no filtering (current federation-wide behaviour, fully backward-compatible).
*
* Distinct from the singular `filter.project` (which narrows candidate-gathering
* at the cache level): this is a multi-project, SELECTION-time gate that composes
* with the `project-busy` gate (both can apply to the same candidate).
*/
projectAllowlist?: string[];
/**
* Current time. When provided, an active task whose `claimExpiresAt` has
* lapsed is treated as reclaimable (lazy revoke, design decision #10 / (b)).
* When omitted, only ⚪ ready + unowned tasks are claimable.
*/
now?: Date;
}
export type SkipReason =
| 'runtime-not-allowed'
| 'capabilities-missing'
| 'needs-human'
| 'project-busy'
| 'out-of-scope';
export interface SkipNote {
project: string;
slug: string;
reason: SkipReason;
/** human-readable detail (allowed runtimes, missing caps, …) */
detail?: string;
}
export type SelectOutcome =
| { ok: true; candidate: ClaimCandidate }
| { ok: false; reason: 'no-candidates'; skipped: SkipNote[] };
/** A task is claimable only when it is ⚪ ready and currently unowned. */
export function isClaimable(t: ParsedTask): boolean {
return t.status === 'ready' && !t.owner;
}
/**
* An owned task whose claim TTL has lapsed — eligible for lazy revoke.
*
* Invariant (root-cause-#3): ONLY an *active* (🔴 in-progress) owned task is ever
* reclaimable on TTL lapse — that is the legitimate crashed-agent recovery path.
* A terminal/non-active task (done 🟢, blocked/parked 🔵, ready ⚪, paused 🟡) is
* NEVER reclaimable, even if a stale claim-stamp lingers on it. Without the status
* guard, a closed task whose stamp was left in place and whose TTL elapsed got
* re-claimed in a loop, burning tokens in prod. ('active' is the exact status the
* claim mutation in tools/tasks.ts writes — see makeClaimStamp + status:'active'.)
*/
export function isExpiredClaim(t: ParsedTask, now: Date): boolean {
if (!t.owner || !t.claimExpiresAt || t.status !== 'active') return false;
const exp = Date.parse(t.claimExpiresAt);
return !Number.isNaN(exp) && exp < now.getTime();
}
/** Claimable now, accounting for lazy revoke of expired claims. */
export function isReclaimable(t: ParsedTask, now: Date): boolean {
return isClaimable(t) || isExpiredClaim(t, now);
}
/**
* A task holds a *live* claim — an agent is actively working it: it is owned AND
* its claim TTL has NOT lapsed. An owned task with no `claimExpiresAt` counts as
* live (in-flight, no crash-revoke window). An owned task whose TTL has expired
* is NOT live — it is itself reclaimable (lazy revoke) and so must not mark its
* project busy. Done/ready/unowned tasks are never live.
*/
export function hasLiveClaim(t: ParsedTask, now: Date): boolean {
if (!t.owner || t.status === 'done') return false;
return !isExpiredClaim(t, now);
}
/**
* One-agent-per-project gate (fix for workspace-divergence): the set of projects
* that already have a live-claimed (in-progress) task. A second claim in such a
* project would put two agents in the same repo → push race → diverged checkout.
* Built from the FULL per-project task lists (`boardTasks`), since a live claim is
* not itself reclaimable and therefore never appears in the candidate list.
*/
export function busyProjects(
boardTasks: Record<string, ParsedTask[]> | undefined,
now: Date,
): Set<string> {
const busy = new Set<string>();
for (const [project, tasks] of Object.entries(boardTasks ?? {})) {
if (tasks.some((t) => hasLiveClaim(t, now))) busy.add(project);
}
return busy;
}
/**
* Pick the first eligible candidate in board order. Candidates that fail a gate
* are recorded in `skipped` (so the caller can explain why nothing matched);
* the first one that passes every gate is returned.
*/
export function selectClaimableTask(candidates: ClaimCandidate[], opts: SelectOpts): SelectOutcome {
const skipped: SkipNote[] = [];
// One-agent-per-project gate: projects with a live-claimed task are off-limits
// (the authoritative concurrency control — even if the poller overlaps ticks,
// it cannot put a second agent into a repo that already has one in flight).
// Requires `now` to distinguish a live claim from an expired (reclaimable) one;
// without it the gate is inactive (caller always supplies `now` in practice).
const busy = opts.now ? busyProjects(opts.boardTasks, opts.now) : new Set<string>();
// Rollout scope guard: when set & non-empty, restrict claims to listed projects.
// Empty/undefined → no filtering (federation-wide). Composes with project-busy.
const allowlist =
opts.projectAllowlist && opts.projectAllowlist.length ? new Set(opts.projectAllowlist) : null;
for (const c of candidates) {
const t = c.task;
const claimableNow = opts.now ? isReclaimable(t, opts.now) : isClaimable(t);
if (!claimableNow) continue;
if (allowlist && !allowlist.has(c.project)) {
skipped.push({ project: c.project, slug: t.slug, reason: 'out-of-scope' });
continue;
}
if (busy.has(c.project)) {
skipped.push({ project: c.project, slug: t.slug, reason: 'project-busy' });
continue;
}
// Execution-policy L3 gate: a `needs-human` task is never handed to an
// autonomous claimer. The effective weight (task field or project default) is
// overlaid by the caller before selection. Design: `.workshop` buffer
// `agent-execution-policy-levels.md`.
if (t.weight === 'needs-human') {
skipped.push({ project: c.project, slug: t.slug, reason: 'needs-human' });
continue;
}
if (t.runtimeAllowed && t.runtimeAllowed.length) {
if (!opts.runtime || !t.runtimeAllowed.includes(opts.runtime)) {
skipped.push({
project: c.project,
slug: t.slug,
reason: 'runtime-not-allowed',
detail: t.runtimeAllowed.join(', '),
});
continue;
}
}
if (t.requirements && t.requirements.length) {
const have = new Set(opts.capabilities ?? []);
const missing = t.requirements.filter((r) => !have.has(r));
if (missing.length) {
skipped.push({
project: c.project,
slug: t.slug,
reason: 'capabilities-missing',
detail: missing.join(', '),
});
continue;
}
}
return { ok: true, candidate: c };
}
return { ok: false, reason: 'no-candidates', skipped };
}
export interface ClaimStamp {
owner: string;
claimToken: string;
claimExpiresAt: string;
}
/** Default claim TTL — crash-revoke window (design decision #10). */
export const DEFAULT_CLAIM_TTL_MS = 10 * 60 * 1000;
/** Build the fields written onto a task at claim time. */
export function makeClaimStamp(owner: string, now: Date, ttlMs: number = DEFAULT_CLAIM_TTL_MS): ClaimStamp {
return {
owner,
claimToken: randomUUID(),
claimExpiresAt: new Date(now.getTime() + ttlMs).toISOString(),
};
}

View File

@@ -0,0 +1,103 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { parse as parseToml } from 'smol-toml';
import { z } from 'zod';
export interface AuthConfig {
giteaUrl: string;
giteaUser: string;
giteaToken: string;
/**
* Gitea owners (users / orgs) sync iterates over to discover repos.
* Falls back to `[giteaUser]` when `gitea_owners` is absent or empty.
*/
giteaOwners: string[];
/**
* Bare name of the Gitea repo holding the cross-project agenda tasks board
* (e.g. `agenda`). When the user writes `agenda_tasks_repo = "<owner>/<name>"`
* in auth.toml, the `<name>` part lands here and the `<owner>` part lands
* in {@link agendaTasksOwner}.
*/
agendaTasksRepo: string;
/**
* Optional explicit owner for the agenda repo. Set when `agenda_tasks_repo`
* was qualified (`<owner>/<name>`); `undefined` for the bare-name form
* (in which case the agenda is searched/written under any owner from
* {@link giteaOwners} and addressed via the acting `gitea_user`).
*/
agendaTasksOwner: string | undefined;
/** Name of the Gitea repo holding the cross-project shared wiki. */
projectsWikiRepo: string;
/**
* Owners synced into the cache for write-tool resolution but **hidden** from
* aggregation views (`tasks.aggregate / tasks.search / tasks.get`). Used to
* keep MCP-mutate working for infra repos (`OpeItcLoc03/common`,
* `OpeItcLoc03/claude-skills`, etc.) without polluting the cross-project
* dashboard. Disjoint from `gitea_owners` semantically (sync = union of
* both, aggregation = `gitea_owners` only). Defaults to `[]`.
*/
giteaAggregateSkipOwners: string[];
}
const DEFAULT_AGENDA_TASKS_REPO = 'projects-tasks';
const DEFAULT_PROJECTS_WIKI_REPO = 'projects-wiki';
export interface Paths {
cacheDir: string;
cacheFile: string;
syncLog: string;
authFile: string;
sharedWikiClone: string;
agendaTasksDir: string;
}
export function getPaths(home: string): Paths {
const cacheDir = join(home, '.cache', 'projects-mcp');
return {
cacheDir,
cacheFile: join(cacheDir, 'tasks.json'),
syncLog: join(cacheDir, 'sync.log'),
authFile: join(home, '.config', 'projects-mcp', 'auth.toml'),
// Repo `projects-wiki` клонится напрямую в `~/projects/.wiki/`.
// Контент живёт в корне клона (concepts/, entities/, packages/, etc.).
sharedWikiClone: join(home, 'projects', '.wiki'),
agendaTasksDir: join(home, 'projects', '.tasks'),
};
}
const AuthSchema = z.object({
gitea_url: z.string().min(1),
gitea_user: z.string().min(1),
gitea_token: z.string().min(1),
gitea_owners: z.array(z.string()).optional(),
gitea_aggregate_skip_owners: z.array(z.string()).optional(),
agenda_tasks_repo: z.string().optional(),
projects_wiki_repo: z.string().optional(),
});
export async function loadAuth(authFile: string): Promise<AuthConfig> {
const raw = await readFile(authFile, 'utf8');
const parsed = AuthSchema.parse(parseToml(raw));
const tasksRepoRaw = parsed.agenda_tasks_repo?.trim();
const wikiRepo = parsed.projects_wiki_repo?.trim();
const giteaUser = parsed.gitea_user.trim();
const trimmedOwners = (parsed.gitea_owners ?? []).map((o) => o.trim()).filter((o) => o.length > 0);
const giteaOwners = trimmedOwners.length > 0 ? trimmedOwners : [giteaUser];
const giteaAggregateSkipOwners = (parsed.gitea_aggregate_skip_owners ?? [])
.map((o) => o.trim())
.filter((o) => o.length > 0);
const tasksRepoSource = tasksRepoRaw && tasksRepoRaw.length > 0 ? tasksRepoRaw : DEFAULT_AGENDA_TASKS_REPO;
const qualified = /^([^/\s]+)\/([^/\s]+)$/.exec(tasksRepoSource);
const agendaTasksOwner = qualified ? qualified[1] : undefined;
const agendaTasksRepo = qualified ? qualified[2] : tasksRepoSource;
return {
giteaUrl: parsed.gitea_url.trim().replace(/\/+$/, ''),
giteaUser,
giteaToken: parsed.gitea_token.trim(),
giteaOwners,
agendaTasksRepo,
agendaTasksOwner,
projectsWikiRepo: wikiRepo && wikiRepo.length > 0 ? wikiRepo : DEFAULT_PROJECTS_WIKI_REPO,
giteaAggregateSkipOwners,
};
}

View File

@@ -0,0 +1,72 @@
/**
* Writer for the `## Decision trail` section of a per-task `.tasks/<slug>.md`.
*
* The consult-tier records every arbiter ruling / human escalation in the task's
* own markdown — git-native, diffable, where the review-umbrella already reads.
* Crucially this is written by INFRA (the consult backend via the
* `tasks.append_decision_trail` mutation), never by the worker being audited.
*
* Design: shared wiki `concepts/consult-tier-escalation.md`
* §«Decision-trail: пишет инфра, не аудируемый агент».
*/
export interface DecisionTrailEntry {
/** ISO timestamp of the consult. */
timestampIso: string;
question: string;
/** reversible | cross-cutting | irreversible | null */
blastRadius?: string | null;
/** arbiter | round-table | human-required */
decidedBy: string;
/** The ruling, or null on a halt (escalated) outcome. */
ruling?: string | null;
/** Arbiter reasoning, or "escalated: <reason>". */
rationale?: string | null;
/** Tiers the consult passed through, e.g. ['brief', 'read-repo', 'arbiter-fork']. */
escalationChain: string[];
}
const SECTION_HEADER = '## Decision trail';
function detectSep(md: string): string {
return /\r\n/.test(md) ? '\r\n' : '\n';
}
/** How many `### consult N` entries already exist (to number the next one). */
function countConsults(md: string): number {
return (md.match(/^###\s+consult\s+\d+\s+/gmu) ?? []).length;
}
function formatEntry(entry: DecisionTrailEntry, n: number): string {
const chain = entry.escalationChain.length ? entry.escalationChain.join(' → ') : '—';
return [
`### consult ${n}${entry.timestampIso}`,
`- question: ${entry.question}`,
`- blast_radius: ${entry.blastRadius ?? '—'}`,
`- decided_by: ${entry.decidedBy}`,
`- ruling: ${entry.ruling ?? '—'}`,
`- rationale: ${entry.rationale ?? '—'}`,
`- escalation_chain: ${chain}`,
].join('\n');
}
/**
* Append a consult entry to the task markdown. Creates the `## Decision trail`
* section on first use; appends underneath it (numbered) thereafter. Pure — the
* caller commits the result through the backend.
*/
export function appendDecisionTrail(md: string, entry: DecisionTrailEntry): string {
const sep = detectSep(md);
const n = countConsults(md) + 1;
const block = formatEntry(entry, n).split('\n').join(sep);
const trimmed = md.replace(/\s+$/u, '');
if (!new RegExp(`^${SECTION_HEADER}\\s*$`, 'mu').test(md)) {
// No section yet — create it after the existing content.
const base = trimmed.length ? `${trimmed}${sep}${sep}` : '';
return `${base}${SECTION_HEADER}${sep}${sep}${block}${sep}`;
}
// Section exists — append the new entry to the end of the file (entries are
// chronological and the section is conventionally the last one).
return `${trimmed}${sep}${sep}${block}${sep}`;
}

View File

@@ -0,0 +1,146 @@
import { execFile } from 'node:child_process';
import { readdir, readFile, stat } from 'node:fs/promises';
import { basename, join } from 'node:path';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
export type Domain = 'node' | 'embedded' | 'web' | 'unknown';
export interface ProjectIdentity {
owner: string;
repo: string;
}
/** Function type for dependency injection in tests. */
export type ExecAsync = (
file: string,
args: string[],
options: Record<string, unknown>,
) => Promise<{ stdout: string; stderr: string }>;
const EMBEDDED_NEEDLES = ['arm-none-eabi', 'STM32', 'ESP_IDF', 'STM32CubeMX'];
async function exists(p: string): Promise<boolean> {
try {
await stat(p);
return true;
} catch {
return false;
}
}
async function listFiles(dir: string): Promise<string[]> {
try {
const entries = await readdir(dir, { withFileTypes: true });
return entries.filter((e) => e.isFile()).map((e) => e.name);
} catch {
return [];
}
}
export async function detectDomain(cwd: string): Promise<Domain> {
const pkgJson = await exists(join(cwd, 'package.json'));
if (pkgJson) return 'node';
if (await exists(join(cwd, 'platformio.ini'))) return 'embedded';
const files = await listFiles(cwd);
if (files.some((f) => f.endsWith('.ino'))) return 'embedded';
if (files.includes('CMakeLists.txt')) {
const txt = await readFile(join(cwd, 'CMakeLists.txt'), 'utf8').catch(() => '');
if (EMBEDDED_NEEDLES.some((n) => txt.includes(n))) return 'embedded';
}
if (files.some((f) => /^next\.config\.(js|mjs|cjs|ts)$/.test(f))) return 'web';
if (files.some((f) => /^vite\.config\.(js|mjs|cjs|ts)$/.test(f))) return 'web';
if (files.includes('index.html')) return 'web';
return 'unknown';
}
/**
* Parse a git remote URL into `{owner, repo}`. Supports:
* - https://host[:port]/owner/repo[.git][/]
* - http://host/owner/repo
* - git@host:owner/repo[.git]
*
* Returns null on empty input, local paths, or URLs without an `owner/repo`
* tail. Does not handle GitLab-style nested groups — Gitea doesn't have them.
*/
export function parseRemoteUrl(url: string): ProjectIdentity | null {
if (!url) return null;
const trimmed = url.trim().replace(/\/+$/, '').replace(/\.git$/, '');
if (!trimmed) return null;
// SSH form: git@host:owner/repo (or any user@host:path)
const sshMatch = /^[^@\s]+@[^:\s]+:(.+)$/.exec(trimmed);
if (sshMatch) {
return splitOwnerRepo(sshMatch[1]);
}
// http(s)://host[:port]/path
const urlMatch = /^[a-z]+:\/\/[^/]+\/(.+)$/i.exec(trimmed);
if (urlMatch) {
return splitOwnerRepo(urlMatch[1]);
}
return null;
}
function splitOwnerRepo(path: string): ProjectIdentity | null {
const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
if (parts.length < 2) return null;
const [owner, repo] = parts;
if (!owner || !repo) return null;
return { owner, repo };
}
/**
* Detect the qualified `<owner>/<repo>` identity of a working tree by
* inspecting `git remote get-url origin`. Returns null if the directory
* is not a git work-tree, has no `origin` remote, or the URL doesn't
* parse to an owner/repo pair.
*/
export async function detectProjectIdentity(
cwd: string,
execAsync: ExecAsync = (file, args, opts) => execFileAsync(file, args, opts),
): Promise<ProjectIdentity | null> {
try {
const { stdout } = await execAsync(
'git',
['-C', cwd, 'remote', 'get-url', 'origin'],
{ timeout: 5_000 },
);
return parseRemoteUrl(stdout);
} catch {
return null;
}
}
export interface SourceProjectResolution {
/**
* Qualified `<owner>/<repo>` when a Gitea remote was detected, otherwise
* `basename(cwd)` (a "bare" project name — pre-2.0 footer format).
*/
sourceProject: string;
/** True when the qualified resolve failed and `sourceProject` is a basename fallback. */
fellBack: boolean;
}
/**
* Resolve the value the identity-footer's `from:` / `source_project` field
* should carry. Wrapper around `detectProjectIdentity` with a deterministic
* fallback so callers don't have to branch.
*/
export async function resolveSourceProject(
cwd: string,
execAsync?: ExecAsync,
): Promise<SourceProjectResolution> {
const identity = await detectProjectIdentity(cwd, execAsync);
if (identity) {
return { sourceProject: `${identity.owner}/${identity.repo}`, fellBack: false };
}
return { sourceProject: basename(cwd), fellBack: true };
}

View File

@@ -0,0 +1,30 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
/** Function type for dependency injection in tests. */
export type ExecAsync = (file: string, args: string[], options: Record<string, unknown>) => Promise<{ stdout: string; stderr: string }>;
/**
* Pull the local wiki clone with --ff-only.
*
* Called after ingest/promote commit to ensure the local clone reflects
* the just-pushed content, so search finds it without a manual pull.
*
* Non-ff errors are logged but not thrown — the commit already succeeded
* in Gitea, so a stale clone is recoverable on the next pull.
*/
export async function pullLocalClone(
wikiRoot: string,
execAsync: ExecAsync = async (file, args, opts) => execFileAsync(file, args, opts),
): Promise<{ pulled: boolean; error?: string }> {
try {
await execAsync('git', ['-C', wikiRoot, 'pull', '--ff-only'], { timeout: 30_000 });
return { pulled: true };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// Non-fatal: commit succeeded, clone is just stale
return { pulled: false, error: msg };
}
}

View File

@@ -0,0 +1,119 @@
import { Buffer } from 'node:buffer';
import type { Backend, CommitFileArgs, CommitResult, FileWithSha, RepoInfo } from './backend.js';
interface Options {
baseUrl: string;
token: string;
fetchImpl?: typeof fetch;
}
const PAGE_SIZE = 50;
const MAX_ERROR_BODY = 500;
/**
* Carries the HTTP status off a failed Gitea call so callers can branch on it
* (e.g. sha-CAS conflict = 409/422) instead of regex-matching the message.
*/
export class GiteaHttpError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = 'GiteaHttpError';
this.status = status;
}
}
function encodePath(path: string): string {
return path.split('/').map(encodeURIComponent).join('/');
}
async function errorBody(res: Response): Promise<string> {
const text = await res.text().catch(() => '');
return text.length > MAX_ERROR_BODY ? `${text.slice(0, MAX_ERROR_BODY)}` : text;
}
export function makeGiteaBackend(opts: Options): Backend {
const fetchImpl = opts.fetchImpl ?? fetch;
const baseUrl = opts.baseUrl.replace(/\/+$/, '');
const headers = { Authorization: `token ${opts.token}` };
const jsonHeaders = { ...headers, 'Content-Type': 'application/json' };
return {
async listUserRepos(user: string): Promise<RepoInfo[]> {
const repos: RepoInfo[] = [];
let page = 1;
while (true) {
const url =
`${baseUrl}/api/v1/users/${encodeURIComponent(user)}/repos?limit=${PAGE_SIZE}` +
(page > 1 ? `&page=${page}` : '');
const res = await fetchImpl(url, { headers });
if (!res.ok) {
throw new GiteaHttpError(res.status, `Gitea listUserRepos ${res.status}: ${await errorBody(res)}`);
}
const batch = (await res.json()) as Array<{ name: string; default_branch: string; archived?: boolean }>;
if (batch.length === 0) break;
for (const r of batch) repos.push({ name: r.name, default_branch: r.default_branch, archived: r.archived ?? false });
page += 1;
}
return repos;
},
async getRawFile(user, repo, path, branch) {
const url = `${baseUrl}/api/v1/repos/${encodeURIComponent(user)}/${encodeURIComponent(
repo,
)}/raw/${encodePath(path)}?ref=${encodeURIComponent(branch)}`;
const res = await fetchImpl(url, { headers });
if (res.status === 404) return null;
if (!res.ok) {
throw new GiteaHttpError(res.status, `Gitea getRawFile ${res.status}: ${await errorBody(res)}`);
}
return await res.text();
},
async getFileWithSha(user, repo, path, branch): Promise<FileWithSha | null> {
const url = `${baseUrl}/api/v1/repos/${encodeURIComponent(user)}/${encodeURIComponent(
repo,
)}/contents/${encodePath(path)}?ref=${encodeURIComponent(branch)}`;
const res = await fetchImpl(url, { headers });
if (res.status === 404) return null;
if (!res.ok) {
throw new GiteaHttpError(res.status, `Gitea getFileWithSha ${res.status}: ${await errorBody(res)}`);
}
const data = (await res.json()) as { content: string; encoding: string; sha: string };
const decoded = data.encoding === 'base64' ? Buffer.from(data.content, 'base64').toString('utf8') : data.content;
return { content: decoded, sha: data.sha };
},
async commitFile(args: CommitFileArgs): Promise<CommitResult> {
const url = `${baseUrl}/api/v1/repos/${encodeURIComponent(args.user)}/${encodeURIComponent(
args.repo,
)}/contents/${encodePath(args.path)}`;
const body: Record<string, unknown> = {
branch: args.branch,
content: Buffer.from(args.content, 'utf8').toString('base64'),
message: args.message,
};
if (args.sha) body.sha = args.sha;
if (args.authorName && args.authorEmail) {
body.author = { name: args.authorName, email: args.authorEmail };
}
const method = args.sha ? 'PUT' : 'POST';
const res = await fetchImpl(url, {
method,
headers: jsonHeaders,
body: JSON.stringify(body),
});
if (!res.ok) {
throw new GiteaHttpError(res.status, `Gitea commitFile ${method} ${res.status}: ${await errorBody(res)}`);
}
const data = (await res.json()) as {
content: { sha: string };
commit: { sha: string };
};
return { fileSha: data.content.sha, commitSha: data.commit.sha };
},
};
}
/** @deprecated Use {@link makeGiteaBackend}. Kept as an alias during MVP-3 transition. */
export const makeGiteaClient = makeGiteaBackend;

View File

@@ -0,0 +1,84 @@
import { hostname } from 'node:os';
import { metaPull, metaPush } from './meta-sync.js';
/**
* CLI layer for the `projects-meta-sync meta-pull <dir>` / `meta-push <dir>`
* subcommands invoked by the global Claude Code hooks (shell, not the MCP
* server). Both share the same git-backed core as the agent's MCP tools.
*
* Contract: a hook fires on every session, so a dir with no meta-host must be
* an instant no-op, and neither command may ever block (non-zero exit) on a
* network failure or a conflict — they log to stderr and return 0.
*/
/** Where a folder's meta lives and where its hidden meta git-dir is kept. */
export interface HostResolution {
/** Clone URL of the Gitea meta-host. */
remoteUrl: string;
/** Host branch. */
branch: string;
/** Hidden meta git-dir, kept outside the work-tree (no github-leak risk). */
gitDir: string;
}
/**
* Map a project folder to its meta-host, or `null` when the folder has none
* (→ instant no-op). The real implementation (cache lookup by the `meta-<basename>`
* convention) is the autodiscovery layer; injected here so the CLI plumbing is
* testable on its own.
*/
export type ResolveHost = (dir: string) => Promise<HostResolution | null>;
export interface CliDeps {
resolveHost: ResolveHost;
/** stderr sink; defaults to `console.error`. */
log?: (msg: string) => void;
}
function makeLog(deps: CliDeps): (msg: string) => void {
return deps.log ?? ((m) => console.error(m));
}
/**
* Materialize the host's meta into `dir`. Always returns 0 — a pull failure
* (network, etc.) is logged but must not block the start of a session.
*/
export async function runMetaPull(dir: string, deps: CliDeps): Promise<number> {
const log = makeLog(deps);
const host = await deps.resolveHost(dir);
if (!host) return 0; // no meta-host for this folder — fast no-op
try {
const res = await metaPull({ gitDir: host.gitDir, workTree: dir, remoteUrl: host.remoteUrl, branch: host.branch });
if (res.status === 'conflict') {
log(`meta-pull: CONFLICT in ${res.conflicts.map((c) => c.path).join(', ')} — resolve manually; host left intact`);
}
} catch (err) {
log(`meta-pull: failed for ${dir}: ${err instanceof Error ? err.message : String(err)}`);
}
return 0;
}
/**
* Commit and push meta edits from `dir`. Always returns 0 — clean meta is a
* no-op, and a push failure or conflict is logged but must not block the end
* of a session.
*/
export async function runMetaPush(dir: string, deps: CliDeps): Promise<number> {
const log = makeLog(deps);
const host = await deps.resolveHost(dir);
if (!host) return 0;
const message = `meta sync (${hostname()}) ${new Date().toISOString()}`;
try {
const res = await metaPush({ gitDir: host.gitDir, workTree: dir, branch: host.branch, message });
if (res.status === 'conflict') {
log(`meta-push: CONFLICT in ${res.conflicts.map((c) => c.path).join(', ')} — local commit kept, host left intact; resolve manually`);
} else if (res.status === 'push-failed') {
log(`meta-push: push failed (committed locally, will retry next session): ${res.error}`);
}
} catch (err) {
log(`meta-push: failed for ${dir}: ${err instanceof Error ? err.message : String(err)}`);
}
return 0;
}

View File

@@ -0,0 +1,77 @@
import { homedir } from 'node:os';
import { basename, join } from 'node:path';
import { readCache, type CacheFile } from './cache.js';
import { getPaths, loadAuth } from './config.js';
import type { HostResolution } from './meta-cli.js';
/**
* Resolve a project folder to its meta-host by the zero-config `meta-<basename>`
* convention: the folder `…/yt-tools` maps to the Gitea repo `meta-yt-tools`
* under whichever owner the projects-meta cache lists it. No match → `null`
* (the common case — an instant, network-free no-op).
*
* Own-Gitea projects (meta-in-repo, e.g. `books`, `O_C-Phazerville`) auto-exclude
* themselves: there is no `meta-books` repo, so the lookup misses and no-ops.
*/
export interface ResolveDeps {
/** Pre-loaded projects-meta cache (or `null` if none on disk). */
cache: CacheFile | null;
/** Gitea base URL + token, for building the authenticated clone URL. */
auth: { giteaUrl: string; giteaToken: string };
/** Directory under which hidden per-host git-dirs are kept (outside work-trees). */
gitDirBase: string;
/** stderr sink; defaults to `console.error`. */
log?: (msg: string) => void;
}
/** Build an authenticated HTTPS clone URL: `https://<token>@host/owner/repo.git`. */
function cloneUrl(giteaUrl: string, token: string, owner: string, repo: string): string {
const u = new URL(giteaUrl);
return `${u.protocol}//${encodeURIComponent(token)}@${u.host}/${owner}/${repo}.git`;
}
/** Testable core: resolution given an already-loaded cache + auth. */
export async function resolveMetaHostWith(dir: string, deps: ResolveDeps): Promise<HostResolution | null> {
if (!deps.cache) return null;
const repo = `meta-${basename(dir)}`;
// ProjectStatus.name is qualified `<owner>/<repo>` (cache schema v2+).
const matches = deps.cache.projects.filter((p) => {
const slash = p.name.lastIndexOf('/');
return slash > 0 && p.name.slice(slash + 1) === repo;
});
if (matches.length === 0) return null;
if (matches.length > 1) {
const log = deps.log ?? ((m) => console.error(m));
const owners = matches.map((p) => p.name.slice(0, p.name.lastIndexOf('/'))).join(', ');
log(`meta-host: ambiguous — ${repo} exists under multiple owners (${owners}); refusing to guess, skipping sync`);
return null;
}
const match = matches[0];
const owner = match.name.slice(0, match.name.lastIndexOf('/'));
return {
remoteUrl: cloneUrl(deps.auth.giteaUrl, deps.auth.giteaToken, owner, repo),
branch: match.default_branch,
gitDir: join(deps.gitDirBase, `${owner}__${repo}`),
};
}
/**
* Production `ResolveHost`: loads the real cache + auth from disk and resolves.
* Auth-load failure → `null` (no-op rather than blocking a hook). Cache read is
* a single small local JSON file — no network on the hot path.
*/
export async function resolveMetaHost(dir: string): Promise<HostResolution | null> {
const paths = getPaths(homedir());
const cache = await readCache(paths.cacheFile).catch(() => null);
const auth = await loadAuth(paths.authFile).catch(() => null);
if (!auth) return null;
return resolveMetaHostWith(dir, {
cache,
auth: { giteaUrl: auth.giteaUrl, giteaToken: auth.giteaToken },
gitDirBase: join(paths.cacheDir, 'meta-git'),
});
}

View File

@@ -0,0 +1,241 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { existsSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { ExecAsync } from './git.js';
const execFileAsync = promisify(execFile);
/**
* Git-backed sync core for project-local meta (`.wiki/`, `.tasks/`, `.claude/`).
*
* The model: two git repos share one folder. The *code* git (`<proj>/.git` → github)
* ignores the meta paths; the *meta* git lives in a hidden git-dir **beside** the
* work-tree (never a nested `.git`) and tracks only the meta paths, pushing to a
* Gitea `meta-<project>` host.
*
* Source of truth is the Gitea host (real git under the hood — not API PUTs), so
* merge/history/conflict semantics are genuine. A same-line conflict is **surfaced**
* (both versions returned), never clobbered.
*
* This module owns only the git mechanics for one (workTree, host) pair. Where the
* hidden git-dir lives and how a folder maps to a host are the CLI/autodiscovery
* layers' concern — here both are explicit inputs.
*/
/**
* Meta paths the meta-git tracks. Everything else in the work-tree is code, untouched.
*
* Note `.claude/` — **not** a root `CLAUDE.md`. Per the design
* (concepts/projects-meta-folder-sync.md), the root `CLAUDE.md` is PUBLIC domain doc:
* it belongs to the code-git and goes to github. Private instructions live in
* `.claude/CLAUDE.md`. The user's global ignore covers `.claude/` (the dir) but not the
* root file, so tracking root `CLAUDE.md` here would materialize it where the code-git
* is NOT blind to it → leak. Tracking `.claude/` keeps the private rail on the meta git
* and leaves the public root file to the code-git.
*/
export const DEFAULT_META_PATHS = ['.wiki', '.tasks', '.claude'];
const DEFAULT_BRANCH = 'main';
export interface ConflictFile {
/** Repo-relative path (forward slashes, as git reports). */
path: string;
/** Our version (local work-tree, merge stage 2). */
ours: string;
/** Their version (the host, merge stage 3). */
theirs: string;
}
export type PullResult =
| { status: 'pulled' }
| { status: 'up-to-date' }
| { status: 'conflict'; conflicts: ConflictFile[] };
export type PushResult =
| { status: 'pushed'; commit: string }
| { status: 'no-op' }
| { status: 'conflict'; conflicts: ConflictFile[] }
| { status: 'push-failed'; error: string };
interface CommonOptions {
/** Hidden meta git-dir, beside the work-tree. */
gitDir: string;
/** The project folder — materialization target / work-tree. */
workTree: string;
/** Host branch (default `main`). */
branch?: string;
/** Meta paths to track (default {@link DEFAULT_META_PATHS}). */
metaPaths?: string[];
/** Injectable for tests; defaults to a real `execFile`. */
execAsync?: ExecAsync;
}
export interface MetaPullOptions extends CommonOptions {
/** Clone URL of the Gitea meta-host. */
remoteUrl: string;
}
export interface MetaPushOptions extends CommonOptions {
/** Commit message for the meta change. */
message: string;
}
type Git = (args: string[], opts?: Record<string, unknown>) => Promise<{ stdout: string; stderr: string }>;
function makeGit(gitDir: string, workTree: string, execAsync: ExecAsync): Git {
// Commit identity + no GPG signing supplied per-invocation so the meta-git
// works regardless of the user's global git config. `core.excludesFile=`
// disables the user's global ignore — that ignore is what keeps the *code*
// git blind to `.wiki/.tasks/.claude`, but the *meta* git must track exactly
// those paths, so it has to opt out of the same global rule.
const top = [
'-c', 'core.excludesFile=',
// Meta files are LF markdown — keep them byte-identical across machines so
// line-ending normalization never produces spurious diffs or conflicts.
'-c', 'core.autocrlf=false',
'-c', 'user.email=projects-meta-sync@local',
'-c', 'user.name=projects-meta-sync',
'-c', 'commit.gpgsign=false',
'--git-dir', gitDir,
'--work-tree', workTree,
];
return (args, opts = {}) => execAsync('git', [...top, ...args], { cwd: workTree, ...opts });
}
/** Files left in conflict after a failed merge, with both sides extracted from the index. */
async function collectConflicts(git: Git): Promise<ConflictFile[]> {
const out = await git(['diff', '--name-only', '--diff-filter=U']).then((r) => r.stdout).catch(() => '');
const paths = out.split('\n').map((s) => s.trim()).filter(Boolean);
const conflicts: ConflictFile[] = [];
for (const path of paths) {
const ours = await git(['show', `:2:${path}`]).then((r) => r.stdout).catch(() => '');
const theirs = await git(['show', `:3:${path}`]).then((r) => r.stdout).catch(() => '');
conflicts.push({ path, ours, theirs });
}
return conflicts;
}
/**
* Merge `origin/<branch>` into the current branch.
* Clean merge → `{ ok: true }`. Conflict → extract both sides, abort (restore a
* clean tree), return `{ ok: false, conflicts }`. Non-conflict errors rethrow.
*/
async function mergeRemote(git: Git, branch: string): Promise<{ ok: true } | { ok: false; conflicts: ConflictFile[] }> {
try {
await git(['merge', '--no-edit', `origin/${branch}`]);
return { ok: true };
} catch (err) {
const conflicts = await collectConflicts(git);
if (conflicts.length > 0) {
await git(['merge', '--abort']).catch(() => {});
return { ok: false, conflicts };
}
throw err;
}
}
/** True when `a` is an ancestor of `b` (or equal). */
async function isAncestor(git: Git, a: string, b: string): Promise<boolean> {
try {
await git(['merge-base', '--is-ancestor', a, b]);
return true;
} catch {
return false;
}
}
/** Belt against accidental `git add -A`: ignore everything except meta paths. */
async function writeExcludeBelt(gitDir: string, metaPaths: string[]): Promise<void> {
const lines = ['/*', ...metaPaths.map((p) => `!/${p}`)];
await writeFile(join(gitDir, 'info', 'exclude'), lines.join('\n') + '\n').catch(() => {});
}
/**
* Materialize / refresh the host's meta into the work-tree.
*
* First call (no git-dir yet) initializes the hidden meta-git beside the work-tree,
* fetches the host and checks the branch out into the folder. Later calls fetch and
* merge incoming changes; a conflict is surfaced, not clobbered.
*/
export async function metaPull(opts: MetaPullOptions): Promise<PullResult> {
const branch = opts.branch ?? DEFAULT_BRANCH;
const metaPaths = opts.metaPaths ?? DEFAULT_META_PATHS;
const execAsync: ExecAsync = opts.execAsync ?? ((file, args, o) => execFileAsync(file, args, o));
const git = makeGit(opts.gitDir, opts.workTree, execAsync);
if (!existsSync(opts.gitDir)) {
// First materialization: init meta-git, fetch host, check branch out into the folder.
// `git init --git-dir <p>` does not create intermediate parents — make them first
// (real path is ~/.cache/projects-mcp/meta-git/<owner>__<repo>, nested several deep).
await mkdir(opts.gitDir, { recursive: true });
await git(['init']);
await writeExcludeBelt(opts.gitDir, metaPaths);
await git(['remote', 'add', 'origin', opts.remoteUrl]);
await git(['fetch', 'origin']);
await git(['checkout', '-f', '-B', branch, `origin/${branch}`]);
await git(['branch', `--set-upstream-to=origin/${branch}`, branch]).catch(() => {});
return { status: 'pulled' };
}
await git(['fetch', 'origin']);
// Nothing incoming if the host tip is already in our history.
if (await isAncestor(git, `origin/${branch}`, 'HEAD')) {
return { status: 'up-to-date' };
}
const merged = await mergeRemote(git, branch);
if (!merged.ok) return { status: 'conflict', conflicts: merged.conflicts };
return { status: 'pulled' };
}
/**
* Commit local meta edits, integrate the host, and push.
*
* Only meta paths are staged — untracked code in the same folder never enters the
* meta-git. Clean meta → no-op. Same-line divergence with the host → conflict
* surfaced (local commit kept, host untouched). Network failure on push →
* `push-failed` (caller logs and exits 0 — must not block end of session).
*/
export async function metaPush(opts: MetaPushOptions): Promise<PushResult> {
const branch = opts.branch ?? DEFAULT_BRANCH;
const metaPaths = opts.metaPaths ?? DEFAULT_META_PATHS;
const execAsync: ExecAsync = opts.execAsync ?? ((file, args, o) => execFileAsync(file, args, o));
const git = makeGit(opts.gitDir, opts.workTree, execAsync);
// Stage additions/modifications/deletions, scoped strictly to existing meta paths.
const present = metaPaths.filter((p) => existsSync(join(opts.workTree, p)));
if (present.length > 0) {
await git(['add', '-A', '--', ...present]);
}
let hasStaged = false;
try {
await git(['diff', '--cached', '--quiet']);
} catch {
hasStaged = true;
}
if (hasStaged) {
await git(['commit', '-m', opts.message]);
}
await git(['fetch', 'origin']);
if (!hasStaged) {
// No new meta edits — only push if we carry unpushed commits from before.
const upToDate = await isAncestor(git, 'HEAD', `origin/${branch}`);
if (upToDate) return { status: 'no-op' };
}
const merged = await mergeRemote(git, branch);
if (!merged.ok) return { status: 'conflict', conflicts: merged.conflicts };
try {
await git(['push', 'origin', `HEAD:${branch}`]);
} catch (err) {
return { status: 'push-failed', error: err instanceof Error ? err.message : String(err) };
}
const commit = (await git(['rev-parse', 'HEAD'])).stdout.trim();
return { status: 'pushed', commit };
}

View File

@@ -0,0 +1,122 @@
/**
* Project-level claim policy — `.tasks/policy.toml` (agent-orchestration #7).
*
* A project may publish defaults that apply to its tasks when the task itself
* doesn't specify the field. The override rule is **total, not intersect**: if a
* task carries `runtime_allowed` (even empty), the policy default is ignored for
* that field. `tasks_claim_next` reads the policy once per project per call and
* overlays {@link effectiveClaimFields} onto each candidate before gating.
*
* Use-case: `~/projects/.admin/.tasks/policy.toml` restricts ops tasks to Claude
* runtimes (other LLMs have lower ops judgment + secret-leak risk).
*/
import { parse as parseToml } from 'smol-toml';
import type { ParsedTask, TaskWeight, ConsultPolicy } from './status-md.js';
/** The three execution-policy levels (design: `.workshop` buffer
* `agent-execution-policy-levels.md`). */
export const CONSULT_POLICY_LEVELS: ConsultPolicy[] = ['auto', 'human-only', 'strict-human'];
/** Built-in default execution policy when neither task nor project policy sets it. */
export const DEFAULT_CONSULT_POLICY: ConsultPolicy = 'human-only';
function isConsultPolicy(v: unknown): v is ConsultPolicy {
return typeof v === 'string' && (CONSULT_POLICY_LEVELS as string[]).includes(v);
}
export interface ProjectPolicy {
defaultRuntimeAllowed?: string[];
defaultRequirements?: string[];
defaultWeight?: string;
defaultConsultPolicy?: ConsultPolicy;
}
export type PolicyResult =
| { ok: true; policy: ProjectPolicy | null }
| { ok: false; error: string };
function isStringArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every((x) => typeof x === 'string');
}
/**
* Parse a `.tasks/policy.toml` body. `null` content (no file) yields a null
* policy (= unrestricted). Malformed TOML or wrong field types yield a
* structured error — never silently ignored.
*/
export function parsePolicyToml(content: string | null): PolicyResult {
if (content === null) return { ok: true, policy: null };
let raw: unknown;
try {
raw = parseToml(content);
} catch (e) {
return { ok: false, error: `malformed policy.toml: ${e instanceof Error ? e.message : String(e)}` };
}
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
return { ok: false, error: 'policy.toml must be a TOML table' };
}
const obj = raw as Record<string, unknown>;
const policy: ProjectPolicy = {};
if (obj.default_runtime_allowed !== undefined) {
if (!isStringArray(obj.default_runtime_allowed)) {
return { ok: false, error: 'default_runtime_allowed must be an array of strings' };
}
policy.defaultRuntimeAllowed = obj.default_runtime_allowed;
}
if (obj.default_requirements !== undefined) {
if (!isStringArray(obj.default_requirements)) {
return { ok: false, error: 'default_requirements must be an array of strings' };
}
policy.defaultRequirements = obj.default_requirements;
}
if (obj.default_weight !== undefined) {
if (typeof obj.default_weight !== 'string') {
return { ok: false, error: 'default_weight must be a string' };
}
policy.defaultWeight = obj.default_weight;
}
if (obj.default_consult_policy !== undefined) {
if (typeof obj.default_consult_policy !== 'string') {
return { ok: false, error: 'default_consult_policy must be a string' };
}
if (!isConsultPolicy(obj.default_consult_policy)) {
return {
ok: false,
error: `default_consult_policy must be one of ${CONSULT_POLICY_LEVELS.join(' | ')}`,
};
}
policy.defaultConsultPolicy = obj.default_consult_policy;
}
return { ok: true, policy };
}
export interface EffectiveClaimFields {
runtimeAllowed?: string[];
requirements?: string[];
weight?: TaskWeight;
/** Always resolved — task > project default > built-in `human-only`. */
consultPolicy: ConsultPolicy;
}
/**
* Resolve the claim-gating fields for a task under a project policy. A field set
* on the task wins outright (total override); otherwise the policy default fills
* it; otherwise it stays undefined (= unrestricted for that gate).
*
* `consultPolicy` is the exception — it is ALWAYS resolved to one of the three
* levels (never undefined): task field > project default > built-in
* {@link DEFAULT_CONSULT_POLICY} (`human-only`). The default is human-only, not
* `auto`, by the asymmetry-of-caution principle (silently stricter is fine;
* silently more autonomous is not).
*/
export function effectiveClaimFields(task: ParsedTask, policy: ProjectPolicy | null): EffectiveClaimFields {
return {
runtimeAllowed: task.runtimeAllowed ?? policy?.defaultRuntimeAllowed,
requirements: task.requirements ?? policy?.defaultRequirements,
weight: task.weight ?? (policy?.defaultWeight as TaskWeight | undefined),
consultPolicy: task.consultPolicy ?? policy?.defaultConsultPolicy ?? DEFAULT_CONSULT_POLICY,
};
}

View File

@@ -0,0 +1,107 @@
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import matter from 'gray-matter';
import type { Domain } from './domain-detector.js';
import type { WikiPage } from './wiki-index.js';
export interface PromotionCandidate {
slug: string;
current_path: string;
suggested_domain: string;
confidence: 'low' | 'medium' | 'high';
reason: string;
}
export interface PromotionDeps {
projectWikiDir: string;
sharedPages: WikiPage[];
cwdDomain: Domain;
blacklist: string[];
}
const PLATFORM_KEYWORDS = [
'windows',
'linux',
'macos',
'mac os',
'yarn',
'npm',
'pnpm',
'node',
'mcp',
'git ',
'docker',
'tsx',
'vite',
'webpack',
'eslint',
'tsc',
'powershell',
'bash',
];
function tokenize(s: string): Set<string> {
return new Set(
s
.toLowerCase()
.replace(/[^a-z0-9 ]+/g, ' ')
.split(/\s+/)
.filter((w) => w.length >= 3),
);
}
function jaccard(a: Set<string>, b: Set<string>): number {
if (a.size === 0 && b.size === 0) return 0;
let inter = 0;
for (const x of a) if (b.has(x)) inter += 1;
const union = a.size + b.size - inter;
return union === 0 ? 0 : inter / union;
}
async function listConcepts(dir: string): Promise<string[]> {
try {
const files = await readdir(dir, { withFileTypes: true });
return files.filter((f) => f.isFile() && f.name.endsWith('.md')).map((f) => f.name);
} catch {
return [];
}
}
function suggestDomain(d: Domain): string {
return d === 'unknown' ? 'cross' : d;
}
export async function findCandidates(deps: PromotionDeps): Promise<PromotionCandidate[]> {
const conceptsDir = join(deps.projectWikiDir, 'concepts');
const files = await listConcepts(conceptsDir);
const out: PromotionCandidate[] = [];
for (const f of files) {
const path = join(conceptsDir, f);
const text = await readFile(path, 'utf8');
const { data: fm, content } = matter(text);
const title = typeof fm.title === 'string' ? fm.title : f.replace(/\.md$/, '');
const haystack = `${title}\n${content}`.toLowerCase();
if (deps.blacklist.some((b) => haystack.includes(b.toLowerCase()))) continue;
const matchedKw = PLATFORM_KEYWORDS.find((k) => haystack.includes(k));
if (!matchedKw) continue;
const titleTokens = tokenize(title);
const similar = deps.sharedPages.find((p) => jaccard(titleTokens, tokenize(p.title)) >= 0.4);
const confidence: PromotionCandidate['confidence'] = similar ? 'high' : 'medium';
const reasonParts = [`keyword: ${matchedKw.trim()}`];
if (similar) reasonParts.push(`similar shared page: ${similar.slug}`);
out.push({
slug: f.replace(/\.md$/, ''),
current_path: path,
suggested_domain: suggestDomain(deps.cwdDomain),
confidence,
reason: reasonParts.join('; '),
});
}
return out;
}

View File

@@ -0,0 +1,114 @@
/**
* Shared helper for write tools: turn a `target_project` parameter into a
* concrete `{owner, repo, branch}` triple using the cached project list. Used
* by `tools/tasks.ts`, `tools/knowledge.ts`, and the read-side
* `knowledge.get_from`.
*
* From 2.0 onward `target_project` MUST be either the literal `agenda` (alias
* for the cross-project agenda / wiki repos) or a qualified `<owner>/<repo>`
* pair like `victor/books`. Bare names are rejected with a hint message.
*/
import { readCache } from './cache.js';
import { AGENDA_BRANCH_LOCAL, AGENDA_PROJECT_NAME } from './sync-runner.js';
export interface ResolvedTarget {
owner: string;
repo: string;
branch: string;
isAgenda: boolean;
}
export interface ResolveError {
error: string;
}
export interface ParsedQualified {
agenda: false;
owner: string;
repo: string;
}
export interface ParsedAgenda {
agenda: true;
}
export type ParsedTarget = ParsedQualified | ParsedAgenda;
/**
* Pure shape validator. Accepts `agenda` literal or `<owner>/<repo>` (no
* leading / trailing slash, no spaces, exactly one slash). Returns an
* `{error}` on bare names, malformed input, or anything else.
*
* This is the single source of truth for "what counts as a valid
* `target_project`" — call it first in every mutate handler so the user
* gets a helpful hint instead of a silent cache-miss.
*/
export function parseTargetProject(input: string): ParsedTarget | ResolveError {
if (input === AGENDA_PROJECT_NAME) return { agenda: true };
const m = /^([^/\s]+)\/([^/\s]+)$/.exec(input);
if (!m) {
return {
error:
'target_project must be qualified: use `<owner>/<repo>`, ' +
`e.g. \`victor/books\`. Got: \`${input}\`.`,
};
}
return { agenda: false, owner: m[1], repo: m[2] };
}
/**
* Resolve `target_project` to a concrete `{owner, repo, branch}` triple
* using the cache. `agenda` is rerouted to the configured `agendaRepo`
* (the agenda tasks repo for `tools/tasks.ts` callers, the projects-wiki
* repo for `tools/knowledge.ts` callers).
*
* `fallbackOwner` is the acting Gitea user. `agendaOwner` (when defined)
* pins the agenda repo to a specific owner — used when `auth.toml` declares
* `agenda_tasks_repo = "<owner>/<repo>"`. When `agendaOwner` is undefined,
* `agenda` resolves under `fallbackOwner` (legacy behaviour for the
* bare-name form and for the projects-wiki path which is always single-owner).
*
* Returns `{error}` on validation failure, cache miss, or local-only agenda —
* callers surface that as a tool error so the agent knows to fix the input
* or run sync.
*/
export async function resolveTarget(
cacheFile: string,
target: string,
agendaRepo: string,
fallbackOwner: string,
agendaOwner?: string,
): Promise<ResolvedTarget | ResolveError> {
const parsed = parseTargetProject(target);
if ('error' in parsed) return parsed;
const cache = await readCache(cacheFile);
if (!cache) return { error: 'cache missing — run sync first (`node dist/sync.js`)' };
if (parsed.agenda) {
const agenda = cache.projects.find((p) => p.name === AGENDA_PROJECT_NAME);
if (!agenda) {
return {
error: `agenda not in cache. Create the Gitea repo "${agendaRepo}" with the canonical layout, then re-sync.`,
};
}
if (agenda.default_branch === AGENDA_BRANCH_LOCAL) {
return {
error: `agenda is currently sourced from local FS — Gitea repo "${agendaRepo}" not found. Create it first; writes go through Gitea only.`,
};
}
return {
owner: agendaOwner ?? fallbackOwner,
repo: agendaRepo,
branch: agenda.default_branch,
isAgenda: true,
};
}
const proj = cache.projects.find((p) => p.name === target);
if (!proj) {
return { error: `project not in cache: ${target}. Run sync, or check spelling.` };
}
return { owner: parsed.owner, repo: parsed.repo, branch: proj.default_branch, isAgenda: false };
}

View File

@@ -0,0 +1,345 @@
/**
* Round-trip-safe writer for `.tasks/STATUS.md` files.
*
* Read-only parsing lives in `status-md.ts`. This module is the *write* side —
* formatting new task blocks, locating an existing block by slug, and
* mutating its emoji / status / fields without disturbing surrounding content.
*
* The canonical block format is:
*
* ## 🔴 [<slug>] — <description>
*
* **Status:** active | paused | ready | blocked | done
* **Where I stopped:** ...
* **Next action:** ...
* **Blocker:** (only if blocked)
* **Branch:** ...
* <!-- created-by: <user>@<machine> / from: <source> / <iso-utc> -->
*
* ---
*/
import type { TaskWeight } from './status-md.js';
const STATUS_TO_EMOJI = {
active: '🔴',
paused: '🟡',
ready: '⚪',
blocked: '🔵',
done: '🟢',
} as const;
export type WritableStatus = keyof typeof STATUS_TO_EMOJI;
export interface NewTaskBlock {
slug: string;
status: WritableStatus;
description: string;
whereStopped: string;
nextAction: string;
branch?: string;
blocker?: string;
/** Identity-footer fields. All optional. */
createdBy?: string;
fromProject?: string;
createdAtIso?: string;
// --- agent-orchestration claim/runtime schema (all optional) ---
owner?: string;
claimToken?: string;
claimExpiresAt?: string;
requirements?: string[];
weight?: TaskWeight;
runtimeAllowed?: string[];
runtimePreferences?: string[];
resultArtifactUrl?: string;
workerRegistry?: Record<string, unknown>;
/** Qualified <owner>/<repo> inbox target — written on close/delivery-failed. */
notify?: string;
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function formatTaskBlock(t: NewTaskBlock): string {
const lines: string[] = [];
lines.push(`## ${STATUS_TO_EMOJI[t.status]} [${t.slug}] — ${t.description}`);
lines.push('');
lines.push(`**Status:** ${t.status}`);
lines.push(`**Where I stopped:** ${t.whereStopped}`);
lines.push(`**Next action:** ${t.nextAction}`);
if (t.status === 'blocked' && t.blocker) {
lines.push(`**Blocker:** ${t.blocker}`);
}
lines.push(`**Branch:** ${t.branch ?? 'n/a'}`);
// agent-orchestration schema fields — only emitted when present (legacy blocks stay clean)
if (t.owner) lines.push(`**Owner:** ${t.owner}`);
if (t.claimToken) lines.push(`**Claim token:** ${t.claimToken}`);
if (t.claimExpiresAt) lines.push(`**Claim expires at:** ${t.claimExpiresAt}`);
if (t.requirements && t.requirements.length) lines.push(`**Requirements:** ${t.requirements.join(', ')}`);
if (t.weight) lines.push(`**Weight:** ${t.weight}`);
if (t.runtimeAllowed && t.runtimeAllowed.length)
lines.push(`**Runtime allowed:** ${t.runtimeAllowed.join(', ')}`);
if (t.runtimePreferences && t.runtimePreferences.length)
lines.push(`**Runtime preferences:** ${t.runtimePreferences.join(', ')}`);
if (t.resultArtifactUrl) lines.push(`**Result artifact URL:** ${t.resultArtifactUrl}`);
if (t.workerRegistry) lines.push(`**Worker registry:** ${JSON.stringify(t.workerRegistry)}`);
if (t.notify) lines.push(`**Notify:** ${t.notify}`);
const meta: string[] = [];
if (t.createdBy) meta.push(`created-by: ${t.createdBy}`);
if (t.fromProject) meta.push(`from: ${t.fromProject}`);
if (t.createdAtIso) meta.push(t.createdAtIso);
if (meta.length) lines.push(`<!-- ${meta.join(' / ')} -->`);
lines.push('');
lines.push('---');
return lines.join('\n');
}
/** Append a new task block to existing markdown, normalizing trailing whitespace. */
export function appendTaskBlock(md: string, block: NewTaskBlock): string {
const formatted = formatTaskBlock(block);
const trimmed = md.replace(/\s+$/, '');
if (trimmed.length === 0) return `${formatted}\n`;
return `${trimmed}\n\n${formatted}\n`;
}
export function freshBoardTemplate(today: string): string {
return `# Task Board
_Updated: ${today}_
<!--
Add one block per task, sorted by priority. Use the emoji status legend below.
Per-task deep context lives in .tasks/<task-slug>.md (created on demand by using-tasks).
Status legend:
🔴 Active — only one at a time
🟡 Paused — in progress, resumable
⚪ Ready — defined, not started
🟢 Done — kept until merged
🔵 Blocked — waiting on external input
-->
`;
}
interface BlockBounds {
start: number;
end: number;
}
/**
* Locate the line range of a task block by slug. Returns null if not found.
*
* The block runs from its `## <emoji> [<slug>] — ...` header to (exclusive)
* the next `## ` header or `---` separator line.
*/
function findBlockLines(lines: string[], slug: string): BlockBounds | null {
const headerRe = new RegExp(`^##\\s+\\S+\\s+\\[${escapeRegex(slug)}\\]\\s+—\\s+`);
let start = -1;
for (let i = 0; i < lines.length; i++) {
if (headerRe.test(lines[i])) {
start = i;
break;
}
}
if (start < 0) return null;
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^##\s+/.test(lines[i])) {
end = i;
break;
}
if (/^---\s*$/.test(lines[i])) {
end = i;
break;
}
}
return { start, end };
}
function splitLines(md: string): { lines: string[]; sep: string } {
const sep = /\r\n/.test(md) ? '\r\n' : '\n';
return { lines: md.split(/\r?\n/), sep };
}
export interface CloseOptions {
closedBy?: string;
closedAtIso?: string;
note?: string;
bodyAppend?: string;
}
/**
* Mark a task as 🟢 done. Returns null if the slug wasn't found, otherwise
* the modified markdown.
*/
export function closeTask(md: string, slug: string, opts: CloseOptions = {}): string | null {
const { lines, sep } = splitLines(md);
const bounds = findBlockLines(lines, slug);
if (!bounds) return null;
const block = lines.slice(bounds.start, bounds.end);
// Replace emoji in header line
block[0] = block[0].replace(/^(##\s+)\S+(\s+\[)/, `$1${STATUS_TO_EMOJI.done}$2`);
// Replace **Status:** ... line
for (let i = 1; i < block.length; i++) {
if (/^\*\*Status:\*\*/i.test(block[i])) {
block[i] = '**Status:** done';
break;
}
}
// Normalize **Where I stopped:** — replace with close note or default
const whereStoppedReplacement = opts.note ?? 'closed via tasks_close';
for (let i = 1; i < block.length; i++) {
if (/^\*\*Where I stopped:\*\*/i.test(block[i])) {
block[i] = `**Where I stopped:** ${whereStoppedReplacement}`;
break;
}
}
// Normalize **Next action:** — clear since task is done
for (let i = 1; i < block.length; i++) {
if (/^\*\*Next action:\*\*/i.test(block[i])) {
block[i] = '**Next action:** (none — kept until merged)';
break;
}
}
// Hygiene (root-cause-#3): strip any lingering claim-stamp so a closed task
// carries no Owner / Claim token / Claim expires at fields. A stale stamp on a
// 🟢 done task was the compounding cause of the done-task re-claim loop.
const CLAIM_STAMP_RE = /^\*\*(Owner|Claim token|Claim expires at):\*\*/i;
for (let i = block.length - 1; i >= 1; i--) {
if (CLAIM_STAMP_RE.test(block[i])) block.splice(i, 1);
}
// Optionally append body section (e.g. ## Review outcome) before closing comment
if (opts.bodyAppend) {
let insertAt = block.length;
while (insertAt > 1 && block[insertAt - 1].trim() === '') insertAt--;
const appendLines = opts.bodyAppend.split(/\r?\n/);
block.splice(insertAt, 0, '', ...appendLines);
}
// Optionally append closing comment
const meta: string[] = [];
if (opts.closedBy) meta.push(`closed-by: ${opts.closedBy}`);
if (opts.closedAtIso) meta.push(opts.closedAtIso);
if (opts.note) meta.push(`note: ${opts.note}`);
if (meta.length) {
// Insert closing-comment as the last non-empty line of the block before trailing blank lines
let insertAt = block.length;
while (insertAt > 1 && block[insertAt - 1].trim() === '') insertAt--;
block.splice(insertAt, 0, `<!-- ${meta.join(' / ')} -->`);
}
return [...lines.slice(0, bounds.start), ...block, ...lines.slice(bounds.end)].join(sep);
}
export interface UpdateFields {
whereStopped?: string;
nextAction?: string;
blocker?: string;
branch?: string;
description?: string;
status?: WritableStatus;
// claim-mechanics (agent-orchestration) — inserted after the last field line
// if the block doesn't carry them yet (a ⚪ ready task has none).
owner?: string;
claimToken?: string;
claimExpiresAt?: string;
// orchestration policy fields — same upsert semantics as the claim fields:
// replaced in place when present, inserted after the last field line otherwise.
weight?: TaskWeight;
notify?: string;
}
/**
* Update fields of an existing task block. Returns null if slug not found.
* Empty/undefined fields are left untouched. Status change also updates the
* header emoji to match. Claim-mechanics fields (owner/claimToken/
* claimExpiresAt) are replaced in place when present, or inserted after the
* last `**Field:**` line when absent.
*/
export function updateTaskFields(md: string, slug: string, fields: UpdateFields): string | null {
const { lines, sep } = splitLines(md);
const bounds = findBlockLines(lines, slug);
if (!bounds) return null;
const block = lines.slice(bounds.start, bounds.end);
if (fields.status) {
block[0] = block[0].replace(/^(##\s+)\S+(\s+\[)/, `$1${STATUS_TO_EMOJI[fields.status]}$2`);
}
if (fields.description) {
block[0] = block[0].replace(/—\s+.+$/, `${fields.description}`);
}
const fieldEdits: Array<{ re: RegExp; replacement: string }> = [];
if (fields.status) fieldEdits.push({ re: /^\*\*Status:\*\*.*/i, replacement: `**Status:** ${fields.status}` });
if (fields.whereStopped !== undefined)
fieldEdits.push({ re: /^\*\*Where I stopped:\*\*.*/i, replacement: `**Where I stopped:** ${fields.whereStopped}` });
if (fields.nextAction !== undefined)
fieldEdits.push({ re: /^\*\*Next action:\*\*.*/i, replacement: `**Next action:** ${fields.nextAction}` });
if (fields.blocker !== undefined) {
if (fields.blocker === '') {
// Empty string = remove the Blocker line entirely (used by auto-unblock to clear after unblocking)
for (let i = block.length - 1; i >= 1; i--) {
if (/^\*\*Blocker:\*\*/i.test(block[i])) { block.splice(i, 1); break; }
}
} else {
fieldEdits.push({ re: /^\*\*Blocker:\*\*.*/i, replacement: `**Blocker:** ${fields.blocker}` });
}
}
if (fields.branch !== undefined)
fieldEdits.push({ re: /^\*\*Branch:\*\*.*/i, replacement: `**Branch:** ${fields.branch}` });
for (let i = 1; i < block.length; i++) {
for (const e of fieldEdits) {
if (e.re.test(block[i])) {
block[i] = e.replacement;
break;
}
}
}
// When reverting to ready, strip any stale claim-stamp — a ready task has no
// owner and the poller skips any block that still carries **Owner:**.
if (fields.status === 'ready') {
const CLAIM_STAMP_RE = /^\*\*(Owner|Claim token|Claim expires at):\*\*/i;
for (let i = block.length - 1; i >= 1; i--) {
if (CLAIM_STAMP_RE.test(block[i])) block.splice(i, 1);
}
}
// Upsert fields (claim-mechanics + orchestration policy): replace in place if
// present, else insert after the last existing `**Field:**` line (keeps them
// inside the block, before footer).
const upsertFields: Array<[string, string | undefined]> = [
['Owner', fields.owner],
['Claim token', fields.claimToken],
['Claim expires at', fields.claimExpiresAt],
['Weight', fields.weight],
['Notify', fields.notify],
];
let lastFieldIdx = 0;
for (let i = 1; i < block.length; i++) {
if (/^\*\*[^:*]+:\*\*/.test(block[i])) lastFieldIdx = i;
}
for (const [label, value] of upsertFields) {
if (value === undefined) continue;
const re = new RegExp(`^\\*\\*${escapeRegex(label)}:\\*\\*.*`, 'i');
let found = false;
for (let i = 1; i < block.length; i++) {
if (re.test(block[i])) {
block[i] = `**${label}:** ${value}`;
found = true;
break;
}
}
if (!found) {
block.splice(lastFieldIdx + 1, 0, `**${label}:** ${value}`);
lastFieldIdx++;
}
}
return [...lines.slice(0, bounds.start), ...block, ...lines.slice(bounds.end)].join(sep);
}
/** Returns true if `md` already contains a task with the given slug. */
export function hasTaskSlug(md: string, slug: string): boolean {
const { lines } = splitLines(md);
return findBlockLines(lines, slug) !== null;
}

View File

@@ -0,0 +1,193 @@
export type TaskStatus = 'active' | 'paused' | 'blocked' | 'ready' | 'done';
/** Coarse cost/judgment class of a task — see agent-orchestration design decision #7. */
export type TaskWeight = 'cheap-ok' | 'needs-claude' | 'needs-human';
/** Execution-policy level governing how a consult escalation is routed — see
* `.workshop` buffer `agent-execution-policy-levels.md`. */
export type ConsultPolicy = 'auto' | 'human-only' | 'strict-human';
export interface ParsedTask {
slug: string;
status: TaskStatus;
description: string;
next: string | null;
/** Qualified <owner>/<repo> inbox target for close/delivery-failed/park events. */
notify?: string;
/** CSV list of blocking slug(s) — set when status is blocked. */
blocker?: string;
// --- agent-orchestration claim/runtime schema (all optional, backwards-compat) ---
/** Identity of the claiming agent, `<machine>:<runtime>:<session>`. */
owner?: string;
/** Atomic claim handle. */
claimToken?: string;
/** ISO datetime — TTL for crash-revoke. */
claimExpiresAt?: string;
/** Capability filter, e.g. `needs-db`, `needs-secrets`. */
requirements?: string[];
/** Coarse cost/judgment class. */
weight?: TaskWeight;
/** Runtime whitelist — server-side enforcement in `tasks_claim_next`. */
runtimeAllowed?: string[];
/** Execution-policy level for consult routing (per-task override). */
consultPolicy?: ConsultPolicy;
/** Prioritization hint within the allowed set. */
runtimePreferences?: string[];
/** Where to look for the result (PR link, S3 report). */
resultArtifactUrl?: string;
/** Placeholder for the future centralized worker-registry mode. */
workerRegistry?: Record<string, unknown>;
}
export interface ParsedStatus {
updated: string | null;
tasks: ParsedTask[];
}
const EMOJI_TO_STATUS: Record<string, TaskStatus> = {
'🔴': 'active',
'🟡': 'paused',
'⚪': 'ready',
'🟢': 'done',
'🔵': 'blocked',
};
const TASK_HEADER = /^##\s+(\S+)\s+\[([^\]]+)\]\s+\s+(.+)$/u;
const NEXT_FIELD = /^\*\*Next action:\*\*\s*(.+)$/i;
const UPDATED_FIELD = /^_Updated:\s*([0-9-]+)_/i;
// agent-orchestration schema fields (optional `**Field:**` lines in a task block)
const OWNER_FIELD = /^\*\*Owner:\*\*\s*(.+)$/i;
const CLAIM_TOKEN_FIELD = /^\*\*Claim token:\*\*\s*(.+)$/i;
const CLAIM_EXPIRES_FIELD = /^\*\*Claim expires at:\*\*\s*(.+)$/i;
const REQUIREMENTS_FIELD = /^\*\*Requirements:\*\*\s*(.+)$/i;
const WEIGHT_FIELD = /^\*\*Weight:\*\*\s*(.+)$/i;
const RUNTIME_ALLOWED_FIELD = /^\*\*Runtime allowed:\*\*\s*(.+)$/i;
const CONSULT_POLICY_FIELD = /^\*\*Consult policy:\*\*\s*(.+)$/i;
const CONSULT_POLICY_VALUES: ConsultPolicy[] = ['auto', 'human-only', 'strict-human'];
const RUNTIME_PREFERENCES_FIELD = /^\*\*Runtime preferences:\*\*\s*(.+)$/i;
const RESULT_ARTIFACT_FIELD = /^\*\*Result artifact URL:\*\*\s*(.+)$/i;
const WORKER_REGISTRY_FIELD = /^\*\*Worker registry:\*\*\s*(.+)$/i;
const NOTIFY_FIELD = /^\*\*Notify:\*\*\s*(.+)$/i;
const BLOCKER_FIELD = /^\*\*Blocker:\*\*\s*(.*)$/i;
/** Split a comma-separated field value into trimmed, non-empty parts. */
function parseList(v: string): string[] {
return v
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
export function parseStatusMd(text: string): ParsedStatus {
const lines = text.split(/\r?\n/);
let updated: string | null = null;
const tasks: ParsedTask[] = [];
let current: ParsedTask | null = null;
for (const line of lines) {
if (!updated) {
const m = line.match(UPDATED_FIELD);
if (m) updated = m[1];
}
const head = line.match(TASK_HEADER);
if (head) {
if (current) tasks.push(current);
const [, emoji, slug, description] = head;
current = {
slug,
status: EMOJI_TO_STATUS[emoji] ?? 'ready',
description: description.trim(),
next: null,
};
continue;
}
if (current) {
const nextM = line.match(NEXT_FIELD);
if (nextM) {
current.next = nextM[1].trim();
continue;
}
const ownerM = line.match(OWNER_FIELD);
if (ownerM) {
current.owner = ownerM[1].trim();
continue;
}
const tokenM = line.match(CLAIM_TOKEN_FIELD);
if (tokenM) {
current.claimToken = tokenM[1].trim();
continue;
}
const expiresM = line.match(CLAIM_EXPIRES_FIELD);
if (expiresM) {
current.claimExpiresAt = expiresM[1].trim();
continue;
}
const reqM = line.match(REQUIREMENTS_FIELD);
if (reqM) {
const list = parseList(reqM[1]);
if (list.length) current.requirements = list;
continue;
}
const weightM = line.match(WEIGHT_FIELD);
if (weightM) {
current.weight = weightM[1].trim() as TaskWeight;
continue;
}
const allowedM = line.match(RUNTIME_ALLOWED_FIELD);
if (allowedM) {
const list = parseList(allowedM[1]);
if (list.length) current.runtimeAllowed = list;
continue;
}
const consultM = line.match(CONSULT_POLICY_FIELD);
if (consultM) {
const v = consultM[1].trim();
// Only honour a known level; garbage is left undefined so the effective
// resolver falls back to the project / built-in default (= human-only).
if ((CONSULT_POLICY_VALUES as string[]).includes(v)) {
current.consultPolicy = v as ConsultPolicy;
}
continue;
}
const prefsM = line.match(RUNTIME_PREFERENCES_FIELD);
if (prefsM) {
const list = parseList(prefsM[1]);
if (list.length) current.runtimePreferences = list;
continue;
}
const artifactM = line.match(RESULT_ARTIFACT_FIELD);
if (artifactM) {
current.resultArtifactUrl = artifactM[1].trim();
continue;
}
const registryM = line.match(WORKER_REGISTRY_FIELD);
if (registryM) {
try {
const obj: unknown = JSON.parse(registryM[1].trim());
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
current.workerRegistry = obj as Record<string, unknown>;
}
} catch {
// malformed JSON — leave workerRegistry undefined (lenient parse)
}
continue;
}
const notifyM = line.match(NOTIFY_FIELD);
if (notifyM) {
current.notify = notifyM[1].trim();
continue;
}
const blockerM = line.match(BLOCKER_FIELD);
if (blockerM) {
current.blocker = blockerM[1].trim();
continue;
}
}
}
if (current) tasks.push(current);
return { updated, tasks };
}

View File

@@ -0,0 +1,200 @@
import { CACHE_SCHEMA_VERSION, type CacheFile, type ProjectStatus, type ActiveTask, type SyncError } from './cache.js';
import type { Backend, RepoInfo } from './backend.js';
import type { ParsedStatus, TaskStatus } from './status-md.js';
export interface SyncDeps {
client: Backend;
parseStatus: (raw: string) => ParsedStatus;
now: () => Date;
machine: string;
giteaUrl: string;
/**
* Gitea owners (users / orgs) to scan for project repositories. Sync
* iterates over each owner and aggregates the result; cache-key (`ProjectStatus.name`)
* is always qualified `<owner>/<repo>`.
*/
owners: string[];
/**
* Owners to additionally fetch into the cache **without** including them in
* aggregation views. Used so MCP-mutate tools can resolve infra repos
* (e.g. `OpeItcLoc03/common`) while the cross-project dashboard stays
* focused on `owners` only. Sync visits the deduped union of `owners` and
* `aggregateSkipOwners`. Aggregation filtering is enforced by read-side
* tools (`tasks.aggregate / tasks.search / tasks.get`), not here.
*/
aggregateSkipOwners?: string[];
concurrency?: number;
/**
* Name of the Gitea repo holding the agenda tasks board. Bare repo name —
* matched against any owner's repo list. Found agenda is fetched from that
* owner's namespace and excluded from the regular project list to avoid
* double-counting.
*/
agendaTasksRepo?: string;
/**
* Optional explicit owner for the agenda repo. When set, only that owner's
* repo with `name === agendaTasksRepo` is treated as agenda; same-named
* repos under other owners stay in the regular project list. When unset,
* the first repo matching `agendaTasksRepo` across all owners wins.
*/
agendaTasksOwner?: string;
/**
* Local FS fallback for agenda tasks. Read from `~/projects/.tasks/STATUS.md`
* only if the Gitea repo doesn't exist or has no STATUS.md.
*/
readAgendaTasksLocal?: () => Promise<string | null>;
}
const ACTIVE_STATES: TaskStatus[] = ['active', 'paused', 'blocked'];
export const AGENDA_PROJECT_NAME = 'agenda';
export const AGENDA_BRANCH_LOCAL = 'local';
type RepoWithOwner = RepoInfo & { owner: string };
async function pool<T, R>(items: T[], n: number, fn: (x: T) => Promise<R>): Promise<R[]> {
const out = new Array<R>(items.length);
let cursor = 0;
async function worker() {
while (true) {
const i = cursor++;
if (i >= items.length) return;
out[i] = await fn(items[i]);
}
}
await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));
return out;
}
export async function runSync(deps: SyncDeps): Promise<CacheFile> {
const concurrency = deps.concurrency ?? 10;
const ownersToVisit = Array.from(
new Set([...deps.owners, ...(deps.aggregateSkipOwners ?? [])]),
);
const reposPerOwner = await Promise.all(
ownersToVisit.map(async (owner) => {
const repos = await deps.client.listUserRepos(owner);
return repos.map((r): RepoWithOwner => ({ ...r, owner }));
}),
);
const allRepos: RepoWithOwner[] = reposPerOwner.flat();
const archivedCount = allRepos.filter((r) => r.archived).length;
const liveRepos = allRepos.filter((r) => !r.archived);
const projects: ProjectStatus[] = [];
const errors: SyncError[] = [];
const agendaRepoName = deps.agendaTasksRepo;
const agendaRepoOwner = deps.agendaTasksOwner;
const isAgendaRepo = (r: RepoWithOwner): boolean =>
r.name === agendaRepoName && (agendaRepoOwner === undefined || r.owner === agendaRepoOwner);
const agendaRepo = agendaRepoName ? liveRepos.find(isAgendaRepo) : undefined;
// Exclude agenda repo from the regular project list — it becomes `agenda` instead.
// Only the matching (owner, name) pair is excluded; same-named repos under
// other owners stay in the project list.
const repos = agendaRepoName ? liveRepos.filter((r) => !isAgendaRepo(r)) : liveRepos;
type Outcome =
| { kind: 'project'; data: ProjectStatus }
| { kind: 'skip' }
| { kind: 'error'; data: SyncError };
const results = await pool<RepoWithOwner, Outcome>(repos, concurrency, async (repo) => {
const qualifiedName = `${repo.owner}/${repo.name}`;
try {
const [tasksRaw, wikiIndex] = await Promise.all([
deps.client.getRawFile(repo.owner, repo.name, '.tasks/STATUS.md', repo.default_branch),
deps.client
.getRawFile(repo.owner, repo.name, '.wiki/index.md', repo.default_branch)
.catch(() => null),
]);
if (tasksRaw === null && wikiIndex === null) return { kind: 'skip' as const };
const parsed: ParsedStatus =
tasksRaw === null ? { updated: null, tasks: [] } : deps.parseStatus(tasksRaw);
const active: ActiveTask[] = parsed.tasks
.filter((t) => ACTIVE_STATES.includes(t.status))
.map((t) => ({ slug: t.slug, status: t.status, next: t.next }));
return {
kind: 'project' as const,
data: {
name: qualifiedName,
default_branch: repo.default_branch,
fetched_at: deps.now().toISOString(),
active_tasks: active,
all_tasks_count: parsed.tasks.length,
raw: tasksRaw ?? '',
wiki_index: wikiIndex ?? undefined,
},
};
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
return { kind: 'error' as const, data: { project: qualifiedName, reason } };
}
});
for (const r of results) {
if (r.kind === 'project') projects.push(r.data);
else if (r.kind === 'error') errors.push(r.data);
}
// Resolve agenda source: Gitea repo first, local FS fallback second.
let agendaRaw: string | null = null;
let agendaBranch: string = AGENDA_BRANCH_LOCAL;
let agendaError: string | null = null;
if (agendaRepo) {
try {
const r = await deps.client.getRawFile(
agendaRepo.owner,
agendaRepo.name,
'.tasks/STATUS.md',
agendaRepo.default_branch,
);
if (r !== null) {
agendaRaw = r;
agendaBranch = agendaRepo.default_branch;
}
} catch (err) {
agendaError = err instanceof Error ? err.message : String(err);
}
}
if (agendaRaw === null && deps.readAgendaTasksLocal) {
try {
const r = await deps.readAgendaTasksLocal();
if (r !== null) {
agendaRaw = r;
agendaBranch = AGENDA_BRANCH_LOCAL;
}
} catch (err) {
// Only surface local error if Gitea didn't already produce one.
if (!agendaError) agendaError = err instanceof Error ? err.message : String(err);
}
}
if (agendaRaw !== null) {
const parsed = deps.parseStatus(agendaRaw);
const active: ActiveTask[] = parsed.tasks
.filter((t) => ACTIVE_STATES.includes(t.status))
.map((t) => ({ slug: t.slug, status: t.status, next: t.next }));
projects.push({
name: AGENDA_PROJECT_NAME,
default_branch: agendaBranch,
fetched_at: deps.now().toISOString(),
active_tasks: active,
all_tasks_count: parsed.tasks.length,
raw: agendaRaw,
});
} else if (agendaError) {
errors.push({ project: AGENDA_PROJECT_NAME, reason: agendaError });
}
return {
schemaVersion: CACHE_SCHEMA_VERSION,
synced_at: deps.now().toISOString(),
synced_from: deps.giteaUrl,
machine: deps.machine,
projects,
errors,
archived_skipped: archivedCount || undefined,
};
}

View File

@@ -0,0 +1,45 @@
import type { WikiPageType } from './wiki-writer.js';
export interface WikiIndexEntry {
slug: string;
type: WikiPageType;
title: string;
}
const SECTION_TYPES: Record<string, WikiPageType> = {
Entities: 'entities',
Concepts: 'concepts',
Packages: 'packages',
Sources: 'sources',
Raw: 'raw',
};
const ENTRY_RE = /^- \[(?<label>[^\]]+)\]\((?<href>[^)]+)\)\s*—\s*(?<title>.+?)\s*$/;
export function parseWikiIndex(md: string): WikiIndexEntry[] {
if (!md || typeof md !== 'string') return [];
const lines = md.split(/\r?\n/);
const out: WikiIndexEntry[] = [];
let currentType: WikiPageType | null = null;
for (const line of lines) {
const headMatch = /^##\s+(.+?)\s*$/.exec(line);
if (headMatch) {
currentType = SECTION_TYPES[headMatch[1]] ?? null;
continue;
}
if (!currentType) continue;
const entryMatch = ENTRY_RE.exec(line);
if (!entryMatch?.groups) continue;
const href = entryMatch.groups.href;
const title = entryMatch.groups.title;
const expectedPrefix = `${currentType}/`;
if (!href.startsWith(expectedPrefix) || !href.endsWith('.md')) continue;
const slugPart = href.slice(0, -'.md'.length);
out.push({ slug: slugPart, type: currentType, title });
}
return out;
}

View File

@@ -0,0 +1,67 @@
import { readdir, readFile, stat } from 'node:fs/promises';
import { join } from 'node:path';
import matter from 'gray-matter';
export interface WikiPage {
slug: string;
title: string;
domain: string;
tags: string[];
body: string;
raw: string;
}
const META_TOP = new Set(['CLAUDE.md', 'README.md', 'index.md', 'log.md', 'overview.md']);
async function walk(root: string, prefix = ''): Promise<string[]> {
const out: string[] = [];
let entries;
try {
entries = await readdir(root, { withFileTypes: true });
} catch {
return [];
}
for (const e of entries) {
const rel = prefix ? `${prefix}/${e.name}` : e.name;
if (e.isDirectory()) {
if (e.name.startsWith('.')) continue;
const nested = await walk(join(root, e.name), rel);
out.push(...nested);
} else if (e.isFile() && e.name.endsWith('.md')) {
if (prefix === '' && META_TOP.has(e.name)) continue;
out.push(rel);
}
}
return out;
}
export async function loadWiki(root: string): Promise<WikiPage[]> {
try {
const s = await stat(root);
if (!s.isDirectory()) return [];
} catch {
return [];
}
const relPaths = await walk(root);
const pages: WikiPage[] = [];
for (const rel of relPaths) {
const abs = join(root, ...rel.split('/'));
const raw = await readFile(abs, 'utf8');
const slug = rel.replace(/\.md$/, '');
const parsed = matter(raw);
const fm = parsed.data as Record<string, unknown>;
pages.push({
slug,
title: typeof fm.title === 'string' ? fm.title : slug,
domain: typeof fm.domain === 'string' ? fm.domain : 'unknown',
tags: Array.isArray(fm.tags) ? (fm.tags as unknown[]).filter((t): t is string => typeof t === 'string') : [],
body: parsed.content,
raw,
});
}
return pages;
}

View File

@@ -0,0 +1,191 @@
/**
* Round-trip helpers for ingesting wiki pages and updating index.md / log.md
* across project repos via the Backend.commitFile API.
*
* The canonical Karpathy LLM Wiki layout:
*
* .wiki/
* CLAUDE.md ← schema (project conventions)
* index.md ← catalog of pages, organized by type
* log.md ← append-only op log
* overview.md
* raw/ ← immutable inputs
* entities/ ← entity pages
* concepts/ ← concepts / decisions
* packages/ ← package pages
* sources/ ← summaries of ingested external sources
*
* The page-types enum mirrors `using-wiki` skill canon. `raw/` is writeable
* here only via the ingest pipeline — agents must NOT edit raw afterwards.
*/
import matter from 'gray-matter';
export type WikiPageType = 'entities' | 'concepts' | 'packages' | 'sources' | 'raw';
export const WIKI_PAGE_TYPES: WikiPageType[] = ['entities', 'concepts', 'packages', 'sources', 'raw'];
/** Title-case section name used in `index.md`. */
const TYPE_SECTION_TITLE: Record<WikiPageType, string> = {
entities: 'Entities',
concepts: 'Concepts',
packages: 'Packages',
sources: 'Sources',
raw: 'Raw',
};
export interface PageIngestArgs {
type: WikiPageType;
slug: string;
/** Page body (markdown) without frontmatter. Frontmatter is composed separately. */
body: string;
/** User-supplied frontmatter fields. Auto fields (`ingested_at`/`ingested_by`/`source_project`) are merged on top. */
frontmatter?: Record<string, unknown>;
/** Auto-frontmatter identity. */
ingestedBy?: string;
ingestedAtIso?: string;
sourceProject?: string;
}
/**
* Build the full markdown for the new page, including `---` frontmatter.
* Frontmatter is built by merging user-supplied fields with auto identity
* fields; `title` defaults to the slug if missing.
*/
export function formatPage(args: PageIngestArgs): string {
const fm: Record<string, unknown> = { ...(args.frontmatter ?? {}) };
if (typeof fm.title !== 'string') fm.title = args.slug;
if (typeof fm.type !== 'string') fm.type = args.type === 'raw' ? 'source' : args.type.replace(/s$/, '');
if (args.ingestedAtIso && !fm.ingested_at) fm.ingested_at = args.ingestedAtIso;
if (args.ingestedBy && !fm.ingested_by) fm.ingested_by = args.ingestedBy;
if (args.sourceProject && !fm.source_project) fm.source_project = args.sourceProject;
const trimmed = args.body.replace(/\s+$/, '');
// gray-matter.stringify turns body+data into `---\nyaml\n---\nbody\n`
return matter.stringify(`${trimmed}\n`, fm);
}
/**
* Path within the target repo: `.wiki/<type>/<slug>.md` for project wikis,
* `<type>/<slug>.md` for the shared wiki (projects-wiki).
*
* Layout asymmetry: project repos (e.g. `OpeItcLoc03/yt-tools`) nest wiki
* content under `.wiki/` so it co-exists with the project's code. The shared
* wiki repo (`projects-wiki`) is dedicated to wiki content and stores pages
* at repo root, no `.wiki/` prefix. `resolveTarget` exposes this via the
* `isAgenda: boolean` flag (true == shared wiki target, false == project wiki).
*/
export function pagePath(type: WikiPageType, slug: string, isAgenda = false): string {
const prefix = isAgenda ? '' : '.wiki/';
return `${prefix}${type}/${slug}.md`;
}
/** `.wiki/index.md` for project wikis, `index.md` for the shared wiki. */
export function indexPath(isAgenda = false): string {
return isAgenda ? 'index.md' : '.wiki/index.md';
}
/** `.wiki/log.md` for project wikis, `log.md` for the shared wiki. */
export function logPath(isAgenda = false): string {
return isAgenda ? 'log.md' : '.wiki/log.md';
}
/** `.wiki/raw/<slug>.md` for project wikis, `raw/<slug>.md` for the shared wiki. */
export function rawPath(slug: string, isAgenda = false): string {
const prefix = isAgenda ? '' : '.wiki/';
return `${prefix}raw/${slug}.md`;
}
/**
* Insert a one-line entry into the `index.md` Type section, preserving
* existing entries. If the section is absent, a new section is appended.
*
* The placeholder comment `<!-- (none yet) -->` is removed when first real
* entry lands. Entries are kept in insertion order — alphabetic resort is a
* future feature; out of MVP scope.
*/
export function insertIndexEntry(
indexMd: string,
type: WikiPageType,
slug: string,
title: string,
): string {
const sectionTitle = TYPE_SECTION_TITLE[type];
const sectionHead = `## ${sectionTitle}`;
const entry = `- [${slug}](${type}/${slug}.md) — ${title}`;
// Find section by exact heading
const headIdx = indexMd.indexOf(`\n${sectionHead}\n`);
if (headIdx < 0) {
// Section missing — append it at the end
const trimmed = indexMd.replace(/\s+$/, '');
return `${trimmed}\n\n${sectionHead}\n\n${entry}\n`;
}
// Find next `## ` (or EOF) — that's the end of our section
const sectionStart = headIdx + 1;
const afterHead = sectionStart + sectionHead.length;
const nextHead = indexMd.indexOf('\n## ', afterHead);
const sectionEnd = nextHead < 0 ? indexMd.length : nextHead;
let sectionBody = indexMd.slice(afterHead, sectionEnd);
// Drop placeholder `<!-- (none yet) -->`, plus trailing whitespace cleanup
sectionBody = sectionBody.replace(/\n\s*<!--\s*\(none yet\)\s*-->\s*\n/g, '\n');
// Skip if entry with this slug already exists
const slugLineRe = new RegExp(`^- \\[${slug.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\]`, 'm');
if (slugLineRe.test(sectionBody)) {
return indexMd; // no change
}
// Append entry to the section, with one blank line before EOF
const cleanedBody = sectionBody.replace(/\s+$/, '');
const newSectionBody = cleanedBody.length > 0 ? `${cleanedBody}\n${entry}\n` : `\n${entry}\n`;
return `${indexMd.slice(0, afterHead)}\n${newSectionBody}\n${indexMd.slice(sectionEnd).replace(/^\n+/, '')}`;
}
/**
* Append a `## [YYYY-MM-DD] ingest | <desc>` entry to `log.md`. If the file
* is empty/missing-ish, returns a fresh template + entry.
*/
export function appendLogEntry(logMd: string, dateYmd: string, op: string, desc: string): string {
const entry = `\n## [${dateYmd}] ${op} | ${desc}\n`;
if (logMd.trim().length === 0) {
return `# Wiki Log\n\nAppend-only operation log.\n${entry}`;
}
return `${logMd.replace(/\s+$/, '')}\n${entry}`;
}
/** Default content of an empty `index.md` to use when the target repo has none. */
export function freshIndexMd(): string {
return `# Wiki Index
Catalog of all wiki pages. One line per page, organized by type.
## Overview
- [overview.md](overview.md) — project overview
## Entities
<!-- (none yet) -->
## Concepts
<!-- (none yet) -->
## Packages
<!-- (none yet) -->
## Sources
<!-- (none yet) -->
`;
}
export function freshLogMd(): string {
return `# Wiki Log
Append-only operation log. One entry per operation.
`;
}

View File

@@ -0,0 +1,115 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { readFileSync } from 'node:fs';
import { homedir, hostname } from 'node:os';
import { getPaths, loadAuth } from './lib/config.js';
import { resolveSourceProject } from './lib/domain-detector.js';
import { makeGiteaBackend } from './lib/gitea.js';
import { makeTasksTools } from './tools/tasks.js';
import { makeKnowledgeTools } from './tools/knowledge.js';
import { makeMetaTools } from './tools/meta.js';
import { makeWorkersTools } from './tools/workers.js';
import { makeOpsTools } from './tools/ops.js';
function getCwdArg(): string {
const i = process.argv.indexOf('--cwd');
if (i >= 0 && process.argv[i + 1]) return process.argv[i + 1];
return process.cwd();
}
// Resolves to package.json regardless of run context: dist/server.js → ../package.json,
// or src/server.ts (tests / ts-node) → ../package.json. Keeps the MCP server-info
// version field in sync with package.json without a hardcoded literal.
function readPackageVersion(): string {
const pkgUrl = new URL('../package.json', import.meta.url);
const raw = readFileSync(pkgUrl, 'utf8');
const pkg = JSON.parse(raw) as { version?: unknown };
if (typeof pkg.version !== 'string' || pkg.version.length === 0) {
throw new Error(`package.json at ${pkgUrl.pathname} missing string "version" field`);
}
return pkg.version;
}
async function main(): Promise<void> {
const paths = getPaths(homedir());
const cwd = getCwdArg();
// Auth + backend are optional for read-only operation. Write tools surface
// a clear error if these aren't available.
const auth = await loadAuth(paths.authFile).catch(() => null);
const backend = auth ? makeGiteaBackend({ baseUrl: auth.giteaUrl, token: auth.giteaToken }) : undefined;
const { sourceProject, fellBack } = await resolveSourceProject(cwd);
if (fellBack) {
console.error(
`[projects-meta] warning: cwd=${cwd} has no parseable git remote — ` +
`identity-footer will use bare basename "${sourceProject}" instead of ` +
`qualified <owner>/<repo>. Tasks/wiki created from here will not record their source repo.`,
);
}
const tools = [
...makeTasksTools({
cacheFile: paths.cacheFile,
backend,
giteaUser: auth?.giteaUser,
agendaTasksRepo: auth?.agendaTasksRepo,
agendaTasksOwner: auth?.agendaTasksOwner,
aggregateSkipOwners: auth?.giteaAggregateSkipOwners,
machine: hostname(),
sourceProject,
}),
...makeKnowledgeTools({
wikiRoot: paths.sharedWikiClone,
projectCwd: cwd,
cacheFile: paths.cacheFile,
backend,
giteaUser: auth?.giteaUser,
projectsWikiRepo: auth?.projectsWikiRepo,
machine: hostname(),
sourceProject,
}),
...makeMetaTools({ paths }),
...makeWorkersTools(),
...makeOpsTools({ cacheFile: paths.cacheFile }),
];
const byName = new Map<string, (typeof tools)[number]>();
for (const t of tools) {
if (byName.has(t.name)) throw new Error(`Duplicate tool name: ${t.name}`);
byName.set(t.name, t);
}
const server = new Server(
{ name: 'projects-meta-mcp', version: readPackageVersion() },
{ capabilities: { tools: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: tools.map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
})),
}));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const tool = byName.get(req.params.name);
if (!tool) {
return {
content: [{ type: 'text', text: `Unknown tool: ${req.params.name}` }],
isError: true,
};
}
return tool.handler(req.params.arguments ?? {});
});
const transport = new StdioServerTransport();
await server.connect(transport);
}
await main();

View File

@@ -0,0 +1,95 @@
import { homedir, hostname } from 'node:os';
import { appendFile, mkdir, readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { detectCacheInvalidation, writeCache } from './lib/cache.js';
import { getPaths, loadAuth } from './lib/config.js';
import { makeGiteaBackend } from './lib/gitea.js';
import { parseStatusMd } from './lib/status-md.js';
import { runSync } from './lib/sync-runner.js';
import { runMetaPull, runMetaPush } from './lib/meta-cli.js';
import { resolveMetaHost } from './lib/meta-host-resolver.js';
async function main(): Promise<void> {
const paths = getPaths(homedir());
const auth = await loadAuth(paths.authFile).catch((err) => {
console.error(`auth.toml load failed: ${(err as Error).message}`);
console.error(`Expected at: ${paths.authFile}`);
process.exit(2);
});
const client = makeGiteaBackend({ baseUrl: auth.giteaUrl, token: auth.giteaToken });
const agendaStatusFile = join(paths.agendaTasksDir, 'STATUS.md');
const invalidation = await detectCacheInvalidation(paths.cacheFile);
let cache;
try {
cache = await runSync({
client,
parseStatus: parseStatusMd,
now: () => new Date(),
machine: hostname(),
giteaUrl: auth.giteaUrl,
owners: auth.giteaOwners,
aggregateSkipOwners: auth.giteaAggregateSkipOwners,
agendaTasksRepo: auth.agendaTasksRepo,
agendaTasksOwner: auth.agendaTasksOwner,
readAgendaTasksLocal: async () => {
try {
return await readFile(agendaStatusFile, 'utf8');
} catch (e) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw e;
}
},
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`sync failed: ${msg}`);
if (/\b(401|403)\b/.test(msg)) {
console.error('');
console.error('Auth failed. Check the token in:');
console.error(` ${paths.authFile}`);
console.error('');
console.error('Verify the token directly with curl (replace <token>):');
console.error(` curl -sS -H "Authorization: token <token>" "${auth.giteaUrl}/api/v1/users/${auth.giteaUser}/repos?limit=1"`);
console.error('');
console.error('');
console.error(`Generate a new token at: ${auth.giteaUrl}/user/settings/applications`);
console.error('Required scope: read:repository');
}
process.exit(3);
}
await writeCache(paths.cacheFile, cache);
await mkdir(dirname(paths.syncLog), { recursive: true });
if (invalidation.invalidated) {
const v = invalidation.oldVersion === null ? 'none' : String(invalidation.oldVersion);
await appendFile(paths.syncLog, `[${cache.synced_at}] cache_schema_invalidated old_version=${v}\n`);
}
const line =
`[${cache.synced_at}] projects=${cache.projects.length} errors=${cache.errors.length}` +
(cache.archived_skipped ? ` archived_skipped=${cache.archived_skipped}` : '') +
(cache.errors.length ? ` (${cache.errors.map((e) => e.project).join(',')})` : '') +
'\n';
await appendFile(paths.syncLog, line);
console.log(`synced ${cache.projects.length} projects, ${cache.errors.length} errors` +
(cache.archived_skipped ? ` (${cache.archived_skipped} archived skipped)` : ''));
}
// Subcommand router. The hook-driven meta-pull / meta-push run the git-backed
// meta sync for one folder; with no subcommand we fall back to the cross-project
// cache sync (`main`).
const [cmd, dirArg] = process.argv.slice(2);
if (cmd === 'meta-pull' || cmd === 'meta-push') {
if (!dirArg) {
console.error(`usage: projects-meta-sync ${cmd} <dir>`);
process.exit(2);
}
const deps = { resolveHost: resolveMetaHost };
const code = cmd === 'meta-pull' ? await runMetaPull(dirArg, deps) : await runMetaPush(dirArg, deps);
process.exit(code);
} else {
await main();
}

View File

@@ -0,0 +1,263 @@
/**
* Thin CLI surface over the `tasks.*` MCP mutations, for non-MCP callers — the
* cross-project agent poller (`agents-task-runner`) shells out to this instead
* of speaking the MCP protocol (agent-orchestration variant C). It reuses the
* exact `makeTasksTools` handler closures, so claim CAS-retry, anti-self-review,
* `policy.toml` overlay and lazy-revoke are inherited verbatim — zero logic dup.
*
* `runTasksCli` is the testable core (tools injected); `main` builds the real
* tools the same way `server.ts` does and wires stdin/argv → stdout/exit.
*/
import { homedir, hostname } from 'node:os';
import { pathToFileURL } from 'node:url';
import { getPaths, loadAuth } from './lib/config.js';
import { makeGiteaBackend } from './lib/gitea.js';
import { resolveSourceProject } from './lib/domain-detector.js';
import { makeTasksTools } from './tools/tasks.js';
import type { ToolDef, ToolResult } from './tools/types.js';
export interface TasksCliDeps {
/** The `tasks.*` ToolDefs (production: from `makeTasksTools`; tests: fakes). */
tools: ToolDef[];
/** stdout sink — receives the handler's JSON. Defaults to `console.log`. */
log?: (msg: string) => void;
/** stderr sink — usage / routing errors. Defaults to `console.error`. */
errLog?: (msg: string) => void;
}
/** CLI command → MCP tool name. */
const COMMAND_TOOL: Record<string, string> = {
'claim-next': 'tasks.claim_next',
heartbeat: 'tasks.heartbeat',
close: 'tasks.close',
update: 'tasks.update',
'append-decision-trail': 'tasks.append_decision_trail',
'park-question': 'tasks.park_question',
'get-status': 'tasks.get_status',
'list-blocked': 'tasks.list_blocked',
};
interface ParsedFlags {
[key: string]: string | boolean;
}
/**
* Parse `--flag=value` / `--flag value` / `--flag` (boolean) tokens. Bare values
* are ignored. The `--flag=value` form is required for values that begin with `--`
* (e.g. a blocked-path `where_stopped` carrying an agent's `--`-prefixed error) —
* in the space-separated form such a value would be mistaken for the next flag.
*/
function parseFlags(argv: string[]): ParsedFlags {
const flags: ParsedFlags = {};
for (let i = 0; i < argv.length; i++) {
const tok = argv[i];
if (!tok.startsWith('--')) continue;
const body = tok.slice(2);
const eq = body.indexOf('=');
if (eq !== -1) {
flags[body.slice(0, eq)] = body.slice(eq + 1);
continue;
}
const key = body;
const next = argv[i + 1];
if (next === undefined || next.startsWith('--')) {
flags[key] = true;
} else {
flags[key] = next;
i++;
}
}
return flags;
}
function str(v: string | boolean | undefined): string | undefined {
return typeof v === 'string' ? v : undefined;
}
/** Build the tool input object for a given command from parsed flags. */
function buildInput(command: string, flags: ParsedFlags): Record<string, unknown> {
if (command === 'claim-next') {
const input: Record<string, unknown> = { claimer_identity: str(flags['claimer-identity']) };
if (flags.confirm === true) input.confirm = true;
const filter: Record<string, unknown> = {};
const runtime = str(flags.runtime);
if (runtime) filter.runtime = runtime;
const requirements = str(flags.requirements);
if (requirements) filter.requirements = requirements.split(',').map((s) => s.trim()).filter(Boolean);
const project = str(flags.project);
if (project) filter.project = project;
if (Object.keys(filter).length) input.filter = filter;
// Rollout scope guard (multi-project, selection-time) — distinct from --project.
const projects = str(flags.projects);
if (projects) {
const list = projects.split(',').map((s) => s.trim()).filter(Boolean);
if (list.length) input.project_allowlist = list;
}
return input;
}
if (command === 'heartbeat') {
const input: Record<string, unknown> = {
slug: str(flags.slug),
claim_token: str(flags['claim-token']),
};
const target = str(flags['target-project']);
if (target) input.target_project = target;
return input;
}
if (command === 'close') {
const input: Record<string, unknown> = {
target_project: str(flags['target-project']),
slug: str(flags.slug),
};
const note = str(flags.note);
if (note) input.note = note;
if (flags.confirm === true) input.confirm = true;
return input;
}
if (command === 'update') {
const input: Record<string, unknown> = {
target_project: str(flags['target-project']),
slug: str(flags.slug),
};
const optional: Array<[string, string]> = [
['status', 'status'],
['where-stopped', 'where_stopped'],
['next-action', 'next_action'],
['branch', 'branch'],
['description', 'description'],
['weight', 'weight'],
['notify', 'notify'],
];
for (const [flag, key] of optional) {
const v = str(flags[flag]);
if (v) input[key] = v;
}
// `blocker` is special: an EXPLICIT empty `--blocker=` means "clear the blocker
// line" (updateTaskFields removes it on blocker === ''). The truthy-only guard
// above would silently drop that, so route blocker through its own check that
// distinguishes "flag present with empty value" from "flag absent". The ops
// watchdog relies on this to reset a transient-blocked task: `--status=ready
// --blocker=` flips the status AND strips the stale blocker in one write.
const blocker = str(flags.blocker);
if (blocker !== undefined) input.blocker = blocker;
if (flags.confirm === true) input.confirm = true;
return input;
}
if (command === 'append-decision-trail') {
const input: Record<string, unknown> = {
target_project: str(flags['target-project']),
slug: str(flags.slug),
question: str(flags.question),
decided_by: str(flags['decided-by']),
};
const blastRadius = str(flags['blast-radius']);
if (blastRadius) input.blast_radius = blastRadius;
const ruling = str(flags.ruling);
if (ruling) input.ruling = ruling;
const rationale = str(flags.rationale);
if (rationale) input.rationale = rationale;
const chain = str(flags['escalation-chain']);
if (chain) input.escalation_chain = chain.split(',').map((s) => s.trim()).filter(Boolean);
return input;
}
if (command === 'park-question') {
const input: Record<string, unknown> = {
target_project: str(flags['target-project']),
slug: str(flags.slug),
question: str(flags.question),
};
const token = str(flags['claim-token']);
if (token) input.claim_token = token;
return input;
}
if (command === 'get-status') {
return {
target_project: str(flags['target-project']),
slug: str(flags.slug),
};
}
if (command === 'list-blocked') {
const input: Record<string, unknown> = {};
const projects = str(flags.projects);
if (projects) {
const list = projects.split(',').map((s) => s.trim()).filter(Boolean);
if (list.length) input.project_allowlist = list;
}
return input;
}
return {};
}
/**
* Run one CLI invocation. `argv` is the args after the binary name, starting
* with the command (`claim-next` / `heartbeat` / `close`). Returns the process
* exit code: 0 on a handler result, 1 on a handler error, 2 on a usage error.
*/
export async function runTasksCli(argv: string[], deps: TasksCliDeps): Promise<number> {
const log = deps.log ?? ((m: string) => console.log(m));
const errLog = deps.errLog ?? ((m: string) => console.error(m));
const command = argv[0];
const toolName = command ? COMMAND_TOOL[command] : undefined;
if (!toolName) {
errLog(
`usage: projects-meta-tasks <claim-next|heartbeat|close|update|append-decision-trail|park-question|get-status|list-blocked> [--flags]`,
);
if (command) errLog(`unknown command: ${command}`);
return 2;
}
const tool = deps.tools.find((t) => t.name === toolName);
if (!tool) {
errLog(`internal: tool ${toolName} not registered`);
return 2;
}
const input = buildInput(command, parseFlags(argv.slice(1)));
// A handler can throw (e.g. a zod `.parse` on a missing required flag). The poller
// shells out and parses our stdout as JSON, so emit a structured error and a
// non-zero exit rather than letting the throw escape as an unhandled rejection.
let result: ToolResult;
try {
result = await tool.handler(input);
} catch (err) {
log(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
return 1;
}
log(result.content.map((c) => c.text).join('\n'));
return result.isError ? 1 : 0;
}
/**
* Bin entry. Builds the real `tasks.*` tools exactly as `server.ts` does
* (auth + Gitea backend optional; write tools self-report when absent), then
* routes one argv invocation. Identity-footer source is the cwd's project.
*/
async function main(): Promise<void> {
const paths = getPaths(homedir());
const auth = await loadAuth(paths.authFile).catch(() => null);
const backend = auth ? makeGiteaBackend({ baseUrl: auth.giteaUrl, token: auth.giteaToken }) : undefined;
const { sourceProject } = await resolveSourceProject(process.cwd());
const tools = makeTasksTools({
cacheFile: paths.cacheFile,
backend,
giteaUser: auth?.giteaUser,
agendaTasksRepo: auth?.agendaTasksRepo,
agendaTasksOwner: auth?.agendaTasksOwner,
aggregateSkipOwners: auth?.giteaAggregateSkipOwners,
machine: hostname(),
sourceProject,
});
process.exitCode = await runTasksCli(process.argv.slice(2), { tools });
}
// Run only when executed directly (the `projects-meta-tasks` bin), never on
// import — tests import `runTasksCli` and must not trigger real I/O.
const invokedDirectly =
!!process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (invokedDirectly) {
void main();
}

View File

@@ -0,0 +1,691 @@
import { z } from 'zod';
import { join } from 'node:path';
import type { Backend } from '../lib/backend.js';
import { readCache } from '../lib/cache.js';
import { detectDomain, type Domain } from '../lib/domain-detector.js';
import { pullLocalClone } from '../lib/git.js';
import { parseTargetProject, resolveTarget } from '../lib/resolve-target.js';
import { findCandidates } from '../lib/promotion.js';
import { loadWiki, type WikiPage } from '../lib/wiki-index.js';
import { parseWikiIndex, type WikiIndexEntry } from '../lib/wiki-index-parser.js';
import {
appendLogEntry,
formatPage,
freshIndexMd,
freshLogMd,
indexPath,
insertIndexEntry,
logPath,
pagePath,
rawPath,
WIKI_PAGE_TYPES,
type WikiPageType,
} from '../lib/wiki-writer.js';
import type { ToolDef, ToolResult } from './types.js';
interface Opts {
wikiRoot: string;
projectCwd: string;
/** Cache file path used by ingest/promote to resolve target_project → repo+branch. */
cacheFile?: string;
/** Optional: when provided, ingest/promote tools become functional. */
backend?: Backend;
giteaUser?: string;
/**
* Repo holding the cross-project shared wiki. Default name = `projects-wiki`,
* but for now we don't read from it (out of scope of MVP-4). Used as the
* destination for the `agenda` target route.
*/
projectsWikiRepo?: string;
machine?: string;
sourceProject?: string;
now?: () => Date;
}
const SearchInput = z.object({
query: z.string(),
domain: z.string().optional(),
limit: z.number().int().positive().max(50).optional(),
});
const GetInput = z.object({
slug: z.string().min(1),
});
const IngestInput = z.object({
target_project: z.string().min(1),
type: z.enum(['entities', 'concepts', 'packages', 'sources', 'raw']),
slug: z
.string()
.min(1)
.regex(/^[a-z0-9][a-z0-9/-]*$/u, 'slug must be kebab-case latin (slashes allowed for nesting)'),
body: z.string(),
frontmatter: z.record(z.unknown()).optional(),
source_project: z.string().optional(),
confirm: z.boolean().optional(),
});
const AskProjectsInput = z.object({
query: z.string(),
projects: z.array(z.string().min(1)).min(1),
limit: z.number().int().positive().max(20).optional(),
types: z
.array(z.enum(['entities', 'concepts', 'packages', 'sources', 'raw']))
.optional(),
});
const GetFromInput = z.object({
project: z.string().min(1),
slug: z.string().min(1),
});
const DEFAULT_ASK_TYPES: WikiPageType[] = ['entities', 'concepts', 'packages', 'sources'];
function indexEntryMatches(entry: WikiIndexEntry, query: string): boolean {
if (!query) return true;
const q = query.toLowerCase();
return entry.slug.toLowerCase().includes(q) || entry.title.toLowerCase().includes(q);
}
const PromoteInput = z.object({
target_project: z.string().min(1),
slug: z.string().min(1),
body: z.string().min(1),
frontmatter: z.record(z.unknown()).optional(),
source_project: z.string().optional(),
confirm: z.boolean().optional(),
});
function asJson(value: unknown): ToolResult {
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
}
function asError(msg: string): ToolResult {
return { content: [{ type: 'text', text: msg }], isError: true };
}
function snippet(body: string, query: string, span = 80): string {
const trimmed = body.replace(/\s+/g, ' ').trim();
if (!query) return trimmed.slice(0, span);
const idx = trimmed.toLowerCase().indexOf(query.toLowerCase());
if (idx < 0) return trimmed.slice(0, span);
const start = Math.max(0, idx - 20);
return trimmed.slice(start, start + span);
}
function pageMatches(p: WikiPage, query: string): boolean {
if (!query) return true;
const q = query.toLowerCase();
return (
p.title.toLowerCase().includes(q) ||
p.body.toLowerCase().includes(q) ||
p.tags.some((t) => t.toLowerCase().includes(q))
);
}
function domainAllows(pageDomain: string, filter: string): boolean {
if (filter === 'all') return true;
return pageDomain === filter || pageDomain === 'cross';
}
function identityString(user: string | undefined, machine: string | undefined): string | undefined {
if (!user && !machine) return undefined;
return `${user ?? '?'}@${machine ?? '?'}`;
}
export function makeKnowledgeTools(opts: Opts): ToolDef[] {
let cached: WikiPage[] | null = null;
async function pages(): Promise<WikiPage[]> {
if (cached) return cached;
cached = await loadWiki(opts.wikiRoot);
return cached;
}
let detectedDomain: Domain | null = null;
async function domain(): Promise<Domain> {
if (detectedDomain) return detectedDomain;
detectedDomain = await detectDomain(opts.projectCwd);
return detectedDomain;
}
const writeReady = !!opts.backend && !!opts.giteaUser && !!opts.projectsWikiRepo && !!opts.cacheFile;
const now = opts.now ?? (() => new Date());
return [
{
name: 'knowledge.search',
description:
'Поиск по shared-вики (репо projects-wiki). По умолчанию фильтр по domain соответствует cwd текущего проекта (node/embedded/web). Передай domain="all" чтобы снять фильтр или явное имя домена. limit по умолчанию 10. Возвращает только заголовок + сниппет; полный текст — knowledge.get.',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string' },
domain: { type: 'string' },
limit: { type: 'integer', minimum: 1, maximum: 50 },
},
required: ['query'],
additionalProperties: false,
},
async handler(args) {
const input = SearchInput.parse(args);
const all = await pages();
const det = await domain();
const filter = input.domain ?? (det === 'unknown' ? 'all' : det);
const limit = input.limit ?? 10;
const matched = all
.filter((p) => domainAllows(p.domain, filter))
.filter((p) => pageMatches(p, input.query))
.slice(0, limit)
.map((p) => ({
slug: p.slug,
title: p.title,
domain: p.domain,
snippet: snippet(p.body, input.query),
}));
return asJson({
detected_domain: det,
applied_filter: filter,
results: matched,
});
},
},
{
name: 'knowledge.get',
description: 'Полный текст одной страницы projects-wiki по её slug (например "node/windows-yarn-exec").',
inputSchema: {
type: 'object',
properties: { slug: { type: 'string' } },
required: ['slug'],
additionalProperties: false,
},
async handler(args) {
const input = GetInput.parse(args);
const all = await pages();
const page = all.find((p) => p.slug === input.slug);
if (!page) return asError(`page not found: ${input.slug}`);
return { content: [{ type: 'text', text: page.raw }] };
},
},
{
name: 'knowledge.suggest_promote',
description:
'Возвращает кандидатов из текущего .wiki/concepts/ на промоушен в projects-wiki. Фильтр по платформенным ключам, исключение по чёрному списку проектов. Решение всегда у пользователя — этот tool не пишет файлы.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
async handler() {
const det = await domain();
const shared = await pages();
const candidates = await findCandidates({
projectWikiDir: join(opts.projectCwd, '.wiki'),
sharedPages: shared,
cwdDomain: det,
blacklist: [], // TODO: load from .wiki config (v2)
});
return asJson({ candidates });
},
},
{
name: 'knowledge.ask_projects',
description:
'Опросить указанные другие проекты (их .wiki/) на предмет знания, если в shared (knowledge.search) ничего не нашлось. ' +
'Передай `projects: ["victor/books","OpeItcLoc03/common"]` — qualified `<owner>/<repo>`. По умолчанию ищет в типах [entities, concepts, packages, sources] (raw исключён). ' +
'Возвращает per-project список матчей со снипетами. ' +
'Если знания нет ни у кого — поставь задачу через `tasks.create({target_project, body: "оформи знание о X в .wiki/concepts/x.md"})` (тоже доступно).',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string' },
projects: { type: 'array', items: { type: 'string' }, minItems: 1 },
limit: { type: 'integer', minimum: 1, maximum: 20 },
types: {
type: 'array',
items: { type: 'string', enum: ['entities', 'concepts', 'packages', 'sources', 'raw'] },
},
},
required: ['query', 'projects'],
additionalProperties: false,
},
async handler(args) {
if (!opts.backend || !opts.giteaUser || !opts.cacheFile) {
return asError(
'knowledge.ask_projects disabled — projects-meta-mcp was started without auth.toml. ' +
'Configure ~/.config/projects-mcp/auth.toml and restart Claude Code.',
);
}
const input = AskProjectsInput.parse(args);
const limit = input.limit ?? 5;
const allowedTypes = new Set<WikiPageType>(input.types ?? DEFAULT_ASK_TYPES);
const cache = await readCache(opts.cacheFile);
const projectsMap = new Map((cache?.projects ?? []).map((p) => [p.name, p] as const));
const next_action_hint =
'knowledge.get_from(project, slug) для полного текста; ' +
'tasks.create(target_project, body) если знание не оформлено';
const results = await Promise.all(
input.projects.map(async (projectName) => {
const parsed = parseTargetProject(projectName);
if ('error' in parsed) {
return { project: projectName, error: parsed.error };
}
if (parsed.agenda) {
return {
project: projectName,
error:
'agenda not supported here — agenda is the cross-project board, not a wiki source. Use knowledge.search for the shared wiki.',
};
}
const proj = projectsMap.get(projectName);
if (!proj) {
return { project: projectName, error: 'unknown project — run sync first' };
}
if (!proj.wiki_index) {
return { project: projectName, error: 'no wiki — repo has no .wiki/index.md' };
}
const entries = parseWikiIndex(proj.wiki_index)
.filter((e) => allowedTypes.has(e.type))
.filter((e) => indexEntryMatches(e, input.query))
.slice(0, limit);
const matches = await Promise.all(
entries.map(async (e) => {
const path = `.wiki/${e.slug}.md`;
let snip = '';
try {
const body = await opts.backend!.getRawFile(
parsed.owner,
parsed.repo,
path,
proj.default_branch,
);
if (body === null) {
snip = '(page missing in repo)';
} else {
snip = snippet(body, input.query);
}
} catch {
snip = '(fetch failed)';
}
return { slug: e.slug, type: e.type, title: e.title, snippet: snip };
}),
);
return { project: projectName, matches };
}),
);
return asJson({
asked_projects: input.projects,
results,
next_action_hint,
});
},
},
{
name: 'knowledge.get_from',
description:
'Полный текст одной страницы из конкретного проекта — `<project>/.wiki/<slug>.md`. ' +
'`project` — qualified `<owner>/<repo>` (например `victor/books`). slug включает type-префикс (например "concepts/foo"). `agenda` — алиас для shared-wiki репо (projects-wiki). ' +
'Используй после `knowledge.ask_projects` чтобы дочитать матч.',
inputSchema: {
type: 'object',
properties: {
project: { type: 'string', description: 'qualified <owner>/<repo>, or "agenda"' },
slug: { type: 'string' },
},
required: ['project', 'slug'],
additionalProperties: false,
},
async handler(args) {
if (!opts.backend || !opts.giteaUser || !opts.cacheFile) {
return asError(
'knowledge.get_from disabled — projects-meta-mcp was started without auth.toml.',
);
}
const input = GetFromInput.parse(args);
const wikiRepo = opts.projectsWikiRepo ?? 'projects-wiki';
const target = await resolveTarget(
opts.cacheFile,
input.project,
wikiRepo,
opts.giteaUser,
);
if ('error' in target) return asError(target.error);
// Shared wiki (target.isAgenda) stores content at repo root; project
// wikis nest it under .wiki/. Slug already includes the type prefix
// (e.g. "concepts/foo"), so we only need to prefix with .wiki/ for
// project wikis.
const path = target.isAgenda ? `${input.slug}.md` : `.wiki/${input.slug}.md`;
try {
const body = await opts.backend.getRawFile(
target.owner,
target.repo,
path,
target.branch,
);
if (body === null) {
return asError(`page not found: ${input.project}/${path}`);
}
return { content: [{ type: 'text', text: body }] };
} catch (err) {
return asError(`fetch failed: ${err instanceof Error ? err.message : String(err)}`);
}
},
},
{
name: 'knowledge.ingest',
description:
'[MUTATION] Создаёт новую вики-страницу в `<target>/.wiki/<type>/<slug>.md` через Gitea commit. Также апдейтит `index.md` (catalog) и `log.md` (op-log) тремя последовательными коммитами. ' +
'type ∈ {entities, concepts, packages, sources, raw}. target_project — qualified `<owner>/<repo>` (например `victor/books`), либо литерал `agenda` для shared-wiki репо (projects-wiki). Bare-имя отвергается с подсказкой. ' +
'Без `confirm: true` — dry-run preview. Со `confirm: true` — реальный commit.',
inputSchema: {
type: 'object',
properties: {
target_project: { type: 'string', description: 'qualified <owner>/<repo>, or "agenda" for the shared wiki repo (projects-wiki)' },
type: { type: 'string', enum: ['entities', 'concepts', 'packages', 'sources', 'raw'] },
slug: { type: 'string', description: 'kebab-case; allow nested via slashes' },
body: { type: 'string', description: 'page body (markdown), frontmatter is composed separately' },
frontmatter: { type: 'object', description: 'optional user-supplied frontmatter; auto fields are merged on top' },
source_project: { type: 'string', description: 'override auto-detected source' },
confirm: { type: 'boolean' },
},
required: ['target_project', 'type', 'slug', 'body'],
additionalProperties: false,
},
async handler(args) {
if (!writeReady || !opts.backend || !opts.giteaUser || !opts.projectsWikiRepo || !opts.cacheFile) {
return asError(
'Write tools disabled — projects-meta-mcp was started without auth.toml. ' +
'Configure ~/.config/projects-mcp/auth.toml and restart Claude Code.',
);
}
const input = IngestInput.parse(args);
if (!WIKI_PAGE_TYPES.includes(input.type as WikiPageType)) {
return asError(`type must be one of: ${WIKI_PAGE_TYPES.join(', ')}`);
}
const target = await resolveTarget(
opts.cacheFile,
input.target_project,
opts.projectsWikiRepo,
opts.giteaUser,
);
if ('error' in target) return asError(target.error);
const ts = now();
const isoNow = ts.toISOString();
const ymd = isoNow.slice(0, 10);
const pageContent = formatPage({
type: input.type as WikiPageType,
slug: input.slug,
body: input.body,
frontmatter: input.frontmatter,
ingestedBy: identityString(opts.giteaUser, opts.machine),
ingestedAtIso: isoNow,
sourceProject: input.source_project ?? opts.sourceProject,
});
const pagePathStr = pagePath(input.type as WikiPageType, input.slug, target.isAgenda);
const idxPath = indexPath(target.isAgenda);
const lgPath = logPath(target.isAgenda);
const pageCommitMsg = `meta(wiki): ingest ${input.type}/${input.slug} in ${input.target_project}`;
if (!input.confirm) {
return asJson({
preview: true,
target_owner: target.owner,
target_repo: target.repo,
target_branch: target.branch,
target_path: pagePathStr,
commit_message: pageCommitMsg,
content: pageContent,
also_updates: [idxPath, lgPath],
note: 'Re-call with `confirm: true` to commit. Three sequential commits will be made (page + index + log).',
});
}
// 1. Page itself
const existingPage = await opts.backend.getFileWithSha(target.owner, target.repo, pagePathStr, target.branch);
if (existingPage) {
return asError(
`page already exists: ${pagePathStr} in ${target.owner}/${target.repo}. Use a different slug or knowledge.promote for raw→sources flow.`,
);
}
const pageResult = await opts.backend.commitFile({
user: target.owner,
repo: target.repo,
path: pagePathStr,
branch: target.branch,
content: pageContent,
message: pageCommitMsg,
});
const commits: Array<{ path: string; commitSha: string; fileSha: string }> = [
{ path: pagePathStr, commitSha: pageResult.commitSha, fileSha: pageResult.fileSha },
];
const partial: Array<{ path: string; reason: string }> = [];
// 2. Update index.md
try {
const idx = await opts.backend.getFileWithSha(target.owner, target.repo, idxPath, target.branch);
const idxBefore = idx?.content ?? freshIndexMd();
const titleForIndex = (input.frontmatter?.title as string | undefined) ?? input.slug;
const idxAfter = insertIndexEntry(idxBefore, input.type as WikiPageType, input.slug, titleForIndex);
if (idxAfter !== idxBefore) {
const idxResult = await opts.backend.commitFile({
user: target.owner,
repo: target.repo,
path: idxPath,
branch: target.branch,
content: idxAfter,
message: `meta(wiki): index += ${input.type}/${input.slug}`,
sha: idx?.sha,
});
commits.push({ path: idxPath, commitSha: idxResult.commitSha, fileSha: idxResult.fileSha });
}
} catch (err) {
partial.push({ path: idxPath, reason: err instanceof Error ? err.message : String(err) });
}
// 3. Update log.md
try {
const logF = await opts.backend.getFileWithSha(target.owner, target.repo, lgPath, target.branch);
const logBefore = logF?.content ?? freshLogMd();
const logAfter = appendLogEntry(logBefore, ymd, 'ingest', `${input.type}/${input.slug}`);
const logResult = await opts.backend.commitFile({
user: target.owner,
repo: target.repo,
path: lgPath,
branch: target.branch,
content: logAfter,
message: `meta(wiki): log += ingest ${input.type}/${input.slug}`,
sha: logF?.sha,
});
commits.push({ path: lgPath, commitSha: logResult.commitSha, fileSha: logResult.fileSha });
} catch (err) {
partial.push({ path: lgPath, reason: err instanceof Error ? err.message : String(err) });
}
// Pull the local clone BEFORE invalidating the cache so the next
// search reloads a fresh clone. Pull-then-invalidate (not the reverse)
// closes a race where a concurrent search, arriving after cached=null
// but before the pull finishes, would reload and re-memoize the still
// stale clone.
const pullResult = await pullLocalClone(opts.wikiRoot);
const pullNote = pullResult.pulled
? undefined
: `local clone pull skipped: ${pullResult.error ?? 'unknown'}`;
cached = null; // invalidate search cache so the next search reloads fresh
return asJson({
committed: true,
target_owner: target.owner,
target_repo: target.repo,
target_branch: target.branch,
commits,
partial_failures: partial.length ? partial : undefined,
note: partial.length
? 'Page committed, but index/log updates failed — re-run knowledge.ingest or fix manually'
: pullNote,
});
},
},
{
name: 'knowledge.promote',
description:
'[MUTATION] Promote raw/<slug>.md to sources/<slug>.md — пишет sources-страницу с frontmatter указывающим на raw_path. target_project — qualified `<owner>/<repo>` либо `agenda`. ' +
'body — это LLM-summary (генерится агентом), MCP только перевозит байты. raw/<slug>.md должен уже существовать в target. ' +
'Тоже обновляет index.md и log.md. Без `confirm` — preview.',
inputSchema: {
type: 'object',
properties: {
target_project: { type: 'string', description: 'qualified <owner>/<repo>, or "agenda"' },
slug: { type: 'string', description: 'shared between raw/ and sources/' },
body: { type: 'string', description: 'sources-page body (LLM-generated summary)' },
frontmatter: { type: 'object' },
source_project: { type: 'string' },
confirm: { type: 'boolean' },
},
required: ['target_project', 'slug', 'body'],
additionalProperties: false,
},
async handler(args) {
if (!writeReady || !opts.backend || !opts.giteaUser || !opts.projectsWikiRepo || !opts.cacheFile) {
return asError(
'Write tools disabled — projects-meta-mcp was started without auth.toml.',
);
}
const input = PromoteInput.parse(args);
const target = await resolveTarget(
opts.cacheFile,
input.target_project,
opts.projectsWikiRepo,
opts.giteaUser,
);
if ('error' in target) return asError(target.error);
const rawRel = rawPath(input.slug, target.isAgenda);
const rawExists = await opts.backend.getRawFile(target.owner, target.repo, rawRel, target.branch);
if (rawExists === null) {
return asError(
`raw page not found: ${rawRel} in ${target.owner}/${target.repo}. Use knowledge.ingest with type="raw" first.`,
);
}
const ts = now();
const isoNow = ts.toISOString();
const ymd = isoNow.slice(0, 10);
// Auto-augment frontmatter with raw_path link
const fm: Record<string, unknown> = { ...(input.frontmatter ?? {}) };
if (!fm.raw_path) fm.raw_path = `raw/${input.slug}.md`;
const sourcesContent = formatPage({
type: 'sources',
slug: input.slug,
body: input.body,
frontmatter: fm,
ingestedBy: identityString(opts.giteaUser, opts.machine),
ingestedAtIso: isoNow,
sourceProject: input.source_project ?? opts.sourceProject,
});
const sourcesPath = pagePath('sources', input.slug, target.isAgenda);
const idxPath = indexPath(target.isAgenda);
const lgPath = logPath(target.isAgenda);
const commitMsg = `meta(wiki): promote raw/${input.slug} → sources/${input.slug} in ${input.target_project}`;
if (!input.confirm) {
return asJson({
preview: true,
target_owner: target.owner,
target_repo: target.repo,
target_branch: target.branch,
target_path: sourcesPath,
commit_message: commitMsg,
content: sourcesContent,
note: 'Re-call with `confirm: true` to commit.',
});
}
const existing = await opts.backend.getFileWithSha(target.owner, target.repo, sourcesPath, target.branch);
if (existing) {
return asError(
`sources/${input.slug}.md already exists in ${target.owner}/${target.repo}. Use knowledge.ingest with type="sources" if you want a different slug.`,
);
}
const result = await opts.backend.commitFile({
user: target.owner,
repo: target.repo,
path: sourcesPath,
branch: target.branch,
content: sourcesContent,
message: commitMsg,
});
const commits: Array<{ path: string; commitSha: string; fileSha: string }> = [
{ path: sourcesPath, commitSha: result.commitSha, fileSha: result.fileSha },
];
const partial: Array<{ path: string; reason: string }> = [];
// Index update
try {
const idx = await opts.backend.getFileWithSha(target.owner, target.repo, idxPath, target.branch);
const idxBefore = idx?.content ?? freshIndexMd();
const title = (fm.title as string | undefined) ?? input.slug;
const idxAfter = insertIndexEntry(idxBefore, 'sources', input.slug, title);
if (idxAfter !== idxBefore) {
const idxResult = await opts.backend.commitFile({
user: target.owner,
repo: target.repo,
path: idxPath,
branch: target.branch,
content: idxAfter,
message: `meta(wiki): index += sources/${input.slug} (promoted)`,
sha: idx?.sha,
});
commits.push({ path: idxPath, commitSha: idxResult.commitSha, fileSha: idxResult.fileSha });
}
} catch (err) {
partial.push({ path: idxPath, reason: err instanceof Error ? err.message : String(err) });
}
// Log update
try {
const logF = await opts.backend.getFileWithSha(target.owner, target.repo, lgPath, target.branch);
const logBefore = logF?.content ?? freshLogMd();
const logAfter = appendLogEntry(logBefore, ymd, 'promote', `raw/${input.slug} → sources/${input.slug}`);
const logResult = await opts.backend.commitFile({
user: target.owner,
repo: target.repo,
path: lgPath,
branch: target.branch,
content: logAfter,
message: `meta(wiki): log += promote ${input.slug}`,
sha: logF?.sha,
});
commits.push({ path: lgPath, commitSha: logResult.commitSha, fileSha: logResult.fileSha });
} catch (err) {
partial.push({ path: lgPath, reason: err instanceof Error ? err.message : String(err) });
}
// Pull the local clone BEFORE invalidating the cache so the next
// search reloads a fresh clone. Pull-then-invalidate (not the reverse)
// closes a race where a concurrent search, arriving after cached=null
// but before the pull finishes, would reload and re-memoize the still
// stale clone.
const pullResult = await pullLocalClone(opts.wikiRoot);
const pullNote = pullResult.pulled
? undefined
: `local clone pull skipped: ${pullResult.error ?? 'unknown'}`;
cached = null; // invalidate search cache so the next search reloads fresh
return asJson({
committed: true,
target_owner: target.owner,
target_repo: target.repo,
target_branch: target.branch,
commits,
partial_failures: partial.length ? partial : undefined,
note: pullNote,
});
},
},
];
}

View File

@@ -0,0 +1,65 @@
import { readCache } from '../lib/cache.js';
import type { Paths } from '../lib/config.js';
import { loadWiki } from '../lib/wiki-index.js';
import { AGENDA_PROJECT_NAME, AGENDA_BRANCH_LOCAL } from '../lib/sync-runner.js';
import type { ToolDef, ToolResult } from './types.js';
const STALE_AFTER_SEC = 24 * 3600;
interface Opts {
paths: Paths;
}
function asJson(value: unknown): ToolResult {
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
}
export function makeMetaTools(opts: Opts): ToolDef[] {
return [
{
name: 'meta.status',
description:
'Диагностика: возраст кэша, количество проектов / ошибок / страниц wiki. Используй когда нужно понять, свежие ли данные.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
async handler() {
const cache = await readCache(opts.paths.cacheFile);
const wiki = await loadWiki(opts.paths.sharedWikiClone);
if (!cache) {
return asJson({
cache_missing: true,
cache_path: opts.paths.cacheFile,
wiki_root: opts.paths.sharedWikiClone,
wiki_pages_count: wiki.length,
agenda_tasks_dir: opts.paths.agendaTasksDir,
agenda_present: false,
});
}
const ageMs = Date.now() - new Date(cache.synced_at).getTime();
const ageSec = Math.floor(ageMs / 1000);
const agendaProject = cache.projects.find((p) => p.name === AGENDA_PROJECT_NAME);
const agendaSource = agendaProject
? agendaProject.default_branch === AGENDA_BRANCH_LOCAL
? 'local'
: 'gitea'
: 'none';
return asJson({
synced_at: cache.synced_at,
age_seconds: ageSec,
stale: ageSec > STALE_AFTER_SEC,
projects_count: cache.projects.length,
archived_skipped: cache.archived_skipped ?? 0,
errors_count: cache.errors.length,
cache_path: opts.paths.cacheFile,
wiki_root: opts.paths.sharedWikiClone,
wiki_pages_count: wiki.length,
agenda_tasks_dir: opts.paths.agendaTasksDir,
agenda_present: !!agendaProject,
agenda_source: agendaSource,
agenda_branch: agendaProject?.default_branch ?? null,
agenda_tasks_active: agendaProject?.active_tasks.length ?? 0,
agenda_tasks_total: agendaProject?.all_tasks_count ?? 0,
});
},
},
];
}

View File

@@ -0,0 +1,115 @@
import { exec } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { homedir, platform } from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';
import { readCache } from '../lib/cache.js';
import type { ToolDef } from './types.js';
const execAsync = promisify(exec);
interface Opts {
cacheFile: string;
}
async function checkPollerRunning(): Promise<boolean> {
try {
// Match the specific server file, not the generic dir name — the WMI subprocess
// that runs the check would itself contain "agents-task-runner" in its cmdline and
// cause a self-match false-positive.
if (platform() === 'win32') {
const { stdout } = await execAsync(
'powershell -NonInteractive -Command "Get-WmiObject Win32_Process | Where-Object { $_.CommandLine -like \'*task-runner*server.js*\' } | Select-Object -First 1 -ExpandProperty ProcessId"',
{ timeout: 6000 },
);
return stdout.trim().length > 0;
}
const { stdout } = await execAsync('pgrep -f "task-runner/server.js"', { timeout: 5000 });
return stdout.trim().length > 0;
} catch {
return false;
}
}
async function readPollerProjects(): Promise<string | null> {
try {
const envPath = join(homedir(), 'projects', '.common', 'lib', 'agents-task-runner', '.env');
const content = await readFile(envPath, 'utf8');
const match = /^POLLER_PROJECTS=(.*)$/m.exec(content);
return match ? match[1].trim() || null : null;
} catch {
return null;
}
}
async function getDockerContainers(): Promise<Array<{ name: string; status: string }>> {
try {
const { stdout } = await execAsync('docker ps --format "{{json .}}"', { timeout: 8000 });
const result: Array<{ name: string; status: string }> = [];
for (const line of stdout.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
const rawName =
typeof parsed['Names'] === 'string'
? parsed['Names']
: typeof parsed['Name'] === 'string'
? parsed['Name']
: '';
const name = rawName.replace(/^\//, '');
const status = typeof parsed['Status'] === 'string' ? parsed['Status'] : '';
if (name) result.push({ name, status });
} catch {
// skip malformed line
}
}
return result;
} catch {
return [];
}
}
function asJson(value: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }] };
}
export function makeOpsTools(opts: Opts): ToolDef[] {
return [
{
name: 'meta.system_snapshot',
description:
'Мгновенный снимок системы: статус поллера агентов, Docker-контейнеры, сводка задач (active/blocked) по проектам из кэша.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
async handler() {
const [pollerRunning, pollerProjects, docker, cache] = await Promise.all([
checkPollerRunning(),
readPollerProjects(),
getDockerContainers(),
readCache(opts.cacheFile),
]);
const tasks: Record<string, { active: number; blocked: number }> = {};
if (cache) {
for (const p of cache.projects) {
let active = 0;
let blocked = 0;
for (const t of p.active_tasks) {
if (t.status === 'active' || t.status === 'paused') active++;
else if (t.status === 'blocked') blocked++;
}
if (active + blocked > 0) {
tasks[p.name] = { active, blocked };
}
}
}
return asJson({
poller: { running: pollerRunning, projects: pollerProjects },
docker,
tasks,
});
},
},
];
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
export interface ToolResult {
content: Array<{ type: 'text'; text: string }>;
isError?: boolean;
}
export interface ToolDef {
name: string;
description: string;
inputSchema: {
type: 'object';
properties?: Record<string, unknown>;
required?: string[];
additionalProperties?: boolean;
};
// MCP SDK CallToolResult is a discriminated union with a task-required variant.
// Using `Promise<ToolResult>` here triggers ts(2345) when the handler is passed
// to setRequestHandler — inline shape passes structurally. Do not replace.
handler: (
args: Record<string, unknown>,
) => Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }>;
}

View File

@@ -0,0 +1,73 @@
import { z } from 'zod';
import type { ToolDef, ToolResult } from './types.js';
/**
* Worker-registry tools.
*
* `worker.register` is an intentional **stub** — it reserves the API surface for
* the future *centralized* orchestration mode (workers explicitly register their
* capabilities + endpoint, see `concepts/agent-orchestration-without-user` →
* "Migration to centralized"). The per-machine federation MVP does not register
* workers, so the stub always returns a 501-shaped `not-implemented` verdict and
* performs no side-effects. Keeping it in the surface now is migration insurance:
* the tool's behavioural contract stays backwards-compatible when the logic thaws.
*/
const WorkerRegisterInput = z.object({
machine: z.string().min(1),
runtime: z.string().min(1),
capabilities: z.array(z.string()),
endpoint: z.string().optional(),
confirm: z.boolean().optional(),
});
const NOT_IMPLEMENTED_HINT =
'Worker registry активируется при переходе на centralized mode. Per-machine ' +
'federation MVP не требует регистрации. См. concepts/agent-orchestration-without-user ' +
'секцию "Migration to centralized".';
function notImplemented(): ToolResult {
return {
content: [
{
type: 'text',
text: JSON.stringify(
{ ok: false, reason: 'not-implemented', hint: NOT_IMPLEMENTED_HINT },
null,
2,
),
},
],
isError: true,
};
}
export function makeWorkersTools(): ToolDef[] {
return [
{
name: 'worker.register',
description:
'[STUB — future centralized mode] Регистрация воркера (machine, runtime, capabilities, endpoint). ' +
'НЕ имплементировано: всегда возвращает 501 `not-implemented`, без side-effects. ' +
'Per-machine federation MVP не требует регистрации воркеров — этот тул зарезервирован ' +
'для будущего centralized режима (см. concepts/agent-orchestration-without-user).',
inputSchema: {
type: 'object',
properties: {
machine: { type: 'string' },
runtime: { type: 'string' },
capabilities: { type: 'array', items: { type: 'string' } },
endpoint: { type: 'string', description: 'optional — for centralized mode' },
confirm: { type: 'boolean' },
},
required: ['machine', 'runtime', 'capabilities'],
additionalProperties: false,
},
async handler(args) {
// Validate input (rejects malformed) but never mutate state.
WorkerRegisterInput.parse(args);
return notImplemented();
},
},
];
}