80 Commits

Author SHA1 Message Date
Mert Koseoglu 8476db7970 feat(fetch): finish the ladder — rung 2 recovers SPA pages browser-free
The measured gap: developer.apple.com/documentation/swiftui/view converts to
36 B of text from a 17,486 B shell, and reactnative.dev/docs/view ignores the
Accept header entirely. Both publish the article as a .md sibling. Rung 1
could not reach either; nothing below rung 1 existed.

Rung 2 now fires ONLY when the cheaper rungs did not produce an article, so
the happy path still costs exactly one request (asserted against a request
log, not inferred):

  2a  the page's .md sibling
  2b  the host's llms.txt, followed only when it names this page somewhere 2a
      did not already try

Acceptance is structural: a sibling is taken unless the server handed back an
HTML document. developer.apple.com serves its .md with an EMPTY Content-Type
and an HTML comment as its first bytes, so a 'starts with #' test would reject
a real article; angular.dev answers a missing .md with 200 + the SPA shell, so
status alone would accept a soft 404.

Every fetch now reports WHICH RUNG ANSWERED (stdout line 4) and which rung-2
urls were requested (line 5). The honest refusal names them instead of telling
the caller to go looking for files the ladder already asked for.

classifyExtraction is injected into the subprocess the same way classifyIp is,
so 'is this a shell?' has one definition and two callers. Its thresholds moved
inside the body to survive .toString() under esbuild minification — verified
against the shipped bundle, not just the source.

Also: 'SSRF blocked: redirect chain exceeded' no longer accuses an attack when
a benign locale redirect loop produces it (measured on Google devsite hosts).

Measured with scripts/measure-fetch-ladder.cjs over 36 documentation pages.
2026-08-12 23:39:27 +03:00
Mert Koseoglu 096f9330ae docs(fetch): the measurements, including the one that corrected the brief
Settles the caveat first: the 1,485,503 B Turndown figure came from a RAW
call. The shipped path has always run
`td.remove(['script','style','nav','header','footer','noscript'])`, and its
real output for the Stripe charge page is 26,053 B. The defect was never
byte volume — it was that 28.3% of non-blank lines were link-only and the
article's field definitions were not in the document at all.

Mintlify is the case that kills every byte threshold: the HTML arm produced
a SMALLER document (6,223 B vs 9,549 B) that did not contain the article,
because that page is client-rendered. Fewer bytes is not the goal.

Also records the three invariants read back from the live production store
after the claude -p runs, including csv.html reading back with 17 template
blocks after being fetched as a cold-start page with 0 — the re-run,
persisted.
2026-08-12 22:26:31 +03:00
Mert Koseoglu 5b9c00c965 feat(fetch): extract the article instead of transliterating the page
A format converter answers "what format". It can never answer "which part
of the page". We were using a transliterator where an extractor belongs,
which is why link-density and byte thresholds kept failing: the correct
threshold is 28.3% link-only lines on docs.stripe.com and 0.3% on
resend.com, so no single number separates them.

Two changes, cheapest correct answer first.

1. ASK FOR THE MACHINE-READABLE PAGE. The fetch subprocess now sends
   `Accept: text/markdown, ...;q=...` on the SAME request it was already
   making — zero extra round trips, and the q-values keep it a superset of
   the old request, so no site can newly break. Measured 2026-08-12, all
   six previously-failing platforms honour it.

2. CLASSIFY WHAT IS LEFT. Chrome is what REPEATS ACROSS PAGES OF THE SAME
   HOST; content is what does not. A block is labelled `template` only when
   that exact block was already seen on a DIFFERENT page of the same host,
   so the rule never guesses from the shape of a single page.

NO DATA LOSS MEANS LABEL, NEVER DROP. The complete document is stored
verbatim in fetch-pages.db along with every block and its label; only
`content` blocks reach the FTS index. `reassemble(splitBlocks(x)) === x`
byte for byte — asserted over 14 samples including CRLF, fenced code and
unicode, and verified live on six real pages.

COLD START: the first page of a host has no comparison set, so every block
is admitted as content and the page is marked PROVISIONAL, then re-run the
moment a second page of that host lands. Guessing per-block on page one was
rejected on the measurements above; over-indexing is recoverable, and a
first page wrongly labelled and never revisited is a silent loss.

A page whose every block already exists on other pages of the host is
REFUSED rather than reported as a success — a 21-byte "success" that
indexes a page title stops the model looking, where an error makes it try
another route. The refusal names llms.txt, .md and OpenAPI as next steps.
Its bytes are stored anyway: refusing to index is not a licence to discard.

