{
+ 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 ` does not create intermediate parents — make them first
+ // (real path is ~/.cache/projects-mcp/meta-git/__, 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 {
+ 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 };
+}
diff --git a/lib/projects-meta-mcp/src/lib/policy.ts b/lib/projects-meta-mcp/src/lib/policy.ts
new file mode 100644
index 0000000..4f5fd86
--- /dev/null
+++ b/lib/projects-meta-mcp/src/lib/policy.ts
@@ -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;
+ 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,
+ };
+}
diff --git a/lib/projects-meta-mcp/src/lib/promotion.ts b/lib/projects-meta-mcp/src/lib/promotion.ts
new file mode 100644
index 0000000..024aa11
--- /dev/null
+++ b/lib/projects-meta-mcp/src/lib/promotion.ts
@@ -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 {
+ return new Set(
+ s
+ .toLowerCase()
+ .replace(/[^a-z0-9 ]+/g, ' ')
+ .split(/\s+/)
+ .filter((w) => w.length >= 3),
+ );
+}
+
+function jaccard(a: Set, b: Set): 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 {
+ 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 {
+ 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;
+}
diff --git a/lib/projects-meta-mcp/src/lib/resolve-target.ts b/lib/projects-meta-mcp/src/lib/resolve-target.ts
new file mode 100644
index 0000000..bbc18bd
--- /dev/null
+++ b/lib/projects-meta-mcp/src/lib/resolve-target.ts
@@ -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 `/`
+ * 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 `/` (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 `/`, ' +
+ `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 = "/"`. 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 {
+ 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 };
+}
diff --git a/lib/projects-meta-mcp/src/lib/status-md-writer.ts b/lib/projects-meta-mcp/src/lib/status-md-writer.ts
new file mode 100644
index 0000000..0f4a69e
--- /dev/null
+++ b/lib/projects-meta-mcp/src/lib/status-md-writer.ts
@@ -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:
+ *
+ * ## 🔴 [] —
+ *
+ * **Status:** active | paused | ready | blocked | done
+ * **Where I stopped:** ...
+ * **Next action:** ...
+ * **Blocker:** (only if blocked)
+ * **Branch:** ...
+ *
+ *
+ * ---
+ */
+
+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;
+ /** Qualified / 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(``);
+ 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}_
+
+
+
+`;
+}
+
+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 `## [] — ...` 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, ``);
+ }
+ 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;
+}
diff --git a/lib/projects-meta-mcp/src/lib/status-md.ts b/lib/projects-meta-mcp/src/lib/status-md.ts
new file mode 100644
index 0000000..56bfe15
--- /dev/null
+++ b/lib/projects-meta-mcp/src/lib/status-md.ts
@@ -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 / 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, `::`. */
+ 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;
+}
+
+export interface ParsedStatus {
+ updated: string | null;
+ tasks: ParsedTask[];
+}
+
+const EMOJI_TO_STATUS: Record = {
+ '🔴': '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;
+ }
+ } 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 };
+}
diff --git a/lib/projects-meta-mcp/src/lib/sync-runner.ts b/lib/projects-meta-mcp/src/lib/sync-runner.ts
new file mode 100644
index 0000000..8f1adcf
--- /dev/null
+++ b/lib/projects-meta-mcp/src/lib/sync-runner.ts
@@ -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 `/`.
+ */
+ 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;
+}
+
+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(items: T[], n: number, fn: (x: T) => Promise): Promise {
+ const out = new Array(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 {
+ 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(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,
+ };
+}
diff --git a/lib/projects-meta-mcp/src/lib/wiki-index-parser.ts b/lib/projects-meta-mcp/src/lib/wiki-index-parser.ts
new file mode 100644
index 0000000..edcc14c
--- /dev/null
+++ b/lib/projects-meta-mcp/src/lib/wiki-index-parser.ts
@@ -0,0 +1,45 @@
+import type { WikiPageType } from './wiki-writer.js';
+
+export interface WikiIndexEntry {
+ slug: string;
+ type: WikiPageType;
+ title: string;
+}
+
+const SECTION_TYPES: Record = {
+ Entities: 'entities',
+ Concepts: 'concepts',
+ Packages: 'packages',
+ Sources: 'sources',
+ Raw: 'raw',
+};
+
+const ENTRY_RE = /^- \[(?