From ae633a878b08a0d40d9d63aa0e9b57925d840bc0 Mon Sep 17 00:00:00 2001
From: cdeust
Date: Sat, 1 Aug 2026 19:42:54 +0200
Subject: [PATCH] release: v4.17.0 + MCPB connector bundle in the release
workflow
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Version surfaces moved 4.16.0 -> 4.17.0 across the nine sites that carry
the release identity: pyproject.toml, server.json (document + pypi package),
manifest.json, .claude-plugin/plugin.json, .claude-plugin/marketplace.json
(metadata.version + the hypermnesia-mcp entry), uv.lock's root package,
package.json, and the generated assets/badge-version.svg. The deprecated
`cortex` marketplace entry stays pinned at 4.15.0 — it is a migration shim,
not a shipped version. check_marketplace_pins.py, generate_repo_badges.py
--check, and check_doc_claims.py all exit 0 on the result.
CHANGELOG: the [Unreleased] block had accumulated 3x "### Fixed" and 2x
"### Security" across merges, unlike every published section. Versioned as
[4.17.0] with one sub-section per change type in Keep a Changelog order;
content-preserving (verified by diffing the sorted multiset of body lines
before and after — the only delta is the added dependency-bump entry).
MCPB bundle: manifest.json and .mcpbignore already described a valid MCPB
0.4 bundle, but no workflow built one, so v4.14.3..v4.16.0 shipped zero
release assets and the connector could only be produced by hand. The new
`mcpb-bundle` job packs it on tag, verifies the manifest version matches the
tag, publishes a sha256 companion, mints a Sigstore build-provenance
attestation, and attaches a resolved server.json carrying both the pypi and
the mcpb package entries — the mcpb entry needs the asset URL and its digest,
neither of which exists before the build, which is why the committed
server.json keeps only the version-addressable pypi package.
The CLI is installed with `npm ci` against a committed lockfile rather than
`npx @anthropic-ai/mcpb@2.1.2`: an exact version is not a pin (issue #203).
The lockfile pins 55 packages by sha512 integrity and overrides `tmp` to
0.2.7, because the CLI's transitive @inquirer/prompts -> external-editor ->
tmp chain otherwise resolves inside the GHSA-52f5-9888-hmc6 /
GHSA-ph9p-34f9-6g65 range; `npm audit` reports 0 vulnerabilities after the
override.
.mcpbignore additionally excludes deps/, fuzz/, .clusterfuzzlite/ and
requirements/. deps/ is the git-ignored local pip --target tree: packing a
working tree that had bootstrapped the launcher produced 190.1MB / 26951
files where a clean checkout produces 2.1MB / 561 files, so a local pack and
a CI pack now agree. .gitignore gains node_modules/, absent until now.
Verified locally before commit: npm ci + `mcpb pack` from a `git archive`
tree yields hypermnesia-mcp-4.17.0.mcpb (2.1MB, 561 files) containing
manifest.json, pyproject.toml, uv.lock, icon.png, README/LICENSE/PRIVACY and
536 mcp_server files, with zero files from tests_py, benchmarks, deps, docs,
fuzz, requirements, .github or scripts; the resolved-server.json generator
was executed against that bundle and its fileSha256 matches sha256sum.
Co-Authored-By: Claude Opus 5
---
.bestpractices.json | 6 +-
.claude-plugin/marketplace.json | 4 +-
.claude-plugin/plugin.json | 2 +-
.github/mcpb/package-lock.json | 694 ++++++++++++++++++++++++++++++++
.github/mcpb/package.json | 13 +
.github/workflows/release.yml | 119 ++++++
.gitignore | 5 +
.mcpbignore | 16 +
CHANGELOG.md | 79 ++--
README.md | 2 +-
assets/badge-version.svg | 8 +-
docs/ROADMAP.md | 2 +-
manifest.json | 2 +-
package.json | 2 +-
pyproject.toml | 2 +-
server.json | 4 +-
uv.lock | 2 +-
17 files changed, 908 insertions(+), 54 deletions(-)
create mode 100644 .github/mcpb/package-lock.json
create mode 100644 .github/mcpb/package.json
diff --git a/.bestpractices.json b/.bestpractices.json
index 0863b97d..7a49cd7c 100644
--- a/.bestpractices.json
+++ b/.bestpractices.json
@@ -36,7 +36,7 @@
"english_justification": "README, CONTRIBUTING, SECURITY, PRIVACY, CHANGELOG, the docs/ tree and all issue discussion are written in English.",
"maintained_status": "Met",
- "maintained_justification": "Actively maintained: v4.16.0 was released 2026-07-24, and releases have shipped continuously through the v4.x series: https://github.com/cdeust/Cortex/releases",
+ "maintained_justification": "Actively maintained: v4.17.0 was released 2026-08-01, and releases have shipped continuously through the v4.x series: https://github.com/cdeust/Cortex/releases",
"repo_public_status": "Met",
"repo_public_justification": "The repository is public and readable without an account: https://github.com/cdeust/Cortex",
@@ -51,10 +51,10 @@
"repo_distributed_justification": "git is a distributed version control system.",
"version_unique_status": "Met",
- "version_unique_justification": "Every release carries a unique semantic version tag (latest v4.16.0): https://github.com/cdeust/Cortex/releases",
+ "version_unique_justification": "Every release carries a unique semantic version tag (latest v4.17.0): https://github.com/cdeust/Cortex/releases",
"version_semver_status": "Met",
- "version_semver_justification": "Versions follow Semantic Versioning, tagged vMAJOR.MINOR.PATCH (v4.14.3, v4.15.0, v4.16.0): https://github.com/cdeust/Cortex/releases",
+ "version_semver_justification": "Versions follow Semantic Versioning, tagged vMAJOR.MINOR.PATCH (v4.15.0, v4.16.0, v4.17.0): https://github.com/cdeust/Cortex/releases",
"version_tags_status": "Met",
"version_tags_justification": "Each release has a corresponding git tag: https://github.com/cdeust/Cortex/tags",
diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index f588835c..460b3410 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -6,14 +6,14 @@
},
"metadata": {
"description": "The Cortex family of Claude Code plugins: home of hypermnesia-mcp (the persistent-memory server formerly named cortex, renamed in v4.15.0 over a directory name collision), zetetic-team-subagents, and cortex-viz. The marketplace name stays cortex-plugins: it is the brand-level umbrella, while hypermnesia-mcp is one package inside it.",
- "version": "4.16.0"
+ "version": "4.17.0"
},
"plugins": [
{
"name": "hypermnesia-mcp",
"source": "./",
"description": "Cortex — persistent memory and cognitive profiling for Claude Code (renamed from the 'cortex' plugin in v4.15.0 to match the PyPI/MCP-registry identity hypermnesia-mcp) — thermodynamic memory with heat/decay, intent-aware retrieval, biological plasticity, codebase intelligence, and cognitive profiling. 50 MCP tools (53 with the optional automatised-pipeline + prd-spec-generator integrations) with enriched schemas (visualization extracted to the standalone cortex-viz MCP). PostgreSQL + pgvector in CLI mode; automatic SQLite fallback in Cowork/sandboxed mode. v3.17.0 — autonomous per-project wiki: SessionStart auto-spawns a 6-hour consolidate cycle; a headless `claude -p` worker drains the curation-gap queue, calls codebase-intelligence MCP tools to ground each section in the real call graph, and authors missing anchor pages (architecture / services / api / data-flow / operations / decisions / PRD) per project from the source tree. 15 canonical scopes × 13 file sections; per-project dashboards under `wiki/_dashboards/`. Mermaid diagrams have a 🔍 lens with zoom + pan. Workflow graph with caller-qualified CALLS chains rendering full method-to-method dependencies (native tree-sitter, no AP required). Side panel humanized for non-technical users. Ingests codebase analysis (ai-automatised-pipeline) and PRDs (prd-spec-generator) into wiki + memory + knowledge graph. Docker image available.",
- "version": "4.16.0",
+ "version": "4.17.0",
"author": {
"name": "Clement Deust",
"email": "admin@ai-architect.tools"
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index a6ef4ab6..bcf33493 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "hypermnesia-mcp",
"description": "Cortex — persistent memory for Claude Code that remembers across sessions automatically. Install and forget. Scientific retrieval backed by 97 published references. (Renamed from the 'cortex' plugin to match the PyPI/MCP-registry identity.)",
- "version": "4.16.0",
+ "version": "4.17.0",
"author": {
"name": "Clement Deust",
"email": "admin@ai-architect.tools"
diff --git a/.github/mcpb/package-lock.json b/.github/mcpb/package-lock.json
new file mode 100644
index 00000000..14348731
--- /dev/null
+++ b/.github/mcpb/package-lock.json
@@ -0,0 +1,694 @@
+{
+ "name": "cortex-mcpb-tool",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "cortex-mcpb-tool",
+ "version": "0.0.0",
+ "license": "MIT",
+ "devDependencies": {
+ "@anthropic-ai/mcpb": "2.1.2"
+ }
+ },
+ "node_modules/@anthropic-ai/mcpb": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/mcpb/-/mcpb-2.1.2.tgz",
+ "integrity": "sha512-goRbBC8ySo7SWb7tRzr+tL6FxDc4JPTRCdgfD2omba7freofvjq5rom1lBnYHZHo6Mizs1jAHJeN53aZbDoy8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/prompts": "^6.0.1",
+ "commander": "^13.1.0",
+ "fflate": "^0.8.2",
+ "galactus": "^1.0.0",
+ "ignore": "^7.0.5",
+ "node-forge": "^1.3.2",
+ "pretty-bytes": "^5.6.0",
+ "zod": "^3.25.67",
+ "zod-to-json-schema": "^3.24.6"
+ },
+ "bin": {
+ "mcpb": "dist/cli/cli.js"
+ }
+ },
+ "node_modules/@inquirer/checkbox": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-3.0.1.tgz",
+ "integrity": "sha512-0hm2nrToWUdD6/UHnel/UKGdk1//ke5zGUpHIvk5ZWmaKezlGxZkOJXNSWsdxO/rEqTkbB3lNC2J6nBElV2aAQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^9.2.1",
+ "@inquirer/figures": "^1.0.6",
+ "@inquirer/type": "^2.0.0",
+ "ansi-escapes": "^4.3.2",
+ "yoctocolors-cjs": "^2.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/confirm": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-4.0.1.tgz",
+ "integrity": "sha512-46yL28o2NJ9doViqOy0VDcoTzng7rAb6yPQKU7VDLqkmbCaH4JqK4yk4XqlzNWy9PVC5pG1ZUXPBQv+VqnYs2w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^9.2.1",
+ "@inquirer/type": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/core": {
+ "version": "9.2.1",
+ "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-9.2.1.tgz",
+ "integrity": "sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/figures": "^1.0.6",
+ "@inquirer/type": "^2.0.0",
+ "@types/mute-stream": "^0.0.4",
+ "@types/node": "^22.5.5",
+ "@types/wrap-ansi": "^3.0.0",
+ "ansi-escapes": "^4.3.2",
+ "cli-width": "^4.1.0",
+ "mute-stream": "^1.0.0",
+ "signal-exit": "^4.1.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^6.2.0",
+ "yoctocolors-cjs": "^2.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/editor": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-3.0.1.tgz",
+ "integrity": "sha512-VA96GPFaSOVudjKFraokEEmUQg/Lub6OXvbIEZU1SDCmBzRkHGhxoFAVaF30nyiB4m5cEbDgiI2QRacXZ2hw9Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^9.2.1",
+ "@inquirer/type": "^2.0.0",
+ "external-editor": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/expand": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-3.0.1.tgz",
+ "integrity": "sha512-ToG8d6RIbnVpbdPdiN7BCxZGiHOTomOX94C2FaT5KOHupV40tKEDozp12res6cMIfRKrXLJyexAZhWVHgbALSQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^9.2.1",
+ "@inquirer/type": "^2.0.0",
+ "yoctocolors-cjs": "^2.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/figures": {
+ "version": "1.0.15",
+ "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz",
+ "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/input": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-3.0.1.tgz",
+ "integrity": "sha512-BDuPBmpvi8eMCxqC5iacloWqv+5tQSJlUafYWUe31ow1BVXjW2a5qe3dh4X/Z25Wp22RwvcaLCc2siHobEOfzg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^9.2.1",
+ "@inquirer/type": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/number": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-2.0.1.tgz",
+ "integrity": "sha512-QpR8jPhRjSmlr/mD2cw3IR8HRO7lSVOnqUvQa8scv1Lsr3xoAMMworcYW3J13z3ppjBFBD2ef1Ci6AE5Qn8goQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^9.2.1",
+ "@inquirer/type": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/password": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-3.0.1.tgz",
+ "integrity": "sha512-haoeEPUisD1NeE2IanLOiFr4wcTXGWrBOyAyPZi1FfLJuXOzNmxCJPgUrGYKVh+Y8hfGJenIfz5Wb/DkE9KkMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^9.2.1",
+ "@inquirer/type": "^2.0.0",
+ "ansi-escapes": "^4.3.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/prompts": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-6.0.1.tgz",
+ "integrity": "sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/checkbox": "^3.0.1",
+ "@inquirer/confirm": "^4.0.1",
+ "@inquirer/editor": "^3.0.1",
+ "@inquirer/expand": "^3.0.1",
+ "@inquirer/input": "^3.0.1",
+ "@inquirer/number": "^2.0.1",
+ "@inquirer/password": "^3.0.1",
+ "@inquirer/rawlist": "^3.0.1",
+ "@inquirer/search": "^2.0.1",
+ "@inquirer/select": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/rawlist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-3.0.1.tgz",
+ "integrity": "sha512-VgRtFIwZInUzTiPLSfDXK5jLrnpkuSOh1ctfaoygKAdPqjcjKYmGh6sCY1pb0aGnCGsmhUxoqLDUAU0ud+lGXQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^9.2.1",
+ "@inquirer/type": "^2.0.0",
+ "yoctocolors-cjs": "^2.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/search": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-2.0.1.tgz",
+ "integrity": "sha512-r5hBKZk3g5MkIzLVoSgE4evypGqtOannnB3PKTG9NRZxyFRKcfzrdxXXPcoJQsxJPzvdSU2Rn7pB7lw0GCmGAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^9.2.1",
+ "@inquirer/figures": "^1.0.6",
+ "@inquirer/type": "^2.0.0",
+ "yoctocolors-cjs": "^2.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/select": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-3.0.1.tgz",
+ "integrity": "sha512-lUDGUxPhdWMkN/fHy1Lk7pF3nK1fh/gqeyWXmctefhxLYxlDsc7vsPBEpxrfVGDsVdyYJsiJoD4bJ1b623cV1Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^9.2.1",
+ "@inquirer/figures": "^1.0.6",
+ "@inquirer/type": "^2.0.0",
+ "ansi-escapes": "^4.3.2",
+ "yoctocolors-cjs": "^2.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-2.0.0.tgz",
+ "integrity": "sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mute-stream": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@types/mute-stream": {
+ "version": "0.0.4",
+ "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz",
+ "integrity": "sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "22.20.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
+ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/wrap-ansi": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz",
+ "integrity": "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ansi-escapes": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
+ "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "type-fest": "^0.21.3"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/chardet": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz",
+ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cli-width": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
+ "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/commander": {
+ "version": "13.1.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
+ "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/external-editor": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
+ "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chardet": "^0.7.0",
+ "iconv-lite": "^0.4.24",
+ "tmp": "^0.0.33"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/flora-colossus": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/flora-colossus/-/flora-colossus-2.0.0.tgz",
+ "integrity": "sha512-dz4HxH6pOvbUzZpZ/yXhafjbR2I8cenK5xL0KtBFb7U2ADsR+OwXifnxZjij/pZWF775uSCMzWVd+jDik2H2IA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.4",
+ "fs-extra": "^10.1.0"
+ },
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/fs-extra": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
+ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/galactus": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/galactus/-/galactus-1.0.0.tgz",
+ "integrity": "sha512-R1fam6D4CyKQGNlvJne4dkNF+PvUUl7TAJInvTGa9fti9qAv95quQz29GXapA4d8Ec266mJJxFVh82M4GIIGDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.4",
+ "flora-colossus": "^2.0.0",
+ "fs-extra": "^10.1.0"
+ },
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
+ "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/jsonfile": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
+ "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mute-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz",
+ "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/node-forge": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
+ "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
+ "dev": true,
+ "license": "(BSD-3-Clause OR GPL-2.0)",
+ "engines": {
+ "node": ">= 6.13.0"
+ }
+ },
+ "node_modules/pretty-bytes": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
+ "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tmp": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
+ "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.14"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "0.21.3",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
+ "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/universalify": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
+ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yoctocolors-cjs": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz",
+ "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-to-json-schema": {
+ "version": "3.25.2",
+ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
+ "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
+ "dev": true,
+ "license": "ISC",
+ "peerDependencies": {
+ "zod": "^3.25.28 || ^4"
+ }
+ }
+ }
+}
diff --git a/.github/mcpb/package.json b/.github/mcpb/package.json
new file mode 100644
index 00000000..0a12673c
--- /dev/null
+++ b/.github/mcpb/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "cortex-mcpb-tool",
+ "version": "0.0.0",
+ "private": true,
+ "description": "Pinned MCPB packaging CLI used by .github/workflows/release.yml to build the .mcpb connector bundle. Isolated from the repo-root package.json (pure metadata) and installed with `npm ci` against the committed lockfile, so every byte of the toolchain is integrity-hashed rather than merely version-matched.",
+ "license": "MIT",
+ "devDependencies": {
+ "@anthropic-ai/mcpb": "2.1.2"
+ },
+ "overrides": {
+ "tmp": "0.2.7"
+ }
+}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index fce49c6e..9535a579 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -315,6 +315,125 @@ jobs:
hypermnesia-mcp.cdx.json.sha256
fail_on_unmatched_files: true
+ # MCPB connector bundle (Claude Desktop / Cowork drag-and-drop install).
+ # manifest.json + .mcpbignore already described a valid MCPB 0.4 bundle, but
+ # nothing built it: v4.14.3 through v4.16.0 shipped zero release assets, so
+ # the only way to get this bundle was to clone the repo and pack it by hand.
+ #
+ # The bundle carries source + manifest only — `uv run` resolves dependencies
+ # from the bundled pyproject.toml/uv.lock at launch — which is why it is
+ # 2.1MB and not the ~700MB an installed tree would be.
+ # source: measured 2026-08-01 on the v4.17.0 tree — 2.1MB package /
+ # 5.8MB unpacked / 561 files / 222 ignored by .mcpbignore.
+ mcpb-bundle:
+ name: MCPB bundle + attest (Claude Desktop connector)
+ needs: test
+ # Only the real tag-triggered event ships a bundle; a workflow_dispatch
+ # verification run (issue #247 criterion 5) stops after `test`.
+ if: startsWith(github.ref, 'refs/tags/')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write # upload bundle + checksum + server.json to release
+ id-token: write # OIDC token Sigstore exchanges for a signing cert
+ attestations: write # record the build-provenance statement on the repo
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Set up Node
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: "24"
+
+ # `npm ci` against the committed lockfile, not `npx @anthropic-ai/mcpb@x`:
+ # an exact version is not a pin (§ hash-pinned installs, issue #203) —
+ # only the lockfile's sha512 integrity entries pin the bytes. The
+ # lockfile also carries a `tmp` override, because the CLI's transitive
+ # @inquirer/prompts -> external-editor -> tmp chain resolves to a version
+ # inside the GHSA-52f5-9888-hmc6 / GHSA-ph9p-34f9-6g65 range by default.
+ - name: Install the pinned MCPB CLI
+ run: npm ci --prefix .github/mcpb --ignore-scripts
+
+ - name: Build the MCPB bundle
+ id: pack
+ run: |
+ set -euxo pipefail
+ VERSION="$(python3 -c "import json;print(json.load(open('manifest.json'))['version'])")"
+ # The tag must describe the tree it is built from, or a user installing
+ # the asset gets a version the release notes do not describe.
+ if [ "v${VERSION}" != "${GITHUB_REF_NAME}" ]; then
+ echo "manifest.json version ${VERSION} does not match tag ${GITHUB_REF_NAME}" >&2
+ exit 1
+ fi
+ BUNDLE="hypermnesia-mcp-${VERSION}.mcpb"
+ node .github/mcpb/node_modules/.bin/mcpb pack . "${BUNDLE}"
+ sha256sum "${BUNDLE}" | tee "${BUNDLE}.sha256"
+ echo "bundle=${BUNDLE}" >> "$GITHUB_OUTPUT"
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
+ ls -la "${BUNDLE}"*
+
+ # The MCP registry's `mcpb` registryType requires the artifact's URL and
+ # its sha256 — neither of which exists until the bundle is built and its
+ # download URL is known. The committed server.json therefore keeps the
+ # pypi package (its identifier is version-addressable, not URL-addressed)
+ # and this step emits the resolved variant carrying BOTH packages, as a
+ # release asset to submit to the registry.
+ - name: Emit the resolved server.json (pypi + mcpb packages)
+ run: |
+ set -euxo pipefail
+ python3 - <<'PY'
+ import hashlib, json, os, pathlib
+
+ bundle = pathlib.Path(os.environ["BUNDLE"])
+ version = os.environ["VERSION"]
+ repo = os.environ["GITHUB_REPOSITORY"]
+ tag = os.environ["GITHUB_REF_NAME"]
+
+ doc = json.loads(pathlib.Path("server.json").read_text())
+ if doc["version"] != version:
+ raise SystemExit(f"server.json {doc['version']} != manifest {version}")
+
+ doc["packages"].append(
+ {
+ "registryType": "mcpb",
+ "identifier": (
+ f"https://github.com/{repo}/releases/download/{tag}/{bundle.name}"
+ ),
+ "version": version,
+ "fileSha256": hashlib.sha256(bundle.read_bytes()).hexdigest(),
+ "transport": {"type": "stdio"},
+ }
+ )
+ out = pathlib.Path("server.resolved.json")
+ # ensure_ascii=False so the description's em-dash stays a literal
+ # character instead of a — escape, matching committed server.json.
+ out.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n")
+ print(out.read_text())
+ PY
+ env:
+ BUNDLE: ${{ steps.pack.outputs.bundle }}
+ VERSION: ${{ steps.pack.outputs.version }}
+
+ # Provenance BEFORE upload, same ordering and rationale as the wheel/sdist
+ # and SBOM jobs: the attestation binds the digest a user verifies with
+ # gh attestation verify --repo cdeust/Cortex
+ - name: Attest build provenance (MCPB bundle)
+ uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
+ with:
+ subject-path: ${{ steps.pack.outputs.bundle }}
+
+ - name: Attach bundle + checksum + resolved server.json to the release
+ # Real releases only (issue #246) — same forged-release hazard as the
+ # build and sbom jobs' attach steps.
+ if: github.event_name == 'push'
+ uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
+ with:
+ tag_name: ${{ github.ref_name }}
+ files: |
+ ${{ steps.pack.outputs.bundle }}
+ ${{ steps.pack.outputs.bundle }}.sha256
+ server.resolved.json
+ fail_on_unmatched_files: true
+
publish-pypi:
name: Publish to PyPI (deprecated channel)
needs: build
diff --git a/.gitignore b/.gitignore
index 4a9b1d8e..9536e0ec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -71,3 +71,8 @@ app-tauri/src-tauri/target/
# Benchmark run logs (raw stdout/stderr) — transient artifacts, not release content
benchmarks/results/**/*.log
benchmarks/.reproduce.lock/
+
+# npm install trees. The lockfiles that produce them ARE committed
+# (.github/mcpb/, docker/claude-code/) so `npm ci` reinstalls the exact
+# integrity-hashed bytes; the installed tree itself is a build artifact.
+node_modules/
diff --git a/.mcpbignore b/.mcpbignore
index 9b5feef4..70961690 100644
--- a/.mcpbignore
+++ b/.mcpbignore
@@ -30,6 +30,22 @@ tests_py/
tests_js/
_pipeline/
.claude/
+fuzz/
+.clusterfuzzlite/
+
+# Locally-materialised dependency tree. Git-ignored (.gitignore:69), so a CI
+# checkout never has it and the bundle is unaffected there — but a `pack` run
+# in a working tree that has bootstrapped the plugin launcher picks up ~1.1GB
+# and produces a 190MB bundle instead of 2.8MB. Excluded so a local pack and a
+# CI pack yield the same archive.
+# source: measured 2026-08-01 — same tree, pack with deps/ present: 190.1MB /
+# 26951 files; pack from `git archive HEAD`: 2.8MB / 600 files.
+deps/
+
+# Hashed pip exports consumed by CI and the Docker images. The MCPB runtime
+# resolves from pyproject.toml + uv.lock via `uv run`, and every consumer of
+# these files (scripts/, docker/) is already excluded above.
+requirements/
# Claude Code plugin assets (not used by the MCPB / uv server runtime)
.claude-plugin/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ca7730f1..63bc2a93 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,19 +6,47 @@ adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
+## [4.17.0] - 2026-08-01
+
+### Added
+
+- **The README's repo-derived badges are now self-hosted SVGs, gated in CI** — `assets/badge-{license,python,tests,references,version}.svg`, `scripts/generate_repo_badges.py`, `scripts/badge_render.py`. Five hotlinked `img.shields.io` images became committed files, for the reason the MCP Toplist badge already was one: a remote badge URL is a third-party request fired on every README view, and it lets its host restate our claim with no commit in this repository. **Only repo-derived figures were converted**, and the line is deliberate — every one of these (licence, Python floor, collected test count, bibliography size, package version) is determined BY THIS REPOSITORY, so a committed copy can always be made true again from the working tree with no network access. That is why they are kept honest by a **blocking `--check` gate on every push and PR** rather than by a cron: drift is caught where it is introduced. **Two badges were deliberately NOT converted and must not be:** the CI status badge reports the LIVE result of the last run on main, so a committed copy would assert "passing" while main was broken — a static build-status badge is a false claim by construction, not merely a stale one — and it is GitHub-hosted, so it is not a third-party beacon in the first place; the OpenSSF Best Practices badge reflects an external body's live assessment that can be downgraded without any commit here, and `.bestpractices.json` separately justifies displaying THEIR badge image. The MCP Toplist badge sits between the two and stays committed because it carries an explicit "as of " stamp, which keeps a stale copy a true statement about a point in time. **The conversion had to rewire the gate it would otherwise have silenced:** `check_doc_claims.py` enforced the version and test-count claims by regex over the shields.io URLs (`badge/version-(\d+\.\d+\.\d+)`, `badge/tests-(\d+)_passing`), so self-hosting alone would have left both patterns matching nothing while the gate still reported success. They now read the figure out of the committed SVG's own `` and **fail closed** on a missing file or an unmatched title — the predecessor's `if badge and ...` passed silently the moment its subject disappeared. A new check also fails any reintroduced `img.shields.io` hotlink in the README, so reverting this is loud rather than quiet. Found by the new tests before shipping: `--` is illegal inside an XML comment, and the first cut described its own gate as `--check` and its source as `pytest --collect-only` in the provenance comment, leaving **all five badges unparseable**; the renderer now parses every badge it produces and refuses to return one that is not well-formed, so no future wording can reintroduce the class. The shared renderer was extracted from the MCP Toplist refresher first, as a separate behavior-preserving commit whose proof is that `assets/badge-mcp-toplist.svg` is byte-identical and its 51 tests pass unchanged. Suite grows 6348 → 6373.
+- **MCP Toplist rank badge, self-hosted and refreshed on a cron** — `assets/badge-mcp-toplist.svg`, `scripts/refresh_mcp_toplist_badge.py`, `.github/workflows/mcp-toplist-badge.yml`. The badge records that Cortex ranks in the top 1.2% of MCP servers tracked by MCP Toplist (#964 of 81,919, read 2026-07-28). It is a **committed file, not a hotlinked remote image**: a remote badge URL is a third-party beacon fired on every README view, and it lets its host restate the claim at any time with no commit in this repository. The arriving PR (#241, closed) proposed exactly that. The cost of the static choice is that the badge cannot self-update — the date it carries is part of the claim and goes stale by INACTION, and inaction never opens a PR — so a monthly workflow regenerates it and proposes the diff. Monthly is deliberate: the badge stamps a month, so it is the least frequent cadence that keeps the date honest and the most frequent one that does not churn a PR proposing an identical file. Wording is **attributive throughout** (`RANKED in this tier by MCP Toplist`, never `is a top-1.2% server`), because upstream's own methodology page states the score "is a popularity and activity signal, not a quality assessment" and keeps ~25% of its weighting (organic reach, package downloads) undisclosed — the figure is attributable, not reproducible. Every generated SVG carries its own audit trail in a comment: source path, raw rank and field size, the percentile arithmetic, and the verify URL, so the next maintainer can re-derive the claim from the file alone. **Two extraction paths, fail-closed:** the structured export at `/data/leaderboard.json` is tried first but returns HTTP 503 (measured 2026-07-28: 3/3 attempts, 8–14s each under a browser UA, i.e. a server-side generation timeout, not UA gating or rate limiting), so its schema has never been observed and the parser accepts only a narrow set of documented candidate shapes under strict validation rather than guessing at one; the fallback is the server page's prose sentence `ranks #N of M servers tracked`, the ONLY construct on that page carrying both numbers (the ``, og/twitter meta tags and all four JSON-LD blocks carry the rank without the total, so none can yield a percentile). Both paths feed one validator that refuses a non-numeric, zero, negative, or beyond-the-field figure — guarding the division in `percentile()` and the semantics of the claim — and a figure that fails validation is **never written**: the script exits non-zero, the badge keeps its last good value, and the run goes red. A fallback is never silent; the path that failed is reported even when a later one succeeds. No `pull_request` trigger, so a required check never depends on a third party's uptime; the 51 unit tests cover the logic with no network at all. Caught by those tests before shipping: a top-of-field rank renders the tier as `Top <0.1%`, whose unescaped `<` **made the badge invalid XML** — text and attributes are now XML-escaped, verified across the rank-1, mid-field and last-place boundaries. Note that PRs opened with the default `GITHUB_TOKEN` do not trigger workflows, so `main`'s required checks will not start on them; the workflow prefers an optional `BADGE_REFRESH_TOKEN` secret when present. Suite grows 6297 → 6373.
+- **Pyright is now a zero-diagnostic blocking gate** (#197, final family of the maximal-strictness program). The 568-diagnostic per-rule ratchet backlog was burned to **zero** at `typeCheckingMode: "standard"` (pyright 1.1.410, measured 2026-07-28): no rule disabled, no floor raised; the single per-site suppression is the unpublished optional `cortex_beam_abstain` import whose `except ImportError` arm is the documented degraded mode. The ratchet machinery (`typecheck-baseline.json` + `scripts/check_pyright_ratchet.py`) is retired — CI fails on ANY diagnostic via pyright's own exit code, and the CI type-check env installs the `[otel]` extra so the exporter imports resolve. The burn-down was fixes, not annotations-to-match: a typed host contract for the eight `PgMemoryStore` mixins (`pg_store_host.PgStoreHost` + `MaterializedCursor`, whose honest `DictRow` typing surfaced ten unchecked `INSERT..RETURNING` sites, now `one()` with a real error), a cross-backend `StoreConnection` union for the 16 shared query modules (the psycopg-only annotation had switched checking off for every SQLite call path), and **SQLite store parity for eight methods callers already used unconditionally** — `acquire_interactive`/`acquire_batch`, `_execute`, `search_newer_neighbors`, `update_forgetting_pressure_accum`, `get_memories_by_tag`, `iter_memories_for_decay`, `find_co_accessed_pairs` — each of which previously raised `AttributeError` on the SQLite backend and was swallowed into silent degradation by broad stage boundaries. Latent bugs fixed en route, each with a regression test: the compat cursor lacked `executemany` (SQLite wiki page-sources writes crashed), `lastrowid` honesty (insert paths now raise on a broken row-id contract instead of masking it with a stale `type: ignore`), the pipeline installer accepted a success result carrying no cargo path (None flowed into the build argv), `update_style_ema(None, None)` returned `None` against a `dict` signature, `encode_session` died with a bare `TypeError` on a direction-less feature (now refuses loudly, naming it), `get_causal_chain` could return `reason=None`, and active forgetting sent a `None` timestamp into the store. `.bestpractices.json` flips `warnings_strict` to **Met**, citing the ruff select list, the pyright mode, and the measured zero. Suite grows 6275 → 6297.
+- **ruff `PLC0415` (import-outside-top-level) and `S608` (string-built SQL) are now blocking lint gates** (#197, fourth rule family of the maximal-strictness program). All 520 production `PLC0415` findings (407 `mcp_server/`, 57 `benchmarks/`, 56 `scripts/`) were triaged one by one: **360 lazy imports moved to module top** — so the import graph is static and a broken module fails at boot, not mid-operation — and the 160 that remain each carry a per-site `# noqa: PLC0415 — ` naming one of six sanctioned justifications: an optional dependency behind an extra, an internal module whose top-level closure hard-imports one (hoisting would break `[sqlite]`-only installs at import time), an ImportError-probe boundary where the except arm IS the degraded mode, an import cycle (partner named; the pre-existing #233 family), the hook latency boundary (per-event hook processes boot in ~0.05 s vs ~0.6 s for the registry closure, measured 2026-07-28 — hoisting the handler/store stack into a hook would multiply every hook event's cost), or a deferral the module itself documents. The hoist is behavior-preserving: the per-module import sweep matches the pre-change baseline exactly (515 modules, the same 6 pre-existing cycle failures), and warm import timings are unchanged. All 44 production `S608` sites carry a per-site `# noqa: S608 — ` naming the exact reason the interpolation is safe (two-literal ternaries, generated placeholder lists, module-level `WHERE` literals, or allowlist-gated identifiers per `docs/ASSURANCE-CASE.md` §5), so any NEW string-built SQL fails CI until it states its mechanism. `tests_py/**` adds both rules to its written per-file ignore (function-level imports in tests are the fixture mechanism; SQL built in tests is fixture setup against a throwaway database).
+- **ruff `PLR2004` (magic-value comparison) and `E501` (line-too-long) are now blocking lint gates** (#197, third rule family of the maximal-strictness program). All 420 production `PLR2004` findings (339 `mcp_server/`, 57 `benchmarks/`, 14 `scripts/`, 10 `video/`) were fixed with **zero `# noqa: PLR2004`**: every compared literal became a named constant carrying a `# source:` comment — a real citation where the module documents one (Frey & Morris 1997 / Kandel 2001 / Tse 2007 cascade thresholds, RFC 9110 status bands, FIPS 180-4 digest lengths, issue-quoted gates), a structural rationale for arities (split-parts, tuple lengths), and an explicit `pre-existing tuned value, extracted unchanged; provenance not recorded at introduction` where none is discoverable — never an invented source. `tests_py/**` carries a written per-file ignore (the compared literal in an assertion IS the expected value under test — the spec itself). All 470 `E501` findings (263 `mcp_server/`, 121 `tests_py/`, 48 `benchmarks/`, 37 `scripts/`, 1 `_pipeline`) were fixed by **rewrapping at the unchanged 88-column formatter limit** — string content kept byte-identical via implicit concatenation at existing whitespace (SQL and regex literals machine-verified byte-for-byte) — with exactly two per-site `# noqa: E501 — ` for unsplittable absolute-path tokens; E501 has **no** tests ignore. Two drift risks were closed at the source: `handlers/consolidation/transfer.py` re-declared as bare literals the canonical constants of `core/two_stage_transfer.py` (whose own comment forbids redefinition) and now imports them; `benchmarks/beam/ablation.py`'s copies are named per-module without value drift.
+- **ruff `BLE001` (blind-except) is now a blocking lint gate** (#197, second rule family of the maximal-strictness program). All 351 broad `except Exception` sites were triaged one by one, none blanket-ignored: (a) sites whose failure class is precisely known were **narrowed to typed excepts** — `json.loads` tag decoders to `ValueError`, lazy imports to `ImportError`, `subprocess` probes to `(OSError, SubprocessError)`, SQLite store guards to `sqlite3.Error`, PG connection/read guards to `psycopg.Error`, URL probes to `(OSError, ValueError, HTTPException)`, file I/O to `OSError` — so an unexpected programming error now **propagates instead of being absorbed** by a tolerant fallback; (b) genuine last-resort boundaries (degraded-mechanism wrappers, per-item batch isolation, hook/CLI entry points, diagnostic probes) stay broad and each carries a per-site `# noqa: BLE001 — ` naming the signal it emits; (c) ~50 previously **silent** broad handlers now emit an observable signal — `silent_failure.note()` under 38 new stable component names (spreading-activation, wiki classifier user rules, candidate scans, memify reweight/derive, ingest tag lookups, prospective-trigger injection, source attribution, wiki pointer memories, AP-bridge/groomer config reads, …) or the hook log (`session_start` banner fetches, cached-graph lookups); (d) `mcp_client` connection failures re-raise with `from e`, preserving the causal chain. `tests_py/**` keeps a written per-file ignore (broad excepts in tests are deliberate teardown/optional-path handling). Every new signal is asserted by a test (54 added).
+- **ruff `S110` (try-except-pass) is now a blocking lint gate** (#197, first rule family of the maximal-strictness program). `[tool.ruff.lint] select` is explicit in `pyproject.toml` (the former implicit defaults `E4`/`E7`/`E9`/`F`, plus `S110`), so CI fails on any newly swallowed exception in production code; `tests_py/**` carries a written per-file ignore (teardown/optional-path try-pass in tests is deliberate). All 68 production sites were triaged, none blanket-ignored: mechanism-degradation sites now report through `observability.silent_failure.note()` under stable component names (recall write-backs, RRF vector/FTS signals, sqlite vec-index maintenance, curation dedup, codebase-analyze edge/tag persistence, consolidation cascade/memify, doctor backend resolution, wiki reindex, LLM query reformulation), teardown/cleanup sites log at `DEBUG`, and sites whose failure class is precisely known were narrowed to typed excepts (hook cooldown caches, install-lock release, pre-migration sqlite guards, event-loop teardown). Every new signal is asserted by a test (57 added).
+
+- **Doc-claim gate — `scripts/check_doc_claims.py`**: the counts the documentation advertises (standalone/with-integration tool counts, bibliography references, mechanisms, version, collected tests) are now checked against the repository on every push and pull request, not at release time. Each claim has one owner — `docs/mcp-tools.md` for the tool counts (itself pinned to the live registry by `tests_py/test_main.py::test_standalone_baseline_is_52_tools`), the bibliography for references and mechanisms, `pyproject.toml` for the version, a live `pytest --collect-only` for the test count — and every other file must agree. Release-history lines are exempt. A pattern that matches nothing fails rather than passing vacuously. Wired into the Lint job (static claims) and the 3.12 test job (test count).
+- **Project governance and direction documents**: `GOVERNANCE.md` (decision model, roles and who holds them, continuity of access, DCO/CLA position), `docs/ROADMAP.md` (the twelve months to 2027-07, plus an explicit will-not-do section), and `docs/ASSURANCE-CASE.md` (security requirements, threat model, trust boundaries, secure-design principles mapped to code, CWE-by-CWE countermeasures, and what the case does not claim).
+- **An explicit, mandatory testing policy** in `CONTRIBUTING.md`: behaviour-changing PRs ship tests in the same PR, a bug fix carries a regression test that fails on the pre-fix code, and every failure path asserts the signal it emits.
+- **Document ingestion adapters — `ingest_document`** (#192): a new standalone MCP tool that ingests a **.docx** (OOXML zip, unpacked with the stdlib `zipfile`/`xml.etree` — no heavyweight dependency) or a **Confluence storage-format XHTML export** into the memory/wiki store. Both adapters parse into one typed model (`core/document_model.py`) via pure, zero-I/O parsers (`core/docx_parser.py`, `core/confluence_parser.py`) and a shared normalizer (`core/document_normalizer.py`) → the existing `wiki_write`/`remember` write path. Every produced wiki page and memory carries provenance (source path + content-hash version); re-ingesting the same document version is idempotent. Headings, paragraphs, and tables are extracted; **embedded images are skipped with an explicit notice** (no OCR); a malformed zip/XML fails **loudly and writes nothing** (no partial ingest). Filesystem/zip reading is isolated in `infrastructure/document_reader.py`; the tool composition root is `handlers/ingest_document.py`. This grows the standalone tool count 51 → 52. The shared parsing/normalization seam (`parse_confluence_storage` → `normalize_document` → write path) is what the live-Confluence REST connector (enterprise-backlog#28) will consume — that leg swaps only the byte source (REST fetch) and provenance (page URL + version).
+- **MCP prompts capability** (#176): `prompts/list` + `prompts/get` publish three guided workflows composed from Cortex's real tool surface — `session_recall` (query_methodology → recall → unified_search → recall_hierarchical → memory_stats), `promote_memories` (episodic→semantic CLS: consolidate → memory_stats → curate_distill → remember), and `curate_wiki` (unified_search → curate_wiki → wiki_write → wiki_verify). Prompt step summaries are pulled from the same handler-schema map (`merged_schemas()`) that `tools/list` is built from, so a prompt's description of a tool cannot drift from the tool's own schema (the #98 drift class). `mcp_server/mcp_prompts.py`.
+- **MCP tool profiles** (#177): a `full`/`lean` profile (`mcp_server/tool_profiles.py`) selected by `--profile` or `CORTEX_MCP_PROFILE`, enforced by `ToolProfileMiddleware`. `lean` advertises the 10-tool recall/onboarding surface (derived from `docs/mcp-tools.md` tiers + the common-session workflow); `full` keeps every tool. Per-profile `initialize.instructions`. Measured: `lean` cuts the per-session `initialize`+`tools/list` cost from ~29.9k to ~7.6k estimated tokens (74.6%), benchmark `benchmarks/mcp_profile_tokens.py`.
+
+### Changed
+
+- **Pyright now runs `standard` instead of `basic`** (#197 criterion 3), and the `_conn` annotation that made the raise look expensive is corrected. `SqliteMemoryStore` assigns `self._conn = PsycopgCompatConnection(raw)`, but 9 of its 10 mixins declared `_conn: sqlite3.Connection` (only `SqliteGroomingMixin` was right) — so every `self._conn.…` call in those mixins, and every handler reading `store._conn`, was type-checked against a class the store never holds. With all ten agreeing, `standard` and `basic` report the **identical 418 errors**: the raise costs nothing. Measured at the CI-pinned pyright 1.1.410 in a CI-equivalent environment (`reportMissingImports` lands at 1, matching `typecheck-baseline.json`, so nothing is Unknown-suppressed). `strict` is not adopted — it reports 10,231, ~9,300 of them the Unknown-type family, which is an annotation-coverage project rather than a config flip. The ratchet's blocking rules (`reportOptionalMemberAccess`, `reportOptionalSubscript`) stay at 0 and `typecheck-baseline.json` is untouched — no floor was raised to make the build pass. The corrected annotation immediately surfaced a live defect it had been masking: seven wiki handlers call `store._conn.cursor()`, which `PsycopgCompatConnection` does not implement, so the wiki pipeline is silently dead on the SQLite backend — filed as **#206**. Also fixes two `TYPE_CHECKING` imports in `tool_profile_middleware.py` that still pointed at FastMCP 2.x module paths (`fastmcp.prompts.prompt`, `fastmcp.tools.tool`); under the 3.x the project actually runs, those resolve to nothing and silently degraded `Prompt`/`Tool`/`ToolResult` to Unknown.
+- **The doc-claim gate now covers the OpenSSF answers and the second test-count phrasing.** `.bestpractices.json` was not scanned, and its answers are transcribed verbatim into the OpenSSF Best Practices questionnaire — so a stale number there is published to the badge rather than merely sitting in the repository. Three of its test counts had drifted two corrections behind (5571 against a 5598-test suite), one of them inside a justification dated the day it was read. It joins `SCANNED_FILES`. The count pattern also read only the `N tests` wording, so the `N-test suite` phrasing went unchecked; `TEST_CLAIM` now matches both, with a test pinning that a count of *test files* is still not a claim about suite size.
+- **Stale documentation claims corrected** (surfaced by the new doc-claim gate): the advertised tool count is 52 standalone / 55 with the optional upstream integrations (README said 50/53 in eight places, `CONTRIBUTING.md` said 43/46, `CLAUDE.md` said 51, the MCPB manifest said 49/52); the bibliography is 97 references behind 36 mechanisms (`CONTRIBUTING.md` said 72 and 23, the manifest the same); the advertised test count is 5598 and the version badge is 4.16.0. `CONTRIBUTING.md` documented a `mypy --strict src/cortex/` gate and a `black` formatter the project has never run — the real gates are ruff (pinned 0.15.20) and the pyright per-rule ratchet — and attributed a `pre-tool-secret-shield` file gate to Cortex that belongs to the maintainer's local agent tooling; in-repo the defence is `mcp_server/shared/redaction.py`. Prerequisites no longer claim PostgreSQL is required (SQLite is the default store). `docs/api-reference.md` still documented `get_methodology_graph` and `open_visualization`, extracted to cortex-viz in v3.21.0 — both sections are replaced by a pointer, and the same stale references are corrected in the `/methodology` command, `docs/mcp-tools.md`, and two MCP tool descriptions (`get_rules`, `explore_features`) that told the model about a tool this server no longer registers. The README's citation section pointed at a non-existent `docs/science.md`.
+- **Pyright type-debt burned back below the 568 baseline** (#188): the `MemoryStore` factory now types its `__new__` / `get_shared_store()` / `_construct_store()` results as the real `PgMemoryStore | SqliteMemoryStore` union it actually builds, instead of the empty factory shell pyright previously saw. That shell suppressed attribute resolution across 55+ handlers (every `store.get_memory(...)` read as "attribute for class MemoryStore"); annotating the real return type drops the tree-wide pyright total from 638 to 422 (`reportAttributeAccessIssue` 387→192, `reportReturnType` 52→18, `reportAssignmentType` 26→1). Truthful typing also unmasked latent argument/call errors previously hidden behind the shell — these stay visible in the ratchet's tracked (non-blocking) counters for iterative burn-down. No runtime behaviour change: the `MemoryStore` name is a `TYPE_CHECKING`-only union alias; the runtime factory class is byte-identical. Blocking-tier fix: `wiki_emerge`'s cold-start `COUNT(*)` read now guards the (SQL-unreachable) `fetchone() is None` case the truthful type exposed.
+- **The default MCP tool profile is `full`** (behaviour preserved; H4 note). This diverges from #177 criterion 2's "default to the common-session profile": shrinking the default advertised surface is a breaking change (a client that called a now-hidden tool would break), so — mirroring `automatised-pipeline`'s `ToolProfile` reasoning and this wave's explicit decision — `full` stays the default and `lean` is opt-in. Existing sessions are unchanged; the middleware is a pass-through under `full`.
+
+
+- Development-tooling and CI dependency bumps merged ahead of this tag: `pyright` 1.1.410 -> 1.1.411 (#327), `dill` 0.3.7 -> 0.4.1 (#326), `ruff` 0.15.20 -> 0.16.0 (#321), and a Dependabot configuration change ignoring `caio` >= 0.10 with the pip resync step documented (#329). None touches the retrieval, write-gate, or consolidation paths.
+
+### Removed
+
+- **Two modules whose call site was never built** (#196, criterion 1). `infrastructure/git_diff.py` (with its private siblings `git_diff_exec.py` and `git_diff_format.py`, whose only importer it was): its one caller, `server/http_file_diff.py`, was deleted deliberately in the Phase 6 visualization strip, and the behaviour now lives in the **cortex-viz** MCP — `cortex_viz/server/git_diff_engine.py`, shipped in v2.7.1, routed at `/api/file-diff`, with its own test suite; that module exists precisely because the copies descended from this one had diverged and were losing patches on merge commits. The one proposal to build an in-Cortex caller (`core/git_diff_to_symbols.py`) was retracted in the corrected gap analysis, since `automatised-pipeline`'s `detect_changes` already does it and `hooks/pipeline_impact_bump.py` already calls it. `shared/memory_types.py`: 9 Pydantic models with zero references anywhere, whose docstring claimed to define "the schema for SQLite storage and handler I/O" while the real contract is `dict[str, Any]` normalised by `sqlite_store` and validated per-tool by `validation/schemas.py` — the same species as the 13 scaffolding models removed earlier, verified by the same method.
+- **`core/context_assembly/active_retrieval.py` (issue #201, the second of #196's flagged zero-importer pair alongside `decomposer.py`).** `grep -rn "context_assembly.active_retrieval"` across `mcp_server/`, `tests_py/`, and `benchmarks/` (excluding the module's own directory) returned nothing — no production handler, no benchmark harness (`benchmarks/beam/run_benchmark.py` calls `pg_recall.assemble_context`, which never reaches this module), and no test beyond the module's own two test files. `decomposer.py`'s companion half of #201 was already resolved on `main` before this issue could be picked up: `condensers.condense_assembled_context` (now `condense_stage.py` post-#228 split) calls `decomposer.assemble_prompt` from `pg_recall.assemble_context`'s over-budget path, itself exercised by the BEAM benchmark harness — a real, non-test caller — so `decomposer.py` and its sole consumer `warning.py` both stay. `active_retrieval.py` had no equivalent: checked git history (added whole in the 3.18.4 release commit `5eba011`, touched twice since only for lint-family constant/exception-handling sweeps, never gained a caller) and all 12 unmerged remote branches (`git grep` for `active_retrieval|ActiveRetriever|KeywordExtractor|LLMReformulator` across each `origin/main..` diff — zero hits) for a removed or pending caller; found none. Wiring it would be new retrieval-affecting behaviour (MIRIX-style query reformulation ahead of the WRRF pipeline) needing its own benchmark validation, not a mechanical fix — out of scope for a dead-code removal. Deleted with its direct test file (`tests_py/core/context_assembly/test_active_retrieval.py`, 18 tests) and the two S110-sweep regression tests in `tests_py/core/test_s110_sweep_core.py::TestActiveRetrievalReformulate` that existed only to cover its `except`-logging path (#197 family 1) — both entirely about behaviour that no longer exists. `docs/module-inventory.md`'s `core/context_assembly/` count corrected 15 → 14 (`core/` 230 → 229); `docs/papers/research-post-context-assembly.md` §3.4 and its `docs/arxiv-context-assembly/main.tex` mirror (§ Active Retrieval) reworded from "the interface is defined and wired" to the accurate "never composed into the WRRF pipeline or the BEAM harness — removed as dead code", and both papers' Appendix-C module tables drop the `active_retrieval.py` row. Suite: 6938 passed, 5 skipped, 121 subtests passed (pre-change) → 6918 passed, 5 skipped, 121 subtests passed (post-change), the 20-test delta being exactly the deleted tests; zero tests modified.
+
### Fixed
+
- **The two background-reanalyze spawns resolved the interpreter by PATH name before `sys.executable`, hitting the Windows Store stub** (#315). `mcp_server/shared/platform.py::python_executable()` exists specifically to avoid this (its docstring: `shutil.which("python3")`/`("python")` resolve to the Microsoft Store stub on Windows, which exits without running anything), and `doctor_mcp.py` already used it — but `post_commit_reindex.py::_spawn_reanalyze` and two call sites in `session_start.py` (`_spawn_consolidate_cycle`, `_maybe_background_reanalyze`) still used `shutil.which("python3") or shutil.which("python") or sys.executable`, falling back to `sys.executable` only when PATH resolution failed outright — not the actual failure mode, which is PATH resolution *succeeding* against the broken stub. All three call sites now use `python_executable()`, matching `doctor_mcp.py`'s existing pattern. Both spawn helpers are detached background processes (`ingest_codebase_background`, `consolidate_background`) with no prior direct test coverage of their command construction — `tests_py/hooks/test_post_commit_reindex.py` and `tests_py/hooks/test_session_start.py` gain tests asserting the exact resolution order (a PATH entry that would resolve to a stub interpreter is proven to lose, via a mocked `shutil.which` returning a distinguishable fake path), the full spawned command (interpreter, launcher path, module args), the `subprocess.Popen` kwargs (`stdin`/`stdout`/`stderr`/`start_new_session`), the exact (case-sensitive) log path, the `CLAUDE_PLUGIN_ROOT`-unset fallback to the repo root, and the non-fatal failure/success log messages — a scoped mutation run (`scripts/mutation_check.sh`) against the three modified functions reports 0 surviving mutants.
- **`codebase_analyze` crashed with an uncaught `tree_sitter_language_pack.DownloadError` when a grammar could not be fetched** (main-red, CI run 30592244731, 2026-07-31, `Test (Python 3.10)`: `tests_py/benchmarks/test_codebase_alteration.py` (5 tests) and `tests_py/core/test_ast_extractors.py::test_decorated_function`). `tree-sitter-language-pack` is a declared dependency, but it resolves each grammar's shared library **lazily over the network at `get_parser()` call time**, not at install time — confirmed by measurement: an already-cached language returns in <50ms with no network attempt, an uncached one takes a real round trip against `https://github.com/xberg-io/tree-sitter-language-pack/releases`. `mcp_server/core/ast_parser.py::_get_extractor_and_tree` already treated a **missing** pack (`ImportError`) as a handled degraded mode, but had no handling for a pack that imports fine and then fails to **fetch** a grammar — an offline install, an air-gapped environment, a proxy, or an upstream outage reached `get_parser(language).parse(content)` (line 99) uncaught, so `codebase_analyze` raised a third-party exception type to its caller instead of degrading. Same defect class this repo has already been bitten by twice (the FlashRank silent-absence incident; the MCP stdio response-loss above): a degraded path existed and did not cover the failure that actually happens. Fixed by catching `DownloadError` specifically (not widened to the pack's broader `Error` base — only `DownloadError` is evidenced) around the `get_parser().parse()` call, returning the same `None` degraded-mode signal `parse_file_ast` already reads to select the regex fallback, and logging one actionable warning naming the language and the reason on every occurrence (not suppressed after the first — this runs once per file, not once per process). Regression tests force the failure deterministically (`monkeypatch.setattr` on the pack's own `get_parser`, matching the file's existing `ImportError`-probe convention) rather than by disabling real network: `tests_py/core/test_ast_parser_language_contract.py` gains `test_download_error_falls_back_instead_of_raising`, `test_download_error_logs_language_and_reason` (asserts the log emission itself, not merely the absence of a crash), and `test_download_error_degrades_through_parse_file_ast` (the public entry point `codebase_analyze` calls) — all three verified to fail against the pre-fix code. Made the test suite hermetic rather than papering over the flake: `.github/workflows/ci.yml`'s `test` and `test-sqlite` jobs (the two that install the `codebase` extra) gain a `Resolve tree-sitter cache directory` + `Cache tree-sitter grammars` + `Prefetch tree-sitter grammars` step trio — same cache-then-retry-with-backoff shape as the existing HF-embedding/FlashRank steps, for the same reason: fetch every language in `AST_SUPPORTED` (read from the module, not hand-copied, so the step cannot drift from what `ast_parser.py` actually uses) once, with retries, before `pytest` starts, so the suite's existing direct `get_parser(...)` calls in `test_ast_extractors.py`/`test_ast_parser_language_contract.py` never touch the network mid-run. No other unguarded `get_parser`/`get_language` call site exists in `mcp_server/` (swept repo-wide; `ast_parser.py` line 99 was the only production call).
- **"Docker Smoke" intermittently reported no `tools/list` response with no exception and no JSON-RPC error frame** — reproduced on `main` (CI run 30504042295: attempt 1 failed, attempt 2 succeeded, same commit `56f2f4f`, no code change), on PR #254 and PR #266. Root cause is upstream: `mcp` 1.29.0's `BaseSession._receive_loop` (`mcp/shared/session.py`) closes the write stream unconditionally the instant stdin reaches EOF, even when a request dispatched from an earlier line in the same batch (`tools/list` after `initialize`) is still running in its own task and has not called `respond()` yet — `mcp.server.lowlevel.server.Server._handle_request` catches the resulting `ClosedResourceError` and logs it via `logger.debug()` on a logger with zero handlers by default, so the drop is completely silent. All three JSON-RPC lines are always fully read (measured `parsed_count == 3` on every trial, pass and fail alike) — nothing is ever left unread; only the already-computed response is lost. `fastmcp` 3.4.5's `LowLevelServer.run` override removes the base SDK's own `finally: tg.cancel_scope.cancel()` mitigation with nothing in its place, so Cortex's stdio entry point inherited the hazard unmitigated. Fixed at Cortex's composition root: `mcp_server/infrastructure/stdio_transport.py` interposes a write-stream proxy that no-ops the SDK's premature `aclose()` and closes the real stream only after the low-level server's `run()` call has returned — which, by anyio task-group join semantics, is only once every dispatched handler has had its own chance to respond. `mcp_server/__main__.py::main()` now drives stdio through this wrapper instead of `mcp.run(transport="stdio")` directly. Regression test at the SDK boundary (`tests_py/infrastructure/test_stdio_transport.py`): one test reproduces the drop against the bare upstream call directly (a permanent characterization of the upstream defect), a second drives the identical race through the fix and asserts the response survives — verified to fail against the pre-fix code (bypassing the guard reproduces the exact original symptom: `initialize` answered, `tools/call` silently missing). A scoped mutation run (`scripts/mutation_check.sh`) against the new module found 9 further survivors and 22 uncovered mutants; hardened in `tests_py/infrastructure/test_stdio_transport_wiring.py` (the `stateless` parameter's actual MCP-lifecycle effect, and the outer `run_stdio_drained` wrapper's banner/transport-context-var/log-message wiring) and `_stdio_transport_helpers.py` (shared fixtures, split out to keep both files under the 500-line cap) — final scoped mutation score: 42/43 killed, 1 documented-equivalent (`typing.cast`'s type argument is never read at runtime, same argument as `json_native.py`'s below). `scripts/docker_smoke.sh` gains a second, independent hardening: its `timeout`/`gtimeout` wrapper was itself measured (2026-07-30) not to reliably stop a genuinely hung container (SIGTERM to the `docker run` CLIENT process does not reliably reach the CONTAINER) — the container could outlive its supposed 60s bound indefinitely. A `--cidfile`-based watchdog now `docker kill`s the actual container ID after the same 60s budget, verified against a deliberately hanging test image (fails in exactly 60s, no leaked container) and a deliberately broken/exiting one (fails immediately) — both directions proven stable across repeated runs, on both the `timeout`-available and no-timeout-binary code paths. Boy-scout: `MIN_TOOL_COUNT`'s default and source comment had drifted to 49 (citing a test name, `test_standalone_baseline_is_49_tools`, that no longer exists) against the true current baseline of 52 (`tests_py/test_main.py::test_standalone_baseline_is_52_tools`) — the gate's floor was silently weaker than it should have been by three tools' worth of regression headroom; corrected in the same change. Review found one more divergence before merge: `run_stdio_drained`'s `show_banner` defaulted to a hardcoded `True` rather than resolving `fastmcp.settings.show_server_banner` the way the composition root's replaced call (`mcp.run(transport="stdio")`, via `TransportMixin.run_async`) does — a user who disabled the banner via `FASTMCP_SHOW_SERVER_BANNER=false` got it printed on stderr on every stdio launch regardless. Fixed by defaulting `show_banner` to `None` and resolving the setting at that point, exactly where `run_async` does, so an explicit argument still overrides it; pinned in both directions by `tests_py/infrastructure/test_stdio_transport_wiring.py`. Review found a second, size-only finding: the banner fix's docstring/citation additions pushed `run_stdio_drained` to 54 lines, over both the hard `§4.2` 50-line cap and this repo's own 40-line/method `CLAUDE.md` convention. Behavior-preserving refactor (Fowler 2018 Ch. 6, Extract Function): the banner resolution and its sourced citation move into a new `_resolve_show_banner()` helper (27 lines); the same pattern is applied to `_run_low_level_drained`, which the same measurement pass found already at 57 lines, by extracting the `mcp._mcp_server.run()` call and its `cast()`-equivalence citation into `_run_mcp_with_guarded_stream()` (37 lines) — both public functions land at 39 lines, no test added or modified, same 14/14 targeted + 732/732 (5 skipped) infrastructure-suite pass counts before and after. While relocating the citation, the two upstream line-number references it carried (`fastmcp/server/mixins/transport.py` `L56-57`/`L184-186`) were verified against the actually-installed `fastmcp==3.4.5` in `.venv` and found to be a consistent −32-line offset from the real `run_async`/`run_stdio_async` locations (`L88-89`/`L216-218`); corrected in place rather than carried forward unchecked.
-### Security
-- **`transformers` 4.57.6 → 5.14.1, closing all 30 open Dependabot alerts** (#257; supersedes Dependabot's #255/#256). Three advisories require `>= 5.5.0`: GHSA-fgcw-684q-jj6r (high), GHSA-29pf-2h5f-8g72 (high), GHSA-69w3-r845-3855 (medium). `transformers` is not a direct dependency — it arrives through `sentence-transformers` — so the bump belongs in `uv.lock`, the single source of truth from which `scripts/generate_pip_constraints.py` exports every `requirements/*.txt`. Dependabot edited only the exported files, which left `huggingface-hub` at 0.36.2 against transformers 5's `>=1.5.0,<2.0` floor; that is a `ResolutionImpossible` on every job that installs dependencies, and a lock/export disagreement on Lint. Relocking moves the cluster coherently: `huggingface-hub` 0.36.2 → 1.25.1, `datasets` 2.14.4/5.0.1 → 5.0.1 (the `<3.11` fork existed only to hold `huggingface-hub<1.0`), plus `typer`/`shellingham`/`annotated-doc` as new transitive dependencies of transformers 5 — every one `requires-python >= 3.10`, matching this project's floor. `sentence-transformers` stays 5.6.1 (it declares `transformers<6.0.0,>=4.41.0`) and FlashRank is untouched (it depends on `tokenizers`/`onnxruntime`, never on transformers). **`TRANSFORMERS_OFFLINE` keeps working**: transformers 5 no longer reads it, but `huggingface_hub` 1.25.1 does (`HF_HUB_OFFLINE = _is_true(os.environ.get("HF_HUB_OFFLINE") or os.environ.get("TRANSFORMERS_OFFLINE"))`), so CI's offline test runs are unaffected.
-- **The embedding model's end-to-end behaviour is now asserted, not assumed** (#257). Every prior embedding test mocked `SentenceTransformer`, so a dependency bump could break the real model without turning a single test red — and two mechanisms would have hidden it: `_finalize_loaded` silently overwrites the requested dimension with whatever the model reports, and every non-`LOADED` state degrades to the algorithmic fallback, which also returns 384-dim L2-normalised vectors. `tests_py/infrastructure/test_embedding_live_contract.py` loads the real `all-MiniLM-L6-v2` through the production engine and asserts neural provenance (`ModelState.LOADED`, `mode == "neural"`), dimension 384, determinism across engine instances, batch/single agreement, and that paraphrases outrank unrelated text. It **fails** rather than skips when the model is unavailable under `CI`, where the pre-download step guarantees the weights.
-- **The launcher's hand-restated pins are reconciled against the lock, and the reconciliation is executable** (#257). `scripts/launcher_deps.py` restates a slice of the resolved set so a plugin bootstrap can install without a resolver; nine of its eleven pins had drifted from the `uv.lock` they cited (`fastmcp` 3.2.4 vs 3.4.5, `sentence-transformers` 5.4.1 vs 5.6.1, `pgvector` 0.4.2 vs 0.5.0, `psycopg` 3.3.3 vs 3.3.4, `pydantic` 2.13.3 vs 2.13.4, `pydantic-settings` 2.14.0 vs 2.14.2, `psycopg-pool` 3.3.0 vs 3.3.1, and a numpy fork table that still described a two-way split the lock had made three-way). A plugin install therefore resolved a combination no CI job exercised. The pins move to `scripts/launcher_pins.py` (third stdlib-only sibling, same SRP split as `launcher_deps_fs`/`launcher_deps_install`), numpy becomes a data table instead of an `if/elif` chain so branches the running interpreter does not take are still checkable, and `tests_py/scripts/test_launcher_pins_match_lock.py` fails whenever the pins and `requirements/setup.txt` disagree — on every supported Python, not just the leg's own.
-- **Every dependency install is hash-pinned, and the two that could not be pinned were replaced** (#203; closes all 21 OpenSSF Scorecard Pinned-Dependencies alerts). An exact version is **not a pin**: `foo==1.2.3` still resolves to whatever the index serves under that version today, and only a hash pins the bytes — which is what Scorecard's check encodes and why `ruff==0.15.20` and `torch==2.11.0` counted as unpinned. `pip install --require-hashes` is all-or-nothing, so it needs a resolved lock; **`uv.lock` becomes the single source of truth** and `scripts/generate_pip_constraints.py` exports one hashed file per call site into `requirements/`, refusing an export that is empty or carries an unhashed requirement. `--check` is a blocking Lint step, so a lock change that is not re-exported fails there instead of at install time. All 21 sites rewired: `ci.yml` ×7, `release.yml` ×2, `scripts/setup.sh`, and the three Dockerfiles. The project itself installs `--no-deps` against the hashed set; the root image builds a **wheel** instead, because an editable install leaves a `.pth` pointing at a build directory the runtime stage never copies. CI tool pins (ruff, pyright, build+hatchling) moved into `[dependency-groups]` so they are locked rather than restated as bare version strings in two workflow files — `hatchling` is pinned too, since `python -m build` with isolation would otherwise fetch the build backend from PyPI mid-build, outside every hash check. **The CPU-only torch build is now described by the lock**: the containers passed `--index-url https://download.pytorch.org/whl/cpu` at the call site, so `uv.lock` recorded PyPI's artifact while the image installed a different one and no source of truth could produce a hash for what was actually installed. `[[tool.uv.index]]` + `[tool.uv.sources]` bind torch to that index on Linux; the lock now carries `torch 2.13.0+cpu` with 22 hashes and resolution **drops 18 nvidia/cuda packages plus triton**. torch is named in a `container` dependency-group purely so the source can bind to it — PEP 735 groups are not published, so nothing changes for anyone installing `hypermnesia-mcp` from PyPI. The two non-pip findings had to stop being what they were rather than be pinned: `docker/Dockerfile` piped `https://deb.nodesource.com/setup_22.x` **into bash** — an unreviewed remote script executed as root at build time, with no hash to check a pipe against — and now does what that script does (fetch the signing key, register the signed apt source, install the signed package, with curl feeding `gpg --dearmor`, which executes nothing); and `npm install -g @anthropic-ai/claude-code` was **unversioned**, so the image tracked whatever the registry served that minute, and is now `npm ci` against a committed lockfile that records a sha512 integrity hash for every transitive package. **Making the lock the install source exposed a latent defect in the lock itself**, which is the point of doing it: `uv.lock` had `onnxruntime 1.24.3` recorded for the `python_full_version < '3.11'` fork, and onnxruntime 1.24.x publishes no cp310 artifact and no sdist (1.24.3 ships 24 files whose lowest interpreter tag is cp311; 1.24.0 declares no `Requires-Python` at all, which is why uv accepted it there). The entry was already on `main` and stayed invisible because `main` installs from `pyproject.toml`, where pip quietly re-resolves onnxruntime down to 1.23.2 on 3.10 — a hash-pinned install cannot, so `Test (Python 3.10)` died with `No matching distribution found for onnxruntime==1.24.3`. Fixed at the lock with a `[tool.uv] constraint-dependencies` entry (`onnxruntime<1.24 ; python_full_version < '3.11'`) rather than at the workflow: constraints steer only our resolution and are never published in the wheel metadata, so consumers on 3.11+ still resolve the current onnxruntime. Every requirements file was then re-checked with `pip install --dry-run --require-hashes` on **linux/amd64** against each Python its consumers actually use, and onnxruntime was the only package in the set with this defect.
-- **Coverage-guided fuzzing** (closes the Scorecard Fuzzing alert). Two harnesses in `fuzz/` over pure parsers that read untrusted text (§13.1 D2 — LLM-generated content is untrusted): the hand-rolled YAML frontmatter parser and the wiki source-path canonicaliser. Wired to **ClusterFuzzLite** (`.clusterfuzzlite/`, `.github/workflows/fuzz.yml`) — a 120s batch on PRs that blocks, and a longer scheduled run that does not, because a fuzzer left running will eventually find something and holding the merge queue hostage to an unrelated input makes the check ignored within a week. Writing the path harness **found a live bug**: `normalize_source_path` stripped `./` in a loop and then `/` exactly once, so removing the slashes could expose a `./` the loop had already walked past — `.//./x` came out as `./x`, still carrying the prefix the function exists to remove, and not idempotent. `extract_document_paths` dedupes on that result, so one document reachable by two spellings counted as two. Fixed by iterating to a fixed point; the four reproducers are committed as corpus inputs and **fail on the pre-fix code**. `fuzz/replay_corpus.py` runs every corpus input through its harness with no atheris, so the properties execute in the ordinary `pytest` suite on every platform — atheris publishes manylinux x86_64 wheels for cpython 3.12–3.14 and nothing else, and a property only one CI job can run is one that rots.
-
-### Fixed
- **`condensers.py`'s 123 pre-existing surviving mutants outside #196, closed** (#228). A scoped mutmut run left every non-#196 condenser with survivors no test could distinguish: `condense_code_block` (29), `condense_assistant_message` (28), `condense_memory_content` (24), `condense_timeline_event` (15), `condense_user_message` (11), `condense_entity_triples` (9), plus the fence-splitting helpers (7). The file was also 391 lines (over the 300-line §4.1 cap) with two functions over the 40-line §4.2 cap, so the behaviour-preserving split came first (`docs/audits/condensers-mutation-run-2026-07-30.md`): one file per condenser family — `condense_text.py`, `condense_code.py`, `condense_structured.py`, `condense_dispatch.py`, `condense_stage.py` — behind an unchanged `condensers.py` re-export facade, verified against the pre-existing 36-test suite (plus the #196 `pg_recall` wiring tests) passing unmodified before a single new test was added. Four new test files add exact-equality contract tests (boundary pairs, accounting ladders, literal rosters, exact routing) mirroring the split. Re-scoped mutation run: 383 mutants (383, not 352 — a few new comparison sites from the extraction), 377 killed, 6 documented-equivalent survivors (three `<=`→`<` boundary ties that fall through to an identical no-op truncation, two loop-index `<`→`<=` ties provably unreachable given how the two indices are built, and the #196 priority `3`→`4` tie re-confirmed after the split). One genuine dead-code branch surfaced by the run (`condense_assistant_message`'s trailing code-only fallback, which every mutant of survived) is deleted per §9/§12.1 rather than kept as speculative future-proofing, with the unreachability proof moved to the use site and pinned by two tests; a stale "late import" docstring claim about `assemble_prompt` (the import was already module-scope) is corrected in the same pass (§14).
- **`sqlite_sql_translate.py`'s 20 surviving mutants, closed** (#265). This module (`_translate_sql`/`_returning_was_stripped`, split out of `sqlite_compat.py` by #260) left 20 mutants surviving a scoped mutmut run — every one the same shape: a mutant dropping (or re-spelling the case of) the `flags=re.IGNORECASE` argument on one of the module's `re.sub`/`re.search` calls. Every existing case-insensitivity fixture supplies an input whose case already matches the pattern's own literal spelling, so the flag's presence was never observable. A rescoped run on this tree measured 19 of the 20 named ids still surviving (`mutmut_136` had flipped to killed between runs — non-deterministic mutant/worker ordering, not a real fix, folded back into the equivalent set below by direct regex comparison). Six are real gaps, closed with `tests_py/infrastructure/test_sqlite_sql_translate_265.py` supplying the opposite-case input for each: lowercase `DEFAULT now()`, an uppercase `&&`-overlap column, `XMAX`/`as` case variants on the xmax-drop rule, a lowercase `RETURNING` strip inside `_translate_sql` distinct from the one `_returning_was_stripped` already covered, an uppercase `ARRAY_LENGTH`, and lowercase `_returning_was_stripped` input under a monkeypatched `_SUPPORTS_RETURNING` — 6 new tests, all failing on pre-fix code. The remaining 14 are **documented equivalent mutants**: the mutation only re-spells the pattern's own literal case (`SERIAL`→`serial`, char classes `[a-z_]`↔`[A-Z_]`, etc.) while `re.IGNORECASE` stays in place, which Python's `re` semantics make provably irrelevant to the match — confirmed empirically with a differential harness (uppercase/lowercase/mixed-case probes against the original and mutated pattern, identical match results in every case) rather than asserted by inspection alone. Re-running the reproduction after the fix: 6 killed, 14 equivalent, 0 unaccounted-for survivors.
- **`mcp-toplist-badge.yml`'s monthly refresh can now actually open its PR** (#273). A real `workflow_dispatch` run (triggered while dispatch-verifying #246) reached `Open refresh PR` and failed there: `GitHub Actions is not permitted to create or approve pull requests`. The repo had **"Allow GitHub Actions to create and approve pull requests"** unchecked at Settings → Actions → General, which blocks PR *creation* itself — a stronger failure than the one the workflow's own comment anticipated ("GitHub deliberately does not trigger workflows on `GITHUB_TOKEN`-authored PRs", which only explains why such a PR's checks don't start, not why it would fail to be created at all). Fixed at the repo-policy layer (`gh api -X PUT repos/cdeust/Cortex/actions/permissions/workflow -F can_approve_pull_request_reviews=true`), which is where the root cause lives — not in the workflow, which already had the correct `secrets.BADGE_REFRESH_TOKEN || secrets.GITHUB_TOKEN` fallback and needed no logic change. The workflow's comment now documents both distinct failure modes and the fix, so a future repo transfer or org policy reset that reintroduces this is diagnosable from the file alone. A stray `chore/mcp-toplist-badge-refresh` branch pushed-then-abandoned by the earlier failing run had already been deleted (confirmed absent by this fix); the verification run for this fix leaves no stray branch either — its outcome is quoted in PR #273's description.
@@ -37,23 +65,6 @@ adheres to [Semantic Versioning](https://semver.org/).
- **Any two PRs that added tests conflicted on six files, by construction — eliminated at the root** (#293). The collected test count was hand-carried as an exact figure in `.bestpractices.json`, `CLAUDE.md`, `CONTRIBUTING.md` (×2), `README.md` (×2) and `docs/ASSURANCE-CASE.md`, each checked for EQUALITY against whichever branch's own live `pytest --collect-only` count ran in CI. That count is a property of the post-merge tree, not of any one branch: two branches that each add tests compute two different, both-true numbers and must each edit the same six lines to match, so the second to merge silently overwrites the first's correct figure with its own now-stale one — measured on this repo as two red `main` runs (PR #280 synced to its own total, #278 added more tests against a stale base) and a PR rebased three times solely to resolve the resulting conflicts. `assets/badge-tests.svg` is now the ONLY artifact stating an absolute count; the five others point at it instead of restating the figure. The badge's own check moves from an exact match to a monotone **floor** (`doc_claim_structural.check_badge_floor`, `generate_repo_badges.stale_tests_badge`): a committed count that lags the live one is stale-but-true and passes, so a PR that only adds tests never touches it, and only an actual OVER-claim — a hand-typed number, or tests removed below what was claimed — fails. A standing regression guard (`test_no_prose_file_states_the_suite_size_any_more`) asserts no scanned file, including `.bestpractices.json`, states this claim in prose again. `check_doc_claims.py` (420 lines) and `generate_repo_badges.py` (305 lines) were both over the repo's 300-line file cap before this change needed to touch them further; split into `doc_claim_sources.py`/`doc_claim_scan.py`/`doc_claim_structural.py`/`repo_badge_catalog.py` (Extract Module) with zero behavior change, verified by an unchanged existing test suite before the floor logic was added.
- **`doc_claim_scan.py`, `doc_claim_structural.py`'s remaining functions and `repo_badge_catalog.py` — every mutant reported "no tests"** (#292), the last siblings in the `badge_render`/`check_badge_floor`/`doc_claim_sources` defect family (#262/#280/#293, and #235's own instance of it): `check_doc_claims.py` bare-imports the first two (`import doc_claim_scan`, `import doc_claim_structural`) and `generate_repo_badges.py` bare-imports the third, and a function's `__module__` is fixed at definition time to whatever name it was imported under (`mutmut/mutation/trampoline.py`, `module != decorated_func.__module__`) — never mutmut's dotted, path-derived `"scripts."`, so its trampoline never activated and every mutant in these three files showed "no tests" despite being exercised by real passing tests through the bare-imported path. Fixed the same way as the existing `check_badge_floor`/`doc_claim_sources` precedent: each sibling is loaded a second time via `importlib.util.spec_from_file_location("scripts.", ...)`, and new direct-test classes (`DocClaimScanDirectTests`, `StructuralDirectTests` in `test_check_doc_claims.py`; `RepoBadgeCatalogDirectTests` in `test_generate_repo_badges.py`) call through those dotted references so mutmut's trampoline attributes the mutant to a real test. Verified with a real tally, not the absence of an error: a scoped `mutmut` run (`scripts/mutation_check.sh`) against `doc_claim_structural.py`, `doc_claim_scan.py`, `doc_claim_sources.py` and `repo_badge_catalog.py` plus both test files reports **301 mutants, 301 killed, 0 "no tests", 0 survived** — every assertion tightened to exact `assertEqual` (not `assertIn`) on the full message/dict, plus targeted `continue`-vs-`break` tests for multi-file scans for the loop-shaped survivors exact-match assertions alone don't reach. Boy-scout: `check_scanned_json_parses`'s `FileNotFoundError` branch (a missing scanned `.json` file) had no test at all, direct or indirect, before this change. Test-only; no production code changed.
-### Added
-- **The README's repo-derived badges are now self-hosted SVGs, gated in CI** — `assets/badge-{license,python,tests,references,version}.svg`, `scripts/generate_repo_badges.py`, `scripts/badge_render.py`. Five hotlinked `img.shields.io` images became committed files, for the reason the MCP Toplist badge already was one: a remote badge URL is a third-party request fired on every README view, and it lets its host restate our claim with no commit in this repository. **Only repo-derived figures were converted**, and the line is deliberate — every one of these (licence, Python floor, collected test count, bibliography size, package version) is determined BY THIS REPOSITORY, so a committed copy can always be made true again from the working tree with no network access. That is why they are kept honest by a **blocking `--check` gate on every push and PR** rather than by a cron: drift is caught where it is introduced. **Two badges were deliberately NOT converted and must not be:** the CI status badge reports the LIVE result of the last run on main, so a committed copy would assert "passing" while main was broken — a static build-status badge is a false claim by construction, not merely a stale one — and it is GitHub-hosted, so it is not a third-party beacon in the first place; the OpenSSF Best Practices badge reflects an external body's live assessment that can be downgraded without any commit here, and `.bestpractices.json` separately justifies displaying THEIR badge image. The MCP Toplist badge sits between the two and stays committed because it carries an explicit "as of " stamp, which keeps a stale copy a true statement about a point in time. **The conversion had to rewire the gate it would otherwise have silenced:** `check_doc_claims.py` enforced the version and test-count claims by regex over the shields.io URLs (`badge/version-(\d+\.\d+\.\d+)`, `badge/tests-(\d+)_passing`), so self-hosting alone would have left both patterns matching nothing while the gate still reported success. They now read the figure out of the committed SVG's own `` and **fail closed** on a missing file or an unmatched title — the predecessor's `if badge and ...` passed silently the moment its subject disappeared. A new check also fails any reintroduced `img.shields.io` hotlink in the README, so reverting this is loud rather than quiet. Found by the new tests before shipping: `--` is illegal inside an XML comment, and the first cut described its own gate as `--check` and its source as `pytest --collect-only` in the provenance comment, leaving **all five badges unparseable**; the renderer now parses every badge it produces and refuses to return one that is not well-formed, so no future wording can reintroduce the class. The shared renderer was extracted from the MCP Toplist refresher first, as a separate behavior-preserving commit whose proof is that `assets/badge-mcp-toplist.svg` is byte-identical and its 51 tests pass unchanged. Suite grows 6348 → 6373.
-- **MCP Toplist rank badge, self-hosted and refreshed on a cron** — `assets/badge-mcp-toplist.svg`, `scripts/refresh_mcp_toplist_badge.py`, `.github/workflows/mcp-toplist-badge.yml`. The badge records that Cortex ranks in the top 1.2% of MCP servers tracked by MCP Toplist (#964 of 81,919, read 2026-07-28). It is a **committed file, not a hotlinked remote image**: a remote badge URL is a third-party beacon fired on every README view, and it lets its host restate the claim at any time with no commit in this repository. The arriving PR (#241, closed) proposed exactly that. The cost of the static choice is that the badge cannot self-update — the date it carries is part of the claim and goes stale by INACTION, and inaction never opens a PR — so a monthly workflow regenerates it and proposes the diff. Monthly is deliberate: the badge stamps a month, so it is the least frequent cadence that keeps the date honest and the most frequent one that does not churn a PR proposing an identical file. Wording is **attributive throughout** (`RANKED in this tier by MCP Toplist`, never `is a top-1.2% server`), because upstream's own methodology page states the score "is a popularity and activity signal, not a quality assessment" and keeps ~25% of its weighting (organic reach, package downloads) undisclosed — the figure is attributable, not reproducible. Every generated SVG carries its own audit trail in a comment: source path, raw rank and field size, the percentile arithmetic, and the verify URL, so the next maintainer can re-derive the claim from the file alone. **Two extraction paths, fail-closed:** the structured export at `/data/leaderboard.json` is tried first but returns HTTP 503 (measured 2026-07-28: 3/3 attempts, 8–14s each under a browser UA, i.e. a server-side generation timeout, not UA gating or rate limiting), so its schema has never been observed and the parser accepts only a narrow set of documented candidate shapes under strict validation rather than guessing at one; the fallback is the server page's prose sentence `ranks #N of M servers tracked`, the ONLY construct on that page carrying both numbers (the ``, og/twitter meta tags and all four JSON-LD blocks carry the rank without the total, so none can yield a percentile). Both paths feed one validator that refuses a non-numeric, zero, negative, or beyond-the-field figure — guarding the division in `percentile()` and the semantics of the claim — and a figure that fails validation is **never written**: the script exits non-zero, the badge keeps its last good value, and the run goes red. A fallback is never silent; the path that failed is reported even when a later one succeeds. No `pull_request` trigger, so a required check never depends on a third party's uptime; the 51 unit tests cover the logic with no network at all. Caught by those tests before shipping: a top-of-field rank renders the tier as `Top <0.1%`, whose unescaped `<` **made the badge invalid XML** — text and attributes are now XML-escaped, verified across the rank-1, mid-field and last-place boundaries. Note that PRs opened with the default `GITHUB_TOKEN` do not trigger workflows, so `main`'s required checks will not start on them; the workflow prefers an optional `BADGE_REFRESH_TOKEN` secret when present. Suite grows 6297 → 6373.
-- **Pyright is now a zero-diagnostic blocking gate** (#197, final family of the maximal-strictness program). The 568-diagnostic per-rule ratchet backlog was burned to **zero** at `typeCheckingMode: "standard"` (pyright 1.1.410, measured 2026-07-28): no rule disabled, no floor raised; the single per-site suppression is the unpublished optional `cortex_beam_abstain` import whose `except ImportError` arm is the documented degraded mode. The ratchet machinery (`typecheck-baseline.json` + `scripts/check_pyright_ratchet.py`) is retired — CI fails on ANY diagnostic via pyright's own exit code, and the CI type-check env installs the `[otel]` extra so the exporter imports resolve. The burn-down was fixes, not annotations-to-match: a typed host contract for the eight `PgMemoryStore` mixins (`pg_store_host.PgStoreHost` + `MaterializedCursor`, whose honest `DictRow` typing surfaced ten unchecked `INSERT..RETURNING` sites, now `one()` with a real error), a cross-backend `StoreConnection` union for the 16 shared query modules (the psycopg-only annotation had switched checking off for every SQLite call path), and **SQLite store parity for eight methods callers already used unconditionally** — `acquire_interactive`/`acquire_batch`, `_execute`, `search_newer_neighbors`, `update_forgetting_pressure_accum`, `get_memories_by_tag`, `iter_memories_for_decay`, `find_co_accessed_pairs` — each of which previously raised `AttributeError` on the SQLite backend and was swallowed into silent degradation by broad stage boundaries. Latent bugs fixed en route, each with a regression test: the compat cursor lacked `executemany` (SQLite wiki page-sources writes crashed), `lastrowid` honesty (insert paths now raise on a broken row-id contract instead of masking it with a stale `type: ignore`), the pipeline installer accepted a success result carrying no cargo path (None flowed into the build argv), `update_style_ema(None, None)` returned `None` against a `dict` signature, `encode_session` died with a bare `TypeError` on a direction-less feature (now refuses loudly, naming it), `get_causal_chain` could return `reason=None`, and active forgetting sent a `None` timestamp into the store. `.bestpractices.json` flips `warnings_strict` to **Met**, citing the ruff select list, the pyright mode, and the measured zero. Suite grows 6275 → 6297.
-- **ruff `PLC0415` (import-outside-top-level) and `S608` (string-built SQL) are now blocking lint gates** (#197, fourth rule family of the maximal-strictness program). All 520 production `PLC0415` findings (407 `mcp_server/`, 57 `benchmarks/`, 56 `scripts/`) were triaged one by one: **360 lazy imports moved to module top** — so the import graph is static and a broken module fails at boot, not mid-operation — and the 160 that remain each carry a per-site `# noqa: PLC0415 — ` naming one of six sanctioned justifications: an optional dependency behind an extra, an internal module whose top-level closure hard-imports one (hoisting would break `[sqlite]`-only installs at import time), an ImportError-probe boundary where the except arm IS the degraded mode, an import cycle (partner named; the pre-existing #233 family), the hook latency boundary (per-event hook processes boot in ~0.05 s vs ~0.6 s for the registry closure, measured 2026-07-28 — hoisting the handler/store stack into a hook would multiply every hook event's cost), or a deferral the module itself documents. The hoist is behavior-preserving: the per-module import sweep matches the pre-change baseline exactly (515 modules, the same 6 pre-existing cycle failures), and warm import timings are unchanged. All 44 production `S608` sites carry a per-site `# noqa: S608 — ` naming the exact reason the interpolation is safe (two-literal ternaries, generated placeholder lists, module-level `WHERE` literals, or allowlist-gated identifiers per `docs/ASSURANCE-CASE.md` §5), so any NEW string-built SQL fails CI until it states its mechanism. `tests_py/**` adds both rules to its written per-file ignore (function-level imports in tests are the fixture mechanism; SQL built in tests is fixture setup against a throwaway database).
-- **ruff `PLR2004` (magic-value comparison) and `E501` (line-too-long) are now blocking lint gates** (#197, third rule family of the maximal-strictness program). All 420 production `PLR2004` findings (339 `mcp_server/`, 57 `benchmarks/`, 14 `scripts/`, 10 `video/`) were fixed with **zero `# noqa: PLR2004`**: every compared literal became a named constant carrying a `# source:` comment — a real citation where the module documents one (Frey & Morris 1997 / Kandel 2001 / Tse 2007 cascade thresholds, RFC 9110 status bands, FIPS 180-4 digest lengths, issue-quoted gates), a structural rationale for arities (split-parts, tuple lengths), and an explicit `pre-existing tuned value, extracted unchanged; provenance not recorded at introduction` where none is discoverable — never an invented source. `tests_py/**` carries a written per-file ignore (the compared literal in an assertion IS the expected value under test — the spec itself). All 470 `E501` findings (263 `mcp_server/`, 121 `tests_py/`, 48 `benchmarks/`, 37 `scripts/`, 1 `_pipeline`) were fixed by **rewrapping at the unchanged 88-column formatter limit** — string content kept byte-identical via implicit concatenation at existing whitespace (SQL and regex literals machine-verified byte-for-byte) — with exactly two per-site `# noqa: E501 — ` for unsplittable absolute-path tokens; E501 has **no** tests ignore. Two drift risks were closed at the source: `handlers/consolidation/transfer.py` re-declared as bare literals the canonical constants of `core/two_stage_transfer.py` (whose own comment forbids redefinition) and now imports them; `benchmarks/beam/ablation.py`'s copies are named per-module without value drift.
-- **ruff `BLE001` (blind-except) is now a blocking lint gate** (#197, second rule family of the maximal-strictness program). All 351 broad `except Exception` sites were triaged one by one, none blanket-ignored: (a) sites whose failure class is precisely known were **narrowed to typed excepts** — `json.loads` tag decoders to `ValueError`, lazy imports to `ImportError`, `subprocess` probes to `(OSError, SubprocessError)`, SQLite store guards to `sqlite3.Error`, PG connection/read guards to `psycopg.Error`, URL probes to `(OSError, ValueError, HTTPException)`, file I/O to `OSError` — so an unexpected programming error now **propagates instead of being absorbed** by a tolerant fallback; (b) genuine last-resort boundaries (degraded-mechanism wrappers, per-item batch isolation, hook/CLI entry points, diagnostic probes) stay broad and each carries a per-site `# noqa: BLE001 — ` naming the signal it emits; (c) ~50 previously **silent** broad handlers now emit an observable signal — `silent_failure.note()` under 38 new stable component names (spreading-activation, wiki classifier user rules, candidate scans, memify reweight/derive, ingest tag lookups, prospective-trigger injection, source attribution, wiki pointer memories, AP-bridge/groomer config reads, …) or the hook log (`session_start` banner fetches, cached-graph lookups); (d) `mcp_client` connection failures re-raise with `from e`, preserving the causal chain. `tests_py/**` keeps a written per-file ignore (broad excepts in tests are deliberate teardown/optional-path handling). Every new signal is asserted by a test (54 added).
-- **ruff `S110` (try-except-pass) is now a blocking lint gate** (#197, first rule family of the maximal-strictness program). `[tool.ruff.lint] select` is explicit in `pyproject.toml` (the former implicit defaults `E4`/`E7`/`E9`/`F`, plus `S110`), so CI fails on any newly swallowed exception in production code; `tests_py/**` carries a written per-file ignore (teardown/optional-path try-pass in tests is deliberate). All 68 production sites were triaged, none blanket-ignored: mechanism-degradation sites now report through `observability.silent_failure.note()` under stable component names (recall write-backs, RRF vector/FTS signals, sqlite vec-index maintenance, curation dedup, codebase-analyze edge/tag persistence, consolidation cascade/memify, doctor backend resolution, wiki reindex, LLM query reformulation), teardown/cleanup sites log at `DEBUG`, and sites whose failure class is precisely known were narrowed to typed excepts (hook cooldown caches, install-lock release, pre-migration sqlite guards, event-loop teardown). Every new signal is asserted by a test (57 added).
-
-- **Doc-claim gate — `scripts/check_doc_claims.py`**: the counts the documentation advertises (standalone/with-integration tool counts, bibliography references, mechanisms, version, collected tests) are now checked against the repository on every push and pull request, not at release time. Each claim has one owner — `docs/mcp-tools.md` for the tool counts (itself pinned to the live registry by `tests_py/test_main.py::test_standalone_baseline_is_52_tools`), the bibliography for references and mechanisms, `pyproject.toml` for the version, a live `pytest --collect-only` for the test count — and every other file must agree. Release-history lines are exempt. A pattern that matches nothing fails rather than passing vacuously. Wired into the Lint job (static claims) and the 3.12 test job (test count).
-- **Project governance and direction documents**: `GOVERNANCE.md` (decision model, roles and who holds them, continuity of access, DCO/CLA position), `docs/ROADMAP.md` (the twelve months to 2027-07, plus an explicit will-not-do section), and `docs/ASSURANCE-CASE.md` (security requirements, threat model, trust boundaries, secure-design principles mapped to code, CWE-by-CWE countermeasures, and what the case does not claim).
-- **An explicit, mandatory testing policy** in `CONTRIBUTING.md`: behaviour-changing PRs ship tests in the same PR, a bug fix carries a regression test that fails on the pre-fix code, and every failure path asserts the signal it emits.
-- **Document ingestion adapters — `ingest_document`** (#192): a new standalone MCP tool that ingests a **.docx** (OOXML zip, unpacked with the stdlib `zipfile`/`xml.etree` — no heavyweight dependency) or a **Confluence storage-format XHTML export** into the memory/wiki store. Both adapters parse into one typed model (`core/document_model.py`) via pure, zero-I/O parsers (`core/docx_parser.py`, `core/confluence_parser.py`) and a shared normalizer (`core/document_normalizer.py`) → the existing `wiki_write`/`remember` write path. Every produced wiki page and memory carries provenance (source path + content-hash version); re-ingesting the same document version is idempotent. Headings, paragraphs, and tables are extracted; **embedded images are skipped with an explicit notice** (no OCR); a malformed zip/XML fails **loudly and writes nothing** (no partial ingest). Filesystem/zip reading is isolated in `infrastructure/document_reader.py`; the tool composition root is `handlers/ingest_document.py`. This grows the standalone tool count 51 → 52. The shared parsing/normalization seam (`parse_confluence_storage` → `normalize_document` → write path) is what the live-Confluence REST connector (enterprise-backlog#28) will consume — that leg swaps only the byte source (REST fetch) and provenance (page URL + version).
-- **MCP prompts capability** (#176): `prompts/list` + `prompts/get` publish three guided workflows composed from Cortex's real tool surface — `session_recall` (query_methodology → recall → unified_search → recall_hierarchical → memory_stats), `promote_memories` (episodic→semantic CLS: consolidate → memory_stats → curate_distill → remember), and `curate_wiki` (unified_search → curate_wiki → wiki_write → wiki_verify). Prompt step summaries are pulled from the same handler-schema map (`merged_schemas()`) that `tools/list` is built from, so a prompt's description of a tool cannot drift from the tool's own schema (the #98 drift class). `mcp_server/mcp_prompts.py`.
-- **MCP tool profiles** (#177): a `full`/`lean` profile (`mcp_server/tool_profiles.py`) selected by `--profile` or `CORTEX_MCP_PROFILE`, enforced by `ToolProfileMiddleware`. `lean` advertises the 10-tool recall/onboarding surface (derived from `docs/mcp-tools.md` tiers + the common-session workflow); `full` keeps every tool. Per-profile `initialize.instructions`. Measured: `lean` cuts the per-session `initialize`+`tools/list` cost from ~29.9k to ~7.6k estimated tokens (74.6%), benchmark `benchmarks/mcp_profile_tokens.py`.
-
-### Fixed
- **`json_native.to_json_native`'s own committed mutation scope had 14 surviving mutants** (#250) — a third of `[tool.mutmut]`'s demonstrated example, the module written to guarantee the 2026-06-23 PG/SQLite `structuredContent` contract, had no test pinning it. 9 of the 14 were a real gap: the `tolist()`-failure debug log (`type(obj)`/`exc` args, and the format string itself) was never asserted, so a dropped arg, a swapped arg, or a reworded message all survived — `tests_py/shared/test_json_native.py::TestTolistFailureLogging` now forces the `tolist()` exception path and asserts the exact format string plus both args via `caplog`, killing all 9. The remaining 5 are **documented equivalents, not gaps**: `bytes.decode("utf-8", …)` vs `"UTF-8"` (codec lookup is case-insensitive — verified `codecs.lookup("utf-8") is codecs.lookup("UTF-8")`) and `typing.cast("SupportsFloat", obj)`'s type-hint string, which `inspect.getsource(typing.cast)` shows is `return val` — never read at runtime, so no test can ever observe a change to it. Both rationales are written at the use site (§12.1). Boy-scout: the function was already 48 lines against this repo's own 40-line convention before this change (and my initial fix pushed it to 60); split into `_decode_bytes`/`_coerce_number`/`_tolist_fallback` plus the dispatcher, each independently under 20 lines, with the existing 14 tests passing byte-for-byte unchanged as the behavior-preservation proof. Scoped mutation run: 43 → 51 mutants (the split creates more mutation sites), **0 surviving non-equivalent mutants**, same 5 equivalents renumbered under the new helpers.
- **The synaptic-plasticity modules could not be imported directly** (#233). `synaptic_plasticity.py` held the Tsodyks-Markram implementation AND back-imported its two siblings at the bottom of the file (behind `# noqa: E402`), while `synaptic_plasticity_hebbian.py` and `synaptic_plasticity_stochastic.py` imported names back out of it — a cycle. Whichever of the three was imported FIRST in a fresh interpreter decided whether the import worked: `python -c "import mcp_server.core.synaptic_plasticity_hebbian"` raised `ImportError: cannot import name 'apply_hebbian_update' from partially initialized module`, and `_stochastic` raised the same for `apply_stochastic_hebbian_update`. Fixed by extracting the implementation down into a new leaf, **`synaptic_plasticity_stp.py`** (Tsodyks-Markram state and dynamics, noise injection, theta-phase gating), whose only imports are `math`, `random` and `dataclasses`; the two siblings now depend on the leaf, and `synaptic_plasticity.py` becomes a pure re-export facade with the same 14-name `__all__`. The moved code is **byte-identical** to the lines it came from — no constant, equation or `# source:` comment was touched — so behaviour is unchanged (160 pre-existing tests across the plasticity, ablation-hook and consolidation-handler suites pass untouched). Both `# noqa: E402` markers are gone with the cycle that required them. **The suite was green throughout the whole time this was broken**, because pytest imports the facade first and every later import is a `sys.modules` cache hit — so the regression test (`tests_py/core/test_import_isolation.py`) runs each module in a **separate interpreter**; an in-process import cannot reproduce the class. On the pre-fix tree it fails 3 of 4. Mutation testing on the relocated code found the equations were pinned only by inequality assertions (29 of 128 mutants survived, e.g. `u * (1 - U)` → `u / (1 - U)`, `exp(-t/tau)` → `exp(-t*tau)`, `round(·, 6)` → `round(·, 7)`); 8 exact-value tests now kill 26 of them, and the 3 that remain are equivalent mutants documented with their rationale at the top of `test_stochastic_transmission.py`. Suite grows 6376 → 6388.
- **`update_concept` interpolated arbitrary dict keys into its SQL `SET` clause** — the one string-built-SQL site whose identifiers did NOT flow through an in-code allowlist (`docs/ASSURANCE-CASE.md` §5). Its single caller (`wiki_emerge`) passes literal keys, so no injection was reachable today, but the boundary itself enforced nothing: pre-fix, an injection-shaped key like `"label = 'x', status"` reached the SQL verbatim. Unknown keys are now REFUSED (`ValueError` before any SQL is built) against the `_UPDATABLE_COLUMNS` allowlist — the same refuse-not-escape mechanism as `wiki_view_executor._TABLE_WHITELIST` — and a DDL-drift guard test pins the allowlist to the `wiki.concepts` schema. Surfaced by the #197 family-4 S608 sweep. Tests: `tests_py/infrastructure/test_pg_store_wiki_concepts_allowlist.py`.
@@ -67,19 +78,15 @@ adheres to [Semantic Versioning](https://semver.org/).
- **The stage-aware context assembler dropped memories it had selected, instead of condensing them** (#196). Its own contract reads "may truncate individual chunks but never reduces the count of selected items"; the code did the opposite — Phase 2 skipped any memory that did not fit the remaining budget and Phase 3 broke out of its loop, so the longest memories (the ones retrieval had just ranked highest) vanished from both the rendered text and `selected_memories`, while Phase 1's 60 % share was never computed at all. Measured pre-fix with two ~760-token adjacent memories at a 120-token budget: zero phase-2 memories and empty adjacent text. The domain-aware condensers in `core/context_assembly/condensers.py` were written for exactly this reduction and had no caller — they are now the packing rule's reduction step (`core/context_assembly/stage_phases.py`), giving every item a share of the budget (the Swift ContextDecomposer rule, now a single definition in `budget.proportional_share`) and condensing the over-share ones. One output per input, never a drop. Behaviour is unchanged when `token_budget is None`, which is what every current caller passes. Also fixed: `condense_assistant_message` could return an empty string — a single code block larger than the whole budget kept no blocks and joined an empty list, deleting the memory outright — and now falls back to `truncate_to_budget` like every sibling condenser.
- **The reranker can no longer hang a process on a stalled model download** (`CORTEX_RERANKER_OFFLINE`). FlashRank fetches its ONNX weights with a bare `requests.get(..., stream=True)` carrying **no timeout**, and it bypasses the `huggingface_hub` client entirely — so `HF_HUB_OFFLINE`/`TRANSFORMERS_OFFLINE` never reached it, and a stalled TCP connect blocked the calling thread indefinitely rather than raising, which meant `_ensure_reranker`'s `except Exception` (the 2026-07-10 incident's fix) could not engage. This was not hypothetical: CI run 30263190266 (main, Python 3.12→3.13 leg, 2026-07-27) hung inside `sock.connect` during a recall test until pytest-timeout killed the entire suite at 300s, while every other matrix leg happened to download fine — an intermittent red build whose frequency scales with how cold the cache is. Setting `CORTEX_RERANKER_OFFLINE` to a truthy value now refuses the download when the cached model file is absent and takes the existing, already-tested degraded path (first-stage WRRF scores only, with a warning naming the variable and the exact path it expected). **Production behaviour is unchanged** — the variable is unset by default, so FlashRank's documented first-run self-provisioning download (`PRIVACY.md`) still happens; air-gapped installs gain a real switch. CI now caches `~/.cache/flashrank` and pre-downloads the model in a loud, retrying step (mirroring the embedding model's), then sets the variable for the test steps so no download can ever occur mid-suite.
-### Removed
-- **Two modules whose call site was never built** (#196, criterion 1). `infrastructure/git_diff.py` (with its private siblings `git_diff_exec.py` and `git_diff_format.py`, whose only importer it was): its one caller, `server/http_file_diff.py`, was deleted deliberately in the Phase 6 visualization strip, and the behaviour now lives in the **cortex-viz** MCP — `cortex_viz/server/git_diff_engine.py`, shipped in v2.7.1, routed at `/api/file-diff`, with its own test suite; that module exists precisely because the copies descended from this one had diverged and were losing patches on merge commits. The one proposal to build an in-Cortex caller (`core/git_diff_to_symbols.py`) was retracted in the corrected gap analysis, since `automatised-pipeline`'s `detect_changes` already does it and `hooks/pipeline_impact_bump.py` already calls it. `shared/memory_types.py`: 9 Pydantic models with zero references anywhere, whose docstring claimed to define "the schema for SQLite storage and handler I/O" while the real contract is `dict[str, Any]` normalised by `sqlite_store` and validated per-tool by `validation/schemas.py` — the same species as the 13 scaffolding models removed earlier, verified by the same method.
-- **`core/context_assembly/active_retrieval.py` (issue #201, the second of #196's flagged zero-importer pair alongside `decomposer.py`).** `grep -rn "context_assembly.active_retrieval"` across `mcp_server/`, `tests_py/`, and `benchmarks/` (excluding the module's own directory) returned nothing — no production handler, no benchmark harness (`benchmarks/beam/run_benchmark.py` calls `pg_recall.assemble_context`, which never reaches this module), and no test beyond the module's own two test files. `decomposer.py`'s companion half of #201 was already resolved on `main` before this issue could be picked up: `condensers.condense_assembled_context` (now `condense_stage.py` post-#228 split) calls `decomposer.assemble_prompt` from `pg_recall.assemble_context`'s over-budget path, itself exercised by the BEAM benchmark harness — a real, non-test caller — so `decomposer.py` and its sole consumer `warning.py` both stay. `active_retrieval.py` had no equivalent: checked git history (added whole in the 3.18.4 release commit `5eba011`, touched twice since only for lint-family constant/exception-handling sweeps, never gained a caller) and all 12 unmerged remote branches (`git grep` for `active_retrieval|ActiveRetriever|KeywordExtractor|LLMReformulator` across each `origin/main..` diff — zero hits) for a removed or pending caller; found none. Wiring it would be new retrieval-affecting behaviour (MIRIX-style query reformulation ahead of the WRRF pipeline) needing its own benchmark validation, not a mechanical fix — out of scope for a dead-code removal. Deleted with its direct test file (`tests_py/core/context_assembly/test_active_retrieval.py`, 18 tests) and the two S110-sweep regression tests in `tests_py/core/test_s110_sweep_core.py::TestActiveRetrievalReformulate` that existed only to cover its `except`-logging path (#197 family 1) — both entirely about behaviour that no longer exists. `docs/module-inventory.md`'s `core/context_assembly/` count corrected 15 → 14 (`core/` 230 → 229); `docs/papers/research-post-context-assembly.md` §3.4 and its `docs/arxiv-context-assembly/main.tex` mirror (§ Active Retrieval) reworded from "the interface is defined and wired" to the accurate "never composed into the WRRF pipeline or the BEAM harness — removed as dead code", and both papers' Appendix-C module tables drop the `active_retrieval.py` row. Suite: 6938 passed, 5 skipped, 121 subtests passed (pre-change) → 6918 passed, 5 skipped, 121 subtests passed (post-change), the 20-test delta being exactly the deleted tests; zero tests modified.
-
### Security
-- Destructive tools (`forget`, `wiki_purge`, `wiki_migrate`) are **gated, not merely hidden** under `lean` (#177 criterion 5): excluded tools are absent from `tools/list` AND rejected on call by `ToolProfileMiddleware.on_call_tool`. Hiding a tool from the list while still executing it on call would be a hole, not a token optimisation. Asserted by `tests_py/test_tool_profiles.py::TestSurface::test_lean_hides_and_rejects_destructive_calls`.
-### Changed
-- **Pyright now runs `standard` instead of `basic`** (#197 criterion 3), and the `_conn` annotation that made the raise look expensive is corrected. `SqliteMemoryStore` assigns `self._conn = PsycopgCompatConnection(raw)`, but 9 of its 10 mixins declared `_conn: sqlite3.Connection` (only `SqliteGroomingMixin` was right) — so every `self._conn.…` call in those mixins, and every handler reading `store._conn`, was type-checked against a class the store never holds. With all ten agreeing, `standard` and `basic` report the **identical 418 errors**: the raise costs nothing. Measured at the CI-pinned pyright 1.1.410 in a CI-equivalent environment (`reportMissingImports` lands at 1, matching `typecheck-baseline.json`, so nothing is Unknown-suppressed). `strict` is not adopted — it reports 10,231, ~9,300 of them the Unknown-type family, which is an annotation-coverage project rather than a config flip. The ratchet's blocking rules (`reportOptionalMemberAccess`, `reportOptionalSubscript`) stay at 0 and `typecheck-baseline.json` is untouched — no floor was raised to make the build pass. The corrected annotation immediately surfaced a live defect it had been masking: seven wiki handlers call `store._conn.cursor()`, which `PsycopgCompatConnection` does not implement, so the wiki pipeline is silently dead on the SQLite backend — filed as **#206**. Also fixes two `TYPE_CHECKING` imports in `tool_profile_middleware.py` that still pointed at FastMCP 2.x module paths (`fastmcp.prompts.prompt`, `fastmcp.tools.tool`); under the 3.x the project actually runs, those resolve to nothing and silently degraded `Prompt`/`Tool`/`ToolResult` to Unknown.
-- **The doc-claim gate now covers the OpenSSF answers and the second test-count phrasing.** `.bestpractices.json` was not scanned, and its answers are transcribed verbatim into the OpenSSF Best Practices questionnaire — so a stale number there is published to the badge rather than merely sitting in the repository. Three of its test counts had drifted two corrections behind (5571 against a 5598-test suite), one of them inside a justification dated the day it was read. It joins `SCANNED_FILES`. The count pattern also read only the `N tests` wording, so the `N-test suite` phrasing went unchecked; `TEST_CLAIM` now matches both, with a test pinning that a count of *test files* is still not a claim about suite size.
-- **Stale documentation claims corrected** (surfaced by the new doc-claim gate): the advertised tool count is 52 standalone / 55 with the optional upstream integrations (README said 50/53 in eight places, `CONTRIBUTING.md` said 43/46, `CLAUDE.md` said 51, the MCPB manifest said 49/52); the bibliography is 97 references behind 36 mechanisms (`CONTRIBUTING.md` said 72 and 23, the manifest the same); the advertised test count is 5598 and the version badge is 4.16.0. `CONTRIBUTING.md` documented a `mypy --strict src/cortex/` gate and a `black` formatter the project has never run — the real gates are ruff (pinned 0.15.20) and the pyright per-rule ratchet — and attributed a `pre-tool-secret-shield` file gate to Cortex that belongs to the maintainer's local agent tooling; in-repo the defence is `mcp_server/shared/redaction.py`. Prerequisites no longer claim PostgreSQL is required (SQLite is the default store). `docs/api-reference.md` still documented `get_methodology_graph` and `open_visualization`, extracted to cortex-viz in v3.21.0 — both sections are replaced by a pointer, and the same stale references are corrected in the `/methodology` command, `docs/mcp-tools.md`, and two MCP tool descriptions (`get_rules`, `explore_features`) that told the model about a tool this server no longer registers. The README's citation section pointed at a non-existent `docs/science.md`.
-- **Pyright type-debt burned back below the 568 baseline** (#188): the `MemoryStore` factory now types its `__new__` / `get_shared_store()` / `_construct_store()` results as the real `PgMemoryStore | SqliteMemoryStore` union it actually builds, instead of the empty factory shell pyright previously saw. That shell suppressed attribute resolution across 55+ handlers (every `store.get_memory(...)` read as "attribute for class MemoryStore"); annotating the real return type drops the tree-wide pyright total from 638 to 422 (`reportAttributeAccessIssue` 387→192, `reportReturnType` 52→18, `reportAssignmentType` 26→1). Truthful typing also unmasked latent argument/call errors previously hidden behind the shell — these stay visible in the ratchet's tracked (non-blocking) counters for iterative burn-down. No runtime behaviour change: the `MemoryStore` name is a `TYPE_CHECKING`-only union alias; the runtime factory class is byte-identical. Blocking-tier fix: `wiki_emerge`'s cold-start `COUNT(*)` read now guards the (SQL-unreachable) `fetchone() is None` case the truthful type exposed.
-- **The default MCP tool profile is `full`** (behaviour preserved; H4 note). This diverges from #177 criterion 2's "default to the common-session profile": shrinking the default advertised surface is a breaking change (a client that called a now-hidden tool would break), so — mirroring `automatised-pipeline`'s `ToolProfile` reasoning and this wave's explicit decision — `full` stays the default and `lean` is opt-in. Existing sessions are unchanged; the middleware is a pass-through under `full`.
+- **`transformers` 4.57.6 → 5.14.1, closing all 30 open Dependabot alerts** (#257; supersedes Dependabot's #255/#256). Three advisories require `>= 5.5.0`: GHSA-fgcw-684q-jj6r (high), GHSA-29pf-2h5f-8g72 (high), GHSA-69w3-r845-3855 (medium). `transformers` is not a direct dependency — it arrives through `sentence-transformers` — so the bump belongs in `uv.lock`, the single source of truth from which `scripts/generate_pip_constraints.py` exports every `requirements/*.txt`. Dependabot edited only the exported files, which left `huggingface-hub` at 0.36.2 against transformers 5's `>=1.5.0,<2.0` floor; that is a `ResolutionImpossible` on every job that installs dependencies, and a lock/export disagreement on Lint. Relocking moves the cluster coherently: `huggingface-hub` 0.36.2 → 1.25.1, `datasets` 2.14.4/5.0.1 → 5.0.1 (the `<3.11` fork existed only to hold `huggingface-hub<1.0`), plus `typer`/`shellingham`/`annotated-doc` as new transitive dependencies of transformers 5 — every one `requires-python >= 3.10`, matching this project's floor. `sentence-transformers` stays 5.6.1 (it declares `transformers<6.0.0,>=4.41.0`) and FlashRank is untouched (it depends on `tokenizers`/`onnxruntime`, never on transformers). **`TRANSFORMERS_OFFLINE` keeps working**: transformers 5 no longer reads it, but `huggingface_hub` 1.25.1 does (`HF_HUB_OFFLINE = _is_true(os.environ.get("HF_HUB_OFFLINE") or os.environ.get("TRANSFORMERS_OFFLINE"))`), so CI's offline test runs are unaffected.
+- **The embedding model's end-to-end behaviour is now asserted, not assumed** (#257). Every prior embedding test mocked `SentenceTransformer`, so a dependency bump could break the real model without turning a single test red — and two mechanisms would have hidden it: `_finalize_loaded` silently overwrites the requested dimension with whatever the model reports, and every non-`LOADED` state degrades to the algorithmic fallback, which also returns 384-dim L2-normalised vectors. `tests_py/infrastructure/test_embedding_live_contract.py` loads the real `all-MiniLM-L6-v2` through the production engine and asserts neural provenance (`ModelState.LOADED`, `mode == "neural"`), dimension 384, determinism across engine instances, batch/single agreement, and that paraphrases outrank unrelated text. It **fails** rather than skips when the model is unavailable under `CI`, where the pre-download step guarantees the weights.
+- **The launcher's hand-restated pins are reconciled against the lock, and the reconciliation is executable** (#257). `scripts/launcher_deps.py` restates a slice of the resolved set so a plugin bootstrap can install without a resolver; nine of its eleven pins had drifted from the `uv.lock` they cited (`fastmcp` 3.2.4 vs 3.4.5, `sentence-transformers` 5.4.1 vs 5.6.1, `pgvector` 0.4.2 vs 0.5.0, `psycopg` 3.3.3 vs 3.3.4, `pydantic` 2.13.3 vs 2.13.4, `pydantic-settings` 2.14.0 vs 2.14.2, `psycopg-pool` 3.3.0 vs 3.3.1, and a numpy fork table that still described a two-way split the lock had made three-way). A plugin install therefore resolved a combination no CI job exercised. The pins move to `scripts/launcher_pins.py` (third stdlib-only sibling, same SRP split as `launcher_deps_fs`/`launcher_deps_install`), numpy becomes a data table instead of an `if/elif` chain so branches the running interpreter does not take are still checkable, and `tests_py/scripts/test_launcher_pins_match_lock.py` fails whenever the pins and `requirements/setup.txt` disagree — on every supported Python, not just the leg's own.
+- **Every dependency install is hash-pinned, and the two that could not be pinned were replaced** (#203; closes all 21 OpenSSF Scorecard Pinned-Dependencies alerts). An exact version is **not a pin**: `foo==1.2.3` still resolves to whatever the index serves under that version today, and only a hash pins the bytes — which is what Scorecard's check encodes and why `ruff==0.15.20` and `torch==2.11.0` counted as unpinned. `pip install --require-hashes` is all-or-nothing, so it needs a resolved lock; **`uv.lock` becomes the single source of truth** and `scripts/generate_pip_constraints.py` exports one hashed file per call site into `requirements/`, refusing an export that is empty or carries an unhashed requirement. `--check` is a blocking Lint step, so a lock change that is not re-exported fails there instead of at install time. All 21 sites rewired: `ci.yml` ×7, `release.yml` ×2, `scripts/setup.sh`, and the three Dockerfiles. The project itself installs `--no-deps` against the hashed set; the root image builds a **wheel** instead, because an editable install leaves a `.pth` pointing at a build directory the runtime stage never copies. CI tool pins (ruff, pyright, build+hatchling) moved into `[dependency-groups]` so they are locked rather than restated as bare version strings in two workflow files — `hatchling` is pinned too, since `python -m build` with isolation would otherwise fetch the build backend from PyPI mid-build, outside every hash check. **The CPU-only torch build is now described by the lock**: the containers passed `--index-url https://download.pytorch.org/whl/cpu` at the call site, so `uv.lock` recorded PyPI's artifact while the image installed a different one and no source of truth could produce a hash for what was actually installed. `[[tool.uv.index]]` + `[tool.uv.sources]` bind torch to that index on Linux; the lock now carries `torch 2.13.0+cpu` with 22 hashes and resolution **drops 18 nvidia/cuda packages plus triton**. torch is named in a `container` dependency-group purely so the source can bind to it — PEP 735 groups are not published, so nothing changes for anyone installing `hypermnesia-mcp` from PyPI. The two non-pip findings had to stop being what they were rather than be pinned: `docker/Dockerfile` piped `https://deb.nodesource.com/setup_22.x` **into bash** — an unreviewed remote script executed as root at build time, with no hash to check a pipe against — and now does what that script does (fetch the signing key, register the signed apt source, install the signed package, with curl feeding `gpg --dearmor`, which executes nothing); and `npm install -g @anthropic-ai/claude-code` was **unversioned**, so the image tracked whatever the registry served that minute, and is now `npm ci` against a committed lockfile that records a sha512 integrity hash for every transitive package. **Making the lock the install source exposed a latent defect in the lock itself**, which is the point of doing it: `uv.lock` had `onnxruntime 1.24.3` recorded for the `python_full_version < '3.11'` fork, and onnxruntime 1.24.x publishes no cp310 artifact and no sdist (1.24.3 ships 24 files whose lowest interpreter tag is cp311; 1.24.0 declares no `Requires-Python` at all, which is why uv accepted it there). The entry was already on `main` and stayed invisible because `main` installs from `pyproject.toml`, where pip quietly re-resolves onnxruntime down to 1.23.2 on 3.10 — a hash-pinned install cannot, so `Test (Python 3.10)` died with `No matching distribution found for onnxruntime==1.24.3`. Fixed at the lock with a `[tool.uv] constraint-dependencies` entry (`onnxruntime<1.24 ; python_full_version < '3.11'`) rather than at the workflow: constraints steer only our resolution and are never published in the wheel metadata, so consumers on 3.11+ still resolve the current onnxruntime. Every requirements file was then re-checked with `pip install --dry-run --require-hashes` on **linux/amd64** against each Python its consumers actually use, and onnxruntime was the only package in the set with this defect.
+- **Coverage-guided fuzzing** (closes the Scorecard Fuzzing alert). Two harnesses in `fuzz/` over pure parsers that read untrusted text (§13.1 D2 — LLM-generated content is untrusted): the hand-rolled YAML frontmatter parser and the wiki source-path canonicaliser. Wired to **ClusterFuzzLite** (`.clusterfuzzlite/`, `.github/workflows/fuzz.yml`) — a 120s batch on PRs that blocks, and a longer scheduled run that does not, because a fuzzer left running will eventually find something and holding the merge queue hostage to an unrelated input makes the check ignored within a week. Writing the path harness **found a live bug**: `normalize_source_path` stripped `./` in a loop and then `/` exactly once, so removing the slashes could expose a `./` the loop had already walked past — `.//./x` came out as `./x`, still carrying the prefix the function exists to remove, and not idempotent. `extract_document_paths` dedupes on that result, so one document reachable by two spellings counted as two. Fixed by iterating to a fixed point; the four reproducers are committed as corpus inputs and **fail on the pre-fix code**. `fuzz/replay_corpus.py` runs every corpus input through its harness with no atheris, so the properties execute in the ordinary `pytest` suite on every platform — atheris publishes manylinux x86_64 wheels for cpython 3.12–3.14 and nothing else, and a property only one CI job can run is one that rots.
+
+- Destructive tools (`forget`, `wiki_purge`, `wiki_migrate`) are **gated, not merely hidden** under `lean` (#177 criterion 5): excluded tools are absent from `tools/list` AND rejected on call by `ToolProfileMiddleware.on_call_tool`. Hiding a tool from the list while still executing it on call would be a hole, not a token optimisation. Asserted by `tests_py/test_tool_profiles.py::TestSurface::test_lean_hides_and_rejects_destructive_calls`.
## [4.16.0] - 2026-07-25
diff --git a/README.md b/README.md
index 733d5b75..6d8a74f1 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-
+