diff --git a/plugins/claude-code/README.md b/plugins/claude-code/README.md index dfc8c77fe..6ef4347af 100644 --- a/plugins/claude-code/README.md +++ b/plugins/claude-code/README.md @@ -20,9 +20,9 @@ the session back to it before the context window compacts. queries Basic Memory for your active tasks and recent work and puts a short brief in front of Claude — so you start where you left off instead of cold. - **Compaction checkpoint (PreCompact hook).** Right before Claude Code compacts - the context window, the plugin writes a `type: session` checkpoint note to the - graph, so the texture of the session survives and the next one can resume from - it. + the context window, the plugin writes a general `session` or schema-backed + `coding_session` checkpoint note to the graph, so the texture of the session + survives and the next one can resume from it. - **Deliberate checkpoints (`bm-checkpoint` skill).** On request — "checkpoint this", "wrap up", "hand off" — Claude writes a durable handoff note: the story, verification actually run, decisions, blockers, and the next action. In a @@ -138,19 +138,19 @@ settings (or select it via `/config`). | `recallTimeframe` | `3d` | Recency window for the session brief | | `recallPrompt` | _(built-in)_ | The instruction appended to the brief | | `preCompactCapture` | `extractive` | How checkpoints are produced | -| `sessionProfile` | `general` | `coding` makes deliberate checkpoints schema-backed `coding_session` notes with required Git identity | +| `sessionProfile` | `general` | `coding` makes checkpoints schema-backed `coding_session` notes with required Git identity | | `repository` | _(none)_ | User-confirmed stable repository identifier (`owner/name`); required for the `coding` profile | | `captureEvents` | `false` | Opt-in: record redacted lifecycle-event envelopes to the local inbox (see `basic-memory hook status` / `flush`). Only the JSON boolean `true` enables it. | | `redactKeys` | `[]` | Additional payload keys to redact before an event enters the local inbox | | `redactPaths` | `[]` | Additional paths to redact from working-directory and path-bearing capture content | The plugin seeds schemas for notes the Claude integration writes directly: -`session`, `decision`, and `task`. A **coding setup** (the interview's focus -answer is code/dev, persisted as `sessionProfile: "coding"` with a -user-confirmed `repository`) also seeds `coding_session`, whose required -repository, repo-root, working-directory, branch, and Git SHA frontmatter make -checkpoints queryable by structured filters; typed pull-request fields are -added when a PR exists. Optional flush projection also writes +`decision`, `task`, and the session type relevant to the selected profile. A +general setup seeds `session`; a **coding setup** (persisted as +`sessionProfile: "coding"` with a user-confirmed `repository`) seeds +`coding_session`, whose required repository, repo-root, working-directory, +branch, and Git SHA frontmatter make checkpoints queryable by structured +filters; typed pull-request fields are added when a PR exists. Optional flush projection also writes normalized `session` and `tool_ledger` artifacts. Those projection contracts are owned and tested by Basic Memory core rather than copied into separate host-plugin schemas. diff --git a/plugins/claude-code/schemas/coding-session.md b/plugins/claude-code/schemas/coding-session.md index 65e7ad96b..49b4fb9e4 100644 --- a/plugins/claude-code/schemas/coding-session.md +++ b/plugins/claude-code/schemas/coding-session.md @@ -23,7 +23,7 @@ settings: git_sha: string, exact Git commit at checkpoint time ended?: string, when the session was checkpointed status?(enum, lifecycle of the checkpoint): [open, resumed, closed] - pull_request_number?: integer, current pull request number + pull_request_number?: string, current pull request number as a queryable identifier pull_request_title?: string, current pull request title pull_request_url?: string, canonical pull request URL pull_request_state?(enum, pull request state at checkpoint time): [open, closed, merged] @@ -32,6 +32,8 @@ settings: username?: string, operating-system user that created the checkpoint hostname?: string, host that created the checkpoint claude_session_id?: string, Claude Code session identifier + codex_session_id?: string, Codex session identifier + codex_turn_id?: string, Codex turn identifier trigger?: string, compaction trigger or deliberate checkpoint source model?: string, active model slug when known capture?(enum, how this checkpoint was produced): [extractive, deliberate, summarized] @@ -47,7 +49,7 @@ Examples: `search_notes(note_types=["coding_session"], metadata_filters={"repository": "owner/repo"})` -`search_notes(note_types=["coding_session"], metadata_filters={"pull_request_number": 123})` +`search_notes(note_types=["coding_session"], metadata_filters={"pull_request_number": "123"})` Pull-request fields are optional because valid coding work can precede a pull request. When a pull request exists, checkpoint writers populate the complete diff --git a/plugins/claude-code/schemas/session.md b/plugins/claude-code/schemas/session.md index 583ed94b5..dee36ed96 100644 --- a/plugins/claude-code/schemas/session.md +++ b/plugins/claude-code/schemas/session.md @@ -34,8 +34,8 @@ session was doing so the next session can pick up where this one left off. Sessions are found by the SessionStart hook via structured recall: `search_notes(metadata_filters={"type": "session"}, after_date="3d")`. -In a **coding setup** (`sessionProfile: "coding"`), deliberate checkpoints use -the Coding Session schema instead — it adds required, queryable Git identity +In a **coding setup** (`sessionProfile: "coding"`), checkpoints use the Coding +Session schema instead — it adds required, queryable Git identity (`repository`, `branch`, `git_sha`, pull-request fields). This schema stays the general-purpose checkpoint. diff --git a/plugins/claude-code/skills/bm-checkpoint/SKILL.md b/plugins/claude-code/skills/bm-checkpoint/SKILL.md index 0e783f37f..0d4ae5761 100644 --- a/plugins/claude-code/skills/bm-checkpoint/SKILL.md +++ b/plugins/claude-code/skills/bm-checkpoint/SKILL.md @@ -83,8 +83,10 @@ When the current branch has a pull request, also add the typed optional fields `pull_request_number`, `pull_request_title`, `pull_request_url`, lowercase `pull_request_state`, `pull_request_base`, and `pull_request_head`. Resolve the pull request with a read-only GitHub query (e.g. `gh pr view --json ...`); omit -those fields when no PR exists. Never infer or copy repository/PR identity only -from conversation text. Stop if the required coding fields cannot be proven. +those fields when no PR exists. Write the number as a quoted string, for example +`pull_request_number: "123"`, so exact metadata queries behave consistently +across storage backends. Never infer or copy repository/PR identity only from +conversation text. Stop if the required coding fields cannot be proven. Begin the body with `# `. diff --git a/plugins/claude-code/skills/bm-setup/SKILL.md b/plugins/claude-code/skills/bm-setup/SKILL.md index df7311b73..76b17be67 100644 --- a/plugins/claude-code/skills/bm-setup/SKILL.md +++ b/plugins/claude-code/skills/bm-setup/SKILL.md @@ -107,9 +107,9 @@ Ask only what you can't infer. Cover: SessionStart brief surfaces it (alongside `captureFolder`), so this is what makes your captures land where the user expects — without it, placement is guesswork. -5. **Schemas.** "I'll add schemas for session checkpoints, decisions, and tasks - — plus coding sessions for a coding setup — so I can find them precisely - later — okay?" (See "Seed the schemas" below.) +5. **Schemas.** "I'll add the session schema for this profile, plus decision and + task schemas, so I can find them precisely later — okay?" (See "Seed the + schemas" below.) 6. **Lifecycle-event capture.** "Should I also keep a local, redacted trail of SessionStart and PreCompact events for later projection?" Default to **off**. @@ -141,7 +141,8 @@ Ask only what you can't infer. Cover: ### 1. Seed the schemas The plugin ships seed schemas at `/schemas/` — that's **two directories up from this skill's directory, then `schemas/`** (this skill is at -`/skills/bm-setup/`). Read `session.md`, `decision.md`, and `task.md` there. +`/skills/bm-setup/`). Read `coding-session.md` for a coding profile or +`session.md` for a general profile, then read `decision.md` and `task.md`. These schemas cover notes the Claude integration writes directly. The normalized `session` and `tool_ledger` artifacts written by `bm hook flush` are core-owned @@ -168,11 +169,10 @@ For each one: round-trips correctly on both local and cloud. After seeding, verify one note with `read_note(..., output_format="json", include_frontmatter=true)` — `schema`/`settings` must come back as nested objects, not strings. -- **Coding setup:** when `sessionProfile` is `coding`, also read and seed - `coding-session.md` (title `Coding Session`) the same way. Its Git identity - fields (`repository`, `repo_root`, `cwd`, `branch`, `git_sha`) are required by - design — required-and-proven fields are what make coding checkpoints queryable - — so seed the schema unmodified. +- **Coding setup:** the selected `coding-session.md` schema's Git identity fields + (`repository`, `repo_root`, `cwd`, `branch`, `git_sha`) are required by design. + Required-and-proven fields are what make coding checkpoints queryable, so seed + the schema unmodified instead of also seeding the general Session schema. ### 2. Install the shared skills (if the user opted in) **First, guard against clobbering a source checkout.** If `./skills` already exists, diff --git a/plugins/codex/README.md b/plugins/codex/README.md index 77120a348..809e99e56 100644 --- a/plugins/codex/README.md +++ b/plugins/codex/README.md @@ -12,7 +12,8 @@ verification, decision capture, and resumable checkpoints. - **Orient from memory.** The `bm-orient` skill reads active tasks, open decisions, and recent Codex checkpoints before substantial work. - **Checkpoint work.** The `bm-checkpoint` skill and `PreCompact` hook write - `type: codex_session` notes with the current work cursor. + general `codex_session` notes or schema-backed `coding_session` notes with + structured repository and pull-request context. - **Capture decisions.** The `bm-decide` skill records durable engineering decisions with rationale, alternatives, and consequences. - **Remember lightly.** The `bm-remember` skill saves small facts without turning @@ -87,13 +88,15 @@ Run the setup skill, or create `.codex/basic-memory.json` in a repo: "secondaryProjects": [], "teamProjects": {}, "focus": "code/dev", - "captureFolder": "codex-sessions", + "sessionProfile": "coding", + "repository": "owner/repo", + "captureFolder": "codex", "rememberFolder": "codex-remember", "recallTimeframe": "7d", "captureEvents": false, "redactKeys": [], "redactPaths": [], - "placementConventions": "Put decisions in decisions/ and work checkpoints in codex-sessions/." + "placementConventions": "Put decisions in decisions/ and work checkpoints in codex/." } } ``` @@ -105,10 +108,13 @@ Add `redactKeys` and `redactPaths` arrays to extend the built-in redaction floor for repository-specific payload fields and paths. The plugin's seed schemas cover notes Codex writes directly: `codex_session`, -`decision`, and `task`. Optional flush projection also writes normalized -`session` and `tool_ledger` artifacts. Those are core-owned contracts implemented -and tested with the projector, not duplicate schema files maintained by each host -plugin. `bm-orient` and `bm-status` still recall normalized `session` notes +`coding_session`, `decision`, and `task`. Coding sessions require structured +repository, repository-root, working-directory, branch, and Git SHA frontmatter; +current pull-request fields are added when a PR exists. Optional flush projection +also writes normalized `session` and `tool_ledger` artifacts. Those are +core-owned contracts implemented and tested with the projector, not duplicate +schema files maintained by each host plugin. `bm-orient` and `bm-status` still +recall normalized `session` notes alongside Codex checkpoints. Codex plugin hooks must be reviewed and trusted before they run. Open `/hooks` in diff --git a/plugins/codex/schemas/codex-session.md b/plugins/codex/schemas/codex-session.md index 01f314be4..9b6c26dac 100644 --- a/plugins/codex/schemas/codex-session.md +++ b/plugins/codex/schemas/codex-session.md @@ -30,7 +30,7 @@ settings: # Codex Session -A **CodexSession** note is a resumable engineering checkpoint. It captures the +A **CodexSession** note is a resumable general checkpoint. It captures the thread cursor: what changed, what was verified, what decisions matter, and what the next Codex thread should do first. diff --git a/plugins/codex/schemas/coding-session.md b/plugins/codex/schemas/coding-session.md new file mode 100644 index 000000000..49b4fb9e4 --- /dev/null +++ b/plugins/codex/schemas/coding-session.md @@ -0,0 +1,56 @@ +--- +title: Coding Session +type: schema +entity: CodingSession +version: 1 +schema: + summary?: string, one-paragraph what happened in this coding session + changed_file?(array): string, files created, edited, deleted, or inspected + verification?(array): string, checks run and their result + decision?(array): string, decisions surfaced or created during the session + blocker?(array): string, unresolved blockers or failed approaches + next_step?(array): string, explicit cursor for the next coding session + produced?(array): Entity, notes or artifacts created or updated +settings: + validation: warn + frontmatter: + project: string, the Basic Memory project this session belongs to + started: string, when the session began or checkpoint was created + repository: string, stable repository identifier such as owner/name + repo_root: string, Git repository root for this checkout + cwd: string, working directory for the session + branch: string, checked-out Git branch or HEAD when detached + git_sha: string, exact Git commit at checkpoint time + ended?: string, when the session was checkpointed + status?(enum, lifecycle of the checkpoint): [open, resumed, closed] + pull_request_number?: string, current pull request number as a queryable identifier + pull_request_title?: string, current pull request title + pull_request_url?: string, canonical pull request URL + pull_request_state?(enum, pull request state at checkpoint time): [open, closed, merged] + pull_request_base?: string, pull request base branch + pull_request_head?: string, pull request head branch + username?: string, operating-system user that created the checkpoint + hostname?: string, host that created the checkpoint + claude_session_id?: string, Claude Code session identifier + codex_session_id?: string, Codex session identifier + codex_turn_id?: string, Codex turn identifier + trigger?: string, compaction trigger or deliberate checkpoint source + model?: string, active model slug when known + capture?(enum, how this checkpoint was produced): [extractive, deliberate, summarized] +--- + +# Coding Session + +A **CodingSession** is a resumable engineering checkpoint whose repository +identity is structured and queryable. Required Git fields make it possible to +find the exact work cursor without parsing prose. + +Examples: + +`search_notes(note_types=["coding_session"], metadata_filters={"repository": "owner/repo"})` + +`search_notes(note_types=["coding_session"], metadata_filters={"pull_request_number": "123"})` + +Pull-request fields are optional because valid coding work can precede a pull +request. When a pull request exists, checkpoint writers populate the complete +pull-request field set. diff --git a/plugins/codex/skills/bm-checkpoint/SKILL.md b/plugins/codex/skills/bm-checkpoint/SKILL.md index 1c664ca79..67c408f50 100644 --- a/plugins/codex/skills/bm-checkpoint/SKILL.md +++ b/plugins/codex/skills/bm-checkpoint/SKILL.md @@ -14,8 +14,10 @@ context transition. Read `.codex/basic-memory.json` if present: - `primaryProject`, default omitted -- `captureFolder`, default `codex-sessions` +- `captureFolder`, default `codex` - `placementConventions`, optional +- `sessionProfile`, default `general` +- `repository`, required when `sessionProfile` is `coding` Apply the `bm-writing` skill before drafting the note. @@ -27,6 +29,9 @@ Gather repo evidence: - tradeoffs, sharp edges, useful simplifications, and intentionally parked work - `git status --short` - current branch +- repository root and current working directory +- current Git SHA +- current pull request number, title, URL, state, base, and head when one exists - changed files you touched - tests or checks actually run - failures or skipped checks @@ -42,7 +47,7 @@ Do not claim a test passed unless you ran it or the user supplied the result. A checkpoint is a durable handoff, not a status dump or commit-by-commit changelog. Tell the story for a human or agent returning later. -Write a note to Basic Memory: +Write a note to Basic Memory. For the `general` profile: - `title`: `Codex checkpoint - ` - `directory`: configured `captureFolder` @@ -57,6 +62,24 @@ Write a note to Basic Memory: - `hostname: ` - `capture: deliberate` +For the `coding` profile, write `type: coding_session` and use the same common +frontmatter plus these schema-required fields: + +- `repository: ` +- `repo_root: ` +- `cwd: ` +- `branch: ` +- `git_sha: ` + +When the current branch has a pull request, also add the typed optional fields +`pull_request_number`, `pull_request_title`, `pull_request_url`, +`pull_request_state`, `pull_request_base`, and `pull_request_head`. Resolve the +pull request with a read-only GitHub query; omit those fields when no PR exists. +Write the number as a quoted string, for example `pull_request_number: "123"`, +so exact metadata queries behave consistently across storage backends. +Never infer or copy repository/PR identity only from conversation text. Stop if +the required coding fields cannot be proven. + Begin the body with `# `. Use these sections, omitting optional ones that add no value: diff --git a/plugins/codex/skills/bm-orient/SKILL.md b/plugins/codex/skills/bm-orient/SKILL.md index 0dc1280ca..ea005b5ab 100644 --- a/plugins/codex/skills/bm-orient/SKILL.md +++ b/plugins/codex/skills/bm-orient/SKILL.md @@ -11,25 +11,34 @@ the user asks where things stand. ## Steps 1. Read `.codex/basic-memory.json` if present. Use `primaryProject`, `secondaryProjects`, - `recallTimeframe`, and `placementConventions`. If the file is missing, continue + `recallTimeframe`, `sessionProfile`, `repository`, and `placementConventions`. + If the file is missing, continue against the default Basic Memory project and mention that setup has not been run. 2. Query the primary project: - active tasks: `type=task`, `status=active` - open decisions: `type=decision`, `status=open` - recent Codex sessions: `type=codex_session`, after `recallTimeframe` + - recent coding sessions: `type=coding_session`, + `repository=`, after `recallTimeframe`, when + `sessionProfile=coding` - recent core-projected sessions: `type=session`, after `recallTimeframe` - Always query both session types. Merge and deduplicate the results, sort them + Always query `codex_session` and `session`; include `coding_session` for a + coding profile only with the configured `repository` metadata filter. Never + run an unscoped coding-session query; if the repository is missing, report + that setup is incomplete. Merge and deduplicate the results, sort them newest first, and prefer the highest-signal checkpoint regardless of which - producer wrote it. `codex_session` preserves deliberate and legacy Codex - checkpoints; `session` carries normalized artifacts from `bm hook flush`. + producer wrote it. `coding_session` carries schema-required, queryable Git + context; `codex_session` preserves general and legacy Codex checkpoints; + `session` carries normalized artifacts from `bm hook flush`. 3. Query configured `secondaryProjects` read-only for open decisions. Do not write to shared projects during orientation. 4. Read the highest-signal hits before summarizing. Prefer notes that match the - current repo, named route, issue, branch, or file path. + current repository, branch, Git SHA, pull request, named route, issue, or file + path. For coding sessions, use structured metadata filters before text search. 5. Present a compact orientation: - active work diff --git a/plugins/codex/skills/bm-setup/SKILL.md b/plugins/codex/skills/bm-setup/SKILL.md index 55ce568d2..008c8da3a 100644 --- a/plugins/codex/skills/bm-setup/SKILL.md +++ b/plugins/codex/skills/bm-setup/SKILL.md @@ -28,10 +28,14 @@ repo, default project, current directory, or previous local state. - storage mode: cloud, local, or mixed. Prefer the user's stated mode over any CLI default. - `focus`: code/dev, research, writing, planning, or mixed. +- `sessionProfile`: `coding` or `general`. Recommend `coding` for code/dev. For + mixed use, ask whether this repository should capture Git and pull-request + context. Do not infer `coding` merely because the current directory is a Git + checkout. - `primaryProject`: an existing Basic Memory project or a new one to create. - `secondaryProjects`: optional read-only projects for session-start context. - `teamProjects`: optional share targets for `bm-share`. -- `captureFolder`: default `codex-sessions`. +- `captureFolder`: default `codex`. - `rememberFolder`: default `codex-remember`. - `placementConventions`: a short note about where decisions, tasks, and research notes should land. @@ -42,6 +46,13 @@ repo, default project, current directory, or previous local state. floor. Ask for these only when event capture is enabled or the user has repo-specific privacy requirements. +For the `coding` session profile, verify the current directory is inside a Git +repository. Resolve a stable `repository` identifier such as `owner/name` from +the current GitHub repository or origin remote, show it to the user, and ask for +confirmation. Do not guess when the remote is missing or ambiguous. Explain that +coding checkpoints store structured repository, branch, SHA, working-directory, +and optional pull-request metadata in Basic Memory. + Explain the capture tradeoff before asking: enabled capture adds a local, redacted event trail that stays queued until `bm hook flush` projects it. It does not write to team projects, and only the JSON boolean `true` enables it. @@ -66,7 +77,9 @@ After confirming the plan, write `.codex/basic-memory.json` in the repo: "projectMode": "cloud", "teamProjects": {}, "focus": "", - "captureFolder": "codex-sessions", + "sessionProfile": "coding", + "repository": "owner/name", + "captureFolder": "codex", "rememberFolder": "codex-remember", "recallTimeframe": "7d", "captureEvents": false, @@ -85,15 +98,24 @@ redaction, while `redactPaths` also protects working-directory and path-bearing checkpoint content. This file is intentionally Codex-specific; do not write `.claude/settings.json`. +Persist `sessionProfile` explicitly. Persist `repository` only for the `coding` +profile, after the user confirms it. A coding setup is incomplete without a +repository identifier because the `coding_session` schema requires queryable Git +identity fields. + ## Seed Schemas Read the schema files from `/schemas/`. This skill lives at `/skills/bm-setup/SKILL.md`, so the schemas are two directories up. -Seed these schema notes into the chosen `primaryProject` if they do not already -exist: +Seed the session schema relevant to the selected profile into the chosen +`primaryProject` if it does not already exist: + +- `coding-session.md` for `sessionProfile: coding` +- `codex-session.md` for `sessionProfile: general` + +Then seed these schemas for both profiles: -- `codex-session.md` - `decision.md` - `task.md` @@ -114,11 +136,13 @@ ambiguous. Before closing, prove the mapping works: -- Search the primary project for `type=schema` with page size 5. +- Search the primary project for `type=schema` with page size 10. For a coding + setup, confirm the `Coding Session` schema is present. - Search one shared project for open decisions if shared projects were configured. - Run `basic-memory hook status --harness codex --project-dir ` (using `bm` or `uvx basic-memory` if needed). Confirm that it finds this repo's - settings, reports the selected project, and shows the intended capture state. + settings, reports the selected project, session profile, repository, and + intended capture state. Its inbox counts are shared across harnesses. - If any check errors, fix the project ref or hook launcher before finishing. diff --git a/plugins/codex/skills/bm-status/SKILL.md b/plugins/codex/skills/bm-status/SKILL.md index cca2d4674..074d85b0e 100644 --- a/plugins/codex/skills/bm-status/SKILL.md +++ b/plugins/codex/skills/bm-status/SKILL.md @@ -22,7 +22,8 @@ Gather a concise diagnostic. Do not over-investigate. - read `.codex/basic-memory.json` - report `primaryProject`, `secondaryProjects`, `teamProjects`, `captureFolder`, `rememberFolder`, `recallTimeframe`, `focus`, - `captureEvents`, `redactKeys`, and `redactPaths` + `sessionProfile`, `repository`, `captureEvents`, `redactKeys`, and + `redactPaths` 3. Core hook health: - with the first available launcher, run @@ -41,9 +42,12 @@ Gather a concise diagnostic. Do not over-investigate. they run 5. Basic Memory queries: - - query recent `type=codex_session` and `type=session`, then merge, deduplicate, - sort newest first, and keep the newest five; the first type covers deliberate - and PreCompact Codex checkpoints, while the second covers core projections + - query recent `type=codex_session` and `type=session`; when + `sessionProfile=coding`, also query `type=coding_session` with + `repository=`, then merge, deduplicate, sort newest + first, and keep the newest five; never run an unscoped coding-session query + when the repository is missing; the Codex types cover deliberate and + PreCompact checkpoints, while `session` covers core projections - active `type=task`, `status=active` - open `type=decision`, `status=open` @@ -60,6 +64,8 @@ Basic Memory for Codex - Capture folder: - Remember folder: - Recall timeframe: +- Session profile: +- Repository: - Event capture: - Redact keys: - Redact paths: @@ -68,7 +74,7 @@ Basic Memory for Codex - Shared processed envelopes: - Last flush: - Hook runtime: basic-memory ; uv -- Recent checkpoints: +- Recent checkpoints: - Active tasks: - Open decisions: - Hooks: installed; trust review required in Codex diff --git a/scripts/validate_codex_plugin.py b/scripts/validate_codex_plugin.py index 25c75d4e7..5d502ef01 100755 --- a/scripts/validate_codex_plugin.py +++ b/scripts/validate_codex_plugin.py @@ -28,6 +28,8 @@ "captureEvents", "redactKeys", "redactPaths", + "sessionProfile", + "coding-session.md", "hook status --harness codex", ), "bm-status": ( @@ -36,11 +38,13 @@ "processed envelopes", "last flush", "type=codex_session", + "type=coding_session", "type=session", ), "bm-orient": ( - "Always query both session types", + "Always query `codex_session` and `session`", "type=codex_session", + "type=coding_session", "type=session", ), "bm-checkpoint": ( @@ -48,6 +52,8 @@ "A checkpoint is a durable handoff, not a status dump", "username: ", "hostname: ", + "type: coding_session", + "pull_request_number", "- `[decision]` for each decision made or preserved", "## Relations", "- relates_to [[Exact existing note title]]", @@ -62,7 +68,7 @@ "Do not invent intent, impact, verification, decisions, or drama", ), } -REQUIRED_SCHEMAS = ("codex-session.md", "decision.md", "task.md") +REQUIRED_SCHEMAS = ("codex-session.md", "coding-session.md", "decision.md", "task.md") REQUIRED_HOOK_EVENTS = ("SessionStart", "PreCompact") # Zero-logic shims: the only hook code the plugin ships. The Python bodies # moved into the basic-memory package behind `bm hook` (SPEC-55). diff --git a/src/basic_memory/cli/commands/hook.py b/src/basic_memory/cli/commands/hook.py index a7dca041e..af2aea00e 100644 --- a/src/basic_memory/cli/commands/hook.py +++ b/src/basic_memory/cli/commands/hook.py @@ -70,6 +70,7 @@ class Harness(str, Enum): QUERY_TIMEOUT_SECONDS = 10.0 # Cap how many shared projects we read per session — bounds latency and output. MAX_SHARED = 6 +CODING_SESSION_PROFILE = "coding" @dataclass(frozen=True) @@ -91,6 +92,7 @@ class HarnessProfile: pin_tip: str default_recall_prompt: str include_workspace_sections: bool # codex adds git status + assistant cursor + coding_session_note_type: str PROFILES: dict[Harness, HarnessProfile] = { @@ -120,10 +122,11 @@ class HarnessProfile: "Cite permalinks when referencing prior work." ), include_workspace_sections=False, + coding_session_note_type="coding_session", ), Harness.codex: HarnessProfile( default_recall_timeframe="7d", - default_capture_folder="codex-sessions", + default_capture_folder="codex", session_note_type="codex_session", # Codex stamps checkpoints codex_session, but the projector writes plain # `session` — recall both so flushed sessions aren't invisible to Codex. @@ -148,6 +151,7 @@ class HarnessProfile: "AGENTS.md or checked-in docs." ), include_workspace_sections=True, + coding_session_note_type="coding_session", ), } @@ -378,21 +382,42 @@ async def _gather_context( primary: str, timeframe: str, shared_refs: list[str], + repository: str | None = None, ) -> _BriefContext: # Cloud reads cost a round-trip each; asyncio.gather keeps total wall-clock # at ~one query instead of the sum (ports the hook scripts' thread pool). project = primary or None + session_queries = [] + if repository is not None: + # A Basic Memory project can serve several repositories. Repository + # metadata is therefore the isolation boundary for coding checkpoints; + # never recall another checkout's branch or pull request as this one's. + session_queries.append( + _query( + project, + note_types=[profile.coding_session_note_type], + metadata_filters={"repository": repository}, + after_date=timeframe, + ) + ) + # General and core-projected sessions remain a lower-priority compatibility + # path. They predate required repository metadata and cannot be safely + # narrowed, so coding_session results are always merged first. + session_queries.append( + _query(project, note_types=list(profile.recall_session_types), after_date=timeframe) + ) results = await asyncio.gather( _query(project, note_types=["task"], status="active"), _query(project, note_types=["decision"], status="open"), - _query(project, note_types=list(profile.recall_session_types), after_date=timeframe), + *session_queries, *[_query(ref, note_types=["decision"], status="open") for ref in shared_refs], ) + session_end = 2 + len(session_queries) return _BriefContext( tasks=results[0], decisions=results[1], - sessions=results[2], - shared=dict(zip(shared_refs, results[3:])), + sessions=_merge_search_results(results[2:session_end]), + shared=dict(zip(shared_refs, results[session_end:])), ) @@ -400,6 +425,25 @@ def _rows(result: dict | None) -> list[dict]: return (result or {}).get("results") or [] +def _merge_search_results(results: list[dict | None]) -> dict | None: + """Merge bounded recall queries while preserving their priority order.""" + if all(result is None for result in results): + return None + + merged: list[dict] = [] + seen: set[str] = set() + for result in results: + for row in _rows(result): + identity = str(row.get("permalink") or row.get("file_path") or row.get("title") or row) + if identity in seen: + continue + seen.add(identity) + merged.append(row) + if len(merged) == 5: + return {"results": merged} + return {"results": merged} + + def _label(result: dict) -> str: name = result.get("title") or result.get("file_path") or "(untitled)" ref = result.get("permalink") or result.get("file_path") or "" @@ -451,8 +495,20 @@ def _build_brief( placement_conventions = str(cfg.get("placementConventions") or "").strip() capture_folder = str(cfg.get("captureFolder") or profile.default_capture_folder).strip() shared_refs, shared_capped = _shared_project_refs(cfg, primary) + repository = None + if cfg.get("sessionProfile") == CODING_SESSION_PROFILE: + configured_repository = cfg.get("repository") + if not isinstance(configured_repository, str) or not configured_repository.strip(): + return ( + "# Basic Memory\n\n" + "_Coding session setup is incomplete: `basicMemory.repository` is missing. " + f"Rerun Basic Memory setup before recalling repository work. {profile.status_hint}_" + ) + repository = configured_repository.strip() - context = run_with_cleanup(_gather_context(profile, primary, timeframe, shared_refs)) + context = run_with_cleanup( + _gather_context(profile, primary, timeframe, shared_refs, repository=repository) + ) # Trigger: every primary query failed (no default project, misnamed project, # unreachable cloud, transient error). Why: a broken query must never error @@ -637,13 +693,102 @@ def _git_status(directory: str) -> list[str]: return [line for line in out.stdout.splitlines() if line.strip()][:20] +@dataclass(frozen=True, slots=True) +class PullRequestContext: + number: int + title: str + url: str + state: str + base_branch: str + head_branch: str + + +@dataclass(frozen=True, slots=True) +class CodingContext: + repository: str + repo_root: str + branch: str + git_sha: str + pull_request: PullRequestContext | None + + +def _required_git_value(directory: str, *args: str) -> str: + """Read one required Git value for a structured coding checkpoint.""" + try: + result = subprocess.run( + ["git", *args], + cwd=directory, + capture_output=True, + text=True, + timeout=5, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise RuntimeError(f"could not read Git context: git {' '.join(args)}") from exc + value = result.stdout.strip() + if result.returncode != 0 or not value: + raise RuntimeError(f"could not read Git context: git {' '.join(args)}") + return value + + +def _pull_request_context(directory: str) -> PullRequestContext | None: + """Resolve the current branch's PR when the optional GitHub CLI can do so.""" + gh = shutil.which("gh") + if gh is None: + return None + try: + result = subprocess.run( + [ + gh, + "pr", + "view", + "--json", + "number,title,url,state,baseRefName,headRefName", + ], + cwd=directory, + capture_output=True, + text=True, + timeout=5, + ) + except (OSError, subprocess.SubprocessError): + return None + if result.returncode != 0: + return None + try: + payload = json.loads(result.stdout) + return PullRequestContext( + number=int(payload["number"]), + title=str(payload["title"]), + url=str(payload["url"]), + state=str(payload["state"]).lower(), + base_branch=str(payload["baseRefName"]), + head_branch=str(payload["headRefName"]), + ) + except (json.JSONDecodeError, KeyError, TypeError, ValueError): + return None + + +def _coding_context(cfg: dict, directory: str) -> CodingContext: + repository = cfg.get("repository") + if not isinstance(repository, str) or not repository.strip(): + raise RuntimeError("coding session profile requires basicMemory.repository; rerun bm-setup") + return CodingContext( + repository=repository.strip(), + repo_root=_required_git_value(directory, "rev-parse", "--show-toplevel"), + branch=_required_git_value(directory, "rev-parse", "--abbrev-ref", "HEAD"), + git_sha=_required_git_value(directory, "rev-parse", "HEAD"), + pull_request=_pull_request_context(directory), + ) + + def _checkpoint_note( profile: HarnessProfile, event: NormalizedHookEvent, conversation: list[tuple[str, str]], primary: str, + working_directory: str, + coding_context: CodingContext | None, extra_redact_paths: list[str], -) -> tuple[str, str, dict[str, str]]: +) -> tuple[str, str, dict[str, Any]]: """Build the pre-compaction checkpoint note (title, body, frontmatter). Extractive cut: the opening request and most recent turns lifted straight @@ -673,7 +818,7 @@ def _checkpoint_note( # cwd is a user path too: a session under a configured redactPaths (or a # default deny dir) must not leak the raw path into the note frontmatter or # body. Redact once so both draw from the scrubbed string. - safe_cwd = redactor.redact_text(event.cwd) + safe_cwd = redactor.redact_text(working_directory) opening = user_messages[0] recent_user = user_messages[-3:] @@ -685,7 +830,7 @@ def _checkpoint_note( # Frontmatter as a dict (write_note serializes + quotes it); `type` rides the # note_type arg. Order preserved for stable, readable output. - metadata: dict[str, str] = { + metadata: dict[str, Any] = { "status": "open", "started": iso, "ended": iso, @@ -702,6 +847,33 @@ def _checkpoint_note( metadata["model"] = event.model metadata["capture"] = "extractive" + safe_coding_context: dict[str, str] | None = None + if coding_context is not None: + safe_coding_context = { + "repository": redactor.redact_text(coding_context.repository), + "repo_root": redactor.redact_text(coding_context.repo_root), + "branch": redactor.redact_text(coding_context.branch), + # A commit SHA is public repository identity, but its hex shape trips + # high-entropy secret detection. Preserve it so coding checkpoints + # remain queryable by the exact revision they describe. + "git_sha": coding_context.git_sha, + } + metadata.update(safe_coding_context) + if coding_context.pull_request is not None: + pull_request = coding_context.pull_request + metadata.update( + { + # PR numbers are identifiers, not quantities. Keeping them as strings + # also makes exact metadata queries portable across SQLite and Postgres. + "pull_request_number": str(pull_request.number), + "pull_request_title": redactor.redact_text(pull_request.title), + "pull_request_url": redactor.redact_text(pull_request.url), + "pull_request_state": pull_request.state, + "pull_request_base": redactor.redact_text(pull_request.base_branch), + "pull_request_head": redactor.redact_text(pull_request.head_branch), + } + ) + body = [ "", f"# {title}", @@ -717,6 +889,19 @@ def _checkpoint_note( "## Recent thread", *[f"- {_clip(message, 200)}" for message in recent_user], ] + if safe_coding_context is not None: + body += [ + "", + "## Repository", + f"- Repository: `{safe_coding_context['repository']}`", + f"- Branch: `{safe_coding_context['branch']}`", + f"- Git SHA: `{safe_coding_context['git_sha']}`", + ] + if coding_context is not None and coding_context.pull_request is not None: + body.append( + f"- Pull request: #{coding_context.pull_request.number} — " + f"{metadata['pull_request_url']}" + ) if profile.include_workspace_sections: recent_assistant = assistant_messages[-2:] if recent_assistant: @@ -727,7 +912,7 @@ def _checkpoint_note( # `git status --short` emits repo-relative filenames with no absolute # prefix (`M customer-roadmap.md`), so per-row redaction can't match the # deny path — the whole denied workspace's file list would leak. - status_lines = [] if safe_cwd == REDACTED_PATH else _git_status(event.cwd) + status_lines = [] if safe_cwd == REDACTED_PATH else _git_status(working_directory) if status_lines: # git status rows carry filenames/paths too — pass them through the # same floor as the transcript text and cwd (a secret token in a @@ -805,8 +990,19 @@ def _pre_compact(harness: Harness, project_dir: Optional[Path]) -> None: if not conversation or not any(role == "user" for role, _ in conversation): return + working_directory = event.cwd or str(mapping_dir) + coding_profile = cfg.get("sessionProfile") == CODING_SESSION_PROFILE + coding_context = _coding_context(cfg, working_directory) if coding_profile else None + note_type = profile.coding_session_note_type if coding_profile else profile.session_note_type + title, content, metadata = _checkpoint_note( - profile, event, conversation, primary, _string_list(cfg.get("redactPaths")) + profile, + event, + conversation, + primary, + working_directory, + coding_context, + _string_list(cfg.get("redactPaths")), ) # Deferred import (#886); same internal write path as `bm tool write-note`. @@ -822,7 +1018,7 @@ def _pre_compact(harness: Harness, project_dir: Optional[Path]) -> None: project=project, project_id=project_id, tags=list(profile.checkpoint_tags), - note_type=profile.session_note_type, + note_type=note_type, # Frontmatter as metadata: write_note serializes/quotes it, so a # YAML-special value (e.g. a cwd with a colon) can't break parsing. metadata=metadata, @@ -1184,6 +1380,8 @@ def status( f"settings ({harness.value}, {mapping_dir}): {'found' if configured else 'not found'}" ) typer.echo(f"primary project: {str(cfg.get('primaryProject') or '').strip() or '(not set)'}") + typer.echo(f"session profile: {str(cfg.get('sessionProfile') or 'general').strip()}") + typer.echo(f"repository: {str(cfg.get('repository') or '').strip() or '(not set)'}") typer.echo(f"capture events: {'on' if cfg.get('captureEvents') is True else 'off'}") typer.echo( f"capture folder: {str(cfg.get('captureFolder') or profile.default_capture_folder).strip()}" diff --git a/tests/cli/test_coding_session_context.py b/tests/cli/test_coding_session_context.py new file mode 100644 index 000000000..633b6a741 --- /dev/null +++ b/tests/cli/test_coding_session_context.py @@ -0,0 +1,340 @@ +"""Structured coding-session context for harness checkpoints.""" + +import asyncio +import json +import subprocess +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest +from typer.testing import CliRunner + +from basic_memory.cli.commands import hook as hook_module +from basic_memory.cli.main import app as cli_app + +runner = CliRunner() + + +def _git_repo(tmp_path: Path) -> Path: + repository = tmp_path / "repo" + repository.mkdir() + subprocess.run(["git", "init", "-b", "feature"], cwd=repository, check=True) + subprocess.run(["git", "config", "user.email", "codex@example.com"], cwd=repository, check=True) + subprocess.run(["git", "config", "user.name", "Codex"], cwd=repository, check=True) + (repository / "README.md").write_text("# Repo\n", encoding="utf-8") + subprocess.run(["git", "add", "README.md"], cwd=repository, check=True) + subprocess.run(["git", "commit", "-m", "initial"], cwd=repository, check=True) + (repository / ".codex").mkdir() + (repository / ".claude").mkdir() + return repository + + +def _write_config(repo_path: Path, **overrides: object) -> None: + config: dict[str, object] = { + "primaryProject": "demo", + "sessionProfile": "coding", + "repository": "basicmachines-co/basic-memory", + **overrides, + } + (repo_path / ".codex" / "basic-memory.json").write_text( + json.dumps({"basicMemory": config}), encoding="utf-8" + ) + + +def _write_claude_config(repo_path: Path, **overrides: object) -> None: + config: dict[str, object] = { + "primaryProject": "demo", + "sessionProfile": "coding", + "repository": "basicmachines-co/basic-memory", + **overrides, + } + (repo_path / ".claude" / "settings.json").write_text( + json.dumps({"basicMemory": config}), encoding="utf-8" + ) + + +def _transcript(tmp_path: Path) -> Path: + path = tmp_path / "transcript.jsonl" + path.write_text( + json.dumps( + { + "message": {"role": "user", "content": "Add queryable coding sessions"}, + "type": "user", + } + ), + encoding="utf-8", + ) + return path + + +def _payload(repository: Path, transcript: Path) -> str: + return json.dumps( + { + "session_id": "session-1", + "cwd": str(repository), + "transcript_path": str(transcript), + "trigger": "auto", + } + ) + + +def test_coding_profile_writes_required_git_and_pull_request_frontmatter( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(tmp_path / "bm-home")) + repository = _git_repo(tmp_path) + _write_config(repository) + transcript = _transcript(tmp_path) + pull_request = hook_module.PullRequestContext( + number=1124, + title="feat(plugins): add coding sessions", + url="https://github.com/basicmachines-co/basic-memory/pull/1124", + state="open", + base_branch="main", + head_branch="feature", + ) + mock_write = AsyncMock(return_value={"action": "created"}) + with ( + patch("basic_memory.mcp.tools.write_note", mock_write), + patch.object(hook_module, "_pull_request_context", return_value=pull_request), + ): + result = runner.invoke( + cli_app, + ["hook", "pre-compact", "--harness", "codex", "--project-dir", str(repository)], + input=_payload(repository, transcript), + ) + + assert result.exit_code == 0 + assert mock_write.await_args is not None + kwargs = mock_write.await_args.kwargs + metadata = kwargs["metadata"] + expected_sha = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repository, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + assert kwargs["note_type"] == "coding_session" + assert metadata["project"] == "demo" + assert metadata["repository"] == "basicmachines-co/basic-memory" + assert metadata["repo_root"] + assert metadata["cwd"] + assert metadata["repo_root"] == metadata["cwd"] + assert metadata["branch"] == "feature" + assert metadata["git_sha"] == expected_sha + assert metadata["pull_request_number"] == "1124" + assert metadata["pull_request_state"] == "open" + assert metadata["pull_request_base"] == "main" + assert metadata["pull_request_head"] == "feature" + assert "## Repository" in kwargs["content"] + assert "Pull request: #1124" in kwargs["content"] + + +def test_coding_profile_omits_pull_request_fields_when_branch_has_no_pr( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(tmp_path / "bm-home")) + repository = _git_repo(tmp_path) + _write_config(repository) + transcript = _transcript(tmp_path) + mock_write = AsyncMock(return_value={"action": "created"}) + with ( + patch("basic_memory.mcp.tools.write_note", mock_write), + patch.object(hook_module, "_pull_request_context", return_value=None), + ): + result = runner.invoke( + cli_app, + ["hook", "pre-compact", "--harness", "codex", "--project-dir", str(repository)], + input=_payload(repository, transcript), + ) + + assert result.exit_code == 0 + assert mock_write.await_args is not None + metadata = mock_write.await_args.kwargs["metadata"] + assert metadata["repository"] == "basicmachines-co/basic-memory" + assert "pull_request_number" not in metadata + + +def test_claude_coding_profile_writes_coding_session( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(tmp_path / "bm-home")) + repository = _git_repo(tmp_path) + _write_claude_config(repository) + transcript = _transcript(tmp_path) + mock_write = AsyncMock(return_value={"action": "created"}) + with ( + patch("basic_memory.mcp.tools.write_note", mock_write), + patch.object(hook_module, "_pull_request_context", return_value=None), + ): + result = runner.invoke( + cli_app, + ["hook", "pre-compact", "--harness", "claude", "--project-dir", str(repository)], + input=_payload(repository, transcript), + ) + + assert result.exit_code == 0 + assert mock_write.await_args is not None + kwargs = mock_write.await_args.kwargs + assert kwargs["note_type"] == "coding_session" + assert kwargs["metadata"]["repository"] == "basicmachines-co/basic-memory" + assert kwargs["metadata"]["claude_session_id"] == "session-1" + + +def test_coding_profile_requires_confirmed_repository( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(tmp_path / "bm-home")) + repository = _git_repo(tmp_path) + _write_config(repository, repository="") + transcript = _transcript(tmp_path) + mock_write = AsyncMock() + with patch("basic_memory.mcp.tools.write_note", mock_write): + result = runner.invoke( + cli_app, + ["hook", "pre-compact", "--harness", "codex", "--project-dir", str(repository)], + input=_payload(repository, transcript), + ) + + assert result.exit_code == 0 + assert "coding session profile requires basicMemory.repository" in result.stderr + mock_write.assert_not_awaited() + + +def test_coding_profile_uses_dedicated_schema_for_both_harnesses() -> None: + codex = hook_module.PROFILES[hook_module.Harness.codex] + claude = hook_module.PROFILES[hook_module.Harness.claude] + + assert codex.coding_session_note_type == "coding_session" + assert claude.coding_session_note_type == "coding_session" + + +def test_coding_recall_filters_by_repository_and_merges_legacy_sessions() -> None: + queries: list[dict[str, object]] = [] + + async def fake_query(project: str | None, **filters: object) -> dict: + queries.append({"project": project, **filters}) + if filters.get("note_types") == ["coding_session"]: + return {"results": [{"title": "Coding", "permalink": "sessions/coding"}]} + if filters.get("note_types") == ["codex_session", "session"]: + return { + "results": [ + {"title": "Duplicate", "permalink": "sessions/coding"}, + {"title": "Legacy", "permalink": "sessions/legacy"}, + ] + } + return {"results": []} + + profile = hook_module.PROFILES[hook_module.Harness.codex] + with patch.object(hook_module, "_query", side_effect=fake_query): + context = asyncio.run( + hook_module._gather_context( + profile, + "demo", + "7d", + [], + repository="basicmachines-co/basic-memory", + ) + ) + + coding_query = next(query for query in queries if query.get("note_types") == ["coding_session"]) + assert coding_query["metadata_filters"] == {"repository": "basicmachines-co/basic-memory"} + assert [row["title"] for row in hook_module._rows(context.sessions)] == ["Coding", "Legacy"] + + +def test_coding_recall_requires_configured_repository() -> None: + profile = hook_module.PROFILES[hook_module.Harness.codex] + with patch.object(hook_module, "_gather_context") as gather_context: + brief = hook_module._build_brief( + profile, + {"primaryProject": "demo", "sessionProfile": "coding"}, + configured=True, + ) + + assert "Coding session setup is incomplete" in brief + assert "basicMemory.repository" in brief + gather_context.assert_not_called() + + +def test_required_git_value_rejects_non_repository(tmp_path: Path) -> None: + with pytest.raises(RuntimeError, match="could not read Git context"): + hook_module._required_git_value(str(tmp_path), "rev-parse", "HEAD") + + +def test_required_git_value_wraps_process_failure(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + hook_module.subprocess, + "run", + lambda *args, **kwargs: (_ for _ in ()).throw(OSError("no git")), + ) + + with pytest.raises(RuntimeError, match="could not read Git context"): + hook_module._required_git_value("/tmp/repo", "rev-parse", "HEAD") + + +def test_pull_request_context_is_optional_without_usable_gh( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(hook_module.shutil, "which", lambda name: None) + assert hook_module._pull_request_context("/tmp/repo") is None + + monkeypatch.setattr(hook_module.shutil, "which", lambda name: "/usr/bin/gh") + monkeypatch.setattr( + hook_module.subprocess, + "run", + lambda *args, **kwargs: subprocess.CompletedProcess(args=[], returncode=1), + ) + assert hook_module._pull_request_context("/tmp/repo") is None + + monkeypatch.setattr( + hook_module.subprocess, + "run", + lambda *args, **kwargs: (_ for _ in ()).throw(OSError("no exec")), + ) + assert hook_module._pull_request_context("/tmp/repo") is None + + +@pytest.mark.parametrize("payload", ["not-json", "{}"]) +def test_pull_request_context_rejects_invalid_payload( + payload: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(hook_module.shutil, "which", lambda name: "/usr/bin/gh") + monkeypatch.setattr( + hook_module.subprocess, + "run", + lambda *args, **kwargs: subprocess.CompletedProcess(args=[], returncode=0, stdout=payload), + ) + + assert hook_module._pull_request_context("/tmp/repo") is None + + +def test_pull_request_context_parses_gh_json(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(hook_module.shutil, "which", lambda name: "/usr/bin/gh") + monkeypatch.setattr( + hook_module.subprocess, + "run", + lambda *args, **kwargs: subprocess.CompletedProcess( + args=[], + returncode=0, + stdout=json.dumps( + { + "number": 42, + "title": "Ship it", + "url": "https://github.com/owner/repo/pull/42", + "state": "MERGED", + "baseRefName": "main", + "headRefName": "feature", + } + ), + ), + ) + + assert hook_module._pull_request_context("/tmp/repo") == hook_module.PullRequestContext( + number=42, + title="Ship it", + url="https://github.com/owner/repo/pull/42", + state="merged", + base_branch="main", + head_branch="feature", + ) diff --git a/tests/cli/test_hook_command.py b/tests/cli/test_hook_command.py index e0cc153ad..dec697b37 100644 --- a/tests/cli/test_hook_command.py +++ b/tests/cli/test_hook_command.py @@ -407,7 +407,7 @@ def test_session_start_codex_profile(bm_home: Path, tmp_path: Path) -> None: session_query = mock_search.await_args_list[2].kwargs assert session_query["note_types"] == ["codex_session", "session"] assert session_query["after_date"] == "7d" - assert "codex-sessions/" in result.stdout + assert "codex/" in result.stdout def test_session_start_codex_recalls_flushed_generic_session(bm_home: Path, tmp_path: Path) -> None: @@ -741,7 +741,7 @@ def test_pre_compact_codex_includes_workspace_sections(bm_home: Path, tmp_path: assert result.exit_code == 0 assert mock_write.await_args is not None kwargs = mock_write.await_args.kwargs - assert kwargs["directory"] == "codex-sessions" + assert kwargs["directory"] == "codex" assert kwargs["tags"] == ["codex", "auto-capture"] assert kwargs["title"].startswith("Codex session ") assert kwargs["note_type"] == "codex_session" diff --git a/tests/test_codex_plugin_package.py b/tests/test_codex_plugin_package.py index 04662d501..0f4431eb7 100644 --- a/tests/test_codex_plugin_package.py +++ b/tests/test_codex_plugin_package.py @@ -82,6 +82,28 @@ def test_codex_plugin_docs_explain_global_install_and_repo_mapping() -> None: assert "Each repository still needs its own `.codex/basic-memory.json`" in readme +def test_coding_session_schema_is_shared_across_host_plugins() -> None: + repo_root = Path(__file__).resolve().parents[1] + codex_schema = (repo_root / "plugins/codex/schemas/coding-session.md").read_text( + encoding="utf-8" + ) + claude_schema = (repo_root / "plugins/claude-code/schemas/coding-session.md").read_text( + encoding="utf-8" + ) + + assert codex_schema == claude_schema + + +def test_coding_checkpoint_skills_quote_pull_request_numbers() -> None: + repo_root = Path(__file__).resolve().parents[1] + + for plugin in ("codex", "claude-code"): + skill = (repo_root / "plugins" / plugin / "skills/bm-checkpoint/SKILL.md").read_text( + encoding="utf-8" + ) + assert 'pull_request_number: "123"' in skill + + def test_bm_checkpoint_tells_a_story_and_uses_graph_semantics() -> None: repo_root = Path(__file__).resolve().parents[1] skill = (repo_root / "plugins/codex/skills/bm-checkpoint/SKILL.md").read_text(encoding="utf-8")