diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 795a8e35..f7eeca20 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -44,6 +44,25 @@ ], "repository": "https://github.com/Unsupervisedcom/deepwork", "license": "MIT" + }, + { + "name": "full-convo-memory", + "description": "Search the current Claude Code session's jsonl transcript using jq filters", + "version": "0.1.0", + "source": "./plugins/full-convo-memory", + "author": { + "name": "DeepWork" + }, + "category": "productivity", + "keywords": [ + "memory", + "search", + "transcript", + "conversation", + "jq" + ], + "repository": "https://github.com/Unsupervisedcom/deepwork", + "license": "BSL-1.1" } ] } diff --git a/.deepreview b/.deepreview index e2b21ba4..7c8298f9 100644 --- a/.deepreview +++ b/.deepreview @@ -195,8 +195,11 @@ test_file_quality: blank lines or other comments). 3. **Valid REQ ID**: The requirement ID in parentheses must follow the - pattern `{PREFIX}-REQ-NNN.M` where PREFIX is one of: DW-REQ, - JOBS-REQ, REVIEW-REQ, LA-REQ, PLUG-REQ. + pattern `{PREFIX}-REQ-NNN.M` or `{PREFIX}-REQ-NNN.M.L` where PREFIX + is one of: DW-REQ, JOBS-REQ, REVIEW-REQ, LA-REQ, PLUG-REQ. The + optional third `.L` level is used when a test maps to a specific + sub-clause of a requirement section (e.g. `PLUG-REQ-004.1.2` + referring to clause 2 of section PLUG-REQ-004.1). 4. **Consistency**: If a test references a REQ ID only in a docstring but is missing the formal two-line comment block, flag it. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f80a5d6..ef890184 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- New `full-convo-memory` Claude Code plugin (registered in `.claude-plugin/marketplace.json`) providing a `search_conversation` skill and `scripts/search_conversation.sh`. The script runs `jq` against the current session's jsonl transcript, auto-detecting the log file for both top-level sessions (via `CLAUDE_CODE_SESSION_ID`) and sub-agents (via `CLAUDE_CODE_SESSION_ID` + `CLAUDE_CODE_AGENT_ID`), filtering out compaction-summary messages, and appending a pointer line naming the log file for fallback semantic search via an Explore agent + ### Changed ### Fixed diff --git a/doc/architecture.md b/doc/architecture.md index 53292dc7..c7ac7fba 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -121,6 +121,12 @@ deepwork/ # DeepWork tool repository │ │ │ └── review/SKILL.md │ │ ├── hooks/ # hooks.json, post_commit_reminder.sh, post_compact.sh, startup_context.sh, deepschema_write.sh │ │ └── .mcp.json # MCP server config +│ ├── full-convo-memory/ # Transcript-search plugin +│ │ ├── .claude-plugin/plugin.json +│ │ ├── scripts/search_conversation.sh +│ │ └── skills/search_conversation/ +│ │ ├── SKILL.md +│ │ └── .deepschema.SKILL.md.yml │ └── gemini/ # Gemini CLI extension │ └── skills/deepwork/SKILL.md ├── library/jobs/ # Reusable example jobs diff --git a/doc/specs/deepwork/cli_plugins/PLUG-REQ-004-full-convo-memory-plugin.md b/doc/specs/deepwork/cli_plugins/PLUG-REQ-004-full-convo-memory-plugin.md new file mode 100644 index 00000000..28c1e821 --- /dev/null +++ b/doc/specs/deepwork/cli_plugins/PLUG-REQ-004-full-convo-memory-plugin.md @@ -0,0 +1,94 @@ +# PLUG-REQ-004: Full Convo Memory Plugin + +## Overview + +The `full-convo-memory` plugin is a small Claude Code plugin shipped via the DeepWork marketplace. It provides a `search_conversation` skill and a companion `search_conversation.sh` script that run `jq` against the current Claude Code session's jsonl transcript so the agent can recall earlier turns without rereading the whole conversation. The script auto-detects the log file for both top-level sessions and sub-agents, filters out synthetic compaction-summary messages, and ends its output with a pointer line naming the exact log file path (so a caller can fall back to a semantic search via an Explore sub-agent when `jq` matching is insufficient). + +## Requirements + +### PLUG-REQ-004.1: Plugin Manifest + +1. The plugin MUST provide a manifest at `plugins/full-convo-memory/.claude-plugin/plugin.json`. +2. The manifest `name` field MUST be `"full-convo-memory"`. +3. The manifest MUST include non-empty `description`, `version`, `author.name`, and `repository` fields. + +### PLUG-REQ-004.2: Marketplace Registration + +1. The plugin MUST be registered under the `plugins` array of `.claude-plugin/marketplace.json`. +2. The marketplace entry's `name` field MUST be `"full-convo-memory"`. +3. The marketplace entry's `source` field MUST be `"./plugins/full-convo-memory"`. +4. The marketplace entry MUST include `description`, `version`, `author`, `category`, `keywords`, `repository`, and `license` fields. + +### PLUG-REQ-004.3: Plugin Root Directory Layout + +1. The plugin root `plugins/full-convo-memory/` MUST contain `.claude-plugin/plugin.json`, `scripts/search_conversation.sh`, and `skills/search_conversation/SKILL.md`. + +### PLUG-REQ-004.4: Search Script Existence and Shebang + +1. The script MUST exist at `plugins/full-convo-memory/scripts/search_conversation.sh`. +2. The script MUST have the executable bit set for the owner. +3. The script's first line MUST be `#!/usr/bin/env bash`. + +### PLUG-REQ-004.5: Zero-Argument Guard + +1. When invoked with no positional arguments (and no `--log-file` option consumed), the script MUST print a usage message to stderr and exit with status `2` rather than invoking `jq` with no filter. + +### PLUG-REQ-004.6: jq Dependency Check + +1. If `jq` is not present on `PATH`, the script MUST write an error message to stderr and exit with status `127`. + +### PLUG-REQ-004.7: Explicit Log-File Override + +1. When invoked with `--log-file ` as the first argument, the script MUST use `` as the log file and MUST remove both tokens from the argument list before forwarding the remainder to `jq`. +2. If `--log-file` is supplied without a path argument, the script MUST exit non-zero with an error message to stderr. + +### PLUG-REQ-004.8: Sub-Agent Log-File Resolution + +1. When `--log-file` is not provided and both `CLAUDE_CODE_SESSION_ID` and `CLAUDE_CODE_AGENT_ID` environment variables are set to non-empty values, the script MUST first try to resolve the log file to `$HOME/.claude/projects//$CLAUDE_CODE_SESSION_ID/subagents/agent-$CLAUDE_CODE_AGENT_ID.jsonl`, where `` is `$PWD` with every `/` replaced by `-` (leading `-` preserved). +2. If the sub-agent path does not exist, resolution MUST fall through to PLUG-REQ-004.9. + +### PLUG-REQ-004.9: Top-Level Session Log-File Resolution + +1. When `--log-file` is not provided and the sub-agent path (PLUG-REQ-004.8) did not resolve but `CLAUDE_CODE_SESSION_ID` is set to a non-empty value, the script MUST try to resolve the log file to `$HOME/.claude/projects//$CLAUDE_CODE_SESSION_ID.jsonl`. +2. If the top-level path does not exist, resolution MUST fall through to PLUG-REQ-004.10. + +### PLUG-REQ-004.10: Fallback Log-File Resolution + +1. When neither `--log-file` nor the env-var paths yield an existing file, the script MUST select the most-recently-modified `*.jsonl` file located directly (non-recursively) inside `$HOME/.claude/projects//` as the log file. + +### PLUG-REQ-004.11: Unresolvable Log File + +1. If none of the strategies in PLUG-REQ-004.7 through PLUG-REQ-004.10 yield an existing file, the script MUST print a diagnostic to stderr that includes the values inspected for `CLAUDE_CODE_SESSION_ID`, `CLAUDE_CODE_AGENT_ID`, and the computed project directory, and MUST exit with status `1`. + +### PLUG-REQ-004.12: Compaction-Summary Filter + +1. Before running the caller's `jq` expression, the script MUST pre-filter transcript lines with the equivalent of `jq 'select(.isCompactSummary != true)'` so that entries with `isCompactSummary: true` are dropped and never reach the caller's filter. + +### PLUG-REQ-004.13: jq Pass-Through + +1. After the compaction pre-filter, the script MUST forward all remaining positional arguments to `jq` verbatim, supporting any `jq` flag or filter the caller provides. + +### PLUG-REQ-004.14: Exit Code Propagation + +1. The script's exit code MUST be the exit code of the caller's `jq` invocation (the right-hand side of the filter pipeline). +2. The pre-filter `jq` exit code MUST NOT propagate to the script's exit code. + +### PLUG-REQ-004.15: Trailing Pointer Line + +1. After running the `jq` pipeline, the script MUST print a final line to stdout of the form `If you want a more semantic search of the history, start an Explore agent and tell it what to look for in `, where `` is the absolute path of the resolved log file. +2. The trailing pointer line MUST be printed regardless of whether the caller's `jq` produced any matches. + +### PLUG-REQ-004.16: Skill Location and Frontmatter + +1. The skill MUST exist at `plugins/full-convo-memory/skills/search_conversation/SKILL.md`. +2. The skill's YAML frontmatter `name` field MUST be `"search_conversation"` (matching its directory name). +3. The skill's YAML frontmatter MUST include a non-empty `description` field. + +### PLUG-REQ-004.17: Skill Documentation Content + +1. The skill body MUST document the script path using the `${CLAUDE_PLUGIN_ROOT}` variable. +2. The skill body MUST state that any `jq` arguments are passed through verbatim. +3. The skill body MUST describe the log-file auto-detection order, explicitly covering the sub-agent case so a sub-agent invoking the skill searches its own transcript. +4. The skill body MUST state that compaction-summary messages are excluded automatically. +5. The skill body MUST include at least one worked `jq` example. +6. The skill body MUST direct the agent to use the printed log-file path with an Explore sub-agent when `jq` matching is insufficient. diff --git a/flake.nix b/flake.nix index 576a557c..b0065c56 100644 --- a/flake.nix +++ b/flake.nix @@ -82,9 +82,6 @@ uv sync --extra dev --quiet 2>/dev/null || true export PATH="$REPO_ROOT/.venv/bin:$PATH" - # Also register as a uv tool so `uvx deepwork serve` uses local source - uv tool install -e "$REPO_ROOT" --quiet 2>/dev/null || true - # Create claude wrapper script so direnv (which can't export functions) works _claude_real=$(PATH="$(echo "$PATH" | sed "s|$REPO_ROOT/.venv/bin:||g")" command -v claude) if [ -n "$_claude_real" ]; then diff --git a/plugins/full-convo-memory/.claude-plugin/plugin.json b/plugins/full-convo-memory/.claude-plugin/plugin.json new file mode 100644 index 00000000..1e4a8b73 --- /dev/null +++ b/plugins/full-convo-memory/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "full-convo-memory", + "description": "Search the current Claude Code session's jsonl transcript using jq filters", + "version": "0.1.0", + "author": { + "name": "DeepWork" + }, + "repository": "https://github.com/Unsupervisedcom/deepwork" +} diff --git a/plugins/full-convo-memory/scripts/search_conversation.sh b/plugins/full-convo-memory/scripts/search_conversation.sh new file mode 100755 index 00000000..baf8f842 --- /dev/null +++ b/plugins/full-convo-memory/scripts/search_conversation.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# search_conversation.sh — search the current Claude Code session's jsonl +# transcript using jq. Compaction-summary messages are dropped automatically; +# everything else is passed through to jq verbatim. +# +# Usage: +# search_conversation.sh [--log-file ] +# +# Log-file resolution (first match wins): +# 1. --log-file (explicit override) +# 2. sub-agent : ~/.claude/projects//$CLAUDE_CODE_SESSION_ID/subagents/agent-$CLAUDE_CODE_AGENT_ID.jsonl +# 3. top-level : ~/.claude/projects//$CLAUDE_CODE_SESSION_ID.jsonl +# 4. fallback : most-recently-modified *.jsonl directly in ~/.claude/projects// +# +# is $PWD with every '/' replaced by '-' (leading '-' preserved). + +set -euo pipefail + +usage() { + cat >&2 <<'EOF' +Usage: search_conversation.sh [--log-file ] + +Pre-filters the current Claude Code session's jsonl transcript to drop +compaction-summary entries, then runs `jq ` on the result. + +Examples: + search_conversation.sh 'select(.type == "user")' + search_conversation.sh -r 'select((.message.content | tostring) | test("plan mode"; "i")) | .timestamp' + search_conversation.sh --log-file /tmp/session.jsonl 'select(.type == "assistant")' + +Any flags and filters accepted by jq are passed through verbatim. +EOF +} + +encode_cwd() { + printf '%s' "$1" | sed 's|/|-|g' +} + +# ============================================================================ +# PARSE OPTIONAL --log-file OVERRIDE +# ============================================================================ + +LOG_FILE="" +if [ "${1:-}" = "--log-file" ]; then + if [ $# -lt 2 ]; then + echo "error: --log-file requires a path argument" >&2 + exit 2 + fi + LOG_FILE="$2" + shift 2 +fi + +# ============================================================================ +# GUARD: AT LEAST ONE JQ ARG REQUIRED +# ============================================================================ + +if [ $# -eq 0 ]; then + usage + exit 2 +fi + +# ============================================================================ +# GUARD: jq MUST BE ON PATH +# ============================================================================ + +if ! command -v jq >/dev/null 2>&1; then + echo "error: jq is required but not found on PATH" >&2 + exit 127 +fi + +# ============================================================================ +# RESOLVE THE LOG FILE +# ============================================================================ + +ENCODED_CWD=$(encode_cwd "$PWD") +PROJECT_DIR="$HOME/.claude/projects/$ENCODED_CWD" + +if [ -z "$LOG_FILE" ]; then + if [ -n "${CLAUDE_CODE_AGENT_ID:-}" ] && [ -n "${CLAUDE_CODE_SESSION_ID:-}" ]; then + CANDIDATE="$PROJECT_DIR/$CLAUDE_CODE_SESSION_ID/subagents/agent-$CLAUDE_CODE_AGENT_ID.jsonl" + if [ -f "$CANDIDATE" ]; then + LOG_FILE="$CANDIDATE" + fi + fi +fi + +if [ -z "$LOG_FILE" ] && [ -n "${CLAUDE_CODE_SESSION_ID:-}" ]; then + CANDIDATE="$PROJECT_DIR/$CLAUDE_CODE_SESSION_ID.jsonl" + if [ -f "$CANDIDATE" ]; then + LOG_FILE="$CANDIDATE" + fi +fi + +if [ -z "$LOG_FILE" ] && [ -d "$PROJECT_DIR" ]; then + # Most-recently-modified top-level *.jsonl in the project dir (no recursion). + # `|| true` swallows SIGPIPE (141) from `ls` when `head` closes early, and + # also handles the "no matching files" case cleanly under `set -e`. + CANDIDATE=$(find "$PROJECT_DIR" -maxdepth 1 -type f -name '*.jsonl' -print0 2>/dev/null \ + | { xargs -0 ls -t 2>/dev/null || true; } \ + | head -n 1 \ + || true) + if [ -n "$CANDIDATE" ] && [ -f "$CANDIDATE" ]; then + LOG_FILE="$CANDIDATE" + fi +fi + +if [ -z "$LOG_FILE" ] || [ ! -f "$LOG_FILE" ]; then + cat >&2 <} + CLAUDE_CODE_SESSION_ID : ${CLAUDE_CODE_SESSION_ID:-} + CLAUDE_CODE_AGENT_ID : ${CLAUDE_CODE_AGENT_ID:-} + project dir : $PROJECT_DIR + +Pass --log-file to override. +EOF + exit 1 +fi + +# ============================================================================ +# RUN THE PIPELINE +# ============================================================================ +# Drop compaction-summary messages first, then apply the caller's jq args. +# The `|| true` on the pre-filter stops a single malformed line from flipping +# the pipeline's exit code under `pipefail`; we only care about the user's jq +# exit code, captured via $? after the pipeline. + +set +e +{ jq -c 'select(.isCompactSummary != true)' "$LOG_FILE" || true; } | jq "$@" +USER_JQ_EXIT=$? +set -e + +# ============================================================================ +# TRAILING POINTER LINE (always printed so the caller sees the log path) +# ============================================================================ + +printf '\nIf you want a more semantic search of the history, start an Explore agent and tell it what to look for in %s\n' "$LOG_FILE" + +exit "$USER_JQ_EXIT" diff --git a/plugins/full-convo-memory/skills/search_conversation/.deepschema.SKILL.md.yml b/plugins/full-convo-memory/skills/search_conversation/.deepschema.SKILL.md.yml new file mode 100644 index 00000000..b15c1dfc --- /dev/null +++ b/plugins/full-convo-memory/skills/search_conversation/.deepschema.SKILL.md.yml @@ -0,0 +1,36 @@ +requirements: + # PLUG-REQ-004.17.1 + plugin-root-variable: > + The skill body MUST document the script path using the + `${CLAUDE_PLUGIN_ROOT}` variable so it resolves correctly regardless of + where the plugin is installed. + + # PLUG-REQ-004.17.2 + jq-passthrough-doc: > + The skill body MUST state that any `jq` arguments (flags and filters) + are passed through to jq verbatim. + + # PLUG-REQ-004.17.3 + log-file-resolution-doc: > + The skill body MUST describe the log-file auto-detection order and MUST + explicitly cover the sub-agent case (via `CLAUDE_CODE_SESSION_ID` plus + `CLAUDE_CODE_AGENT_ID`), so that an agent calling the skill from a + sub-agent context understands it will search its own transcript rather + than the parent's. + + # PLUG-REQ-004.17.4 + compaction-filter-doc: > + The skill body MUST state that compaction-summary messages + (`isCompactSummary: true`) are excluded from search results + automatically. + + # PLUG-REQ-004.17.5 + worked-example: > + The skill body MUST include at least one worked `jq` example showing how + to call the script with a concrete filter. + + # PLUG-REQ-004.17.6 + explore-fallback-guidance: > + The skill body MUST direct the agent to use the printed log-file path + with an Explore sub-agent when `jq` matching is insufficient for a + fuzzy/semantic question. diff --git a/plugins/full-convo-memory/skills/search_conversation/SKILL.md b/plugins/full-convo-memory/skills/search_conversation/SKILL.md new file mode 100644 index 00000000..b641fa28 --- /dev/null +++ b/plugins/full-convo-memory/skills/search_conversation/SKILL.md @@ -0,0 +1,78 @@ +--- +name: search_conversation +description: "Search the current Claude Code session's jsonl transcript with jq — use when you need to recall something said earlier in this exact session (user messages, tool calls, prior decisions)." +--- + +# search_conversation + +Runs `jq` against the jsonl transcript of the **current** Claude Code session and prints the matching lines. Useful for exact-string lookups against earlier turns (e.g. "what path did the user mention?", "what command did that tool return?") without rereading the whole conversation or spawning a semantic-search agent. + +## How to invoke + +Run the script directly with `Bash`. Everything you pass is forwarded to `jq` verbatim, so any `jq` flag or filter works. + +```bash +bash ${CLAUDE_PLUGIN_ROOT}/scripts/search_conversation.sh [--log-file ] +``` + +At least one `jq` argument is required — running with zero args prints usage and exits non-zero (this is a guard against accidentally dumping the entire transcript). + +## What the script does for you + +1. **Auto-detects the log file.** Resolution order: + - `--log-file ` (explicit override, must be the first argument). + - Sub-agent transcript, if `CLAUDE_CODE_AGENT_ID` and `CLAUDE_CODE_SESSION_ID` are both set: `~/.claude/projects//$CLAUDE_CODE_SESSION_ID/subagents/agent-$CLAUDE_CODE_AGENT_ID.jsonl`. This means if a sub-agent (Explore, Plan, custom) invokes the script, it will search **its own** transcript, not the parent's. + - Top-level session transcript, if `CLAUDE_CODE_SESSION_ID` is set: `~/.claude/projects//$CLAUDE_CODE_SESSION_ID.jsonl`. + - Fallback: the most-recently-modified `*.jsonl` file directly in `~/.claude/projects//`. + + (`` is `$PWD` with every `/` replaced by `-`, matching Claude Code's on-disk layout.) + +2. **Drops compaction-summary messages.** The script pre-filters with `jq 'select(.isCompactSummary != true)'`, so the synthetic "This session is being continued from a previous conversation…" entries that appear mid-file after a compaction never reach your `jq` filter. Everything else is untouched. + +3. **Passes your args straight to `jq`.** Flags (`-r`, `-c`, `--arg`, `--slurp`, …), filters, or both — all forwarded verbatim. + +4. **Appends a pointer line.** The final line of stdout names the exact log file path, so if `jq` finds nothing useful you can hand the file to an Explore agent for a semantic pass. + +## Transcript shape + +Each line is one JSON object representing a turn or tool event. Commonly-useful top-level fields: + +- `type` — `"user"`, `"assistant"`, `"system"`, or `"tool_use"`-like variants. +- `message.role` — `"user"` / `"assistant"`. +- `message.content` — either a string or an array of content blocks (`{type: "text", text: "…"}`, `{type: "tool_use", …}`, `{type: "tool_result", …}`). +- `timestamp` — ISO-8601 string. +- `isCompactSummary` — present and `true` only on compaction-summary entries (already filtered out). + +Because `message.content` is sometimes a string and sometimes an array, `(.message.content | tostring)` is the easiest way to text-match across both shapes. + +## Examples + +Find every user message: + +```bash +bash ${CLAUDE_PLUGIN_ROOT}/scripts/search_conversation.sh 'select(.type == "user")' +``` + +Case-insensitive substring search across every turn, printed as ` `: + +```bash +bash ${CLAUDE_PLUGIN_ROOT}/scripts/search_conversation.sh -r \ + 'select((.message.content | tostring) | test("plan mode"; "i")) + | (.timestamp // "") + " " + (.message.content | tostring)[0:200]' +``` + +All assistant messages, piped to `head`: + +```bash +bash ${CLAUDE_PLUGIN_ROOT}/scripts/search_conversation.sh 'select(.type == "assistant") | .message.content' | head +``` + +Inspect an arbitrary transcript without touching env vars: + +```bash +bash ${CLAUDE_PLUGIN_ROOT}/scripts/search_conversation.sh --log-file ~/.claude/projects/-Users-me-proj/abcd.jsonl '.type' | sort -u +``` + +## When to prefer the Explore agent instead + +`jq` only does exact / regex matching. If the user asks a fuzzy question ("when did we talk about the caching strategy?") and nothing obvious matches, use the log file path printed on the last line of the script's output and start an `Explore` sub-agent pointed at that file. diff --git a/tests/unit/plugins/test_claude_plugin.py b/tests/unit/plugins/test_claude_plugin.py index ad10346e..9fcd1c5f 100644 --- a/tests/unit/plugins/test_claude_plugin.py +++ b/tests/unit/plugins/test_claude_plugin.py @@ -43,22 +43,22 @@ class TestPluginManifest: manifest_path = PLUGIN_DIR / ".claude-plugin" / "plugin.json" + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.1.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_manifest_exists(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.1.1). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.1.1: manifest exists at plugins/claude/.claude-plugin/plugin.json.""" assert self.manifest_path.exists() + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.1.2). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_manifest_name_is_deepwork(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.1.2). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.1.2: manifest name field is 'deepwork'.""" data = json.loads(self.manifest_path.read_text()) assert data["name"] == "deepwork" + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.1.3). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_manifest_required_fields(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.1.3). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.1.3: manifest includes description, version, author, repository.""" data = json.loads(self.manifest_path.read_text()) for field in ("description", "version", "author", "repository"): @@ -75,23 +75,23 @@ class TestMCPServerRegistration: mcp_json_path = PLUGIN_DIR / ".mcp.json" + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.2.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_mcp_json_exists(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.2.1). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.2.1: .mcp.json exists at plugins/claude/.mcp.json.""" assert self.mcp_json_path.exists() + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.2.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_mcp_json_registers_deepwork_server(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.2.1). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.2.1: .mcp.json registers a 'deepwork' MCP server.""" data = json.loads(self.mcp_json_path.read_text()) assert "mcpServers" in data assert "deepwork" in data["mcpServers"] + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.2.2). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_mcp_command_is_uvx_deepwork_serve(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.2.2). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES # The first arg must start with "deepwork" (not an exact match) because # the release process may pin a version, e.g. "deepwork==0.10.0a1". """PLUG-REQ-001.2.2: command is uvx with deepwork and serve as first two args.""" @@ -104,9 +104,9 @@ def test_mcp_command_is_uvx_deepwork_serve(self) -> None: ) assert args[1] == "serve" + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.2.3). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_mcp_args_include_platform_claude(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.2.3). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.2.3: arguments include --platform claude.""" data = json.loads(self.mcp_json_path.read_text()) args = data["mcpServers"]["deepwork"]["args"] @@ -114,9 +114,9 @@ def test_mcp_args_include_platform_claude(self) -> None: platform_idx = args.index("--platform") assert args[platform_idx + 1] == "claude" + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.2.4). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_mcp_args_omit_path(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.2.4). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.2.4: arguments MUST NOT include --path (uses listRoots).""" data = json.loads(self.mcp_json_path.read_text()) args = data["mcpServers"]["deepwork"]["args"] @@ -138,30 +138,30 @@ class TestDeepworkSkill: skill_path = SKILLS_DIR / "deepwork" / "SKILL.md" + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.3.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_skill_file_exists(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.3.1). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.3.1: deepwork skill exists at expected path.""" assert self.skill_path.exists() + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.3.2). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_skill_invocable_as_deepwork(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.3.2). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.3.2: skill is invocable as /deepwork.""" fm = _parse_yaml_frontmatter(self.skill_path) assert fm["name"] == "deepwork" + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.3.3). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_skill_references_mcp_tools(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.3.3). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.3.3: skill instructs agent to use MCP tools.""" content = self.skill_path.read_text(encoding="utf-8") for tool in ("get_workflows", "start_workflow", "finished_step"): assert tool in content, f"skill must reference MCP tool: {tool}" + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.3.5). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_skill_supports_creating_new_jobs(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.3.5). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.3.5: skill supports creating jobs via deepwork_jobs new_job.""" content = self.skill_path.read_text(encoding="utf-8") assert "deepwork_jobs" in content @@ -185,36 +185,36 @@ class TestReviewSkill: skill_path = SKILLS_DIR / "review" / "SKILL.md" + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.4.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_skill_file_exists(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.4.1). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.4.1: review skill exists at expected path.""" assert self.skill_path.exists() + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.4.2). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_skill_invocable_as_review(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.4.2). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.4.2: skill is invocable as /review.""" fm = _parse_yaml_frontmatter(self.skill_path) assert fm["name"] == "review" + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.4.3). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_skill_uses_mcp_review_tools(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.4.3). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.4.3: skill uses MCP review tools, not CLI commands.""" content = self.skill_path.read_text(encoding="utf-8") assert "mcp__deepwork__get_review_instructions" in content + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.4.6). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_skill_instructs_present_tradeoffs(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.4.6). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.4.6: skill instructs agent to present trade-off findings to user.""" content = self.skill_path.read_text(encoding="utf-8") assert "AskUserQuestion" in content + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.4.8). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_skill_redirects_config_to_configure_reviews(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.4.8). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.4.8: skill redirects configuration to configure_reviews.""" content = self.skill_path.read_text(encoding="utf-8") assert "configure_reviews" in content @@ -235,29 +235,29 @@ class TestConfigureReviewsSkill: skill_path = SKILLS_DIR / "configure_reviews" / "SKILL.md" + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.5.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_skill_file_exists(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.5.1). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.5.1: configure_reviews skill exists at expected path.""" assert self.skill_path.exists() + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.5.2). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_skill_invocable_as_configure_reviews(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.5.2). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.5.2: skill is invocable as /configure_reviews.""" fm = _parse_yaml_frontmatter(self.skill_path) assert fm["name"] == "configure_reviews" + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.5.3). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_skill_consults_readme(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.5.3). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.5.3: skill tells agent to consult README_REVIEWS.md.""" content = self.skill_path.read_text(encoding="utf-8") assert "README_REVIEWS.md" in content + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.5.5). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_skill_instructs_test_new_rules(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.5.5). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.5.5: skill tells agent to test by triggering a review run.""" content = self.skill_path.read_text(encoding="utf-8") assert "mcp__deepwork__get_review_instructions" in content @@ -273,21 +273,21 @@ class TestReviewReferenceDocumentation: symlink_path = PLUGIN_DIR / "README_REVIEWS.md" + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.6.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_readme_reviews_exists(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.6.1). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.6.1: README_REVIEWS.md exists at plugins/claude/.""" assert self.symlink_path.exists() + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.6.2). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_readme_reviews_is_symlink(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.6.2). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.6.2: README_REVIEWS.md is a symlink.""" assert self.symlink_path.is_symlink() + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.6.2). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_symlink_target(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.6.2). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.6.2: symlink points to ../../README_REVIEWS.md.""" target = self.symlink_path.readlink() assert str(target) == "../../README_REVIEWS.md" @@ -303,15 +303,15 @@ class TestPostCommitReviewReminder: hooks_json_path = PLUGIN_DIR / "hooks" / "hooks.json" + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.7.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_hooks_json_exists(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.7.1). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.7.1: hooks.json exists in plugin hooks directory.""" assert self.hooks_json_path.exists() + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.7.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_registers_post_tool_use_on_bash(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.7.1). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.7.1: hooks.json registers PostToolUse hook on Bash tool.""" data = json.loads(self.hooks_json_path.read_text()) assert "hooks" in data @@ -320,9 +320,9 @@ def test_registers_post_tool_use_on_bash(self) -> None: bash_matchers = [h for h in hooks if h.get("matcher") == "Bash"] assert len(bash_matchers) >= 1, "No PostToolUse hook with Bash matcher found" + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.7.2). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_hook_script_detects_git_commit(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.7.2). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.7.2: Python hook detects git commit and prompts review.""" from deepwork.hooks.post_commit_reminder import post_commit_reminder_hook @@ -340,24 +340,24 @@ def test_hook_script_detects_git_commit(self) -> None: class TestSkillDirectoryConventions: """Tests for skill directory structure (PLUG-REQ-001.8).""" + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.8.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_each_skill_in_own_directory(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.8.1). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.8.1: each skill resides in its own directory.""" skill_dirs = [d for d in SKILLS_DIR.iterdir() if d.is_dir()] assert len(skill_dirs) >= 3 # deepwork, review, configure_reviews + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.8.2). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_each_skill_has_skill_md(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.8.2). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.8.2: each skill directory contains a SKILL.md file.""" skill_dirs = [d for d in SKILLS_DIR.iterdir() if d.is_dir()] for skill_dir in skill_dirs: assert (skill_dir / "SKILL.md").exists(), f"{skill_dir.name} is missing SKILL.md" + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.8.3). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_name_matches_directory(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.8.3). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.8.3: frontmatter name matches directory name.""" skill_dirs = [d for d in SKILLS_DIR.iterdir() if d.is_dir()] for skill_dir in skill_dirs: @@ -368,9 +368,9 @@ def test_name_matches_directory(self) -> None: f"Skill {skill_dir.name} has name={fm['name']} in frontmatter" ) + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.8.4). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_each_skill_has_description(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.8.4). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.8.4: each skill's frontmatter includes a description.""" skill_dirs = [d for d in SKILLS_DIR.iterdir() if d.is_dir()] for skill_dir in skill_dirs: @@ -393,9 +393,9 @@ class TestSharedSkillContent: gemini_skill = PROJECT_ROOT / "plugins" / "gemini" / "skills" / "deepwork" / "SKILL.md" platform_body = PLATFORM_DIR / "skill-body.md" + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.9.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_claude_skill_body_matches_platform(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.9.1). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.9.1: Claude deepwork skill body is in sync with platform/skill-body.md.""" platform_content = self.platform_body.read_text(encoding="utf-8").strip() claude_content = self.claude_skill.read_text(encoding="utf-8") @@ -409,9 +409,9 @@ def test_claude_skill_body_matches_platform(self) -> None: "Claude skill body has diverged from platform/skill-body.md" ) + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.9.2). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_gemini_skill_body_matches_platform(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.9.2). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.9.2: Gemini deepwork skill body is in sync with platform/skill-body.md.""" platform_content = self.platform_body.read_text(encoding="utf-8").strip() gemini_content = self.gemini_skill.read_text(encoding="utf-8") @@ -438,9 +438,9 @@ class TestMCPConfiguresClaude: mcp_json_path = PLUGIN_DIR / ".mcp.json" @pytest.mark.xfail(reason="PLUG-REQ-001.10 not yet implemented — needs auto-approval config") + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.10.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES def test_plugin_configures_mcp_tool_auto_approval(self) -> None: - # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-001.10.1). - # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES """PLUG-REQ-001.10.1: plugin configures Claude Code to allow MCP tools without prompts.""" # The plugin must include a mechanism that auto-approves its MCP tool # calls. This could be via allowedTools in plugin settings, a diff --git a/tests/unit/plugins/test_full_convo_memory_plugin.py b/tests/unit/plugins/test_full_convo_memory_plugin.py new file mode 100644 index 00000000..6125c3a0 --- /dev/null +++ b/tests/unit/plugins/test_full_convo_memory_plugin.py @@ -0,0 +1,552 @@ +"""Tests for the full-convo-memory plugin — validates PLUG-REQ-004. + +Each test class maps to a numbered requirement section in +doc/specs/deepwork/cli_plugins/PLUG-REQ-004-full-convo-memory-plugin.md. + +Requirements that need judgment to evaluate (e.g., "skill body MUST instruct +the agent to fall back to an Explore sub-agent when jq matching is +insufficient") are validated by the anonymous DeepSchema next to SKILL.md, +not by tests. Only deterministic, boolean-verifiable requirements have tests +here. +""" + +from __future__ import annotations + +import json +import os +import shutil +import stat +import subprocess +import uuid +from pathlib import Path +from typing import Any + +import pytest +import yaml + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +PLUGIN_DIR = PROJECT_ROOT / "plugins" / "full-convo-memory" +SCRIPT_PATH = PLUGIN_DIR / "scripts" / "search_conversation.sh" +SKILL_PATH = PLUGIN_DIR / "skills" / "search_conversation" / "SKILL.md" +MARKETPLACE_PATH = PROJECT_ROOT / ".claude-plugin" / "marketplace.json" + +POINTER_LINE_PREFIX = ( + "If you want a more semantic search of the history, " + "start an Explore agent and tell it what to look for in" +) + + +def _encode_cwd(path: Path) -> str: + """Mirror the script's rule: every '/' → '-'.""" + return str(path).replace("/", "-") + + +def _parse_yaml_frontmatter(skill_path: Path) -> dict[str, Any]: + text = skill_path.read_text(encoding="utf-8") + assert text.startswith("---"), f"{skill_path} must start with YAML frontmatter" + end = text.index("---", 3) + result: dict[str, Any] = yaml.safe_load(text[3:end]) + return result + + +@pytest.fixture +def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Point `$HOME` at a tmp dir and create the encoded-cwd project dir. + + The script resolves log files under `$HOME/.claude/projects//`, + where `` is computed from `$PWD`. We chdir into `tmp_path/cwd` + (a path with no `-` in it) so the encoded form is deterministic for tests. + """ + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + + cwd = tmp_path / "cwd" + cwd.mkdir() + monkeypatch.chdir(cwd) + + encoded = _encode_cwd(cwd) + project_dir = home / ".claude" / "projects" / encoded + project_dir.mkdir(parents=True) + + # Keep sub-agent env vars unset by default; tests opt in. + monkeypatch.delenv("CLAUDE_CODE_SESSION_ID", raising=False) + monkeypatch.delenv("CLAUDE_CODE_AGENT_ID", raising=False) + return project_dir + + +def _run_script( + *args: str, + env_overrides: dict[str, str] | None = None, + cwd: Path | None = None, +) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + if env_overrides is not None: + env.update(env_overrides) + return subprocess.run( + ["bash", str(SCRIPT_PATH), *args], + capture_output=True, + text=True, + env=env, + cwd=str(cwd) if cwd is not None else None, + check=False, + ) + + +# --------------------------------------------------------------------------- +# PLUG-REQ-004.1: Plugin Manifest +# --------------------------------------------------------------------------- + + +class TestPluginManifest: + manifest_path = PLUGIN_DIR / ".claude-plugin" / "plugin.json" + + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.1.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_manifest_exists(self) -> None: + """PLUG-REQ-004.1.1: manifest exists at the expected path.""" + assert self.manifest_path.exists() + + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.1.2). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_manifest_name_is_full_convo_memory(self) -> None: + """PLUG-REQ-004.1.2: manifest name field is 'full-convo-memory'.""" + data = json.loads(self.manifest_path.read_text()) + assert data["name"] == "full-convo-memory" + + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.1.3). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_manifest_required_fields(self) -> None: + """PLUG-REQ-004.1.3: manifest includes non-empty required fields.""" + data = json.loads(self.manifest_path.read_text()) + assert data["description"] + assert data["version"] + assert data["author"]["name"] + assert data["repository"] + + +# --------------------------------------------------------------------------- +# PLUG-REQ-004.2: Marketplace Registration +# --------------------------------------------------------------------------- + + +class TestMarketplaceRegistration: + @pytest.fixture(scope="class") + def entry(self) -> dict[str, Any]: + data = json.loads(MARKETPLACE_PATH.read_text()) + matches = [p for p in data["plugins"] if p.get("name") == "full-convo-memory"] + assert len(matches) == 1, "expected exactly one marketplace entry for full-convo-memory" + return matches[0] + + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.2.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_marketplace_entry_present(self) -> None: + """PLUG-REQ-004.2.1: plugin is registered in marketplace.json.""" + data = json.loads(MARKETPLACE_PATH.read_text()) + names = [p.get("name") for p in data["plugins"]] + assert "full-convo-memory" in names + + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.2.2). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_marketplace_name(self, entry: dict[str, Any]) -> None: + """PLUG-REQ-004.2.2: marketplace entry name is 'full-convo-memory'.""" + assert entry["name"] == "full-convo-memory" + + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.2.3). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_marketplace_source(self, entry: dict[str, Any]) -> None: + """PLUG-REQ-004.2.3: marketplace source points to the plugin root.""" + assert entry["source"] == "./plugins/full-convo-memory" + + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.2.4). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_marketplace_fields_present(self, entry: dict[str, Any]) -> None: + """PLUG-REQ-004.2.4: marketplace entry has all required fields.""" + for field in ( + "description", + "version", + "author", + "category", + "keywords", + "repository", + "license", + ): + assert field in entry, f"marketplace entry missing field: {field}" + + +# --------------------------------------------------------------------------- +# PLUG-REQ-004.3: Plugin Root Directory Layout +# --------------------------------------------------------------------------- + + +class TestPluginLayout: + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.3.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_expected_files_exist(self) -> None: + """PLUG-REQ-004.3.1: plugin root contains manifest, script, and SKILL.md.""" + assert (PLUGIN_DIR / ".claude-plugin" / "plugin.json").is_file() + assert SCRIPT_PATH.is_file() + assert SKILL_PATH.is_file() + + +# --------------------------------------------------------------------------- +# PLUG-REQ-004.4: Search Script Existence and Shebang +# --------------------------------------------------------------------------- + + +class TestScriptExistence: + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.4.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_script_exists(self) -> None: + """PLUG-REQ-004.4.1: script exists at the expected path.""" + assert SCRIPT_PATH.is_file() + + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.4.2). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_script_is_executable(self) -> None: + """PLUG-REQ-004.4.2: script has the owner-executable bit set.""" + mode = SCRIPT_PATH.stat().st_mode + assert mode & stat.S_IXUSR + + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.4.3). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_script_shebang(self) -> None: + """PLUG-REQ-004.4.3: script's first line is the env-bash shebang.""" + first_line = SCRIPT_PATH.read_text(encoding="utf-8").splitlines()[0] + assert first_line == "#!/usr/bin/env bash" + + +# --------------------------------------------------------------------------- +# PLUG-REQ-004.5: Zero-Argument Guard +# --------------------------------------------------------------------------- + + +class TestZeroArgGuard: + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.5.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_no_args_exits_2_with_usage(self, fake_home: Path) -> None: + """PLUG-REQ-004.5.1: no args → usage on stderr, exit 2.""" + result = _run_script() + assert result.returncode == 2 + assert "Usage:" in result.stderr + + +# --------------------------------------------------------------------------- +# PLUG-REQ-004.6: jq Dependency Check +# --------------------------------------------------------------------------- + + +class TestJqDependency: + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.6.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_missing_jq_exits_127(self, fake_home: Path, tmp_path: Path) -> None: + """PLUG-REQ-004.6.1: jq absent → error on stderr, exit 127.""" + # Construct a minimal PATH that contains only the coreutils the script + # needs (bash, sed, find, xargs, ls, head, printf, cat) but excludes jq. + empty_bin = tmp_path / "empty_bin" + empty_bin.mkdir() + for cmd in ( + "bash", + "sed", + "find", + "xargs", + "ls", + "head", + "printf", + "cat", + "chmod", + "env", + ): + src = shutil.which(cmd) + if src is not None: + os.symlink(src, empty_bin / cmd) + + result = _run_script( + "select(true)", + env_overrides={"PATH": str(empty_bin)}, + ) + assert result.returncode == 127 + assert "jq" in result.stderr + + +# --------------------------------------------------------------------------- +# PLUG-REQ-004.7: Explicit Log-File Override +# --------------------------------------------------------------------------- + + +class TestLogFileOverride: + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.7.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_override_uses_supplied_path(self, fake_home: Path, tmp_path: Path) -> None: + """PLUG-REQ-004.7.1: --log-file targets the supplied file.""" + log = tmp_path / "custom.jsonl" + log.write_text('{"type":"user","message":{"content":"hello"}}\n') + result = _run_script("--log-file", str(log), 'select(.type == "user") | .message.content') + assert result.returncode == 0, result.stderr + assert '"hello"' in result.stdout + # pointer line names the exact overridden path + assert str(log) in result.stdout + + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.7.2). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_override_without_path_errors(self, fake_home: Path) -> None: + """PLUG-REQ-004.7.2: --log-file with no value exits non-zero.""" + result = _run_script("--log-file") + assert result.returncode != 0 + assert result.stderr # diagnostic message required + + +# --------------------------------------------------------------------------- +# PLUG-REQ-004.8 / 004.9 / 004.10 / 004.11: Log-File Resolution Chain +# --------------------------------------------------------------------------- + + +class TestLogFileResolution: + def _write_jsonl(self, path: Path, value: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + # Include a unique marker so we can tell which file the script picked. + path.write_text( + f'{{"type":"user","message":{{"content":"{value}"}}}}\n', + encoding="utf-8", + ) + + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.8.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_subagent_resolution(self, fake_home: Path) -> None: + """PLUG-REQ-004.8.1: sub-agent path is preferred when both IDs set.""" + sid = str(uuid.uuid4()) + aid = "abc123def" + sub = fake_home / sid / "subagents" / f"agent-{aid}.jsonl" + self._write_jsonl(sub, "SUB") + top = fake_home / f"{sid}.jsonl" + self._write_jsonl(top, "TOP") + result = _run_script( + ".message.content", + env_overrides={ + "CLAUDE_CODE_SESSION_ID": sid, + "CLAUDE_CODE_AGENT_ID": aid, + }, + ) + assert result.returncode == 0, result.stderr + assert '"SUB"' in result.stdout + assert str(sub) in result.stdout + assert '"TOP"' not in result.stdout + + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.8.2). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_subagent_fallthrough_to_top_level(self, fake_home: Path) -> None: + """PLUG-REQ-004.8.2: missing sub-agent file falls through to top-level.""" + sid = str(uuid.uuid4()) + aid = "missingagent" + top = fake_home / f"{sid}.jsonl" + self._write_jsonl(top, "TOP") + # Do NOT create the sub-agent file. + result = _run_script( + ".message.content", + env_overrides={ + "CLAUDE_CODE_SESSION_ID": sid, + "CLAUDE_CODE_AGENT_ID": aid, + }, + ) + assert result.returncode == 0, result.stderr + assert '"TOP"' in result.stdout + assert str(top) in result.stdout + + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.9.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_top_level_resolution(self, fake_home: Path) -> None: + """PLUG-REQ-004.9.1: SESSION_ID alone resolves to top-level path.""" + sid = str(uuid.uuid4()) + top = fake_home / f"{sid}.jsonl" + self._write_jsonl(top, "TOP") + result = _run_script( + ".message.content", + env_overrides={"CLAUDE_CODE_SESSION_ID": sid}, + ) + assert result.returncode == 0, result.stderr + assert '"TOP"' in result.stdout + assert str(top) in result.stdout + + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.10.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_fallback_picks_most_recent(self, fake_home: Path) -> None: + """PLUG-REQ-004.10.1: fallback picks most-recently-modified jsonl.""" + older = fake_home / f"{uuid.uuid4()}.jsonl" + self._write_jsonl(older, "OLDER") + newer = fake_home / f"{uuid.uuid4()}.jsonl" + self._write_jsonl(newer, "NEWER") + # Force mtimes so `newer` is strictly more recent. + os.utime(older, (1_000_000, 1_000_000)) + os.utime(newer, (2_000_000, 2_000_000)) + result = _run_script(".message.content") + assert result.returncode == 0, result.stderr + assert '"NEWER"' in result.stdout + assert str(newer) in result.stdout + + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.11.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_unresolvable_exits_1(self, fake_home: Path) -> None: + """PLUG-REQ-004.11.1: nothing to resolve → diagnostic + exit 1.""" + # fake_home's project dir is empty and no env vars are set. + result = _run_script(".") + assert result.returncode == 1 + assert "CLAUDE_CODE_SESSION_ID" in result.stderr + assert "CLAUDE_CODE_AGENT_ID" in result.stderr + + +# --------------------------------------------------------------------------- +# PLUG-REQ-004.12: Compaction-Summary Filter +# --------------------------------------------------------------------------- + + +class TestCompactionFilter: + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.12.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_compaction_lines_are_dropped(self, fake_home: Path, tmp_path: Path) -> None: + """PLUG-REQ-004.12.1: isCompactSummary lines are pre-filtered out.""" + log = tmp_path / "compact.jsonl" + log.write_text( + '{"type":"user","message":{"content":"keep-me"}}\n' + '{"type":"user","isCompactSummary":true,"message":{"content":"DROP"}}\n' + '{"type":"user","message":{"content":"also-keep"}}\n', + encoding="utf-8", + ) + # A filter that would match the compaction entry ONLY. If the pre-filter + # works, this returns zero lines of jq output. + result = _run_script( + "--log-file", + str(log), + "select(.isCompactSummary == true) | .message.content", + ) + assert result.returncode == 0, result.stderr + # jq stdout should have no match lines (pointer line is stripped below) + jq_lines = [ + ln + for ln in result.stdout.splitlines() + if ln.strip() and not ln.startswith(POINTER_LINE_PREFIX) + ] + assert jq_lines == [] + + +# --------------------------------------------------------------------------- +# PLUG-REQ-004.13: jq Pass-Through +# --------------------------------------------------------------------------- + + +class TestJqPassthrough: + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.13.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_raw_flag_is_honored(self, fake_home: Path, tmp_path: Path) -> None: + """PLUG-REQ-004.13.1: jq flags (e.g. -r) pass through verbatim.""" + log = tmp_path / "raw.jsonl" + log.write_text( + '{"type":"user","message":{"content":"hello"}}\n', + encoding="utf-8", + ) + result = _run_script("--log-file", str(log), "-r", ".message.content") + assert result.returncode == 0, result.stderr + # With -r the first line of output is unquoted `hello`, not `"hello"`. + first = result.stdout.splitlines()[0] + assert first == "hello" + + +# --------------------------------------------------------------------------- +# PLUG-REQ-004.14: Exit Code Propagation +# --------------------------------------------------------------------------- + + +class TestExitCodePropagation: + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.14.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_user_jq_error_surfaces(self, fake_home: Path, tmp_path: Path) -> None: + """PLUG-REQ-004.14.1: user jq's non-zero exit propagates.""" + log = tmp_path / "ok.jsonl" + log.write_text( + '{"type":"user","message":{"content":"x"}}\n', + encoding="utf-8", + ) + # Malformed jq filter → jq exits 3. + result = _run_script("--log-file", str(log), "this is not valid jq syntax ###") + assert result.returncode != 0 + # Pointer line is still emitted (see PLUG-REQ-004.15.2). + assert "Explore agent" in result.stdout + + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.14.2). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_prefilter_error_does_not_mask_user_jq_success( + self, fake_home: Path, tmp_path: Path + ) -> None: + """PLUG-REQ-004.14.2: malformed JSON in the transcript doesn't flip exit.""" + log = tmp_path / "malformed.jsonl" + # The second line is malformed JSON — jq -c of the pre-filter will + # exit non-zero when it hits this line. The user's jq (.) on the + # preceding good line should still succeed and set exit 0. + log.write_text( + '{"type":"user","message":{"content":"good"}}\nthis is not json\n', + encoding="utf-8", + ) + result = _run_script("--log-file", str(log), ".message.content") + assert result.returncode == 0, result.stderr + assert '"good"' in result.stdout + + +# --------------------------------------------------------------------------- +# PLUG-REQ-004.15: Trailing Pointer Line +# --------------------------------------------------------------------------- + + +class TestTrailingPointerLine: + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.15.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_pointer_line_on_match(self, fake_home: Path, tmp_path: Path) -> None: + """PLUG-REQ-004.15.1: pointer line names the resolved log file path.""" + log = tmp_path / "ptr.jsonl" + log.write_text( + '{"type":"user","message":{"content":"hi"}}\n', + encoding="utf-8", + ) + result = _run_script("--log-file", str(log), ".type") + assert result.returncode == 0, result.stderr + assert f"{POINTER_LINE_PREFIX} {log}" in result.stdout + + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.15.2). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_pointer_line_on_empty_match(self, fake_home: Path, tmp_path: Path) -> None: + """PLUG-REQ-004.15.2: pointer line is printed even when jq returns nothing.""" + log = tmp_path / "empty.jsonl" + log.write_text( + '{"type":"user","message":{"content":"hi"}}\n', + encoding="utf-8", + ) + result = _run_script("--log-file", str(log), 'select(.type == "never_matches")') + assert result.returncode == 0, result.stderr + assert str(log) in result.stdout + assert "Explore agent" in result.stdout + + +# --------------------------------------------------------------------------- +# PLUG-REQ-004.16: Skill Location and Frontmatter +# --------------------------------------------------------------------------- + + +class TestSkillFrontmatter: + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.16.1). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_skill_exists(self) -> None: + """PLUG-REQ-004.16.1: SKILL.md exists at the expected path.""" + assert SKILL_PATH.is_file() + + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.16.2). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_skill_name_matches_directory(self) -> None: + """PLUG-REQ-004.16.2: frontmatter name matches directory name.""" + fm = _parse_yaml_frontmatter(SKILL_PATH) + assert fm["name"] == "search_conversation" + + # THIS TEST VALIDATES A HARD REQUIREMENT (PLUG-REQ-004.16.3). + # YOU MUST NOT MODIFY THIS TEST UNLESS THE REQUIREMENT CHANGES + def test_skill_description_non_empty(self) -> None: + """PLUG-REQ-004.16.3: frontmatter has a non-empty description.""" + fm = _parse_yaml_frontmatter(SKILL_PATH) + assert isinstance(fm["description"], str) + assert fm["description"].strip()