diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index e6751cf..64bfa12 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "scribe", "description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.", - "version": "0.1.28", + "version": "0.1.29", "author": { "name": "Bryan Van Deusen" }, "mcpServers": { "scribe": { diff --git a/plugin/hooks/scribe_prior_art.sh b/plugin/hooks/scribe_prior_art.sh index 027315b..69cf351 100755 --- a/plugin/hooks/scribe_prior_art.sh +++ b/plugin/hooks/scribe_prior_art.sh @@ -196,6 +196,21 @@ if [ -n "$body" ]; then fi fi +# ARM 1½ — the RECORD nudge (#2664). The local arm just proved the thing being +# written already exists elsewhere in this repo, and Scribe returned no record +# of anything for it. That is the one moment "record it" is earned rather than +# noise: the duplication is demonstrated, not guessed. Gated on BOTH sides so +# an ordinary new helper (no other copies) and an already-recorded one (the +# server spoke) stay nudge-free — a reflex that fires on everything is one +# that gets skipped. An unreachable server counts as "nothing recorded": the +# local finding needed no server, and the nudge fails open with it. +if [ -n "$local_lines" ]; then + n_recorded=$(printf '%s' "$body" | jq -r '.note_ids | length' 2>/dev/null) || n_recorded=0 + if [ "${n_recorded:-0}" = "0" ] || [ "$n_recorded" = "" ]; then + local_context="${local_context}"$'\n'"> None of those existing copies is recorded in Scribe. If the version being written is the canonical one — or this edit is consolidating the copies — record it now with create_snippet (name, code, when-to-reach-for-it, location) so the next session is offered it instead of writing another copy." + fi +fi + # Local first. It answers "this already EXISTS", which is a stronger claim than # "this resembles something recorded" — and it is the one the recorded arms are # structurally unable to make. diff --git a/plugin/hooks/scribe_static_context.md b/plugin/hooks/scribe_static_context.md index 9bd8493..82936d4 100644 --- a/plugin/hooks/scribe_static_context.md +++ b/plugin/hooks/scribe_static_context.md @@ -52,11 +52,16 @@ for the operator's work, and as your own working memory across sessions. it. An untagged project record carries the `systems_hint` question instead, on creates, updates, and work-logs alike — treat it as the tagging question asked at the moment of work, not as noise to skip past. -- **Reuse before rebuilding** — before writing a new helper/utility/component, - search recorded **snippets** (reusable code recorded once for recall) and - reuse the prior art instead of re-solving it; when you build something - reusable, record it with `create_snippet` (name, code, when-to-reach-for-it, - location) so a later session is offered it, not left to write it again. +- **Reuse before rebuilding — and record what you build** — before writing a + new helper/utility/component, search recorded **snippets** (reusable code + recorded once for recall) and reuse the prior art instead of re-solving it. + The recording half has NAMED TRIGGERS, not a vibe: the moment you extract a + shared component, hoist a helper into a common module, or notice you are + writing the second copy of anything, record it with `create_snippet` (name, + code, when-to-reach-for-it, location) in the same breath as the commit. + Work that "refactors X into a shared Y" is not finished until Y is recorded + — an unrecorded shared component is invisible to every later session, which + is how a codebase grows four `.btn-primary` definitions. - Do **not** keep the operator's rules, plans, or project notes in local memory / CLAUDE.md in parallel with Scribe — Scribe holds the single copy. - **Compact at clean seams** — because you record as you go, a context diff --git a/tests/test_instruction_surfaces_agree.py b/tests/test_instruction_surfaces_agree.py index 6e851fb..b5e4017 100644 --- a/tests/test_instruction_surfaces_agree.py +++ b/tests/test_instruction_surfaces_agree.py @@ -128,6 +128,25 @@ def test_floor_states_the_systems_reflex(): ) +def test_floor_names_the_snippet_recording_triggers(): + """The recording half of reuse needs NAMED trigger moments on the floor. + + #2664's behavioral finding: with recording guidance as a trailing clause of + the reuse bullet, zero snippets were ever recorded outside sessions already + thinking about snippets — extracting a shared component (Roundtable's + BaseModal) produced task prose and no record. The floor must name the + moments, not just the tool. + """ + floor = (ROOT / "plugin" / "hooks" / "scribe_static_context.md").read_text() + for needle in ("create_snippet", "second copy"): + assert needle in floor, ( + f"plugin/hooks/scribe_static_context.md no longer states the " + f"snippet-recording trigger ({needle!r}) — the record-as-you-build " + f"reflex must be stated on the floor with its trigger moments " + f"(#2664)." + ) + + # Topics displaced from _INSTRUCTIONS when it was cut to fit the fold. Each # must remain stated on at least one DELIVERED surface: a tool docstring # (arrives with the tool schema), the plugin static context (always arrives), diff --git a/tests/test_write_path_trigger.py b/tests/test_write_path_trigger.py index 6f709b5..8de1a11 100644 --- a/tests/test_write_path_trigger.py +++ b/tests/test_write_path_trigger.py @@ -789,3 +789,53 @@ def test_plugin_version_bumped_with_the_hook(): manifest = json.loads((PLUGIN / ".claude-plugin" / "plugin.json").read_text()) version = tuple(int(p) for p in manifest["version"].split(".")) assert version >= (0, 1, 18) + + +def test_hook_nudges_recording_when_copies_exist_but_nothing_is_recorded(tmp_path): + """#2664: the local arm proves duplication; when Scribe has no record of it, + the same context block must ask for create_snippet — the one moment the + recording nudge is earned rather than noise. An unreachable server counts + as "nothing recorded": the local finding needed no server, and the nudge + fails open with it (here: a refused connection stands in for the instance).""" + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + (repo / "a.py").write_text("def debounce(fn):\n return fn\n") + # git grep searches the index, so the existing copy must be staged. + subprocess.run(["git", "add", "."], cwd=repo, check=True) + out = subprocess.run( + ["bash", str(HOOK)], + input=json.dumps({ + "session_id": "s-nudge", "cwd": str(repo), "tool_name": "Write", + "tool_input": {"file_path": str(repo / "b.py"), + "content": "def debounce(fn):\n return fn\n"}, + }), + capture_output=True, text=True, + env={"PATH": "/usr/bin:/bin", "SCRIBE_URL": "http://127.0.0.1:9", + "SCRIBE_TOKEN": "t"}, + ) + assert out.returncode == 0 + ctx = json.loads(out.stdout)["hookSpecificOutput"]["additionalContext"] + assert "already defined" in ctx # the duplication finding + assert "create_snippet" in ctx # the recording ask riding it + + +def test_hook_stays_quiet_about_recording_when_nothing_is_duplicated(tmp_path): + """A brand-new helper with no other copies earns no nudge — a reflex that + fires on every Write is one sessions learn to skip.""" + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + out = subprocess.run( + ["bash", str(HOOK)], + input=json.dumps({ + "session_id": "s-quiet", "cwd": str(repo), "tool_name": "Write", + "tool_input": {"file_path": str(repo / "b.py"), + "content": "def debounce(fn):\n return fn\n"}, + }), + capture_output=True, text=True, + env={"PATH": "/usr/bin:/bin", "SCRIBE_URL": "http://127.0.0.1:9", + "SCRIBE_TOKEN": "t"}, + ) + assert out.returncode == 0 + assert "create_snippet" not in out.stdout