Moment proposals count only unexplained opens; the shape check hears every definition #216
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "grimoire",
|
"name": "grimoire",
|
||||||
"description": "Grimoire for Claude Code: connects the grimoire MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Grimoire skills (using-grimoire, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting, family-canon), and syncs your saved Grimoire Processes as skills (/grimoire:sync).",
|
"description": "Grimoire for Claude Code: connects the grimoire MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Grimoire skills (using-grimoire, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting, family-canon), and syncs your saved Grimoire Processes as skills (/grimoire:sync).",
|
||||||
"version": "2026.10.22.1945",
|
"version": "2026.10.26.2029",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Bryan Van Deusen"
|
"name": "Bryan Van Deusen"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -119,10 +119,11 @@ combined=""
|
|||||||
n_files=0
|
n_files=0
|
||||||
while IFS= read -r rel_path; do
|
while IFS= read -r rel_path; do
|
||||||
[ -n "${rel_path:-}" ] || continue
|
[ -n "${rel_path:-}" ] || continue
|
||||||
# A Bash call that rewrote many files is a refactor or a generator, not a
|
# Every changed file goes into the end-of-turn ledger, up to forty — a bound
|
||||||
# shape being instantiated; four is enough to name what matters.
|
# on this hook's run time, past which the call was a generator. The arms
|
||||||
|
# below speak for the first four only (see the check after the ledger).
|
||||||
n_files=$((n_files + 1))
|
n_files=$((n_files + 1))
|
||||||
[ "$n_files" -le 4 ] || break
|
[ "$n_files" -le 40 ] || break
|
||||||
file_path="$repo_root/$rel_path"
|
file_path="$repo_root/$rel_path"
|
||||||
|
|
||||||
# The code just written: the ADDED lines of the uncommitted diff for a
|
# The code just written: the ADDED lines of the uncommitted diff for a
|
||||||
@@ -136,14 +137,19 @@ while IFS= read -r rel_path; do
|
|||||||
fresh="new"
|
fresh="new"
|
||||||
fi
|
fi
|
||||||
[ -n "$code" ] || continue
|
[ -n "$code" ] || continue
|
||||||
# `|| true` inside: an early-exiting `head` must not void its own output.
|
all_names=$(printf '%s' "$code" | grimoire_defs | sort -u || true)
|
||||||
names=$(printf '%s' "$code" | grimoire_defs | sort -u | head -12 || true)
|
# What this write defined, for the end-of-turn question: every definition,
|
||||||
# What this write defined, for the end-of-turn question —
|
|
||||||
# recorded before the arms below, which skip a write that defines nothing.
|
# recorded before the arms below, which skip a write that defines nothing.
|
||||||
printf '%s\n' "$names" | grimoire_written_append "$safe_sid" "$rel_path" "$fresh" "$code"
|
printf '%s\n' "$all_names" | grimoire_written_append "$safe_sid" "$rel_path" "$fresh" "$code"
|
||||||
# Nothing DEFINED in what was written (prose, data, a call-site edit) →
|
# Nothing DEFINED in what was written (prose, data, a call-site edit) →
|
||||||
# nothing to say; the arms are about shapes.
|
# nothing to say; the arms are about shapes.
|
||||||
[ -n "$names" ] || continue
|
[ -n "$all_names" ] || continue
|
||||||
|
# A Bash call that rewrote many files is a refactor or a generator, not a
|
||||||
|
# shape being instantiated; four files and twelve names per file are enough
|
||||||
|
# for the arms to name what matters, and bound their git grep and URL.
|
||||||
|
[ "$n_files" -le 4 ] || continue
|
||||||
|
# `|| true` inside: an early-exiting `head` must not void its own output.
|
||||||
|
names=$(printf '%s\n' "$all_names" | head -12 || true)
|
||||||
|
|
||||||
local_lines=$(grimoire_local_dups "$repo_root" "$rel_path" <<< "$names") || local_lines=""
|
local_lines=$(grimoire_local_dups "$repo_root" "$rel_path" <<< "$names") || local_lines=""
|
||||||
local_context=""
|
local_context=""
|
||||||
|
|||||||
@@ -79,10 +79,16 @@ fi
|
|||||||
|
|
||||||
# ARM 1 — BY NAME, LOCALLY: does a definition of this already exist
|
# ARM 1 — BY NAME, LOCALLY: does a definition of this already exist
|
||||||
# in the repo? (grimoire_local_dups in grimoire_defs.sh carries the why.)
|
# in the repo? (grimoire_local_dups in grimoire_defs.sh carries the why.)
|
||||||
|
#
|
||||||
|
# `all_names` is every definition the write carries; it feeds the end-of-turn
|
||||||
|
# ledger, which must hear about all of them. `names` is the first twelve, and
|
||||||
|
# bounds the queries below — a git grep and a URL per name.
|
||||||
|
all_names=""
|
||||||
names=""
|
names=""
|
||||||
if [ -n "$code" ]; then
|
if [ -n "$code" ]; then
|
||||||
|
all_names=$(printf '%s' "$code" | grimoire_defs | sort -u || true)
|
||||||
# `|| true` inside: an early-exiting `head` must not void its own output.
|
# `|| true` inside: an early-exiting `head` must not void its own output.
|
||||||
names=$(printf '%s' "$code" | grimoire_defs | sort -u | head -12 || true)
|
names=$(printf '%s\n' "$all_names" | head -12 || true)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local_lines=""
|
local_lines=""
|
||||||
@@ -204,10 +210,11 @@ if [ -n "$session_id" ]; then
|
|||||||
# Write onto a path that does not exist yet creates a file, which may be a
|
# Write onto a path that does not exist yet creates a file, which may be a
|
||||||
# unit in its own right (see grimoire_written_append). Written before any
|
# unit in its own right (see grimoire_written_append). Written before any
|
||||||
# server call: the ask at the end of the turn must not depend on this
|
# server call: the ask at the end of the turn must not depend on this
|
||||||
# write's hint having been answered.
|
# write's hint having been answered. Every definition, not the capped list
|
||||||
|
# the queries use; an Edit that names none records its enclosing one.
|
||||||
fresh=""
|
fresh=""
|
||||||
[ -e "$file_path" ] || fresh="new"
|
[ -e "$file_path" ] || fresh="new"
|
||||||
printf '%s\n' "$shapes" | grimoire_written_append "$safe_sid" "$rel_path" "$fresh" "$code"
|
printf '%s\n' "${all_names:-$shapes}" | grimoire_written_append "$safe_sid" "$rel_path" "$fresh" "$code"
|
||||||
idfile="$state_dir/${safe_sid}.ids"
|
idfile="$state_dir/${safe_sid}.ids"
|
||||||
syncfile="$state_dir/${safe_sid}.sync.ids"
|
syncfile="$state_dir/${safe_sid}.sync.ids"
|
||||||
derivefile="$state_dir/${safe_sid}.derive.ids"
|
derivefile="$state_dir/${safe_sid}.derive.ids"
|
||||||
|
|||||||
@@ -72,19 +72,29 @@ printf '%s\n' "$rule_id" | grimoire_rules_append "$state_dir/${safe_sid}.opened.
|
|||||||
|
|
||||||
# ── What came just before the open ───────────────────────────────────────
|
# ── What came just before the open ───────────────────────────────────────
|
||||||
#
|
#
|
||||||
# grimoire_moment.sh writes every call to `grimoire-moment/<sid>.acts`. The ones
|
# grimoire_moment.sh writes every call to `grimoire-moment/<sid>.acts`. The
|
||||||
# from the last three minutes go to the server with this rule's id: a rule
|
# last three, from the last ninety seconds, go to the server with this rule's
|
||||||
# opened just after a moment fired is evidence it belongs on that moment, and
|
# id: a rule the agent went and opened right after a moment fired is evidence
|
||||||
# once that repeats across sessions the server answers with one line asking
|
# it belongs on that moment, and once that repeats across sessions the server
|
||||||
# the reader to offer the operator the mount. Nothing is mounted by this.
|
# answers with one line putting the question to the reader. Nothing is
|
||||||
|
# mounted by this.
|
||||||
|
#
|
||||||
|
# An open of a rule a hook has just NAMED is not reported. A ranked arm or a
|
||||||
|
# moment line put it in front of the reader, and that explains the open; the
|
||||||
|
# moment beside it did not, so counting it would propose mounts on whatever
|
||||||
|
# moment happened to fire while search was surfacing the rule. The window is
|
||||||
|
# short for the same reason: a few minutes of agent work reaches many moments.
|
||||||
command -v curl >/dev/null 2>&1 || exit 0
|
command -v curl >/dev/null 2>&1 || exit 0
|
||||||
grimoire_config || exit 0
|
grimoire_config || exit 0
|
||||||
|
case ",$(grimoire_rules_live "$state_dir/${safe_sid}.rules.ids")," in
|
||||||
|
*",${rule_id},"*) exit 0 ;;
|
||||||
|
esac
|
||||||
acts_file="${TMPDIR:-/tmp}/grimoire-moment/${safe_sid}.acts"
|
acts_file="${TMPDIR:-/tmp}/grimoire-moment/${safe_sid}.acts"
|
||||||
[ -f "$acts_file" ] || exit 0
|
[ -f "$acts_file" ] || exit 0
|
||||||
now=$(date +%s 2>/dev/null) || exit 0
|
now=$(date +%s 2>/dev/null) || exit 0
|
||||||
acts=$(awk -F '\t' -v since=$((now - 180)) \
|
acts=$(awk -F '\t' -v since=$((now - 90)) \
|
||||||
'$1 + 0 >= since { sub(/^[^\t]*\t/, ""); print }' "$acts_file" 2>/dev/null \
|
'$1 + 0 >= since { sub(/^[^\t]*\t/, ""); print }' "$acts_file" 2>/dev/null \
|
||||||
| tail -n 30 | paste -sd ',' - 2>/dev/null)
|
| tail -n 3 | paste -sd ',' - 2>/dev/null)
|
||||||
[ -n "$acts" ] || exit 0
|
[ -n "$acts" ] || exit 0
|
||||||
|
|
||||||
sid_esc=$(printf '%s' "$session_id" | grimoire_json_escape) || exit 0
|
sid_esc=$(printf '%s' "$session_id" | grimoire_json_escape) || exit 0
|
||||||
|
|||||||
@@ -73,12 +73,16 @@ than a mount is fixed through its trigger instead.
|
|||||||
|
|
||||||
## When a line proposes a mount
|
## When a line proposes a mount
|
||||||
|
|
||||||
A rule that keeps being opened just after the same moment, across several
|
A rule that sessions keep going and opening just after the same moment —
|
||||||
sessions, probably belongs on that moment. A line says so, naming the rule,
|
without a hook having shown it to them first — may belong on that moment. A
|
||||||
the moment and how often. It is a question for the operator, not a change you
|
line says so, naming the rule, the moment, the action that reached it and how
|
||||||
make: offer it in one line, and record their answer with
|
often. That is co-occurrence, not a recommendation, so judge it before it
|
||||||
`judge_rule_moments` — `confirm` mounts the rule, `reject` with their reason
|
reaches the operator: read the rule's trigger against what the moment means
|
||||||
stops the question being asked again.
|
(`list_moments`). Offer it only when the rule governs that point in the work
|
||||||
|
whatever the work is about, as a question with your reason; a rule about a
|
||||||
|
subject (a library, a file, a style) is reached by meaning, and the line is
|
||||||
|
left alone. Record their answer with `judge_rule_moments` — `confirm` mounts
|
||||||
|
the rule, `reject` with their reason stops the question being asked again.
|
||||||
|
|
||||||
The same tool answers proposals from a pass over the rules
|
The same tool answers proposals from a pass over the rules
|
||||||
(`rules_to_mount`, `propose_rule_moments`, `rule_moment_proposals`): a pass
|
(`rules_to_mount`, `propose_rule_moments`, `rule_moment_proposals`): a pass
|
||||||
|
|||||||
@@ -494,17 +494,50 @@ def _keep(row: RuleMomentJudgment, note: str, now: datetime) -> None:
|
|||||||
# ── The signal ───────────────────────────────────────────────────────────────
|
# ── The signal ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
# How many distinct actions a signal pair remembers as having reached its
|
||||||
|
# moment. Enough to show the operator what the opens followed.
|
||||||
|
_SIGNAL_ACTIONS_CAP = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _reached_phrase(evidence: dict) -> str:
|
||||||
|
reached = (evidence or {}).get("reached_by") or {}
|
||||||
|
if not isinstance(reached, dict) or not reached:
|
||||||
|
return ""
|
||||||
|
top = sorted(reached.items(), key=lambda kv: (-int(kv[1] or 0), kv[0]))[:3]
|
||||||
|
return " (reached by " + ", ".join(f"`{match}`" for match, _n in top) + ")"
|
||||||
|
|
||||||
|
|
||||||
|
def _signal_note(moment: str, evidence: dict) -> str:
|
||||||
|
"""The `why` a signal proposal shows the operator: what the opens followed."""
|
||||||
|
counts = lesson_rules.evidence_summary(evidence)
|
||||||
|
return (
|
||||||
|
f"Opened just after `{moment}`{_reached_phrase(evidence)} in "
|
||||||
|
f"{counts['situations']} distinct sessions, each time without a hook having "
|
||||||
|
f"shown the rule first. Co-occurrence only: confirm it if the rule governs "
|
||||||
|
f"that point in the work whatever the work is about."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _proposal_line(rule: Rule, moment: str, evidence: dict) -> str:
|
def _proposal_line(rule: Rule, moment: str, evidence: dict) -> str:
|
||||||
|
"""The line an open past the bar returns to the session.
|
||||||
|
|
||||||
|
It says what the evidence is, and makes offering it conditional on the
|
||||||
|
reader's own reading: a session relays an unqualified "offer this" as a
|
||||||
|
recommendation, and co-occurrence is not one.
|
||||||
|
"""
|
||||||
counts = lesson_rules.evidence_summary(evidence)
|
counts = lesson_rules.evidence_summary(evidence)
|
||||||
kind = rule.kind or "rule"
|
kind = rule.kind or "rule"
|
||||||
return (
|
return (
|
||||||
f"> {kind.capitalize()} #{rule.id} \"{rule.title}\" has been opened just after "
|
f"> {kind.capitalize()} #{rule.id} \"{rule.title}\" was opened just after "
|
||||||
f"`{moment}` in {counts['situations']} distinct sessions — it may belong on "
|
f"`{moment}`{_reached_phrase(evidence)} in {counts['situations']} distinct "
|
||||||
f"that moment, where it would arrive without being searched for. Offer the "
|
f"sessions, each time without a hook having shown it first. That is "
|
||||||
f"operator the mount in one line; on their yes, `judge_rule_moments("
|
f"co-occurrence, not a recommendation. Read the {kind}'s trigger against what "
|
||||||
f"[{{\"rule_id\": {rule.id}, \"moment\": \"{moment}\", \"verdict\": \"confirm\", "
|
f"`{moment}` means (`list_moments`): only if the {kind} governs that point in "
|
||||||
f"\"note\": \"why\"}}])` mounts it; on a no, `\"reject\"` with their why stops "
|
f"the work whatever the work is about, ask the operator whether to mount it, "
|
||||||
f"the asking."
|
f"with your reason; if it is about a subject, leave it. On their yes, "
|
||||||
|
f"`judge_rule_moments([{{\"rule_id\": {rule.id}, \"moment\": \"{moment}\", "
|
||||||
|
f"\"verdict\": \"confirm\", \"note\": \"why\"}}])` mounts it; on a no, "
|
||||||
|
f"`\"reject\"` with their why stops the asking."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -521,24 +554,39 @@ def signal_moments(moments) -> list[str]:
|
|||||||
|
|
||||||
async def co_occurred(
|
async def co_occurred(
|
||||||
user_id: int, rule_id: int, moments, *, situation: str,
|
user_id: int, rule_id: int, moments, *, situation: str,
|
||||||
project_id: int | None = None,
|
project_id: int | None = None, reached: dict | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Record that a rule was opened shortly after these moments fired, and
|
"""Record that a rule was opened shortly after these moments fired, and
|
||||||
return a proposal line once a (rule, moment) pair has earned one.
|
return a proposal line once a (rule, moment) pair has earned one.
|
||||||
|
|
||||||
`situation` is what makes two opens distinct — the session, so a rule
|
`situation` is what makes two opens distinct — the session, so a rule
|
||||||
opened after every push in one long session counts once. A pair already
|
opened after every push in one long session counts once. A pair already
|
||||||
mounted or judged gathers nothing. One proposal per open at most. Fails
|
judged gathers nothing, and neither does a rule already mounted anywhere:
|
||||||
open: this rides a hook, and bookkeeping must never break the read.
|
it arrives by lookup at its own moments, so an open of it is far likelier
|
||||||
|
to follow that delivery than to reveal a missing moment. `reached` maps a
|
||||||
|
moment to the action that reached it, kept as the proposal's evidence.
|
||||||
|
One proposal per open at most. Fails open: this rides a hook, and
|
||||||
|
bookkeeping must never break the read.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return await _co_occurred(user_id, rule_id, moments, situation, project_id)
|
return await _co_occurred(user_id, rule_id, moments, situation, project_id,
|
||||||
|
reached or {})
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("rule/moment co-occurrence could not be recorded", exc_info=True)
|
logger.warning("rule/moment co-occurrence could not be recorded", exc_info=True)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
async def _co_occurred(user_id, rule_id, moments, situation, project_id) -> str:
|
def _add_reached(evidence: dict, match: str) -> dict:
|
||||||
|
"""`evidence` with one more open counted against the action that reached
|
||||||
|
the moment, keeping at most _SIGNAL_ACTIONS_CAP distinct actions."""
|
||||||
|
reached = dict(evidence.get("reached_by") or {})
|
||||||
|
match = (match or "").strip()[:80]
|
||||||
|
if match and (match in reached or len(reached) < _SIGNAL_ACTIONS_CAP):
|
||||||
|
reached[match] = int(reached.get(match) or 0) + 1
|
||||||
|
return {**evidence, "reached_by": reached}
|
||||||
|
|
||||||
|
|
||||||
|
async def _co_occurred(user_id, rule_id, moments, situation, project_id, reached) -> str:
|
||||||
wanted = signal_moments(moments)
|
wanted = signal_moments(moments)
|
||||||
key = lesson_rules.situation_key("s", situation)
|
key = lesson_rules.situation_key("s", situation)
|
||||||
rid = _int(rule_id)
|
rid = _int(rule_id)
|
||||||
@@ -551,11 +599,10 @@ async def _co_occurred(user_id, rule_id, moments, situation, project_id) -> str:
|
|||||||
rule = owned.get(rid)
|
rule = owned.get(rid)
|
||||||
if rule is None:
|
if rule is None:
|
||||||
return ""
|
return ""
|
||||||
mounted = (await _mounts(session, [rid])).get(rid, set())
|
if (await _mounts(session, [rid])).get(rid):
|
||||||
|
return ""
|
||||||
existing = await _rows(session, [rid])
|
existing = await _rows(session, [rid])
|
||||||
for moment in wanted:
|
for moment in wanted:
|
||||||
if moment in mounted:
|
|
||||||
continue
|
|
||||||
row = existing.get((rid, moment))
|
row = existing.get((rid, moment))
|
||||||
if row is not None and row.state != SUGGESTED:
|
if row is not None and row.state != SUGGESTED:
|
||||||
continue
|
continue
|
||||||
@@ -563,11 +610,13 @@ async def _co_occurred(user_id, rule_id, moments, situation, project_id) -> str:
|
|||||||
row = RuleMomentJudgment(rule_id=rid, moment=moment, state=SUGGESTED, source="signal")
|
row = RuleMomentJudgment(rule_id=rid, moment=moment, state=SUGGESTED, source="signal")
|
||||||
session.add(row)
|
session.add(row)
|
||||||
ev = lesson_rules.add_evidence(row.evidence, key, project_id, now)
|
ev = lesson_rules.add_evidence(row.evidence, key, project_id, now)
|
||||||
|
ev = _add_reached(ev, reached.get(moment, ""))
|
||||||
if proposal is None and lesson_rules.proposal_due(ev, now):
|
if proposal is None and lesson_rules.proposal_due(ev, now):
|
||||||
ev["proposed_at"] = now.isoformat()
|
ev["proposed_at"] = now.isoformat()
|
||||||
ev["proposed_count"] = int(ev.get("proposed_count") or 0) + 1
|
ev["proposed_count"] = int(ev.get("proposed_count") or 0) + 1
|
||||||
proposal = (moment, ev)
|
proposal = (moment, ev)
|
||||||
row.evidence = ev
|
row.evidence = ev
|
||||||
|
row.note = _signal_note(moment, ev)
|
||||||
try:
|
try:
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
@@ -599,6 +648,7 @@ async def opened_after(
|
|||||||
from grimoire.services.moment_actions import moments_for
|
from grimoire.services.moment_actions import moments_for
|
||||||
|
|
||||||
reached: list[str] = []
|
reached: list[str] = []
|
||||||
|
reached_by: dict[str, str] = {}
|
||||||
for act in list(acts or [])[:_ACTS_CAP]:
|
for act in list(acts or [])[:_ACTS_CAP]:
|
||||||
if not isinstance(act, dict):
|
if not isinstance(act, dict):
|
||||||
continue
|
continue
|
||||||
@@ -615,11 +665,13 @@ async def opened_after(
|
|||||||
name = hit.get("moment") if isinstance(hit, dict) else None
|
name = hit.get("moment") if isinstance(hit, dict) else None
|
||||||
if name and name not in reached:
|
if name and name not in reached:
|
||||||
reached.append(name)
|
reached.append(name)
|
||||||
|
reached_by[name] = str(hit.get("match") or tool)
|
||||||
moments = signal_moments(reached)
|
moments = signal_moments(reached)
|
||||||
context = ""
|
context = ""
|
||||||
if moments and (session_id or "").strip():
|
if moments and (session_id or "").strip():
|
||||||
context = await co_occurred(
|
context = await co_occurred(
|
||||||
user_id, rule_id, moments, situation=session_id.strip(), project_id=project_id,
|
user_id, rule_id, moments, situation=session_id.strip(), project_id=project_id,
|
||||||
|
reached=reached_by,
|
||||||
)
|
)
|
||||||
return {"moments": moments, "context": context}
|
return {"moments": moments, "context": context}
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,23 @@ def test_after_write_names_what_bash_just_wrote_then_stays_quiet_until_the_next_
|
|||||||
assert seen[0]["exclude_derive"] == ["dup:483a"]
|
assert seen[0]["exclude_derive"] == ["dup:483a"]
|
||||||
assert set(seen[0]["shapes"][0].split(",")) == {"css:log-empty", "css:other"}
|
assert set(seen[0]["shapes"][0].split(",")) == {"css:log-empty", "css:other"}
|
||||||
|
|
||||||
|
def test_after_write_records_every_definition_for_the_turn_but_queries_only_the_first_four_files(tmp_path):
|
||||||
|
"""The end-of-turn ledger hears every file and every definition a Bash call
|
||||||
|
wrote; the prior-art arms stay bounded to four files and twelve names."""
|
||||||
|
with http_sink(SINK_REPLY) as (port, seen):
|
||||||
|
env = _env(tmp_path, url=f"http://127.0.0.1:{port}")
|
||||||
|
repo = _repo(tmp_path, env)
|
||||||
|
for n in range(6):
|
||||||
|
body = "".join(f"def f{n}_{i:02d}():\n return {i}\n\n" for i in range(15))
|
||||||
|
(repo / f"gen{n}.py").write_text(body)
|
||||||
|
_run(repo, env, session="s-many")
|
||||||
|
ledger = (tmp_path / "grimoire-priorart" / "s-many.written.ids").read_text().splitlines()
|
||||||
|
assert len(ledger) == 6 * 15
|
||||||
|
assert "gen5.py\tsym\tf5_14" in ledger
|
||||||
|
assert len(seen) == 4
|
||||||
|
assert all(len(q["shapes"][0].split(",")) == 12 for q in seen)
|
||||||
|
|
||||||
|
|
||||||
def test_after_write_is_silent_where_it_has_nothing_to_say(tmp_path):
|
def test_after_write_is_silent_where_it_has_nothing_to_say(tmp_path):
|
||||||
env = _env(tmp_path)
|
env = _env(tmp_path)
|
||||||
repo = _repo(tmp_path, env)
|
repo = _repo(tmp_path, env)
|
||||||
|
|||||||
@@ -195,7 +195,23 @@ async def test_the_open_resolves_acts_through_the_installs_mappings(world):
|
|||||||
out = await judgments_svc.opened_after(uid, done.id, acts, session_id="s1")
|
out = await judgments_svc.opened_after(uid, done.id, acts, session_id="s1")
|
||||||
assert "work.deliver" in out["moments"]
|
assert "work.deliver" in out["moments"]
|
||||||
assert "work.run" not in out["moments"]
|
assert "work.run" not in out["moments"]
|
||||||
assert (await _row(done.id, "work.deliver")).source == "signal"
|
row = await _row(done.id, "work.deliver")
|
||||||
|
assert row.source == "signal"
|
||||||
|
# The action that reached the moment is kept, and shown as the why.
|
||||||
|
assert row.evidence["reached_by"] == {"git push": 1}
|
||||||
|
assert row.note.startswith("Opened just after `work.deliver` (reached by `git push`)")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_rule_already_mounted_gathers_no_signal(world):
|
||||||
|
"""A mounted rule arrives by lookup at its own moments, so an open of it
|
||||||
|
is no evidence it is missing another."""
|
||||||
|
uid, done = world["uid"], world["done"]
|
||||||
|
await rulebooks_svc.set_rule_moments(done.id, uid, ["work.finish"])
|
||||||
|
for situation in ("a", "b", "c", "d"):
|
||||||
|
assert await judgments_svc.co_occurred(
|
||||||
|
uid, done.id, ["work.verify"], situation=situation,
|
||||||
|
) == ""
|
||||||
|
assert await _row(done.id, "work.verify") is None
|
||||||
|
|
||||||
|
|
||||||
# ── The misfire ──────────────────────────────────────────────────────────────
|
# ── The misfire ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -61,6 +61,29 @@ def test_the_proposal_line_names_the_tool_that_answers_it():
|
|||||||
assert "judge_rule_moments" in line and '"reject"' in line
|
assert "judge_rule_moments" in line and '"reject"' in line
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_proposal_line_is_a_question_to_check_not_advice_to_relay():
|
||||||
|
rule = SimpleNamespace(id=11, title="Definition of done", kind="rule")
|
||||||
|
evidence = {"situations": ["a", "b", "c"],
|
||||||
|
"reached_by": {"git push": 3, "git push --tags": 1}}
|
||||||
|
line = svc._proposal_line(rule, "work.deliver", evidence)
|
||||||
|
assert "co-occurrence, not a recommendation" in line
|
||||||
|
assert "`list_moments`" in line and "if it is about a subject, leave it" in line
|
||||||
|
assert "(reached by `git push`, `git push --tags`)" in line
|
||||||
|
note = svc._signal_note("work.deliver", evidence)
|
||||||
|
assert note.startswith("Opened just after `work.deliver` (reached by `git push`")
|
||||||
|
assert "3 distinct sessions" in note
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_actions_a_signal_remembers_are_bounded():
|
||||||
|
ev: dict = {}
|
||||||
|
for i in range(svc._SIGNAL_ACTIONS_CAP + 3):
|
||||||
|
ev = svc._add_reached(ev, f"cmd {i}")
|
||||||
|
ev = svc._add_reached(ev, "cmd 0")
|
||||||
|
assert len(ev["reached_by"]) == svc._SIGNAL_ACTIONS_CAP
|
||||||
|
assert ev["reached_by"]["cmd 0"] == 2
|
||||||
|
assert svc._add_reached({}, "")["reached_by"] == {}
|
||||||
|
|
||||||
|
|
||||||
# ── The misfire ──────────────────────────────────────────────────────────────
|
# ── The misfire ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,49 @@ def test_an_open_sends_the_window_and_prints_the_answer(sandbox):
|
|||||||
assert (tmp / "grimoire-priorart" / "s1.opened.ids").read_text().startswith("11\t")
|
assert (tmp / "grimoire-priorart" / "s1.opened.ids").read_text().startswith("11\t")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_window_is_the_last_three_acts(sandbox):
|
||||||
|
tmp, env = sandbox
|
||||||
|
ledger = tmp / "grimoire-moment"
|
||||||
|
ledger.mkdir()
|
||||||
|
now = int(time.time())
|
||||||
|
acts = [{"tool_name": "Bash", "tool_input": {"command": f"step {i}"}} for i in range(5)]
|
||||||
|
(ledger / "s1.acts").write_text(
|
||||||
|
"".join(f"{now - 10 + i}\t{json.dumps(a)}\n" for i, a in enumerate(acts))
|
||||||
|
)
|
||||||
|
_run("grimoire_record_opened.sh",
|
||||||
|
{"session_id": "s1", "tool_name": "mcp__grimoire__get_rule",
|
||||||
|
"tool_input": {"rule_id": 11}}, env)
|
||||||
|
body = json.loads((tmp / "curl.body").read_text())
|
||||||
|
assert body["acts"] == acts[-3:]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_open_of_a_rule_a_hook_just_named_is_not_reported(sandbox):
|
||||||
|
"""A ranked arm or a moment line explains that open; the moment beside it
|
||||||
|
does not, so it is no evidence for a mount."""
|
||||||
|
tmp, env = sandbox
|
||||||
|
ledger = tmp / "grimoire-moment"
|
||||||
|
ledger.mkdir()
|
||||||
|
now = int(time.time())
|
||||||
|
(ledger / "s1.acts").write_text(
|
||||||
|
f"{now - 5}\t{json.dumps({'tool_name': 'Bash', 'tool_input': {'command': 'git push'}})}\n"
|
||||||
|
)
|
||||||
|
named = tmp / "grimoire-priorart"
|
||||||
|
named.mkdir()
|
||||||
|
(named / "s1.rules.ids").write_text(f"7\t{now - 30}\n11\t{now - 20}\n")
|
||||||
|
out = _run("grimoire_record_opened.sh",
|
||||||
|
{"session_id": "s1", "tool_name": "mcp__grimoire__get_rule",
|
||||||
|
"tool_input": {"rule_id": 11}}, env)
|
||||||
|
assert out.stdout == ""
|
||||||
|
assert not (tmp / "curl.log").exists()
|
||||||
|
# The open itself is still recorded.
|
||||||
|
assert (named / "s1.opened.ids").read_text().startswith("11\t")
|
||||||
|
# A rule no hook named is still reported.
|
||||||
|
_run("grimoire_record_opened.sh",
|
||||||
|
{"session_id": "s1", "tool_name": "mcp__grimoire__get_rule",
|
||||||
|
"tool_input": {"rule_id": 1}}, env)
|
||||||
|
assert json.loads((tmp / "curl.body").read_text())["rule_id"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_an_open_with_nothing_in_the_window_sends_nothing(sandbox):
|
def test_an_open_with_nothing_in_the_window_sends_nothing(sandbox):
|
||||||
tmp, env = sandbox
|
tmp, env = sandbox
|
||||||
ledger = tmp / "grimoire-moment"
|
ledger = tmp / "grimoire-moment"
|
||||||
|
|||||||
@@ -103,6 +103,19 @@ def test_an_edit_to_an_existing_file_adds_no_file_line(tmp_path):
|
|||||||
assert lines == ["lib.py\tsym\thelper"]
|
assert lines == ["lib.py\tsym\thelper"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_write_with_many_definitions_notes_every_one(tmp_path):
|
||||||
|
"""The ledger is not bounded by the twelve names the prior-art query sends:
|
||||||
|
a new test file with twenty functions is asked about in full."""
|
||||||
|
with http_sink(b'{"note_ids":[]}') as (port, _seen):
|
||||||
|
env = _env(tmp_path, f"http://127.0.0.1:{port}")
|
||||||
|
repo = _repo(tmp_path)
|
||||||
|
body = "".join(f"def test_case_{i:02d}():\n assert {i}\n\n" for i in range(20))
|
||||||
|
_pre_write(env, repo, "test_many.py", body)
|
||||||
|
lines = _ledger(tmp_path).read_text().splitlines()
|
||||||
|
assert len(lines) == 20
|
||||||
|
assert "test_many.py\tsym\ttest_case_19" in lines
|
||||||
|
|
||||||
|
|
||||||
def test_a_skipped_path_notes_nothing(tmp_path):
|
def test_a_skipped_path_notes_nothing(tmp_path):
|
||||||
with http_sink(b'{"note_ids":[]}') as (port, _seen):
|
with http_sink(b'{"note_ids":[]}') as (port, _seen):
|
||||||
env = _env(tmp_path, f"http://127.0.0.1:{port}")
|
env = _env(tmp_path, f"http://127.0.0.1:{port}")
|
||||||
|
|||||||
Reference in New Issue
Block a user