diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json
new file mode 100644
index 0000000..35e5616
--- /dev/null
+++ b/.agents/plugins/marketplace.json
@@ -0,0 +1,32 @@
+{
+ "name": "hypermemory-ai",
+ "interface": {
+ "displayName": "HyperMemory AI"
+ },
+ "plugins": [
+ {
+ "name": "hypermemory",
+ "source": {
+ "source": "local",
+ "path": "./plugins/hypermemory"
+ },
+ "policy": {
+ "installation": "AVAILABLE",
+ "authentication": "ON_INSTALL"
+ },
+ "category": "Memory & Knowledge"
+ },
+ {
+ "name": "hypercolab",
+ "source": {
+ "source": "local",
+ "path": "./plugins/hypercolab"
+ },
+ "policy": {
+ "installation": "AVAILABLE",
+ "authentication": "ON_INSTALL"
+ },
+ "category": "Developer Tools"
+ }
+ ]
+}
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
new file mode 100644
index 0000000..c6c0685
--- /dev/null
+++ b/.github/workflows/validate.yml
@@ -0,0 +1,27 @@
+name: Validate marketplace
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-python@v7
+ with:
+ python-version: "3.12"
+ - name: Install validation dependencies
+ run: python -m pip install -e "packages/hypercolab-cli[dev]"
+ - name: Lint
+ run: ruff check plugins packages tests scripts
+ - name: Test
+ run: pytest -q
+ - name: Build review archives
+ run: python scripts/build_plugin_archives.py
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9c16f4e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+__pycache__/
+*.py[cod]
+.pytest_cache/
+.ruff_cache/
+dist/
+.venv/
+.DS_Store
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..267f144
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,32 @@
+# Repository guidance
+
+This repository publishes two OpenAI plugins through one marketplace. Keep the
+plugins independently installable and do not introduce dependencies between
+their manifests.
+
+## Invariants
+
+- Marketplace name: `hypermemory-ai`
+- Plugin IDs and folders: `hypermemory`, `hypercolab`
+- OpenAI surfaces only; do not add formats or instructions for unrelated agent
+ ecosystems.
+- Keep `.codex-plugin/plugin.json` as the required manifest entry point.
+- Keep MCP credentials out of source. Use OAuth or local credential storage.
+- Preserve explicit hook trust and safe degraded behavior.
+- Keep HyperMemory recall on the main agent and persistence/token reporting on
+ one awaited memory-writer sub-agent.
+- Keep HyperColab join/sync/claims on the main agent. Delegated coordination
+ writers may record progress but must not bypass ownership conflicts.
+
+## Validation
+
+Run before committing:
+
+```bash
+ruff check plugins packages tests scripts
+pytest -q
+python scripts/build_plugin_archives.py
+```
+
+When Codex's authoring skills are installed, validate both plugin roots and both
+skills with their bundled validators.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..20660ea
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,36 @@
+# Contributing
+
+Thank you for improving the HyperMemory AI OpenAI plugins.
+
+## Development setup
+
+```bash
+git clone https://github.com/hypermemory-ai/hm-plugins-openai.git
+cd hm-plugins-openai
+python -m pip install -e "packages/hypercolab-cli[dev]"
+```
+
+Create a focused branch, change only the plugin or shared catalog behavior in
+scope, and include tests for lifecycle or manifest changes.
+
+## Quality checks
+
+```bash
+ruff check plugins packages tests scripts
+pytest -q
+python scripts/build_plugin_archives.py
+```
+
+Also run the Codex plugin and skill validators listed in the root README when
+available.
+
+## Release checklist
+
+1. Increment the changed plugin's semantic version.
+2. Confirm marketplace name, plugin ID, folder name, and manifest name match.
+3. Validate every referenced asset, skill, hook, and MCP path.
+4. Review hook commands for safe failure behavior and explicit trust.
+5. Confirm no credentials, tokens, test accounts, or private endpoints entered
+ the package.
+6. Build fresh review ZIPs and test installation in a new Codex task.
+7. Document user-visible changes.
diff --git a/README.md b/README.md
index 15cc1bb..9e987cf 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,814 @@
-# hm-plugins-openai
\ No newline at end of file
+
+
+
+
+
+
+HyperMemory AI plugins for ChatGPT and Codex
+
+
+ Durable, relationship-aware memory for every conversation.
+ Shared project context and collision-safe coordination for every repository.
+
+
+
+
+
+
+
+
+
+> [!IMPORTANT]
+> This repository is the Git-backed development marketplace for ChatGPT and
+> Codex. HyperMemory currently connects to the Rust staging MCP at
+> `https://stage.hypermemory.io/mcp`. Public, one-click installation for normal
+> ChatGPT and Codex users requires separate publication of each plugin through
+> OpenAI's universal Plugins Directory.
+
+## Contents
+
+- [What this repository provides](#what-this-repository-provides)
+- [Why two plugins?](#why-two-plugins)
+- [Capability matrix](#capability-matrix)
+- [Supported surfaces](#supported-surfaces)
+- [Quick start](#quick-start)
+- [HyperMemory](#hypermemory)
+- [HyperColab](#hypercolab)
+- [Combined architecture](#combined-architecture)
+- [Repository layout](#repository-layout)
+- [Authentication and secrets](#authentication-and-secrets)
+- [Updating](#updating)
+- [Removing](#removing)
+- [Development](#development)
+- [Release and publication model](#release-and-publication-model)
+- [Troubleshooting](#troubleshooting)
+- [Frequently asked questions](#frequently-asked-questions)
+- [Documentation](#documentation)
+- [Support and security](#support-and-security)
+
+## What this repository provides
+
+This repository is one plugin marketplace containing two independently
+installable OpenAI plugins:
+
+| Plugin | Package ID | Current version | Purpose |
+| --- | --- | ---: | --- |
+| **HyperMemory** | `hypermemory@hypermemory-ai` | `2.1.0` | Persistent personal and project memory, relationship-aware recall, delegated writes, timeline logging, and token telemetry |
+| **HyperColab** | `hypercolab@hypermemory-ai` | `0.1.0` | Shared project context, work ownership, path claims, project timelines, graph search, and multi-agent collision prevention |
+
+The marketplace is named `hypermemory-ai`. A marketplace is a catalog and
+source of plugins; registering it does **not** install either plugin. Users add
+the marketplace once, then choose HyperMemory, HyperColab, or both.
+
+```text
+GitHub repository Marketplace Installable plugins
+hypermemory-ai/hm-plugins-openai -> hypermemory-ai -> hypermemory
+ -> hypercolab
+```
+
+## Why two plugins?
+
+HyperMemory and HyperColab share a graph-oriented foundation, but they solve
+different problems and have different runtime boundaries:
+
+- **HyperMemory follows a person or agent across conversations.** It recalls
+ durable context before work begins and maintains that context after each
+ turn.
+- **HyperColab follows a Git project.** It resolves the active repository,
+ coordinates concurrent developers and coding agents, protects claimed paths,
+ and records a structured development timeline.
+
+Keeping them separate lets a user install durable memory without repository
+coordination, add coordination only where needed, or run both together.
+
+## Capability matrix
+
+| Capability | HyperMemory | HyperColab |
+| --- | :---: | :---: |
+| Hosted OAuth MCP | Yes | No |
+| Local stdio MCP shim | No | Yes |
+| Bundled skill | Yes | Yes |
+| Codex lifecycle hooks | Yes | Yes |
+| Packaged sub-agent role contract | Memory writer | Coordination writer |
+| Relationship-aware graph | Personal and cross-session | Project-scoped |
+| Chronological timeline | Conversation and decision timeline | Development activity timeline |
+| Exact local Codex token deltas | Yes | No |
+| Path claims and collision protection | No | Yes |
+| Git event capture | No | Optional |
+| Works without the other plugin | Yes | Yes |
+
+## Supported surfaces
+
+| Surface | HyperMemory | HyperColab |
+| --- | --- | --- |
+| Codex CLI | Full local behavior after MCP authorization and hook trust | Full behavior after CLI installation, login, plugin installation, and hook trust |
+| Codex in the ChatGPT desktop app | Supported where local plugins, MCP, hooks, and sub-agents are available | Supported when the local environment can launch `hypercolab` and access the repository |
+| ChatGPT developer/workspace testing | Hosted MCP and skill can be tested | Skill can be tested; repository coordination requires a local process and Git checkout |
+| Public Plugins Directory | Requires publication through OpenAI's submission flow | Requires a publication design compatible with the target surface's MCP transport |
+| ChatGPT web without a local coding environment | Hosted HyperMemory MCP can work after publication | Local Git claims and activity capture are unavailable |
+
+## Quick start
+
+### Prerequisites
+
+- A current Codex CLI or Codex-enabled ChatGPT desktop app
+- A HyperMemory account
+- Git
+- Python 3.10 or newer and [`pipx`](https://pipx.pypa.io/) for HyperColab
+
+### 1. Register the marketplace
+
+Run this once:
+
+```bash
+codex plugin marketplace add hypermemory-ai/hm-plugins-openai
+```
+
+Confirm Codex can see it:
+
+```bash
+codex plugin marketplace list
+```
+
+### 2. Install HyperMemory
+
+```bash
+codex plugin add hypermemory@hypermemory-ai
+```
+
+Complete the HyperMemory OAuth flow when prompted, then start a new task.
+
+### 3. Install HyperColab
+
+HyperColab needs its local CLI/MCP shim before Codex loads the plugin:
+
+```bash
+pipx install "git+https://github.com/hypermemory-ai/hm-plugins-openai.git#subdirectory=packages/hypercolab-cli"
+hypercolab login
+hypercolab doctor
+codex plugin add hypercolab@hypermemory-ai
+```
+
+Start a new task inside a Git repository that is enrolled in HyperColab.
+
+### 4. Review and trust hooks
+
+In Codex CLI, run:
+
+```text
+/hooks
+```
+
+Review each plugin's hook definition and trust the hooks you want to run. Codex
+does not automatically trust non-managed plugin hooks. Trust is tied to the
+exact hook definition, so changed hooks require review again after an update.
+
+### 5. Verify the installation
+
+```bash
+codex plugin list
+hypercolab status
+```
+
+Try these prompts in a new task:
+
+```text
+What do you remember about this project?
+```
+
+```text
+Join this HyperColab project, sync active work, and claim the files needed for my task.
+```
+
+For a step-by-step guide, see [Installation](docs/INSTALLATION.md).
+
+## HyperMemory
+
+HyperMemory adds durable, relationship-aware memory to ChatGPT and Codex. It is
+designed to recall the right context before a response and preserve important
+knowledge after the requested work is complete.
+
+### Included components
+
+| Component | Path | Responsibility |
+| --- | --- | --- |
+| Plugin manifest | `plugins/hypermemory/.codex-plugin/plugin.json` | Identity, version, discovery metadata, branding, skill path, and MCP declaration |
+| MCP configuration | `plugins/hypermemory/.mcp.json` | Connects to the hosted Rust staging MCP over HTTP |
+| Skill | `plugins/hypermemory/skills/hypermemory/` | Defines recall, graph hygiene, delegation, and telemetry behavior |
+| Lifecycle hooks | `plugins/hypermemory/hooks/hooks.json` | Reinforces recall at session/prompt boundaries and finalization at stop |
+| Memory-writer role | `plugins/hypermemory/agents/memory-writer.md` | Bounded contract for delegated storage, timeline, and telemetry work |
+| Hook bridge | `plugins/hypermemory/scripts/hypermemory_hook.py` | Creates lifecycle context and bounded finalization jobs |
+| Token listener | `plugins/hypermemory/scripts/codex_token_listener.py` | Reads exact local Codex token-counter deltas without reading chat content |
+
+### Turn lifecycle
+
+```mermaid
+sequenceDiagram
+ participant U as User
+ participant M as Main agent
+ participant MCP as HyperMemory MCP
+ participant W as Memory-writer sub-agent
+ participant L as Codex token listener
+
+ U->>M: Submit a prompt
+ M->>MCP: Overview and relevant recall
+ MCP-->>M: Relationship-aware context
+ M->>M: Complete the requested work
+ M->>W: Delegate a concise finalization summary
+ W->>MCP: Recall before writing
+ W->>MCP: Store or update durable knowledge
+ W->>MCP: Write one timeline entry
+ W->>L: Inspect token-counter delta
+ L-->>W: Exact payload or fallback instruction
+ W->>MCP: Report tokens once
+ W->>L: Acknowledge accepted exact claim
+ W-->>M: Return brief status
+ M-->>U: Return final response
+```
+
+The main agent performs recall because remembered context must be available
+while reasoning about the user's request. Persistence and telemetry are moved
+to one awaited memory-writer sub-agent to keep the main context focused. The
+role contract prevents recursive delegation.
+
+### Memory operations
+
+The skill uses the HyperMemory MCP for:
+
+- graph overview and relevant recall;
+- exact-node hydration and relationship traversal;
+- durable storage and correction of existing knowledge;
+- graph relationships and orphan cleanup;
+- chronological timeline entries;
+- user-requested file storage; and
+- per-turn token telemetry.
+
+The hosted MCP currently exposes these tool families:
+
+| Area | Tools |
+| --- | --- |
+| Recall and context | `hm_get_overview`, `hm_recall`, `hm_get_nodes`, `hm_get_chat_context`, `hm_find_related` |
+| Graph writes and hygiene | `hm_store`, `hm_update`, `hm_forget`, `hm_add_relationships`, `hm_ingest`, `hm_list_orphans` |
+| Timeline | `hm_timeline`, `hm_timeline_write` |
+| Files | `hm_upload_file`, `hm_list_files` |
+| Skill distribution | `hm_skill` |
+| Telemetry | `hm_tokens` |
+
+Writes follow canonical node types and stable keys. The writer recalls before
+changing the graph, updates existing nodes instead of duplicating them, and
+gives each new node a specific relationship. File upload is used only when the
+user explicitly asks to store a file.
+
+### OAuth and credentials
+
+The plugin connects to:
+
+```text
+https://stage.hypermemory.io/mcp
+```
+
+The server supports authorization-code OAuth, PKCE S256, refresh tokens, and
+dynamic client registration. The plugin package contains the server URL only;
+it does not contain or require a checked-in API key.
+
+### Codex token telemetry
+
+Codex persists cumulative usage counters in local rollout JSONL files. The
+listener reads only `session_meta` and `token_count` records from the logical
+session's parent and memory-writer rollouts. It does not return or upload:
+
+- prompts or model responses;
+- tool arguments or tool results;
+- source code or file contents; or
+- complete transcripts.
+
+Reporting uses a two-phase inspect/ack protocol:
+
+1. Inspect computes the delta since the last acknowledged checkpoint.
+2. The memory-writer sends that payload to `hm_tokens` exactly once.
+3. Ack advances the checkpoint only after the MCP accepts the report.
+
+If reporting fails, the checkpoint does not advance and usage remains eligible
+for a later retry. Tokens generated after the final inspection are carried into
+the next successful report. If the session has no later turn, that final tail
+can remain unreported; the plugin never labels a guess as client-exact to hide
+this host limitation.
+
+Consumer ChatGPT does not expose Codex's local rollout counters. On that
+surface, HyperMemory reports an uncertainty-labelled estimate instead of
+claiming exact or provider-actual usage.
+
+### Always-on behavior and its boundary
+
+HyperMemory uses three complementary layers:
+
+1. The skill declares itself applicable on every turn.
+2. Session and prompt hooks remind the active agent to recall before work.
+3. The stop hook requires delegated memory finalization before the response is
+ released.
+
+This is the strongest enforcement available to an installed plugin, but it is
+not an operating-system guarantee. If the plugin is disabled, its hooks are not
+trusted, hooks are disabled by policy, the MCP is unavailable, or the current
+surface cannot spawn sub-agents, behavior degrades accordingly. The skill
+defines a direct-write fallback when delegation is unavailable so memory is not
+silently abandoned.
+
+## HyperColab
+
+HyperColab coordinates human developers and coding agents working in the same
+Git project. It combines shared context, explicit work ownership, atomic path
+claims, structured activity, and project-scoped graph search.
+
+### Included components
+
+| Component | Path | Responsibility |
+| --- | --- | --- |
+| Plugin manifest | `plugins/hypercolab/.codex-plugin/plugin.json` | Identity, version, discovery metadata, branding, skill path, and MCP declaration |
+| MCP registration | `plugins/hypercolab/.mcp.json` | Launches `hypercolab mcp` as a local stdio server |
+| Skill | `plugins/hypercolab/skills/hypercolab/` | Defines join, sync, claim, progress, activity, and completion behavior |
+| Lifecycle hooks | `plugins/hypercolab/hooks/hooks.json` | Loads project context, checks writes, and records structured activity |
+| Coordination-writer role | `plugins/hypercolab/agents/coordination-writer.md` | Bounded contract for delegated timeline maintenance |
+| Hook launcher | `plugins/hypercolab/scripts/hypercolab_hook.py` | Bridges Codex lifecycle events to the installed CLI and degrades safely if absent |
+| CLI and MCP shim | `packages/hypercolab-cli/` | OAuth, Git discovery, MCP tools, direct commands, claims, offline leases, and Git hooks |
+
+### Why a local shim?
+
+HyperColab must know which repository the user is actually working in. The
+local shim derives the Git root, canonical remote, branch, and worktree before
+calling the project service. Agents do not select arbitrary graph or timeline
+database identifiers.
+
+```mermaid
+flowchart LR
+ Agent["Codex agent"] --> Plugin["HyperColab plugin"]
+ Plugin --> Shim["Local stdio MCP shim"]
+ Shim --> Git["Git root, remote, branch, worktree"]
+ Shim --> API["HyperColab API"]
+ API --> Claims["Sessions, claims, and leases"]
+ API --> Timeline["Append-only project timeline"]
+ API --> Graph["Project-scoped HyperMemory graph"]
+```
+
+This routing is a safety boundary: the backend resolves the authorized project
+from the authenticated developer and canonical repository identity.
+
+### MCP tool reference
+
+| Tool | Purpose |
+| --- | --- |
+| `colab_join` | Join the project associated with the current Git repository and publish the work goal |
+| `colab_sync` | Retrieve active sessions, ownership, recent events, and touch/do-not-touch guidance |
+| `colab_claim` | Atomically claim repository-relative files or directories before editing |
+| `colab_check` | Check create, modify, rename, or delete operations immediately before a write |
+| `colab_update` | Publish material progress, scope, status, rationale, and claim renewal |
+| `colab_finish` | Complete, release, abandon, or hand off work and release the claim |
+| `colab_log_activity` | Append a structured project event for decisions, discoveries, tests, commits, or releases |
+| `colab_timeline` | Read or search the chronological development record |
+| `colab_graph_search` | Search durable knowledge in the project-scoped graph |
+
+### Coordination lifecycle
+
+The main agent joins and synchronizes before planning, then claims intended
+paths before editing. Join, sync, and claim operations stay on the main agent
+because their results affect planning and write safety. Routine progress and
+timeline maintenance may be delegated to one awaited coordination writer.
+
+```text
+join -> sync -> claim -> check before writes -> update during work -> finish or hand off
+```
+
+Live conflicts are not bypassed. If another session owns an overlapping path,
+the agent coordinates a handoff, waits for lease expiry, or changes scope.
+
+### Lifecycle and Git hooks
+
+The plugin hooks cover:
+
+- `SessionStart`: load the current project brief;
+- `PreToolUse`: check inferred file operations and claims before writes;
+- `PostToolUse`: record structured completion or Git-push events; and
+- `Stop`: record that the coding session stopped.
+
+The optional repository Git hooks record non-blocking events such as commits,
+checkouts, merges, rewrites, and pushes:
+
+```bash
+hypercolab hooks install
+```
+
+Remove them before uninstalling the CLI:
+
+```bash
+hypercolab hooks uninstall
+```
+
+### Offline behavior
+
+HyperColab treats coordination conservatively during an outage:
+
+- new path claims fail closed;
+- a previously approved cached lease is honored only until its server-issued
+ expiration;
+- Git activity is queued locally and retried later; and
+- repositories that are not registered with HyperColab remain unaffected.
+
+### CLI command reference
+
+| Command | Purpose |
+| --- | --- |
+| `hypercolab login` / `logout` | Create or remove the local OAuth session |
+| `hypercolab status` | Show authentication, Git, project, and session state |
+| `hypercolab doctor` | Check the CLI, Git, authentication, API URL, and repository without modifying it |
+| `hypercolab setup` | Add project-scoped Codex MCP configuration and install Git activity hooks |
+| `hypercolab join` | Join the project resolved from the active Git remote |
+| `hypercolab sync` / `who` | Read current coordination state and active sessions |
+| `hypercolab claim` / `check` | Claim paths or check an intended file operation |
+| `hypercolab update` | Publish progress, status, paths, and visible rationale |
+| `hypercolab finish` / `release` | Complete, hand off, abandon, or release claimed work |
+| `hypercolab timeline` | Read or search the project timeline |
+| `hypercolab timeline add` | Append a deliberate structured timeline event |
+| `hypercolab graph search` | Search the project-scoped durable graph |
+| `hypercolab hooks install` / `uninstall` | Manage optional repository Git hooks |
+| `hypercolab mcp` | Run the local stdio MCP shim used by the plugin |
+
+### Data boundary
+
+HyperColab records structured summaries, repository-relative paths, commit
+identifiers, claims, statuses, test results, small metadata objects, and visible
+rationale summaries. By default it does not send raw source, raw diffs, full
+shell output, complete transcripts, or hidden model reasoning.
+
+## Combined architecture
+
+```mermaid
+flowchart TB
+ Repo["hypermemory-ai/hm-plugins-openai"] --> Catalog["hypermemory-ai marketplace"]
+ Catalog --> HM["HyperMemory plugin"]
+ Catalog --> HC["HyperColab plugin"]
+
+ subgraph PersonalMemory["Durable cross-session memory"]
+ HM --> HMMCP["Hosted OAuth MCP"]
+ HM --> HMSkill["Always-on memory skill"]
+ HM --> HMHooks["Recall and finalization hooks"]
+ HM --> HMAgent["Memory-writer role"]
+ HM --> Tokens["Privacy-preserving token listener"]
+ end
+
+ subgraph ProjectCoordination["Project-scoped coordination"]
+ HC --> HCSkill["Coordination skill"]
+ HC --> HCHooks["Claim and activity hooks"]
+ HC --> HCAgent["Coordination-writer role"]
+ HC --> LocalMCP["Local stdio MCP shim"]
+ LocalMCP --> ColabAPI["HyperColab project services"]
+ end
+```
+
+The plugins may be enabled independently. When both are enabled, HyperMemory
+retains durable conversational context while HyperColab supplies the live,
+repository-specific coordination state.
+
+## Repository layout
+
+```text
+.
+├── .agents/plugins/marketplace.json # Shared Git marketplace catalog
+├── .github/workflows/validate.yml # Lint, test, and archive CI
+├── plugins/
+│ ├── hypermemory/
+│ │ ├── .codex-plugin/plugin.json # HyperMemory manifest
+│ │ ├── .mcp.json # Hosted OAuth MCP connection
+│ │ ├── agents/ # Memory-writer role contract
+│ │ ├── assets/ # Marketplace icon and logo
+│ │ ├── hooks/hooks.json # Codex lifecycle hooks
+│ │ ├── scripts/ # Hook bridge and token listener
+│ │ └── skills/hypermemory/ # Memory workflow and references
+│ └── hypercolab/
+│ ├── .codex-plugin/plugin.json # HyperColab manifest
+│ ├── .mcp.json # Local stdio MCP registration
+│ ├── agents/ # Coordination-writer role contract
+│ ├── assets/ # Marketplace icon and logo
+│ ├── hooks/hooks.json # Codex coordination hooks
+│ ├── scripts/ # Graceful hook launcher
+│ └── skills/hypercolab/ # Coordination workflow and references
+├── packages/hypercolab-cli/ # Installable CLI and local MCP shim
+├── docs/
+│ ├── ARCHITECTURE.md # Runtime design and trust boundaries
+│ ├── INSTALLATION.md # Detailed setup and troubleshooting
+│ └── MARKETPLACE.md # Catalog and release maintenance
+├── scripts/build_plugin_archives.py # Deterministic review ZIP builder
+├── tests/ # Marketplace and lifecycle tests
+├── AGENTS.md # Repository rules for coding agents
+├── CONTRIBUTING.md # Contribution and release checklist
+├── SECURITY.md # Vulnerability reporting and boundaries
+└── LICENSE # MIT license
+```
+
+Only `plugin.json` lives inside each `.codex-plugin/` directory. Skills, MCP
+configuration, hooks, assets, scripts, and role contracts remain at the plugin
+root according to the Codex plugin package layout.
+
+## Agent role packaging
+
+Each plugin contains an `agents/` role contract and a matching skill reference:
+
+- HyperMemory uses `memory-writer` for storage, timeline, and telemetry.
+- HyperColab uses `coordination-writer` for project activity maintenance.
+
+These files document the bounded role that the skill asks the host to spawn.
+They are not a separate manifest-level custom-agent registry: the current
+OpenAI plugin manifest packages skills, MCP servers, hooks, apps, and assets,
+but does not auto-install arbitrary project-scoped agent TOML files. The skill
+therefore controls when delegation happens, what information is passed, and how
+recursive delegation is prevented.
+
+## Authentication and secrets
+
+| Component | Authentication | Where credentials live |
+| --- | --- | --- |
+| HyperMemory MCP | OAuth authorization code with PKCE | Codex/host MCP credential storage |
+| HyperColab CLI | `hypercolab login` OAuth flow with PKCE | OS keyring, with a restricted local fallback when no keyring is available |
+| Git marketplace | Public GitHub repository | No credentials required for this repository |
+
+No access token, refresh token, client secret, API key, or reviewer credential
+belongs in this repository. See [Security](SECURITY.md) for reporting and trust
+boundaries.
+
+## Hook trust and permissions
+
+Plugin installation does not automatically trust bundled command hooks. Users
+must review them with `/hooks`. This provides an explicit boundary around local
+commands that can inspect token counters, query Git state, or check write
+ownership.
+
+Administrators may disable hooks or restrict marketplace/MCP sources through
+managed Codex policy. Sub-agents inherit the active parent sandbox and
+permission mode. Neither plugin expands operating-system permissions on its
+own.
+
+## Updating
+
+Refresh the Git marketplace snapshot, reinstall the plugins you use, and start
+a new task:
+
+```bash
+codex plugin marketplace upgrade hypermemory-ai
+codex plugin add hypermemory@hypermemory-ai
+codex plugin add hypercolab@hypermemory-ai
+pipx upgrade hypercolab
+```
+
+Review hooks again if their definitions changed.
+
+## Removing
+
+If you installed HyperColab Git hooks, remove those first while the CLI is still
+available:
+
+```bash
+hypercolab hooks uninstall
+```
+
+Then remove the plugins, marketplace, and optional CLI:
+
+```bash
+codex plugin remove hypermemory@hypermemory-ai
+codex plugin remove hypercolab@hypermemory-ai
+codex plugin marketplace remove hypermemory-ai
+pipx uninstall hypercolab
+```
+
+Removing a plugin or marketplace does not delete durable data already stored by
+HyperMemory or HyperColab.
+
+## Development
+
+### Clone and create an environment
+
+```bash
+git clone https://github.com/hypermemory-ai/hm-plugins-openai.git
+cd hm-plugins-openai
+python3 -m venv .venv
+source .venv/bin/activate
+python -m pip install -e "packages/hypercolab-cli[dev]"
+```
+
+### Run the test suite
+
+```bash
+ruff check plugins packages tests scripts
+pytest -q
+```
+
+The tests cover:
+
+- catalog-to-plugin path and identity consistency;
+- required manifests, MCP declarations, hooks, skills, and assets;
+- HyperMemory stop-hook delegation and recursion protection;
+- exact token aggregation and two-phase checkpointing;
+- HyperColab hook behavior, Git discovery, cached leases, and queued events;
+- logo format and dimensions; and
+- graceful behavior when the HyperColab CLI is missing.
+
+### Run Codex package validators
+
+```bash
+python3 ~/.codex/skills/.system/plugin-creator/scripts/validate_plugin.py \
+ plugins/hypermemory
+python3 ~/.codex/skills/.system/plugin-creator/scripts/validate_plugin.py \
+ plugins/hypercolab
+python3 ~/.codex/skills/.system/skill-creator/scripts/quick_validate.py \
+ plugins/hypermemory/skills/hypermemory
+python3 ~/.codex/skills/.system/skill-creator/scripts/quick_validate.py \
+ plugins/hypercolab/skills/hypercolab
+```
+
+### Build review archives
+
+```bash
+python scripts/build_plugin_archives.py
+```
+
+This creates deterministic ZIP archives in `dist/`. Marketplace installation
+uses the source directories referenced by `marketplace.json`, so checked-in
+`.plugin` files are not required. Generated archives remain ignored to avoid
+stale binary packages.
+
+### Test a local marketplace checkout
+
+In a clean development profile, or after removing another configured source
+with the same marketplace name, run from the repository root:
+
+```bash
+codex plugin marketplace add .
+codex plugin add hypermemory@hypermemory-ai
+codex plugin add hypercolab@hypermemory-ai
+```
+
+Start a new task after reinstalling so Codex loads the updated skills and MCP
+configuration.
+
+## Release and publication model
+
+There are two distinct distribution paths:
+
+1. **Git marketplace:** this repository supports local authoring, Codex
+ installation, team testing, and controlled pre-publication distribution.
+2. **Universal Plugins Directory:** each plugin is submitted and reviewed
+ independently for public, one-click discovery across supported ChatGPT and
+ Codex surfaces.
+
+Plugin versions live in each `.codex-plugin/plugin.json`; the marketplace does
+not have a shared plugin version. Increment only the package that changed,
+validate its complete file tree, build a fresh review archive, test a clean
+installation, and document user-visible changes.
+
+HyperMemory should use OpenAI's **With MCP** submission path because it combines
+a hosted MCP server with a skill. HyperColab's public submission must preserve
+its repository-identity safety boundary while using a transport supported by
+the target public surface.
+
+See [Marketplace maintenance](docs/MARKETPLACE.md) and
+[Contributing](CONTRIBUTING.md) for the complete release checklist.
+
+## Troubleshooting
+
+### The marketplace was added, but no plugin is installed
+
+That is expected. Registering the marketplace adds the catalog only. Install a
+plugin explicitly:
+
+```bash
+codex plugin add hypermemory@hypermemory-ai
+codex plugin add hypercolab@hypermemory-ai
+```
+
+### MCP tools are missing
+
+Confirm that the plugin is installed and enabled with `codex plugin list`, then
+start a new task. HyperColab also requires the `hypercolab` executable to be on
+`PATH`; run `hypercolab doctor` to verify its prerequisites.
+
+### HyperMemory OAuth did not open
+
+Invoke a HyperMemory MCP operation and complete the connection flow. Confirm
+the installed MCP URL is `https://stage.hypermemory.io/mcp` and check whether a
+workspace policy blocks the server.
+
+### HyperColab authentication failed
+
+Run:
+
+```bash
+hypercolab login
+hypercolab doctor
+hypercolab status
+```
+
+The local callback needs an available loopback port and a browser capable of
+completing OAuth.
+
+### Hooks do not run
+
+Open `/hooks`, locate the plugin hook source, and trust its current definition.
+Also confirm hooks are not disabled in Codex configuration or managed policy.
+
+### A HyperColab write is blocked
+
+Run `hypercolab sync` to inspect active ownership and claims. Coordinate a
+handoff, wait for the conflicting lease to expire, or change the intended path.
+Do not bypass a valid ownership conflict.
+
+### The plugin changed but Codex still uses the old copy
+
+Refresh and reinstall:
+
+```bash
+codex plugin marketplace upgrade hypermemory-ai
+codex plugin add @hypermemory-ai
+```
+
+Then start a new task. Codex loads an installed marketplace snapshot rather than
+executing directly from an arbitrary source checkout.
+
+### Exact token reporting is unavailable
+
+Exact reporting requires a local Codex rollout with `token_count` records and a
+working inspect/ack job. When those counters are unavailable, the memory writer
+submits one uncertainty-labelled estimate instead. Consumer ChatGPT always uses
+the estimated path.
+
+## Frequently asked questions
+
+### Is the marketplace itself a plugin?
+
+No. The marketplace is the catalog named `hypermemory-ai`. It currently lists
+the separate `hypermemory` and `hypercolab` plugins.
+
+### Do I need both plugins?
+
+No. HyperMemory and HyperColab are independent. Install only the capabilities
+you need.
+
+### Does HyperColab replace HyperMemory?
+
+No. HyperColab uses project-scoped knowledge and coordination. HyperMemory is
+the durable cross-conversation memory plugin. They complement one another.
+
+### Are the hooks automatically trusted?
+
+No. Codex requires explicit trust for non-managed plugin hooks, and changed
+definitions must be reviewed again.
+
+### Are the packaged `agents/` files automatically registered custom agents?
+
+No. They are bounded role contracts invoked through the bundled skills. They
+document delegation behavior but are not a separate manifest-level agent
+registry.
+
+### Does token telemetry upload my conversations?
+
+No. The Codex listener parses cumulative token counters only. It does not
+return or upload chat content, tool payloads, or source code.
+
+### Can a normal ChatGPT user install directly from this Git URL?
+
+This repository supports Git-marketplace development and Codex distribution.
+Normal public one-click installation requires publication through OpenAI's
+universal Plugins Directory.
+
+### Is the MCP endpoint production?
+
+No. The checked-in HyperMemory configuration currently targets the Rust staging
+endpoint. Treat the package as pre-production until the manifest and docs are
+updated to a production MCP URL.
+
+## Documentation
+
+- [Detailed installation and troubleshooting](docs/INSTALLATION.md)
+- [Architecture and trust boundaries](docs/ARCHITECTURE.md)
+- [Marketplace and release maintenance](docs/MARKETPLACE.md)
+- [HyperMemory package notes](plugins/hypermemory/README.md)
+- [HyperColab package notes](plugins/hypercolab/README.md)
+- [Contribution guide](CONTRIBUTING.md)
+- [Security policy](SECURITY.md)
+
+For the current Codex plugin model, see OpenAI's
+[plugin packaging documentation](https://developers.openai.com/plugins/build/plugins).
+
+## Support and security
+
+For general project questions, use the repository's GitHub issues. Do not post
+credentials, tokens, private repository content, or vulnerability details in a
+public issue.
+
+Report security concerns privately according to [SECURITY.md](SECURITY.md).
+Legal and product information is available at:
+
+- [HyperMemory AI](https://hypermemory.io)
+- [Privacy policy](https://hypermemory.io/privacy)
+- [Terms of service](https://hypermemory.io/terms)
+
+## License
+
+Licensed under the MIT License. See [LICENSE](LICENSE).
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..3ba77c5
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,26 @@
+# Security policy
+
+## Reporting a vulnerability
+
+Do not open a public issue for a vulnerability, credential exposure, or tenant
+isolation concern. Email `hello@runstack.ai` with a concise description,
+affected component, reproduction steps, and impact. Do not include live access
+tokens or personal data.
+
+## Security boundaries
+
+- HyperMemory MCP authentication uses OAuth. The plugin contains no API key or
+ bearer token.
+- HyperColab credentials are stored by the local CLI, outside the repository
+ and plugin package.
+- Non-managed plugin hooks require explicit trust in Codex.
+- HyperMemory token telemetry parses counters only and must not upload chat or
+ source content.
+- HyperColab timeline events contain structured summaries and repository paths,
+ not raw code, diffs, transcripts, or hidden reasoning.
+- Path claims are coordination controls, not a substitute for operating-system
+ access control or code review.
+
+## Supported versions
+
+Security fixes target the latest version of each plugin on the default branch.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..c92880b
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,129 @@
+# Architecture
+
+The repository contains one marketplace catalog and two independently
+installable plugins. Shared branding and distribution live at the repository
+level; runtime behavior remains isolated by plugin.
+
+## Package topology
+
+```mermaid
+flowchart TD
+ Marketplace[".agents/plugins/marketplace.json"] --> HM["plugins/hypermemory"]
+ Marketplace --> HC["plugins/hypercolab"]
+
+ HM --> HMM[".codex-plugin/plugin.json"]
+ HM --> HMMCP["Hosted OAuth MCP"]
+ HM --> HMSkill["Memory skill + writer contract"]
+ HM --> HMHooks["Session, prompt, and stop hooks"]
+ HM --> HMToken["Exact Codex token listener"]
+
+ HC --> HCM[".codex-plugin/plugin.json"]
+ HC --> HCMCP["Local stdio MCP registration"]
+ HC --> HCSkill["Coordination skill + writer contract"]
+ HC --> HCHooks["Join, claims, and activity hooks"]
+ HCMCP --> CLI["packages/hypercolab-cli"]
+```
+
+## HyperMemory lifecycle
+
+```mermaid
+sequenceDiagram
+ participant U as User
+ participant M as Main agent
+ participant MCP as HyperMemory MCP
+ participant W as Memory-writer sub-agent
+ participant L as Codex token listener
+
+ U->>M: Prompt
+ M->>MCP: overview + recall
+ MCP-->>M: Relevant graph context
+ M->>M: Perform the requested work
+ M->>W: Bounded finalization summary
+ W->>MCP: recall, store/update, timeline
+ W->>L: inspect exact counter delta
+ L-->>W: hm_tokens payload
+ W->>MCP: one token report
+ W->>L: acknowledge accepted claim
+ W-->>M: Brief status
+ M-->>U: Final response
+```
+
+Recall remains on the main agent because it changes how the task is understood.
+Persistence and telemetry are delegated so they do not crowd the main context.
+The stop hook uses a recursion guard to prevent an end-of-turn delegation loop.
+
+The token listener reads only `session_meta` and `token_count` records from the
+logical Codex session's parent and sub-agent rollouts. Its inspect/ack protocol
+does not advance the checkpoint until the MCP accepts the report. Tokens that
+appear after inspection roll into the next successful report.
+
+## HyperColab lifecycle
+
+```mermaid
+sequenceDiagram
+ participant A as Coding agent
+ participant S as Local MCP shim
+ participant API as HyperColab API
+ participant G as Project graph
+ participant T as Project timeline
+
+ A->>S: colab_join / colab_sync
+ S->>S: Resolve Git root, remote, branch, worktree
+ S->>API: Authenticated project request
+ API-->>A: Active work and path guidance
+ A->>S: colab_claim before edits
+ S->>API: Atomic path claim
+ A->>S: update / activity / finish
+ API->>T: Append structured event
+ API->>G: Project durable decisions and outcomes
+```
+
+The local shim is intentional: it derives repository identity locally so an
+agent cannot choose another project's graph or timeline identifier. Redis holds
+short-lived sessions, claims, and leases; the project graph and append-only
+timeline remain the durable systems of record.
+
+## Hooks and trust
+
+Both plugins use the default `hooks/hooks.json` discovery path. Plugin hooks
+are non-managed, so Codex requires users to review and trust their exact
+definition. Changed hook content receives a new hash and must be reviewed again.
+
+HyperMemory hooks add recall instructions and create bounded token-listener
+jobs. HyperColab hooks load project context, check writes against claims, and
+record structured activity. The HyperColab launcher degrades safely when its
+CLI is missing: it explains the prerequisite at session start and does not
+block writes in an unconfigured environment.
+
+## Agent packaging
+
+The current OpenAI plugin manifest supports skills, MCP servers, apps, hooks,
+and presentation assets; it does not define a separate auto-installed custom
+agent registry. Each plugin therefore ships its agent role as a skill reference:
+
+- `memory-writer-agent.md` defines HyperMemory finalization.
+- `coordination-agent.md` defines delegated HyperColab timeline maintenance.
+
+The skills instruct the host when to spawn these bounded roles, what context to
+provide, and how to prevent recursive delegation. The `agents/openai.yaml`
+files alongside each skill provide OpenAI skill interface metadata and MCP
+dependencies; they are not custom-agent TOML files.
+
+## Authentication boundaries
+
+- HyperMemory uses the hosted MCP's OAuth flow. Codex stores MCP OAuth state;
+ the plugin contains only the server URL.
+- HyperColab authenticates through `hypercolab login`. Credentials remain in
+ local HyperColab configuration and are not embedded in `.mcp.json`.
+- Token provenance and cost provenance are independent. The plugin never
+ invents provider-actual billing data.
+
+## Surface behavior
+
+| Capability | ChatGPT | Codex |
+| --- | --- | --- |
+| HyperMemory hosted MCP and skill | Supported through MCP/plugin publication | Supported through Git marketplace or public directory |
+| HyperMemory exact local token delta | Not exposed by consumer ChatGPT | Supported through local rollout counters |
+| HyperColab skill | Supported where installed | Supported |
+| HyperColab local MCP shim and Git hooks | Requires a local surface able to launch the CLI | Supported in local CLI/app/IDE workflows |
+| Plugin lifecycle hooks | Surface-dependent | Supported after explicit trust |
diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md
new file mode 100644
index 0000000..c8f2e5f
--- /dev/null
+++ b/docs/INSTALLATION.md
@@ -0,0 +1,157 @@
+# Installation
+
+This repository is a Git-backed Codex marketplace named `hypermemory-ai`.
+Registering it makes the catalog available; it does not install either plugin.
+
+## Prerequisites
+
+- A current Codex CLI or Codex in the ChatGPT desktop app
+- A HyperMemory account
+- Python 3.10 or newer and `pipx` for the HyperColab local shim
+
+## 1. Register the marketplace
+
+```bash
+codex plugin marketplace add hypermemory-ai/hm-plugins-openai
+```
+
+Confirm the source:
+
+```bash
+codex plugin marketplace list
+```
+
+The catalog exposes two plugin IDs:
+
+- `hypermemory@hypermemory-ai`
+- `hypercolab@hypermemory-ai`
+
+## 2. Install HyperMemory
+
+```bash
+codex plugin add hypermemory@hypermemory-ai
+```
+
+The plugin connects to:
+
+```text
+https://stage.hypermemory.io/mcp
+```
+
+Complete OAuth when prompted. No API key is stored in the plugin package. The
+server handles authorization-code flow, PKCE, refresh tokens, and dynamic
+client registration.
+
+Start a new task after installation so Codex loads the MCP server and bundled
+skill. In Codex CLI, run `/hooks`, review the HyperMemory hook definition, and
+trust it to enable lifecycle recall enforcement and the exact local token
+listener.
+
+## 3. Install HyperColab
+
+HyperColab resolves the current Git root, remote, branch, and worktree locally.
+Install its CLI and stdio MCP shim before installing the plugin:
+
+```bash
+pipx install "git+https://github.com/hypermemory-ai/hm-plugins-openai.git#subdirectory=packages/hypercolab-cli"
+hypercolab login
+hypercolab doctor
+```
+
+Then install the plugin:
+
+```bash
+codex plugin add hypercolab@hypermemory-ai
+```
+
+Inside a repository enrolled in HyperColab, optionally install the non-blocking
+Git activity hooks:
+
+```bash
+hypercolab hooks install
+```
+
+Start a new task and run `/hooks` to review and trust the HyperColab lifecycle
+hooks. Repositories that are not registered with HyperColab remain unaffected.
+
+## 4. Verify
+
+```bash
+codex plugin list
+hypercolab status
+```
+
+Useful first prompts:
+
+- `What do you remember about this project?`
+- `Join this HyperColab project and show active work.`
+- `Claim the files needed for this task before editing.`
+
+## Updates
+
+Refresh the Git catalog and reinstall the desired package:
+
+```bash
+codex plugin marketplace upgrade hypermemory-ai
+codex plugin add hypermemory@hypermemory-ai
+codex plugin add hypercolab@hypermemory-ai
+pipx upgrade hypercolab
+```
+
+Start a new task after updating. If a hook changed, Codex will require review of
+the new definition because hook trust is tied to its exact content.
+
+## Removal
+
+```bash
+codex plugin remove hypermemory --marketplace hypermemory-ai
+codex plugin remove hypercolab --marketplace hypermemory-ai
+codex plugin marketplace remove hypermemory-ai
+pipx uninstall hypercolab
+```
+
+Removing the marketplace does not delete data already stored in HyperMemory or
+HyperColab. Remove local HyperColab Git hooks before uninstalling the CLI if you
+installed them:
+
+```bash
+hypercolab hooks uninstall
+```
+
+## ChatGPT surfaces
+
+The Git marketplace is intended for Codex installation, local development, and
+workspace testing. Public one-click installation for normal ChatGPT and Codex
+users requires publishing through the universal Plugins Directory.
+
+For pre-publication HyperMemory testing in ChatGPT developer mode, register
+`https://stage.hypermemory.io/mcp` and use the bundled HyperMemory skill. Since
+consumer ChatGPT does not expose Codex rollout JSONL counters, HyperMemory uses
+an uncertainty-labelled token estimate there.
+
+HyperColab's full behavior depends on its local stdio shim and Git context. It
+therefore requires a local coding surface that can execute `hypercolab`; a web
+chat without access to that process cannot provide repository claims or Git
+activity capture.
+
+## Troubleshooting
+
+### MCP tools are missing
+
+Confirm the plugin is installed and enabled, then start a new task. For
+HyperColab, also confirm `hypercolab` is on `PATH` with `hypercolab doctor`.
+
+### Hooks do not run
+
+Run `/hooks`, inspect the source and current hash, and trust the definition.
+Hooks are non-managed and intentionally skipped until trusted.
+
+### OAuth did not open
+
+For HyperMemory, invoke a HyperMemory MCP tool and complete the connection flow.
+For HyperColab, run `hypercolab login` directly in a terminal.
+
+### A HyperColab write is blocked
+
+Run `hypercolab sync` to inspect current ownership. Coordinate a handoff or wait
+for the conflicting lease instead of bypassing a live claim.
diff --git a/docs/MARKETPLACE.md b/docs/MARKETPLACE.md
new file mode 100644
index 0000000..8107dd2
--- /dev/null
+++ b/docs/MARKETPLACE.md
@@ -0,0 +1,93 @@
+# Marketplace maintenance
+
+## Catalog identity
+
+The marketplace file lives at `.agents/plugins/marketplace.json` and has the
+stable identifier `hypermemory-ai`. Its ordered plugin list is:
+
+1. `hypermemory`
+2. `hypercolab`
+
+Install identifiers follow `@`:
+
+```text
+hypermemory@hypermemory-ai
+hypercolab@hypermemory-ai
+```
+
+The catalog entries use `source: local` because Codex first checks out the Git
+marketplace and then resolves each `./plugins/...` path inside that snapshot.
+Registering the repository is separate from installing a plugin.
+
+## Required entry fields
+
+Every catalog entry includes:
+
+- `name`, matching the plugin folder and manifest name
+- a repository-relative source path
+- `policy.installation`
+- `policy.authentication`
+- `category`
+
+Both plugins are `AVAILABLE` and authenticate on installation. Do not add
+product gating unless there is an explicit release requirement for it.
+
+## Plugin package contract
+
+Each plugin root contains:
+
+```text
+.codex-plugin/plugin.json
+.mcp.json
+assets/
+hooks/hooks.json
+skills//SKILL.md
+skills//agents/openai.yaml
+skills//references/
+```
+
+HyperMemory also includes its token listener and lifecycle bridge under
+`scripts/`. HyperColab includes a graceful hook launcher, while its complete CLI
+and stdio MCP shim live under `packages/hypercolab-cli/` for `pipx`
+installation.
+
+## Versions and cache behavior
+
+Use semantic versions in each `.codex-plugin/plugin.json`. Increment only the
+plugin that changed. After publishing a Git change:
+
+```bash
+codex plugin marketplace upgrade hypermemory-ai
+codex plugin add @hypermemory-ai
+```
+
+Start a new task to load the updated package. Changed hook definitions require
+new trust approval.
+
+## Archives and `.plugin` files
+
+Codex marketplace installation consumes the plugin directories referenced by
+the catalog; it does not require checked-in `.plugin` archives. OpenAI's public
+submission workflow uses the plugin manifest, MCP details, and skill uploads;
+skills-only upload artifacts are ZIP files.
+
+For review or release automation, build deterministic ZIPs locally:
+
+```bash
+python scripts/build_plugin_archives.py
+```
+
+The command writes one archive per plugin under `dist/`. Generated archives are
+ignored by Git so the repository remains source-first and avoids stale binary
+packages.
+
+## Public Plugins Directory
+
+This Git marketplace supports Codex installation, development, and private or
+team distribution. To offer one-click installation to normal ChatGPT and Codex
+users, submit each plugin separately through OpenAI's universal Plugins
+Directory.
+
+HyperMemory should use the **With MCP** flow with its hosted MCP URL and final
+skill bundle. HyperColab needs a publication plan that preserves repository
+identity while satisfying the target surface's MCP transport requirements.
diff --git a/packages/hypercolab-cli/README.md b/packages/hypercolab-cli/README.md
new file mode 100644
index 0000000..166612f
--- /dev/null
+++ b/packages/hypercolab-cli/README.md
@@ -0,0 +1,21 @@
+# Hypercolab CLI
+
+`hypercolab` is both the standalone project-coordination CLI and the local
+stdio MCP shim used by the HyperColab plugin for ChatGPT and Codex.
+
+```bash
+pipx install .
+hypercolab login
+hypercolab setup
+hypercolab join --goal "Implement project invitations"
+hypercolab mcp
+```
+
+The CLI detects the current Git root, remote, branch, and worktree. Project
+graph and timeline identifiers are resolved by the HyperMemory backend and are
+never selected directly by an agent.
+
+The CLI uses the same service methods for direct commands, coding-client hooks,
+Git activity capture, and MCP tools. Git events are queued when the API is down.
+New claims fail closed while offline; existing cached leases are honored only
+until their server-issued expiry. The package configures Codex only.
diff --git a/packages/hypercolab-cli/hypercolab_cli/__init__.py b/packages/hypercolab-cli/hypercolab_cli/__init__.py
new file mode 100644
index 0000000..7fb977f
--- /dev/null
+++ b/packages/hypercolab-cli/hypercolab_cli/__init__.py
@@ -0,0 +1,3 @@
+"""Hypercolab CLI and MCP shim."""
+
+__version__ = "0.1.0"
diff --git a/packages/hypercolab-cli/hypercolab_cli/auth.py b/packages/hypercolab-cli/hypercolab_cli/auth.py
new file mode 100644
index 0000000..2d4e301
--- /dev/null
+++ b/packages/hypercolab-cli/hypercolab_cli/auth.py
@@ -0,0 +1,117 @@
+"""OAuth 2.1 PKCE authentication for Hypercolab."""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import http.server
+import secrets
+import urllib.parse
+import webbrowser
+from typing import Any
+
+import httpx
+
+from .config import Config, load_config, save_config
+
+
+def _pkce() -> tuple[str, str]:
+ verifier = secrets.token_urlsafe(64)
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
+ return verifier, base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
+
+
+class _Callback(http.server.BaseHTTPRequestHandler):
+ code: str | None = None
+ error: str | None = None
+
+ def do_GET(self) -> None:
+ params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
+ _Callback.code = params.get("code", [None])[0]
+ _Callback.error = params.get("error_description", params.get("error", [None]))[0]
+ self.send_response(200 if _Callback.code else 400)
+ self.send_header("Content-Type", "text/html")
+ self.end_headers()
+ message = (
+ "Authenticated. You can close this tab." if _Callback.code else f"Authentication failed: {_Callback.error}"
+ )
+ self.wfile.write(
+ f"{message}
".encode()
+ )
+
+ def log_message(self, _format: str, *args: Any) -> None:
+ return
+
+
+def login() -> Config:
+ config = load_config(require_auth=False)
+ verifier, challenge = _pkce()
+ server = http.server.HTTPServer(("127.0.0.1", 0), _Callback)
+ redirect_uri = f"http://127.0.0.1:{server.server_address[1]}"
+ registration = httpx.post(
+ f"{config.api_url}/register",
+ json={
+ "client_name": "Hypercolab CLI",
+ "redirect_uris": [redirect_uri],
+ "grant_types": ["authorization_code", "refresh_token"],
+ "response_types": ["code"],
+ "token_endpoint_auth_method": "none",
+ },
+ timeout=15,
+ )
+ registration.raise_for_status()
+ client_id = registration.json()["client_id"]
+ auth_url = (
+ f"{config.api_url}/authorize?response_type=code&client_id={urllib.parse.quote(client_id)}"
+ f"&redirect_uri={urllib.parse.quote(redirect_uri)}&code_challenge={challenge}"
+ "&code_challenge_method=S256&scope=memory:read+memory:write+memory:admin"
+ )
+ print(f"Open this URL to authenticate:\n{auth_url}")
+ webbrowser.open(auth_url)
+ _Callback.code = None
+ _Callback.error = None
+ server.timeout = 120
+ while _Callback.code is None and _Callback.error is None:
+ server.handle_request()
+ server.server_close()
+ if not _Callback.code:
+ raise RuntimeError(_Callback.error or "No authorization code received")
+ response = httpx.post(
+ f"{config.api_url}/token",
+ json={
+ "grant_type": "authorization_code",
+ "code": _Callback.code,
+ "code_verifier": verifier,
+ "client_id": client_id,
+ "redirect_uri": redirect_uri,
+ },
+ timeout=15,
+ )
+ response.raise_for_status()
+ tokens = response.json()
+ config.access_token = tokens["access_token"]
+ config.refresh_token = tokens.get("refresh_token", "")
+ config.client_id = client_id
+ save_config(config)
+ return config
+
+
+def refresh(config: Config) -> bool:
+ if not config.refresh_token or not config.client_id:
+ return False
+ response = httpx.post(
+ f"{config.api_url}/token",
+ json={
+ "grant_type": "refresh_token",
+ "refresh_token": config.refresh_token,
+ "client_id": config.client_id,
+ },
+ timeout=15,
+ )
+ if response.status_code >= 400:
+ return False
+ tokens = response.json()
+ config.access_token = tokens.get("access_token", "")
+ config.refresh_token = tokens.get("refresh_token", config.refresh_token)
+ save_config(config)
+ return bool(config.access_token)
diff --git a/packages/hypercolab-cli/hypercolab_cli/client.py b/packages/hypercolab-cli/hypercolab_cli/client.py
new file mode 100644
index 0000000..14441f3
--- /dev/null
+++ b/packages/hypercolab-cli/hypercolab_cli/client.py
@@ -0,0 +1,208 @@
+"""Shared HTTP client used by direct CLI commands, hooks, and MCP tools."""
+
+from __future__ import annotations
+
+import json
+from typing import Any, Self
+
+import httpx
+
+from .auth import refresh
+from .config import Config, load_config, remember_check, remember_repository, repository_state
+from .git import GitContext, discover_git_context
+
+
+class HypercolabError(RuntimeError):
+ def __init__(self, message: str, *, status_code: int = 0, detail: Any = None) -> None:
+ super().__init__(message)
+ self.status_code = status_code
+ self.detail = detail
+
+
+class HypercolabClient:
+ def __init__(self, config: Config | None = None) -> None:
+ self.config = config or load_config()
+ self.http = httpx.Client(base_url=f"{self.config.api_url}/api/colab", timeout=30)
+
+ def close(self) -> None:
+ self.http.close()
+
+ def __enter__(self) -> Self:
+ return self
+
+ def __exit__(self, *_args: object) -> None:
+ self.close()
+
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
+ headers = {"Authorization": f"Bearer {self.config.auth_token}"}
+ try:
+ response = self.http.request(method, path, headers=headers, **kwargs)
+ except (httpx.ConnectError, httpx.TimeoutException) as exc:
+ raise HypercolabError("Hypercolab service is unavailable") from exc
+ if response.status_code == 401 and refresh(self.config):
+ headers["Authorization"] = f"Bearer {self.config.auth_token}"
+ response = self.http.request(method, path, headers=headers, **kwargs)
+ try:
+ data = response.json()
+ except ValueError:
+ data = response.text
+ if response.status_code >= 400:
+ detail = data.get("detail", data) if isinstance(data, dict) else data
+ raise HypercolabError(str(detail), status_code=response.status_code, detail=detail)
+ return data
+
+ def get(self, path: str) -> Any:
+ return self._request("GET", path)
+
+ def post(self, path: str, body: dict[str, Any]) -> Any:
+ return self._request("POST", path, json=body)
+
+ def join(
+ self,
+ *,
+ git: GitContext | None = None,
+ goal: str | None = None,
+ agent_name: str = "coding-agent",
+ client_name: str = "cli",
+ ) -> dict[str, Any]:
+ git = git or discover_git_context()
+ result = self.post(
+ "/join",
+ {
+ "repository_remote": git.remote,
+ "agent_name": agent_name,
+ "client_name": client_name,
+ "branch": git.branch,
+ "worktree": git.worktree,
+ "goal": goal,
+ },
+ )
+ remember_repository(git.remote, project=result["project"], session=result["session"])
+ return result
+
+ def ensure_session(self, git: GitContext | None = None, *, client_name: str = "cli") -> tuple[GitContext, dict]:
+ git = git or discover_git_context()
+ state = repository_state(git.remote)
+ if state and state.get("session", {}).get("session_id"):
+ return git, state
+ joined = self.join(git=git, client_name=client_name)
+ return git, {"project": joined["project"], "session": joined["session"]}
+
+ def sync(self, *, git: GitContext | None = None, client_name: str = "cli") -> dict[str, Any]:
+ _, state = self.ensure_session(git, client_name=client_name)
+ return self.get(f"/sync/{state['session']['session_id']}")
+
+ def claim(self, paths: list[str], task: str = "", *, git: GitContext | None = None) -> dict[str, Any]:
+ _, state = self.ensure_session(git)
+ return self.post(
+ "/claim",
+ {"session_id": state["session"]["session_id"], "paths": paths, "task": task, "ttl_seconds": 600},
+ )
+
+ def check(
+ self,
+ operations: list[dict[str, Any]],
+ *,
+ git: GitContext | None = None,
+ auto_claim: bool = True,
+ client_name: str = "cli",
+ ) -> dict[str, Any]:
+ git, state = self.ensure_session(git, client_name=client_name)
+ result = self.post(
+ "/check",
+ {"session_id": state["session"]["session_id"], "operations": operations, "auto_claim": auto_claim},
+ )
+ remember_check(git.remote, operations, result)
+ return result
+
+ def update(
+ self,
+ summary: str,
+ *,
+ status: str = "working",
+ rationale_summary: str | None = None,
+ paths: list[str] | None = None,
+ claim_id: str | None = None,
+ git: GitContext | None = None,
+ ) -> dict[str, Any]:
+ _, state = self.ensure_session(git)
+ return self.post(
+ "/update",
+ {
+ "session_id": state["session"]["session_id"],
+ "claim_id": claim_id,
+ "status": status,
+ "summary": summary,
+ "rationale_summary": rationale_summary,
+ "paths": paths or [],
+ },
+ )
+
+ def finish(
+ self,
+ summary: str,
+ *,
+ outcome: str = "completed",
+ changed_files: list[str] | None = None,
+ commit_refs: list[str] | None = None,
+ tests: list[str] | None = None,
+ claim_id: str | None = None,
+ git: GitContext | None = None,
+ ) -> dict[str, Any]:
+ _, state = self.ensure_session(git)
+ return self.post(
+ "/finish",
+ {
+ "session_id": state["session"]["session_id"],
+ "claim_id": claim_id,
+ "outcome": outcome,
+ "summary": summary,
+ "changed_files": changed_files or [],
+ "commit_refs": commit_refs or [],
+ "tests": tests or [],
+ },
+ )
+
+ def log_activity(
+ self,
+ *,
+ kind: str,
+ summary: str,
+ source: str = "cli",
+ rationale_summary: str | None = None,
+ paths: list[str] | None = None,
+ commit_refs: list[str] | None = None,
+ metadata: dict[str, Any] | None = None,
+ git: GitContext | None = None,
+ ) -> dict[str, Any]:
+ git, state = self.ensure_session(git)
+ return self.post(
+ f"/projects/{state['project']['project_id']}/activity",
+ {
+ "session_id": state["session"]["session_id"],
+ "source": source,
+ "kind": kind,
+ "summary": summary,
+ "rationale_summary": rationale_summary,
+ "branch": git.branch,
+ "worktree": git.worktree,
+ "paths": paths or [],
+ "commit_refs": commit_refs or [],
+ "metadata": metadata or {},
+ },
+ )
+
+ def timeline(self, *, query: str | None = None, limit: int = 100, git: GitContext | None = None):
+ _, state = self.ensure_session(git)
+ return self.post(
+ f"/projects/{state['project']['project_id']}/timeline",
+ {"query": query, "limit": limit, "offset": 0},
+ )
+
+ def graph_search(self, query: str, *, limit: int = 25, git: GitContext | None = None):
+ _, state = self.ensure_session(git)
+ return self.post(f"/projects/{state['project']['project_id']}/graph/search", {"query": query, "limit": limit})
+
+
+def compact_json(value: Any) -> str:
+ return json.dumps(value, separators=(",", ":"), default=str)
diff --git a/packages/hypercolab-cli/hypercolab_cli/config.py b/packages/hypercolab-cli/hypercolab_cli/config.py
new file mode 100644
index 0000000..26dd500
--- /dev/null
+++ b/packages/hypercolab-cli/hypercolab_cli/config.py
@@ -0,0 +1,188 @@
+"""Local Hypercolab configuration, credentials and repository session state."""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import posixpath
+import time
+from dataclasses import asdict, dataclass
+from pathlib import Path
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_API_URL = "https://api.hypermemory.io"
+CONFIG_DIR = Path(os.environ.get("HYPERCOLAB_CONFIG_DIR", Path.home() / ".config" / "hypercolab"))
+CONFIG_FILE = CONFIG_DIR / "config.json"
+STATE_FILE = CONFIG_DIR / "state.json"
+QUEUE_FILE = CONFIG_DIR / "timeline-queue.jsonl"
+KEYRING_SERVICE = "io.hypermemory.hypercolab"
+
+
+@dataclass(slots=True)
+class Config:
+ api_url: str = DEFAULT_API_URL
+ access_token: str = ""
+ refresh_token: str = ""
+ client_id: str = ""
+
+ @property
+ def auth_token(self) -> str:
+ return os.environ.get("HYPERCOLAB_API_TOKEN") or os.environ.get("HYPERMEMORY_API_KEY") or self.access_token
+
+
+def _ensure_dir() -> None:
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
+ try:
+ CONFIG_DIR.chmod(0o700)
+ except OSError:
+ pass
+
+
+def _read_json(path: Path) -> dict[str, Any]:
+ try:
+ return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
+ except (OSError, json.JSONDecodeError) as exc:
+ logger.debug("config_read_failed: %s", exc)
+ return {}
+
+
+def _keyring_get(name: str) -> str:
+ try:
+ import keyring
+
+ return keyring.get_password(KEYRING_SERVICE, name) or ""
+ except Exception as exc: # noqa: BLE001 - keyring backends raise platform-specific errors
+ logger.debug("keyring_read_failed: %s", exc)
+ return ""
+
+
+def _keyring_set(name: str, value: str) -> None:
+ try:
+ import keyring
+
+ if value:
+ keyring.set_password(KEYRING_SERVICE, name, value)
+ else:
+ keyring.delete_password(KEYRING_SERVICE, name)
+ except Exception as exc: # noqa: BLE001 - keyring backends raise platform-specific errors
+ logger.debug("keyring_write_failed: %s", exc)
+
+
+def load_config(*, require_auth: bool = True) -> Config:
+ data = _read_json(CONFIG_FILE)
+ config = Config(
+ api_url=(os.environ.get("HYPERCOLAB_API_URL") or data.get("api_url") or DEFAULT_API_URL).rstrip("/"),
+ access_token=_keyring_get("access_token") or data.get("access_token", ""),
+ refresh_token=_keyring_get("refresh_token") or data.get("refresh_token", ""),
+ client_id=data.get("client_id", ""),
+ )
+ if require_auth and not config.auth_token:
+ raise RuntimeError("Not authenticated. Run `hypercolab login` or set HYPERCOLAB_API_TOKEN.")
+ return config
+
+
+def save_config(config: Config) -> None:
+ _ensure_dir()
+ _keyring_set("access_token", config.access_token)
+ _keyring_set("refresh_token", config.refresh_token)
+ data = {"api_url": config.api_url, "client_id": config.client_id}
+ # Keyring-less/headless installations need a restricted fallback.
+ if config.access_token and not _keyring_get("access_token"):
+ data["access_token"] = config.access_token
+ data["refresh_token"] = config.refresh_token
+ CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
+ try:
+ CONFIG_FILE.chmod(0o600)
+ except OSError:
+ pass
+
+
+def clear_credentials() -> None:
+ current = load_config(require_auth=False)
+ current.access_token = ""
+ current.refresh_token = ""
+ current.client_id = ""
+ save_config(current)
+
+
+def load_state() -> dict[str, Any]:
+ return _read_json(STATE_FILE)
+
+
+def save_state(state: dict[str, Any]) -> None:
+ _ensure_dir()
+ STATE_FILE.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
+ try:
+ STATE_FILE.chmod(0o600)
+ except OSError:
+ pass
+
+
+def remember_repository(remote: str, *, project: dict[str, Any], session: dict[str, Any]) -> None:
+ state = load_state()
+ state.setdefault("repositories", {})[remote] = {"project": project, "session": session}
+ save_state(state)
+
+
+def repository_state(remote: str) -> dict[str, Any] | None:
+ return load_state().get("repositories", {}).get(remote)
+
+
+def remember_check(remote: str, operations: list[dict[str, Any]], result: dict[str, Any]) -> None:
+ """Cache only server-approved, leased write scopes for brief outages."""
+
+ state = load_state()
+ repository = state.setdefault("repositories", {}).setdefault(remote, {})
+ leases = repository.setdefault("leases", {})
+ for operation, decision in zip(operations, result.get("decisions", []), strict=False):
+ expires_at = decision.get("lease_expires_at")
+ if decision.get("decision") != "allow" or not decision.get("claim_id") or not expires_at:
+ continue
+ leases[_normalized_path(operation["path"])] = {
+ "claim_id": decision["claim_id"],
+ "lease_expires_at": float(expires_at),
+ }
+ repository["leases"] = {
+ path: lease for path, lease in leases.items() if float(lease.get("lease_expires_at", 0)) > time.time()
+ }
+ save_state(state)
+
+
+def operations_have_live_cached_leases(remote: str, operations: list[dict[str, Any]]) -> bool:
+ repository = repository_state(remote) or {}
+ now = time.time()
+ leases = {
+ path: lease
+ for path, lease in repository.get("leases", {}).items()
+ if float(lease.get("lease_expires_at", 0)) > now
+ }
+ if not leases:
+ return False
+ for operation in operations:
+ if operation.get("operation") == "read":
+ continue
+ targets = [_normalized_path(operation["path"])]
+ if operation.get("destination"):
+ targets.append(_normalized_path(operation["destination"]))
+ if not all(any(_paths_overlap(target, scope) for scope in leases) for target in targets):
+ return False
+ return True
+
+
+def _normalized_path(path: str) -> str:
+ value = posixpath.normpath(str(path).replace("\\", "/").removeprefix("./"))
+ return value.rstrip("/")
+
+
+def _paths_overlap(left: str, right: str) -> bool:
+ return left == right or left.startswith(right + "/") or right.startswith(left + "/")
+
+
+def config_dict(config: Config) -> dict[str, Any]:
+ data = asdict(config)
+ data["access_token"] = "***" if config.access_token else ""
+ data["refresh_token"] = "***" if config.refresh_token else ""
+ return data
diff --git a/packages/hypercolab-cli/hypercolab_cli/git.py b/packages/hypercolab-cli/hypercolab_cli/git.py
new file mode 100644
index 0000000..33026f7
--- /dev/null
+++ b/packages/hypercolab-cli/hypercolab_cli/git.py
@@ -0,0 +1,55 @@
+"""Git repository context used for safe project resolution and audit events."""
+
+from __future__ import annotations
+
+import subprocess
+from dataclasses import asdict, dataclass
+from pathlib import Path
+
+
+@dataclass(slots=True)
+class GitContext:
+ root: str
+ remote: str
+ branch: str
+ worktree: str
+
+ def to_dict(self) -> dict[str, str]:
+ return asdict(self)
+
+
+def _git(*args: str, cwd: str | Path | None = None, check: bool = True) -> str:
+ result = subprocess.run(
+ ["git", *args],
+ cwd=cwd,
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=15,
+ )
+ if check and result.returncode:
+ raise RuntimeError(result.stderr.strip() or "Not inside a Git repository")
+ return result.stdout.strip()
+
+
+def discover_git_context(cwd: str | Path | None = None) -> GitContext:
+ root = _git("rev-parse", "--show-toplevel", cwd=cwd)
+ remote = _git("remote", "get-url", "origin", cwd=root, check=False)
+ if not remote:
+ remotes = _git("remote", cwd=root, check=False).splitlines()
+ if len(remotes) == 1:
+ remote = _git("remote", "get-url", remotes[0], cwd=root)
+ if not remote:
+ raise RuntimeError("The repository has no resolvable Git remote")
+ branch = _git("branch", "--show-current", cwd=root, check=False) or "detached"
+ worktree = _git("rev-parse", "--show-toplevel", cwd=root)
+ return GitContext(root=root, remote=remote, branch=branch, worktree=worktree)
+
+
+def changed_paths(cwd: str | Path | None = None, revision: str = "HEAD") -> list[str]:
+ output = _git("diff-tree", "--root", "--no-commit-id", "--name-only", "-r", revision, cwd=cwd, check=False)
+ return [line for line in output.splitlines() if line]
+
+
+def current_commit(cwd: str | Path | None = None) -> str:
+ return _git("rev-parse", "HEAD", cwd=cwd, check=False)
diff --git a/packages/hypercolab-cli/hypercolab_cli/hooks.py b/packages/hypercolab-cli/hypercolab_cli/hooks.py
new file mode 100644
index 0000000..cee855b
--- /dev/null
+++ b/packages/hypercolab-cli/hypercolab_cli/hooks.py
@@ -0,0 +1,286 @@
+"""Coding-client guard hook and non-blocking Git timeline hooks."""
+
+from __future__ import annotations
+
+import json
+import re
+import shlex
+import subprocess
+import sys
+from pathlib import Path
+from typing import Any
+
+from .client import HypercolabClient, HypercolabError
+from .config import QUEUE_FILE, operations_have_live_cached_leases
+from .git import GitContext, changed_paths, current_commit, discover_git_context
+
+MANAGED_START = "# >>> hypercolab managed hook >>>"
+MANAGED_END = "# <<< hypercolab managed hook <<<"
+GIT_HOOKS = ("post-commit", "post-checkout", "post-merge", "post-rewrite", "pre-push")
+READ_ONLY_COMMANDS = re.compile(
+ r"^\s*(git\s+(status|diff|log|show|branch|remote|rev-parse)|"
+ r"(rg|grep|find|ls|pwd|sed\s+-n|head|tail|cat|wc|which|type)\b)"
+)
+WRITE_COMMANDS = re.compile(
+ r"\b(rm|mv|cp|mkdir|touch|install|truncate|tee|sed\s+-i|git\s+(add|commit|merge|rebase))\b|>>?|\|\s*tee\b"
+)
+GIT_PUSH = re.compile(r"(?:^|[;&|]\s*)git\s+(?:-[^\s]+\s+)*push\b")
+
+
+def _hook_block(reason: str, event_name: str = "PreToolUse") -> dict[str, Any]:
+ return {
+ "hookSpecificOutput": {
+ "hookEventName": event_name,
+ "permissionDecision": "deny",
+ "permissionDecisionReason": reason,
+ }
+ }
+
+
+def _extract_file_paths(payload: dict[str, Any]) -> list[dict[str, Any]]:
+ tool = payload.get("tool_name", "")
+ tool_input = payload.get("tool_input") or {}
+ for key in ("file_path", "path", "target_file"):
+ if tool_input.get(key):
+ return [{"path": tool_input[key], "operation": "modify"}]
+ if tool == "apply_patch":
+ command = str(tool_input.get("command") or tool_input.get("patch") or "")
+ paths = re.findall(r"^\*\*\* (?:Update|Add|Delete) File: (.+)$", command, re.MULTILINE)
+ return [{"path": path, "operation": "modify"} for path in paths]
+ return []
+
+
+def _command(payload: dict[str, Any]) -> str:
+ tool_input = payload.get("tool_input") or {}
+ return str(tool_input.get("command") or tool_input.get("cmd") or payload.get("command") or "")
+
+
+def evaluate_client_hook(payload: dict[str, Any]) -> dict[str, Any]:
+ """Return cross-client hook JSON. Empty dict means allow."""
+
+ event = payload.get("hook_event_name", "")
+ if event in {"SessionStart", "sessionStart"}:
+ try:
+ with HypercolabClient() as client:
+ brief = client.sync(client_name="plugin")
+ return {
+ "hookSpecificOutput": {
+ "hookEventName": payload.get("hook_event_name", "SessionStart"),
+ "additionalContext": "Hypercolab coordination brief:\n" + json.dumps(brief, default=str),
+ }
+ }
+ except (HypercolabError, RuntimeError):
+ # Unregistered repositories are intentionally unaffected.
+ return {}
+
+ if event in {"PostToolUse", "afterFileEdit", "afterShellExecution"}:
+ try:
+ operations = _extract_file_paths(payload)
+ tool = payload.get("tool_name") or payload.get("tool") or "tool"
+ paths = [operation["path"] for operation in operations]
+ command = _command(payload)
+ response = payload.get("tool_response") or payload.get("tool_output") or {}
+ exit_code = response.get("exit_code") if isinstance(response, dict) else None
+ kind = "git.push_completed" if GIT_PUSH.search(command) else "tool.completed"
+ summary = (
+ "Git push completed"
+ if kind == "git.push_completed" and exit_code in {None, 0}
+ else "Git push failed"
+ if kind == "git.push_completed"
+ else f"Agent completed {tool}"
+ )
+ with HypercolabClient() as client:
+ client.log_activity(
+ kind=kind,
+ summary=summary,
+ source="plugin",
+ paths=paths,
+ metadata={"tool": tool, "path_count": len(paths), "exit_code": exit_code},
+ )
+ except (HypercolabError, RuntimeError):
+ pass
+ return {}
+
+ if event in {"Stop", "SessionEnd", "sessionEnd"}:
+ try:
+ with HypercolabClient() as client:
+ client.log_activity(
+ kind="session.stopped",
+ summary="Agent session stopped",
+ source="plugin",
+ metadata={"hook_event": event},
+ )
+ except (HypercolabError, RuntimeError):
+ pass
+ return {}
+
+ if event not in {"PreToolUse", "beforeFileEdit", "beforeShellExecution"}:
+ return {}
+ operations = _extract_file_paths(payload)
+ command = _command(payload)
+ if not operations and command:
+ if GIT_PUSH.search(command):
+ try:
+ with HypercolabClient() as client:
+ client.log_activity(
+ kind="git.push_attempted",
+ summary="Git push attempted",
+ source="plugin",
+ metadata={"tool": payload.get("tool_name") or payload.get("tool") or "shell"},
+ )
+ except (HypercolabError, RuntimeError):
+ pass
+ return {}
+ if READ_ONLY_COMMANDS.search(command) and not WRITE_COMMANDS.search(command):
+ return {}
+ if WRITE_COMMANDS.search(command):
+ return _hook_block(
+ "Hypercolab cannot safely infer every path written by this shell command. "
+ "Claim the intended files first or use a file-edit tool."
+ )
+ return {}
+ if not operations:
+ return {}
+ try:
+ git = discover_git_context(payload.get("cwd") or None)
+ relative: list[dict[str, Any]] = []
+ root = Path(git.root).resolve()
+ for operation in operations:
+ path = Path(operation["path"])
+ if path.is_absolute():
+ try:
+ path = path.resolve().relative_to(root)
+ except ValueError:
+ return _hook_block("Hypercolab blocks writes outside the coordinated repository")
+ relative.append({**operation, "path": path.as_posix()})
+ with HypercolabClient() as client:
+ result = client.check(relative, git=git, auto_claim=True, client_name="plugin")
+ if not result.get("allowed", False):
+ blocked = [item for item in result.get("decisions", []) if item.get("decision") == "block"]
+ return _hook_block("Hypercolab claim conflict: " + json.dumps(blocked, default=str))
+ return {}
+ except HypercolabError as exc:
+ if exc.status_code == 404:
+ return {}
+ if exc.status_code == 0 and operations_have_live_cached_leases(git.remote, relative):
+ return {}
+ return _hook_block(str(exc))
+ except RuntimeError:
+ return {}
+
+
+def run_client_hook() -> int:
+ try:
+ payload = json.load(sys.stdin)
+ except (json.JSONDecodeError, OSError):
+ payload = {}
+ output = evaluate_client_hook(payload)
+ if output:
+ print(json.dumps(output))
+ return 0
+
+
+def install_git_hooks(root: str | Path | None = None) -> list[str]:
+ git = discover_git_context(root)
+ git_dir_text = subprocess.run(
+ ["git", "-C", git.root, "rev-parse", "--git-dir"],
+ check=True,
+ capture_output=True,
+ text=True,
+ timeout=15,
+ ).stdout.strip()
+ git_dir = Path(git.root, git_dir_text) if not Path(git_dir_text).is_absolute() else Path(git_dir_text)
+ hook_dir = git_dir / "hooks"
+ hook_dir.mkdir(parents=True, exist_ok=True)
+ installed = []
+ for hook_name in GIT_HOOKS:
+ path = hook_dir / hook_name
+ existing = path.read_text(encoding="utf-8") if path.exists() else "#!/bin/sh\n"
+ if MANAGED_START in existing:
+ continue
+ block = (
+ f"\n{MANAGED_START}\nhypercolab git-event {shlex.quote(hook_name)} >/dev/null 2>&1 || true\n{MANAGED_END}\n"
+ )
+ path.write_text(existing.rstrip() + block, encoding="utf-8")
+ path.chmod(path.stat().st_mode | 0o111)
+ installed.append(str(path))
+ return installed
+
+
+def uninstall_git_hooks(root: str | Path | None = None) -> list[str]:
+ git = discover_git_context(root)
+ git_dir_text = subprocess.run(
+ ["git", "-C", git.root, "rev-parse", "--git-dir"],
+ check=True,
+ capture_output=True,
+ text=True,
+ timeout=15,
+ ).stdout.strip()
+ git_dir = Path(git.root, git_dir_text) if not Path(git_dir_text).is_absolute() else Path(git_dir_text)
+ changed = []
+ for hook_name in GIT_HOOKS:
+ path = git_dir / "hooks" / hook_name
+ if not path.exists():
+ continue
+ content = path.read_text(encoding="utf-8")
+ updated = re.sub(
+ rf"\n?{re.escape(MANAGED_START)}.*?{re.escape(MANAGED_END)}\n?", "\n", content, flags=re.DOTALL
+ )
+ if updated != content:
+ path.write_text(updated, encoding="utf-8")
+ changed.append(str(path))
+ return changed
+
+
+def queue_event(event: dict[str, Any]) -> None:
+ QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
+ with QUEUE_FILE.open("a", encoding="utf-8") as handle:
+ handle.write(json.dumps(event, default=str) + "\n")
+
+
+def flush_queued_events(client: HypercolabClient) -> int:
+ """Retry queued Git events, retaining only entries that still fail."""
+
+ if not QUEUE_FILE.exists():
+ return 0
+ try:
+ entries = [json.loads(line) for line in QUEUE_FILE.read_text(encoding="utf-8").splitlines() if line.strip()]
+ except (OSError, json.JSONDecodeError):
+ return 0
+ remaining: list[dict[str, Any]] = []
+ sent = 0
+ for entry in entries:
+ original = dict(entry)
+ try:
+ git_data = entry.get("git")
+ event = {key: value for key, value in entry.items() if key != "git"}
+ git = GitContext(**git_data)
+ client.log_activity(git=git, **event)
+ sent += 1
+ except (HypercolabError, RuntimeError, TypeError):
+ remaining.append(original)
+ QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
+ QUEUE_FILE.write_text("".join(json.dumps(entry, default=str) + "\n" for entry in remaining), encoding="utf-8")
+ return sent
+
+
+def record_git_event(kind: str) -> bool:
+ git = discover_git_context()
+ commit = current_commit(git.root)
+ paths = changed_paths(git.root)
+ event = {
+ "kind": f"git.{kind.replace('-', '_')}",
+ "summary": f"Git {kind.replace('-', ' ')} on {git.branch}",
+ "source": "git",
+ "paths": paths,
+ "commit_refs": [commit] if commit else [],
+ "metadata": {"branch": git.branch},
+ }
+ try:
+ with HypercolabClient() as client:
+ flush_queued_events(client)
+ client.log_activity(git=git, **event)
+ return True
+ except (HypercolabError, RuntimeError):
+ queue_event({"git": git.to_dict(), **event})
+ return False
diff --git a/packages/hypercolab-cli/hypercolab_cli/main.py b/packages/hypercolab-cli/hypercolab_cli/main.py
new file mode 100644
index 0000000..8815ee8
--- /dev/null
+++ b/packages/hypercolab-cli/hypercolab_cli/main.py
@@ -0,0 +1,303 @@
+"""Hypercolab command-line interface."""
+
+from __future__ import annotations
+
+import json
+import shutil
+import sys
+from pathlib import Path
+from typing import Any
+
+import typer
+from rich.console import Console
+
+from . import __version__
+from .auth import login as oauth_login
+from .client import HypercolabClient, HypercolabError
+from .config import clear_credentials, config_dict, load_config, repository_state
+from .git import current_commit, discover_git_context
+from .hooks import install_git_hooks, record_git_event, run_client_hook, uninstall_git_hooks
+from .mcp_server import run as run_mcp
+
+app = typer.Typer(help="Coordinate developers and AI coding agents with project-scoped HyperMemory.")
+timeline_app = typer.Typer(help="Read or append project timeline events.")
+graph_app = typer.Typer(help="Search the project-scoped HyperMemory graph.")
+hooks_app = typer.Typer(help="Install or remove non-blocking Git activity hooks.")
+app.add_typer(timeline_app, name="timeline")
+app.add_typer(graph_app, name="graph")
+app.add_typer(hooks_app, name="hooks")
+console = Console(stderr=True)
+
+
+def _print(value: Any) -> None:
+ print(json.dumps(value, indent=2, default=str))
+
+
+def _run(callable_, *args: Any, **kwargs: Any) -> None:
+ try:
+ with HypercolabClient() as client:
+ _print(callable_(client, *args, **kwargs))
+ except (HypercolabError, RuntimeError) as exc:
+ console.print(f"[red]Error:[/red] {exc}")
+ raise typer.Exit(1) from exc
+
+
+@app.callback(invoke_without_command=True)
+def root(
+ ctx: typer.Context,
+ version: bool = typer.Option(False, "--version", help="Show the installed version."),
+) -> None:
+ if version:
+ print(__version__)
+ raise typer.Exit()
+ if ctx.invoked_subcommand is None:
+ console.print(ctx.get_help())
+
+
+@app.command()
+def login() -> None:
+ """Authenticate through HyperMemory OAuth."""
+
+ try:
+ config = oauth_login()
+ console.print(f"[green]Authenticated[/green] with {config.api_url}")
+ except Exception as exc:
+ console.print(f"[red]Authentication failed:[/red] {exc}")
+ raise typer.Exit(1) from exc
+
+
+@app.command()
+def logout() -> None:
+ """Remove local Hypercolab credentials."""
+
+ clear_credentials()
+ console.print("Logged out.")
+
+
+@app.command()
+def status() -> None:
+ """Show authentication, repository, project, and session status."""
+
+ try:
+ config = load_config(require_auth=False)
+ git = discover_git_context()
+ _print({"config": config_dict(config), "git": git.to_dict(), "state": repository_state(git.remote)})
+ except RuntimeError as exc:
+ _print({"config": config_dict(load_config(require_auth=False)), "repository": None, "warning": str(exc)})
+
+
+@app.command()
+def doctor() -> None:
+ """Check prerequisites without modifying the repository."""
+
+ config = load_config(require_auth=False)
+ checks: dict[str, Any] = {
+ "hypercolab": __version__,
+ "git": shutil.which("git") or False,
+ "authenticated": bool(config.auth_token),
+ "api_url": config.api_url,
+ }
+ try:
+ checks["repository"] = discover_git_context().to_dict()
+ except RuntimeError as exc:
+ checks["repository"] = False
+ checks["repository_error"] = str(exc)
+ _print(checks)
+ if not all((checks["git"], checks["authenticated"])):
+ raise typer.Exit(1)
+
+
+@app.command()
+def setup(
+ client: str = typer.Option("codex", help="OpenAI coding client to configure (codex)."),
+) -> None:
+ """Configure project-scoped MCP files and install Git timeline hooks."""
+
+ git = discover_git_context()
+ if client != "codex":
+ raise typer.BadParameter(f"Unknown client: {client}")
+ written = []
+ root_path = Path(git.root)
+ path = root_path / ".codex" / "config.toml"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ existing = path.read_text(encoding="utf-8") if path.exists() else ""
+ marker = "[mcp_servers.hypercolab]"
+ if marker not in existing:
+ block = '\n[mcp_servers.hypercolab]\ncommand = "hypercolab"\nargs = ["mcp"]\n'
+ path.write_text(existing.rstrip() + block, encoding="utf-8")
+ written.append(str(path))
+ hooks = install_git_hooks(git.root)
+ _print({"configured": written, "git_hooks": hooks})
+
+
+@app.command()
+def join(
+ goal: str = typer.Option("", help="Current work goal."),
+ agent_name: str = typer.Option("coding-agent"),
+ client_name: str = typer.Option("cli"),
+) -> None:
+ """Join the project associated with the current Git remote."""
+
+ _run(lambda client: client.join(goal=goal or None, agent_name=agent_name, client_name=client_name))
+
+
+@app.command()
+def sync() -> None:
+ """Get current work ownership, recent timeline, and path guidance."""
+
+ _run(lambda client: client.sync())
+
+
+@app.command()
+def who() -> None:
+ """List active developers and agents."""
+
+ _run(lambda client: {"active_sessions": client.sync().get("active_sessions", [])})
+
+
+@app.command()
+def claim(paths: list[str], task: str = typer.Option("", help="Task or intended outcome.")) -> None:
+ """Atomically claim files or directories."""
+
+ _run(lambda client: client.claim(paths, task))
+
+
+@app.command()
+def check(
+ paths: list[str],
+ operation: str = typer.Option("modify", help="read, create, modify, rename, or delete"),
+ auto_claim: bool = typer.Option(True),
+) -> None:
+ """Check paths immediately before an operation."""
+
+ _run(lambda client: client.check([{"path": path, "operation": operation} for path in paths], auto_claim=auto_claim))
+
+
+@app.command()
+def update(
+ summary: str,
+ status: str = typer.Option("working"),
+ rationale: str = typer.Option(""),
+ paths: list[str] = typer.Option(None),
+ claim_id: str = typer.Option(""),
+) -> None:
+ """Publish progress and visible rationale to the project timeline."""
+
+ _run(
+ lambda client: client.update(
+ summary,
+ status=status,
+ rationale_summary=rationale or None,
+ paths=paths or [],
+ claim_id=claim_id or None,
+ )
+ )
+
+
+@app.command()
+def finish(
+ summary: str,
+ outcome: str = typer.Option("completed"),
+ changed_files: list[str] = typer.Option(None),
+ commit: list[str] = typer.Option(None),
+ test: list[str] = typer.Option(None),
+ claim_id: str = typer.Option(""),
+) -> None:
+ """Finish, abandon, release, or hand off coordinated work."""
+
+ _run(
+ lambda client: client.finish(
+ summary,
+ outcome=outcome,
+ changed_files=changed_files or [],
+ commit_refs=commit or [],
+ tests=test or [],
+ claim_id=claim_id or None,
+ )
+ )
+
+
+@app.command()
+def release(claim_id: str, summary: str = typer.Option("Released work scope")) -> None:
+ """Release a claim without marking the task complete."""
+
+ _run(lambda client: client.finish(summary, outcome="released", claim_id=claim_id))
+
+
+@timeline_app.callback(invoke_without_command=True)
+def timeline_read(ctx: typer.Context, query: str = typer.Option(""), limit: int = typer.Option(100)) -> None:
+ """Read or search the project timeline."""
+
+ if ctx.invoked_subcommand is None:
+ _run(lambda client: client.timeline(query=query or None, limit=limit))
+
+
+@timeline_app.command("add")
+def timeline_add(
+ summary: str,
+ kind: str = typer.Option("note.added"),
+ rationale: str = typer.Option(""),
+ paths: list[str] = typer.Option(None),
+) -> None:
+ """Append an explicit structured project event."""
+
+ _run(
+ lambda client: client.log_activity(
+ kind=kind,
+ summary=summary,
+ rationale_summary=rationale or None,
+ paths=paths or [],
+ )
+ )
+
+
+@graph_app.command("search")
+def graph_search(query: str, limit: int = typer.Option(25)) -> None:
+ """Search durable project graph knowledge."""
+
+ _run(lambda client: client.graph_search(query, limit=limit))
+
+
+@hooks_app.command("install")
+def hooks_install() -> None:
+ _print({"installed": install_git_hooks()})
+
+
+@hooks_app.command("uninstall")
+def hooks_uninstall() -> None:
+ _print({"removed": uninstall_git_hooks()})
+
+
+@app.command()
+def mcp() -> None:
+ """Run the local Hypercolab MCP server over stdio."""
+
+ run_mcp()
+
+
+@app.command(hidden=True)
+def hook() -> None:
+ raise typer.Exit(run_client_hook())
+
+
+@app.command("git-event", hidden=True)
+def git_event(kind: str) -> None:
+ raise typer.Exit(0 if record_git_event(kind) else 1)
+
+
+@app.command(hidden=True)
+def commit() -> None:
+ """Print the current commit; useful for hook diagnostics."""
+
+ print(current_commit())
+
+
+def main() -> None:
+ try:
+ app()
+ except BrokenPipeError:
+ sys.stdout.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/packages/hypercolab-cli/hypercolab_cli/mcp_server.py b/packages/hypercolab-cli/hypercolab_cli/mcp_server.py
new file mode 100644
index 0000000..b917c9d
--- /dev/null
+++ b/packages/hypercolab-cli/hypercolab_cli/mcp_server.py
@@ -0,0 +1,131 @@
+"""Local stdio MCP shim. All tools share the same Hypercolab client methods."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from mcp.server.fastmcp import FastMCP
+
+from .client import HypercolabClient
+
+mcp = FastMCP(
+ "Hypercolab",
+ instructions=(
+ "Join and sync before planning. Claim intended paths before editing. "
+ "Publish progress, rationale summaries, blockers, and completions to the project timeline."
+ ),
+)
+
+
+def _call(method: str, *args: Any, **kwargs: Any) -> Any:
+ with HypercolabClient() as client:
+ return getattr(client, method)(*args, **kwargs)
+
+
+@mcp.tool()
+def colab_join(goal: str = "", agent_name: str = "coding-agent", client_name: str = "mcp") -> dict[str, Any]:
+ """Join the Hypercolab project associated with the current Git repository."""
+
+ return _call("join", goal=goal or None, agent_name=agent_name, client_name=client_name)
+
+
+@mcp.tool()
+def colab_sync() -> dict[str, Any]:
+ """Get active work, ownership, recent timeline, and touch/do-not-touch guidance."""
+
+ return _call("sync", client_name="mcp")
+
+
+@mcp.tool()
+def colab_claim(paths: list[str], task: str = "") -> dict[str, Any]:
+ """Atomically claim repository-relative files or directories."""
+
+ return _call("claim", paths, task)
+
+
+@mcp.tool()
+def colab_check(operations: list[dict[str, Any]], auto_claim: bool = True) -> dict[str, Any]:
+ """Check create/modify/rename/delete operations immediately before editing."""
+
+ return _call("check", operations, auto_claim=auto_claim, client_name="mcp")
+
+
+@mcp.tool()
+def colab_update(
+ summary: str,
+ status: str = "working",
+ rationale_summary: str = "",
+ paths: list[str] | None = None,
+ claim_id: str = "",
+) -> dict[str, Any]:
+ """Publish meaningful progress and renew an active claim."""
+
+ return _call(
+ "update",
+ summary,
+ status=status,
+ rationale_summary=rationale_summary or None,
+ paths=paths or [],
+ claim_id=claim_id or None,
+ )
+
+
+@mcp.tool()
+def colab_finish(
+ summary: str,
+ outcome: str = "completed",
+ changed_files: list[str] | None = None,
+ commit_refs: list[str] | None = None,
+ tests: list[str] | None = None,
+ claim_id: str = "",
+) -> dict[str, Any]:
+ """Complete, release, abandon, or hand off work and release its claim."""
+
+ return _call(
+ "finish",
+ summary,
+ outcome=outcome,
+ changed_files=changed_files or [],
+ commit_refs=commit_refs or [],
+ tests=tests or [],
+ claim_id=claim_id or None,
+ )
+
+
+@mcp.tool()
+def colab_log_activity(
+ kind: str,
+ summary: str,
+ rationale_summary: str = "",
+ paths: list[str] | None = None,
+ commit_refs: list[str] | None = None,
+) -> dict[str, Any]:
+ """Append a structured, project-scoped development timeline event."""
+
+ return _call(
+ "log_activity",
+ kind=kind,
+ summary=summary,
+ source="plugin",
+ rationale_summary=rationale_summary or None,
+ paths=paths or [],
+ commit_refs=commit_refs or [],
+ )
+
+
+@mcp.tool()
+def colab_timeline(query: str = "", limit: int = 100) -> dict[str, Any]:
+ """Search or read the current project's chronological development record."""
+
+ return _call("timeline", query=query or None, limit=limit)
+
+
+@mcp.tool()
+def colab_graph_search(query: str, limit: int = 25) -> dict[str, Any]:
+ """Search durable project knowledge in the project-scoped HyperMemory graph."""
+
+ return _call("graph_search", query, limit=limit)
+
+
+def run() -> None:
+ mcp.run(transport="stdio")
diff --git a/packages/hypercolab-cli/pyproject.toml b/packages/hypercolab-cli/pyproject.toml
new file mode 100644
index 0000000..bebbb0a
--- /dev/null
+++ b/packages/hypercolab-cli/pyproject.toml
@@ -0,0 +1,39 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "hypercolab"
+version = "0.1.0"
+description = "Project-scoped coordination, graph memory, and development timelines for AI coding teams."
+readme = "README.md"
+license = "MIT"
+requires-python = ">=3.10"
+authors = [{ name = "HyperMemory AI", email = "hello@runstack.ai" }]
+dependencies = [
+ "httpx>=0.27,<1",
+ "keyring>=25,<26",
+ "mcp[cli]>=1.2,<2",
+ "rich>=13,<14",
+ "typer>=0.12,<1",
+]
+
+[project.optional-dependencies]
+dev = ["pytest>=8", "pytest-asyncio>=0.23", "ruff>=0.8"]
+
+[project.scripts]
+hypercolab = "hypercolab_cli.main:main"
+
+[tool.hatch.build.targets.wheel]
+packages = ["hypercolab_cli"]
+
+[tool.ruff]
+line-length = 120
+target-version = "py310"
+
+[tool.ruff.lint]
+select = ["B", "E", "F", "I", "UP"]
+ignore = ["B008"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
diff --git a/packages/hypercolab-cli/tests/test_git.py b/packages/hypercolab-cli/tests/test_git.py
new file mode 100644
index 0000000..068281d
--- /dev/null
+++ b/packages/hypercolab-cli/tests/test_git.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+import subprocess
+from pathlib import Path
+
+from hypercolab_cli.git import changed_paths, discover_git_context
+
+
+def _run(repo: Path, *args: str) -> None:
+ subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True)
+
+
+def test_discover_git_context(tmp_path: Path):
+ _run(tmp_path, "init")
+ _run(tmp_path, "remote", "add", "origin", "git@github.com:RunStack-AI/example.git")
+ context = discover_git_context(tmp_path)
+ assert context.root == str(tmp_path)
+ assert context.remote == "git@github.com:RunStack-AI/example.git"
+
+
+def test_changed_paths_from_commit(tmp_path: Path):
+ _run(tmp_path, "init")
+ _run(tmp_path, "config", "user.email", "test@example.com")
+ _run(tmp_path, "config", "user.name", "Test")
+ (tmp_path / "hello.txt").write_text("hello\n", encoding="utf-8")
+ _run(tmp_path, "add", "hello.txt")
+ _run(tmp_path, "commit", "-m", "initial")
+ assert changed_paths(tmp_path) == ["hello.txt"]
diff --git a/packages/hypercolab-cli/tests/test_hooks.py b/packages/hypercolab-cli/tests/test_hooks.py
new file mode 100644
index 0000000..0bd6e43
--- /dev/null
+++ b/packages/hypercolab-cli/tests/test_hooks.py
@@ -0,0 +1,53 @@
+from hypercolab_cli import hooks
+from hypercolab_cli.hooks import evaluate_client_hook
+
+
+def test_read_only_shell_is_allowed():
+ assert (
+ evaluate_client_hook(
+ {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "git status"}}
+ )
+ == {}
+ )
+
+
+def test_unknown_writing_shell_command_is_blocked():
+ result = evaluate_client_hook(
+ {
+ "hook_event_name": "PreToolUse",
+ "tool_name": "Bash",
+ "tool_input": {"command": "sed -i '' s/old/new/ src/app.py"},
+ }
+ )
+ assert result["hookSpecificOutput"]["permissionDecision"] == "deny"
+
+
+def test_git_push_attempt_and_result_are_structured(monkeypatch):
+ events = []
+
+ class _Client:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_args):
+ return None
+
+ def log_activity(self, **event):
+ events.append(event)
+
+ monkeypatch.setattr(hooks, "HypercolabClient", _Client)
+ before = {
+ "hook_event_name": "PreToolUse",
+ "tool_name": "Bash",
+ "tool_input": {"command": "git push origin colab"},
+ }
+ after = {
+ **before,
+ "hook_event_name": "PostToolUse",
+ "tool_response": {"exit_code": 0, "output": "raw output must not be logged"},
+ }
+
+ assert evaluate_client_hook(before) == {}
+ assert evaluate_client_hook(after) == {}
+ assert [event["kind"] for event in events] == ["git.push_attempted", "git.push_completed"]
+ assert all("output" not in event["metadata"] for event in events)
diff --git a/packages/hypercolab-cli/tests/test_offline.py b/packages/hypercolab-cli/tests/test_offline.py
new file mode 100644
index 0000000..1f98ca6
--- /dev/null
+++ b/packages/hypercolab-cli/tests/test_offline.py
@@ -0,0 +1,71 @@
+from __future__ import annotations
+
+import json
+import time
+
+from hypercolab_cli import config, hooks
+from hypercolab_cli.config import operations_have_live_cached_leases, remember_check, remember_repository
+
+
+def _configure_paths(monkeypatch, tmp_path):
+ monkeypatch.setattr(config, "CONFIG_DIR", tmp_path)
+ monkeypatch.setattr(config, "CONFIG_FILE", tmp_path / "config.json")
+ monkeypatch.setattr(config, "STATE_FILE", tmp_path / "state.json")
+ monkeypatch.setattr(config, "QUEUE_FILE", tmp_path / "timeline-queue.jsonl")
+ monkeypatch.setattr(hooks, "QUEUE_FILE", tmp_path / "timeline-queue.jsonl")
+
+
+def test_live_cached_lease_is_honored_only_until_expiry(monkeypatch, tmp_path):
+ _configure_paths(monkeypatch, tmp_path)
+ remote = "git@github.com:runstack-ai/example.git"
+ operation = {"path": "src/auth/token.py", "operation": "modify"}
+ remember_repository(remote, project={"project_id": "p1"}, session={"session_id": "s1"})
+ remember_check(
+ remote,
+ [operation],
+ {
+ "decisions": [
+ {
+ **operation,
+ "decision": "allow",
+ "claim_id": "c1",
+ "lease_expires_at": time.time() + 60,
+ }
+ ]
+ },
+ )
+
+ assert operations_have_live_cached_leases(remote, [operation])
+ assert not operations_have_live_cached_leases(remote, [{"path": "src/billing/invoice.py", "operation": "modify"}])
+
+
+def test_git_queue_retries_and_removes_delivered_events(monkeypatch, tmp_path):
+ _configure_paths(monkeypatch, tmp_path)
+ queued = {
+ "git": {
+ "root": str(tmp_path),
+ "remote": "git@github.com:runstack-ai/example.git",
+ "branch": "colab",
+ "worktree": str(tmp_path),
+ },
+ "kind": "git.post_commit",
+ "summary": "Git post commit on colab",
+ "source": "git",
+ "paths": ["src/app.py"],
+ "commit_refs": ["abc"],
+ "metadata": {"branch": "colab"},
+ }
+ hooks.queue_event(queued)
+
+ class _Client:
+ def __init__(self):
+ self.events = []
+
+ def log_activity(self, **event):
+ self.events.append(event)
+
+ client = _Client()
+ assert hooks.flush_queued_events(client) == 1
+ assert len(client.events) == 1
+ assert json.loads(json.dumps(client.events[0]["git"].to_dict()))["branch"] == "colab"
+ assert hooks.QUEUE_FILE.read_text(encoding="utf-8") == ""
diff --git a/plugins/hypercolab/.codex-plugin/plugin.json b/plugins/hypercolab/.codex-plugin/plugin.json
new file mode 100644
index 0000000..663c291
--- /dev/null
+++ b/plugins/hypercolab/.codex-plugin/plugin.json
@@ -0,0 +1,35 @@
+{
+ "name": "hypercolab",
+ "version": "0.1.0",
+ "description": "Coordinate developers and coding agents with project-scoped memory, timelines, work claims, and collision protection.",
+ "author": {
+ "name": "HyperMemory AI",
+ "email": "hello@runstack.ai",
+ "url": "https://hypermemory.io"
+ },
+ "homepage": "https://hypermemory.io",
+ "repository": "https://github.com/hypermemory-ai/hm-plugins-openai",
+ "license": "MIT",
+ "keywords": ["hypermemory", "collaboration", "coding-agents", "mcp", "git"],
+ "skills": "./skills/",
+ "mcpServers": "./.mcp.json",
+ "interface": {
+ "displayName": "HyperColab",
+ "shortDescription": "Keep coding agents coordinated across a project",
+ "longDescription": "Give every developer and coding agent the same project graph, chronological development timeline, active work ownership, and deterministic file-level collision protection.",
+ "developerName": "HyperMemory AI",
+ "category": "Developer Tools",
+ "capabilities": ["Interactive", "Write"],
+ "websiteURL": "https://hypermemory.io",
+ "privacyPolicyURL": "https://hypermemory.io/privacy",
+ "termsOfServiceURL": "https://hypermemory.io/terms",
+ "defaultPrompt": [
+ "Join this Hypercolab project and show what everyone is working on",
+ "Claim the files needed for this task before editing",
+ "Summarize recent project decisions and development activity"
+ ],
+ "brandColor": "#2875E5",
+ "composerIcon": "./assets/icon.png",
+ "logo": "./assets/logo.png"
+ }
+}
diff --git a/plugins/hypercolab/.mcp.json b/plugins/hypercolab/.mcp.json
new file mode 100644
index 0000000..18972c6
--- /dev/null
+++ b/plugins/hypercolab/.mcp.json
@@ -0,0 +1,8 @@
+{
+ "mcpServers": {
+ "hypercolab": {
+ "command": "hypercolab",
+ "args": ["mcp"]
+ }
+ }
+}
diff --git a/plugins/hypercolab/README.md b/plugins/hypercolab/README.md
new file mode 100644
index 0000000..5facab9
--- /dev/null
+++ b/plugins/hypercolab/README.md
@@ -0,0 +1,36 @@
+# HyperColab for ChatGPT and Codex
+
+HyperColab coordinates developers and coding agents around the Git repository
+they are actually working in. It combines a project graph, chronological
+development timeline, active ownership, path claims, and file-level collision
+protection.
+
+## Included
+
+- `.mcp.json` registers the local `hypercolab mcp` stdio shim.
+- `skills/hypercolab/` defines join, sync, claim, update, and finish behavior.
+- `hooks/hooks.json` loads project context, checks write ownership, and records
+ structured development activity after explicit Codex trust.
+- `scripts/hypercolab_hook.py` provides a safe launcher when the CLI is absent.
+- `agents/coordination-writer.md` defines the bounded timeline-writer role used
+ by the skill.
+
+## Install
+
+```bash
+pipx install "git+https://github.com/hypermemory-ai/hm-plugins-openai.git#subdirectory=packages/hypercolab-cli"
+hypercolab login
+codex plugin marketplace add hypermemory-ai/hm-plugins-openai
+codex plugin add hypercolab@hypermemory-ai
+```
+
+Start a new task and review the plugin hooks with `/hooks`. HyperMemory's
+personal memory MCP remains a separate plugin and can be installed alongside
+HyperColab.
+
+## Data boundary
+
+HyperColab sends structured summaries, affected paths, commit identifiers,
+claims, and visible rationale to the project service. It does not send raw
+source, raw diffs, transcripts, complete shell output, or hidden reasoning by
+default.
diff --git a/plugins/hypercolab/agents/coordination-writer.md b/plugins/hypercolab/agents/coordination-writer.md
new file mode 100644
index 0000000..0114dab
--- /dev/null
+++ b/plugins/hypercolab/agents/coordination-writer.md
@@ -0,0 +1,21 @@
+---
+name: coordination-writer
+description: Bounded HyperColab project-timeline role for progress, decisions, tests, and handoffs.
+---
+
+# Coordination writer
+
+This is a packaged role contract, not a user-facing skill. The HyperColab skill
+may ask the host to spawn one awaited sub-agent when recording project activity
+would distract the main implementation agent.
+
+1. Sync active project context before writing an event.
+2. Record only the visible summary, paths, status, tests, commits, and rationale
+ supplied by the parent.
+3. Use update for progress or blockers and activity logging for decisions,
+ discoveries, tests, commits, and releases.
+4. Finish or release work only when the parent explicitly authorizes it.
+5. Return a brief status and never spawn another agent.
+
+Never bypass a path conflict or send credentials, hidden reasoning, raw source,
+raw diffs, transcripts, or complete command output.
diff --git a/plugins/hypercolab/assets/icon.png b/plugins/hypercolab/assets/icon.png
new file mode 100644
index 0000000..b7d5462
Binary files /dev/null and b/plugins/hypercolab/assets/icon.png differ
diff --git a/plugins/hypercolab/assets/logo.png b/plugins/hypercolab/assets/logo.png
new file mode 100644
index 0000000..b7d5462
Binary files /dev/null and b/plugins/hypercolab/assets/logo.png differ
diff --git a/plugins/hypercolab/hooks/hooks.json b/plugins/hypercolab/hooks/hooks.json
new file mode 100644
index 0000000..53ef006
--- /dev/null
+++ b/plugins/hypercolab/hooks/hooks.json
@@ -0,0 +1,53 @@
+{
+ "description": "Load Hypercolab context, protect claimed paths, and record structured development activity.",
+ "hooks": {
+ "SessionStart": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "python3 ${PLUGIN_ROOT}/scripts/hypercolab_hook.py",
+ "statusMessage": "Loading Hypercolab project context",
+ "additionalContextLimit": 12000
+ }
+ ]
+ }
+ ],
+ "PreToolUse": [
+ {
+ "matcher": "Bash|exec_command|apply_patch|Edit|Write",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "python3 ${PLUGIN_ROOT}/scripts/hypercolab_hook.py",
+ "statusMessage": "Checking Hypercolab ownership",
+ "timeout": 30
+ }
+ ]
+ }
+ ],
+ "PostToolUse": [
+ {
+ "matcher": "Bash|exec_command|apply_patch|Edit|Write",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "python3 ${PLUGIN_ROOT}/scripts/hypercolab_hook.py",
+ "timeout": 10
+ }
+ ]
+ }
+ ],
+ "Stop": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "python3 ${PLUGIN_ROOT}/scripts/hypercolab_hook.py",
+ "timeout": 10
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/plugins/hypercolab/scripts/hypercolab_hook.py b/plugins/hypercolab/scripts/hypercolab_hook.py
new file mode 100755
index 0000000..78f67f3
--- /dev/null
+++ b/plugins/hypercolab/scripts/hypercolab_hook.py
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+"""Bridge Codex lifecycle events to the installed HyperColab CLI.
+
+The plugin keeps the MCP shim and hook implementation in one CLI package. This
+launcher makes hook behavior graceful when the CLI has not been installed yet:
+session start explains the missing prerequisite, while write operations remain
+unblocked rather than making an unconfigured repository unusable.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+from typing import Any
+
+
+def _payload(raw: bytes) -> dict[str, Any]:
+ try:
+ value = json.loads(raw or b"{}")
+ except json.JSONDecodeError:
+ return {}
+ return value if isinstance(value, dict) else {}
+
+
+def _binary() -> str | None:
+ configured = os.environ.get("HYPERCOLAB_BIN")
+ if configured:
+ candidate = Path(configured).expanduser()
+ if candidate.is_file():
+ return str(candidate)
+ return shutil.which("hypercolab")
+
+
+def _missing_cli_context(payload: dict[str, Any]) -> None:
+ event = str(payload.get("hook_event_name") or payload.get("hookEventName") or "")
+ if event != "SessionStart":
+ return
+ print(
+ json.dumps(
+ {
+ "hookSpecificOutput": {
+ "hookEventName": "SessionStart",
+ "additionalContext": (
+ "HyperColab is installed, but its local CLI/MCP shim is missing. "
+ "Install the bundled package from this marketplace repository, run "
+ "`hypercolab login`, and start a new task before relying on project "
+ "claims or timeline coordination."
+ ),
+ }
+ }
+ )
+ )
+
+
+def main() -> int:
+ raw = sys.stdin.buffer.read()
+ binary = _binary()
+ if binary is None:
+ _missing_cli_context(_payload(raw))
+ print("HyperColab CLI not found; lifecycle coordination is inactive.", file=sys.stderr)
+ return 0
+ completed = subprocess.run([binary, "hook"], input=raw, check=False)
+ return completed.returncode
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/plugins/hypercolab/skills/hypercolab/SKILL.md b/plugins/hypercolab/skills/hypercolab/SKILL.md
new file mode 100644
index 0000000..60fd96a
--- /dev/null
+++ b/plugins/hypercolab/skills/hypercolab/SKILL.md
@@ -0,0 +1,28 @@
+---
+name: hypercolab
+description: Coordinate work in a Hypercolab-enabled Git repository. Join and sync before planning, claim paths before edits, respect other agents' ownership, and record meaningful project activity.
+---
+
+# Hypercolab
+
+Use this skill whenever the current Git repository resolves to a Hypercolab project.
+
+1. Call `colab_join` at the start of a coding session, then read the returned brief.
+2. Call `colab_sync` before accepting or decomposing work.
+3. Call `colab_claim` for planned directories or file sets. File edits may auto-claim a free file through the hook.
+4. Treat `do_not_touch` paths as blocked. Coordinate or request a handoff instead of bypassing a conflict.
+5. Call `colab_update` when the plan, rationale, scope, status, or blocker changes.
+6. Use `colab_log_activity` for architectural choices, rejected alternatives, discoveries, test results, or release activity.
+7. Call `colab_finish` before stopping completed, released, abandoned, or handed-off work.
+
+For work where timeline maintenance would distract the implementation agent,
+delegate one bounded coordination writer and wait for it. Follow
+[references/coordination-agent.md](references/coordination-agent.md). Keep
+`colab_join`, `colab_sync`, and path claims on the main agent because their
+results directly affect planning and write safety.
+
+Never put hidden reasoning, secrets, raw source contents, or full command output in timeline summaries.
+
+If the `hypercolab` executable is missing, tell the developer to install the
+bundled CLI/MCP shim from the marketplace repository and run `hypercolab login`.
+Repositories that are not registered with HyperColab remain unaffected.
diff --git a/plugins/hypercolab/skills/hypercolab/agents/openai.yaml b/plugins/hypercolab/skills/hypercolab/agents/openai.yaml
new file mode 100644
index 0000000..4aab1ab
--- /dev/null
+++ b/plugins/hypercolab/skills/hypercolab/agents/openai.yaml
@@ -0,0 +1,14 @@
+interface:
+ display_name: "HyperColab"
+ short_description: "Coordinate coding work across one repository"
+ default_prompt: "Use $hypercolab to join this project, sync active work, and claim the files needed for my task."
+
+dependencies:
+ tools:
+ - type: "mcp"
+ value: "hypercolab"
+ description: "Local project-aware HyperColab MCP shim"
+ transport: "stdio"
+
+policy:
+ allow_implicit_invocation: true
diff --git a/plugins/hypercolab/skills/hypercolab/references/coordination-agent.md b/plugins/hypercolab/skills/hypercolab/references/coordination-agent.md
new file mode 100644
index 0000000..c685300
--- /dev/null
+++ b/plugins/hypercolab/skills/hypercolab/references/coordination-agent.md
@@ -0,0 +1,19 @@
+# Coordination agent contract
+
+Use one bounded coordination sub-agent when project activity must be recorded
+without distracting the main implementation agent. The parent supplies the
+visible plan, affected repository-relative paths, current status, test results,
+and any public rationale summary.
+
+The coordination agent:
+
+1. Calls `colab_sync` to refresh active work and ownership.
+2. Uses `colab_update` for material progress, scope, status, or blocker changes.
+3. Uses `colab_log_activity` for decisions, discoveries, test outcomes, commits,
+ or release activity that belongs in the project timeline.
+4. Calls `colab_finish` only when the parent explicitly says the work is
+ completed, released, abandoned, or handed off.
+5. Returns a short status and never delegates again.
+
+It never sends secrets, hidden reasoning, raw source, raw diffs, transcripts,
+or complete command output to HyperColab.
diff --git a/plugins/hypermemory/.codex-plugin/plugin.json b/plugins/hypermemory/.codex-plugin/plugin.json
new file mode 100644
index 0000000..c2188f1
--- /dev/null
+++ b/plugins/hypermemory/.codex-plugin/plugin.json
@@ -0,0 +1,47 @@
+{
+ "name": "hypermemory",
+ "version": "2.1.0",
+ "description": "Always-on, relationship-aware memory for ChatGPT and Codex with OAuth MCP access, delegated persistence, and token telemetry.",
+ "author": {
+ "name": "HyperMemory AI",
+ "email": "hello@runstack.ai",
+ "url": "https://hypermemory.io"
+ },
+ "homepage": "https://hypermemory.io",
+ "repository": "https://github.com/hypermemory-ai/hm-plugins-openai",
+ "license": "MIT",
+ "keywords": [
+ "memory",
+ "knowledge-graph",
+ "mcp",
+ "oauth",
+ "chatgpt",
+ "codex"
+ ],
+ "skills": "./skills/",
+ "mcpServers": "./.mcp.json",
+ "interface": {
+ "displayName": "HyperMemory",
+ "shortDescription": "Durable memory for every ChatGPT and Codex turn",
+ "longDescription": "Recall relevant context at the start of work, then delegate durable memory writes, timeline updates, and honest token telemetry to a memory-writer sub-agent. Codex adds lifecycle hooks and exact local session-token deltas; ChatGPT uses uncertainty-labelled estimates.",
+ "developerName": "HyperMemory AI",
+ "category": "Memory & Knowledge",
+ "capabilities": [
+ "Read",
+ "Write",
+ "OAuth",
+ "Subagents"
+ ],
+ "websiteURL": "https://hypermemory.io",
+ "privacyPolicyURL": "https://hypermemory.io/privacy",
+ "termsOfServiceURL": "https://hypermemory.io/terms",
+ "defaultPrompt": [
+ "Recall the context for this work and keep memory current.",
+ "What do you remember about this project?",
+ "Remember this decision and connect it to the project."
+ ],
+ "brandColor": "#2875E5",
+ "composerIcon": "./assets/icon.png",
+ "logo": "./assets/logo.png"
+ }
+}
diff --git a/plugins/hypermemory/.mcp.json b/plugins/hypermemory/.mcp.json
new file mode 100644
index 0000000..ff592e5
--- /dev/null
+++ b/plugins/hypermemory/.mcp.json
@@ -0,0 +1,8 @@
+{
+ "mcpServers": {
+ "hypermemory": {
+ "type": "http",
+ "url": "https://stage.hypermemory.io/mcp"
+ }
+ }
+}
diff --git a/plugins/hypermemory/README.md b/plugins/hypermemory/README.md
new file mode 100644
index 0000000..cdf3c68
--- /dev/null
+++ b/plugins/hypermemory/README.md
@@ -0,0 +1,50 @@
+# HyperMemory plugin for ChatGPT and Codex
+
+This universal plugin bundles one ChatGPT/Codex skill, the OAuth-protected
+HyperMemory MCP server, and Codex lifecycle enforcement. The shared skill has
+separate token-reporting branches for ChatGPT and Codex.
+
+## Behavior
+
+- Main agent: overview and recall so memory can inform the response.
+- Memory-writer sub-agent: store, update, forget, timeline write, and one token
+ report before the final response.
+- Codex: trusted hooks enforce the lifecycle and read exact cumulative token
+ counters from the active rollout JSONL using a two-phase inspect/ack helper.
+- ChatGPT: reports an uncertainty-labelled workload estimate because consumer
+ ChatGPT does not expose a stable local exact-usage file to plugins.
+
+The token listener parses only `token_count` records. It does not return or
+upload prompts, model responses, tool arguments, or tool results.
+
+Codex cannot observe tokens produced after the final tool call of a turn. The
+listener carries that exact tail into the next successful report. If a session
+never receives another turn, its final tail remains unreported; the plugin does
+not falsely label a guess as client-exact.
+
+## Install from the public Git marketplace
+
+Install directly from GitHub:
+
+```bash
+codex plugin marketplace add hypermemory-ai/hm-plugins-openai
+codex plugin add hypermemory@hypermemory-ai
+```
+
+Restart the ChatGPT desktop app or start a new Codex session. Complete the
+OAuth sign-in when prompted. In Codex CLI, open `/hooks`, review the bundled
+hook definition, and trust it; Codex does not run non-managed plugin hooks until
+the user explicitly trusts their current hash.
+
+ChatGPT web local testing requires registering
+`https://stage.hypermemory.io/mcp` in developer mode. A public directory
+submission should use the **With MCP** flow and submit this MCP server directly;
+it does not require a checked-in `.app.json`.
+
+## Validate
+
+```bash
+python3 ~/.codex/skills/.system/plugin-creator/scripts/validate_plugin.py plugins/hypermemory
+python3 ~/.codex/skills/.system/skill-creator/scripts/quick_validate.py plugins/hypermemory/skills/hypermemory
+pytest -q tests/test_hypermemory_plugin.py
+```
diff --git a/plugins/hypermemory/agents/memory-writer.md b/plugins/hypermemory/agents/memory-writer.md
new file mode 100644
index 0000000..2ef1890
--- /dev/null
+++ b/plugins/hypermemory/agents/memory-writer.md
@@ -0,0 +1,22 @@
+---
+name: memory-writer
+description: Bounded HyperMemory persistence, timeline, and token-reporting role used after every turn.
+---
+
+# Memory writer
+
+This is a packaged role contract, not a user-facing skill. The HyperMemory
+skill asks the host to spawn one awaited sub-agent with this role after the main
+agent has completed the requested work.
+
+1. Recall related nodes before changing the graph.
+2. Store durable new knowledge or update the existing canonical node.
+3. Give every new node at least one specific relationship.
+4. Write exactly one concise timeline entry.
+5. Report token usage exactly once. Use the Codex listener's exact payload when
+ available; otherwise use an honest estimate with uncertainty.
+6. Acknowledge a Codex listener claim only after the MCP accepts the report.
+7. Return a brief status to the parent and never spawn another agent.
+
+Never store credentials, hidden reasoning, raw transcripts, complete command
+output, tool payloads, or large code bodies.
diff --git a/plugins/hypermemory/assets/icon.png b/plugins/hypermemory/assets/icon.png
new file mode 100644
index 0000000..d512a1a
Binary files /dev/null and b/plugins/hypermemory/assets/icon.png differ
diff --git a/plugins/hypermemory/assets/logo.png b/plugins/hypermemory/assets/logo.png
new file mode 100644
index 0000000..d512a1a
Binary files /dev/null and b/plugins/hypermemory/assets/logo.png differ
diff --git a/plugins/hypermemory/hooks/hooks.json b/plugins/hypermemory/hooks/hooks.json
new file mode 100644
index 0000000..8b4f3e4
--- /dev/null
+++ b/plugins/hypermemory/hooks/hooks.json
@@ -0,0 +1,44 @@
+{
+ "description": "Enforce HyperMemory recall and delegated end-of-turn persistence in Codex.",
+ "hooks": {
+ "SessionStart": [
+ {
+ "matcher": "startup|resume|clear|compact",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "python3 ${PLUGIN_ROOT}/scripts/hypermemory_hook.py session-start",
+ "statusMessage": "Preparing HyperMemory",
+ "timeout": 10,
+ "additionalContextLimit": 6000
+ }
+ ]
+ }
+ ],
+ "UserPromptSubmit": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "python3 ${PLUGIN_ROOT}/scripts/hypermemory_hook.py user-prompt",
+ "statusMessage": "Recalling HyperMemory context",
+ "timeout": 10,
+ "additionalContextLimit": 6000
+ }
+ ]
+ }
+ ],
+ "Stop": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "python3 ${PLUGIN_ROOT}/scripts/hypermemory_hook.py stop",
+ "statusMessage": "Delegating HyperMemory finalization",
+ "timeout": 10
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/plugins/hypermemory/scripts/codex_token_listener.py b/plugins/hypermemory/scripts/codex_token_listener.py
new file mode 100755
index 0000000..a802ec8
--- /dev/null
+++ b/plugins/hypermemory/scripts/codex_token_listener.py
@@ -0,0 +1,360 @@
+#!/usr/bin/env python3
+"""Read exact Codex token counters without reading or uploading chat content.
+
+The memory-writer sub-agent uses a two-phase inspect/ack protocol. Inspect
+returns a delta and a ready-to-submit hm_tokens payload. Ack checkpoints the
+cumulative counter only after the MCP submission succeeds, so failures are
+retried without losing usage.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+import time
+from collections.abc import Iterator
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Any
+
+COUNTER_FIELDS = (
+ "input_tokens",
+ "cached_input_tokens",
+ "cache_write_input_tokens",
+ "output_tokens",
+ "reasoning_output_tokens",
+ "total_tokens",
+)
+ZERO_COUNTERS = {field: 0 for field in COUNTER_FIELDS}
+
+
+def _read_json(path: Path) -> dict[str, Any]:
+ try:
+ value = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as exc:
+ raise RuntimeError(f"cannot read JSON {path}: {exc}") from exc
+ if not isinstance(value, dict):
+ raise TypeError(f"JSON root must be an object: {path}")
+ return value
+
+
+def _atomic_json(path: Path, payload: dict[str, Any]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = path.with_suffix(path.suffix + f".{os.getpid()}.tmp")
+ with temporary.open("w", encoding="utf-8") as handle:
+ json.dump(payload, handle, indent=2, sort_keys=True)
+ handle.write("\n")
+ os.chmod(temporary, 0o600)
+ os.replace(temporary, path)
+
+
+@contextmanager
+def _state_lock(state_file: Path, timeout: float = 5.0) -> Iterator[None]:
+ """Serialize state updates across concurrent Codex sessions/sub-agents."""
+ lock_path = state_file.with_suffix(state_file.suffix + ".lock")
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
+ deadline = time.monotonic() + timeout
+ descriptor: int | None = None
+ while descriptor is None:
+ try:
+ descriptor = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
+ os.write(descriptor, f"{os.getpid()}\n".encode())
+ except FileExistsError:
+ try:
+ stale = time.time() - lock_path.stat().st_mtime > 30
+ if stale:
+ lock_path.unlink(missing_ok=True)
+ continue
+ except FileNotFoundError:
+ continue
+ if time.monotonic() >= deadline:
+ raise RuntimeError(
+ f"timed out waiting for token state lock: {lock_path}"
+ ) from None
+ time.sleep(0.05)
+ try:
+ yield
+ finally:
+ os.close(descriptor)
+ lock_path.unlink(missing_ok=True)
+
+
+def _state(path: Path) -> dict[str, Any]:
+ if not path.exists():
+ return {"version": 1, "sessions": {}}
+ value = _read_json(path)
+ if value.get("version") != 1 or not isinstance(value.get("sessions"), dict):
+ raise RuntimeError(f"unsupported token state format: {path}")
+ return value
+
+
+def _reverse_lines(path: Path, chunk_size: int = 65536) -> Iterator[str]:
+ with path.open("rb") as handle:
+ handle.seek(0, os.SEEK_END)
+ position = handle.tell()
+ remainder = b""
+ while position > 0:
+ size = min(chunk_size, position)
+ position -= size
+ handle.seek(position)
+ block = handle.read(size) + remainder
+ lines = block.split(b"\n")
+ remainder = lines[0]
+ for line in reversed(lines[1:]):
+ if line:
+ yield line.decode("utf-8", errors="replace")
+ if remainder:
+ yield remainder.decode("utf-8", errors="replace")
+
+
+def _latest_counter(path: Path) -> dict[str, Any] | None:
+ if not path.exists():
+ return None
+ for line in _reverse_lines(path):
+ if '"token_count"' not in line:
+ continue
+ try:
+ event = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ payload = event.get("payload") or {}
+ if payload.get("type") != "token_count":
+ continue
+ info = payload.get("info") or {}
+ usage = info.get("total_token_usage") or {}
+ if not usage:
+ continue
+ return {
+ "timestamp": str(event.get("timestamp") or ""),
+ "model": str(info.get("model") or ""),
+ "counters": {field: int(usage.get(field) or 0) for field in COUNTER_FIELDS},
+ }
+ return None
+
+
+def _rollout_files() -> list[Path]:
+ codex_home = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex"))
+ matches: list[Path] = []
+ for directory in (codex_home / "sessions", codex_home / "archived_sessions"):
+ if directory.exists():
+ matches.extend(directory.rglob("rollout-*.jsonl"))
+ return matches
+
+
+def _session_metadata(path: Path) -> tuple[str, str] | None:
+ """Return physical and logical session ids without reading chat content."""
+ try:
+ with path.open("r", encoding="utf-8", errors="replace") as handle:
+ for _ in range(32):
+ line = handle.readline()
+ if not line:
+ break
+ if '"session_meta"' not in line:
+ continue
+ try:
+ event = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ if event.get("type") != "session_meta":
+ continue
+ payload = event.get("payload") or {}
+ physical = str(payload.get("id") or path.stem)
+ logical = str(payload.get("session_id") or physical)
+ return physical, logical
+ except OSError:
+ return None
+ return None
+
+
+def _session_rollouts(job: dict[str, Any]) -> list[Path]:
+ session_id = str(job.get("session_id") or "")
+ explicit = job.get("transcript_path")
+ candidates = _rollout_files()
+ if explicit and Path(str(explicit)).is_file():
+ explicit_path = Path(str(explicit)).resolve()
+ if explicit_path not in candidates:
+ candidates.append(explicit_path)
+
+ matches: list[Path] = []
+ for path in candidates:
+ metadata = _session_metadata(path)
+ if metadata and session_id in metadata:
+ matches.append(path.resolve())
+ if not matches:
+ raise RuntimeError(f"no Codex rollout transcripts found for session {session_id!r}")
+ return sorted(set(matches))
+
+
+def _delta(current: dict[str, int], previous: dict[str, int]) -> dict[str, int]:
+ result = {field: current[field] - int(previous.get(field) or 0) for field in COUNTER_FIELDS}
+ if any(value < 0 for value in result.values()):
+ raise RuntimeError("Codex token counters moved backwards; create a new baseline")
+ return result
+
+
+def baseline(job_path: Path) -> int:
+ job = _read_json(job_path)
+ state_file = Path(str(job["state_file"]))
+ session_id = str(job.get("session_id") or "unknown-session")
+ rollouts: dict[str, dict[str, int]] = {}
+ transcript = job.get("transcript_path")
+ try:
+ paths = _session_rollouts(job)
+ except RuntimeError:
+ paths = [Path(str(transcript)).resolve()] if transcript and Path(str(transcript)).is_file() else []
+ for path in paths:
+ latest = _latest_counter(path)
+ rollouts[str(path)] = (latest or {"counters": ZERO_COUNTERS})["counters"]
+ with _state_lock(state_file):
+ state = _state(state_file)
+ existing = state["sessions"].get(session_id)
+ if existing:
+ print(json.dumps({"baselined": False, "reason": "session_already_tracked", "session_id": session_id}))
+ return 0
+ state["sessions"][session_id] = {
+ "rollouts": rollouts,
+ "turn_sequence": 0,
+ "transcript_path": transcript,
+ }
+ _atomic_json(state_file, state)
+ print(json.dumps({"baselined": True, "session_id": session_id}))
+ return 0
+
+
+def inspect(job_path: Path, wait_seconds: float) -> int:
+ job = _read_json(job_path)
+ state_file = Path(str(job["state_file"]))
+ session_id = str(job.get("session_id") or "unknown-session")
+ with _state_lock(state_file):
+ state = _state(state_file)
+ previous_entry = dict(state["sessions"].get(session_id) or {})
+ previous_rollouts = previous_entry.get("rollouts") or {}
+
+ deadline = time.monotonic() + max(0.0, wait_seconds)
+ paths = _session_rollouts(job)
+
+ def collect() -> tuple[dict[str, dict[str, int]], dict[str, int], str]:
+ current_rollouts: dict[str, dict[str, int]] = {}
+ combined = ZERO_COUNTERS.copy()
+ discovered_model = ""
+ for path in paths:
+ latest = _latest_counter(path)
+ current = (latest or {"counters": ZERO_COUNTERS})["counters"]
+ current_rollouts[str(path)] = current
+ physical_delta = _delta(current, previous_rollouts.get(str(path)) or ZERO_COUNTERS)
+ for field in COUNTER_FIELDS:
+ combined[field] += physical_delta[field]
+ if latest and latest.get("model"):
+ discovered_model = str(latest["model"])
+ return current_rollouts, combined, discovered_model
+
+ current_rollouts, delta, discovered_model = collect()
+ while delta["total_tokens"] == 0 and time.monotonic() < deadline:
+ time.sleep(0.1)
+ paths = _session_rollouts(job)
+ current_rollouts, delta, discovered_model = collect()
+
+ if delta["total_tokens"] == 0:
+ print(
+ json.dumps(
+ {
+ "exact_available": False,
+ "reason": "no_new_token_count_record",
+ "session_id": session_id,
+ "fallback_turn_sequence": int(previous_entry.get("turn_sequence") or 0) + 1,
+ "fallback": "Submit one self_estimated hm_tokens report with uncertainty.",
+ },
+ indent=2,
+ )
+ )
+ return 0
+
+ turn_sequence = int(previous_entry.get("turn_sequence") or 0) + 1
+ model = str(job.get("model") or discovered_model or "unknown")
+ report = {
+ "ai_tool": "codex",
+ "provider": "openai",
+ "model": model,
+ "session_id": session_id,
+ "turn_sequence": turn_sequence,
+ "measurement_quality": "client_exact",
+ "input_tokens": delta["input_tokens"],
+ "output_tokens": delta["output_tokens"],
+ "cache_tokens": delta["cached_input_tokens"],
+ "reasoning_tokens": delta["reasoning_output_tokens"],
+ "total_tokens": delta["total_tokens"],
+ "cost_quality": "unavailable",
+ "segments": [
+ {"category": "memory", "weight": 10},
+ {"category": "context", "weight": 90},
+ ],
+ }
+ claim = {
+ "version": 1,
+ "session_id": session_id,
+ "turn_id": job.get("turn_id"),
+ "rollouts": current_rollouts,
+ "turn_sequence": turn_sequence,
+ }
+ claim_path = job_path.with_suffix(job_path.suffix + ".claim.json")
+ _atomic_json(claim_path, claim)
+ print(
+ json.dumps(
+ {
+ "exact_available": True,
+ "privacy": "Only token_count counters were parsed; no conversation content is returned or uploaded.",
+ "claim_path": str(claim_path),
+ "hm_tokens_payload": report,
+ },
+ indent=2,
+ )
+ )
+ return 0
+
+
+def ack(job_path: Path) -> int:
+ job = _read_json(job_path)
+ claim_path = job_path.with_suffix(job_path.suffix + ".claim.json")
+ claim = _read_json(claim_path)
+ if claim.get("session_id") != job.get("session_id") or claim.get("turn_id") != job.get("turn_id"):
+ raise RuntimeError("token claim does not match its job")
+ state_file = Path(str(job["state_file"]))
+ session_id = str(claim["session_id"])
+ with _state_lock(state_file):
+ state = _state(state_file)
+ state["sessions"][session_id] = {
+ "rollouts": claim["rollouts"],
+ "turn_sequence": int(claim["turn_sequence"]),
+ }
+ _atomic_json(state_file, state)
+ claim_path.unlink(missing_ok=True)
+ job_path.unlink(missing_ok=True)
+ print(json.dumps({"acknowledged": True, "session_id": session_id}))
+ return 0
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(prog="codex-token-listener")
+ subparsers = parser.add_subparsers(dest="command", required=True)
+ for name in ("baseline", "ack"):
+ command = subparsers.add_parser(name)
+ command.add_argument("--job", type=Path, required=True)
+ inspect_parser = subparsers.add_parser("inspect")
+ inspect_parser.add_argument("--job", type=Path, required=True)
+ inspect_parser.add_argument("--wait-seconds", type=float, default=2.0)
+ args = parser.parse_args()
+ try:
+ if args.command == "baseline":
+ return baseline(args.job)
+ if args.command == "inspect":
+ return inspect(args.job, args.wait_seconds)
+ return ack(args.job)
+ except (KeyError, OSError, RuntimeError, TypeError, ValueError) as exc:
+ print(f"Error: {exc}", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/plugins/hypermemory/scripts/hypermemory_hook.py b/plugins/hypermemory/scripts/hypermemory_hook.py
new file mode 100755
index 0000000..16dda3f
--- /dev/null
+++ b/plugins/hypermemory/scripts/hypermemory_hook.py
@@ -0,0 +1,177 @@
+#!/usr/bin/env python3
+"""Codex lifecycle bridge for mandatory HyperMemory behavior.
+
+The hook injects recall instructions and turns end-of-turn persistence into a
+bounded sub-agent job. It does not call the MCP server or read conversation
+content from the transcript.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+import tempfile
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any
+
+
+def _read_input() -> dict[str, Any]:
+ try:
+ value = json.load(sys.stdin)
+ except json.JSONDecodeError as exc:
+ raise RuntimeError(f"invalid hook JSON: {exc}") from exc
+ if not isinstance(value, dict):
+ raise TypeError("hook input must be a JSON object")
+ return value
+
+
+def _plugin_root() -> Path:
+ configured = os.environ.get("PLUGIN_ROOT")
+ return Path(configured).resolve() if configured else Path(__file__).resolve().parents[1]
+
+
+def _plugin_data() -> Path:
+ configured = os.environ.get("PLUGIN_DATA")
+ path = (
+ Path(configured).resolve()
+ if configured
+ else Path(tempfile.gettempdir()) / "hypermemory-plugin-data"
+ )
+ path.mkdir(parents=True, exist_ok=True)
+ return path
+
+
+def _safe_id(value: object, fallback: str) -> str:
+ text = str(value or fallback)
+ return "".join(char if char.isalnum() or char in "-_" else "_" for char in text)[:160]
+
+
+def _atomic_json(path: Path, payload: dict[str, Any]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = path.with_suffix(path.suffix + f".{os.getpid()}.tmp")
+ with temporary.open("w", encoding="utf-8") as handle:
+ json.dump(payload, handle, indent=2, sort_keys=True)
+ handle.write("\n")
+ os.chmod(temporary, 0o600)
+ os.replace(temporary, path)
+
+
+def _context(event: str, text: str) -> None:
+ print(
+ json.dumps(
+ {
+ "hookSpecificOutput": {
+ "hookEventName": event,
+ "additionalContext": text,
+ }
+ }
+ )
+ )
+
+
+def session_start(payload: dict[str, Any]) -> int:
+ session_id = _safe_id(payload.get("session_id"), "unknown-session")
+ state_file = _plugin_data() / "token-state.json"
+ baseline = {
+ "version": 1,
+ "session_id": session_id,
+ "transcript_path": payload.get("transcript_path"),
+ "state_file": str(state_file),
+ }
+ baseline_path = _plugin_data() / "jobs" / f"baseline-{session_id}.json"
+ _atomic_json(baseline_path, baseline)
+
+ # Baseline synchronously so installation never uploads historical usage.
+ listener = _plugin_root() / "scripts" / "codex_token_listener.py"
+ if listener.exists():
+ import subprocess
+
+ subprocess.run(
+ [sys.executable, str(listener), "baseline", "--job", str(baseline_path)],
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=5,
+ )
+
+ _context(
+ "SessionStart",
+ "HyperMemory is mandatory for this session. Before the first response, "
+ "call hm_get_overview and hm_recall. On every turn recall on the main "
+ "agent, then delegate store/update/forget, one timeline entry, and one "
+ "token report to a memory-writer sub-agent before returning the final response.",
+ )
+ return 0
+
+
+def user_prompt(_: dict[str, Any]) -> int:
+ _context(
+ "UserPromptSubmit",
+ "For this turn, silently call HyperMemory hm_recall before substantive "
+ "work. Keep graph writes, hm_timeline_write, and hm_tokens off the main "
+ "agent: a memory-writer sub-agent must perform them during finalization.",
+ )
+ return 0
+
+
+def stop(payload: dict[str, Any]) -> int:
+ if bool(payload.get("stop_hook_active")):
+ print(json.dumps({"continue": True}))
+ return 0
+
+ session_id = _safe_id(payload.get("session_id"), "unknown-session")
+ turn_id = _safe_id(payload.get("turn_id"), "unknown-turn")
+ data_dir = _plugin_data()
+ job_path = data_dir / "jobs" / f"turn-{session_id}-{turn_id}.json"
+ job = {
+ "version": 1,
+ "created_at": datetime.now(UTC).isoformat(),
+ "session_id": str(payload.get("session_id") or session_id),
+ "turn_id": str(payload.get("turn_id") or turn_id),
+ "transcript_path": payload.get("transcript_path"),
+ "model": str(payload.get("model") or "unknown"),
+ "state_file": str(data_dir / "token-state.json"),
+ }
+ _atomic_json(job_path, job)
+
+ listener = _plugin_root() / "scripts" / "codex_token_listener.py"
+ reason = (
+ "Mandatory HyperMemory finalization before showing the response. Spawn "
+ "exactly one memory-writer sub-agent and wait for it. Do not perform "
+ "graph writes or token reporting on the main agent. Give the sub-agent "
+ "a concise summary of this turn. It must recall first; store or update "
+ "durable knowledge with specific relationships; write exactly one "
+ "hm_timeline_write entry; run the local token listener; call hm_tokens "
+ "exactly once; and acknowledge the listener only after hm_tokens "
+ "succeeds. Pass these exact local paths:\n"
+ f"listener={listener}\njob={job_path}\n"
+ "Listener sequence: python3 inspect --job ; call MCP "
+ "hm_tokens with hm_tokens_payload from stdout; then python3 "
+ "ack --job . If exact inspection is unavailable, the sub-agent "
+ "must send one honest self_estimated report instead."
+ )
+ print(json.dumps({"decision": "block", "reason": reason}))
+ return 0
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("event", choices=("session-start", "user-prompt", "stop"))
+ args = parser.parse_args()
+ try:
+ payload = _read_input()
+ if args.event == "session-start":
+ return session_start(payload)
+ if args.event == "user-prompt":
+ return user_prompt(payload)
+ return stop(payload)
+ except (OSError, RuntimeError, TypeError) as exc:
+ print(f"HyperMemory hook error: {exc}", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/plugins/hypermemory/skills/hypermemory/SKILL.md b/plugins/hypermemory/skills/hypermemory/SKILL.md
new file mode 100644
index 0000000..33ec351
--- /dev/null
+++ b/plugins/hypermemory/skills/hypermemory/SKILL.md
@@ -0,0 +1,106 @@
+---
+name: hypermemory
+description: >-
+ Use HyperMemory as mandatory durable memory in every ChatGPT and Codex chat,
+ task, session, and turn whenever the HyperMemory MCP tools are available.
+ Recall context before responding, then delegate graph writes, updates,
+ timeline logging, and token reporting to a memory-writer sub-agent. Apply to
+ every prompt, including short, conversational, planning, coding, blocked,
+ and no-change turns.
+---
+
+# HyperMemory
+
+Keep recall on the main agent because it informs the answer. Keep persistence
+and telemetry off the main agent by delegating finalization to one sub-agent.
+Routine memory operations are silent.
+
+## Start and recall
+
+On the first response in a conversation:
+
+1. Call `hm_get_overview`.
+2. Call `hm_recall` with terms from the user's request.
+3. Hydrate exact relevant keys with `hm_get_nodes` when full details or
+ relationships are needed.
+
+On every later user turn, call `hm_recall` before substantive work. Use recalled
+information naturally and never re-ask for facts already in memory.
+
+## Delegate finalization
+
+Before the final response, spawn exactly one memory-writer sub-agent and wait
+for it. Pass a concise summary of the user's request, material actions,
+decisions, corrections, durable facts, and relevant project or component keys.
+The main agent must not call `hm_store`, `hm_update`, `hm_forget`,
+`hm_timeline_write`, or `hm_tokens` when delegation is available.
+
+Use the bounded role contract in
+[references/memory-writer-agent.md](references/memory-writer-agent.md) when
+constructing the delegated task.
+
+Tell the memory-writer sub-agent to:
+
+1. Call `hm_recall` before any write.
+2. Use `hm_store` for new durable knowledge and `hm_update` for changed
+ knowledge. Avoid duplicates and trivial or transient facts.
+3. Give every new node at least one specific relationship.
+4. Call `hm_timeline_write` exactly once with a concise turn record.
+5. Call `hm_tokens` exactly once.
+
+When operating as the memory-writer sub-agent, execute these finalization steps
+directly and do not spawn another sub-agent. This rule prevents recursive
+delegation.
+
+If the host cannot spawn sub-agents, perform persistence directly as an
+explicit degraded fallback so memory is not silently lost. Mention degraded
+mode only when the user asks about memory operation.
+
+## Codex token reporting
+
+When a Codex lifecycle hook supplies a token-listener job path, the
+memory-writer sub-agent must:
+
+1. Run `codex_token_listener.py inspect --job `.
+2. Submit the returned `hm_tokens_payload` exactly once through MCP.
+3. Run `codex_token_listener.py ack --job ` only after the MCP call
+ succeeds.
+
+The listener reads only `session_meta` and `token_count` records from the
+logical Codex session's parent and sub-agent rollout JSONL files. It never
+returns or uploads prompts, responses, tool arguments, or tool results. It
+reports cumulative-counter deltas as `client_exact`. Tokens written after
+inspection roll into the next successful delta rather than being discarded.
+
+If exact local telemetry is unavailable, submit one honest `self_estimated`
+report with uncertainty and no invented cost. Use the listener's
+`fallback_turn_sequence` and do not run `ack` because no exact claim exists.
+
+No in-turn observer can count tokens generated after its last tool call. The
+listener therefore preserves the unreported tail and includes it in the next
+successful Codex delta. A session with no later turn can retain a final tail;
+never mislabel an estimate as exact to hide this host limitation.
+
+## ChatGPT token reporting
+
+Consumer ChatGPT does not expose a stable, client-exact per-turn usage file to
+plugins. The memory-writer sub-agent must estimate the complete workload across
+model invocations, including hidden context and tool continuations. Use
+`measurement_quality: self_estimated`, normally
+`uncertainty_percentage: 40`, an honest `estimation_bias` (prefer `high` for
+tool-heavy turns), and `cost_quality: unavailable`. Never claim
+provider-actual usage or cost.
+
+## Canonical segments
+
+Use exactly these activity segments because the Rust MCP canonicalizes them:
+
+```json
+[
+ {"category": "memory", "weight": 10},
+ {"category": "context", "weight": 90}
+]
+```
+
+Read [references/protocol.md](references/protocol.md) when storing nodes,
+creating relationships, ingesting dense text, or cleaning graph orphans.
diff --git a/plugins/hypermemory/skills/hypermemory/agents/openai.yaml b/plugins/hypermemory/skills/hypermemory/agents/openai.yaml
new file mode 100644
index 0000000..2b7c4b2
--- /dev/null
+++ b/plugins/hypermemory/skills/hypermemory/agents/openai.yaml
@@ -0,0 +1,15 @@
+interface:
+ display_name: "HyperMemory"
+ short_description: "Always-on memory for ChatGPT and Codex"
+ default_prompt: "Use $hypermemory to recall context and maintain memory for this turn."
+
+dependencies:
+ tools:
+ - type: "mcp"
+ value: "hypermemory"
+ description: "OAuth-protected HyperMemory graph and token telemetry"
+ transport: "streamable_http"
+ url: "https://stage.hypermemory.io/mcp"
+
+policy:
+ allow_implicit_invocation: true
diff --git a/plugins/hypermemory/skills/hypermemory/references/memory-writer-agent.md b/plugins/hypermemory/skills/hypermemory/references/memory-writer-agent.md
new file mode 100644
index 0000000..af22af7
--- /dev/null
+++ b/plugins/hypermemory/skills/hypermemory/references/memory-writer-agent.md
@@ -0,0 +1,24 @@
+# Memory-writer agent contract
+
+The parent agent delegates exactly one bounded finalization task and waits for
+it before returning the user-facing response. The delegated agent must not
+delegate again.
+
+The parent supplies a concise turn summary, relevant project or component keys,
+the host name, model, session identifier, and token-listener job paths when
+Codex exposes them.
+
+The memory-writer agent then:
+
+1. Recalls related nodes before changing the graph.
+2. Stores only durable new knowledge and updates existing nodes instead of
+ duplicating them.
+3. Gives every new node at least one specific relationship.
+4. Writes exactly one concise timeline entry.
+5. Reports tokens exactly once, using the Codex listener payload when available
+ and an uncertainty-labelled estimate otherwise.
+6. Acknowledges a Codex listener claim only after the MCP accepted the report.
+
+It never stores credentials, hidden reasoning, raw transcripts, full tool
+output, or large code bodies. Its return value is a short operational status
+for the parent agent.
diff --git a/plugins/hypermemory/skills/hypermemory/references/protocol.md b/plugins/hypermemory/skills/hypermemory/references/protocol.md
new file mode 100644
index 0000000..81450d9
--- /dev/null
+++ b/plugins/hypermemory/skills/hypermemory/references/protocol.md
@@ -0,0 +1,15 @@
+# HyperMemory write protocol
+
+- Recall before writing. Update an existing node instead of creating a duplicate.
+- Use canonical node types: `user`, `person`, `organization`, `component`,
+ `event`, `decision`, `concept`, `artifact`, `project`, `technology`,
+ `preference`, `fact`, or `skill`.
+- Shape keys as `{type}_{name}`. Use `user_profile` for the primary user.
+- Never store passwords, API keys, OAuth tokens, credentials, or large code blobs.
+- Give each stored node a specific relationship that explains why it connects
+ to a project, component, person, organization, or decision.
+- Use `hm_ingest` only for dense multi-entity text. Immediately call
+ `hm_list_orphans`; connect useful orphans with `hm_add_relationships` and
+ delete unenriched noise with `hm_forget`.
+- Use `hm_find_related` for traversal and `hm_get_nodes` to hydrate exact keys.
+- Use `hm_upload_file` only when the user explicitly asks to store a file.
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..93cf6ef
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,10 @@
+[tool.ruff]
+line-length = 120
+target-version = "py310"
+
+[tool.ruff.lint]
+select = ["B", "E", "F", "I", "UP"]
+ignore = ["B008"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests", "packages/hypercolab-cli/tests"]
diff --git a/scripts/build_plugin_archives.py b/scripts/build_plugin_archives.py
new file mode 100755
index 0000000..cb3da82
--- /dev/null
+++ b/scripts/build_plugin_archives.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+"""Build deterministic OpenAI plugin review archives from marketplace sources."""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
+
+ROOT = Path(__file__).resolve().parents[1]
+PLUGINS = ROOT / "plugins"
+DIST = ROOT / "dist"
+EXCLUDED_PARTS = {"__pycache__", ".DS_Store"}
+
+
+def _files(plugin: Path) -> list[Path]:
+ return sorted(
+ file
+ for file in plugin.rglob("*")
+ if file.is_file()
+ and not any(part in EXCLUDED_PARTS for part in file.relative_to(plugin).parts)
+ and file.suffix != ".pyc"
+ )
+
+
+def build(plugin_name: str) -> Path:
+ plugin = PLUGINS / plugin_name
+ manifest_path = plugin / ".codex-plugin" / "plugin.json"
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ if manifest.get("name") != plugin_name:
+ raise ValueError(f"manifest name mismatch for {plugin_name}")
+ version = str(manifest["version"])
+ DIST.mkdir(parents=True, exist_ok=True)
+ destination = DIST / f"{plugin_name}-{version}.zip"
+ with ZipFile(destination, "w", compression=ZIP_DEFLATED, compresslevel=9) as archive:
+ for file in _files(plugin):
+ relative = file.relative_to(plugin).as_posix()
+ info = ZipInfo(relative, date_time=(2020, 1, 1, 0, 0, 0))
+ info.compress_type = ZIP_DEFLATED
+ info.external_attr = (0o755 if file.stat().st_mode & 0o111 else 0o644) << 16
+ archive.writestr(info, file.read_bytes(), compress_type=ZIP_DEFLATED, compresslevel=9)
+ return destination
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("plugins", nargs="*", default=["hypermemory", "hypercolab"])
+ args = parser.parse_args()
+ for plugin_name in args.plugins:
+ print(build(plugin_name).relative_to(ROOT))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_hypercolab_plugin.py b/tests/test_hypercolab_plugin.py
new file mode 100644
index 0000000..97e0ef6
--- /dev/null
+++ b/tests/test_hypercolab_plugin.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+PLUGIN = ROOT / "plugins" / "hypercolab"
+HOOK = PLUGIN / "scripts" / "hypercolab_hook.py"
+
+
+def test_hypercolab_plugin_registers_local_shim_and_agent() -> None:
+ manifest = json.loads((PLUGIN / ".codex-plugin" / "plugin.json").read_text(encoding="utf-8"))
+ mcp = json.loads((PLUGIN / ".mcp.json").read_text(encoding="utf-8"))
+ hooks = json.loads((PLUGIN / "hooks" / "hooks.json").read_text(encoding="utf-8"))
+
+ assert manifest["name"] == "hypercolab"
+ assert manifest["mcpServers"] == "./.mcp.json"
+ assert mcp == {"mcpServers": {"hypercolab": {"command": "hypercolab", "args": ["mcp"]}}}
+ assert (PLUGIN / "agents" / "coordination-writer.md").is_file()
+ assert "exec_command" in hooks["hooks"]["PreToolUse"][0]["matcher"]
+ assert all(
+ "${PLUGIN_ROOT}/scripts/hypercolab_hook.py" in handler["command"]
+ for event in hooks["hooks"].values()
+ for group in event
+ for handler in group["hooks"]
+ )
+
+
+def test_hook_launcher_explains_missing_cli_without_blocking(tmp_path: Path) -> None:
+ env = {**os.environ, "PATH": str(tmp_path)}
+ completed = subprocess.run(
+ [sys.executable, str(HOOK)],
+ input=json.dumps({"hook_event_name": "SessionStart"}),
+ text=True,
+ capture_output=True,
+ check=True,
+ env=env,
+ )
+ output = json.loads(completed.stdout)
+ assert "CLI/MCP shim is missing" in output["hookSpecificOutput"]["additionalContext"]
+ assert "lifecycle coordination is inactive" in completed.stderr
diff --git a/tests/test_hypermemory_plugin.py b/tests/test_hypermemory_plugin.py
new file mode 100644
index 0000000..0e035db
--- /dev/null
+++ b/tests/test_hypermemory_plugin.py
@@ -0,0 +1,246 @@
+from __future__ import annotations
+
+import importlib.util
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+PLUGIN = ROOT / "plugins" / "hypermemory"
+LISTENER = PLUGIN / "scripts" / "codex_token_listener.py"
+HOOK = PLUGIN / "scripts" / "hypermemory_hook.py"
+
+
+def _module(path: Path, name: str):
+ spec = importlib.util.spec_from_file_location(name, path)
+ module = importlib.util.module_from_spec(spec)
+ assert spec.loader is not None
+ sys.modules[name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+def _write_rollout(
+ path: Path,
+ *,
+ physical_id: str,
+ logical_id: str,
+ total: int,
+ input_tokens: int,
+ output_tokens: int,
+) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ events = [
+ {
+ "timestamp": "2026-08-12T10:00:00Z",
+ "type": "session_meta",
+ "payload": {"id": physical_id, "session_id": logical_id, "source": "test"},
+ },
+ {"type": "event_msg", "payload": {"type": "message", "content": "must not be parsed"}},
+ {
+ "timestamp": "2026-08-12T10:01:00Z",
+ "type": "event_msg",
+ "payload": {
+ "type": "token_count",
+ "info": {
+ "model": "gpt-test",
+ "total_token_usage": {
+ "input_tokens": input_tokens,
+ "cached_input_tokens": max(0, input_tokens - 5),
+ "cache_write_input_tokens": 0,
+ "output_tokens": output_tokens,
+ "reasoning_output_tokens": 3,
+ "total_tokens": total,
+ },
+ },
+ },
+ },
+ ]
+ path.write_text("\n".join(json.dumps(event) for event in events) + "\n", encoding="utf-8")
+
+
+def test_plugin_is_chatgpt_and_codex_only() -> None:
+ manifest = json.loads((PLUGIN / ".codex-plugin" / "plugin.json").read_text())
+ assert manifest["name"] == "hypermemory"
+ assert manifest["mcpServers"] == "./.mcp.json"
+ assert "hooks" not in manifest # default hooks/hooks.json is auto-discovered
+ assert (PLUGIN / "hooks" / "hooks.json").is_file()
+ assert (PLUGIN / "agents" / "memory-writer.md").is_file()
+
+
+def test_public_marketplace_is_self_contained() -> None:
+ marketplace = json.loads((ROOT / ".agents" / "plugins" / "marketplace.json").read_text())
+ assert marketplace["name"] == "hypermemory-ai"
+ assert marketplace["plugins"] == [
+ {
+ "name": "hypermemory",
+ "source": {"source": "local", "path": "./plugins/hypermemory"},
+ "policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"},
+ "category": "Memory & Knowledge",
+ },
+ {
+ "name": "hypercolab",
+ "source": {"source": "local", "path": "./plugins/hypercolab"},
+ "policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"},
+ "category": "Developer Tools",
+ },
+ ]
+
+
+def test_mcp_uses_rust_stage_oauth_endpoint() -> None:
+ config = json.loads((PLUGIN / ".mcp.json").read_text())
+ assert config == {
+ "mcpServers": {
+ "hypermemory": {"type": "http", "url": "https://stage.hypermemory.io/mcp"}
+ }
+ }
+
+
+def test_stop_hook_requires_one_subagent_and_guards_recursion(tmp_path: Path) -> None:
+ env = {**os.environ, "PLUGIN_ROOT": str(PLUGIN), "PLUGIN_DATA": str(tmp_path)}
+ base = {
+ "session_id": "session-1",
+ "turn_id": "turn-1",
+ "transcript_path": None,
+ "model": "gpt-test",
+ "hook_event_name": "Stop",
+ }
+ first = subprocess.run(
+ [sys.executable, str(HOOK), "stop"],
+ input=json.dumps({**base, "stop_hook_active": False}),
+ text=True,
+ capture_output=True,
+ check=True,
+ env=env,
+ )
+ output = json.loads(first.stdout)
+ assert output["decision"] == "block"
+ assert "exactly one memory-writer sub-agent" in output["reason"]
+ assert list((tmp_path / "jobs").glob("turn-*.json"))
+
+ second = subprocess.run(
+ [sys.executable, str(HOOK), "stop"],
+ input=json.dumps({**base, "stop_hook_active": True}),
+ text=True,
+ capture_output=True,
+ check=True,
+ env=env,
+ )
+ assert json.loads(second.stdout) == {"continue": True}
+
+
+def test_listener_aggregates_parent_and_subagent_then_acks(tmp_path: Path, capsys) -> None:
+ logical = "logical-session"
+ parent = tmp_path / ".codex" / "sessions" / "rollout-parent.jsonl"
+ child = tmp_path / ".codex" / "sessions" / "rollout-child.jsonl"
+ _write_rollout(parent, physical_id="parent", logical_id=logical, total=110, input_tokens=90, output_tokens=20)
+
+ state_file = tmp_path / "plugin-data" / "token-state.json"
+ baseline_job = tmp_path / "baseline.json"
+ baseline_job.write_text(
+ json.dumps(
+ {
+ "version": 1,
+ "session_id": logical,
+ "transcript_path": str(parent),
+ "state_file": str(state_file),
+ }
+ ),
+ encoding="utf-8",
+ )
+ listener = _module(LISTENER, "hypermemory_token_listener_test")
+ old_codex_home = os.environ.get("CODEX_HOME")
+ os.environ["CODEX_HOME"] = str(tmp_path / ".codex")
+ try:
+ assert listener.baseline(baseline_job) == 0
+ capsys.readouterr()
+
+ _write_rollout(parent, physical_id="parent", logical_id=logical, total=150, input_tokens=125, output_tokens=25)
+ _write_rollout(child, physical_id="child", logical_id=logical, total=60, input_tokens=45, output_tokens=15)
+ job_path = tmp_path / "turn.json"
+ job_path.write_text(
+ json.dumps(
+ {
+ "version": 1,
+ "session_id": logical,
+ "turn_id": "turn-1",
+ "transcript_path": str(parent),
+ "model": "gpt-test",
+ "state_file": str(state_file),
+ }
+ ),
+ encoding="utf-8",
+ )
+ assert listener.inspect(job_path, 0) == 0
+ inspected = json.loads(capsys.readouterr().out)
+ report = inspected["hm_tokens_payload"]
+ assert inspected["exact_available"] is True
+ assert report["measurement_quality"] == "client_exact"
+ assert report["total_tokens"] == 100
+ assert report["input_tokens"] == 80
+ assert report["output_tokens"] == 20
+
+ assert listener.ack(job_path) == 0
+ capsys.readouterr()
+ state = json.loads(state_file.read_text())
+ assert state["sessions"][logical]["turn_sequence"] == 1
+ assert len(state["sessions"][logical]["rollouts"]) == 2
+ finally:
+ if old_codex_home is None:
+ os.environ.pop("CODEX_HOME", None)
+ else:
+ os.environ["CODEX_HOME"] = old_codex_home
+
+
+def test_baseline_does_not_discard_unreported_resume_tail(tmp_path: Path, capsys) -> None:
+ logical = "resume-session"
+ rollout = tmp_path / ".codex" / "sessions" / "rollout-resume.jsonl"
+ _write_rollout(rollout, physical_id="parent", logical_id=logical, total=30, input_tokens=20, output_tokens=10)
+ state_file = tmp_path / "plugin-data" / "token-state.json"
+ job = tmp_path / "baseline.json"
+ job.write_text(
+ json.dumps(
+ {
+ "version": 1,
+ "session_id": logical,
+ "transcript_path": str(rollout),
+ "state_file": str(state_file),
+ }
+ ),
+ encoding="utf-8",
+ )
+ listener = _module(LISTENER, "hypermemory_token_listener_resume_test")
+ old_codex_home = os.environ.get("CODEX_HOME")
+ os.environ["CODEX_HOME"] = str(tmp_path / ".codex")
+ try:
+ assert listener.baseline(job) == 0
+ capsys.readouterr()
+ _write_rollout(rollout, physical_id="parent", logical_id=logical, total=50, input_tokens=35, output_tokens=15)
+ assert listener.baseline(job) == 0
+ resumed = json.loads(capsys.readouterr().out)
+ assert resumed["baselined"] is False
+
+ turn_job = tmp_path / "turn.json"
+ turn_job.write_text(
+ json.dumps(
+ {
+ "version": 1,
+ "session_id": logical,
+ "turn_id": "turn-after-resume",
+ "transcript_path": str(rollout),
+ "model": "gpt-test",
+ "state_file": str(state_file),
+ }
+ ),
+ encoding="utf-8",
+ )
+ assert listener.inspect(turn_job, 0) == 0
+ inspected = json.loads(capsys.readouterr().out)
+ assert inspected["hm_tokens_payload"]["total_tokens"] == 20
+ finally:
+ if old_codex_home is None:
+ os.environ.pop("CODEX_HOME", None)
+ else:
+ os.environ["CODEX_HOME"] = old_codex_home
diff --git a/tests/test_marketplace.py b/tests/test_marketplace.py
new file mode 100644
index 0000000..9042d4f
--- /dev/null
+++ b/tests/test_marketplace.py
@@ -0,0 +1,38 @@
+from __future__ import annotations
+
+import json
+import struct
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+MARKETPLACE = ROOT / ".agents" / "plugins" / "marketplace.json"
+
+
+def test_marketplace_entries_resolve_to_complete_plugins() -> None:
+ catalog = json.loads(MARKETPLACE.read_text(encoding="utf-8"))
+ assert catalog["name"] == "hypermemory-ai"
+ assert [entry["name"] for entry in catalog["plugins"]] == ["hypermemory", "hypercolab"]
+
+ for entry in catalog["plugins"]:
+ relative = entry["source"]["path"].removeprefix("./")
+ plugin = ROOT / relative
+ manifest = json.loads((plugin / ".codex-plugin" / "plugin.json").read_text(encoding="utf-8"))
+ assert plugin.name == entry["name"] == manifest["name"]
+ assert manifest["repository"] == "https://github.com/hypermemory-ai/hm-plugins-openai"
+ assert (plugin / manifest["mcpServers"].removeprefix("./")).is_file()
+ assert (plugin / "hooks" / "hooks.json").is_file()
+ assert (plugin / "skills" / entry["name"] / "SKILL.md").is_file()
+ assert (plugin / "agents").is_dir()
+ assert entry["policy"] == {"installation": "AVAILABLE", "authentication": "ON_INSTALL"}
+
+
+def test_brand_assets_are_valid_square_pngs() -> None:
+ for plugin_name in ("hypermemory", "hypercolab"):
+ plugin = ROOT / "plugins" / plugin_name
+ manifest = json.loads((plugin / ".codex-plugin" / "plugin.json").read_text(encoding="utf-8"))
+ for field in ("composerIcon", "logo"):
+ path = plugin / manifest["interface"][field].removeprefix("./")
+ data = path.read_bytes()
+ assert data[:8] == b"\x89PNG\r\n\x1a\n"
+ assert data[12:16] == b"IHDR"
+ assert struct.unpack(">II", data[16:24]) == (256, 256)