kin: KIN-091 Улучшения из исследования рынка: (1) Revise button с feedback loop, (2) auto-test before review — агент сам прогоняет тесты и фиксит до review, (3) spec-driven workflow для новых проектов — constitution → spec → plan → tasks, (4) git worktrees для параллельных агентов без конфликтов, (5) auto-trigger pipeline при создании задачи с label auto

This commit is contained in:
Gros Frumos 2026-03-16 22:35:31 +02:00
parent 0cc063d47a
commit 0ccd451b4b
14 changed files with 1660 additions and 18 deletions

View file

@ -448,11 +448,12 @@ class TestAttachmentsInContext:
assert "mockup.jpg" in filenames
assert "/tmp/prj/.kin/attachments/PRJ-001/screenshot.png" in paths
def test_build_context_no_attachments_key_when_empty(self, conn):
"""KIN-090: ключ 'attachments' отсутствует в контексте, если вложений нет."""
def test_build_context_attachments_key_always_present(self, conn):
"""KIN-094 #213: ключ 'attachments' всегда присутствует в контексте (пустой список если нет вложений)."""
# conn fixture has no attachments
ctx = build_context(conn, "VDOL-001", "debugger", "vdol")
assert "attachments" not in ctx
assert "attachments" in ctx
assert ctx["attachments"] == []
def test_all_roles_get_attachments(self, conn_with_attachments):
"""KIN-090: AC2 — все роли (debugger, pm, tester, reviewer) получают вложения."""
@ -473,3 +474,193 @@ class TestAttachmentsInContext:
ctx = build_context(conn, "VDOL-001", "debugger", "vdol")
prompt = format_prompt(ctx, "debugger", "Debug this.")
assert "## Attachments" not in prompt
# ---------------------------------------------------------------------------
# KIN-094: Attachments — ctx["attachments"] always present + inline text content
# ---------------------------------------------------------------------------
class TestAttachmentsKIN094:
"""KIN-094: AC3 — PM и другие агенты всегда получают ключ attachments в контексте;
текстовые файлы <= 32 KB вставляются inline в промпт."""
@pytest.fixture
def conn_no_attachments(self):
c = init_db(":memory:")
models.create_project(c, "prj", "Prj", "/tmp/prj")
models.create_task(c, "PRJ-001", "prj", "Task")
yield c
c.close()
@pytest.fixture
def conn_text_attachment(self, tmp_path):
"""Проект с текстовым вложением <= 32 KB на диске."""
c = init_db(":memory:")
models.create_project(c, "prj", "Prj", str(tmp_path))
models.create_task(c, "PRJ-001", "prj", "Task")
txt_file = tmp_path / "spec.txt"
txt_file.write_text("Привет, это спека задачи", encoding="utf-8")
models.create_attachment(
c, "PRJ-001", "spec.txt", str(txt_file), "text/plain", txt_file.stat().st_size,
)
yield c
c.close()
@pytest.fixture
def conn_md_attachment(self, tmp_path):
"""Проект с .md вложением (text/markdown или определяется по расширению)."""
c = init_db(":memory:")
models.create_project(c, "prj", "Prj", str(tmp_path))
models.create_task(c, "PRJ-001", "prj", "Task")
md_file = tmp_path / "README.md"
md_file.write_text("# Title\n\nContent of readme", encoding="utf-8")
models.create_attachment(
c, "PRJ-001", "README.md", str(md_file), "text/markdown", md_file.stat().st_size,
)
yield c
c.close()
@pytest.fixture
def conn_json_attachment(self, tmp_path):
"""Проект с JSON-вложением (application/json)."""
c = init_db(":memory:")
models.create_project(c, "prj", "Prj", str(tmp_path))
models.create_task(c, "PRJ-001", "prj", "Task")
json_file = tmp_path / "config.json"
json_file.write_text('{"key": "value"}', encoding="utf-8")
models.create_attachment(
c, "PRJ-001", "config.json", str(json_file), "application/json", json_file.stat().st_size,
)
yield c
c.close()
@pytest.fixture
def conn_large_text_attachment(self, tmp_path):
"""Проект с текстовым вложением > 32 KB (не должно инлайниться)."""
c = init_db(":memory:")
models.create_project(c, "prj", "Prj", str(tmp_path))
models.create_task(c, "PRJ-001", "prj", "Task")
big_file = tmp_path / "big.txt"
big_file.write_text("x" * (32 * 1024 + 1), encoding="utf-8")
models.create_attachment(
c, "PRJ-001", "big.txt", str(big_file), "text/plain", big_file.stat().st_size,
)
yield c
c.close()
@pytest.fixture
def conn_image_attachment(self, tmp_path):
"""Проект с бинарным PNG-вложением (не должно инлайниться)."""
c = init_db(":memory:")
models.create_project(c, "prj", "Prj", str(tmp_path))
models.create_task(c, "PRJ-001", "prj", "Task")
png_file = tmp_path / "screen.png"
png_file.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 64)
models.create_attachment(
c, "PRJ-001", "screen.png", str(png_file), "image/png", png_file.stat().st_size,
)
yield c
c.close()
# ------------------------------------------------------------------
# ctx["attachments"] always present
# ------------------------------------------------------------------
def test_pm_context_attachments_empty_list_when_no_attachments(self, conn_no_attachments):
"""KIN-094: PM получает пустой список attachments, а не отсутствующий ключ."""
ctx = build_context(conn_no_attachments, "PRJ-001", "pm", "prj")
assert "attachments" in ctx
assert ctx["attachments"] == []
def test_all_roles_attachments_key_present_when_empty(self, conn_no_attachments):
"""KIN-094: все роли получают ключ attachments (пустой список) даже без вложений."""
for role in ("pm", "debugger", "tester", "reviewer", "backend_dev", "frontend_dev", "architect"):
ctx = build_context(conn_no_attachments, "PRJ-001", role, "prj")
assert "attachments" in ctx, f"Role '{role}' missing 'attachments' key"
assert isinstance(ctx["attachments"], list), f"Role '{role}': attachments is not a list"
# ------------------------------------------------------------------
# Inline content for small text files
# ------------------------------------------------------------------
def test_format_prompt_inlines_small_text_file_content(self, conn_text_attachment):
"""KIN-094: содержимое текстового файла <= 32 KB вставляется inline в промпт."""
ctx = build_context(conn_text_attachment, "PRJ-001", "pm", "prj")
prompt = format_prompt(ctx, "pm", "You are PM.")
assert "Привет, это спека задачи" in prompt
def test_format_prompt_inlines_text_file_in_code_block(self, conn_text_attachment):
"""KIN-094: inline-контент обёрнут в блок кода (``` ... ```)."""
ctx = build_context(conn_text_attachment, "PRJ-001", "pm", "prj")
prompt = format_prompt(ctx, "pm", "You are PM.")
assert "```" in prompt
def test_format_prompt_inlines_md_file_by_extension(self, conn_md_attachment):
"""KIN-094: .md файл определяется по расширению и вставляется inline."""
ctx = build_context(conn_md_attachment, "PRJ-001", "pm", "prj")
prompt = format_prompt(ctx, "pm", "You are PM.")
assert "# Title" in prompt
assert "Content of readme" in prompt
def test_format_prompt_inlines_json_file_by_mime(self, conn_json_attachment):
"""KIN-094: application/json файл вставляется inline по MIME-типу."""
ctx = build_context(conn_json_attachment, "PRJ-001", "pm", "prj")
prompt = format_prompt(ctx, "pm", "You are PM.")
assert '"key": "value"' in prompt
# ------------------------------------------------------------------
# NOT inlined: binary and large files
# ------------------------------------------------------------------
def test_format_prompt_does_not_inline_image_file(self, conn_image_attachment):
"""KIN-094: бинарный PNG файл НЕ вставляется inline."""
ctx = build_context(conn_image_attachment, "PRJ-001", "pm", "prj")
prompt = format_prompt(ctx, "pm", "You are PM.")
# File is listed in ## Attachments section but no ``` block with binary content
assert "screen.png" in prompt # listed
assert "image/png" in prompt
# Should not contain raw binary or ``` code block for the PNG
# We verify the file content (PNG header) is NOT inlined
assert "\x89PNG" not in prompt
def test_format_prompt_does_not_inline_large_text_file(self, conn_large_text_attachment):
"""KIN-094: текстовый файл > 32 KB НЕ вставляется inline."""
ctx = build_context(conn_large_text_attachment, "PRJ-001", "pm", "prj")
prompt = format_prompt(ctx, "pm", "You are PM.")
assert "big.txt" in prompt # listed
# Content should NOT be inlined (32KB+1 of 'x' chars)
assert "x" * 100 not in prompt
# ------------------------------------------------------------------
# Resilience: missing file on disk
# ------------------------------------------------------------------
def test_format_prompt_handles_missing_file_gracefully(self, tmp_path):
"""KIN-094: если файл отсутствует на диске, format_prompt не падает."""
c = init_db(":memory:")
models.create_project(c, "prj", "Prj", str(tmp_path))
models.create_task(c, "PRJ-001", "prj", "Task")
# Register attachment pointing to non-existent file
models.create_attachment(
c, "PRJ-001", "missing.txt",
str(tmp_path / "missing.txt"),
"text/plain", 100,
)
ctx = build_context(c, "PRJ-001", "pm", "prj")
# Should not raise — exception is caught silently
prompt = format_prompt(ctx, "pm", "You are PM.")
assert "missing.txt" in prompt # still listed
c.close()
# ------------------------------------------------------------------
# PM pipeline: attachments available in brief context
# ------------------------------------------------------------------
def test_pm_context_includes_attachment_paths_for_pipeline(self, conn_text_attachment):
"""KIN-094: PM-агент получает пути к вложениям в контексте для старта pipeline."""
ctx = build_context(conn_text_attachment, "PRJ-001", "pm", "prj")
assert len(ctx["attachments"]) == 1
att = ctx["attachments"][0]
assert att["filename"] == "spec.txt"
assert att["mime_type"] == "text/plain"
assert "path" in att