feat(moments): skills and stored processes declare the moments they are for (milestone 458 step 5, #4923)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m3s
CI & Build / Python tests (push) Failing after 1m25s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m3s
CI & Build / Python tests (push) Failing after 1m25s
CI & Build / Build & push image (push) Skipped
Loading a procedure now also reaches the moment it is for. Loading the reporting procedure is a report; loading the release procedure is a delivery. - Bundled skills: each SKILL.md declares `metadata: moments:`. The same declaration ships as Skill defaults (BUNDLED_SKILL_MOMENTS), because the server never sees the plugin's files. test_skill_moments holds the two together and pins the plugin name that qualifies the skill. - Stored processes: `moments` on create_process and update_process, stored in the note's data and returned by get_process. A `scribe-proc-<slug>` load resolves its process through the sync manifest at load time. The moments are not copied into the stub, which would go stale mid-session. - reachable_tools lists the skill loader whenever anything is mounted, since a process's moments are known only when it loads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -166,6 +166,88 @@ async def test_delete_process_refuses_a_plain_note():
|
||||
mock_delete.assert_not_awaited()
|
||||
|
||||
|
||||
# ── the moments a procedure is for (milestone 458 step 5) ────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_process_stores_the_moments_it_is_for():
|
||||
created = fake_note(title="Release", note_type="process",
|
||||
data={"moments": ["work.deliver"]})
|
||||
with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note",
|
||||
AsyncMock(return_value=None)), \
|
||||
patch("scribe.services.notes.create_note",
|
||||
AsyncMock(return_value=created)) as mock_create:
|
||||
from scribe.mcp.tools.processes import create_process
|
||||
out = await create_process(title="Release", body="steps",
|
||||
moments=["Work.Deliver", "work.deliver"])
|
||||
assert mock_create.await_args.kwargs["data"] == {"moments": ["work.deliver"]}
|
||||
assert out["moments"] == ["work.deliver"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_process_refuses_an_unknown_moment_before_writing():
|
||||
"""A typo'd moment would be stored and never fire — a procedure that looks
|
||||
attached and is not."""
|
||||
with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note",
|
||||
AsyncMock(return_value=None)), \
|
||||
patch("scribe.services.notes.create_note", AsyncMock()) as mock_create:
|
||||
from scribe.mcp.tools.processes import create_process
|
||||
with pytest.raises(ValueError, match="unknown moment"):
|
||||
await create_process(title="Release", body="steps", moments=["work.shipped"])
|
||||
mock_create.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_process_without_moments_stores_no_data():
|
||||
created = fake_note(title="Release", note_type="process")
|
||||
with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note",
|
||||
AsyncMock(return_value=None)), \
|
||||
patch("scribe.services.notes.create_note",
|
||||
AsyncMock(return_value=created)) as mock_create:
|
||||
from scribe.mcp.tools.processes import create_process
|
||||
out = await create_process(title="Release", body="steps")
|
||||
assert mock_create.await_args.kwargs["data"] is None
|
||||
assert out["moments"] == []
|
||||
|
||||
|
||||
async def _update(moments, data):
|
||||
proc = fake_note(id=5, title="Release", note_type="process", data=data)
|
||||
with patch("scribe.services.notes.get_note_for_user",
|
||||
AsyncMock(return_value=(proc, "owner"))), \
|
||||
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), \
|
||||
patch("scribe.services.notes.update_note",
|
||||
AsyncMock(return_value=proc)) as mock_update:
|
||||
from scribe.mcp.tools.processes import update_process
|
||||
await update_process(process_id=5, moments=moments)
|
||||
return mock_update.await_args.kwargs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_process_replaces_moments_and_keeps_the_rest_of_data():
|
||||
sent = await _update(["work.verify"], {"moments": ["work.deliver"], "other": 1})
|
||||
assert sent["data"] == {"moments": ["work.verify"], "other": 1}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_process_empty_moments_clears_them():
|
||||
assert (await _update([], {"moments": ["work.deliver"]}))["data"] is None
|
||||
assert (await _update([], {"moments": ["work.deliver"], "other": 1}))["data"] == {"other": 1}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_process_without_moments_leaves_data_alone():
|
||||
assert "data" not in await _update(None, {"moments": ["work.deliver"]})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_process_says_which_moments_it_is_for():
|
||||
note = fake_note(id=7, title="Release", note_type="process",
|
||||
data={"moments": ["work.deliver"]})
|
||||
with patch("scribe.services.notes.resolve_process", AsyncMock(return_value=(note, []))):
|
||||
from scribe.mcp.tools.processes import get_process
|
||||
out = await get_process("release")
|
||||
assert out["moments"] == ["work.deliver"]
|
||||
|
||||
|
||||
def test_register_attaches_every_tool_in_the_module():
|
||||
"""Derived from the module rather than listed: a tool written but never
|
||||
registered is invisible to an agent, and nothing else would notice."""
|
||||
|
||||
Reference in New Issue
Block a user