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:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -3,6 +3,13 @@ dist/*-linux-*
|
||||
dist/*-darwin-*
|
||||
dist/*-windows-*.exe.bak
|
||||
|
||||
# MCP server build artefacts (lib/ contains sources; bootstrap builds on install)
|
||||
lib/*/node_modules/
|
||||
lib/*/dist/
|
||||
lib/*/.venv/
|
||||
lib/**/__pycache__/
|
||||
lib/**/*.pyc
|
||||
|
||||
# Local dev state
|
||||
*.log
|
||||
.vscode/
|
||||
|
||||
@@ -174,6 +174,53 @@ $lines = @(
|
||||
|
||||
Write-Ok "Wrote $HOME_TOML"
|
||||
|
||||
# Build MCP servers from lib/
|
||||
Write-Step "Building MCP servers"
|
||||
|
||||
$libPath = "$factoryLocal\lib"
|
||||
|
||||
if (Test-Path $libPath) {
|
||||
# TypeScript servers (npm install + npm run build)
|
||||
foreach ($server in @("projects-meta-mcp", "wiki-graph")) {
|
||||
$serverPath = "$libPath\$server"
|
||||
if (Test-Path $serverPath) {
|
||||
Write-Host " Building $server..."
|
||||
Push-Location $serverPath
|
||||
try {
|
||||
npm install --silent 2>&1 | Out-Null
|
||||
npm run build 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Gap "$server: build failed (npm run build exited $LASTEXITCODE)"
|
||||
} else {
|
||||
Write-Ok "$server built"
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Python server (interns-mcp) — venv + pip install
|
||||
$internsMcpPath = "$libPath\interns-mcp"
|
||||
if (Test-Path $internsMcpPath) {
|
||||
Write-Host " Setting up interns-mcp..."
|
||||
Push-Location $internsMcpPath
|
||||
try {
|
||||
python -m venv .venv 2>&1 | Out-Null
|
||||
& ".venv\Scripts\pip.exe" install -e . --quiet
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Gap "interns-mcp: pip install failed"
|
||||
} else {
|
||||
Write-Ok "interns-mcp installed"
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Skip "lib/ not found — skipping MCP server build"
|
||||
}
|
||||
|
||||
# Print next steps
|
||||
Write-Host "`n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Green
|
||||
Write-Host " L0b COMPLETE" -ForegroundColor Green
|
||||
|
||||
1
lib/interns-mcp/interns_mcp/__init__.py
Normal file
1
lib/interns-mcp/interns_mcp/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Interns MCP — cheap LLM interns for Claude Code."""
|
||||
43
lib/interns-mcp/interns_mcp/client.py
Normal file
43
lib/interns-mcp/interns_mcp/client.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""OpenAI-compatible HTTP client, persistent per-endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
# Bound every LLM call. Without this the SDK default (600s) lets a stalled
|
||||
# endpoint hang the worker thread that ran the tool; enough such threads exhaust
|
||||
# FastMCP's pool and wedge the whole stdio server. 90s is well above a healthy
|
||||
# deepseek-v4-flash response yet short enough to fail fast and free the thread.
|
||||
DEFAULT_REQUEST_TIMEOUT = 90.0
|
||||
|
||||
|
||||
def _resolve_timeout(endpoint_cfg: dict[str, Any]) -> float:
|
||||
"""Per-endpoint request timeout in seconds; falls back to the bounded default."""
|
||||
return float(endpoint_cfg.get("request_timeout", DEFAULT_REQUEST_TIMEOUT))
|
||||
|
||||
|
||||
def make_client(endpoint_cfg: dict[str, Any], api_key: str) -> OpenAI:
|
||||
"""Create a persistent OpenAI client for an endpoint."""
|
||||
kwargs: dict[str, Any] = {
|
||||
"base_url": endpoint_cfg["base_url"],
|
||||
"api_key": api_key,
|
||||
"timeout": _resolve_timeout(endpoint_cfg),
|
||||
}
|
||||
defaults = endpoint_cfg.get("request_defaults", {})
|
||||
if "extra_body" in defaults:
|
||||
kwargs["default_query"] = {}
|
||||
return OpenAI(**kwargs)
|
||||
|
||||
|
||||
class ClientPool:
|
||||
"""Manages persistent HTTP clients per endpoint."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._clients: dict[str, OpenAI] = {}
|
||||
|
||||
def get(self, name: str, endpoint_cfg: dict[str, Any], api_key: str) -> OpenAI:
|
||||
if name not in self._clients:
|
||||
self._clients[name] = make_client(endpoint_cfg, api_key)
|
||||
return self._clients[name]
|
||||
0
lib/interns-mcp/interns_mcp/interns/__init__.py
Normal file
0
lib/interns-mcp/interns_mcp/interns/__init__.py
Normal file
52
lib/interns-mcp/interns_mcp/interns/base.py
Normal file
52
lib/interns-mcp/interns_mcp/interns/base.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Base intern protocol and response types."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class InternResponse:
|
||||
text: str
|
||||
usage: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlockedByPolicy:
|
||||
path: str
|
||||
pattern: str
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class BudgetExceededError:
|
||||
tokens: int
|
||||
limit: int
|
||||
top_files: list[str] = field(default_factory=list)
|
||||
hint: str = "use compress=True or narrow paths"
|
||||
|
||||
|
||||
class Intern:
|
||||
id: str = ""
|
||||
description: str = ""
|
||||
endpoint: str | None = None
|
||||
model: str | None = None
|
||||
max_tokens: int = 4096
|
||||
temperature: float = 0.2
|
||||
system_prompt: str = ""
|
||||
context_budget: int = 120000
|
||||
|
||||
def __init__(self, config: dict[str, Any] | None = None, client: Any = None, safety: Any = None):
|
||||
self.client = client
|
||||
self.safety = safety
|
||||
if config:
|
||||
self._apply_config(config)
|
||||
|
||||
def _apply_config(self, config: dict[str, Any]) -> None:
|
||||
for k, v in config.items():
|
||||
if hasattr(self, k):
|
||||
setattr(self, k, v)
|
||||
|
||||
def run(self, paths: list[str], question: str, **kwargs: Any) -> InternResponse | BlockedByPolicy | BudgetExceededError:
|
||||
raise NotImplementedError
|
||||
46
lib/interns-mcp/interns_mcp/interns/bulk_text_read.py
Normal file
46
lib/interns-mcp/interns_mcp/interns/bulk_text_read.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Bulk text read intern — read N files and answer a focused question."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .base import BlockedByPolicy, Intern, InternResponse
|
||||
|
||||
|
||||
class BulkTextRead(Intern):
|
||||
id = "bulk_text_read"
|
||||
description = "Read N files and answer a focused question. Returns concise summary."
|
||||
|
||||
def run(self, paths: list[str], question: str, **kwargs: Any) -> InternResponse | BlockedByPolicy:
|
||||
contents: list[str] = []
|
||||
for p in paths:
|
||||
text = Path(p).read_text(encoding="utf-8", errors="replace")
|
||||
contents.append(f"--- {p} ---\n{text}")
|
||||
|
||||
combined = "\n\n".join(contents)
|
||||
prompt = f"Files:\n{combined}\n\nQuestion: {question}"
|
||||
|
||||
if self.client is None:
|
||||
return InternResponse(text="No client configured", usage={})
|
||||
|
||||
extra_body = kwargs.get("extra_body", {})
|
||||
resp = self.client.chat.completions.create(
|
||||
model=self.model or "deepseek-v4-flash",
|
||||
messages=[
|
||||
{"role": "system", "content": self.system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
max_tokens=kwargs.get("max_tokens", self.max_tokens),
|
||||
temperature=self.temperature,
|
||||
extra_body=extra_body if extra_body else None,
|
||||
)
|
||||
|
||||
choice = resp.choices[0]
|
||||
return InternResponse(
|
||||
text=choice.message.content or "",
|
||||
usage={
|
||||
"tokens_in": resp.usage.prompt_tokens if resp.usage else 0,
|
||||
"tokens_out": resp.usage.completion_tokens if resp.usage else 0,
|
||||
},
|
||||
)
|
||||
104
lib/interns-mcp/interns_mcp/interns/grep_audit.py
Normal file
104
lib/interns-mcp/interns_mcp/interns/grep_audit.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Grep audit intern — deterministic matrix of N paths x M patterns. No LLM call."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Literal
|
||||
|
||||
from .base import Intern, InternResponse
|
||||
|
||||
|
||||
class GrepAudit(Intern):
|
||||
id = "grep_audit"
|
||||
description = (
|
||||
"Deterministic grep matrix over N paths x M patterns (substring or regex). "
|
||||
"No LLM call, no endpoint cost. Returns markdown table (default) or JSON."
|
||||
)
|
||||
|
||||
def run(
|
||||
self,
|
||||
paths: list[str],
|
||||
patterns: list[str | dict[str, Any]],
|
||||
output: Literal["table", "json"] = "table",
|
||||
case_sensitive: bool = True,
|
||||
**_kwargs: Any,
|
||||
) -> InternResponse:
|
||||
compiled = _compile_patterns(patterns, case_sensitive)
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
matches_total = 0
|
||||
for p in paths:
|
||||
try:
|
||||
text = Path(p).read_text(encoding="utf-8", errors="replace")
|
||||
except (FileNotFoundError, PermissionError, IsADirectoryError, OSError) as exc:
|
||||
rows.append(
|
||||
{
|
||||
"path": p,
|
||||
"matches": {c["name"]: None for c in compiled},
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
continue
|
||||
row_matches: dict[str, bool] = {}
|
||||
for c in compiled:
|
||||
hit = bool(c["matcher"](text))
|
||||
row_matches[c["name"]] = hit
|
||||
if hit:
|
||||
matches_total += 1
|
||||
rows.append({"path": p, "matches": row_matches})
|
||||
|
||||
if output == "json":
|
||||
rendered = json.dumps({"rows": rows}, ensure_ascii=False, indent=2)
|
||||
else:
|
||||
rendered = _render_table(rows, [c["name"] for c in compiled])
|
||||
|
||||
return InternResponse(
|
||||
text=rendered,
|
||||
usage={
|
||||
"files_scanned": len(paths),
|
||||
"patterns_evaluated": len(compiled),
|
||||
"matches_total": matches_total,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _compile_patterns(
|
||||
patterns: list[str | dict[str, Any]], case_sensitive: bool
|
||||
) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for p in patterns:
|
||||
if isinstance(p, str):
|
||||
out.append({"name": p, "matcher": _substring_matcher(p, case_sensitive)})
|
||||
continue
|
||||
name = p.get("name", p["pattern"])
|
||||
if p.get("regex"):
|
||||
flags = 0 if case_sensitive else re.IGNORECASE
|
||||
compiled = re.compile(p["pattern"], flags)
|
||||
out.append({"name": name, "matcher": compiled.search})
|
||||
else:
|
||||
out.append(
|
||||
{"name": name, "matcher": _substring_matcher(p["pattern"], case_sensitive)}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _substring_matcher(needle: str, case_sensitive: bool) -> Callable[[str], bool]:
|
||||
if case_sensitive:
|
||||
return lambda text: needle in text
|
||||
lowered = needle.lower()
|
||||
return lambda text: lowered in text.lower()
|
||||
|
||||
|
||||
def _render_table(rows: list[dict[str, Any]], names: list[str]) -> str:
|
||||
header = "| Path | " + " | ".join(names) + " |"
|
||||
sep = "|" + "---|" * (len(names) + 1)
|
||||
lines = [header, sep]
|
||||
for row in rows:
|
||||
cells: list[str] = []
|
||||
for n in names:
|
||||
v = row["matches"].get(n)
|
||||
cells.append("⚠️" if v is None else ("✅" if v else "❌"))
|
||||
lines.append(f"| `{row['path']}` | " + " | ".join(cells) + " |")
|
||||
return "\n".join(lines)
|
||||
164
lib/interns-mcp/interns_mcp/interns/repo_read.py
Normal file
164
lib/interns-mcp/interns_mcp/interns/repo_read.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""Repo read intern — pack repo with repomix, answer question with LLM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .base import BudgetExceededError, Intern, InternResponse
|
||||
from ..safety import always_ask_globs
|
||||
|
||||
|
||||
# Conservative token estimate: chars/4 overestimates for repomix XML (tag overhead),
|
||||
# so BudgetExceededError fires early rather than late. Real enforcement is on the
|
||||
# endpoint side — this is a client-side safety net, not a precision counter.
|
||||
CHARS_PER_TOKEN = 4
|
||||
|
||||
|
||||
def _estimate_tokens(text: str) -> int:
|
||||
return len(text) // CHARS_PER_TOKEN
|
||||
|
||||
|
||||
def _top_files_by_size(packed: str, n: int = 5) -> list[str]:
|
||||
"""Return the n largest files in repomix XML output, by block content size.
|
||||
|
||||
Parses each ``<file path="X">...</file>`` block and measures the byte length
|
||||
of its content, so BudgetExceededError.top_files points the user at the real
|
||||
overflow culprits — not the first files in document order.
|
||||
"""
|
||||
sizes: list[tuple[int, str]] = []
|
||||
pos = 0
|
||||
while True:
|
||||
open_idx = packed.find('<file ', pos)
|
||||
if open_idx == -1:
|
||||
break
|
||||
close_idx = packed.find('</file>', open_idx)
|
||||
if close_idx == -1:
|
||||
break
|
||||
path_start = packed.find('path="', open_idx) + 6
|
||||
path_end = packed.find('"', path_start)
|
||||
path = packed[path_start:path_end]
|
||||
# Content sits between the opening tag's '>' and the closing '</file>'.
|
||||
content_start = packed.find('>', open_idx) + 1
|
||||
content_size = close_idx - content_start
|
||||
sizes.append((content_size, path))
|
||||
pos = close_idx + len('</file>')
|
||||
|
||||
sizes.sort(key=lambda x: x[0], reverse=True)
|
||||
return [path for _, path in sizes[:n]]
|
||||
|
||||
|
||||
class RepoRead(Intern):
|
||||
id = "repo_read"
|
||||
description = "Pack directory/repo with repomix and answer a question. Cheap repo comprehension."
|
||||
|
||||
def run(
|
||||
self,
|
||||
paths: list[str],
|
||||
question: str,
|
||||
compress: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> InternResponse | BudgetExceededError:
|
||||
if not paths:
|
||||
return InternResponse(text="No paths provided", usage={})
|
||||
|
||||
# Build repomix args: pass all paths, not just first.
|
||||
ignore_globs = always_ask_globs()
|
||||
|
||||
# Resolve npx via PATH. On Windows, subprocess.run(shell=False) does not
|
||||
# expand PATHEXT, so passing bare "npx" fails to find npx.cmd; shutil.which
|
||||
# finds the .cmd (or .exe / shell wrapper on POSIX) and returns a full path
|
||||
# that CreateProcess / execve can dispatch directly.
|
||||
npx = shutil.which("npx") or shutil.which("npx.cmd")
|
||||
if npx is None:
|
||||
return InternResponse(text="npx not found - install Node.js", usage={})
|
||||
|
||||
# Use mkstemp to avoid Windows file-sharing issues with NamedTemporaryFile.
|
||||
# os.close(fd) releases the handle before repomix writes to the path.
|
||||
fd, tmp_path = tempfile.mkstemp(suffix=".xml")
|
||||
os.close(fd)
|
||||
err_fd, err_path = tempfile.mkstemp(suffix=".stderr")
|
||||
os.close(err_fd)
|
||||
try:
|
||||
args = [
|
||||
npx, "-y", "repomix@latest",
|
||||
*paths,
|
||||
"--output", tmp_path,
|
||||
"--style", "xml",
|
||||
]
|
||||
if compress:
|
||||
args.append("--compress")
|
||||
for glob in ignore_globs:
|
||||
args += ["--ignore", glob]
|
||||
|
||||
try:
|
||||
# Redirect repomix's stdio to FILES/DEVNULL, never PIPES. capture_output
|
||||
# spawns reader threads that communicate() joins; if any process in the
|
||||
# child tree (an orphaned `node` grandchild, or — under concurrent launches
|
||||
# on Windows — a sibling subprocess that inherited the pipe handle) keeps a
|
||||
# pipe write-end open, that join blocks FOREVER, past the timeout, wedging
|
||||
# the worker thread. repomix's real output already goes to --output, so its
|
||||
# stdout is unused; stderr is captured to a file for error reporting only.
|
||||
with open(err_path, "w", encoding="utf-8", errors="replace") as err_f:
|
||||
subprocess.run(
|
||||
args,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=err_f,
|
||||
timeout=120,
|
||||
check=True,
|
||||
)
|
||||
packed_text = Path(tmp_path).read_text()
|
||||
except subprocess.TimeoutExpired:
|
||||
return InternResponse(text="Repomix timed out (120s)", usage={})
|
||||
except subprocess.CalledProcessError:
|
||||
err = Path(err_path).read_text(encoding="utf-8", errors="replace")
|
||||
return InternResponse(text=f"Repomix failed: {err[:500]}", usage={})
|
||||
except FileNotFoundError:
|
||||
return InternResponse(text="npx not found - install Node.js", usage={})
|
||||
finally:
|
||||
# Always clean up the temp files, even on error.
|
||||
for _p in (tmp_path, err_path):
|
||||
try:
|
||||
os.unlink(_p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Budget check before sending to LLM.
|
||||
tokens = _estimate_tokens(packed_text)
|
||||
if tokens > self.context_budget:
|
||||
return BudgetExceededError(
|
||||
tokens=tokens,
|
||||
limit=self.context_budget,
|
||||
top_files=_top_files_by_size(packed_text),
|
||||
)
|
||||
|
||||
prompt = f"# Packed repo\n\n{packed_text}\n\n# Question\n\n{question}"
|
||||
|
||||
if self.client is None:
|
||||
return InternResponse(text="No client configured", usage={})
|
||||
|
||||
extra_body = kwargs.get("extra_body", {})
|
||||
resp = self.client.chat.completions.create(
|
||||
model=self.model or "deepseek-v4-flash",
|
||||
messages=[
|
||||
{"role": "system", "content": self.system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
max_tokens=kwargs.get("max_tokens", self.max_tokens),
|
||||
temperature=self.temperature,
|
||||
extra_body=extra_body if extra_body else None,
|
||||
)
|
||||
|
||||
choice = resp.choices[0]
|
||||
return InternResponse(
|
||||
text=choice.message.content or "",
|
||||
usage={
|
||||
"tokens_in": resp.usage.prompt_tokens if resp.usage else 0,
|
||||
"tokens_out": resp.usage.completion_tokens if resp.usage else 0,
|
||||
},
|
||||
)
|
||||
46
lib/interns-mcp/interns_mcp/interns/transcript_distill.py
Normal file
46
lib/interns-mcp/interns_mcp/interns/transcript_distill.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Transcript distill intern — compress session transcript into structured action-list."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .base import BlockedByPolicy, Intern, InternResponse
|
||||
|
||||
|
||||
class TranscriptDistill(Intern):
|
||||
id = "transcript_distill"
|
||||
description = "Compress a session transcript/log into a structured action-list."
|
||||
|
||||
def run(self, paths: list[str], question: str = "Extract action items, decisions, and unresolved questions.", **kwargs: Any) -> InternResponse | BlockedByPolicy:
|
||||
contents: list[str] = []
|
||||
for p in paths:
|
||||
text = Path(p).read_text(encoding="utf-8", errors="replace")
|
||||
contents.append(f"--- {p} ---\n{text}")
|
||||
|
||||
combined = "\n\n".join(contents)
|
||||
prompt = f"Transcript:\n{combined}\n\nTask: {question}"
|
||||
|
||||
if self.client is None:
|
||||
return InternResponse(text="No client configured", usage={})
|
||||
|
||||
extra_body = kwargs.get("extra_body", {})
|
||||
resp = self.client.chat.completions.create(
|
||||
model=self.model or "deepseek-v4-flash",
|
||||
messages=[
|
||||
{"role": "system", "content": self.system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
max_tokens=kwargs.get("max_tokens", self.max_tokens),
|
||||
temperature=self.temperature,
|
||||
extra_body=extra_body if extra_body else None,
|
||||
)
|
||||
|
||||
choice = resp.choices[0]
|
||||
return InternResponse(
|
||||
text=choice.message.content or "",
|
||||
usage={
|
||||
"tokens_in": resp.usage.prompt_tokens if resp.usage else 0,
|
||||
"tokens_out": resp.usage.completion_tokens if resp.usage else 0,
|
||||
},
|
||||
)
|
||||
38
lib/interns-mcp/interns_mcp/registry.py
Normal file
38
lib/interns-mcp/interns_mcp/registry.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Load intern catalog from config.yaml."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from .interns.base import Intern
|
||||
from .interns.bulk_text_read import BulkTextRead
|
||||
from .interns.grep_audit import GrepAudit
|
||||
from .interns.repo_read import RepoRead
|
||||
from .interns.transcript_distill import TranscriptDistill
|
||||
|
||||
# Explicit registry — MVP choice (see design spec open questions).
|
||||
INTERN_CLASSES: dict[str, type[Intern]] = {
|
||||
"bulk_text_read": BulkTextRead,
|
||||
"grep_audit": GrepAudit,
|
||||
"repo_read": RepoRead,
|
||||
"transcript_distill": TranscriptDistill,
|
||||
}
|
||||
|
||||
|
||||
def load_config(config_path: Path) -> dict[str, Any]:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def load_interns(config: dict[str, Any], client: Any = None, safety: Any = None) -> list[Intern]:
|
||||
"""Instantiate interns from config, wiring shared client and safety."""
|
||||
interns = []
|
||||
for intern_id, intern_cfg in config.get("interns", {}).items():
|
||||
cls = INTERN_CLASSES.get(intern_id)
|
||||
if cls is None:
|
||||
continue
|
||||
interns.append(cls(config=intern_cfg, client=client, safety=safety))
|
||||
return interns
|
||||
65
lib/interns-mcp/interns_mcp/safety.py
Normal file
65
lib/interns-mcp/interns_mcp/safety.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Always-ask path matcher — safety enforcement for intern calls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import PurePath
|
||||
|
||||
# Patterns that always require explicit user approval before sending to an intern.
|
||||
ALWAYS_ASK_PATTERNS: list[str] = [
|
||||
"**/.env",
|
||||
"**/.env.*",
|
||||
"**/secrets/**",
|
||||
"**/projects-secrets/**",
|
||||
"**/credentials*",
|
||||
"**/*.key",
|
||||
"**/*.pem",
|
||||
"**/.ssh/**",
|
||||
"**/.aws/credentials",
|
||||
"**/.aws/config",
|
||||
"**/.netrc",
|
||||
"**/.npmrc",
|
||||
"**/.pypirc",
|
||||
]
|
||||
|
||||
ALWAYS_ASK_REASON = (
|
||||
"Path matched always-ask pattern. "
|
||||
"Sending this file to an external endpoint requires explicit approval."
|
||||
)
|
||||
|
||||
|
||||
def check_paths(paths: list[str], extra_patterns: list[str] | None = None) -> list[dict]:
|
||||
"""Check each path against always-ask patterns.
|
||||
|
||||
Returns list of blocked entries: [{"path": ..., "pattern": ..., "reason": ...}].
|
||||
Empty list = all paths clear.
|
||||
"""
|
||||
patterns = list(ALWAYS_ASK_PATTERNS)
|
||||
if extra_patterns:
|
||||
patterns.extend(extra_patterns)
|
||||
|
||||
blocked = []
|
||||
for p in paths:
|
||||
pp = PurePath(p)
|
||||
for pattern in patterns:
|
||||
if pp.match(pattern):
|
||||
blocked.append({"path": p, "pattern": pattern, "reason": ALWAYS_ASK_REASON})
|
||||
break
|
||||
return blocked
|
||||
|
||||
|
||||
def always_ask_globs() -> list[str]:
|
||||
"""Return glob patterns for always-ask paths, suitable for --ignore flags.
|
||||
|
||||
Used by tool-wrapping interns (e.g. repo_read) to inject --ignore flags
|
||||
into subprocess calls, preventing repomix from packaging sensitive files
|
||||
even if they exist inside the target directory.
|
||||
"""
|
||||
# Convert PurePath-style patterns to glob patterns repomix understands.
|
||||
# Strip leading **/ since repomix --ignore uses gitignore-style patterns.
|
||||
globs = []
|
||||
for p in ALWAYS_ASK_PATTERNS:
|
||||
if p.startswith("**/"):
|
||||
globs.append(p[3:])
|
||||
else:
|
||||
globs.append(p)
|
||||
return globs
|
||||
114
lib/interns-mcp/interns_mcp/server.py
Normal file
114
lib/interns-mcp/interns_mcp/server.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""FastMCP stdio server — MCP entry point for interns."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from .client import ClientPool
|
||||
from .registry import load_config, load_interns
|
||||
from .safety import check_paths
|
||||
|
||||
# Resolve config path relative to .common root; secrets live outside the git tree.
|
||||
_COMMON_ROOT = Path(__file__).resolve().parents[3] # lib/interns-mcp/interns_mcp/ → .common/
|
||||
CONFIG_PATH = _COMMON_ROOT / "config" / "interns" / "config.yaml"
|
||||
SECRETS_PATH = Path(
|
||||
os.environ.get("INTERNS_SECRETS_PATH")
|
||||
or (Path.home() / ".config" / "projects-secrets" / "interns.env")
|
||||
)
|
||||
|
||||
mcp = FastMCP("interns")
|
||||
|
||||
|
||||
def _get_api_key(endpoint_name: str, endpoints: dict[str, Any]) -> str:
|
||||
env_var = endpoints[endpoint_name]["api_key_env"]
|
||||
key = os.environ.get(env_var, "")
|
||||
if not key:
|
||||
raise RuntimeError(f"Missing API key: set {env_var} in {SECRETS_PATH}")
|
||||
return key
|
||||
|
||||
|
||||
def _build_extra_body(endpoint_cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
return endpoint_cfg.get("request_defaults", {}).get("extra_body", {})
|
||||
|
||||
|
||||
def register_tool(intern: Any, client_pool: ClientPool, endpoints: dict[str, Any]) -> None:
|
||||
"""Register a single intern as an MCP tool."""
|
||||
|
||||
@mcp.tool(name=intern.id, description=intern.description)
|
||||
def intern_tool(paths: list[str], question: str, max_tokens: int | None = None) -> dict:
|
||||
if intern.safety:
|
||||
blocked = check_paths(paths)
|
||||
if blocked:
|
||||
return {"blocked": True, "path": blocked[0]["path"], "pattern": blocked[0]["pattern"], "reason": blocked[0]["reason"]}
|
||||
|
||||
# Wire endpoint client if intern needs one.
|
||||
if intern.endpoint and intern.client is None:
|
||||
ep_cfg = endpoints[intern.endpoint]
|
||||
api_key = _get_api_key(intern.endpoint, endpoints)
|
||||
intern.client = client_pool.get(intern.endpoint, ep_cfg, api_key)
|
||||
|
||||
kwargs: dict[str, Any] = {}
|
||||
if intern.endpoint and intern.endpoint in endpoints:
|
||||
kwargs["extra_body"] = _build_extra_body(endpoints[intern.endpoint])
|
||||
if max_tokens is not None:
|
||||
kwargs["max_tokens"] = max_tokens
|
||||
|
||||
result = intern.run(paths, question, **kwargs)
|
||||
if isinstance(result, dict) and result.get("blocked"):
|
||||
return result
|
||||
return {"text": result.text, "usage": result.usage}
|
||||
|
||||
|
||||
def register_grep_audit_tool(intern: Any) -> None:
|
||||
"""Register grep_audit with its distinct (paths, patterns, output, case_sensitive) shape.
|
||||
|
||||
Deterministic: no LLM client wiring path. Safety gate still applies — server is
|
||||
the single trust boundary regardless of whether the intern hits an endpoint.
|
||||
"""
|
||||
|
||||
@mcp.tool(name=intern.id, description=intern.description)
|
||||
def grep_audit_tool(
|
||||
paths: list[str],
|
||||
patterns: list[Any],
|
||||
output: str = "table",
|
||||
case_sensitive: bool = True,
|
||||
) -> dict:
|
||||
if intern.safety:
|
||||
blocked = check_paths(paths)
|
||||
if blocked:
|
||||
return {"blocked": True, "path": blocked[0]["path"], "pattern": blocked[0]["pattern"], "reason": blocked[0]["reason"]}
|
||||
|
||||
result = intern.run(
|
||||
paths=paths,
|
||||
patterns=patterns,
|
||||
output=output,
|
||||
case_sensitive=case_sensitive,
|
||||
)
|
||||
return {"text": result.text, "usage": result.usage}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
load_dotenv(SECRETS_PATH)
|
||||
config = load_config(CONFIG_PATH)
|
||||
endpoints = config.get("endpoints", {})
|
||||
|
||||
pool = ClientPool()
|
||||
from . import safety as _safety_mod
|
||||
|
||||
interns = load_interns(config, safety=_safety_mod, client=None)
|
||||
for intern in interns:
|
||||
if intern.id == "grep_audit":
|
||||
register_grep_audit_tool(intern)
|
||||
else:
|
||||
register_tool(intern, pool, endpoints)
|
||||
|
||||
mcp.run(transport="stdio")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
18
lib/interns-mcp/pyproject.toml
Normal file
18
lib/interns-mcp/pyproject.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[project]
|
||||
name = "interns-mcp"
|
||||
version = "0.3.3"
|
||||
description = "FastMCP stdio server exposing cheap intern LLM tools for Claude Code"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastmcp>=2.0",
|
||||
"openai>=1.0",
|
||||
"pyyaml>=6.0",
|
||||
"python-dotenv>=1.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.0"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
21
lib/interns-mcp/tests/test_client.py
Normal file
21
lib/interns-mcp/tests/test_client.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Tests for HTTP client construction — esp. the request timeout that bounds
|
||||
LLM calls so a stalled endpoint can never hang a worker thread forever."""
|
||||
|
||||
from interns_mcp.client import make_client, _resolve_timeout, DEFAULT_REQUEST_TIMEOUT
|
||||
|
||||
|
||||
def test_default_timeout_is_bounded():
|
||||
# A bare endpoint config must still get a finite, short-ish timeout —
|
||||
# the OpenAI SDK default (600s) is long enough to wedge the server.
|
||||
assert _resolve_timeout({}) == DEFAULT_REQUEST_TIMEOUT
|
||||
assert DEFAULT_REQUEST_TIMEOUT <= 120
|
||||
|
||||
|
||||
def test_configured_timeout_overrides_default():
|
||||
assert _resolve_timeout({"request_timeout": 42}) == 42.0
|
||||
|
||||
|
||||
def test_client_is_built_with_timeout():
|
||||
c = make_client({"base_url": "http://example.invalid", "request_timeout": 30}, "k")
|
||||
# OpenAI client exposes the configured timeout; must not be None.
|
||||
assert c.timeout is not None
|
||||
237
lib/interns-mcp/tests/test_grep_audit.py
Normal file
237
lib/interns-mcp/tests/test_grep_audit.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""Tests for grep_audit intern — deterministic, LLM-free."""
|
||||
|
||||
import json
|
||||
|
||||
from interns_mcp.interns.base import Intern, InternResponse
|
||||
from interns_mcp.interns.grep_audit import GrepAudit
|
||||
|
||||
|
||||
def test_substring_case_sensitive(tmp_path):
|
||||
f = tmp_path / "claude.md"
|
||||
f.write_text("follow project discipline\nuse superpowers\n", encoding="utf-8")
|
||||
|
||||
intern = GrepAudit()
|
||||
result = intern.run(
|
||||
paths=[str(f)],
|
||||
patterns=["follow project discipline", "Follow Project Discipline"],
|
||||
output="json",
|
||||
)
|
||||
|
||||
assert isinstance(result, InternResponse)
|
||||
data = json.loads(result.text)
|
||||
matches = data["rows"][0]["matches"]
|
||||
assert matches["follow project discipline"] is True
|
||||
assert matches["Follow Project Discipline"] is False
|
||||
|
||||
|
||||
def test_substring_case_insensitive(tmp_path):
|
||||
f = tmp_path / "claude.md"
|
||||
f.write_text("follow project discipline\n", encoding="utf-8")
|
||||
|
||||
intern = GrepAudit()
|
||||
result = intern.run(
|
||||
paths=[str(f)],
|
||||
patterns=["FOLLOW PROJECT DISCIPLINE"],
|
||||
output="json",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
data = json.loads(result.text)
|
||||
assert data["rows"][0]["matches"]["FOLLOW PROJECT DISCIPLINE"] is True
|
||||
|
||||
|
||||
def test_regex_named(tmp_path):
|
||||
f = tmp_path / "pyproject.toml"
|
||||
f.write_text('version = "0.3.0"\n', encoding="utf-8")
|
||||
|
||||
intern = GrepAudit()
|
||||
result = intern.run(
|
||||
paths=[str(f)],
|
||||
patterns=[{"pattern": r'version\s*=\s*"\d+\.\d+\.\d+"', "name": "version-line", "regex": True}],
|
||||
output="json",
|
||||
)
|
||||
|
||||
data = json.loads(result.text)
|
||||
assert data["rows"][0]["matches"]["version-line"] is True
|
||||
|
||||
|
||||
def test_dict_substring(tmp_path):
|
||||
f = tmp_path / "skill.md"
|
||||
f.write_text("trigger string here\n", encoding="utf-8")
|
||||
|
||||
intern = GrepAudit()
|
||||
result = intern.run(
|
||||
paths=[str(f)],
|
||||
patterns=[{"pattern": "trigger string", "name": "trigger"}],
|
||||
output="json",
|
||||
)
|
||||
|
||||
data = json.loads(result.text)
|
||||
assert data["rows"][0]["matches"]["trigger"] is True
|
||||
|
||||
|
||||
def test_output_table(tmp_path):
|
||||
f1 = tmp_path / "a.md"
|
||||
f1.write_text("alpha beta\n", encoding="utf-8")
|
||||
f2 = tmp_path / "b.md"
|
||||
f2.write_text("gamma\n", encoding="utf-8")
|
||||
|
||||
intern = GrepAudit()
|
||||
result = intern.run(
|
||||
paths=[str(f1), str(f2)],
|
||||
patterns=["alpha", "gamma"],
|
||||
output="table",
|
||||
)
|
||||
|
||||
text = result.text
|
||||
assert text.splitlines()[0].startswith("| Path |")
|
||||
assert "alpha" in text.splitlines()[0]
|
||||
assert "gamma" in text.splitlines()[0]
|
||||
# f1 has alpha but not gamma
|
||||
f1_line = [l for l in text.splitlines() if str(f1) in l][0]
|
||||
assert "✅" in f1_line
|
||||
assert "❌" in f1_line
|
||||
# f2 has gamma but not alpha
|
||||
f2_line = [l for l in text.splitlines() if str(f2) in l][0]
|
||||
assert "✅" in f2_line
|
||||
assert "❌" in f2_line
|
||||
|
||||
|
||||
def test_output_json(tmp_path):
|
||||
f = tmp_path / "a.md"
|
||||
f.write_text("alpha\n", encoding="utf-8")
|
||||
|
||||
intern = GrepAudit()
|
||||
result = intern.run(
|
||||
paths=[str(f)],
|
||||
patterns=["alpha", "missing"],
|
||||
output="json",
|
||||
)
|
||||
|
||||
data = json.loads(result.text)
|
||||
assert "rows" in data
|
||||
assert len(data["rows"]) == 1
|
||||
assert data["rows"][0]["path"] == str(f)
|
||||
assert data["rows"][0]["matches"]["alpha"] is True
|
||||
assert data["rows"][0]["matches"]["missing"] is False
|
||||
|
||||
|
||||
def test_file_not_found_partial_result(tmp_path):
|
||||
f_real = tmp_path / "exists.md"
|
||||
f_real.write_text("hello\n", encoding="utf-8")
|
||||
f_ghost = tmp_path / "ghost.md" # never created
|
||||
|
||||
intern = GrepAudit()
|
||||
result = intern.run(
|
||||
paths=[str(f_real), str(f_ghost)],
|
||||
patterns=["hello"],
|
||||
output="json",
|
||||
)
|
||||
|
||||
data = json.loads(result.text)
|
||||
assert len(data["rows"]) == 2 # partial result, no abort
|
||||
rows_by_path = {r["path"]: r for r in data["rows"]}
|
||||
assert rows_by_path[str(f_real)]["matches"]["hello"] is True
|
||||
# Ghost row: match is None (unreadable cell), error field present
|
||||
assert rows_by_path[str(f_ghost)]["matches"]["hello"] is None
|
||||
assert "error" in rows_by_path[str(f_ghost)]
|
||||
|
||||
|
||||
def test_file_not_found_table_renders_warning(tmp_path):
|
||||
f_ghost = tmp_path / "ghost.md" # never created
|
||||
|
||||
intern = GrepAudit()
|
||||
result = intern.run(
|
||||
paths=[str(f_ghost)],
|
||||
patterns=["x"],
|
||||
output="table",
|
||||
)
|
||||
|
||||
assert "⚠️" in result.text
|
||||
|
||||
|
||||
def test_empty_paths():
|
||||
intern = GrepAudit()
|
||||
result = intern.run(paths=[], patterns=["x"], output="json")
|
||||
data = json.loads(result.text)
|
||||
assert data["rows"] == []
|
||||
assert result.usage["files_scanned"] == 0
|
||||
assert result.usage["matches_total"] == 0
|
||||
|
||||
|
||||
def test_empty_patterns(tmp_path):
|
||||
f = tmp_path / "a.md"
|
||||
f.write_text("alpha\n", encoding="utf-8")
|
||||
|
||||
intern = GrepAudit()
|
||||
result = intern.run(paths=[str(f)], patterns=[], output="json")
|
||||
data = json.loads(result.text)
|
||||
assert len(data["rows"]) == 1
|
||||
assert data["rows"][0]["matches"] == {}
|
||||
assert result.usage["patterns_evaluated"] == 0
|
||||
|
||||
|
||||
def test_unicode_content_utf8(tmp_path):
|
||||
f = tmp_path / "ru.md"
|
||||
f.write_text("русский текст\n", encoding="utf-8")
|
||||
|
||||
intern = GrepAudit()
|
||||
result = intern.run(
|
||||
paths=[str(f)],
|
||||
patterns=["русский"],
|
||||
output="json",
|
||||
)
|
||||
|
||||
data = json.loads(result.text)
|
||||
assert data["rows"][0]["matches"]["русский"] is True
|
||||
|
||||
|
||||
def test_unicode_content_binary_garbage_does_not_crash(tmp_path):
|
||||
f = tmp_path / "binary.bin"
|
||||
f.write_bytes(b"\xff\xfe\x00\x01\x02 hello \xff")
|
||||
|
||||
intern = GrepAudit()
|
||||
# errors="replace" should let us still grep for "hello"
|
||||
result = intern.run(
|
||||
paths=[str(f)],
|
||||
patterns=["hello"],
|
||||
output="json",
|
||||
)
|
||||
|
||||
data = json.loads(result.text)
|
||||
assert data["rows"][0]["matches"]["hello"] is True
|
||||
|
||||
|
||||
def test_usage_counts(tmp_path):
|
||||
f1 = tmp_path / "a.md"
|
||||
f1.write_text("alpha\n", encoding="utf-8")
|
||||
f2 = tmp_path / "b.md"
|
||||
f2.write_text("alpha beta\n", encoding="utf-8")
|
||||
|
||||
intern = GrepAudit()
|
||||
result = intern.run(
|
||||
paths=[str(f1), str(f2)],
|
||||
patterns=["alpha", "beta"],
|
||||
output="json",
|
||||
)
|
||||
|
||||
assert result.usage["files_scanned"] == 2
|
||||
assert result.usage["patterns_evaluated"] == 2
|
||||
# f1: alpha ✓, beta ✗ → 1
|
||||
# f2: alpha ✓, beta ✓ → 2
|
||||
assert result.usage["matches_total"] == 3
|
||||
|
||||
|
||||
def test_endpoint_null_no_client_no_crash():
|
||||
"""LLM-free intern must not require client wiring."""
|
||||
intern = GrepAudit(config={"endpoint": None})
|
||||
assert intern.client is None
|
||||
assert intern.endpoint is None
|
||||
|
||||
|
||||
def test_base_intern_accepts_endpoint_null_config():
|
||||
"""Base class survives config with explicit endpoint=null (no LLM init triggered)."""
|
||||
intern = Intern(config={"endpoint": None, "model": None})
|
||||
assert intern.endpoint is None
|
||||
assert intern.model is None
|
||||
assert intern.client is None
|
||||
40
lib/interns-mcp/tests/test_registry.py
Normal file
40
lib/interns-mcp/tests/test_registry.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Tests for registry loading."""
|
||||
|
||||
from interns_mcp.registry import load_interns, INTERN_CLASSES
|
||||
|
||||
|
||||
def test_classes_registered():
|
||||
assert "bulk_text_read" in INTERN_CLASSES
|
||||
assert "repo_read" in INTERN_CLASSES
|
||||
assert "transcript_distill" in INTERN_CLASSES
|
||||
|
||||
|
||||
def test_load_creates_interns():
|
||||
config = {
|
||||
"endpoints": {},
|
||||
"interns": {
|
||||
"bulk_text_read": {
|
||||
"description": "test",
|
||||
"endpoint": None,
|
||||
"model": "test-model",
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.1,
|
||||
"system_prompt": "test prompt",
|
||||
},
|
||||
},
|
||||
}
|
||||
interns = load_interns(config)
|
||||
assert len(interns) == 1
|
||||
assert interns[0].id == "bulk_text_read"
|
||||
assert interns[0].model == "test-model"
|
||||
|
||||
|
||||
def test_unknown_intern_skipped():
|
||||
config = {
|
||||
"endpoints": {},
|
||||
"interns": {
|
||||
"unknown_intern": {"description": "ghost"},
|
||||
},
|
||||
}
|
||||
interns = load_interns(config)
|
||||
assert len(interns) == 0
|
||||
283
lib/interns-mcp/tests/test_repo_read.py
Normal file
283
lib/interns-mcp/tests/test_repo_read.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""Tests for repo_read intern."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from interns_mcp.interns.base import BudgetExceededError, InternResponse
|
||||
from interns_mcp.interns.repo_read import RepoRead, _top_files_by_size
|
||||
|
||||
|
||||
def _make_llm_mock(text="The code prints hello", prompt_tokens=100, completion_tokens=50):
|
||||
"""Build a mock LLM response."""
|
||||
mock = MagicMock()
|
||||
mock.choices = [MagicMock()]
|
||||
mock.choices[0].message.content = text
|
||||
mock.usage.prompt_tokens = prompt_tokens
|
||||
mock.usage.completion_tokens = completion_tokens
|
||||
return mock
|
||||
|
||||
|
||||
def test_happy_path(tmp_path):
|
||||
"""Repomix pack + LLM answer works."""
|
||||
intern = RepoRead()
|
||||
intern.system_prompt = "You are a helpful assistant."
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.chat.completions.create.return_value = _make_llm_mock()
|
||||
|
||||
target = str(tmp_path)
|
||||
|
||||
with patch("subprocess.run") as mock_run, \
|
||||
patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[".env"]):
|
||||
def fake_run(args, **kwargs):
|
||||
out_idx = args.index("--output")
|
||||
Path(args[out_idx + 1]).write_text("# Repo\n\n```python\nprint('hello')\n```")
|
||||
return MagicMock(returncode=0, stdout="", stderr="")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
|
||||
intern.client = mock_client
|
||||
result = intern.run([target], "What does this repo do?")
|
||||
|
||||
assert isinstance(result, InternResponse)
|
||||
assert "hello" in result.text.lower()
|
||||
assert result.usage["tokens_in"] == 100
|
||||
assert result.usage["tokens_out"] == 50
|
||||
|
||||
# Verify --ignore flag injected
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert "--ignore" in call_args
|
||||
ignore_idx = call_args.index("--ignore")
|
||||
assert call_args[ignore_idx + 1] == ".env"
|
||||
|
||||
# Verify --style xml
|
||||
assert "--style" in call_args
|
||||
style_idx = call_args.index("--style")
|
||||
assert call_args[style_idx + 1] == "xml"
|
||||
|
||||
|
||||
def test_tempfile_cleaned_up(tmp_path):
|
||||
"""Temp file is removed after successful call."""
|
||||
intern = RepoRead()
|
||||
intern.system_prompt = "assistant"
|
||||
intern.client = MagicMock()
|
||||
intern.client.chat.completions.create.return_value = _make_llm_mock()
|
||||
|
||||
created_files = []
|
||||
|
||||
with patch("subprocess.run") as mock_run, \
|
||||
patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]):
|
||||
original_mkstemp = tempfile.mkstemp
|
||||
|
||||
def tracking_mkstemp(*args, **kwargs):
|
||||
fd, path = original_mkstemp(*args, **kwargs)
|
||||
created_files.append(path)
|
||||
return fd, path
|
||||
|
||||
with patch("interns_mcp.interns.repo_read.tempfile.mkstemp", side_effect=tracking_mkstemp):
|
||||
def fake_run(args, **kwargs):
|
||||
out_idx = args.index("--output")
|
||||
Path(args[out_idx + 1]).write_text("packed")
|
||||
return MagicMock(returncode=0, stdout="", stderr="")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
intern.run([str(tmp_path)], "summarize")
|
||||
|
||||
# Temp file should be cleaned up
|
||||
for f in created_files:
|
||||
assert not os.path.exists(f), f"Temp file not cleaned up: {f}"
|
||||
|
||||
|
||||
def test_tempfile_cleaned_up_on_error(tmp_path):
|
||||
"""Temp file is removed even when repomix fails."""
|
||||
intern = RepoRead()
|
||||
|
||||
created_files = []
|
||||
|
||||
with patch("subprocess.run") as mock_run, \
|
||||
patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]):
|
||||
original_mkstemp = tempfile.mkstemp
|
||||
|
||||
def tracking_mkstemp(*args, **kwargs):
|
||||
fd, path = original_mkstemp(*args, **kwargs)
|
||||
created_files.append(path)
|
||||
return fd, path
|
||||
|
||||
with patch("interns_mcp.interns.repo_read.tempfile.mkstemp", side_effect=tracking_mkstemp):
|
||||
mock_run.side_effect = subprocess.TimeoutExpired("npx", 120)
|
||||
intern.run([str(tmp_path)], "summarize")
|
||||
|
||||
for f in created_files:
|
||||
assert not os.path.exists(f), f"Temp file leaked on error: {f}"
|
||||
|
||||
|
||||
def test_compress_flag_passed(tmp_path):
|
||||
"""compress=True adds --compress to repomix args."""
|
||||
intern = RepoRead()
|
||||
intern.system_prompt = "assistant"
|
||||
intern.client = MagicMock()
|
||||
intern.client.chat.completions.create.return_value = _make_llm_mock()
|
||||
|
||||
with patch("subprocess.run") as mock_run, \
|
||||
patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]):
|
||||
def fake_run(args, **kwargs):
|
||||
out_idx = args.index("--output")
|
||||
Path(args[out_idx + 1]).write_text("packed content")
|
||||
return MagicMock(returncode=0, stdout="", stderr="")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
|
||||
intern.run([str(tmp_path)], "summarize", compress=True)
|
||||
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert "--compress" in call_args
|
||||
|
||||
|
||||
def test_multiple_paths_passed_to_repomix(tmp_path):
|
||||
"""All paths are forwarded to repomix, not just the first."""
|
||||
intern = RepoRead()
|
||||
intern.system_prompt = "assistant"
|
||||
intern.client = MagicMock()
|
||||
intern.client.chat.completions.create.return_value = _make_llm_mock()
|
||||
|
||||
dir1 = tmp_path / "a"
|
||||
dir2 = tmp_path / "b"
|
||||
dir1.mkdir()
|
||||
dir2.mkdir()
|
||||
|
||||
with patch("subprocess.run") as mock_run, \
|
||||
patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]):
|
||||
def fake_run(args, **kwargs):
|
||||
out_idx = args.index("--output")
|
||||
Path(args[out_idx + 1]).write_text("packed")
|
||||
return MagicMock(returncode=0, stdout="", stderr="")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
|
||||
intern.run([str(dir1), str(dir2)], "overview")
|
||||
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert str(dir1) in call_args
|
||||
assert str(dir2) in call_args
|
||||
|
||||
|
||||
def test_empty_paths_returns_error():
|
||||
"""Empty paths list returns error."""
|
||||
intern = RepoRead()
|
||||
result = intern.run([], "summarize")
|
||||
assert isinstance(result, InternResponse)
|
||||
assert "no paths" in result.text.lower()
|
||||
|
||||
|
||||
def test_repomix_timeout_returns_error(tmp_path):
|
||||
"""Repomix timeout returns graceful error."""
|
||||
intern = RepoRead()
|
||||
|
||||
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("npx", 120)), \
|
||||
patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]):
|
||||
result = intern.run([str(tmp_path)], "summarize")
|
||||
assert isinstance(result, InternResponse)
|
||||
assert "timed out" in result.text.lower()
|
||||
|
||||
|
||||
def test_repomix_failure_returns_error(tmp_path):
|
||||
"""Repomix non-zero return returns stderr."""
|
||||
intern = RepoRead()
|
||||
|
||||
with patch("subprocess.run") as mock_run, \
|
||||
patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]):
|
||||
mock_run.side_effect = subprocess.CalledProcessError(
|
||||
1, "npx", stderr="package not found"
|
||||
)
|
||||
|
||||
result = intern.run([str(tmp_path)], "summarize")
|
||||
assert isinstance(result, InternResponse)
|
||||
assert "failed" in result.text.lower()
|
||||
|
||||
|
||||
def test_npx_not_found_returns_error(tmp_path):
|
||||
"""Missing npx returns helpful message."""
|
||||
intern = RepoRead()
|
||||
|
||||
with patch("subprocess.run", side_effect=FileNotFoundError), \
|
||||
patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]):
|
||||
result = intern.run([str(tmp_path)], "summarize")
|
||||
assert isinstance(result, InternResponse)
|
||||
assert "node.js" in result.text.lower()
|
||||
|
||||
|
||||
def test_budget_exceeded_returns_error(tmp_path):
|
||||
"""Large packed output triggers BudgetExceededError."""
|
||||
intern = RepoRead()
|
||||
intern.context_budget = 10 # very low to force overflow
|
||||
|
||||
with patch("subprocess.run") as mock_run, \
|
||||
patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]):
|
||||
def fake_run(args, **kwargs):
|
||||
out_idx = args.index("--output")
|
||||
Path(args[out_idx + 1]).write_text("x" * 200)
|
||||
return MagicMock(returncode=0, stdout="", stderr="")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
|
||||
result = intern.run([str(tmp_path)], "summarize")
|
||||
assert isinstance(result, BudgetExceededError)
|
||||
assert result.tokens > result.limit
|
||||
assert result.hint
|
||||
|
||||
|
||||
def test_top_files_by_size_sorts_by_content_length():
|
||||
"""_top_files_by_size returns files sorted by content size, not document order."""
|
||||
packed = (
|
||||
'<file path="small.py">x</file>\n'
|
||||
'<file path="large.py">' + "y" * 500 + '</file>\n'
|
||||
'<file path="medium.py">' + "z" * 100 + '</file>\n'
|
||||
)
|
||||
result = _top_files_by_size(packed, n=3)
|
||||
assert result[0] == "large.py", f"Expected large.py first, got {result[0]}"
|
||||
assert result[1] == "medium.py", f"Expected medium.py second, got {result[1]}"
|
||||
assert result[2] == "small.py", f"Expected small.py third, got {result[2]}"
|
||||
|
||||
|
||||
def test_repomix_runs_without_pipes(tmp_path):
|
||||
"""Anti-deadlock invariant: repomix must NOT be launched with captured pipes.
|
||||
|
||||
capture_output / stdout=PIPE spawns reader threads that communicate() joins;
|
||||
an orphaned grandchild holding the pipe write-end makes that join hang forever
|
||||
(the real-world 6-minute repo_read wedge). stdout must be DEVNULL, stdin DEVNULL,
|
||||
and stderr a real file (has fileno) — never subprocess.PIPE.
|
||||
"""
|
||||
intern = RepoRead()
|
||||
intern.system_prompt = "assistant"
|
||||
intern.client = MagicMock()
|
||||
intern.client.chat.completions.create.return_value = _make_llm_mock()
|
||||
|
||||
with patch("subprocess.run") as mock_run, \
|
||||
patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]):
|
||||
def fake_run(args, **kwargs):
|
||||
out_idx = args.index("--output")
|
||||
Path(args[out_idx + 1]).write_text("packed")
|
||||
return MagicMock(returncode=0)
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
intern.run([str(tmp_path)], "summarize")
|
||||
|
||||
kwargs = mock_run.call_args.kwargs
|
||||
assert kwargs.get("capture_output") is not True, "must not capture_output (pipes)"
|
||||
assert kwargs.get("stdout") is subprocess.DEVNULL, "stdout must be DEVNULL"
|
||||
assert kwargs.get("stdin") is subprocess.DEVNULL, "stdin must be DEVNULL"
|
||||
assert kwargs.get("stderr") is not subprocess.PIPE, "stderr must not be a PIPE"
|
||||
|
||||
|
||||
def test_top_files_by_size_limits_count():
|
||||
"""_top_files_by_size respects the n parameter."""
|
||||
packed = (
|
||||
'<file path="a.py">xx</file>\n'
|
||||
'<file path="b.py">yy</file>\n'
|
||||
'<file path="c.py">zz</file>\n'
|
||||
)
|
||||
result = _top_files_by_size(packed, n=2)
|
||||
assert len(result) == 2
|
||||
54
lib/interns-mcp/tests/test_safety.py
Normal file
54
lib/interns-mcp/tests/test_safety.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Tests for safety path matcher."""
|
||||
|
||||
from interns_mcp.safety import check_paths
|
||||
|
||||
|
||||
def test_clean_paths_pass():
|
||||
assert check_paths(["/home/user/project/src/main.py"]) == []
|
||||
|
||||
|
||||
def test_env_file_blocked():
|
||||
blocked = check_paths(["project/.env"])
|
||||
assert len(blocked) == 1
|
||||
assert blocked[0]["pattern"] == "**/.env"
|
||||
|
||||
|
||||
def test_env_local_blocked():
|
||||
blocked = check_paths(["project/.env.local"])
|
||||
assert len(blocked) == 1
|
||||
|
||||
|
||||
def test_secrets_dir_blocked():
|
||||
blocked = check_paths(["project/secrets/api_key.txt"])
|
||||
assert len(blocked) == 1
|
||||
|
||||
|
||||
def test_pem_file_blocked():
|
||||
blocked = check_paths(["/home/user/.ssh/id_rsa.pem"])
|
||||
assert len(blocked) == 1
|
||||
|
||||
|
||||
def test_ssh_dir_blocked():
|
||||
blocked = check_paths(["/home/user/.ssh/config"])
|
||||
assert len(blocked) == 1
|
||||
|
||||
|
||||
def test_credentials_blocked():
|
||||
blocked = check_paths(["project/credentials.json"])
|
||||
assert len(blocked) == 1
|
||||
|
||||
|
||||
def test_key_file_blocked():
|
||||
blocked = check_paths(["project/server.key"])
|
||||
assert len(blocked) == 1
|
||||
|
||||
|
||||
def test_mixed_paths():
|
||||
blocked = check_paths(["src/app.py", "project/.env", "README.md"])
|
||||
assert len(blocked) == 1
|
||||
assert blocked[0]["path"] == "project/.env"
|
||||
|
||||
|
||||
def test_extra_patterns():
|
||||
blocked = check_paths(["data/private.csv"], extra_patterns=["data/**"])
|
||||
assert len(blocked) == 1
|
||||
11
lib/projects-meta-mcp/.gitignore
vendored
Normal file
11
lib/projects-meta-mcp/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
auth.toml
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
11
lib/projects-meta-mcp/CLAUDE.md
Normal file
11
lib/projects-meta-mcp/CLAUDE.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# CLAUDE.md
|
||||
# Agent instructions. Each line is a trigger for an installed skill.
|
||||
|
||||
talk like a caveman
|
||||
use superpowers
|
||||
use project wiki
|
||||
use task management system
|
||||
check across all projects
|
||||
pull remote before work
|
||||
follow project discipline
|
||||
we're on Windows
|
||||
74
lib/projects-meta-mcp/README.md
Normal file
74
lib/projects-meta-mcp/README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# projects-meta-mcp
|
||||
|
||||
MCP-сервер: агрегирует статусы задач (`.tasks/STATUS.md`) и общие знания (репо `projects-wiki`, локально клонится в `~/projects/.wiki/`) поверх множества проектов и нескольких машин.
|
||||
|
||||
- Источник правды: Gitea (`https://git.kzntsv.site`, owner `OpeItcLoc03`).
|
||||
- Процесс — локальный на каждой машине (offline-критично).
|
||||
- Данные — локальный кэш + клон `projects-wiki`, синхронизируются скриптом.
|
||||
|
||||
## Bootstrap
|
||||
|
||||
```bash
|
||||
git clone https://git.kzntsv.site/OpeItcLoc03/projects-meta-mcp ~/.local/projects-meta-mcp
|
||||
cd ~/.local/projects-meta-mcp
|
||||
npm install && npm run build
|
||||
|
||||
git clone https://git.kzntsv.site/OpeItcLoc03/projects-wiki ~/projects/.wiki
|
||||
|
||||
mkdir -p ~/.config/projects-mcp
|
||||
cp auth.toml.example ~/.config/projects-mcp/auth.toml
|
||||
# отредактировать auth.toml — заполнить gitea_token
|
||||
|
||||
node dist/sync.js # первый sync
|
||||
```
|
||||
|
||||
## Регистрация в Claude Code
|
||||
|
||||
Добавить в `~/.claude.json` (расположение файла см. ниже для вашей ОС):
|
||||
|
||||
### Windows (PowerShell)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"projects-meta": {
|
||||
"command": "node",
|
||||
"args": ["C:\\Users\\<USER>\\.local\\projects-meta-mcp\\dist\\server.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Файл расположен по адресу: `%USERPROFILE%\.claude.json`
|
||||
|
||||
### Linux / macOS
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"projects-meta": {
|
||||
"command": "node",
|
||||
"args": ["/home/<USER>/.local/projects-meta-mcp/dist/server.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Файл расположен по адресу: `~/.claude.json`
|
||||
|
||||
**Примечание:** замените `<USER>` на ваше имя пользователя.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Назначение |
|
||||
|------|------------|
|
||||
| `tasks.aggregate` | Свод активных задач по всем проектам |
|
||||
| `tasks.search` | Поиск по slug/next-action |
|
||||
| `tasks.get` | Полный STATUS.md одного проекта |
|
||||
| `knowledge.search` | Поиск по shared-вики, фильтр по домену cwd |
|
||||
| `knowledge.get` | Полный текст одной страницы |
|
||||
| `knowledge.suggest_promote` | Кандидаты на промоушен из `.wiki/concepts/` текущего проекта |
|
||||
| `meta.status` | Возраст кэша + диагностика |
|
||||
|
||||
См. дизайн: [docs/superpowers/specs/2026-04-29-projects-meta-mcp-design.md](docs/superpowers/specs/2026-04-29-projects-meta-mcp-design.md)
|
||||
План реализации: [docs/superpowers/plans/2026-04-29-bootstrap-implementation.md](docs/superpowers/plans/2026-04-29-bootstrap-implementation.md)
|
||||
31
lib/projects-meta-mcp/auth.toml.example
Normal file
31
lib/projects-meta-mcp/auth.toml.example
Normal file
@@ -0,0 +1,31 @@
|
||||
# projects-meta-mcp auth config
|
||||
# Copy to ~/.config/projects-mcp/auth.toml and fill gitea_token.
|
||||
|
||||
gitea_url = "https://git.kzntsv.site"
|
||||
gitea_user = "OpeItcLoc03" # acting identity for commits
|
||||
gitea_token = "REPLACE_WITH_PERSONAL_ACCESS_TOKEN" # scope: read:repository (write:repository for tasks.create / knowledge.ingest)
|
||||
|
||||
# Optional: Gitea owners (users / orgs) sync iterates over to discover repos.
|
||||
# Defaults to [gitea_user] when absent or empty. Add organisations here to
|
||||
# aggregate cross-organisation projects under one acting identity.
|
||||
# gitea_owners = ["victor", "cancel_music"]
|
||||
|
||||
# Optional: owners synced into the cache (so MCP-mutate tools can target them)
|
||||
# but **hidden** from aggregation views (`tasks.aggregate / tasks.search /
|
||||
# tasks.get`). Use this for infra repos you want to write to via MCP without
|
||||
# polluting the cross-project dashboard. Default: [].
|
||||
#
|
||||
# Example: keep `OpeItcLoc03/{common,claude-skills,factory,...}` resolvable for
|
||||
# `tasks.create target_project: "OpeItcLoc03/common"` but not in aggregation:
|
||||
# gitea_aggregate_skip_owners = ["OpeItcLoc03"]
|
||||
|
||||
# Optional: name of the Gitea repo holding the cross-project agenda tasks board.
|
||||
# Accepts bare name (resolved against gitea_user) or qualified "<owner>/<repo>".
|
||||
# Default: "projects-tasks". If the repo doesn't exist (or has no STATUS.md),
|
||||
# sync falls back to ~/projects/.tasks/STATUS.md on the local FS.
|
||||
# agenda_tasks_repo = "OpeItcLoc03/agenda"
|
||||
|
||||
# Optional: name of the Gitea repo holding the cross-project shared wiki.
|
||||
# Default: "projects-wiki". Used as the destination when knowledge.ingest /
|
||||
# knowledge.promote receive `target_project = "agenda"`.
|
||||
# projects_wiki_repo = "projects-wiki"
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,269 @@
|
||||
# projects-meta-mcp — Design
|
||||
|
||||
**Date:** 2026-04-29
|
||||
**Status:** Approved (брейнсторм завершён, спека к ревью)
|
||||
**Owner:** vitya
|
||||
|
||||
## Цель
|
||||
|
||||
Дать Claude (и любому MCP-клиенту) единый, дешёвый и offline-устойчивый доступ к двум видам мета-информации, разбросанным по множеству проектов и нескольких машин:
|
||||
|
||||
1. **Статусы задач** — свод `.tasks/STATUS.md` всех проектов в Gitea.
|
||||
2. **Общие знания** — поиск и чтение страниц общей вики (`projects-wiki`), отфильтрованных по домену текущего проекта.
|
||||
|
||||
## Не цели (явный YAGNI)
|
||||
|
||||
- ❌ Удалённый MCP-процесс на Synology (HTTP/SSE-транспорт). Откладывается до момента, когда «truly always-on» станет реальной болью.
|
||||
- ❌ Карантин `_pending/` для projects-wiki. Решение по промоушену принимается в моменте.
|
||||
- ❌ Sparse-checkout всех проектов на каждую машину. API + кэш проще.
|
||||
- ❌ Серверный агрегатор / git-hook на стороне Gitea. Клиентский sync проще.
|
||||
- ❌ Embedding/семантический поиск projects-wiki. Достаточно поиска по тексту/тегам.
|
||||
- ❌ Управление зависимостями между проектами, GANTT, deadlines. Только статусы.
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ Gitea (Synology)│
|
||||
│ - все проекты │
|
||||
│ - projects-wiki │
|
||||
│ - projects-meta-│
|
||||
│ mcp (этот код)│
|
||||
└────────┬─────────┘
|
||||
│ pull / API
|
||||
▼
|
||||
┌────────────────── каждая машина ─────────────────┐
|
||||
│ │
|
||||
│ ~/projects/.wiki/ ← git clone │
|
||||
│ ~/.cache/projects-mcp/ ← JSON-кэш STATUS │
|
||||
│ │
|
||||
│ sync-script (cron / руками): │
|
||||
│ - git pull в projects-wiki │
|
||||
│ - Gitea API → собрать STATUS.md → JSON-кэш │
|
||||
│ - offline → no-op, кэш остаётся │
|
||||
│ │
|
||||
│ mcp-server (локальный, stdio): │
|
||||
│ Tools: │
|
||||
│ tasks.aggregate() │
|
||||
│ tasks.search(q) │
|
||||
│ knowledge.search(q, domain?) │
|
||||
│ knowledge.get(slug) │
|
||||
│ knowledge.suggest_promote() │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Принцип:** MCP в hot path читает только локальные файлы. Сетевые вызовы — только в `sync-script`.
|
||||
|
||||
## Компоненты
|
||||
|
||||
### 1. `projects-wiki` — отдельный git-репозиторий на Gitea
|
||||
|
||||
**Имя репо:** `projects-wiki`. Клонируется на каждую машину под `~/projects/.wiki/`.
|
||||
|
||||
**Структура:**
|
||||
|
||||
```
|
||||
~/projects/.wiki/
|
||||
├── CLAUDE.md ← правила промоушена + список доменов
|
||||
├── README.md
|
||||
├── cross/ ← кросс-доменные знания (git, vscode, claude-code, общие гочи)
|
||||
├── node/ ← Node.js / TypeScript / npm / yarn
|
||||
├── embedded/ ← микроконтроллеры, PlatformIO, STM32, Arduino
|
||||
├── web/ ← фронтенд, браузер, HTTP, Nginx
|
||||
└── ... ← новые домены добавляются по мере появления проектов
|
||||
```
|
||||
|
||||
**Frontmatter каждой страницы:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Windows yarn requires exec()
|
||||
domain: node # обязательное: node | embedded | web | cross | ...
|
||||
shared_at: 2026-04-29 # дата промоушена
|
||||
applies_to: [windows] # опц. — платформенные ограничения
|
||||
source_project: npm-mcp # опц. — откуда промоутили
|
||||
---
|
||||
```
|
||||
|
||||
Тело — стандартный concept: **Why** + **Когда / Как чинить**.
|
||||
|
||||
**`projects-wiki/CLAUDE.md`** содержит:
|
||||
- Полный список валидных доменов и что в каждый кладётся.
|
||||
- Эвристику промоушена (см. ниже).
|
||||
- Чёрный список (имена проектов, бизнес-доменов — никогда не упоминаются в shared).
|
||||
|
||||
### 2. `sync-script`
|
||||
|
||||
Bash или Node-скрипт. На каждой машине, запускается cron'ом / руками / git-хуком.
|
||||
|
||||
**Шаги:**
|
||||
|
||||
1. **Pull projects-wiki:**
|
||||
```
|
||||
git -C ~/projects/.wiki pull --quiet
|
||||
```
|
||||
Offline → silently fail (exit 0), кэш не трогается.
|
||||
|
||||
2. **Скачать список репозиториев пользователя через Gitea API:**
|
||||
```
|
||||
GET https://git.kzntsv.site/api/v1/users/OpeItcLoc03/repos?limit=50
|
||||
```
|
||||
С токеном (в `~/.config/projects-mcp/auth.toml`).
|
||||
|
||||
3. **Параллельно (limit 10) скачать `STATUS.md` каждого:**
|
||||
```
|
||||
GET https://git.kzntsv.site/api/v1/repos/OpeItcLoc03/{repo}/raw/.tasks/STATUS.md
|
||||
```
|
||||
404 → у проекта нет тасок, пропустить. Сетевая ошибка → пропустить, оставить старое значение в кэше.
|
||||
|
||||
4. **Собрать в `~/.cache/projects-mcp/tasks.json`** атомарно (write to `tasks.json.tmp`, rename):
|
||||
```json
|
||||
{
|
||||
"synced_at": "2026-04-29T12:00:00Z",
|
||||
"synced_from": "https://git.kzntsv.site",
|
||||
"machine": "vitya-desktop",
|
||||
"projects": [
|
||||
{
|
||||
"name": "npm-mcp",
|
||||
"default_branch": "main",
|
||||
"fetched_at": "2026-04-29T12:00:01Z",
|
||||
"active_tasks": [
|
||||
{"slug": "wiki-self-ingest", "status": "active", "next": "..."}
|
||||
],
|
||||
"all_tasks_count": 5,
|
||||
"raw": "<полный STATUS.md>"
|
||||
}
|
||||
],
|
||||
"errors": [{"project": "books", "reason": "network"}]
|
||||
}
|
||||
```
|
||||
|
||||
5. **Exit 0 даже при частичных ошибках.** Логи в `~/.cache/projects-mcp/sync.log`.
|
||||
|
||||
### 3. `mcp-server`
|
||||
|
||||
Stateless Node-процесс, stdio-транспорт MCP. Читает только локальные файлы. Не делает сетевых вызовов.
|
||||
|
||||
#### Tool surface
|
||||
|
||||
| Tool | Аргументы | Возвращает |
|
||||
|------|-----------|------------|
|
||||
| `tasks.aggregate()` | `(filter?: { status?, project? })` | Список активных задач по всем проектам. Только мета (slug, project, status, next-action), не полный текст. |
|
||||
| `tasks.search(query)` | `query: string` | Поиск по `STATUS.md`-кэшу (substring + per-task title). |
|
||||
| `tasks.get(project)` | `project: string` | Полный `STATUS.md` одного проекта. |
|
||||
| `knowledge.search(query, opts?)` | `query: string, opts?: { domain?: string\|"all", limit?: number }` | До 10 заголовков + 1-строчное описание. По умолчанию фильтр зависит от detected domain текущего проекта (см. ниже). |
|
||||
| `knowledge.get(slug)` | `slug: string` (например `node/windows-yarn-exec`) | Полная страница (frontmatter + тело). |
|
||||
| `knowledge.suggest_promote()` | `()` | Возвращает кандидатов на промоушен из текущего проекта. См. логику ниже. |
|
||||
| `meta.status()` | `()` | Возвращает диагностику: когда был последний sync, кэш-stale-возраст, сколько проектов, сколько shared-страниц. |
|
||||
|
||||
#### Two-step паттерн
|
||||
|
||||
`search` всегда возвращает только индекс (заголовок + 1 строка). `get` возвращает полный текст. Минимизирует токены — модель сама решает что углублять.
|
||||
|
||||
#### Detect domain текущего проекта
|
||||
|
||||
При запуске MCP получает `cwd` от клиента. Алгоритм:
|
||||
|
||||
1. Если в `cwd` есть `package.json` → `node`.
|
||||
2. Если есть `platformio.ini` / `*.ino` / `CMakeLists.txt` с упоминанием `arm-none-eabi` или embedded SDK → `embedded`.
|
||||
3. Если есть `next.config.*` / `vite.config.*` / `index.html` без `package.json` (статика) → `web`.
|
||||
4. Иначе → `unknown`.
|
||||
|
||||
Логика фильтрации `knowledge.search`:
|
||||
- Detected domain — конкретный (`node` / `embedded` / `web`) → показываем `domain == detected || domain == "cross"`.
|
||||
- Detected domain `unknown` → фильтра нет (видны все домены).
|
||||
- Опциональный аргумент `opts.domain` пользователя/Claude переопределяет: `"all"` снимает фильтр явно, конкретное имя меняет таргет.
|
||||
|
||||
`cross` в frontmatter — это **тег страницы** (универсальное знание). Detected `unknown` — это **режим поиска** (фильтра нет). Не путать.
|
||||
|
||||
Логика выносится в отдельный модуль `domain-detector` (тестируется юнит-тестами).
|
||||
|
||||
#### `knowledge.suggest_promote()`
|
||||
|
||||
Логика:
|
||||
|
||||
1. Прочесть все `concepts/*.md` в `<cwd>/.wiki/concepts/`.
|
||||
2. Для каждого применить эвристику:
|
||||
- **Включить как кандидата**, если страница: упоминает платформу/тулинг (Windows, yarn, Node, MCP, git, Docker), не упоминает доменные имена из чёрного списка `projects-wiki/CLAUDE.md`.
|
||||
- **Усилить сигнал**, если в `projects-wiki` есть страница с близким заголовком в другом домене (= знание подтверждено повторно).
|
||||
3. Вернуть список кандидатов: `{ slug, current_path, suggested_domain, confidence, reason }`.
|
||||
4. Решение принимает пользователь (Claude в чате предлагает строкой `_Кандидат на shared: ... Промоутить?_`, ждёт `y`/`n`).
|
||||
|
||||
Промоушен ≠ автомат. Tool только **возвращает кандидатов**, ничего не пишет.
|
||||
|
||||
### 4. Bootstrap
|
||||
|
||||
На новой машине (пример Windows + Git Bash; на других ОС адаптировать пути):
|
||||
|
||||
```bash
|
||||
# один раз:
|
||||
GITEA=https://git.kzntsv.site
|
||||
OWNER=OpeItcLoc03
|
||||
git clone $GITEA/$OWNER/projects-meta-mcp ~/.local/projects-meta-mcp
|
||||
cd ~/.local/projects-meta-mcp && npm install && npm run build
|
||||
git clone $GITEA/$OWNER/projects-wiki ~/projects/.wiki
|
||||
|
||||
mkdir -p ~/.config/projects-mcp
|
||||
cp ~/.local/projects-meta-mcp/auth.toml.example ~/.config/projects-mcp/auth.toml
|
||||
# отредактировать auth.toml — заполнить gitea_token
|
||||
|
||||
node ~/.local/projects-meta-mcp/dist/sync.js # первый sync
|
||||
# зарегистрировать MCP в claude code config (см. ниже)
|
||||
```
|
||||
|
||||
**Формат `auth.toml`:**
|
||||
|
||||
```toml
|
||||
gitea_url = "https://git.kzntsv.site"
|
||||
gitea_user = "OpeItcLoc03"
|
||||
gitea_token = "..." # personal access token, scope: read:repository
|
||||
```
|
||||
|
||||
**Регистрация в Claude Code** (`~/.claude.json` или per-project) — пути в Windows-формате с прямыми слэшами:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"projects-meta": {
|
||||
"command": "node",
|
||||
"args": ["C:/Users/vitya/.local/projects-meta-mcp/dist/server.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Поведение в offline
|
||||
|
||||
| Ситуация | Что работает | Что не работает |
|
||||
|----------|--------------|------------------|
|
||||
| Сеть есть, синк свежий | Всё | — |
|
||||
| Сеть есть, sync не запускался N часов | Всё (кэш отдаёт старое) | Свежесть других машин |
|
||||
| Сеть пропала | `tasks.*`, `knowledge.search/get` — всё работает | `knowledge.suggest_promote` работает; `git push` после промоушена откладывается |
|
||||
| Кэш отсутствует (новая машина без bootstrap) | Только `meta.status()` (отдаёт diagnostic с пустым кэшем) | Всё остальное |
|
||||
|
||||
`meta.status()` всегда возвращает возраст кэша, чтобы Claude мог предупредить пользователя «данные старше 24 часов».
|
||||
|
||||
## Авторизация
|
||||
|
||||
- Gitea API token: scope `read:repository` (и `write:repository` для пуша projects-wiki).
|
||||
- Хранится в `~/.config/projects-mcp/auth.toml` (gitignore'd).
|
||||
- MCP-сервер сам не читает токен — только `sync-script`. MCP оперирует уже скачанными локальными файлами.
|
||||
|
||||
## Тестирование
|
||||
|
||||
- **Unit:** `domain-detector`, парсер `STATUS.md`, эвристика промоушена.
|
||||
- **Integration:** sync-script против локального Gitea (Docker fixture). Проверка: rate limit, 404 на отсутствующий `STATUS.md`, частичный отказ.
|
||||
- **MCP smoke test:** запустить сервер, дёрнуть каждый tool через MCP-клиент-fixture.
|
||||
|
||||
## Открытые вопросы (не блокеры)
|
||||
|
||||
1. **Cron vs git-hook vs руками** — определится при первом использовании. Стартовать с «руками + npm script», добавить cron когда станет лень.
|
||||
2. **Уведомление о stale-кэше** — порог в часах? Стартовать с 24, корректировать.
|
||||
3. **Multi-user** — пока однопользовательский (`OpeItcLoc03`). Если кто-то ещё начнёт пользоваться — переделать `users/{owner}/repos` в параметр из `auth.toml`.
|
||||
4. **`projects-wiki` GC** — раз в полгода аудит, какие страницы не использовались. Будет отдельный tool `meta.audit_shared(months)` в v2.
|
||||
|
||||
## Дальнейшие шаги
|
||||
|
||||
1. Создать репозиторий `projects-meta-mcp` на Gitea, запушить этот скелет.
|
||||
2. Создать репозиторий `projects-wiki` на Gitea с минимальной структурой (`CLAUDE.md`, `cross/`, `node/`).
|
||||
3. Перейти к фазе writing-plans: расписать пошаговый план реализации (sync-script сначала, MCP-сервер вторым, эвристика промоушена третьим).
|
||||
@@ -0,0 +1,170 @@
|
||||
# Federated knowledge query — `knowledge.ask_projects` / `knowledge.get_from`
|
||||
|
||||
**Status:** design approved 2026-04-30
|
||||
**Repo:** `projects-meta-mcp`
|
||||
**Adds:** 2 read-only MCP tools + 1 sync extension + 1 cache field
|
||||
|
||||
## Problem
|
||||
|
||||
Сейчас `knowledge.search` / `knowledge.get` читают только из shared `projects-wiki` (общая мета-вики). Если знания там нет — у агента тупик. При этом локальные `.wiki/` отдельных проектов могут это знание содержать (например, `node-utils/.wiki/concepts/windows-yarn-exec.md` — частный кейс который никто ещё не промоутил в shared).
|
||||
|
||||
Хотим: если в shared не нашлось → опросить **конкретные** другие проекты (юзер указывает имена) на предмет того же знания. Если и там нет — оформить через уже существующий `tasks.create({target_project, body})`.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Broadcast по всем проектам.** Спрашиваем только перечисленные явно — экономим токены и сетку, юзер знает кого спрашивать.
|
||||
- **Авто-fallback внутри `knowledge.search`.** `search` остаётся узким (только shared); агент сам решает звать ли `ask_projects`.
|
||||
- **Новый "request-knowledge-prep" механизм.** `tasks.create` уже умеет ставить задачу другому проекту — Оккам.
|
||||
- **Confirm-gate / dry-run.** Оба новых тула read-only.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Tools (новые, read-only)
|
||||
|
||||
#### `knowledge.ask_projects(query, projects[], limit?, types?)`
|
||||
|
||||
Ищет в `.wiki/` указанных репо.
|
||||
|
||||
**Input:**
|
||||
| field | type | required | default |
|
||||
|---|---|---|---|
|
||||
| `query` | string | yes | — |
|
||||
| `projects` | string[] | yes | — |
|
||||
| `limit` | int (1–20) | no | `5` (per project) |
|
||||
| `types` | string[] | no | `["entities","concepts","packages","sources"]` (`raw` исключён по дефолту — тяжёлые dumps; чтобы включить, явно перечисли все 5) |
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"asked_projects": ["foo", "bar"],
|
||||
"results": [
|
||||
{
|
||||
"project": "foo",
|
||||
"matches": [
|
||||
{"slug": "concepts/x", "type": "concepts", "title": "...", "snippet": "..."}
|
||||
]
|
||||
},
|
||||
{"project": "bar", "error": "no wiki — repo has no .wiki/index.md"}
|
||||
],
|
||||
"next_action_hint": "knowledge.get_from(project, slug) для полного текста; tasks.create(target_project, body) если знание не оформлено"
|
||||
}
|
||||
```
|
||||
|
||||
#### `knowledge.get_from(project, slug)`
|
||||
|
||||
Полный текст одной страницы из конкретного проекта (live через Gitea API).
|
||||
|
||||
**Input:**
|
||||
| field | type | required |
|
||||
|---|---|---|
|
||||
| `project` | string | yes |
|
||||
| `slug` | string | yes — относительный путь под `.wiki/` без `.md`, включает type-префикс (например `concepts/foo` или `entities/bar`) |
|
||||
|
||||
**Output:** raw markdown body, либо `{error}` если не найдено.
|
||||
|
||||
### Sync extension
|
||||
|
||||
Расширяем `runSync` в [src/lib/sync-runner.ts](../../src/lib/sync-runner.ts):
|
||||
- В per-repo worker добавляется параллельный `getRawFile(user, repo, '.wiki/index.md', branch)` рядом с уже существующим `.tasks/STATUS.md`.
|
||||
- Если оба `null` → skip как и раньше. Если есть хоть один — пишем `ProjectStatus`.
|
||||
|
||||
### Cache extension
|
||||
|
||||
В [src/lib/cache.ts](../../src/lib/cache.ts):
|
||||
|
||||
```ts
|
||||
interface ProjectStatus {
|
||||
// ...existing fields
|
||||
wiki_index?: string; // NEW — raw .wiki/index.md, undefined если нет
|
||||
}
|
||||
```
|
||||
|
||||
Один общий `cache.json`, не плодим параллельный кэш.
|
||||
|
||||
### Index parser
|
||||
|
||||
Новый `src/lib/wiki-index-parser.ts`:
|
||||
- Парсит canonical `index.md` (формат из [src/lib/wiki-writer.ts](../../src/lib/wiki-writer.ts) `freshIndexMd()` / `insertIndexEntry()`).
|
||||
- Возвращает `Array<{slug: string; type: WikiPageType; title: string}>`.
|
||||
- Структура — секции по типам, под каждой list `- [title](type/slug.md)`.
|
||||
|
||||
### Read flow `ask_projects`
|
||||
|
||||
1. `readCache(cacheFile)` → найти проекты из `projects[]` параметра по `name`.
|
||||
2. Для каждого:
|
||||
- `wiki_index === undefined` → `{project, error: "no wiki — ..."}`
|
||||
- Иначе: парсим index, фильтр по `types`, substring case-insensitive по `slug` + `title` против `query`.
|
||||
- Берём первые `limit` совпадений.
|
||||
- Для каждого — `backend.getRawFile(user, project, '.wiki/' + type + '/' + slug + '.md', branch)` (concurrency-pool).
|
||||
- Из body вырезаем snippet (~80 char вокруг матча, как в `knowledge.search`).
|
||||
3. Если проекта нет в кэше → `{project, error: "unknown project — run sync first"}`.
|
||||
4. Если live fetch упал → matched entry с `snippet: "(fetch failed)"`, slug+title сохраняем.
|
||||
|
||||
### Read flow `get_from`
|
||||
|
||||
1. `readCache` → найти project → `default_branch`.
|
||||
2. `backend.getRawFile(user, project, '.wiki/' + slug + '.md', branch)` — slug включает type-префикс (тот же формат что отдаёт `ask_projects` в `matches[].slug`).
|
||||
3. `null` → error `page not found: <project>/.wiki/<slug>.md`.
|
||||
|
||||
## Matching semantics
|
||||
|
||||
Реплицируем `knowledge.search`:
|
||||
- Substring case-insensitive
|
||||
- Сначала по `slug` + `title` (на index)
|
||||
- Потом по `body` (для snippet) при fetched-странице
|
||||
- `raw` тип исключён по дефолту (тяжёлые dumps); включается явным перечислением — `types: ["concepts","raw"]` или все 5 типов
|
||||
|
||||
## Error branches
|
||||
|
||||
| Случай | Поведение |
|
||||
|---|---|
|
||||
| Index.md отсутствует в репо | `{project, error: "no wiki"}` — тихо, остальные проекты обрабатываются |
|
||||
| Project не в `cache.projects[]` | `{project, error: "unknown project — run sync first"}` |
|
||||
| Live fetch страницы упал | matched entry с `snippet: "(fetch failed)"` |
|
||||
| Парсинг index.md упал | `{project, error: "wiki index malformed: <reason>"}` |
|
||||
| `backend` не сконфигурирован (auth.toml нет) | tool возвращает error — как сейчас у write-tools |
|
||||
|
||||
## Configuration
|
||||
|
||||
Новых конфиг-полей нет. Целевые проекты передаются параметром `projects[]`. Sync уже знает все репы юзера через `listUserRepos`.
|
||||
|
||||
## Testing
|
||||
|
||||
Все новые тесты — vitest, stub Backend. Никаких реальных Gitea-вызовов.
|
||||
|
||||
| Файл | Что покрывает |
|
||||
|---|---|
|
||||
| `tests/lib/wiki-index-parser.test.ts` | Валидный index.md, пустые секции, поломанный markdown, nested slugs (`concepts/foo/bar`) |
|
||||
| `tests/lib/sync-runner.test.ts` (расширение) | `wiki_index` подтягивается параллельно с STATUS.md; репо только с `.wiki/` без `.tasks/` попадает в кэш; ни того ни другого → skip |
|
||||
| `tests/tools/knowledge-ask-projects.test.ts` | Match по slug+title, default types-фильтр (raw исключён), limit per project, error-branches (`no wiki`, `unknown project`, fetch fail, malformed index), partial success (один проект упал, другие отдали) |
|
||||
| `tests/tools/knowledge-get-from.test.ts` | Happy path, project not found, page not found |
|
||||
|
||||
## Files
|
||||
|
||||
**New:**
|
||||
- `src/lib/wiki-index-parser.ts`
|
||||
- `tests/lib/wiki-index-parser.test.ts`
|
||||
- `tests/tools/knowledge-ask-projects.test.ts`
|
||||
- `tests/tools/knowledge-get-from.test.ts`
|
||||
|
||||
**Modify:**
|
||||
- `src/lib/cache.ts` — `wiki_index?: string` в `ProjectStatus`
|
||||
- `src/lib/sync-runner.ts` — параллельный `getRawFile` для `.wiki/index.md`
|
||||
- `src/tools/knowledge.ts` — 2 новых тула, описания включают цепочку с `tasks.create` для случая "знания нет"
|
||||
- `src/server.ts` — проверить что `cacheFile` + `backend` пробрасываются в `makeKnowledgeTools` (уже частично есть)
|
||||
- `tests/lib/sync-runner.test.ts` — расширить existing
|
||||
|
||||
## Implementation phases
|
||||
|
||||
1. **Parser** — `wiki-index-parser.ts` + тест.
|
||||
2. **Cache + sync** — поле `wiki_index`, расширенный `runSync`, тесты.
|
||||
3. **`ask_projects`** — тул + тест.
|
||||
4. **`get_from`** — тул + тест.
|
||||
5. **Wiring** — `server.ts` deps + `description` тулов с hint про `tasks.create`.
|
||||
6. **Smoke** — `npm run build`, реальный sync, ручной вызов из новой Claude-сессии.
|
||||
|
||||
## Out of scope (для будущих итераций)
|
||||
|
||||
- Авто-suggest проектов в response от `knowledge.search` (heuristic / тематический mapping).
|
||||
- Federated write — пробрасывать ingest сразу в чужой репо (можно уже сейчас через `knowledge.ingest({target_project: "foo", ...})` — есть в MVP-4).
|
||||
- Кэширование тел страниц (сейчас кэшируем только index — это компромисс размер/свежесть).
|
||||
2685
lib/projects-meta-mcp/package-lock.json
generated
Normal file
2685
lib/projects-meta-mcp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
lib/projects-meta-mcp/package.json
Normal file
33
lib/projects-meta-mcp/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "projects-meta-mcp",
|
||||
"version": "2.25.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"projects-meta-mcp": "dist/server.js",
|
||||
"projects-meta-sync": "dist/sync.js",
|
||||
"projects-meta-tasks": "dist/tasks-cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"sync": "node dist/sync.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"smol-toml": "^1.3.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
63
lib/projects-meta-mcp/src/lib/backend.ts
Normal file
63
lib/projects-meta-mcp/src/lib/backend.ts
Normal 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>;
|
||||
}
|
||||
86
lib/projects-meta-mcp/src/lib/cache.ts
Normal file
86
lib/projects-meta-mcp/src/lib/cache.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
227
lib/projects-meta-mcp/src/lib/claim.ts
Normal file
227
lib/projects-meta-mcp/src/lib/claim.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
103
lib/projects-meta-mcp/src/lib/config.ts
Normal file
103
lib/projects-meta-mcp/src/lib/config.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
72
lib/projects-meta-mcp/src/lib/decision-trail-writer.ts
Normal file
72
lib/projects-meta-mcp/src/lib/decision-trail-writer.ts
Normal 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}`;
|
||||
}
|
||||
146
lib/projects-meta-mcp/src/lib/domain-detector.ts
Normal file
146
lib/projects-meta-mcp/src/lib/domain-detector.ts
Normal 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 };
|
||||
}
|
||||
30
lib/projects-meta-mcp/src/lib/git.ts
Normal file
30
lib/projects-meta-mcp/src/lib/git.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
119
lib/projects-meta-mcp/src/lib/gitea.ts
Normal file
119
lib/projects-meta-mcp/src/lib/gitea.ts
Normal 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;
|
||||
84
lib/projects-meta-mcp/src/lib/meta-cli.ts
Normal file
84
lib/projects-meta-mcp/src/lib/meta-cli.ts
Normal 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;
|
||||
}
|
||||
77
lib/projects-meta-mcp/src/lib/meta-host-resolver.ts
Normal file
77
lib/projects-meta-mcp/src/lib/meta-host-resolver.ts
Normal 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'),
|
||||
});
|
||||
}
|
||||
241
lib/projects-meta-mcp/src/lib/meta-sync.ts
Normal file
241
lib/projects-meta-mcp/src/lib/meta-sync.ts
Normal 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 };
|
||||
}
|
||||
122
lib/projects-meta-mcp/src/lib/policy.ts
Normal file
122
lib/projects-meta-mcp/src/lib/policy.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
107
lib/projects-meta-mcp/src/lib/promotion.ts
Normal file
107
lib/projects-meta-mcp/src/lib/promotion.ts
Normal 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;
|
||||
}
|
||||
114
lib/projects-meta-mcp/src/lib/resolve-target.ts
Normal file
114
lib/projects-meta-mcp/src/lib/resolve-target.ts
Normal 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 };
|
||||
}
|
||||
345
lib/projects-meta-mcp/src/lib/status-md-writer.ts
Normal file
345
lib/projects-meta-mcp/src/lib/status-md-writer.ts
Normal 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;
|
||||
}
|
||||
193
lib/projects-meta-mcp/src/lib/status-md.ts
Normal file
193
lib/projects-meta-mcp/src/lib/status-md.ts
Normal 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 };
|
||||
}
|
||||
200
lib/projects-meta-mcp/src/lib/sync-runner.ts
Normal file
200
lib/projects-meta-mcp/src/lib/sync-runner.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
45
lib/projects-meta-mcp/src/lib/wiki-index-parser.ts
Normal file
45
lib/projects-meta-mcp/src/lib/wiki-index-parser.ts
Normal 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;
|
||||
}
|
||||
67
lib/projects-meta-mcp/src/lib/wiki-index.ts
Normal file
67
lib/projects-meta-mcp/src/lib/wiki-index.ts
Normal 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;
|
||||
}
|
||||
191
lib/projects-meta-mcp/src/lib/wiki-writer.ts
Normal file
191
lib/projects-meta-mcp/src/lib/wiki-writer.ts
Normal 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.
|
||||
`;
|
||||
}
|
||||
115
lib/projects-meta-mcp/src/server.ts
Normal file
115
lib/projects-meta-mcp/src/server.ts
Normal 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();
|
||||
95
lib/projects-meta-mcp/src/sync.ts
Normal file
95
lib/projects-meta-mcp/src/sync.ts
Normal 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();
|
||||
}
|
||||
263
lib/projects-meta-mcp/src/tasks-cli.ts
Normal file
263
lib/projects-meta-mcp/src/tasks-cli.ts
Normal 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();
|
||||
}
|
||||
691
lib/projects-meta-mcp/src/tools/knowledge.ts
Normal file
691
lib/projects-meta-mcp/src/tools/knowledge.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
65
lib/projects-meta-mcp/src/tools/meta.ts
Normal file
65
lib/projects-meta-mcp/src/tools/meta.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
115
lib/projects-meta-mcp/src/tools/ops.ts
Normal file
115
lib/projects-meta-mcp/src/tools/ops.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
1235
lib/projects-meta-mcp/src/tools/tasks.ts
Normal file
1235
lib/projects-meta-mcp/src/tools/tasks.ts
Normal file
File diff suppressed because it is too large
Load Diff
21
lib/projects-meta-mcp/src/tools/types.ts
Normal file
21
lib/projects-meta-mcp/src/tools/types.ts
Normal 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 }>;
|
||||
}
|
||||
73
lib/projects-meta-mcp/src/tools/workers.ts
Normal file
73
lib/projects-meta-mcp/src/tools/workers.ts
Normal 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();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
128
lib/projects-meta-mcp/tests/lib/cache.test.ts
Normal file
128
lib/projects-meta-mcp/tests/lib/cache.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
writeCache,
|
||||
readCache,
|
||||
detectCacheInvalidation,
|
||||
CACHE_SCHEMA_VERSION,
|
||||
type CacheFile,
|
||||
} from '../../src/lib/cache.js';
|
||||
|
||||
const sample: CacheFile = {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-04-29T12:00:00.000Z',
|
||||
synced_from: 'https://git.kzntsv.site',
|
||||
machine: 'test',
|
||||
projects: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
describe('cache', () => {
|
||||
it('writes JSON atomically and reads it back', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const file = join(dir, 'tasks.json');
|
||||
await writeCache(file, sample);
|
||||
const back = await readCache(file);
|
||||
expect(back).toEqual(sample);
|
||||
});
|
||||
|
||||
it('does not leave .tmp file on success', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const file = join(dir, 'tasks.json');
|
||||
await writeCache(file, sample);
|
||||
await expect(stat(file + '.tmp')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('returns null when cache file missing', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const back = await readCache(join(dir, 'missing.json'));
|
||||
expect(back).toBeNull();
|
||||
});
|
||||
|
||||
it('creates parent directory if missing', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const file = join(dir, 'nested', 'deep', 'tasks.json');
|
||||
await writeCache(file, sample);
|
||||
const txt = await readFile(file, 'utf8');
|
||||
expect(JSON.parse(txt)).toEqual(sample);
|
||||
});
|
||||
|
||||
it('exports CACHE_SCHEMA_VERSION = 3', () => {
|
||||
expect(CACHE_SCHEMA_VERSION).toBe(3);
|
||||
});
|
||||
|
||||
it('returns null on legacy cache without schemaVersion', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const file = join(dir, 'tasks.json');
|
||||
const legacy = {
|
||||
synced_at: '2026-04-29T12:00:00.000Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'test',
|
||||
projects: [{ name: 'bare-name', default_branch: 'main', fetched_at: '2026-04-29T12:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '' }],
|
||||
errors: [],
|
||||
};
|
||||
await writeFile(file, JSON.stringify(legacy), 'utf8');
|
||||
const back = await readCache(file);
|
||||
expect(back).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on schemaVersion mismatch', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const file = join(dir, 'tasks.json');
|
||||
const v1 = { ...sample, schemaVersion: 1 };
|
||||
await writeFile(file, JSON.stringify(v1), 'utf8');
|
||||
const back = await readCache(file);
|
||||
expect(back).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on v2 cache (pre-agenda-rename, name === "_meta" entries are stale)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const file = join(dir, 'tasks.json');
|
||||
const v2 = { ...sample, schemaVersion: 2 };
|
||||
await writeFile(file, JSON.stringify(v2), 'utf8');
|
||||
const back = await readCache(file);
|
||||
expect(back).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectCacheInvalidation', () => {
|
||||
it('returns invalidated:false when file missing', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const r = await detectCacheInvalidation(join(dir, 'missing.json'));
|
||||
expect(r).toEqual({ invalidated: false, oldVersion: null });
|
||||
});
|
||||
|
||||
it('returns invalidated:true, oldVersion:null on legacy (no field)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const file = join(dir, 'tasks.json');
|
||||
await writeFile(file, JSON.stringify({ synced_at: 't', projects: [] }), 'utf8');
|
||||
const r = await detectCacheInvalidation(file);
|
||||
expect(r).toEqual({ invalidated: true, oldVersion: null });
|
||||
});
|
||||
|
||||
it('returns invalidated:true, oldVersion:1 on v1 cache', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const file = join(dir, 'tasks.json');
|
||||
await writeFile(file, JSON.stringify({ schemaVersion: 1, projects: [] }), 'utf8');
|
||||
const r = await detectCacheInvalidation(file);
|
||||
expect(r).toEqual({ invalidated: true, oldVersion: 1 });
|
||||
});
|
||||
|
||||
it('returns invalidated:true, oldVersion:2 on v2 cache (pre-agenda-rename)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const file = join(dir, 'tasks.json');
|
||||
await writeFile(file, JSON.stringify({ schemaVersion: 2, projects: [] }), 'utf8');
|
||||
const r = await detectCacheInvalidation(file);
|
||||
expect(r).toEqual({ invalidated: true, oldVersion: 2 });
|
||||
});
|
||||
|
||||
it('returns invalidated:false, oldVersion:3 on current cache', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cache-'));
|
||||
const file = join(dir, 'tasks.json');
|
||||
await writeCache(file, sample);
|
||||
const r = await detectCacheInvalidation(file);
|
||||
expect(r).toEqual({ invalidated: false, oldVersion: 3 });
|
||||
});
|
||||
});
|
||||
418
lib/projects-meta-mcp/tests/lib/claim.test.ts
Normal file
418
lib/projects-meta-mcp/tests/lib/claim.test.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isClaimable,
|
||||
isExpiredClaim,
|
||||
isReclaimable,
|
||||
selectClaimableTask,
|
||||
makeClaimStamp,
|
||||
type ClaimCandidate,
|
||||
} from '../../src/lib/claim.js';
|
||||
import type { ParsedTask } from '../../src/lib/status-md.js';
|
||||
|
||||
function task(slug: string, over: Partial<ParsedTask> = {}): ParsedTask {
|
||||
return { slug, status: 'ready', description: slug, next: null, ...over };
|
||||
}
|
||||
|
||||
function cand(project: string, t: ParsedTask): ClaimCandidate {
|
||||
return { project, task: t };
|
||||
}
|
||||
|
||||
describe('isClaimable', () => {
|
||||
it('true only for ⚪ ready + unowned', () => {
|
||||
expect(isClaimable(task('a'))).toBe(true);
|
||||
expect(isClaimable(task('a', { status: 'active' }))).toBe(false);
|
||||
expect(isClaimable(task('a', { owner: 'm:r:s' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectClaimableTask', () => {
|
||||
const owner = 'win11:claude-opus:s1';
|
||||
|
||||
it('happy: returns first claimable candidate in board order', () => {
|
||||
const out = selectClaimableTask(
|
||||
[cand('p', task('first')), cand('p', task('second'))],
|
||||
{ claimerOwner: owner },
|
||||
);
|
||||
expect(out.ok).toBe(true);
|
||||
if (out.ok) expect(out.candidate.task.slug).toBe('first');
|
||||
});
|
||||
|
||||
it('skips non-claimable (active / owned) silently', () => {
|
||||
const out = selectClaimableTask(
|
||||
[cand('p', task('busy', { status: 'active' })), cand('p', task('free'))],
|
||||
{ claimerOwner: owner },
|
||||
);
|
||||
expect(out.ok).toBe(true);
|
||||
if (out.ok) expect(out.candidate.task.slug).toBe('free');
|
||||
});
|
||||
|
||||
it('reject: runtime not in task.runtimeAllowed', () => {
|
||||
const out = selectClaimableTask(
|
||||
[cand('p', task('locked', { runtimeAllowed: ['claude-opus'] }))],
|
||||
{ runtime: 'hermes-glm51', claimerOwner: owner },
|
||||
);
|
||||
expect(out.ok).toBe(false);
|
||||
if (!out.ok) {
|
||||
expect(out.skipped).toHaveLength(1);
|
||||
expect(out.skipped[0].reason).toBe('runtime-not-allowed');
|
||||
expect(out.skipped[0].detail).toBe('claude-opus');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts when runtime IS allowed; unrestricted task ignores runtime', () => {
|
||||
const allowed = selectClaimableTask(
|
||||
[cand('p', task('locked', { runtimeAllowed: ['claude-opus', 'claude-sonnet'] }))],
|
||||
{ runtime: 'claude-opus', claimerOwner: owner },
|
||||
);
|
||||
expect(allowed.ok).toBe(true);
|
||||
const open = selectClaimableTask([cand('p', task('open'))], { claimerOwner: owner });
|
||||
expect(open.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('reject: capabilities mismatch (task.requirements ⊄ self.capabilities)', () => {
|
||||
const out = selectClaimableTask(
|
||||
[cand('p', task('dbjob', { requirements: ['needs-db', 'needs-secrets'] }))],
|
||||
{ capabilities: ['needs-db'], claimerOwner: owner },
|
||||
);
|
||||
expect(out.ok).toBe(false);
|
||||
if (!out.ok) {
|
||||
expect(out.skipped[0].reason).toBe('capabilities-missing');
|
||||
expect(out.skipped[0].detail).toBe('needs-secrets');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts when requirements are a subset of capabilities', () => {
|
||||
const out = selectClaimableTask(
|
||||
[cand('p', task('dbjob', { requirements: ['needs-db'] }))],
|
||||
{ capabilities: ['needs-db', 'needs-secrets'], claimerOwner: owner },
|
||||
);
|
||||
expect(out.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('no candidates → ok:false with empty skipped', () => {
|
||||
const out = selectClaimableTask([], { claimerOwner: owner });
|
||||
expect(out.ok).toBe(false);
|
||||
if (!out.ok) expect(out.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it('reject: weight needs-human is excluded from autonomous claim', () => {
|
||||
const out = selectClaimableTask(
|
||||
[cand('p', task('touchy', { weight: 'needs-human' }))],
|
||||
{ claimerOwner: owner },
|
||||
);
|
||||
expect(out.ok).toBe(false);
|
||||
if (!out.ok) {
|
||||
expect(out.skipped).toHaveLength(1);
|
||||
expect(out.skipped[0].reason).toBe('needs-human');
|
||||
}
|
||||
});
|
||||
|
||||
it('needs-human is skipped but a following claimable task is still picked', () => {
|
||||
const out = selectClaimableTask(
|
||||
[cand('p', task('touchy', { weight: 'needs-human' })), cand('p', task('ok'))],
|
||||
{ claimerOwner: owner },
|
||||
);
|
||||
expect(out.ok).toBe(true);
|
||||
if (out.ok) expect(out.candidate.task.slug).toBe('ok');
|
||||
});
|
||||
|
||||
it('other weights (cheap-ok / needs-claude) remain claimable', () => {
|
||||
expect(
|
||||
selectClaimableTask([cand('p', task('c', { weight: 'cheap-ok' }))], { claimerOwner: owner }).ok,
|
||||
).toBe(true);
|
||||
expect(
|
||||
selectClaimableTask([cand('p', task('n', { weight: 'needs-claude' }))], { claimerOwner: owner }).ok,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('lazy revoke: selects an expired-claim task when now is provided', () => {
|
||||
const stale = task('stale', {
|
||||
status: 'active',
|
||||
owner: 'old:claude-opus:s0',
|
||||
claimExpiresAt: '2026-06-07T11:00:00.000Z',
|
||||
});
|
||||
const out = selectClaimableTask([cand('p', stale)], {
|
||||
claimerOwner: owner,
|
||||
now: new Date('2026-06-07T12:00:00.000Z'),
|
||||
});
|
||||
expect(out.ok).toBe(true);
|
||||
if (out.ok) expect(out.candidate.task.slug).toBe('stale');
|
||||
});
|
||||
|
||||
it('does NOT select a live (unexpired) claim even with now provided', () => {
|
||||
const live = task('live', {
|
||||
status: 'active',
|
||||
owner: 'other:claude-opus:s2',
|
||||
claimExpiresAt: '2026-06-07T12:30:00.000Z',
|
||||
});
|
||||
const out = selectClaimableTask([cand('p', live)], {
|
||||
claimerOwner: owner,
|
||||
now: new Date('2026-06-07T12:00:00.000Z'),
|
||||
});
|
||||
expect(out.ok).toBe(false);
|
||||
});
|
||||
|
||||
describe('one-agent-per-project claim-gate', () => {
|
||||
const now = new Date('2026-06-07T12:00:00.000Z');
|
||||
|
||||
// (a) a project with one live-claimed task → its OTHER ready tasks are skipped.
|
||||
it('skips a ready task whose project already has a LIVE-claimed task', () => {
|
||||
const liveClaimed = task('in-progress', {
|
||||
status: 'active',
|
||||
owner: 'other:claude-opus:s2',
|
||||
claimExpiresAt: '2026-06-07T12:30:00.000Z', // future → live
|
||||
});
|
||||
const ready = task('also-ready');
|
||||
const out = selectClaimableTask([cand('p', ready)], {
|
||||
claimerOwner: owner,
|
||||
boardTasks: { p: [liveClaimed, ready] },
|
||||
now,
|
||||
});
|
||||
expect(out.ok).toBe(false);
|
||||
if (!out.ok) {
|
||||
expect(out.skipped).toHaveLength(1);
|
||||
expect(out.skipped[0].reason).toBe('project-busy');
|
||||
expect(out.skipped[0].slug).toBe('also-ready');
|
||||
}
|
||||
});
|
||||
|
||||
// a live claim with no explicit TTL (owned, in-flight) still marks the project busy.
|
||||
it('treats an owned task without an expiry as a live claim (project busy)', () => {
|
||||
const owned = task('owned-no-ttl', { status: 'active', owner: 'other:claude-opus:s2' });
|
||||
const ready = task('also-ready');
|
||||
const out = selectClaimableTask([cand('p', ready)], {
|
||||
claimerOwner: owner,
|
||||
boardTasks: { p: [owned, ready] },
|
||||
now,
|
||||
});
|
||||
expect(out.ok).toBe(false);
|
||||
if (!out.ok) expect(out.skipped[0].reason).toBe('project-busy');
|
||||
});
|
||||
|
||||
// (b) a project whose only active task has an EXPIRED claim → its ready tasks ARE claimable.
|
||||
it('does NOT mark a project busy when its only active task has an EXPIRED claim', () => {
|
||||
const stale = task('stale', {
|
||||
status: 'active',
|
||||
owner: 'old:claude-opus:s0',
|
||||
claimExpiresAt: '2026-06-07T11:00:00.000Z', // past → expired, reclaimable
|
||||
});
|
||||
const ready = task('also-ready');
|
||||
const out = selectClaimableTask([cand('p', ready)], {
|
||||
claimerOwner: owner,
|
||||
boardTasks: { p: [stale, ready] },
|
||||
now,
|
||||
});
|
||||
expect(out.ok).toBe(true);
|
||||
if (out.ok) expect(out.candidate.task.slug).toBe('also-ready');
|
||||
});
|
||||
|
||||
// (c) two DIFFERENT projects each with a ready task → no cross-project exclusion.
|
||||
it('does not exclude across projects — a busy project P does not block project Q', () => {
|
||||
const liveP = task('p-busy', {
|
||||
status: 'active',
|
||||
owner: 'other:claude-opus:s2',
|
||||
claimExpiresAt: '2026-06-07T12:30:00.000Z',
|
||||
});
|
||||
const readyP = task('p-ready');
|
||||
const readyQ = task('q-ready');
|
||||
const out = selectClaimableTask([cand('p', readyP), cand('q', readyQ)], {
|
||||
claimerOwner: owner,
|
||||
boardTasks: { p: [liveP, readyP], q: [readyQ] },
|
||||
now,
|
||||
});
|
||||
// P is busy → p-ready skipped, but q-ready claims.
|
||||
expect(out.ok).toBe(true);
|
||||
if (out.ok) expect(out.candidate.task.slug).toBe('q-ready');
|
||||
});
|
||||
|
||||
// (d) regression: a project with no active task → its ready task claims normally.
|
||||
it('claims normally when the project has no active task', () => {
|
||||
const ready = task('clean-ready');
|
||||
const out = selectClaimableTask([cand('p', ready)], {
|
||||
claimerOwner: owner,
|
||||
boardTasks: { p: [ready] },
|
||||
now,
|
||||
});
|
||||
expect(out.ok).toBe(true);
|
||||
if (out.ok) expect(out.candidate.task.slug).toBe('clean-ready');
|
||||
});
|
||||
|
||||
// the claimer's own live claim still marks the project busy (no second task in same repo).
|
||||
it('a project the claimer itself live-claimed is also busy', () => {
|
||||
const mine = task('mine', {
|
||||
status: 'active',
|
||||
owner,
|
||||
claimExpiresAt: '2026-06-07T12:30:00.000Z',
|
||||
});
|
||||
const ready = task('second');
|
||||
const out = selectClaimableTask([cand('p', ready)], {
|
||||
claimerOwner: owner,
|
||||
boardTasks: { p: [mine, ready] },
|
||||
now,
|
||||
});
|
||||
expect(out.ok).toBe(false);
|
||||
if (!out.ok) expect(out.skipped[0].reason).toBe('project-busy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectAllowlist (rollout scope guard)', () => {
|
||||
const now = new Date('2026-06-07T12:00:00.000Z');
|
||||
|
||||
// (a) allowlist set → non-listed project skipped (out-of-scope); listed claimable.
|
||||
it('skips a candidate whose project is NOT in the allowlist, claims a listed one', () => {
|
||||
const out = selectClaimableTask(
|
||||
[cand('owner/off-scope', task('a')), cand('owner/in-scope', task('b'))],
|
||||
{ claimerOwner: owner, projectAllowlist: ['owner/in-scope'] },
|
||||
);
|
||||
expect(out.ok).toBe(true);
|
||||
if (out.ok) expect(out.candidate.project).toBe('owner/in-scope');
|
||||
});
|
||||
|
||||
it('reports out-of-scope when the ONLY candidate is not in the allowlist', () => {
|
||||
const out = selectClaimableTask([cand('owner/off-scope', task('a'))], {
|
||||
claimerOwner: owner,
|
||||
projectAllowlist: ['owner/in-scope'],
|
||||
});
|
||||
expect(out.ok).toBe(false);
|
||||
if (!out.ok) {
|
||||
expect(out.skipped).toHaveLength(1);
|
||||
expect(out.skipped[0].reason).toBe('out-of-scope');
|
||||
expect(out.skipped[0].project).toBe('owner/off-scope');
|
||||
}
|
||||
});
|
||||
|
||||
// (b) empty / undefined allowlist → no filtering (regression / backward-compat).
|
||||
it('does no filtering when the allowlist is undefined (federation-wide)', () => {
|
||||
const out = selectClaimableTask([cand('owner/anything', task('a'))], {
|
||||
claimerOwner: owner,
|
||||
});
|
||||
expect(out.ok).toBe(true);
|
||||
if (out.ok) expect(out.candidate.project).toBe('owner/anything');
|
||||
});
|
||||
|
||||
it('does no filtering when the allowlist is empty (federation-wide)', () => {
|
||||
const out = selectClaimableTask([cand('owner/anything', task('a'))], {
|
||||
claimerOwner: owner,
|
||||
projectAllowlist: [],
|
||||
});
|
||||
expect(out.ok).toBe(true);
|
||||
if (out.ok) expect(out.candidate.project).toBe('owner/anything');
|
||||
});
|
||||
|
||||
// (c) allowlist + project-busy compose: a listed-but-busy project is still skipped.
|
||||
it('composes with project-busy — listed but busy project → project-busy skip', () => {
|
||||
const liveClaimed = task('in-progress', {
|
||||
status: 'active',
|
||||
owner: 'other:claude-opus:s2',
|
||||
claimExpiresAt: '2026-06-07T12:30:00.000Z', // future → live
|
||||
});
|
||||
const ready = task('also-ready');
|
||||
const out = selectClaimableTask([cand('owner/in-scope', ready)], {
|
||||
claimerOwner: owner,
|
||||
projectAllowlist: ['owner/in-scope'],
|
||||
boardTasks: { 'owner/in-scope': [liveClaimed, ready] },
|
||||
now,
|
||||
});
|
||||
expect(out.ok).toBe(false);
|
||||
if (!out.ok) {
|
||||
expect(out.skipped).toHaveLength(1);
|
||||
expect(out.skipped[0].reason).toBe('project-busy');
|
||||
}
|
||||
});
|
||||
|
||||
it('out-of-scope takes precedence over project-busy for a non-listed busy project', () => {
|
||||
const liveClaimed = task('in-progress', {
|
||||
status: 'active',
|
||||
owner: 'other:claude-opus:s2',
|
||||
claimExpiresAt: '2026-06-07T12:30:00.000Z',
|
||||
});
|
||||
const ready = task('also-ready');
|
||||
const out = selectClaimableTask([cand('owner/off-scope', ready)], {
|
||||
claimerOwner: owner,
|
||||
projectAllowlist: ['owner/in-scope'],
|
||||
boardTasks: { 'owner/off-scope': [liveClaimed, ready] },
|
||||
now,
|
||||
});
|
||||
expect(out.ok).toBe(false);
|
||||
if (!out.ok) expect(out.skipped[0].reason).toBe('out-of-scope');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExpiredClaim / isReclaimable', () => {
|
||||
const now = new Date('2026-06-07T12:00:00.000Z');
|
||||
|
||||
it('expired claim true, live claim false', () => {
|
||||
const expired = task('x', { status: 'active', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:50:00.000Z' });
|
||||
const live = task('y', { status: 'active', owner: 'm:r:s', claimExpiresAt: '2026-06-07T12:10:00.000Z' });
|
||||
expect(isExpiredClaim(expired, now)).toBe(true);
|
||||
expect(isExpiredClaim(live, now)).toBe(false);
|
||||
});
|
||||
|
||||
it('owned-without-expiry and unowned are not expired claims', () => {
|
||||
expect(isExpiredClaim(task('a', { owner: 'm:r:s' }), now)).toBe(false);
|
||||
expect(isExpiredClaim(task('b'), now)).toBe(false);
|
||||
});
|
||||
|
||||
it('isReclaimable: ready OR expired claim', () => {
|
||||
expect(isReclaimable(task('r'), now)).toBe(true);
|
||||
expect(isReclaimable(task('e', { status: 'active', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:00:00.000Z' }), now)).toBe(true);
|
||||
expect(isReclaimable(task('busy', { status: 'active', owner: 'm:r:s', claimExpiresAt: '2026-06-07T13:00:00.000Z' }), now)).toBe(false);
|
||||
});
|
||||
|
||||
it('terminal/non-active task with stale stamp is NEVER reclaimable', () => {
|
||||
// root-cause-#3: a 🟢 done task whose claim-stamp lingered and TTL lapsed
|
||||
// must NOT become reclaimable (was re-claimed in a loop, burning tokens).
|
||||
const doneStale = task('d', { status: 'done', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:00:00.000Z' });
|
||||
expect(isExpiredClaim(doneStale, now)).toBe(false);
|
||||
expect(isReclaimable(doneStale, now)).toBe(false);
|
||||
// 🔵 blocked/parked task with a stale stamp is equally not reclaimable.
|
||||
const blockedStale = task('b', { status: 'blocked', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:00:00.000Z' });
|
||||
expect(isExpiredClaim(blockedStale, now)).toBe(false);
|
||||
expect(isReclaimable(blockedStale, now)).toBe(false);
|
||||
// crash-recovery preserved: an ACTIVE owned task with lapsed TTL stays reclaimable.
|
||||
const activeStale = task('a', { status: 'active', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:00:00.000Z' });
|
||||
expect(isExpiredClaim(activeStale, now)).toBe(true);
|
||||
expect(isReclaimable(activeStale, now)).toBe(true);
|
||||
});
|
||||
|
||||
it('selectClaimableTask skips a done/blocked task with stale expired stamp', () => {
|
||||
const owner = 'win11:claude-opus:s1';
|
||||
const doneStale = task('d', { status: 'done', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:00:00.000Z' });
|
||||
const blockedStale = task('b', { status: 'blocked', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:00:00.000Z' });
|
||||
const out = selectClaimableTask([cand('p', doneStale), cand('p', blockedStale)], {
|
||||
claimerOwner: owner,
|
||||
now,
|
||||
});
|
||||
expect(out.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('selectClaimableTask still selects an active task with lapsed TTL (crash recovery)', () => {
|
||||
const owner = 'win11:claude-opus:s1';
|
||||
const activeStale = task('a', { status: 'active', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:00:00.000Z' });
|
||||
const out = selectClaimableTask([cand('p', activeStale)], {
|
||||
claimerOwner: owner,
|
||||
now,
|
||||
boardTasks: { p: [activeStale] },
|
||||
});
|
||||
expect(out.ok).toBe(true);
|
||||
if (out.ok) expect(out.candidate.task.slug).toBe('a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeClaimStamp', () => {
|
||||
it('stamps owner, a uuid token, and now+ttl expiry', () => {
|
||||
const now = new Date('2026-06-07T12:00:00.000Z');
|
||||
const s = makeClaimStamp('win11:claude-opus:s1', now, 10 * 60 * 1000);
|
||||
expect(s.owner).toBe('win11:claude-opus:s1');
|
||||
expect(s.claimToken).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(s.claimExpiresAt).toBe('2026-06-07T12:10:00.000Z');
|
||||
});
|
||||
|
||||
it('produces distinct tokens across calls', () => {
|
||||
const now = new Date('2026-06-07T12:00:00.000Z');
|
||||
const a = makeClaimStamp('o', now);
|
||||
const b = makeClaimStamp('o', now);
|
||||
expect(a.claimToken).not.toBe(b.claimToken);
|
||||
});
|
||||
});
|
||||
200
lib/projects-meta-mcp/tests/lib/config.test.ts
Normal file
200
lib/projects-meta-mcp/tests/lib/config.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path/posix';
|
||||
import { getPaths, loadAuth } from '../../src/lib/config.js';
|
||||
|
||||
describe('getPaths', () => {
|
||||
it('builds canonical paths from $HOME', () => {
|
||||
const p = getPaths('/home/u');
|
||||
expect(p.cacheDir.replaceAll('\\', '/')).toBe('/home/u/.cache/projects-mcp');
|
||||
expect(p.cacheFile.replaceAll('\\', '/')).toBe('/home/u/.cache/projects-mcp/tasks.json');
|
||||
expect(p.authFile.replaceAll('\\', '/')).toBe('/home/u/.config/projects-mcp/auth.toml');
|
||||
expect(p.sharedWikiClone.replaceAll('\\', '/')).toBe('/home/u/projects/.wiki');
|
||||
expect(p.agendaTasksDir.replaceAll('\\', '/')).toBe('/home/u/projects/.tasks');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadAuth', () => {
|
||||
it('parses well-formed auth.toml', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
[
|
||||
'gitea_url = "https://git.kzntsv.site"',
|
||||
'gitea_user = "OpeItcLoc03"',
|
||||
'gitea_token = "abc123"',
|
||||
].join('\n'),
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.giteaUrl).toBe('https://git.kzntsv.site');
|
||||
expect(cfg.giteaUser).toBe('OpeItcLoc03');
|
||||
expect(cfg.giteaToken).toBe('abc123');
|
||||
expect(cfg.agendaTasksRepo).toBe('projects-tasks');
|
||||
});
|
||||
|
||||
it('honors custom agenda_tasks_repo from auth.toml', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "u"\ngitea_token = "t"\nagenda_tasks_repo = "custom-meta"\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.agendaTasksRepo).toBe('custom-meta');
|
||||
});
|
||||
|
||||
it('falls back to default agenda_tasks_repo when value is empty string', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "u"\ngitea_token = "t"\nagenda_tasks_repo = ""\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.agendaTasksRepo).toBe('projects-tasks');
|
||||
});
|
||||
|
||||
it('strips trailing slash from gitea_url', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://git.kzntsv.site/"\ngitea_user = "x"\ngitea_token = "y"\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.giteaUrl).toBe('https://git.kzntsv.site');
|
||||
});
|
||||
|
||||
it('throws on missing field', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(file, 'gitea_url = "x"\ngitea_user = "y"\n');
|
||||
await expect(loadAuth(file)).rejects.toThrow(/gitea_token/);
|
||||
});
|
||||
|
||||
it('falls back to [gitea_user] when gitea_owners is absent (legacy single-owner)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "OpeItcLoc03"\ngitea_token = "t"\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.giteaOwners).toEqual(['OpeItcLoc03']);
|
||||
});
|
||||
|
||||
it('honors gitea_owners array when provided', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "OpeItcLoc03"\ngitea_token = "t"\ngitea_owners = ["victor", "cancel_music"]\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.giteaOwners).toEqual(['victor', 'cancel_music']);
|
||||
});
|
||||
|
||||
it('falls back to [gitea_user] when gitea_owners is empty array', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "u"\ngitea_token = "t"\ngitea_owners = []\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.giteaOwners).toEqual(['u']);
|
||||
});
|
||||
|
||||
it('trims whitespace from gitea_owners entries', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "u"\ngitea_token = "t"\ngitea_owners = [" victor ", "cancel_music"]\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.giteaOwners).toEqual(['victor', 'cancel_music']);
|
||||
});
|
||||
|
||||
it('splits qualified <owner>/<repo> in agenda_tasks_repo into owner + bare name', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "u"\ngitea_token = "t"\nagenda_tasks_repo = "OpeItcLoc03/agenda"\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.agendaTasksRepo).toBe('agenda');
|
||||
expect(cfg.agendaTasksOwner).toBe('OpeItcLoc03');
|
||||
});
|
||||
|
||||
it('leaves agendaTasksOwner undefined when agenda_tasks_repo is bare', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "u"\ngitea_token = "t"\nagenda_tasks_repo = "agenda"\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.agendaTasksRepo).toBe('agenda');
|
||||
expect(cfg.agendaTasksOwner).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves agendaTasksOwner undefined for default agenda_tasks_repo', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "u"\ngitea_token = "t"\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.agendaTasksRepo).toBe('projects-tasks');
|
||||
expect(cfg.agendaTasksOwner).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses gitea_aggregate_skip_owners as string[]', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "u"\ngitea_token = "t"\ngitea_aggregate_skip_owners = ["OpeItcLoc03"]\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.giteaAggregateSkipOwners).toEqual(['OpeItcLoc03']);
|
||||
});
|
||||
|
||||
it('defaults gitea_aggregate_skip_owners to [] when absent', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "u"\ngitea_token = "t"\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.giteaAggregateSkipOwners).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults gitea_aggregate_skip_owners to [] when empty array', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "u"\ngitea_token = "t"\ngitea_aggregate_skip_owners = []\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.giteaAggregateSkipOwners).toEqual([]);
|
||||
});
|
||||
|
||||
it('trims whitespace and drops empty strings from gitea_aggregate_skip_owners entries', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'auth-'));
|
||||
const file = join(dir, 'auth.toml');
|
||||
await writeFile(
|
||||
file,
|
||||
'gitea_url = "https://g"\ngitea_user = "u"\ngitea_token = "t"\ngitea_aggregate_skip_owners = [" OpeItcLoc03 ", "", " "]\n',
|
||||
);
|
||||
const cfg = await loadAuth(file);
|
||||
expect(cfg.giteaAggregateSkipOwners).toEqual(['OpeItcLoc03']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { appendDecisionTrail, type DecisionTrailEntry } from '../../src/lib/decision-trail-writer.js';
|
||||
|
||||
const entry: DecisionTrailEntry = {
|
||||
timestampIso: '2026-06-08T10:00:00.000Z',
|
||||
question: 'cache key includes locale?',
|
||||
blastRadius: 'reversible',
|
||||
decidedBy: 'arbiter',
|
||||
ruling: 'include locale',
|
||||
rationale: 'avoids cross-locale bleed',
|
||||
escalationChain: ['brief'],
|
||||
};
|
||||
|
||||
describe('appendDecisionTrail', () => {
|
||||
it('creates a "## Decision trail" section when none exists', () => {
|
||||
const md = '# my-task\n\n## Goal\nDo the thing.\n';
|
||||
const out = appendDecisionTrail(md, entry);
|
||||
expect(out).toContain('## Decision trail');
|
||||
expect(out).toContain('### consult 1 — 2026-06-08T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('keeps the original content intact', () => {
|
||||
const md = '# my-task\n\n## Goal\nDo the thing.\n';
|
||||
const out = appendDecisionTrail(md, entry);
|
||||
expect(out).toContain('# my-task');
|
||||
expect(out).toContain('## Goal');
|
||||
expect(out).toContain('Do the thing.');
|
||||
});
|
||||
|
||||
it('renders every contract field', () => {
|
||||
const out = appendDecisionTrail('# t\n', entry);
|
||||
expect(out).toContain('- question: cache key includes locale?');
|
||||
expect(out).toContain('- blast_radius: reversible');
|
||||
expect(out).toContain('- decided_by: arbiter');
|
||||
expect(out).toContain('- ruling: include locale');
|
||||
expect(out).toContain('- rationale: avoids cross-locale bleed');
|
||||
expect(out).toContain('- escalation_chain: brief');
|
||||
});
|
||||
|
||||
it('numbers consecutive consults 1, 2, 3 …', () => {
|
||||
let md = '# t\n';
|
||||
md = appendDecisionTrail(md, entry);
|
||||
md = appendDecisionTrail(md, { ...entry, question: 'second?' });
|
||||
md = appendDecisionTrail(md, { ...entry, question: 'third?' });
|
||||
expect(md).toContain('### consult 1 —');
|
||||
expect(md).toContain('### consult 2 —');
|
||||
expect(md).toContain('### consult 3 —');
|
||||
expect((md.match(/## Decision trail/g) ?? []).length).toBe(1); // section created once
|
||||
});
|
||||
|
||||
it('joins the escalation chain with arrows', () => {
|
||||
const out = appendDecisionTrail('# t\n', {
|
||||
...entry,
|
||||
escalationChain: ['brief', 'read-repo', 'arbiter-fork'],
|
||||
});
|
||||
expect(out).toContain('- escalation_chain: brief → read-repo → arbiter-fork');
|
||||
});
|
||||
|
||||
it('renders a halt entry (no ruling) with the escalation rationale', () => {
|
||||
const out = appendDecisionTrail('# t\n', {
|
||||
...entry,
|
||||
decidedBy: 'human-required',
|
||||
ruling: null,
|
||||
rationale: 'escalated: consult-cap exceeded',
|
||||
escalationChain: ['brief', 'arbiter-fork'],
|
||||
});
|
||||
expect(out).toContain('- decided_by: human-required');
|
||||
expect(out).toContain('- ruling: —');
|
||||
expect(out).toContain('- rationale: escalated: consult-cap exceeded');
|
||||
});
|
||||
});
|
||||
187
lib/projects-meta-mcp/tests/lib/domain-detector.test.ts
Normal file
187
lib/projects-meta-mcp/tests/lib/domain-detector.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
detectDomain,
|
||||
detectProjectIdentity,
|
||||
parseRemoteUrl,
|
||||
resolveSourceProject,
|
||||
} from '../../src/lib/domain-detector.js';
|
||||
|
||||
async function tmp(): Promise<string> {
|
||||
return mkdtemp(join(tmpdir(), 'dd-'));
|
||||
}
|
||||
|
||||
describe('detectDomain', () => {
|
||||
it('returns node when package.json present', async () => {
|
||||
const d = await tmp();
|
||||
await writeFile(join(d, 'package.json'), '{}');
|
||||
expect(await detectDomain(d)).toBe('node');
|
||||
});
|
||||
|
||||
it('returns embedded for platformio.ini', async () => {
|
||||
const d = await tmp();
|
||||
await writeFile(join(d, 'platformio.ini'), '[env]');
|
||||
expect(await detectDomain(d)).toBe('embedded');
|
||||
});
|
||||
|
||||
it('returns embedded for .ino', async () => {
|
||||
const d = await tmp();
|
||||
await writeFile(join(d, 'sketch.ino'), 'void setup() {}');
|
||||
expect(await detectDomain(d)).toBe('embedded');
|
||||
});
|
||||
|
||||
it('returns embedded for ARM CMakeLists', async () => {
|
||||
const d = await tmp();
|
||||
await writeFile(join(d, 'CMakeLists.txt'), 'set(CMAKE_C_COMPILER arm-none-eabi-gcc)');
|
||||
expect(await detectDomain(d)).toBe('embedded');
|
||||
});
|
||||
|
||||
it('returns web for vite config', async () => {
|
||||
const d = await tmp();
|
||||
await writeFile(join(d, 'vite.config.ts'), 'export default {}');
|
||||
expect(await detectDomain(d)).toBe('web');
|
||||
});
|
||||
|
||||
it('returns web for static index.html without package.json', async () => {
|
||||
const d = await tmp();
|
||||
await writeFile(join(d, 'index.html'), '<html></html>');
|
||||
expect(await detectDomain(d)).toBe('web');
|
||||
});
|
||||
|
||||
it('returns unknown for empty dir', async () => {
|
||||
const d = await tmp();
|
||||
expect(await detectDomain(d)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('package.json wins over index.html', async () => {
|
||||
const d = await tmp();
|
||||
await writeFile(join(d, 'package.json'), '{}');
|
||||
await writeFile(join(d, 'index.html'), '<html></html>');
|
||||
expect(await detectDomain(d)).toBe('node');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRemoteUrl', () => {
|
||||
it('parses https Gitea URL with .git suffix', () => {
|
||||
expect(parseRemoteUrl('https://git.kzntsv.site/OpeItcLoc03/common.git')).toEqual({
|
||||
owner: 'OpeItcLoc03',
|
||||
repo: 'common',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses https Gitea URL without .git suffix', () => {
|
||||
expect(parseRemoteUrl('https://git.kzntsv.site/victor/books')).toEqual({
|
||||
owner: 'victor',
|
||||
repo: 'books',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses https URL with port', () => {
|
||||
expect(parseRemoteUrl('https://git.kzntsv.site:443/victor/books.git')).toEqual({
|
||||
owner: 'victor',
|
||||
repo: 'books',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses ssh URL', () => {
|
||||
expect(parseRemoteUrl('git@git.kzntsv.site:victor/books.git')).toEqual({
|
||||
owner: 'victor',
|
||||
repo: 'books',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses ssh URL without .git suffix', () => {
|
||||
expect(parseRemoteUrl('git@github.com:owner/repo')).toEqual({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
});
|
||||
});
|
||||
|
||||
it('strips trailing slash before parsing', () => {
|
||||
expect(parseRemoteUrl('https://git.kzntsv.site/victor/books/')).toEqual({
|
||||
owner: 'victor',
|
||||
repo: 'books',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(parseRemoteUrl('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-URL local path', () => {
|
||||
expect(parseRemoteUrl('/path/to/local/repo')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for URL without owner/repo path', () => {
|
||||
expect(parseRemoteUrl('https://example.com/single')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectProjectIdentity', () => {
|
||||
it('returns parsed identity when git remote returns Gitea https URL', async () => {
|
||||
const fakeExec = async () => ({
|
||||
stdout: 'https://git.kzntsv.site/OpeItcLoc03/common.git\n',
|
||||
stderr: '',
|
||||
});
|
||||
expect(await detectProjectIdentity('/cwd', fakeExec)).toEqual({
|
||||
owner: 'OpeItcLoc03',
|
||||
repo: 'common',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns parsed identity when git remote returns ssh URL', async () => {
|
||||
const fakeExec = async () => ({
|
||||
stdout: 'git@git.kzntsv.site:victor/books.git\n',
|
||||
stderr: '',
|
||||
});
|
||||
expect(await detectProjectIdentity('/cwd', fakeExec)).toEqual({
|
||||
owner: 'victor',
|
||||
repo: 'books',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when git command fails (no remote / not a repo)', async () => {
|
||||
const fakeExec = async () => {
|
||||
throw new Error('fatal: not a git repository');
|
||||
};
|
||||
expect(await detectProjectIdentity('/cwd', fakeExec)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when remote URL is unparseable', async () => {
|
||||
const fakeExec = async () => ({ stdout: 'garbage-not-a-url\n', stderr: '' });
|
||||
expect(await detectProjectIdentity('/cwd', fakeExec)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSourceProject', () => {
|
||||
it('returns qualified <owner>/<repo> with fellBack=false when remote resolves', async () => {
|
||||
const fakeExec = async () => ({
|
||||
stdout: 'https://git.kzntsv.site/victor/books.git\n',
|
||||
stderr: '',
|
||||
});
|
||||
expect(await resolveSourceProject('/some/cwd/path/books', fakeExec)).toEqual({
|
||||
sourceProject: 'victor/books',
|
||||
fellBack: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to basename(cwd) with fellBack=true when remote command fails', async () => {
|
||||
const fakeExec = async () => {
|
||||
throw new Error('fatal: not a git repository');
|
||||
};
|
||||
expect(await resolveSourceProject('/home/user/projects/.workshop', fakeExec)).toEqual({
|
||||
sourceProject: '.workshop',
|
||||
fellBack: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to basename(cwd) with fellBack=true when remote URL is unparseable', async () => {
|
||||
const fakeExec = async () => ({ stdout: 'garbage-not-a-url\n', stderr: '' });
|
||||
expect(await resolveSourceProject('/tmp/loose-folder', fakeExec)).toEqual({
|
||||
sourceProject: 'loose-folder',
|
||||
fellBack: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
37
lib/projects-meta-mcp/tests/lib/git.test.ts
Normal file
37
lib/projects-meta-mcp/tests/lib/git.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { pullLocalClone } from '../../src/lib/git.js';
|
||||
|
||||
describe('pullLocalClone', () => {
|
||||
it('returns pulled:true on successful ff-only pull', async () => {
|
||||
const mockExec = async () => ({ stdout: '', stderr: 'Already up to date.\n' });
|
||||
const result = await pullLocalClone('/fake/wiki', mockExec);
|
||||
expect(result.pulled).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes correct git args', async () => {
|
||||
const calls: Array<{ file: string; args: string[] }> = [];
|
||||
const mockExec = async (file: string, args: string[]) => {
|
||||
calls.push({ file, args });
|
||||
return { stdout: '', stderr: '' };
|
||||
};
|
||||
await pullLocalClone('/some/wiki', mockExec);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].file).toBe('git');
|
||||
expect(calls[0].args).toEqual(['-C', '/some/wiki', 'pull', '--ff-only']);
|
||||
});
|
||||
|
||||
it('returns pulled:false with error message on non-ff failure', async () => {
|
||||
const mockExec = async () => { throw new Error('fatal: Not possible to fast-forward, aborting.'); };
|
||||
const result = await pullLocalClone('/fake/wiki', mockExec);
|
||||
expect(result.pulled).toBe(false);
|
||||
expect(result.error).toContain('fast-forward');
|
||||
});
|
||||
|
||||
it('returns pulled:false when git is not found', async () => {
|
||||
const mockExec = async () => { throw new Error('ENOENT: git not found'); };
|
||||
const result = await pullLocalClone('/fake/wiki', mockExec);
|
||||
expect(result.pulled).toBe(false);
|
||||
expect(result.error).toContain('ENOENT');
|
||||
});
|
||||
});
|
||||
214
lib/projects-meta-mcp/tests/lib/gitea.test.ts
Normal file
214
lib/projects-meta-mcp/tests/lib/gitea.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { makeGiteaBackend } from '../../src/lib/gitea.js';
|
||||
const makeGiteaClient = makeGiteaBackend;
|
||||
|
||||
function mockFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) {
|
||||
return async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
return handler(url, init);
|
||||
};
|
||||
}
|
||||
|
||||
describe('GiteaClient', () => {
|
||||
it('lists user repos with token in header', async () => {
|
||||
let receivedAuth = '';
|
||||
let callCount = 0;
|
||||
const fetchImpl = mockFetch((url, init) => {
|
||||
receivedAuth = (init?.headers as Record<string, string>)['Authorization'];
|
||||
if (callCount === 0) {
|
||||
expect(url).toBe('https://g/api/v1/users/u/repos?limit=50');
|
||||
callCount++;
|
||||
return new Response(
|
||||
JSON.stringify([
|
||||
{ name: 'r1', default_branch: 'main' },
|
||||
{ name: 'r2', default_branch: 'master' },
|
||||
]),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
return new Response(JSON.stringify([]), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
});
|
||||
const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
const repos = await c.listUserRepos('u');
|
||||
expect(repos).toEqual([
|
||||
{ name: 'r1', default_branch: 'main', archived: false },
|
||||
{ name: 'r2', default_branch: 'master', archived: false },
|
||||
]);
|
||||
expect(receivedAuth).toBe('token t');
|
||||
});
|
||||
|
||||
it('paginates listUserRepos', async () => {
|
||||
const pages = [
|
||||
[{ name: 'r1', default_branch: 'main' }, { name: 'r2', default_branch: 'main' }],
|
||||
[{ name: 'r3', default_branch: 'main' }],
|
||||
[],
|
||||
];
|
||||
let i = 0;
|
||||
const fetchImpl = mockFetch(() => {
|
||||
const body = JSON.stringify(pages[i++]);
|
||||
return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
});
|
||||
const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
const repos = await c.listUserRepos('u');
|
||||
expect(repos.map((r) => r.name)).toEqual(['r1', 'r2', 'r3']);
|
||||
});
|
||||
|
||||
it('returns null on 404 raw file', async () => {
|
||||
const fetchImpl = mockFetch(() => new Response('Not Found', { status: 404 }));
|
||||
const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
const r = await c.getRawFile('u', 'repo', '.tasks/STATUS.md', 'main');
|
||||
expect(r).toBeNull();
|
||||
});
|
||||
|
||||
it('returns body on 200 raw file', async () => {
|
||||
const fetchImpl = mockFetch((url) => {
|
||||
expect(url).toBe('https://g/api/v1/repos/u/repo/raw/.tasks/STATUS.md?ref=main');
|
||||
return new Response('# Task Board\n', { status: 200 });
|
||||
});
|
||||
const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
const r = await c.getRawFile('u', 'repo', '.tasks/STATUS.md', 'main');
|
||||
expect(r).toBe('# Task Board\n');
|
||||
});
|
||||
|
||||
it('throws on 500', async () => {
|
||||
const fetchImpl = mockFetch(() => new Response('boom', { status: 500 }));
|
||||
const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
await expect(c.getRawFile('u', 'r', 'p', 'main')).rejects.toThrow(/500/);
|
||||
});
|
||||
|
||||
it('throws on 401', async () => {
|
||||
const fetchImpl = mockFetch(() => new Response('nope', { status: 401 }));
|
||||
const c = makeGiteaClient({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
await expect(c.listUserRepos('u')).rejects.toThrow(/401/);
|
||||
});
|
||||
|
||||
describe('getFileWithSha', () => {
|
||||
it('returns content + sha (base64-decoded)', async () => {
|
||||
const content = '# Task Board\n## 🔴 [t1] — first\n';
|
||||
const fetchImpl = mockFetch((url) => {
|
||||
expect(url).toBe('https://g/api/v1/repos/u/r/contents/.tasks/STATUS.md?ref=main');
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
content: Buffer.from(content, 'utf8').toString('base64'),
|
||||
encoding: 'base64',
|
||||
sha: 'abc123',
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
const c = makeGiteaBackend({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
const r = await c.getFileWithSha('u', 'r', '.tasks/STATUS.md', 'main');
|
||||
expect(r).toEqual({ content, sha: 'abc123' });
|
||||
});
|
||||
|
||||
it('returns null on 404', async () => {
|
||||
const fetchImpl = mockFetch(() => new Response('not found', { status: 404 }));
|
||||
const c = makeGiteaBackend({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
const r = await c.getFileWithSha('u', 'r', 'missing', 'main');
|
||||
expect(r).toBeNull();
|
||||
});
|
||||
|
||||
it('throws on 500', async () => {
|
||||
const fetchImpl = mockFetch(() => new Response('boom', { status: 500 }));
|
||||
const c = makeGiteaBackend({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
await expect(c.getFileWithSha('u', 'r', 'p', 'main')).rejects.toThrow(/500/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('commitFile', () => {
|
||||
it('POSTs to create new file (no sha)', async () => {
|
||||
let receivedMethod = '';
|
||||
let receivedBody: Record<string, unknown> = {};
|
||||
const fetchImpl = mockFetch((url, init) => {
|
||||
expect(url).toBe('https://g/api/v1/repos/u/r/contents/.tasks/STATUS.md');
|
||||
receivedMethod = init?.method ?? '';
|
||||
receivedBody = JSON.parse(init?.body as string);
|
||||
return new Response(
|
||||
JSON.stringify({ content: { sha: 'new-blob' }, commit: { sha: 'new-commit' } }),
|
||||
{ status: 201, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
const c = makeGiteaBackend({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
const r = await c.commitFile({
|
||||
user: 'u',
|
||||
repo: 'r',
|
||||
path: '.tasks/STATUS.md',
|
||||
branch: 'main',
|
||||
content: '# board\n',
|
||||
message: 'init',
|
||||
});
|
||||
expect(receivedMethod).toBe('POST');
|
||||
expect(receivedBody.branch).toBe('main');
|
||||
expect(Buffer.from(receivedBody.content as string, 'base64').toString('utf8')).toBe('# board\n');
|
||||
expect(receivedBody.message).toBe('init');
|
||||
expect(receivedBody).not.toHaveProperty('sha');
|
||||
expect(r).toEqual({ fileSha: 'new-blob', commitSha: 'new-commit' });
|
||||
});
|
||||
|
||||
it('PUTs to update existing file (with sha)', async () => {
|
||||
let receivedMethod = '';
|
||||
let receivedBody: Record<string, unknown> = {};
|
||||
const fetchImpl = mockFetch((_url, init) => {
|
||||
receivedMethod = init?.method ?? '';
|
||||
receivedBody = JSON.parse(init?.body as string);
|
||||
return new Response(
|
||||
JSON.stringify({ content: { sha: 'updated-blob' }, commit: { sha: 'updated-commit' } }),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
const c = makeGiteaBackend({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
const r = await c.commitFile({
|
||||
user: 'u',
|
||||
repo: 'r',
|
||||
path: '.tasks/STATUS.md',
|
||||
branch: 'main',
|
||||
content: 'updated',
|
||||
message: 'update',
|
||||
sha: 'old-blob',
|
||||
});
|
||||
expect(receivedMethod).toBe('PUT');
|
||||
expect(receivedBody.sha).toBe('old-blob');
|
||||
expect(r.fileSha).toBe('updated-blob');
|
||||
});
|
||||
|
||||
it('attaches author when both name and email provided', async () => {
|
||||
let receivedBody: Record<string, unknown> = {};
|
||||
const fetchImpl = mockFetch((_url, init) => {
|
||||
receivedBody = JSON.parse(init?.body as string);
|
||||
return new Response(
|
||||
JSON.stringify({ content: { sha: 'b' }, commit: { sha: 'c' } }),
|
||||
{ status: 201, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
const c = makeGiteaBackend({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
await c.commitFile({
|
||||
user: 'u',
|
||||
repo: 'r',
|
||||
path: 'x.md',
|
||||
branch: 'main',
|
||||
content: '',
|
||||
message: 'm',
|
||||
authorName: 'Vitya',
|
||||
authorEmail: 'v@x',
|
||||
});
|
||||
expect(receivedBody.author).toEqual({ name: 'Vitya', email: 'v@x' });
|
||||
});
|
||||
|
||||
it('throws on 422 (sha conflict)', async () => {
|
||||
const fetchImpl = mockFetch(() => new Response('sha mismatch', { status: 422 }));
|
||||
const c = makeGiteaBackend({ baseUrl: 'https://g', token: 't', fetchImpl: fetchImpl as never });
|
||||
await expect(
|
||||
c.commitFile({
|
||||
user: 'u',
|
||||
repo: 'r',
|
||||
path: 'x',
|
||||
branch: 'main',
|
||||
content: 'y',
|
||||
message: 'm',
|
||||
sha: 'stale',
|
||||
}),
|
||||
).rejects.toThrow(/422/);
|
||||
});
|
||||
});
|
||||
});
|
||||
127
lib/projects-meta-mcp/tests/lib/meta-cli.test.ts
Normal file
127
lib/projects-meta-mcp/tests/lib/meta-cli.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { runMetaPull, runMetaPush, type CliDeps, type HostResolution } from '../../src/lib/meta-cli.js';
|
||||
|
||||
const exec = promisify(execFile);
|
||||
let scratch: string;
|
||||
|
||||
async function rawGit(cwd: string, ...args: string[]): Promise<string> {
|
||||
const { stdout } = await exec(
|
||||
'git',
|
||||
['-c', 'core.excludesFile=', '-c', 'core.autocrlf=false', '-c', 'user.email=t@t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args],
|
||||
{ cwd },
|
||||
);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async function makeHost(files: Record<string, string>): Promise<string> {
|
||||
const host = join(scratch, 'host.git');
|
||||
await exec('git', ['init', '--bare', '--initial-branch=main', host]);
|
||||
const seed = join(scratch, 'seed');
|
||||
await exec('git', ['clone', host, seed]);
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const abs = join(seed, rel);
|
||||
await mkdir(join(abs, '..'), { recursive: true });
|
||||
await writeFile(abs, content);
|
||||
}
|
||||
await rawGit(seed, 'add', '-A');
|
||||
await rawGit(seed, 'commit', '-m', 'seed');
|
||||
await rawGit(seed, 'push', 'origin', 'main');
|
||||
return host;
|
||||
}
|
||||
|
||||
/** Deps whose resolver maps any dir to the given host, with a per-dir gitDir under scratch. */
|
||||
function depsForHost(host: string): CliDeps {
|
||||
const logs: string[] = [];
|
||||
const deps: CliDeps & { logs: string[] } = {
|
||||
logs,
|
||||
log: (m) => logs.push(m),
|
||||
resolveHost: async (dir: string): Promise<HostResolution> => ({
|
||||
remoteUrl: host,
|
||||
branch: 'main',
|
||||
gitDir: `${dir}.gitdir`,
|
||||
}),
|
||||
};
|
||||
return deps;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
scratch = await mkdtemp(join(tmpdir(), 'pmfs-cli-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(scratch, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('runMetaPull', () => {
|
||||
it('materializes meta into the work-tree and exits 0', { timeout: 30000 }, async () => {
|
||||
const host = await makeHost({ '.tasks/STATUS.md': '# board\n' });
|
||||
const dir = join(scratch, 'proj');
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const code = await runMetaPull(dir, depsForHost(host));
|
||||
expect(code).toBe(0);
|
||||
expect(await readFile(join(dir, '.tasks/STATUS.md'), 'utf8')).toBe('# board\n');
|
||||
});
|
||||
|
||||
it('is a fast no-op (no git, no git-dir) when the dir has no host', { timeout: 30000 }, async () => {
|
||||
const dir = join(scratch, 'noproj');
|
||||
await mkdir(dir, { recursive: true });
|
||||
const deps: CliDeps = { resolveHost: async () => null };
|
||||
|
||||
const code = await runMetaPull(dir, deps);
|
||||
expect(code).toBe(0);
|
||||
expect(existsSync(`${dir}.gitdir`)).toBe(false);
|
||||
});
|
||||
|
||||
it('exits 0 even when the host is unreachable (must not block session start)', { timeout: 30000 }, async () => {
|
||||
const dir = join(scratch, 'proj');
|
||||
await mkdir(dir, { recursive: true });
|
||||
const deps: CliDeps = {
|
||||
resolveHost: async () => ({ remoteUrl: join(scratch, 'does-not-exist.git'), branch: 'main', gitDir: `${dir}.gitdir` }),
|
||||
};
|
||||
const code = await runMetaPull(dir, deps);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runMetaPush — round-trip', () => {
|
||||
it('pull -> edit -> push lands on the host; a second machine pulls it', { timeout: 30000 }, async () => {
|
||||
const host = await makeHost({ '.tasks/STATUS.md': 'v1\n' });
|
||||
const deps = depsForHost(host);
|
||||
|
||||
const a = join(scratch, 'a');
|
||||
await mkdir(a, { recursive: true });
|
||||
await runMetaPull(a, deps);
|
||||
await writeFile(join(a, '.tasks/STATUS.md'), 'v2\n');
|
||||
const pushCode = await runMetaPush(a, deps);
|
||||
expect(pushCode).toBe(0);
|
||||
|
||||
const b = join(scratch, 'b');
|
||||
await mkdir(b, { recursive: true });
|
||||
await runMetaPull(b, deps);
|
||||
expect(await readFile(join(b, '.tasks/STATUS.md'), 'utf8')).toBe('v2\n');
|
||||
});
|
||||
|
||||
it('is a no-op exit 0 when meta is clean', { timeout: 30000 }, async () => {
|
||||
const host = await makeHost({ '.tasks/STATUS.md': 'v1\n' });
|
||||
const deps = depsForHost(host);
|
||||
const a = join(scratch, 'a');
|
||||
await mkdir(a, { recursive: true });
|
||||
await runMetaPull(a, deps);
|
||||
|
||||
const code = await runMetaPush(a, deps);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
it('exits 0 when the dir has no host (fast no-op)', { timeout: 30000 }, async () => {
|
||||
const dir = join(scratch, 'noproj');
|
||||
await mkdir(dir, { recursive: true });
|
||||
const code = await runMetaPush(dir, { resolveHost: async () => null });
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
});
|
||||
70
lib/projects-meta-mcp/tests/lib/meta-host-resolver.test.ts
Normal file
70
lib/projects-meta-mcp/tests/lib/meta-host-resolver.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { join } from 'node:path';
|
||||
import { resolveMetaHostWith, type ResolveDeps } from '../../src/lib/meta-host-resolver.js';
|
||||
import type { CacheFile, ProjectStatus } from '../../src/lib/cache.js';
|
||||
|
||||
function project(name: string, defaultBranch = 'main'): ProjectStatus {
|
||||
return {
|
||||
name,
|
||||
default_branch: defaultBranch,
|
||||
fetched_at: '2026-05-27T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
};
|
||||
}
|
||||
|
||||
function cacheWith(...names: string[]): CacheFile {
|
||||
return {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-05-27T00:00:00Z',
|
||||
synced_from: 'gitea',
|
||||
machine: 'test',
|
||||
projects: names.map((n) => project(n)),
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
function deps(cache: CacheFile | null, log?: (m: string) => void): ResolveDeps {
|
||||
return {
|
||||
cache,
|
||||
auth: { giteaUrl: 'https://git.example.com', giteaToken: 'TOK123' },
|
||||
gitDirBase: '/cache/meta-git',
|
||||
log,
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveMetaHostWith', () => {
|
||||
it('resolves a folder to its meta-<basename> host under the matching owner', async () => {
|
||||
const res = await resolveMetaHostWith('/home/u/projects/yt-tools', deps(cacheWith('OpeItcLoc03/meta-yt-tools', 'OpeItcLoc03/common')));
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.branch).toBe('main');
|
||||
expect(res!.remoteUrl).toBe('https://TOK123@git.example.com/OpeItcLoc03/meta-yt-tools.git');
|
||||
expect(res!.gitDir).toBe(join('/cache/meta-git', 'OpeItcLoc03__meta-yt-tools'));
|
||||
});
|
||||
|
||||
it('carries the host project default branch', async () => {
|
||||
const c = cacheWith('OpeItcLoc03/common');
|
||||
c.projects.push(project('OpeItcLoc03/meta-yt-tools', 'master'));
|
||||
const res = await resolveMetaHostWith('/x/yt-tools', deps(c));
|
||||
expect(res!.branch).toBe('master');
|
||||
});
|
||||
|
||||
it('returns null when no meta-<basename> host exists (instant no-op)', async () => {
|
||||
// own-Gitea project `books` is present as a bare repo, NOT `meta-books`
|
||||
const res = await resolveMetaHostWith('/home/u/projects/books', deps(cacheWith('victor/books')));
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the cache is absent', async () => {
|
||||
const res = await resolveMetaHostWith('/x/yt-tools', deps(null));
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses to guess when meta-<basename> exists under multiple owners', async () => {
|
||||
const logs: string[] = [];
|
||||
const res = await resolveMetaHostWith('/x/shared', deps(cacheWith('owner-a/meta-shared', 'owner-b/meta-shared'), (m) => logs.push(m)));
|
||||
expect(res).toBeNull();
|
||||
expect(logs.join('\n')).toMatch(/ambiguous|multiple/i);
|
||||
});
|
||||
});
|
||||
233
lib/projects-meta-mcp/tests/lib/meta-sync.test.ts
Normal file
233
lib/projects-meta-mcp/tests/lib/meta-sync.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { mkdtemp, rm, mkdir, writeFile, readFile, readdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { metaPull, metaPush, DEFAULT_META_PATHS } from '../../src/lib/meta-sync.js';
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
// Real-git integration: mocking `git merge` would test nothing real.
|
||||
// We build a bare "host" repo + N work-trees ("machines") in temp dirs.
|
||||
|
||||
let scratch: string;
|
||||
|
||||
/** Run a plain git command (used only by the test harness, not the lib). */
|
||||
async function rawGit(cwd: string, ...args: string[]): Promise<string> {
|
||||
const { stdout } = await exec(
|
||||
'git',
|
||||
['-c', 'core.excludesFile=', '-c', 'core.autocrlf=false', '-c', 'user.email=t@t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args],
|
||||
{ cwd },
|
||||
);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
/** Create a bare host repo seeded with initial meta files on `main`. */
|
||||
async function makeHost(initialFiles: Record<string, string>): Promise<string> {
|
||||
const host = join(scratch, 'host.git');
|
||||
await exec('git', ['init', '--bare', '--initial-branch=main', host]);
|
||||
const seed = join(scratch, 'seed');
|
||||
await exec('git', ['clone', host, seed]);
|
||||
for (const [rel, content] of Object.entries(initialFiles)) {
|
||||
const abs = join(seed, rel);
|
||||
await mkdir(join(abs, '..'), { recursive: true });
|
||||
await writeFile(abs, content);
|
||||
}
|
||||
await rawGit(seed, 'add', '-A');
|
||||
await rawGit(seed, 'commit', '-m', 'seed');
|
||||
await rawGit(seed, 'push', 'origin', 'main');
|
||||
return host;
|
||||
}
|
||||
|
||||
/** Allocate a fresh (gitDir, workTree) pair for one "machine". */
|
||||
async function machine(name: string): Promise<{ gitDir: string; workTree: string }> {
|
||||
const gitDir = join(scratch, `${name}.gitdir`);
|
||||
const workTree = join(scratch, `${name}.wt`);
|
||||
await mkdir(workTree, { recursive: true });
|
||||
return { gitDir, workTree };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
scratch = await mkdtemp(join(tmpdir(), 'pmfs-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(scratch, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('metaPull — first materialization', () => {
|
||||
it('materializes meta files from host into an empty work-tree', { timeout: 30000 }, async () => {
|
||||
const host = await makeHost({
|
||||
'.tasks/STATUS.md': '# board\n',
|
||||
'CLAUDE.md': 'inst\n',
|
||||
});
|
||||
const m = await machine('a');
|
||||
|
||||
const res = await metaPull({ gitDir: m.gitDir, workTree: m.workTree, remoteUrl: host, branch: 'main' });
|
||||
|
||||
expect(res.status).toBe('pulled');
|
||||
expect(await readFile(join(m.workTree, '.tasks/STATUS.md'), 'utf8')).toBe('# board\n');
|
||||
expect(await readFile(join(m.workTree, 'CLAUDE.md'), 'utf8')).toBe('inst\n');
|
||||
expect(existsSync(m.gitDir)).toBe(true);
|
||||
// hidden git-dir is beside the work-tree, NOT nested
|
||||
expect(existsSync(join(m.workTree, '.git'))).toBe(false);
|
||||
});
|
||||
|
||||
it('creates the git-dir even when its parent directory does not exist', { timeout: 30000 }, async () => {
|
||||
const host = await makeHost({ 'CLAUDE.md': 'inst\n' });
|
||||
// git-dir nested under directories that do not exist yet (real case:
|
||||
// ~/.cache/projects-mcp/meta-git/<owner>__<repo> on first ever sync).
|
||||
const gitDir = join(scratch, 'nested', 'deep', 'a.gitdir');
|
||||
const workTree = join(scratch, 'a.wt');
|
||||
await mkdir(workTree, { recursive: true });
|
||||
|
||||
const res = await metaPull({ gitDir, workTree, remoteUrl: host, branch: 'main' });
|
||||
expect(res.status).toBe('pulled');
|
||||
expect(await readFile(join(workTree, 'CLAUDE.md'), 'utf8')).toBe('inst\n');
|
||||
});
|
||||
|
||||
it('reports up-to-date on a second pull with no remote change', { timeout: 30000 }, async () => {
|
||||
const host = await makeHost({ 'CLAUDE.md': 'inst\n' });
|
||||
const m = await machine('a');
|
||||
await metaPull({ gitDir: m.gitDir, workTree: m.workTree, remoteUrl: host, branch: 'main' });
|
||||
|
||||
const res = await metaPull({ gitDir: m.gitDir, workTree: m.workTree, remoteUrl: host, branch: 'main' });
|
||||
expect(res.status).toBe('up-to-date');
|
||||
});
|
||||
});
|
||||
|
||||
describe('metaPush — clean meta', () => {
|
||||
it('is a no-op when nothing changed', { timeout: 30000 }, async () => {
|
||||
const host = await makeHost({ '.tasks/STATUS.md': '# board\n' });
|
||||
const m = await machine('a');
|
||||
await metaPull({ gitDir: m.gitDir, workTree: m.workTree, remoteUrl: host, branch: 'main' });
|
||||
|
||||
const res = await metaPush({ gitDir: m.gitDir, workTree: m.workTree, branch: 'main', message: 'noop' });
|
||||
expect(res.status).toBe('no-op');
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip — two machines, clean block merge', () => {
|
||||
it('merges edits to different blocks without conflict', { timeout: 30000 }, async () => {
|
||||
const host = await makeHost({ '.tasks/STATUS.md': 'A: 1\n\nB: 1\n' });
|
||||
const a = await machine('a');
|
||||
const b = await machine('b');
|
||||
await metaPull({ gitDir: a.gitDir, workTree: a.workTree, remoteUrl: host, branch: 'main' });
|
||||
await metaPull({ gitDir: b.gitDir, workTree: b.workTree, remoteUrl: host, branch: 'main' });
|
||||
|
||||
// machine A edits the first block, pushes
|
||||
await writeFile(join(a.workTree, '.tasks/STATUS.md'), 'A: 2\n\nB: 1\n');
|
||||
const ap = await metaPush({ gitDir: a.gitDir, workTree: a.workTree, branch: 'main', message: 'edit A' });
|
||||
expect(ap.status).toBe('pushed');
|
||||
|
||||
// machine B edits the second block, pushes — must merge A's change cleanly
|
||||
await writeFile(join(b.workTree, '.tasks/STATUS.md'), 'A: 1\n\nB: 2\n');
|
||||
const bp = await metaPush({ gitDir: b.gitDir, workTree: b.workTree, branch: 'main', message: 'edit B' });
|
||||
expect(bp.status).toBe('pushed');
|
||||
|
||||
// host now carries BOTH edits
|
||||
const a2 = await machine('verify');
|
||||
await metaPull({ gitDir: a2.gitDir, workTree: a2.workTree, remoteUrl: host, branch: 'main' });
|
||||
expect(await readFile(join(a2.workTree, '.tasks/STATUS.md'), 'utf8')).toBe('A: 2\n\nB: 2\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('conflict — same line on two machines', () => {
|
||||
it('surfaces both versions and does NOT clobber the host', { timeout: 30000 }, async () => {
|
||||
const host = await makeHost({ '.tasks/STATUS.md': 'shared: 1\n' });
|
||||
const a = await machine('a');
|
||||
const b = await machine('b');
|
||||
await metaPull({ gitDir: a.gitDir, workTree: a.workTree, remoteUrl: host, branch: 'main' });
|
||||
await metaPull({ gitDir: b.gitDir, workTree: b.workTree, remoteUrl: host, branch: 'main' });
|
||||
|
||||
await writeFile(join(a.workTree, '.tasks/STATUS.md'), 'shared: A\n');
|
||||
await metaPush({ gitDir: a.gitDir, workTree: a.workTree, branch: 'main', message: 'A wins line' });
|
||||
|
||||
// B edits the same line — push must surface, not clobber
|
||||
await writeFile(join(b.workTree, '.tasks/STATUS.md'), 'shared: B\n');
|
||||
const bp = await metaPush({ gitDir: b.gitDir, workTree: b.workTree, branch: 'main', message: 'B same line' });
|
||||
|
||||
expect(bp.status).toBe('conflict');
|
||||
if (bp.status !== 'conflict') throw new Error('unreachable');
|
||||
const f = bp.conflicts.find((c) => c.path === '.tasks/STATUS.md');
|
||||
expect(f).toBeDefined();
|
||||
expect(f!.ours).toBe('shared: B\n');
|
||||
expect(f!.theirs).toBe('shared: A\n');
|
||||
|
||||
// host still holds A's version — B did not clobber it
|
||||
const v = await machine('verify');
|
||||
await metaPull({ gitDir: v.gitDir, workTree: v.workTree, remoteUrl: host, branch: 'main' });
|
||||
expect(await readFile(join(v.workTree, '.tasks/STATUS.md'), 'utf8')).toBe('shared: A\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('default meta paths — private .claude, not public root CLAUDE.md', () => {
|
||||
// Design contract (concepts/projects-meta-folder-sync.md, "Граничные / отложенное"):
|
||||
// root CLAUDE.md is PUBLIC (code-git → github); private instructions live in
|
||||
// `.claude/CLAUDE.md` (covered by the global ignore, on the meta rail). The default
|
||||
// meta set must therefore track `.claude/`, never the root CLAUDE.md — otherwise a
|
||||
// host carrying a root CLAUDE.md would materialize it where the code-git is NOT blind
|
||||
// to it (global ignore covers `.claude/` the dir, not the root file) → leak.
|
||||
it('tracks .claude and never the root CLAUDE.md', () => {
|
||||
expect(DEFAULT_META_PATHS).toEqual(['.wiki', '.tasks', '.claude']);
|
||||
expect(DEFAULT_META_PATHS).not.toContain('CLAUDE.md');
|
||||
});
|
||||
|
||||
it('stages private .claude/CLAUDE.md but leaves a root CLAUDE.md to the code-git', { timeout: 30000 }, async () => {
|
||||
const host = await makeHost({ '.claude/CLAUDE.md': 'private\n' });
|
||||
const m = await machine('a');
|
||||
await metaPull({ gitDir: m.gitDir, workTree: m.workTree, remoteUrl: host, branch: 'main' });
|
||||
|
||||
// a root CLAUDE.md sitting in the same folder is PUBLIC — code-git owns it
|
||||
await writeFile(join(m.workTree, 'CLAUDE.md'), 'public domain doc\n');
|
||||
// a real private-instructions edit on the meta rail
|
||||
await writeFile(join(m.workTree, '.claude/CLAUDE.md'), 'private edited\n');
|
||||
|
||||
const res = await metaPush({ gitDir: m.gitDir, workTree: m.workTree, branch: 'main', message: 'private only' });
|
||||
expect(res.status).toBe('pushed');
|
||||
|
||||
const tracked = (await rawGit(m.workTree, '--git-dir', m.gitDir, '--work-tree', m.workTree, 'ls-files')).trim().split('\n');
|
||||
expect(tracked).toContain('.claude/CLAUDE.md');
|
||||
expect(tracked).not.toContain('CLAUDE.md');
|
||||
|
||||
// the host clone never received the public root CLAUDE.md
|
||||
const v = await machine('verify');
|
||||
await metaPull({ gitDir: v.gitDir, workTree: v.workTree, remoteUrl: host, branch: 'main' });
|
||||
const names = await readdir(v.workTree);
|
||||
expect(names).not.toContain('CLAUDE.md');
|
||||
expect(existsSync(join(v.workTree, '.claude/CLAUDE.md'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no-leak — code files never enter the meta git', () => {
|
||||
it('commits only meta paths, ignoring untracked code in the work-tree', { timeout: 30000 }, async () => {
|
||||
const host = await makeHost({ '.tasks/STATUS.md': 'x\n' });
|
||||
const m = await machine('a');
|
||||
await metaPull({ gitDir: m.gitDir, workTree: m.workTree, remoteUrl: host, branch: 'main' });
|
||||
|
||||
// untracked code sitting in the same folder
|
||||
await writeFile(join(m.workTree, 'app.js'), 'console.log(1)\n');
|
||||
await mkdir(join(m.workTree, 'src'), { recursive: true });
|
||||
await writeFile(join(m.workTree, 'src/index.ts'), 'export {}\n');
|
||||
// a real meta edit
|
||||
await writeFile(join(m.workTree, '.tasks/STATUS.md'), 'y\n');
|
||||
|
||||
const res = await metaPush({ gitDir: m.gitDir, workTree: m.workTree, branch: 'main', message: 'meta only' });
|
||||
expect(res.status).toBe('pushed');
|
||||
|
||||
// the meta git index must not contain code paths
|
||||
const tracked = (await rawGit(m.workTree, '--git-dir', m.gitDir, '--work-tree', m.workTree, 'ls-files')).trim().split('\n');
|
||||
expect(tracked).toContain('.tasks/STATUS.md');
|
||||
expect(tracked).not.toContain('app.js');
|
||||
expect(tracked).not.toContain('src/index.ts');
|
||||
|
||||
// and the host clone never received code
|
||||
const v = await machine('verify');
|
||||
await metaPull({ gitDir: v.gitDir, workTree: v.workTree, remoteUrl: host, branch: 'main' });
|
||||
const names = await readdir(v.workTree);
|
||||
expect(names).not.toContain('app.js');
|
||||
expect(names).not.toContain('src');
|
||||
});
|
||||
});
|
||||
119
lib/projects-meta-mcp/tests/lib/policy.test.ts
Normal file
119
lib/projects-meta-mcp/tests/lib/policy.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parsePolicyToml, effectiveClaimFields } from '../../src/lib/policy.js';
|
||||
import type { ParsedTask } from '../../src/lib/status-md.js';
|
||||
|
||||
function task(over: Partial<ParsedTask> = {}): ParsedTask {
|
||||
return { slug: 'x', status: 'ready', description: 'x', next: null, ...over };
|
||||
}
|
||||
|
||||
describe('parsePolicyToml', () => {
|
||||
it('null content → null policy (unrestricted)', () => {
|
||||
const r = parsePolicyToml(null);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.policy).toBeNull();
|
||||
});
|
||||
|
||||
it('parses default_runtime_allowed', () => {
|
||||
const r = parsePolicyToml('default_runtime_allowed = ["claude-opus", "claude-sonnet"]\n');
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.policy?.defaultRuntimeAllowed).toEqual(['claude-opus', 'claude-sonnet']);
|
||||
});
|
||||
|
||||
it('parses default_requirements + default_weight', () => {
|
||||
const r = parsePolicyToml('default_requirements = ["needs-secrets"]\ndefault_weight = "needs-claude"\n');
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.policy?.defaultRequirements).toEqual(['needs-secrets']);
|
||||
expect(r.policy?.defaultWeight).toBe('needs-claude');
|
||||
}
|
||||
});
|
||||
|
||||
it('empty file → empty policy object', () => {
|
||||
const r = parsePolicyToml('\n');
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.policy).toEqual({});
|
||||
});
|
||||
|
||||
it('malformed TOML → structured error', () => {
|
||||
const r = parsePolicyToml('default_runtime_allowed = [unterminated\n');
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain('malformed');
|
||||
});
|
||||
|
||||
it('wrong type (string not array) → structured error', () => {
|
||||
const r = parsePolicyToml('default_runtime_allowed = "claude-opus"\n');
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain('array of strings');
|
||||
});
|
||||
|
||||
it('wrong type for default_weight → structured error', () => {
|
||||
const r = parsePolicyToml('default_weight = ["a"]\n');
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain('default_weight must be a string');
|
||||
});
|
||||
|
||||
it('parses default_consult_policy (valid enum)', () => {
|
||||
const r = parsePolicyToml('default_consult_policy = "strict-human"\n');
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.policy?.defaultConsultPolicy).toBe('strict-human');
|
||||
});
|
||||
|
||||
it('default_consult_policy must be a string', () => {
|
||||
const r = parsePolicyToml('default_consult_policy = ["auto"]\n');
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain('default_consult_policy must be a string');
|
||||
});
|
||||
|
||||
it('default_consult_policy invalid enum → structured error', () => {
|
||||
const r = parsePolicyToml('default_consult_policy = "yolo"\n');
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain('default_consult_policy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectiveClaimFields', () => {
|
||||
it('task field wins over policy (total override, not intersect)', () => {
|
||||
const eff = effectiveClaimFields(task({ runtimeAllowed: ['hermes-glm51'] }), {
|
||||
defaultRuntimeAllowed: ['claude-opus'],
|
||||
});
|
||||
expect(eff.runtimeAllowed).toEqual(['hermes-glm51']);
|
||||
});
|
||||
|
||||
it('policy default fills when the task omits the field', () => {
|
||||
const eff = effectiveClaimFields(task(), { defaultRuntimeAllowed: ['claude-opus'] });
|
||||
expect(eff.runtimeAllowed).toEqual(['claude-opus']);
|
||||
});
|
||||
|
||||
it('null policy → all undefined (unrestricted)', () => {
|
||||
const eff = effectiveClaimFields(task(), null);
|
||||
expect(eff.runtimeAllowed).toBeUndefined();
|
||||
expect(eff.requirements).toBeUndefined();
|
||||
expect(eff.weight).toBeUndefined();
|
||||
});
|
||||
|
||||
it('overlays requirements + weight from policy', () => {
|
||||
const eff = effectiveClaimFields(task(), {
|
||||
defaultRequirements: ['needs-db'],
|
||||
defaultWeight: 'needs-claude',
|
||||
});
|
||||
expect(eff.requirements).toEqual(['needs-db']);
|
||||
expect(eff.weight).toBe('needs-claude');
|
||||
});
|
||||
|
||||
it('consult_policy: task field wins over policy default', () => {
|
||||
const eff = effectiveClaimFields(task({ consultPolicy: 'auto' }), {
|
||||
defaultConsultPolicy: 'strict-human',
|
||||
});
|
||||
expect(eff.consultPolicy).toBe('auto');
|
||||
});
|
||||
|
||||
it('consult_policy: policy default fills when task omits it', () => {
|
||||
const eff = effectiveClaimFields(task(), { defaultConsultPolicy: 'strict-human' });
|
||||
expect(eff.consultPolicy).toBe('strict-human');
|
||||
});
|
||||
|
||||
it('consult_policy: built-in default is human-only when neither task nor policy sets it', () => {
|
||||
expect(effectiveClaimFields(task(), null).consultPolicy).toBe('human-only');
|
||||
expect(effectiveClaimFields(task(), {}).consultPolicy).toBe('human-only');
|
||||
});
|
||||
});
|
||||
93
lib/projects-meta-mcp/tests/lib/promotion.test.ts
Normal file
93
lib/projects-meta-mcp/tests/lib/promotion.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { findCandidates } from '../../src/lib/promotion.js';
|
||||
import type { WikiPage } from '../../src/lib/wiki-index.js';
|
||||
|
||||
let projectWiki: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'promo-'));
|
||||
projectWiki = join(root, '.wiki');
|
||||
await mkdir(join(projectWiki, 'concepts'), { recursive: true });
|
||||
});
|
||||
|
||||
const sharedPages: WikiPage[] = [
|
||||
{ slug: 'embedded/yarn-on-windows', title: 'Yarn on Windows', domain: 'embedded', tags: [], body: '', raw: '' },
|
||||
];
|
||||
|
||||
describe('findCandidates', () => {
|
||||
it('includes platform-flavored concept', async () => {
|
||||
await writeFile(
|
||||
join(projectWiki, 'concepts', 'windows-yarn-quirk.md'),
|
||||
'---\ntitle: Windows yarn quirk\n---\n\nWhen yarn on Windows, exec() is required.',
|
||||
);
|
||||
const r = await findCandidates({
|
||||
projectWikiDir: projectWiki,
|
||||
sharedPages,
|
||||
cwdDomain: 'node',
|
||||
blacklist: [],
|
||||
});
|
||||
expect(r).toHaveLength(1);
|
||||
expect(r[0].suggested_domain).toBe('node');
|
||||
expect(r[0].confidence).toBe('high'); // platform keyword + similar shared title
|
||||
});
|
||||
|
||||
it('excludes when project name in blacklist matched', async () => {
|
||||
await writeFile(
|
||||
join(projectWiki, 'concepts', 'snolla-edge-case.md'),
|
||||
'---\ntitle: Snolla edge case\n---\n\nWhen yarn meets the Snolla pipeline, special handling for npm-mcp.',
|
||||
);
|
||||
const r = await findCandidates({
|
||||
projectWikiDir: projectWiki,
|
||||
sharedPages,
|
||||
cwdDomain: 'node',
|
||||
blacklist: ['snolla', 'npm-mcp'],
|
||||
});
|
||||
expect(r).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns confidence=medium when keyword match but no similar shared title', async () => {
|
||||
await writeFile(
|
||||
join(projectWiki, 'concepts', 'docker-on-linux.md'),
|
||||
'---\ntitle: Docker on Linux\n---\n\nDocker daemon socket on Linux.',
|
||||
);
|
||||
const r = await findCandidates({
|
||||
projectWikiDir: projectWiki,
|
||||
sharedPages,
|
||||
cwdDomain: 'node',
|
||||
blacklist: [],
|
||||
});
|
||||
expect(r).toHaveLength(1);
|
||||
expect(r[0].confidence).toBe('medium');
|
||||
});
|
||||
|
||||
it('skips concept without platform keyword', async () => {
|
||||
await writeFile(
|
||||
join(projectWiki, 'concepts', 'business-rule.md'),
|
||||
'---\ntitle: Order discount logic\n---\n\nWhen total > 100 apply 5% off.',
|
||||
);
|
||||
const r = await findCandidates({
|
||||
projectWikiDir: projectWiki,
|
||||
sharedPages,
|
||||
cwdDomain: 'node',
|
||||
blacklist: [],
|
||||
});
|
||||
expect(r).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('cwdDomain unknown → suggested_domain=cross', async () => {
|
||||
await writeFile(
|
||||
join(projectWiki, 'concepts', 'git-trick.md'),
|
||||
'---\ntitle: Git tip\n---\n\ngit reflog tip.',
|
||||
);
|
||||
const r = await findCandidates({
|
||||
projectWikiDir: projectWiki,
|
||||
sharedPages,
|
||||
cwdDomain: 'unknown',
|
||||
blacklist: [],
|
||||
});
|
||||
expect(r[0].suggested_domain).toBe('cross');
|
||||
});
|
||||
});
|
||||
213
lib/projects-meta-mcp/tests/lib/resolve-target.test.ts
Normal file
213
lib/projects-meta-mcp/tests/lib/resolve-target.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtemp } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { writeCache, type CacheFile } from '../../src/lib/cache.js';
|
||||
import { parseTargetProject, resolveTarget } from '../../src/lib/resolve-target.js';
|
||||
|
||||
describe('parseTargetProject', () => {
|
||||
it('accepts the agenda literal', () => {
|
||||
const r = parseTargetProject('agenda');
|
||||
expect(r).toEqual({ agenda: true });
|
||||
});
|
||||
|
||||
it('accepts qualified <owner>/<repo>', () => {
|
||||
const r = parseTargetProject('victor/books');
|
||||
expect(r).toEqual({ agenda: false, owner: 'victor', repo: 'books' });
|
||||
});
|
||||
|
||||
it('rejects bare repo name with hint', () => {
|
||||
const r = parseTargetProject('books');
|
||||
expect(r).toEqual({ error: expect.stringContaining('must be qualified') });
|
||||
const err = (r as { error: string }).error;
|
||||
expect(err).toContain('use `<owner>/<repo>`');
|
||||
expect(err).toContain('victor/books');
|
||||
expect(err).toContain('Got: `books`');
|
||||
});
|
||||
|
||||
it('rejects the legacy _meta literal (replaced by `agenda` in 2.0)', () => {
|
||||
const r = parseTargetProject('_meta');
|
||||
expect(r).toEqual({ error: expect.stringContaining('must be qualified') });
|
||||
expect((r as { error: string }).error).toContain('Got: `_meta`');
|
||||
});
|
||||
|
||||
it('rejects empty string', () => {
|
||||
const r = parseTargetProject('');
|
||||
expect('error' in r).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects leading slash', () => {
|
||||
const r = parseTargetProject('/books');
|
||||
expect('error' in r).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects trailing slash', () => {
|
||||
const r = parseTargetProject('victor/');
|
||||
expect('error' in r).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects more than one slash', () => {
|
||||
const r = parseTargetProject('a/b/c');
|
||||
expect('error' in r).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects whitespace inside', () => {
|
||||
const r = parseTargetProject('victor /books');
|
||||
expect('error' in r).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty owner segment', () => {
|
||||
const r = parseTargetProject('//books');
|
||||
expect('error' in r).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTarget', () => {
|
||||
let cacheFile: string;
|
||||
const baseFixture: CacheFile = {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-05-08T00:00:00Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [
|
||||
{
|
||||
name: 'victor/books',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-05-08T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
{
|
||||
name: 'OpeItcLoc03/common',
|
||||
default_branch: 'master',
|
||||
fetched_at: '2026-05-08T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'resolve-target-'));
|
||||
cacheFile = join(dir, 'cache.json');
|
||||
await writeCache(cacheFile, baseFixture);
|
||||
});
|
||||
|
||||
it('returns owner+repo+branch for qualified target in cache', async () => {
|
||||
const r = await resolveTarget(cacheFile, 'victor/books', 'agenda', 'OpeItcLoc03');
|
||||
expect(r).toEqual({ owner: 'victor', repo: 'books', branch: 'main', isAgenda: false });
|
||||
});
|
||||
|
||||
it('routes agenda to fallbackOwner + agendaRepo', async () => {
|
||||
await writeCache(cacheFile, {
|
||||
...baseFixture,
|
||||
projects: [
|
||||
...baseFixture.projects,
|
||||
{
|
||||
name: 'agenda',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-05-08T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
const r = await resolveTarget(cacheFile, 'agenda', 'agenda', 'OpeItcLoc03');
|
||||
expect(r).toEqual({ owner: 'OpeItcLoc03', repo: 'agenda', branch: 'main', isAgenda: true });
|
||||
});
|
||||
|
||||
it('rejects bare name with hint message', async () => {
|
||||
const r = await resolveTarget(cacheFile, 'books', 'agenda', 'OpeItcLoc03');
|
||||
expect('error' in r).toBe(true);
|
||||
expect((r as { error: string }).error).toContain('must be qualified');
|
||||
});
|
||||
|
||||
it('errors on cache miss for qualified target', async () => {
|
||||
const r = await resolveTarget(cacheFile, 'ghost/repo', 'agenda', 'OpeItcLoc03');
|
||||
expect('error' in r).toBe(true);
|
||||
expect((r as { error: string }).error).toContain('not in cache');
|
||||
});
|
||||
|
||||
it('qualified-unknown owner returns cache-miss error, not shape-error', async () => {
|
||||
const r = await resolveTarget(cacheFile, 'ekassir/books', 'agenda', 'OpeItcLoc03');
|
||||
expect('error' in r).toBe(true);
|
||||
const err = (r as { error: string }).error;
|
||||
expect(err).not.toContain('must be qualified');
|
||||
expect(err).toContain('not in cache');
|
||||
expect(err).toContain('ekassir/books');
|
||||
});
|
||||
|
||||
it('errors on missing agenda entry in cache', async () => {
|
||||
const r = await resolveTarget(cacheFile, 'agenda', 'agenda', 'OpeItcLoc03');
|
||||
expect('error' in r).toBe(true);
|
||||
expect((r as { error: string }).error).toContain('agenda not in cache');
|
||||
});
|
||||
|
||||
it('errors when agenda is local-only', async () => {
|
||||
await writeCache(cacheFile, {
|
||||
...baseFixture,
|
||||
projects: [
|
||||
...baseFixture.projects,
|
||||
{
|
||||
name: 'agenda',
|
||||
default_branch: 'local',
|
||||
fetched_at: '2026-05-08T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
const r = await resolveTarget(cacheFile, 'agenda', 'agenda', 'OpeItcLoc03');
|
||||
expect('error' in r).toBe(true);
|
||||
expect((r as { error: string }).error).toContain('Gitea repo "agenda" not found');
|
||||
});
|
||||
|
||||
it('errors when cache missing entirely', async () => {
|
||||
const r = await resolveTarget('/nonexistent/cache.json', 'victor/books', 'agenda', 'OpeItcLoc03');
|
||||
expect('error' in r).toBe(true);
|
||||
expect((r as { error: string }).error).toContain('cache missing');
|
||||
});
|
||||
|
||||
it('routes agenda to agendaOwner override (qualified agenda_tasks_repo)', async () => {
|
||||
await writeCache(cacheFile, {
|
||||
...baseFixture,
|
||||
projects: [
|
||||
...baseFixture.projects,
|
||||
{
|
||||
name: 'agenda',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-05-08T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
const r = await resolveTarget(cacheFile, 'agenda', 'agenda', 'OpeItcLoc03', 'victor');
|
||||
expect(r).toEqual({ owner: 'victor', repo: 'agenda', branch: 'main', isAgenda: true });
|
||||
});
|
||||
|
||||
it('falls back to fallbackOwner when agendaOwner is undefined (bare agenda_tasks_repo)', async () => {
|
||||
await writeCache(cacheFile, {
|
||||
...baseFixture,
|
||||
projects: [
|
||||
...baseFixture.projects,
|
||||
{
|
||||
name: 'agenda',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-05-08T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
const r = await resolveTarget(cacheFile, 'agenda', 'agenda', 'OpeItcLoc03', undefined);
|
||||
expect(r).toEqual({ owner: 'OpeItcLoc03', repo: 'agenda', branch: 'main', isAgenda: true });
|
||||
});
|
||||
});
|
||||
383
lib/projects-meta-mcp/tests/lib/status-md-writer.test.ts
Normal file
383
lib/projects-meta-mcp/tests/lib/status-md-writer.test.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
formatTaskBlock,
|
||||
appendTaskBlock,
|
||||
freshBoardTemplate,
|
||||
closeTask,
|
||||
updateTaskFields,
|
||||
hasTaskSlug,
|
||||
} from '../../src/lib/status-md-writer.js';
|
||||
import { parseStatusMd } from '../../src/lib/status-md.js';
|
||||
|
||||
describe('formatTaskBlock', () => {
|
||||
it('formats minimal active block', () => {
|
||||
const out = formatTaskBlock({
|
||||
slug: 't1',
|
||||
status: 'active',
|
||||
description: 'first task',
|
||||
whereStopped: 'just started',
|
||||
nextAction: 'do x',
|
||||
});
|
||||
expect(out).toContain('## 🔴 [t1] — first task');
|
||||
expect(out).toContain('**Status:** active');
|
||||
expect(out).toContain('**Where I stopped:** just started');
|
||||
expect(out).toContain('**Next action:** do x');
|
||||
expect(out).toContain('**Branch:** n/a');
|
||||
expect(out).toContain('---');
|
||||
expect(out).not.toContain('Blocker');
|
||||
});
|
||||
|
||||
it('formats blocked block with blocker line', () => {
|
||||
const out = formatTaskBlock({
|
||||
slug: 't2',
|
||||
status: 'blocked',
|
||||
description: 'waiting',
|
||||
whereStopped: 'depends',
|
||||
nextAction: 'wait',
|
||||
blocker: 'upstream review',
|
||||
});
|
||||
expect(out).toContain('## 🔵 [t2] — waiting');
|
||||
expect(out).toContain('**Blocker:** upstream review');
|
||||
});
|
||||
|
||||
it('writes identity footer', () => {
|
||||
const out = formatTaskBlock({
|
||||
slug: 't3',
|
||||
status: 'ready',
|
||||
description: 'd',
|
||||
whereStopped: 'w',
|
||||
nextAction: 'n',
|
||||
createdBy: 'vitya@laptop',
|
||||
fromProject: 'modules-db',
|
||||
createdAtIso: '2026-04-29T12:00:00Z',
|
||||
});
|
||||
expect(out).toContain(
|
||||
'<!-- created-by: vitya@laptop / from: modules-db / 2026-04-29T12:00:00Z -->',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTaskBlock — notify field', () => {
|
||||
it('emits **Notify:** line when notify is set', () => {
|
||||
const out = formatTaskBlock({
|
||||
slug: 't-notify',
|
||||
status: 'ready',
|
||||
description: 'needs notify',
|
||||
whereStopped: 'w',
|
||||
nextAction: 'n',
|
||||
notify: 'OpeItcLoc03/workshop',
|
||||
});
|
||||
expect(out).toContain('**Notify:** OpeItcLoc03/workshop');
|
||||
});
|
||||
|
||||
it('omits Notify line when notify is absent', () => {
|
||||
const out = formatTaskBlock({
|
||||
slug: 't-no-notify',
|
||||
status: 'ready',
|
||||
description: 'no notify',
|
||||
whereStopped: 'w',
|
||||
nextAction: 'n',
|
||||
});
|
||||
expect(out).not.toContain('Notify');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendTaskBlock', () => {
|
||||
it('appends to non-empty markdown with one blank-line separator', () => {
|
||||
const before = '# Task Board\n\nsome stuff';
|
||||
const out = appendTaskBlock(before, {
|
||||
slug: 't1',
|
||||
status: 'ready',
|
||||
description: 'd',
|
||||
whereStopped: 'w',
|
||||
nextAction: 'n',
|
||||
});
|
||||
expect(out).toMatch(/some stuff\n\n## ⚪ \[t1\]/);
|
||||
expect(out.endsWith('\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('initializes from empty content', () => {
|
||||
const out = appendTaskBlock('', {
|
||||
slug: 't1',
|
||||
status: 'ready',
|
||||
description: 'd',
|
||||
whereStopped: 'w',
|
||||
nextAction: 'n',
|
||||
});
|
||||
expect(out).toMatch(/^## ⚪ \[t1\]/);
|
||||
});
|
||||
|
||||
it('round-trips through the parser', () => {
|
||||
const board = freshBoardTemplate('2026-04-29');
|
||||
const after = appendTaskBlock(board, {
|
||||
slug: 'rt',
|
||||
status: 'paused',
|
||||
description: 'roundtrip',
|
||||
whereStopped: 'mid-flow',
|
||||
nextAction: 'resume',
|
||||
});
|
||||
const parsed = parseStatusMd(after);
|
||||
expect(parsed.tasks.map((t) => t.slug)).toEqual(['rt']);
|
||||
expect(parsed.tasks[0].status).toBe('paused');
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeTask', () => {
|
||||
const board = `# Task Board\n_Updated: 2026-04-29_\n\n## 🔴 [t1] — first\n**Status:** active\n**Where I stopped:** mid\n**Next action:** ship\n**Branch:** main\n\n---\n\n## ⚪ [t2] — second\n**Status:** ready\n**Next action:** start\n**Branch:** n/a\n\n---\n`;
|
||||
|
||||
it('closes 🔴 active to 🟢 done', () => {
|
||||
const out = closeTask(board, 't1');
|
||||
expect(out).not.toBeNull();
|
||||
expect(out).toMatch(/^## 🟢 \[t1\] — first/m);
|
||||
expect(out).toContain('**Status:** done');
|
||||
expect(out).toMatch(/^## ⚪ \[t2\]/m);
|
||||
const parsed = parseStatusMd(out!);
|
||||
expect(parsed.tasks.find((t) => t.slug === 't1')?.status).toBe('done');
|
||||
expect(parsed.tasks.find((t) => t.slug === 't2')?.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('appends closing-by metadata', () => {
|
||||
const out = closeTask(board, 't1', {
|
||||
closedBy: 'vitya@laptop',
|
||||
closedAtIso: '2026-04-29T12:00:00Z',
|
||||
note: 'shipped',
|
||||
});
|
||||
expect(out).toContain('<!-- closed-by: vitya@laptop / 2026-04-29T12:00:00Z / note: shipped -->');
|
||||
});
|
||||
|
||||
it('normalizes Where I stopped and Next action on close', () => {
|
||||
const out = closeTask(board, 't1');
|
||||
expect(out).toContain('**Where I stopped:** closed via tasks_close');
|
||||
expect(out).toContain('**Next action:** (none — kept until merged)');
|
||||
});
|
||||
|
||||
it('uses close note as Where I stopped when provided', () => {
|
||||
const out = closeTask(board, 't1', { note: 'shipped v1.0' });
|
||||
expect(out).toContain('**Where I stopped:** shipped v1.0');
|
||||
expect(out).toContain('**Next action:** (none — kept until merged)');
|
||||
});
|
||||
|
||||
it('returns null when slug missing', () => {
|
||||
const out = closeTask(board, 'nope');
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it('clears the claim-stamp (Owner/Claim token/Claim expires at) on close', () => {
|
||||
// root-cause-#3 hygiene: a closed task must carry no lingering claim fields,
|
||||
// otherwise its stale stamp can be re-claimed once the TTL lapses.
|
||||
const claimed = `# Task Board\n_Updated: 2026-04-29_\n\n## 🔴 [t1] — first\n**Status:** active\n**Where I stopped:** mid\n**Next action:** ship\n**Branch:** main\n**Owner:** win11:claude-opus:s1\n**Claim token:** tok-1\n**Claim expires at:** 2026-04-29T12:10:00.000Z\n\n---\n`;
|
||||
const out = closeTask(claimed, 't1', { closedBy: 'vitya@laptop', closedAtIso: '2026-04-29T12:00:00Z' });
|
||||
expect(out).not.toBeNull();
|
||||
expect(out).toContain('**Status:** done');
|
||||
expect(out).not.toContain('**Owner:**');
|
||||
expect(out).not.toContain('**Claim token:**');
|
||||
expect(out).not.toContain('**Claim expires at:**');
|
||||
// closed-by footer is preserved
|
||||
expect(out).toContain('closed-by: vitya@laptop');
|
||||
const parsed = parseStatusMd(out!);
|
||||
const t1 = parsed.tasks.find((t) => t.slug === 't1');
|
||||
expect(t1?.status).toBe('done');
|
||||
expect(t1?.owner).toBeUndefined();
|
||||
expect(t1?.claimToken).toBeUndefined();
|
||||
expect(t1?.claimExpiresAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('appends bodyAppend section before closing ---', () => {
|
||||
const outcome = '## Review outcome\n\nstatus: approve\nsummary: all criteria met';
|
||||
const out = closeTask(board, 't1', { bodyAppend: outcome });
|
||||
expect(out).not.toBeNull();
|
||||
expect(out).toContain('## Review outcome');
|
||||
expect(out).toContain('status: approve');
|
||||
const bodyPos = out!.indexOf('## Review outcome');
|
||||
const sepPos = out!.indexOf('\n---\n');
|
||||
expect(bodyPos).toBeLessThan(sepPos);
|
||||
});
|
||||
|
||||
it('bodyAppend appears before closing comment when both present', () => {
|
||||
const outcome = '## Review outcome\n\nstatus: reject\nsummary: criteria failed';
|
||||
const out = closeTask(board, 't1', {
|
||||
bodyAppend: outcome,
|
||||
closedBy: 'bot@host',
|
||||
closedAtIso: '2026-06-09T00:00:00Z',
|
||||
});
|
||||
expect(out).not.toBeNull();
|
||||
const appendPos = out!.indexOf('## Review outcome');
|
||||
const commentPos = out!.indexOf('<!-- closed-by:');
|
||||
expect(appendPos).toBeGreaterThan(0);
|
||||
expect(appendPos).toBeLessThan(commentPos);
|
||||
});
|
||||
|
||||
it('no bodyAppend → output unchanged vs current behavior', () => {
|
||||
const out = closeTask(board, 't1', {});
|
||||
expect(out).not.toContain('## Review outcome');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTaskFields', () => {
|
||||
const board = `# Task Board\n_Updated: 2026-04-29_\n\n## 🔴 [t1] — first\n**Status:** active\n**Where I stopped:** old\n**Next action:** old next\n**Branch:** main\n\n---\n`;
|
||||
|
||||
it('updates whereStopped + nextAction without touching others', () => {
|
||||
const out = updateTaskFields(board, 't1', {
|
||||
whereStopped: 'NEW stopped',
|
||||
nextAction: 'NEW next',
|
||||
});
|
||||
expect(out).toContain('**Where I stopped:** NEW stopped');
|
||||
expect(out).toContain('**Next action:** NEW next');
|
||||
expect(out).toContain('## 🔴 [t1]');
|
||||
expect(out).toContain('**Status:** active');
|
||||
});
|
||||
|
||||
it('changes status updates both Status field and header emoji', () => {
|
||||
const out = updateTaskFields(board, 't1', { status: 'paused' });
|
||||
expect(out).toMatch(/^## 🟡 \[t1\]/m);
|
||||
expect(out).toContain('**Status:** paused');
|
||||
});
|
||||
|
||||
it('returns null when slug missing', () => {
|
||||
expect(updateTaskFields(board, 'nope', { whereStopped: 'x' })).toBeNull();
|
||||
});
|
||||
|
||||
it('changes description in header', () => {
|
||||
const out = updateTaskFields(board, 't1', { description: 'renamed task' });
|
||||
expect(out).toMatch(/^## 🔴 \[t1\] — renamed task$/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTaskBlock — agent-orchestration schema fields', () => {
|
||||
it('serializes provided new fields and round-trips through the parser', () => {
|
||||
const out = formatTaskBlock({
|
||||
slug: 'claimable',
|
||||
status: 'ready',
|
||||
description: 'd',
|
||||
whereStopped: 'w',
|
||||
nextAction: 'n',
|
||||
branch: 'master',
|
||||
owner: 'win11:claude-opus:s1',
|
||||
claimToken: 'tok-1',
|
||||
claimExpiresAt: '2026-06-07T12:00:00Z',
|
||||
requirements: ['needs-db'],
|
||||
weight: 'needs-claude',
|
||||
runtimeAllowed: ['claude-opus', 'claude-sonnet'],
|
||||
runtimePreferences: ['claude-opus'],
|
||||
resultArtifactUrl: 'https://x/pr/1',
|
||||
workerRegistry: { node: 'win11' },
|
||||
});
|
||||
expect(out).toContain('**Owner:** win11:claude-opus:s1');
|
||||
expect(out).toContain('**Runtime allowed:** claude-opus, claude-sonnet');
|
||||
const t = parseStatusMd(`${out}\n`).tasks[0];
|
||||
expect(t.owner).toBe('win11:claude-opus:s1');
|
||||
expect(t.claimToken).toBe('tok-1');
|
||||
expect(t.claimExpiresAt).toBe('2026-06-07T12:00:00Z');
|
||||
expect(t.requirements).toEqual(['needs-db']);
|
||||
expect(t.weight).toBe('needs-claude');
|
||||
expect(t.runtimeAllowed).toEqual(['claude-opus', 'claude-sonnet']);
|
||||
expect(t.runtimePreferences).toEqual(['claude-opus']);
|
||||
expect(t.resultArtifactUrl).toBe('https://x/pr/1');
|
||||
expect(t.workerRegistry).toEqual({ node: 'win11' });
|
||||
});
|
||||
|
||||
it('omits lines for absent new fields (no noise on legacy create)', () => {
|
||||
const out = formatTaskBlock({
|
||||
slug: 't',
|
||||
status: 'ready',
|
||||
description: 'd',
|
||||
whereStopped: 'w',
|
||||
nextAction: 'n',
|
||||
});
|
||||
expect(out).not.toContain('**Owner:**');
|
||||
expect(out).not.toContain('**Claim token:**');
|
||||
expect(out).not.toContain('**Requirements:**');
|
||||
expect(out).not.toContain('**Worker registry:**');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTaskFields — preserves agent-orchestration fields', () => {
|
||||
const board = `# Task Board\n_Updated: 2026-06-07_\n\n## 🔴 [t1] — first\n**Status:** active\n**Where I stopped:** old\n**Next action:** old next\n**Branch:** main\n**Owner:** win11:claude-opus:s1\n**Claim token:** tok-1\n\n---\n`;
|
||||
|
||||
it('keeps Owner + Claim token lines when editing an unrelated field', () => {
|
||||
const out = updateTaskFields(board, 't1', { whereStopped: 'NEW' })!;
|
||||
expect(out).toContain('**Where I stopped:** NEW');
|
||||
expect(out).toContain('**Owner:** win11:claude-opus:s1');
|
||||
expect(out).toContain('**Claim token:** tok-1');
|
||||
const t = parseStatusMd(out).tasks[0];
|
||||
expect(t.owner).toBe('win11:claude-opus:s1');
|
||||
expect(t.claimToken).toBe('tok-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTaskFields — weight + notify upsert', () => {
|
||||
const plainBoard =
|
||||
'# Task Board\n_Updated: 2026-06-11_\n\n## ⚪ [t1] — ready task\n**Status:** ready\n**Where I stopped:** none\n**Next action:** start\n**Branch:** master\n\n---\n';
|
||||
|
||||
it('inserts **Weight:** and **Notify:** lines when absent', () => {
|
||||
const out = updateTaskFields(plainBoard, 't1', {
|
||||
weight: 'needs-claude',
|
||||
notify: 'OpeItcLoc03/workshop',
|
||||
})!;
|
||||
expect(out).not.toBeNull();
|
||||
expect(out).toContain('**Weight:** needs-claude');
|
||||
expect(out).toContain('**Notify:** OpeItcLoc03/workshop');
|
||||
const t = parseStatusMd(out).tasks[0];
|
||||
expect(t.weight).toBe('needs-claude');
|
||||
expect(t.notify).toBe('OpeItcLoc03/workshop');
|
||||
});
|
||||
|
||||
it('replaces an existing **Weight:** line in place', () => {
|
||||
const weighted =
|
||||
'# Task Board\n\n## ⚪ [t1] — ready task\n**Status:** ready\n**Next action:** go\n**Branch:** master\n**Weight:** cheap-ok\n\n---\n';
|
||||
const out = updateTaskFields(weighted, 't1', { weight: 'needs-human' })!;
|
||||
expect(out).toContain('**Weight:** needs-human');
|
||||
expect(out).not.toContain('**Weight:** cheap-ok');
|
||||
// exactly one Weight line — replaced, not duplicated
|
||||
expect(out.match(/^\*\*Weight:\*\*/gm)!.length).toBe(1);
|
||||
});
|
||||
|
||||
it('replaces an existing **Notify:** line in place', () => {
|
||||
const withNotify =
|
||||
'# Task Board\n\n## ⚪ [t1] — ready task\n**Status:** ready\n**Next action:** go\n**Branch:** master\n**Notify:** OpeItcLoc03/old\n\n---\n';
|
||||
const out = updateTaskFields(withNotify, 't1', { notify: 'OpeItcLoc03/new' })!;
|
||||
expect(out).toContain('**Notify:** OpeItcLoc03/new');
|
||||
expect(out).not.toContain('**Notify:** OpeItcLoc03/old');
|
||||
expect(out.match(/^\*\*Notify:\*\*/gm)!.length).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves weight/notify untouched when not provided', () => {
|
||||
const weighted =
|
||||
'# Task Board\n\n## ⚪ [t1] — ready task\n**Status:** ready\n**Next action:** go\n**Branch:** master\n**Weight:** cheap-ok\n**Notify:** OpeItcLoc03/keep\n\n---\n';
|
||||
const out = updateTaskFields(weighted, 't1', { whereStopped: 'x' })!;
|
||||
expect(out).toContain('**Weight:** cheap-ok');
|
||||
expect(out).toContain('**Notify:** OpeItcLoc03/keep');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasTaskSlug', () => {
|
||||
const board = `## 🔴 [foo] — d\n**Next action:** n\n\n---\n## ⚪ [bar-baz] — d\n`;
|
||||
it('finds existing slug', () => {
|
||||
expect(hasTaskSlug(board, 'foo')).toBe(true);
|
||||
expect(hasTaskSlug(board, 'bar-baz')).toBe(true);
|
||||
});
|
||||
it('returns false for missing slug', () => {
|
||||
expect(hasTaskSlug(board, 'qux')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTaskFields — blocker clear (blocker: empty string removes the line)', () => {
|
||||
const blockedBoard =
|
||||
'# Task Board\n_Updated: 2026-06-09_\n\n## 🔵 [t1] — blocked task\n**Status:** blocked\n**Where I stopped:** dep pending\n**Next action:** wait\n**Blocker:** dep-a, dep-b\n**Branch:** master\n**Weight:** needs-claude\n\n---\n';
|
||||
|
||||
it('updateTaskFields({status: "ready", blocker: ""}) removes **Blocker:** line and flips emoji', () => {
|
||||
const out = updateTaskFields(blockedBoard, 't1', { status: 'ready', blocker: '' })!;
|
||||
expect(out).not.toBeNull();
|
||||
expect(out).toContain('## ⚪ [t1]');
|
||||
expect(out).toContain('**Status:** ready');
|
||||
expect(out).not.toContain('**Blocker:**');
|
||||
});
|
||||
|
||||
it('blocker: "" on a task without Blocker line is a no-op for that line', () => {
|
||||
const noBlocker =
|
||||
'# Task Board\n\n## ⚪ [t2] — ready task\n**Status:** ready\n**Next action:** go\n**Branch:** master\n\n---\n';
|
||||
const out = updateTaskFields(noBlocker, 't2', { blocker: '' })!;
|
||||
expect(out).not.toContain('**Blocker:**');
|
||||
expect(out).toContain('## ⚪ [t2]');
|
||||
});
|
||||
});
|
||||
170
lib/projects-meta-mcp/tests/lib/status-md.test.ts
Normal file
170
lib/projects-meta-mcp/tests/lib/status-md.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseStatusMd } from '../../src/lib/status-md.js';
|
||||
|
||||
const sample = `# Task Board
|
||||
_Updated: 2026-04-29_
|
||||
|
||||
## 🔴 [auth-rewrite] — Rebuild OAuth pipeline
|
||||
**Status:** active
|
||||
**Where I stopped:** Mid-refactor of token validator
|
||||
**Next action:** Add unit tests for refresh flow
|
||||
**Branch:** feat/auth-rewrite
|
||||
|
||||
---
|
||||
|
||||
## 🟡 [docs-sweep] — Refresh README
|
||||
**Status:** paused
|
||||
**Where I stopped:** Drafted intro, blocked on screenshots
|
||||
**Next action:** Capture login screencast
|
||||
**Branch:** docs/readme
|
||||
|
||||
---
|
||||
|
||||
## 🔵 [vendor-bug] — Upstream pagination broken
|
||||
**Status:** blocked
|
||||
**Blocker:** Waiting on vendor patch v2.4.1
|
||||
**Next action:** Re-run integration test once patched
|
||||
**Branch:** fix/vendor-bug
|
||||
|
||||
---
|
||||
`;
|
||||
|
||||
describe('parseStatusMd', () => {
|
||||
it('extracts updated timestamp', () => {
|
||||
const r = parseStatusMd(sample);
|
||||
expect(r.updated).toBe('2026-04-29');
|
||||
});
|
||||
|
||||
it('parses three tasks with correct statuses', () => {
|
||||
const r = parseStatusMd(sample);
|
||||
expect(r.tasks.map((t) => t.slug)).toEqual([
|
||||
'auth-rewrite',
|
||||
'docs-sweep',
|
||||
'vendor-bug',
|
||||
]);
|
||||
expect(r.tasks.map((t) => t.status)).toEqual(['active', 'paused', 'blocked']);
|
||||
});
|
||||
|
||||
it('captures description and next action', () => {
|
||||
const r = parseStatusMd(sample);
|
||||
expect(r.tasks[0].description).toBe('Rebuild OAuth pipeline');
|
||||
expect(r.tasks[0].next).toBe('Add unit tests for refresh flow');
|
||||
});
|
||||
|
||||
it('returns empty tasks when board empty', () => {
|
||||
const r = parseStatusMd('# Task Board\n_Updated: 2026-04-29_\n');
|
||||
expect(r.tasks).toEqual([]);
|
||||
expect(r.updated).toBe('2026-04-29');
|
||||
});
|
||||
|
||||
it('returns null updated when missing', () => {
|
||||
expect(parseStatusMd('# Task Board\n').updated).toBeNull();
|
||||
});
|
||||
|
||||
it('treats unknown emoji as ready (best effort)', () => {
|
||||
const txt = '## ⚫ [weird-task] — desc\n**Next action:** something\n';
|
||||
const r = parseStatusMd(txt);
|
||||
expect(r.tasks[0].status).toBe('ready');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStatusMd — agent-orchestration schema fields', () => {
|
||||
const board = `# Task Board
|
||||
_Updated: 2026-06-07_
|
||||
|
||||
## ⚪ [claimable] — A claimable task
|
||||
**Status:** ready
|
||||
**Next action:** start
|
||||
**Branch:** master
|
||||
**Owner:** win11:claude-opus:sess-1
|
||||
**Claim token:** tok-abc123
|
||||
**Claim expires at:** 2026-06-07T12:00:00Z
|
||||
**Requirements:** needs-db, needs-secrets
|
||||
**Weight:** needs-claude
|
||||
**Runtime allowed:** claude-opus, claude-sonnet
|
||||
**Runtime preferences:** claude-opus
|
||||
**Result artifact URL:** https://git.example/pr/1
|
||||
**Worker registry:** {"node":"win11"}
|
||||
**Consult policy:** strict-human
|
||||
|
||||
---
|
||||
`;
|
||||
|
||||
it('parses all nine new fields', () => {
|
||||
const t = parseStatusMd(board).tasks[0];
|
||||
expect(t.owner).toBe('win11:claude-opus:sess-1');
|
||||
expect(t.claimToken).toBe('tok-abc123');
|
||||
expect(t.claimExpiresAt).toBe('2026-06-07T12:00:00Z');
|
||||
expect(t.requirements).toEqual(['needs-db', 'needs-secrets']);
|
||||
expect(t.weight).toBe('needs-claude');
|
||||
expect(t.runtimeAllowed).toEqual(['claude-opus', 'claude-sonnet']);
|
||||
expect(t.runtimePreferences).toEqual(['claude-opus']);
|
||||
expect(t.resultArtifactUrl).toBe('https://git.example/pr/1');
|
||||
expect(t.workerRegistry).toEqual({ node: 'win11' });
|
||||
});
|
||||
|
||||
it('parses the consult_policy task field', () => {
|
||||
expect(parseStatusMd(board).tasks[0].consultPolicy).toBe('strict-human');
|
||||
});
|
||||
|
||||
it('leaves new fields undefined for legacy blocks (backwards-compat)', () => {
|
||||
const legacy = '## 🔴 [old] — legacy\n**Status:** active\n**Next action:** go\n**Branch:** main\n';
|
||||
const t = parseStatusMd(legacy).tasks[0];
|
||||
expect(t.owner).toBeUndefined();
|
||||
expect(t.claimToken).toBeUndefined();
|
||||
expect(t.requirements).toBeUndefined();
|
||||
expect(t.weight).toBeUndefined();
|
||||
expect(t.runtimeAllowed).toBeUndefined();
|
||||
expect(t.workerRegistry).toBeUndefined();
|
||||
// legacy fields still parse correctly
|
||||
expect(t.status).toBe('active');
|
||||
expect(t.next).toBe('go');
|
||||
});
|
||||
|
||||
it('ignores malformed worker_registry JSON without throwing', () => {
|
||||
const bad = '## ⚪ [x] — d\n**Next action:** n\n**Worker registry:** {not json\n';
|
||||
const t = parseStatusMd(bad).tasks[0];
|
||||
expect(t.workerRegistry).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStatusMd — notify field', () => {
|
||||
it('parses **Notify:** OpeItcLoc03/workshop', () => {
|
||||
const md = '## ⚪ [t] — desc\n**Status:** ready\n**Next action:** go\n**Branch:** master\n**Notify:** OpeItcLoc03/workshop\n\n---\n';
|
||||
const t = parseStatusMd(md).tasks[0];
|
||||
expect(t.notify).toBe('OpeItcLoc03/workshop');
|
||||
});
|
||||
|
||||
it('leaves notify undefined when absent', () => {
|
||||
const md = '## ⚪ [t] — desc\n**Status:** ready\n**Next action:** go\n**Branch:** master\n\n---\n';
|
||||
const t = parseStatusMd(md).tasks[0];
|
||||
expect(t.notify).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStatusMd — blocker field', () => {
|
||||
it('parses **Blocker:** CSV into blocker string', () => {
|
||||
const md =
|
||||
'## 🔵 [t] — blocked task\n**Status:** blocked\n**Next action:** wait\n**Blocker:** dep-a, dep-b\n**Branch:** master\n\n---\n';
|
||||
const t = parseStatusMd(md).tasks[0];
|
||||
expect(t.blocker).toBe('dep-a, dep-b');
|
||||
});
|
||||
|
||||
it('parses single blocker slug', () => {
|
||||
const md =
|
||||
'## 🔵 [t] — blocked\n**Status:** blocked\n**Next action:** wait\n**Blocker:** only-dep\n**Branch:** master\n\n---\n';
|
||||
const t = parseStatusMd(md).tasks[0];
|
||||
expect(t.blocker).toBe('only-dep');
|
||||
});
|
||||
|
||||
it('leaves blocker undefined when field absent', () => {
|
||||
const md = '## ⚪ [t] — ready\n**Status:** ready\n**Next action:** go\n**Branch:** master\n\n---\n';
|
||||
const t = parseStatusMd(md).tasks[0];
|
||||
expect(t.blocker).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses blocker from the existing sample fixture', () => {
|
||||
const t = parseStatusMd(sample).tasks.find((x) => x.slug === 'vendor-bug')!;
|
||||
expect(t.blocker).toBe('Waiting on vendor patch v2.4.1');
|
||||
});
|
||||
});
|
||||
637
lib/projects-meta-mcp/tests/lib/sync-runner.test.ts
Normal file
637
lib/projects-meta-mcp/tests/lib/sync-runner.test.ts
Normal file
@@ -0,0 +1,637 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runSync } from '../../src/lib/sync-runner.js';
|
||||
import { parseStatusMd } from '../../src/lib/status-md.js';
|
||||
import type { Backend } from '../../src/lib/backend.js';
|
||||
|
||||
type FileMap = Record<string, string | null | Error>;
|
||||
type RepoFiles = string | null | Error | FileMap;
|
||||
|
||||
function fakeClient(
|
||||
repos: Array<{ name: string; default_branch: string; archived?: boolean }>,
|
||||
files: Record<string, RepoFiles>,
|
||||
): Backend {
|
||||
return {
|
||||
async listUserRepos() {
|
||||
return repos;
|
||||
},
|
||||
async getRawFile(_u, repo, path) {
|
||||
const v = files[repo];
|
||||
if (v === undefined) return null;
|
||||
if (typeof v === 'string' || v === null || v instanceof Error) {
|
||||
if (path === '.tasks/STATUS.md') {
|
||||
if (v instanceof Error) throw v;
|
||||
return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const inner = (v as FileMap)[path];
|
||||
if (inner === undefined) return null;
|
||||
if (inner instanceof Error) throw inner;
|
||||
return inner;
|
||||
},
|
||||
async getFileWithSha() {
|
||||
throw new Error('not used in sync tests');
|
||||
},
|
||||
async commitFile() {
|
||||
throw new Error('not used in sync tests');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('runSync', () => {
|
||||
const now = () => new Date('2026-04-29T12:00:00Z');
|
||||
const sample = `# Task Board\n_Updated: 2026-04-29_\n\n## 🔴 [t1] — first\n**Next action:** do x\n`;
|
||||
|
||||
it('aggregates STATUS.md across repos', async () => {
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[{ name: 'a', default_branch: 'main' }, { name: 'b', default_branch: 'main' }],
|
||||
{ a: sample, b: sample },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
});
|
||||
expect(cache.projects).toHaveLength(2);
|
||||
expect(cache.errors).toEqual([]);
|
||||
expect(cache.projects[0].active_tasks[0].slug).toBe('t1');
|
||||
});
|
||||
|
||||
// Regression [aggregate-blocked-no-slug-invisible]: a blocked task whose
|
||||
// **Blocker:** is free text (e.g. "design-hold") rather than another task's
|
||||
// slug must still be indexed into active_tasks — status is keyed off the 🔵
|
||||
// emoji, never off the shape of the blocker. The aggregate view reads from
|
||||
// active_tasks, so a missed index here makes such tasks invisible.
|
||||
it('indexes a blocked task with a non-slug (text) blocker', async () => {
|
||||
const md =
|
||||
`# Task Board\n_Updated: 2026-04-29_\n\n` +
|
||||
`## 🔵 [held] — held on a design decision\n` +
|
||||
`**Status:** blocked\n` +
|
||||
`**Where I stopped:** waiting on design\n` +
|
||||
`**Next action:** resume after design ruling\n` +
|
||||
`**Blocker:** design-hold\n` +
|
||||
`**Branch:** n/a\n`;
|
||||
const cache = await runSync({
|
||||
client: fakeClient([{ name: 'a', default_branch: 'main' }], { a: md }),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
});
|
||||
const blocked = cache.projects[0].active_tasks.filter((t) => t.status === 'blocked');
|
||||
expect(blocked.map((t) => t.slug)).toEqual(['held']);
|
||||
});
|
||||
|
||||
it('skips 404 (no STATUS.md) without error', async () => {
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[{ name: 'a', default_branch: 'main' }, { name: 'no-tasks', default_branch: 'main' }],
|
||||
{ a: sample, 'no-tasks': null },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
});
|
||||
expect(cache.projects.map((p) => p.name)).toEqual(['u/a']);
|
||||
expect(cache.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('records error and continues on network failure', async () => {
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[{ name: 'a', default_branch: 'main' }, { name: 'broken', default_branch: 'main' }],
|
||||
{ a: sample, broken: new Error('network down') },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
});
|
||||
expect(cache.projects.map((p) => p.name)).toEqual(['u/a']);
|
||||
expect(cache.errors).toEqual([{ project: 'u/broken', reason: 'network down' }]);
|
||||
});
|
||||
|
||||
it('sets synced_at, machine, synced_from', async () => {
|
||||
const cache = await runSync({
|
||||
client: fakeClient([], {}),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'host-x',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
});
|
||||
expect(cache.synced_at).toBe('2026-04-29T12:00:00.000Z');
|
||||
expect(cache.machine).toBe('host-x');
|
||||
expect(cache.synced_from).toBe('https://g');
|
||||
});
|
||||
|
||||
it('injects agenda pseudo-project when readAgendaTasksLocal returns content', async () => {
|
||||
const agendaSample = `# Task Board\n_Updated: 2026-04-29_\n\n## 🔴 [m1] — agenda task\n**Next action:** ship MVP-1\n\n## ⚪ [m2] — placeholder\n**Next action:** none\n`;
|
||||
const cache = await runSync({
|
||||
client: fakeClient([{ name: 'a', default_branch: 'main' }], { a: sample }),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
readAgendaTasksLocal: async () => agendaSample,
|
||||
});
|
||||
const names = cache.projects.map((p) => p.name);
|
||||
expect(names).toContain('agenda');
|
||||
expect(names).toContain('u/a');
|
||||
const agenda = cache.projects.find((p) => p.name === 'agenda')!;
|
||||
expect(agenda.default_branch).toBe('local');
|
||||
expect(agenda.all_tasks_count).toBe(2);
|
||||
expect(agenda.active_tasks.map((t) => t.slug)).toEqual(['m1']);
|
||||
expect(cache.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips agenda when readAgendaTasksLocal returns null', async () => {
|
||||
const cache = await runSync({
|
||||
client: fakeClient([{ name: 'a', default_branch: 'main' }], { a: sample }),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
readAgendaTasksLocal: async () => null,
|
||||
});
|
||||
expect(cache.projects.map((p) => p.name)).toEqual(['u/a']);
|
||||
expect(cache.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('records error under "agenda" when readAgendaTasksLocal throws', async () => {
|
||||
const cache = await runSync({
|
||||
client: fakeClient([{ name: 'a', default_branch: 'main' }], { a: sample }),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
readAgendaTasksLocal: async () => {
|
||||
throw new Error('disk read failed');
|
||||
},
|
||||
});
|
||||
expect(cache.projects.map((p) => p.name)).toEqual(['u/a']);
|
||||
expect(cache.errors).toEqual([{ project: 'agenda', reason: 'disk read failed' }]);
|
||||
});
|
||||
|
||||
it('uses Gitea repo for agenda when agendaTasksRepo is found and excludes it from regular projects', async () => {
|
||||
const agendaFromGitea = `# Agenda\n_Updated: 2026-04-29_\n\n## 🔴 [g1] — gitea-side\n**Next action:** ship\n`;
|
||||
const agendaFromLocal = `# Agenda\n## 🔴 [l1] — local should not win\n`;
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[
|
||||
{ name: 'a', default_branch: 'main' },
|
||||
{ name: 'projects-tasks', default_branch: 'main' },
|
||||
],
|
||||
{ a: sample, 'projects-tasks': agendaFromGitea },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
agendaTasksRepo: 'projects-tasks',
|
||||
readAgendaTasksLocal: async () => agendaFromLocal,
|
||||
});
|
||||
const names = cache.projects.map((p) => p.name).sort();
|
||||
expect(names).toEqual(['agenda', 'u/a']);
|
||||
const agenda = cache.projects.find((p) => p.name === 'agenda')!;
|
||||
expect(agenda.default_branch).toBe('main');
|
||||
expect(agenda.active_tasks.map((t) => t.slug)).toEqual(['g1']);
|
||||
});
|
||||
|
||||
it('falls back to local when agendaTasksRepo is configured but absent in Gitea', async () => {
|
||||
const localContent = `# Local agenda\n## 🔴 [l1] — fallback\n**Next action:** x\n`;
|
||||
const cache = await runSync({
|
||||
client: fakeClient([{ name: 'a', default_branch: 'main' }], { a: sample }),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
agendaTasksRepo: 'projects-tasks',
|
||||
readAgendaTasksLocal: async () => localContent,
|
||||
});
|
||||
const agenda = cache.projects.find((p) => p.name === 'agenda')!;
|
||||
expect(agenda).toBeDefined();
|
||||
expect(agenda.default_branch).toBe('local');
|
||||
expect(agenda.active_tasks.map((t) => t.slug)).toEqual(['l1']);
|
||||
});
|
||||
|
||||
it('falls back to local when Gitea repo exists but has no STATUS.md', async () => {
|
||||
const localContent = `# Local\n## 🔴 [l2] — fallback-2\n`;
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[
|
||||
{ name: 'a', default_branch: 'main' },
|
||||
{ name: 'projects-tasks', default_branch: 'main' },
|
||||
],
|
||||
{ a: sample, 'projects-tasks': null },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
agendaTasksRepo: 'projects-tasks',
|
||||
readAgendaTasksLocal: async () => localContent,
|
||||
});
|
||||
const agenda = cache.projects.find((p) => p.name === 'agenda')!;
|
||||
expect(agenda).toBeDefined();
|
||||
expect(agenda.default_branch).toBe('local');
|
||||
// projects-tasks excluded from regular project list even when its STATUS.md is absent.
|
||||
expect(cache.projects.map((p) => p.name)).not.toContain('u/projects-tasks');
|
||||
});
|
||||
|
||||
it('records gitea error in cache.errors when fetch throws and no local fallback', async () => {
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[
|
||||
{ name: 'a', default_branch: 'main' },
|
||||
{ name: 'projects-tasks', default_branch: 'main' },
|
||||
],
|
||||
{ a: sample, 'projects-tasks': new Error('gitea 500') },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
agendaTasksRepo: 'projects-tasks',
|
||||
});
|
||||
expect(cache.projects.map((p) => p.name)).toEqual(['u/a']);
|
||||
expect(cache.errors).toEqual([{ project: 'agenda', reason: 'gitea 500' }]);
|
||||
});
|
||||
|
||||
it('captures wiki_index alongside STATUS.md when both exist', async () => {
|
||||
const wikiIndex = `# Wiki Index\n\n## Concepts\n\n- [foo](concepts/foo.md) — Foo\n`;
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[{ name: 'a', default_branch: 'main' }],
|
||||
{ a: { '.tasks/STATUS.md': sample, '.wiki/index.md': wikiIndex } },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
});
|
||||
expect(cache.projects).toHaveLength(1);
|
||||
expect(cache.projects[0].wiki_index).toBe(wikiIndex);
|
||||
});
|
||||
|
||||
it('captures wiki-only repo (no STATUS.md) into projects list', async () => {
|
||||
const wikiIndex = `# Wiki Index\n\n## Concepts\n\n- [foo](concepts/foo.md) — Foo\n`;
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[{ name: 'wiki-only', default_branch: 'main' }],
|
||||
{ 'wiki-only': { '.tasks/STATUS.md': null, '.wiki/index.md': wikiIndex } },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
});
|
||||
expect(cache.projects).toHaveLength(1);
|
||||
expect(cache.projects[0].name).toBe('u/wiki-only');
|
||||
expect(cache.projects[0].raw).toBe('');
|
||||
expect(cache.projects[0].active_tasks).toEqual([]);
|
||||
expect(cache.projects[0].all_tasks_count).toBe(0);
|
||||
expect(cache.projects[0].wiki_index).toBe(wikiIndex);
|
||||
});
|
||||
|
||||
it('omits wiki_index when .wiki/index.md is absent', async () => {
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[{ name: 'a', default_branch: 'main' }],
|
||||
{ a: { '.tasks/STATUS.md': sample, '.wiki/index.md': null } },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
});
|
||||
expect(cache.projects[0].wiki_index).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips repo when neither STATUS.md nor wiki_index exist', async () => {
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[
|
||||
{ name: 'a', default_branch: 'main' },
|
||||
{ name: 'empty', default_branch: 'main' },
|
||||
],
|
||||
{
|
||||
a: { '.tasks/STATUS.md': sample, '.wiki/index.md': null },
|
||||
empty: { '.tasks/STATUS.md': null, '.wiki/index.md': null },
|
||||
},
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
});
|
||||
expect(cache.projects.map((p) => p.name)).toEqual(['u/a']);
|
||||
expect(cache.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not record error when wiki_index fetch fails — degrades silently', async () => {
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[{ name: 'a', default_branch: 'main' }],
|
||||
{ a: { '.tasks/STATUS.md': sample, '.wiki/index.md': new Error('wiki fetch 500') } },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
});
|
||||
expect(cache.projects).toHaveLength(1);
|
||||
expect(cache.projects[0].wiki_index).toBeUndefined();
|
||||
expect(cache.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('local wins when agendaTasksRepo not configured at all', async () => {
|
||||
const localContent = `# Local\n## 🔴 [only] — local\n`;
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[
|
||||
{ name: 'a', default_branch: 'main' },
|
||||
{ name: 'projects-tasks', default_branch: 'main' },
|
||||
],
|
||||
{ a: sample, 'projects-tasks': 'should not be read' },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
// agendaTasksRepo intentionally omitted
|
||||
readAgendaTasksLocal: async () => localContent,
|
||||
});
|
||||
const agenda = cache.projects.find((p) => p.name === 'agenda')!;
|
||||
expect(agenda.default_branch).toBe('local');
|
||||
expect(agenda.active_tasks.map((t) => t.slug)).toEqual(['only']);
|
||||
// Without agendaTasksRepo, projects-tasks shouldn't be filtered out.
|
||||
expect(cache.projects.map((p) => p.name).sort()).toEqual(['agenda', 'u/a', 'u/projects-tasks']);
|
||||
});
|
||||
|
||||
it('skips archived repos', async () => {
|
||||
const cache = await runSync({
|
||||
client: fakeClient(
|
||||
[
|
||||
{ name: 'live', default_branch: 'main', archived: false },
|
||||
{ name: 'archived-repo', default_branch: 'main', archived: true },
|
||||
],
|
||||
{ live: sample },
|
||||
),
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'test',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['u'],
|
||||
});
|
||||
expect(cache.projects.map((p) => p.name)).toEqual(['u/live']);
|
||||
expect(cache.archived_skipped).toBe(1);
|
||||
});
|
||||
|
||||
it('aggregates repos across multiple owners with qualified <owner>/<repo> names', async () => {
|
||||
const ownerARepos = [
|
||||
{ name: 'repo-a1', default_branch: 'main' },
|
||||
{ name: 'repo-a2', default_branch: 'main' },
|
||||
];
|
||||
const ownerBRepos = [{ name: 'repo-b1', default_branch: 'main' }];
|
||||
const calls: Array<{ owner: string; repo: string }> = [];
|
||||
|
||||
const client: Backend = {
|
||||
async listUserRepos(owner) {
|
||||
if (owner === 'owner-a') return ownerARepos;
|
||||
if (owner === 'owner-b') return ownerBRepos;
|
||||
return [];
|
||||
},
|
||||
async getRawFile(owner, repo, path) {
|
||||
calls.push({ owner, repo });
|
||||
if (path !== '.tasks/STATUS.md') return null;
|
||||
return sample;
|
||||
},
|
||||
async getFileWithSha() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async commitFile() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
|
||||
const cache = await runSync({
|
||||
client,
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['owner-a', 'owner-b'],
|
||||
});
|
||||
|
||||
expect(cache.projects.map((p) => p.name).sort()).toEqual([
|
||||
'owner-a/repo-a1',
|
||||
'owner-a/repo-a2',
|
||||
'owner-b/repo-b1',
|
||||
]);
|
||||
// getRawFile should have been called with each repo's actual owner — not
|
||||
// a uniform "acting user" — which is the whole point of multi-owner sync.
|
||||
const ownerARepoCalls = calls.filter((c) => c.repo === 'repo-a1' || c.repo === 'repo-a2');
|
||||
expect(ownerARepoCalls.every((c) => c.owner === 'owner-a')).toBe(true);
|
||||
const ownerBRepoCalls = calls.filter((c) => c.repo === 'repo-b1');
|
||||
expect(ownerBRepoCalls.every((c) => c.owner === 'owner-b')).toBe(true);
|
||||
expect(cache.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses agendaTasksOwner to pin agenda to a specific owner — non-acting owner', async () => {
|
||||
const agendaContent = `# Agenda\n_Updated: 2026-04-29_\n\n## 🔴 [v1] — victor agenda\n**Next action:** ship\n`;
|
||||
const calls: Array<{ owner: string; repo: string; path: string }> = [];
|
||||
|
||||
const client: Backend = {
|
||||
async listUserRepos(owner) {
|
||||
if (owner === 'OpeItcLoc03') return [{ name: 'agenda', default_branch: 'main' }];
|
||||
if (owner === 'victor') return [{ name: 'agenda', default_branch: 'main' }];
|
||||
return [];
|
||||
},
|
||||
async getRawFile(owner, repo, path) {
|
||||
calls.push({ owner, repo, path });
|
||||
if (path !== '.tasks/STATUS.md') return null;
|
||||
if (owner === 'victor' && repo === 'agenda') return agendaContent;
|
||||
if (owner === 'OpeItcLoc03' && repo === 'agenda') return sample;
|
||||
return null;
|
||||
},
|
||||
async getFileWithSha() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async commitFile() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
|
||||
const cache = await runSync({
|
||||
client,
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['OpeItcLoc03', 'victor'],
|
||||
agendaTasksRepo: 'agenda',
|
||||
agendaTasksOwner: 'victor',
|
||||
});
|
||||
|
||||
const names = cache.projects.map((p) => p.name).sort();
|
||||
// agenda pinned to victor; OpeItcLoc03/agenda stays in regular project list.
|
||||
expect(names).toEqual(['OpeItcLoc03/agenda', 'agenda']);
|
||||
const agenda = cache.projects.find((p) => p.name === 'agenda')!;
|
||||
expect(agenda.active_tasks.map((t) => t.slug)).toEqual(['v1']);
|
||||
const agendaCalls = calls.filter(
|
||||
(c) => c.repo === 'agenda' && c.path === '.tasks/STATUS.md',
|
||||
);
|
||||
// Only victor was called for the agenda fetch — OpeItcLoc03 wasn't
|
||||
// misroute-targeted just because it shares the repo name.
|
||||
const victorAgendaCalls = agendaCalls.filter((c) => c.owner === 'victor');
|
||||
expect(victorAgendaCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('iterates union of owners + aggregateSkipOwners, populating cache with qualified keys for skip-owner repos', async () => {
|
||||
// Acceptance: OpeItcLoc03 infra repos must be present in cache (qualified)
|
||||
// so MCP-write tools can resolve them — even though they're hidden from
|
||||
// aggregation views (the aggregation filter is enforced separately at the
|
||||
// tasks.aggregate / search / get layer, not here).
|
||||
const ownerARepos = [{ name: 'biz1', default_branch: 'main' }];
|
||||
const skipOwnerRepos = [
|
||||
{ name: 'common', default_branch: 'master' },
|
||||
{ name: 'claude-skills', default_branch: 'master' },
|
||||
];
|
||||
|
||||
const client: Backend = {
|
||||
async listUserRepos(owner) {
|
||||
if (owner === 'victor') return ownerARepos;
|
||||
if (owner === 'OpeItcLoc03') return skipOwnerRepos;
|
||||
return [];
|
||||
},
|
||||
async getRawFile(_owner, _repo, path) {
|
||||
if (path !== '.tasks/STATUS.md') return null;
|
||||
return sample;
|
||||
},
|
||||
async getFileWithSha() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async commitFile() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
|
||||
const cache = await runSync({
|
||||
client,
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['victor'],
|
||||
aggregateSkipOwners: ['OpeItcLoc03'],
|
||||
});
|
||||
|
||||
expect(cache.projects.map((p) => p.name).sort()).toEqual([
|
||||
'OpeItcLoc03/claude-skills',
|
||||
'OpeItcLoc03/common',
|
||||
'victor/biz1',
|
||||
]);
|
||||
expect(cache.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('dedupes when an owner appears in both owners and aggregateSkipOwners', async () => {
|
||||
// If user fat-fingers and lists same owner in both — sync should not
|
||||
// double-fetch. Real-world cause: copy-paste error in auth.toml.
|
||||
let listUserReposCalls = 0;
|
||||
const client: Backend = {
|
||||
async listUserRepos() {
|
||||
listUserReposCalls++;
|
||||
return [{ name: 'r1', default_branch: 'main' }];
|
||||
},
|
||||
async getRawFile(_o, _r, path) {
|
||||
return path === '.tasks/STATUS.md' ? sample : null;
|
||||
},
|
||||
async getFileWithSha() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async commitFile() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
|
||||
const cache = await runSync({
|
||||
client,
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['victor'],
|
||||
aggregateSkipOwners: ['victor'],
|
||||
});
|
||||
|
||||
expect(listUserReposCalls).toBe(1);
|
||||
expect(cache.projects.map((p) => p.name)).toEqual(['victor/r1']);
|
||||
});
|
||||
|
||||
it('finds agenda repo under whichever owner contains it', async () => {
|
||||
const agendaContent = `# Agenda\n_Updated: 2026-04-29_\n\n## 🔴 [a1] — agenda task\n**Next action:** ship\n`;
|
||||
const calls: Array<{ owner: string; repo: string; path: string }> = [];
|
||||
|
||||
const client: Backend = {
|
||||
async listUserRepos(owner) {
|
||||
if (owner === 'owner-x') return [{ name: 'biz-repo', default_branch: 'main' }];
|
||||
if (owner === 'acting-user') return [{ name: 'agenda', default_branch: 'main' }];
|
||||
return [];
|
||||
},
|
||||
async getRawFile(owner, repo, path) {
|
||||
calls.push({ owner, repo, path });
|
||||
if (path !== '.tasks/STATUS.md') return null;
|
||||
if (owner === 'owner-x' && repo === 'biz-repo') return sample;
|
||||
if (owner === 'acting-user' && repo === 'agenda') return agendaContent;
|
||||
return null;
|
||||
},
|
||||
async getFileWithSha() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async commitFile() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
|
||||
const cache = await runSync({
|
||||
client,
|
||||
parseStatus: parseStatusMd,
|
||||
now,
|
||||
machine: 'm',
|
||||
giteaUrl: 'https://g',
|
||||
owners: ['owner-x', 'acting-user'],
|
||||
agendaTasksRepo: 'agenda',
|
||||
});
|
||||
|
||||
const names = cache.projects.map((p) => p.name).sort();
|
||||
expect(names).toEqual(['agenda', 'owner-x/biz-repo']);
|
||||
const agenda = cache.projects.find((p) => p.name === 'agenda')!;
|
||||
expect(agenda.active_tasks.map((t) => t.slug)).toEqual(['a1']);
|
||||
// Confirm agenda was fetched from the right owner — not from owner-x.
|
||||
const agendaCalls = calls.filter(
|
||||
(c) => c.repo === 'agenda' && c.path === '.tasks/STATUS.md',
|
||||
);
|
||||
expect(agendaCalls).toHaveLength(1);
|
||||
expect(agendaCalls[0].owner).toBe('acting-user');
|
||||
});
|
||||
});
|
||||
80
lib/projects-meta-mcp/tests/lib/wiki-index-parser.test.ts
Normal file
80
lib/projects-meta-mcp/tests/lib/wiki-index-parser.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseWikiIndex } from '../../src/lib/wiki-index-parser.js';
|
||||
|
||||
describe('parseWikiIndex', () => {
|
||||
it('extracts entries from canonical index.md', () => {
|
||||
const md = `# Wiki Index
|
||||
|
||||
## Overview
|
||||
|
||||
- [overview.md](overview.md) — project overview
|
||||
|
||||
## Entities
|
||||
|
||||
- [some-entity](entities/some-entity.md) — Some Entity Title
|
||||
|
||||
## Concepts
|
||||
|
||||
- [foo](concepts/foo.md) — Foo Concept
|
||||
- [bar/baz](concepts/bar/baz.md) — Nested Bar Baz
|
||||
|
||||
## Packages
|
||||
|
||||
<!-- (none yet) -->
|
||||
|
||||
## Sources
|
||||
|
||||
<!-- (none yet) -->
|
||||
`;
|
||||
const entries = parseWikiIndex(md);
|
||||
expect(entries).toEqual([
|
||||
{ slug: 'entities/some-entity', type: 'entities', title: 'Some Entity Title' },
|
||||
{ slug: 'concepts/foo', type: 'concepts', title: 'Foo Concept' },
|
||||
{ slug: 'concepts/bar/baz', type: 'concepts', title: 'Nested Bar Baz' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when all sections are empty', () => {
|
||||
const md = `# Wiki Index
|
||||
|
||||
## Entities
|
||||
|
||||
<!-- (none yet) -->
|
||||
|
||||
## Concepts
|
||||
|
||||
<!-- (none yet) -->
|
||||
`;
|
||||
expect(parseWikiIndex(md)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array on completely malformed input', () => {
|
||||
expect(parseWikiIndex('not markdown at all')).toEqual([]);
|
||||
expect(parseWikiIndex('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips Overview section (special-cased: link is to overview.md, not type/slug.md)', () => {
|
||||
const md = `# Wiki Index\n\n## Overview\n\n- [overview.md](overview.md) — project overview\n`;
|
||||
expect(parseWikiIndex(md)).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips entries whose link does not match expected type/slug.md pattern', () => {
|
||||
const md = `# Wiki Index
|
||||
|
||||
## Concepts
|
||||
|
||||
- [external](https://example.com) — External Link
|
||||
- [proper](concepts/proper.md) — Proper Entry
|
||||
`;
|
||||
expect(parseWikiIndex(md)).toEqual([
|
||||
{ slug: 'concepts/proper', type: 'concepts', title: 'Proper Entry' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles Raw section', () => {
|
||||
const md = `# Wiki Index\n\n## Raw\n\n- [doc](raw/doc.md) — Some PDF\n`;
|
||||
expect(parseWikiIndex(md)).toEqual([
|
||||
{ slug: 'raw/doc', type: 'raw', title: 'Some PDF' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
70
lib/projects-meta-mcp/tests/lib/wiki-index.test.ts
Normal file
70
lib/projects-meta-mcp/tests/lib/wiki-index.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { loadWiki } from '../../src/lib/wiki-index.js';
|
||||
|
||||
let root: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'wiki-'));
|
||||
await mkdir(join(root, 'node'), { recursive: true });
|
||||
await mkdir(join(root, 'cross'), { recursive: true });
|
||||
|
||||
await writeFile(join(root, 'CLAUDE.md'), 'should be skipped');
|
||||
await writeFile(join(root, 'README.md'), 'should be skipped');
|
||||
|
||||
await writeFile(
|
||||
join(root, 'node', 'windows-yarn-exec.md'),
|
||||
[
|
||||
'---',
|
||||
'title: Windows yarn requires exec()',
|
||||
'domain: node',
|
||||
'tags: [windows, yarn]',
|
||||
'---',
|
||||
'',
|
||||
'## Why',
|
||||
'Windows is special.',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
join(root, 'cross', 'git-tips.md'),
|
||||
['---', 'title: Git tips', 'domain: cross', 'tags: [git]', '---', '', 'body'].join('\n'),
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
join(root, 'cross', 'no-fm.md'),
|
||||
'just a body, no frontmatter',
|
||||
);
|
||||
});
|
||||
|
||||
describe('loadWiki', () => {
|
||||
it('skips top-level meta files', async () => {
|
||||
const pages = await loadWiki(root);
|
||||
expect(pages.find((p) => p.slug === 'CLAUDE')).toBeUndefined();
|
||||
expect(pages.find((p) => p.slug === 'README')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('loads pages with frontmatter', async () => {
|
||||
const pages = await loadWiki(root);
|
||||
const yarn = pages.find((p) => p.slug === 'node/windows-yarn-exec');
|
||||
expect(yarn).toBeDefined();
|
||||
expect(yarn!.title).toBe('Windows yarn requires exec()');
|
||||
expect(yarn!.domain).toBe('node');
|
||||
expect(yarn!.tags).toEqual(['windows', 'yarn']);
|
||||
expect(yarn!.body).toContain('Windows is special.');
|
||||
});
|
||||
|
||||
it('marks frontmatterless pages as unknown domain', async () => {
|
||||
const pages = await loadWiki(root);
|
||||
const fm = pages.find((p) => p.slug === 'cross/no-fm');
|
||||
expect(fm).toBeDefined();
|
||||
expect(fm!.domain).toBe('unknown');
|
||||
});
|
||||
|
||||
it('returns empty array if root missing', async () => {
|
||||
const pages = await loadWiki(join(root, 'does-not-exist'));
|
||||
expect(pages).toEqual([]);
|
||||
});
|
||||
});
|
||||
132
lib/projects-meta-mcp/tests/lib/wiki-writer.test.ts
Normal file
132
lib/projects-meta-mcp/tests/lib/wiki-writer.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
formatPage,
|
||||
pagePath,
|
||||
indexPath,
|
||||
logPath,
|
||||
rawPath,
|
||||
insertIndexEntry,
|
||||
appendLogEntry,
|
||||
freshIndexMd,
|
||||
freshLogMd,
|
||||
} from '../../src/lib/wiki-writer.js';
|
||||
|
||||
describe('formatPage', () => {
|
||||
it('produces frontmatter+body via gray-matter', () => {
|
||||
const out = formatPage({
|
||||
type: 'concepts',
|
||||
slug: 'tasks-routing-rule',
|
||||
body: 'Read local `.tasks/STATUS.md` for current project.',
|
||||
frontmatter: { tags: ['routing'], domain: 'projects-meta' },
|
||||
});
|
||||
expect(out.startsWith('---\n')).toBe(true);
|
||||
expect(out).toContain("title: tasks-routing-rule");
|
||||
expect(out).toContain('type: concept');
|
||||
expect(out).toContain('domain: projects-meta');
|
||||
expect(out).toContain('Read local `.tasks/STATUS.md`');
|
||||
});
|
||||
|
||||
it('auto-injects ingested_at / ingested_by / source_project', () => {
|
||||
const out = formatPage({
|
||||
type: 'sources',
|
||||
slug: 's',
|
||||
body: 'b',
|
||||
ingestedAtIso: '2026-04-29T12:00:00Z',
|
||||
ingestedBy: 'vitya@laptop',
|
||||
sourceProject: 'modules-db',
|
||||
});
|
||||
expect(out).toContain("ingested_at: '2026-04-29T12:00:00Z'");
|
||||
expect(out).toContain('ingested_by: vitya@laptop');
|
||||
expect(out).toContain('source_project: modules-db');
|
||||
});
|
||||
|
||||
it('respects user-supplied title and type override', () => {
|
||||
const out = formatPage({
|
||||
type: 'concepts',
|
||||
slug: 's',
|
||||
body: 'b',
|
||||
frontmatter: { title: 'Hand title', type: 'design-decision' },
|
||||
});
|
||||
expect(out).toContain('title: Hand title');
|
||||
expect(out).toContain('type: design-decision');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagePath', () => {
|
||||
it('builds .wiki/<type>/<slug>.md for project wikis (default / isAgenda=false)', () => {
|
||||
expect(pagePath('concepts', 'foo')).toBe('.wiki/concepts/foo.md');
|
||||
expect(pagePath('raw', 'pdf-note')).toBe('.wiki/raw/pdf-note.md');
|
||||
expect(pagePath('concepts', 'foo', false)).toBe('.wiki/concepts/foo.md');
|
||||
});
|
||||
|
||||
it('builds <type>/<slug>.md for the shared wiki (isAgenda=true, no .wiki/ prefix)', () => {
|
||||
expect(pagePath('concepts', 'foo', true)).toBe('concepts/foo.md');
|
||||
expect(pagePath('sources', 'bar', true)).toBe('sources/bar.md');
|
||||
expect(pagePath('raw', 'pdf', true)).toBe('raw/pdf.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('indexPath / logPath / rawPath', () => {
|
||||
it('project wiki layout — .wiki/ prefix', () => {
|
||||
expect(indexPath()).toBe('.wiki/index.md');
|
||||
expect(indexPath(false)).toBe('.wiki/index.md');
|
||||
expect(logPath()).toBe('.wiki/log.md');
|
||||
expect(logPath(false)).toBe('.wiki/log.md');
|
||||
expect(rawPath('foo')).toBe('.wiki/raw/foo.md');
|
||||
expect(rawPath('foo', false)).toBe('.wiki/raw/foo.md');
|
||||
});
|
||||
|
||||
it('shared wiki layout — repo root, no prefix', () => {
|
||||
expect(indexPath(true)).toBe('index.md');
|
||||
expect(logPath(true)).toBe('log.md');
|
||||
expect(rawPath('foo', true)).toBe('raw/foo.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertIndexEntry', () => {
|
||||
it('inserts entry into existing section, removes (none yet) placeholder', () => {
|
||||
const before = freshIndexMd();
|
||||
const after = insertIndexEntry(before, 'concepts', 'tasks-routing-rule', 'Tasks Routing Rule');
|
||||
expect(after).toContain('- [tasks-routing-rule](concepts/tasks-routing-rule.md) — Tasks Routing Rule');
|
||||
// Placeholder gone for Concepts section, but other sections still empty
|
||||
const conceptsSection = after.slice(after.indexOf('## Concepts'), after.indexOf('## Packages'));
|
||||
expect(conceptsSection).not.toContain('(none yet)');
|
||||
const packagesSection = after.slice(after.indexOf('## Packages'), after.indexOf('## Sources'));
|
||||
expect(packagesSection).toContain('(none yet)');
|
||||
});
|
||||
|
||||
it('appends to non-empty section preserving prior entries', () => {
|
||||
const before = `# Wiki Index\n\n## Concepts\n\n- [first](concepts/first.md) — first\n\n## Packages\n\n<!-- (none yet) -->\n`;
|
||||
const after = insertIndexEntry(before, 'concepts', 'second', 'Second');
|
||||
expect(after).toContain('- [first](concepts/first.md) — first');
|
||||
expect(after).toContain('- [second](concepts/second.md) — Second');
|
||||
});
|
||||
|
||||
it('skips if entry with the same slug already present', () => {
|
||||
const before = `# Wiki Index\n\n## Concepts\n\n- [foo](concepts/foo.md) — Foo\n\n## Packages\n\n`;
|
||||
const after = insertIndexEntry(before, 'concepts', 'foo', 'Foo Again');
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it('appends section if missing', () => {
|
||||
const before = `# Wiki Index\n\n## Overview\n\n- [overview.md](overview.md) — overview\n`;
|
||||
const after = insertIndexEntry(before, 'sources', 's1', 'Source One');
|
||||
expect(after).toContain('## Sources');
|
||||
expect(after).toContain('- [s1](sources/s1.md) — Source One');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendLogEntry', () => {
|
||||
it('appends to existing log', () => {
|
||||
const before = freshLogMd();
|
||||
const after = appendLogEntry(before, '2026-04-29', 'ingest', 'concepts/x');
|
||||
expect(after).toContain('## [2026-04-29] ingest | concepts/x');
|
||||
expect(after).toContain('# Wiki Log'); // header preserved
|
||||
});
|
||||
|
||||
it('initializes when log is empty', () => {
|
||||
const after = appendLogEntry('', '2026-04-29', 'init', 'bootstrap');
|
||||
expect(after).toMatch(/^# Wiki Log/);
|
||||
expect(after).toContain('## [2026-04-29] init | bootstrap');
|
||||
});
|
||||
});
|
||||
475
lib/projects-meta-mcp/tests/tools/knowledge-ask-projects.test.ts
Normal file
475
lib/projects-meta-mcp/tests/tools/knowledge-ask-projects.test.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtemp, mkdir } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { makeKnowledgeTools } from '../../src/tools/knowledge.js';
|
||||
import { writeCache, type CacheFile } from '../../src/lib/cache.js';
|
||||
import type { Backend, CommitFileArgs, FileWithSha } from '../../src/lib/backend.js';
|
||||
|
||||
let wikiRoot: string;
|
||||
let projectCwd: string;
|
||||
let cacheFile: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
wikiRoot = await mkdtemp(join(tmpdir(), 'kw-shared-'));
|
||||
await mkdir(wikiRoot, { recursive: true });
|
||||
projectCwd = await mkdtemp(join(tmpdir(), 'kw-cwd-'));
|
||||
cacheFile = join(await mkdtemp(join(tmpdir(), 'kw-cache-')), 'cache.json');
|
||||
});
|
||||
|
||||
interface FetchedPage {
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages are keyed by qualified `<owner>/<repo>` so the mock backend can verify
|
||||
* that handlers route fetches to the correct owner — the whole point of the
|
||||
* cross-owner regression in [multi-owner-ask-projects-owner-fix].
|
||||
*/
|
||||
function makeBackend(pages: Record<string, Record<string, FetchedPage | Error>>): Backend {
|
||||
return {
|
||||
async listUserRepos() {
|
||||
return [];
|
||||
},
|
||||
async getRawFile(owner, repo, path) {
|
||||
const repoPages = pages[`${owner}/${repo}`];
|
||||
if (!repoPages) return null;
|
||||
const v = repoPages[path];
|
||||
if (v === undefined) return null;
|
||||
if (v instanceof Error) throw v;
|
||||
return v.body;
|
||||
},
|
||||
async getFileWithSha(): Promise<FileWithSha | null> {
|
||||
return null;
|
||||
},
|
||||
async commitFile(_a: CommitFileArgs) {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function seedCache(projects: CacheFile['projects']) {
|
||||
const file: CacheFile = {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-04-30T00:00:00Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects,
|
||||
errors: [],
|
||||
};
|
||||
await writeCache(cacheFile, file);
|
||||
}
|
||||
|
||||
function call(
|
||||
tools: ReturnType<typeof makeKnowledgeTools>,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
) {
|
||||
const t = tools.find((x) => x.name === name);
|
||||
if (!t) throw new Error(`tool ${name} missing`);
|
||||
return t.handler(args);
|
||||
}
|
||||
|
||||
const sampleIndex = `# Wiki Index
|
||||
|
||||
## Concepts
|
||||
|
||||
- [yarn-on-windows](concepts/yarn-on-windows.md) — Yarn on Windows
|
||||
- [other](concepts/other.md) — Other Concept
|
||||
|
||||
## Raw
|
||||
|
||||
- [pdf-dump](raw/pdf-dump.md) — Heavy PDF
|
||||
`;
|
||||
|
||||
describe('knowledge.ask_projects', () => {
|
||||
it('returns matches across listed projects with snippets', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'u/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: sampleIndex,
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({
|
||||
'u/foo': {
|
||||
'.wiki/concepts/yarn-on-windows.md': {
|
||||
body: '---\ntitle: Yarn on Windows\n---\n\nyarn install on windows is fiddly',
|
||||
},
|
||||
},
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'yarn',
|
||||
projects: ['u/foo'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.asked_projects).toEqual(['u/foo']);
|
||||
expect(parsed.results).toHaveLength(1);
|
||||
expect(parsed.results[0].project).toBe('u/foo');
|
||||
expect(parsed.results[0].matches).toHaveLength(1);
|
||||
expect(parsed.results[0].matches[0].slug).toBe('concepts/yarn-on-windows');
|
||||
expect(parsed.results[0].matches[0].type).toBe('concepts');
|
||||
expect(parsed.results[0].matches[0].title).toBe('Yarn on Windows');
|
||||
expect(parsed.results[0].matches[0].snippet).toContain('yarn');
|
||||
});
|
||||
|
||||
it('routes fetches to the correct owner across multiple owners (cross-owner)', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'victor/books',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: sampleIndex,
|
||||
},
|
||||
{
|
||||
name: 'OpeItcLoc03/common',
|
||||
default_branch: 'master',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: sampleIndex,
|
||||
},
|
||||
]);
|
||||
const calls: Array<{ owner: string; repo: string }> = [];
|
||||
const backend: Backend = {
|
||||
async listUserRepos() {
|
||||
return [];
|
||||
},
|
||||
async getRawFile(owner, repo, path) {
|
||||
calls.push({ owner, repo });
|
||||
if (owner === 'victor' && repo === 'books') {
|
||||
return path === '.wiki/concepts/yarn-on-windows.md' ? 'victor body about yarn' : null;
|
||||
}
|
||||
if (owner === 'OpeItcLoc03' && repo === 'common') {
|
||||
return path === '.wiki/concepts/yarn-on-windows.md' ? 'common body about yarn' : null;
|
||||
}
|
||||
// Reject acting-identity routing — proves the bug is gone.
|
||||
throw new Error(`unexpected fetch ${owner}/${repo}`);
|
||||
},
|
||||
async getFileWithSha(): Promise<FileWithSha | null> {
|
||||
return null;
|
||||
},
|
||||
async commitFile() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'OpeItcLoc03',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'yarn',
|
||||
projects: ['victor/books', 'OpeItcLoc03/common'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results).toHaveLength(2);
|
||||
const victorResult = parsed.results.find((x: { project: string }) => x.project === 'victor/books');
|
||||
const commonResult = parsed.results.find((x: { project: string }) => x.project === 'OpeItcLoc03/common');
|
||||
expect(victorResult.matches[0].snippet).toContain('victor body');
|
||||
expect(commonResult.matches[0].snippet).toContain('common body');
|
||||
// Owner extracted from projectName, not from acting giteaUser.
|
||||
expect(calls.some((c) => c.owner === 'victor' && c.repo === 'books')).toBe(true);
|
||||
expect(calls.some((c) => c.owner === 'OpeItcLoc03' && c.repo === 'common')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects bare project name with parseTargetProject hint', async () => {
|
||||
await seedCache([]);
|
||||
const backend = makeBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'x',
|
||||
projects: ['books'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results).toHaveLength(1);
|
||||
expect(parsed.results[0].project).toBe('books');
|
||||
expect(parsed.results[0].error).toContain('must be qualified');
|
||||
});
|
||||
|
||||
it('continues with valid projects when one is bare', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'u/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: sampleIndex,
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({
|
||||
'u/foo': {
|
||||
'.wiki/concepts/yarn-on-windows.md': { body: 'yarn body' },
|
||||
},
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'yarn',
|
||||
projects: ['books', 'u/foo'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results).toHaveLength(2);
|
||||
expect(parsed.results[0].error).toContain('must be qualified');
|
||||
expect(parsed.results[1].matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects agenda literal with explanatory error', async () => {
|
||||
await seedCache([]);
|
||||
const backend = makeBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'x',
|
||||
projects: ['agenda'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results[0].error).toContain('agenda not supported');
|
||||
});
|
||||
|
||||
it('reports "no wiki" for projects without wiki_index', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'u/no-wiki',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'anything',
|
||||
projects: ['u/no-wiki'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results).toEqual([
|
||||
{ project: 'u/no-wiki', error: 'no wiki — repo has no .wiki/index.md' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports "unknown project" when project not in cache', async () => {
|
||||
await seedCache([]);
|
||||
const backend = makeBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'x',
|
||||
projects: ['u/ghost'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results).toEqual([
|
||||
{ project: 'u/ghost', error: 'unknown project — run sync first' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes raw type by default', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'u/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: sampleIndex,
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({
|
||||
'u/foo': {
|
||||
'.wiki/raw/pdf-dump.md': { body: 'pdf about pdf' },
|
||||
},
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'pdf',
|
||||
projects: ['u/foo'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results[0].matches).toEqual([]);
|
||||
});
|
||||
|
||||
it('includes raw when explicitly asked', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'u/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: sampleIndex,
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({
|
||||
'u/foo': { '.wiki/raw/pdf-dump.md': { body: 'pdf body' } },
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'pdf',
|
||||
projects: ['u/foo'],
|
||||
types: ['raw'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results[0].matches).toHaveLength(1);
|
||||
expect(parsed.results[0].matches[0].slug).toBe('raw/pdf-dump');
|
||||
});
|
||||
|
||||
it('respects limit per project', async () => {
|
||||
const wideIndex = `# Wiki Index\n\n## Concepts\n\n- [a](concepts/a.md) — A\n- [b](concepts/b.md) — B\n- [c](concepts/c.md) — C\n`;
|
||||
await seedCache([
|
||||
{
|
||||
name: 'u/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: wideIndex,
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({
|
||||
'u/foo': {
|
||||
'.wiki/concepts/a.md': { body: '' },
|
||||
'.wiki/concepts/b.md': { body: '' },
|
||||
'.wiki/concepts/c.md': { body: '' },
|
||||
},
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: '',
|
||||
projects: ['u/foo'],
|
||||
limit: 2,
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results[0].matches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('marks snippet as "(fetch failed)" when live fetch throws', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'u/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: sampleIndex,
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({
|
||||
'u/foo': {
|
||||
'.wiki/concepts/yarn-on-windows.md': new Error('boom'),
|
||||
},
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'yarn',
|
||||
projects: ['u/foo'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results[0].matches[0].snippet).toBe('(fetch failed)');
|
||||
expect(parsed.results[0].matches[0].slug).toBe('concepts/yarn-on-windows');
|
||||
});
|
||||
|
||||
it('returns error when backend not configured', async () => {
|
||||
await seedCache([]);
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile });
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'x',
|
||||
projects: ['u/foo'],
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('disabled');
|
||||
});
|
||||
|
||||
it('next_action_hint mentions both knowledge.get_from and tasks.create', async () => {
|
||||
await seedCache([]);
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend: makeBackend({}),
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ask_projects', {
|
||||
query: 'x',
|
||||
projects: ['u/foo'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.next_action_hint).toContain('knowledge.get_from');
|
||||
expect(parsed.next_action_hint).toContain('tasks.create');
|
||||
});
|
||||
});
|
||||
217
lib/projects-meta-mcp/tests/tools/knowledge-get-from.test.ts
Normal file
217
lib/projects-meta-mcp/tests/tools/knowledge-get-from.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtemp } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { makeKnowledgeTools } from '../../src/tools/knowledge.js';
|
||||
import { writeCache, type CacheFile } from '../../src/lib/cache.js';
|
||||
import type { Backend, CommitFileArgs, FileWithSha } from '../../src/lib/backend.js';
|
||||
|
||||
let wikiRoot: string;
|
||||
let projectCwd: string;
|
||||
let cacheFile: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
wikiRoot = await mkdtemp(join(tmpdir(), 'kw-shared-'));
|
||||
projectCwd = await mkdtemp(join(tmpdir(), 'kw-cwd-'));
|
||||
cacheFile = join(await mkdtemp(join(tmpdir(), 'kw-cache-')), 'cache.json');
|
||||
});
|
||||
|
||||
function makeBackend(files: Record<string, Record<string, string | null | Error>>): Backend {
|
||||
return {
|
||||
async listUserRepos() {
|
||||
return [];
|
||||
},
|
||||
async getRawFile(_u, repo, path) {
|
||||
const v = files[repo]?.[path];
|
||||
if (v === undefined) return null;
|
||||
if (v instanceof Error) throw v;
|
||||
return v;
|
||||
},
|
||||
async getFileWithSha(): Promise<FileWithSha | null> {
|
||||
return null;
|
||||
},
|
||||
async commitFile(_a: CommitFileArgs) {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function seedCache(projects: CacheFile['projects']) {
|
||||
await writeCache(cacheFile, {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-04-30T00:00:00Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects,
|
||||
errors: [],
|
||||
});
|
||||
}
|
||||
|
||||
function call(
|
||||
tools: ReturnType<typeof makeKnowledgeTools>,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
) {
|
||||
const t = tools.find((x) => x.name === name);
|
||||
if (!t) throw new Error(`tool ${name} missing`);
|
||||
return t.handler(args);
|
||||
}
|
||||
|
||||
describe('knowledge.get_from', () => {
|
||||
it('returns full page body for a known qualified project + slug', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'victor/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
wiki_index: '',
|
||||
},
|
||||
]);
|
||||
const body = '---\ntitle: Foo\n---\n\nfoo body content';
|
||||
const backend = makeBackend({
|
||||
foo: { '.wiki/concepts/foo.md': body },
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.get_from', {
|
||||
project: 'victor/foo',
|
||||
slug: 'concepts/foo',
|
||||
});
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content[0].text).toBe(body);
|
||||
});
|
||||
|
||||
it('rejects bare project name with hint', async () => {
|
||||
await seedCache([]);
|
||||
const backend = makeBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.get_from', {
|
||||
project: 'foo',
|
||||
slug: 'concepts/x',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('must be qualified');
|
||||
});
|
||||
|
||||
it('returns error when qualified project not in cache', async () => {
|
||||
await seedCache([]);
|
||||
const backend = makeBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.get_from', {
|
||||
project: 'ghost/repo',
|
||||
slug: 'concepts/x',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('project not in cache');
|
||||
});
|
||||
|
||||
it('returns error when page does not exist', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'victor/foo',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
]);
|
||||
const backend = makeBackend({ foo: {} });
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.get_from', {
|
||||
project: 'victor/foo',
|
||||
slug: 'concepts/missing',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('page not found');
|
||||
});
|
||||
|
||||
it('resolves agenda alias to projects-wiki', async () => {
|
||||
await seedCache([
|
||||
{
|
||||
name: 'agenda',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-04-30T00:00:00Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
]);
|
||||
const body = '---\ntitle: Agenda\n---\n\nagenda content';
|
||||
// Shared wiki (projects-wiki) stores content at repo root, NO .wiki/ prefix.
|
||||
// Asymmetric with project wikis where pages live under <repo>/.wiki/.
|
||||
const backend = makeBackend({
|
||||
'projects-wiki': { 'concepts/test.md': body },
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.get_from', {
|
||||
project: 'agenda',
|
||||
slug: 'concepts/test',
|
||||
});
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content[0].text).toBe(body);
|
||||
});
|
||||
|
||||
it('rejects legacy "_meta" literal with hint (renamed to "agenda" in 2.0)', async () => {
|
||||
await seedCache([]);
|
||||
const backend = makeBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.get_from', {
|
||||
project: '_meta',
|
||||
slug: 'concepts/test',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('must be qualified');
|
||||
});
|
||||
|
||||
it('returns error when backend not configured', async () => {
|
||||
await seedCache([]);
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile });
|
||||
const r = await call(tools, 'knowledge.get_from', {
|
||||
project: 'victor/foo',
|
||||
slug: 'concepts/x',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('disabled');
|
||||
});
|
||||
});
|
||||
455
lib/projects-meta-mcp/tests/tools/knowledge.test.ts
Normal file
455
lib/projects-meta-mcp/tests/tools/knowledge.test.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { makeKnowledgeTools } from '../../src/tools/knowledge.js';
|
||||
import { writeCache, type CacheFile } from '../../src/lib/cache.js';
|
||||
import type { Backend, CommitFileArgs, FileWithSha } from '../../src/lib/backend.js';
|
||||
|
||||
let wikiRoot: string;
|
||||
let nodeProject: string;
|
||||
let unknownProject: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
wikiRoot = await mkdtemp(join(tmpdir(), 'kw-wiki-'));
|
||||
await mkdir(join(wikiRoot, 'node'), { recursive: true });
|
||||
await mkdir(join(wikiRoot, 'embedded'), { recursive: true });
|
||||
await mkdir(join(wikiRoot, 'cross'), { recursive: true });
|
||||
|
||||
await writeFile(
|
||||
join(wikiRoot, 'node', 'yarn-windows.md'),
|
||||
'---\ntitle: Yarn on Windows\ndomain: node\n---\n\nyarn body',
|
||||
);
|
||||
await writeFile(
|
||||
join(wikiRoot, 'embedded', 'stm32-flash.md'),
|
||||
'---\ntitle: STM32 Flashing\ndomain: embedded\n---\n\nstm32 body',
|
||||
);
|
||||
await writeFile(
|
||||
join(wikiRoot, 'cross', 'git-trick.md'),
|
||||
'---\ntitle: Git Trick\ndomain: cross\n---\n\ngit yarn body',
|
||||
);
|
||||
|
||||
nodeProject = await mkdtemp(join(tmpdir(), 'kw-proj-'));
|
||||
await writeFile(join(nodeProject, 'package.json'), '{}');
|
||||
|
||||
unknownProject = await mkdtemp(join(tmpdir(), 'kw-proj-'));
|
||||
});
|
||||
|
||||
function call(tools: ReturnType<typeof makeKnowledgeTools>, name: string, args: Record<string, unknown>) {
|
||||
const t = tools.find((x) => x.name === name);
|
||||
if (!t) throw new Error(`tool ${name} missing`);
|
||||
return t.handler(args);
|
||||
}
|
||||
|
||||
describe('knowledge.search (node project)', () => {
|
||||
it('shows node + cross by default, hides embedded', async () => {
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
|
||||
const r = await call(tools, 'knowledge.search', { query: 'yarn' });
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
const slugs = parsed.results.map((x: { slug: string }) => x.slug).sort();
|
||||
expect(slugs).toEqual(['cross/git-trick', 'node/yarn-windows']);
|
||||
});
|
||||
|
||||
it('domain="all" shows everything', async () => {
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
|
||||
const r = await call(tools, 'knowledge.search', { query: '', domain: 'all' });
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results.length).toBe(3);
|
||||
});
|
||||
|
||||
it('explicit domain switches filter', async () => {
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
|
||||
const r = await call(tools, 'knowledge.search', { query: '', domain: 'embedded' });
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
const slugs = parsed.results.map((x: { slug: string }) => x.slug).sort();
|
||||
expect(slugs).toEqual(['cross/git-trick', 'embedded/stm32-flash']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledge.search (unknown project)', () => {
|
||||
it('returns all when cwd domain unknown', async () => {
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: unknownProject });
|
||||
const r = await call(tools, 'knowledge.search', { query: '' });
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.results.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledge.get', () => {
|
||||
it('returns full page including frontmatter', async () => {
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
|
||||
const r = await call(tools, 'knowledge.get', { slug: 'node/yarn-windows' });
|
||||
expect(r.content[0].text).toContain('domain: node');
|
||||
expect(r.content[0].text).toContain('yarn body');
|
||||
});
|
||||
|
||||
it('errors on missing slug', async () => {
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
|
||||
const r = await call(tools, 'knowledge.get', { slug: 'ghost/page' });
|
||||
expect(r.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledge.suggest_promote', () => {
|
||||
it('returns candidates from project .wiki/concepts', async () => {
|
||||
await mkdir(join(nodeProject, '.wiki', 'concepts'), { recursive: true });
|
||||
await writeFile(
|
||||
join(nodeProject, '.wiki', 'concepts', 'docker-quirk.md'),
|
||||
'---\ntitle: Docker quirk\n---\n\nDocker daemon stuff.',
|
||||
);
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
|
||||
const r = await call(tools, 'knowledge.suggest_promote', {});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.candidates).toHaveLength(1);
|
||||
expect(parsed.candidates[0].slug).toBe('docker-quirk');
|
||||
expect(parsed.candidates[0].suggested_domain).toBe('node');
|
||||
});
|
||||
});
|
||||
|
||||
interface RecordedCommit extends CommitFileArgs {}
|
||||
|
||||
function recordingBackend(initial: Record<string, FileWithSha> = {}): {
|
||||
backend: Backend;
|
||||
commits: RecordedCommit[];
|
||||
files: Record<string, FileWithSha>;
|
||||
} {
|
||||
const files = { ...initial };
|
||||
const commits: RecordedCommit[] = [];
|
||||
const backend: Backend = {
|
||||
async listUserRepos() {
|
||||
return [];
|
||||
},
|
||||
async getRawFile(_u, repo, path) {
|
||||
return files[`${repo}:${path}`]?.content ?? null;
|
||||
},
|
||||
async getFileWithSha(_u, repo, path) {
|
||||
return files[`${repo}:${path}`] ?? null;
|
||||
},
|
||||
async commitFile(args) {
|
||||
commits.push({ ...args });
|
||||
const fileSha = `sha-${commits.length}`;
|
||||
files[`${args.repo}:${args.path}`] = { content: args.content, sha: fileSha };
|
||||
return { fileSha, commitSha: `commit-${commits.length}` };
|
||||
},
|
||||
};
|
||||
return { backend, commits, files };
|
||||
}
|
||||
|
||||
const fixedNow = () => new Date('2026-04-29T12:00:00Z');
|
||||
const fixture: CacheFile = {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-04-29T12:00:00.000Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [
|
||||
{ name: 'OpeItcLoc03/alpha', default_branch: 'main', fetched_at: '2026-04-29T12:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '' },
|
||||
{ name: 'victor/books', default_branch: 'main', fetched_at: '2026-04-29T12:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '' },
|
||||
{ name: 'projects-wiki', default_branch: 'main', fetched_at: '2026-04-29T12:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '' },
|
||||
],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
async function makeCacheFile(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'kw-cache-'));
|
||||
const f = join(dir, 'tasks.json');
|
||||
await writeCache(f, fixture);
|
||||
return f;
|
||||
}
|
||||
|
||||
describe('knowledge.ingest — preview', () => {
|
||||
it('returns preview without committing', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
machine: 'host-x',
|
||||
sourceProject: 'modules-db',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
type: 'concepts',
|
||||
slug: 'tasks-routing-rule',
|
||||
body: 'Read local STATUS.md for current project.',
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.preview).toBe(true);
|
||||
expect(parsed.target_owner).toBe('OpeItcLoc03');
|
||||
expect(parsed.target_repo).toBe('alpha');
|
||||
expect(parsed.target_path).toBe('.wiki/concepts/tasks-routing-rule.md');
|
||||
expect(parsed.content).toContain('title: tasks-routing-rule');
|
||||
expect(parsed.content).toContain('source_project: modules-db');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects bare target_project with hint', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'alpha',
|
||||
type: 'concepts',
|
||||
slug: 'x',
|
||||
body: 'b',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('must be qualified');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledge.ingest — confirm', () => {
|
||||
it('writes page + index + log (3 commits) and removes (none yet) placeholder', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits, files } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
machine: 'host-x',
|
||||
sourceProject: 'modules-db',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
type: 'concepts',
|
||||
slug: 'routing-rule',
|
||||
body: 'rule body',
|
||||
frontmatter: { title: 'Tasks Routing Rule', tags: ['routing'] },
|
||||
confirm: true,
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.committed).toBe(true);
|
||||
expect(parsed.target_owner).toBe('OpeItcLoc03');
|
||||
expect(parsed.target_repo).toBe('alpha');
|
||||
expect(parsed.commits).toHaveLength(3);
|
||||
const paths = parsed.commits.map((c: { path: string }) => c.path);
|
||||
expect(paths).toEqual(['.wiki/concepts/routing-rule.md', '.wiki/index.md', '.wiki/log.md']);
|
||||
expect(files['alpha:.wiki/concepts/routing-rule.md'].content).toContain('Tasks Routing Rule');
|
||||
expect(files['alpha:.wiki/index.md'].content).toContain('- [routing-rule](concepts/routing-rule.md) — Tasks Routing Rule');
|
||||
expect(files['alpha:.wiki/log.md'].content).toContain('## [2026-04-29] ingest | concepts/routing-rule');
|
||||
// Three commits = page + index + log
|
||||
expect(commits).toHaveLength(3);
|
||||
expect(commits[0].user).toBe('OpeItcLoc03');
|
||||
expect(commits[0].repo).toBe('alpha');
|
||||
});
|
||||
|
||||
it('routes victor/books to owner=victor', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
machine: 'host-x',
|
||||
sourceProject: 'modules-db',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'victor/books',
|
||||
type: 'concepts',
|
||||
slug: 'pagination',
|
||||
body: 'how to paginate',
|
||||
confirm: true,
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.committed).toBe(true);
|
||||
expect(parsed.target_owner).toBe('victor');
|
||||
expect(parsed.target_repo).toBe('books');
|
||||
expect(commits[0].user).toBe('victor');
|
||||
expect(commits[0].repo).toBe('books');
|
||||
});
|
||||
|
||||
it('rejects when page already exists', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({
|
||||
'alpha:.wiki/concepts/x.md': { content: '---\ntitle: x\n---\n', sha: 's' },
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
type: 'concepts',
|
||||
slug: 'x',
|
||||
body: 'b',
|
||||
confirm: true,
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('already exists');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects unknown target_project (cache miss)', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
});
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'ghost/repo',
|
||||
type: 'concepts',
|
||||
slug: 's',
|
||||
body: 'b',
|
||||
confirm: true,
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('not in cache');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('write tools disabled when no backend/auth', async () => {
|
||||
const tools = makeKnowledgeTools({ wikiRoot, projectCwd: nodeProject });
|
||||
const r = await call(tools, 'knowledge.ingest', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
type: 'concepts',
|
||||
slug: 's',
|
||||
body: 'b',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('Write tools disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledge.promote', () => {
|
||||
it('preview returns sources content with raw_path frontmatter', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({
|
||||
'alpha:.wiki/raw/x.md': { content: '# raw\n', sha: 'r' },
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.promote', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'x',
|
||||
body: 'summary of raw',
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.preview).toBe(true);
|
||||
expect(parsed.target_owner).toBe('OpeItcLoc03');
|
||||
expect(parsed.target_repo).toBe('alpha');
|
||||
expect(parsed.target_path).toBe('.wiki/sources/x.md');
|
||||
expect(parsed.content).toContain('raw_path: raw/x.md');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects bare target_project', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.promote', {
|
||||
target_project: 'alpha',
|
||||
slug: 'x',
|
||||
body: 'b',
|
||||
confirm: true,
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('must be qualified');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects when raw page is missing', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.promote', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'missing',
|
||||
body: 'b',
|
||||
confirm: true,
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.content[0].text).toContain('raw page not found');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('confirm writes sources + updates index/log', async () => {
|
||||
const cacheFile = await makeCacheFile();
|
||||
const { backend, commits, files } = recordingBackend({
|
||||
'alpha:.wiki/raw/notes.md': { content: '# notes\n', sha: 'r' },
|
||||
});
|
||||
const tools = makeKnowledgeTools({
|
||||
wikiRoot,
|
||||
projectCwd: nodeProject,
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
projectsWikiRepo: 'projects-wiki',
|
||||
now: fixedNow,
|
||||
});
|
||||
const r = await call(tools, 'knowledge.promote', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'notes',
|
||||
body: 'summary text',
|
||||
frontmatter: { title: 'Notes Summary' },
|
||||
confirm: true,
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.committed).toBe(true);
|
||||
expect(parsed.target_owner).toBe('OpeItcLoc03');
|
||||
expect(parsed.target_repo).toBe('alpha');
|
||||
expect(parsed.commits.map((c: { path: string }) => c.path)).toEqual([
|
||||
'.wiki/sources/notes.md',
|
||||
'.wiki/index.md',
|
||||
'.wiki/log.md',
|
||||
]);
|
||||
expect(files['alpha:.wiki/sources/notes.md'].content).toContain('raw_path: raw/notes.md');
|
||||
expect(files['alpha:.wiki/log.md'].content).toContain('promote | raw/notes → sources/notes');
|
||||
expect(commits).toHaveLength(3);
|
||||
expect(commits[0].user).toBe('OpeItcLoc03');
|
||||
expect(commits[0].repo).toBe('alpha');
|
||||
});
|
||||
});
|
||||
153
lib/projects-meta-mcp/tests/tools/meta.test.ts
Normal file
153
lib/projects-meta-mcp/tests/tools/meta.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { writeCache } from '../../src/lib/cache.js';
|
||||
import { makeMetaTools } from '../../src/tools/meta.js';
|
||||
import type { Paths } from '../../src/lib/config.js';
|
||||
|
||||
function mkPaths(home: string): Paths {
|
||||
const cacheDir = join(home, '.cache');
|
||||
return {
|
||||
cacheDir,
|
||||
cacheFile: join(cacheDir, 'tasks.json'),
|
||||
syncLog: join(cacheDir, 'sync.log'),
|
||||
authFile: join(home, '.config', 'auth.toml'),
|
||||
sharedWikiClone: join(home, 'wiki'),
|
||||
agendaTasksDir: join(home, 'tasks'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('meta.status', () => {
|
||||
let paths: Paths;
|
||||
|
||||
beforeEach(async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'meta-'));
|
||||
paths = mkPaths(home);
|
||||
await mkdir(paths.sharedWikiClone, { recursive: true });
|
||||
await mkdir(join(paths.sharedWikiClone, 'node'), { recursive: true });
|
||||
await writeFile(
|
||||
join(paths.sharedWikiClone, 'node', 'one.md'),
|
||||
'---\ntitle: t\ndomain: node\n---\n\nbody',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports cache_missing when no cache', async () => {
|
||||
const tools = makeMetaTools({ paths });
|
||||
const r = await (tools.find((t) => t.name === 'meta.status')!.handler({}));
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.cache_missing).toBe(true);
|
||||
expect(parsed.wiki_pages_count).toBe(1);
|
||||
expect(parsed.agenda_present).toBe(false);
|
||||
expect(parsed.agenda_tasks_dir).toBe(paths.agendaTasksDir);
|
||||
});
|
||||
|
||||
it('reports age and stale=false for fresh cache', async () => {
|
||||
await writeCache(paths.cacheFile, {
|
||||
schemaVersion: 3,
|
||||
synced_at: new Date().toISOString(),
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [],
|
||||
errors: [],
|
||||
});
|
||||
const tools = makeMetaTools({ paths });
|
||||
const r = await (tools.find((t) => t.name === 'meta.status')!.handler({}));
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.stale).toBe(false);
|
||||
expect(parsed.age_seconds).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('reports stale=true for >24h cache', async () => {
|
||||
const old = new Date(Date.now() - 25 * 3600 * 1000).toISOString();
|
||||
await writeCache(paths.cacheFile, {
|
||||
schemaVersion: 3,
|
||||
synced_at: old,
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [{ name: 'p', default_branch: 'main', fetched_at: old, active_tasks: [], all_tasks_count: 0, raw: '' }],
|
||||
errors: [{ project: 'q', reason: 'x' }],
|
||||
});
|
||||
const tools = makeMetaTools({ paths });
|
||||
const r = await (tools.find((t) => t.name === 'meta.status')!.handler({}));
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.stale).toBe(true);
|
||||
expect(parsed.projects_count).toBe(1);
|
||||
expect(parsed.errors_count).toBe(1);
|
||||
});
|
||||
|
||||
it('exposes agenda-project counts and source=local when agenda default_branch is "local"', async () => {
|
||||
await writeCache(paths.cacheFile, {
|
||||
schemaVersion: 3,
|
||||
synced_at: new Date().toISOString(),
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [
|
||||
{ name: 'p', default_branch: 'main', fetched_at: new Date().toISOString(), active_tasks: [], all_tasks_count: 0, raw: '' },
|
||||
{
|
||||
name: 'agenda',
|
||||
default_branch: 'local',
|
||||
fetched_at: new Date().toISOString(),
|
||||
active_tasks: [
|
||||
{ slug: 'm1', status: 'active', next: 'do x' },
|
||||
{ slug: 'm2', status: 'paused', next: 'do y' },
|
||||
],
|
||||
all_tasks_count: 3,
|
||||
raw: '...',
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
});
|
||||
const tools = makeMetaTools({ paths });
|
||||
const r = await (tools.find((t) => t.name === 'meta.status')!.handler({}));
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.agenda_present).toBe(true);
|
||||
expect(parsed.agenda_tasks_active).toBe(2);
|
||||
expect(parsed.agenda_tasks_total).toBe(3);
|
||||
expect(parsed.agenda_source).toBe('local');
|
||||
expect(parsed.agenda_branch).toBe('local');
|
||||
});
|
||||
|
||||
it('reports agenda_source=gitea when agenda default_branch is a real branch', async () => {
|
||||
await writeCache(paths.cacheFile, {
|
||||
schemaVersion: 3,
|
||||
synced_at: new Date().toISOString(),
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [
|
||||
{
|
||||
name: 'agenda',
|
||||
default_branch: 'main',
|
||||
fetched_at: new Date().toISOString(),
|
||||
active_tasks: [{ slug: 'g1', status: 'active', next: 'x' }],
|
||||
all_tasks_count: 1,
|
||||
raw: '...',
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
});
|
||||
const tools = makeMetaTools({ paths });
|
||||
const r = await (tools.find((t) => t.name === 'meta.status')!.handler({}));
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.agenda_present).toBe(true);
|
||||
expect(parsed.agenda_source).toBe('gitea');
|
||||
expect(parsed.agenda_branch).toBe('main');
|
||||
});
|
||||
|
||||
it('reports agenda_source=none when agenda absent', async () => {
|
||||
await writeCache(paths.cacheFile, {
|
||||
schemaVersion: 3,
|
||||
synced_at: new Date().toISOString(),
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [{ name: 'p', default_branch: 'main', fetched_at: new Date().toISOString(), active_tasks: [], all_tasks_count: 0, raw: '' }],
|
||||
errors: [],
|
||||
});
|
||||
const tools = makeMetaTools({ paths });
|
||||
const r = await (tools.find((t) => t.name === 'meta.status')!.handler({}));
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.agenda_present).toBe(false);
|
||||
expect(parsed.agenda_source).toBe('none');
|
||||
expect(parsed.agenda_branch).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtemp } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { writeCache, type CacheFile } from '../../src/lib/cache.js';
|
||||
import { makeTasksTools } from '../../src/tools/tasks.js';
|
||||
import type { Backend, CommitFileArgs, CommitResult, FileWithSha } from '../../src/lib/backend.js';
|
||||
|
||||
function recordingBackend(initial: Record<string, FileWithSha> = {}) {
|
||||
const files = { ...initial };
|
||||
const commits: CommitFileArgs[] = [];
|
||||
const backend: Backend = {
|
||||
async listUserRepos() {
|
||||
return [];
|
||||
},
|
||||
async getRawFile(_u, repo, path) {
|
||||
return files[`${repo}:${path}`]?.content ?? null;
|
||||
},
|
||||
async getFileWithSha(_u, repo, path) {
|
||||
return files[`${repo}:${path}`] ?? null;
|
||||
},
|
||||
async commitFile(args: CommitFileArgs): Promise<CommitResult> {
|
||||
commits.push({ ...args });
|
||||
const fileSha = `sha-${commits.length}`;
|
||||
files[`${args.repo}:${args.path}`] = { content: args.content, sha: fileSha };
|
||||
return { fileSha, commitSha: `commit-${commits.length}` };
|
||||
},
|
||||
};
|
||||
return { backend, commits, files };
|
||||
}
|
||||
|
||||
const fixture: CacheFile = {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-06-08T12:00:00.000Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [
|
||||
{
|
||||
name: 'OpeItcLoc03/alpha',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-06-08T12:00:01.000Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
let cacheFile: string;
|
||||
const fixedNow = () => new Date('2026-06-08T10:00:00.000Z');
|
||||
|
||||
beforeEach(async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'trail-tool-'));
|
||||
cacheFile = join(dir, 'tasks.json');
|
||||
await writeCache(cacheFile, fixture);
|
||||
});
|
||||
|
||||
function makeTools(backend: Backend) {
|
||||
return makeTasksTools({
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
agendaTasksRepo: 'projects-tasks',
|
||||
machine: 'host-x',
|
||||
now: fixedNow,
|
||||
});
|
||||
}
|
||||
|
||||
function call(tools: ReturnType<typeof makeTasksTools>, name: string, args: Record<string, unknown>) {
|
||||
const t = tools.find((x) => x.name === name);
|
||||
if (!t) throw new Error(`tool ${name} not registered`);
|
||||
return t.handler(args);
|
||||
}
|
||||
|
||||
const base = {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'my-task',
|
||||
question: 'cache key includes locale?',
|
||||
decided_by: 'arbiter',
|
||||
ruling: 'include locale',
|
||||
rationale: 'avoids cross-locale bleed',
|
||||
blast_radius: 'reversible',
|
||||
escalation_chain: ['brief'],
|
||||
};
|
||||
|
||||
describe('tasks.append_decision_trail', () => {
|
||||
it('is registered', () => {
|
||||
const { backend } = recordingBackend();
|
||||
const tools = makeTools(backend);
|
||||
expect(tools.find((t) => t.name === 'tasks.append_decision_trail')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('commits straight (no confirm gate — infra append) and targets .tasks/<slug>.md', async () => {
|
||||
const { backend, commits } = recordingBackend({
|
||||
'alpha:.tasks/my-task.md': { content: '# my-task\n\n## Goal\nx\n', sha: 'old' },
|
||||
});
|
||||
const tools = makeTools(backend);
|
||||
const r = await call(tools, 'tasks.append_decision_trail', base);
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
|
||||
expect(parsed.committed).toBe(true);
|
||||
expect(commits).toHaveLength(1);
|
||||
expect(commits[0].path).toBe('.tasks/my-task.md');
|
||||
expect(commits[0].sha).toBe('old'); // updated existing file via sha lock
|
||||
expect(commits[0].content).toContain('## Decision trail');
|
||||
expect(commits[0].content).toContain('### consult 1 —');
|
||||
});
|
||||
|
||||
it('returns a trail_ref pointer the worker can be handed', async () => {
|
||||
const { backend } = recordingBackend({
|
||||
'alpha:.tasks/my-task.md': { content: '# my-task\n', sha: 'old' },
|
||||
});
|
||||
const tools = makeTools(backend);
|
||||
const r = await call(tools, 'tasks.append_decision_trail', base);
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(typeof parsed.trail_ref).toBe('string');
|
||||
expect(parsed.trail_ref).toContain('my-task');
|
||||
});
|
||||
|
||||
it('creates the task file when it does not exist yet (no sha)', async () => {
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeTools(backend);
|
||||
const r = await call(tools, 'tasks.append_decision_trail', base);
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.committed).toBe(true);
|
||||
expect(commits[0].sha).toBeUndefined(); // create, not update
|
||||
expect(commits[0].content).toContain('# my-task');
|
||||
expect(commits[0].content).toContain('### consult 1 —');
|
||||
});
|
||||
|
||||
it('writes every contract field', async () => {
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeTools(backend);
|
||||
await call(tools, 'tasks.append_decision_trail', base);
|
||||
const c = commits[0].content;
|
||||
expect(c).toContain('- question: cache key includes locale?');
|
||||
expect(c).toContain('- blast_radius: reversible');
|
||||
expect(c).toContain('- decided_by: arbiter');
|
||||
expect(c).toContain('- ruling: include locale');
|
||||
expect(c).toContain('- rationale: avoids cross-locale bleed');
|
||||
expect(c).toContain('- escalation_chain: brief');
|
||||
});
|
||||
|
||||
it('numbers a second consult as 2', async () => {
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeTools(backend);
|
||||
await call(tools, 'tasks.append_decision_trail', base);
|
||||
await call(tools, 'tasks.append_decision_trail', { ...base, question: 'second?' });
|
||||
expect(commits[1].content).toContain('### consult 2 —');
|
||||
});
|
||||
|
||||
it('logs a halt outcome (decided_by human-required, no ruling)', async () => {
|
||||
const { backend, commits } = recordingBackend({});
|
||||
const tools = makeTools(backend);
|
||||
await call(tools, 'tasks.append_decision_trail', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'my-task',
|
||||
question: 'priorities?',
|
||||
decided_by: 'human-required',
|
||||
rationale: 'escalated: arbiter declared a fork',
|
||||
escalation_chain: ['brief', 'arbiter-fork'],
|
||||
});
|
||||
expect(commits[0].content).toContain('- decided_by: human-required');
|
||||
expect(commits[0].content).toContain('- ruling: —');
|
||||
});
|
||||
|
||||
it('is disabled without a backend', async () => {
|
||||
const tools = makeTasksTools({ cacheFile });
|
||||
const r = await call(tools, 'tasks.append_decision_trail', base);
|
||||
expect(r.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
373
lib/projects-meta-mcp/tests/tools/tasks-cli.test.ts
Normal file
373
lib/projects-meta-mcp/tests/tools/tasks-cli.test.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runTasksCli } from '../../src/tasks-cli.js';
|
||||
import type { ToolDef, ToolResult } from '../../src/tools/types.js';
|
||||
|
||||
function fakeTool(
|
||||
name: string,
|
||||
impl: (args: Record<string, unknown>) => ToolResult,
|
||||
): ToolDef {
|
||||
return {
|
||||
name,
|
||||
description: '',
|
||||
inputSchema: { type: 'object' },
|
||||
handler: async (args) => impl(args),
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResult(value: unknown): ToolResult {
|
||||
return { content: [{ type: 'text', text: JSON.stringify(value) }] };
|
||||
}
|
||||
|
||||
describe('runTasksCli', () => {
|
||||
it('routes claim-next to tasks.claim_next with parsed flags and prints the handler JSON', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.claim_next', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ ok: true, claimed: true, slug: 'foo' });
|
||||
}),
|
||||
];
|
||||
const out: string[] = [];
|
||||
|
||||
const code = await runTasksCli(
|
||||
[
|
||||
'claim-next',
|
||||
'--claimer-identity',
|
||||
'host:claude-opus:123',
|
||||
'--runtime',
|
||||
'claude-opus',
|
||||
'--requirements',
|
||||
'needs-db,needs-internet',
|
||||
'--confirm',
|
||||
],
|
||||
{ tools, log: (s) => out.push(s) },
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
claimer_identity: 'host:claude-opus:123',
|
||||
confirm: true,
|
||||
filter: { runtime: 'claude-opus', requirements: ['needs-db', 'needs-internet'] },
|
||||
});
|
||||
expect(JSON.parse(out.join('\n'))).toEqual({ ok: true, claimed: true, slug: 'foo' });
|
||||
});
|
||||
|
||||
it('routes append-decision-trail to tasks.append_decision_trail, splitting the chain', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.append_decision_trail', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true, trail_ref: 'x' });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
[
|
||||
'append-decision-trail',
|
||||
'--target-project',
|
||||
'OpeItcLoc03/alpha',
|
||||
'--slug',
|
||||
'my-task',
|
||||
'--question',
|
||||
'cache key includes locale?',
|
||||
'--decided-by',
|
||||
'arbiter',
|
||||
'--blast-radius',
|
||||
'reversible',
|
||||
'--ruling',
|
||||
'include locale',
|
||||
'--rationale',
|
||||
'avoids bleed',
|
||||
'--escalation-chain',
|
||||
'brief,read-repo',
|
||||
],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'my-task',
|
||||
question: 'cache key includes locale?',
|
||||
decided_by: 'arbiter',
|
||||
blast_radius: 'reversible',
|
||||
ruling: 'include locale',
|
||||
rationale: 'avoids bleed',
|
||||
escalation_chain: ['brief', 'read-repo'],
|
||||
});
|
||||
});
|
||||
|
||||
it('omits ruling on a halt trail entry', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.append_decision_trail', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
await runTasksCli(
|
||||
[
|
||||
'append-decision-trail',
|
||||
'--target-project',
|
||||
'OpeItcLoc03/alpha',
|
||||
'--slug',
|
||||
'my-task',
|
||||
'--question',
|
||||
'priorities?',
|
||||
'--decided-by',
|
||||
'human-required',
|
||||
'--escalation-chain',
|
||||
'brief,arbiter-fork',
|
||||
],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(received).not.toHaveProperty('ruling');
|
||||
expect(received!.decided_by).toBe('human-required');
|
||||
});
|
||||
|
||||
it('routes park-question to tasks.park_question with question + optional claim_token', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.park_question', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ parked: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
[
|
||||
'park-question',
|
||||
'--target-project',
|
||||
'OpeItcLoc03/alpha',
|
||||
'--slug',
|
||||
'my-task',
|
||||
'--question',
|
||||
'drop the legacy column?',
|
||||
'--claim-token',
|
||||
'tok-123',
|
||||
],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'my-task',
|
||||
question: 'drop the legacy column?',
|
||||
claim_token: 'tok-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes get-status to tasks.get_status', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.get_status', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ found: true, status: 'blocked' });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['get-status', '--target-project', 'OpeItcLoc03/alpha', '--slug', 'my-task'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({ target_project: 'OpeItcLoc03/alpha', slug: 'my-task' });
|
||||
});
|
||||
|
||||
it('routes heartbeat to tasks.heartbeat with slug + claim_token (+ optional target_project)', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.heartbeat', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ ok: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['heartbeat', '--slug', 'foo', '--claim-token', 'tok-1', '--target-project', 'victor/books'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({ slug: 'foo', claim_token: 'tok-1', target_project: 'victor/books' });
|
||||
});
|
||||
|
||||
it('routes close to tasks.close with target_project + slug + note + confirm', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.close', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['close', '--target-project', 'victor/books', '--slug', 'foo', '--note', 'done via poller', '--confirm'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'victor/books',
|
||||
slug: 'foo',
|
||||
note: 'done via poller',
|
||||
confirm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('routes update to tasks.update with target_project + slug + status + where_stopped', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.update', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
[
|
||||
'update',
|
||||
'--target-project',
|
||||
'victor/books',
|
||||
'--slug',
|
||||
'foo',
|
||||
'--status',
|
||||
'blocked',
|
||||
'--where-stopped',
|
||||
'spawn exited 1: boom',
|
||||
'--confirm',
|
||||
],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'victor/books',
|
||||
slug: 'foo',
|
||||
status: 'blocked',
|
||||
where_stopped: 'spawn exited 1: boom',
|
||||
confirm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes an explicit empty --blocker= through as blocker:"" (clear the blocker)', async () => {
|
||||
// The ops watchdog resets a transient-blocked task with `--status=ready
|
||||
// --blocker=`; the empty value must reach the handler so updateTaskFields
|
||||
// strips the stale **Blocker:** line, not be dropped as a falsy flag.
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.update', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['update', '--target-project=victor/books', '--slug=foo', '--status=ready', '--blocker=', '--confirm'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'victor/books',
|
||||
slug: 'foo',
|
||||
status: 'ready',
|
||||
blocker: '',
|
||||
confirm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('omits blocker entirely when --blocker is absent', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.update', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['update', '--target-project=victor/books', '--slug=foo', '--status=ready', '--confirm'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).not.toHaveProperty('blocker');
|
||||
});
|
||||
|
||||
it('returns exit code 1 when the handler result isError', async () => {
|
||||
const tools = [
|
||||
fakeTool('tasks.claim_next', () => ({
|
||||
content: [{ type: 'text', text: 'cache missing — run sync first' }],
|
||||
isError: true,
|
||||
})),
|
||||
];
|
||||
const out: string[] = [];
|
||||
const code = await runTasksCli(['claim-next', '--claimer-identity', 'h:r:1'], {
|
||||
tools,
|
||||
log: (s) => out.push(s),
|
||||
});
|
||||
expect(code).toBe(1);
|
||||
expect(out.join('\n')).toContain('cache missing');
|
||||
});
|
||||
|
||||
it('returns exit code 2 and usage on an unknown command', async () => {
|
||||
const err: string[] = [];
|
||||
const code = await runTasksCli(['frobnicate'], { tools: [], errLog: (s) => err.push(s) });
|
||||
expect(code).toBe(2);
|
||||
expect(err.join('\n')).toContain('unknown command: frobnicate');
|
||||
});
|
||||
|
||||
it('a throwing handler (e.g. zod parse on a missing required flag) → JSON + non-zero, not an uncaught throw', async () => {
|
||||
const tools = [
|
||||
fakeTool('tasks.claim_next', () => {
|
||||
throw new Error('claimer_identity: Required');
|
||||
}),
|
||||
];
|
||||
const out: string[] = [];
|
||||
const code = await runTasksCli(['claim-next'], { tools, log: (s) => out.push(s) });
|
||||
expect(code).not.toBe(0);
|
||||
const parsed = JSON.parse(out.join('\n'));
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.error).toContain('claimer_identity');
|
||||
});
|
||||
|
||||
it('--flag=value form: a value beginning with -- survives (blocked-path where_stopped)', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.update', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ committed: true });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(
|
||||
['update', '--target-project=victor/books', '--slug=foo', '--status=blocked', '--where-stopped=--boom: bad flag', '--confirm'],
|
||||
{ tools, log: () => {} },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({
|
||||
target_project: 'victor/books',
|
||||
slug: 'foo',
|
||||
status: 'blocked',
|
||||
where_stopped: '--boom: bad flag',
|
||||
confirm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('routes list-blocked to tasks.list_blocked with optional project_allowlist', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.list_blocked', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ ok: true, tasks: [] });
|
||||
}),
|
||||
];
|
||||
const out: string[] = [];
|
||||
const code = await runTasksCli(
|
||||
['list-blocked', '--projects', 'OpeItcLoc03/alpha,OpeItcLoc03/beta'],
|
||||
{ tools, log: (s) => out.push(s) },
|
||||
);
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({ project_allowlist: ['OpeItcLoc03/alpha', 'OpeItcLoc03/beta'] });
|
||||
expect(JSON.parse(out.join('\n'))).toEqual({ ok: true, tasks: [] });
|
||||
});
|
||||
|
||||
it('routes list-blocked without --projects (no allowlist)', async () => {
|
||||
let received: Record<string, unknown> | undefined;
|
||||
const tools = [
|
||||
fakeTool('tasks.list_blocked', (args) => {
|
||||
received = args;
|
||||
return jsonResult({ ok: true, tasks: [] });
|
||||
}),
|
||||
];
|
||||
const code = await runTasksCli(['list-blocked'], { tools, log: () => {} });
|
||||
expect(code).toBe(0);
|
||||
expect(received).toEqual({});
|
||||
});
|
||||
});
|
||||
102
lib/projects-meta-mcp/tests/tools/tasks-get-status.test.ts
Normal file
102
lib/projects-meta-mcp/tests/tools/tasks-get-status.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtemp } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { writeCache, type CacheFile } from '../../src/lib/cache.js';
|
||||
import { makeTasksTools } from '../../src/tools/tasks.js';
|
||||
import type { Backend, CommitFileArgs, CommitResult, FileWithSha } from '../../src/lib/backend.js';
|
||||
|
||||
function recordingBackend(initial: Record<string, FileWithSha> = {}) {
|
||||
const files = { ...initial };
|
||||
const backend: Backend = {
|
||||
async listUserRepos() {
|
||||
return [];
|
||||
},
|
||||
async getRawFile(_u, repo, path) {
|
||||
return files[`${repo}:${path}`]?.content ?? null;
|
||||
},
|
||||
async getFileWithSha(_u, repo, path) {
|
||||
return files[`${repo}:${path}`] ?? null;
|
||||
},
|
||||
async commitFile(args: CommitFileArgs): Promise<CommitResult> {
|
||||
return { fileSha: 'x', commitSha: 'y' };
|
||||
},
|
||||
};
|
||||
return { backend, files };
|
||||
}
|
||||
|
||||
const fixture: CacheFile = {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-06-08T12:00:00.000Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [
|
||||
{
|
||||
name: 'OpeItcLoc03/alpha',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-06-08T12:00:01.000Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
const BOARD =
|
||||
'# Task Board\n\n## 🔵 [parked-task] — x\n\n**Status:** blocked\n**Branch:** master\n\n---\n' +
|
||||
'## 🔴 [live-task] — y\n\n**Status:** active\n**Branch:** master\n\n---\n';
|
||||
|
||||
let cacheFile: string;
|
||||
beforeEach(async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'getstatus-'));
|
||||
cacheFile = join(dir, 'tasks.json');
|
||||
await writeCache(cacheFile, fixture);
|
||||
});
|
||||
|
||||
function makeTools(backend: Backend) {
|
||||
return makeTasksTools({ cacheFile, backend, giteaUser: 'u', agendaTasksRepo: 'projects-tasks' });
|
||||
}
|
||||
function call(tools: ReturnType<typeof makeTasksTools>, name: string, args: Record<string, unknown>) {
|
||||
const t = tools.find((x) => x.name === name)!;
|
||||
return t.handler(args);
|
||||
}
|
||||
|
||||
describe('tasks.get_status', () => {
|
||||
it('returns the LIVE status of a task (reads the board, not the cache)', async () => {
|
||||
const { backend } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: BOARD, sha: 's' } });
|
||||
const r = await call(makeTools(backend), 'tasks.get_status', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'parked-task',
|
||||
});
|
||||
expect(JSON.parse(r.content[0].text).status).toBe('blocked');
|
||||
});
|
||||
|
||||
it('distinguishes an active task', async () => {
|
||||
const { backend } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: BOARD, sha: 's' } });
|
||||
const r = await call(makeTools(backend), 'tasks.get_status', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'live-task',
|
||||
});
|
||||
expect(JSON.parse(r.content[0].text).status).toBe('active');
|
||||
});
|
||||
|
||||
it('reports not-found for an absent slug', async () => {
|
||||
const { backend } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: BOARD, sha: 's' } });
|
||||
const r = await call(makeTools(backend), 'tasks.get_status', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'ghost',
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.status).toBeNull();
|
||||
expect(parsed.found).toBe(false);
|
||||
});
|
||||
|
||||
it('is disabled without a backend', async () => {
|
||||
const r = await call(makeTasksTools({ cacheFile }), 'tasks.get_status', {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'live-task',
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
197
lib/projects-meta-mcp/tests/tools/tasks-list-blocked.test.ts
Normal file
197
lib/projects-meta-mcp/tests/tools/tasks-list-blocked.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtemp } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { writeCache, type CacheFile } from '../../src/lib/cache.js';
|
||||
import { makeTasksTools } from '../../src/tools/tasks.js';
|
||||
import type { Backend, FileWithSha } from '../../src/lib/backend.js';
|
||||
|
||||
function recordingBackend(initial: Record<string, FileWithSha> = {}) {
|
||||
const files = { ...initial };
|
||||
const backend: Backend = {
|
||||
async listUserRepos() { return []; },
|
||||
async getRawFile(_u, repo, path) { return files[`${repo}:${path}`]?.content ?? null; },
|
||||
async getFileWithSha(_u, repo, path) { return files[`${repo}:${path}`] ?? null; },
|
||||
async commitFile() { return { fileSha: 'x', commitSha: 'y' }; },
|
||||
};
|
||||
return { backend, files };
|
||||
}
|
||||
|
||||
const ALPHA_BOARD = `# Task Board
|
||||
_Updated: 2026-06-09_
|
||||
|
||||
## 🔵 [blocked-cheap] — blocked cheap task
|
||||
**Status:** blocked
|
||||
**Where I stopped:** dep pending
|
||||
**Next action:** wait
|
||||
**Blocker:** dep-a, dep-b
|
||||
**Branch:** master
|
||||
**Weight:** cheap-ok
|
||||
|
||||
---
|
||||
|
||||
## 🔵 [blocked-claude] — blocked claude task
|
||||
**Status:** blocked
|
||||
**Where I stopped:** dep pending
|
||||
**Next action:** wait
|
||||
**Blocker:** dep-c
|
||||
**Branch:** master
|
||||
**Weight:** needs-claude
|
||||
|
||||
---
|
||||
|
||||
## 🔵 [blocked-human] — blocked human task (should be skipped)
|
||||
**Status:** blocked
|
||||
**Where I stopped:** dep pending
|
||||
**Next action:** wait
|
||||
**Blocker:** dep-d
|
||||
**Branch:** master
|
||||
**Weight:** needs-human
|
||||
|
||||
---
|
||||
|
||||
## 🔵 [blocked-noweight] — blocked no-weight task (should be skipped)
|
||||
**Status:** blocked
|
||||
**Where I stopped:** dep pending
|
||||
**Next action:** wait
|
||||
**Blocker:** dep-e
|
||||
**Branch:** master
|
||||
|
||||
---
|
||||
|
||||
## ⚪ [ready-task] — not blocked, should not appear
|
||||
**Status:** ready
|
||||
**Next action:** go
|
||||
**Branch:** master
|
||||
|
||||
---
|
||||
`;
|
||||
|
||||
const BETA_BOARD = `# Task Board
|
||||
_Updated: 2026-06-09_
|
||||
|
||||
## 🔵 [blocked-beta] — blocked beta task
|
||||
**Status:** blocked
|
||||
**Where I stopped:** waiting
|
||||
**Next action:** wait
|
||||
**Blocker:** dep-x
|
||||
**Branch:** master
|
||||
**Weight:** needs-claude
|
||||
|
||||
---
|
||||
`;
|
||||
|
||||
const fixture: CacheFile = {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-06-09T12:00:00.000Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [
|
||||
{
|
||||
name: 'OpeItcLoc03/alpha',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-06-09T12:00:00.000Z',
|
||||
active_tasks: [
|
||||
{ slug: 'blocked-cheap', status: 'blocked', next: 'wait' },
|
||||
{ slug: 'blocked-claude', status: 'blocked', next: 'wait' },
|
||||
{ slug: 'blocked-human', status: 'blocked', next: 'wait' },
|
||||
{ slug: 'blocked-noweight', status: 'blocked', next: 'wait' },
|
||||
],
|
||||
all_tasks_count: 5,
|
||||
raw: '',
|
||||
},
|
||||
{
|
||||
name: 'OpeItcLoc03/beta',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-06-09T12:00:00.000Z',
|
||||
active_tasks: [
|
||||
{ slug: 'blocked-beta', status: 'blocked', next: 'wait' },
|
||||
],
|
||||
all_tasks_count: 1,
|
||||
raw: '',
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
let cacheFile: string;
|
||||
beforeEach(async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'listblocked-'));
|
||||
cacheFile = join(dir, 'tasks.json');
|
||||
await writeCache(cacheFile, fixture);
|
||||
});
|
||||
|
||||
function makeTools(backend: Backend) {
|
||||
return makeTasksTools({ cacheFile, backend, giteaUser: 'u', agendaTasksRepo: 'projects-tasks' });
|
||||
}
|
||||
function call(tools: ReturnType<typeof makeTasksTools>, name: string, args: Record<string, unknown>) {
|
||||
const t = tools.find((x) => x.name === name)!;
|
||||
return t.handler(args);
|
||||
}
|
||||
|
||||
describe('tasks.list_blocked', () => {
|
||||
it('returns blocked tasks with weight needs-claude or cheap-ok, skips needs-human and no-weight', async () => {
|
||||
const { backend } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ALPHA_BOARD, sha: 's' },
|
||||
'beta:.tasks/STATUS.md': { content: BETA_BOARD, sha: 's' },
|
||||
});
|
||||
const r = await call(makeTools(backend), 'tasks.list_blocked', {});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.ok).toBe(true);
|
||||
const slugs = parsed.tasks.map((t: { slug: string }) => t.slug).sort();
|
||||
expect(slugs).toEqual(['blocked-beta', 'blocked-cheap', 'blocked-claude']);
|
||||
});
|
||||
|
||||
it('includes blocker and weight fields', async () => {
|
||||
const { backend } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ALPHA_BOARD, sha: 's' },
|
||||
'beta:.tasks/STATUS.md': { content: BETA_BOARD, sha: 's' },
|
||||
});
|
||||
const r = await call(makeTools(backend), 'tasks.list_blocked', {});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
const cheap = parsed.tasks.find((t: { slug: string }) => t.slug === 'blocked-cheap');
|
||||
expect(cheap.blocker).toBe('dep-a, dep-b');
|
||||
expect(cheap.weight).toBe('cheap-ok');
|
||||
expect(cheap.project).toBe('OpeItcLoc03/alpha');
|
||||
const claude = parsed.tasks.find((t: { slug: string }) => t.slug === 'blocked-claude');
|
||||
expect(claude.blocker).toBe('dep-c');
|
||||
expect(claude.weight).toBe('needs-claude');
|
||||
});
|
||||
|
||||
it('respects project_allowlist — only returns tasks from listed projects', async () => {
|
||||
const { backend } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ALPHA_BOARD, sha: 's' },
|
||||
'beta:.tasks/STATUS.md': { content: BETA_BOARD, sha: 's' },
|
||||
});
|
||||
const r = await call(makeTools(backend), 'tasks.list_blocked', {
|
||||
project_allowlist: ['OpeItcLoc03/beta'],
|
||||
});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.tasks.every((t: { project: string }) => t.project === 'OpeItcLoc03/beta')).toBe(true);
|
||||
expect(parsed.tasks.map((t: { slug: string }) => t.slug)).toEqual(['blocked-beta']);
|
||||
});
|
||||
|
||||
it('returns empty tasks when cache is missing', async () => {
|
||||
const { backend } = recordingBackend({});
|
||||
const tools = makeTasksTools({ cacheFile: '/nonexistent/tasks.json', backend, giteaUser: 'u', agendaTasksRepo: 'projects-tasks' });
|
||||
const t = tools.find((x) => x.name === 'tasks.list_blocked')!;
|
||||
const r = await t.handler({});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.tasks).toEqual([]);
|
||||
expect(parsed.cache_missing).toBe(true);
|
||||
});
|
||||
|
||||
it('skips project when STATUS.md not found in backend (graceful degradation)', async () => {
|
||||
const { backend } = recordingBackend({
|
||||
'beta:.tasks/STATUS.md': { content: BETA_BOARD, sha: 's' },
|
||||
// alpha STATUS.md is missing
|
||||
});
|
||||
const r = await call(makeTools(backend), 'tasks.list_blocked', {});
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.ok).toBe(true);
|
||||
const slugs = parsed.tasks.map((t: { slug: string }) => t.slug);
|
||||
expect(slugs).toContain('blocked-beta');
|
||||
expect(slugs).not.toContain('blocked-cheap');
|
||||
});
|
||||
});
|
||||
174
lib/projects-meta-mcp/tests/tools/tasks-park-question.test.ts
Normal file
174
lib/projects-meta-mcp/tests/tools/tasks-park-question.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtemp } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { writeCache, type CacheFile } from '../../src/lib/cache.js';
|
||||
import { makeTasksTools } from '../../src/tools/tasks.js';
|
||||
import type { Backend, CommitFileArgs, CommitResult, FileWithSha } from '../../src/lib/backend.js';
|
||||
|
||||
function recordingBackend(initial: Record<string, FileWithSha> = {}) {
|
||||
const files = { ...initial };
|
||||
const commits: CommitFileArgs[] = [];
|
||||
const backend: Backend = {
|
||||
async listUserRepos() {
|
||||
return [];
|
||||
},
|
||||
async getRawFile(_u, repo, path) {
|
||||
return files[`${repo}:${path}`]?.content ?? null;
|
||||
},
|
||||
async getFileWithSha(_u, repo, path) {
|
||||
return files[`${repo}:${path}`] ?? null;
|
||||
},
|
||||
async commitFile(args: CommitFileArgs): Promise<CommitResult> {
|
||||
commits.push({ ...args });
|
||||
const fileSha = `sha-${commits.length}`;
|
||||
files[`${args.repo}:${args.path}`] = { content: args.content, sha: fileSha };
|
||||
return { fileSha, commitSha: `commit-${commits.length}` };
|
||||
},
|
||||
};
|
||||
return { backend, commits, files };
|
||||
}
|
||||
|
||||
const fixture: CacheFile = {
|
||||
schemaVersion: 3,
|
||||
synced_at: '2026-06-08T12:00:00.000Z',
|
||||
synced_from: 'https://g',
|
||||
machine: 'm',
|
||||
projects: [
|
||||
{
|
||||
name: 'OpeItcLoc03/alpha',
|
||||
default_branch: 'main',
|
||||
fetched_at: '2026-06-08T12:00:01.000Z',
|
||||
active_tasks: [],
|
||||
all_tasks_count: 0,
|
||||
raw: '',
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
const ACTIVE_BOARD =
|
||||
'# Task Board\n_Updated: 2026-06-08_\n\n## 🔴 [my-task] — A thing\n\n' +
|
||||
'**Status:** active\n**Where I stopped:** mid-flight\n**Next action:** keep going\n' +
|
||||
'**Branch:** master\n**Owner:** host:claude-opus:1\n**Claim token:** tok-123\n\n---\n';
|
||||
|
||||
let cacheFile: string;
|
||||
const fixedNow = () => new Date('2026-06-08T10:00:00.000Z');
|
||||
|
||||
beforeEach(async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'park-tool-'));
|
||||
cacheFile = join(dir, 'tasks.json');
|
||||
await writeCache(cacheFile, fixture);
|
||||
});
|
||||
|
||||
function makeTools(backend: Backend) {
|
||||
return makeTasksTools({
|
||||
cacheFile,
|
||||
backend,
|
||||
giteaUser: 'u',
|
||||
agendaTasksRepo: 'projects-tasks',
|
||||
machine: 'host-x',
|
||||
now: fixedNow,
|
||||
});
|
||||
}
|
||||
|
||||
function call(tools: ReturnType<typeof makeTasksTools>, name: string, args: Record<string, unknown>) {
|
||||
const t = tools.find((x) => x.name === name);
|
||||
if (!t) throw new Error(`tool ${name} not registered`);
|
||||
return t.handler(args);
|
||||
}
|
||||
|
||||
const base = {
|
||||
target_project: 'OpeItcLoc03/alpha',
|
||||
slug: 'my-task',
|
||||
question: 'Should we drop the legacy column? (irreversible)',
|
||||
};
|
||||
|
||||
describe('tasks.park_question', () => {
|
||||
it('is registered', () => {
|
||||
const { backend } = recordingBackend();
|
||||
expect(makeTools(backend).find((t) => t.name === 'tasks.park_question')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('flips status to blocked and writes the pending question (atomic, single commit)', async () => {
|
||||
const { backend, commits } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ACTIVE_BOARD, sha: 'old' },
|
||||
});
|
||||
const tools = makeTools(backend);
|
||||
const r = await call(tools, 'tasks.park_question', base);
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
|
||||
expect(parsed.parked).toBe(true);
|
||||
expect(commits).toHaveLength(1);
|
||||
expect(commits[0].path).toBe('.tasks/STATUS.md');
|
||||
expect(commits[0].content).toContain('## 🔵 [my-task]');
|
||||
expect(commits[0].content).toContain('**Status:** blocked');
|
||||
expect(commits[0].content).toContain('Should we drop the legacy column?');
|
||||
});
|
||||
|
||||
it('commits straight (no confirm gate — infra mutation)', async () => {
|
||||
const { backend, commits } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ACTIVE_BOARD, sha: 'old' },
|
||||
});
|
||||
const tools = makeTools(backend);
|
||||
await call(tools, 'tasks.park_question', base); // no confirm flag
|
||||
expect(commits).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('with a matching claim_token, parks (token-gated like heartbeat)', async () => {
|
||||
const { backend, commits } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ACTIVE_BOARD, sha: 'old' },
|
||||
});
|
||||
const tools = makeTools(backend);
|
||||
const r = await call(tools, 'tasks.park_question', { ...base, claim_token: 'tok-123' });
|
||||
expect(JSON.parse(r.content[0].text).parked).toBe(true);
|
||||
expect(commits).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects a claim_token mismatch without committing (someone else holds the claim)', async () => {
|
||||
const { backend, commits } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ACTIVE_BOARD, sha: 'old' },
|
||||
});
|
||||
const tools = makeTools(backend);
|
||||
const r = await call(tools, 'tasks.park_question', { ...base, claim_token: 'WRONG' });
|
||||
const parsed = JSON.parse(r.content[0].text);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.reason).toBe('token-mismatch');
|
||||
expect(commits).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('errors when the task is not on the board', async () => {
|
||||
const { backend } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: ACTIVE_BOARD, sha: 'old' },
|
||||
});
|
||||
const tools = makeTools(backend);
|
||||
const r = await call(tools, 'tasks.park_question', { ...base, slug: 'ghost' });
|
||||
expect(r.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('is disabled without a backend', async () => {
|
||||
const tools = makeTasksTools({ cacheFile });
|
||||
const r = await call(tools, 'tasks.park_question', base);
|
||||
expect(r.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('calls inboxWriter with event:blocked when task has Notify field', async () => {
|
||||
const boardWithNotify =
|
||||
'# Task Board\n_Updated: 2026-06-08_\n\n## 🔴 [my-task] — A thing\n\n' +
|
||||
'**Status:** active\n**Where I stopped:** mid-flight\n**Next action:** keep going\n' +
|
||||
'**Branch:** master\n**Owner:** host:claude-opus:1\n**Claim token:** tok-123\n' +
|
||||
'**Notify:** OpeItcLoc03/workshop\n\n---\n';
|
||||
const { backend } = recordingBackend({
|
||||
'alpha:.tasks/STATUS.md': { content: boardWithNotify, sha: 'old' },
|
||||
});
|
||||
const inboxCalls: unknown[] = [];
|
||||
const inboxWriter = async (args: unknown) => { inboxCalls.push(args); };
|
||||
const tools = makeTasksTools({ cacheFile, backend, giteaUser: 'u', agendaTasksRepo: 'pt', now: fixedNow, inboxWriter });
|
||||
await call(tools, 'tasks.park_question', base);
|
||||
|
||||
expect(inboxCalls).toHaveLength(1);
|
||||
expect((inboxCalls[0] as Record<string, string>).event).toBe('blocked');
|
||||
expect((inboxCalls[0] as Record<string, string>).notify).toBe('OpeItcLoc03/workshop');
|
||||
expect((inboxCalls[0] as Record<string, string>).slug).toBe('my-task');
|
||||
});
|
||||
});
|
||||
1473
lib/projects-meta-mcp/tests/tools/tasks.test.ts
Normal file
1473
lib/projects-meta-mcp/tests/tools/tasks.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
49
lib/projects-meta-mcp/tests/tools/workers.test.ts
Normal file
49
lib/projects-meta-mcp/tests/tools/workers.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { makeWorkersTools } from '../../src/tools/workers.js';
|
||||
|
||||
describe('worker.register (stub)', () => {
|
||||
const tool = makeWorkersTools().find((t) => t.name === 'worker.register')!;
|
||||
|
||||
it('exists and documents itself as a stub', () => {
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool.description.toLowerCase()).toContain('stub');
|
||||
});
|
||||
|
||||
it('returns a 501 not-implemented verdict for valid input', async () => {
|
||||
const r = await tool.handler({
|
||||
machine: 'win11',
|
||||
runtime: 'claude-opus',
|
||||
capabilities: ['needs-db'],
|
||||
});
|
||||
expect(r.isError).toBe(true);
|
||||
const body = JSON.parse(r.content[0].text);
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.reason).toBe('not-implemented');
|
||||
expect(typeof body.hint).toBe('string');
|
||||
expect(body.hint.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('keeps the verdict for optional endpoint + confirm (no side-effects)', async () => {
|
||||
const r = await tool.handler({
|
||||
machine: 'win11',
|
||||
runtime: 'claude-opus',
|
||||
capabilities: [],
|
||||
endpoint: 'https://node/agent',
|
||||
confirm: true,
|
||||
});
|
||||
const body = JSON.parse(r.content[0].text);
|
||||
expect(body.reason).toBe('not-implemented');
|
||||
});
|
||||
|
||||
it('rejects malformed input — missing machine', async () => {
|
||||
await expect(
|
||||
tool.handler({ runtime: 'claude-opus', capabilities: [] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects malformed input — capabilities not an array', async () => {
|
||||
await expect(
|
||||
tool.handler({ machine: 'm', runtime: 'r', capabilities: 'nope' }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
19
lib/projects-meta-mcp/tsconfig.json
Normal file
19
lib/projects-meta-mcp/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2023"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules", "tests"]
|
||||
}
|
||||
8
lib/projects-meta-mcp/vitest.config.ts
Normal file
8
lib/projects-meta-mcp/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
10
lib/wiki-graph/.gitignore
vendored
Normal file
10
lib/wiki-graph/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
2578
lib/wiki-graph/package-lock.json
generated
Normal file
2578
lib/wiki-graph/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
lib/wiki-graph/package.json
Normal file
27
lib/wiki-graph/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "wiki-graph",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"wiki-graph-mcp": "dist/server.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
82
lib/wiki-graph/scripts/smoke.mjs
Normal file
82
lib/wiki-graph/scripts/smoke.mjs
Normal file
@@ -0,0 +1,82 @@
|
||||
// Smoke test: drive the built MCP server over stdio against a live .wiki corpus.
|
||||
// Usage: node scripts/smoke.mjs <corpusDir> [from] [to] [hubNode]
|
||||
import { spawn } from 'node:child_process';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const serverPath = join(here, '..', 'dist', 'server.js');
|
||||
|
||||
const corpus = process.argv[2];
|
||||
const from = process.argv[3] ?? 'alm-pamelas-pro-workout';
|
||||
const to = process.argv[4] ?? 'euclidean-rhythms';
|
||||
const hub = process.argv[5] ?? 'euclidean-rhythms';
|
||||
if (!corpus) {
|
||||
console.error('usage: node scripts/smoke.mjs <corpusDir> [from] [to] [hubNode]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const child = spawn('node', [serverPath], { stdio: ['pipe', 'pipe', 'inherit'] });
|
||||
let buf = '';
|
||||
const pending = new Map();
|
||||
child.stdout.on('data', (d) => {
|
||||
buf += d.toString();
|
||||
let nl;
|
||||
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.id && pending.has(msg.id)) {
|
||||
pending.get(msg.id)(msg);
|
||||
pending.delete(msg.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let nextId = 1;
|
||||
function rpc(method, params) {
|
||||
const id = nextId++;
|
||||
return new Promise((resolve) => {
|
||||
pending.set(id, resolve);
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
|
||||
});
|
||||
}
|
||||
function notify(method, params) {
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
|
||||
}
|
||||
async function call(name, args) {
|
||||
const t0 = performance.now();
|
||||
const res = await rpc('tools/call', { name, arguments: { corpus, ...args } });
|
||||
const ms = (performance.now() - t0).toFixed(1);
|
||||
return { text: res.result.content.map((c) => c.text).join('\n'), ms };
|
||||
}
|
||||
|
||||
const init = await rpc('initialize', {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'wg-smoke', version: '0' },
|
||||
});
|
||||
notify('notifications/initialized', {});
|
||||
console.log(`server: ${init.result.serverInfo.name} ${init.result.serverInfo.version}`);
|
||||
console.log(`corpus: ${corpus}\n`);
|
||||
|
||||
const cold = await call('stats', {});
|
||||
console.log(`stats [cold ${cold.ms}ms] ${cold.text}`);
|
||||
const warm = await call('stats', {});
|
||||
console.log(`stats [warm ${warm.ms}ms] ${warm.text}`);
|
||||
|
||||
const p = await call('path', { from, to });
|
||||
console.log(`path [${p.ms}ms] ${from} → ${to}\n ${p.text}`);
|
||||
|
||||
const n = await call('neighbors', { node: hub, depth: 1 });
|
||||
console.log(`neighbrs[${n.ms}ms] ${n.text}`);
|
||||
|
||||
const b = await call('backlinks', { node: hub });
|
||||
console.log(`backlnks[${b.ms}ms] ${b.text}`);
|
||||
|
||||
const o = await call('orphans', {});
|
||||
console.log(`orphans [${o.ms}ms] ${o.text.split('\n')[0]} ...`);
|
||||
|
||||
child.stdin.end();
|
||||
child.kill();
|
||||
60
lib/wiki-graph/src/cache.ts
Normal file
60
lib/wiki-graph/src/cache.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { extractLinks } from './parser.js';
|
||||
import { listPages, readPageContent, type ListOptions } from './corpus.js';
|
||||
import { buildGraphFromParsed, WikiGraph, type ParsedPage } from './graph.js';
|
||||
|
||||
interface CacheEntry {
|
||||
mtimeMs: number;
|
||||
page: ParsedPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* mtime-revalidated graph cache.
|
||||
* First `load` builds the whole corpus; later loads `stat` files and re-read +
|
||||
* re-parse only the ones whose mtime changed (or that appeared). Removed files
|
||||
* drop out. No on-disk index, always fresh.
|
||||
*/
|
||||
export class GraphCache {
|
||||
private entries = new Map<string, CacheEntry>();
|
||||
private graph: WikiGraph | null = null;
|
||||
private signature: string | null = null;
|
||||
private readonly opts: ListOptions;
|
||||
|
||||
/** Count of file reads performed across this cache's lifetime (for tests/metrics). */
|
||||
reads = 0;
|
||||
|
||||
constructor(opts: ListOptions = {}) {
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
load(corpusDir: string): WikiGraph {
|
||||
const files = listPages(corpusDir, this.opts);
|
||||
const signature = files
|
||||
.map((f) => `${f.path}:${f.mtimeMs}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
|
||||
if (this.graph && this.signature === signature) return this.graph;
|
||||
|
||||
const live = new Set<string>();
|
||||
for (const f of files) {
|
||||
live.add(f.path);
|
||||
const cached = this.entries.get(f.path);
|
||||
if (cached && cached.mtimeMs === f.mtimeMs) continue; // unchanged → keep parsed
|
||||
const content = readPageContent(f);
|
||||
this.reads++;
|
||||
this.entries.set(f.path, {
|
||||
mtimeMs: f.mtimeMs,
|
||||
page: { slug: f.slug, dir: f.dir, links: extractLinks(content) },
|
||||
});
|
||||
}
|
||||
// drop deleted files
|
||||
for (const path of [...this.entries.keys()]) {
|
||||
if (!live.has(path)) this.entries.delete(path);
|
||||
}
|
||||
|
||||
const pages = [...this.entries.values()].map((e) => e.page);
|
||||
this.graph = buildGraphFromParsed(pages);
|
||||
this.signature = signature;
|
||||
return this.graph;
|
||||
}
|
||||
}
|
||||
59
lib/wiki-graph/src/corpus.ts
Normal file
59
lib/wiki-graph/src/corpus.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { readdirSync, statSync, readFileSync } from 'node:fs';
|
||||
import { join, dirname, basename } from 'node:path';
|
||||
|
||||
export interface PageFile {
|
||||
/** Page basename without `.md`, original case. */
|
||||
slug: string;
|
||||
/** Directory relative to corpus root, '' for root-level pages. POSIX-ish, using OS sep. */
|
||||
dir: string;
|
||||
/** Absolute path on disk. */
|
||||
path: string;
|
||||
/** Last-modified time in ms (for cache revalidation). */
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level dirs excluded from the graph by default. In the Karpathy wiki schema
|
||||
* `raw/` (verbatim ingested docs) and `sources/` (provenance summaries) are NOT
|
||||
* graph pages — they share basenames with canonical concept/entity pages and would
|
||||
* collapse into them (false collisions) and inject provenance edges. `assets/` is
|
||||
* binary. The graph models the canonical concept/entity network only.
|
||||
*/
|
||||
export const DEFAULT_EXCLUDE: readonly string[] = ['raw', 'sources', 'assets'];
|
||||
|
||||
export interface ListOptions {
|
||||
/** Top-level dir names to skip. Defaults to {@link DEFAULT_EXCLUDE}; pass `[]` to index everything. */
|
||||
exclude?: readonly string[];
|
||||
}
|
||||
|
||||
function firstSegment(dir: string): string {
|
||||
if (dir === '') return '';
|
||||
return dir.split(/[\\/]/)[0];
|
||||
}
|
||||
|
||||
/** List every `.md` file under `corpusDir` (recursive, minus excluded dirs), with mtime. */
|
||||
export function listPages(corpusDir: string, opts: ListOptions = {}): PageFile[] {
|
||||
const exclude = new Set(opts.exclude ?? DEFAULT_EXCLUDE);
|
||||
const entries = readdirSync(corpusDir, { recursive: true }) as string[];
|
||||
const pages: PageFile[] = [];
|
||||
for (const rel of entries) {
|
||||
if (!rel.endsWith('.md')) continue;
|
||||
const dirRaw = dirname(rel);
|
||||
const dir = dirRaw === '.' ? '' : dirRaw;
|
||||
if (exclude.has(firstSegment(dir))) continue;
|
||||
const abs = join(corpusDir, rel);
|
||||
const st = statSync(abs);
|
||||
if (!st.isFile()) continue;
|
||||
pages.push({
|
||||
slug: basename(rel, '.md'),
|
||||
dir,
|
||||
path: abs,
|
||||
mtimeMs: st.mtimeMs,
|
||||
});
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
export function readPageContent(page: PageFile): string {
|
||||
return readFileSync(page.path, 'utf8');
|
||||
}
|
||||
221
lib/wiki-graph/src/graph.ts
Normal file
221
lib/wiki-graph/src/graph.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { extractLinks, type WikiLink } from './parser.js';
|
||||
import { buildSlugIndex, resolveTarget, type FileRef } from './resolver.js';
|
||||
import { listPages, readPageContent, type PageFile, type ListOptions } from './corpus.js';
|
||||
|
||||
export interface ParsedPage {
|
||||
slug: string;
|
||||
dir: string;
|
||||
links: WikiLink[];
|
||||
}
|
||||
|
||||
export interface GraphStats {
|
||||
nodes: number;
|
||||
edges: number;
|
||||
/** Connected components over the undirected projection. */
|
||||
components: number;
|
||||
/** Number of distinct unresolved link targets. */
|
||||
dangling: number;
|
||||
/** Number of basename slugs claimed by more than one file. */
|
||||
collisions: number;
|
||||
}
|
||||
|
||||
export interface OrphanReport {
|
||||
/** Nodes with no incoming and no outgoing edges, sorted. */
|
||||
pages: string[];
|
||||
/** Distinct unresolved link targets, sorted. */
|
||||
dangling: string[];
|
||||
}
|
||||
|
||||
interface BuiltGraph {
|
||||
nodes: Set<string>;
|
||||
out: Map<string, Set<string>>;
|
||||
in: Map<string, Set<string>>;
|
||||
dangling: Set<string>;
|
||||
collisions: number;
|
||||
}
|
||||
|
||||
/** In-memory wiki link graph. Node ids are lowercased slugs. */
|
||||
export class WikiGraph {
|
||||
private readonly nodes: Set<string>;
|
||||
private readonly out: Map<string, Set<string>>;
|
||||
private readonly in: Map<string, Set<string>>;
|
||||
private readonly danglingTargets: Set<string>;
|
||||
private readonly collisionCount: number;
|
||||
|
||||
constructor(g: BuiltGraph) {
|
||||
this.nodes = g.nodes;
|
||||
this.out = g.out;
|
||||
this.in = g.in;
|
||||
this.danglingTargets = g.dangling;
|
||||
this.collisionCount = g.collisions;
|
||||
}
|
||||
|
||||
/** Shortest chain between two pages over the undirected projection; [] if none. */
|
||||
path(from: string, to: string): string[] {
|
||||
const start = from.toLowerCase();
|
||||
const goal = to.toLowerCase();
|
||||
if (!this.nodes.has(start) || !this.nodes.has(goal)) return [];
|
||||
if (start === goal) return [start];
|
||||
|
||||
const prev = new Map<string, string | null>([[start, null]]);
|
||||
const queue: string[] = [start];
|
||||
while (queue.length > 0) {
|
||||
const cur = queue.shift()!;
|
||||
if (cur === goal) break;
|
||||
for (const next of this.undirectedNeighbors(cur)) {
|
||||
if (!prev.has(next)) {
|
||||
prev.set(next, cur);
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prev.has(goal)) return [];
|
||||
const chain: string[] = [];
|
||||
let node: string | null = goal;
|
||||
while (node !== null) {
|
||||
chain.unshift(node);
|
||||
node = prev.get(node) ?? null;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
/** Pages reachable via outgoing edges within `depth` hops, sorted. */
|
||||
neighbors(node: string, depth = 1): string[] {
|
||||
const start = node.toLowerCase();
|
||||
if (!this.nodes.has(start)) return [];
|
||||
const seen = new Set<string>([start]);
|
||||
let frontier = [start];
|
||||
for (let d = 0; d < depth; d++) {
|
||||
const nextFrontier: string[] = [];
|
||||
for (const cur of frontier) {
|
||||
for (const next of this.out.get(cur) ?? []) {
|
||||
if (!seen.has(next)) {
|
||||
seen.add(next);
|
||||
nextFrontier.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
frontier = nextFrontier;
|
||||
}
|
||||
seen.delete(start);
|
||||
return [...seen].sort();
|
||||
}
|
||||
|
||||
/** Pages that link TO `node`, sorted. */
|
||||
backlinks(node: string): string[] {
|
||||
const key = node.toLowerCase();
|
||||
return [...(this.in.get(key) ?? [])].sort();
|
||||
}
|
||||
|
||||
orphans(): OrphanReport {
|
||||
const pages: string[] = [];
|
||||
for (const n of this.nodes) {
|
||||
const outDeg = this.out.get(n)?.size ?? 0;
|
||||
const inDeg = this.in.get(n)?.size ?? 0;
|
||||
if (outDeg === 0 && inDeg === 0) pages.push(n);
|
||||
}
|
||||
return {
|
||||
pages: pages.sort(),
|
||||
dangling: [...this.danglingTargets].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
stats(): GraphStats {
|
||||
let edges = 0;
|
||||
for (const set of this.out.values()) edges += set.size;
|
||||
return {
|
||||
nodes: this.nodes.size,
|
||||
edges,
|
||||
components: this.countComponents(),
|
||||
dangling: this.danglingTargets.size,
|
||||
collisions: this.collisionCount,
|
||||
};
|
||||
}
|
||||
|
||||
private undirectedNeighbors(node: string): Set<string> {
|
||||
const acc = new Set<string>();
|
||||
for (const n of this.out.get(node) ?? []) acc.add(n);
|
||||
for (const n of this.in.get(node) ?? []) acc.add(n);
|
||||
return acc;
|
||||
}
|
||||
|
||||
private countComponents(): number {
|
||||
const seen = new Set<string>();
|
||||
let count = 0;
|
||||
for (const start of this.nodes) {
|
||||
if (seen.has(start)) continue;
|
||||
count++;
|
||||
const stack = [start];
|
||||
seen.add(start);
|
||||
while (stack.length > 0) {
|
||||
const cur = stack.pop()!;
|
||||
for (const next of this.undirectedNeighbors(cur)) {
|
||||
if (!seen.has(next)) {
|
||||
seen.add(next);
|
||||
stack.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a graph from already-parsed pages (no disk, no parsing). */
|
||||
export function buildGraphFromParsed(pages: ParsedPage[]): WikiGraph {
|
||||
const refs: FileRef[] = pages.map((p) => ({ slug: p.slug, dir: p.dir }));
|
||||
const index = buildSlugIndex(refs);
|
||||
|
||||
let collisions = 0;
|
||||
for (const bucket of index.values()) if (bucket.length > 1) collisions++;
|
||||
|
||||
const nodes = new Set<string>();
|
||||
const out = new Map<string, Set<string>>();
|
||||
const inAdj = new Map<string, Set<string>>();
|
||||
const dangling = new Set<string>();
|
||||
|
||||
for (const p of pages) {
|
||||
const node = p.slug.toLowerCase();
|
||||
nodes.add(node);
|
||||
if (!out.has(node)) out.set(node, new Set());
|
||||
if (!inAdj.has(node)) inAdj.set(node, new Set());
|
||||
}
|
||||
|
||||
for (const p of pages) {
|
||||
const node = p.slug.toLowerCase();
|
||||
for (const link of p.links) {
|
||||
const resolved = resolveTarget(link.target, p.dir, index);
|
||||
if (resolved === null) {
|
||||
// resolution is case-insensitive, so dedupe dangling on the same key —
|
||||
// [[Foo]] and [[foo]] are the one missing page, not two
|
||||
dangling.add(link.target.toLowerCase());
|
||||
continue;
|
||||
}
|
||||
if (resolved.node === node) continue; // skip self-links
|
||||
out.get(node)!.add(resolved.node);
|
||||
inAdj.get(resolved.node)!.add(node);
|
||||
}
|
||||
}
|
||||
|
||||
return new WikiGraph({ nodes, out, in: inAdj, dangling, collisions });
|
||||
}
|
||||
|
||||
/** Build a graph from already-read pages (parses content, no disk access). */
|
||||
export function buildGraph(
|
||||
pages: Array<{ slug: string; dir: string; content: string }>,
|
||||
): WikiGraph {
|
||||
return buildGraphFromParsed(
|
||||
pages.map((p) => ({ slug: p.slug, dir: p.dir, links: extractLinks(p.content) })),
|
||||
);
|
||||
}
|
||||
|
||||
/** Read a `.wiki/` corpus from disk and build the graph. Provenance dirs excluded by default. */
|
||||
export function buildGraphFromDir(corpusDir: string, opts: ListOptions = {}): WikiGraph {
|
||||
const files: PageFile[] = listPages(corpusDir, opts);
|
||||
const pages = files.map((f) => ({
|
||||
slug: f.slug,
|
||||
dir: f.dir,
|
||||
content: readPageContent(f),
|
||||
}));
|
||||
return buildGraph(pages);
|
||||
}
|
||||
25
lib/wiki-graph/src/index.ts
Normal file
25
lib/wiki-graph/src/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export { extractLinks, type WikiLink } from './parser.js';
|
||||
export {
|
||||
buildSlugIndex,
|
||||
resolveTarget,
|
||||
type FileRef,
|
||||
type ResolveResult,
|
||||
type SlugIndex,
|
||||
} from './resolver.js';
|
||||
export {
|
||||
listPages,
|
||||
readPageContent,
|
||||
DEFAULT_EXCLUDE,
|
||||
type PageFile,
|
||||
type ListOptions,
|
||||
} from './corpus.js';
|
||||
export {
|
||||
WikiGraph,
|
||||
buildGraph,
|
||||
buildGraphFromParsed,
|
||||
buildGraphFromDir,
|
||||
type ParsedPage,
|
||||
type GraphStats,
|
||||
type OrphanReport,
|
||||
} from './graph.js';
|
||||
export { GraphCache } from './cache.js';
|
||||
30
lib/wiki-graph/src/parser.ts
Normal file
30
lib/wiki-graph/src/parser.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface WikiLink {
|
||||
/** Raw target slug as written inside [[...]], left of any `|`. */
|
||||
target: string;
|
||||
/** Display alias after `|`, or null when absent. */
|
||||
alias: string | null;
|
||||
}
|
||||
|
||||
const LINK_RE = /\[\[([^\]]+)\]\]/g;
|
||||
|
||||
/**
|
||||
* Extract `[[wikilinks]]` from page content.
|
||||
* `[[slug]]` → { target: 'slug', alias: null }.
|
||||
* `[[slug|Label]]` → { target: 'slug', alias: 'Label' }.
|
||||
*/
|
||||
export function extractLinks(content: string): WikiLink[] {
|
||||
const links: WikiLink[] = [];
|
||||
for (const match of content.matchAll(LINK_RE)) {
|
||||
const inner = match[1];
|
||||
const pipe = inner.indexOf('|');
|
||||
if (pipe === -1) {
|
||||
links.push({ target: inner.trim(), alias: null });
|
||||
} else {
|
||||
links.push({
|
||||
target: inner.slice(0, pipe).trim(),
|
||||
alias: inner.slice(pipe + 1).trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}
|
||||
49
lib/wiki-graph/src/resolver.ts
Normal file
49
lib/wiki-graph/src/resolver.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export interface FileRef {
|
||||
/** Page basename without `.md`, original case. */
|
||||
slug: string;
|
||||
/** Directory of the file relative to the corpus root (e.g. 'concepts'). */
|
||||
dir: string;
|
||||
}
|
||||
|
||||
export interface ResolveResult {
|
||||
/** Canonical node id — lowercased slug. */
|
||||
node: string;
|
||||
/** True when more than one file shares this basename slug. */
|
||||
collision: boolean;
|
||||
}
|
||||
|
||||
/** Index of lowercased-slug → all files claiming that slug. */
|
||||
export type SlugIndex = Map<string, FileRef[]>;
|
||||
|
||||
export function buildSlugIndex(refs: FileRef[]): SlugIndex {
|
||||
const index: SlugIndex = new Map();
|
||||
for (const ref of refs) {
|
||||
const key = ref.slug.toLowerCase();
|
||||
const bucket = index.get(key);
|
||||
if (bucket) bucket.push(ref);
|
||||
else index.set(key, [ref]);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `[[target]]` to a canonical node id.
|
||||
* - case-insensitive (slugs are lowercase-kebab by convention);
|
||||
* - unresolved targets (placeholders, links to uncreated pages) → null (dangling);
|
||||
* - basename collision: node id is the bare slug, so ALL files sharing a basename
|
||||
* collapse into ONE node — their edges merge and the pages become indistinguishable
|
||||
* in the graph. There is no file-pick (the v1 graph keys on slug, not path); `collision`
|
||||
* is the only signal. Acceptable while collisions are rare (0 on modulair); a
|
||||
* dir-qualified node id is a phase-2 decision. `_fromDir` is kept for that future
|
||||
* same-dir disambiguation. See wiki-graph-design.md §5.
|
||||
*/
|
||||
export function resolveTarget(
|
||||
target: string,
|
||||
_fromDir: string,
|
||||
index: SlugIndex,
|
||||
): ResolveResult | null {
|
||||
const key = target.trim().toLowerCase();
|
||||
const bucket = index.get(key);
|
||||
if (!bucket || bucket.length === 0) return null;
|
||||
return { node: key, collision: bucket.length > 1 };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user