diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 0e93f8d..5531520 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "foreman-supervisor", - "version": "0.2.0", + "version": "0.2.1", "description": "Semantic supervision for our own Claude agents. Wraps thruwire/foreman (MIT) — a runtime that watches a coding agent with a bounded observation (job, git status, diff, output tails, repository instructions), scores ten independent yes/no questions, and lets a deterministic Python policy decide continue / stop / retry / verify / finish / escalate. This plugin replaces both of upstream's paid dependencies: the worker is `claude -p` (Worker protocol, steer() returns False so the policy degrades to stop/retry, per upstream's own docs/workers.md), and the judge is the free-model gateway (free-worker.sh) instead of TypeSafe Jev, so a supervised run costs no paid tokens. Adds two things measured here rather than assumed: the observation carries commits since the run's base commit (our agents commit, which empties `git diff` exactly when the worker does the right thing), and warning dimensions stay muted until the factory has produced evidence (a small judge escalated a healthy run at 25 seconds, before the worker touched a file). Ships foreman-calibrate.py, which replays thresholds over the runs' own JSONL timelines — the calibration upstream says it has not done. v0.2.0 adds bin/foreman-attach.py: supervision of a background worker someone ELSE already started (worker.sh / `claude agents`), which upstream cannot do because its runtime launches the worker itself. The observation is rebuilt from traces the worker already leaves — its state.json, the commits in its worktree, the tail of its transcript — so nothing is injected into the worker. Measured here on a live worker: warning dimensions read stuck 0.80 / off-track 0.80 at 362 s and 0.05 / 0.10 at 766 s on the same run with no commit in between, which is why warmup and confirmation matter more than threshold tuning. Also records that upstream's policy gates STEER_WORKER on `codex_backend == \"app-server\"` — the name of another backend rather than whether the worker accepts direction — so a steerable third-party worker can only ever be killed; the adapter works around it by printing the steering message for the lead to deliver.", "author": { "name": "oleks", diff --git a/bin/foreman-attach.py b/bin/foreman-attach.py index 0a75d88..31017cd 100755 --- a/bin/foreman-attach.py +++ b/bin/foreman-attach.py @@ -126,7 +126,39 @@ def repo_instructions(repo: Path) -> tuple[str | None, str]: return None, "" -def build_observation(short_id: str, state: dict, base: str) -> FactoryObservation: +def read_briefs(paths: list[str], repo: Path, budget: int = 12_000) -> str: + """ТЗ проекта для судьи: чем «хорошо» отличается от «плохо» ЗДЕСЬ, а не вообще. + + Зачем: наблюдение несло только промпт воркера и первые 4 КБ CLAUDE.md репозитория. Судья видел, + ЧТО поручено, но не видел, ЗАЧЕМ и по какому канону это принимается, — и «работа в рамках + задания» оценивалась вслепую (слово Антона 2026-09-20: «ознакомь надзирателя, над чем мы + работаем»). Пути берём как есть или относительно рабочего дерева воркера; каждый файл + урезается по бюджету, чтобы наблюдение осталось в пределах окна бесплатной модели. + """ + if not paths: + return "" + share = max(1_000, budget // len(paths)) + parts: list[str] = [] + for raw in paths: + f = Path(raw) + if not f.is_absolute() and not f.exists(): + f = repo / raw + if not f.exists(): + parts.append("=== %s === (файла нет — пропущен)" % raw) + continue + try: + text = f.read_text(encoding="utf-8", errors="replace") + except OSError as e: + parts.append("=== %s === (не читается: %s)" % (raw, e)) + continue + cut = text[:share] + if len(text) > share: + cut += "\n… (урезано, всего %d символов)" % len(text) + parts.append("=== %s ===\n%s" % (f, cut)) + return "\n\n".join(parts) + + +def build_observation(short_id: str, state: dict, base: str, briefs: str = "") -> FactoryObservation: repo = Path(state.get("worktreePath") or state.get("cwd") or ".") status = str(state.get("state", "unknown")) started = state.get("createdAt") or "" @@ -149,7 +181,9 @@ def build_observation(short_id: str, state: dict, base: str) -> FactoryObservati "committed_diffstat": diffstat, } return FactoryObservation( - original_job=str(state.get("intent") or state.get("name") or "")[:6_000], + original_job=(str(state.get("intent") or state.get("name") or "")[:6_000] + + ("\n\n=== ТЕХНИЧЕСКОЕ ЗАДАНИЕ ПРОЕКТА (канон, по которому работа принимается) ===\n" + + briefs if briefs else "")), run_id="attach-%s" % short_id, factory_status=status, iteration=1, @@ -230,11 +264,11 @@ def decide(scores: dict, warmup_ok: bool) -> tuple[str, str]: def assess(worker_id: str, base: str, channel: str | None, attempts: int, - warmup_seconds: float) -> tuple[dict, str, str, dict, bool]: + warmup_seconds: float, briefs: list[str] | None = None) -> tuple[dict, str, str, dict, bool]: """Один замер: наблюдение → бесплатный судья → решение. Возвращает (state, decision, why, scores, warmup_ok).""" state = read_state(worker_id) - obs = build_observation(worker_id, state, base) repo = Path(state.get("worktreePath") or state.get("cwd") or ".") + obs = build_observation(worker_id, state, base, read_briefs(briefs or [], repo)) workers = obs.active_workers or obs.worker_history has_commits = bool(workers and str(workers[0].get("commits_since_base", "")).strip()) warmup_ok = has_commits or obs.elapsed_factory_seconds >= warmup_seconds @@ -256,7 +290,7 @@ def watch(args: argparse.Namespace) -> int: try: state, decision, why, scores, warmup_ok = assess( args.worker_id, args.base, args.judge_channel, - args.judge_attempts, args.warmup_seconds, + args.judge_attempts, args.warmup_seconds, args.brief, ) except SystemExit: raise @@ -309,6 +343,9 @@ def main(argv: list[str] | None = None) -> int: ap.add_argument("worker_id", help="короткий id воркера, как в `claude agents`") ap.add_argument("--base", default="origin/main", help="точка отсчёта коммитов воркера (по умолчанию origin/main)") + ap.add_argument("--brief", action="append", default=[], metavar="ФАЙЛ", + help="файл ТЗ/канона проекта в наблюдение судьи (повторяемый): спека, docs/LOOK.md, " + "тело issue. Путь абсолютный или относительно рабочего дерева воркера") ap.add_argument("--judge-channel", default=None, help="принудительный канал free-worker.sh") ap.add_argument("--judge-attempts", type=int, default=3) ap.add_argument("--warmup-seconds", type=float, default=420.0, @@ -329,7 +366,8 @@ def main(argv: list[str] | None = None) -> int: return watch(args) state = read_state(args.worker_id) - obs = build_observation(args.worker_id, state, args.base) + _repo = Path(state.get("worktreePath") or state.get("cwd") or ".") + obs = build_observation(args.worker_id, state, args.base, read_briefs(args.brief, _repo)) elapsed = obs.elapsed_factory_seconds has_commits = bool( diff --git a/skills/supervise-agent/SKILL.md b/skills/supervise-agent/SKILL.md index da3815c..d2448a6 100644 --- a/skills/supervise-agent/SKILL.md +++ b/skills/supervise-agent/SKILL.md @@ -33,10 +33,18 @@ description: "Поставить надзирателя над агентом, ```bash ~/pro/foreman/.venv/bin/python "$CLAUDE_PLUGIN_ROOT/bin/foreman-attach.py" \ + --brief docs/LOOK.md --brief specs/<фича>/spec.md \ --watch 300 --confirm-warnings # присмотр в цикле, пока воркер жив ~/pro/foreman/.venv/bin/python "$CLAUDE_PLUGIN_ROOT/bin/foreman-attach.py" # одна оценка ``` +**`--brief` — дай судье ТЗ, а не только промпт воркера** (повторяемый; путь абсолютный или +относительно рабочего дерева воркера). Без него судья видит задание воркера и первые 4 КБ +`CLAUDE.md` репозитория — то есть ЧТО поручено, но не ЗАЧЕМ и по какому канону это принимается, +и «работа в рамках задания» оценивается вслепую. Замер на живом прогоне 2026-09-20: задание для +судьи 4.4 КБ → 14.3 КБ, когда в него вклеили `docs/LOOK.md`, `docs/AI_GUIDE.md` и тело issue. +Скармливай СУЩЕСТВУЮЩИЕ доки проекта, а не пересказ: канон внешнего вида, спеку, тело issue. + ⚠️ Только интерпретатором `~/pro/foreman/.venv/bin/python` — модуль `foreman` живёт там. Наблюдение строится из следов, которые воркер и так оставляет; в него ничего не внедряется: diff --git a/tests/test_attach_brief.py b/tests/test_attach_brief.py new file mode 100644 index 0000000..bc5b700 --- /dev/null +++ b/tests/test_attach_brief.py @@ -0,0 +1,56 @@ +"""Тесты флага --brief: ТЗ проекта должно доезжать до судьи, а не только промпт воркера. + +Зачем именно так: наблюдение несло `intent` воркера и первые 4 КБ CLAUDE.md репозитория, и судья +оценивал «в рамках ли задания» вслепую — без канона, по которому работа принимается +(слово Антона 2026-09-20). Тесты держат три свойства: файлы читаются и относительно рабочего +дерева воркера, бюджет делится между ними, отсутствующий файл не роняет присмотр. + + ~/pro/foreman/.venv/bin/python -m pytest tests/test_attach_brief.py -q +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +MODULE_PATH = Path(__file__).resolve().parents[1] / "bin" / "foreman-attach.py" +spec = importlib.util.spec_from_file_location("foreman_attach", MODULE_PATH) +assert spec and spec.loader +attach = importlib.util.module_from_spec(spec) +sys.modules["foreman_attach"] = attach +spec.loader.exec_module(attach) + + +def test_no_briefs_is_empty(tmp_path: Path) -> None: + assert attach.read_briefs([], tmp_path) == "" + + +def test_relative_path_resolves_against_worker_tree(tmp_path: Path) -> None: + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "LOOK.md").write_text("канон картинки", encoding="utf-8") + out = attach.read_briefs(["docs/LOOK.md"], tmp_path) + assert "канон картинки" in out + assert "LOOK.md" in out + + +def test_missing_file_is_reported_not_fatal(tmp_path: Path) -> None: + out = attach.read_briefs(["docs/НЕТУ.md"], tmp_path) + assert "файла нет" in out + + +def test_budget_is_split_between_briefs(tmp_path: Path) -> None: + for name in ("a.md", "b.md"): + (tmp_path / name).write_text("я" * 50_000, encoding="utf-8") + out = attach.read_briefs(["a.md", "b.md"], tmp_path, budget=4_000) + assert "урезано" in out + assert len(out) < 12_000 # два файла по доле бюджета, а не два полных файла + + +def test_brief_lands_in_the_job_the_judge_sees(tmp_path: Path) -> None: + (tmp_path / "SPEC.md").write_text("приёмка: здание читается как постройка", encoding="utf-8") + state = {"intent": "почини казарму", "worktreePath": str(tmp_path), "state": "working"} + obs = attach.build_observation("deadbeef", state, "main", attach.read_briefs(["SPEC.md"], tmp_path)) + assert "почини казарму" in obs.original_job + assert "ТЕХНИЧЕСКОЕ ЗАДАНИЕ" in obs.original_job + assert "здание читается как постройка" in obs.original_job