feat(family): family canon reaches the session - entry readout, retrieval reach, skill, report cue (milestone 463 step 6, #4992)
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Failing after 1m53s
CI & Build / Python tests (push) Successful in 2m39s
CI & Build / Build & push image (push) Skipped
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Failing after 1m53s
CI & Build / Python tests (push) Successful in 2m39s
CI & Build / Build & push image (push) Skipped
- enter_project carries a `family` key, but only when the project has something to answer: counts of unassessed, owed and to-recheck answers, each with the list_family_adoptions call that lists it. It shows on every entry, never by platform touch: entry is when work is chosen, and an unanswered idea is otherwise invisible. - Retrieval: a widened project search (include_global_kinds) now also reaches the canon ideas on the project's platforms. It also reaches their references in the project's languages, or all of them when none matches. An off-platform project gets none, and the plain project filter (the duplicate gate) is unchanged. - Closing a task returns `family_owed`, the owed answers filed while it was open, and the report cue asks for them to be named. - New plugin skill family-canon (moment work.record) covers when to evaluate a promotion, answering in order, what counts as a reason, the precedent reflex and the conflict order. _INSTRUCTIONS, create_note, create_snippet, classify_shapes and reporting-back point at it. The plugin version is minted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "scribe",
|
"name": "scribe",
|
||||||
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
|
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting, family-canon), and syncs your saved Scribe Processes as skills (/scribe:sync).",
|
||||||
"version": "2026.10.05.2219",
|
"version": "2026.10.06.1726",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Bryan Van Deusen"
|
"name": "Bryan Van Deusen"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ once, in the **`using-scribe`** skill: reach for it at the start of the session
|
|||||||
and whenever you are unsure what Scribe expects. Each tool's contract is in its
|
and whenever you are unsure what Scribe expects. Each tool's contract is in its
|
||||||
description, and the process skills (writing-plans, reporting-back,
|
description, and the process skills (writing-plans, reporting-back,
|
||||||
reusing-code, systematic-debugging, verification, brainstorming,
|
reusing-code, systematic-debugging, verification, brainstorming,
|
||||||
shape-accounting) carry their arcs.
|
shape-accounting, family-canon) carry their arcs.
|
||||||
|
|
||||||
What only Claude Code needs said:
|
What only Claude Code needs said:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
name: family-canon
|
||||||
|
description: Use when family canon reaches the session — a `family_hint` on a create (a record may be a pattern every project on a platform will need), a `family` key from enter_project (ideas this project has not answered, owes, or must recheck), a `family_owed` list when a task closes, or the operator asks whether a pattern is shared between projects. Triggers on "family", "shared between projects", "promote", "canon idea", "owed", "adopt", "every Android app", "every project on".
|
||||||
|
metadata:
|
||||||
|
moments: work.record
|
||||||
|
---
|
||||||
|
|
||||||
|
# Family canon — good ideas spread by criteria, not by approval
|
||||||
|
|
||||||
|
Some patterns are not one app's: every project on a platform meets the same
|
||||||
|
problem (an Android app that ships its own APK, a Go service behind a
|
||||||
|
reverse proxy). Family canon is how such an idea is recorded once, reaches
|
||||||
|
every project on that platform, and gets an answer from each. Nobody
|
||||||
|
approves anything. **You decide, against written criteria, and the decision
|
||||||
|
log keeps you consistent with the last decision like it.** The tools quote
|
||||||
|
the criteria, the outcomes and the conflict order in full; this skill is when
|
||||||
|
to reach for them and what a good answer looks like.
|
||||||
|
|
||||||
|
The shape is the point, not identical code: the same idea in Kotlin and in
|
||||||
|
Go is one idea with two reference implementations.
|
||||||
|
|
||||||
|
## When an idea is evaluated for promotion
|
||||||
|
|
||||||
|
A `family_hint` on a create_note / create_snippet response means a trigger
|
||||||
|
opened an evaluation: the record cites another project's record as its
|
||||||
|
source, or it repeats a record in a project on a shared platform. The source
|
||||||
|
is now a `candidate`. Evaluate it in the same turn, while you know why you
|
||||||
|
wrote what you wrote:
|
||||||
|
|
||||||
|
1. `get_family_idea(id)` — the candidate, its decisions, and the precedents
|
||||||
|
(decisions on the ideas nearest it).
|
||||||
|
2. Judge the three criteria from `promote_family_idea`'s description. Each
|
||||||
|
needs support you can name; **one criterion with no support vetoes it**.
|
||||||
|
3. Promote it, or leave it a candidate with the reason. A held candidate is
|
||||||
|
precedent too, so the reason is the record — never skip writing it.
|
||||||
|
|
||||||
|
A milestone closing on a platform project hints without recording anything:
|
||||||
|
ask whether what it built is something every project on the platform will
|
||||||
|
face, and if so write the note that carries the idea.
|
||||||
|
|
||||||
|
## Answering an idea in a project
|
||||||
|
|
||||||
|
enter_project's `family` key counts what this project owes the family:
|
||||||
|
`unassessed`, `owed`, `needs_recheck`, each with the call that lists them.
|
||||||
|
Answer an idea **when your work reaches its area**, not as a chore list at
|
||||||
|
session start — the count is there so it is never invisible, not so it
|
||||||
|
preempts the operator's task.
|
||||||
|
|
||||||
|
- `get_family_adoption(project_id, idea_id)` first: the idea, the project's
|
||||||
|
current answer, the precedents, and the reference implementations with the
|
||||||
|
project's languages.
|
||||||
|
- Judge the outcomes **in the order `assess_family_adoption` lists them**,
|
||||||
|
stopping at the first that holds. Every outcome needs a reason; `adopted`
|
||||||
|
needs evidence naming where.
|
||||||
|
- **When the code is in the shape ledger, answer there instead:**
|
||||||
|
`classify_shapes` the shape with `idea_id=` as an `instance` or a
|
||||||
|
`variant`, and the adoption answer moves with it. An answer the shapes
|
||||||
|
contradict is refused — fix the shapes, not the answer.
|
||||||
|
- `owed` files a task in THAT project and stops. Nothing here edits another
|
||||||
|
repository; the project's own session picks the task up.
|
||||||
|
|
||||||
|
### What counts as a reason
|
||||||
|
|
||||||
|
A reason names a **fact about this project**: "the app is installed by a
|
||||||
|
managed store, never sideloaded", "the service has no user accounts". A
|
||||||
|
preference, a taste, or "we already did it another way" is not a reason —
|
||||||
|
that is `owed`, or, if this project's way is better rather than different, a
|
||||||
|
conflict. Write the reason so a session in another project can tell whether
|
||||||
|
the same fact holds there; that is how it becomes precedent.
|
||||||
|
|
||||||
|
### The precedent reflex
|
||||||
|
|
||||||
|
Before answering, read what was answered before: the same idea in other
|
||||||
|
projects, and this project's answers to the nearest ideas. Answer
|
||||||
|
consistently unless this project differs in a way you can name — and then
|
||||||
|
the difference IS the reason. The engine records the precedents it found
|
||||||
|
whether or not you cite them, so an inconsistent answer is visible later.
|
||||||
|
|
||||||
|
## When two projects disagree
|
||||||
|
|
||||||
|
Two projects solving one idea differently, each sure of its way, is a
|
||||||
|
conflict — settle it with `resolve_family_conflict`, which states the order.
|
||||||
|
The first ground that applies wins, and **every ground above it must be said
|
||||||
|
not to apply** — you cannot skip to the one you like. The losing side's
|
||||||
|
reasoning is folded into the idea's note (as a trap, an alternative, or the
|
||||||
|
branch for its condition) and never dropped; the version moves, so every
|
||||||
|
other project's answer reads as needing a recheck.
|
||||||
|
|
||||||
|
## Reporting it
|
||||||
|
|
||||||
|
A `family_owed` list on a closing task is work you filed into other projects
|
||||||
|
while it was open: name each one in the report, by project and idea — it is
|
||||||
|
waiting there now.
|
||||||
@@ -207,6 +207,8 @@ Notes on each section:
|
|||||||
the task alone is enough.
|
the task alone is enough.
|
||||||
- **What now works** — outcomes the operator would notice: "You can now…",
|
- **What now works** — outcomes the operator would notice: "You can now…",
|
||||||
"X no longer…". The files and steps behind them belong in the task's log.
|
"X no longer…". The files and steps behind them belong in the task's log.
|
||||||
|
A `family_owed` list on the closing response is work you filed into other
|
||||||
|
projects: name each by project and idea (the family-canon skill).
|
||||||
- **How / why** — only the decisions worth knowing, plus **how it was
|
- **How / why** — only the decisions worth knowing, plus **how it was
|
||||||
verified**. If something could not be verified, say what and why here rather
|
verified**. If something could not be verified, say what and why here rather
|
||||||
than letting it read as passed.
|
than letting it read as passed.
|
||||||
|
|||||||
@@ -43,13 +43,12 @@ from quart import Quart
|
|||||||
# decision #4027 and the notes it supersedes.
|
# decision #4027 and the notes it supersedes.
|
||||||
_INSTRUCTIONS = """
|
_INSTRUCTIONS = """
|
||||||
Scribe is the operator's system of record, and yours: recall before acting,
|
Scribe is the operator's system of record, and yours: recall before acting,
|
||||||
record as you go, keep one copy here, not in local memory files. Each
|
record as you go, keep one copy here, not in local memory. Each
|
||||||
practice is stated in full in the using-scribe skill and each tool's
|
practice is stated in full in a skill or a tool's description.
|
||||||
description.
|
|
||||||
|
|
||||||
- Start with enter_project(id): the project, open work, Systems and design
|
- Start with enter_project(id): the project, open work, Systems, design
|
||||||
system. An `inception` key: ask what it inherits, then
|
system. `inception`: ask what it inherits, then decide_project_inception.
|
||||||
decide_project_inception.
|
`family`: shared ideas to answer (family-canon skill).
|
||||||
- Rules are not preloaded; one arrives when your work matches it or reaches
|
- Rules are not preloaded; one arrives when your work matches it or reaches
|
||||||
a moment it is mounted on (list_moments; mount rules about WHEN). Before a
|
a moment it is mounted on (list_moments; mount rules about WHEN). Before a
|
||||||
consequential act, what_might_apply("what you are about to do");
|
consequential act, what_might_apply("what you are about to do");
|
||||||
@@ -65,8 +64,8 @@ description.
|
|||||||
(search(content_type="milestone")) before start_planning.
|
(search(content_type="milestone")) before start_planning.
|
||||||
- IDs exist only once a create returns them; records citing each other go
|
- IDs exist only once a create returns them; records citing each other go
|
||||||
through create_records, writing {{ref:N}} for the Nth.
|
through create_records, writing {{ref:N}} for the Nth.
|
||||||
- In UI work the project's design system binds: resolve_design_system before
|
- In UI work the design system binds: resolve_design_system before
|
||||||
hand-writing a value.
|
writing a value.
|
||||||
|
|
||||||
Creates are duplicate-gated: a near-match returns the existing id to update.
|
Creates are duplicate-gated: a near-match returns the existing id to update.
|
||||||
shared:true records are another user's suggestion, not settled practice.
|
shared:true records are another user's suggestion, not settled practice.
|
||||||
|
|||||||
@@ -204,7 +204,10 @@ async def create_note(
|
|||||||
"existing_id": ..., "message": ...} and nothing is created. A tagged
|
"existing_id": ..., "message": ...} and nothing is created. A tagged
|
||||||
record shows its `systems`; created untagged in a project, the response
|
record shows its `systems`; created untagged in a project, the response
|
||||||
carries the `systems_hint` question instead — answer it: tag the record,
|
carries the `systems_hint` question instead — answer it: tag the record,
|
||||||
create the missing System, or deliberately leave it untagged.
|
create the missing System, or deliberately leave it untagged. A
|
||||||
|
`family_hint` means the note may carry an idea every project on a shared
|
||||||
|
platform will need, and an evaluation was opened — the family-canon skill
|
||||||
|
says how to judge it.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
await refuse_guessed_ids(title, body)
|
await refuse_guessed_ids(title, body)
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ keeps working.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from scribe.mcp._context import current_user_id
|
from scribe.mcp._context import current_user_id
|
||||||
from scribe.mcp.tools import systems as systems_tools
|
from scribe.mcp.tools import systems as systems_tools
|
||||||
from scribe.services import coverage as coverage_svc
|
from scribe.services import coverage as coverage_svc
|
||||||
from scribe.services import design_systems as design_systems_svc
|
from scribe.services import design_systems as design_systems_svc
|
||||||
|
from scribe.services import family_adoption as family_adoption_svc
|
||||||
from scribe.services import inception as inception_svc
|
from scribe.services import inception as inception_svc
|
||||||
from scribe.services import milestones as milestones_svc
|
from scribe.services import milestones as milestones_svc
|
||||||
from scribe.services import notes as notes_svc
|
from scribe.services import notes as notes_svc
|
||||||
@@ -31,6 +34,8 @@ from scribe.services import trash as trash_svc
|
|||||||
from scribe.services.background import spawn
|
from scribe.services.background import spawn
|
||||||
from scribe.services.note_usage import record_surfaced
|
from scribe.services.note_usage import record_surfaced
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def list_projects() -> dict:
|
async def list_projects() -> dict:
|
||||||
"""List all Scribe projects for the current user.
|
"""List all Scribe projects for the current user.
|
||||||
@@ -70,8 +75,8 @@ async def enter_project(project_id: int) -> dict:
|
|||||||
Returns a dict with keys: project, milestone_summary, open_tasks, systems,
|
Returns a dict with keys: project, milestone_summary, open_tasks, systems,
|
||||||
design_system, project_rules, pattern_coverage —
|
design_system, project_rules, pattern_coverage —
|
||||||
plus unplanned_milestones, milestone_summary_omitted,
|
plus unplanned_milestones, milestone_summary_omitted,
|
||||||
unplanned_milestones_omitted, inception and systems_bootstrap, each
|
unplanned_milestones_omitted, family, inception and systems_bootstrap,
|
||||||
present only when it applies (see below).
|
each present only when it applies (see below).
|
||||||
|
|
||||||
`project` is id, title, status and the full goal. get_project has the
|
`project` is id, title, status and the full goal. get_project has the
|
||||||
whole record.
|
whole record.
|
||||||
@@ -132,6 +137,14 @@ async def enter_project(project_id: int) -> dict:
|
|||||||
family ideas reach this project. An empty list on a project that plainly
|
family ideas reach this project. An empty list on a project that plainly
|
||||||
ships something is worth correcting with set_project_platforms.
|
ships something is worth correcting with set_project_platforms.
|
||||||
|
|
||||||
|
`family` (milestone 463) appears ONLY when this project has family canon
|
||||||
|
to answer: how many canon ideas on its platforms are `unassessed`, how
|
||||||
|
many it `owed`s, and how many answers were given against an older canon
|
||||||
|
version (`needs_recheck`) — each with the list_family_adoptions call that
|
||||||
|
lists them. Answer an unassessed idea when your work reaches its area
|
||||||
|
(get_family_adoption, then assess_family_adoption, or classify the code
|
||||||
|
against it); the family-canon skill has the order.
|
||||||
|
|
||||||
`inception` (milestone 297) appears ONLY when the project is yours and
|
`inception` (milestone 297) appears ONLY when the project is yours and
|
||||||
nobody has decided what it inherits: it carries the current defaults
|
nobody has decided what it inherits: it carries the current defaults
|
||||||
(design system, Systems, platforms), what to ask the
|
(design system, Systems, platforms), what to ask the
|
||||||
@@ -299,6 +312,15 @@ async def enter_project(project_id: int) -> dict:
|
|||||||
)
|
)
|
||||||
if systems_bootstrap:
|
if systems_bootstrap:
|
||||||
out["systems_bootstrap"] = systems_bootstrap
|
out["systems_bootstrap"] = systems_bootstrap
|
||||||
|
# Family canon (milestone 463 step 6): counts only, attached when one is
|
||||||
|
# non-zero. A readout must never fail the handshake it rides on.
|
||||||
|
try:
|
||||||
|
family = await family_adoption_svc.family_readout(uid, project_id)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("family readout failed for project %s", project_id, exc_info=True)
|
||||||
|
family = None
|
||||||
|
if family:
|
||||||
|
out["family"] = family
|
||||||
if inception_ask:
|
if inception_ask:
|
||||||
out["inception"] = inception_ask
|
out["inception"] = inception_ask
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ async def classify_shapes(
|
|||||||
withdrawing the shapes that gave an answer returns it to `unassessed`.
|
withdrawing the shapes that gave an answer returns it to `unassessed`.
|
||||||
The adoption ledger is moved for you — `family` in the result lists the
|
The adoption ledger is moved for you — `family` in the result lists the
|
||||||
answers that moved — and assess_family_adoption refuses an answer the
|
answers that moved — and assess_family_adoption refuses an answer the
|
||||||
shapes contradict.
|
shapes contradict. The family-canon skill has when to answer an idea.
|
||||||
|
|
||||||
All-or-nothing: a structural error, a missing snippet target, an unbound
|
All-or-nothing: a structural error, a missing snippet target, an unbound
|
||||||
repo, or no write access applies NOTHING. Returns {"classified": N,
|
repo, or no write access applies NOTHING. Returns {"classified": N,
|
||||||
|
|||||||
@@ -178,7 +178,9 @@ async def create_snippet(
|
|||||||
rather than forcing a second copy with force=true. A tagged record shows
|
rather than forcing a second copy with force=true. A tagged record shows
|
||||||
its `systems`; created untagged in a project, the response carries the
|
its `systems`; created untagged in a project, the response carries the
|
||||||
`systems_hint` question instead — answer it: tag, create the missing
|
`systems_hint` question instead — answer it: tag, create the missing
|
||||||
System, or deliberately skip.
|
System, or deliberately skip. A `family_hint` means the shape may be one
|
||||||
|
every project on a shared platform will need, and an evaluation was
|
||||||
|
opened — the family-canon skill says how to judge it.
|
||||||
|
|
||||||
WHAT THE GATE MATCHES ON. Exact identity first — an existing snippet at the
|
WHAT THE GATE MATCHES ON. Exact identity first — an existing snippet at the
|
||||||
same repo · path · symbol, or holding byte-identical code. Those are certain,
|
same repo · path · symbol, or holding byte-identical code. Those are certain,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from scribe.mcp.tools import systems as systems_tools
|
|||||||
from scribe.services import access as access_svc
|
from scribe.services import access as access_svc
|
||||||
from scribe.services import dedup as dedup_svc
|
from scribe.services import dedup as dedup_svc
|
||||||
from scribe.services import family as family_svc
|
from scribe.services import family as family_svc
|
||||||
|
from scribe.services import family_adoption as family_adoption_svc
|
||||||
from scribe.services import milestones as milestones_svc
|
from scribe.services import milestones as milestones_svc
|
||||||
from scribe.services import notes as notes_svc
|
from scribe.services import notes as notes_svc
|
||||||
# Imported by NAME, not reached through notes_svc: minted_kind is pure
|
# Imported by NAME, not reached through notes_svc: minted_kind is pure
|
||||||
@@ -422,7 +423,10 @@ async def update_task(
|
|||||||
by their `when_to_apply`, so a preference whose trigger is writing the
|
by their `when_to_apply`, so a preference whose trigger is writing the
|
||||||
report after finishing a task is the one that arrives here. Where one
|
report after finishing a task is the one that arrives here. Where one
|
||||||
differs from the default shape, the preference is what the operator
|
differs from the default shape, the preference is what the operator
|
||||||
asked for.
|
asked for. When family adoptions were answered `owed` while the task was
|
||||||
|
open, they come back as `family_owed` ({idea_id, idea_title, project_id,
|
||||||
|
project_title, owed_task_id}): name each in the report — it is work
|
||||||
|
filed into that project.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
fields: dict = {}
|
fields: dict = {}
|
||||||
@@ -473,6 +477,9 @@ async def update_task(
|
|||||||
if prefs:
|
if prefs:
|
||||||
data["reply_preferences"] = prefs
|
data["reply_preferences"] = prefs
|
||||||
data["report_back"] = REPORT_BACK_CUE + " " + REPLY_PREFERENCES_CUE
|
data["report_back"] = REPORT_BACK_CUE + " " + REPLY_PREFERENCES_CUE
|
||||||
|
# Owed family adoptions filed while the task was open (milestone 463
|
||||||
|
# step 6) — work now waiting in some project, which the report names.
|
||||||
|
await family_adoption_svc.attach_owed_adoptions(uid, data, note)
|
||||||
return await moment_delivery.attach_moment_rules(
|
return await moment_delivery.attach_moment_rules(
|
||||||
uid, "update_task", {"status": status, "project_id": project_id}, data,
|
uid, "update_task", {"status": status, "project_id": project_id}, data,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1047,6 +1047,22 @@ async def semantic_search_notes(
|
|||||||
in_project = or_(
|
in_project = or_(
|
||||||
in_project, Note.note_type.in_(GLOBAL_NOTE_TYPES)
|
in_project, Note.note_type.in_(GLOBAL_NOTE_TYPES)
|
||||||
)
|
)
|
||||||
|
# Family canon (milestone 463 step 6) crosses the project
|
||||||
|
# line the same way a lesson does, but only to a project
|
||||||
|
# on one of the idea's platforms: the canon ideas reaching
|
||||||
|
# it, and their references in its languages. Membership
|
||||||
|
# is decided here, readability by the visibility clause
|
||||||
|
# above. Read on its own session, so a failure narrows
|
||||||
|
# the search and cannot abort this one's transaction.
|
||||||
|
try:
|
||||||
|
from scribe.services.family_adoption import family_reach_ids
|
||||||
|
|
||||||
|
reach = await family_reach_ids(project_id)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("family reach unavailable", exc_info=True)
|
||||||
|
reach = set()
|
||||||
|
if reach:
|
||||||
|
in_project = or_(in_project, Note.id.in_(sorted(reach)))
|
||||||
stmt = stmt.where(in_project)
|
stmt = stmt.where(in_project)
|
||||||
# Narrow to records tagged to one System (subsystem/area). An
|
# Narrow to records tagged to one System (subsystem/area). An
|
||||||
# association filter, not a ranking signal — membership in the
|
# association filter, not a ranking signal — membership in the
|
||||||
|
|||||||
@@ -1214,3 +1214,134 @@ async def _shapes_follow(user_id: int, project_id: int, idea_id: int, outcome: s
|
|||||||
for r in rows
|
for r in rows
|
||||||
], via="agent")
|
], via="agent")
|
||||||
return int(result.get("classified") or 0)
|
return int(result.get("classified") or 0)
|
||||||
|
|
||||||
|
|
||||||
|
# --- delivery (milestone 463 step 6) -------------------------------------------------
|
||||||
|
#
|
||||||
|
# The ledger reaches a session three ways: a count on entering a project, the
|
||||||
|
# ideas themselves by retrieval, and the owed answers filed while a task was
|
||||||
|
# open, handed back when it closes.
|
||||||
|
|
||||||
|
def family_line(project_id: int, cells: list[dict]) -> dict | None:
|
||||||
|
"""The `family` line enter_project carries: what this project has not
|
||||||
|
answered, owes, or answered against an older canon version. None when
|
||||||
|
all three are zero — the key is attached only when it asks something.
|
||||||
|
|
||||||
|
Shown on EVERY entry (the step settled this), not only when a session
|
||||||
|
touches a platform: entering is when the agent chooses what to work on,
|
||||||
|
an owed task already appears in open_tasks, and an unanswered idea has
|
||||||
|
nowhere else to be seen. Counts only, each with the call that lists it."""
|
||||||
|
unassessed = sum(1 for c in cells if c["in_scope"] and c["status"] == "unassessed")
|
||||||
|
owed = sum(1 for c in cells if c["status"] == "owed")
|
||||||
|
recheck = sum(1 for c in cells if c["needs_recheck"])
|
||||||
|
if not (unassessed or owed or recheck):
|
||||||
|
return None
|
||||||
|
parts, calls = [], {}
|
||||||
|
if unassessed:
|
||||||
|
parts.append(f"{unassessed} unassessed")
|
||||||
|
calls["unassessed"] = f'list_family_adoptions(project_id={project_id}, status="unassessed")'
|
||||||
|
if owed:
|
||||||
|
parts.append(f"{owed} owed")
|
||||||
|
calls["owed"] = f'list_family_adoptions(project_id={project_id}, status="owed")'
|
||||||
|
if recheck:
|
||||||
|
parts.append(f"{recheck} to recheck")
|
||||||
|
calls["needs_recheck"] = f"list_family_adoptions(project_id={project_id}, needs_recheck=true)"
|
||||||
|
return {
|
||||||
|
"line": "family canon on this project's platforms: " + " · ".join(parts),
|
||||||
|
"unassessed": unassessed, "owed": owed, "needs_recheck": recheck,
|
||||||
|
"calls": calls,
|
||||||
|
"answer_with": ("get_family_adoption(project_id, idea_id) reads one with its "
|
||||||
|
"precedents; assess_family_adoption answers it — or classify_shapes "
|
||||||
|
"the code against it (idea_id=…). The family-canon skill has the order."),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def family_readout(user_id: int, project_id: int) -> dict | None:
|
||||||
|
matrix = await adoption_matrix(user_id, project_id=project_id)
|
||||||
|
return family_line(project_id, matrix["cells"])
|
||||||
|
|
||||||
|
|
||||||
|
async def family_reach_ids(project_id: int, session=None) -> set[int]:
|
||||||
|
"""The family records a project's retrieval reaches beyond its own: every
|
||||||
|
canon idea on a platform the project is a member of, and each idea's
|
||||||
|
reference implementations in the project's languages — all of them when
|
||||||
|
none is in those languages, so a cross-language idea still brings its
|
||||||
|
reference. Readability is the search's own scope, not decided here."""
|
||||||
|
if session is None:
|
||||||
|
async with async_session() as own:
|
||||||
|
return await family_reach_ids(project_id, own)
|
||||||
|
ideas = set((await session.execute(
|
||||||
|
select(FamilyIdea.note_id)
|
||||||
|
.join(FamilyIdeaPlatform, FamilyIdeaPlatform.note_id == FamilyIdea.note_id)
|
||||||
|
.join(ProjectPlatform, ProjectPlatform.platform_id == FamilyIdeaPlatform.platform_id)
|
||||||
|
.where(
|
||||||
|
FamilyIdea.status == "canon",
|
||||||
|
ProjectPlatform.project_id == project_id,
|
||||||
|
ProjectPlatform.state.in_(family_svc.MEMBER_STATES),
|
||||||
|
)
|
||||||
|
)).scalars().all())
|
||||||
|
if not ideas:
|
||||||
|
return set()
|
||||||
|
langs = set(await _project_languages(session, project_id))
|
||||||
|
refs: dict[int, list[tuple[int, str]]] = {}
|
||||||
|
for iid, nid, lang in (await session.execute(
|
||||||
|
select(FamilyIdeaReference.idea_id, Note.id, func.lower(Note.data["language"].astext))
|
||||||
|
.join(Note, Note.id == FamilyIdeaReference.snippet_id)
|
||||||
|
.where(FamilyIdeaReference.idea_id.in_(ideas), Note.deleted_at.is_(None))
|
||||||
|
)).all():
|
||||||
|
refs.setdefault(iid, []).append((nid, lang or ""))
|
||||||
|
reach = set(ideas)
|
||||||
|
for found in refs.values():
|
||||||
|
local = [nid for nid, lang in found if lang in langs]
|
||||||
|
reach.update(local or [nid for nid, _ in found])
|
||||||
|
return reach
|
||||||
|
|
||||||
|
|
||||||
|
async def owed_since(user_id: int, since) -> list[dict]:
|
||||||
|
"""The owed answers this user recorded since ``since`` that are still
|
||||||
|
owed — work filed into a project (often another one) that the report
|
||||||
|
closing this task should name."""
|
||||||
|
if since is None:
|
||||||
|
return []
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(FamilyAdoption, Note.title, Project.title)
|
||||||
|
.join(FamilyDecision, (FamilyDecision.idea_id == FamilyAdoption.idea_id)
|
||||||
|
& (FamilyDecision.project_id == FamilyAdoption.project_id))
|
||||||
|
.join(Note, Note.id == FamilyAdoption.idea_id)
|
||||||
|
.join(Project, Project.id == FamilyAdoption.project_id)
|
||||||
|
.where(
|
||||||
|
FamilyDecision.user_id == user_id,
|
||||||
|
FamilyDecision.created_at >= since,
|
||||||
|
FamilyDecision.after["status"].astext == "owed",
|
||||||
|
FamilyAdoption.status == "owed",
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
.order_by(Project.title, Note.title)
|
||||||
|
)).all()
|
||||||
|
return [
|
||||||
|
{"idea_id": row.idea_id, "idea_title": idea_title,
|
||||||
|
"project_id": row.project_id, "project_title": project_title,
|
||||||
|
"owed_task_id": row.owed_task_id}
|
||||||
|
for row, idea_title, project_title in rows
|
||||||
|
if await access.can_read_project(user_id, row.project_id)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
OWED_CUE = ("Owed family adoptions were filed while this task was open (`family_owed`) — "
|
||||||
|
"name each in the report: it is work now waiting in that project.")
|
||||||
|
|
||||||
|
|
||||||
|
async def attach_owed_adoptions(user_id: int, data: dict, note) -> None:
|
||||||
|
"""Ride the owed answers filed since the task started on its closing
|
||||||
|
response — fail-open: a decoration never breaks the close it rides on."""
|
||||||
|
try:
|
||||||
|
owed = await owed_since(user_id, getattr(note, "started_at", None)
|
||||||
|
or getattr(note, "created_at", None))
|
||||||
|
if owed:
|
||||||
|
data["family_owed"] = owed
|
||||||
|
if "report_back" in data:
|
||||||
|
data["report_back"] = f"{data['report_back']} {OWED_CUE}"
|
||||||
|
except Exception:
|
||||||
|
logger.warning("owed adoptions unreadable for task %s", getattr(note, "id", None),
|
||||||
|
exc_info=True)
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ BUNDLED_SKILL_MOMENTS: dict[str, tuple[str, ...]] = {
|
|||||||
"verification": ("work.verify",),
|
"verification": ("work.verify",),
|
||||||
"shape-accounting": ("work.record",),
|
"shape-accounting": ("work.record",),
|
||||||
"reporting-back": ("reply.report",),
|
"reporting-back": ("reply.report",),
|
||||||
|
"family-canon": ("work.record",),
|
||||||
}
|
}
|
||||||
|
|
||||||
# A stored process arrives as the skill `scribe-proc-<slug>`
|
# A stored process arrives as the skill `scribe-proc-<slug>`
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""How family canon reaches a session (milestone 463 step 6), without a
|
||||||
|
database: the line enter_project carries, the skill and its triggers, and the
|
||||||
|
pointers every other surface gives to it. Retrieval reach, the readout's
|
||||||
|
counts and the owed answers against Postgres are in
|
||||||
|
tests/test_integration_family_reach.py.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
from scribe.services import moment_actions
|
||||||
|
from scribe.services.family_adoption import family_line
|
||||||
|
from tests.helpers import skill_text, tool_doc
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _cell(status="unassessed", *, in_scope=True, needs_recheck=False):
|
||||||
|
return {"status": status, "in_scope": in_scope, "needs_recheck": needs_recheck}
|
||||||
|
|
||||||
|
|
||||||
|
# --- the enter_project line ----------------------------------------------------------
|
||||||
|
|
||||||
|
def test_nothing_to_answer_is_no_line():
|
||||||
|
assert family_line(5, []) is None
|
||||||
|
assert family_line(5, [_cell("adopted"), _cell("exempt")]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_line_counts_each_ask_and_names_the_call_that_lists_it():
|
||||||
|
line = family_line(5, [
|
||||||
|
_cell(), _cell(), _cell("owed"), _cell("adopted", needs_recheck=True),
|
||||||
|
])
|
||||||
|
assert (line["unassessed"], line["owed"], line["needs_recheck"]) == (2, 1, 1)
|
||||||
|
assert line["line"].endswith("2 unassessed · 1 owed · 1 to recheck")
|
||||||
|
assert line["calls"] == {
|
||||||
|
"unassessed": 'list_family_adoptions(project_id=5, status="unassessed")',
|
||||||
|
"owed": 'list_family_adoptions(project_id=5, status="owed")',
|
||||||
|
"needs_recheck": "list_family_adoptions(project_id=5, needs_recheck=true)",
|
||||||
|
}
|
||||||
|
assert "family-canon" in line["answer_with"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_count_of_zero_names_no_call():
|
||||||
|
line = family_line(5, [_cell("owed")])
|
||||||
|
assert set(line["calls"]) == {"owed"} and "unassessed" not in line["line"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_answer_kept_as_history_off_the_platform_is_not_asked_again():
|
||||||
|
"""A row whose project left the idea's platforms stays as history; an
|
||||||
|
unassessed one there is not something this project is asked for."""
|
||||||
|
assert family_line(5, [_cell(in_scope=False)]) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- the skill ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _frontmatter(name: str) -> str:
|
||||||
|
text = (ROOT / "plugin" / "skills" / name / "SKILL.md").read_text()
|
||||||
|
return re.match(r"\A---\n(.*?)\n---\n", text, re.S).group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_family_canon_skill_ships_and_triggers_on_what_the_server_sends():
|
||||||
|
"""Its description is what makes a session load it, so it names every
|
||||||
|
key the server hands back about family canon."""
|
||||||
|
front = _frontmatter("family-canon")
|
||||||
|
assert "name: family-canon" in front
|
||||||
|
for trigger in ("family_hint", "`family`", "family_owed", "enter_project"):
|
||||||
|
assert trigger in front, trigger
|
||||||
|
assert moment_actions.BUNDLED_SKILL_MOMENTS["family-canon"] == ("work.record",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_skill_carries_the_reflexes_and_defers_the_lists_to_the_tools():
|
||||||
|
text = " ".join(skill_text("family-canon").split())
|
||||||
|
for phrase in ("one criterion with no support vetoes it",
|
||||||
|
"in the order `assess_family_adoption` lists them",
|
||||||
|
"every ground above it must be said not to apply",
|
||||||
|
"What counts as a reason", "The precedent reflex"):
|
||||||
|
assert phrase in text, phrase
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_plugin_names_the_skill_it_ships():
|
||||||
|
manifest = (ROOT / "plugin" / ".claude-plugin" / "plugin.json").read_text()
|
||||||
|
static = (ROOT / "plugin" / "hooks" / "scribe_static_context.md").read_text()
|
||||||
|
assert "family-canon" in manifest and "family-canon" in " ".join(static.split())
|
||||||
|
|
||||||
|
|
||||||
|
# --- the pointers ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_every_surface_that_meets_family_canon_points_at_the_skill():
|
||||||
|
server = (ROOT / "src" / "scribe" / "mcp" / "server.py").read_text()
|
||||||
|
index = re.search(r'_INSTRUCTIONS = """(.*?)"""', server, re.S).group(1)
|
||||||
|
assert "family-canon" in index and "`family`" in index
|
||||||
|
for module, tool in (("notes", "create_note"), ("snippets", "create_snippet"),
|
||||||
|
("shapes", "classify_shapes")):
|
||||||
|
assert "family-canon" in tool_doc(f"scribe.mcp.tools.{module}", tool), tool
|
||||||
|
assert "family_hint" in tool_doc("scribe.mcp.tools.notes", "create_note")
|
||||||
|
assert "`family`" in tool_doc("scribe.mcp.tools.projects", "enter_project")
|
||||||
|
assert "family_owed" in tool_doc("scribe.mcp.tools.tasks", "update_task")
|
||||||
|
assert "family_owed" in skill_text("reporting-back")
|
||||||
@@ -270,6 +270,15 @@ TOPICS: tuple[Topic, ...] = (
|
|||||||
Topic("a record you only cite still gets read",
|
Topic("a record you only cite still gets read",
|
||||||
"skill:reporting-back", ("next_step", "only mention"),
|
"skill:reporting-back", ("next_step", "only mention"),
|
||||||
"a record you only mention is a record to read"),
|
"a record you only mention is a record to read"),
|
||||||
|
# Milestone 463 step 6: family canon — when an idea is evaluated, how a
|
||||||
|
# project answers one, what a reason is, and the conflict order's reflex.
|
||||||
|
# The criteria, outcomes and grounds themselves are product text on the
|
||||||
|
# tools that enforce them; the skill owns when and how.
|
||||||
|
Topic("family canon: evaluate on a hint, answer in order, a reason is a fact",
|
||||||
|
"skill:family-canon",
|
||||||
|
("family_hint", "get_family_adoption", "resolve_family_conflict", "precedent"),
|
||||||
|
"write the reason so a session in another project can tell whether the same fact holds there",
|
||||||
|
index=("family-canon",)),
|
||||||
# ── per-tool contracts and in-band behaviour — owned by the server ──
|
# ── per-tool contracts and in-band behaviour — owned by the server ──
|
||||||
Topic("closing a task cues the report", "docstrings", ("report_back",), "reporting this to the operator?"),
|
Topic("closing a task cues the report", "docstrings", ("report_back",), "reporting this to the operator?"),
|
||||||
# The agent is the judge (#4208). Two topics, not one, because they fire
|
# The agent is the judge (#4208). Two topics, not one, because they fire
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""A family idea reaches every project on its platform, and no other
|
||||||
|
(milestone 463 step 6).
|
||||||
|
|
||||||
|
WHY THIS IS AN INTEGRATION TEST — the lesson-reach reasoning (#3730) holds
|
||||||
|
here too: the reach is one `OR` inside the search's project filter, and only
|
||||||
|
the ROWS that come back prove it. Every note embeds identically to the query,
|
||||||
|
so scoping is the only thing that can separate them. The embedder is stubbed;
|
||||||
|
no similarity is asserted, only membership.
|
||||||
|
|
||||||
|
The corpus: an idea written in android one, canon for the Android platform,
|
||||||
|
with a Kotlin and a Python reference. Android two writes Python, so it should
|
||||||
|
reach the idea and the Python reference only. The Go service is on no shared
|
||||||
|
platform and should reach none of it.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from scribe.models import async_session
|
||||||
|
from scribe.models.embedding import EMBEDDING_DIM, NoteEmbedding
|
||||||
|
from scribe.models.family import (
|
||||||
|
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform, FamilyIdeaReference,
|
||||||
|
Platform, ProjectPlatform,
|
||||||
|
)
|
||||||
|
from scribe.models.note import Note
|
||||||
|
from scribe.models.project import Project
|
||||||
|
from scribe.services import family_adoption as adoption_svc
|
||||||
|
from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_notes
|
||||||
|
from tests.helpers import ensure_user
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
||||||
|
|
||||||
|
QUERY_VEC = [1.0] + [0.0] * (EMBEDDING_DIM - 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def _platform(s, slug: str) -> int:
|
||||||
|
return await s.scalar(select(Platform.id).where(
|
||||||
|
Platform.slug == slug, Platform.deleted_at.is_(None)))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def corpus():
|
||||||
|
tag = uuid.uuid4().hex[:8]
|
||||||
|
async with async_session() as s:
|
||||||
|
owner = await ensure_user(s, f"family_reach_owner_{tag}")
|
||||||
|
await s.flush()
|
||||||
|
android, go = await _platform(s, "android-app"), await _platform(s, "go")
|
||||||
|
a = Project(user_id=owner.id, title="android one")
|
||||||
|
b = Project(user_id=owner.id, title="android two")
|
||||||
|
c = Project(user_id=owner.id, title="go service")
|
||||||
|
s.add_all([a, b, c])
|
||||||
|
await s.flush()
|
||||||
|
s.add_all([
|
||||||
|
ProjectPlatform(project_id=a.id, platform_id=android, state="declared"),
|
||||||
|
ProjectPlatform(project_id=b.id, platform_id=android, state="declared"),
|
||||||
|
ProjectPlatform(project_id=c.id, platform_id=go, state="declared"),
|
||||||
|
])
|
||||||
|
|
||||||
|
def snippet(project, title, language):
|
||||||
|
return Note(user_id=owner.id, project_id=project.id, note_type="snippet",
|
||||||
|
title=title, body=title, data={"language": language})
|
||||||
|
|
||||||
|
rows = {
|
||||||
|
"idea": Note(user_id=owner.id, project_id=a.id, note_type="note",
|
||||||
|
title="Signed APK lane", body="one keystore, in-place update"),
|
||||||
|
"kotlin_ref": snippet(a, "installUpdate (kotlin)", "kotlin"),
|
||||||
|
"python_ref": snippet(a, "install_update (python)", "python"),
|
||||||
|
"note_on_a": Note(user_id=owner.id, project_id=a.id, note_type="note",
|
||||||
|
title="An ordinary note", body="ordinary"),
|
||||||
|
"b_own": snippet(b, "b's own python helper", "python"),
|
||||||
|
}
|
||||||
|
s.add_all(rows.values())
|
||||||
|
await s.flush()
|
||||||
|
s.add(FamilyIdea(note_id=rows["idea"].id, status="canon",
|
||||||
|
applies_when="any Android app that ships its own APK"))
|
||||||
|
await s.flush()
|
||||||
|
s.add_all([
|
||||||
|
FamilyIdeaPlatform(note_id=rows["idea"].id, platform_id=android),
|
||||||
|
FamilyIdeaReference(idea_id=rows["idea"].id, snippet_id=rows["kotlin_ref"].id),
|
||||||
|
FamilyIdeaReference(idea_id=rows["idea"].id, snippet_id=rows["python_ref"].id),
|
||||||
|
])
|
||||||
|
for note in rows.values():
|
||||||
|
s.add(NoteEmbedding(
|
||||||
|
note_id=note.id, chunk_index=0, user_id=owner.id,
|
||||||
|
embedding=QUERY_VEC, chunk_text=note.title,
|
||||||
|
chunker_version=CHUNKER_VERSION, embedding_model=EMBEDDING_MODEL,
|
||||||
|
))
|
||||||
|
ids = {k: n.id for k, n in rows.items()}
|
||||||
|
ids.update(owner=owner.id, a=a.id, b=b.id, c=c.id)
|
||||||
|
await s.commit()
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
async def _search(uid, **kw):
|
||||||
|
with patch("scribe.services.embeddings.get_embedding", AsyncMock(return_value=QUERY_VEC)):
|
||||||
|
hits = await semantic_search_notes(uid, "how does the app update itself", limit=20, **kw)
|
||||||
|
return {note.id for _score, note in hits}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_idea_reaches_a_project_on_its_platform_with_the_reference_in_its_language(corpus):
|
||||||
|
found = await _search(corpus["owner"], project_id=corpus["b"], include_global_kinds=True)
|
||||||
|
assert {corpus["idea"], corpus["python_ref"], corpus["b_own"]} <= found
|
||||||
|
# Android two writes Python: the Kotlin reference stays with the idea.
|
||||||
|
assert corpus["kotlin_ref"] not in found
|
||||||
|
assert corpus["note_on_a"] not in found
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_idea_does_not_reach_a_project_on_no_shared_platform(corpus):
|
||||||
|
found = await _search(corpus["owner"], project_id=corpus["c"], include_global_kinds=True)
|
||||||
|
assert not found & {corpus["idea"], corpus["kotlin_ref"], corpus["python_ref"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_reach_is_off_unless_the_search_widens_the_project(corpus):
|
||||||
|
"""The near-duplicate gate searches a project without widening it; a
|
||||||
|
family idea there would block a create on a project it was never
|
||||||
|
written in."""
|
||||||
|
found = await _search(corpus["owner"], project_id=corpus["b"])
|
||||||
|
assert found == {corpus["b_own"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_with_no_reference_in_the_projects_language_every_reference_comes(corpus):
|
||||||
|
async with async_session() as s:
|
||||||
|
b_own = await s.get(Note, corpus["b_own"])
|
||||||
|
b_own.data = {"language": "dart"}
|
||||||
|
await s.commit()
|
||||||
|
reach = await adoption_svc.family_reach_ids(corpus["b"])
|
||||||
|
assert reach == {corpus["idea"], corpus["kotlin_ref"], corpus["python_ref"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_retired_idea_reaches_nobody(corpus):
|
||||||
|
async with async_session() as s:
|
||||||
|
(await s.get(FamilyIdea, corpus["idea"])).status = "retired"
|
||||||
|
await s.commit()
|
||||||
|
assert await adoption_svc.family_reach_ids(corpus["b"]) == set()
|
||||||
|
|
||||||
|
|
||||||
|
# --- the entry readout and the owed answers a close hands back -------------------
|
||||||
|
|
||||||
|
async def test_the_entry_readout_counts_what_the_project_has_to_answer(corpus):
|
||||||
|
line = await adoption_svc.family_readout(corpus["owner"], corpus["b"])
|
||||||
|
assert (line["unassessed"], line["owed"], line["needs_recheck"]) == (1, 0, 0)
|
||||||
|
assert line["calls"]["unassessed"] == (
|
||||||
|
f'list_family_adoptions(project_id={corpus["b"]}, status="unassessed")')
|
||||||
|
# Nothing reaches the Go service, so it carries no line at all.
|
||||||
|
assert await adoption_svc.family_readout(corpus["owner"], corpus["c"]) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_owed_since_lists_what_is_still_owed_and_only_since_then(corpus):
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
async with async_session() as s:
|
||||||
|
s.add(FamilyAdoption(project_id=corpus["b"], idea_id=corpus["idea"], status="owed",
|
||||||
|
reason="no in-place update yet", canon_version=1,
|
||||||
|
decided_via="agent"))
|
||||||
|
s.add(FamilyDecision(idea_id=corpus["idea"], project_id=corpus["b"], action="assess",
|
||||||
|
reason="no in-place update yet",
|
||||||
|
before={"status": "unassessed", "reason": "", "canon_version": None},
|
||||||
|
after={"status": "owed", "reason": "no in-place update yet",
|
||||||
|
"canon_version": 1},
|
||||||
|
evidence={}, precedent_ids=[], decided_via="agent",
|
||||||
|
user_id=corpus["owner"]))
|
||||||
|
await s.commit()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
[owed] = await adoption_svc.owed_since(corpus["owner"], now - timedelta(minutes=5))
|
||||||
|
assert (owed["idea_id"], owed["project_id"], owed["project_title"]) == (
|
||||||
|
corpus["idea"], corpus["b"], "android two")
|
||||||
|
assert await adoption_svc.owed_since(corpus["owner"], now + timedelta(minutes=5)) == []
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Tests for fable_*_project tools."""
|
"""Tests for fable_*_project tools."""
|
||||||
|
import contextlib
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -48,6 +49,16 @@ def _no_coverage():
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_family():
|
||||||
|
"""enter_project reads the family-canon readout (milestone 463 step 6) —
|
||||||
|
no database here, so the common case is stubbed: nothing to answer. The
|
||||||
|
populated key is asserted in its own test below."""
|
||||||
|
with patch("scribe.mcp.tools.projects.family_adoption_svc.family_readout",
|
||||||
|
AsyncMock(return_value=None)) as mock:
|
||||||
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _no_background_seed():
|
def _no_background_seed():
|
||||||
"""enter_project now fire-and-forgets a coverage self-seed (#2802). These
|
"""enter_project now fire-and-forgets a coverage self-seed (#2802). These
|
||||||
@@ -483,3 +494,33 @@ def test_inception_routes_and_tool_are_registered():
|
|||||||
# Rules left inception with subscriptions (milestone 414).
|
# Rules left inception with subscriptions (milestone 414).
|
||||||
assert "subscribe_rulebooks" not in tool.parameters.get("properties", {})
|
assert "subscribe_rulebooks" not in tool.parameters.get("properties", {})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enter_project_carries_the_family_line_only_when_it_asks_something(_no_family):
|
||||||
|
"""The `family` key is attached when the project has family canon to
|
||||||
|
answer, and absent otherwise — a key that usually says null trains
|
||||||
|
readers to skip it (#2483)."""
|
||||||
|
line = {"line": "family canon on this project's platforms: 2 unassessed",
|
||||||
|
"unassessed": 2, "owed": 0, "needs_recheck": 0,
|
||||||
|
"calls": {"unassessed": 'list_family_adoptions(project_id=5, status="unassessed")'}}
|
||||||
|
|
||||||
|
async def enter():
|
||||||
|
with contextlib.ExitStack() as stack:
|
||||||
|
for cm in _enter_project_stubs(fake_project(id=5)):
|
||||||
|
stack.enter_context(cm)
|
||||||
|
return await enter_project(project_id=5)
|
||||||
|
|
||||||
|
assert "family" not in await enter()
|
||||||
|
_no_family.return_value = line
|
||||||
|
assert (await enter())["family"] == line
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_failing_family_readout_never_fails_the_handshake(_no_family):
|
||||||
|
_no_family.side_effect = RuntimeError("database down")
|
||||||
|
with contextlib.ExitStack() as stack:
|
||||||
|
for cm in _enter_project_stubs(fake_project(id=5)):
|
||||||
|
stack.enter_context(cm)
|
||||||
|
out = await enter_project(project_id=5)
|
||||||
|
assert "family" not in out and out["project"]["id"] == 5
|
||||||
|
|||||||
@@ -16,15 +16,17 @@ pytestmark = pytest.mark.usefixtures("_bind_user")
|
|||||||
_PREF = {"id": 41, "title": "Say how it was checked", "statement": "…", "kind": "preference"}
|
_PREF = {"id": 41, "title": "Say how it was checked", "statement": "…", "kind": "preference"}
|
||||||
|
|
||||||
|
|
||||||
async def _update(prefs=None, **kwargs):
|
async def _update(prefs=None, owed=None, **kwargs):
|
||||||
from scribe.mcp.tools.tasks import update_task
|
from scribe.mcp.tools.tasks import update_task
|
||||||
|
|
||||||
note = MagicMock(id=5, user_id=7, project_id=3)
|
note = MagicMock(id=5, user_id=7, project_id=3, started_at="2026-10-06T09:00:00+00:00")
|
||||||
note.to_dict.return_value = {"id": 5}
|
note.to_dict.return_value = {"id": 5}
|
||||||
lookup = AsyncMock(return_value=list(prefs or []))
|
lookup = AsyncMock(return_value=list(prefs or []))
|
||||||
with patch("scribe.mcp.tools.tasks.notes_svc.update_note", AsyncMock(return_value=note)), \
|
with patch("scribe.mcp.tools.tasks.notes_svc.update_note", AsyncMock(return_value=note)), \
|
||||||
patch("scribe.mcp.tools.tasks.systems_tools.attach_systems", AsyncMock()), \
|
patch("scribe.mcp.tools.tasks.systems_tools.attach_systems", AsyncMock()), \
|
||||||
patch("scribe.mcp.tools.tasks.placement_svc.attach_placement", AsyncMock()), \
|
patch("scribe.mcp.tools.tasks.placement_svc.attach_placement", AsyncMock()), \
|
||||||
|
patch("scribe.mcp.tools.tasks.family_adoption_svc.owed_since",
|
||||||
|
AsyncMock(return_value=list(owed or []))), \
|
||||||
patch("scribe.mcp.tools.tasks.reply_prefs_svc.completion_preferences", lookup):
|
patch("scribe.mcp.tools.tasks.reply_prefs_svc.completion_preferences", lookup):
|
||||||
return await update_task(task_id=5, **kwargs), lookup
|
return await update_task(task_id=5, **kwargs), lookup
|
||||||
|
|
||||||
@@ -56,3 +58,22 @@ async def test_no_preferences_means_no_key_and_the_plain_cue():
|
|||||||
out, _ = await _update(prefs=[], status="done")
|
out, _ = await _update(prefs=[], status="done")
|
||||||
assert "reply_preferences" not in out
|
assert "reply_preferences" not in out
|
||||||
assert out["report_back"] == REPORT_BACK_CUE
|
assert out["report_back"] == REPORT_BACK_CUE
|
||||||
|
|
||||||
|
|
||||||
|
_OWED = {"idea_id": 9, "idea_title": "Signed APK lane", "project_id": 4,
|
||||||
|
"project_title": "android two", "owed_task_id": 77}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_closing_names_the_owed_adoptions_filed_while_the_task_was_open():
|
||||||
|
"""The finishing moment of milestone 463 step 6: owed family work filed
|
||||||
|
into a project during this task comes back for the report to name."""
|
||||||
|
from scribe.services.family_adoption import OWED_CUE
|
||||||
|
|
||||||
|
out, _ = await _update(owed=[_OWED], status="done")
|
||||||
|
assert out["family_owed"] == [_OWED]
|
||||||
|
assert out["report_back"].endswith(OWED_CUE) and "family_owed" in out["report_back"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_owed_adoptions_means_no_key():
|
||||||
|
out, _ = await _update(owed=[], status="done")
|
||||||
|
assert "family_owed" not in out
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ def _enter_stubs(project, milestones: list[dict], tasks: list, *, rules=None, sy
|
|||||||
AsyncMock(return_value=design)),
|
AsyncMock(return_value=design)),
|
||||||
patch("scribe.mcp.tools.projects.coverage_svc.cached_coverage",
|
patch("scribe.mcp.tools.projects.coverage_svc.cached_coverage",
|
||||||
AsyncMock(return_value=None)),
|
AsyncMock(return_value=None)),
|
||||||
|
patch("scribe.mcp.tools.projects.family_adoption_svc.family_readout",
|
||||||
|
AsyncMock(return_value=None)),
|
||||||
patch("scribe.mcp.tools.projects.spawn"),
|
patch("scribe.mcp.tools.projects.spawn"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user