From a3cb0a7aba6fd0ba49dd62d5587023832c28d650 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Wed, 6 May 2026 13:53:31 -0700 Subject: [PATCH] import from local vendored copy (2026-05-06) --- .gitattributes | 8 ++ .github/workflows/ci.yml | 5 ++ .gitignore | 25 ++++++ .molecule-ci/scripts/requirements.txt | 1 + .molecule-ci/scripts/validate-plugin.py | 50 +++++++++++ CLAUDE.md | 106 ++++++++++++++++++++++++ README.md | 19 +++++ adapters/__init__.py | 0 adapters/claude_code.py | 2 + hooks/_lib.py | 46 ++++++++++ hooks/session-start-context.py | 71 ++++++++++++++++ hooks/session-start-context.sh | 2 + known-issues.md | 54 ++++++++++++ plugin.yaml | 11 +++ runbooks/local-dev-setup.md | 92 ++++++++++++++++++++ settings-fragment.json | 1 + 16 files changed, 493 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .molecule-ci/scripts/requirements.txt create mode 100644 .molecule-ci/scripts/validate-plugin.py create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 adapters/__init__.py create mode 100644 adapters/claude_code.py create mode 100755 hooks/_lib.py create mode 100755 hooks/session-start-context.py create mode 100755 hooks/session-start-context.sh create mode 100644 known-issues.md create mode 100644 plugin.yaml create mode 100644 runbooks/local-dev-setup.md create mode 100644 settings-fragment.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..034aa0e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Shell scripts and Python hooks are executed by Linux containers. +# Force LF so Windows checkouts (core.autocrlf=true) don't break the +# hook dispatch path — see Molecule-AI/molecule-core#507 where CRLF +# line endings made claude-code try to exec `session-start-context.py\r` +# and the SessionStart hook failed silently, producing +# "(no response generated)" on every agent A2A call. +*.sh text eol=lf +*.py text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c8fb9d3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,5 @@ +name: CI +on: [push, pull_request] +jobs: + validate: + uses: Molecule-AI/molecule-ci/.github/workflows/validate-plugin.yml@main diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6fbe4b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Credentials — never commit. Use .env.example as the template. +.env +.env.local +.env.*.local +.env.* +!.env.example +!.env.sample + +# Private keys + certs +*.pem +*.key +*.crt +*.p12 +*.pfx + +# Secret directories +.secrets/ + +# Workspace auth tokens +.auth-token +.auth_token + +# Python bytecode +__pycache__/ +*.py[cod] diff --git a/.molecule-ci/scripts/requirements.txt b/.molecule-ci/scripts/requirements.txt new file mode 100644 index 0000000..3aecde9 --- /dev/null +++ b/.molecule-ci/scripts/requirements.txt @@ -0,0 +1 @@ +pyyaml>=6.0 diff --git a/.molecule-ci/scripts/validate-plugin.py b/.molecule-ci/scripts/validate-plugin.py new file mode 100644 index 0000000..4bf0a15 --- /dev/null +++ b/.molecule-ci/scripts/validate-plugin.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Validate a Molecule AI plugin repo.""" +import os, sys, yaml + +errors = [] + +if not os.path.isfile("plugin.yaml"): + print("::error::plugin.yaml not found at repo root") + sys.exit(1) + +with open("plugin.yaml") as f: + plugin = yaml.safe_load(f) + +for field in ["name", "version", "description"]: + if not plugin.get(field): + errors.append(f"Missing required field: {field}") + +v = str(plugin.get("version", "")) +if v and not all(c in "0123456789." for c in v): + errors.append(f"Invalid version format: {v}") + +runtimes = plugin.get("runtimes") +if runtimes is not None and not isinstance(runtimes, list): + errors.append(f"runtimes must be a list, got {type(runtimes).__name__}") + +content_paths = ["SKILL.md", "hooks", "skills", "rules"] +found = [p for p in content_paths if os.path.exists(p)] +if not found: + errors.append("Plugin must contain at least one of: SKILL.md, hooks/, skills/, rules/") + +if os.path.isfile("SKILL.md"): + with open("SKILL.md") as f: + first_line = f.readline().strip() + if first_line and not first_line.startswith("#"): + print("::warning::SKILL.md should start with a markdown heading (e.g., # Plugin Name)") + +if errors: + for e in errors: + print(f"::error::{e}") + sys.exit(1) + +pn = plugin["name"]; pv = plugin["version"] +print(f"\u2713 plugin.yaml valid: {pn} v{pv}") +if found: + print(f" Content: {', '.join(found)}") +if runtimes: + print(f" Runtimes: {', '.join(runtimes)}") + + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f9e213e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,106 @@ +# molecule-session-context — Session Start Context Loader + +`molecule-session-context` is a **session-initialisation hook plugin** that +auto-loads recent cron-learnings and repo PR/issue counts at `SessionStart`. +Pairs with `molecule-cron-learnings`. + +**Version:** 1.0.0 +**Runtime:** `claude_code` + +--- + +## Repository Layout + +``` +molecule-session-context/ +├── plugin.yaml — Plugin manifest +├── hooks/ +│ └── session-start-context/ +│ └── hook.json — SessionStart hook definition +└── adapters/ — Harness adaptors +``` + +--- + +## What It Does + +At the start of every session, this hook: +1. Reads the last N lines of `~/.claude/projects//cron-learnings.jsonl` +2. Loads current PR/issue counts for the workspace repo +3. Surfaces this context to the agent in the first response + +This means the agent enters every session already knowing: +- What went wrong last time (from cron-learnings) +- How many open PRs and issues exist (context before acting) + +--- + +## SessionStart Hook + +The hook fires on every new session for a workspace. Configure how many +learnings to load via workspace settings: + +```json +{ + "session_context": { + "learnings_lines": 20, + "include_pr_counts": true, + "include_issue_counts": true + } +} +``` + +--- + +## Development + +### Prerequisites + +- Python 3.11+ +- `gh` CLI authenticated +- Write access to `Molecule-AI/molecule-ai-plugin-molecule-session-context` + +### Setup + +```bash +git clone https://github.com/Molecule-AI/molecule-ai-plugin-molecule-session-context.git +cd molecule-ai-plugin-molecule-session-context + +# YAML validation +python3 -c "import yaml; yaml.safe_load(open('plugin.yaml'))" +``` + +### Pre-Commit Checklist + +```bash +# YAML structure +python3 -c "import yaml; yaml.safe_load(open('plugin.yaml'))" + +# Credential scan +python3 -c " +import re, sys +with open('plugin.yaml') as f: + content = f.read() +patterns = [r'sk.ant', r'ghp.', r'AKIA[A-Z0-9]'] +if any(re.search(p, content) for p in patterns): + print('FAIL: possible credentials found') + sys.exit(1) +print('No credentials: OK') +" +``` + +--- + +## Release Process + +1. Review changes: `git log origin/main..HEAD --oneline` +2. Bump `version` in `plugin.yaml` (semver) +3. Commit: `chore: bump version to X.Y.Z` +4. Tag and push: `git tag vX.Y.Z && git push origin main --tags` +5. Create GitHub Release with changelog + +--- + +## Known Issues + +See `known-issues.md` at the repo root. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0feaf56 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# molecule-session-context + +Molecule AI plugin. Install via the Molecule AI platform plugin system. + +## Usage + +### In org template (org.yaml) +```yaml +plugins: + - molecule-session-context +``` + +### From URL (community install) +``` +github://Molecule-AI/molecule-ai-plugin-molecule-session-context +``` + +## License +Business Source License 1.1 — © Molecule AI. diff --git a/adapters/__init__.py b/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adapters/claude_code.py b/adapters/claude_code.py new file mode 100644 index 0000000..cc58993 --- /dev/null +++ b/adapters/claude_code.py @@ -0,0 +1,2 @@ +"""Claude Code adaptor — uses the generic rule+skill+hooks installer.""" +from plugins_registry.builtins import AgentskillsAdaptor as Adaptor # noqa: F401 diff --git a/hooks/_lib.py b/hooks/_lib.py new file mode 100755 index 0000000..1d0555a --- /dev/null +++ b/hooks/_lib.py @@ -0,0 +1,46 @@ +"""Common helpers for Claude Code hooks. Imported by the .py hook scripts. + +Hooks receive JSON on stdin per the Claude Code hook spec, and may emit +JSON on stdout or exit with code 2 to block. This module wraps both. +""" +import json +import sys + + +def read_input() -> dict: + """Parse stdin JSON. Empty input → empty dict.""" + raw = sys.stdin.read().strip() + if not raw: + return {} + try: + return json.loads(raw) + except json.JSONDecodeError: + return {} + + +def emit(payload: dict) -> None: + """Print JSON payload to stdout for the harness to interpret.""" + print(json.dumps(payload)) + + +def deny_pretooluse(reason: str) -> None: + """Emit a PreToolUse denial with reason and exit 0.""" + emit({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + }) + sys.exit(0) + + +def add_context(text: str) -> None: + """Emit additionalContext for SessionStart / UserPromptSubmit hooks.""" + if text and text.strip(): + emit({"additionalContext": text}) + + +def warn_to_stderr(msg: str) -> None: + """Non-blocking warning visible to the next agent turn via stderr.""" + print(msg, file=sys.stderr) diff --git a/hooks/session-start-context.py b/hooks/session-start-context.py new file mode 100755 index 0000000..8f418f6 --- /dev/null +++ b/hooks/session-start-context.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""SessionStart hook — auto-load recent cron-learnings, freeze status, +and a one-line repo snapshot into Claude's context. +""" +import os +import subprocess +import sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _lib import add_context, warn_to_stderr # noqa + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +LEARNINGS = os.path.expanduser( + "~/.claude/projects/-Users-hongming-Documents-GitHub-molecule-monorepo/memory/cron-learnings.jsonl" +) +FREEZE = os.path.join(REPO, ".claude", "freeze") + + +def tail(path: str, n: int) -> str: + if not os.path.isfile(path): + return "" + try: + with open(path) as f: + lines = f.readlines() + return "".join(lines[-n:]).rstrip() + except Exception: + return "" + + +def gh_count(args: list) -> str: + try: + out = subprocess.run( + ["gh"] + args + ["--json", "number"], + capture_output=True, text=True, timeout=4, + ) + if out.returncode != 0: + return "?" + import json + return str(len(json.loads(out.stdout or "[]"))) + except Exception: + return "?" + + +def main() -> None: + parts = [] + + learnings = tail(LEARNINGS, 20) + if learnings: + parts.append(f"## Recent cron learnings (last 20)\n{learnings}") + + if os.path.isfile(FREEZE): + try: + with open(FREEZE) as f: + frozen = f.readline().strip() + parts.append(f"## ⚠ FREEZE ACTIVE\nEdits restricted to: {frozen}\nRemove .claude/freeze to unlock.") + except Exception: + pass + + pr = gh_count(["pr", "list", "--repo", "Molecule-AI/molecule-monorepo", "--state", "open"]) + iss = gh_count(["issue", "list", "--repo", "Molecule-AI/molecule-monorepo", "--state", "open"]) + parts.append(f"## Repo state\nOpen PRs: {pr} · Open issues: {iss}") + + if parts: + add_context("\n\n".join(parts)) + + +if __name__ == "__main__": + try: + main() + except Exception as e: + warn_to_stderr(f"[session-start hook error] {e}") + sys.exit(0) diff --git a/hooks/session-start-context.sh b/hooks/session-start-context.sh new file mode 100755 index 0000000..f0068a6 --- /dev/null +++ b/hooks/session-start-context.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec python3 "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/session-start-context.py" diff --git a/known-issues.md b/known-issues.md new file mode 100644 index 0000000..41a7385 --- /dev/null +++ b/known-issues.md @@ -0,0 +1,54 @@ +# Known Issues — molecule-session-context + +--- + +## Active Issues + +*(None currently open. This section is updated when issues are filed.)* + +--- + +## Recently Resolved + +*(No recently resolved issues.)* + +--- + +## How to Update This File + +When a new issue is identified: +1. Add it under **Active Issues** using the template below +2. Include: symptom, cause (if known), workaround +3. When fixed, move to **Recently Resolved** and note the fix version + +### Issue Template + +```markdown +## [TICKET-NUMBER] + +**Severity:** P0 / P1 / P2 / P3 +**Status:** Workaround / Fix in progress / Fix available +**Affected versions:** All / vX.Y.Z+ + +**Symptoms:** +**Cause:** +**Workaround:** +**Fix (if available):** +``` + +--- + +## Severity Definitions + +| Level | Description | +|---|---| +| P0 | Session start crashes; no context loaded | +| P1 | Context loaded but wrong workspace | +| P2 | Context stale or missing learnings | +| P3 | Cosmetic or documentation issue | + +--- + +## Reporting + +Use the Molecule-AI/internal issue tracker. Tag with `plugin-molecule-session-context`. diff --git a/plugin.yaml b/plugin.yaml new file mode 100644 index 0000000..d196824 --- /dev/null +++ b/plugin.yaml @@ -0,0 +1,11 @@ +name: molecule-session-context +version: 1.0.0 +description: Auto-load recent cron-learnings + repo PR/issue counts at SessionStart. Pairs well with molecule-cron-learnings. +author: Molecule AI +tags: [molecule, guardrails] + +runtimes: + - claude_code + +hooks: + - session-start-context diff --git a/runbooks/local-dev-setup.md b/runbooks/local-dev-setup.md new file mode 100644 index 0000000..d610249 --- /dev/null +++ b/runbooks/local-dev-setup.md @@ -0,0 +1,92 @@ +# Local Development Setup + +This runbook covers setting up a local development environment for +`molecule-session-context`. + +--- + +## Prerequisites + +- Python 3.11+ +- `gh` CLI authenticated +- Write access to `Molecule-AI/molecule-ai-plugin-molecule-session-context` + +--- + +## Clone & Bootstrap + +```bash +git clone https://github.com/Molecule-AI/molecule-ai-plugin-molecule-session-context.git +cd molecule-ai-plugin-molecule-session-context +``` + +--- + +## Validating Plugin Structure + +```bash +# YAML structure +python3 -c "import yaml; yaml.safe_load(open('plugin.yaml'))" +echo "plugin.yaml OK" + +# Check all hook paths exist +python3 -c " +import yaml, os +with open('plugin.yaml') as f: + data = yaml.safe_load(f) +for hook in data.get('hooks', []): + path = f'hooks/{hook}/hook.json' + exists = os.path.exists(path) + print(f'[{\"OK\" if exists else \"MISSING\"}] {path}') +" +``` + +--- + +## Testing the SessionStart Hook + +The harness wrapper is provided by the Molecule AI platform at runtime. To test: + +1. Install the plugin in a test workspace +2. Start a new session +3. Verify the first agent response references recent learnings +4. Check the cron-learnings JSONL file has entries + +--- + +## Simulating Session Context + +To test without a live workspace: + +```bash +# Create a mock learnings file +mkdir -p ~/.claude/projects/test-project +cat > ~/.claude/projects/test-project/cron-learnings.jsonl << 'EOF' +{"tick": "2026-04-21T00:00Z", "role": "test-lead", "learnings": ["GH_TOKEN expired — refresh needed", "PR template missing Testing section"]} +EOF + +# Simulate session start (hook reads this file) +cat ~/.claude/projects/test-project/cron-learnings.jsonl +``` + +--- + +## Troubleshooting + +### No context loaded at session start + +- Verify `hooks/session-start-context/hook.json` is correctly named and placed +- Check the hook is registered in `plugin.yaml` +- Verify the workspace has read access to `~/.claude/projects/` + +### Stale learnings + +- The hook reads the JSONL file on every session start — check file permissions +- If learnings are not being appended, check `molecule-cron-learnings` is installed + +--- + +## Related + +- `molecule-cron-learnings` — appends learnings at end of each tick +- `molecule-workflow-retro` — generates weekly retrospectives from learnings diff --git a/settings-fragment.json b/settings-fragment.json new file mode 100644 index 0000000..1f560a1 --- /dev/null +++ b/settings-fragment.json @@ -0,0 +1 @@ +{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"bash ${CLAUDE_DIR}/hooks/session-start-context.sh"}]}]}}