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

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

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

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

View File

@@ -0,0 +1 @@
"""Interns MCP — cheap LLM interns for Claude Code."""

View 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]

View 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

View 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,
},
)

View 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)

View 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,
},
)

View 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,
},
)

View 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

View 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

View 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()

View 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"

View 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

View 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

View 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

View 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

View 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
View File

@@ -0,0 +1,11 @@
node_modules/
dist/
build/
*.log
.env
.env.local
auth.toml
.DS_Store
Thumbs.db
.vscode/
.idea/

View 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

View 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)

View 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"

View File

@@ -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-сервер вторым, эвристика промоушена третьим).

View File

@@ -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 (120) | 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View 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 });
});
});

View 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);
});
});

View 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']);
});
});

View File

@@ -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');
});
});

View 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,
});
});
});

View 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');
});
});

View 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/);
});
});
});

View 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);
});
});

View 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);
});
});

View 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');
});
});

View 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');
});
});

View 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');
});
});

View 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 });
});
});

View 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]');
});
});

View 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');
});
});

View 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');
});
});

View 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' },
]);
});
});

View 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([]);
});
});

View 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');
});
});

View 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');
});
});

View 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');
});
});

View 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');
});
});

View 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();
});
});

View File

@@ -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);
});
});

View 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({});
});
});

View 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);
});
});

View 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');
});
});

View 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');
});
});

File diff suppressed because it is too large Load Diff

View 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();
});
});

View 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"]
}

View 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
View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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();

View 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;
}
}

View 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
View 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);
}

View 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';

View 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;
}

View 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 };
}

View File

@@ -0,0 +1,55 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
type ServerResult,
} from '@modelcontextprotocol/sdk/types.js';
import { readFileSync } from 'node:fs';
import { GraphCache } from './cache.js';
import { makeWikiGraphTools } from './tools.js';
function readPackageVersion(): string {
const pkgUrl = new URL('../package.json', import.meta.url);
const pkg = JSON.parse(readFileSync(pkgUrl, 'utf8')) 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 cache = new GraphCache();
const tools = makeWikiGraphTools(cache);
const byName = new Map(tools.map((t) => [t.name, t]));
const server = new Server(
{ name: 'wiki-graph', 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): Promise<ServerResult> => {
const tool = byName.get(req.params.name);
if (!tool) {
return {
content: [{ type: 'text', text: `Unknown tool: ${req.params.name}` }],
isError: true,
};
}
// ToolResult is a structural CallToolResult; cast over the widened SDK union.
return tool.handler(req.params.arguments ?? {}) as ServerResult;
});
const transport = new StdioServerTransport();
await server.connect(transport);
}
await main();

166
lib/wiki-graph/src/tools.ts Normal file
View File

@@ -0,0 +1,166 @@
import { GraphCache } from './cache.js';
import type { WikiGraph } from './graph.js';
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[];
};
handler: (args: Record<string, unknown>) => ToolResult;
}
function text(s: string, isError = false): ToolResult {
return { content: [{ type: 'text', text: s }], isError };
}
const CORPUS_PROP = {
corpus: {
type: 'string',
description: 'Absolute path to the `.wiki/` corpus directory.',
},
};
/**
* Build the 5 v1 wiki-graph tools over a shared mtime cache.
* Each tool takes `corpus` (abs path to a `.wiki/`); the engine parses
* server-side, so the corpus never enters the LLM context.
*/
export function makeWikiGraphTools(cache: GraphCache): ToolDef[] {
function load(args: Record<string, unknown>): WikiGraph | ToolResult {
const corpus = args.corpus;
if (typeof corpus !== 'string' || corpus.length === 0) {
return text('Missing required "corpus" (absolute path to a .wiki/ directory).', true);
}
try {
return cache.load(corpus);
} catch (e) {
return text(`Failed to read corpus "${corpus}": ${(e as Error).message}`, true);
}
}
function str(args: Record<string, unknown>, key: string): string | null {
const v = args[key];
return typeof v === 'string' && v.length > 0 ? v : null;
}
return [
{
name: 'path',
description:
'Shortest chain of pages between two wiki pages (undirected). The main relational query — "what connects X and Y".',
inputSchema: {
type: 'object',
properties: {
...CORPUS_PROP,
from: { type: 'string', description: 'Start page slug.' },
to: { type: 'string', description: 'Target page slug.' },
},
required: ['corpus', 'from', 'to'],
},
handler: (args) => {
const g = load(args);
if ('content' in g) return g;
const from = str(args, 'from');
const to = str(args, 'to');
if (!from || !to) return text('path requires "from" and "to" slugs.', true);
const chain = g.path(from, to);
if (chain.length === 0) return text(`No path between "${from}" and "${to}".`);
return text(`Path (${chain.length} nodes): ${chain.join(' → ')}`);
},
},
{
name: 'neighbors',
description: 'Pages reachable via outgoing links within N hops (direction respected).',
inputSchema: {
type: 'object',
properties: {
...CORPUS_PROP,
node: { type: 'string', description: 'Page slug.' },
depth: { type: 'number', description: 'Hop radius (default 1).' },
},
required: ['corpus', 'node'],
},
handler: (args) => {
const g = load(args);
if ('content' in g) return g;
const node = str(args, 'node');
if (!node) return text('neighbors requires a "node" slug.', true);
const depthRaw = args.depth;
const depth =
typeof depthRaw === 'number' && Number.isInteger(depthRaw) && depthRaw >= 1
? depthRaw
: 1;
const list = g.neighbors(node, depth);
if (list.length === 0) {
return text(`"${node}" has no outgoing links within depth ${depth}.`);
}
return text(`Neighbors of "${node}" (depth ${depth}): ${list.join(', ')}`);
},
},
{
name: 'backlinks',
description: 'Pages that link TO this page (incoming references).',
inputSchema: {
type: 'object',
properties: {
...CORPUS_PROP,
node: { type: 'string', description: 'Page slug.' },
},
required: ['corpus', 'node'],
},
handler: (args) => {
const g = load(args);
if ('content' in g) return g;
const node = str(args, 'node');
if (!node) return text('backlinks requires a "node" slug.', true);
const list = g.backlinks(node);
if (list.length === 0) return text(`Nothing links to "${node}".`);
return text(`Backlinks of "${node}": ${list.join(', ')}`);
},
},
{
name: 'orphans',
description: 'Wiki health: pages with no links at all, plus dangling (unresolved) link targets.',
inputSchema: {
type: 'object',
properties: { ...CORPUS_PROP },
required: ['corpus'],
},
handler: (args) => {
const g = load(args);
if ('content' in g) return g;
const o = g.orphans();
return text(
`Orphan pages (${o.pages.length}): ${o.pages.join(', ') || '—'}\n` +
`Dangling links (${o.dangling.length}): ${o.dangling.join(', ') || '—'}`,
);
},
},
{
name: 'stats',
description: 'Corpus counters: nodes, edges, connected components, dangling links, basename collisions.',
inputSchema: {
type: 'object',
properties: { ...CORPUS_PROP },
required: ['corpus'],
},
handler: (args) => {
const g = load(args);
if ('content' in g) return g;
const s = g.stats();
return text(
`nodes=${s.nodes} edges=${s.edges} components=${s.components} ` +
`dangling=${s.dangling} collisions=${s.collisions}`,
);
},
},
];
}

Some files were not shown because too many files have changed in this diff Show More