No regular expressions. Nothing truncated.
2026-08-12 22:26:31 +03:00
Ken Jo 2608e344bb refactor(antigravity-cli): one-command agy plugin install (drop npm wrapper) + doc cleanup (#853)
refactor(antigravity-cli): one-command agy plugin install; drop npm wrapper

agy 1.0.7 added GitHub-subpath plugin install (with branch resolution), so the
former three-step flow shipped in #787 — `npm install -g` + `git clone` +
`npm run install:agy` (scripts/install-antigravity-cli-plugin.mjs) — is dead
weight. agy (<=1.0.6) `plugin install` accepted only a local directory, which is
why the wrapper existed; that constraint is gone.

Install is now one command, no clone, no wrapper:

  npm install -g context-mode
  agy plugin install https://github.com/mksglu/context-mode/tree/main/configs/antigravity-cli

- remove scripts/install-antigravity-cli-plugin.mjs + the install:agy npm script
  and its files[] entry
- antigravity-cli doctor `fix` strings now point at the one-command install
- README: split the conflated "Antigravity" entry into Antigravity IDE vs
  Antigravity CLI (agy); bring the agy section to the other install guides'
  level (Prerequisites / Install / MCP-only / Verify / Routing / Full configs);
  Verify points to the existing "Try It" prompts. Deep mechanics and
  troubleshooting stay in docs/platform-support.md
- docs/platform-support.md: one-command update + a "Verified: agy 1.0.10" note
  recording the >=1.0.7 install floor; hook contract unchanged through 1.0.10
  (config/hooks.json canonical since 1.0.8)
- tests: drop the wrapper-shape regression assertions (no leftover trace);
  bundle-content tests still guard the installable artifact

Preserved invariants: the bundle still registers MCP via its native
mcp_config.json (command: context-mode, env-pinned
CONTEXT_MODE_PLATFORM=antigravity-cli); the dual hooks.json + hooks/hooks.json
is kept (agy runtime reads root, validate reads subdir).

Verified on agy 1.0.10 (Linux): clean-room single-command install registers
MCP + hooks + skill from a zero baseline; tools/list exposes 11 Gemini-safe
ctx_* tools (0 const / 0 additionalProperties); `agy -p` smoke returns 12.
npm run build + tsc --noEmit + targeted vitest (47) pass. Bundles are
CI-managed (bundle.yml) and not included.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 04:50:52 +03:00
Mert Koseoglu 1aee4808d0 fix(install): derive version from package.json across all manifests so none bake stale (#768) 2026-06-21 18:37:32 +03:00
Ken Jo 9f34c6f11b Add GitHub Copilot CLI + Antigravity CLI (agy) support (#787)
* feat(adapters): add Antigravity CLI (agy) + GitHub Copilot CLI support

Add two agentic CLI adapters onto next's existing adapter registration —
without the abandoned PR's setup subcommand / consolidated registry.

Antigravity CLI (agy):
- MCP + capture-only PostToolUse hook adapter (agy honors no stdout veto in
  auto-run mode; verified against agy 1.0.5). The agy hook payload
  {conversationId, toolCall, workspacePaths} is mapped onto the shared
  capture pipeline.
- Ships a Claude-layout plugin bundle (configs/antigravity-cli/) installed via
  `npm run install:agy` (mirrors install:openclaw), with a version-skew
  capture-hook probe in the installer.

GitHub Copilot CLI (1.0.59):
- json-stdio hook adapter with six events: PreToolUse, PostToolUse, PreCompact,
  SessionStart, UserPromptSubmit, Stop. Overrides CopilotBaseAdapter to emit the
  FLAT {type,command} + top-level "version": 1 hook config Copilot CLI requires.
- MCP install via `copilot mcp add context-mode -- context-mode`.
- Fix a latent Stop-hook bug: a session_end event with no `data` threw inside
  insertEvent (createHash(undefined)) and was silently dropped.

Cross-cutting:
- #774: probe agy/copilot config markers before the generic ~/.claude check.
  The copilot marker is narrowed to context-mode-written files
  (~/.copilot/mcp-config.json | hooks/context-mode.json), not a bare ~/.copilot/
  dir, so a co-installed-but-unconfigured Copilot CLI cannot steal detection
  from a Claude Code user.
- Dispatcher fails OPEN (exit 0) on a missing hook script: GitHub Copilot CLI
  treats an exit-1 PreToolUse hook as DENY, so a version skew (a newer adapter's
  hook command on an older global) would otherwise brick the agent.

Fixes #774. Fixes #775.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: regenerate bundles for antigravity-cli + copilot-cli support

Picks up the new HOOK_MAP entries, client-map keys, validPlatforms,
getSessionDirSegments cases, and the fail-open dispatcher into the
esbuild-generated runtime bundles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(platform-support): sync support docs to 18 platforms + fix stale Kiro classification

Make README.md and docs/platform-support.md internally consistent and aligned
with the adapter source of truth.

Header sync (18 platforms everywhere):
- The Main Comparison Table (was 11 cols), the Capability Matrix (was 11), and
  the README Platform Compatibility table (was 17, missing Kimi Code) now list
  the SAME 18 platforms in one shared order. Adds the two branch-new platforms
  (GitHub Copilot CLI, Antigravity CLI `agy`) plus previously-omitted Qwen Code,
  KiloCode, OpenClaw, Zed, Pi as columns. Each cell sourced from the per-platform
  detail sections / adapter source and independently verified.
- Fix five ragged rows in the Main Comparison Table (a dropped trailing OMP cell)
  and add CLI Hook Dispatcher rows for qwen-code + copilot-cli.
- GitHub Copilot CLI section: normalize the `**Hook Names:**` label and add the
  missing `**Output Modification:**` field for json-stdio-family parity.

Fix stale Kiro classification (code is the source of truth):
- The kiro adapter is json-stdio with working preToolUse/postToolUse hooks
  (hooks/kiro/{pretooluse,posttooluse}.mjs + a kiro HOOK_MAP entry), yet the docs
  called it "MCP-only (Phase 2 — not implemented)" and the README contradicted
  itself ("no hook support" in one place, "native preToolUse/postToolUse" in two
  others).
- Reclassify Kiro as json-stdio with PreToolUse + PostToolUse + exit-code-2
  blocking across the Overview paradigm table, both wide tables, the dispatcher
  table, and the Kiro detail section; document that agentSpawn (SessionStart) and
  stop are not yet wired, so session restore after compaction is unavailable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-cli): drop vestigial .mcp.json dependency that broke fresh clones

The agy plugin-bundle test asserted configs/antigravity-cli/.mcp.json, but
.mcp.json is gitignored repo-wide and was never committed — so the test passed
on the dev machine (file present locally) yet failed on a fresh clone with
ENOENT. Committing the file is the wrong fix: the .gitignore comment documents
that shipping .mcp.json has silently broken fresh installs before (#253/#531).

- The bundle declares MCP the Claude way via .claude-plugin/plugin.json
  mcpServers (committed — the mechanism agy reads on `agy plugin install`),
  mirrored by the agy-native mcp_config.json (committed). Remove the vestigial
  bundle .mcp.json and stop the test + docs from requiring it. Every file the
  plugin test reads is now git-tracked, so a fresh clone passes.
- README: Kiro was still grouped under "Non-hook platforms" in the routing-
  enforcement note. Kiro has native preToolUse/postToolUse hooks; it needs the
  manual KIRO.md copy only because agentSpawn/SessionStart is not yet wired.
  Reword to say so.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(adapters): cross-platform agy installer + copilot-cli COPILOT_HOME parity

Windows fix (real): replace the bash-only agy plugin installer with a
cross-platform Node script so `npm run install:agy` runs natively on Windows
(PowerShell/cmd), not just Git Bash/WSL. agy runs on Windows, so its installer
must too — the old `node -e` wrapper hard-exited 1 on win32. openclaw stays
bash-only (it is genuinely POSIX-only). Removes
scripts/install-antigravity-cli-plugin.sh in favor of
scripts/install-antigravity-cli-plugin.mjs (same preflight + version-skew probe).

copilot-cli hardening (COPILOT_HOME edge case only — the default ~/.copilot
install was and remains correct):
- CopilotCliAdapter.getSessionDir() now roots at getConfigDir() (COPILOT_HOME-
  aware), mirroring codex/kimi, so the TS server reads sessions from the same
  place the hook runtime (COPILOT_OPTS configDirEnv: COPILOT_HOME) writes them.
  Previously a relocated COPILOT_HOME split hook writes ($COPILOT_HOME/...) from
  server reads (~/.copilot/...), making sessions appear empty.
- detect.ts copilot-cli marker honors COPILOT_HOME, not just ~/.copilot.

No change to the default (COPILOT_HOME-unset) behavior; a regression test pins
both the ~/.copilot default and the COPILOT_HOME-rooted path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: regenerate bundles for copilot-cli COPILOT_HOME parity

Picks up CopilotCliAdapter.getSessionDir() and the COPILOT_HOME-aware detect.ts
marker into the esbuild runtime bundles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-cli): installer registers the MCP server (agy plugin install skips it)

`npm run install:agy` ran only `agy plugin install`, which — verified against
agy 1.0.5 — processes a bundle's skills + hooks but logs "mcpServers : skipped
(not found)" and registers NO MCP server. agy reads a plugin's MCP only from a
bundle `.mcp.json` (intentionally not shipped — gitignored repo-wide after
#253/#531) and has no `agy mcp add` command, so context-mode's MCP server was
never registered: users had to add it to ~/.gemini/config/mcp_config.json by hand
(reported on Windows; reproduced on Linux: `mcpServers : skipped (not found)`).

The installer now also writes context-mode into agy's GLOBAL MCP profile
~/.gemini/config/mcp_config.json (idempotent JSON merge, preserves other servers,
tolerates a malformed file) — the file agy actually loads and `context-mode
doctor` checks. Verified end-to-end on agy 1.0.5: `npm run install:agy` →
mcp_config.json gains context-mode → `agy -p "... ctx_execute ... 7 + 5"` → 12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(server): emit Gemini-safe tool schemas so agy/Gemini CLI expose ctx_* tools

Antigravity CLI (agy) and Gemini CLI use Gemini's function-calling API, which
rejects JSON Schema `const` and `additionalProperties`. When a tool's parameter
schema contains either, the host SILENTLY DROPS that tool from the model's
function list — so agy never sees the ctx_* tools and works around them by
hand-rolling the MCP protocol through its Bash tool (verified on Windows: agy
wrote scratch/call_ctx_stats.js + list_mcp_tools.js MCP clients instead of
calling the tools natively). That defeats the point of context-mode — bash
output floods the context window instead of staying in the sandbox.

context-mode builds schemas with Zod, which emits `const` (from coerce/preprocess
constructs) and `additionalProperties`, with no Gemini sanitization. Wrap the
SDK's tools/list handler to rewrite the EMITTED schema:
  - `const: X` -> `enum: [X]`   (an identical single-value constraint)
  - drop `additionalProperties` (advisory-only; every ctx_* handler parses args
    with Zod, which strips unknown keys server-side regardless)

Both transforms are behavior-preserving for every other client (Claude Code,
Copilot, Cursor): const and a one-value enum are equivalent, and no model sends
undeclared properties — only the wire schema changes, never validation or how a
tool is called. Best-effort: if the MCP SDK internals shift, the original handler
is left untouched (no regression). Verified on the real tools/list: all 11 ctx_*
tools now emit 0 `const` / 0 `additionalProperties`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: regenerate bundles for Gemini-safe tool schema sanitizer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-cli): clear agy's stale MCP tool-schema cache on install

agy caches each MCP server's tool schemas under
~/.gemini/antigravity-cli/mcp/<server>/ and does NOT refresh them on reconnect
(verified on agy 1.0.6 against a live Windows install). A cache captured by a
context-mode older than the Gemini-safe-schema fix (ae6e7d3) keeps the
`const` / `additionalProperties` schemas that make Antigravity CLI silently drop
the ctx_* tools from the model's function list — so the schema fix never reaches
the model and the agent keeps working around the tools via shell scripts.

The installer now clears that cache after registering the MCP server, so agy
re-fetches the current Gemini-safe tools/list on its next launch. Verified on
Windows: clearing the cache + reconnecting makes agy re-store ctx_execute.json
with 0 `const` / 0 `additionalProperties`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: document agy Gemini-safe schemas + installer cache-clear + copilot COPILOT_HOME

Reflect this branch's recent behavior changes in the support docs:
- agy: context-mode emits Gemini-safe tool schemas (const->enum, additionalProperties
  stripped) so Antigravity CLI exposes the ctx_* tools instead of silently dropping
  them; agy caches tool schemas and never refreshes them, so `npm run install:agy`
  clears that cache. Added to the agy Known Issues + install steps (platform-support.md
  + README).
- copilot-cli: COPILOT_HOME now relocates the session-DB root too (getSessionDir honors
  it), and the detection marker honors COPILOT_HOME.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: correct GitHub Copilot CLI plugin capability (plugins DO support MCP + hooks)

The README + platform-support docs claimed Copilot CLI plugins register only
skills/agents — not MCP servers or hooks. That's wrong: `copilot plugin --help`
and `copilot mcp --help` (Copilot CLI 1.x) confirm a plugin can register MCP
servers (a `.mcp.json` in the plugin root or `.github/mcp.json`) and hooks
(`hooks.json`), installed in one command via `copilot plugin install owner/repo:path`
(from a GitHub repo subdirectory, no clone). The "direct installs deprecated for
plugin@marketplace" note was also inaccurate (all source forms are current).

Corrected both docs. context-mode still registers via `copilot mcp add` +
`context-mode upgrade` today; a shippable Copilot plugin bundle
(configs/copilot-cli/) is noted as a planned follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(copilot-cli): ship a GitHub Copilot CLI plugin bundle (MCP + skill, phase 1)

`copilot plugin install mksglu/context-mode:configs/copilot-cli` registers the
context-mode MCP server + routing skill in one command — no `context-mode
upgrade` / agent call.

The bundle's .mcp.json pins CONTEXT_MODE_PLATFORM=copilot-cli so the server
self-identifies as Copilot. This fixes the detection trap where a co-installed
Claude Code (~/.claude/plugins/installed_plugins.json) makes standalone
`context-mode upgrade` — and even ctx_upgrade — resolve claude-code and write
Claude's config instead of Copilot's.

Real Copilot plugins discover MCP from a root `.mcp.json`, so this is the one
bundle whose .mcp.json is committed: .gitignore un-ignores exactly this path
(the repo-wide ignore from #253/#531 guards the repo-ROOT dev file, not a
plugin's own config).

Phase 2 (capture hooks via the plugin's hooks.json) follows once its format is
verified on Windows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(copilot-cli): add capture hooks to the Copilot CLI plugin bundle (phase 2)

configs/copilot-cli/hooks.json registers all six Copilot hook events
(PreToolUse, PostToolUse, SessionStart, UserPromptSubmit, Stop, PreCompact),
each dispatching `context-mode hook copilot-cli <event>` against the global
binary. It is byte-equivalent to what `context-mode upgrade` writes to
~/.copilot/hooks/context-mode.json (the format verified against the
@github/copilot binary), so `copilot plugin install …:configs/copilot-cli` now
registers MCP + skill + capture hooks in one command — no `upgrade` / agent call.

Verified on Windows: with the plugin's env-pinned MCP config + a current global
context-mode, Copilot calls ctx_execute (→ 12) and ctx_upgrade resolves
copilot-cli (writes the Copilot hook, leaves Claude Code's config untouched).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(copilot-cli): document the plugin bundle as the recommended install

README + platform-support now lead with `copilot plugin install
mksglu/context-mode:configs/copilot-cli` (one command: MCP + hooks + skill, no
upgrade/agent call), keeping `copilot mcp add` + `context-mode upgrade` as the
manual no-plugin path. Notes the .mcp.json env pin (CONTEXT_MODE_PLATFORM=
copilot-cli) that fixes detection under a co-installed Claude Code, the
.gitignore un-ignore for the bundle's .mcp.json, and the `copilot --plugin-dir`
local-test path. Drops the earlier "planned follow-up" wording.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(antigravity-cli): ship .mcp.json so `agy plugin install` registers MCP directly

The agy bundle declared MCP in two places that nothing consumed — a `mcpServers`
block in .claude-plugin/plugin.json (which `agy plugin install` SKIPS) and a dead
mcp_config.json (read by no code) — and relied on the installer writing agy's
GLOBAL ~/.gemini/config/mcp_config.json as a workaround for not shipping .mcp.json.

agy's plugin system is Claude-compatible and reads MCP from a bundle `.mcp.json`,
exactly like the Copilot bundle. Verified on agy 1.0.6: `agy plugin install` with
a bundle .mcp.json logs "mcpServers : 1 processed" and registers the server (env
preserved) into ~/.gemini/config/plugins/<name>/mcp_config.json. So:

- ship configs/antigravity-cli/.mcp.json (un-ignored via a .gitignore negation),
  pinning CONTEXT_MODE_PLATFORM=antigravity-cli so the server self-identifies as
  agy — fixing the #774 mis-detection at the MCP level, not only via dir markers;
- drop the dead mcp_config.json and the manifest's redundant mcpServers;
- simplify the installer: `agy plugin install` now registers MCP + skill + hook;
  it keeps the stale tool-schema cache-clear + version-skew probe, and now
  self-verifies the plugin-scoped MCP registration (one-line manual fallback if a
  future agy skips it) instead of blindly writing the global profile.

Both CLI plugin bundles (copilot-cli, antigravity-cli) are now consistent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(antigravity-cli): doctor recognizes the plugin-scoped MCP + hook registration

After the bundle moved to `.mcp.json` (so `agy plugin install` registers MCP +
the capture hook into agy's plugin profile ~/.gemini/config/plugins/context-mode/),
doctor still only checked the global ~/.gemini/config/{mcp_config,hooks}.json and
warned "context-mode not found" / "capture hook not configured" on a working install.

- checkPluginRegistration + validateHooks now accept the plugin profile (the
  canonical `agy plugin install` location) OR the global path (manual fallback).
- getInstalledVersion reads the installed plugin.json version so the version line
  shows a real semver (PASS when current) instead of the bogus "vconfigured".
- fix hints point to `npm run install:agy`.

Unit-tested (plugin-scoped PASS for both MCP + hook). Runtime already confirmed on
agy 1.0.6: `npm run install:agy` + `agy -p "...ctx_execute...7+5..."` → 12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: clarify supported client count

* fix(copilot-cli): fail-open PreToolUse hook + gate debug logs (#787 review)

A thrown PreToolUse hook exited non-zero with empty stdout, which GitHub
Copilot CLI 1.0.59 treats as "Denied by preToolUse hook (hook errored)" and
uses to block EVERY tool — bricking the agent. parseStdin runs JSON.parse, so
a malformed payload alone triggers it. Wrap the hook body in a fail-open
try/catch: a legitimate veto is a normal stdout write + return (never a
throw), so only real errors are swallowed (empty stdout + exit 0 => ALLOW).
Adds a regression test that spawns the hook with a throwing payload.

Also gate the per-invocation debug logs (posttooluse/precompact/sessionstart)
behind CONTEXT_MODE_DEBUG, matching the kimi hooks — the PostToolUse log grew
on every tool call under the user's config dir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(util/jsonc): string-aware trailing-comma strip (#787 review)

stripJsonComments stripped trailing commas with a regex over the whole string,
silently eating commas INSIDE string values (e.g. "[1, ]" -> "[1 ]") on the
comment-strip path (reached whenever strict JSON.parse fails). Move the
trailing-comma removal into a second string-aware pass over the comment-free
output: in-string commas are preserved while real trailing commas — including
those separated from } or ] by a comment — are still stripped. Regenerated
bundles (jsonc is bundled into cli/server.bundle.mjs).

The identical duplicates in src/server.ts and src/adapters/opencode/index.ts
are left for a follow-up consolidation PR (they parse third-party configs;
wider blast radius).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: consolidate per-adapter test files per CONTRIBUTING (#787 review)

CONTRIBUTING.md ("Test file organization") keeps one test file per adapter /
core module. Merge the standalone bundle-guard + schema files into their
canonical homes and delete the standalones — zero net-new test files:
  - copilot-cli-plugin.test.ts    -> adapters/copilot-cli.test.ts
  - antigravity-cli-plugin.test.ts -> adapters/antigravity.test.ts
  - strict-client-schema.test.ts  -> core/server.test.ts (sanitizeSchemaForStrictClients)

Also add the jsonc string-aware regression test to core/server.test.ts (its
home per the domain table; jsonc.ts has no test file of its own).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: rename copilot capture hooks file to the <platform>-hooks convention (#787 review)

The repo's per-platform hook test files are named tests/hooks/<platform>-hooks.test.ts
(cursor-hooks, gemini-hooks, vscode-hooks, jetbrains-hooks, kiro-hooks, kimi-hooks).
copilot-cli's was the lone deviation (copilot-cli-capture.test.ts). Rename it to
copilot-cli-hooks.test.ts and add the matching row to the CONTRIBUTING.md test-file
table. (antigravity-cli stays folded into antigravity.test.ts — capture-only single
hook, mirroring the GUI variant in the same family file, per the repo's precedent.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(version-sync): register the Copilot CLI bundle manifest (#787 review)

configs/copilot-cli/.github/plugin/plugin.json carries a pinned "version" but,
unlike the antigravity-cli bundle, was missing from version-sync — so it would
freeze on the next `npm version` bump (the .cursor-plugin v1.0.111 drift class
the version-sync test guards against). Add it to scripts/version-sync.mjs targets,
the package.json `version` git-add list, and the version-sync test (targets + pkg
list + SHIPPED lockstep + end-to-end), mirroring the agy bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(antigravity-cli): bounded PreToolUse enforcement via agy's native decision contract

agy honors a top-level PreToolUse decision `{"decision":"deny"|"ask",reason}`
(verified on agy 1.0.6) — not Claude's permissionDecision/additionalContext — so
context-mode can ENFORCE routing on agy, not just capture.

- PreToolUse routing hook (hooks/antigravity-cli/pretooluse.mjs) emits agy's
  native decision; deny/ask enforce, context/modify collapse to an enforceable
  deny (agy ignores additionalContext). Fail-open.
- Shared agy payload mapper (hooks/antigravity-cli/payload.mjs) used by
  pre/post/stop; posttooluse refactored onto it. New capture-only Stop hook
  (best-effort — agy Stop firing unconfirmed, so it's excluded from doctor health).
- Native root bundle: ships plugin.json + mcp_config.json + hooks.json +
  rules/context-mode.md (agy reads bundle-ROOT files); .mcp.json and
  .claude-plugin/plugin.json removed. hooks/hooks.json kept as the validate/install
  mirror — agy runtime fires from root hooks.json, but `agy plugin validate/install`
  only REPORTS hooks when the subdir hooks/hooks.json also exists.
- routing.mjs agy aliases (run_command->Bash, view_file->Read, ...) + CommandLine/
  AbsolutePath/URL extractors; tool-naming.mjs maps agy to context-mode/<tool>.
- adapter: capabilities preToolUse/postToolUse true, paradigm json-stdio, native
  decision formatter, doctor; cli.ts HOOK_MAP pretooluse/posttooluse/stop;
  version-sync tracks the bundle plugin.json.

Fixes a marker-handoff bug: pretooluse keyed rejected/redirect markers on
conversationId while posttooluse reads via getSessionId (which prefers the
transcript UUID) — both now use getSessionId, with a <uuid>.jsonl round-trip
regression test. Also corrects a stale core-routing assertion to agy's
context-mode/<tool> surface, adds the CONTRIBUTING test-file row, and includes
incidental CODEX_* test-env isolation hardening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(antigravity-cli): review polish — modify guidance, ask fallback, sync comments, test placement

- formatters: agy `modify` now surfaces routing's per-tool redirect guidance
  (curl/build-tool/inline-HTTP) extracted from the echo payload instead of one
  generic line; `ask` carries a fallback reason so a security-policy confirmation
  prompt is never bare. Adapter formatPreToolUseResponse ask branch mirrored.
- comments: cross-reference the three agy tool-name maps (payload.mjs /
  routing.mjs / extract.ts) and the two agyContextReason copies (formatters.mjs /
  adapter) so they don't silently drift (single shared table = follow-up).
- tests: move the agy formatter tests to the canonical tests/hooks/formatters.test.ts
  (formatDecision wrapper style, beside the other per-platform blocks); assert the
  surfaced modify guidance + the ask fallback. Update the run_command deny test to
  the specific (non-generic) guidance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(antigravity-cli): default exec timeout under agy + anti-dump rules

Two agy-specific hardening fixes surfaced by interactive testing:

- ctx_execute / ctx_execute_file / ctx_batch_execute apply a default execution
  timeout (120s, tunable via CONTEXT_MODE_AGY_EXEC_TIMEOUT_MS) ONLY under agy.
  agy does not enforce an MCP RPC timeout, so a runaway/blocking script hung
  forever and had to be interrupted; every other host keeps the unbounded
  behavior (Issue #406). resolveExecTimeout() centralizes this; timed-out
  messages now report the effective timeout (was "undefinedms"). Unit-tested +
  e2e-verified (runaway ctx_execute killed at the bound instead of hanging).
- rules/context-mode.md: add a prominent "Do not dump — derive" section. agy
  artifacts each MCP tool's stdout to a step file the model then reads back, so
  a whole-file dump costs the context window twice; steer the model to
  value/match/known-slice extraction instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(copilot-cli): use camelCase hook event names so hooks actually fire

GitHub Copilot CLI (verified against the @github/copilot 1.0.60 binary)
dispatches hooks by camelCase event names ONLY — preToolUse / postToolUse /
sessionStart / userPromptSubmitted / agentStop / preCompact. The adapter
shipped PascalCase keys (PreToolUse / ...), which the binary silently ignores,
so context-mode's PreToolUse routing enforcement and PostToolUse capture never
fired on Copilot CLI. MCP tool exposure (.mcp.json auto-discovery) was
unaffected, which masked the regression.

- HOOK_TYPES values -> Copilot's camelCase. UserPromptSubmit->userPromptSubmitted
  and Stop->agentStop are NAME changes, not just casing.
- Decouple the CLI dispatch token from the event name: buildHookCommand now
  derives the token from the .mjs script base (pretooluse, ...), so the event
  KEY can be camelCase while the dispatcher and cli.ts hook handler stay stable.
- Update configs/copilot-cli/hooks.json keys, README, index.ts comments, tests.

Verified e2e on real Copilot CLI 1.0.60 via the documented plugin install:
PreToolUse denied a raw `curl` and redirected to ctx_fetch_and_index (the model
obeyed); PostToolUse fired (posttooluse-debug.log advanced under
CONTEXT_MODE_DEBUG). The internal DB event-type labels in hooks/copilot-cli/*.mjs
are context-mode's cross-adapter taxonomy and are intentionally unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Keep Copilot CLI plugin MCP config loadable on older CLI

Mac smoke testing found that Copilot CLI 1.0.44 rejects the plugin MCP entry before startup unless the no-argument server still declares an explicit empty args array.

Constraint: Copilot CLI 1.0.44 requires an explicit args array for plugin stdio MCP entries

Rejected: Omit args because context-mode takes no arguments | older Copilot CLI rejects the plugin config before MCP startup

Confidence: high

Scope-risk: narrow

Directive: Keep args: [] in the Copilot plugin .mcp.json unless Copilot documents it as optional across supported versions

Tested: vitest copilot-cli adapter and hook suites; real Copilot CLI 1.0.44 loaded context-mode MCP after patch; real agy prompt returned 12

Not-tested: Copilot prompt completion, because local Copilot CLI fails to list models even without this plugin

Co-authored-by: OmX <omx@oh-my-codex.dev>

* Ship the agy installer in the npm package

Clean-install testing exposed that the package declared npm run install:agy but omitted the installer file from package.json files, so the installed tarball failed before agy plugin install could run.

Constraint: npm tarball contents are limited by package.json files

Rejected: Rely on repository-local installer presence | npm install -g ships only allowlisted files

Confidence: high

Scope-risk: narrow

Directive: Keep package scripts and package.json files in lockstep for shipped install commands

Tested: vitest antigravity and copilot adapter hook suites; npm pack includes scripts/install-antigravity-cli-plugin.mjs; npm uninstall -g context-mode then npm install -g tarball; npm --prefix installed package run install:agy; real agy prompt returned 12; Copilot loaded installed plugin MCP

Not-tested: Copilot prompt completion, because local Copilot CLI fails to list models after MCP startup

Co-authored-by: OmX <omx@oh-my-codex.dev>

* test(server): use valid tsc option for on-demand build

* fix(copilot-cli): validate plugin runtime hooks

* docs(copilot-cli,antigravity-cli): correct hook comments + fields to match upstream refs

Ground the new Copilot CLI / Antigravity CLI adapters against the real
upstream sources (refs/platforms) and fix misleading comments + one
contradicted field. No runtime behavior change to working paths.

Copilot CLI:
- version:1 is OPTIONAL, not mandatory — the CLI accepts hook configs
  that omit the version field (copilot-cli changelog.md:1109). Keep
  emitting version:1 (harmless, self-documenting); fix the comments,
  README, and docs that claimed hooks never fire without it.
- PascalCase event names are ACCEPTED and fire — the CLI loads configs
  across VS Code / Claude Code / CLI by accepting PascalCase alongside
  camelCase (changelog.md:1065, :811, :1081). Drop the 'silently
  ignored / never fires' claim; we use camelCase as the native naming.
- session_id (snake_case) is the documented payload field
  (changelog.md:811). Read it first; keep sessionId (camelCase) as a
  defensive, undocumented fallback.

Antigravity CLI:
- The only refs-backed payload field is workspace.current_dir, an object
  field (examples/title/title.sh:10, examples/title/README.md:11). Read
  workspace.current_dir FIRST for the project dir, falling back to the
  empirically-derived workspacePaths[0]. Annotate conversationId /
  workspacePaths as unverified. Stop stays best-effort/unverified on
  agy 1.0.6.

Docs: platform-support table + README continuity matrix now show
Antigravity CLI Stop as best-effort/unverified and the corrected
session-id / project-dir fields; 17-platform count unchanged (correct).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Mert Köseoğlu <bm.ksglu@gmail.com>
Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-06-21 16:06:25 +03:00
Mert Koseoglu aa1afc01dc feat(hooks): plumb jsRuntimePath through normalize-hooks for bun rewrite (#738)
Extends normalizeHooksOnStartup / normalizeHooksJsonOnly with an optional
jsRuntimePath parameter. When present (and different from nodePath), the
static hooks/hooks.json rewrite swaps the bare `node` prefix for the
resolved Bun ≥1.0 path so PreToolUse/PostToolUse fires inherit the same
cold-start win as the in-place adapter-generated configs.

Lifts the prior platform gate (`win32 || linux`) for the hooks.json
branch when a bun swap is requested. The original #378 path stays
Windows/Linux-only when only #378's placeholder healing is needed, but
macOS now also rewrites when jsRuntimePath !== nodePath — the issue was
filed from macOS and the historical gate skipped darwin because system
node was reliable, not because the rewrite was unsafe.

plugin.json normalization is explicitly EXEMPT from the bun swap (MCP
server stays on Node, #543 better-sqlite3 ABI).

Callers updated:
  - start.mjs: probe resolveHookRuntime at MCP boot, forward to
    normalizeHooksOnStartup. Inner probe wrapped in its own try so a
    missing build/runtime never blocks boot.
  - src/cli.ts: /ctx-upgrade also probes + forwards so the upgrade-time
    healing picks bun.
  - scripts/postinstall.mjs: global install heal also probes.

tests/cli/upgrade-plugin-json-assertion.test.ts widens its source slice
window 16k→20k chars: the new bun-probe block pushed
healPluginJsonMcpServers past the 16k cap and the downstream `Plugin
manifest drift` throw fell outside the per-test slice.
2026-05-31 17:04:00 +03:00
Seba Breguel 63d0a3ae19 fix(upgrade): stop baking stale version paths during /ctx-upgrade (#711) (#713) 2026-05-31 16:32:27 +03:00
Jefferson Santos b70498a70a feat(pi): fix adapter routing, MCP bridge, startup diet, and pricing (#741) 2026-05-31 16:30:58 +03:00
Sultan Alsawaf 547c18b69b security: containment, info-disclosure, and test-isolation hardening (#716) 2026-05-31 16:30:52 +03:00
Baijack-star fa61570941 Fix Claude plugin skills path and pack integrity guard (#661)
* ci: update server.bundle.mjs, cli.bundle.mjs, session hook & security bundles

* ci: update install stats

* ci: update install stats

* ci: update install stats

* Fix Claude plugin skills manifest path

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-22 13:05:09 +03:00
Mert Köseoğlu 6cb1490c9b fix(family-A): persistence-tier rules — drop stale .mcp.json + portable Tier C hooks (#620)
* fix(family-A): persistence-tier rules — drop stale .mcp.json + portable Tier C hooks

Single unified PR for the persistence-tier family. Three issues, one
architectural decision: classify every file by who reads / mutates it
(plugin-cache vs. user-home vs. workspace-committed) and enforce the
correct mutability contract per tier.

Issue #604 — hooks.json bidirectional ratchet (already fixed on `next`
by merged PR #611 / commit 97792c5 — closing the issue via the
"Closes #604" keyword in this PR). No additional code change needed.

Issue #609 — .mcp.json stale-write removal:

  - src/cli.ts: stop writing `.mcp.json` into the per-version plugin
    cache dir at upgrade time. Claude Code reads `.claude-plugin/
    plugin.json.mcpServers` as the canonical source (verified upstream:
    refs/platforms/claude-code/src/utils/plugins/mcpPluginIntegration.ts:131-212).
    The cli-side write was the producer of the stale carry-forward that
    Claude Code's native plugin auto-update copies into a fresh version
    dir → MODULE_NOT_FOUND on every MCP boot.
  - src/server.ts: same removal in the inline-fallback upgrade path.
  - scripts/heal-installed-plugins.mjs: new sweepStaleMcpJson() removes
    any pre-existing .mcp.json from every per-version cache dir, with
    path-traversal guard against malicious pluginKey segments.
  - start.mjs, scripts/postinstall.mjs, src/cli.ts: wire sweepStaleMcpJson
    into the existing heal block. Belt-and-braces second-pass assertion
    in cli.ts upgrade() — second sweep MUST report removed:[] or throw.

Issue #613 — buildHookCommand portable Tier C:

  - src/adapters/vscode-copilot/hooks.ts: drop the absolute-path branch
    added by commit f5c9d02 (2026-03-06). Always emit the CLI dispatcher
    form `context-mode hook vscode-copilot <event>`. The reverted shape
    is the pre-f5c9d02 portable form already in production for cursor
    and codex adapters.
  - src/adapters/jetbrains-copilot/hooks.ts: same fix — same Tier C
    (.github/hooks/context-mode.json is workspace-committed and lands
    in every teammate's `git status`).

Why Tier C MUST be portable: refs/platforms/vscode-copilot/assets/
prompts/skills/agent-customization/references/hooks.md line 7 confirms
`.github/hooks/*.json` is "Workspace (team-shared)". Embedding
`process.execPath` (which under fnm-windows is the per-session-ephemeral
`fnm_multishells/<PID>_<TS>/node.exe` shim) into a committable file
leaks PII (`C:/Users/<user>/...`) AND breaks cross-machine portability.

Test coverage:

  - tests/hooks/cache-heal-self-heal.test.ts: 6 new tests for
    sweepStaleMcpJson — happy path, no-op, missing cache root,
    path-traversal guard, sibling-file preservation, best-effort
    on race condition.
  - tests/adapters/vscode-copilot.test.ts: 4 new Tier C lock tests
    asserting buildHookCommand never bakes absolute paths.
  - tests/adapters/jetbrains-copilot.test.ts: 4 matching Tier C tests.
  - tests/core/cli.test.ts: amended .mcp.json describe block — reversed
    the #411 "must write" assertions to enforce "MUST NOT write" +
    "MUST sweep". server.ts inline-fallback assertion reversed in
    parallel. Bug-class protection from #531 (placeholder in example,
    files[] excludes .mcp.json) preserved unchanged.
  - tests/cli/upgrade-mcp-json-assertion.test.ts: pivoted from
    healMcpJsonArgs lock to sweepStaleMcpJson lock — same
    architectural-lock pattern, new mechanism.
  - tests/util/postinstall-heal-mcp-json.test.ts,
    tests/util/start-mjs-self-heal.test.ts: amended to assert
    sweepStaleMcpJson wiring in postinstall + start.mjs.
  - tests/scripts/asymmetric-drift-assert.test.ts: stub updated to
    export sweepStaleMcpJson alongside healMcpJsonArgs.

Targeted test verification: 321/321 tests pass across all touched
files. `npx tsc --noEmit` — clean.

Closes #604
Closes #609
Closes #613

* feat(doctor): proactive Tier C absolute-path + stale .mcp.json checks (PR #620 slice 4)

PR #620 fixed the WRITE-time root causes (#609 stop writing per-version cache
.mcp.json; #613 emit CLI-dispatcher form for vscode/jetbrains-copilot hooks),
but users running pre-v1.0.137 still carry poisoned state on disk:

  - Tier C workspace-committed files (.github/hooks/context-mode.json,
    .cursor/hooks.json, .jetbrains/copilot/hooks.json) with absolute
    Windows fnm shim paths baked by old /ctx-upgrade runs.
  - Leftover per-version .mcp.json files in
    ~/.claude/plugins/cache/context-mode/context-mode/<ver>/ that the
    architectural untrack now treats as drift.

Per ISSUE-604-VERDICT §11 ("silent-green doctor while hooks are dead is itself
a P0 trust bug"), doctor must SURFACE this state before the user hits the
runtime failure.

  CHECK A (FAIL): scan each Tier C file under process.cwd(); recurse all
    string values; flag any absolute path (unix /, Windows [A-Z]:[/\\],
    double-backslash UNC), fnm_multishells shim, or process.execPath
    literal. Missing config -> SKIP (no false fail). Remediation points
    at /context-mode:ctx-upgrade.

  CHECK B (WARN): enumerate cache version dirs under homedir() (Mert
    standing Windows-safety rule -- never use literal '~/'); count
    stale .mcp.json. Recoverable, so WARN not FAIL. Remediation: next
    ctx_upgrade sweep removes them via sweepStaleMcpJson.

TDD evidence:
  RED: 3 new tests in tests/core/cli.test.ts under 'PR #620 slice 4 --
       doctor() surfaces persistence-tier bug class' -- all 3 fail on
       current main (anchors '#613' / '#609' / 'fnm_multishells' /
       homedir() absent from doctor()).
  GREEN: 3/3 pass; 160/160 cli.test.ts tests pass; tsc --noEmit clean.

Tests slot into existing tests/core/cli.test.ts (CONTRIBUTING L275 -- no
new test files). Static-source-analysis pattern matches the Issue #564
doctor test precedent (lines 2056-2101). No bundle files touched.

* test(ci-lint): configs/** Tier C portability invariant (PR #620 slice 5)

PR #620 surgically fixed vscode-copilot + jetbrains-copilot adapters
(commit f5c9d02 had baked absolute process.execPath + script paths into
workspace-committed .github/hooks/context-mode.json). The fix was
adapter-local; nothing structural prevents a future contributor from
re-introducing the same bug class in any of the other 13 adapters
under configs/.

This invariant extends tests/scripts/asymmetric-drift-assert.test.ts
(the existing CI lint surface wired into `npm run build`) with a
recursive scan of every .json template under configs/**. For each
string value, fail the test if it matches:

  - unix absolute paths (^/Users/, ^/home/)
  - Windows drive-letter absolute ([A-Z]:[/\\])
  - Windows UNC (^\\\\)
  - fnm session shim (fnm_multishells) -- the #613 reporter symptom
  - process.execPath literal -- the f5c9d02 anti-pattern signature
  - literal `~/...` tilde paths (not JSON-portable)
  - `${HOME}/...` shell expansion (not JSON-portable)

Error message names the offending file:jsonPath:value and the matched
pattern so future contributors get the fix direction without grepping.

Per ISSUE-613-VERDICT 6.1 persistence-tier rule: Tier C files MUST be
born portable -- no heal seam exists for files committed to user repos.
This invariant catches the bug class at PR review time across the
entire configs/ surface, not just the two adapters PR #620 fixed.

TDD evidence:
  RED proof: dropping a poisoned `configs/vscode-copilot/poison.json`
  with `/opt/homebrew/...`, `/Users/jowch/...` and `fnm_multishells/...`
  paths -> test fails with the exact offence list (verified locally,
  fixture removed before commit).
  GREEN: 9/9 tests in asymmetric-drift-assert.test.ts pass against
  the post-PR-620 clean source tree; full Family-A regression
  91 files / 2018 tests pass; tsc --noEmit clean.

Slots into existing CI-lint test file (CONTRIBUTING L275 -- no new
test files). Same wiring posture as the existing assert-asymmetric-drift
invariant: catches the regression at `npm run build` before publish.

* feat(doctor-dx): solution-first messages for Tier C + stale .mcp.json checks (PR #620 slice 6)

Slice 4 (commit f17e8a1) added two new doctor checks that surface
pre-v1.0.137 poisoned state on disk. The detection logic is correct,
but the user-facing messages were written in internal vocabulary
("Tier C", "per-version cache dirs", "sweepStaleMcpJson", "command
shapes"). Per Mert's DX/UX directive — "anlamsiz mesajlar vermeyelim
User'a. Yonlendirici olmali. Cozum odakli olmali." — rewrite each
message to lead with the fix, not the diagnosis.

Each new message now follows: diagnosis (one sentence, user words)
-> why-it-matters (consequence the user emotionally cares about)
-> fix (single actionable command, /context-mode:ctx-upgrade for
both) -> link to issue for deep-dive.

Cross-OS safety: no `rm`/`del` in any remediation. All paths route
through /context-mode:ctx-upgrade which is portable on macOS, Linux,
and Windows.

Changes:
  CHECK A (Issue #613 — workspace hook config):
    - Step line: "Tier C" -> "team-shared in your workspace"
    - FAIL: leads with "this file is committed to git, your teammates
      and CI will get your path and the hooks will break for them"
      before naming the technical cause; drops "portable command shape"
      jargon; adds issues/613 URL.
    - PASS / SKIP / parse-WARN: aligned to plain-English "Hook config:"
      label; parse-WARN now explains why the user should care + gives
      two recovery paths.

  CHECK B (Issue #609 — stale .mcp.json):
    - Step line: "stale per-version .mcp.json" -> "leftover .mcp.json
      from older versions"
    - Stale-WARN: opens with "these are harmless but should be cleaned
      up so they cannot confuse Claude Code after an auto-update" to
      prevent panic at WARN; replaces internal function name
      "sweepStaleMcpJson" with "it sweeps these files automatically";
      adds issues/609 URL.
    - PASS / SKIP / enumerate-WARN: aligned to "Leftover .mcp.json
      check:" label; enumerate-WARN now labels path + reason on
      separate lines + gives a concrete next step.

Test preservation: test contract in tests/core/cli.test.ts asserts
on `#613`/`#609` anchors, `fnm_multishells`, `homedir()`, log-level,
and `ctx_upgrade` token within window slices of doctorBody(). All
anchors live in in-function comments + the detection helper, which
are unchanged. Issue URLs at the end of FAIL/WARN messages keep the
`ctx_upgrade` token comfortably inside the window.

Verification:
  - npx vitest run tests/core/cli.test.ts -> 160/160 passed
  - npx tsc --noEmit -> clean

No new test files (CONTRIBUTING L275). No bundle files touched.
2026-05-18 23:04:25 +03:00
Omer Cohen 09efefdc4a feat(opencode): register ctx tools natively via plugin (#574) (#597)
Move OpenCode/Kilo from plugin+MCP dual registration to plugin-native ctx_*
tools. The plugin now imports the shared server tool registry without
starting stdio, exposes all 11 ctx_* tools via the OpenCode/Kilo tool map,
and uses AsyncLocalStorage to pass project/session context into existing
handlers without a process.env race.

Upgrade safety for existing users:
- configureAllHooks removes only legacy mcp.context-mode while preserving
  other MCP servers.
- doctor warns when a legacy mcp.context-mode block remains and points to
  context-mode upgrade.
- stale legacy OpenCode/Kilo MCP children suppress ctx_* registration and
  become no-op rather than exposing duplicate tools.

Safety cleanup:
- Remove CONTEXT_MODE_IDLE_TIMEOUT_MS entirely; plugin-native tools remove
  the need for timer-driven MCP death, and timer shutdown was unsafe for
  hosts that keep registered tool handles.
- Guard process-wide exception handlers so importing server.js for native
  tools does not alter OpenCode/Kilo host crash semantics.
- Scope CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS to the dynamic import and restore
  it so child commands do not inherit the internal guard.

Tests cover native tool registration, native ctx_stats execution, host
side-effect leakage, native session attribution, legacy MCP config cleanup,
doctor warning, and stale MCP no-op predicate.

Refs: #574, #565, #592

Co-authored-by: Ousama Ben Younes <benyounes.ousama@gmail.com>
2026-05-18 17:10:16 +02:00
Omer Cohen 3e39ff7dee fix(pi): move respawn guard to request() + single-flight + smoke pin (#583 follow-up) (#585)
The original #583 patch put the respawn-on-idle-exit guard inside
MCPStdioClient.callTool() only. Three remaining issues identified by
audit on top of that fix:

1. tools/list and initialize go straight through request() and miss the
   respawn guard. If Pi ever calls listTools() after an idle exit (e.g.
   a tool-list refresh on session resume), it still rejects with
   "MCP server has exited" the same way the registered tool path used
   to before #583.

2. Two concurrent callTool() calls after the child exits both observe
   `this.exited === true`, both invoke respawn(), each spawns its own
   child. The loser of the race overwrites `this.child` and its child
   becomes an orphan with no `.kill()` reference — silent process leak.

3. respawn()'s state-reset ordering was undocumented. If a future
   refactor moves `this.exited = false` to AFTER `await this.initialize()`,
   the recursive request("initialize", ...) inside respawn() sees
   `exited === true` and re-enters respawn forever (infinite loop, not
   just a stale reject) — silent test/CI hang.

Fix:

- Move the respawn-on-exited guard from callTool() into request() — the
  single chokepoint for initialize / tools/list / tools/call / any
  future method. callTool() simplified to delegate.
- Add `private respawnPromise: Promise<void> | null` for single-flight
  semantics: concurrent callers awaiting `request()` after an idle exit
  observe the SAME respawn promise. Cleared in `.finally()` so the next
  exit can respawn again.
- Document the respawn() state-reset ordering invariant in JSDoc, with
  an explicit pointer to the new regression test that pins it.

Plus a tier-2 smoke harness fix:
- `scripts/tier2-smoke/run-pi-smoke.sh` now exports
  CONTEXT_MODE_IDLE_TIMEOUT_MS=0 at startup. Without this, the smoke's
  long idle gaps trigger the auto-respawn path, which would mask a real
  silent-death regression by making it look like a normal idle exit.

Regression tests (3 new cases in tests/adapters/pi-mcp-bridge.test.ts):

- "listTools() after an idle exit triggers respawn (not just callTool)"
  Fake server exits after first tools/list response. Second listTools()
  must respawn + return — pre-fix this rejected because the respawn
  guard only lived in callTool().

- "concurrent callTool() invocations after exit share ONE respawn (no
   orphan children)"
  Fake server records its pid to a marker dir on every boot. Two
  parallel callTool() calls fire from the exited state. Assertion:
  exactly TWO pid files on disk (original + one respawn), and both
  callers' responses carry the SAME pid. Pre-fix without single-flight
  this would have 3 pid files and divergent caller pids.

- "respawn() resets state in the documented order — `exited=false`
   BEFORE initialize()"
  Manually flips internal.exited=true (post-onExit shape without an
  actual kill, for determinism), then callTool() must run respawn →
  initialize() through request() recursively, which only terminates if
  `exited` was cleared before the recursive call.

Targeted tests: 9/9 pass (full pi-mcp-bridge.test.ts).
Full suite: 3331 pass, 48 skipped — same 3 pre-existing
environment-dependent failures unchanged from `upstream/next` HEAD
(VSCODE_PID inheritance, JetBrains IDEA_INITIAL_DIRECTORY).

Refs: #583, #565, #568

Co-authored-by: Ubuntu <omer@Omer.tail8b8831.ts.net>
2026-05-15 12:06:03 +03:00
Michael e63271b212 fix(opencode): detect desktop sessions via OPENCODE_CLIENT/OPENCODE_TERMINAL (#581)
OpenCode desktop sessions now resolve from desktop env markers
(`OPENCODE_CLIENT=desktop`, `OPENCODE_TERMINAL=1`) instead of falling
through to `~/.claude/` and being misclassified as Claude Code.

Verified upstream against sst/opencode:
- packages/desktop/src/main/server.ts:64 sets OPENCODE_CLIENT=desktop
- packages/opencode/src/pty/index.ts:191 sets OPENCODE_TERMINAL=1

Also threads CONTEXT_MODE_PLATFORM into the doctor child spawned by
`upgrade()` so the verification step does not rediscover Claude Code
after upgrade() has already resolved OpenCode.

Adapter parity preserved across detect.ts, hooks/core/platform-detect.mjs,
and scripts/ctx-debug.sh. Fork-before-parent ordering (kilo > opencode)
intact.

Tests: tests/adapters/detect.test.ts (+2 desktop env tests),
tests/adapters/detect-config-dir.test.ts (+1 OPENCODE_CLIENT test),
tests/util/ctx-upgrade-platform-threading.test.ts (+1 threading test).
107 tests pass locally.

Co-authored-by: Michael <mlalpho@users.noreply.github.com>
2026-05-15 10:03:21 +03:00
Scott Haskell 7f1e1e8e29 fix(upgrade): heal ~/.claude.json user MCP registrations after version bump (#579)
* ci: update server.bundle.mjs, cli.bundle.mjs, session hook & security bundles

* fix(upgrade): heal ~/.claude.json user MCP registrations after version bump

Users who work around anthropics/claude-code#59310 (plugin-registered MCP
servers don't expose tools to the AI) by registering via `claude mcp add
--scope user` end up with an absolute path to a specific version dir in
~/.claude.json. After /ctx-upgrade the path is stale.

Detect any mcpServers args in ~/.claude.json pointing inside the context-mode
plugin cache and update them to the new pluginRoot. Best-effort — never
blocks the upgrade if the file is missing or unparseable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(upgrade): heal ~/.claude.json user MCP registrations after version bump

Users who work around anthropics/claude-code#59310 (plugin-registered MCP
servers don't expose tools to the AI) by running `claude mcp add --scope
user` end up with an absolute path to a specific version dir in ~/.claude.json.
After /ctx-upgrade that path is stale and tools stop working.

Extract healClaudeJsonMcpArgs() into heal-installed-plugins.mjs (shared
module) so it runs during upgrade() and is unit-testable. Detects any
mcpServers args inside the context-mode plugin cache and updates the version
dir to the new pluginRoot. Best-effort — never blocks the upgrade.

5 new tests covering: happy path, no-op (already current), missing file,
no mcpServers key, unrelated server args.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 09:42:01 +03:00
Mert Koseoglu 61e680f1f3 fix(v1.0.132): stats measurement + #563/#564/#567/#569 + PR follow-ups
- stats: populate bytes_avoided for external_ref via ctx_fetch_and_index preamble; indexer chunks now FK-attributed (chunks.session_id/event_id) at all 7 callers in server.ts
- #563: drop .refine() from ctx_purge schema (MCP SDK normalizeObjectSchema requires .shape); ambiguity check moved to handler; class-wide CI guard added for all 11 tools
- #564: engines.node>=22.5 + scripts/postinstall.mjs hard-fail on Linux+Node<22.5+no-Bun + ctx_doctor RED FAIL + README/docs sync to canonical 22.5 floor
- #567: vscode-copilot + jetbrains-copilot mcp.json npx-y -> global context-mode (npx-y was scaffold residue from Mar 2026, ghost-installs bypass user's npm i -g causing better-sqlite3 ABI mismatch)
- #569: anti-pattern docs centralized to anti-patterns.md §8 + SKILL.md ref (capture-vs-filter principle, no tool enumeration)
- #571 follow-up: vswhere timeout 5s->15s, year regex caps at currentYear+5
- #568 follow-up: documented CONTEXT_MODE_IDLE_TIMEOUT_MS + CONTEXT_MODE_STARTUP_SWEEP env vars; realpath guard in lifecycle-e2e-real-binary.test.ts
2026-05-14 20:46:51 +03:00
Kishan 8e2d568452 fix(win32): detect VS year via vswhere displayName for VS 2026+ (#571)
Closes #566

Adds detectWindowsVsYear() in scripts/heal-better-sqlite3.mjs that queries vswhere.exe for displayName (e.g. "Visual Studio Community 2026") and extracts the 4-digit year via regex. Wired into buildSafeEnv() so npm_config_msvs_version is set automatically on Windows when not already provided, and into upgrade() in src/cli.ts so /ctx-upgrade picks up the same hint.

12 new dependency-injected unit tests (run on any OS, no real vswhere needed) plus 4 source-contract assertions. All 16 tests pass on macOS/ubuntu/windows CI.

Co-authored-by: Kishan08 <Kishan08@users.noreply.github.com>
2026-05-14 19:41:41 +03:00
Mert Koseoglu 43b2477575 fix(integrity): algorithmic Algo-D4 — derive required siblings from scripts.bundle (closes #558 partial — 3 of 4)
v1.0.126 shipped Algo-D4 with a hardcoded REQUIRED_RUNTIME_SIBLINGS
array that omitted `hooks/security.bundle.mjs` (the bundle didn't
ship until v1.0.127, but the algorithmic intent was already
documented). The hardcoded list silently passed integrity checks on
v1.0.126 marketplace installs even when the security regression
was active — Algo-D4 reported `{ ok: true }` while permissions.deny
was fail-open. The same trap would have re-bitten the next bundle.

Algorithmic redesign:

- Replace `REQUIRED_RUNTIME_SIBLINGS` const with
  `getRequiredRuntimeSiblings(pluginRoot)` exported function.
- Algorithm: union of LEGACY_FALLBACK (the v1.0.126 contract,
  preserved verbatim) plus every esbuild outfile parsed from
  `package.json scripts.bundle` minus an explicit
  SOFT_FALLBACK_BUNDLES whitelist (session-* bundles, which have
  bundle-first/build-fallback in session-loaders.mjs and don't need
  to fail-fast).
- Source of truth: `scripts.bundle` `--outfile=` arguments. Adding
  a new bundle to that script auto-extends the integrity check —
  no parallel hardcoded list to maintain.
- Safety net: if package.json is unreadable, fall back to the
  legacy hardcoded set so the boot gate never goes silent.
- `assertPluginCacheIntegrity` now calls the new function. Public
  signature unchanged. start.mjs + the doctor surface are
  zero-touch — both consume the same algorithmically-derived set.

Tests (extend tests/core/cli.test.ts per CONTRIBUTING):
- "Algo-D4 algorithmically requires hooks/security.bundle.mjs" —
  the headline #558 regression: with security bundle missing on a
  fakeRoot, integrity must report ok=false (pre-558 hardcoded check
  vacuously passed).
- "Algo-D4 derivation reads scripts.bundle outfiles" — synthetic
  package.json proves a future hooks/foo.bundle.mjs is auto-gated,
  while soft-fallback session-db.bundle.mjs is correctly excluded.
- "Algo-D4 preserves the legacy hardcoded contract" — anti-
  regression pin: every entry in v1.0.126's hardcoded list is still
  in the algorithmic set. Strictly additive refactor.

Verified: 152/152 cli.test.ts tests pass (4 new Algo-D4 + 4
pre-existing plugin-cache + 144 unrelated). typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:30:50 +03:00
Mert Koseoglu 30b4891840 Merge remote-tracking branch 'origin/next' into v126/algorithmic-defenses 2026-05-13 18:17:56 +03:00
Plamen Ivanov a510c37eca Fix Windows path compatibility in ctx-debug.sh (#553)
* Fix Windows path compatibility in ctx-debug.sh

* Fix NODE_PATH MSYS2→Windows conversion in ctx-debug.sh

NODE_PATH was set to an MSYS2-style path (/d/Coding/...) that native
Node.js could not resolve, causing all better-sqlite3 and server
resolution checks to fail silently. Added PLUGIN_ROOT to the Windows
path bridge block so its cygpath -m conversion feeds NODE_PATH.

Rejected: hardcode PLUGIN_ROOT to Windows path | brittle, breaks
  portable runs
Rejected: remove NODE_PATH override entirely | would break non-local
  node_module resolution in adapters
Rejected: delay NODE_PATH export until after path bridge | same end
  result, less visible

Constraint: Git Bash's /d/ paths are invisible to native Windows Node.js
Constraint: Node.js on Windows silently treats unknown paths in
  NODE_PATH as no-ops — no error, just module not found
Confidence: high
Scope-risk: narrow
Directive: Any future MSYS2 path stored in a variable and passed to
  node must be cygpath -m converted first.
Directive: NODE_PATH export happens before the bridge block — the
  bridge re-exports it with the converted PLUGIN_ROOT value.
Tested: diagnostic now passes 27/30 (was 22/30), stderr clean

* Remove accidentally committed pnpm-lock.yaml

pnpm-lock.yaml was auto-generated by pnpm install during debugging
and committed by accident in a32bd37. The project uses npm, not
pnpm — this file is irrelevant and should not be tracked.

* Fix comment

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Fix indentation

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Mert Köseoğlu <bm.ksglu@gmail.com>
2026-05-13 18:12:56 +03:00
Mert Koseoglu df561af0a8 feat(start): plugin-cache integrity check derived from package.json files[] (algo defense 5 of 6)
#550: a partial install (interrupted npm install, broken marketplace
pull, half-finished /ctx-upgrade) leaves start.mjs spawnable but a
boot-critical sibling (server.bundle.mjs, cli.bundle.mjs,
hooks/<event>.mjs) missing. Today the MCP child dies silently
downstream — the user sees an opaque "MCP server failed to start" with
no actionable signal pointing at the missing files.

scripts/plugin-cache-integrity.mjs (new, ships in package.json files[])
exposes:

  - derivePluginManifest({ pkg, pluginRoot }) — reads files[] from the
    supplied pkg, expands directories recursively, returns the
    relative file list. Algorithmic: adding a new entry to files[]
    auto-extends manifest coverage. No parallel hardcoded list to
    maintain.

  - assertPluginCacheIntegrity({ pluginRoot }) — verifies each entry in
    a frozen REQUIRED_RUNTIME_SIBLINGS list (server.bundle.mjs,
    cli.bundle.mjs, the 5 hook scripts) exists. Pure: returns
    `{ ok, missing }` — the caller decides the surface (fail-fast at
    boot vs. doctor diagnostic).

  - formatPartialInstallReport({ pluginRoot, missing }) — the
    structured stderr block start.mjs emits on !ok. Marker
    `CONTEXT_MODE_PARTIAL_INSTALL` lets external monitoring grep for
    the exact failure mode.

start.mjs wiring: runs AFTER the existing heal layers (so missing
files they can fix get a chance first), BEFORE
`import("./server.bundle.mjs")`. On !ok, emits the structured report
and exits 2 instead of letting the downstream import surface the
opaque error. Skipped under VITEST so the repo's own test invocations
against in-tree start.mjs don't fail when running before
`npm run build` produces the bundles.

The helper is intentionally a separate `.mjs` (not src/util/*.ts) so
start.mjs (which ships as raw JS for cold-boot speed) can `await import`
it without a TS toolchain. The same `.mjs` is consumable by src/cli.ts
for D5's doctor surface.

15-adapter universality: start.mjs is the single MCP entry for every
adapter. One change here protects all 15.

Reproduce evidence (RED before GREEN):

  FAIL tests/core/cli.test.ts > start.mjs CLI self-heal > scripts/plugin-cache-integrity.mjs derives expected files from package.json files[]
  Error: Failed to resolve import "../../scripts/plugin-cache-integrity.mjs"

  FAIL tests/core/cli.test.ts > start.mjs CLI self-heal > start.mjs invokes assertPluginCacheIntegrity with stderr + exit 2 on failure (Algo-D4)
  AssertionError: expected start.mjs to contain "plugin-cache-integrity.mjs"

  FAIL tests/core/cli.test.ts > start.mjs CLI self-heal > scripts/plugin-cache-integrity.mjs ships in npm tarball (package.json files[])
  AssertionError: expected [ 'build', 'hooks', 'configs', …(21) ] to include 'scripts/plugin-cache-integrity.mjs'

5 RED tests, all GREEN post-fix. Full suite: 3199 pass / 8 baseline
opencode failures (unchanged). typecheck: PASS.

RED→GREEN: tests/core/cli.test.ts:1066-1156
2026-05-13 18:03:10 +03:00
Mert Koseoglu ceaec16eff fix(postinstall): gate hook-normalization heal with isGlobalInstall (#531 fix-of-fix)
CI run 25734987495 (windows-latest) failed on `npm run build` at the
`assert-asymmetric-drift` step with:

  asymmetric-drift: FAIL
    - .claude-plugin/plugin.json args[0] is
      "D:/a/context-mode/context-mode/start.mjs" but must equal
      "${CLAUDE_PLUGIN_ROOT}/start.mjs".

Root cause: scripts/postinstall.mjs section 4 ("Hook normalization at
install time (#414)") calls `normalizeHooksOnStartup` which substitutes
`${CLAUDE_PLUGIN_ROOT}` with an absolute pluginRoot path. Section 4's
only existing guard was a `TMPDIR_UPGRADE_RE` check that catches
/ctx-upgrade staging but does NOT catch contributor / CI installs.

On Windows CI the pipeline runs:
  1. `npm install`  → triggers `npm run postinstall` → section 4 runs
                      against the cloned repo, rewriting source-tracked
                      `.claude-plugin/plugin.json` args[0] from the
                      placeholder to `D:/a/.../start.mjs`.
  2. `npm run build` → invokes `scripts/assert-asymmetric-drift.mjs` (the
                       new Issue #531 invariant). It reads the now-mutated
                       file, sees drift, exits 1, build fails.

Fix: gate section 4 with `isGlobalInstall()` — the same heuristic
section -1 already uses ("npm_config_global=true" AND no `.git` walking
up the tree). A contributor's `npm install` from a clone (and CI checkouts)
always have `.git` → isGlobalInstall returns false → section 4 skips →
source files stay untouched. Real `npm install -g context-mode` is
unaffected: no `.git` near the cache dir → guard passes → heal runs.

Test coverage:
- New regression test "postinstall.mjs DOES NOT mutate source-tracked
  plugin.json when run from a clone (Windows CI regression)" in
  tests/scripts/asymmetric-drift-assert.test.ts. The test runs the REAL
  postinstall.mjs (not a mock) against a temp clone-like layout with
  `.git` and `node_modules` siblings, asserts plugin.json args[0] is
  STILL the placeholder. Any future regression that lets section 4
  mutate source files surfaces immediately as a vitest failure.

Verified locally:
  $ npm run build  → all bundles OK + asymmetric-drift OK + exit 0
  $ npx vitest run tests/scripts/asymmetric-drift-assert.test.ts
    Test Files  1 passed (1)
    Tests       8 passed (8)
2026-05-12 16:46:29 +03:00
Mert Koseoglu 4da170e010 fix(ci): assert-asymmetric-drift reads .mcp.json.example after #531 untrack
After the #531 architectural untrack (commit 9261377), .mcp.json is no
longer tracked in source (added to .gitignore, removed from package.json
files[]). CI checkouts therefore don't have the file, and the asserter
fails with 'missing: .mcp.json' on all 3 OS in the Build step.

Fix: switch the source-tracked half of the invariant from .mcp.json
to .mcp.json.example (the canonical template that contributors copy
locally). Keep an optional local-.mcp.json check that fires only if
the file exists — contributor convenience, no CI impact.

Symmetry now holds across:
  - .mcp.json.example (source template)
  - .claude-plugin/plugin.json (Claude Code primary read path)
  - cli.ts upgrade() write target (verified by existing test)
  - package.json files[] guard ('MUST NOT contain .mcp.json' — tests/core/cli.test.ts)

Verified: 7/7 asymmetric-drift-assert tests pass + local script run OK.
2026-05-12 13:06:28 +03:00
Mert Koseoglu 1b872b0bdb merge: v122/issue-533-conda-python-a99ae59d 2026-05-12 12:48:30 +03:00
Mert Koseoglu beb72dee6b fix(install): postinstall.mjs runs healMcpJsonArgs alongside #523 heal (closes #531 partial — 10 of 10)
Closes the escape-hatch path for users broken by the #253/aea633c bare
./start.mjs regression or by /ctx-upgrade tmpdir leak. When MCP is dead
(because .mcp.json is poisoned and Claude Code can't spawn the child)
the only recovery path is `npm install -g context-mode` whose postinstall
MUST heal both sibling files.

Wires Layer 6 heal into the existing #523 per-entry loop so both
.mcp.json and .claude-plugin/plugin.json drift heals from a single
npm install. Per-call try/catch — one poisoned entry must not block
heals on others. Combined stderr summary line mentions both issue
numbers so users grep-find the heal in the install transcript.

This completes Issue #531 fix slices 2-10. With slice 1 (committed
at 2a9cabf), the full 10-slice vertical is now landed:
  1. source .mcp.json template fix         — 2a9cabf
  2-6. healMcpJsonArgs module               — 4362fa5
  7. start.mjs Layer 5b wiring              — 0fe1078
  8. cli.ts upgrade() assertion             — 666b556
  9. CI invariant (asymmetric-drift)        — 951cd92
  10. postinstall.mjs Layer 6 wiring        — this commit
2026-05-12 12:41:10 +03:00
Mert Koseoglu 951cd925f6 fix(install): CI invariant prevents future .mcp.json / plugin.json asymmetric drift (closes #531 partial — 9 of 10)
Architectural guardrail that prevents the class of bug that caused #531.
The repo ships TWO sibling files carrying MCP server args:
  1. .mcp.json                  (Claude Code reads at plugin load)
  2. .claude-plugin/plugin.json (used by Cursor adapter)

v1.0.118 fixed .mcp.json (#411). v1.0.119 fixed plugin.json AND added
self-heal — but ONLY for plugin.json. Asymmetric coverage. Then commit
aea633c (#253, 2026-04-13) regressed .mcp.json to bare ./start.mjs and
there was no invariant to catch it. Fresh installs broke for a full
release cycle.

This invariant has two layers:
  - tests/scripts/asymmetric-drift-assert.test.ts — vitest source-tree check
  - scripts/assert-asymmetric-drift.mjs — build-chain check, wired into
    npm run build alongside assert-bundle so regressions surface in CI
    before publish

Any future commit that rewrites either sibling without rewriting the
other fails loud — no more silent #531-class regressions.
2026-05-12 12:39:52 +03:00
Mert Koseoglu 4362fa5376 fix(install): healMcpJsonArgs deep module for .mcp.json drift (closes #531 partial — 2-6 of 10)
Asymmetric-heal sibling of healPluginJsonMcpServers (#523). v1.0.119
healed .claude-plugin/plugin.json but missed the sibling .mcp.json —
same plugin, same drift class, different file.

Detects two drift shapes:
  1. Bare relative ./start.mjs (#253/aea633c regression — fresh-install
     class, the slice 1 commit fixed the source template).
  2. Tmpdir-prefixed <...>/context-mode-upgrade-<digits>/start.mjs
     (mirrors healPluginJsonMcpServers's #523 tmpdir class for
     /ctx-upgrade tmpdir poisoning).

Both rewrite to the literal ${CLAUDE_PLUGIN_ROOT}/start.mjs placeholder
Claude Code resolves at load-time.

Same regex, same placeholder, same traversal guard as #523. Only
difference: target is <pluginRoot>/.mcp.json (flat shape, no
.claude-plugin/ subdir) and structure is .mcpServers.<pluginName>.args[].

Slices 2-6 of 10:
  - Slice 2: rewrites bare relative ./start.mjs
  - Slice 3: rewrites tmpdir-prefixed paths (POSIX + Windows backslash)
  - Slice 4: idempotent on healthy placeholder
  - Slice 5: traversal guard refuses paths outside pluginCacheRoot
  - Slice 6: preserves unrelated mcpServers entries
2026-05-12 12:36:32 +03:00
Mert Koseoglu a910c42be3 fix(heal): override conda PYTHON for better-sqlite3 build (closes #533)
scripts/heal-better-sqlite3.mjs spawned `npm install better-sqlite3`
and prebuild-install without overriding PYTHON. When a user has
Anaconda/Miniconda's python3 first on PATH (common macOS data-science
setup), node-gyp picked it up via its `python3` PATH fallback and
better-sqlite3's native build died on Node 26 arm64.

node-gyp's PYTHON resolution order (verified against
nodejs/node-gyp main/lib/find-python.js — see `const checks = [...]`):
  1. --python CLI flag                ← we now set via npm_config_python
  2. env.PYTHON                       ← we now pin to safe interpreter
  3. `python3` on PATH                ← conda hijacks this slot
  4. `python` on PATH

Fix — five interlocking pieces, mapped to PRD slices:

  Slice 1: resolveSafePython() — exported helper, dep-injected for unit
    test. On darwin returns /usr/bin/python3 when it exists, else null.
    Filters /opt/anaconda*, /opt/miniconda*, miniforge, .conda, conda
    prefixes. On linux walks PATH and picks first non-conda python3.

  Slice 2: package-missing branch now passes a sanitised env to
    execFileSync — PYTHON + npm_config_python pinned to safe python,
    CONDA_PREFIX / CONDA_DEFAULT_ENV / CONDA_EXE / CONDA_PROMPT_MODIFIER
    / CONDA_SHLVL / CONDA_PYTHON_EXE all deleted, /usr/bin prepended
    to PATH on darwin so sub-scripts that shell `python3` unqualified
    still resolve to system python.

  Slice 3: Layer A spawnSync (prebuild-install fast path) now receives
    the same childEnv instead of a bare `{ ...process.env }` spread.
    Layer B execSync also receives childEnv.

  Slice 4: stderr breadcrumb when conda detected — single line naming
    the chosen PYTHON, gated on isCondaActive() so users without conda
    see no noise.

  Slice 5: new reason code "python-conda-blocked" returned when conda
    is active AND no safe python fallback found (rare — stripped-down
    Linux images / Docker base images without system python). Lets
    /ctx-upgrade render conda-specific remediation in a future patch.

tests/util/heal-better-sqlite3-python.test.ts — 10 tests covering:
  - resolveSafePython() exported and behaves on darwin
  - conda path filter (4 representative conda layouts rejected)
  - null fallback when /usr/bin/python3 absent
  - package-missing branch passes env (not bare process.env spread)
  - CONDA_* keys stripped
  - /usr/bin prepend on darwin
  - Layer A spawnSync env wired to safe env
  - stderr breadcrumb mentions conda + python path
  - python-conda-blocked reason code present

All 10 GREEN. Full util suite (110 tests) passes. Typecheck clean.

Refs: #533
2026-05-12 12:22:15 +03:00
Mert Koseoglu 1e73a13d19 fix(codex): make plugin discoverable via .agents/plugins/marketplace.json (#525)
PR #525 (tedjy971) reported that context-mode never shows up in Codex
CLI's `/plugin` listing despite shipping a `.codex-plugin/marketplace.json`.
The reported claims were verified against the Codex Rust source in
refs/platforms/codex and OpenAI's published docs — all three load-bearing
assertions hold:

  1. Codex reads `MARKETPLACE_MANIFEST_RELATIVE_PATHS` =
     `[.agents/plugins/marketplace.json, .claude-plugin/marketplace.json]`
     (codex-rs/core-plugins/src/marketplace.rs:21). `.codex-plugin/
     marketplace.json` is NOT in this list — Codex never opens it.

  2. The local-plugin source `path` must be `./<subdir>`, not `./`.
     Codex's `resolve_local_plugin_source_path` (marketplace.rs:502-518)
     does `path.strip_prefix("./")` then rejects empty results with
     `"local plugin source path must not be empty"`. Our shipped
     `.claude-plugin/marketplace.json` uses `source: "./"`, which hits
     this rejection. The error is swallowed silently at marketplace.rs:
     446-452 via `warn!(... skipping marketplace plugin that failed to
     resolve)`, so `codex plugin marketplace add` succeeds with exit 0
     but the plugins vec is empty and the user sees nothing in /plugin.

  3. `${CODEX_PLUGIN_ROOT}` / `${CLAUDE_PLUGIN_ROOT}` placeholders are
     NOT interpolated by Codex (upstream openai/codex#19582 OPEN). Grep
     of codex-rs/core-plugins/src/ confirms zero `interpolat*` /
     `expand_env*` / `envsubst*` logic in non-test source code.

These were also corroborated by OpenAI's own docs at
https://developers.openai.com/codex/plugins/build which spell out:
  - "a repo marketplace at $REPO_ROOT/.agents/plugins/marketplace.json"
  - "source.path points to that plugin directory with a `./`-prefixed
    relative path" (example: `./plugins/my-plugin`)
  - "Only plugin.json belongs in .codex-plugin/"

End-to-end verification with Codex CLI v0.130.0:
  $ codex plugin marketplace add /path/to/context-mode
    Added marketplace `context-mode`.
  No silent-drop warning emitted by the warn! path now that source.path
  resolves to a real plugin tree.

Changes:

  1. Add .agents/plugins/marketplace.json with canonical schema:
       { name, interface: { displayName }, plugins: [{
         name, source: { source: "local", path: "./plugins/context-mode" },
         policy, category
       }] }
     Matches the Rust serde shape at marketplace.rs:694-744 exactly.

  2. Add plugins/context-mode symlink → repo root, so Codex's
     `resolve_local_plugin_source_path` lands on a directory that
     contains `.codex-plugin/plugin.json` (the per-plugin manifest path
     Codex's load_plugin_manifest expects).

  3. Delete .codex-plugin/marketplace.json (dead — Codex never reads it,
     keeping it ships dead bytes and misleads contributors).

  4. Remove .codex-plugin/marketplace.json from version-sync.mjs targets
     and from the `version` lifecycle git-add list. The Codex marketplace
     schema has no top-level `version` field per the Rust serde struct,
     so the new .agents/plugins/marketplace.json doesn't need syncing.
     Per-plugin version still flows through .codex-plugin/plugin.json
     which remains in the targets list.

  5. Add tests/codex/marketplace-layout.test.ts (6 tests) that mirror
     Codex's exact discovery logic — strip_prefix("./"), non-empty
     check, plugin.json presence, placeholder absence — so future drift
     produces a deterministic local failure long before users hit it.

  6. Update tests/plugins/codex-manifest.test.ts and tests/scripts/
     version-sync.test.ts to reflect the deletion (with comments
     pointing at the Rust line numbers for future maintainers).

Why we shipped our own fix instead of merging tedjy971's PR #525:
Same end-state, more rigor — full Rust-source citations, mirror-the-
deserializer tests, e2e verification with the v0.130.0 CLI. Their
analysis pointed us at the right problem; this commit gives the project
a durable test contract so a regression can't slip past CI silently
like the original bug did.

Credit: tedjy971's PR #525 surfaced the issue and the canonical layout.
2026-05-11 17:32:42 +03:00
Ahmet Selçuk Özyurt 13d134270c fix(ctx-upgrade): stop baking tmpdir path into hooks.json (#528, Windows hotfix)
Fifth heal in the post-/ctx-upgrade cascade family (after v1.0.114 enabledPlugins, v1.0.116 settings.json, v1.0.119 plugin.json mcpServers.args). `/ctx-upgrade` was poisoning `hooks/hooks.json` with the upgrade tmpdir's absolute path via `scripts/postinstall.mjs` calling `normalizeHooksOnStartup({pluginRoot: pkgRoot})` where pkgRoot was the tmpdir. After tmpdir cleanup, every hook fired MODULE_NOT_FOUND on Windows.

Fix:
- `scripts/postinstall.mjs` — skip `normalizeHooksOnStartup` when pkgRoot matches the `context-mode-upgrade-<digits>` regex
- `src/cli.ts` — after the in-place cpSync, call `normalizeHooksOnStartup` against the REAL plugin dir; also self-heals legacy poisoned configs

Reviewed by 5 parallel architect-level agents:
- Claim verifier reproduced the bug locally (Windows-only via `platform !== 'win32'` guard in normalize-hooks.mjs:149)
- Solution correctness: regex SAFE across 15 adversarial paths (macOS/Linux/Windows tmpdir conventions all covered)
- Windows specialist: regex bulletproof, heal sequence sound
- Architect: 5th heal correctly placed, wire complete (boot+postinstall+upgrade)
- QA: test slice 7b is behavioral (real spawn, real script). Coverage of cli.ts heal + legacy self-heal will be added in a follow-up.

Thanks @asozyurt for the staff-grade diagnosis: tracing the chain from cli.ts:771 → postinstall normalize → cpSync poison flow, file:line citations, and reproducing on Windows 11 manually.
2026-05-11 17:08:08 +03:00
Mert Koseoglu e9a7e69629 fix(assert-bundle): Windows entry-point detection (closes #525 windows ci)
CI run 25655545561 windows-latest failed on
  tests/scripts/assert-bundle.test.ts > exits 0 on a clean fixture bundle
  tests/scripts/assert-bundle.test.ts > exits 1 when given polluted fixture

with "expected '' to match /OK/" and "expected +0 to be 1" — assertions
that only fire if the script produces no output and exits 0 by default.

Root cause: the direct-invocation check at the bottom of the script used
string equality between two values that diverge on Windows:

  import.meta.url            = "file:///C:/path/to/assert-bundle.mjs"
  `file://${process.argv[1]}` = "file://C:\\path\\to\\assert-bundle.mjs"

The first has triple-slash + forward separators (URL form). The second
has double-slash + backslashes (template-literal of the OS path). They
never compare equal on Windows, the fallback `endsWith` likewise never
matches (URL has `/`, argv has `\`), so `isDirectInvocation` was always
false → main() never ran → script exited 0 silently.

The G3 invariant check (`npm run assert-bundle`) was therefore a no-op
on the windows-latest runner — bundles could ship polluted with the
`Dynamic require of` shim and the guardrail wouldn't catch it. This
also explained why the test "current production bundles pass the
assert-bundle clean check" passed-by-accident on Windows: exit 0 from
a silent no-op satisfies `expect(r.status).toBe(0)`.

Fix: use `pathToFileURL(process.argv[1]).href` so the entry-point
comparison is OS-agnostic. Both sides are now normalized to the
canonical `file:///C:/...` form on Windows and `file:///...` on POSIX.

Verified locally on macOS:
  $ npm run bundle && npx vitest run tests/scripts/assert-bundle.test.ts
   Test Files  1 passed (1)
        Tests  4 passed (4)

Bundles are intentionally not rebuilt here — CI step `npm run bundle`
regenerates them from source on every run.
2026-05-11 10:21:57 +03:00
Mert Koseoglu 675e63cc24 chore(ci): assert-bundle catches backtick + whitespace evasions
Review surfaced two evasion gaps in the G3 invariant regex:
1. Template-literal form: require(`node:fs`) slipped through
2. Whitespace expansion: require   (   "node:fs"   ) slipped through

Extend both patterns to allow optional whitespace around require/__require
and the parenthesis, plus accept backtick (`) as a valid quote character.
2026-05-11 10:06:29 +03:00
Mert Koseoglu d3574d564e merge: v119/bundle-assert-g3 — post-build invariant CI assert (G3 architectural guardrail) 2026-05-11 09:51:52 +03:00
Mert Koseoglu 46ac5c6b24 merge: v119/pr-512-codex-marketplace — Codex marketplace + version-sync targets (extends PR #512 by @tedjy971) 2026-05-11 09:51:42 +03:00
Mert Koseoglu 94d72c1e59 merge: v119/issue-523-heal-layer-5 — healPluginJsonMcpServers + Layer 5b boot heal (closes #523) 2026-05-11 09:51:38 +03:00
Mert Koseoglu 49d1a8ab42 fix(install): start.mjs + postinstall wire Layer 5b plugin.json heal (closes #523 partial — 8 of 8)
Slice 8 — escape hatch for already-broken users. Slice 7 prevents the
bug going forward (cli.ts upgrade() asserts pre-success), but anyone
already poisoned by v1.0.118's /ctx-upgrade has a dead MCP server and
no /ctx-upgrade to recover with. Two recovery paths:

  1. start.mjs HEAL block: every MCP boot, after HEAL 3 + HEAL 4, also
     iterate installed_plugins.json's plugins["context-mode@context-mode"]
     entries and run healPluginJsonMcpServers on each entry's installPath.
     The next time Claude Code spawns the plugin, args[0] is healed and
     subsequent boots work.

  2. scripts/postinstall.mjs: same iteration after the v1.0.114 +
     v1.0.116 heals. Triggered by `npm install -g context-mode@1.0.119`
     — the universal escape hatch that runs even when MCP is dead.

Per-entry try/catch wraps each heal call so one poisoned entry cannot
block heals on the others. Outer try/catch around the dynamic import
preserves the "never block MCP boot" contract.

5 vertical TDD assertions in tests/util/start-mjs-self-heal.test.ts:
  - imports healPluginJsonMcpServers from the shared module
  - heal call lives inside HEAL 3+4 try-block (co-located, single import)
  - iterates ALL cache entries via installPath (multi-version support)
  - 3+ try/catch layers (defensive posture)
  - postinstall.mjs also wires Layer 5b (escape hatch)

Cumulative defense (v1.0.113→v1.0.119):
  - v1.0.113: start.mjs no-poison + getProjectDir env-chain rejection
  - v1.0.114: HEAL 3+4 + ctx-upgrade asserts (installed_plugins.json)
  - v1.0.115: transcript heuristic
  - v1.0.116: HEAL 4 targets settings.json (the file CC actually reads)
  - v1.0.119: HEAL 5b targets plugin.json mcpServers args (Issue #523)
2026-05-11 09:45:59 +03:00
Mert Koseoglu 308a80f9a3 fix(codex): add .codex-plugin/* to version-sync targets (extends PR #512 by @tedjy971)
Without this, every release bump would drift `.codex-plugin/plugin.json`
and `.codex-plugin/marketplace.json` further out of sync with the
canonical `package.json:version`. Same hazard previously hit
`.cursor-plugin/plugin.json` (stuck at v1.0.111 vs current v1.0.118)
because it was missing from BOTH the targets[] in version-sync.mjs
and the npm `version` lifecycle `git add` list.

Two-part fix:
- `scripts/version-sync.mjs` → append the two Codex manifests to
  `targets[]` (so the rewrite touches them).
- `package.json` → extend the `version` script's `git add` list to
  include the two Codex manifests AND `.cursor-plugin/plugin.json`
  (the cursor manifest had the same defect; without it staged, the
  rewrite is silently discarded by the npm `version` commit).

End-to-end test in tests/scripts/version-sync.test.ts copies all
manifests into a scratch repo with a synthetic version, runs the
script, and asserts every (version | metadata.version | plugins[].version)
field gets rewritten — catches future targets[] drift automatically.
2026-05-11 09:40:11 +03:00
Mert Koseoglu 550ca73204 fix(install): healPluginJsonMcpServers detects tmpdir-prefixed args[0] (closes #523 partial — 1 of 8)
Issue #523: /ctx-upgrade in v1.0.118 wrote .mcp.json with the
${CLAUDE_PLUGIN_ROOT} placeholder (#411 fix) but did NOT touch
.claude-plugin/plugin.json. On Windows + Claude Code, normalize-hooks
rewrites that file's mcpServers.args[0] to an absolute path. When
pluginRoot resolves to the upgrade tmpdir, the resulting plugin.json
carries <tmpdir>/context-mode-upgrade-<epoch>/start.mjs. After tmpdir
cleanup, MCP fails to spawn with ENOENT — and the user has no
/ctx-upgrade escape hatch.

Tracer-bullet slice — Layer 5 heal:
  - New healPluginJsonMcpServers() in scripts/heal-installed-plugins.mjs
  - Detects tmpdir-prefixed args[0] (epoch-pattern, OS-agnostic regex
    /[/\\]context-mode-upgrade-\d+[/\\]/) ending in start.mjs
  - Rewrites to literal ${CLAUDE_PLUGIN_ROOT}/start.mjs placeholder
  - Path-traversal guard mirrors HEAL 3 (refuses outside cache root)
  - Best-effort posture, never throws — same contract as healInstalledPlugins

Sibling of #411 — closes the gap that fix left in plugin.json.
2026-05-11 09:40:09 +03:00
Mert Koseoglu 0b5717c563 fix(heal): install better-sqlite3 when package directory is missing (closes #514 partial — 2 of 4)
scripts/heal-better-sqlite3.mjs treated the package-missing branch as a
no-op — it returned {healed:false, reason:'package-missing'} and trusted
ensure-deps's install branch to recover. On Node 26, ensure-deps's npm
install also silently skipped the package because it lived under
optionalDependencies. Result: both healers fell through to a manual
remediation hint that /ctx-upgrade never surfaced.

Take ownership of the branch: when node_modules/better-sqlite3 does not
exist, run `npm install better-sqlite3 --no-optional --no-save
--no-audit --no-fund` via execFileSync with a 180s timeout. --no-optional
defends against future regressions if anyone reverts package.json. On
success, fall through into the existing prebuild-install / npm install /
stderr-advice flow so binding-missing recovery still runs.

tests/util/heal-better-sqlite3.test.ts — guards no-early-return,
--no-optional usage, execFileSync+timeout, and continuation into the
binding-missing path.

Refs: #514
2026-05-11 09:39:20 +03:00
Mert Koseoglu 61f85398b1 chore(ci): slice 1 — assert-bundle script detects 'Dynamic require of' shim
G3 guardrail (Issue #511 class). Adds scripts/assert-bundle.mjs which
scans bundle files for the esbuild throwing-require shim and exits 1
if any forbidden pattern is matched.

Tracer-bullet RED→GREEN: fixture bundle containing the shim string is
correctly rejected.
2026-05-11 09:36:36 +03:00
Mert Koseoglu 4337f472aa fix(install): heal settings.json.enabledPlugins (v1.0.116 hotfix)
v1.0.114's heal targeted installed_plugins.json.enabledPlugins, which
is what we control. But Claude Code's plugin loader actually reads the
truth from ~/.claude/settings.json.enabledPlugins. After every
/ctx-upgrade, Claude Code's plugin manager seems to clear that key
(likely on version-mismatch detection), so the plugin appears disabled
and /reload-plugins returns 0 plugins. v1.0.114 self-heal silently
fixed the wrong file.

Fix:
- New healSettingsEnabledPlugins() in scripts/heal-installed-plugins.mjs.
- Wired into start.mjs HEAL 4 (every MCP boot) and scripts/postinstall.mjs
  (every npm install -g).
- Respects explicit user opt-out: if the key is `false`, leaves it alone.
- Idempotent: no rewrite when key is already true.

Tests: 5 new vertical TDD slices in tests/util/heal-installed-plugins.test.ts:
- creates section + adds key when settings is missing the section
- adds key when section exists but ours is missing
- idempotent — no rewrite when already true
- respects user opt-out (false stays false)
- silent skip when settings.json doesn't exist

2,821 pass / 8 baseline opencode failures / 24 skipped. Typecheck clean.

Cumulative defense (v1.0.113→v1.0.114→v1.0.115→v1.0.116):
- v1.0.113: start.mjs no-poison + getProjectDir env-chain rejection
- v1.0.114: HEAL 3+4 + ctx-upgrade asserts (wrong file)
- v1.0.115: transcript heuristic
- v1.0.116: HEAL 4 finally targets the RIGHT file (settings.json)
2026-05-10 19:12:16 +03:00
Mert Koseoglu 8c045f96ed fix(install): npm postinstall self-heal for poisoned installed_plugins.json (v1.0.114)
v1.0.113's /ctx-upgrade poisoned ~/.claude/plugins/installed_plugins.json
in two ways: (a) per-entry version drifted from the cache directory's
plugin.json version, and (b) the top-level enabledPlugins[<key>] was
emptied. Claude Code's plugin loader then refuses to load context-mode,
killing MCP — and with MCP gone the user can no longer run /ctx-upgrade
to recover. The escape hatch is `npm install -g context-mode@1.0.114`,
which executes regardless of plugin-loader state.

Adds a shared heal module (scripts/heal-installed-plugins.mjs) that:
  - HEAL 3: rewrites entry.version from each cache dir's plugin.json
  - HEAL 4: ensures enabledPlugins[<key>] is set when missing/empty
  - returns a result object — never throws, best-effort posture

Wires it into scripts/postinstall.mjs behind an isGlobalInstall() guard
(npm_config_global=true AND no nearby .git) so contributor `npm install`
runs do not rewrite their HOME registry. Emits exactly one ASCII stderr
summary line per run: healed / no-heal-needed / no-Claude-Code-registry.

Coordination: this module is the single source of truth; start.mjs HEAL
3+4 should import from `./scripts/heal-installed-plugins.mjs` so install-
time and runtime heals stay aligned.

Tests:
  - tests/util/heal-installed-plugins.test.ts (9): HEAL 3 sync, HEAL 4
    create/rewrite/idempotent, no-registry skip, healthy no-op, path-
    traversal guard, native sep, package.json files[] guard.
  - tests/util/postinstall-heal.test.ts (4): integration via spawnSync
    against a staged npm-install layout — non-global skip, poisoned
    registry repair, no-Claude-Code silent OK, already-healthy no-op.

Full suite: 2824 tests, 8 failed (pre-existing opencode baseline),
2787 passed, 24 skipped. +13 tests, 0 regressions.
2026-05-10 18:31:53 +03:00
Mert Koseoglu 43c63cb434 Merge remote-tracking branch 'origin/next' into v108-fixes 2026-05-10 14:52:33 +03:00
Mert Koseoglu fe421d7816 feat(scripts): tsx production-proof for ctx_stats narrative renderer
Slice 7 — invokes the EXACT formatReport function the production
ctx_stats handler calls (src/server.ts:2636) with a fixture mirroring
the Mert-approved demo (67 days × 128 conversations × 356 MB lifetime,
1277 captures × 12 days × 1552 KB rescue conversation, 22 preferences
across 6 projects) and prints the rendered output verbatim.

Output matches the target line-for-line including:
- Opener: 'Across 67 days you ran 128 conversations in Claude Code.'
- Section 1: started 28 Apr 2026 at 12:16 (Europe/Istanbul) + /compact
  rescue at 9 May 2026 at 20:54 (Europe/Istanbul) + horizontal timeline
  with rescue ◆ at column 50, peak █ at column 28
- Section 2: 1,277 things — all 18 categories rendered, no truncation
- Section 3: receipt rows for this conversation + all real work
- Section 4: $1399.73 on Opus 4 + 70 months Cursor / 7.0 months Claude
  Max / 19 weekends + ~$13997 team scale + ~$76254/year
- Section 5: 22 preferences picked up across 6 projects
- Footer: locale en-TR · timezone Europe/Istanbul · v1.0.111

Run via: npx tsx scripts/prove-narrative-render.ts
2026-05-10 14:18:25 +03:00
Mert Koseoglu b465acd834 docs(omp): drop hardcoded version from install guide + prune redundant manifest field
The previous manual install path pasted a literal `"version": "1.0.111"`
into a JSON snippet for omp-plugins.lock.json. That number drifts
silently on every release — anyone reading the README a week from
now would copy a stale version into their lock file.

Verified upstream that the snippet was unnecessary in the first
place. The plugin loader at refs/platforms/oh-my-pi/packages/
coding-agent/src/extensibility/plugins/loader.ts:89-94 only consults
the lock file when a plugin is explicitly disabled:

    const runtimeState = runtimeConfig.plugins[name];
    if (runtimeState && !runtimeState.enabled) continue;

Plugins missing from the lock file load with default-enabled state.
So the manual install collapses to two commands: `cd ~/.omp/plugins`
+ `bun add context-mode`, then restart. No JSON to edit, no version
to pin.

Same logic eliminates the `omp.version` field we had been carrying in
the root package.json. The upstream loader stamps
`manifest.version = pluginPkg.version` from the top-level
package.json:version on every load (loader.ts:87), so duplicating it
inside the omp block adds a drift surface and zero signal. The
matching `pi` block follows the same convention, so consistent.

Drops the corresponding omp.version sync code from
scripts/version-sync.mjs — it can no longer drift if the field
doesn't exist.
2026-05-10 13:34:43 +03:00
Mert Koseoglu 2ddae394c4 feat(omp): plugin path with native hook enforcement (HookAPI tool_call/tool_result/session_start/session_before_compact)
Promotes OMP from MCP-only delivery to a proper plugin. `omp plugin
install context-mode` now wires programmatic enforcement equivalent to
Claude Code's PreToolUse/PostToolUse/PreCompact/SessionStart pipeline.

Verified end-to-end against the upstream OMP source cloned to
refs/platforms/oh-my-pi @ v3.20.1 (no LLM trust, every claim
file:line cited):

  - Manifest format: `omp` or `pi` field on root package.json
    Source: refs/.../extensibility/plugins/loader.ts:75
      `const manifest = pluginPkg.omp || pluginPkg.pi;`
    + line 82: `manifest.version = pluginPkg.version;` (loader stamps
      version from top-level pkg.version on load — explicit
      `omp.version` is belt-and-suspenders, kept synced by
      scripts/version-sync.mjs).

  - Install command: `omp plugin install <pkg>` runs
    `bun install <pkg>` inside ~/.omp/plugins per
    refs/.../extensibility/plugins/manager.ts:158, then reads
    `~/.omp/plugins/node_modules/<pkg>/package.json` for the manifest.

  - HookFactory contract: `(pi: HookAPI) => void` per
    refs/.../extensibility/hooks/types.ts:809.

  - Block return shape: `{ block?: boolean; reason?: string }` per
    refs/.../extensibility/hooks/types.ts:566.

  - Event payloads:
    - ToolCallEvent  (refs/.../hooks/types.ts:448): {toolName, toolCallId, input}
    - ToolResultEvent (refs/.../hooks/types.ts:461 onward): {toolName, toolCallId, input, content[], isError}

  - Example reference: refs/.../examples/hooks/permission-gate.ts.

What the plugin actually does:

  - tool_call: hard-blocks bash containing curl/wget/inline-fetch
    (`requests.get`, `http.get`, `Invoke-WebRequest`, etc.) — same
    pattern set as the Pi extension.
  - tool_result: feeds OMP-shaped events through the existing
    extractEvents pipeline → SessionDB at ~/.omp/context-mode/.
  - session_start: derives a stable 16-hex session id from
    sessionManager.getSessionFile() (or wall-clock fallback), runs
    7-day cleanup.
  - session_before_compact: persists a buildResumeSnapshot output via
    upsertResume + increments compact_count for resume-on-restart.

Reference parity:

  - Mirrors src/adapters/pi/extension.ts shape closely. OMP differs in
    two ways that justify a dedicated file:
      1. Storage at ~/.omp/context-mode/ via OMPAdapter (not ~/.pi/)
      2. OMP has native MCP via mcp.json — the Pi extension's
         mcp-bridge.ts is dead weight under OMP and is intentionally
         omitted here.
  - Mirrors src/adapters/openclaw/plugin.ts integration shape (root
    package.json field → built JS entry).

Smoke test (run locally before commit):
  - pkg.omp.hooks resolves to build/adapters/omp/plugin.js ✓
  - default export is a function ✓
  - 4 handlers register: session_start, tool_call, tool_result,
    session_before_compact ✓
  - tool_call({toolName: 'bash', input: {command: 'curl ...'}}) →
    {block: true, reason: '...'} ✓

Tests: tests/adapters/omp-plugin.test.ts adds 17 cases across 4 TDD
slices (routing, extraction, session lifecycle, resume snapshot). All
green. Full vitest run: 2642 passed, 20 skipped, 0 failed.

scripts/version-sync.mjs now also stamps package.json:omp.version
when running on `npm version` lifecycle so OMP manifest version
never drifts from top-level pkg.version (verified by simulating a
stale 0.0.0 value and watching it correct to current).

README updated:

  - OMP install section reordered: plugin path is now primary, with
    upstream file:line citations for the loader and block contract;
    MCP-only path retained as the alternative.
  - Hook coverage table (lines ~1024-1031): OMP rows promoted from
    "--" to ✓ (via tool_call event), etc.
  - Platform compatibility table: OMP PreToolUse/PostToolUse/
    SessionStart/PreCompact/CanBlockTools all marked Plugin.
  - Routing-enforcement note: OMP moved from non-hook list to
    hook-capable list.
  - All "OMP MCP-only / no hook integration" prose paragraphs
    rewritten.
2026-05-10 13:22:22 +03:00
Yicheng Sun 7b86ee5a20 feat(cursor): add Marketplace plugin packaging (#489)
* feat(cursor): add Marketplace plugin packaging

Mirror the Claude Code plugin layout for Cursor's plugin marketplace:

- .cursor-plugin/plugin.json: manifest pointing at ./configs/cursor/context-mode.mdc, ./skills/, ./hooks/cursor/hooks.json, and an MCP server entry running 'npx -y context-mode'.

- hooks/cursor/hooks.json: registers preToolUse, postToolUse, sessionStart, afterAgentResponse, and stop, all dispatched through 'npx -y context-mode hook cursor <event>' so users do not need a local clone.

- src/adapters/cursor/index.ts: doctor now detects plugin installs under ~/.cursor/plugins/{local,cache} and warns when both the plugin and a native .cursor/hooks.json register context-mode hooks.

- scripts/version-sync.mjs: keeps .cursor-plugin/plugin.json in lockstep with package.json.

- README.md, docs/platform-support.md: document the Marketplace install path alongside the existing manual install.

Refs #485

* feat(cursor): add plugin README + drop non-schema displayName field

- Add .cursor-plugin/README.md so the Marketplace tile has a dedicated landing page (project root README is unchanged).

- Remove 'displayName' from .cursor-plugin/plugin.json: the field is not in Cursor's plugin manifest schema (https://cursor.com/docs/reference/plugins) and would be flagged by the validator.

Validated all manifest keys against the official schema; no other extra fields. Cursor adapter test suite: 50/50 pass.

* feat(cursor): add Marketplace logo

Adds .cursor-plugin/assets/logo.png and references it via the manifest 'logo' field. Cursor resolves relative paths to raw.githubusercontent.com URLs at the commit SHA, so the Marketplace tile renders the snowflake icon directly from the repo.

* docs(cursor): add local-install quickstart for testers

Document the robocopy/symlink workflow so reviewers (and early adopters) can try the plugin from the repo before Marketplace acceptance. Calls out the Windows symlink limitation explicitly so testers do not waste time debugging mklink.

* docs(cursor): mark Marketplace plugin as work-in-progress until review

Per maintainer feedback: until Cursor's review team lists the plugin, the README needs an explicit 'work in progress' notice plus copy-pasteable local-install commands for both Windows (robocopy) and macOS/Linux (ln -s). Calls out the Windows symlink limitation directly so testers do not waste time debugging mklink.

Refs #485, #489

---------

Co-authored-by: Maxwell_sun <Maxwell_sun@noreply.gitcode.com>
Co-authored-by: Mert Köseoğlu <bm.ksglu@gmail.com>
2026-05-09 18:14:36 +03:00
Ben Younes 25a8f844e7 feat(ci): tier-2 E2E smoke scaffolding (#477) (#478)
* feat(ci): tier-2 E2E smoke scaffolding (#477)

Tier-1 mock harness in tests/pi-extension.test.ts already pins the
canonical ctx_* tool set and the #426 wiring, so registration regressions
are caught on every PR for free.

What that suite cannot catch is whether a real LLM, running through a
real host binary, actually invokes ctx_search / ctx_execute / ctx_index
and reports a positive token saving via ctx-stats. That gap is what
mystilleef has been QA-ing manually each release.

This commit lands the scaffolding for the tier-2 workflow proposed in
issue #477:

  - .github/workflows/tier2-e2e-smoke.yml: workflow_dispatch only matrix
    over pi / claude-code / opencode, with concurrency cancel, 15-minute
    timeout, max-tokens cap, and a header comment pointing maintainers at
    the Anthropic console for monthly spend caps. Cron is intentionally
    commented out until all three hosts go green twice in a row.

  - scripts/tier2-smoke/run-pi-smoke.sh: boots Pi headless on a fixture
    prompt that forces ctx_index + ctx_search + ctx_execute usage, then
    captures the structured ctx-stats payload.

  - scripts/tier2-smoke/assert-stats.mjs: host-agnostic assertion CLI
    used by every host in the matrix. Fails if any required ctx_* tool
    was not invoked, if tokens_saved is not positive, or if any tool
    reported an error.

  - scripts/tier2-smoke/fixtures/search-corpus.txt: the prompt itself,
    pinned in-repo so a future change to the fixture is reviewable.

  - tests/tier2-smoke-assert.test.ts: black-box vitest coverage for
    assert-stats.mjs so regressions in the gating logic are caught by
    tier-1 CI before any tier-2 run is even scheduled.

The claude-code and opencode jobs are placeholders for now — they exit
0 with a pointer to the issue. Pi lands first; the other two follow in
separate PRs once Pi is stable.

Refs #477 #426

* feat(ci): pin tier-2 to Haiku 4.5 + beef fixture + clarify CLI fallback

Three follow-ups on PR #478 review:

1. Pin model to claude-haiku-4-5-20251001 (workflow env + forwarded as
   ANTHROPIC_MODEL/PI_MODEL to the smoke runner). Same Anthropic family as
   Sonnet/Opus so tool-calling behavior tracks what real users hit, but
   ~5-10x cheaper per smoke run — keeps weekly cron tenable without
   degrading the signal. Header comment explicitly warns against swapping
   for Gemini/DeepSeek: tier-2 must test the model that actually ships,
   otherwise a passing smoke can mask a tool-routing regression in Claude.

2. Beef up scripts/tier2-smoke/fixtures/search-corpus.txt. Old fixture was
   ~6 lines, which made tokens_saved>0 flaky for reasons unrelated to a
   real bug (tiny corpus = no measurable savings). New fixture indexes
   README + CLAUDE.md + src/ + tests/ + workflow files, runs 7 batched
   ctx_search queries, and forces a ctx_execute pass that emits structured
   JSON. Big enough that ctx-stats reports a clearly positive saving.

3. Add explicit comment on the run-pi-smoke.sh fallback explaining that it
   only works because Pi and the bundled CLI share state under
   $HOME/.context-mode/. If a future Pi release sandboxes per-extension
   state, this fallback would silently return zeros — flagged so a future
   reader either keeps state shared via CTX_STATE_DIR or deletes the
   fallback and fails hard instead of masking.

Refs #477 #478

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:30:55 +03:00
Ben Younes c805410878 fix(pi): bridge MCP tools into pi.registerTool() so the LLM can call them (#426) (#472)
* fix(pi): bridge MCP tools into pi.registerTool() so the LLM can call them (#426)

Pi 0.73.x has no native MCP support — its README is explicit:

> No MCP. Build CLI tools with READMEs (see Skills), or build an
> extension that adds MCP support.

Without a bridge inside the context-mode Pi extension, the routing
block tells the LLM to call `ctx_execute` / `ctx_search` / etc. but
those tools never enter Pi's tool list and the LLM cannot reach them.
The reporter measured 18 sessions over 2 days: ~2,500 tokens of
system-prompt overhead per window, 0 actual ctx_* calls, 447 events
recorded but never retrieved. Net ROI on Pi was negative.

This adds a stdio JSON-RPC client (`MCPStdioClient`) plus a thin
bootstrap (`bootstrapMCPTools`) that:

  - spawns `server.bundle.mjs` as a long-lived MCP child,
  - performs the standard MCP handshake (initialize →
    notifications/initialized),
  - lists tools once via `tools/list`, and
  - registers each tool through `pi.registerTool({ name, label,
    description, parameters, execute })` so the LLM sees the canonical
    bare names (matching what hooks/core/tool-naming.mjs emits for
    Pi).

Each Pi `execute()` callback forwards into the MCP child via
`tools/call`. Errors are translated to `throw` (Pi's contract for
"tool failed") so the LLM sees the MCP server's diagnostic text.

Lifecycle:

  - Bridge bootstrap is fire-and-forget at extension load — the rest
    of the extension (session capture, hooks, slash commands) is not
    blocked by spawn / handshake latency.
  - `session_shutdown` terminates the child via SIGTERM.
  - A missing `server.bundle.mjs` or any spawn / handshake error is
    surfaced once on stderr, then the extension keeps running with
    only the existing hooks + commands. Defense-in-depth so the bridge
    can never break Pi sessions for users with broken installs.

## Why a JSON Schema parameters object instead of TypeBox

MCP `tools/list` returns JSON Schema. Pi's parameter validator accepts
JSON Schema directly (TypeBox just produces JSON Schema with extra
Symbol metadata for type inference). Passing the schema through
unchanged avoids a runtime translation pass and keeps the bridge a
true thin layer over the MCP protocol — what works in Claude Code,
Gemini CLI, and the other adapters now also works in Pi.

## No new runtime dependencies

Pure `node:child_process` + `node:path`. The `@earendil-works/pi-*`
packages are NOT pulled in as build deps — `pi` is typed structurally
as `any` (matching the existing src/pi-extension.ts style) and the
bridge only touches the documented `pi.registerTool()` shape.

## Tests

Added two new `describe` blocks in `tests/pi-extension.test.ts`:

  1. `MCPStdioClient` (5 tests) — wire-protocol contract pinned with
     fake stdio servers: id-matched responses, concurrent in-flight
     requests with out-of-order delivery, child-exit cancellation,
     timeout, non-JSON noise tolerance.
  2. `bootstrapMCPTools` (2 integration tests) — spawn the real
     `start.mjs` MCP server, assert that the canonical ctx_* set
     (`ctx_execute`, `ctx_execute_file`, `ctx_search`, `ctx_index`,
     `ctx_batch_execute`, `ctx_fetch_and_index`, `ctx_doctor`,
     `ctx_stats`, `ctx_purge`) is registered, and round-trip
     `ctx_index` through `tools/call` to confirm execute() forwards
     args and returns text.

## Test plan

  - [x] `npm run build`
  - [x] `npm run typecheck` clean
  - [x] `npm test` — 73 files, 2406 pass / 25 skipped / 0 fail
  - [x] `npx vitest run tests/pi-extension.test.ts` — 44 pass
        (37 pre-existing + 7 new for the bridge)
  - [x] see-real-bug repro: `pi.registerTool` count = 0 in installed
        binary (`/home/$USER/.nvm/.../context-mode/build/pi-extension.js`)
        on `next` @ 1f70bee, plus Pi README's "No MCP" stance, plus
        the issue reporter's 18-session measurements — all three
        agree.
  - [-] Live LLM tool-call probe in Pi: blocked — free-tier Gemini
        quota was exhausted on every available key during the fix
        session. The integration test exercises the same code path
        (real MCP server + the Pi-facing registerTool surface), so
        the regression contract is enforced from CI.

## Out of scope

  - Removing the MCP server stanza from the Pi install README. Once
    this lands, the `~/.pi/agent/mcp.json` step is still harmless but
    no longer load-bearing. Cleanup left to a docs-only follow-up.
  - In-process refactor of server.ts handlers. The subprocess bridge
    is the same model used by every other adapter; a refactor that
    inlines the handlers is its own scope.

Co-Authored-By: Ora Studio <noreply@oratelecom.net>

* fix(pi): address self-review findings on the MCP bridge (#426)

Three follow-up changes from the empirical self-review on PR #472:

1. **C1 HIGH — wiring not test-covered.**
   Phase A of the empirical review only failed because removing
   `src/pi-mcp-bridge.ts` produced an import error, not because the
   bug reproduced behaviorally. If a future refactor dropped the
   `bootstrapMCPTools(pi, …)` call from `src/pi-extension.ts` while
   keeping the bridge module intact, every existing bridge test
   stayed green and the bug silently re-entered.

   Fix: export `_mcpBridgeReady: Promise<void>` from
   `src/pi-extension.ts`. Bootstrap is still fire-and-forget (so
   spawn / handshake latency does not block session_start), but the
   promise gives tests a deterministic await point. Reset to a fresh
   promise on every `piExtension(pi)` call so multiple registrations
   in one process do not see a stale resolution.

   New test in `tests/pi-extension.test.ts` ("pi-extension.ts wiring
   (#426 regression guard)"): calls `registerPiExtension(api)`,
   awaits `_mcpBridgeReady`, asserts `api.registerTool.mock.calls`
   includes at least the canonical `ctx_execute` / `ctx_search` /
   `ctx_index` / `ctx_batch_execute` / `ctx_fetch_and_index` set.
   Verified red-on-revert: with the bridge module intact but the
   `bootstrapMCPTools(...)` call reverted to next, this test fails
   with `registeredNames: []`. Pre-fix it would have stayed green.

2. **C2 LOW — duplicated path resolution in the integration tests.**
   `tests/pi-extension.test.ts` had `path.dirname(...) +
   path.resolve(here, "..", "start.mjs")` recomputed in each `it()`.
   Lifted to a single `mcpEntry` const at the top of the
   `bootstrapMCPTools — registers every ctx_* tool with Pi` describe
   block, plus a shared `mcpEnv` for the `CONTEXT_MODE_DISABLE_VERSION_CHECK`
   override. One place to update if `start.mjs` ever moves.

3. **C3 LOW — dead `running` getter on MCPStdioClient.**
   Exported in the original commit but had zero callers anywhere in
   `src/` or `tests/`. Dropped — five lines, no behavioral impact.

## Test plan

- [x] `npm run build`
- [x] `npm run typecheck` — clean
- [x] `npm test` — 73/73 files, 2407 pass / 25 skipped / 0 fail
- [x] `npx vitest run tests/pi-extension.test.ts -t "MCP bridge|wiring"` — 8 pass
- [x] Phase A re-validation: revert ONLY the wiring in
      `src/pi-extension.ts` (keep `src/pi-mcp-bridge.ts` intact),
      run the wiring test → fails with `registeredNames: []`. Restore
      and the test goes green again.

Co-Authored-By: Ora Studio <noreply@oratelecom.net>

* refactor(openclaw): consolidate src/openclaw/* into src/adapters/openclaw/

Pre-fix layout split OpenClaw across two locations:
  - src/adapters/openclaw/ — config, hooks, index, session-db (standard
    adapter pattern matching every other platform)
  - src/openclaw/         — mcp-tools, workspace-router (rogue location)

The split predates the adapter pattern: workspace-router.ts was added
first by Pedro Almeida (#aa8d93c), then mcp-tools.ts by the maintainer
(#ff0a9a2 v1.0.107), while the adapter dir was bootstrapped later by
the copilot-swe-agent (#5fd6a9e). Source-of-truth for every platform
should live under src/adapters/<platform>/, so we move the two
stragglers in.

## Changes

  - git mv src/openclaw/mcp-tools.ts        → src/adapters/openclaw/mcp-tools.ts
  - git mv src/openclaw/workspace-router.ts → src/adapters/openclaw/workspace-router.ts
  - rmdir src/openclaw

  - src/openclaw-plugin.ts: 3 import-path updates
  - tests/plugins/openclaw.test.ts: 1 import-path update
  - tests/core/cli.test.ts: 1 readFileSync source-grep path update
    (the existing PR #183 path-traversal regression test reads the
    workspace-router source file directly to grep for safe-regex
    patterns; pin updated to the new location)

## Test plan

  - [x] npm run typecheck — clean
  - [x] npx vitest run tests/plugins/openclaw.test.ts tests/core/cli.test.ts
        → 225/225 pass
  - [x] npm test — 73 files, 2405+ pass / 25 skipped / 0 fail

Co-Authored-By: Ora Studio <noreply@oratelecom.net>

* refactor: flatten src/concurrency/runPool.ts → src/runPool.ts

The src/concurrency/ directory held a single file. A whole directory
for one module is structural noise — flatten it to src/runPool.ts.

## Changes

  - git mv src/concurrency/runPool.ts → src/runPool.ts
  - rmdir src/concurrency
  - src/server.ts: import path updated
  - tests/core/server.test.ts: import path updated

## Test plan

  - [x] npm run typecheck — clean
  - [x] npx vitest run tests/core/server.test.ts -t "runPool" — pass

Co-Authored-By: Ora Studio <noreply@oratelecom.net>

* refactor: relocate plugin entry files into src/adapters/<platform>/

Pre-fix layout had three platform plugin entry files at the src/ root:

  src/pi-extension.ts        — Pi Coding Agent extension
  src/pi-mcp-bridge.ts       — Pi MCP bridge (added in #426)
  src/openclaw-plugin.ts     — OpenClaw gateway plugin
  src/opencode-plugin.ts     — OpenCode plugin

Every other platform follows the src/adapters/<name>/ pattern (config,
hooks, index, …). The four root-level files were the last hold-outs:
inconsistent layout, plus they made adapter discovery harder for new
contributors.

## Changes (file moves)

  - git mv src/pi-extension.ts        → src/adapters/pi/extension.ts
  - git mv src/pi-mcp-bridge.ts       → src/adapters/pi/mcp-bridge.ts
  - git mv src/openclaw-plugin.ts     → src/adapters/openclaw/plugin.ts
  - git mv src/opencode-plugin.ts     → src/adapters/opencode/plugin.ts

## Internal import-path updates inside the moved files

  - ./session/db.js    → ../../session/db.js   (depth +2)
  - ./types.js         → ../../types.js
  - ./adapters/X/Y.js  → ./Y.js                (now sibling)
  - ./adapters/types.js → ../types.js          (now parent)
  - ./pi-mcp-bridge.js → ./mcp-bridge.js       (renamed + sibling)

## Runtime path-resolution updates

The plugins read sibling resources (hooks/, package.json, etc.) via
`resolve(buildDir, "..")`. After the move buildDir lives 2 dirs
deeper, so every `..` is now `../../..`:

  - resolve(buildDir, "..")                                 → resolve(buildDir, "..", "..", "..")
  - resolve(buildDir, "..", "hooks", "core", "routing.mjs")    → resolve(buildDir, "..", "..", "..", "hooks", "core", "routing.mjs")
  - (and similar for routing-block / tool-naming / auto-injection)

For opencode/plugin.ts the version-from-package.json walker prepends
`../../../package.json` to its search list (keeps the legacy
`../package.json` and `./package.json` entries as fall-backs so
unbundled or old-layout dev environments still resolve).

## Build-output paths in package.json

tsc preserves src/ structure under build/, so:

  ./build/pi-extension.js     → ./build/adapters/pi/extension.js
  ./build/openclaw-plugin.js  → ./build/adapters/openclaw/plugin.js
  ./build/opencode-plugin.js  → ./build/adapters/opencode/plugin.js

Updated:

  - package.json: pi.extensions[0], openclaw.extensions[0], main,
    exports["."], exports["./plugin"], exports["./openclaw"]
  - .pi/extensions/context-mode/index.ts: re-export delegate path
  - .openclaw-plugin/index.ts: re-export delegate path + JSDoc

## Test-side updates

  - tests/pi-extension.test.ts: dynamic-import paths updated
  - tests/opencode-plugin.test.ts: dynamic-import paths updated
  - tests/plugins/openclaw.test.ts: dynamic-import paths updated
  - tests/core/cli.test.ts: 4 dynamic-import paths + 1
    `readFileSync(src/openclaw-plugin.ts)` source-grep updated to the
    new location
  - src/adapters/detect.ts: comment-line ref updated
  - tests/adapters/detect.test.ts: comment-line ref updated

## Test plan

  - [x] npm run build                                        clean
  - [x] npm run typecheck                                    clean
  - [x] npm test                                             73 files,
        2407 pass / 25 skipped / 0 fail
  - [x] npx vitest run tests/opencode-plugin.test.ts         33/33 pass
        (regression: marker test that needed package.json walker fix)
  - [x] npx vitest run tests/plugins/openclaw.test.ts        225/225 pass
  - [x] npx vitest run tests/pi-extension.test.ts            45/45 pass
        (incl. the wiring guard added in the previous commit)
  - [x] npx vitest run tests/core/cli.test.ts -t "openclaw-plugin.ts doctor/upgrade"
        passes against the new src/adapters/openclaw/plugin.ts location
  - [x] Manual sanity: every old root-level path (build/pi-extension.js,
        src/opencode-plugin.ts, etc.) is gone from the repo — grep
        confirms zero stale refs in src/ + tests/ + package.json + the
        .pi/.openclaw-plugin/ thin wrappers.

Co-Authored-By: Ora Studio <noreply@oratelecom.net>

* fix(ci): update E2E + install scripts for relocated openclaw plugin path

The structural refactor in 4911c07 (src/openclaw-plugin.ts → src/adapters/
openclaw/plugin.ts) moved the build output from build/openclaw-plugin.js
to build/adapters/openclaw/plugin.js. Three scripts still pointed at the
legacy path and broke on next-CI.

## OpenClaw E2E (failing on ubuntu-latest + macos-latest)

  scripts/test-openclaw-e2e.sh:34
    join(process.cwd(), "build", "openclaw-plugin.js")

The Phase 1 plugin-load check failed at "❌ build/openclaw-plugin.js
exists" → exit 1. Updated to look for the new path first, fall back to
the legacy one for transition safety:

  build/adapters/openclaw/plugin.js → fall back → build/openclaw-plugin.js

Loaded-tag tracks which path actually resolved.

## OpenClaw global install (would have broken at user-install time)

  scripts/install-openclaw-plugin.sh:49 (auto-generated index.ts stub)

Updated the absolute re-export path written into the generated stub
plus the jiti cache-clear glob (now matches both
`build-adapters-openclaw-plugin.*.cjs` and the legacy
`build-openclaw-plugin.*.cjs` filenames).

## Bonus: security.js path was wrong post-refactor

The opencode + openclaw plugins called `routing.initSecurity(buildDir)`
where buildDir = build/adapters/<platform>/. That made initSecurity look
for build/adapters/<platform>/security.js — which never exists. The
security module lives at build/security.js (top-level). The fix-open
fallback meant tests still passed but every plugin load emitted a
spurious WARNING about deny-policy enforcement being off.

  - opencode/plugin.ts: pass `resolve(buildDir, "..", "..")` (= build/)
  - openclaw/plugin.ts: same

Verified locally: `bash scripts/test-openclaw-e2e.sh` → 39/39 pass, no
security warning.

## Test plan

  - [x] npm run build              clean
  - [x] npm run typecheck          clean
  - [x] npm test                   73 files, 2405+ pass / 25 skipped /
        0 fail (2 pre-existing flake worker-pool timeouts on
        kiro-hooks + insight-cors; both pass when run in isolation)
  - [x] bash scripts/test-openclaw-e2e.sh
        → "Results: 39 passed  0 warned  0 failed"  +  "✅ E2E test PASSED"

Co-Authored-By: Ora Studio <noreply@oratelecom.net>

* docs(openclaw): update Key Files paths after src/adapters/<platform>/ refactor

Independent PR review on #472 caught 3 stale path strings in
`docs/adapters/openclaw.md` that the structural refactor (4911c07)
missed:

  - src/openclaw-plugin.ts          → src/adapters/openclaw/plugin.ts
  - src/openclaw/workspace-router.ts → src/adapters/openclaw/workspace-router.ts (×2)

Doc-only — no code paths reference these strings.

Co-Authored-By: Ora Studio <noreply@oratelecom.net>

---------

Co-authored-by: Ora Studio <noreply@oratelecom.net>
2026-05-08 02:30:46 +03:00