From 7113985debbab630fd01d8e77f7967f6d0764975 Mon Sep 17 00:00:00 2001 From: Sebastian Gonzalez Date: Mon, 21 Sep 2026 12:26:11 -0700 Subject: [PATCH] fix(harness): the authoring child is never captured or sensed (v0.74.1) `run_author` runs `claude -p --resume --fork-session`. That is a NEW session id whose transcript copies the person's whole history. The child was launched with MEMHUB_HARNESS_CHILD=1 ("its hooks stay silent"), but nothing read that flag: - flush_turn / flush_session shipped every fork as another conversation under the parent's title. On staging one session reached 80 rows. - The child's EXTRACT=0 is overridden by a settings.json `env` value. Installs that opted in with MEMHUB_HARNESS_EXTRACT=1 re-armed the sensor in every child, so forks classified their own turns, drained the repo's moments, and spawned more forks. Now MEMHUB_HARNESS_CHILD gates both transcript-capture scripts (transcript_filter.is_harness_child) and both harness switches (harness_extract.extract_enabled, rulebook_hook.harness_extract_on). The child's rulebook hook still runs, for the forward test. Co-Authored-By: Claude Opus 5 (1M context) --- .../memhub-staging/.claude-plugin/plugin.json | 2 +- .../memhub-staging/.codex-plugin/plugin.json | 2 +- plugins/memhub-staging/.mcp.json | 2 +- plugins/memhub/.claude-plugin/plugin.json | 2 +- plugins/memhub/.codex-plugin/plugin.json | 2 +- plugins/memhub/.cursor-plugin/plugin.json | 2 +- plugins/memhub/.mcp.json | 2 +- plugins/memhub/mcp.json | 2 +- plugins/memhub/plugin.json | 2 +- plugins/memhub/scripts/flush_session.py | 3 ++ plugins/memhub/scripts/flush_turn.py | 3 ++ plugins/memhub/scripts/harness_extract.py | 8 +++ plugins/memhub/scripts/harness_stop.py | 10 +++- plugins/memhub/scripts/rulebook_hook.py | 2 + plugins/memhub/scripts/transcript_filter.py | 17 +++++++ tests/flush_session_test.py | 33 ++++++++++++ tests/flush_turn_test.py | 34 +++++++++++++ tests/harness_extract_test.py | 14 ++++++ tests/harness_stop_test.py | 50 +++++++++++++++++++ 19 files changed, 181 insertions(+), 11 deletions(-) diff --git a/plugins/memhub-staging/.claude-plugin/plugin.json b/plugins/memhub-staging/.claude-plugin/plugin.json index 407ec99f..c92ef7c3 100644 --- a/plugins/memhub-staging/.claude-plugin/plugin.json +++ b/plugins/memhub-staging/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memhub-staging", "description": "STAGING build of the MemHub plugin \u2014 identical behavior to `memhub` but pointed at the staging backend (api.staging.memhub.xtrace.ai) for MemHub developers iterating on the service. Skills, hooks, and scripts are shared with `memhub` (symlinked), so the two never drift; only the backend URL and OAuth client differ. Install this instead of `memhub` \u2014 never both (both register an MCP server named `memhub`). Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point.", - "version": "0.74.0", + "version": "0.74.1", "license": "Apache-2.0", "hooks": "./hooks/claude-hooks.json", "author": { diff --git a/plugins/memhub-staging/.codex-plugin/plugin.json b/plugins/memhub-staging/.codex-plugin/plugin.json index 394c2f24..1cb94f48 100644 --- a/plugins/memhub-staging/.codex-plugin/plugin.json +++ b/plugins/memhub-staging/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memhub-staging", "description": "MemHub staging for Codex: team memory and Rulebook hooks. Use the setup skill to verify the active hook route and credential. Repository shell commands in projectless tasks must include an explicit cd prefix when the host omits workdir. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point.", - "version": "0.74.0", + "version": "0.74.1", "license": "Apache-2.0", "author": { "name": "XTrace", diff --git a/plugins/memhub-staging/.mcp.json b/plugins/memhub-staging/.mcp.json index 393af657..05504e61 100644 --- a/plugins/memhub-staging/.mcp.json +++ b/plugins/memhub-staging/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "memhub": { "type": "http", - "url": "https://api.staging.memhub.xtrace.ai/mcp-server/mcp?memhub_plugin_version=0.74.0", + "url": "https://api.staging.memhub.xtrace.ai/mcp-server/mcp?memhub_plugin_version=0.74.1", "auth": { "CLIENT_ID": "mYTjrWldX9ZFGtDES3hQDEHSis9gIjiq" }, diff --git a/plugins/memhub/.claude-plugin/plugin.json b/plugins/memhub/.claude-plugin/plugin.json index 644f6c8c..5724f754 100644 --- a/plugins/memhub/.claude-plugin/plugin.json +++ b/plugins/memhub/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memhub", "description": "Auto-capture Claude Code sessions into MemHub team memory. A Stop hook ships each turn as it happens \u2014 sending only the transcript bytes written since the last successful flush, so a session is captured even if it never reaches a clean exit \u2014 alongside flushes on commit/PR events (PostToolUse hooks) and a SessionEnd backstop. The memhub MCP runs tool-aware (agentic) extraction of facts, episodes, and artifacts with watermark-based delta dedup, batching extraction so per-turn capture doesn't fragment episodes. A PreToolUse hook surfaces situated team directives (lessons/procedures) before each Edit/Write/Bash by firing the memhub recall_directives symbol-tripwire on the in-flight call and injecting any hits as context (a client-side precision gate drops directives whose triggers don't concretely match the call, e.g. an over-broad repo-name trigger). Includes skills for: plugin install/health (setup), plugin authentication with its own hook credential (login), repo brain creation and seeding (onboard), terminal artifact uploads (save-artifact), on-demand session import (import-session), team-memory recall (search-memory), teammate session handoff (handoff-session), git-authored spec development with ownership frontmatter (spec), Rulebook rule authoring (create-rule), rules proposed from CLAUDE.md and past sessions in one run (start-rulebook), and PR babysitting (pr-babysit) \u2014 a hook arms a self-paced loop after `gh pr create` that fixes review-bot findings and saves the fixing process to the repo's agent brain. Rules live in RULEBOOKS \u2014 containers with their own membership, so a rule reaches exactly the people its book binds, and one person can be bound by several (their org's book plus their team's): the authoring skills resolve which book a rule lands in and offer to create one when there is none, the hooks cache every book that binds you, and when two books fire on one call the wider book's rule is the one the per-call cap keeps. A PostToolUse hook reminds authors when an edited file belongs to a git-authored spec. Spec ownership frontmatter replaces the retired artifact map; the backend maintains read-only mirrors and opens bootstrap or audit PRs. Sessions are named with the title their own host shows \u2014 Claude Code's generated title, or on Codex the thread_name Codex generated (from the rollout, else ~/.codex/session_index.jsonl), passed through verbatim so MemHub's sessions list matches the editor's; a session its host never named falls back to a title derived from its first prompt \u2014 and route into the repo's own agent brain via a per-user room cache (~/.config/memhub-plugin/rooms.json, never written into the repo) \u2014 resolved once by /memhub:onboard and read by every writer, including the two automatic capture paths, so team memory lands in the repo's room instead of personal memory. Session \u2194 PR linking: after a call that addresses GitHub (`gh pr \u2026`, a `curl`/`gh api` REST request, or a GitHub MCP tool) whose output names exactly one pull request, a PostToolUse hook asks the backend one question and injects one instruction \u2014 a session that ran `gh pr create` \u2014 or the GitHub MCP create tool \u2014 links itself unconditionally (opening it is work the session did, and a PR has many sessions), while every other GitHub call, a hand-rolled `curl` POST included, leaves the authorship judgment to the agent, and an org with no GitHub integration is told once that connecting it is what links sessions to shipped code. The hook is stateless apart from one short-lived negative answer (an enabled org with GitHub disconnected, cached 30 minutes per credential, and never inferred from a reply that merely says the feature is off) and degrades to silence on every failure. Two skills complete it: link-pr (link this or a named session by hand \u2014 also the answer for a PR opened by a script or CI helper, which is deliberately not detected) and find-contributing-sessions (scan this machine's local session history for the sessions that wrote a PR's code, rank the candidates by the evidence that matched, and link the ones you approve). Every rulebook fire is now disclosed to the user in a fixed shape on two channels: the hook's own terminal line leads with `\ud83d\udccf Rule fired: ` (`\u26d4\ufe0f` when a gate actually blocked the call) above the detail line it already showed, and the agent is told to echo that same byte-identical line at the top of its reply so the fire reaches the transcript everything downstream reads. Session start now says what rules ARE \u2014 standing instructions from teammates carrying CLAUDE.md's weight \u2014 before listing them. The agent records ONE work type for a pull request it links (feat, fix, chore, docs, perf, refactor, other), and only when the PR has none yet \u2014 the first label is permanent, and asking again would cost the link, not just the type. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point.", - "version": "0.74.0", + "version": "0.74.1", "license": "Apache-2.0", "hooks": "./hooks/claude-hooks.json", "author": { diff --git a/plugins/memhub/.codex-plugin/plugin.json b/plugins/memhub/.codex-plugin/plugin.json index cffa3c72..c5dc65cd 100644 --- a/plugins/memhub/.codex-plugin/plugin.json +++ b/plugins/memhub/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memhub", "description": "XTrace MemHub team memory in Codex: automatic session capture, situated directive recall, artifact sync, plus skills for setup, login, onboarding, artifact upload, session import, search, handoff, spec-driven development, Rulebook rule authoring (create-rule) and a team's first Rulebook — tested starter rules fitted to the repo, and rules proposed from CLAUDE.md + past sessions (start-rulebook), and PR babysitting. A rule lives in a RULEBOOK \u2014 a container with its own membership, so it reaches exactly the people that book binds; the authoring skills resolve which book it lands in and offer to create one when there is none. Run memhub:setup to verify hook routing and authentication. Current desktop builds support bundled hooks; older hosts need the user bridge. Bundled handlers defer to matching installed user bridge handlers, which require trust. Codex's own threads are not captured as your sessions: spawned subagents, guardian action-reviews and memory consolidation are recognised from the rollout's thread_source and skipped by name, while every other kind \u2014 including a product surface we have not seen, since Codex's ThreadSource type is open-ended \u2014 is captured, because refusing an unknown kind would silently and unrecoverably drop real work. Every rule fire is reported under the same namespaced session id capture uploads, so a fire is linked to the Codex session it fired in. Session \u2194 PR linking: after a call that addresses GitHub (`gh pr \u2026`, a `curl`/`gh api` REST request, or a GitHub MCP tool) whose output names exactly one pull request, a PostToolUse hook asks the backend one question and injects one instruction \u2014 a session that ran `gh pr create` \u2014 or the GitHub MCP create tool \u2014 links itself unconditionally (opening it is work the session did, and a PR has many sessions), while every other GitHub call, a hand-rolled `curl` POST included, leaves the authorship judgment to the agent, and an org with no GitHub integration is told once that connecting it is what links sessions to shipped code. The hook is stateless apart from one short-lived negative answer (an enabled org with GitHub disconnected, cached 30 minutes per credential, and never inferred from a reply that merely says the feature is off) and degrades to silence on every failure. Two skills complete it: link-pr (link this or a named session by hand \u2014 also the answer for a PR opened by a script or CI helper, which is deliberately not detected) and find-contributing-sessions (scan this machine's local session history for the sessions that wrote a PR's code, rank the candidates by the evidence that matched, and link the ones you approve). The agent records ONE work type for a pull request it links (feat, fix, chore, docs, perf, refactor, other), and only when the PR has none yet \u2014 the first label is permanent, and asking again would cost the link, not just the type. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point.", - "version": "0.74.0", + "version": "0.74.1", "license": "Apache-2.0", "author": { "name": "XTrace", diff --git a/plugins/memhub/.cursor-plugin/plugin.json b/plugins/memhub/.cursor-plugin/plugin.json index 35ac427b..d98aa304 100644 --- a/plugins/memhub/.cursor-plugin/plugin.json +++ b/plugins/memhub/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "memhub", "displayName": "MemHub", "description": "XTrace MemHub team memory in Cursor: search_memory / save_artifact / import_conversation tools plus skills for setup, login, onboarding, artifact upload, session import, recall, handoff, spec-driven development, Rulebook rule authoring (create-rule) and a team's first Rulebook — tested starter rules fitted to the repo, and rules proposed from CLAUDE.md + past sessions (start-rulebook), and PR babysitting. A rule lives in a RULEBOOK \u2014 a container with its own membership, so it reaches exactly the people that book binds; the authoring skills resolve which book it lands in and offer to create one when there is none. Sessions are captured automatically. Session \u2194 PR linking ships as skills on Cursor: link-pr and find-contributing-sessions. Cursor's shell hooks are observational \u2014 `afterShellExecution` defines no reply the agent can see \u2014 so the automatic post-`gh pr create` link that Claude Code and Codex get is not available here. The agent records ONE work type for a pull request it links (feat, fix, chore, docs, perf, refactor, other), and only when the PR has none yet \u2014 the first label is permanent, and asking again would cost the link, not just the type. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point.", - "version": "0.74.0", + "version": "0.74.1", "license": "Apache-2.0", "author": { "name": "XTrace" diff --git a/plugins/memhub/.mcp.json b/plugins/memhub/.mcp.json index f6deebb3..55e0232f 100644 --- a/plugins/memhub/.mcp.json +++ b/plugins/memhub/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "memhub": { "type": "http", - "url": "https://api.memhub.xtrace.ai/mcp-server/mcp?memhub_plugin_version=0.74.0", + "url": "https://api.memhub.xtrace.ai/mcp-server/mcp?memhub_plugin_version=0.74.1", "auth": { "CLIENT_ID": "xHoQkYd7uNUfaNX6Tyv143e1Ev7u3XS0" }, diff --git a/plugins/memhub/mcp.json b/plugins/memhub/mcp.json index 3431ac71..9e16b15d 100644 --- a/plugins/memhub/mcp.json +++ b/plugins/memhub/mcp.json @@ -3,7 +3,7 @@ "mcpServers": { "memhub": { "type": "streamable-http", - "url": "https://api.memhub.xtrace.ai/mcp-server/mcp?memhub_plugin_version=0.74.0" + "url": "https://api.memhub.xtrace.ai/mcp-server/mcp?memhub_plugin_version=0.74.1" } } } diff --git a/plugins/memhub/plugin.json b/plugins/memhub/plugin.json index bc97c8e0..bd9cf0d3 100644 --- a/plugins/memhub/plugin.json +++ b/plugins/memhub/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "memhub", "description": "XTrace MemHub team memory: search_memory / save_artifact / import_conversation MCP tools plus skills for setup, login, onboarding, artifact upload, session import, team-memory recall, session handoff, spec-driven development, Rulebook rule authoring (create-rule) and a team's first Rulebook — tested starter rules fitted to the repo, and rules proposed from CLAUDE.md + past sessions (start-rulebook), and PR babysitting. A rule lives in a RULEBOOK \u2014 a container with its own membership, so it reaches exactly the people that book binds; the authoring skills resolve which book it lands in and offer to create one when there is none. Session auto-capture ships through the host-native plugin layer (hooks are outside the Agent Plugins spec). Session \u2194 PR linking: after a call that addresses GitHub (`gh pr \u2026`, a `curl`/`gh api` REST request, or a GitHub MCP tool) whose output names exactly one pull request, a PostToolUse hook asks the backend one question and injects one instruction \u2014 a session that ran `gh pr create` \u2014 or the GitHub MCP create tool \u2014 links itself unconditionally (opening it is work the session did, and a PR has many sessions), while every other GitHub call, a hand-rolled `curl` POST included, leaves the authorship judgment to the agent, and an org with no GitHub integration is told once that connecting it is what links sessions to shipped code. The hook is stateless apart from one short-lived negative answer (an enabled org with GitHub disconnected, cached 30 minutes per credential, and never inferred from a reply that merely says the feature is off) and degrades to silence on every failure. Two skills complete it: link-pr (link this or a named session by hand \u2014 also the answer for a PR opened by a script or CI helper, which is deliberately not detected) and find-contributing-sessions (scan this machine's local session history for the sessions that wrote a PR's code, rank the candidates by the evidence that matched, and link the ones you approve). The agent records ONE work type for a pull request it links (feat, fix, chore, docs, perf, refactor, other), and only when the PR has none yet \u2014 the first label is permanent, and asking again would cost the link, not just the type. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point.", - "version": "0.74.0", + "version": "0.74.1", "license": "Apache-2.0", "author": { "name": "XTrace", diff --git a/plugins/memhub/scripts/flush_session.py b/plugins/memhub/scripts/flush_session.py index 2e714242..7a5fe2f1 100644 --- a/plugins/memhub/scripts/flush_session.py +++ b/plugins/memhub/scripts/flush_session.py @@ -65,6 +65,7 @@ from transcript_filter import ( # noqa: E402 drop_command_wrappers, elide_oversized_tool_results, + is_harness_child, ) @@ -591,6 +592,8 @@ def _auth_required(e: BaseException) -> bool: def main() -> int: + if is_harness_child(): + return 0 # the harness's forked copy of a session; see is_harness_child # Bound BEFORE the try, because the handler reads them. Assigned inside it, # any failure earlier in the block — a malformed stdin payload is enough — # would make the handler itself raise NameError, and this script's one hard diff --git a/plugins/memhub/scripts/flush_turn.py b/plugins/memhub/scripts/flush_turn.py index 3136c146..f2d06082 100644 --- a/plugins/memhub/scripts/flush_turn.py +++ b/plugins/memhub/scripts/flush_turn.py @@ -72,6 +72,7 @@ from transcript_filter import ( # noqa: E402 elide_oversized_tool_results, is_command_wrapper, + is_harness_child, ) # All at module scope now. These used to be deferred into :func:`_flush` @@ -1075,6 +1076,8 @@ class _NoCredential(RuntimeError): def main() -> int: + if is_harness_child(): + return 0 # the harness's forked copy of a session; see is_harness_child lock_fd: int | None = None # Bound BEFORE the try so the handler can always write a breadcrumb. Reading # stdin or parsing it is itself a failure path, and a NameError raised from diff --git a/plugins/memhub/scripts/harness_extract.py b/plugins/memhub/scripts/harness_extract.py index 4fb4b437..9903cad6 100644 --- a/plugins/memhub/scripts/harness_extract.py +++ b/plugins/memhub/scripts/harness_extract.py @@ -46,6 +46,7 @@ HERE = Path(__file__).resolve().parent FLAG = "MEMHUB_HARNESS_EXTRACT" +CHILD_FLAG = "MEMHUB_HARNESS_CHILD" # set by harness_stop.run_author _ON = ("1", "on", "true", "yes") _OFF = ("0", "off", "false", "no") @@ -72,6 +73,13 @@ def extract_enabled(environ=None) -> bool: and not one this function should relitigate — but it is why the off switch has to keep working for every spelling someone reaches for.""" env = os.environ if environ is None else environ + if str(env.get(CHILD_FLAG, "")).strip().lower() not in ("",) + _OFF: + # An authoring child is never sensed, whatever FLAG says. `run_author` + # sets FLAG=0 for it, but Claude Code applies a settings.json `env` + # OVER the environment a process inherits. So an install that opted in + # with FLAG=1 there re-armed the lane in every child. The child's own + # turns were classified and drained, and one fork spawned the next. + return False return str(env.get(FLAG, "")).strip().lower() not in _OFF diff --git a/plugins/memhub/scripts/harness_stop.py b/plugins/memhub/scripts/harness_stop.py index 02e9d8e0..59435121 100644 --- a/plugins/memhub/scripts/harness_stop.py +++ b/plugins/memhub/scripts/harness_stop.py @@ -689,8 +689,14 @@ def run_author(session: str, moment: dict, repo: str, mcp_cfg: Path) -> tuple[st return "failed", {"detail": "the moment carries no session id to resume"} with tempfile.TemporaryDirectory(prefix="memhub-drain-") as scratch: env = dict(os.environ, - MEMHUB_HARNESS_CHILD="1", # its hooks stay silent - MEMHUB_HARNESS_EXTRACT="0", # and it senses nothing + # Its capture and harness lanes stay silent: the forked + # transcript is a copy of the person's, and must neither + # ship as a second conversation nor be sensed again. The + # two lanes read this, not EXTRACT below, because a + # settings.json `env` overrides what the child inherits. + # Its rulebook hook still runs, for the forward test. + MEMHUB_HARNESS_CHILD="1", + MEMHUB_HARNESS_EXTRACT="0", # its forward test arms a candidate in ITS OWN base MEMHUB_RULEBOOK_BASE=os.path.join(scratch, "rulebook")) argv = [claude_bin(), "-p", author_prompt(session, moment, repo), diff --git a/plugins/memhub/scripts/rulebook_hook.py b/plugins/memhub/scripts/rulebook_hook.py index a9272022..224dceee 100644 --- a/plugins/memhub/scripts/rulebook_hook.py +++ b/plugins/memhub/scripts/rulebook_hook.py @@ -3375,6 +3375,8 @@ def harness_extract_on(environ=None): """Default ON, matching `harness_extract.extract_enabled` — the two gates are one switch and must not disagree about what it says.""" env = os.environ if environ is None else environ + if str(env.get("MEMHUB_HARNESS_CHILD", "")).strip().lower() not in ("", "0", "off", "false", "no"): + return False # an authoring child is never sensed (see extract_enabled) return str(env.get(HARNESS_FLAG, "")).strip().lower() not in ("0", "off", "false", "no") diff --git a/plugins/memhub/scripts/transcript_filter.py b/plugins/memhub/scripts/transcript_filter.py index 733f0401..f1f72f2c 100644 --- a/plugins/memhub/scripts/transcript_filter.py +++ b/plugins/memhub/scripts/transcript_filter.py @@ -23,6 +23,7 @@ from __future__ import annotations import json +import os import re # The wrappers the client emits around a slash command: the invocation, the @@ -287,3 +288,19 @@ def _hard_trim_block(block, keep: int): HARD_MAX_RECORD_BYTES)}} return {"type": "text", "text": _elision_note(_size(block), None, HARD_MAX_RECORD_BYTES)} + + +# Set on the headless `claude -p --resume --fork-session` that +# `harness_stop.run_author` spawns. Its transcript is a COPY of the person's +# session under a NEW session id, so shipping it lands the person's whole +# history a second time as a separate conversation under the same title, and +# every later pass adds another. It was measured on staging at 80 copies of one +# session. The child is the plugin's own work, never the person's, so no path +# may capture it. +HARNESS_CHILD_ENV = "MEMHUB_HARNESS_CHILD" + + +def is_harness_child(environ=None) -> bool: + env = os.environ if environ is None else environ + return str(env.get(HARNESS_CHILD_ENV, "")).strip().lower() not in ( + "", "0", "off", "false", "no") diff --git a/tests/flush_session_test.py b/tests/flush_session_test.py index f4cc432b..a8d5d4ce 100644 --- a/tests/flush_session_test.py +++ b/tests/flush_session_test.py @@ -371,6 +371,39 @@ def read(self): any("skipped" in line for line in lines)) +# The harness's authoring pass forks the person's session under a NEW id +# (`claude -p --resume --fork-session`). Its SessionEnd and commit +# flushes shipped that copy as another conversation, once per pass. The same +# payload without the flag must reach the flush, or this proves nothing. + + +def _child_run(flag): + calls = [] + + async def fake_flush(session_id, transcript_path): + calls.append(session_id) + + class FakeIn: + def read(self): + return _json.dumps({"session_id": "fork-1", "transcript_path": __file__}) + + real_flush, real_log, stdin = fs._flush, fs._log, sys.stdin + saved = os.environ.pop("MEMHUB_HARNESS_CHILD", None) + os.environ["MEMHUB_HARNESS_CHILD"] = flag + fs._flush, fs._log, sys.stdin = fake_flush, (lambda _m: None), FakeIn() + try: + return fs.main(), calls + finally: + fs._flush, fs._log, sys.stdin = real_flush, real_log, stdin + os.environ.pop("MEMHUB_HARNESS_CHILD", None) + if saved is not None: + os.environ["MEMHUB_HARNESS_CHILD"] = saved + + +check("a harness child exits 0 and ships nothing", _child_run("1") == (0, [])) +check("the same payload outside a child ships", _child_run("") == (0, ["fork-1"])) + + print(f"{'FAIL' if FAILURES else 'PASS'}: flush_session") for f in FAILURES: print(f" - {f}") diff --git a/tests/flush_turn_test.py b/tests/flush_turn_test.py index 4d6ca08f..7dc5fea1 100644 --- a/tests/flush_turn_test.py +++ b/tests/flush_turn_test.py @@ -451,6 +451,40 @@ async def no_room(_session, _cwd, _env): ft._log = originals["log"] +def test_a_harness_child_is_never_captured(): + """The harness's authoring pass is `claude -p --resume + --fork-session`: a NEW session id whose transcript copies the person's whole + history. Captured, it landed on staging as another conversation under the + same title, once per pass (80 copies of one session). The same payload + without the flag must reach the flush, or this proves nothing.""" + print("harness child is never captured") + calls = [] + + async def fake_flush(session_id, transcript_path): + calls.append(session_id) + + class FakeIn: + def read(self): + return json.dumps({"session_id": "fork-1", "transcript_path": __file__}) + + real_flush, real_state, stdin = ft._flush, ft.STATE_DIR, sys.stdin + saved = os.environ.pop("MEMHUB_HARNESS_CHILD", None) + try: + with tempfile.TemporaryDirectory() as tmp: + ft._flush, ft.STATE_DIR = fake_flush, Path(tmp) + for flag, want in (("1", []), ("", ["fork-1"])): + calls.clear() + os.environ["MEMHUB_HARNESS_CHILD"] = flag + sys.stdin = FakeIn() + check(f"CHILD={flag!r} exits 0", ft.main(), 0) + check(f"CHILD={flag!r} flushed", calls, want) + finally: + ft._flush, ft.STATE_DIR, sys.stdin = real_flush, real_state, stdin + os.environ.pop("MEMHUB_HARNESS_CHILD", None) + if saved is not None: + os.environ["MEMHUB_HARNESS_CHILD"] = saved + + def test_timeout_override_never_breaks_the_hook(): """The override is parsed at CALL time and floors at the default. Parsing it at import meant a bad value crashed the module before the handler that keeps diff --git a/tests/harness_extract_test.py b/tests/harness_extract_test.py index 8e2e36ae..37e88a5b 100644 --- a/tests/harness_extract_test.py +++ b/tests/harness_extract_test.py @@ -299,6 +299,20 @@ def test_the_flag_is_on_by_default_and_every_off_spelling_works(): print("PASS test_the_flag_is_on_by_default_and_every_off_spelling_works") +def test_an_authoring_child_is_never_sensed_whatever_the_flag_says(): + """`run_author` sets EXTRACT=0 for its child, but Claude Code applies a + settings.json `env` over what a process inherits. So an install that opted + in with EXTRACT=1 there re-armed the sensor in every child, and forks + spawned forks. The child flag is the switch that setting cannot reach.""" + for flag in ("1", "true", "yes"): + for extract in ("", "1", "on"): + env = {"MEMHUB_HARNESS_CHILD": flag, "MEMHUB_HARNESS_EXTRACT": extract} + assert not hx.extract_enabled(env), env + for flag in ("", "0", "off"): + assert hx.extract_enabled({"MEMHUB_HARNESS_CHILD": flag, "MEMHUB_HARNESS_EXTRACT": "1"}), flag + print("PASS test_an_authoring_child_is_never_sensed_whatever_the_flag_says") + + def test_spawn_detaches_and_returns(): seen = {} real = hx.subprocess.Popen diff --git a/tests/harness_stop_test.py b/tests/harness_stop_test.py index 113d5b89..ee1aa621 100644 --- a/tests/harness_stop_test.py +++ b/tests/harness_stop_test.py @@ -755,6 +755,56 @@ def test_the_hook_command_runs_by_default_and_stops_on_every_off_spelling(): print("PASS test_the_hook_command_runs_by_default_and_stops_on_every_off_spelling") +def test_an_authoring_child_neither_senses_nor_drains_when_settings_re_arm_the_flag(): + """Live on staging: an install with MEMHUB_HARNESS_EXTRACT=1 in settings.json + `env` saw that value override the child's EXTRACT=0. The fork's own Stop + classified its turns and drained the repo's moments, which spawned more + forks. Under the child flag the Stop does nothing and the arc sensor records + nothing. The same Stop without the flag must still run.""" + with _Env(): + ran = [] + real = hs.cmd_stop + hs.cmd_stop = lambda payload: ran.append(payload.get("session_id")) or 0 + saved = os.environ.pop("MEMHUB_HARNESS_CHILD", None) + try: + os.environ["MEMHUB_HARNESS_CHILD"] = "1" # EXTRACT is "1" in _Env + assert _stop(session_id="fork-1")[0] == 0 + assert ran == [], ran + os.environ.pop("MEMHUB_HARNESS_CHILD") + _stop(session_id="owner-1") + assert ran == ["owner-1"], ran + finally: + hs.cmd_stop = real + os.environ.pop("MEMHUB_HARNESS_CHILD", None) + if saved is not None: + os.environ["MEMHUB_HARNESS_CHILD"] = saved + import rulebook_hook as rh # noqa: PLC0415 + assert not rh.harness_extract_on({"MEMHUB_HARNESS_CHILD": "1", "MEMHUB_HARNESS_EXTRACT": "1"}) + assert rh.harness_extract_on({"MEMHUB_HARNESS_EXTRACT": "1"}) + print("PASS test_an_authoring_child_neither_senses_nor_drains_when_settings_re_arm_the_flag") + + +def test_the_author_child_is_launched_as_a_harness_child(): + """The flag the capture and sensor lanes read is set by `run_author`.""" + seen = {} + + def fake_run(argv, **kw): + seen.update(argv=argv, env=kw.get("env") or {}) + return subprocess.CompletedProcess(argv, 0, stdout="HARNESS-RESULT: none | x\n", stderr="") + + real = hs.subprocess.run + hs.subprocess.run = fake_run + try: + hs.run_author("owner", {"state": {"session_id": "owner"}, "turn": 1, + "source_ref": "owner#1", "kind": "error_arc"}, + "repo", Path("/nonexistent/mcp.json")) + finally: + hs.subprocess.run = real + assert "--fork-session" in seen["argv"] + assert seen["env"].get("MEMHUB_HARNESS_CHILD") == "1" + print("PASS test_the_author_child_is_launched_as_a_harness_child") + + def test_the_sensor_never_sends_activate(): import re # noqa: PLC0415 src = (SCRIPTS / "harness_stop.py").read_text(encoding="utf-8")