From 8a7a328819ec4ad7ac5f8fa8c44aa061dc1234ae Mon Sep 17 00:00:00 2001 From: blocks Date: Thu, 26 Mar 2026 11:19:00 +0800 Subject: [PATCH 01/62] feat: add Codex integration stack surfaces --- package.json | 5 +- pnpm-lock.yaml | 737 ++++++ src/lib/cli/register-commands.ts | 174 +- src/lib/commands/hooks.ts | 44 +- src/lib/commands/integrations.ts | 480 ++++ src/lib/commands/mcp.ts | 133 ++ src/lib/commands/recall.ts | 147 ++ src/lib/commands/skills.ts | 48 + src/lib/domain/memory-retrieval-contract.ts | 158 ++ src/lib/domain/memory-retrieval.ts | 66 + src/lib/integration/agents-guidance.ts | 148 ++ src/lib/integration/assets.ts | 442 ++++ src/lib/integration/codex-stack.ts | 596 +++++ src/lib/integration/install-assets.ts | 142 ++ src/lib/integration/mcp-config.ts | 79 + src/lib/integration/mcp-doctor.ts | 789 +++++++ src/lib/integration/mcp-hosts.ts | 282 +++ src/lib/integration/mcp-install.ts | 233 ++ src/lib/integration/retrieval-contract.ts | 94 + src/lib/integration/skills-paths.ts | 139 ++ src/lib/mcp/retrieval-server.ts | 211 ++ src/lib/runtime/runtime-context.ts | 19 +- test/dist-cli-smoke.test.ts | 572 +++++ test/helpers/cli-runner.ts | 24 +- test/helpers/mcp-client.ts | 41 + test/hooks-command.test.ts | 185 ++ test/integrations-command.test.ts | 583 +++++ test/mcp-command.test.ts | 2264 +++++++++++++++++++ test/recall-command.test.ts | 308 +++ test/skills-command.test.ts | 198 ++ test/tarball-install-smoke.test.ts | 176 +- 31 files changed, 9474 insertions(+), 43 deletions(-) create mode 100644 src/lib/commands/integrations.ts create mode 100644 src/lib/commands/mcp.ts create mode 100644 src/lib/commands/recall.ts create mode 100644 src/lib/commands/skills.ts create mode 100644 src/lib/domain/memory-retrieval-contract.ts create mode 100644 src/lib/domain/memory-retrieval.ts create mode 100644 src/lib/integration/agents-guidance.ts create mode 100644 src/lib/integration/assets.ts create mode 100644 src/lib/integration/codex-stack.ts create mode 100644 src/lib/integration/install-assets.ts create mode 100644 src/lib/integration/mcp-config.ts create mode 100644 src/lib/integration/mcp-doctor.ts create mode 100644 src/lib/integration/mcp-hosts.ts create mode 100644 src/lib/integration/mcp-install.ts create mode 100644 src/lib/integration/retrieval-contract.ts create mode 100644 src/lib/integration/skills-paths.ts create mode 100644 src/lib/mcp/retrieval-server.ts create mode 100644 test/helpers/mcp-client.ts create mode 100644 test/hooks-command.test.ts create mode 100644 test/integrations-command.test.ts create mode 100644 test/mcp-command.test.ts create mode 100644 test/recall-command.test.ts create mode 100644 test/skills-command.test.ts diff --git a/package.json b/package.json index 114d336..44feabd 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,10 @@ "pack:check": "npm pack --dry-run", "prepack": "pnpm build", "test": "vitest run --exclude test/dist-cli-smoke.test.ts --exclude test/tarball-install-smoke.test.ts", - "test:cli-smoke": "vitest run test/audit.test.ts test/memory-command.test.ts test/session-command.test.ts test/wrapper-session-continuity.test.ts", + "test:cli-smoke": "vitest run test/audit.test.ts test/hooks-command.test.ts test/integrations-command.test.ts test/mcp-command.test.ts test/skills-command.test.ts test/memory-command.test.ts test/recall-command.test.ts test/session-command.test.ts test/wrapper-session-continuity.test.ts", "test:dist-cli-smoke": "vitest run test/dist-cli-smoke.test.ts", "test:docs-contract": "vitest run test/docs-contract.test.ts", - "test:reviewer-smoke": "vitest run test/docs-contract.test.ts test/memory-command.test.ts test/session-command.test.ts test/wrapper-session-continuity.test.ts test/session-continuity.test.ts", + "test:reviewer-smoke": "vitest run test/docs-contract.test.ts test/hooks-command.test.ts test/integrations-command.test.ts test/mcp-command.test.ts test/skills-command.test.ts test/memory-command.test.ts test/recall-command.test.ts test/session-command.test.ts test/wrapper-session-continuity.test.ts test/session-continuity.test.ts", "test:tarball-install-smoke": "vitest run test/tarball-install-smoke.test.ts", "test:watch": "vitest", "verify:release": "pnpm lint && pnpm test:docs-contract && pnpm test:reviewer-smoke && pnpm test:cli-smoke && pnpm test && pnpm build && pnpm test:dist-cli-smoke && pnpm pack:check && pnpm test:tarball-install-smoke" @@ -54,6 +54,7 @@ }, "homepage": "https://github.com/Boulea7/Codex-Auto-Memory#readme", "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", "commander": "^14.0.1", "smol-toml": "^1.4.1", "zod": "^3.25.76" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9a5947..a0863e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.27.1 + version: 1.27.1(zod@3.25.76) commander: specifier: ^14.0.1 version: 14.0.3 @@ -192,9 +195,25 @@ packages: cpu: [x64] os: [win32] + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@modelcontextprotocol/sdk@1.27.1': + resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@rollup/rollup-android-arm-eabi@4.59.0': resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] @@ -374,6 +393,21 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -382,14 +416,30 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + brace-expansion@5.0.4: resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} engines: {node: 18 || 20 || >=22} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -402,6 +452,30 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -415,21 +489,79 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + esbuild@0.27.4: resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} engines: {node: '>=18'} hasBin: true + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.3.1: + resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -439,11 +571,34 @@ packages: picomatch: optional: true + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} @@ -451,9 +606,59 @@ packages: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hono@4.12.9: + resolution: {integrity: sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -464,6 +669,26 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} @@ -480,13 +705,43 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -501,10 +756,34 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -518,6 +797,48 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -532,6 +853,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -560,11 +885,19 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} hasBin: true + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -573,6 +906,14 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -646,11 +987,24 @@ packages: jsdom: optional: true + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -734,8 +1088,34 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true + '@hono/node-server@1.19.11(hono@4.12.9)': + dependencies: + hono: 4.12.9 + '@jridgewell/sourcemap-codec@1.5.5': {} + '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.11(hono@4.12.9) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.1(express@5.2.1) + hono: 4.12.9 + jose: 6.2.2 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - supports-color + '@rollup/rollup-android-arm-eabi@4.59.0': optional: true @@ -866,16 +1246,58 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + assertion-error@2.0.1: {} balanced-match@4.0.4: {} + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + brace-expansion@5.0.4: dependencies: balanced-match: 4.0.4 + bytes@3.1.2: {} + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -888,14 +1310,53 @@ snapshots: commander@14.0.3: {} + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + debug@4.4.3: dependencies: ms: 2.1.3 deep-eql@5.0.2: {} + depd@2.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + esbuild@0.27.4: optionalDependencies: '@esbuild/aix-ppc64': 0.27.4 @@ -925,19 +1386,106 @@ snapshots: '@esbuild/win32-ia32': 0.27.4 '@esbuild/win32-x64': 0.27.4 + escape-html@1.0.3: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 + etag@1.8.1: {} + + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + expect-type@1.3.0: {} + express-rate-limit@8.3.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.0: {} + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 @@ -948,8 +1496,46 @@ snapshots: minipass: 7.1.3 path-scurry: 2.0.2 + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hono@4.12.9: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ip-address@10.1.0: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + jose@6.2.2: {} + js-tokens@9.0.1: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + loupe@3.2.1: {} lru-cache@11.2.7: {} @@ -958,6 +1544,18 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + minimatch@10.2.4: dependencies: brace-expansion: 5.0.4 @@ -968,13 +1566,33 @@ snapshots: nanoid@3.3.11: {} + negotiator@1.0.0: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + package-json-from-dist@1.0.1: {} + parseurl@1.3.3: {} + + path-key@3.1.1: {} + path-scurry@2.0.2: dependencies: lru-cache: 11.2.7 minipass: 7.1.3 + path-to-regexp@8.3.0: {} + pathe@2.0.3: {} pathval@2.0.1: {} @@ -983,12 +1601,34 @@ snapshots: picomatch@4.0.3: {} + pkce-challenge@5.0.1: {} + postcss@8.5.8: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + require-from-string@2.0.2: {} + resolve-pkg-maps@1.0.0: {} rimraf@6.1.3: @@ -1027,6 +1667,79 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} smol-toml@1.6.0: {} @@ -1035,6 +1748,8 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} strip-literal@3.1.0: @@ -1056,6 +1771,8 @@ snapshots: tinyspy@4.0.4: {} + toidentifier@1.0.1: {} + tsx@4.21.0: dependencies: esbuild: 0.27.4 @@ -1063,10 +1780,20 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript@5.9.3: {} undici-types@7.16.0: {} + unpipe@1.0.0: {} + + vary@1.1.2: {} + vite-node@3.2.4(@types/node@24.12.0)(tsx@4.21.0): dependencies: cac: 6.7.14 @@ -1142,9 +1869,19 @@ snapshots: - tsx - yaml + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + wrappy@1.0.2: {} + + zod-to-json-schema@3.25.1(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod@3.25.76: {} diff --git a/src/lib/cli/register-commands.ts b/src/lib/cli/register-commands.ts index eb7f15e..cbd5f52 100644 --- a/src/lib/cli/register-commands.ts +++ b/src/lib/cli/register-commands.ts @@ -3,10 +3,24 @@ import { runAudit } from "../commands/audit.js"; import { runDoctor } from "../commands/doctor.js"; import { runForget } from "../commands/forget.js"; import { installHooks, removeHooks } from "../commands/hooks.js"; +import { + runIntegrationsApply, + runIntegrationsDoctor, + runIntegrationsInstall +} from "../commands/integrations.js"; import { runInit } from "../commands/init.js"; import { runMemory } from "../commands/memory.js"; +import { + runMcpApplyGuidance, + runMcpDoctor, + runMcpInstall, + runMcpPrintConfig, + runMcpServe +} from "../commands/mcp.js"; +import { runRecall } from "../commands/recall.js"; import { runRemember } from "../commands/remember.js"; import { runSession } from "../commands/session.js"; +import { installSkills, removeSkills } from "../commands/skills.js"; import { runSync } from "../commands/sync.js"; type AsyncCommandHandler = (...args: Args) => Promise; @@ -93,11 +107,11 @@ function registerSessionCommands(program: Command): void { function registerHookCommands(program: Command): void { const hooksCommand = program .command("hooks") - .description("Manage future hook bridge assets"); + .description("Manage the local bridge and fallback helper bundle for current and upcoming integrations"); hooksCommand .command("install") - .description("Generate local hook bridge assets") + .description("Generate the local recall bridge bundle plus startup and post-session helper scripts") .action(withStdout(async () => installHooks())); hooksCommand @@ -106,6 +120,158 @@ function registerHookCommands(program: Command): void { .action(withStdout(async () => removeHooks())); } +function registerSkillCommands(program: Command): void { + const skillsCommand = program + .command("skills") + .description("Manage Codex skill assets for MCP-first and CLI-fallback durable memory retrieval"); + + skillsCommand + .command("install") + .description("Install a Codex skill that teaches search -> timeline -> details memory retrieval") + .option( + "--surface ", + "Skill install surface: runtime, official-user, or official-project", + "runtime" + ) + .option("--cwd ", "Project directory to anchor project-scoped skill installs to") + .action(withStdout(async (options) => installSkills(options))); + + skillsCommand + .command("remove") + .description("Describe how to remove the installed Codex skill assets") + .option( + "--surface ", + "Skill surface to remove: runtime, official-user, or official-project", + "runtime" + ) + .option("--cwd ", "Project directory to anchor project-scoped skill installs to") + .action(withStdout(async (options) => removeSkills(options))); +} + +function registerRecallCommands(program: Command): void { + const recallCommand = program + .command("recall") + .description("Search, inspect, and drill into durable memory with progressive disclosure"); + + addJsonOption( + recallCommand + .command("search") + .description("Search compact memory candidates without loading full details") + .argument("", "Search query") + .option("--scope ", "Limit search scope: global, project, project-local, or all", "all") + .option( + "--state ", + "Limit memory state: active, archived, all, or auto", + "auto" + ) + .option("--limit ", "Maximum number of results to return", "8") + ).action(withStdout(async (query, options) => runRecall("search", query, options))); + + addJsonOption( + recallCommand + .command("timeline") + .description("Show the recorded lifecycle timeline for a specific memory ref") + .argument("", "Memory ref from recall search") + ).action(withStdout(async (ref, options) => runRecall("timeline", ref, options))); + + addJsonOption( + recallCommand + .command("details") + .description("Fetch full Markdown-backed details for a specific memory ref") + .argument("", "Memory ref from recall search") + ).action(withStdout(async (ref, options) => runRecall("details", ref, options))); +} + +function registerMcpCommands(program: Command): void { + const mcpCommand = program + .command("mcp") + .description( + "Serve the retrieval MCP plane, print or install host snippets, and inspect project wiring" + ); + + mcpCommand + .command("serve") + .description( + "Start a read-only retrieval MCP server with search_memories, timeline_memories, and get_memory_details" + ) + .option("--cwd ", "Project directory to anchor retrieval to") + .action(async (options) => { + await runMcpServe(options); + }); + + addJsonOption( + mcpCommand + .command("install") + .description("Install the recommended project-scoped MCP wiring for a supported host") + .requiredOption("--host ", "Target host: codex, claude, or gemini") + .option("--cwd ", "Project directory to write host wiring for") + ).action(withStdout(async (options) => runMcpInstall(options))); + + addJsonOption( + mcpCommand + .command("print-config") + .description("Print a ready-to-paste MCP config snippet for a supported host") + .requiredOption("--host ", "Target host: codex, claude, gemini, or generic") + .option("--cwd ", "Project directory to render the snippet for") + ).action(withStdout(async (options) => runMcpPrintConfig(options))); + + addJsonOption( + mcpCommand + .command("apply-guidance") + .description("Safely create or update the managed Codex Auto Memory block inside AGENTS.md") + .requiredOption("--host ", "Target host: codex") + .option("--cwd ", "Project directory whose AGENTS.md should be updated") + ).action(withStdout(async (options) => runMcpApplyGuidance(options))); + + addJsonOption( + mcpCommand + .command("doctor") + .description("Inspect the recommended project-scoped MCP wiring without writing host config") + .option("--host ", "Target host: codex, claude, gemini, generic, or all", "all") + .option("--cwd ", "Project directory to inspect") + ).action(withStdout(async (options) => runMcpDoctor(options))); +} + +function registerIntegrationCommands(program: Command): void { + const integrationsCommand = program + .command("integrations") + .description("Install the recommended Codex integration stack on top of existing hook, skill, and MCP surfaces"); + + addJsonOption( + integrationsCommand + .command("apply") + .description("Install the recommended Codex integration stack and safely apply the managed AGENTS guidance block") + .requiredOption("--host ", "Target host: codex") + .option( + "--skill-surface ", + "Skill install surface: runtime, official-user, or official-project", + "runtime" + ) + .option("--cwd ", "Project directory to write host wiring for") + ).action(withStdout(async (options) => runIntegrationsApply(options))); + + addJsonOption( + integrationsCommand + .command("install") + .description("Install the recommended project-scoped Codex integration stack") + .requiredOption("--host ", "Target host: codex") + .option( + "--skill-surface ", + "Skill install surface: runtime, official-user, or official-project", + "runtime" + ) + .option("--cwd ", "Project directory to write host wiring for") + ).action(withStdout(async (options) => runIntegrationsInstall(options))); + + addJsonOption( + integrationsCommand + .command("doctor") + .description("Inspect the current Codex integration stack without mutating memory or host config") + .requiredOption("--host ", "Target host: codex") + .option("--cwd ", "Project directory to inspect") + ).action(withStdout(async (options) => runIntegrationsDoctor(options))); +} + export function registerCommands(program: Command): void { program .command("init") @@ -168,5 +334,9 @@ export function registerCommands(program: Command): void { .action(withStdout(async (options) => runAudit(options))); registerSessionCommands(program); + registerRecallCommands(program); + registerMcpCommands(program); registerHookCommands(program); + registerSkillCommands(program); + registerIntegrationCommands(program); } diff --git a/src/lib/commands/hooks.ts b/src/lib/commands/hooks.ts index 4cd6904..58c9d25 100644 --- a/src/lib/commands/hooks.ts +++ b/src/lib/commands/hooks.ts @@ -1,41 +1,25 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { ensureDir, writeTextFile } from "../util/fs.js"; - -function hookDir(): string { - return path.join(os.homedir(), ".codex-auto-memory", "hooks"); -} +import { + buildRecallBridgeSummaryLines, + hookAssetDir +} from "../integration/assets.js"; +import { LOCAL_BRIDGE_BUNDLE_NOTE } from "../integration/codex-stack.js"; +import { installIntegrationAssets } from "../integration/install-assets.js"; export async function installHooks(): Promise { - const dir = hookDir(); - await ensureDir(dir); - - const postSessionPath = path.join(dir, "post-session-sync.sh"); - const startupPath = path.join(dir, "startup-doctor.sh"); - - await writeTextFile( - postSessionPath, - "#!/bin/sh\n# Sync the latest rollout for the current project.\ncam sync \"$@\"\n" - ); - await fs.chmod(postSessionPath, 0o755); - await writeTextFile( - startupPath, - "#!/bin/sh\n# Print diagnostic information at session start.\ncam doctor \"$@\"\n" - ); - await fs.chmod(startupPath, 0o755); + const result = await installIntegrationAssets("hooks"); return [ - `Generated hook bridge assets in ${dir}`, - `- ${startupPath}`, - `- ${postSessionPath}`, + `Generated hook bridge bundle in ${result.targetDir}`, + `Action: ${result.action}`, + ...result.assets.map((asset) => `- [${asset.action}] ${asset.path}`), "", - "These files are companion hook targets for future Codex native hooks integration." + "These files now form a local bridge bundle for current Codex workflows and future hook/skill/MCP-aware retrieval flows.", + LOCAL_BRIDGE_BUNDLE_NOTE, + ...buildRecallBridgeSummaryLines() ].join("\n"); } export async function removeHooks(): Promise { - const dir = hookDir(); + const dir = hookAssetDir(); return `Hook bridge assets live under ${dir}. Remove the directory manually if you no longer need them.`; } - diff --git a/src/lib/commands/integrations.ts b/src/lib/commands/integrations.ts new file mode 100644 index 0000000..7c49408 --- /dev/null +++ b/src/lib/commands/integrations.ts @@ -0,0 +1,480 @@ +import { + buildCodexIntegrationSubchecks, + buildCodexIntegrationNextSteps, + buildCodexRouteSummary, + buildCodexStackNotes, + CODEX_HOOK_CAPTURE_ASSET_IDS, + CODEX_HOOK_RECALL_ASSET_IDS, + CODEX_WORKFLOW_CONSISTENCY_ASSET_IDS, + formatIntegrationActionHeadline, + summarizeCodexIntegrationStatus, + type CodexIntegrationStatus +} from "../integration/codex-stack.js"; +import { applyCodexAgentsGuidance } from "../integration/agents-guidance.js"; +import { installIntegrationAssets } from "../integration/install-assets.js"; +import { installMcpProjectConfig, type McpInstallResult } from "../integration/mcp-install.js"; +import { normalizeMcpHost } from "../integration/mcp-config.js"; +import { resolveMcpProjectRoot } from "../integration/mcp-config.js"; +import { inspectMcpDoctor, type McpDoctorReport } from "../integration/mcp-doctor.js"; +import { + formatCodexSkillInstallSurface, + normalizeCodexSkillInstallSurface, + type CodexSkillInstallSurface +} from "../integration/skills-paths.js"; + +type IntegrationStackAction = "created" | "updated" | "unchanged" | "blocked"; +type InstallStackAction = Exclude; +type IntegrationSubactionStatus = "ok" | "blocked"; + +interface IntegrationsInstallOptions { + cwd?: string; + host?: string; + skillSurface?: string; + json?: boolean; +} + +interface IntegrationsApplyOptions { + cwd?: string; + host?: string; + skillSurface?: string; + json?: boolean; +} + +interface IntegrationsDoctorOptions { + cwd?: string; + host?: string; + json?: boolean; +} + +interface IntegrationSubactionResult { + status: IntegrationSubactionStatus; + action: IntegrationStackAction; + targetPath?: string; + targetDir?: string; + surface?: CodexSkillInstallSurface; + projectPinned?: true; + readOnlyRetrieval: true; + notes: string[]; +} + +interface IntegrationStackInstallResult { + host: "codex"; + projectRoot: string; + stackAction: InstallStackAction; + skillsSurface: CodexSkillInstallSurface; + readOnlyRetrieval: true; + subactions: { + mcp: IntegrationSubactionResult; + hooks: IntegrationSubactionResult; + skills: IntegrationSubactionResult; + }; + notes: string[]; +} + +interface IntegrationStackApplyResult { + host: "codex"; + projectRoot: string; + stackAction: IntegrationStackAction; + skillsSurface: CodexSkillInstallSurface; + readOnlyRetrieval: true; + subactions: { + mcp: IntegrationSubactionResult; + agents: IntegrationSubactionResult; + hooks: IntegrationSubactionResult; + skills: IntegrationSubactionResult; + }; + notes: string[]; +} + +interface IntegrationDoctorSubcheck { + status: CodexIntegrationStatus; + summary: string; +} + +interface IntegrationDoctorResult { + host: "codex"; + projectRoot: string; + readOnlyRetrieval: true; + status: CodexIntegrationStatus; + recommendedRoute: McpDoctorReport["codexStack"]["recommendedRoute"]; + recommendedPreset: string; + preferredSkillSurface: CodexSkillInstallSurface; + recommendedSkillInstallCommand: string; + installedSkillSurfaces: CodexSkillInstallSurface[]; + readySkillSurfaces: CodexSkillInstallSurface[]; + subchecks: { + mcp: IntegrationDoctorSubcheck; + agents: IntegrationDoctorSubcheck; + hookCapture: IntegrationDoctorSubcheck; + hookRecall: IntegrationDoctorSubcheck; + skill: IntegrationDoctorSubcheck; + workflowConsistency: IntegrationDoctorSubcheck; + }; + notes: string[]; + nextSteps: string[]; +} + +function summarizeStackAction(actions: IntegrationStackAction[]): IntegrationStackAction { + if (actions.includes("blocked")) { + return "blocked"; + } + + if (actions.every((action) => action === "unchanged")) { + return "unchanged"; + } + + if (actions.every((action) => action === "created")) { + return "created"; + } + + return "updated"; +} + +function toMcpSubaction(result: McpInstallResult): IntegrationSubactionResult { + return { + status: "ok", + action: result.action, + targetPath: result.targetPath, + projectPinned: result.projectPinned, + readOnlyRetrieval: result.readOnlyRetrieval, + notes: [...result.notes] + }; +} + +function normalizeIntegrationsHost( + host: string | undefined, + action: "install" | "apply" | "doctor" +): "codex" { + const normalized = normalizeMcpHost(host); + if (normalized !== "codex") { + throw new Error( + `cam integrations ${action} is Codex-only in this repository. Use --host codex, not "${normalized}".` + ); + } + + return normalized; +} + +function formatIntegrationApplyHeadline(action: IntegrationStackAction): string { + if (action === "blocked") { + return "Codex integration apply was partially blocked."; + } + + return formatIntegrationActionHeadline(action, "Codex integration apply"); +} + +function hasAnyInstalledAsset( + report: McpDoctorReport, + ids: readonly string[] +): boolean { + return ids.some((id) => report.fallbackAssets.assets.find((asset) => asset.id === id)?.installed); +} + +function buildIntegrationsDoctorResult(report: McpDoctorReport): IntegrationDoctorResult { + const codexHost = report.hosts.find((host) => host.host === "codex"); + if (!codexHost) { + throw new Error("Codex host inspection is required for integrations doctor."); + } + + const hasCaptureAssets = hasAnyInstalledAsset(report, CODEX_HOOK_CAPTURE_ASSET_IDS); + const hasRecallAssets = hasAnyInstalledAsset(report, CODEX_HOOK_RECALL_ASSET_IDS); + const hasWorkflowAssets = hasAnyInstalledAsset(report, CODEX_WORKFLOW_CONSISTENCY_ASSET_IDS); + const hasSkillAssets = hasAnyInstalledAsset(report, ["codex-memory-skill"]); + + const subchecks = buildCodexIntegrationSubchecks( + { + mcpReady: report.codexStack.mcpReady, + mcpOperationalReady: report.codexStack.mcpOperationalReady, + camCommandAvailable: report.codexStack.camCommandAvailable, + hookCaptureReady: report.codexStack.hookCaptureReady, + hookRecallReady: report.codexStack.hookRecallReady, + skillReady: report.codexStack.skillReady, + workflowConsistent: report.codexStack.workflowConsistent + }, + { + hasCaptureAssets, + hasRecallAssets, + hasSkillAssets, + hasWorkflowAssets + }, + { + status: codexHost.status === "missing" ? "missing" : "warning", + summary: codexHost.summary + } + ); + const agents: IntegrationDoctorSubcheck = + report.agentsGuidance.status === "ok" + ? { + status: "ok", + summary: "Repository-level AGENTS.md includes the current Codex Auto Memory guidance." + } + : report.agentsGuidance.status === "warning" + ? { + status: "warning", + summary: + "Repository-level AGENTS.md exists, but the Codex Auto Memory guidance is missing or outdated." + } + : { + status: "missing", + summary: "Repository-level AGENTS.md does not exist yet." + }; + const allSubchecks: IntegrationDoctorResult["subchecks"] = { + ...subchecks, + agents + }; + + const status = summarizeCodexIntegrationStatus( + Object.values(allSubchecks).map((subcheck) => subcheck.status) + ); + const notes = [ + buildCodexRouteSummary(report.codexStack.recommendedRoute), + ...buildCodexStackNotes(), + "AGENTS guidance is inspected read-only and is never auto-written by integrations doctor." + ]; + const nextSteps = buildCodexIntegrationNextSteps({ + mcpReady: report.codexStack.mcpReady, + mcpOperationalReady: report.codexStack.mcpOperationalReady, + camCommandAvailable: report.codexStack.camCommandAvailable, + hookCaptureReady: report.codexStack.hookCaptureReady, + hookRecallReady: report.codexStack.hookRecallReady, + skillReady: report.codexStack.skillReady, + workflowConsistent: report.codexStack.workflowConsistent + }, { + skillInstallCommand: report.fallbackAssets.recommendedSkillInstallCommand + }); + const needsAgents = report.agentsGuidance.status !== "ok"; + const needsOtherStackSurface = + !report.codexStack.mcpReady || + !report.codexStack.hookCaptureReady || + !report.codexStack.hookRecallReady || + !report.codexStack.skillReady; + if (needsAgents && needsOtherStackSurface) { + nextSteps.unshift( + `Run \`cam integrations apply --host codex --skill-surface ${report.fallbackAssets.preferredInstallSurface}\` to install project-scoped MCP wiring, refresh hook and skill assets, and safely apply the managed Codex Auto Memory AGENTS.md block in one step.` + ); + } else if (needsAgents) { + nextSteps.push( + "Run `cam mcp apply-guidance --host codex` to create or update the managed Codex Auto Memory block in the repository-level AGENTS.md." + ); + } + + return { + host: "codex", + projectRoot: report.projectRoot, + readOnlyRetrieval: true, + status, + recommendedRoute: report.codexStack.recommendedRoute, + recommendedPreset: report.codexStack.preset, + preferredSkillSurface: report.fallbackAssets.preferredInstallSurface, + recommendedSkillInstallCommand: report.fallbackAssets.recommendedSkillInstallCommand, + installedSkillSurfaces: [...report.fallbackAssets.installedSkillSurfaces], + readySkillSurfaces: [...report.fallbackAssets.readySkillSurfaces], + subchecks: allSubchecks, + notes: [...new Set(notes)], + nextSteps: [...new Set(nextSteps)] + }; +} + +function formatIntegrationsDoctorResult(result: IntegrationDoctorResult): string { + return [ + "Codex Auto Memory Integrations Doctor", + `Host: ${result.host}`, + `Project root: ${result.projectRoot}`, + `Retrieval plane: ${result.readOnlyRetrieval ? "read-only" : "unexpected"}`, + `Status: ${result.status}`, + `Recommended route: ${result.recommendedRoute}`, + `Recommended preset: ${result.recommendedPreset}`, + `Preferred skill surface: ${formatCodexSkillInstallSurface(result.preferredSkillSurface)}`, + `Recommended skill install command: ${result.recommendedSkillInstallCommand}`, + `Installed skill surfaces: ${result.installedSkillSurfaces.length > 0 ? result.installedSkillSurfaces.join(", ") : "none"}`, + `Ready skill surfaces: ${result.readySkillSurfaces.length > 0 ? result.readySkillSurfaces.join(", ") : "none"}`, + "", + "Subchecks:", + `- [${result.subchecks.mcp.status}] mcp: ${result.subchecks.mcp.summary}`, + `- [${result.subchecks.agents.status}] agents: ${result.subchecks.agents.summary}`, + `- [${result.subchecks.hookCapture.status}] hookCapture: ${result.subchecks.hookCapture.summary}`, + `- [${result.subchecks.hookRecall.status}] hookRecall: ${result.subchecks.hookRecall.summary}`, + `- [${result.subchecks.skill.status}] skill: ${result.subchecks.skill.summary}`, + `- [${result.subchecks.workflowConsistency.status}] workflowConsistency: ${result.subchecks.workflowConsistency.summary}`, + "", + "Next steps:", + ...result.nextSteps.map((step) => `- ${step}`), + "", + "Notes:", + ...result.notes.map((note) => `- ${note}`) + ].join("\n"); +} + +export async function runIntegrationsInstall( + options: IntegrationsInstallOptions = {} +): Promise { + normalizeIntegrationsHost(options.host, "install"); + + const projectRoot = resolveMcpProjectRoot(options.cwd); + const skillSurface = normalizeCodexSkillInstallSurface(options.skillSurface); + const mcpResult = await installMcpProjectConfig("codex", projectRoot); + const hooksResult = await installIntegrationAssets("hooks", { + projectRoot + }); + const skillsResult = await installIntegrationAssets("skills", { + projectRoot, + skillSurface + }); + const stackAction = summarizeStackAction([ + mcpResult.action, + hooksResult.action, + skillsResult.action + ]) as InstallStackAction; + + const result: IntegrationStackInstallResult = { + host: "codex", + projectRoot, + stackAction, + skillsSurface: skillSurface, + readOnlyRetrieval: true, + subactions: { + mcp: toMcpSubaction(mcpResult), + hooks: { + status: "ok", + action: hooksResult.action, + targetDir: hooksResult.targetDir, + readOnlyRetrieval: hooksResult.readOnlyRetrieval, + notes: [...hooksResult.notes] + }, + skills: { + status: "ok", + action: skillsResult.action, + targetDir: skillsResult.targetDir, + surface: skillSurface, + readOnlyRetrieval: skillsResult.readOnlyRetrieval, + notes: [...skillsResult.notes] + } + }, + notes: [ + "This orchestration surface is Codex-only.", + "It writes project-scoped MCP wiring and refreshes user-scoped hook and skill assets without touching Markdown memory files.", + buildCodexRouteSummary("mcp"), + `Skill surface: ${formatCodexSkillInstallSurface(skillSurface)}.`, + `Recommended retrieval preset: ${hooksResult.recommendedPreset}.` + ] + }; + + if (options.json) { + return JSON.stringify(result, null, 2); + } + + return [ + formatIntegrationActionHeadline(result.stackAction, "Codex integration stack"), + `Host: ${result.host}`, + `Project root: ${result.projectRoot}`, + `Stack action: ${result.stackAction}`, + `Skill surface: ${formatCodexSkillInstallSurface(result.skillsSurface)}`, + "Subactions:", + `- mcp: [${result.subactions.mcp.status}] ${result.subactions.mcp.action} -> ${result.subactions.mcp.targetPath}`, + `- hooks: [${result.subactions.hooks.status}] ${result.subactions.hooks.action} -> ${result.subactions.hooks.targetDir}`, + `- skills: [${result.subactions.skills.status}] ${result.subactions.skills.action} -> ${result.subactions.skills.targetDir}`, + "", + "Notes:", + ...result.notes.map((note) => `- ${note}`) + ].join("\n"); +} + +export async function runIntegrationsApply( + options: IntegrationsApplyOptions = {} +): Promise { + normalizeIntegrationsHost(options.host, "apply"); + + const projectRoot = resolveMcpProjectRoot(options.cwd); + const skillSurface = normalizeCodexSkillInstallSurface(options.skillSurface); + const mcpResult = await installMcpProjectConfig("codex", projectRoot); + const agentsResult = await applyCodexAgentsGuidance(projectRoot); + const hooksResult = await installIntegrationAssets("hooks", { + projectRoot + }); + const skillsResult = await installIntegrationAssets("skills", { + projectRoot, + skillSurface + }); + + const result: IntegrationStackApplyResult = { + host: "codex", + projectRoot, + skillsSurface: skillSurface, + stackAction: summarizeStackAction([ + mcpResult.action, + agentsResult.action, + hooksResult.action, + skillsResult.action + ]), + readOnlyRetrieval: true, + subactions: { + mcp: toMcpSubaction(mcpResult), + agents: { + status: agentsResult.action === "blocked" ? "blocked" : "ok", + action: agentsResult.action, + targetPath: agentsResult.targetPath, + readOnlyRetrieval: true, + notes: [...agentsResult.notes] + }, + hooks: { + status: "ok", + action: hooksResult.action, + targetDir: hooksResult.targetDir, + readOnlyRetrieval: hooksResult.readOnlyRetrieval, + notes: [...hooksResult.notes] + }, + skills: { + status: "ok", + action: skillsResult.action, + targetDir: skillsResult.targetDir, + surface: skillSurface, + readOnlyRetrieval: skillsResult.readOnlyRetrieval, + notes: [...skillsResult.notes] + } + }, + notes: [ + "This orchestration surface is Codex-only and explicit.", + "Unlike `cam integrations install --host codex`, this command also manages the repository-level AGENTS.md guidance block through the existing additive, marker-scoped, fail-closed flow.", + buildCodexRouteSummary("mcp"), + `Skill surface: ${formatCodexSkillInstallSurface(skillSurface)}.`, + `Recommended retrieval preset: ${hooksResult.recommendedPreset}.` + ] + }; + + if (options.json) { + return JSON.stringify(result, null, 2); + } + + return [ + formatIntegrationApplyHeadline(result.stackAction), + `Host: ${result.host}`, + `Project root: ${result.projectRoot}`, + `Stack action: ${result.stackAction}`, + `Skill surface: ${formatCodexSkillInstallSurface(result.skillsSurface)}`, + "Subactions:", + `- mcp: [${result.subactions.mcp.status}] ${result.subactions.mcp.action} -> ${result.subactions.mcp.targetPath}`, + `- agents: [${result.subactions.agents.status}] ${result.subactions.agents.action} -> ${result.subactions.agents.targetPath}`, + `- hooks: [${result.subactions.hooks.status}] ${result.subactions.hooks.action} -> ${result.subactions.hooks.targetDir}`, + `- skills: [${result.subactions.skills.status}] ${result.subactions.skills.action} -> ${result.subactions.skills.targetDir}`, + "", + "Notes:", + ...result.notes.map((note) => `- ${note}`) + ].join("\n"); +} + +export async function runIntegrationsDoctor( + options: IntegrationsDoctorOptions = {} +): Promise { + normalizeIntegrationsHost(options.host, "doctor"); + const report = await inspectMcpDoctor({ + cwd: options.cwd, + host: "codex" + }); + const result = buildIntegrationsDoctorResult(report); + + if (options.json) { + return JSON.stringify(result, null, 2); + } + + return formatIntegrationsDoctorResult(result); +} diff --git a/src/lib/commands/mcp.ts b/src/lib/commands/mcp.ts new file mode 100644 index 0000000..c037801 --- /dev/null +++ b/src/lib/commands/mcp.ts @@ -0,0 +1,133 @@ +import path from "node:path"; +import { formatIntegrationActionHeadline } from "../integration/codex-stack.js"; +import { applyCodexAgentsGuidance } from "../integration/agents-guidance.js"; +import { + buildMcpHostConfigSnippet, + formatMcpHostConfigSnippet, + normalizeMcpHost, + resolveMcpProjectRoot +} from "../integration/mcp-config.js"; +import { installMcpProjectConfig } from "../integration/mcp-install.js"; +import { formatMcpDoctorReport, inspectMcpDoctor } from "../integration/mcp-doctor.js"; +import { startRetrievalMcpServer } from "../mcp/retrieval-server.js"; + +interface McpServeOptions { + cwd?: string; +} + +interface McpPrintConfigOptions { + cwd?: string; + host?: string; + json?: boolean; +} + +interface McpDoctorOptions { + cwd?: string; + host?: string; + json?: boolean; +} + +interface McpInstallOptions { + cwd?: string; + host?: string; + json?: boolean; +} + +interface McpApplyGuidanceOptions { + cwd?: string; + host?: string; + json?: boolean; +} + +function resolveCommandCwd(cwd: string | undefined): string { + return cwd ? path.resolve(cwd) : process.cwd(); +} + +export async function runMcpServe(options: McpServeOptions = {}): Promise { + await startRetrievalMcpServer(resolveCommandCwd(options.cwd)); +} + +export async function runMcpPrintConfig(options: McpPrintConfigOptions = {}): Promise { + const host = normalizeMcpHost(options.host); + const projectRoot = resolveMcpProjectRoot(resolveCommandCwd(options.cwd)); + const snippet = buildMcpHostConfigSnippet(host, projectRoot); + + if (options.json) { + return JSON.stringify(snippet, null, 2); + } + + return formatMcpHostConfigSnippet(snippet); +} + +export async function runMcpDoctor(options: McpDoctorOptions = {}): Promise { + const report = await inspectMcpDoctor({ + cwd: resolveCommandCwd(options.cwd), + host: options.host + }); + + if (options.json) { + return JSON.stringify(report, null, 2); + } + + return formatMcpDoctorReport(report); +} + +export async function runMcpInstall(options: McpInstallOptions = {}): Promise { + const host = normalizeMcpHost(options.host); + const projectRoot = resolveMcpProjectRoot(resolveCommandCwd(options.cwd)); + const result = await installMcpProjectConfig(host, projectRoot); + + if (options.json) { + return JSON.stringify(result, null, 2); + } + + return [ + formatIntegrationActionHeadline(result.action, `project-scoped MCP wiring for ${result.host}`), + `Target path: ${result.targetPath}`, + `Action: ${result.action}`, + "Retrieval plane: read-only", + "", + "Notes:", + ...result.notes.map((note) => `- ${note}`) + ].join("\n"); +} + +export async function runMcpApplyGuidance( + options: McpApplyGuidanceOptions = {} +): Promise { + const host = normalizeMcpHost(options.host); + if (host !== "codex") { + throw new Error( + `cam mcp apply-guidance currently supports only --host codex, not "${host}".` + ); + } + + const projectRoot = resolveMcpProjectRoot(resolveCommandCwd(options.cwd)); + const result = await applyCodexAgentsGuidance(projectRoot); + + if (options.json) { + return JSON.stringify(result, null, 2); + } + + if (result.action === "blocked") { + return [ + "Codex AGENTS guidance update was blocked.", + `Target path: ${result.targetPath}`, + `Managed block version: ${result.managedBlockVersion}`, + `Reason: ${result.blockedReason ?? "unknown"}`, + "", + "Notes:", + ...result.notes.map((note) => `- ${note}`) + ].join("\n"); + } + + return [ + formatIntegrationActionHeadline(result.action, "Codex AGENTS guidance"), + `Target path: ${result.targetPath}`, + `Managed block version: ${result.managedBlockVersion}`, + `Created file: ${result.createdFile ? "yes" : "no"}`, + "", + "Notes:", + ...result.notes.map((note) => `- ${note}`) + ].join("\n"); +} diff --git a/src/lib/commands/recall.ts b/src/lib/commands/recall.ts new file mode 100644 index 0000000..60720c9 --- /dev/null +++ b/src/lib/commands/recall.ts @@ -0,0 +1,147 @@ +import { buildReadOnlyMemoryRetrievalService } from "../runtime/runtime-context.js"; +import type { + MemoryDetailsResult, + MemoryRetrievalScope, + MemoryRetrievalStateFilter, + MemorySearchResponse, + MemoryTimelineEvent +} from "../types.js"; +import { + buildMemoryTimelineResponse, + normalizeMemoryRetrievalScope, + normalizeMemoryRetrievalState, + parseMemoryRetrievalLimit +} from "../domain/memory-retrieval-contract.js"; +import { assertValidMemoryRef } from "../domain/memory-lifecycle.js"; + +type RecallAction = "search" | "timeline" | "details"; + +interface RecallOptions { + cwd?: string; + json?: boolean; + scope?: MemoryRetrievalScope; + state?: MemoryRetrievalStateFilter; + limit?: string | number; +} + +function formatSearchResults(response: MemorySearchResponse): string { + const lines = [ + "Codex Auto Memory Recall Search", + `Query: ${response.query}`, + `Scope: ${response.scope} | Requested state: ${response.state} | Resolved state: ${response.resolvedState} | Results: ${response.results.length}`, + `Archived fallback used: ${response.fallbackUsed ? "yes" : "no"}` + ]; + + if (response.results.length === 0) { + lines.push("", "No memory results matched this query."); + return lines.join("\n"); + } + + lines.push(""); + for (const result of response.results) { + lines.push( + `- ${result.ref}`, + ` ${result.scope}/${result.state}/${result.topic} | Updated: ${result.updatedAt}`, + ` Summary: ${result.summary}`, + ` Matched: ${result.matchedFields.join(", ")} | Approx read cost: ${result.approxReadCost}` + ); + } + + return lines.join("\n"); +} + +function formatTimeline(ref: string, timeline: MemoryTimelineEvent[]): string { + const lines = [ + "Codex Auto Memory Recall Timeline", + `Ref: ${ref}`, + `Events: ${timeline.length}` + ]; + + if (timeline.length === 0) { + lines.push("", "No timeline events were recorded for this memory ref."); + return lines.join("\n"); + } + + lines.push(""); + for (const event of timeline) { + lines.push(`- ${event.at}: [${event.action}] ${event.summary}`); + lines.push(` Scope: ${event.scope} | State: ${event.state} | Topic: ${event.topic}`); + if (event.reason) { + lines.push(` Reason: ${event.reason}`); + } + if (event.source) { + lines.push(` Source: ${event.source}`); + } + if (event.rolloutPath) { + lines.push(` Rollout: ${event.rolloutPath}`); + } + } + + return lines.join("\n"); +} + +function formatDetails(details: MemoryDetailsResult): string { + const lines = [ + "Codex Auto Memory Recall Details", + `Ref: ${details.ref}`, + `Path: ${details.path}`, + `Scope: ${details.scope} | State: ${details.state} | Topic: ${details.topic}`, + `Updated: ${details.entry.updatedAt}`, + `Summary: ${details.entry.summary}`, + "Details:", + ...details.entry.details.map((detail) => `- ${detail}`) + ]; + + if (details.entry.sources.length > 0) { + lines.push("Sources:", ...details.entry.sources.map((source) => `- ${source}`)); + } + + if (details.entry.reason) { + lines.push(`Reason: ${details.entry.reason}`); + } + + return lines.join("\n"); +} + +export async function runRecall( + action: RecallAction, + target: string, + options: RecallOptions = {} +): Promise { + const retrieval = await buildReadOnlyMemoryRetrievalService(options.cwd); + const scope = normalizeMemoryRetrievalScope(options.scope); + const state = normalizeMemoryRetrievalState(options.state); + + switch (action) { + case "search": { + const response = await retrieval.searchMemories(target, { + scope, + state, + limit: parseMemoryRetrievalLimit(options.limit) + }); + if (options.json) { + return JSON.stringify(response, null, 2); + } + return formatSearchResults(response); + } + case "timeline": { + assertValidMemoryRef(target); + const timeline = await retrieval.timelineMemories(target); + if (options.json) { + return JSON.stringify(buildMemoryTimelineResponse(target, timeline), null, 2); + } + return formatTimeline(target, timeline); + } + case "details": { + assertValidMemoryRef(target); + const details = await retrieval.getMemoryDetails(target); + if (!details) { + throw new Error(`No memory details were found for ref "${target}".`); + } + if (options.json) { + return JSON.stringify(details, null, 2); + } + return formatDetails(details); + } + } +} diff --git a/src/lib/commands/skills.ts b/src/lib/commands/skills.ts new file mode 100644 index 0000000..949740a --- /dev/null +++ b/src/lib/commands/skills.ts @@ -0,0 +1,48 @@ +import { + buildRecallBridgeSummaryLines, + codexSkillAssetDirForSurface +} from "../integration/assets.js"; +import { installIntegrationAssets } from "../integration/install-assets.js"; +import { + CODEX_MEMORY_SKILL_NAME, + formatCodexSkillInstallSurface, + normalizeCodexSkillInstallSurface +} from "../integration/skills-paths.js"; +import { resolveMcpProjectRoot } from "../integration/mcp-config.js"; + +interface SkillsCommandOptions { + cwd?: string; + surface?: string; +} + +export async function installSkills(options: SkillsCommandOptions = {}): Promise { + const projectRoot = resolveMcpProjectRoot(options.cwd); + const skillSurface = normalizeCodexSkillInstallSurface(options.surface); + const result = await installIntegrationAssets("skills", { + projectRoot, + skillSurface + }); + + return [ + `Installed Codex skill assets in ${result.targetDir}`, + `Action: ${result.action}`, + `Skill surface: ${formatCodexSkillInstallSurface(skillSurface)}`, + `Preferred skill surface: ${result.preferredSkillSurface ?? "runtime"}`, + ...result.assets.map((asset) => `- [${asset.action}] ${asset.path}`), + "", + `Skill name: ${CODEX_MEMORY_SKILL_NAME}`, + "This skill teaches Codex to use durable memory with search -> timeline -> details instead of loading full memory bodies up front.", + skillSurface === "runtime" + ? "This keeps the current runtime-first install target unchanged." + : "This writes an explicit official .agents/skills copy without changing the runtime-first default.", + ...buildRecallBridgeSummaryLines(), + "If a host prefers shell-based fallback helpers, run cam hooks install to generate memory-recall.sh, compatibility wrappers, and recall-bridge.md." + ].join("\n"); +} + +export async function removeSkills(options: SkillsCommandOptions = {}): Promise { + const projectRoot = resolveMcpProjectRoot(options.cwd); + const skillSurface = normalizeCodexSkillInstallSurface(options.surface); + const dir = codexSkillAssetDirForSurface(skillSurface, projectRoot); + return `Codex skill assets live under ${dir}. Remove the directory manually if you no longer need them.`; +} diff --git a/src/lib/domain/memory-retrieval-contract.ts b/src/lib/domain/memory-retrieval-contract.ts new file mode 100644 index 0000000..0b029eb --- /dev/null +++ b/src/lib/domain/memory-retrieval-contract.ts @@ -0,0 +1,158 @@ +import type { + MemoryDetailsResult, + MemoryRecordState, + MemoryRetrievalResolvedState, + MemoryRetrievalScope, + MemoryRetrievalStateFilter, + MemoryScope, + MemorySearchResponse, + MemorySearchResult, + MemoryTimelineEvent, + MemoryTimelineResponse +} from "../types.js"; + +export const DEFAULT_MEMORY_RETRIEVAL_STATE: Extract = "auto"; +export const DEFAULT_MEMORY_RETRIEVAL_LIMIT = 8; + +export function parseMemoryRetrievalLimit(limit: string | number | undefined): number { + if (typeof limit === "number" && Number.isFinite(limit)) { + return Math.min(100, Math.max(1, Math.trunc(limit))); + } + + if (typeof limit === "string") { + const parsed = Number.parseInt(limit, 10); + if (Number.isFinite(parsed)) { + return Math.min(100, Math.max(1, parsed)); + } + } + + return DEFAULT_MEMORY_RETRIEVAL_LIMIT; +} + +export function normalizeMemoryRetrievalScope( + scope: MemoryRetrievalScope | undefined +): MemoryRetrievalScope { + if (!scope || scope === "all") { + return "all"; + } + + if (scope === "global" || scope === "project" || scope === "project-local") { + return scope; + } + + throw new Error(`Unsupported recall scope "${scope}".`); +} + +export function normalizeMemoryRetrievalState( + state: MemoryRetrievalStateFilter | undefined +): MemoryRetrievalStateFilter { + if (!state) { + return DEFAULT_MEMORY_RETRIEVAL_STATE; + } + + if (state === "auto") { + return "auto"; + } + + if (state === "all") { + return "all"; + } + + if (state === "active" || state === "archived") { + return state; + } + + throw new Error(`Unsupported recall state "${state}".`); +} + +export function buildMemorySearchResponse( + query: string, + scope: MemoryRetrievalScope, + state: MemoryRetrievalStateFilter, + resolvedState: MemoryRetrievalResolvedState, + fallbackUsed: boolean, + results: MemorySearchResult[] +): MemorySearchResponse { + return { + query, + scope, + state, + resolvedState, + fallbackUsed, + results + }; +} + +export function buildMemoryTimelineResponse( + ref: string, + events: MemoryTimelineEvent[] +): MemoryTimelineResponse { + return { + ref, + events + }; +} + +export interface MemorySearchRequest { + query: string; + scope: MemoryRetrievalScope; + state: MemoryRetrievalStateFilter; + limit: number; +} + +export interface MemorySearchResultShape { + ref: string; + scope: MemoryScope; + state: MemoryRecordState; + topic: string; + id: string; + summary: string; + updatedAt: string; + matchedFields: string[]; + approxReadCost: number; +} + +export function toMemorySearchRequest(options: { + query: string; + scope?: MemoryRetrievalScope; + state?: MemoryRetrievalStateFilter; + limit?: string | number; +}): MemorySearchRequest { + return { + query: options.query, + scope: normalizeMemoryRetrievalScope(options.scope), + state: normalizeMemoryRetrievalState(options.state), + limit: parseMemoryRetrievalLimit(options.limit) + }; +} + +export function toMemorySearchResultShape(result: MemorySearchResult): MemorySearchResultShape { + return { + ref: result.ref, + scope: result.scope, + state: result.state, + topic: result.topic, + id: result.id, + summary: result.summary, + updatedAt: result.updatedAt, + matchedFields: [...result.matchedFields], + approxReadCost: result.approxReadCost + }; +} + +export function toMemorySearchResultShapes( + results: MemorySearchResult[] +): MemorySearchResultShape[] { + return results.map(toMemorySearchResultShape); +} + +export function toMemoryDetailsResultShape(details: MemoryDetailsResult): MemoryDetailsResult { + return { + ...details, + entry: { + ...details.entry, + details: [...details.entry.details], + sources: [...details.entry.sources] + } + }; +} diff --git a/src/lib/domain/memory-retrieval.ts b/src/lib/domain/memory-retrieval.ts new file mode 100644 index 0000000..33ecaea --- /dev/null +++ b/src/lib/domain/memory-retrieval.ts @@ -0,0 +1,66 @@ +import type { + MemoryDetailsResult, + MemoryRetrievalScope, + MemoryRetrievalStateFilter, + MemorySearchResponse, + MemoryTimelineEvent +} from "../types.js"; +import { + buildMemorySearchResponse, + DEFAULT_MEMORY_RETRIEVAL_LIMIT, + DEFAULT_MEMORY_RETRIEVAL_STATE +} from "./memory-retrieval-contract.js"; +import { MemoryStore } from "./memory-store.js"; + +export class MemoryRetrievalService { + public constructor(private readonly memoryStore: MemoryStore) {} + + public async searchMemories( + query: string, + options: { + scope?: MemoryRetrievalScope; + state?: MemoryRetrievalStateFilter; + limit?: number; + } = {} + ): Promise { + const scope = options.scope ?? "all"; + const state = options.state ?? DEFAULT_MEMORY_RETRIEVAL_STATE; + const limit = options.limit ?? DEFAULT_MEMORY_RETRIEVAL_LIMIT; + + if (state === "auto") { + const activeResults = await this.memoryStore.searchEntries(query, { + scope, + state: "active", + limit + }); + + if (activeResults.length > 0) { + return buildMemorySearchResponse(query, scope, state, "active", false, activeResults); + } + + const archivedResults = await this.memoryStore.searchEntries(query, { + scope, + state: "archived", + limit + }); + + return buildMemorySearchResponse(query, scope, state, "archived", true, archivedResults); + } + + const results = await this.memoryStore.searchEntries(query, { + scope, + state, + limit + }); + + return buildMemorySearchResponse(query, scope, state, state, false, results); + } + + public async timelineMemories(ref: string): Promise { + return this.memoryStore.readTimeline(ref); + } + + public async getMemoryDetails(ref: string): Promise { + return this.memoryStore.getEntryByRef(ref); + } +} diff --git a/src/lib/integration/agents-guidance.ts b/src/lib/integration/agents-guidance.ts new file mode 100644 index 0000000..c13e097 --- /dev/null +++ b/src/lib/integration/agents-guidance.ts @@ -0,0 +1,148 @@ +import path from "node:path"; +import { + buildCodexAgentsManagedBlock, + CODEX_AGENTS_GUIDANCE_VERSION, + parseCodexAgentsGuidanceContents +} from "./codex-stack.js"; +import { fileExists, readTextFile, writeTextFileAtomic } from "../util/fs.js"; + +export type CodexAgentsGuidanceApplyAction = "created" | "updated" | "unchanged" | "blocked"; + +export interface CodexAgentsGuidanceApplyResult { + host: "codex"; + projectRoot: string; + targetPath: string; + action: CodexAgentsGuidanceApplyAction; + managedBlockVersion: string; + createdFile: boolean; + blockedReason?: string; + notes: string[]; +} + +function appendManagedBlock( + contents: string, + managedBlock: string, + lineEnding: "\n" | "\r\n" | "\r" +): string { + if (contents.length === 0) { + return `${managedBlock}${lineEnding}`; + } + + if (contents.endsWith(`${lineEnding}${lineEnding}`)) { + return `${contents}${managedBlock}${lineEnding}`; + } + + if (contents.endsWith(lineEnding)) { + return `${contents}${lineEnding}${managedBlock}${lineEnding}`; + } + + return `${contents}${lineEnding}${lineEnding}${managedBlock}${lineEnding}`; +} + +function replaceManagedBlock( + contents: string, + range: { startIndex: number; endIndex: number }, + managedBlock: string +): string { + const trailingLineEnding = range.endIndex > range.startIndex + ? contents.slice(range.startIndex, range.endIndex).match(/(\r\n|\n|\r)$/u)?.[1] ?? "" + : ""; + return `${contents.slice(0, range.startIndex)}${managedBlock}${trailingLineEnding}${contents.slice(range.endIndex)}`; +} + +function normalizeManagedBlockForComparison(value: string): string { + return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n$/u, ""); +} + +function buildNotes(): string[] { + return [ + "This command only creates or updates the Codex Auto Memory managed block inside AGENTS.md.", + "When updating an existing managed block, AGENTS.md content outside that block is preserved byte-for-byte.", + "When appending a new managed block, the command adds only the minimum separator and trailing newline required.", + "If the managed block markers are missing, duplicated, or malformed, the command fails closed instead of rewriting the file." + ]; +} + +export async function applyCodexAgentsGuidance( + projectRoot: string +): Promise { + const targetPath = path.join(projectRoot, "AGENTS.md"); + const notes = buildNotes(); + const exists = await fileExists(targetPath); + + if (!exists) { + await writeTextFileAtomic(targetPath, `${buildCodexAgentsManagedBlock()}\n`); + return { + host: "codex", + projectRoot, + targetPath, + action: "created", + managedBlockVersion: CODEX_AGENTS_GUIDANCE_VERSION, + createdFile: true, + notes + }; + } + + const currentContents = await readTextFile(targetPath); + const parsed = parseCodexAgentsGuidanceContents(currentContents); + const managedBlock = buildCodexAgentsManagedBlock(parsed.lineEnding); + + if (parsed.unsafeManagedBlock) { + return { + host: "codex", + projectRoot, + targetPath, + action: "blocked", + managedBlockVersion: CODEX_AGENTS_GUIDANCE_VERSION, + createdFile: false, + blockedReason: parsed.unsafeReason, + notes + }; + } + + if (!parsed.managedBlock) { + await writeTextFileAtomic( + targetPath, + appendManagedBlock(currentContents, managedBlock, parsed.lineEnding) + ); + return { + host: "codex", + projectRoot, + targetPath, + action: "updated", + managedBlockVersion: CODEX_AGENTS_GUIDANCE_VERSION, + createdFile: false, + notes + }; + } + + const currentBlock = parsed.managedBlock.contents; + if ( + normalizeManagedBlockForComparison(currentBlock) === + normalizeManagedBlockForComparison(managedBlock) + ) { + return { + host: "codex", + projectRoot, + targetPath, + action: "unchanged", + managedBlockVersion: CODEX_AGENTS_GUIDANCE_VERSION, + createdFile: false, + notes + }; + } + + await writeTextFileAtomic( + targetPath, + replaceManagedBlock(currentContents, parsed.managedBlock, managedBlock) + ); + return { + host: "codex", + projectRoot, + targetPath, + action: "updated", + managedBlockVersion: CODEX_AGENTS_GUIDANCE_VERSION, + createdFile: false, + notes + }; +} diff --git a/src/lib/integration/assets.ts b/src/lib/integration/assets.ts new file mode 100644 index 0000000..24044e1 --- /dev/null +++ b/src/lib/integration/assets.ts @@ -0,0 +1,442 @@ +import os from "node:os"; +import path from "node:path"; +import { + ARCHIVE_BOUNDARY, + buildCliSearchCommand, + buildMarkdownAssetVersionComment, + buildRecommendedCliSearchCommand, + buildRecommendedSearchPresetGuidance, + buildRecommendedMcpSearchInstruction, + buildRecommendedRetrievalSummaryLines, + buildShellAssetVersionComment, + CLI_FALLBACK_RECALL_WORKFLOW, + MCP_DOCTOR_GUIDANCE, + MCP_FIRST_RECALL_WORKFLOW, + MCP_SERVE_GUIDANCE, + MEMORY_AUDIT_BOUNDARY, + RECOMMENDED_RETRIEVAL_LIMIT, + RECOMMENDED_RETRIEVAL_STATE, + RETRIEVAL_INTEGRATION_ASSET_VERSION, + RETRIEVAL_MCP_DETAILS_TOOL, + RETRIEVAL_MCP_SEARCH_TOOL, + RETRIEVAL_MCP_TIMELINE_TOOL, + SESSION_CONTINUITY_BOUNDARY +} from "./retrieval-contract.js"; +import { LOCAL_BRIDGE_BUNDLE_NOTE } from "./codex-stack.js"; +import { + CODEX_MEMORY_SKILL_NAME, + type CodexSkillInstallSurface, + resolveCodexSkillInstallDir, + resolveCodexSkillPaths +} from "./skills-paths.js"; + +export interface GeneratedAsset { + relativePath: string; + contents: string; + executable?: boolean; +} + +export type IntegrationAssetInstallSurface = "hooks" | "skills"; +export type IntegrationAssetRole = "capture-helper" | "recall-helper" | "guidance"; + +export interface InstalledIntegrationAssetDescriptor { + id: string; + name: string; + path: string; + installSurface: IntegrationAssetInstallSurface; + role: IntegrationAssetRole; + expectedVersion: string; + expectedSignatures: string[]; + executableExpected: boolean; + doctorVisible: boolean; + relativePath: string; + contents: string; +} + +interface IntegrationAssetContext { + projectRoot: string; + hookDir: string; + skillDir: string; + skillSurface: CodexSkillInstallSurface; +} + +interface IntegrationAssetDefinition { + id: string; + name: string; + installSurface: IntegrationAssetInstallSurface; + relativePath: string; + executable?: boolean; + role: IntegrationAssetRole; + doctorVisible: boolean; + doctorSignatures?: string[]; + renderContents: (context: IntegrationAssetContext) => string; +} + +function buildIntegrationAssetContext( + homeDir = os.homedir(), + projectRoot = process.cwd(), + skillSurface: CodexSkillInstallSurface = "runtime" +): IntegrationAssetContext { + const skillPaths = resolveCodexSkillPaths(projectRoot, homeDir); + return { + projectRoot, + hookDir: hookAssetDir(homeDir), + skillDir: resolveCodexSkillInstallDir(skillPaths, skillSurface), + skillSurface + }; +} + +function resolveInstallDir( + surface: IntegrationAssetInstallSurface, + context: IntegrationAssetContext +): string { + return surface === "hooks" ? context.hookDir : context.skillDir; +} + +function buildRecallDispatcherScript(): string { + return `#!/bin/sh +${buildShellAssetVersionComment()} +# Dispatch recall lookups through a single host-agnostic bridge helper. + +ACTION="$1" +if [ "$#" -gt 0 ]; then + shift +fi + +contains_flag() { + FLAG="$1" + shift + for ARG in "$@"; do + case "$ARG" in + "$FLAG"|"$FLAG"=*) + return 0 + ;; + esac + done + return 1 +} + +case "$ACTION" in + search) + if ! contains_flag "--state" "$@"; then + set -- "$@" "--state" "${RECOMMENDED_RETRIEVAL_STATE}" + fi + if ! contains_flag "--limit" "$@"; then + set -- "$@" "--limit" "${RECOMMENDED_RETRIEVAL_LIMIT}" + fi + exec cam recall search "$@" + ;; + timeline) + exec cam recall timeline "$@" + ;; + details) + exec cam recall details "$@" + ;; + *) + echo "Usage: memory-recall.sh " >&2 + exit 1 + ;; +esac +`; +} + +function buildRecallWrapperScript(action: "search" | "timeline" | "details"): string { + return `#!/bin/sh +${buildShellAssetVersionComment()} +# Compatibility wrapper around memory-recall.sh for hosts that already expect this file name. +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec "$SCRIPT_DIR/memory-recall.sh" ${action} "$@" +`; +} + +function buildRecallBridgeGuideMarkdown(): string { + return `# Codex Auto Memory Recall Bridge + +${buildMarkdownAssetVersionComment()} + +This bundle keeps durable-memory recall host-agnostic. + +- ${LOCAL_BRIDGE_BUNDLE_NOTE} + +## Preferred path + +- ${MCP_FIRST_RECALL_WORKFLOW} +- ${buildRecommendedMcpSearchInstruction()} +- ${MCP_SERVE_GUIDANCE} +- ${MCP_DOCTOR_GUIDANCE} + +## CLI fallback bundle + +- ${CLI_FALLBACK_RECALL_WORKFLOW} +- Search example: \`memory-recall.sh search "pnpm"\` +- CLI equivalent: \`${buildRecommendedCliSearchCommand("\"pnpm\"")}\` +- Timeline example: \`memory-recall.sh timeline "project:active:workflow:prefer-pnpm"\` +- Details example: \`memory-recall.sh details "project:active:workflow:prefer-pnpm"\` +- Compatibility wrappers \`memory-search.sh\`, \`memory-timeline.sh\`, and \`memory-details.sh\` call the same dispatcher. +- ${buildRecommendedSearchPresetGuidance()} + +## Boundaries + +- ${MEMORY_AUDIT_BOUNDARY} +- ${SESSION_CONTINUITY_BOUNDARY} +- ${ARCHIVE_BOUNDARY} + +## Workflow discipline + +1. Search first. +2. Inspect timeline only for promising refs. +3. Fetch full details only when you still need the full Markdown body. +4. After finishing work that should update durable memory, run \`cam sync\` or review \`cam memory --recent\`. +`; +} + +function buildCodexSkillMarkdown(): string { + return `--- +name: codex-auto-memory-recall +description: Search Codex Auto Memory before repeating work. Use when the user asks whether we solved something before, asks for prior repo-specific decisions, or wants past fixes, preferences, or architecture context. +--- + +${buildMarkdownAssetVersionComment()} + +# Codex Auto Memory Recall + +Use this skill when the question is about durable memory from previous sessions, not just the current thread. + +## When to Use + +- "Did we already solve this?" +- "What did we decide last time?" +- "What are this repo's standing preferences or commands?" +- Before risky edits in an unfamiliar part of the repository, when prior durable memory could narrow the search space. + +## Workflow + +Always use progressive disclosure. + +Prefer the host's retrieval MCP tools when Codex Auto Memory has been wired in as an MCP server: + +- \`${RETRIEVAL_MCP_SEARCH_TOOL}\` +- \`${RETRIEVAL_MCP_TIMELINE_TOOL}\` +- \`${RETRIEVAL_MCP_DETAILS_TOOL}\` + +Recommended MCP-first search preset: + +- \`${buildRecommendedMcpSearchInstruction()}\` + +Otherwise fall back to the CLI workflow: + +1. Search first: + \`${buildRecommendedCliSearchCommand()}\` +2. Inspect timeline for promising refs: + \`cam recall timeline ""\` +3. Fetch full details only for the refs that still look relevant: + \`cam recall details ""\` + +If you need both active and archived results in one pass instead of active-first fallback: + +- \`${buildCliSearchCommand("\"\"", { state: "all" })}\` + +## Guardrails + +- Do not jump straight to \`cam recall details\` for every result. +- ${LOCAL_BRIDGE_BUNDLE_NOTE} +- \`cam mcp serve\` exposes the same retrieval contract over stdio MCP when the host can consume it. +- If you are unsure whether retrieval MCP is wired into the current host, run \`cam mcp doctor\`. +- If a host needs shell-based fallback assets, run \`cam hooks install\` and use the generated recall bridge bundle. +- After finishing work that should update durable memory, run \`cam sync\` or review \`cam memory --recent\`. +- Use \`cam memory\` for inspect/audit surfaces, startup payload, and recent sync review. +- Use \`cam session\` only for temporary continuity, not durable memory retrieval. +- Treat archived memory as historical context that does not participate in default startup recall. +- If recall finds nothing useful, continue with normal repository inspection instead of forcing a memory answer. +`; +} + +const INTEGRATION_ASSET_DEFINITIONS: readonly IntegrationAssetDefinition[] = [ + { + id: "post-session-sync", + name: "post-session-sync.sh", + installSurface: "hooks", + relativePath: "post-session-sync.sh", + executable: true, + role: "capture-helper", + doctorVisible: true, + doctorSignatures: ['cam sync "$@"'], + renderContents: () => + `#!/bin/sh +${buildShellAssetVersionComment()} +# Sync the latest rollout for the current project. +cam sync "$@" +` + }, + { + id: "startup-doctor", + name: "startup-doctor.sh", + installSurface: "hooks", + relativePath: "startup-doctor.sh", + executable: true, + role: "capture-helper", + doctorVisible: true, + doctorSignatures: ['cam doctor "$@"'], + renderContents: () => + `#!/bin/sh +${buildShellAssetVersionComment()} +# Print diagnostic information at session start. +cam doctor "$@" +` + }, + { + id: "memory-recall", + name: "memory-recall.sh", + installSurface: "hooks", + relativePath: "memory-recall.sh", + executable: true, + role: "recall-helper", + doctorVisible: true, + doctorSignatures: [ + 'exec cam recall search "$@"', + 'exec cam recall timeline "$@"', + 'exec cam recall details "$@"' + ], + renderContents: () => buildRecallDispatcherScript() + }, + { + id: "memory-search", + name: "memory-search.sh", + installSurface: "hooks", + relativePath: "memory-search.sh", + executable: true, + role: "recall-helper", + doctorVisible: true, + doctorSignatures: ['exec "$SCRIPT_DIR/memory-recall.sh" search "$@"'], + renderContents: () => buildRecallWrapperScript("search") + }, + { + id: "memory-timeline", + name: "memory-timeline.sh", + installSurface: "hooks", + relativePath: "memory-timeline.sh", + executable: true, + role: "recall-helper", + doctorVisible: true, + doctorSignatures: ['exec "$SCRIPT_DIR/memory-recall.sh" timeline "$@"'], + renderContents: () => buildRecallWrapperScript("timeline") + }, + { + id: "memory-details", + name: "memory-details.sh", + installSurface: "hooks", + relativePath: "memory-details.sh", + executable: true, + role: "recall-helper", + doctorVisible: true, + doctorSignatures: ['exec "$SCRIPT_DIR/memory-recall.sh" details "$@"'], + renderContents: () => buildRecallWrapperScript("details") + }, + { + id: "recall-bridge-guide", + name: "recall-bridge.md", + installSurface: "hooks", + relativePath: "recall-bridge.md", + role: "guidance", + doctorVisible: true, + doctorSignatures: [ + "search_memories", + 'memory-recall.sh search "pnpm"', + "Workflow discipline" + ], + renderContents: () => buildRecallBridgeGuideMarkdown() + }, + { + id: "codex-memory-skill", + name: `${CODEX_MEMORY_SKILL_NAME} SKILL.md`, + installSurface: "skills", + relativePath: "SKILL.md", + role: "guidance", + doctorVisible: true, + doctorSignatures: ["name: codex-auto-memory-recall", "search_memories"], + renderContents: () => buildCodexSkillMarkdown() + } +] as const; + +export function hookAssetDir(homeDir = os.homedir()): string { + return path.join(homeDir, ".codex-auto-memory", "hooks"); +} + +export function codexSkillAssetDir(homeDir = os.homedir()): string { + return resolveCodexSkillPaths(process.cwd(), homeDir).runtimeAssetDir; +} + +export function codexSkillAssetDirForSurface( + surface: CodexSkillInstallSurface, + projectRoot = process.cwd(), + homeDir = os.homedir() +): string { + return resolveCodexSkillInstallDir( + resolveCodexSkillPaths(projectRoot, homeDir), + surface + ); +} + +export function codexOfficialUserSkillAssetDir(homeDir = os.homedir()): string { + return resolveCodexSkillPaths(process.cwd(), homeDir).officialUserSkillDir; +} + +export function codexOfficialProjectSkillAssetDir(projectRoot: string): string { + return resolveCodexSkillPaths(projectRoot).officialProjectSkillDir; +} + +export function buildHookAssets(homeDir = os.homedir()): GeneratedAsset[] { + return listIntegrationAssets(homeDir, "hooks").map((asset) => ({ + relativePath: asset.relativePath, + contents: asset.contents, + executable: asset.executableExpected + })); +} + +export function buildCodexSkillAssets(homeDir = os.homedir()): GeneratedAsset[] { + return listIntegrationAssets(homeDir, "skills").map((asset) => ({ + relativePath: asset.relativePath, + contents: asset.contents, + executable: asset.executableExpected + })); +} + +export function listIntegrationAssets( + homeDir = os.homedir(), + installSurface?: IntegrationAssetInstallSurface, + options: { + projectRoot?: string; + skillSurface?: CodexSkillInstallSurface; + } = {} +): InstalledIntegrationAssetDescriptor[] { + const context = buildIntegrationAssetContext( + homeDir, + options.projectRoot, + options.skillSurface + ); + return INTEGRATION_ASSET_DEFINITIONS.filter( + (asset) => installSurface === undefined || asset.installSurface === installSurface + ).map((asset) => ({ + id: asset.id, + name: asset.name, + path: path.join(resolveInstallDir(asset.installSurface, context), asset.relativePath), + installSurface: asset.installSurface, + role: asset.role, + expectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + expectedSignatures: [...(asset.doctorSignatures ?? [])], + executableExpected: Boolean(asset.executable), + doctorVisible: asset.doctorVisible, + relativePath: asset.relativePath, + contents: asset.renderContents(context) + })); +} + +export function listDoctorVisibleIntegrationAssets( + homeDir = os.homedir() +): InstalledIntegrationAssetDescriptor[] { + return listIntegrationAssets(homeDir).filter((asset) => asset.doctorVisible); +} + +export function buildRecallBridgeSummaryLines(): string[] { + return buildRecommendedRetrievalSummaryLines(); +} diff --git a/src/lib/integration/codex-stack.ts b/src/lib/integration/codex-stack.ts new file mode 100644 index 0000000..fda7d36 --- /dev/null +++ b/src/lib/integration/codex-stack.ts @@ -0,0 +1,596 @@ +import { + buildRecommendedCliSearchCommand, + buildRecommendedMcpSearchInstruction, + DURABLE_MEMORY_SYNC_GUIDANCE, + formatRecommendedRetrievalPreset +} from "./retrieval-contract.js"; +import { + RETRIEVAL_MCP_DETAILS_TOOL, + RETRIEVAL_MCP_SEARCH_TOOL, + RETRIEVAL_MCP_TIMELINE_TOOL +} from "./retrieval-contract.js"; + +export type CodexIntegrationRoute = "mcp" | "hooks-fallback" | "cli-direct"; +export type CodexIntegrationStatus = "ok" | "warning" | "missing"; +export type IntegrationInstallAction = "created" | "updated" | "unchanged"; +export type CodexAgentsGuidanceInspectionStatus = "ok" | "warning" | "missing"; + +export interface CodexStackReadiness { + mcpReady: boolean; + mcpOperationalReady: boolean; + camCommandAvailable: boolean; + hookCaptureReady: boolean; + hookRecallReady: boolean; + skillReady: boolean; + workflowConsistent: boolean; +} + +export interface CodexIntegrationSubcheck { + status: CodexIntegrationStatus; + summary: string; +} + +export interface CodexIntegrationAssetAvailability { + hasCaptureAssets: boolean; + hasRecallAssets: boolean; + hasSkillAssets: boolean; + hasWorkflowAssets: boolean; +} + +export interface CodexIntegrationSubchecks { + mcp: CodexIntegrationSubcheck; + hookCapture: CodexIntegrationSubcheck; + hookRecall: CodexIntegrationSubcheck; + skill: CodexIntegrationSubcheck; + workflowConsistency: CodexIntegrationSubcheck; +} + +export interface CodexAgentsGuidance { + targetFileHint: "AGENTS.md"; + snippetFormat: "markdown"; + snippet: string; + notes: string[]; +} + +export interface CodexAgentsGuidanceInspection { + path: string; + exists: boolean; + status: CodexAgentsGuidanceInspectionStatus; + expectedVersion: string; + detectedVersion: string | null; + matchedSignatures: string[]; + missingSignatures: string[]; +} + +export const CODEX_HOOK_CAPTURE_ASSET_IDS = [ + "post-session-sync", + "startup-doctor" +] as const; + +export const CODEX_HOOK_RECALL_ASSET_IDS = [ + "memory-recall", + "memory-search", + "memory-timeline", + "memory-details" +] as const; + +export const CODEX_WORKFLOW_CONSISTENCY_ASSET_IDS = [ + ...CODEX_HOOK_RECALL_ASSET_IDS, + "recall-bridge-guide", + "codex-memory-skill" +] as const; + +export const READ_ONLY_RETRIEVAL_NOTE = + "Codex Auto Memory exposes a read-only retrieval MCP plane. Markdown remains the canonical memory surface."; +export const LOCAL_BRIDGE_BUNDLE_NOTE = + "Hook assets in this repository are local bridge and fallback helpers, not an official Codex hook surface."; +export const CODEX_AGENTS_TARGET_FILE_HINT = "AGENTS.md"; +export const CODEX_AGENTS_GUIDANCE_VERSION = "codex-agents-guidance-v1"; +export const CODEX_AGENTS_GUIDANCE_VERSION_MARKER = "cam:agents-guidance-version"; +export const CODEX_AGENTS_MANAGED_BLOCK_START = ""; +export const CODEX_AGENTS_MANAGED_BLOCK_END = ""; +export const CODEX_AGENTS_REQUIRED_SIGNATURES = [ + RETRIEVAL_MCP_SEARCH_TOOL, + RETRIEVAL_MCP_TIMELINE_TOOL, + RETRIEVAL_MCP_DETAILS_TOOL, + "cam recall search", + "cam memory", + "cam session", + LOCAL_BRIDGE_BUNDLE_NOTE +] as const; + +interface GuidanceLine { + lineStart: number; + lineEnd: number; + raw: string; + content: string; +} + +interface GuidanceFenceState { + marker: "`" | "~"; + length: number; +} + +interface ManagedBlockRange { + startIndex: number; + endIndex: number; + contents: string; + body: string; +} + +export interface CodexAgentsGuidanceParseResult { + lineEnding: "\n" | "\r\n" | "\r"; + visibleText: string; + managedBlock: ManagedBlockRange | null; + unsafeManagedBlock: boolean; + unsafeReason?: string; +} + +function capitalizeFirstLetter(value: string): string { + return value.length === 0 ? value : `${value[0]!.toUpperCase()}${value.slice(1)}`; +} + +function normalizeLineEndings(value: string): string { + return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +} + +function detectLineEnding(contents: string): "\n" | "\r\n" | "\r" { + if (contents.includes("\r\n")) { + return "\r\n"; + } + + if (contents.includes("\r")) { + return "\r"; + } + + return "\n"; +} + +function readLines(contents: string): GuidanceLine[] { + if (contents.length === 0) { + return []; + } + + const lines: GuidanceLine[] = []; + let offset = 0; + while (offset < contents.length) { + const lineStart = offset; + while ( + offset < contents.length && + contents[offset] !== "\n" && + contents[offset] !== "\r" + ) { + offset += 1; + } + + let lineEnd = offset; + if (contents[offset] === "\r" && contents[offset + 1] === "\n") { + offset += 2; + lineEnd = offset; + } else if (contents[offset] === "\n" || contents[offset] === "\r") { + offset += 1; + lineEnd = offset; + } + + lines.push({ + lineStart, + lineEnd, + raw: contents.slice(lineStart, lineEnd), + content: contents.slice(lineStart, lineEnd).replace(/(?:\r\n|\n|\r)$/u, "") + }); + } + + return lines; +} + +function matchFenceLine(line: string): GuidanceFenceState | null { + const match = line.match(/^[ \t]*([`~])\1{2,}.*$/u); + if (!match) { + return null; + } + + const marker = match[1] as "`" | "~"; + const sequence = line.trimStart().match(/^([`~]+)/u)?.[1] ?? marker.repeat(3); + return { + marker, + length: sequence.length + }; +} + +function isClosingFence(line: string, fence: GuidanceFenceState): boolean { + const trimmed = line.trimStart(); + const match = trimmed.match(/^([`~]+)/u); + return Boolean( + match && + match[1]?.[0] === fence.marker && + match[1].length >= fence.length + ); +} + +export function buildCodexAgentsManagedBlock( + lineEnding: "\n" | "\r\n" | "\r" = "\n" +): string { + return [ + CODEX_AGENTS_MANAGED_BLOCK_START, + buildCodexAgentsGuidance().snippet, + CODEX_AGENTS_MANAGED_BLOCK_END + ].join("\n").replace(/\n/g, lineEnding); +} + +export function parseCodexAgentsGuidanceContents( + contents: string +): CodexAgentsGuidanceParseResult { + const lineEnding = detectLineEnding(contents); + const lines = readLines(contents); + let fence: GuidanceFenceState | null = null; + let managedBlockStart: GuidanceLine | null = null; + let managedBlockEnd: GuidanceLine | null = null; + let managedBlockCount = 0; + let unsafeReason: string | undefined; + const visibleLines: string[] = []; + + for (const line of lines) { + if (fence) { + if (isClosingFence(line.content, fence)) { + fence = null; + } + continue; + } + + const nextFence = matchFenceLine(line.content); + if (nextFence) { + fence = nextFence; + continue; + } + + visibleLines.push(line.raw); + const trimmed = line.content.trim(); + + if (trimmed === CODEX_AGENTS_MANAGED_BLOCK_START) { + managedBlockCount += 1; + if (managedBlockCount > 1 || managedBlockStart) { + unsafeReason = + "Could not update AGENTS.md safely because the managed guidance block markers are duplicated outside fenced code blocks."; + continue; + } + + managedBlockStart = line; + continue; + } + + if (trimmed === CODEX_AGENTS_MANAGED_BLOCK_END) { + if (!managedBlockStart || managedBlockEnd) { + unsafeReason = + "Could not update AGENTS.md safely because the managed guidance block markers are missing, duplicated, or unbalanced."; + continue; + } + + managedBlockEnd = line; + } + } + + if ((managedBlockStart && !managedBlockEnd) || (!managedBlockStart && managedBlockEnd)) { + unsafeReason = + "Could not update AGENTS.md safely because the managed guidance block markers are missing, duplicated, or unbalanced."; + } + + const managedBlock = + managedBlockStart && managedBlockEnd + ? { + startIndex: managedBlockStart.lineStart, + endIndex: managedBlockEnd.lineEnd, + contents: contents.slice(managedBlockStart.lineStart, managedBlockEnd.lineEnd), + body: contents + .slice(managedBlockStart.lineEnd, managedBlockEnd.lineStart) + .replace(/^(?:\r\n|\n|\r)/u, "") + .replace(/(?:\r\n|\n|\r)$/u, "") + } + : null; + + return { + lineEnding, + visibleText: visibleLines.join(""), + managedBlock, + unsafeManagedBlock: Boolean(unsafeReason), + unsafeReason + }; +} + +export function formatIntegrationActionHeadline( + action: IntegrationInstallAction, + subject: string +): string { + switch (action) { + case "created": + return `Installed ${subject}.`; + case "updated": + return `Updated ${subject}.`; + case "unchanged": + return `${capitalizeFirstLetter(subject)} is already up to date.`; + } +} + +export function resolveCodexIntegrationRoute( + readiness: Pick +): CodexIntegrationRoute { + if (readiness.mcpOperationalReady) { + return "mcp"; + } + + if (readiness.hookRecallReady) { + return "hooks-fallback"; + } + + return "cli-direct"; +} + +export function summarizeCodexIntegrationStatus( + statuses: CodexIntegrationStatus[] +): CodexIntegrationStatus { + if (statuses.length === 0) { + return "missing"; + } + + const hasOk = statuses.includes("ok"); + const hasWarning = statuses.includes("warning"); + const hasMissing = statuses.includes("missing"); + + if (hasWarning || (hasOk && hasMissing)) { + return "warning"; + } + + return hasOk ? "ok" : "missing"; +} + +export function buildCodexStackNotes(): string[] { + return [ + READ_ONLY_RETRIEVAL_NOTE, + LOCAL_BRIDGE_BUNDLE_NOTE, + "Recommended route prefers project-scoped MCP, then local bridge recall helpers, then direct cam recall CLI usage.", + `Recommended retrieval preset: ${formatRecommendedRetrievalPreset()}.`, + DURABLE_MEMORY_SYNC_GUIDANCE, + "Run `cam mcp print-config --host codex` to inspect the recommended project-scoped MCP wiring together with an AGENTS.md snippet for Codex agents.", + "Run `cam mcp apply-guidance --host codex` to create or update the managed Codex Auto Memory block inside the repository-level AGENTS.md.", + "Codex skill readiness is guidance-only and does not replace executable hook fallback helpers.", + "Workflow consistency expects the shared search -> timeline -> details contract and the recommended preset to stay aligned across hooks and skills." + ]; +} + +export function buildCodexAgentsGuidance(): CodexAgentsGuidance { + const snippet = [ + "## Codex Auto Memory", + "", + ``, + `- When durable memory may help, prefer the retrieval MCP workflow: \`${RETRIEVAL_MCP_SEARCH_TOOL}\` -> \`${RETRIEVAL_MCP_TIMELINE_TOOL}\` -> \`${RETRIEVAL_MCP_DETAILS_TOOL}\`.`, + `- ${buildRecommendedMcpSearchInstruction()}`, + `- If the retrieval MCP server is unavailable, fall back to \`${buildRecommendedCliSearchCommand()}\`, then \`cam recall timeline \"\"\`, then \`cam recall details \"\"\`.`, + `- ${DURABLE_MEMORY_SYNC_GUIDANCE}`, + "- Use `cam memory` for inspect/audit surfaces and startup payload review.", + "- Use `cam session` only for temporary continuity, not durable memory retrieval.", + `- ${LOCAL_BRIDGE_BUNDLE_NOTE}` + ].join("\n"); + + return { + targetFileHint: CODEX_AGENTS_TARGET_FILE_HINT, + snippetFormat: "markdown", + snippet, + notes: [ + "Paste this snippet into the repository-level AGENTS.md so future Codex agents can discover the durable-memory workflow without extra setup.", + "Keep the snippet additive; do not overwrite existing project-specific AGENTS guidance.", + LOCAL_BRIDGE_BUNDLE_NOTE + ] + }; +} + +export function detectCodexAgentsGuidanceVersion(contents: string): string | null { + const match = contents.match( + new RegExp(`${CODEX_AGENTS_GUIDANCE_VERSION_MARKER}\\s+([A-Za-z0-9._-]+)`, "u") + ); + return match?.[1] ?? null; +} + +export function inspectCodexAgentsGuidance( + path: string, + contents: string | null +): CodexAgentsGuidanceInspection { + if (contents === null) { + return { + path, + exists: false, + status: "missing", + expectedVersion: CODEX_AGENTS_GUIDANCE_VERSION, + detectedVersion: null, + matchedSignatures: [], + missingSignatures: [...CODEX_AGENTS_REQUIRED_SIGNATURES] + }; + } + + const parsed = parseCodexAgentsGuidanceContents(contents); + const inspectionTarget = parsed.managedBlock?.body ?? parsed.visibleText; + const normalizedTarget = normalizeLineEndings(inspectionTarget); + const expectedSnippet = normalizeLineEndings(buildCodexAgentsGuidance().snippet); + const detectedVersion = detectCodexAgentsGuidanceVersion(normalizedTarget); + const matchedSignatures = CODEX_AGENTS_REQUIRED_SIGNATURES.filter((signature) => + normalizedTarget.includes(signature) + ); + const missingSignatures = CODEX_AGENTS_REQUIRED_SIGNATURES.filter( + (signature) => !matchedSignatures.includes(signature) + ); + const hasCurrentGuidance = parsed.managedBlock + ? normalizeLineEndings(parsed.managedBlock.body) === expectedSnippet + : normalizeLineEndings(parsed.visibleText).includes(expectedSnippet); + + return { + path, + exists: true, + status: hasCurrentGuidance ? "ok" : "warning", + expectedVersion: CODEX_AGENTS_GUIDANCE_VERSION, + detectedVersion, + matchedSignatures: [...matchedSignatures], + missingSignatures: [...missingSignatures] + }; +} + +export function buildCodexIntegrationSubchecks( + readiness: CodexStackReadiness, + assetAvailability: CodexIntegrationAssetAvailability, + mcpFallback: CodexIntegrationSubcheck +): CodexIntegrationSubchecks { + return { + mcp: !readiness.mcpReady + ? mcpFallback + : readiness.mcpOperationalReady + ? { + status: "ok", + summary: + "Project-scoped retrieval MCP wiring is present and the `cam` command is available." + } + : { + status: "warning", + summary: + "Project-scoped retrieval MCP wiring is present, but the current shell could not resolve `cam` on PATH." + }, + hookCapture: readiness.hookCaptureReady + ? { + status: "ok", + summary: "Capture helpers are ready for post-session sync and startup diagnostics." + } + : assetAvailability.hasCaptureAssets + ? { + status: "warning", + summary: "Some capture helpers exist, but the bundle is incomplete or stale." + } + : { + status: "missing", + summary: "No capture helper bundle is installed yet." + }, + hookRecall: readiness.hookRecallReady + ? { + status: "ok", + summary: "Recall helpers are ready for shell-based search -> timeline -> details fallback." + } + : assetAvailability.hasRecallAssets + ? { + status: "warning", + summary: "Some recall helper assets exist, but the bundle is incomplete or stale." + } + : { + status: "missing", + summary: "No hook recall helper bundle is installed yet." + }, + skill: readiness.skillReady + ? { + status: "ok", + summary: "Codex skill guidance is installed for MCP-first, CLI-fallback retrieval." + } + : assetAvailability.hasSkillAssets + ? { + status: "warning", + summary: "Skill assets exist, but the installed guidance is stale." + } + : { + status: "missing", + summary: "No Codex durable-memory skill is installed yet." + }, + workflowConsistency: readiness.workflowConsistent + ? { + status: "ok", + summary: + "Hooks and skills agree on the shared search -> timeline -> details workflow and preset." + } + : assetAvailability.hasWorkflowAssets + ? { + status: "warning", + summary: + "Some integration assets exist, but they do not fully agree on the shared retrieval workflow yet." + } + : { + status: "missing", + summary: "Shared retrieval workflow assets have not been installed yet." + } + }; +} + +export function buildCodexRouteSummary(route: CodexIntegrationRoute): string { + switch (route) { + case "mcp": + return "Project-scoped retrieval MCP is ready and should be the default route."; + case "hooks-fallback": + return "Use the hook recall bundle for now; MCP is not fully operational yet."; + case "cli-direct": + return "Use cam recall directly until either MCP or hook recall helpers become ready."; + } +} + +export function buildCodexIntegrationNextSteps( + readiness: CodexStackReadiness, + options: { + skillInstallCommand?: string; + } = {} +): string[] { + const route = resolveCodexIntegrationRoute(readiness); + const skillInstallCommand = options.skillInstallCommand ?? "cam skills install"; + const nextSteps: string[] = []; + + if ( + !readiness.mcpReady && + !readiness.hookCaptureReady && + !readiness.hookRecallReady && + !readiness.skillReady + ) { + return [ + "Run `cam integrations install --host codex` to install the recommended Codex integration stack in one step.", + `Until the stack is installed, use \`${buildRecommendedCliSearchCommand()}\` directly.`, + "Run `cam mcp print-config --host codex` to print the recommended project-scoped MCP wiring and AGENTS.md snippet." + ]; + } + + if (!readiness.mcpReady) { + nextSteps.push( + "Run `cam mcp install --host codex` to write the recommended project-scoped retrieval MCP wiring." + ); + } else if (!readiness.camCommandAvailable) { + nextSteps.push( + "Make sure the host process can resolve `cam` on PATH before relying on the MCP route." + ); + } + + if (!readiness.hookCaptureReady || !readiness.hookRecallReady) { + nextSteps.push( + "Run `cam hooks install` to refresh the shared hook helper bundle for capture and recall." + ); + } + + if (!readiness.skillReady) { + nextSteps.push( + `Run \`${skillInstallCommand}\` to install the Codex retrieval skill guidance.` + ); + } + + if (!readiness.workflowConsistent && (readiness.hookRecallReady || readiness.skillReady)) { + nextSteps.push( + `Re-run \`cam hooks install\` and \`${skillInstallCommand}\` to realign retrieval guidance and fallback assets.` + ); + } + + if (route === "mcp") { + nextSteps.push( + `Prefer retrieval MCP with the recommended preset \`${formatRecommendedRetrievalPreset()}\`; keep \`cam recall\` as a direct fallback.` + ); + } else if (route === "hooks-fallback") { + nextSteps.push( + "Use `memory-recall.sh search|timeline|details` for the current local bridge fallback path while MCP is being finished." + ); + } else { + nextSteps.push( + `Use \`${buildRecommendedCliSearchCommand()}\` directly until a richer integration route becomes ready.` + ); + } + + nextSteps.push( + "Run `cam mcp print-config --host codex` to print the recommended project-scoped MCP wiring and AGENTS.md snippet." + ); + nextSteps.push(DURABLE_MEMORY_SYNC_GUIDANCE); + + return [...new Set(nextSteps)]; +} diff --git a/src/lib/integration/install-assets.ts b/src/lib/integration/install-assets.ts new file mode 100644 index 0000000..a17fddf --- /dev/null +++ b/src/lib/integration/install-assets.ts @@ -0,0 +1,142 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { listIntegrationAssets, type IntegrationAssetInstallSurface } from "./assets.js"; +import { READ_ONLY_RETRIEVAL_NOTE } from "./codex-stack.js"; +import { + formatRecommendedRetrievalPreset, + MCP_FIRST_RECALL_WORKFLOW, + CLI_FALLBACK_RECALL_WORKFLOW, + RETRIEVAL_INTEGRATION_ASSET_VERSION +} from "./retrieval-contract.js"; +import { + type CodexSkillInstallSurface, + formatCodexSkillInstallSurface +} from "./skills-paths.js"; +import { ensureDir, fileExists, readTextFile, writeTextFile } from "../util/fs.js"; + +export type IntegrationAssetInstallAction = "created" | "updated" | "unchanged"; + +export interface InstalledIntegrationAssetResult { + id: string; + name: string; + path: string; + role: "capture-helper" | "recall-helper" | "guidance"; + action: IntegrationAssetInstallAction; + executableExpected: boolean; +} + +export interface IntegrationAssetInstallResult { + installSurface: IntegrationAssetInstallSurface; + targetDir: string; + action: IntegrationAssetInstallAction; + readOnlyRetrieval: true; + assetVersion: string; + recommendedPreset: string; + skillSurface?: CodexSkillInstallSurface; + preferredSkillSurface?: CodexSkillInstallSurface; + notes: string[]; + assets: InstalledIntegrationAssetResult[]; +} + +function isExecutableMode(mode: number): boolean { + return (mode & 0o111) !== 0; +} + +function summarizeInstallAction( + actions: IntegrationAssetInstallAction[] +): IntegrationAssetInstallAction { + if (actions.every((action) => action === "unchanged")) { + return "unchanged"; + } + + if (actions.every((action) => action === "created")) { + return "created"; + } + + return "updated"; +} + +function buildInstallNotes(): string[] { + return [ + READ_ONLY_RETRIEVAL_NOTE, + `Recommended retrieval preset: ${formatRecommendedRetrievalPreset()}.`, + MCP_FIRST_RECALL_WORKFLOW, + CLI_FALLBACK_RECALL_WORKFLOW + ]; +} + +interface InstallIntegrationAssetsOptions { + homeDir?: string; + projectRoot?: string; + skillSurface?: CodexSkillInstallSurface; +} + +export async function installIntegrationAssets( + installSurface: IntegrationAssetInstallSurface, + options: InstallIntegrationAssetsOptions = {} +): Promise { + const homeDir = options.homeDir ?? os.homedir(); + const skillSurface = options.skillSurface ?? "runtime"; + const assets = listIntegrationAssets(homeDir, installSurface, { + projectRoot: options.projectRoot, + skillSurface + }); + const targetDir = path.dirname(assets[0]?.path ?? path.join(homeDir, ".codex-auto-memory")); + await ensureDir(targetDir); + + const assetResults: InstalledIntegrationAssetResult[] = []; + for (const asset of assets) { + const exists = await fileExists(asset.path); + const currentContents = exists ? await readTextFile(asset.path) : null; + const executableOk = + exists && asset.executableExpected + ? isExecutableMode((await fs.stat(asset.path)).mode) + : !asset.executableExpected; + const action: IntegrationAssetInstallAction = !exists + ? "created" + : currentContents !== asset.contents || !executableOk + ? "updated" + : "unchanged"; + + if (action !== "unchanged") { + await ensureDir(path.dirname(asset.path)); + await writeTextFile(asset.path, asset.contents); + if (asset.executableExpected) { + await fs.chmod(asset.path, 0o755); + } + } + + assetResults.push({ + id: asset.id, + name: asset.name, + path: asset.path, + role: asset.role, + action, + executableExpected: asset.executableExpected + }); + } + + return { + installSurface, + targetDir, + action: summarizeInstallAction(assetResults.map((asset) => asset.action)), + readOnlyRetrieval: true, + assetVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + recommendedPreset: formatRecommendedRetrievalPreset(), + skillSurface: installSurface === "skills" ? skillSurface : undefined, + preferredSkillSurface: installSurface === "skills" ? "runtime" : undefined, + notes: + installSurface === "skills" + ? [ + ...buildInstallNotes(), + `Preferred skill install surface: runtime.`, + `Installed skill surface: ${formatCodexSkillInstallSurface(skillSurface)}.`, + skillSurface === "runtime" + ? "Runtime remains the default Codex skill target for this repository." + : "Official .agents/skills targets stay explicit opt-in surfaces and do not replace the runtime default." + ] + : buildInstallNotes(), + assets: assetResults + }; +} diff --git a/src/lib/integration/mcp-config.ts b/src/lib/integration/mcp-config.ts new file mode 100644 index 0000000..bffb208 --- /dev/null +++ b/src/lib/integration/mcp-config.ts @@ -0,0 +1,79 @@ +import path from "node:path"; +import { detectProjectContext } from "../domain/project-context.js"; +import { + buildCodexAgentsGuidance, + READ_ONLY_RETRIEVAL_NOTE, + type CodexAgentsGuidance +} from "./codex-stack.js"; +import { + buildMcpHostSnippet, + getMcpHostDefinition, + MEMORY_RETRIEVAL_MCP_SERVER_NAME, + normalizeMcpHost, + type McpHost +} from "./mcp-hosts.js"; + +export type { McpHost } from "./mcp-hosts.js"; +export { MEMORY_RETRIEVAL_MCP_SERVER_NAME } from "./mcp-hosts.js"; + +export interface McpHostConfigSnippet { + host: McpHost; + serverName: string; + transport: "stdio"; + targetFileHint: string; + projectRoot: string; + snippetFormat: "toml" | "json"; + snippet: string; + notes: string[]; + agentsGuidance?: CodexAgentsGuidance; +} + +export { normalizeMcpHost }; + +export function resolveMcpProjectRoot(cwd = process.cwd()): string { + return detectProjectContext(path.resolve(cwd)).projectRoot; +} + +export function buildMcpHostConfigSnippet(host: McpHost, projectRoot: string): McpHostConfigSnippet { + const definition = getMcpHostDefinition(host); + + return { + host, + serverName: MEMORY_RETRIEVAL_MCP_SERVER_NAME, + transport: "stdio", + targetFileHint: definition.targetFileHint, + projectRoot, + snippetFormat: definition.snippetFormat, + snippet: buildMcpHostSnippet(host, projectRoot), + notes: [...definition.notes], + ...(host === "codex" ? { agentsGuidance: buildCodexAgentsGuidance() } : {}) + }; +} + +export function formatMcpHostConfigSnippet(snippet: McpHostConfigSnippet): string { + const lines = [ + READ_ONLY_RETRIEVAL_NOTE, + `Target file hint: ${snippet.targetFileHint}`, + `Server name: ${snippet.serverName}`, + "", + snippet.snippet, + "", + "Notes:", + ...snippet.notes.map((note) => `- ${note}`) + ]; + + if (snippet.agentsGuidance) { + lines.push( + "", + "AGENTS.md guidance:", + `Target file hint: ${snippet.agentsGuidance.targetFileHint}`, + "", + snippet.agentsGuidance.snippet, + "", + "AGENTS notes:", + ...snippet.agentsGuidance.notes.map((note) => `- ${note}`) + ); + } + + return lines.join("\n"); +} diff --git a/src/lib/integration/mcp-doctor.ts b/src/lib/integration/mcp-doctor.ts new file mode 100644 index 0000000..81b3da6 --- /dev/null +++ b/src/lib/integration/mcp-doctor.ts @@ -0,0 +1,789 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import * as toml from "smol-toml"; +import { + listDoctorVisibleIntegrationAssets +} from "./assets.js"; +import { + buildCodexStackNotes, + inspectCodexAgentsGuidance, + CODEX_HOOK_CAPTURE_ASSET_IDS, + CODEX_HOOK_RECALL_ASSET_IDS, + CODEX_WORKFLOW_CONSISTENCY_ASSET_IDS, + resolveCodexIntegrationRoute, + summarizeCodexIntegrationStatus, + type CodexAgentsGuidanceInspection, + type CodexIntegrationRoute +} from "./codex-stack.js"; +import { + detectIntegrationAssetVersion, + formatRecommendedRetrievalPreset, + RETRIEVAL_INTEGRATION_ASSET_VERSION +} from "./retrieval-contract.js"; +import { + getMcpHostDefinition, + inspectCanonicalMcpServerConfig, + listMcpHosts, + MEMORY_RETRIEVAL_MCP_SERVER_NAME, + normalizeMcpDoctorHostSelection, + resolveMcpHostProjectConfigPath, + type McpDoctorHostSelection, + type McpHost, + type McpHostPinningMode +} from "./mcp-hosts.js"; +import { + buildCodexSkillInstallCommand, + resolveCodexSkillPaths, + type CodexSkillInstallSurface, + type CodexSkillPathResolution +} from "./skills-paths.js"; +import { fileExists, readTextFile } from "../util/fs.js"; +import { resolveMcpProjectRoot } from "./mcp-config.js"; + +type McpDoctorStatus = "ok" | "warning" | "missing" | "manual"; +type McpDoctorConfigInspection = "ok" | "missing" | "parse-error" | "shape-mismatch"; + +interface McpDoctorConfigCheck { + path: string; + exists: boolean; + hasServerName: boolean; + hasCamCommand: boolean; + hasServeInvocation: boolean; + projectPinned: boolean; +} + +interface McpDoctorConfigInspectionResult { + inspection: McpDoctorConfigInspection; + configCheck?: McpDoctorConfigCheck; +} + +interface McpDoctorHostReport { + host: McpHost; + status: McpDoctorStatus; + targetFileHint: string; + pinning: McpHostPinningMode; + summary: string; + notes: string[]; + configCheck?: McpDoctorConfigCheck; +} + +interface McpDoctorAssetCheck { + id: string; + name: string; + path: string; + installed: boolean; + status: "ok" | "missing" | "stale"; + expectedVersion: string; + detectedVersion: string | null; + installSurface: "hooks" | "skills"; + role: "capture-helper" | "recall-helper" | "guidance"; + executableExpected: boolean; + executableOk: boolean | null; +} + +function hasExpectedAssetSignatures(contents: string, expectedSignatures: string[]): boolean { + return expectedSignatures.every((signature) => contents.includes(signature)); +} + +interface SkillSurfaceInspection { + installed: boolean; + matchesCanonical: boolean; + ready: boolean; +} + +async function inspectSkillSurfaceFile( + skillDir: string, + canonicalContents: string +): Promise { + const skillFilePath = path.join(skillDir, "SKILL.md"); + const installed = await fileExists(skillFilePath); + if (!installed) { + return { + installed, + matchesCanonical: false, + ready: false + }; + } + + const contents = await readTextFile(skillFilePath); + const matchesCanonical = contents === canonicalContents; + return { + installed, + matchesCanonical, + ready: + matchesCanonical && + detectIntegrationAssetVersion(contents) === RETRIEVAL_INTEGRATION_ASSET_VERSION + }; +} + +interface McpDoctorFallbackAssets { + hooksDir: string; + skillDir: string; + runtimeSkillDir: string; + runtimeAssetDir: string; + runtimeSource: CodexSkillPathResolution["runtimeSource"]; + preferredInstallSurface: CodexSkillInstallSurface; + recommendedSkillInstallCommand: string; + officialUserSkillDir: string; + officialProjectSkillDir: string; + runtimeSkillInstalled: boolean; + officialUserSkillInstalled: boolean; + officialProjectSkillInstalled: boolean; + runtimeSkillMatchesCanonical: boolean; + officialUserSkillMatchesRuntime: boolean; + officialProjectSkillMatchesRuntime: boolean; + runtimeSkillReady: boolean; + officialUserSkillReady: boolean; + officialProjectSkillReady: boolean; + installedSkillSurfaces: CodexSkillInstallSurface[]; + readySkillSurfaces: CodexSkillInstallSurface[]; + skillPathDrift: boolean; + postSessionSyncInstalled: boolean; + captureHelpersInstalled: boolean; + hookHelpersInstalled: boolean; + startupDoctorInstalled: boolean; + skillInstalled: boolean; + shellFallbackAvailable: boolean; + guidanceAvailable: boolean; + fallbackAvailable: boolean; + assets: McpDoctorAssetCheck[]; +} + +export interface McpDoctorReport { + cwd: string; + projectRoot: string; + cwdWithinProjectRoot: boolean; + serverName: string; + readOnlyRetrieval: true; + commandSurface: { + install: true; + serve: true; + printConfig: true; + doctor: true; + }; + agentsGuidance: CodexAgentsGuidanceInspection; + fallbackAssets: McpDoctorFallbackAssets; + hosts: McpDoctorHostReport[]; + codexStack: { + status: McpDoctorStatus; + recommendedRoute: CodexIntegrationRoute; + preset: string; + assetVersion: string; + mcpReady: boolean; + mcpOperationalReady: boolean; + camCommandAvailable: boolean; + hookCaptureReady: boolean; + hookRecallReady: boolean; + skillReady: boolean; + workflowConsistent: boolean; + notes: string[]; + }; +} + +function isExecutableMode(mode: number): boolean { + return (mode & 0o111) !== 0; +} + +function isPathWithin(parent: string, child: string): boolean { + const relative = path.relative(parent, child); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function isRecordLike(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +async function normalizeComparablePath(input: string): Promise { + try { + return await fs.realpath(input); + } catch { + return path.resolve(input); + } +} + +async function isCommandAvailableInPath(command: string): Promise { + const pathValue = process.env.PATH ?? ""; + if (!pathValue.trim()) { + return false; + } + + const entries = pathValue.split(path.delimiter).filter(Boolean); + const extensions = + process.platform === "win32" + ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") + .split(";") + .filter(Boolean) + : [""]; + + for (const entry of entries) { + for (const extension of extensions) { + const candidate = path.join( + entry, + process.platform === "win32" ? `${command}${extension}` : command + ); + if (!(await fileExists(candidate))) { + continue; + } + + if (process.platform === "win32") { + return true; + } + + if (isExecutableMode((await fs.stat(candidate)).mode)) { + return true; + } + } + } + + return false; +} + +function formatPinning(pinning: McpHostPinningMode): string { + switch (pinning) { + case "cwd-field": + return "cwd field"; + case "cwd-arg": + return "--cwd argument"; + case "manual": + return "manual"; + } +} + +async function inspectHostConfig( + host: McpHost, + projectRoot: string +): Promise { + const configPath = resolveMcpHostProjectConfigPath(host, projectRoot); + if (!configPath) { + return { + inspection: "missing" + }; + } + + const exists = await fileExists(configPath); + if (!exists) { + return { + inspection: "missing", + configCheck: { + path: configPath, + exists: false, + hasServerName: false, + hasCamCommand: false, + hasServeInvocation: false, + projectPinned: false + } + }; + } + + const rawConfig = await readTextFile(configPath); + if (host === "codex") { + let parsedConfig: unknown; + try { + parsedConfig = toml.parse(rawConfig); + } catch { + return { + inspection: "parse-error", + configCheck: { + path: configPath, + exists: true, + hasServerName: false, + hasCamCommand: false, + hasServeInvocation: false, + projectPinned: false + } + }; + } + + const mcpServers = isRecordLike(parsedConfig) && isRecordLike(parsedConfig.mcp_servers) + ? parsedConfig.mcp_servers + : null; + const serverConfig = mcpServers?.[MEMORY_RETRIEVAL_MCP_SERVER_NAME]; + const configCheck = await inspectCanonicalMcpServerConfig("codex", serverConfig, projectRoot); + + return { + inspection: + configCheck.hasServerName && + configCheck.hasCamCommand && + configCheck.hasServeInvocation && + configCheck.projectPinned + ? "ok" + : "shape-mismatch", + configCheck: { + path: configPath, + exists: true, + ...configCheck + } + }; + } + + let parsedConfig: unknown; + try { + parsedConfig = JSON.parse(rawConfig) as unknown; + } catch { + return { + inspection: "parse-error", + configCheck: { + path: configPath, + exists: true, + hasServerName: false, + hasCamCommand: false, + hasServeInvocation: false, + projectPinned: false + } + }; + } + + const structuredHost = host === "claude" ? "claude" : "gemini"; + const mcpServers = isRecordLike(parsedConfig) && isRecordLike(parsedConfig.mcpServers) + ? parsedConfig.mcpServers + : null; + const serverConfig = mcpServers?.[MEMORY_RETRIEVAL_MCP_SERVER_NAME]; + const configCheck = await inspectCanonicalMcpServerConfig( + structuredHost, + serverConfig, + projectRoot + ); + + return { + inspection: + configCheck.hasServerName && + configCheck.hasCamCommand && + configCheck.hasServeInvocation && + configCheck.projectPinned + ? "ok" + : "shape-mismatch", + configCheck: { + path: configPath, + exists: true, + ...configCheck + } + }; +} + +function summarizeHostReport( + host: McpHost, + inspectionResult: McpDoctorConfigInspectionResult +): Pick { + const { inspection, configCheck } = inspectionResult; + + if (!configCheck) { + return { + status: "manual", + summary: + "This host has no single project-scoped config file to inspect. Use the printed snippet and verify the wiring manually." + }; + } + + if (inspection === "missing" || !configCheck.exists) { + return { + status: "missing", + summary: "The recommended project-scoped config file does not exist yet." + }; + } + + if (inspection === "parse-error") { + return { + status: "warning", + summary: + "A project-scoped config file exists, but it could not be parsed as valid host configuration." + }; + } + + if ( + inspection === "shape-mismatch" && + !(configCheck.hasServerName && configCheck.hasCamCommand && configCheck.hasServeInvocation) + ) { + return { + status: "warning", + summary: + "A project-scoped config file exists, but the expected codex_auto_memory stdio wiring was not detected completely." + }; + } + + if (!configCheck.projectPinned) { + return { + status: "warning", + summary: + "The config looks wired, but the retrieval server is not clearly pinned to this project root yet." + }; + } + + return { + status: "ok", + summary: "The recommended project-scoped wiring looks present and pinned to this repository." + }; +} + +async function inspectHost(host: McpHost, projectRoot: string): Promise { + const definition = getMcpHostDefinition(host); + const inspectionResult = await inspectHostConfig(host, projectRoot); + const summary = summarizeHostReport(host, inspectionResult); + + return { + host, + status: summary.status, + targetFileHint: definition.targetFileHint, + pinning: definition.pinning, + summary: summary.summary, + notes: [...definition.notes], + configCheck: inspectionResult.configCheck + }; +} + +async function inspectFallbackAssets(projectRoot: string): Promise { + const descriptors = listDoctorVisibleIntegrationAssets(); + const hooksDir = descriptors.find((asset) => asset.installSurface === "hooks")?.path; + const skillPaths = resolveCodexSkillPaths(projectRoot); + const skillDir = skillPaths.runtimeAssetDir; + const runtimeSkillDir = skillPaths.runtimeSkillDir; + const officialUserSkillDir = skillPaths.officialUserSkillDir; + const officialProjectSkillDir = skillPaths.officialProjectSkillDir; + const assets: McpDoctorAssetCheck[] = descriptors.map((asset) => ({ + id: asset.id, + name: asset.name, + path: asset.path, + installed: false, + status: "missing", + expectedVersion: asset.expectedVersion, + detectedVersion: null, + installSurface: asset.installSurface, + role: asset.role, + executableExpected: asset.executableExpected, + executableOk: asset.executableExpected ? false : null + })); + + for (const asset of assets) { + asset.installed = await fileExists(asset.path); + if (!asset.installed) { + asset.status = "missing"; + continue; + } + + const raw = await readTextFile(asset.path); + asset.detectedVersion = detectIntegrationAssetVersion(raw); + if (asset.executableExpected) { + asset.executableOk = isExecutableMode((await fs.stat(asset.path)).mode); + } + asset.status = + asset.detectedVersion === asset.expectedVersion && + hasExpectedAssetSignatures( + raw, + descriptors.find((descriptor) => descriptor.name === asset.name)?.expectedSignatures ?? [] + ) && + (asset.executableExpected ? asset.executableOk === true : true) + ? "ok" + : "stale"; + } + + const retrievalHelpers = descriptors + .filter((asset) => asset.installSurface === "hooks" && asset.role === "recall-helper") + .map((asset) => asset.name); + const postSessionSyncInstalled = + assets.find((asset) => asset.id === "post-session-sync")?.status === "ok"; + const hookHelpersInstalled = + retrievalHelpers.length > 0 && + retrievalHelpers.every((name) => + assets.find((asset) => asset.name === name)?.status === "ok" + ); + const startupDoctorInstalled = + assets.find((asset) => asset.name === "startup-doctor.sh")?.status === "ok"; + const runtimeSkillInstalled = + descriptors + .filter((asset) => asset.installSurface === "skills") + .every((asset) => assets.find((candidate) => candidate.name === asset.name)?.installed === true); + const canonicalSkillContents = + descriptors.find((asset) => asset.installSurface === "skills")?.contents ?? ""; + const runtimeSkillReady = + descriptors + .filter((asset) => asset.installSurface === "skills") + .every((asset) => assets.find((candidate) => candidate.name === asset.name)?.status === "ok"); + const runtimeSkillContents = runtimeSkillInstalled + ? await readTextFile(path.join(skillDir, "SKILL.md")) + : null; + const runtimeSkillMatchesCanonical = + runtimeSkillContents !== null && runtimeSkillContents === canonicalSkillContents; + const officialUserSkillInspection = await inspectSkillSurfaceFile( + officialUserSkillDir, + canonicalSkillContents + ); + const officialProjectSkillInspection = await inspectSkillSurfaceFile( + officialProjectSkillDir, + canonicalSkillContents + ); + const installedSkillSurfaces: CodexSkillInstallSurface[] = []; + if (runtimeSkillInstalled) { + installedSkillSurfaces.push("runtime"); + } + if (officialUserSkillInspection.installed) { + installedSkillSurfaces.push("official-user"); + } + if (officialProjectSkillInspection.installed) { + installedSkillSurfaces.push("official-project"); + } + const readySkillSurfaces: CodexSkillInstallSurface[] = []; + if (runtimeSkillReady) { + readySkillSurfaces.push("runtime"); + } + if (officialUserSkillInspection.ready) { + readySkillSurfaces.push("official-user"); + } + if (officialProjectSkillInspection.ready) { + readySkillSurfaces.push("official-project"); + } + + return { + hooksDir: hooksDir ? path.dirname(hooksDir) : "", + skillDir, + runtimeSkillDir, + runtimeAssetDir: skillDir, + runtimeSource: skillPaths.runtimeSource, + preferredInstallSurface: skillPaths.preferredInstallSurface, + recommendedSkillInstallCommand: buildCodexSkillInstallCommand( + skillPaths.preferredInstallSurface + ), + officialUserSkillDir, + officialProjectSkillDir, + runtimeSkillInstalled, + officialUserSkillInstalled: officialUserSkillInspection.installed, + officialProjectSkillInstalled: officialProjectSkillInspection.installed, + runtimeSkillMatchesCanonical, + officialUserSkillMatchesRuntime: officialUserSkillInspection.matchesCanonical, + officialProjectSkillMatchesRuntime: officialProjectSkillInspection.matchesCanonical, + runtimeSkillReady, + officialUserSkillReady: officialUserSkillInspection.ready, + officialProjectSkillReady: officialProjectSkillInspection.ready, + installedSkillSurfaces, + readySkillSurfaces, + skillPathDrift: + runtimeSkillDir.length > 0 && + path.resolve(runtimeSkillDir) !== path.resolve(officialUserSkillDir), + postSessionSyncInstalled, + captureHelpersInstalled: postSessionSyncInstalled && Boolean(startupDoctorInstalled), + hookHelpersInstalled, + startupDoctorInstalled, + skillInstalled: readySkillSurfaces.length > 0, + shellFallbackAvailable: hookHelpersInstalled, + guidanceAvailable: readySkillSurfaces.length > 0, + fallbackAvailable: hookHelpersInstalled || readySkillSurfaces.length > 0, + assets + }; +} + +function isAssetReady( + assets: McpDoctorAssetCheck[], + ids: string[] +): boolean { + return ids.every((id) => assets.find((asset) => asset.id === id)?.status === "ok"); +} + +function buildCodexStackReport( + codexHost: McpDoctorHostReport, + fallbackAssets: McpDoctorFallbackAssets, + camCommandAvailable: boolean +): McpDoctorReport["codexStack"] { + const mcpReady = codexHost.status === "ok"; + const mcpOperationalReady = mcpReady && camCommandAvailable; + const hookCaptureReady = isAssetReady( + fallbackAssets.assets, + [...CODEX_HOOK_CAPTURE_ASSET_IDS] + ); + const hookRecallReady = isAssetReady( + fallbackAssets.assets, + [...CODEX_HOOK_RECALL_ASSET_IDS] + ); + const skillReady = fallbackAssets.readySkillSurfaces.length > 0; + const workflowConsistent = + isAssetReady( + fallbackAssets.assets, + [...CODEX_HOOK_RECALL_ASSET_IDS, "recall-bridge-guide"] + ) && skillReady; + const status = summarizeCodexIntegrationStatus([ + mcpOperationalReady ? "ok" : mcpReady ? "warning" : "missing", + hookCaptureReady ? "ok" : "missing", + hookRecallReady ? "ok" : "missing", + skillReady ? "ok" : "missing", + workflowConsistent + ? "ok" + : hookRecallReady || skillReady + ? "warning" + : "missing" + ]) as McpDoctorStatus; + const notes = buildCodexStackNotes(); + if (mcpReady && !camCommandAvailable) { + notes.push( + "The current shell could not resolve `cam` on PATH, so MCP wiring may still fail at runtime." + ); + } + + return { + status, + recommendedRoute: resolveCodexIntegrationRoute({ + mcpOperationalReady, + hookRecallReady + }), + preset: formatRecommendedRetrievalPreset(), + assetVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + mcpReady, + mcpOperationalReady, + camCommandAvailable, + hookCaptureReady, + hookRecallReady, + skillReady, + workflowConsistent, + notes + }; +} + +export async function inspectMcpDoctor(options: { + cwd?: string; + host?: string; +} = {}): Promise { + const cwd = await normalizeComparablePath(options.cwd ?? process.cwd()); + const projectRoot = resolveMcpProjectRoot(cwd); + const agentsGuidancePath = path.join(projectRoot, "AGENTS.md"); + const hostSelection: McpDoctorHostSelection = normalizeMcpDoctorHostSelection(options.host); + const hosts = await Promise.all( + listMcpHosts(hostSelection).map((host) => inspectHost(host, projectRoot)) + ); + const fallbackAssets = await inspectFallbackAssets(projectRoot); + const camCommandAvailable = await isCommandAvailableInPath("cam"); + const codexHost = hosts.find((host) => host.host === "codex") ?? (await inspectHost("codex", projectRoot)); + const agentsGuidance = inspectCodexAgentsGuidance( + agentsGuidancePath, + (await fileExists(agentsGuidancePath)) ? await readTextFile(agentsGuidancePath) : null + ); + + return { + cwd, + projectRoot, + cwdWithinProjectRoot: isPathWithin(projectRoot, cwd), + serverName: MEMORY_RETRIEVAL_MCP_SERVER_NAME, + readOnlyRetrieval: true, + commandSurface: { + install: true, + serve: true, + printConfig: true, + doctor: true + }, + agentsGuidance, + fallbackAssets, + hosts, + codexStack: buildCodexStackReport(codexHost, fallbackAssets, camCommandAvailable) + }; +} + +export function formatMcpDoctorReport(report: McpDoctorReport): string { + const lines = [ + "Codex Auto Memory MCP Doctor", + `Working directory: ${report.cwd}`, + `Project root: ${report.projectRoot}`, + `Inside project root: ${report.cwdWithinProjectRoot ? "yes" : "no"}`, + `Server name: ${report.serverName}`, + "Retrieval plane: read-only", + "Command surface: cam mcp install, cam mcp serve, cam mcp print-config, cam mcp doctor", + "", + "Host checks:" + ]; + + for (const host of report.hosts) { + lines.push( + `- [${host.status}] ${host.host}`, + ` Target file hint: ${host.targetFileHint}`, + ` Project pinning: ${formatPinning(host.pinning)}`, + ` Summary: ${host.summary}` + ); + + if (host.configCheck) { + lines.push( + ` Config path: ${host.configCheck.path}`, + ` Exists: ${host.configCheck.exists ? "yes" : "no"}`, + ` Contains server name: ${host.configCheck.hasServerName ? "yes" : "no"}`, + ` Contains cam command: ${host.configCheck.hasCamCommand ? "yes" : "no"}`, + ` Contains mcp serve invocation: ${host.configCheck.hasServeInvocation ? "yes" : "no"}`, + ` Project pinned: ${host.configCheck.projectPinned ? "yes" : "no"}` + ); + } + + for (const note of host.notes) { + lines.push(` Note: ${note}`); + } + } + + lines.push("", "Fallback assets:"); + for (const asset of report.fallbackAssets.assets) { + const versionInfo = + asset.status === "ok" + ? `version ${asset.detectedVersion}` + : asset.status === "stale" + ? `expected ${asset.expectedVersion}, detected ${asset.detectedVersion ?? "none"}` + : `expected ${asset.expectedVersion}`; + const executableInfo = asset.executableExpected + ? ` | executable: ${asset.executableOk ? "yes" : "no"}` + : ""; + lines.push(`- ${asset.name}: ${asset.status} (${versionInfo})${executableInfo} (${asset.path})`); + } + lines.push( + `- Post-session sync helper installed: ${report.fallbackAssets.postSessionSyncInstalled ? "yes" : "no"}`, + `- Capture helpers installed: ${report.fallbackAssets.captureHelpersInstalled ? "yes" : "no"}`, + `- Hook helpers installed: ${report.fallbackAssets.hookHelpersInstalled ? "yes" : "no"}`, + `- Startup doctor installed: ${report.fallbackAssets.startupDoctorInstalled ? "yes" : "no"}`, + `- Codex skill installed: ${report.fallbackAssets.skillInstalled ? "yes" : "no"}`, + `- Shell fallback available: ${report.fallbackAssets.shellFallbackAvailable ? "yes" : "no"}`, + `- Guidance available: ${report.fallbackAssets.guidanceAvailable ? "yes" : "no"}`, + `- Retrieval fallback available: ${report.fallbackAssets.fallbackAvailable ? "yes" : "no"}`, + `- Runtime skill dir: ${report.fallbackAssets.runtimeSkillDir || "n/a"}`, + `- Runtime asset dir: ${report.fallbackAssets.runtimeAssetDir || "n/a"}`, + `- Runtime source: ${report.fallbackAssets.runtimeSource}`, + `- Preferred skill surface: ${report.fallbackAssets.preferredInstallSurface}`, + `- Recommended skill install command: ${report.fallbackAssets.recommendedSkillInstallCommand}`, + `- Runtime skill installed: ${report.fallbackAssets.runtimeSkillInstalled ? "yes" : "no"}`, + `- Runtime skill matches canonical: ${report.fallbackAssets.runtimeSkillMatchesCanonical ? "yes" : "no"}`, + `- Runtime skill ready: ${report.fallbackAssets.runtimeSkillReady ? "yes" : "no"}`, + `- Official user skill dir: ${report.fallbackAssets.officialUserSkillDir}`, + `- Official project skill dir: ${report.fallbackAssets.officialProjectSkillDir}`, + `- Official user skill installed: ${report.fallbackAssets.officialUserSkillInstalled ? "yes" : "no"}`, + `- Official project skill installed: ${report.fallbackAssets.officialProjectSkillInstalled ? "yes" : "no"}`, + `- Official user skill matches runtime: ${report.fallbackAssets.officialUserSkillMatchesRuntime ? "yes" : "no"}`, + `- Official project skill matches runtime: ${report.fallbackAssets.officialProjectSkillMatchesRuntime ? "yes" : "no"}`, + `- Official user skill ready: ${report.fallbackAssets.officialUserSkillReady ? "yes" : "no"}`, + `- Official project skill ready: ${report.fallbackAssets.officialProjectSkillReady ? "yes" : "no"}`, + `- Installed skill surfaces: ${report.fallbackAssets.installedSkillSurfaces.length > 0 ? report.fallbackAssets.installedSkillSurfaces.join(", ") : "none"}`, + `- Ready skill surfaces: ${report.fallbackAssets.readySkillSurfaces.length > 0 ? report.fallbackAssets.readySkillSurfaces.join(", ") : "none"}`, + `- Skill path drift: ${report.fallbackAssets.skillPathDrift ? "yes" : "no"}`, + "", + "AGENTS guidance:", + `- Path: ${report.agentsGuidance.path}`, + `- Exists: ${report.agentsGuidance.exists ? "yes" : "no"}`, + `- Status: ${report.agentsGuidance.status}`, + `- Expected version: ${report.agentsGuidance.expectedVersion}`, + `- Detected version: ${report.agentsGuidance.detectedVersion ?? "none"}`, + `- Missing signatures: ${ + report.agentsGuidance.missingSignatures.length > 0 + ? report.agentsGuidance.missingSignatures.join(", ") + : "none" + }`, + "", + "Codex stack readiness:", + `- Status: ${report.codexStack.status}`, + `- Recommended route: ${report.codexStack.recommendedRoute}`, + `- Recommended preset: ${report.codexStack.preset}`, + `- Asset version: ${report.codexStack.assetVersion}`, + `- MCP ready: ${report.codexStack.mcpReady ? "yes" : "no"}`, + `- MCP operational ready: ${report.codexStack.mcpOperationalReady ? "yes" : "no"}`, + `- cam command available: ${report.codexStack.camCommandAvailable ? "yes" : "no"}`, + `- Hook capture ready: ${report.codexStack.hookCaptureReady ? "yes" : "no"}`, + `- Hook recall ready: ${report.codexStack.hookRecallReady ? "yes" : "no"}`, + `- Skill ready: ${report.codexStack.skillReady ? "yes" : "no"}`, + `- Workflow consistent: ${report.codexStack.workflowConsistent ? "yes" : "no"}`, + "", + "Notes:", + "- cam mcp install writes the recommended project-scoped host config for codex, claude, or gemini only.", + "- cam mcp doctor only inspects the recommended project-scoped wiring and never writes host config files.", + "- Re-run cam hooks install or cam skills install if a fallback asset is reported as stale.", + "- cam memory is the inspect/audit surface for durable memory.", + "- cam session is the temporary continuity surface and is not the same as durable memory retrieval.", + ...report.codexStack.notes.map((note) => `- ${note}`) + ); + + return lines.join("\n"); +} diff --git a/src/lib/integration/mcp-hosts.ts b/src/lib/integration/mcp-hosts.ts new file mode 100644 index 0000000..cc1a2ed --- /dev/null +++ b/src/lib/integration/mcp-hosts.ts @@ -0,0 +1,282 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export type McpHost = "codex" | "claude" | "gemini" | "generic"; +export type McpDoctorHostSelection = McpHost | "all"; +export type McpHostPinningMode = "cwd-field" | "cwd-arg" | "manual"; + +export interface McpServerConfigShape { + command: string; + args: string[]; + cwd?: string; + env?: Record; + trust?: boolean; +} + +export interface McpHostDefinition { + host: McpHost; + targetFileHint: string; + snippetFormat: "toml" | "json"; + pinning: McpHostPinningMode; + projectConfigRelativePath?: string; + notes: string[]; +} + +export interface McpCanonicalConfigInspection { + hasServerName: boolean; + hasCamCommand: boolean; + hasServeInvocation: boolean; + projectPinned: boolean; +} + +export const MEMORY_RETRIEVAL_MCP_SERVER_NAME = "codex_auto_memory"; + +export const SUPPORTED_MCP_HOSTS: readonly McpHost[] = [ + "codex", + "claude", + "gemini", + "generic" +] as const; + +const HOST_DEFINITIONS: Record = { + codex: { + host: "codex", + targetFileHint: ".codex/config.toml", + snippetFormat: "toml", + pinning: "cwd-field", + projectConfigRelativePath: path.join(".codex", "config.toml"), + notes: [ + "Paste this into a project-scoped .codex/config.toml file. ~/.codex/config.toml also works if you want the same server across repositories.", + "This MCP surface only exposes search_memories, timeline_memories, and get_memory_details." + ] + }, + claude: { + host: "claude", + targetFileHint: ".mcp.json", + snippetFormat: "json", + pinning: "cwd-arg", + projectConfigRelativePath: ".mcp.json", + notes: [ + "Paste this into a project-scoped .mcp.json file. Claude Code asks for approval before using project-scoped MCP servers.", + "The explicit --cwd argument keeps retrieval pinned to this repository root even when the host starts the server elsewhere." + ] + }, + gemini: { + host: "gemini", + targetFileHint: ".gemini/settings.json", + snippetFormat: "json", + pinning: "cwd-field", + projectConfigRelativePath: path.join(".gemini", "settings.json"), + notes: [ + "Paste this into .gemini/settings.json or ~/.gemini/settings.json.", + "The snippet leaves trust set to false so tool confirmations stay host-controlled." + ] + }, + generic: { + host: "generic", + targetFileHint: "Your MCP client's stdio server config", + snippetFormat: "json", + pinning: "manual", + notes: [ + "Wrap this server definition under your client's server registry using the serverName shown below.", + "If your client supports a working-directory field, you can move the project root out of args and into that field instead." + ] + } +}; + +function toTomlString(value: string): string { + return JSON.stringify(value); +} + +function isRecordLike(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} + +function isStringRecord(value: unknown): value is Record { + return isRecordLike(value) && Object.values(value).every((item) => typeof item === "string"); +} + +function isComparableServerShape(value: unknown): value is McpServerConfigShape { + return ( + isRecordLike(value) && + typeof value.command === "string" && + isStringArray(value.args) && + (value.cwd === undefined || typeof value.cwd === "string") && + (value.env === undefined || isStringRecord(value.env)) && + (value.trust === undefined || typeof value.trust === "boolean") + ); +} + +async function normalizeComparablePath(input: string): Promise { + try { + return await fs.realpath(input); + } catch { + return path.resolve(input); + } +} + +async function pathsMatch(left: string, right: string): Promise { + return (await normalizeComparablePath(left)) === (await normalizeComparablePath(right)); +} + +function hasExpectedServeInvocation( + host: Extract, + args: unknown +): args is string[] { + if (!isStringArray(args)) { + return false; + } + + switch (host) { + case "codex": + case "gemini": + return args.length === 2 && args[0] === "mcp" && args[1] === "serve"; + case "claude": + return args.length === 4 && args[0] === "mcp" && args[1] === "serve" && args[2] === "--cwd"; + } +} + +function toTomlArray(values: string[]): string { + return `[${values.map((value) => toTomlString(value)).join(", ")}]`; +} + +export function normalizeMcpHost(host: string | undefined): McpHost { + switch (host) { + case "codex": + case "claude": + case "gemini": + case "generic": + return host; + default: + throw new Error( + `Unsupported MCP host "${host ?? ""}". Expected one of: codex, claude, gemini, generic.` + ); + } +} + +export function normalizeMcpDoctorHostSelection( + host: string | undefined +): McpDoctorHostSelection { + if (!host || host === "all") { + return "all"; + } + + return normalizeMcpHost(host); +} + +export function listMcpHosts(selection: McpDoctorHostSelection): McpHost[] { + return selection === "all" ? [...SUPPORTED_MCP_HOSTS] : [selection]; +} + +export function getMcpHostDefinition(host: McpHost): McpHostDefinition { + return HOST_DEFINITIONS[host]; +} + +export function resolveMcpHostProjectConfigPath( + host: McpHost, + projectRoot: string +): string | null { + const relativePath = getMcpHostDefinition(host).projectConfigRelativePath; + return relativePath ? path.join(projectRoot, relativePath) : null; +} + +export function buildCanonicalMcpServerConfig( + host: Exclude, + projectRoot: string +): McpServerConfigShape { + switch (host) { + case "codex": + return { + command: "cam", + args: ["mcp", "serve"], + cwd: projectRoot + }; + case "claude": + return { + command: "cam", + args: ["mcp", "serve", "--cwd", projectRoot], + env: {} + }; + case "gemini": + return { + command: "cam", + args: ["mcp", "serve"], + cwd: projectRoot, + trust: false + }; + } +} + +export async function inspectCanonicalMcpServerConfig( + host: Extract, + serverConfig: unknown, + projectRoot: string +): Promise { + const expected = buildCanonicalMcpServerConfig(host, projectRoot); + const hasServerName = isComparableServerShape(serverConfig); + const hasCamCommand = hasServerName && serverConfig.command === expected.command; + const hasServeInvocation = + hasServerName && hasExpectedServeInvocation(host, serverConfig.args); + const projectPinned = + hasServerName && + (host === "claude" + ? typeof serverConfig.args[3] === "string" && + (await pathsMatch(serverConfig.args[3], projectRoot)) + : typeof serverConfig.cwd === "string" && + (await pathsMatch(serverConfig.cwd, expected.cwd ?? projectRoot))); + + return { + hasServerName, + hasCamCommand, + hasServeInvocation, + projectPinned + }; +} + +export function buildMcpHostSnippet(host: McpHost, projectRoot: string): string { + switch (host) { + case "codex": + return (() => { + const config = buildCanonicalMcpServerConfig("codex", projectRoot); + return [ + `[mcp_servers.${MEMORY_RETRIEVAL_MCP_SERVER_NAME}]`, + `command = ${toTomlString(config.command)}`, + `args = ${toTomlArray(config.args)}`, + `cwd = ${toTomlString(config.cwd ?? projectRoot)}` + ].join("\n"); + })(); + case "claude": + return JSON.stringify( + { + mcpServers: { + [MEMORY_RETRIEVAL_MCP_SERVER_NAME]: buildCanonicalMcpServerConfig("claude", projectRoot) + } + }, + null, + 2 + ); + case "gemini": + return JSON.stringify( + { + mcpServers: { + [MEMORY_RETRIEVAL_MCP_SERVER_NAME]: buildCanonicalMcpServerConfig("gemini", projectRoot) + } + }, + null, + 2 + ); + case "generic": + return JSON.stringify( + { + command: "cam", + args: ["mcp", "serve", "--cwd", projectRoot] + }, + null, + 2 + ); + } +} diff --git a/src/lib/integration/mcp-install.ts b/src/lib/integration/mcp-install.ts new file mode 100644 index 0000000..21da5ce --- /dev/null +++ b/src/lib/integration/mcp-install.ts @@ -0,0 +1,233 @@ +import path from "node:path"; +import * as toml from "smol-toml"; +import { ensureDir, fileExists, readTextFile, writeTextFile } from "../util/fs.js"; +import { READ_ONLY_RETRIEVAL_NOTE } from "./codex-stack.js"; +import { + buildCanonicalMcpServerConfig, + MEMORY_RETRIEVAL_MCP_SERVER_NAME, + resolveMcpHostProjectConfigPath, + type McpHost +} from "./mcp-hosts.js"; + +export interface McpInstallResult { + host: McpHost; + serverName: string; + projectRoot: string; + targetPath: string; + action: "created" | "updated" | "unchanged"; + projectPinned: true; + readOnlyRetrieval: true; + notes: string[]; +} + +interface RecordLike { + [key: string]: unknown; +} + +interface McpServerRecord extends RecordLike { + command: string; + args: string[]; + cwd?: string; + env?: Record; + trust?: boolean; +} + +const PROJECT_SCOPED_PINNING_NOTE = + "This install is project-scoped and keeps codex_auto_memory pinned to the current project root."; +const SINGLE_ENTRY_NOTE = + "Only the codex_auto_memory server entry is created or replaced. Other host config remains untouched."; +const HOOKS_FALLBACK_NOTE = + "If you also want shell fallback helpers, run cam hooks install separately."; +const SKILLS_FALLBACK_NOTE = + "If you also want the Codex durable-memory skill, run cam skills install separately."; + +function isRecordLike(value: unknown): value is RecordLike { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} + +function isStringRecord(value: unknown): value is Record { + return isRecordLike(value) && Object.values(value).every((item) => typeof item === "string"); +} + +function isMcpServerRecord(value: unknown): value is McpServerRecord { + return ( + isRecordLike(value) && + typeof value.command === "string" && + isStringArray(value.args) && + (value.cwd === undefined || typeof value.cwd === "string") && + (value.env === undefined || isStringRecord(value.env)) && + (value.trust === undefined || typeof value.trust === "boolean") + ); +} + +function deepEqual(left: unknown, right: unknown): boolean { + if (left === right) { + return true; + } + + if (Array.isArray(left) && Array.isArray(right)) { + return ( + left.length === right.length && + left.every((value, index) => deepEqual(value, right[index])) + ); + } + + if (isRecordLike(left) && isRecordLike(right)) { + const leftKeys = Object.keys(left).sort(); + const rightKeys = Object.keys(right).sort(); + return ( + deepEqual(leftKeys, rightKeys) && + leftKeys.every((key) => deepEqual(left[key], right[key])) + ); + } + + return false; +} + +function ensureRecordProperty( + parent: RecordLike, + key: string, + context: string +): RecordLike { + const current = parent[key]; + if (current === undefined) { + const next: RecordLike = {}; + parent[key] = next; + return next; + } + + if (!isRecordLike(current)) { + throw new Error(`${context} must be an object so codex_auto_memory can be installed safely.`); + } + + return current; +} + +function buildInstallNotes(): string[] { + return [ + READ_ONLY_RETRIEVAL_NOTE, + PROJECT_SCOPED_PINNING_NOTE, + SINGLE_ENTRY_NOTE, + HOOKS_FALLBACK_NOTE, + SKILLS_FALLBACK_NOTE + ]; +} + +async function writeConfigIfChanged( + targetPath: string, + nextContents: string, + action: "created" | "updated" | "unchanged" +): Promise { + if (action === "unchanged") { + return; + } + + await ensureDir(path.dirname(targetPath)); + await writeTextFile(targetPath, nextContents.endsWith("\n") ? nextContents : `${nextContents}\n`); +} + +async function installCodexProjectConfig(projectRoot: string): Promise { + const targetPath = resolveMcpHostProjectConfigPath("codex", projectRoot); + if (!targetPath) { + throw new Error("Missing project-scoped config path for codex MCP install."); + } + + const targetExists = await fileExists(targetPath); + const rawConfig = targetExists ? await readTextFile(targetPath) : ""; + const parsed = targetExists ? (toml.parse(rawConfig) as unknown) : {}; + if (!isRecordLike(parsed)) { + throw new Error("The existing .codex/config.toml must parse to a TOML table."); + } + + const mcpServers = ensureRecordProperty(parsed, "mcp_servers", ".codex/config.toml[mcp_servers]"); + const canonicalServer = buildCanonicalMcpServerConfig("codex", projectRoot); + const existingServer = mcpServers[MEMORY_RETRIEVAL_MCP_SERVER_NAME]; + const hadServer = Object.hasOwn(mcpServers, MEMORY_RETRIEVAL_MCP_SERVER_NAME); + const action: McpInstallResult["action"] = !hadServer + ? "created" + : isMcpServerRecord(existingServer) && deepEqual(existingServer, canonicalServer) + ? "unchanged" + : "updated"; + + if (action !== "unchanged") { + mcpServers[MEMORY_RETRIEVAL_MCP_SERVER_NAME] = canonicalServer; + } + + await writeConfigIfChanged(targetPath, toml.stringify(parsed), action); + + return { + host: "codex", + serverName: MEMORY_RETRIEVAL_MCP_SERVER_NAME, + projectRoot, + targetPath, + action, + projectPinned: true, + readOnlyRetrieval: true, + notes: buildInstallNotes() + }; +} + +async function installJsonProjectConfig( + host: Extract, + projectRoot: string +): Promise { + const targetPath = resolveMcpHostProjectConfigPath(host, projectRoot); + if (!targetPath) { + throw new Error(`Missing project-scoped config path for ${host} MCP install.`); + } + + const targetExists = await fileExists(targetPath); + const rawConfig = targetExists ? await readTextFile(targetPath) : ""; + const parsed = targetExists ? (JSON.parse(rawConfig) as unknown) : {}; + if (!isRecordLike(parsed)) { + throw new Error(`The existing ${targetPath} must contain a top-level JSON object.`); + } + + const mcpServers = ensureRecordProperty(parsed, "mcpServers", `${targetPath}#mcpServers`); + const canonicalServer = buildCanonicalMcpServerConfig(host, projectRoot); + const existingServer = mcpServers[MEMORY_RETRIEVAL_MCP_SERVER_NAME]; + const hadServer = Object.hasOwn(mcpServers, MEMORY_RETRIEVAL_MCP_SERVER_NAME); + const action: McpInstallResult["action"] = !hadServer + ? "created" + : isMcpServerRecord(existingServer) && deepEqual(existingServer, canonicalServer) + ? "unchanged" + : "updated"; + + if (action !== "unchanged") { + mcpServers[MEMORY_RETRIEVAL_MCP_SERVER_NAME] = canonicalServer; + } + + await writeConfigIfChanged(targetPath, JSON.stringify(parsed, null, 2), action); + + return { + host, + serverName: MEMORY_RETRIEVAL_MCP_SERVER_NAME, + projectRoot, + targetPath, + action, + projectPinned: true, + readOnlyRetrieval: true, + notes: buildInstallNotes() + }; +} + +export async function installMcpProjectConfig( + host: McpHost, + projectRoot: string +): Promise { + switch (host) { + case "codex": + return installCodexProjectConfig(projectRoot); + case "claude": + case "gemini": + return installJsonProjectConfig(host, projectRoot); + case "generic": + throw new Error( + 'MCP install does not support host "generic". generic wiring remains manual-only; use cam mcp print-config instead.' + ); + } +} diff --git a/src/lib/integration/retrieval-contract.ts b/src/lib/integration/retrieval-contract.ts new file mode 100644 index 0000000..143935c --- /dev/null +++ b/src/lib/integration/retrieval-contract.ts @@ -0,0 +1,94 @@ +import { + DEFAULT_MEMORY_RETRIEVAL_LIMIT, + DEFAULT_MEMORY_RETRIEVAL_STATE +} from "../domain/memory-retrieval-contract.js"; +import type { MemoryRetrievalStateFilter } from "../types.js"; + +export const RECOMMENDED_RETRIEVAL_STATE: Extract = + DEFAULT_MEMORY_RETRIEVAL_STATE; +export const RECOMMENDED_RETRIEVAL_LIMIT = DEFAULT_MEMORY_RETRIEVAL_LIMIT; +export const RETRIEVAL_INTEGRATION_ASSET_VERSION = "retrieval-contract-v1"; + +export const RETRIEVAL_MCP_SEARCH_TOOL = "search_memories"; +export const RETRIEVAL_MCP_TIMELINE_TOOL = "timeline_memories"; +export const RETRIEVAL_MCP_DETAILS_TOOL = "get_memory_details"; + +export const RETRIEVAL_CLI_SEARCH_COMMAND = "cam recall search"; +export const RETRIEVAL_CLI_TIMELINE_COMMAND = "cam recall timeline"; +export const RETRIEVAL_CLI_DETAILS_COMMAND = "cam recall details"; + +export const MCP_FIRST_RECALL_WORKFLOW = + `Prefer retrieval MCP when it is already wired in: ${RETRIEVAL_MCP_SEARCH_TOOL} -> ${RETRIEVAL_MCP_TIMELINE_TOOL} -> ${RETRIEVAL_MCP_DETAILS_TOOL}.`; +export const CLI_FALLBACK_RECALL_WORKFLOW = + "Otherwise fall back to the local recall bridge bundle through memory-recall.sh search|timeline|details."; +export const MCP_SERVE_GUIDANCE = + "cam mcp serve exposes the same retrieval contract over stdio MCP when a host can consume it."; +export const MCP_DOCTOR_GUIDANCE = + "Run cam mcp doctor if you are unsure whether the recommended project-scoped retrieval MCP wiring is already in place."; +export const MEMORY_AUDIT_BOUNDARY = + "Use cam memory for inspect/audit surfaces and startup payload review."; +export const SESSION_CONTINUITY_BOUNDARY = + "Use cam session only for temporary continuity, not durable memory retrieval."; +export const ARCHIVE_BOUNDARY = + "Treat archived memory as historical context that does not participate in default startup recall."; +export const DURABLE_MEMORY_SYNC_GUIDANCE = + "After finishing work that should affect durable memory, run cam sync or review cam memory --recent instead of assuming temporary continuity already updated Markdown memory."; + +export function formatRecommendedRetrievalPreset(): string { + return `state=${RECOMMENDED_RETRIEVAL_STATE}, limit=${RECOMMENDED_RETRIEVAL_LIMIT}`; +} + +export function buildRecommendedCliSearchCommand(query = "\"\""): string { + return buildCliSearchCommand(query); +} + +export function buildCliSearchCommand( + query = "\"\"", + options: { + state?: MemoryRetrievalStateFilter; + limit?: number; + } = {} +): string { + const state = options.state ?? RECOMMENDED_RETRIEVAL_STATE; + const limit = options.limit ?? RECOMMENDED_RETRIEVAL_LIMIT; + return `${RETRIEVAL_CLI_SEARCH_COMMAND} ${query} --state ${state} --limit ${limit}`; +} + +export function buildRecommendedMcpSearchInstruction(): string { + return `When using ${RETRIEVAL_MCP_SEARCH_TOOL}, pass state: "${RECOMMENDED_RETRIEVAL_STATE}" and limit: ${RECOMMENDED_RETRIEVAL_LIMIT}.`; +} + +export function buildRecommendedSearchPresetGuidance(): string { + return `The recommended search preset is --state ${RECOMMENDED_RETRIEVAL_STATE} --limit ${RECOMMENDED_RETRIEVAL_LIMIT} unless you override those flags explicitly.`; +} + +export function buildRecommendedRetrievalSummaryLines(): string[] { + return [ + "Before repeating prior work or repo-specific decisions, recall durable memory first.", + "Use progressive disclosure: search -> timeline -> details.", + "Use this workflow when a host or skill needs read-only retrieval without reading full topic files up front.", + MCP_FIRST_RECALL_WORKFLOW, + buildRecommendedMcpSearchInstruction(), + MCP_SERVE_GUIDANCE, + CLI_FALLBACK_RECALL_WORKFLOW, + buildRecommendedSearchPresetGuidance(), + MCP_DOCTOR_GUIDANCE, + DURABLE_MEMORY_SYNC_GUIDANCE, + MEMORY_AUDIT_BOUNDARY, + SESSION_CONTINUITY_BOUNDARY, + ARCHIVE_BOUNDARY + ]; +} + +export function buildShellAssetVersionComment(): string { + return `# cam:asset-version ${RETRIEVAL_INTEGRATION_ASSET_VERSION}`; +} + +export function buildMarkdownAssetVersionComment(): string { + return ``; +} + +export function detectIntegrationAssetVersion(contents: string): string | null { + const match = contents.match(/cam:asset-version\s+([A-Za-z0-9._-]+)/u); + return match?.[1] ?? null; +} diff --git a/src/lib/integration/skills-paths.ts b/src/lib/integration/skills-paths.ts new file mode 100644 index 0000000..7f0f987 --- /dev/null +++ b/src/lib/integration/skills-paths.ts @@ -0,0 +1,139 @@ +import os from "node:os"; +import path from "node:path"; + +export const CODEX_MEMORY_SKILL_NAME = "codex-auto-memory-recall"; +export const CODEX_SKILL_INSTALL_SURFACES = [ + "runtime", + "official-user", + "official-project" +] as const; + +export type CodexSkillRuntimeSource = "CODEX_HOME" | "HOME_DOT_CODEX"; +export type CodexSkillInstallSurface = (typeof CODEX_SKILL_INSTALL_SURFACES)[number]; + +export interface CodexSkillSurfacePath { + surface: CodexSkillInstallSurface; + dir: string; +} + +export interface CodexSkillPathResolution { + skillName: string; + runtimeSource: CodexSkillRuntimeSource; + runtimeBaseDir: string; + runtimeSkillDir: string; + runtimeAssetDir: string; + officialUserSkillDir: string; + officialProjectSkillDir: string; + preferredInstallSurface: CodexSkillInstallSurface; + availableSurfaces: CodexSkillSurfacePath[]; +} + +function resolveCodexHomeOverride(): string | null { + const rawCodexHome = process.env.CODEX_HOME; + if (!rawCodexHome) { + return null; + } + + const trimmedCodexHome = rawCodexHome.trim(); + if (trimmedCodexHome.length === 0) { + return null; + } + + if (!path.isAbsolute(trimmedCodexHome)) { + throw new Error("CODEX_HOME must be an absolute path when set."); + } + + return path.resolve(trimmedCodexHome); +} + +export function normalizeCodexSkillInstallSurface( + surface: string | undefined +): CodexSkillInstallSurface { + if (!surface) { + return "runtime"; + } + + if ( + (CODEX_SKILL_INSTALL_SURFACES as readonly string[]).includes(surface) + ) { + return surface as CodexSkillInstallSurface; + } + + throw new Error( + `Unsupported skill install surface "${surface}". Use runtime, official-user, or official-project.` + ); +} + +export function formatCodexSkillInstallSurface(surface: CodexSkillInstallSurface): string { + switch (surface) { + case "runtime": + return "runtime"; + case "official-user": + return "official-user"; + case "official-project": + return "official-project"; + } +} + +export function buildCodexSkillInstallCommand( + surface: CodexSkillInstallSurface = "runtime" +): string { + return `cam skills install --surface ${surface}`; +} + +export function resolveCodexSkillInstallDir( + resolution: CodexSkillPathResolution, + surface: CodexSkillInstallSurface = resolution.preferredInstallSurface +): string { + switch (surface) { + case "runtime": + return resolution.runtimeAssetDir; + case "official-user": + return resolution.officialUserSkillDir; + case "official-project": + return resolution.officialProjectSkillDir; + } +} + +export function resolveCodexSkillPaths( + projectRoot: string, + homeDir = os.homedir() +): CodexSkillPathResolution { + const resolvedProjectRoot = path.resolve(projectRoot); + const codexHomeOverride = resolveCodexHomeOverride(); + const runtimeSource: CodexSkillRuntimeSource = codexHomeOverride ? "CODEX_HOME" : "HOME_DOT_CODEX"; + const runtimeBaseDir = codexHomeOverride ?? path.join(homeDir, ".codex"); + const runtimeAssetDir = path.join(runtimeBaseDir, "skills", CODEX_MEMORY_SKILL_NAME); + const officialUserSkillDir = path.join(homeDir, ".agents", "skills", CODEX_MEMORY_SKILL_NAME); + const officialProjectSkillDir = path.join( + resolvedProjectRoot, + ".agents", + "skills", + CODEX_MEMORY_SKILL_NAME + ); + + return { + skillName: CODEX_MEMORY_SKILL_NAME, + runtimeSource, + runtimeBaseDir, + runtimeSkillDir: runtimeAssetDir, + runtimeAssetDir, + officialUserSkillDir, + officialProjectSkillDir, + preferredInstallSurface: "runtime", + availableSurfaces: [ + { + surface: "runtime", + dir: runtimeAssetDir + }, + { + surface: "official-user", + dir: officialUserSkillDir + }, + { + surface: "official-project", + dir: officialProjectSkillDir + } + ] + }; +} diff --git a/src/lib/mcp/retrieval-server.ts b/src/lib/mcp/retrieval-server.ts new file mode 100644 index 0000000..d16a97e --- /dev/null +++ b/src/lib/mcp/retrieval-server.ts @@ -0,0 +1,211 @@ +import { createRequire } from "node:module"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { + buildMemoryTimelineResponse, + toMemoryDetailsResultShape, + toMemorySearchRequest, +} from "../domain/memory-retrieval-contract.js"; +import { assertValidMemoryRef } from "../domain/memory-lifecycle.js"; +import { + buildRecommendedMcpSearchInstruction, + RETRIEVAL_MCP_DETAILS_TOOL, + RETRIEVAL_MCP_SEARCH_TOOL, + RETRIEVAL_MCP_TIMELINE_TOOL +} from "../integration/retrieval-contract.js"; +import { MemoryRetrievalService } from "../domain/memory-retrieval.js"; +import { buildReadOnlyMemoryRetrievalService } from "../runtime/runtime-context.js"; + +const require = createRequire(import.meta.url); +const { version } = require("../../../package.json") as { version: string }; + +const retrievalScopeSchema = z.enum(["global", "project", "project-local", "all"]); +const retrievalStateSchema = z.enum(["active", "archived", "all", "auto"]); +const resolvedRetrievalStateSchema = z.enum(["active", "archived", "all"]); +const memoryRecordStateSchema = z.enum(["active", "archived"]); +const memoryHistoryRecordStateSchema = z.enum(["active", "archived", "deleted"]); +const memoryLifecycleActionSchema = z.enum(["add", "update", "delete", "archive"]); + +const memorySearchResultSchema = z.object({ + ref: z.string(), + scope: z.enum(["global", "project", "project-local"]), + state: memoryRecordStateSchema, + topic: z.string(), + id: z.string(), + summary: z.string(), + updatedAt: z.string(), + matchedFields: z.array(z.string()), + approxReadCost: z.number().int().nonnegative() +}); + +const memorySearchResponseSchema = z.object({ + query: z.string(), + scope: retrievalScopeSchema, + state: retrievalStateSchema, + resolvedState: resolvedRetrievalStateSchema, + fallbackUsed: z.boolean(), + results: z.array(memorySearchResultSchema) +}); + +const memoryTimelineEventSchema = z.object({ + at: z.string(), + action: memoryLifecycleActionSchema, + scope: z.enum(["global", "project", "project-local"]), + state: memoryHistoryRecordStateSchema, + topic: z.string(), + id: z.string(), + ref: z.string().optional(), + summary: z.string(), + reason: z.string().optional(), + source: z.string().optional(), + sessionId: z.string().optional(), + rolloutPath: z.string().optional() +}); + +const memoryTimelineResponseSchema = z.object({ + ref: z.string(), + events: z.array(memoryTimelineEventSchema) +}); + +const memoryDetailsResponseSchema = z.object({ + ref: z.string(), + scope: z.enum(["global", "project", "project-local"]), + state: memoryRecordStateSchema, + topic: z.string(), + id: z.string(), + path: z.string(), + approxReadCost: z.number().int().nonnegative(), + entry: z.object({ + id: z.string(), + scope: z.enum(["global", "project", "project-local"]), + topic: z.string(), + summary: z.string(), + details: z.array(z.string()), + updatedAt: z.string(), + sources: z.array(z.string()), + reason: z.string().optional() + }) +}); + +async function withRetrievalService( + cwd: string, + handler: (retrieval: MemoryRetrievalService) => Promise +): Promise { + const retrieval = await buildReadOnlyMemoryRetrievalService(cwd); + return handler(retrieval); +} + +function createJsonResult>(payload: T): { + content: Array<{ type: "text"; text: string }>; + structuredContent: T; +} { + return { + content: [ + { + type: "text", + text: JSON.stringify(payload, null, 2) + } + ], + structuredContent: payload + }; +} + +export function createRetrievalMcpServer(cwd = process.cwd()): McpServer { + const server = new McpServer({ + name: "codex-auto-memory-retrieval", + version + }); + + server.registerTool( + RETRIEVAL_MCP_SEARCH_TOOL, + { + title: "Search durable memories", + description: + `Search compact durable-memory candidates without loading full Markdown details. ${buildRecommendedMcpSearchInstruction()}`, + inputSchema: z.object({ + query: z.string().min(1), + scope: retrievalScopeSchema.optional(), + state: retrievalStateSchema.optional(), + limit: z.number().int().positive().max(100).optional() + }), + outputSchema: memorySearchResponseSchema + }, + async ({ query, scope, state, limit }) => { + const request = toMemorySearchRequest({ + query, + scope, + state, + limit + }); + + const payload = await withRetrievalService(cwd, async (retrieval) => { + const response = await retrieval.searchMemories(request.query, { + scope: request.scope, + state: request.state, + limit: request.limit + }); + + return memorySearchResponseSchema.parse(response); + }); + + return createJsonResult(payload); + } + ); + + server.registerTool( + RETRIEVAL_MCP_TIMELINE_TOOL, + { + title: "Inspect memory timeline", + description: + "Read lifecycle history for a specific durable-memory ref, including archive or delete transitions.", + inputSchema: z.object({ + ref: z.string().min(1) + }), + outputSchema: memoryTimelineResponseSchema + }, + async ({ ref }) => { + assertValidMemoryRef(ref); + const payload = await withRetrievalService(cwd, async (retrieval) => { + const events = await retrieval.timelineMemories(ref); + return memoryTimelineResponseSchema.parse(buildMemoryTimelineResponse(ref, events)); + }); + + return createJsonResult(payload); + } + ); + + server.registerTool( + RETRIEVAL_MCP_DETAILS_TOOL, + { + title: "Get memory details", + description: + "Fetch the full Markdown-backed durable-memory details for a specific ref.", + inputSchema: z.object({ + ref: z.string().min(1) + }), + outputSchema: memoryDetailsResponseSchema + }, + async ({ ref }) => { + assertValidMemoryRef(ref); + const payload = await withRetrievalService(cwd, async (retrieval) => { + const details = await retrieval.getMemoryDetails(ref); + if (!details) { + throw new Error(`No memory details were found for ref "${ref}".`); + } + + return memoryDetailsResponseSchema.parse(toMemoryDetailsResultShape(details)); + }); + + return createJsonResult(payload); + } + ); + + return server; +} + +export async function startRetrievalMcpServer(cwd = process.cwd()): Promise { + const server = createRetrievalMcpServer(cwd); + const transport = new StdioServerTransport(); + await server.connect(transport); +} diff --git a/src/lib/runtime/runtime-context.ts b/src/lib/runtime/runtime-context.ts index e78b7a2..6809b42 100644 --- a/src/lib/runtime/runtime-context.ts +++ b/src/lib/runtime/runtime-context.ts @@ -1,5 +1,6 @@ import { loadConfig } from "../config/load-config.js"; import { patchConfigFile } from "../config/write-config.js"; +import { MemoryRetrievalService } from "../domain/memory-retrieval.js"; import { detectProjectContext } from "../domain/project-context.js"; import { SessionContinuityStore } from "../domain/session-continuity-store.js"; import { SyncService } from "../domain/sync-service.js"; @@ -22,15 +23,22 @@ export interface ReloadedRuntimeContext { configUpdatePath: string; } +export interface RuntimeContextOptions { + ensureMemoryLayout?: boolean; +} + export async function buildRuntimeContext( cwd = process.cwd(), - overrides: Partial = {} + overrides: Partial = {}, + options: RuntimeContextOptions = {} ): Promise { const project = detectProjectContext(cwd); const loadedConfig = await loadConfig(project, overrides); const syncService = new SyncService(project, loadedConfig.config); const sessionContinuityStore = new SessionContinuityStore(project, loadedConfig.config); - await syncService.memoryStore.ensureLayout(); + if (options.ensureMemoryLayout !== false) { + await syncService.memoryStore.ensureLayout(); + } return { project, @@ -40,6 +48,13 @@ export async function buildRuntimeContext( }; } +export async function buildReadOnlyMemoryRetrievalService( + cwd = process.cwd() +): Promise { + const runtime = await buildRuntimeContext(cwd, {}, { ensureMemoryLayout: false }); + return new MemoryRetrievalService(runtime.syncService.memoryStore); +} + export async function patchConfigAndReloadRuntime( cwd: string, configScope: ConfigScope, diff --git a/test/dist-cli-smoke.test.ts b/test/dist-cli-smoke.test.ts index 03f5794..e30da3a 100644 --- a/test/dist-cli-smoke.test.ts +++ b/test/dist-cli-smoke.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import * as toml from "smol-toml"; import { afterEach, describe, expect, it } from "vitest"; import { detectProjectContext } from "../src/lib/domain/project-context.js"; import { MemoryStore } from "../src/lib/domain/memory-store.js"; @@ -11,9 +12,11 @@ import { makeAppConfig, writeCamConfig } from "./helpers/cam-test-fixtures.js"; +import { connectCliMcpClient } from "./helpers/mcp-client.js"; import { runCli } from "./helpers/cli-runner.js"; const tempDirs: string[] = []; +const originalCodexHome = process.env.CODEX_HOME; async function tempDir(prefix: string): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); @@ -21,6 +24,17 @@ async function tempDir(prefix: string): Promise { return dir; } +async function writeCamShim(binDir: string): Promise { + if (process.platform === "win32") { + await fs.writeFile(path.join(binDir, "cam.cmd"), "@echo off\r\nexit /b 0\r\n", "utf8"); + return; + } + + const shimPath = path.join(binDir, "cam"); + await fs.writeFile(shimPath, "#!/bin/sh\nexit 0\n", "utf8"); + await fs.chmod(shimPath, 0o755); +} + async function waitForFile(pathname: string, timeoutMs = 2_000): Promise { const deadline = Date.now() + timeoutMs; while (true) { @@ -42,6 +56,11 @@ async function waitForFile(pathname: string, timeoutMs = 2_000): Promise } afterEach(async () => { + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); @@ -164,6 +183,559 @@ describe("dist cli smoke", () => { expect(sessionPayload.projectLocation.exists).toBe(true); }, 30_000); + it("uses the recommended recall search preset from the compiled cli entrypoint without creating memory layout on first lookup", async () => { + const homeDir = await tempDir("cam-dist-recall-home-"); + const projectDir = await tempDir("cam-dist-recall-project-"); + const memoryRootParent = await tempDir("cam-dist-recall-memory-parent-"); + const memoryRoot = path.join(memoryRootParent, "memory-root"); + + await writeCamConfig(projectDir, makeAppConfig(), { + autoMemoryDirectory: memoryRoot + }); + + const result = runCli( + projectDir, + ["recall", "search", "pnpm", "--json"], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + state: "auto", + resolvedState: "archived", + fallbackUsed: true, + results: [] + }); + await expect(fs.access(memoryRoot)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("serves retrieval MCP tools from the compiled cli entrypoint", async () => { + const homeDir = await tempDir("cam-dist-mcp-home-"); + const projectDir = await tempDir("cam-dist-mcp-project-"); + const memoryRoot = await tempDir("cam-dist-mcp-memory-root-"); + const cliEnv = { HOME: homeDir }; + + const config = makeAppConfig(); + await writeCamConfig(projectDir, config, { + autoMemoryDirectory: memoryRoot + }); + + const project = detectProjectContext(projectDir); + const memoryStore = new MemoryStore(project, { + ...config, + autoMemoryDirectory: memoryRoot + }); + await memoryStore.ensureLayout(); + await memoryStore.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const client = await connectCliMcpClient(projectDir, { + entrypoint: "dist", + env: cliEnv + }); + + try { + const { tools } = await client.listTools(); + expect(tools.map((tool) => tool.name)).toEqual( + expect.arrayContaining(["search_memories", "timeline_memories", "get_memory_details"]) + ); + + const result = await client.callTool({ + name: "search_memories", + arguments: { + query: "pnpm", + limit: 3 + } + }); + expect(result.structuredContent).toMatchObject({ + query: "pnpm", + results: [ + expect.objectContaining({ + ref: "project:active:workflow:prefer-pnpm", + summary: "Prefer pnpm in this repository." + }) + ] + }); + } finally { + await client.close(); + } + }, 30_000); + + it("prints host MCP config snippets from the compiled cli entrypoint", async () => { + const homeDir = await tempDir("cam-dist-mcp-print-home-"); + const projectDir = await tempDir("cam-dist-mcp-print-project-"); + + const result = runCli(projectDir, ["mcp", "print-config", "--host", "codex", "--json"], { + entrypoint: "dist", + env: { HOME: homeDir } + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "codex", + serverName: "codex_auto_memory", + targetFileHint: ".codex/config.toml", + agentsGuidance: { + targetFileHint: "AGENTS.md", + snippetFormat: "markdown" + } + }); + expect(JSON.parse(result.stdout).agentsGuidance.snippet).toContain("search_memories"); + expect(JSON.parse(result.stdout).agentsGuidance.snippet).toContain("cam recall search"); + + const claudeResult = runCli( + projectDir, + ["mcp", "print-config", "--host", "claude", "--json"], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + + expect(claudeResult.exitCode, claudeResult.stderr).toBe(0); + expect(JSON.parse(claudeResult.stdout)).toMatchObject({ + host: "claude", + serverName: "codex_auto_memory", + targetFileHint: ".mcp.json" + }); + }); + + it("applies the Codex AGENTS guidance from the compiled cli entrypoint", async () => { + const homeDir = await tempDir("cam-dist-mcp-apply-guidance-home-"); + const projectDir = await tempDir("cam-dist-mcp-apply-guidance-project-"); + const realProjectDir = await fs.realpath(projectDir); + + const created = runCli( + projectDir, + ["mcp", "apply-guidance", "--host", "codex", "--json"], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + expect(created.exitCode, created.stderr).toBe(0); + expect(JSON.parse(created.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + action: "created", + targetPath: path.join(realProjectDir, "AGENTS.md"), + managedBlockVersion: "codex-agents-guidance-v1" + }); + + const unchanged = runCli( + projectDir, + ["mcp", "apply-guidance", "--host", "codex", "--json"], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + expect(unchanged.exitCode, unchanged.stderr).toBe(0); + expect(JSON.parse(unchanged.stdout)).toMatchObject({ + host: "codex", + action: "unchanged" + }); + + const agentsContents = await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8"); + expect(agentsContents).toContain("cam:codex-agents-guidance:start"); + expect(agentsContents).toContain("cam:codex-agents-guidance:end"); + }); + + it("does not treat fenced AGENTS examples as managed guidance from the compiled cli entrypoint", async () => { + const homeDir = await tempDir("cam-dist-mcp-apply-guidance-fenced-home-"); + const projectDir = await tempDir("cam-dist-mcp-apply-guidance-fenced-project-"); + const realProjectDir = await fs.realpath(projectDir); + + const printConfigResult = runCli( + projectDir, + ["mcp", "print-config", "--host", "codex", "--json"], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + expect(printConfigResult.exitCode, printConfigResult.stderr).toBe(0); + const printConfigPayload = JSON.parse(printConfigResult.stdout) as { + agentsGuidance: { snippet: string }; + }; + await fs.writeFile( + path.join(realProjectDir, "AGENTS.md"), + ["# Example", "", "```md", printConfigPayload.agentsGuidance.snippet, "```"].join("\n"), + "utf8" + ); + + const doctorResult = runCli( + projectDir, + ["mcp", "doctor", "--host", "codex", "--json"], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + expect(doctorResult.exitCode, doctorResult.stderr).toBe(0); + expect(JSON.parse(doctorResult.stdout)).toMatchObject({ + agentsGuidance: { + exists: true, + status: "warning" + } + }); + + const applyResult = runCli( + projectDir, + ["mcp", "apply-guidance", "--host", "codex", "--json"], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + expect(applyResult.exitCode, applyResult.stderr).toBe(0); + expect(JSON.parse(applyResult.stdout)).toMatchObject({ + host: "codex", + action: "updated" + }); + const agentsContents = await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8"); + expect(agentsContents).toContain("```md"); + expect(agentsContents).toContain("cam:agents-guidance-version codex-agents-guidance-v1"); + }); + + it("uses action-aware MCP install text output from the compiled cli entrypoint", async () => { + const homeDir = await tempDir("cam-dist-mcp-install-text-home-"); + const projectDir = await tempDir("cam-dist-mcp-install-text-project-"); + + const created = runCli(projectDir, ["mcp", "install", "--host", "codex"], { + entrypoint: "dist", + env: { HOME: homeDir } + }); + expect(created.exitCode, created.stderr).toBe(0); + expect(created.stdout).toContain("Installed project-scoped MCP wiring for codex."); + + const unchanged = runCli(projectDir, ["mcp", "install", "--host", "codex"], { + entrypoint: "dist", + env: { HOME: homeDir } + }); + expect(unchanged.exitCode, unchanged.stderr).toBe(0); + expect(unchanged.stdout).toContain( + "Project-scoped MCP wiring for codex is already up to date." + ); + }); + + it("inspects MCP wiring from the compiled cli entrypoint", async () => { + const homeDir = await tempDir("cam-dist-mcp-doctor-home-"); + const projectDir = await tempDir("cam-dist-mcp-doctor-project-"); + + const result = runCli(projectDir, ["mcp", "doctor", "--host", "generic", "--json"], { + entrypoint: "dist", + env: { HOME: homeDir } + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + serverName: "codex_auto_memory", + readOnlyRetrieval: true, + agentsGuidance: { + exists: false, + status: "missing" + }, + commandSurface: { + install: true, + serve: true, + printConfig: true, + doctor: true + }, + hosts: [ + { + host: "generic", + status: "manual", + targetFileHint: "Your MCP client's stdio server config" + } + ] + }); + }); + + it("installs project-scoped MCP wiring from the compiled cli entrypoint", async () => { + const homeDir = await tempDir("cam-dist-mcp-install-home-"); + const projectDir = await tempDir("cam-dist-mcp-install-project-"); + const realProjectDir = await fs.realpath(projectDir); + + const result = runCli(projectDir, ["mcp", "install", "--host", "codex", "--json"], { + entrypoint: "dist", + env: { HOME: homeDir } + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "codex", + action: "created", + serverName: "codex_auto_memory", + projectRoot: realProjectDir, + targetPath: path.join(realProjectDir, ".codex", "config.toml"), + projectPinned: true, + readOnlyRetrieval: true + }); + + const writtenConfig = toml.parse( + await fs.readFile(path.join(realProjectDir, ".codex", "config.toml"), "utf8") + ) as Record; + expect(writtenConfig).toMatchObject({ + mcp_servers: { + codex_auto_memory: { + command: "cam", + args: ["mcp", "serve"], + cwd: realProjectDir + } + } + }); + + const claudeResult = runCli( + projectDir, + ["mcp", "install", "--host", "claude", "--json"], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + + expect(claudeResult.exitCode, claudeResult.stderr).toBe(0); + expect(JSON.parse(claudeResult.stdout)).toMatchObject({ + host: "claude", + action: "created", + serverName: "codex_auto_memory", + projectRoot: realProjectDir, + targetPath: path.join(realProjectDir, ".mcp.json"), + projectPinned: true, + readOnlyRetrieval: true + }); + }); + + it("installs hooks and skills from the compiled cli entrypoint", async () => { + const homeDir = await tempDir("cam-dist-hook-skill-home-"); + const projectDir = await tempDir("cam-dist-hook-skill-project-"); + const env = { HOME: homeDir }; + + const hooksResult = runCli(projectDir, ["hooks", "install"], { + entrypoint: "dist", + env + }); + expect(hooksResult.exitCode, hooksResult.stderr).toBe(0); + + const hooksDir = path.join(homeDir, ".codex-auto-memory", "hooks"); + const recallScript = await fs.readFile(path.join(hooksDir, "memory-recall.sh"), "utf8"); + const recallGuide = await fs.readFile(path.join(hooksDir, "recall-bridge.md"), "utf8"); + expect(recallScript).toContain("cam:asset-version"); + expect(recallGuide).toContain("cam:asset-version"); + + const skillsResult = runCli(projectDir, ["skills", "install"], { + entrypoint: "dist", + env + }); + expect(skillsResult.exitCode, skillsResult.stderr).toBe(0); + + const skillFile = await fs.readFile( + path.join(homeDir, ".codex", "skills", "codex-auto-memory-recall", "SKILL.md"), + "utf8" + ); + expect(skillFile).toContain("cam:asset-version"); + expect(skillFile).toContain("search_memories"); + }); + + it("installs an explicit official user skill surface from the compiled cli entrypoint", async () => { + const homeDir = await tempDir("cam-dist-skill-official-user-home-"); + const projectDir = await tempDir("cam-dist-skill-official-user-project-"); + + const env = { HOME: homeDir }; + const skillsResult = runCli( + projectDir, + ["skills", "install", "--surface", "official-user"], + { + entrypoint: "dist", + env + } + ); + expect(skillsResult.exitCode, skillsResult.stderr).toBe(0); + expect(skillsResult.stdout).toContain("Skill surface: official-user"); + + const skillFile = await fs.readFile( + path.join(homeDir, ".agents", "skills", "codex-auto-memory-recall", "SKILL.md"), + "utf8" + ); + expect(skillFile).toContain("cam:asset-version"); + expect(skillFile).toContain("search_memories"); + }); + + it("installs skills under CODEX_HOME from the compiled cli entrypoint", async () => { + const homeDir = await tempDir("cam-dist-skill-codex-home-home-"); + const codexHome = await tempDir("cam-dist-skill-codex-home-codex-home-"); + const projectDir = await tempDir("cam-dist-skill-codex-home-project-"); + + const env = { HOME: homeDir, CODEX_HOME: codexHome }; + const skillsResult = runCli(projectDir, ["skills", "install"], { + entrypoint: "dist", + env + }); + expect(skillsResult.exitCode, skillsResult.stderr).toBe(0); + + const skillFile = await fs.readFile( + path.join(codexHome, "skills", "codex-auto-memory-recall", "SKILL.md"), + "utf8" + ); + expect(skillFile).toContain("cam:asset-version"); + + const doctorResult = runCli(projectDir, ["mcp", "doctor", "--json"], { + entrypoint: "dist", + env + }); + expect(doctorResult.exitCode, doctorResult.stderr).toBe(0); + expect(JSON.parse(doctorResult.stdout)).toMatchObject({ + fallbackAssets: { + runtimeSkillDir: path.join(codexHome, "skills", "codex-auto-memory-recall"), + skillPathDrift: true + } + }); + }); + + it("installs the Codex integration stack from the compiled cli entrypoint", async () => { + const homeDir = await tempDir("cam-dist-integrations-home-"); + const projectDir = await tempDir("cam-dist-integrations-project-"); + const realProjectDir = await fs.realpath(projectDir); + + const result = runCli( + projectDir, + ["integrations", "install", "--host", "codex", "--json"], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + stackAction: "created", + skillsSurface: "runtime", + readOnlyRetrieval: true, + subactions: { + mcp: { + action: "created", + targetPath: path.join(realProjectDir, ".codex", "config.toml") + }, + hooks: { + action: "created", + targetDir: path.join(homeDir, ".codex-auto-memory", "hooks") + }, + skills: { + action: "created", + surface: "runtime", + targetDir: path.join(homeDir, ".codex", "skills", "codex-auto-memory-recall") + } + } + }); + }); + + it("applies the full Codex integration stack from the compiled cli entrypoint", async () => { + const homeDir = await tempDir("cam-dist-integrations-apply-home-"); + const projectDir = await tempDir("cam-dist-integrations-apply-project-"); + const realProjectDir = await fs.realpath(projectDir); + + const result = runCli( + projectDir, + ["integrations", "apply", "--host", "codex", "--json"], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + stackAction: "created", + subactions: { + mcp: { action: "created" }, + agents: { action: "created" }, + hooks: { action: "created" }, + skills: { action: "created" } + } + }); + }); + + it("inspects the Codex integration stack from the compiled cli entrypoint", async () => { + const homeDir = await tempDir("cam-dist-integrations-doctor-home-"); + const projectDir = await tempDir("cam-dist-integrations-doctor-project-"); + const binDir = await tempDir("cam-dist-integrations-doctor-bin-"); + const realProjectDir = await fs.realpath(projectDir); + await writeCamShim(binDir); + + const env = { + HOME: homeDir, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}` + }; + + const installResult = runCli( + projectDir, + ["integrations", "install", "--host", "codex", "--json"], + { + entrypoint: "dist", + env + } + ); + expect(installResult.exitCode, installResult.stderr).toBe(0); + + const printConfigResult = runCli( + projectDir, + ["mcp", "print-config", "--host", "codex", "--json"], + { + entrypoint: "dist", + env + } + ); + expect(printConfigResult.exitCode, printConfigResult.stderr).toBe(0); + const printConfigPayload = JSON.parse(printConfigResult.stdout) as { + agentsGuidance: { snippet: string }; + }; + await fs.writeFile( + path.join(realProjectDir, "AGENTS.md"), + `${printConfigPayload.agentsGuidance.snippet}\n`, + "utf8" + ); + + const doctorResult = runCli( + projectDir, + ["integrations", "doctor", "--host", "codex", "--json"], + { + entrypoint: "dist", + env + } + ); + expect(doctorResult.exitCode, doctorResult.stderr).toBe(0); + expect(JSON.parse(doctorResult.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + readOnlyRetrieval: true, + status: "ok", + recommendedRoute: "mcp", + recommendedPreset: "state=auto, limit=8", + subchecks: { + mcp: { status: "ok" }, + agents: { status: "ok" }, + hookCapture: { status: "ok" }, + hookRecall: { status: "ok" }, + skill: { status: "ok" }, + workflowConsistency: { status: "ok" } + } + }); + }); + it("routes exec through the compiled wrapper entrypoint", async () => { const repoDir = await tempDir("cam-dist-wrapper-repo-"); const homeDir = await tempDir("cam-dist-wrapper-home-"); diff --git a/test/helpers/cli-runner.ts b/test/helpers/cli-runner.ts index a53d55c..8216c6b 100644 --- a/test/helpers/cli-runner.ts +++ b/test/helpers/cli-runner.ts @@ -10,6 +10,22 @@ const tsxBinaryPath = path.resolve( process.platform === "win32" ? "node_modules/.bin/tsx.cmd" : "node_modules/.bin/tsx" ); +export function resolveCliInvocation( + entrypoint: CliEntrypoint = "source" +): { command: string; args: string[] } { + if (entrypoint === "dist") { + return { + command: "node", + args: [distCliPath] + }; + } + + return { + command: tsxBinaryPath, + args: [sourceCliPath] + }; +} + export function runCli( repoDir: string, args: string[], @@ -18,11 +34,7 @@ export function runCli( env?: NodeJS.ProcessEnv; } = {} ): ProcessOutput { - const entrypoint = options.entrypoint ?? "source"; + const invocation = resolveCliInvocation(options.entrypoint ?? "source"); const env = options.env ? { ...process.env, ...options.env } : process.env; - if (entrypoint === "dist") { - return runCommandCapture("node", [distCliPath, ...args], repoDir, env); - } - - return runCommandCapture(tsxBinaryPath, [sourceCliPath, ...args], repoDir, env); + return runCommandCapture(invocation.command, [...invocation.args, ...args], repoDir, env); } diff --git a/test/helpers/mcp-client.ts b/test/helpers/mcp-client.ts new file mode 100644 index 0000000..5258f66 --- /dev/null +++ b/test/helpers/mcp-client.ts @@ -0,0 +1,41 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import type { CliEntrypoint } from "./cli-runner.js"; +import { resolveCliInvocation } from "./cli-runner.js"; + +function toStringEnv(env: NodeJS.ProcessEnv): Record { + return Object.fromEntries( + Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === "string") + ); +} + +export async function connectCliMcpClient( + repoDir: string, + options: { + entrypoint?: CliEntrypoint; + env?: NodeJS.ProcessEnv; + serverCwd?: string; + } = {} +): Promise { + const invocation = resolveCliInvocation(options.entrypoint ?? "source"); + const args = [...invocation.args, "mcp", "serve"]; + if (options.serverCwd) { + args.push("--cwd", options.serverCwd); + } + const transport = new StdioClientTransport({ + command: invocation.command, + args, + cwd: repoDir, + env: toStringEnv({ + ...process.env, + ...options.env + }) + }); + const client = new Client({ + name: "codex-auto-memory-test-client", + version: "1.0.0" + }); + + await client.connect(transport); + return client; +} diff --git a/test/hooks-command.test.ts b/test/hooks-command.test.ts new file mode 100644 index 0000000..ba6478f --- /dev/null +++ b/test/hooks-command.test.ts @@ -0,0 +1,185 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { detectProjectContext } from "../src/lib/domain/project-context.js"; +import { MemoryStore } from "../src/lib/domain/memory-store.js"; +import { runCommandCapture } from "../src/lib/util/process.js"; +import { makeAppConfig, writeCamConfig } from "./helpers/cam-test-fixtures.js"; +import { resolveCliInvocation, runCli } from "./helpers/cli-runner.js"; + +const tempDirs: string[] = []; +const originalHome = process.env.HOME; +const shellOnlyIt = process.platform === "win32" ? it.skip : it; + +async function tempDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +async function writeCamShim(binDir: string): Promise { + const invocation = resolveCliInvocation("source"); + const shimPath = path.join(binDir, "cam"); + const commandAndArgs = [invocation.command, ...invocation.args] + .map((value) => JSON.stringify(value)) + .join(" "); + await fs.writeFile( + shimPath, + `#!/bin/sh\nexec ${commandAndArgs} "$@"\n`, + "utf8" + ); + await fs.chmod(shimPath, 0o755); + return shimPath; +} + +afterEach(async () => { + process.env.HOME = originalHome; + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("hooks command", () => { + it("generates recall helper assets for hook and skill bridge flows", async () => { + const homeDir = await tempDir("cam-hooks-home-"); + const projectDir = await tempDir("cam-hooks-project-"); + process.env.HOME = homeDir; + + const result = runCli(projectDir, ["hooks", "install"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Generated hook bridge bundle"); + expect(result.stdout).toContain("memory-recall.sh"); + expect(result.stdout).toContain("memory-search.sh"); + expect(result.stdout).toContain("memory-timeline.sh"); + expect(result.stdout).toContain("memory-details.sh"); + expect(result.stdout).toContain("recall-bridge.md"); + expect(result.stdout).toContain("search -> timeline -> details"); + expect(result.stdout).toContain("read-only"); + expect(result.stdout).toContain("--state auto"); + expect(result.stdout).toContain("--limit 8"); + expect(result.stdout).toContain("search_memories"); + expect(result.stdout).toContain("cam mcp serve"); + expect(result.stdout).toContain("cam mcp doctor"); + expect(result.stdout).toContain("cam memory"); + expect(result.stdout).toContain("cam session"); + expect(result.stdout).toContain("local bridge"); + expect(result.stdout).toContain("not an official Codex hook surface"); + + const hooksDir = path.join(homeDir, ".codex-auto-memory", "hooks"); + const recallScript = await fs.readFile(path.join(hooksDir, "memory-recall.sh"), "utf8"); + const searchScript = await fs.readFile(path.join(hooksDir, "memory-search.sh"), "utf8"); + const timelineScript = await fs.readFile(path.join(hooksDir, "memory-timeline.sh"), "utf8"); + const detailsScript = await fs.readFile(path.join(hooksDir, "memory-details.sh"), "utf8"); + const recallGuide = await fs.readFile(path.join(hooksDir, "recall-bridge.md"), "utf8"); + + expect(recallScript).toContain('exec cam recall search "$@"'); + expect(recallScript).toContain("--state"); + expect(recallScript).toContain("auto"); + expect(recallScript).toContain("--limit"); + expect(recallScript).toContain("8"); + expect(searchScript).toContain('exec "$SCRIPT_DIR/memory-recall.sh" search "$@"'); + expect(timelineScript).toContain('exec "$SCRIPT_DIR/memory-recall.sh" timeline "$@"'); + expect(detailsScript).toContain('exec "$SCRIPT_DIR/memory-recall.sh" details "$@"'); + expect(recallGuide).toContain("search_memories"); + expect(recallGuide).toContain("memory-recall.sh search"); + expect(recallGuide).toContain("cam memory"); + expect(recallGuide).toContain("cam session"); + expect(recallGuide).toContain("local bridge"); + expect(recallGuide).toContain("not an official Codex hook surface"); + }); + + shellOnlyIt("executes the recall bridge bundle without overriding explicit state or limit flags", async () => { + const homeDir = await tempDir("cam-hooks-exec-home-"); + const projectDir = await tempDir("cam-hooks-exec-project-"); + const memoryRoot = await tempDir("cam-hooks-exec-memory-"); + const binDir = await tempDir("cam-hooks-exec-bin-"); + process.env.HOME = homeDir; + + await writeCamConfig(projectDir, makeAppConfig(), { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...makeAppConfig(), + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "historical-note-one", + "Historical note one.", + ["Historical archive note one."], + "Manual note." + ); + await store.remember( + "project", + "workflow", + "historical-note-two", + "Historical note two.", + ["Historical archive note two."], + "Manual note." + ); + await store.forget("project", "historical", { archive: true }); + + const installResult = runCli(projectDir, ["hooks", "install"], { + env: { HOME: homeDir } + }); + expect(installResult.exitCode, installResult.stderr).toBe(0); + + await writeCamShim(binDir); + const hooksDir = path.join(homeDir, ".codex-auto-memory", "hooks"); + const recallScriptPath = path.join(hooksDir, "memory-recall.sh"); + const searchScriptPath = path.join(hooksDir, "memory-search.sh"); + const env = { + ...process.env, + HOME: homeDir, + PATH: `${binDir}:${process.env.PATH ?? ""}` + }; + + const defaultResult = runCommandCapture( + recallScriptPath, + ["search", "historical", "--json"], + projectDir, + env + ); + expect(defaultResult.exitCode, defaultResult.stderr).toBe(0); + const defaultPayload = JSON.parse(defaultResult.stdout) as { + state: string; + resolvedState: string; + fallbackUsed: boolean; + results: Array<{ ref: string }>; + }; + expect(defaultPayload).toMatchObject({ + state: "auto", + resolvedState: "archived", + fallbackUsed: true + }); + expect(defaultPayload.results).toHaveLength(2); + expect(defaultPayload.results.map((result) => result.ref)).toEqual( + expect.arrayContaining([ + "project:archived:workflow:historical-note-one", + "project:archived:workflow:historical-note-two" + ]) + ); + + const explicitResult = runCommandCapture( + searchScriptPath, + ["historical", "--state=all", "--limit=1", "--json"], + projectDir, + env + ); + expect(explicitResult.exitCode, explicitResult.stderr).toBe(0); + const explicitPayload = JSON.parse(explicitResult.stdout) as { + state: string; + resolvedState: string; + fallbackUsed: boolean; + results: Array<{ ref: string }>; + }; + expect(explicitPayload).toMatchObject({ + state: "all", + resolvedState: "all", + fallbackUsed: false + }); + expect(explicitPayload.results).toHaveLength(1); + }); +}); diff --git a/test/integrations-command.test.ts b/test/integrations-command.test.ts new file mode 100644 index 0000000..c07b3bd --- /dev/null +++ b/test/integrations-command.test.ts @@ -0,0 +1,583 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { makeAppConfig, writeCamConfig } from "./helpers/cam-test-fixtures.js"; +import { runCli } from "./helpers/cli-runner.js"; + +const tempDirs: string[] = []; +const originalHome = process.env.HOME; +const originalCodexHome = process.env.CODEX_HOME; + +async function tempDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +async function pathExists(targetPath: string): Promise { + try { + await fs.access(targetPath); + return true; + } catch { + return false; + } +} + +async function writeCamShim(binDir: string): Promise { + if (process.platform === "win32") { + await fs.writeFile(path.join(binDir, "cam.cmd"), "@echo off\r\nexit /b 0\r\n", "utf8"); + return; + } + + const shimPath = path.join(binDir, "cam"); + await fs.writeFile(shimPath, "#!/bin/sh\nexit 0\n", "utf8"); + await fs.chmod(shimPath, 0o755); +} + +async function pathContainsCam(dir: string): Promise { + const candidates = + process.platform === "win32" + ? [path.join(dir, "cam.cmd"), path.join(dir, "cam.exe")] + : [path.join(dir, "cam")]; + + for (const candidate of candidates) { + if (await pathExists(candidate)) { + return true; + } + } + + return false; +} + +async function buildPathWithoutCam(extraDir: string): Promise { + const baseEntries = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean); + const filteredEntries: string[] = []; + + for (const entry of baseEntries) { + if (!(await pathContainsCam(entry))) { + filteredEntries.push(entry); + } + } + + return [extraDir, ...filteredEntries].join(path.delimiter); +} + +afterEach(async () => { + process.env.HOME = originalHome; + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("integrations command", () => { + it("installs the recommended Codex integration stack without creating memory layout", async () => { + const homeDir = await tempDir("cam-integrations-home-"); + const projectDir = await tempDir("cam-integrations-project-"); + const memoryRootParent = await tempDir("cam-integrations-memory-parent-"); + const memoryRoot = path.join(memoryRootParent, "memory-root"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + await writeCamConfig(projectDir, makeAppConfig(), { + autoMemoryDirectory: memoryRoot + }); + + const first = runCli( + projectDir, + ["integrations", "install", "--host", "codex", "--json"], + { env: { HOME: homeDir } } + ); + expect(first.exitCode, first.stderr).toBe(0); + expect(JSON.parse(first.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + stackAction: "created", + skillsSurface: "runtime", + readOnlyRetrieval: true, + subactions: { + mcp: { + status: "ok", + action: "created", + targetPath: path.join(realProjectDir, ".codex", "config.toml"), + projectPinned: true, + readOnlyRetrieval: true + }, + hooks: { + status: "ok", + action: "created", + targetDir: path.join(homeDir, ".codex-auto-memory", "hooks"), + readOnlyRetrieval: true + }, + skills: { + status: "ok", + action: "created", + targetDir: path.join(homeDir, ".codex", "skills", "codex-auto-memory-recall"), + surface: "runtime", + readOnlyRetrieval: true + } + } + }); + + expect( + await fs.readFile(path.join(realProjectDir, ".codex", "config.toml"), "utf8") + ).toContain("[mcp_servers.codex_auto_memory]"); + expect( + await fs.readFile(path.join(homeDir, ".codex-auto-memory", "hooks", "memory-recall.sh"), "utf8") + ).toContain("cam:asset-version"); + expect( + await fs.readFile( + path.join(homeDir, ".codex", "skills", "codex-auto-memory-recall", "SKILL.md"), + "utf8" + ) + ).toContain("cam:asset-version"); + expect(await pathExists(memoryRoot)).toBe(false); + + const second = runCli( + projectDir, + ["integrations", "install", "--host", "codex", "--json"], + { env: { HOME: homeDir } } + ); + expect(second.exitCode, second.stderr).toBe(0); + expect(JSON.parse(second.stdout)).toMatchObject({ + host: "codex", + stackAction: "unchanged", + subactions: { + mcp: { action: "unchanged" }, + hooks: { action: "unchanged" }, + skills: { action: "unchanged" } + } + }); + }); + + it("rejects non-codex hosts for the integration stack orchestration surface", async () => { + const homeDir = await tempDir("cam-integrations-invalid-home-"); + const projectDir = await tempDir("cam-integrations-invalid-project-"); + process.env.HOME = homeDir; + + const result = runCli(projectDir, ["integrations", "install", "--host", "gemini"], { + env: { HOME: homeDir } + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Codex-only"); + expect(result.stderr).toContain("codex"); + }); + + it("surfaces the missing Codex integration stack through integrations doctor without creating memory layout", async () => { + const homeDir = await tempDir("cam-integrations-doctor-missing-home-"); + const projectDir = await tempDir("cam-integrations-doctor-missing-project-"); + const emptyPathDir = await tempDir("cam-integrations-doctor-empty-path-"); + const memoryRootParent = await tempDir("cam-integrations-doctor-missing-memory-parent-"); + const memoryRoot = path.join(memoryRootParent, "memory-root"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + await writeCamConfig(projectDir, makeAppConfig(), { + autoMemoryDirectory: memoryRoot + }); + + const result = runCli( + projectDir, + ["integrations", "doctor", "--host", "codex", "--json"], + { + env: { + HOME: homeDir, + PATH: await buildPathWithoutCam(emptyPathDir) + } + } + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + readOnlyRetrieval: true, + status: "missing", + recommendedRoute: "cli-direct", + recommendedPreset: "state=auto, limit=8", + subchecks: { + mcp: { + status: "missing" + }, + agents: { + status: "missing" + }, + hookCapture: { + status: "missing" + }, + hookRecall: { + status: "missing" + }, + skill: { + status: "missing" + }, + workflowConsistency: { + status: "missing" + } + } + }); + expect(JSON.parse(result.stdout).nextSteps[0]).toContain( + "cam integrations apply --host codex" + ); + expect(JSON.parse(result.stdout).nextSteps).toEqual( + expect.arrayContaining([ + expect.stringContaining("cam mcp print-config --host codex") + ]) + ); + expect(await pathExists(memoryRoot)).toBe(false); + }); + + it("surfaces a ready Codex integration stack through integrations doctor", async () => { + const homeDir = await tempDir("cam-integrations-doctor-ready-home-"); + const projectDir = await tempDir("cam-integrations-doctor-ready-project-"); + const binDir = await tempDir("cam-integrations-doctor-ready-bin-"); + const memoryRootParent = await tempDir("cam-integrations-doctor-ready-memory-parent-"); + const memoryRoot = path.join(memoryRootParent, "memory-root"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + await writeCamShim(binDir); + await writeCamConfig(projectDir, makeAppConfig(), { + autoMemoryDirectory: memoryRoot + }); + + const env = { + HOME: homeDir, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}` + }; + + const installResult = runCli( + projectDir, + ["integrations", "install", "--host", "codex", "--json"], + { env } + ); + expect(installResult.exitCode, installResult.stderr).toBe(0); + + const printConfigResult = runCli( + projectDir, + ["mcp", "print-config", "--host", "codex", "--json"], + { env } + ); + expect(printConfigResult.exitCode, printConfigResult.stderr).toBe(0); + const printConfigPayload = JSON.parse(printConfigResult.stdout) as { + agentsGuidance: { snippet: string }; + }; + await fs.writeFile( + path.join(realProjectDir, "AGENTS.md"), + `${printConfigPayload.agentsGuidance.snippet}\n`, + "utf8" + ); + + const doctorResult = runCli( + projectDir, + ["integrations", "doctor", "--host", "codex", "--json"], + { env } + ); + expect(doctorResult.exitCode, doctorResult.stderr).toBe(0); + expect(JSON.parse(doctorResult.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + readOnlyRetrieval: true, + status: "ok", + recommendedRoute: "mcp", + recommendedPreset: "state=auto, limit=8", + subchecks: { + mcp: { + status: "ok" + }, + agents: { + status: "ok" + }, + hookCapture: { + status: "ok" + }, + hookRecall: { + status: "ok" + }, + skill: { + status: "ok" + }, + workflowConsistency: { + status: "ok" + } + } + }); + expect(JSON.parse(doctorResult.stdout).nextSteps).toEqual( + expect.arrayContaining([ + expect.stringContaining("cam mcp print-config --host codex"), + expect.stringContaining("Prefer retrieval MCP") + ]) + ); + expect(await pathExists(memoryRoot)).toBe(false); + }); + + it("does not let a fenced AGENTS guidance example satisfy integrations doctor", async () => { + const homeDir = await tempDir("cam-integrations-doctor-fenced-home-"); + const projectDir = await tempDir("cam-integrations-doctor-fenced-project-"); + const binDir = await tempDir("cam-integrations-doctor-fenced-bin-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + await writeCamShim(binDir); + const env = { + HOME: homeDir, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}` + }; + + const installResult = runCli( + projectDir, + ["integrations", "install", "--host", "codex", "--json"], + { env } + ); + expect(installResult.exitCode, installResult.stderr).toBe(0); + + const printConfigResult = runCli( + projectDir, + ["mcp", "print-config", "--host", "codex", "--json"], + { env } + ); + expect(printConfigResult.exitCode, printConfigResult.stderr).toBe(0); + const printConfigPayload = JSON.parse(printConfigResult.stdout) as { + agentsGuidance: { snippet: string }; + }; + await fs.writeFile( + path.join(realProjectDir, "AGENTS.md"), + ["# Example", "", "```md", printConfigPayload.agentsGuidance.snippet, "```"].join("\n"), + "utf8" + ); + + const doctorResult = runCli( + projectDir, + ["integrations", "doctor", "--host", "codex", "--json"], + { env } + ); + expect(doctorResult.exitCode, doctorResult.stderr).toBe(0); + expect(JSON.parse(doctorResult.stdout)).toMatchObject({ + subchecks: { + agents: { + status: "warning" + } + } + }); + expect(JSON.parse(doctorResult.stdout).nextSteps).toEqual( + expect.arrayContaining([ + expect.stringContaining("cam mcp apply-guidance --host codex") + ]) + ); + }); + + it("uses action-aware text output for integrations install", async () => { + const homeDir = await tempDir("cam-integrations-text-home-"); + const projectDir = await tempDir("cam-integrations-text-project-"); + process.env.HOME = homeDir; + + const created = runCli(projectDir, ["integrations", "install", "--host", "codex"], { + env: { HOME: homeDir } + }); + expect(created.exitCode, created.stderr).toBe(0); + expect(created.stdout).toContain("Installed Codex integration stack."); + + const unchanged = runCli(projectDir, ["integrations", "install", "--host", "codex"], { + env: { HOME: homeDir } + }); + expect(unchanged.exitCode, unchanged.stderr).toBe(0); + expect(unchanged.stdout).toContain("Codex integration stack is already up to date."); + }); + + it("applies the full Codex integration stack including AGENTS guidance", async () => { + const homeDir = await tempDir("cam-integrations-apply-home-"); + const projectDir = await tempDir("cam-integrations-apply-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + const result = runCli( + projectDir, + ["integrations", "apply", "--host", "codex", "--json"], + { env: { HOME: homeDir } } + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + stackAction: "created", + skillsSurface: "runtime", + readOnlyRetrieval: true, + subactions: { + mcp: { + status: "ok", + action: "created" + }, + agents: { + status: "ok", + action: "created", + targetPath: path.join(realProjectDir, "AGENTS.md") + }, + hooks: { + status: "ok", + action: "created" + }, + skills: { + status: "ok", + action: "created", + surface: "runtime" + } + } + }); + expect(await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8")).toContain( + "cam:codex-agents-guidance:start" + ); + }); + + it("keeps integrations install non-mutating for AGENTS.md while integrations apply writes it", async () => { + const homeDir = await tempDir("cam-integrations-apply-boundary-home-"); + const projectDir = await tempDir("cam-integrations-apply-boundary-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + const installResult = runCli( + projectDir, + ["integrations", "install", "--host", "codex", "--json"], + { env: { HOME: homeDir } } + ); + expect(installResult.exitCode, installResult.stderr).toBe(0); + await expect(fs.access(path.join(realProjectDir, "AGENTS.md"))).rejects.toMatchObject({ + code: "ENOENT" + }); + + const applyResult = runCli( + projectDir, + ["integrations", "apply", "--host", "codex", "--json"], + { env: { HOME: homeDir } } + ); + expect(applyResult.exitCode, applyResult.stderr).toBe(0); + expect(JSON.parse(applyResult.stdout)).toMatchObject({ + stackAction: "updated", + subactions: { + mcp: { action: "unchanged" }, + agents: { action: "created" }, + hooks: { action: "unchanged" }, + skills: { action: "unchanged" } + } + }); + }); + + it("uses CODEX_HOME as the runtime skill location for integrations install and doctor", async () => { + const homeDir = await tempDir("cam-integrations-codex-home-home-"); + const codexHome = await tempDir("cam-integrations-codex-home-codex-home-"); + const projectDir = await tempDir("cam-integrations-codex-home-project-"); + const binDir = await tempDir("cam-integrations-codex-home-bin-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + process.env.CODEX_HOME = codexHome; + + await writeCamShim(binDir); + const env = { + HOME: homeDir, + CODEX_HOME: codexHome, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}` + }; + + const installResult = runCli( + projectDir, + ["integrations", "install", "--host", "codex", "--json"], + { env } + ); + expect(installResult.exitCode, installResult.stderr).toBe(0); + expect(JSON.parse(installResult.stdout)).toMatchObject({ + subactions: { + skills: { + targetDir: path.join(codexHome, "skills", "codex-auto-memory-recall"), + surface: "runtime" + } + } + }); + + const printConfigResult = runCli( + projectDir, + ["mcp", "print-config", "--host", "codex", "--json"], + { env } + ); + expect(printConfigResult.exitCode, printConfigResult.stderr).toBe(0); + const printConfigPayload = JSON.parse(printConfigResult.stdout) as { + agentsGuidance: { snippet: string }; + }; + await fs.writeFile( + path.join(realProjectDir, "AGENTS.md"), + `${printConfigPayload.agentsGuidance.snippet}\n`, + "utf8" + ); + + const doctorResult = runCli( + projectDir, + ["integrations", "doctor", "--host", "codex", "--json"], + { env } + ); + expect(doctorResult.exitCode, doctorResult.stderr).toBe(0); + expect(JSON.parse(doctorResult.stdout)).toMatchObject({ + subchecks: { + agents: { + status: "ok" + }, + skill: { + status: "ok" + } + }, + preferredSkillSurface: "runtime", + recommendedSkillInstallCommand: "cam skills install --surface runtime", + installedSkillSurfaces: ["runtime"], + readySkillSurfaces: ["runtime"] + }); + }); + + it("passes through an explicit official user skill surface for integrations install and apply", async () => { + const homeDir = await tempDir("cam-integrations-official-surface-home-"); + const projectDir = await tempDir("cam-integrations-official-surface-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + const installResult = runCli( + projectDir, + ["integrations", "install", "--host", "codex", "--skill-surface", "official-user", "--json"], + { env: { HOME: homeDir } } + ); + expect(installResult.exitCode, installResult.stderr).toBe(0); + expect(JSON.parse(installResult.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + skillsSurface: "official-user", + subactions: { + skills: { + action: "created", + surface: "official-user", + targetDir: path.join(homeDir, ".agents", "skills", "codex-auto-memory-recall") + } + } + }); + + const applyResult = runCli( + projectDir, + ["integrations", "apply", "--host", "codex", "--skill-surface", "official-user", "--json"], + { env: { HOME: homeDir } } + ); + expect(applyResult.exitCode, applyResult.stderr).toBe(0); + expect(JSON.parse(applyResult.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + skillsSurface: "official-user", + subactions: { + skills: { + surface: "official-user", + targetDir: path.join(homeDir, ".agents", "skills", "codex-auto-memory-recall") + } + } + }); + expect( + await fs.readFile( + path.join(homeDir, ".agents", "skills", "codex-auto-memory-recall", "SKILL.md"), + "utf8" + ) + ).toContain("cam:asset-version"); + }); +}); diff --git a/test/mcp-command.test.ts b/test/mcp-command.test.ts new file mode 100644 index 0000000..2b37b2f --- /dev/null +++ b/test/mcp-command.test.ts @@ -0,0 +1,2264 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import * as toml from "smol-toml"; +import { detectProjectContext } from "../src/lib/domain/project-context.js"; +import { MemoryStore } from "../src/lib/domain/memory-store.js"; +import { RETRIEVAL_INTEGRATION_ASSET_VERSION } from "../src/lib/integration/retrieval-contract.js"; +import { makeAppConfig, writeCamConfig } from "./helpers/cam-test-fixtures.js"; +import { runCli } from "./helpers/cli-runner.js"; +import { connectCliMcpClient } from "./helpers/mcp-client.js"; + +const tempDirs: string[] = []; +const originalHome = process.env.HOME; +const originalCodexHome = process.env.CODEX_HOME; + +interface SearchMemoriesResponse { + query: string; + scope: string; + state: string; + resolvedState: string; + fallbackUsed: boolean; + results: Array<{ + ref: string; + state: string; + summary: string; + matchedFields: string[]; + approxReadCost: number; + }>; +} + +interface TimelineMemoriesResponse { + ref: string; + events: Array<{ + action: string; + state: string; + }>; +} + +interface MemoryDetailsResponse { + ref: string; + path: string; + entry: { + summary: string; + details: string[]; + }; +} + +interface ToolCallResultLike { + structuredContent?: unknown; + content?: Array<{ type: string; text?: string }>; + toolResult?: { + structuredContent?: unknown; + content?: Array<{ type: string; text?: string }>; + }; +} + +async function tempDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +async function pathExists(pathname: string): Promise { + try { + await fs.access(pathname); + return true; + } catch { + return false; + } +} + +async function readTomlFile(pathname: string): Promise> { + const raw = await fs.readFile(pathname, "utf8"); + return toml.parse(raw) as Record; +} + +async function readJsonFile(pathname: string): Promise> { + return JSON.parse(await fs.readFile(pathname, "utf8")) as Record; +} + +async function writeCamShim(binDir: string): Promise { + if (process.platform === "win32") { + await fs.writeFile(path.join(binDir, "cam.cmd"), "@echo off\r\nexit /b 0\r\n", "utf8"); + return; + } + + const shimPath = path.join(binDir, "cam"); + await fs.writeFile(shimPath, "#!/bin/sh\nexit 0\n", "utf8"); + await fs.chmod(shimPath, 0o755); +} + +async function pathContainsCam(dir: string): Promise { + const candidates = + process.platform === "win32" + ? [path.join(dir, "cam.cmd"), path.join(dir, "cam.exe")] + : [path.join(dir, "cam")]; + + for (const candidate of candidates) { + if (await pathExists(candidate)) { + return true; + } + } + + return false; +} + +async function buildPathWithoutCam(extraDir: string): Promise { + const baseEntries = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean); + const filteredEntries: string[] = []; + + for (const entry of baseEntries) { + if (!(await pathContainsCam(entry))) { + filteredEntries.push(entry); + } + } + + return [extraDir, ...filteredEntries].join(path.delimiter); +} + +function readStructuredContent(result: ToolCallResultLike): T { + const payload = result.toolResult ?? result; + + if (payload.structuredContent) { + return payload.structuredContent as T; + } + + const textBlock = payload.content?.find( + (block): block is { type: "text"; text: string } => + block.type === "text" && typeof block.text === "string" + ); + if (!textBlock) { + throw new Error("Expected MCP result to contain structuredContent or a text content block."); + } + + return JSON.parse(textBlock.text) as T; +} + +afterEach(async () => { + process.env.HOME = originalHome; + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("mcp command", () => { + it("installs project-scoped MCP wiring for codex, claude, and gemini without replacing unrelated config", async () => { + const homeDir = await tempDir("cam-mcp-install-home-"); + const projectDir = await tempDir("cam-mcp-install-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + const codexConfigPath = path.join(realProjectDir, ".codex", "config.toml"); + const claudeConfigPath = path.join(realProjectDir, ".mcp.json"); + const geminiConfigPath = path.join(realProjectDir, ".gemini", "settings.json"); + + await fs.mkdir(path.dirname(codexConfigPath), { recursive: true }); + await fs.mkdir(path.dirname(geminiConfigPath), { recursive: true }); + await fs.writeFile( + codexConfigPath, + [ + 'model_reasoning_effort = "high"', + "", + "[mcp_servers.other_server]", + 'command = "other"', + 'args = ["serve"]' + ].join("\n"), + "utf8" + ); + await fs.writeFile( + claudeConfigPath, + JSON.stringify( + { + approvalMode: "project", + mcpServers: { + other_server: { + command: "other", + args: ["serve"] + } + } + }, + null, + 2 + ), + "utf8" + ); + await fs.writeFile( + geminiConfigPath, + JSON.stringify( + { + theme: "ocean", + mcpServers: { + other_server: { + command: "other", + args: ["serve"], + trust: true + } + } + }, + null, + 2 + ), + "utf8" + ); + + const codexInstall = runCli(projectDir, ["mcp", "install", "--host", "codex", "--json"], { + env: { HOME: homeDir } + }); + const claudeInstall = runCli(projectDir, ["mcp", "install", "--host", "claude", "--json"], { + env: { HOME: homeDir } + }); + const geminiInstall = runCli(projectDir, ["mcp", "install", "--host", "gemini", "--json"], { + env: { HOME: homeDir } + }); + + expect(codexInstall.exitCode, codexInstall.stderr).toBe(0); + expect(claudeInstall.exitCode, claudeInstall.stderr).toBe(0); + expect(geminiInstall.exitCode, geminiInstall.stderr).toBe(0); + + expect(JSON.parse(codexInstall.stdout)).toMatchObject({ + host: "codex", + serverName: "codex_auto_memory", + action: "created", + projectPinned: true, + readOnlyRetrieval: true, + targetPath: codexConfigPath + }); + expect(JSON.parse(claudeInstall.stdout)).toMatchObject({ + host: "claude", + serverName: "codex_auto_memory", + action: "created", + projectPinned: true, + readOnlyRetrieval: true, + targetPath: claudeConfigPath + }); + expect(JSON.parse(geminiInstall.stdout)).toMatchObject({ + host: "gemini", + serverName: "codex_auto_memory", + action: "created", + projectPinned: true, + readOnlyRetrieval: true, + targetPath: geminiConfigPath + }); + + const codexConfig = await readTomlFile(codexConfigPath); + expect(codexConfig).toMatchObject({ + model_reasoning_effort: "high", + mcp_servers: { + other_server: { + command: "other", + args: ["serve"] + }, + codex_auto_memory: { + command: "cam", + args: ["mcp", "serve"], + cwd: realProjectDir + } + } + }); + + const claudeConfig = await readJsonFile(claudeConfigPath); + expect(claudeConfig).toMatchObject({ + approvalMode: "project", + mcpServers: { + other_server: { + command: "other", + args: ["serve"] + }, + codex_auto_memory: { + command: "cam", + args: ["mcp", "serve", "--cwd", realProjectDir], + env: {} + } + } + }); + + const geminiConfig = await readJsonFile(geminiConfigPath); + expect(geminiConfig).toMatchObject({ + theme: "ocean", + mcpServers: { + other_server: { + command: "other", + args: ["serve"], + trust: true + }, + codex_auto_memory: { + command: "cam", + args: ["mcp", "serve"], + cwd: realProjectDir, + trust: false + } + } + }); + }); + + it("reports updated then unchanged on repeated install and makes doctor report ok for installed hosts", async () => { + const homeDir = await tempDir("cam-mcp-install-repeat-home-"); + const projectDir = await tempDir("cam-mcp-install-repeat-project-"); + process.env.HOME = homeDir; + + await fs.mkdir(path.join(projectDir, ".codex"), { recursive: true }); + await fs.mkdir(path.join(projectDir, ".gemini"), { recursive: true }); + await fs.writeFile( + path.join(projectDir, ".codex", "config.toml"), + [ + "[mcp_servers.codex_auto_memory]", + 'command = "cam"', + 'args = ["mcp", "serve"]', + 'cwd = "/tmp/not-this-project"' + ].join("\n"), + "utf8" + ); + await fs.writeFile( + path.join(projectDir, ".mcp.json"), + JSON.stringify( + { + mcpServers: { + codex_auto_memory: { + command: "cam", + args: ["mcp", "serve"], + env: { + KEEP_ME: "no" + } + } + } + }, + null, + 2 + ), + "utf8" + ); + await fs.writeFile( + path.join(projectDir, ".gemini", "settings.json"), + JSON.stringify( + { + mcpServers: { + codex_auto_memory: { + command: "cam", + args: ["mcp", "serve"], + cwd: projectDir, + trust: true + } + } + }, + null, + 2 + ), + "utf8" + ); + + for (const host of ["codex", "claude", "gemini"] as const) { + const first = runCli(projectDir, ["mcp", "install", "--host", host, "--json"], { + env: { HOME: homeDir } + }); + expect(first.exitCode, first.stderr).toBe(0); + expect(JSON.parse(first.stdout)).toMatchObject({ + host, + action: "updated" + }); + + const second = runCli(projectDir, ["mcp", "install", "--host", host, "--json"], { + env: { HOME: homeDir } + }); + expect(second.exitCode, second.stderr).toBe(0); + expect(JSON.parse(second.stdout)).toMatchObject({ + host, + action: "unchanged", + projectPinned: true, + readOnlyRetrieval: true + }); + } + + const doctor = runCli(projectDir, ["mcp", "doctor", "--json"], { + env: { HOME: homeDir } + }); + expect(doctor.exitCode, doctor.stderr).toBe(0); + const doctorPayload = JSON.parse(doctor.stdout) as { + hosts: Array<{ + host: string; + status: string; + configCheck?: { + exists: boolean; + projectPinned: boolean; + }; + }>; + }; + expect(doctorPayload.hosts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + host: "codex", + status: "ok", + configCheck: expect.objectContaining({ + exists: true, + projectPinned: true + }) + }), + expect.objectContaining({ + host: "claude", + status: "ok", + configCheck: expect.objectContaining({ + exists: true, + projectPinned: true + }) + }), + expect.objectContaining({ + host: "gemini", + status: "ok", + configCheck: expect.objectContaining({ + exists: true, + projectPinned: true + }) + }), + expect.objectContaining({ + host: "generic", + status: "manual" + }) + ]) + ); + }); + + it("supports install --cwd for writing another project's host config", async () => { + const homeDir = await tempDir("cam-mcp-install-cwd-home-"); + const projectDir = await tempDir("cam-mcp-install-cwd-project-"); + const callerDir = await tempDir("cam-mcp-install-cwd-caller-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + const result = runCli( + callerDir, + ["mcp", "install", "--host", "claude", "--cwd", projectDir, "--json"], + { env: { HOME: homeDir } } + ); + + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "claude", + action: "created", + projectRoot: realProjectDir, + targetPath: path.join(realProjectDir, ".mcp.json") + }); + + const claudeConfig = await readJsonFile(path.join(realProjectDir, ".mcp.json")); + expect(claudeConfig).toMatchObject({ + mcpServers: { + codex_auto_memory: { + command: "cam", + args: ["mcp", "serve", "--cwd", realProjectDir], + env: {} + } + } + }); + }); + + it("prints ready-to-paste host snippets without mutating host config files", async () => { + const homeDir = await tempDir("cam-mcp-print-home-"); + const projectDir = await tempDir("cam-mcp-print-project-"); + process.env.HOME = homeDir; + + const expectations = [ + { + host: "codex", + targetFile: path.join(projectDir, ".codex", "config.toml"), + expected: [ + "Target file hint: .codex/config.toml", + "[mcp_servers.codex_auto_memory]", + "AGENTS.md", + "search_memories", + "cam recall search" + ] + }, + { + host: "claude", + targetFile: path.join(projectDir, ".mcp.json"), + expected: ['Target file hint: .mcp.json', '"mcpServers"', '"--cwd"'] + }, + { + host: "gemini", + targetFile: path.join(projectDir, ".gemini", "settings.json"), + expected: ['Target file hint: .gemini/settings.json', '"trust": false', '"cwd"'] + }, + { + host: "generic", + targetFile: path.join(projectDir, "generic-mcp-config.json"), + expected: ["Target file hint: Your MCP client's stdio server config", '"command": "cam"'] + } + ] as const; + + for (const expectation of expectations) { + const result = runCli(projectDir, ["mcp", "print-config", "--host", expectation.host], { + env: { HOME: homeDir } + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("read-only retrieval MCP plane"); + expect(result.stdout).toContain("Server name: codex_auto_memory"); + for (const fragment of expectation.expected) { + expect(result.stdout).toContain(fragment); + } + expect(await pathExists(expectation.targetFile)).toBe(false); + } + }); + + it("prints a stable JSON contract for host snippets", async () => { + const homeDir = await tempDir("cam-mcp-json-home-"); + const projectDir = await tempDir("cam-mcp-json-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + const result = runCli(projectDir, ["mcp", "print-config", "--host", "codex", "--json"], { + env: { HOME: homeDir } + }); + + expect(result.exitCode, result.stderr).toBe(0); + const payload = JSON.parse(result.stdout) as { + host: string; + serverName: string; + transport: string; + targetFileHint: string; + projectRoot: string; + snippetFormat: string; + snippet: string; + notes: string[]; + agentsGuidance?: { + targetFileHint: string; + snippetFormat: string; + snippet: string; + notes: string[]; + }; + }; + + expect(payload).toMatchObject({ + host: "codex", + serverName: "codex_auto_memory", + transport: "stdio", + targetFileHint: ".codex/config.toml", + projectRoot: realProjectDir, + snippetFormat: "toml" + }); + expect(payload.snippet).toContain('[mcp_servers.codex_auto_memory]'); + expect(payload.notes).toHaveLength(2); + expect(payload.agentsGuidance).toMatchObject({ + targetFileHint: "AGENTS.md", + snippetFormat: "markdown" + }); + expect(payload.agentsGuidance?.snippet).toContain("search_memories"); + expect(payload.agentsGuidance?.snippet).toContain("cam recall search"); + expect(payload.agentsGuidance?.notes).toEqual( + expect.arrayContaining([expect.stringContaining("local bridge")]) + ); + }); + + it("applies the recommended AGENTS guidance by creating a managed block when AGENTS.md is missing", async () => { + const homeDir = await tempDir("cam-mcp-apply-guidance-create-home-"); + const projectDir = await tempDir("cam-mcp-apply-guidance-create-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + const result = runCli( + projectDir, + ["mcp", "apply-guidance", "--host", "codex", "--json"], + { + env: { HOME: homeDir } + } + ); + expect(result.exitCode, result.stderr).toBe(0); + + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + targetPath: path.join(realProjectDir, "AGENTS.md"), + action: "created", + createdFile: true, + managedBlockVersion: "codex-agents-guidance-v1" + }); + const agentsContents = await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8"); + expect(agentsContents).toContain("cam:codex-agents-guidance:start"); + expect(agentsContents).toContain("cam:agents-guidance-version codex-agents-guidance-v1"); + expect(agentsContents).toContain("cam:codex-agents-guidance:end"); + expect(agentsContents).toContain("search_memories"); + }); + + it("updates an existing managed AGENTS guidance block without replacing unrelated content", async () => { + const homeDir = await tempDir("cam-mcp-apply-guidance-update-home-"); + const projectDir = await tempDir("cam-mcp-apply-guidance-update-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + await fs.writeFile( + path.join(realProjectDir, "AGENTS.md"), + [ + "# Project Notes", + "", + "- Keep existing repo guidance.", + "", + "", + "", + "- stale guidance", + "", + "", + "- Preserve this trailing note." + ].join("\n"), + "utf8" + ); + + const result = runCli( + projectDir, + ["mcp", "apply-guidance", "--host", "codex", "--json"], + { + env: { HOME: homeDir } + } + ); + expect(result.exitCode, result.stderr).toBe(0); + + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + action: "updated", + createdFile: false, + managedBlockVersion: "codex-agents-guidance-v1" + }); + const agentsContents = await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8"); + expect(agentsContents).toContain("# Project Notes"); + expect(agentsContents).toContain("- Preserve this trailing note."); + expect(agentsContents).toContain("cam:agents-guidance-version codex-agents-guidance-v1"); + expect(agentsContents).not.toContain("codex-agents-guidance-v0"); + expect(agentsContents).toContain("search_memories"); + }); + + it("ignores managed block markers that appear only inside fenced code blocks", async () => { + const homeDir = await tempDir("cam-mcp-apply-guidance-fenced-home-"); + const projectDir = await tempDir("cam-mcp-apply-guidance-fenced-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + const exampleBlock = [ + "```md", + "", + "", + "- example only", + "", + "```" + ].join("\n"); + await fs.writeFile( + path.join(realProjectDir, "AGENTS.md"), + ["# Project Notes", "", exampleBlock, "", "- Keep this note."].join("\n"), + "utf8" + ); + + const result = runCli( + projectDir, + ["mcp", "apply-guidance", "--host", "codex", "--json"], + { + env: { HOME: homeDir } + } + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + action: "updated", + createdFile: false, + managedBlockVersion: "codex-agents-guidance-v1" + }); + + const agentsContents = await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8"); + expect(agentsContents).toContain(exampleBlock); + expect(agentsContents).toContain("- Keep this note."); + expect(agentsContents).toContain("cam:agents-guidance-version codex-agents-guidance-v1"); + }); + + it("reports unchanged when the managed AGENTS guidance block is already current", async () => { + const homeDir = await tempDir("cam-mcp-apply-guidance-unchanged-home-"); + const projectDir = await tempDir("cam-mcp-apply-guidance-unchanged-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + const printConfigResult = runCli( + projectDir, + ["mcp", "print-config", "--host", "codex", "--json"], + { env: { HOME: homeDir } } + ); + expect(printConfigResult.exitCode, printConfigResult.stderr).toBe(0); + const printConfigPayload = JSON.parse(printConfigResult.stdout) as { + agentsGuidance: { snippet: string }; + }; + await fs.writeFile( + path.join(realProjectDir, "AGENTS.md"), + [ + "# Project Notes", + "", + "", + printConfigPayload.agentsGuidance.snippet, + "" + ].join("\n"), + "utf8" + ); + + const before = await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8"); + const result = runCli( + projectDir, + ["mcp", "apply-guidance", "--host", "codex", "--json"], + { + env: { HOME: homeDir } + } + ); + expect(result.exitCode, result.stderr).toBe(0); + + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + action: "unchanged", + createdFile: false, + managedBlockVersion: "codex-agents-guidance-v1" + }); + const after = await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8"); + expect(after).toBe(before); + }); + + it("preserves bytes outside the managed block when updating guidance", async () => { + const homeDir = await tempDir("cam-mcp-apply-guidance-verbatim-home-"); + const projectDir = await tempDir("cam-mcp-apply-guidance-verbatim-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + const prefix = "# Intro\r\n\r\nParagraph with trailing spaces. \r\n\r\n"; + const staleBlock = [ + "", + "", + "- stale guidance", + "" + ].join("\r\n"); + const suffix = "\r\n\r\n```md\r\nexample snippet\r\n```\r\n\r\nTail line. \r\n"; + + await fs.writeFile(path.join(realProjectDir, "AGENTS.md"), `${prefix}${staleBlock}${suffix}`, "utf8"); + + const result = runCli( + projectDir, + ["mcp", "apply-guidance", "--host", "codex", "--json"], + { + env: { HOME: homeDir } + } + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + action: "updated", + managedBlockVersion: "codex-agents-guidance-v1" + }); + + const agentsContents = await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8"); + expect(agentsContents.startsWith(prefix)).toBe(true); + expect(agentsContents.endsWith(suffix)).toBe(true); + expect(agentsContents).toContain("cam:agents-guidance-version codex-agents-guidance-v1"); + }); + + it("fails closed when AGENTS.md contains an unsafe managed guidance shape", async () => { + const homeDir = await tempDir("cam-mcp-apply-guidance-blocked-home-"); + const projectDir = await tempDir("cam-mcp-apply-guidance-blocked-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + await fs.writeFile( + path.join(realProjectDir, "AGENTS.md"), + [ + "# Project Notes", + "", + "", + "", + "- stale guidance" + ].join("\n"), + "utf8" + ); + + const before = await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8"); + const result = runCli( + projectDir, + ["mcp", "apply-guidance", "--host", "codex", "--json"], + { + env: { HOME: homeDir } + } + ); + expect(result.exitCode, result.stderr).toBe(0); + + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + action: "blocked", + createdFile: false, + blockedReason: expect.stringContaining("managed guidance block") + }); + const after = await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8"); + expect(after).toBe(before); + }); + + it("reports missing AGENTS guidance when the repository has no AGENTS.md", async () => { + const homeDir = await tempDir("cam-mcp-doctor-agents-missing-home-"); + const projectDir = await tempDir("cam-mcp-doctor-agents-missing-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + const result = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + expect(JSON.parse(result.stdout)).toMatchObject({ + agentsGuidance: { + path: path.join(realProjectDir, "AGENTS.md"), + exists: false, + status: "missing" + } + }); + }); + + it("reports warning when AGENTS.md exists but does not contain the recommended guidance", async () => { + const homeDir = await tempDir("cam-mcp-doctor-agents-warning-home-"); + const projectDir = await tempDir("cam-mcp-doctor-agents-warning-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + await fs.writeFile( + path.join(realProjectDir, "AGENTS.md"), + "# Project Notes\n\n- This repo uses Codex.\n", + "utf8" + ); + + const result = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + expect(JSON.parse(result.stdout)).toMatchObject({ + agentsGuidance: { + path: path.join(realProjectDir, "AGENTS.md"), + exists: true, + status: "warning" + } + }); + }); + + it("reports ok when AGENTS.md contains the current recommended guidance snippet", async () => { + const homeDir = await tempDir("cam-mcp-doctor-agents-ok-home-"); + const projectDir = await tempDir("cam-mcp-doctor-agents-ok-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + const printConfigResult = runCli( + projectDir, + ["mcp", "print-config", "--host", "codex", "--json"], + { env: { HOME: homeDir } } + ); + expect(printConfigResult.exitCode, printConfigResult.stderr).toBe(0); + const printConfigPayload = JSON.parse(printConfigResult.stdout) as { + agentsGuidance: { snippet: string }; + }; + await fs.writeFile( + path.join(realProjectDir, "AGENTS.md"), + `${printConfigPayload.agentsGuidance.snippet}\n`, + "utf8" + ); + + const result = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + expect(JSON.parse(result.stdout)).toMatchObject({ + agentsGuidance: { + path: path.join(realProjectDir, "AGENTS.md"), + exists: true, + status: "ok" + } + }); + }); + + it("does not treat a fenced guidance example as installed AGENTS guidance", async () => { + const homeDir = await tempDir("cam-mcp-doctor-agents-fenced-home-"); + const projectDir = await tempDir("cam-mcp-doctor-agents-fenced-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + const printConfigResult = runCli( + projectDir, + ["mcp", "print-config", "--host", "codex", "--json"], + { env: { HOME: homeDir } } + ); + expect(printConfigResult.exitCode, printConfigResult.stderr).toBe(0); + const printConfigPayload = JSON.parse(printConfigResult.stdout) as { + agentsGuidance: { snippet: string }; + }; + await fs.writeFile( + path.join(realProjectDir, "AGENTS.md"), + ["# Example", "", "```md", printConfigPayload.agentsGuidance.snippet, "```"].join("\n"), + "utf8" + ); + + const result = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + expect(JSON.parse(result.stdout)).toMatchObject({ + agentsGuidance: { + path: path.join(realProjectDir, "AGENTS.md"), + exists: true, + status: "warning" + } + }); + }); + + it("reports warning when AGENTS.md carries an outdated guidance marker", async () => { + const homeDir = await tempDir("cam-mcp-doctor-agents-stale-home-"); + const projectDir = await tempDir("cam-mcp-doctor-agents-stale-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + await fs.writeFile( + path.join(realProjectDir, "AGENTS.md"), + [ + "## Codex Auto Memory", + "", + "", + "- search_memories", + "- timeline_memories", + "- get_memory_details", + "- cam recall search", + "- cam memory", + "- cam session", + "- Hook assets in this repository are local bridge and fallback helpers, not an official Codex hook surface." + ].join("\n"), + "utf8" + ); + + const result = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + expect(JSON.parse(result.stdout)).toMatchObject({ + agentsGuidance: { + path: path.join(realProjectDir, "AGENTS.md"), + exists: true, + status: "warning", + detectedVersion: "codex-agents-guidance-v0" + } + }); + }); + + it("uses action-aware text output for mcp install", async () => { + const homeDir = await tempDir("cam-mcp-install-text-home-"); + const projectDir = await tempDir("cam-mcp-install-text-project-"); + process.env.HOME = homeDir; + + const created = runCli(projectDir, ["mcp", "install", "--host", "codex"], { + env: { HOME: homeDir } + }); + expect(created.exitCode, created.stderr).toBe(0); + expect(created.stdout).toContain("Installed project-scoped MCP wiring for codex."); + + await fs.writeFile( + path.join(projectDir, ".codex", "config.toml"), + [ + "[mcp_servers.codex_auto_memory]", + 'command = "cam"', + 'args = ["mcp", "serve"]', + 'cwd = "/tmp/not-this-project"' + ].join("\n"), + "utf8" + ); + + const updated = runCli(projectDir, ["mcp", "install", "--host", "codex"], { + env: { HOME: homeDir } + }); + expect(updated.exitCode, updated.stderr).toBe(0); + expect(updated.stdout).toContain("Updated project-scoped MCP wiring for codex."); + + const unchanged = runCli(projectDir, ["mcp", "install", "--host", "codex"], { + env: { HOME: homeDir } + }); + expect(unchanged.exitCode, unchanged.stderr).toBe(0); + expect(unchanged.stdout).toContain( + "Project-scoped MCP wiring for codex is already up to date." + ); + }); + + it("supports print-config --cwd for generating snippets from another directory", async () => { + const homeDir = await tempDir("cam-mcp-print-cwd-home-"); + const projectDir = await tempDir("cam-mcp-print-cwd-project-"); + const callerDir = await tempDir("cam-mcp-print-cwd-caller-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + const result = runCli( + callerDir, + ["mcp", "print-config", "--host", "generic", "--cwd", projectDir, "--json"], + { + env: { HOME: homeDir } + } + ); + + expect(result.exitCode, result.stderr).toBe(0); + const payload = JSON.parse(result.stdout) as { + host: string; + projectRoot: string; + snippet: string; + }; + expect(payload).toMatchObject({ + host: "generic", + projectRoot: realProjectDir + }); + expect(payload.snippet).toContain(realProjectDir); + }); + + it("inspects project-scoped MCP wiring and fallback bridge assets", async () => { + const homeDir = await tempDir("cam-mcp-doctor-home-"); + const projectDir = await tempDir("cam-mcp-doctor-project-"); + const callerDir = await tempDir("cam-mcp-doctor-caller-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + const codexSnippetResult = runCli( + projectDir, + ["mcp", "print-config", "--host", "codex", "--json"], + { env: { HOME: homeDir } } + ); + expect(codexSnippetResult.exitCode, codexSnippetResult.stderr).toBe(0); + const codexSnippetPayload = JSON.parse(codexSnippetResult.stdout) as { snippet: string }; + + const claudeSnippetResult = runCli( + projectDir, + ["mcp", "print-config", "--host", "claude", "--json"], + { env: { HOME: homeDir } } + ); + expect(claudeSnippetResult.exitCode, claudeSnippetResult.stderr).toBe(0); + const claudeSnippetPayload = JSON.parse(claudeSnippetResult.stdout) as { snippet: string }; + + await fs.mkdir(path.join(projectDir, ".codex"), { recursive: true }); + await fs.writeFile( + path.join(projectDir, ".codex", "config.toml"), + codexSnippetPayload.snippet.replace(`cwd = ${JSON.stringify(realProjectDir)}`, ""), + "utf8" + ); + await fs.writeFile(path.join(projectDir, ".mcp.json"), `${claudeSnippetPayload.snippet}\n`, "utf8"); + + expect(runCli(projectDir, ["hooks", "install"], { env: { HOME: homeDir } }).exitCode).toBe(0); + expect(runCli(projectDir, ["skills", "install"], { env: { HOME: homeDir } }).exitCode).toBe(0); + + const result = runCli( + callerDir, + ["mcp", "doctor", "--cwd", projectDir, "--json"], + { env: { HOME: homeDir } } + ); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + projectRoot: string; + serverName: string; + readOnlyRetrieval: boolean; + agentsGuidance: { + path: string; + exists: boolean; + status: string; + }; + commandSurface: { + install: boolean; + serve: boolean; + printConfig: boolean; + doctor: boolean; + }; + fallbackAssets: { + hookHelpersInstalled: boolean; + startupDoctorInstalled: boolean; + skillInstalled: boolean; + fallbackAvailable: boolean; + assets: Array<{ + id: string; + name: string; + installed: boolean; + status: string; + expectedVersion: string; + detectedVersion: string | null; + executableExpected: boolean; + executableOk: boolean | null; + }>; + }; + codexStack: { + status: string; + recommendedRoute: string; + preset: string; + assetVersion: string; + mcpReady: boolean; + hookCaptureReady: boolean; + hookRecallReady: boolean; + skillReady: boolean; + workflowConsistent: boolean; + }; + hosts: Array<{ + host: string; + status: string; + configCheck?: { + exists: boolean; + projectPinned: boolean; + }; + }>; + }; + + expect(payload.projectRoot).toBe(realProjectDir); + expect(payload.serverName).toBe("codex_auto_memory"); + expect(payload.readOnlyRetrieval).toBe(true); + expect(payload.commandSurface).toMatchObject({ + install: true, + serve: true, + printConfig: true, + doctor: true + }); + expect(payload.fallbackAssets).toMatchObject({ + hookHelpersInstalled: true, + startupDoctorInstalled: true, + skillInstalled: true, + fallbackAvailable: true + }); + expect(payload.fallbackAssets.assets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "post-session-sync", + name: "post-session-sync.sh", + installed: true, + status: "ok", + expectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + detectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + executableExpected: true, + executableOk: true + }), + expect.objectContaining({ + name: "memory-recall.sh", + installed: true, + status: "ok", + expectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + detectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + executableExpected: true, + executableOk: true + }), + expect.objectContaining({ + name: "memory-search.sh", + installed: true, + status: "ok", + expectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + detectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + executableExpected: true, + executableOk: true + }), + expect.objectContaining({ + name: "memory-timeline.sh", + installed: true, + status: "ok", + expectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + detectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + executableExpected: true, + executableOk: true + }), + expect.objectContaining({ + name: "memory-details.sh", + installed: true, + status: "ok", + expectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + detectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + executableExpected: true, + executableOk: true + }), + expect.objectContaining({ + name: "recall-bridge.md", + installed: true, + status: "ok", + expectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + detectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + executableExpected: false, + executableOk: null + }), + expect.objectContaining({ + name: "codex-auto-memory-recall SKILL.md", + installed: true, + status: "ok", + expectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + detectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + executableExpected: false, + executableOk: null + }) + ]) + ); + expect(payload.codexStack).toMatchObject({ + status: "warning", + recommendedRoute: "hooks-fallback", + preset: "state=auto, limit=8", + assetVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + mcpReady: false, + hookCaptureReady: true, + hookRecallReady: true, + skillReady: true, + workflowConsistent: true + }); + expect(payload.agentsGuidance).toMatchObject({ + path: path.join(realProjectDir, "AGENTS.md"), + exists: false, + status: "missing" + }); + expect(payload.hosts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + host: "codex", + status: "warning", + configCheck: expect.objectContaining({ + exists: true, + projectPinned: false + }) + }), + expect.objectContaining({ + host: "claude", + status: "ok", + configCheck: expect.objectContaining({ + exists: true, + projectPinned: true + }) + }), + expect.objectContaining({ + host: "gemini", + status: "missing", + configCheck: expect.objectContaining({ + exists: false + }) + }), + expect.objectContaining({ + host: "generic", + status: "manual" + }) + ]) + ); + }); + + it("keeps mcp doctor read-only and does not create memory layout", async () => { + const homeDir = await tempDir("cam-mcp-doctor-readonly-home-"); + const projectDir = await tempDir("cam-mcp-doctor-readonly-project-"); + const memoryRootParent = await tempDir("cam-mcp-doctor-readonly-memory-parent-"); + const memoryRoot = path.join(memoryRootParent, "memory-root"); + process.env.HOME = homeDir; + + await writeCamConfig(projectDir, makeAppConfig(), { + autoMemoryDirectory: memoryRoot + }); + + const result = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { + env: { HOME: homeDir } + }); + + expect(result.exitCode, result.stderr).toBe(0); + const payload = JSON.parse(result.stdout) as { + hosts: Array<{ host: string; status: string }>; + }; + expect(payload.hosts).toEqual([ + expect.objectContaining({ + host: "codex", + status: "missing" + }) + ]); + expect(await pathExists(memoryRoot)).toBe(false); + }); + + it("reports CODEX_HOME runtime skills path separately from the official skills path", async () => { + const homeDir = await tempDir("cam-mcp-doctor-codex-home-home-"); + const codexHome = await tempDir("cam-mcp-doctor-codex-home-codex-home-"); + const projectDir = await tempDir("cam-mcp-doctor-codex-home-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + process.env.CODEX_HOME = codexHome; + + expect( + runCli(projectDir, ["skills", "install"], { + env: { HOME: homeDir, CODEX_HOME: codexHome } + }).exitCode + ).toBe(0); + + const result = runCli(projectDir, ["mcp", "doctor", "--json"], { + env: { HOME: homeDir, CODEX_HOME: codexHome } + }); + expect(result.exitCode, result.stderr).toBe(0); + + expect(JSON.parse(result.stdout)).toMatchObject({ + fallbackAssets: { + skillDir: path.join(codexHome, "skills", "codex-auto-memory-recall"), + runtimeSkillDir: path.join(codexHome, "skills", "codex-auto-memory-recall"), + runtimeAssetDir: path.join(codexHome, "skills", "codex-auto-memory-recall"), + runtimeSource: "CODEX_HOME", + preferredInstallSurface: "runtime", + recommendedSkillInstallCommand: "cam skills install --surface runtime", + runtimeSkillInstalled: true, + runtimeSkillMatchesCanonical: true, + runtimeSkillReady: true, + officialUserSkillDir: path.join( + homeDir, + ".agents", + "skills", + "codex-auto-memory-recall" + ), + officialProjectSkillDir: path.join( + realProjectDir, + ".agents", + "skills", + "codex-auto-memory-recall" + ), + officialUserSkillInstalled: false, + officialProjectSkillInstalled: false, + officialUserSkillMatchesRuntime: false, + officialProjectSkillMatchesRuntime: false, + officialUserSkillReady: false, + officialProjectSkillReady: false, + installedSkillSurfaces: ["runtime"], + readySkillSurfaces: ["runtime"], + skillPathDrift: true, + skillInstalled: true + } + }); + }); + + it("reports explicit official skill copies while keeping runtime as the preferred surface", async () => { + const homeDir = await tempDir("cam-mcp-doctor-official-surface-home-"); + const projectDir = await tempDir("cam-mcp-doctor-official-surface-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + expect( + runCli(projectDir, ["skills", "install", "--surface", "official-user"], { + env: { HOME: homeDir } + }).exitCode + ).toBe(0); + + const result = runCli(projectDir, ["mcp", "doctor", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + expect(JSON.parse(result.stdout)).toMatchObject({ + fallbackAssets: { + runtimeSkillDir: path.join(homeDir, ".codex", "skills", "codex-auto-memory-recall"), + preferredInstallSurface: "runtime", + recommendedSkillInstallCommand: "cam skills install --surface runtime", + runtimeSkillInstalled: false, + runtimeSkillReady: false, + officialUserSkillDir: path.join( + homeDir, + ".agents", + "skills", + "codex-auto-memory-recall" + ), + officialProjectSkillDir: path.join( + realProjectDir, + ".agents", + "skills", + "codex-auto-memory-recall" + ), + officialUserSkillInstalled: true, + officialUserSkillMatchesRuntime: true, + officialUserSkillReady: true, + officialProjectSkillInstalled: false, + officialProjectSkillReady: false, + installedSkillSurfaces: ["official-user"], + readySkillSurfaces: ["official-user"], + skillInstalled: true + }, + codexStack: { + skillReady: true + } + }); + }); + + it("fails closed when CODEX_HOME is a relative path", async () => { + const homeDir = await tempDir("cam-mcp-doctor-relative-codex-home-home-"); + const projectDir = await tempDir("cam-mcp-doctor-relative-codex-home-project-"); + process.env.HOME = homeDir; + process.env.CODEX_HOME = "relative-codex-home"; + + const result = runCli(projectDir, ["mcp", "doctor", "--json"], { + env: { HOME: homeDir, CODEX_HOME: "relative-codex-home" } + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("CODEX_HOME"); + expect(result.stderr).toContain("absolute path"); + }); + + it("flags stale fallback assets when legacy files are present without the current contract marker", async () => { + const homeDir = await tempDir("cam-mcp-doctor-stale-home-"); + const projectDir = await tempDir("cam-mcp-doctor-stale-project-"); + process.env.HOME = homeDir; + + const hooksDir = path.join(homeDir, ".codex-auto-memory", "hooks"); + const skillDir = path.join(homeDir, ".codex", "skills", "codex-auto-memory-recall"); + await fs.mkdir(hooksDir, { recursive: true }); + await fs.mkdir(skillDir, { recursive: true }); + + for (const fileName of [ + "memory-recall.sh", + "memory-search.sh", + "memory-timeline.sh", + "memory-details.sh", + "startup-doctor.sh", + "recall-bridge.md" + ]) { + await fs.writeFile(path.join(hooksDir, fileName), "legacy asset without version marker\n", "utf8"); + } + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + "---\nname: codex-auto-memory-recall\n---\nlegacy skill without version marker\n", + "utf8" + ); + + const result = runCli(projectDir, ["mcp", "doctor", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + fallbackAssets: { + hookHelpersInstalled: boolean; + startupDoctorInstalled: boolean; + skillInstalled: boolean; + fallbackAvailable: boolean; + assets: Array<{ + name: string; + installed: boolean; + status: string; + expectedVersion: string; + detectedVersion: string | null; + }>; + }; + }; + expect(payload.fallbackAssets).toMatchObject({ + hookHelpersInstalled: false, + startupDoctorInstalled: false, + skillInstalled: false, + fallbackAvailable: false + }); + expect(payload.fallbackAssets.assets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "memory-recall.sh", + installed: true, + status: "stale", + expectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + detectedVersion: null + }), + expect.objectContaining({ + name: "codex-auto-memory-recall SKILL.md", + installed: true, + status: "stale", + expectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + detectedVersion: null + }) + ]) + ); + }); + + it("flags stale fallback assets when version markers remain but key content signatures are missing", async () => { + const homeDir = await tempDir("cam-mcp-doctor-marker-only-home-"); + const projectDir = await tempDir("cam-mcp-doctor-marker-only-project-"); + process.env.HOME = homeDir; + + const hooksDir = path.join(homeDir, ".codex-auto-memory", "hooks"); + const skillDir = path.join(homeDir, ".codex", "skills", "codex-auto-memory-recall"); + await fs.mkdir(hooksDir, { recursive: true }); + await fs.mkdir(skillDir, { recursive: true }); + + const shellMarker = `# cam:asset-version ${RETRIEVAL_INTEGRATION_ASSET_VERSION}\n`; + const markdownMarker = `\n`; + + await fs.writeFile( + path.join(hooksDir, "memory-recall.sh"), + `#!/bin/sh\n${shellMarker}echo broken\n`, + "utf8" + ); + await fs.writeFile( + path.join(hooksDir, "memory-search.sh"), + `#!/bin/sh\n${shellMarker}echo broken\n`, + "utf8" + ); + await fs.writeFile( + path.join(hooksDir, "memory-timeline.sh"), + `#!/bin/sh\n${shellMarker}echo broken\n`, + "utf8" + ); + await fs.writeFile( + path.join(hooksDir, "memory-details.sh"), + `#!/bin/sh\n${shellMarker}echo broken\n`, + "utf8" + ); + await fs.writeFile( + path.join(hooksDir, "startup-doctor.sh"), + `#!/bin/sh\n${shellMarker}echo broken\n`, + "utf8" + ); + await fs.writeFile( + path.join(hooksDir, "recall-bridge.md"), + `# Broken Recall Bridge\n\n${markdownMarker}\n`, + "utf8" + ); + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + `---\nname: codex-auto-memory-recall\n---\n\n${markdownMarker}\n`, + "utf8" + ); + + const result = runCli(projectDir, ["mcp", "doctor", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + fallbackAssets: { + hookHelpersInstalled: boolean; + startupDoctorInstalled: boolean; + skillInstalled: boolean; + fallbackAvailable: boolean; + assets: Array<{ + name: string; + installed: boolean; + status: string; + expectedVersion: string; + detectedVersion: string | null; + }>; + }; + }; + expect(payload.fallbackAssets).toMatchObject({ + hookHelpersInstalled: false, + startupDoctorInstalled: false, + skillInstalled: false, + fallbackAvailable: false + }); + expect(payload.fallbackAssets.assets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "memory-recall.sh", + installed: true, + status: "stale", + expectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + detectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION + }), + expect.objectContaining({ + name: "startup-doctor.sh", + installed: true, + status: "stale", + expectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + detectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION + }), + expect.objectContaining({ + name: "recall-bridge.md", + installed: true, + status: "stale", + expectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + detectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION + }), + expect.objectContaining({ + name: "codex-auto-memory-recall SKILL.md", + installed: true, + status: "stale", + expectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + detectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION + }) + ]) + ); + }); + + it("flags hook assets as stale when executable bits are missing", async () => { + const homeDir = await tempDir("cam-mcp-doctor-exec-home-"); + const projectDir = await tempDir("cam-mcp-doctor-exec-project-"); + process.env.HOME = homeDir; + + expect(runCli(projectDir, ["hooks", "install"], { env: { HOME: homeDir } }).exitCode).toBe(0); + expect(runCli(projectDir, ["skills", "install"], { env: { HOME: homeDir } }).exitCode).toBe(0); + + const brokenScriptPath = path.join(homeDir, ".codex-auto-memory", "hooks", "memory-recall.sh"); + await fs.chmod(brokenScriptPath, 0o644); + + const result = runCli(projectDir, ["mcp", "doctor", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + fallbackAssets: { + hookHelpersInstalled: boolean; + assets: Array<{ + id: string; + name: string; + status: string; + executableExpected: boolean; + executableOk: boolean | null; + }>; + }; + codexStack: { + status: string; + recommendedRoute: string; + hookRecallReady: boolean; + }; + }; + expect(payload.fallbackAssets.hookHelpersInstalled).toBe(false); + expect(payload.fallbackAssets.assets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "memory-recall", + name: "memory-recall.sh", + status: "stale", + executableExpected: true, + executableOk: false + }) + ]) + ); + expect(payload.codexStack).toMatchObject({ + status: "warning", + recommendedRoute: "cli-direct", + hookRecallReady: false + }); + }); + + it("distinguishes configured MCP wiring from operational readiness when cam is unavailable on PATH", async () => { + const homeDir = await tempDir("cam-mcp-doctor-command-home-"); + const projectDir = await tempDir("cam-mcp-doctor-command-project-"); + const emptyPathDir = await tempDir("cam-mcp-doctor-command-empty-path-"); + process.env.HOME = homeDir; + + const installResult = runCli(projectDir, ["mcp", "install", "--host", "codex", "--json"], { + env: { + HOME: homeDir, + PATH: await buildPathWithoutCam(emptyPathDir) + } + }); + expect(installResult.exitCode, installResult.stderr).toBe(0); + + const doctorResult = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { + env: { + HOME: homeDir, + PATH: await buildPathWithoutCam(emptyPathDir) + } + }); + expect(doctorResult.exitCode, doctorResult.stderr).toBe(0); + + const payload = JSON.parse(doctorResult.stdout) as { + codexStack: { + recommendedRoute: string; + mcpReady: boolean; + mcpOperationalReady: boolean; + camCommandAvailable: boolean; + }; + hosts: Array<{ + host: string; + status: string; + }>; + }; + + expect(payload.hosts).toEqual([ + expect.objectContaining({ + host: "codex", + status: "ok" + }) + ]); + expect(payload.codexStack).toMatchObject({ + recommendedRoute: "cli-direct", + mcpReady: true, + mcpOperationalReady: false, + camCommandAvailable: false + }); + }); + + it("reports an operational MCP route once cam is available on PATH", async () => { + const homeDir = await tempDir("cam-mcp-doctor-command-ready-home-"); + const projectDir = await tempDir("cam-mcp-doctor-command-ready-project-"); + const binDir = await tempDir("cam-mcp-doctor-command-ready-bin-"); + process.env.HOME = homeDir; + + await writeCamShim(binDir); + + const env = { + HOME: homeDir, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}` + }; + + const installResult = runCli(projectDir, ["mcp", "install", "--host", "codex", "--json"], { + env + }); + expect(installResult.exitCode, installResult.stderr).toBe(0); + + const doctorResult = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { + env + }); + expect(doctorResult.exitCode, doctorResult.stderr).toBe(0); + + const payload = JSON.parse(doctorResult.stdout) as { + codexStack: { + recommendedRoute: string; + mcpReady: boolean; + mcpOperationalReady: boolean; + camCommandAvailable: boolean; + }; + }; + + expect(payload.codexStack).toMatchObject({ + recommendedRoute: "mcp", + mcpReady: true, + mcpOperationalReady: true, + camCommandAvailable: true + }); + }); + + it("does not treat stray config tokens as valid codex wiring", async () => { + const homeDir = await tempDir("cam-mcp-doctor-false-positive-home-"); + const projectDir = await tempDir("cam-mcp-doctor-false-positive-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + await fs.mkdir(path.join(realProjectDir, ".codex"), { recursive: true }); + await fs.writeFile( + path.join(realProjectDir, ".codex", "config.toml"), + [ + `note = ${JSON.stringify(`codex_auto_memory cam mcp serve ${realProjectDir}`)}`, + "", + "[mcp_servers.other_server]", + 'command = "other"', + 'args = ["serve"]' + ].join("\n"), + "utf8" + ); + + const result = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + hosts: Array<{ + host: string; + status: string; + configCheck?: { + exists: boolean; + hasServerName: boolean; + hasCamCommand: boolean; + hasServeInvocation: boolean; + projectPinned: boolean; + }; + }>; + }; + expect(payload.hosts).toEqual([ + expect.objectContaining({ + host: "codex", + status: "warning", + configCheck: expect.objectContaining({ + exists: true, + hasServerName: false, + hasCamCommand: false, + hasServeInvocation: false, + projectPinned: false + }) + }) + ]); + }); + + it("reports warning when host config exists but cannot be parsed structurally", async () => { + const homeDir = await tempDir("cam-mcp-doctor-parse-home-"); + const projectDir = await tempDir("cam-mcp-doctor-parse-project-"); + process.env.HOME = homeDir; + + await fs.writeFile(path.join(projectDir, ".mcp.json"), "{ this is not valid json", "utf8"); + + const result = runCli(projectDir, ["mcp", "doctor", "--host", "claude", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + hosts: Array<{ + host: string; + status: string; + configCheck?: { + exists: boolean; + hasServerName: boolean; + hasCamCommand: boolean; + hasServeInvocation: boolean; + projectPinned: boolean; + }; + }>; + }; + expect(payload.hosts).toEqual([ + expect.objectContaining({ + host: "claude", + status: "warning", + configCheck: expect.objectContaining({ + exists: true, + hasServerName: false, + hasCamCommand: false, + hasServeInvocation: false, + projectPinned: false + }) + }) + ]); + }); + + it("rejects unsupported MCP hosts", async () => { + const homeDir = await tempDir("cam-mcp-invalid-home-"); + const projectDir = await tempDir("cam-mcp-invalid-project-"); + process.env.HOME = homeDir; + + const result = runCli(projectDir, ["mcp", "print-config", "--host", "cursor"], { + env: { HOME: homeDir } + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Unsupported MCP host "cursor"'); + }); + + it("rejects generic host installs because generic wiring remains manual-only", async () => { + const homeDir = await tempDir("cam-mcp-install-generic-home-"); + const projectDir = await tempDir("cam-mcp-install-generic-project-"); + process.env.HOME = homeDir; + + const result = runCli(projectDir, ["mcp", "install", "--host", "generic"], { + env: { HOME: homeDir } + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("generic"); + expect(result.stderr).toContain("manual-only"); + }); + + it("serves read-only retrieval MCP tools over stdio", async () => { + const homeDir = await tempDir("cam-mcp-home-"); + const projectDir = await tempDir("cam-mcp-project-"); + const memoryRoot = await tempDir("cam-mcp-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await store.forget("project", "pnpm", { archive: true }); + + const client = await connectCliMcpClient(projectDir, { + env: { HOME: homeDir } + }); + + try { + const { tools } = await client.listTools(); + expect(tools.map((tool) => tool.name).sort()).toEqual([ + "get_memory_details", + "search_memories", + "timeline_memories" + ]); + + const searchResult = await client.callTool({ + name: "search_memories", + arguments: { + query: "pnpm", + state: "archived", + limit: 5 + } + }); + const searchPayload = readStructuredContent( + searchResult as ToolCallResultLike + ); + + expect(searchPayload).toMatchObject({ + query: "pnpm", + state: "archived", + resolvedState: "archived", + fallbackUsed: false + }); + expect(searchPayload.results).toHaveLength(1); + expect(searchPayload.results[0]).toMatchObject({ + ref: "project:archived:workflow:prefer-pnpm", + state: "archived", + summary: "Prefer pnpm in this repository." + }); + expect(searchPayload.results[0]?.matchedFields).toEqual( + expect.arrayContaining(["summary", "details"]) + ); + expect(JSON.stringify(searchPayload)).not.toContain("Use pnpm instead of npm in this repository."); + + const ref = searchPayload.results[0]!.ref; + const timelineResult = await client.callTool({ + name: "timeline_memories", + arguments: { ref } + }); + const timelinePayload = readStructuredContent( + timelineResult as ToolCallResultLike + ); + expect(timelinePayload.ref).toBe(ref); + expect(timelinePayload.events.slice(0, 2)).toEqual([ + expect.objectContaining({ action: "archive", state: "archived" }), + expect.objectContaining({ action: "add", state: "active" }) + ]); + + const detailsResult = await client.callTool({ + name: "get_memory_details", + arguments: { ref } + }); + const detailsPayload = readStructuredContent( + detailsResult as ToolCallResultLike + ); + expect(detailsPayload).toMatchObject({ + ref, + path: store.getArchiveTopicFile("project", "workflow"), + entry: { + summary: "Prefer pnpm in this repository.", + details: ["Use pnpm instead of npm in this repository."] + } + }); + + const missingResult = await client.callTool({ + name: "get_memory_details", + arguments: { ref: "project:active:workflow:missing-entry" } + }); + expect("isError" in missingResult && missingResult.isError).toBe(true); + expect(JSON.stringify(missingResult)).toContain("No memory details were found"); + + const invalidTimelineResult = await client.callTool({ + name: "timeline_memories", + arguments: { ref: "not-a-valid-ref" } + }); + expect("isError" in invalidTimelineResult && invalidTimelineResult.isError).toBe(true); + expect(JSON.stringify(invalidTimelineResult)).toContain("Invalid memory ref"); + } finally { + await client.close(); + } + }, 30_000); + + it("anchors retrieval MCP to an explicit cwd when requested", async () => { + const homeDir = await tempDir("cam-mcp-cwd-home-"); + const projectDir = await tempDir("cam-mcp-cwd-project-"); + const callerDir = await tempDir("cam-mcp-cwd-caller-"); + const memoryRoot = await tempDir("cam-mcp-cwd-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const client = await connectCliMcpClient(callerDir, { + env: { HOME: homeDir }, + serverCwd: projectDir + }); + + try { + const result = await client.callTool({ + name: "search_memories", + arguments: { query: "pnpm", limit: 3 } + }); + const payload = readStructuredContent(result as ToolCallResultLike); + expect(payload.results).toEqual([ + expect.objectContaining({ + ref: "project:active:workflow:prefer-pnpm", + summary: "Prefer pnpm in this repository." + }) + ]); + } finally { + await client.close(); + } + }, 30_000); + + it("supports auto search state by preferring active results before archived fallback", async () => { + const homeDir = await tempDir("cam-mcp-auto-home-"); + const projectDir = await tempDir("cam-mcp-auto-project-"); + const memoryRoot = await tempDir("cam-mcp-auto-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await store.remember( + "project", + "workflow", + "historical-pnpm", + "Historical pnpm migration note.", + ["Old pnpm migration note kept for history."], + "Manual note." + ); + await store.forget("project", "historical pnpm", { archive: true }); + + const client = await connectCliMcpClient(projectDir, { + env: { HOME: homeDir } + }); + + try { + const preferredResult = await client.callTool({ + name: "search_memories", + arguments: { + query: "pnpm", + state: "auto", + limit: 5 + } + }); + const preferredPayload = readStructuredContent( + preferredResult as ToolCallResultLike + ); + expect(preferredPayload).toMatchObject({ + query: "pnpm", + state: "auto", + resolvedState: "active", + fallbackUsed: false + }); + expect(preferredPayload.results).toEqual([ + expect.objectContaining({ + ref: "project:active:workflow:prefer-pnpm", + state: "active", + summary: "Prefer pnpm in this repository." + }) + ]); + + const fallbackResult = await client.callTool({ + name: "search_memories", + arguments: { + query: "historical", + state: "auto", + limit: 5 + } + }); + const fallbackPayload = readStructuredContent( + fallbackResult as ToolCallResultLike + ); + expect(fallbackPayload).toMatchObject({ + query: "historical", + state: "auto", + resolvedState: "archived", + fallbackUsed: true + }); + expect(fallbackPayload.results).toEqual([ + expect.objectContaining({ + ref: "project:archived:workflow:historical-pnpm", + state: "archived", + summary: "Historical pnpm migration note." + }) + ]); + } finally { + await client.close(); + } + }, 30_000); + + it("uses the recommended search preset by default when MCP search omits state and limit", async () => { + const homeDir = await tempDir("cam-mcp-default-preset-home-"); + const projectDir = await tempDir("cam-mcp-default-preset-project-"); + const memoryRoot = await tempDir("cam-mcp-default-preset-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + for (let index = 1; index <= 9; index += 1) { + await store.remember( + "project", + "workflow", + `historical-pnpm-${index}`, + `Historical pnpm migration note ${index}.`, + [`Historical archive note ${index}.`], + "Manual note." + ); + } + await store.forget("project", "Historical pnpm migration note", { archive: true }); + + const client = await connectCliMcpClient(projectDir, { + env: { HOME: homeDir } + }); + + try { + const result = await client.callTool({ + name: "search_memories", + arguments: { + query: "historical" + } + }); + const payload = readStructuredContent(result as ToolCallResultLike); + + expect(payload).toMatchObject({ + query: "historical", + state: "auto", + resolvedState: "archived", + fallbackUsed: true + }); + expect(payload.results).toHaveLength(8); + expect(payload.results.every((entry) => entry.state === "archived")).toBe(true); + } finally { + await client.close(); + } + }, 30_000); + + it("keeps CLI and MCP retrieval aligned when both use the recommended explicit search preset", async () => { + const homeDir = await tempDir("cam-mcp-cli-parity-home-"); + const projectDir = await tempDir("cam-mcp-cli-parity-project-"); + const memoryRoot = await tempDir("cam-mcp-cli-parity-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "historical-pnpm-one", + "Historical pnpm migration note one.", + ["Old pnpm migration note one kept for history."], + "Manual note." + ); + await store.remember( + "project", + "workflow", + "historical-pnpm-two", + "Historical pnpm migration note two.", + ["Old pnpm migration note two kept for history."], + "Manual note." + ); + await store.forget("project", "historical pnpm", { archive: true }); + + const cliResult = runCli( + projectDir, + ["recall", "search", "historical", "--state", "auto", "--limit", "8", "--json"], + { + env: { HOME: homeDir } + } + ); + expect(cliResult.exitCode, cliResult.stderr).toBe(0); + const cliPayload = JSON.parse(cliResult.stdout) as SearchMemoriesResponse; + + const client = await connectCliMcpClient(projectDir, { + env: { HOME: homeDir } + }); + + try { + const mcpResult = await client.callTool({ + name: "search_memories", + arguments: { + query: "historical", + state: "auto", + limit: 8 + } + }); + const mcpPayload = readStructuredContent(mcpResult as ToolCallResultLike); + + expect(mcpPayload.query).toBe(cliPayload.query); + expect(mcpPayload.state).toBe(cliPayload.state); + expect(mcpPayload.resolvedState).toBe(cliPayload.resolvedState); + expect(mcpPayload.fallbackUsed).toBe(cliPayload.fallbackUsed); + expect(mcpPayload.results.map((result) => result.ref)).toEqual( + cliPayload.results.map((result) => result.ref) + ); + } finally { + await client.close(); + } + }, 30_000); + + it("keeps retrieval MCP read-only and does not create memory layout on first lookup", async () => { + const homeDir = await tempDir("cam-mcp-readonly-home-"); + const projectDir = await tempDir("cam-mcp-readonly-project-"); + const memoryRootParent = await tempDir("cam-mcp-readonly-memory-parent-"); + const memoryRoot = path.join(memoryRootParent, "memory-root"); + process.env.HOME = homeDir; + + await writeCamConfig(projectDir, makeAppConfig(), { + autoMemoryDirectory: memoryRoot + }); + + const client = await connectCliMcpClient(projectDir, { + env: { HOME: homeDir } + }); + + try { + const result = await client.callTool({ + name: "search_memories", + arguments: { query: "pnpm", limit: 3 } + }); + const payload = readStructuredContent(result as ToolCallResultLike); + expect(payload.results).toEqual([]); + } finally { + await client.close(); + } + + expect(await pathExists(memoryRoot)).toBe(false); + }, 30_000); + + it("keeps mcp install read-only with respect to memory layout", async () => { + const homeDir = await tempDir("cam-mcp-install-readonly-home-"); + const projectDir = await tempDir("cam-mcp-install-readonly-project-"); + const memoryRootParent = await tempDir("cam-mcp-install-readonly-memory-parent-"); + const memoryRoot = path.join(memoryRootParent, "memory-root"); + process.env.HOME = homeDir; + + await writeCamConfig(projectDir, makeAppConfig(), { + autoMemoryDirectory: memoryRoot + }); + + const result = runCli(projectDir, ["mcp", "install", "--host", "codex", "--json"], { + env: { HOME: homeDir } + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "codex", + action: "created", + readOnlyRetrieval: true + }); + expect(await pathExists(memoryRoot)).toBe(false); + }); +}); diff --git a/test/recall-command.test.ts b/test/recall-command.test.ts new file mode 100644 index 0000000..1e6a468 --- /dev/null +++ b/test/recall-command.test.ts @@ -0,0 +1,308 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { detectProjectContext } from "../src/lib/domain/project-context.js"; +import { MemoryStore } from "../src/lib/domain/memory-store.js"; +import { + makeAppConfig, + writeCamConfig +} from "./helpers/cam-test-fixtures.js"; +import { runCli } from "./helpers/cli-runner.js"; + +const tempDirs: string[] = []; +const originalHome = process.env.HOME; + +async function tempDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + process.env.HOME = originalHome; + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("runRecall", () => { + it("uses the recommended search preset by default when state and limit flags are omitted", async () => { + const homeDir = await tempDir("cam-recall-default-preset-home-"); + const projectDir = await tempDir("cam-recall-default-preset-project-"); + const memoryRoot = await tempDir("cam-recall-default-preset-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + + for (let index = 1; index <= 9; index += 1) { + await store.remember( + "project", + "workflow", + `historical-pnpm-${index}`, + `Historical pnpm migration note ${index}.`, + [`Historical archive note ${index}.`], + "Manual note." + ); + } + await store.forget("project", "Historical pnpm migration note", { archive: true }); + + const result = runCli(projectDir, ["recall", "search", "historical", "--json"]); + expect(result.exitCode).toBe(0); + + const output = JSON.parse(result.stdout) as { + state: string; + resolvedState: string; + fallbackUsed: boolean; + results: Array<{ ref: string; state: string; topic: string }>; + }; + expect(output).toMatchObject({ + state: "auto", + resolvedState: "archived", + fallbackUsed: true + }); + expect(output.results).toHaveLength(8); + expect(output.results.every((result) => result.state === "archived")).toBe(true); + }); + + it("prefers active memory before archived fallback when search state is auto", async () => { + const homeDir = await tempDir("cam-recall-auto-active-home-"); + const projectDir = await tempDir("cam-recall-auto-active-project-"); + const memoryRoot = await tempDir("cam-recall-auto-active-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await store.remember( + "project", + "workflow", + "historical-pnpm", + "Historical pnpm migration note.", + ["Old pnpm migration note kept for history."], + "Manual note." + ); + await store.forget("project", "historical pnpm", { archive: true }); + + const result = runCli(projectDir, ["recall", "search", "pnpm", "--state", "auto", "--json"]); + expect(result.exitCode).toBe(0); + + const output = JSON.parse(result.stdout) as { + state: string; + resolvedState: string; + fallbackUsed: boolean; + results: Array<{ ref: string; state: string; topic: string }>; + }; + expect(output).toMatchObject({ + state: "auto", + resolvedState: "active", + fallbackUsed: false + }); + expect(output.results).toEqual([ + expect.objectContaining({ + ref: "project:active:workflow:prefer-pnpm", + state: "active", + topic: "workflow" + }) + ]); + }); + + it("falls back to archived memory when search state is auto and active memory has no match", async () => { + const homeDir = await tempDir("cam-recall-auto-archived-home-"); + const projectDir = await tempDir("cam-recall-auto-archived-project-"); + const memoryRoot = await tempDir("cam-recall-auto-archived-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "historical-pnpm", + "Historical pnpm migration note.", + ["Old pnpm migration note kept for history."], + "Manual note." + ); + await store.forget("project", "historical pnpm", { archive: true }); + + const searchResult = runCli(projectDir, [ + "recall", + "search", + "historical", + "--state", + "auto", + "--json" + ]); + expect(searchResult.exitCode).toBe(0); + + const searchOutput = JSON.parse(searchResult.stdout) as { + state: string; + resolvedState: string; + fallbackUsed: boolean; + results: Array<{ ref: string; state: string; topic: string }>; + }; + expect(searchOutput).toMatchObject({ + state: "auto", + resolvedState: "archived", + fallbackUsed: true + }); + expect(searchOutput.results).toEqual([ + expect.objectContaining({ + ref: "project:archived:workflow:historical-pnpm", + state: "archived", + topic: "workflow" + }) + ]); + }); + + it("supports search, timeline, and details from the CLI surface for archived memory", async () => { + const homeDir = await tempDir("cam-recall-home-"); + const projectDir = await tempDir("cam-recall-project-"); + const memoryRoot = await tempDir("cam-recall-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await store.forget("project", "pnpm", { archive: true }); + + const searchResult = runCli(projectDir, [ + "recall", + "search", + "pnpm", + "--state", + "archived", + "--json" + ]); + expect(searchResult.exitCode).toBe(0); + + const searchOutput = JSON.parse(searchResult.stdout) as { + results: Array<{ ref: string; state: string; topic: string }>; + }; + expect(searchOutput.results).toHaveLength(1); + expect(searchOutput.results[0]).toMatchObject({ + ref: "project:archived:workflow:prefer-pnpm", + state: "archived", + topic: "workflow" + }); + + const ref = searchOutput.results[0]!.ref; + const timelineResult = runCli(projectDir, ["recall", "timeline", ref, "--json"]); + expect(timelineResult.exitCode).toBe(0); + const timelineOutput = JSON.parse(timelineResult.stdout) as { + events: Array<{ action: string }>; + }; + expect(timelineOutput.events.slice(0, 2).map((event) => event.action)).toEqual([ + "archive", + "add" + ]); + + const detailsResult = runCli(projectDir, ["recall", "details", ref, "--json"]); + expect(detailsResult.exitCode).toBe(0); + const detailsOutput = JSON.parse(detailsResult.stdout) as { + ref: string; + path: string; + entry: { summary: string }; + }; + expect(detailsOutput).toMatchObject({ + ref, + path: store.getArchiveTopicFile("project", "workflow"), + entry: { + summary: "Prefer pnpm in this repository." + } + }); + }); + + it("keeps recall search read-only and does not create memory layout on first lookup", async () => { + const homeDir = await tempDir("cam-recall-readonly-home-"); + const projectDir = await tempDir("cam-recall-readonly-project-"); + const memoryRootParent = await tempDir("cam-recall-readonly-memory-parent-"); + const memoryRoot = path.join(memoryRootParent, "memory-root"); + process.env.HOME = homeDir; + + await writeCamConfig(projectDir, makeAppConfig(), { + autoMemoryDirectory: memoryRoot + }); + + const result = runCli(projectDir, ["recall", "search", "pnpm", "--state", "auto", "--json"]); + expect(result.exitCode).toBe(0); + + const output = JSON.parse(result.stdout) as { + state: string; + resolvedState: string; + fallbackUsed: boolean; + results: unknown[]; + }; + expect(output).toMatchObject({ + state: "auto", + resolvedState: "archived", + fallbackUsed: true, + results: [] + }); + + await expect(fs.access(memoryRoot)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects invalid memory refs for timeline and details lookups", async () => { + const homeDir = await tempDir("cam-recall-invalid-ref-home-"); + const projectDir = await tempDir("cam-recall-invalid-ref-project-"); + process.env.HOME = homeDir; + + const timelineResult = runCli(projectDir, ["recall", "timeline", "not-a-valid-ref"], { + env: { HOME: homeDir } + }); + expect(timelineResult.exitCode).toBe(1); + expect(timelineResult.stderr).toContain("Invalid memory ref"); + + const detailsResult = runCli(projectDir, ["recall", "details", "not-a-valid-ref"], { + env: { HOME: homeDir } + }); + expect(detailsResult.exitCode).toBe(1); + expect(detailsResult.stderr).toContain("Invalid memory ref"); + }); +}); diff --git a/test/skills-command.test.ts b/test/skills-command.test.ts new file mode 100644 index 0000000..9997832 --- /dev/null +++ b/test/skills-command.test.ts @@ -0,0 +1,198 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { runCli } from "./helpers/cli-runner.js"; + +const tempDirs: string[] = []; +const originalHome = process.env.HOME; +const originalCodexHome = process.env.CODEX_HOME; + +async function tempDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + process.env.HOME = originalHome; + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("skills command", () => { + it("installs a Codex skill for progressive durable memory retrieval", async () => { + const homeDir = await tempDir("cam-skills-home-"); + const projectDir = await tempDir("cam-skills-project-"); + process.env.HOME = homeDir; + delete process.env.CODEX_HOME; + + const result = runCli(projectDir, ["skills", "install"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("codex-auto-memory-recall"); + expect(result.stdout).toContain("search -> timeline -> details"); + expect(result.stdout).toContain("read-only"); + expect(result.stdout).toContain("search_memories"); + expect(result.stdout).toContain('state: "auto"'); + expect(result.stdout).toContain("limit: 8"); + expect(result.stdout).toContain("memory-recall.sh"); + expect(result.stdout).toContain("recall-bridge.md"); + expect(result.stdout).toContain("cam mcp doctor"); + expect(result.stdout).toContain("cam memory"); + expect(result.stdout).toContain("cam session"); + + const skillDir = path.join(homeDir, ".codex", "skills", "codex-auto-memory-recall"); + const skillFile = await fs.readFile(path.join(skillDir, "SKILL.md"), "utf8"); + + expect(skillFile).toContain("search_memories"); + expect(skillFile).toContain("timeline_memories"); + expect(skillFile).toContain("get_memory_details"); + expect(skillFile).toContain('state: "auto"'); + expect(skillFile).toContain("limit: 8"); + expect(skillFile).toContain("cam recall search"); + expect(skillFile).toContain("--state auto"); + expect(skillFile).toContain("cam recall timeline"); + expect(skillFile).toContain("cam recall details"); + expect(skillFile).toContain("cam mcp doctor"); + expect(skillFile).toContain("cam hooks install"); + expect(skillFile).toContain("cam memory"); + expect(skillFile).toContain("cam session"); + }); + + it("installs skill assets under CODEX_HOME when it is set", async () => { + const homeDir = await tempDir("cam-skills-codex-home-home-"); + const codexHome = await tempDir("cam-skills-codex-home-codex-home-"); + const projectDir = await tempDir("cam-skills-codex-home-project-"); + process.env.HOME = homeDir; + process.env.CODEX_HOME = codexHome; + + const result = runCli(projectDir, ["skills", "install"]); + expect(result.exitCode).toBe(0); + + const skillDir = path.join( + codexHome, + "skills", + "codex-auto-memory-recall" + ); + const skillFile = await fs.readFile(path.join(skillDir, "SKILL.md"), "utf8"); + + expect(skillFile).toContain("cam:asset-version"); + expect(skillFile).toContain("search_memories"); + await expect( + fs.access( + path.join( + homeDir, + ".codex", + "skills", + "codex-auto-memory-recall", + "SKILL.md" + ) + ) + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("installs an explicit official user skill copy without changing the runtime default", async () => { + const homeDir = await tempDir("cam-skills-official-user-home-"); + const projectDir = await tempDir("cam-skills-official-user-project-"); + process.env.HOME = homeDir; + delete process.env.CODEX_HOME; + + const result = runCli(projectDir, ["skills", "install", "--surface", "official-user"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Skill surface: official-user"); + expect(result.stdout).toContain("official .agents/skills copy"); + + const officialSkillPath = path.join( + homeDir, + ".agents", + "skills", + "codex-auto-memory-recall", + "SKILL.md" + ); + const skillFile = await fs.readFile(officialSkillPath, "utf8"); + expect(skillFile).toContain("cam:asset-version"); + expect(skillFile).toContain("search_memories"); + + await expect( + fs.access( + path.join( + homeDir, + ".codex", + "skills", + "codex-auto-memory-recall", + "SKILL.md" + ) + ) + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("installs an explicit official project skill copy inside the project root", async () => { + const homeDir = await tempDir("cam-skills-official-project-home-"); + const projectDir = await tempDir("cam-skills-official-project-project-"); + process.env.HOME = homeDir; + delete process.env.CODEX_HOME; + + const result = runCli(projectDir, ["skills", "install", "--surface", "official-project"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Skill surface: official-project"); + + const officialSkillPath = path.join( + projectDir, + ".agents", + "skills", + "codex-auto-memory-recall", + "SKILL.md" + ); + const skillFile = await fs.readFile(officialSkillPath, "utf8"); + expect(skillFile).toContain("cam:asset-version"); + expect(skillFile).toContain("timeline_memories"); + + await expect( + fs.access( + path.join( + homeDir, + ".codex", + "skills", + "codex-auto-memory-recall", + "SKILL.md" + ) + ) + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("trims CODEX_HOME before choosing the runtime skill directory", async () => { + const homeDir = await tempDir("cam-skills-trimmed-codex-home-home-"); + const codexHome = await tempDir("cam-skills-trimmed-codex-home-codex-home-"); + const projectDir = await tempDir("cam-skills-trimmed-codex-home-project-"); + process.env.HOME = homeDir; + process.env.CODEX_HOME = ` ${codexHome} `; + + const result = runCli(projectDir, ["skills", "install"], { + env: { HOME: homeDir, CODEX_HOME: ` ${codexHome} ` } + }); + expect(result.exitCode, result.stderr).toBe(0); + + const trimmedSkillPath = path.join(codexHome, "skills", "codex-auto-memory-recall", "SKILL.md"); + const spacedSkillPath = path.join(` ${codexHome} `, "skills", "codex-auto-memory-recall", "SKILL.md"); + await expect(fs.access(trimmedSkillPath)).resolves.toBeUndefined(); + await expect(fs.access(spacedSkillPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("fails closed when CODEX_HOME is relative", async () => { + const homeDir = await tempDir("cam-skills-relative-codex-home-home-"); + const projectDir = await tempDir("cam-skills-relative-codex-home-project-"); + process.env.HOME = homeDir; + process.env.CODEX_HOME = "relative-codex-home"; + + const result = runCli(projectDir, ["skills", "install"], { + env: { HOME: homeDir, CODEX_HOME: "relative-codex-home" } + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("CODEX_HOME"); + expect(result.stderr).toContain("absolute path"); + }); +}); diff --git a/test/tarball-install-smoke.test.ts b/test/tarball-install-smoke.test.ts index 3fd525c..372a988 100644 --- a/test/tarball-install-smoke.test.ts +++ b/test/tarball-install-smoke.test.ts @@ -74,11 +74,16 @@ describe("tarball install smoke", () => { expect(versionResult.exitCode).toBe(0); expect(versionResult.stdout.trim()).toBe(packageJson.version); + const envWithBin = { + ...env, + PATH: `${path.join(installDir, "node_modules", ".bin")}${path.delimiter}${env.PATH ?? ""}` + }; + const sessionStatusResult = runCommandCapture( camBinaryPath(installDir), ["session", "status", "--json"], installDir, - env + envWithBin ); expect(sessionStatusResult.exitCode).toBe(0); @@ -90,5 +95,174 @@ describe("tarball install smoke", () => { expect(payload.projectLocation.exists).toBe(false); expect(payload.latestContinuityAuditEntry).toBeNull(); expect(payload.pendingContinuityRecovery).toBeNull(); + + const mcpInstallResult = runCommandCapture( + camBinaryPath(installDir), + ["mcp", "install", "--host", "codex", "--json"], + installDir, + envWithBin + ); + expect(mcpInstallResult.exitCode).toBe(0); + expect(JSON.parse(mcpInstallResult.stdout)).toMatchObject({ + host: "codex", + action: "created", + readOnlyRetrieval: true + }); + expect( + await fs.readFile(path.join(installDir, ".codex", "config.toml"), "utf8") + ).toContain("[mcp_servers.codex_auto_memory]"); + + const codexPrintConfigResult = runCommandCapture( + camBinaryPath(installDir), + ["mcp", "print-config", "--host", "codex", "--json"], + installDir, + envWithBin + ); + expect(codexPrintConfigResult.exitCode).toBe(0); + expect(JSON.parse(codexPrintConfigResult.stdout)).toMatchObject({ + host: "codex", + serverName: "codex_auto_memory", + targetFileHint: ".codex/config.toml", + agentsGuidance: { + targetFileHint: "AGENTS.md", + snippetFormat: "markdown" + } + }); + const codexPrintConfigPayload = JSON.parse(codexPrintConfigResult.stdout) as { + agentsGuidance: { snippet: string }; + }; + const applyGuidanceResult = runCommandCapture( + camBinaryPath(installDir), + ["mcp", "apply-guidance", "--host", "codex", "--json"], + installDir, + envWithBin + ); + expect(applyGuidanceResult.exitCode).toBe(0); + expect(JSON.parse(applyGuidanceResult.stdout)).toMatchObject({ + host: "codex", + action: "created", + managedBlockVersion: "codex-agents-guidance-v1" + }); + expect( + await fs.readFile(path.join(installDir, "AGENTS.md"), "utf8") + ).toContain(codexPrintConfigPayload.agentsGuidance.snippet); + + const claudePrintConfigResult = runCommandCapture( + camBinaryPath(installDir), + ["mcp", "print-config", "--host", "claude", "--json"], + installDir, + envWithBin + ); + expect(claudePrintConfigResult.exitCode).toBe(0); + expect(JSON.parse(claudePrintConfigResult.stdout)).toMatchObject({ + host: "claude", + serverName: "codex_auto_memory", + targetFileHint: ".mcp.json" + }); + + const hooksResult = runCommandCapture( + camBinaryPath(installDir), + ["hooks", "install"], + installDir, + envWithBin + ); + expect(hooksResult.exitCode).toBe(0); + expect( + await fs.readFile(path.join(homeDir, ".codex-auto-memory", "hooks", "memory-recall.sh"), "utf8") + ).toContain("cam:asset-version"); + + const skillsResult = runCommandCapture( + camBinaryPath(installDir), + ["skills", "install"], + installDir, + envWithBin + ); + expect(skillsResult.exitCode).toBe(0); + expect( + await fs.readFile( + path.join(homeDir, ".codex", "skills", "codex-auto-memory-recall", "SKILL.md"), + "utf8" + ) + ).toContain("cam:asset-version"); + + const officialSkillsResult = runCommandCapture( + camBinaryPath(installDir), + ["skills", "install", "--surface", "official-user"], + installDir, + envWithBin + ); + expect(officialSkillsResult.exitCode).toBe(0); + expect( + await fs.readFile( + path.join(homeDir, ".agents", "skills", "codex-auto-memory-recall", "SKILL.md"), + "utf8" + ) + ).toContain("cam:asset-version"); + + const integrationsResult = runCommandCapture( + camBinaryPath(installDir), + ["integrations", "install", "--host", "codex", "--json"], + installDir, + envWithBin + ); + expect(integrationsResult.exitCode).toBe(0); + expect(JSON.parse(integrationsResult.stdout)).toMatchObject({ + host: "codex", + stackAction: "unchanged", + skillsSurface: "runtime", + readOnlyRetrieval: true, + subactions: { + mcp: { action: "unchanged" }, + hooks: { action: "unchanged" }, + skills: { action: "unchanged", surface: "runtime" } + } + }); + + const integrationsApplyResult = runCommandCapture( + camBinaryPath(installDir), + ["integrations", "apply", "--host", "codex", "--json"], + installDir, + envWithBin + ); + expect(integrationsApplyResult.exitCode).toBe(0); + expect(JSON.parse(integrationsApplyResult.stdout)).toMatchObject({ + host: "codex", + stackAction: "unchanged", + skillsSurface: "runtime", + readOnlyRetrieval: true, + subactions: { + mcp: { action: "unchanged" }, + agents: { action: "unchanged" }, + hooks: { action: "unchanged" }, + skills: { action: "unchanged", surface: "runtime" } + } + }); + + const integrationsDoctorResult = runCommandCapture( + camBinaryPath(installDir), + ["integrations", "doctor", "--host", "codex", "--json"], + installDir, + envWithBin + ); + expect(integrationsDoctorResult.exitCode).toBe(0); + expect(JSON.parse(integrationsDoctorResult.stdout)).toMatchObject({ + host: "codex", + readOnlyRetrieval: true, + status: "ok", + recommendedRoute: "mcp", + recommendedPreset: "state=auto, limit=8", + preferredSkillSurface: "runtime", + recommendedSkillInstallCommand: "cam skills install --surface runtime", + installedSkillSurfaces: ["runtime", "official-user"], + readySkillSurfaces: ["runtime", "official-user"], + subchecks: { + mcp: { status: "ok" }, + agents: { status: "ok" }, + hookCapture: { status: "ok" }, + hookRecall: { status: "ok" }, + skill: { status: "ok" }, + workflowConsistency: { status: "ok" } + } + }); }, 60_000); }); From 622844ef5f6e078b056020b3d287c1df37af9eb5 Mon Sep 17 00:00:00 2001 From: blocks Date: Thu, 26 Mar 2026 11:19:51 +0800 Subject: [PATCH 02/62] docs: sync Codex-first hybrid integration contracts --- CONTRIBUTING.md | 14 ++- README.en.md | 189 +++++++++++++++++----------- README.ja.md | 233 ++++++++++++++++++++++------------- README.md | 231 ++++++++++++++++++++-------------- README.zh-TW.md | 189 ++++++++++++++++++---------- docs/README.en.md | 59 +++++---- docs/README.md | 50 +++++--- docs/architecture.en.md | 207 ++++++++++++++++++------------- docs/architecture.md | 221 +++++++++++++++++++++++---------- docs/claude-reference.en.md | 48 +++++--- docs/host-surfaces.md | 127 +++++++++++++++++++ docs/integration-strategy.md | 163 ++++++++++++++++++++++++ docs/native-migration.en.md | 66 +++++++--- docs/native-migration.md | 64 ++++++---- docs/release-checklist.md | 22 +++- docs/session-continuity.md | 4 +- test/docs-contract.test.ts | 166 +++++++++++++++++++++++-- 17 files changed, 1455 insertions(+), 598 deletions(-) create mode 100644 docs/host-surfaces.md create mode 100644 docs/integration-strategy.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bceab61..156a265 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thanks for helping build `codex-auto-memory`. ## What we are building -This project is not a generic note-taking tool. It is a Codex companion that tries to reproduce the observable behavior of Claude Code auto memory: +This project is not a generic note-taking tool. It is a Codex-first Hybrid memory runtime that currently delivers its strongest experience through the companion CLI and wrapper path while preserving a Markdown-first memory contract: - automatic memory capture after work - local Markdown storage @@ -13,6 +13,7 @@ This project is not a generic note-taking tool. It is a Codex companion that tri - worktree-aware repository memory sharing - temporary cross-session continuity through `cam session` - repository privacy auditing through `cam audit` +- future hook / skill / MCP-aware integration surfaces that must preserve the same auditable memory semantics When proposing changes, evaluate them against that product contract first. @@ -44,13 +45,16 @@ Use Node 20+ and `pnpm`. - Include screenshots or terminal output only when it helps explain the UX. - If you touch release-facing CLI behavior, validate `node dist/cli.js` or `pnpm test:dist-cli-smoke`. - If you touch packaging, release verification, or install-time CLI behavior, also validate `pnpm test:tarball-install-smoke`. +- If you touch `cam integrations`, `cam mcp doctor`, or shared readiness guidance, treat the change as release-facing even when the implementation is only additive. +- If you touch `cam mcp print-config`, `cam mcp apply-guidance`, `cam integrations apply`, Codex `AGENTS.md` guidance, or shared MCP/AGENTS snippet builders, treat the change as release-facing too. +- If you touch `cam skills install`, skill surface selection, or runtime vs official `.agents/skills` compatibility wording, treat the change as release-facing and verify both source tests and dist/tarball smoke. ## Current maintainer focus -- Prefer structural simplification over feature expansion in the next phase. +- Prefer structural simplification over unnecessary sprawl, but treat the issue-level memory goals and the new integration surfaces as intentional product expansion. - If you refactor repository structure, keep the command surface stable unless a behavior change is intentional and documented. -- Before borrowing ideas from similar tools such as `mem0`, first inspect their current public docs or repository context and extract only patterns that fit this project's companion-first posture. -- Use external research to improve module boundaries, reviewer surfaces, and maintainability, not to broaden the product scope by default. +- Before borrowing ideas from similar tools such as `mem0` or `claude-mem`, first inspect their current public docs or repository context and extract only patterns that fit this project's Markdown-first and Codex-first posture. +- Use external research to improve module boundaries, reviewer surfaces, lifecycle semantics, and integration surfaces, while keeping the current repository out of multi-host platform sprawl. ## Coding Guidelines @@ -79,6 +83,7 @@ If your change affects one of these areas, update the matching file: - reviewer continuity contract: `docs/session-continuity.md` - release-time reviewer checks: `docs/release-checklist.md` - future native compatibility: `docs/native-migration.md` +- integration direction and host boundaries: `docs/integration-strategy.md`, `docs/host-surfaces.md` - onboarding and positioning: `README.md` and `README.en.md` The repository now uses a bilingual public-doc setup: @@ -87,6 +92,7 @@ The repository now uses a bilingual public-doc setup: - `README.en.md` is the English landing page - `docs/claude-reference.*`, `docs/architecture.*`, and `docs/native-migration.*` are maintained in both Chinese and English - `docs/session-continuity.md` and `docs/release-checklist.md` are English-first maintainer/reviewer docs and should still be updated when reviewer surfaces or command contracts change +- `docs/integration-strategy.md` and `docs/host-surfaces.md` are currently Chinese-first strategy docs and should be kept aligned with the public README posture If you change shared meaning in one of those files, update the sibling language version in the same task or explicitly note the follow-up gap in your handoff. diff --git a/README.en.md b/README.en.md index d77c78f..1a7c97f 100644 --- a/README.en.md +++ b/README.en.md @@ -1,10 +1,10 @@

Codex Auto Memory

-

A local-first companion CLI that brings Claude-style auto memory workflows to Codex

+

A Markdown-first local memory runtime for Codex, evolving from a companion CLI into a hook/skill/MCP-aware hybrid workflow

简体中文 | 繁體中文 | - English + English | 日本語

@@ -26,15 +26,15 @@

> `codex-auto-memory` is not a generic note-taking app and not a cloud memory service. -> Its job is to recreate the observable Claude Code auto memory contract for today's Codex runtime with local Markdown files, compact startup injection, topic-file lookup on demand, and a clean migration seam toward future native memory features. +> It is a Markdown-first, local-first memory runtime for Codex. Today it is strongest as a Codex wrapper and companion CLI, and it is now explicitly evolving toward hook, skill, and MCP-aware integration surfaces without giving up auditable local Markdown files as the source of truth. --- **Three things to know up front:** -1. **What it does** — After each Codex session, it automatically extracts useful knowledge from the session log and writes it into local Markdown files. Those files are injected at next startup so Codex "remembers" your project. -2. **How it stores** — Everything is plain Markdown under `~/.codex-auto-memory/`. You can read, edit, and include it in code review at any time. -3. **Relation to Claude** — This is a companion CLI. It replicates the Claude Code auto memory workflow on top of Codex. It is not an Anthropic product and has no cloud component. +1. **What it does** — It extracts future-useful knowledge from Codex sessions, keeps it as local Markdown, and brings it back into future sessions. +2. **How it stores** — Durable memory stays in local Markdown under `~/.codex-auto-memory/`, with compact indexes and topic files rather than opaque cache. +3. **Where it is going** — The repository remains Codex-first, but it is no longer documenting only a narrow companion seam. The roadmap now explicitly includes lower-friction hook, skill, and MCP-friendly paths alongside the existing wrapper flow. --- @@ -42,6 +42,7 @@ - [Why this project exists](#why-this-project-exists) - [Who this is for](#who-this-is-for) +- [Current priorities](#current-priorities) - [Core capabilities](#core-capabilities) - [Capability matrix](#capability-matrix) - [Quick start](#quick-start) @@ -55,24 +56,25 @@ ## Why this project exists -Claude Code already exposes a fairly clear public auto memory contract: +Claude Code publicly exposes a relatively clear memory contract: -- memory is written automatically by the assistant +- memory can be written automatically by the assistant - memory is stored as local Markdown -- `MEMORY.md` is the compact startup entrypoint +- `MEMORY.md` acts as the compact startup entrypoint - only the first 200 lines are loaded at startup -- detail lives in topic files and is read on demand +- details live in topic files and are read on demand - worktrees in the same repository share project memory - `/memory` provides audit and edit controls -Codex already has useful primitives, but not the same complete public memory surface: +Codex already exposes useful building blocks, but not the same complete public memory surface: - `AGENTS.md` - multi-agent workflows -- local persistent sessions and rollout logs -- local `cam doctor` / feature-output signals for `memories` and `codex_hooks` +- local sessions and rollout logs +- configurable MCP servers and growing skill/subagent surfaces +- local `cam doctor` / feature-output readiness signals for `memories` and `codex_hooks` -`codex-auto-memory` fills that gap with a companion-first design and only a narrow compatibility seam instead of pretending native Codex memory is already ready for daily use. Near-term UX work stays focused on clearer `cam memory` / `cam session` reviewer flows. +`codex-auto-memory` exists to close that gap with a Codex-first implementation that keeps memory local, inspectable, and editable. The current repository is still most mature as a wrapper-driven companion layer, but its product direction is now broader: preserve the Markdown-first contract while also making memory easier to consume through future hook, skill, and MCP-aware flows. ## Who this is for @@ -80,45 +82,60 @@ Good fit: - Codex users who want a Claude-style auto memory workflow today - teams that want fully local, auditable, editable Markdown memory -- maintainers who need worktree-shared project memory with worktree-local continuity -- projects that want the user mental model to stay stable even if official Codex surfaces evolve later +- users who prefer explicit CLI control now but want more automation later +- maintainers who want a stable mental model even if Codex gains stronger native surfaces Not a good fit: -- users looking for a general note-taking or knowledge-base app +- users looking for a generic note-taking or knowledge-base app - teams that need account-level cloud memory -- users expecting full Claude `/memory` interaction depth today +- users expecting a full Claude `/memory` clone today + +## Current priorities + +The repository is currently optimizing for four concrete product goals: + +1. Automatically extract reusable long-term memory from conversations and tasks. +2. Automatically recall that memory in later sessions. +3. Support memory updates, deduplication, overwrite, and archive-friendly lifecycle handling. +4. Reduce the amount of manual memory-file maintenance users need to do. + +These goals now take priority over documenting the project only as a narrow migration seam. ## Core capabilities | Capability | What it means | | :-- | :-- | -| Automatic post-session sync | extracts stable knowledge from Codex rollout JSONL and writes it back into Markdown memory | -| Markdown-first memory | `MEMORY.md` and topic files are the product surface, not hidden cache | -| Compact startup injection | injects only the quoted `MEMORY.md` startup files that actually enter the payload, plus on-demand topic refs, instead of eager topic loading | +| Automatic post-session sync | extracts stable knowledge from Codex rollout JSONL and writes it back into durable Markdown memory | +| Automatic startup recall | compiles compact startup memory so durable knowledge can re-enter later sessions automatically | +| Markdown-first memory | `MEMORY.md` and topic files remain the product surface, not a hidden cache layer | +| Lifecycle-aware updates | supports explicit correction, dedupe, overwrite, delete, and reviewer-visible conflict suppression | +| Formal retrieval MCP surface | `cam mcp serve` exposes `search_memories`, `timeline_memories`, and `get_memory_details` as a read-only stdio retrieval plane | +| Project-scoped MCP install surface | `cam mcp install --host ` writes the recommended project-scoped host wiring for `codex_auto_memory` without changing the retrieval contract itself | | Worktree-aware storage | shares project memory across worktrees while keeping local continuity isolated | | Optional session continuity | separates temporary working state from durable memory | -| Reviewer surfaces | exposes `cam memory`, `cam session`, and `cam audit` for review and debugging | +| Integration-aware evolution | keeps the current wrapper flow while moving toward hook, skill, and MCP-friendly surfaces | +| Reviewer surfaces | exposes `cam memory`, `cam session`, `cam recall`, and `cam audit` for review and debugging | ## Capability matrix | Capability | Claude Code | Codex today | Codex Auto Memory | | :-- | :-- | :-- | :-- | -| Automatic memory writing | Built in | No complete public contract | Yes, via companion sync flow | +| Automatic memory writing | Built in | No complete public contract | Yes, via rollout-driven sync | | Local Markdown memory | Built in | No complete public contract | Yes | | `MEMORY.md` startup entrypoint | Built in | No | Yes | | 200-line startup budget | Built in | No | Yes | | Topic files on demand | Built in | No | Partial: startup exposes structured topic refs for later on-demand reads | -| Session continuity | Community patterns | No complete public contract | Yes, as a separate companion layer | +| Session continuity | Community patterns | No complete public contract | Yes, as a separate layer | | Worktree-shared project memory | Built in | No public contract | Yes | | Inspect / audit memory | `/memory` | No equivalent | `cam memory` | -| Native hooks / memory integration | Built in | Experimental / under development | Compatibility seam only | +| Skill / hook / MCP-aware evolution | Built in or strong host surfaces | Emerging / uneven | Now an explicit repository direction | + +`cam memory` remains intentionally reviewer-oriented. It shows the quoted startup files that actually entered the startup payload, the startup budget, on-demand topic refs, edit paths, and recent sync audit entries behind `--recent [count]`. -`cam memory` is intentionally an inspection and audit surface. It exposes the quoted startup files that actually made it into the startup payload, currently the scoped `MEMORY.md` / index content, plus the startup budget, on-demand topic refs, edit paths, and recent durable sync audit events behind `--recent [count]`. Those topic refs are lookup pointers, not proof that topic bodies were eagerly loaded at startup. -Recent durable sync audit events now also surface conservatively suppressed conflict candidates so contradictory rollout output does not silently merge into durable memory. -Those recent sync events come from `~/.codex-auto-memory/projects//audit/sync-log.jsonl` and only cover sync-flow `applied`, `no-op`, and `skipped` events. Manual `cam remember` / `cam forget` updates stay outside that audit stream by design. -When primary memory files were written but the reviewer sidecar did not complete, `cam memory` will try to expose a pending sync recovery marker so reviewers can see that partial-success state explicitly; that marker is only cleared when the same rollout/session later completes successfully, not by an unrelated successful sync. -Explicit updates still happen through `cam remember`, `cam forget`, or direct Markdown edits rather than a `/memory`-style in-command editor. +Those recent audit entries now explicitly surface conservatively suppressed conflict candidates so contradictory rollout output does not silently merge into durable memory. Explicit updates still happen through `cam remember`, `cam forget`, or direct Markdown edits. Future lower-friction integration paths should preserve that same auditable memory contract instead of replacing it. + +Duplicate writes against unchanged active memory, plus delete/archive requests that do not hit an active record, now surface as explicit `noop` reviewer results. They do not rewrite Markdown or append lifecycle history. ## Quick start @@ -148,23 +165,33 @@ cam init This creates `codex-auto-memory.json` in your project root (committed to Git) and `.codex-auto-memory.local.json` locally (gitignored by default). -### 4. Launch Codex through the wrapper (memory starts working) +### 4. Launch Codex through the wrapper ```bash cam run ``` -After each session ends, `cam` automatically extracts knowledge from the Codex rollout log and writes it into the memory files. +This is still the most mature end-to-end path today. After each session ends, `cam` can extract knowledge from the Codex rollout log and write it into the memory files automatically. -### 5. Inspect your memory +### 5. Inspect or correct memory ```bash -cam memory # show active memory files and startup budget -cam session status # show session continuity state -cam session refresh # regenerate continuity from provenance and replace the selected scope -cam remember "Always use pnpm instead of npm" # manually record a preference -cam forget "old debug note" # remove a stale entry -cam audit # check the repository for unexpected sensitive content +cam memory +cam recall search pnpm --state auto +cam mcp serve +cam integrations install --host codex +cam integrations apply --host codex +cam integrations doctor --host codex +cam mcp install --host codex +cam mcp print-config --host codex +cam mcp apply-guidance --host codex +cam mcp doctor +cam session status +cam session refresh +cam remember "Always use pnpm instead of npm" +cam forget "old debug note" +cam forget "old debug note" --archive +cam audit ``` ## Common commands @@ -173,24 +200,24 @@ cam audit # check the repository for unexpected sensitive content | :-- | :-- | | `cam run` / `cam exec` / `cam resume` | compile startup memory and launch Codex through the wrapper | | `cam sync` | manually sync the latest rollout into durable memory | -| `cam memory` | inspect the quoted startup files that actually entered the payload, on-demand topic refs, startup budget, edit paths, and durable sync audit events plus suppressed conflict candidates via `--recent [count]` | -| `cam remember` / `cam forget` | explicitly add or remove durable memory | -| `cam session save` | merge / incremental save; append rollout-derived continuity without cleaning stale state immediately | -| `cam session refresh` | replace / clean regeneration; rebuild continuity from the selected provenance and replace the selected scope | -| `cam session load` / `status` | continuity reviewer surface for the latest continuity diagnostics, including `confidence` / warnings when present, plus the latest audit drill-down, compact prior preview, and any pending continuity recovery marker | -| `cam session clear` / `open` | clear active continuity files or open the local continuity directory | -| `cam audit` | run privacy and secret-hygiene checks against the repository | -| `cam doctor` | inspect local companion wiring and native-readiness posture | - -## Audit Surface Map - -- `cam audit`: repository-level privacy and secret-hygiene audit. -- `cam memory --recent [count]`: durable sync audit for recent `applied`, `no-op`, and `skipped` sync events, without mixing in manual `remember` / `forget`; suppressed conflict candidates stay reviewer-visible here instead of silently merging. -- `cam session save`: the merge path for the continuity audit surface. It records the latest diagnostics, latest rollout, and latest audit drill-down, but it remains an incremental save and does not immediately clean polluted state. -- `cam session refresh`: the replace path for the continuity audit surface. It regenerates continuity from selected provenance and replaces the selected scope; `--json` additionally exposes `action`, `writeMode`, and `rolloutSelection`. -- `cam session load|status`: reviewer surface for the latest continuity diagnostics, latest rollout, latest audit drill-down, and a compact prior audit preview sourced from the continuity audit log that excludes the latest entry, coalesces consecutive repeats, and is not a full prior-history replay. Their `--json` output continues to expose raw recent audit entries, plus continuity `confidence` and warnings for conservative summaries. -- continuity reviewer warnings still belong to the audit/reviewer surface rather than the continuity body; the current implementation applies a minimal deterministic scrub so obvious reviewer warning prose is not written back into continuity Markdown. -- `pending continuity recovery marker`: a visible warning that continuity Markdown was written but the audit sidecar failed. It is not a general repair mechanism and is not equivalent to `cam session refresh`. +| `cam memory` | inspect startup files, topic refs, startup budget, edit paths, and recent durable sync audit events plus suppressed conflict candidates | +| `cam remember` / `cam forget` | explicitly add or remove durable memory; `cam forget --archive` moves matching entries into the archive layer | +| `cam recall search` / `timeline` / `details` | progressively retrieve durable memory through a search -> timeline -> details workflow; `search` now defaults to `state=auto, limit=8`, so active memory is checked before archived fallback while staying read-only | +| `cam mcp serve` | start a read-only retrieval MCP server that exposes the same workflow through `search_memories`, `timeline_memories`, and `get_memory_details` | +| `cam integrations install --host codex` | install the recommended Codex integration stack in one explicit step by writing project-scoped MCP wiring and refreshing the hook bridge bundle plus Codex skill assets; it defaults to the runtime skill target, but also accepts `--skill-surface runtime|official-user|official-project`; stays idempotent, Codex-only, and does not touch the Markdown memory store | +| `cam integrations apply --host codex` | explicitly apply the full Codex integration state: it keeps `integrations install` unchanged, but also orchestrates `cam mcp apply-guidance --host codex`; it defaults to the runtime skill target, but also accepts `--skill-surface runtime|official-user|official-project`; if `AGENTS.md` cannot be updated safely, the command returns `blocked` and preserves the additive fail-closed boundary | +| `cam integrations doctor --host codex` | inspect the current Codex integration stack through a thin read-only aggregation surface that reports the recommended route, recommended preset, subchecks, and minimum next steps; it now recommends `cam integrations apply --host codex` when multiple Codex stack surfaces are still missing, and keeps `cam mcp apply-guidance --host codex` as the precise next step when only the managed `AGENTS.md` block is missing or outdated | +| `cam mcp install --host ` | explicitly write the recommended project-scoped host config for `codex_auto_memory`; only that server entry is replaced, hooks/skills stay opt-in, and `generic` remains manual-only | +| `cam mcp print-config --host ` | print a ready-to-paste host snippet so the read-only retrieval plane can be wired into an existing MCP client with less manual setup; for `--host codex`, it also prints a recommended `AGENTS.md` snippet that teaches future Codex agents to prefer MCP and fall back to `cam recall` only when needed | +| `cam mcp apply-guidance --host codex` | create or update the Codex Auto Memory managed block inside the repository-level `AGENTS.md` through an additive, auditable, fail-closed flow; it only appends a new block or replaces the same marker block, and returns `blocked` if it cannot locate that block safely | +| `cam mcp doctor` | inspect the recommended project-scoped retrieval MCP wiring, project pinning, and hook/skill fallback assets; it now also adds a `codexStack` readiness summary for the recommended route, executable bits, shared asset version, and workflow consistency without modifying host config files | +| `cam session save` | merge / incremental save for continuity | +| `cam session refresh` | replace / clean regeneration for continuity | +| `cam session load` / `status` | inspect the continuity reviewer surface | +| `cam hooks` | manage the current local bridge / fallback recall bundle, including `memory-recall.sh`, compatibility wrappers, and `recall-bridge.md`; it is not an official Codex hook surface, and the bundle's recommended search preset is `state=auto`, `limit=8` | +| `cam skills` | install Codex skill assets with `cam skills install`; the default target remains the runtime surface, while `--surface runtime|official-user|official-project` enables explicit migration-prep copies on official `.agents/skills` paths; all surfaces teach the same MCP-first, CLI-fallback progressive durable-memory retrieval workflow and the same recommended search preset: `state=auto`, `limit=8` | +| `cam audit` | run privacy and secret-hygiene checks | +| `cam doctor` | inspect local wiring and native-readiness posture | ## How it works @@ -198,8 +225,9 @@ cam audit # check the repository for unexpected sensitive content - `local-first and auditable` - `Markdown files are the product surface` -- `companion-first, with a narrow compatibility seam` -- `session continuity` stays separate from durable memory +- `Codex-first hybrid runtime` +- `durable memory and session continuity remain separate` +- `wrapper-first today, integration-aware tomorrow` ### Runtime flow @@ -217,11 +245,13 @@ flowchart TD G --> K[Update shared and local continuity files] ``` -### Why the project does not switch to native memory yet +### Why the project does not switch to a native-first path yet + +- public Codex docs still do not define a Claude-equivalent native memory contract +- local `cam doctor --json` still exposes `memories` / `codex_hooks` more as readiness signals than as a stable primary implementation path +- the repository therefore continues to treat the wrapper flow as the strongest current implementation -- public Codex docs still do not define a full, stable native memory contract equivalent to Claude Code, and local `cam doctor --json` continues to treat `memories` / `codex_hooks` only as readiness signals rather than a trusted primary path -- local source inspection is useful when re-evaluating the compatibility seam, but not a stable product contract -- the repository therefore stays companion-first until public docs, runtime behavior, and CI-verifiable stability all improve together +The difference is product direction: this repository is no longer documenting hooks, skills, and MCP as mere distant future ideas. They are now part of the planned integration surface, provided they keep the same Markdown-first and auditable behavior contract. ## Storage layout @@ -247,7 +277,14 @@ Session continuity: /.codex-auto-memory/sessions/active.md ``` -See the architecture docs for the full storage and boundary breakdown. +If retrieval indexes are added later: + +- Markdown remains canonical. +- `cam recall` and `cam mcp serve` both stay read-only retrieval planes, not a second source of truth. +- `cam mcp serve` stays a read-only retrieval plane, not a second source of truth. +- SQLite / FTS / vector / graph layers remain sidecars only. + +See the architecture docs for the full boundary breakdown. ## Documentation hub @@ -260,6 +297,8 @@ See the architecture docs for the full storage and boundary breakdown. - [Claude reference contract (中文)](docs/claude-reference.md) | [English](docs/claude-reference.en.md) - [Architecture (中文)](docs/architecture.md) | [English](docs/architecture.en.md) +- [Integration strategy (中文)](docs/integration-strategy.md) +- [Host surfaces (中文)](docs/host-surfaces.md) - [Native migration strategy (中文)](docs/native-migration.md) | [English](docs/native-migration.en.md) ### Maintainer and reviewer docs @@ -272,11 +311,12 @@ See the architecture docs for the full storage and boundary breakdown. Current public-ready status: -- durable memory companion path: available -- topic-aware startup lookup: available -- session continuity companion layer: available +- durable memory path: available +- startup recall path: available - reviewer audit surfaces: available -- tagged GitHub Releases: the release workflow is defined with tarball artifacts as the target; before pushing the first real tag, confirm that the default branch exposes and activates that workflow; npm publish remains manual +- session continuity layer: available +- wrapper-driven Codex flow: available +- hook / skill / MCP-aware evolution: now part of the documented direction, but not yet the primary end-user path - native memory / native hooks primary path: not enabled and not trusted as the main implementation path ## Roadmap @@ -291,15 +331,16 @@ Current public-ready status: ### v0.2 -- stronger contradiction handling +- complete the issue-level memory goals, including the first shipped archive path through `cam forget --archive` - clearer `cam memory` and `cam session` reviewer UX -- tighter continuity diagnostics and reviewer packets, with explicit confidence and warning surfaces -- tighter release-facing verification through tarball install smoke so the `.tgz`-installed `cam` bin shim is exercised directly -- keep a compatibility seam for future hook surfaces +- stronger contradiction handling and explicit memory lifecycle documentation +- define and document hook, skill, and MCP-friendly integration surfaces without replacing the current Markdown-first contract +- ship the first progressive-disclosure retrieval surface through `cam recall search / timeline / details` ### v0.3+ -- continue tracking official Codex memory and hook surfaces without implying a primary-path change +- expand the Codex-first hybrid path on top of the new recall and archive-ready foundation +- evaluate which integration pieces should stay in this repo versus move into a host-adaptable shared runtime later - optional GUI or TUI browser - stronger cross-session diagnostics and confidence surfaces @@ -308,7 +349,7 @@ Current public-ready status: - Contribution guide: [CONTRIBUTING.md](./CONTRIBUTING.md) - License: [Apache-2.0](./LICENSE) -If you ever find a mismatch between the README, official docs, and local runtime observations, prefer: +If you find a mismatch between the README, official docs, and local runtime observations, prefer: 1. official product documentation 2. verified local behavior diff --git a/README.ja.md b/README.ja.md index 1b2a229..8fc4daa 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,6 +1,6 @@

Codex Auto Memory

-

Codex 向けに Claude-style auto memory ワークフローを再現する local-first companion CLI

+

Markdown-first のローカル memory runtime。Codex を主軸に、companion CLI から hook / skill / MCP-aware なハイブリッド運用へ進化中

简体中文 | 繁體中文 | @@ -25,102 +25,117 @@

-> `codex-auto-memory` は汎用メモアプリでもクラウド型メモリサービスでもありません。
-> 現在の Codex CLI に対して、ローカル Markdown、コンパクトな startup injection、必要時のみの topic file 読み出し、そして companion runtime を使い、Claude Code auto memory の観測可能な契約をできるだけ再現することが目的です。 +> `codex-auto-memory` は汎用ノートアプリでもクラウド記憶サービスでもありません。 +> これは Markdown-first / local-first の Codex 向け memory runtime です。現時点で最も成熟している入口は wrapper と CLI ですが、今後は hook・skill・MCP を取り込んだ低摩擦な統合面へ正式に進化していきます。 --- -**まず押さえるべき 3 点** +**最初に知っておくべき 3 点** -1. **何をするか**:Codex セッション終了後に有用な情報を抽出し、ローカル Markdown に書き戻します。次回起動時にそれを注入し、Codex がプロジェクトを「覚えている」状態を作ります。 -2. **どう保存するか**:すべて `~/.codex-auto-memory/` 配下の Markdown です。いつでも閲覧・編集でき、Git レビューにも載せられます。 -3. **Claude との関係**:これは companion CLI です。Codex 上で Claude Code auto memory の作業感を再現するためのもので、Anthropic の公式製品でもクラウド機能でもありません。 +1. **何をするか**: Codex セッションから将来も使える知識を抽出し、ローカル Markdown に保存し、次回以降の会話で再利用します。 +2. **どう保存するか**: `MEMORY.md` と topic files を中心とした Markdown が主表面であり、隠れた DB やキャッシュを主真相にはしません。 +3. **どこへ向かうか**: 現在も Codex-first ですが、今後は companion CLI に閉じず、hook / skill / MCP-aware なハイブリッド運用を正式な方向として扱います。 --- ## 目次 -- [このプロジェクトが存在する理由](#このプロジェクトが存在する理由) -- [どんな人向けか](#どんな人向けか) -- [主要機能](#主要機能) +- [なぜこのプロジェクトがあるのか](#なぜこのプロジェクトがあるのか) +- [誰に向いているか](#誰に向いているか) +- [現在の優先目標](#現在の優先目標) +- [コア機能](#コア機能) - [機能比較](#機能比較) - [クイックスタート](#クイックスタート) - [主要コマンド](#主要コマンド) -- [仕組み](#仕組み) +- [動作の仕組み](#動作の仕組み) - [保存レイアウト](#保存レイアウト) - [ドキュメント案内](#ドキュメント案内) - [現在の状態](#現在の状態) - [ロードマップ](#ロードマップ) - [コントリビュートとライセンス](#コントリビュートとライセンス) -## このプロジェクトが存在する理由 +## なぜこのプロジェクトがあるのか -Claude Code には比較的明確な auto memory 契約があります。 +Claude Code はすでに比較的はっきりした auto memory 契約を公開しています。 - AI が memory を自動で書く -- memory はローカル Markdown で保存される -- `MEMORY.md` が起動時の入口になる -- 起動時に読むのは先頭 200 行だけ -- 詳細は topic files に分かれ、必要になった時だけ読む +- memory はローカル Markdown に保存される +- `MEMORY.md` が起動時のエントリポイントになる +- 起動時は先頭 200 行だけ読む +- 詳細は topic files に分けて必要時に読む - 同じリポジトリの worktree 間で project memory を共有する -- `/memory` で監査・編集できる +- `/memory` で監査と編集ができる -一方で現在の Codex CLI には便利な基盤はあるものの、同等に完成した public memory surface はまだありません。 +一方、Codex は有用な基礎能力を持ちながらも、同等の memory product surface をまだ公開していません。 - `AGENTS.md` - multi-agent workflows -- local persistent sessions / rollout logs -- `cam doctor` や feature output で見える `memories` / `codex_hooks` signal +- local sessions と rollout logs +- 拡張されつつある MCP / skills / subagents 面 +- `cam doctor` や feature output に見える `memories` / `codex_hooks` signal -そこで `codex-auto-memory` は、native memory を既成事実にせず、companion-first で監査しやすいルートを提供します。現在の UX 改善は `cam memory` と `cam session` の reviewer surface をより分かりやすくすることに集中しています。 +`codex-auto-memory` はそのギャップを埋めるために存在します。Codex-first の現実に合わせて、ローカルで監査可能・編集可能な Markdown memory を主契約として維持しつつ、将来的には hook・skill・MCP などの統合面でも同じ記憶契約を使えるように進化させていく、という立ち位置です。 -## どんな人向けか +## 誰に向いているか -向いている人: +向いている人: -- 今すぐ Codex で Claude-style auto memory に近い体験がほしい人 -- memory を完全にローカル・可編集・監査可能な Markdown で持ちたいチーム -- worktree 間で project memory を共有しつつ、worktree-local continuity も分けたい人 -- 将来 Codex の公式 surface が変わっても、ユーザーの mental model を壊したくないメンテナ +- Codex で Claude-style auto memory に近い体験を今すぐ使いたい人 +- 記憶を完全にローカル・監査可能・編集可能な Markdown で管理したいチーム +- いまは CLI/workflow を使い、将来はもっと自動化された統合面も使いたい人 +- 公式 surface が変わってもユーザーの心象を大きく変えたくない保守者 -向いていない人: +向いていない人: -- 汎用ナレッジベースやメモアプリを求めている人 -- 現時点で Claude `/memory` の完全な操作性を期待している人 -- アカウント単位やクラウド同期型の記憶が必要な人 +- 汎用ナレッジベースやノートアプリを探している人 +- アカウント単位のクラウド記憶が必要な人 +- 今日の時点で Claude `/memory` と同等の完全な対話面を期待する人 -## 主要機能 +## 現在の優先目標 + +今の最重要目標は、以下の 4 つを製品として明確に満たすことです。 + +1. 対話やタスクから再利用可能な長期記憶を自動で抽出すること +2. その記憶を後続セッションで自動的に呼び戻すこと +3. 更新・重複排除・上書き・アーカイブを含む記憶ライフサイクルを持つこと +4. 手動で memory ファイルを保守する負担を減らすこと + +## コア機能 | 機能 | 説明 | | :-- | :-- | -| 自動 memory 同期 | Codex rollout JSONL から将来も有用な知識を抽出し、Markdown memory に書き戻す | -| Markdown-first | `MEMORY.md` と topic files 自体がプロダクト surface であり、隠れたキャッシュではない | -| コンパクトな起動注入 | 実際に payload に入った quoted `MEMORY.md` startup files と on-demand topic refs のみを注入し、topic body を eager load しない | -| worktree-aware | project memory を worktree 間で共有しつつ、local continuity は分離する | -| session continuity | 一時的な作業状態を durable memory から分離して扱う | -| reviewer surface | `cam memory`、`cam session`、`cam audit` で review・監査しやすい surface を提供する | +| 自動 post-session sync | Codex rollout JSONL から安定した知識を抽出し durable Markdown memory に書き戻す | +| 自動 startup recall | 緊凑な startup memory を組み立て、後続セッションへ durable knowledge を戻す | +| Markdown-first | `MEMORY.md` と topic files が主表面であり、二次的な導出物ではない | +| 記憶ライフサイクル | 明示的な訂正、重複排除、上書き、削除、reviewer 可視の conflict suppression に対応 | +| formal retrieval MCP surface | `cam mcp serve` が `search_memories` / `timeline_memories` / `get_memory_details` を read-only な stdio MCP surface として公開する | +| project-scoped MCP install surface | `cam mcp install --host ` が推奨される project-scoped 宿主設定を書き込み、MCP 配線の摩擦を下げる | +| worktree-aware | 同一 git リポジトリ内の worktree で project memory を共有しつつ local continuity は分離する | +| session continuity | 一時的な working state と durable memory を分離して扱う | +| integration-aware evolution | wrapper 主導の現在地を保ちつつ、hook / skill / MCP 統合へ正式に進む | +| reviewer surface | `cam memory` / `cam session` / `cam audit` による監査入口を提供する | ## 機能比較 -| 機能 | Claude Code | 現在の Codex | Codex Auto Memory | +| 機能 | Claude Code | Codex today | Codex Auto Memory | | :-- | :-- | :-- | :-- | -| memory の自動書き込み | Built in | 完全な公開契約は未整備 | companion sync flow で提供 | -| ローカル Markdown memory | Built in | 完全な公開契約は未整備 | 対応 | -| `MEMORY.md` 起動入口 | Built in | なし | あり | -| 200 行の起動予算 | Built in | なし | あり | -| topic files の必要時読込 | Built in | なし | 部分対応 | -| セッション continuity | コミュニティ解法が多い | 完全な公開契約は未整備 | 独立した companion layer として対応 | -| worktree 間の project memory 共有 | Built in | 公開契約なし | 対応 | -| inspect / audit memory | `/memory` | 相当コマンドなし | `cam memory` | -| native hooks / memory | Built in | Experimental / under development | compatibility seam のみ保持 | - -`cam memory` は inspection / audit surface として設計されています。
-実際に startup payload に入った quoted startup files、startup budget、on-demand topic refs、edit paths、さらに `--recent [count]` の recent durable sync audit を表示します。
-recent sync audit では、保守的に suppress された conflict candidates も reviewer-visible に保持され、矛盾する rollout 出力が silent merge されないようになっています。 +| 自動 memory 書き込み | Built in | 完全な公開契約なし | rollout-driven sync で対応 | +| ローカル Markdown memory | Built in | 完全な公開契約なし | 対応 | +| `MEMORY.md` 起動エントリ | Built in | なし | 対応 | +| 200 行起動予算 | Built in | なし | 対応 | +| topic files の遅延読込 | Built in | なし | 一部対応。起動時に refs を公開し、後で必要時に読む | +| session continuity | Community patterns | 完全な公開契約なし | 独立 layer として対応 | +| worktree 共有 project memory | Built in | 公開契約なし | 対応 | +| inspect / audit memory | `/memory` | 同等コマンドなし | `cam memory` | +| hook / skill / MCP-aware evolution | Built in または宿主能力が強い | 新興で不均一 | 公式方向として採用済み | + +`cam memory` は今後も reviewer-oriented な surface のままです。実際に startup payload に入った quoted startup files、startup budget、topic refs、edit paths、さらに `--recent [count]` で durable sync audit を見せます。 + +audit では保守的に suppress された conflict candidates も明示され、矛盾する rollout 出力が durable memory に静かに混ざらないようにします。将来 hook / skill / MCP の経路が増えても、同じ Markdown-first かつ監査可能な memory 契約を保つ前提です。 ## クイックスタート -### 1. Clone とインストール +### 1. Clone と install ```bash git clone https://github.com/Boulea7/Codex-Auto-Memory.git @@ -135,16 +150,14 @@ pnpm build pnpm link --global ``` -> これで `cam` コマンドを任意のディレクトリから使えます。 - -### 3. プロジェクト内で初期化 +### 3. プロジェクトで初期化 ```bash cd /your/project cam init ``` -これにより、プロジェクトルートに `codex-auto-memory.json` が作成され、ローカル専用の `.codex-auto-memory.local.json` も生成されます。 +`codex-auto-memory.json` がプロジェクトに作成され、ローカル用に `.codex-auto-memory.local.json` が作られます。 ### 4. wrapper 経由で Codex を起動 @@ -152,48 +165,87 @@ cam init cam run ``` -各セッション終了後、`cam` が rollout ログから情報を抽出し、memory ファイルへ自動で書き込みます。 +これが現在もっとも成熟しているエンドツーエンド経路です。セッション終了後、`cam` は rollout ログから知識を抽出して memory に反映します。 -### 5. 状態を確認 +### 5. memory を確認・修正 ```bash cam memory +cam recall search pnpm --state auto +cam mcp serve +cam integrations install --host codex +cam integrations apply --host codex +cam integrations doctor --host codex +cam mcp install --host codex +cam mcp print-config --host codex +cam mcp apply-guidance --host codex +cam mcp doctor cam session status cam session refresh cam remember "Always use pnpm instead of npm" cam forget "old debug note" +cam forget "old debug note" --archive cam audit ``` ## 主要コマンド -| コマンド | 用途 | +| コマンド | 役割 | | :-- | :-- | -| `cam run` / `cam exec` / `cam resume` | startup memory を組み立て、wrapper 経由で Codex を起動 | +| `cam run` / `cam exec` / `cam resume` | startup memory を生成して wrapper 経由で Codex を起動 | | `cam sync` | 最新 rollout を durable memory に手動同期 | -| `cam memory` | quoted startup files、on-demand topic refs、startup budget、edit paths、suppressed conflict candidates を含む durable sync audit を確認 | -| `cam remember` / `cam forget` | durable memory を明示的に追加・削除 | -| `cam session save` | merge / incremental save | -| `cam session refresh` | replace / clean regeneration | -| `cam session load` / `status` | continuity reviewer surface | -| `cam session clear` / `open` | active continuity を消す、または local continuity ディレクトリを開く | -| `cam audit` | privacy / secret hygiene チェック | -| `cam doctor` | companion wiring と native readiness posture を確認 | - -## 仕組み +| `cam memory` | startup files、topic refs、startup budget、edit paths、recent sync audit を確認 | +| `cam remember` / `cam forget` | durable memory の明示的な追加・削除。`cam forget --archive` は一致した項目をアーカイブ層へ移動する | +| `cam recall search` / `timeline` / `details` | `search -> timeline -> details` の progressive disclosure workflow で durable memory を段階的に取得する。`search` は `state=auto, limit=8` を既定値として使い、active を先に調べてヒットしなければ archived にフォールバックしつつ read-only を保つ | +| `cam mcp serve` | `search_memories` / `timeline_memories` / `get_memory_details` を通じて同じ retrieval contract を公開する read-only MCP server を起動する | +| `cam integrations install --host codex` | 推奨される Codex integration stack を一度に導入し、project-scoped MCP wiring を書き込みつつ、hook bridge bundle と Codex skill assets を更新する。skills は runtime target が既定だが、`--skill-surface runtime|official-user|official-project` も指定できる。明示的・冪等・Codex-only を保ち、Markdown memory store には触れない | +| `cam integrations apply --host codex` | 明示的・冪等・Codex-only のまま完全な integration state を適用する。`integrations install` の既存境界は変えず、その上で `cam mcp apply-guidance --host codex` も編成する。skills は runtime target が既定だが、`--skill-surface runtime|official-user|official-project` も指定できる。`AGENTS.md` を安全に更新できない場合は `blocked` を返し、additive / fail-closed 境界を守る | +| `cam integrations doctor --host codex` | 現在の Codex integration stack を薄い read-only 集約面として点検し、推奨ルート、推奨 preset、サブチェック結果、次の最小アクションを返す。複数の Codex stack 面が不足しているときは `cam integrations apply --host codex` を優先し、AGENTS guidance だけが不足している場合は `cam mcp apply-guidance --host codex` を正確な次手として案内する | +| `cam mcp install --host ` | 推奨される project-scoped 宿主設定を明示的に書き込み、`codex_auto_memory` の項目だけを更新する。hooks/skills は自動導入せず、`generic` は引き続き manual-only | +| `cam mcp print-config --host ` | ready-to-paste な接続スニペットを出力し、read-only retrieval plane を既存の MCP client に低摩擦で接続できるようにする。`--host codex` の場合は、将来の Codex エージェントに MCP 優先・`cam recall` フォールバックを教えるための推奨 `AGENTS.md` snippet も合わせて出力する | +| `cam mcp apply-guidance --host codex` | repo ルートの `AGENTS.md` 内にある Codex Auto Memory 管理 block を additive・監査可能・fail-closed に作成または更新する。同じ marker block の追加または置換だけを行い、安全に特定できない場合は書き換えず `blocked` を返す | +| `cam mcp doctor` | 推奨される project-scoped retrieval MCP の配線、project pinning、hook/skill fallback assets を read-only で点検し、さらに `codexStack` readiness によって推奨ルート、executable bit、共有 asset version、workflow consistency を要約する。ホスト設定は書き換えない | +| `cam session save` | continuity の merge / incremental save | +| `cam session refresh` | continuity の replace / clean regeneration | +| `cam session load` / `status` | continuity reviewer surface を確認 | +| `cam hooks` | 現在の local bridge / fallback recall bundle を管理し、`memory-recall.sh`、互換 wrapper、`recall-bridge.md` を通じて今後の hook / skill / MCP-aware retrieval に備える。これは公式な Codex hook surface ではなく、推奨検索 preset は `state=auto`、`limit=8` | +| `cam skills` | `cam skills install` で Codex skill を導入する。既定 target は runtime のままだが、`--surface runtime|official-user|official-project` を使えば公式 `.agents/skills` 経路向けの明示的な互換コピーも置ける。どの surface でも、MCP-first / CLI-fallback の段階的 durable memory retrieval workflow と推奨検索 preset `state=auto`, `limit=8` を共有する | +| `cam audit` | プライバシーと secret hygiene を監査 | +| `cam doctor` | ローカル wiring と native-readiness を確認 | + +## 動作の仕組み ### 設計原則 - `local-first and auditable` - `Markdown files are the product surface` -- `companion-first, with a narrow compatibility seam` -- `session continuity` と `durable memory` は明確に分離 +- `Codex-first hybrid runtime` +- `durable memory` と `session continuity` を分離 +- `wrapper-first today, integration-aware tomorrow` + +### 実行フロー + +```mermaid +flowchart TD + A[Codex セッション開始] --> B[startup memory を生成] + B --> C[quoted MEMORY.md と topic refs を注入] + C --> D[Codex 実行] + D --> E[rollout JSONL を読む] + E --> F[candidate memory operations を抽出] + E --> G[optional continuity summary] + F --> H[contradiction review と conservative suppression] + H --> I[MEMORY.md と topic files を更新] + I --> J[durable sync audit を追記] + G --> K[shared / local continuity を更新] +``` + +### なぜまだ native-first ではないのか -### なぜ今すぐ native memory に切り替えないのか +- 公開された Codex ドキュメントは Claude Code 相当の完全な native memory 契約をまだ定義していません +- `cam doctor --json` に見える `memories` / `codex_hooks` も、今は readiness signal の性格が強いです +- そのため現在もっとも信頼できるのは wrapper-first の主線です -- 公開された Codex ドキュメントは、Claude Code 相当の完全で安定した native memory 契約をまだ定義していない -- ローカルの `cam doctor --json` でも、`memories` / `codex_hooks` は readiness signal として見えているだけで trusted primary path ではない -- そのため、公開ドキュメント・実行時安定性・CI での検証可能性が揃うまでは companion-first を維持する +ただし方向性は変わりました。hooks、skills、MCP は「いつかの案」ではなく、Markdown-first 契約を壊さない範囲で正式に取り込んでいく統合面として扱います。 ## 保存レイアウト @@ -227,6 +279,8 @@ Session continuity: - [Documentation Hub (English)](docs/README.en.md) - [Claude reference contract (中文)](docs/claude-reference.md) | [English](docs/claude-reference.en.md) - [Architecture (中文)](docs/architecture.md) | [English](docs/architecture.en.md) +- [集成演进策略(中文)](docs/integration-strategy.md) +- [宿主能力面(中文)](docs/host-surfaces.md) - [Native migration strategy (中文)](docs/native-migration.md) | [English](docs/native-migration.en.md) - [Session continuity design](docs/session-continuity.md) - [Release checklist](docs/release-checklist.md) @@ -234,11 +288,12 @@ Session continuity: ## 現在の状態 -- durable memory companion path: available -- topic-aware startup lookup: available -- session continuity companion layer: available +- durable memory path: available +- startup recall path: available - reviewer audit surfaces: available -- tagged GitHub Releases: release workflow は tarball artifact を対象として定義済み。最初の real tag を push する前に、default branch 上でその workflow が表示され、active になっていることを確認してください。npm publish は引き続き手動です +- session continuity layer: available +- wrapper-driven Codex flow: available +- hook / skill / MCP-aware evolution: 方向性として明文化済み。ただし最も成熟した利用経路ではまだない - native memory / native hooks primary path: not enabled and not trusted as the main implementation path ## ロードマップ @@ -253,15 +308,15 @@ Session continuity: ### v0.2 -- より堅い contradiction handling +- issue のコア要求を満たす: 自動抽出、自動再呼び出し、更新/重複排除/上書き/アーカイブのライフサイクル、手動保守負担の削減 - `cam memory` と `cam session` の reviewer UX 改善 -- continuity diagnostics と reviewer packet の整理、`confidence` / warnings の明示 -- tarball install smoke を含む release-facing 検証の強化 -- 将来の hook surface に備えた compatibility seam の維持 +- contradiction handling と memory lifecycle の強化 +- Markdown-first 契約を崩さずに hook / skill / MCP-friendly integration surfaces を定義・公開 ### v0.3+ -- 公式 Codex memory / hooks surface を継続的に追跡 +- Codex-first hybrid 路線をさらに進め、retrieval・skill・hook integration を強化 +- どの統合能力をこのリポジトリに残し、どれを将来の共有 runtime に抽出すべきか再評価する - optional GUI / TUI browser - より強い cross-session diagnostics と confidence surface @@ -270,10 +325,10 @@ Session continuity: - Contribution guide: [CONTRIBUTING.md](./CONTRIBUTING.md) - License: [Apache-2.0](./LICENSE) -README、公式ドキュメント、ローカル実行結果のあいだで食い違いを見つけた場合は、次の順で信頼してください。 +README、公式ドキュメント、ローカル実行結果にズレがある場合は、次の順で信頼してください。 1. 公式プロダクトドキュメント 2. 再現可能なローカル挙動 3. 不確実性を明示した記述 -根拠の弱い断定より、確認可能な証拠を優先してください。 +根拠の弱い断定より、検証可能な事実を優先してください。 diff --git a/README.md b/README.md index 171b634..9765bb0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

Codex Auto Memory

-

为 Codex 复现 Claude-style auto memory 工作流的 local-first companion CLI

+

面向 Codex 的 Markdown-first 本地记忆运行层,正在从 companion CLI 演进为 Codex-first Hybrid memory system

简体中文 | 繁體中文 | @@ -25,25 +25,26 @@

-> `codex-auto-memory` 不是通用笔记软件,也不是云端记忆服务。 -> 它的目标是:在今天的 Codex CLI 上,用本地 Markdown、紧凑 startup injection、按需 topic file 读取与 companion runtime,尽可能复现 Claude Code auto memory 的可观察产品契约。 +> `codex-auto-memory` 不是通用笔记软件,也不是云端记忆服务。 +> 它的目标是:在今天的 Codex CLI 上,以本地 Markdown 为主存储表面,先用 companion-first 的方式提供可靠记忆能力,再逐步补齐 hooks、skills、MCP 等更自动化的 integration surfaces。 --- **三个要点,快速定位:** -1. **它做什么** — 每次 Codex 会话结束后,自动把有用的信息提取出来,写进本地 Markdown 文件,下次启动时注入给 Codex,让它"记得"你的项目。 -2. **它怎么存** — 全部是本地 Markdown 文件,放在 `~/.codex-auto-memory/`,你随时可以查看、编辑、纳入 Git 审查。 -3. **它和 Claude 的关系** — 这是一个 companion CLI,目标是在 Codex 上复现 Claude Code auto memory 的工作方式。不是 Claude 官方产品,不涉及云端。 +1. **它做什么** — 为 Codex 提供 durable memory、session continuity、startup recall,以及面向 hooks / skills / MCP 的演进式记忆基础设施。 +2. **它怎么存** — memory 的 canonical source of truth 仍然是本地 Markdown,而不是数据库或云端缓存。 +3. **它现在处于什么阶段** — 当前最稳的主入口仍是 `cam run` / wrapper;同时产品方向已经正式转向 `Codex-first Hybrid`,不再把 hook / skill / MCP 仅视为 future bridge。 --- ## 目录 - [为什么这个项目存在](#为什么这个项目存在) -- [这个项目适合谁](#这个项目适合谁) +- [当前定位](#当前定位) +- [当前主任务](#当前主任务) - [核心能力](#核心能力) -- [能力对照](#能力对照) +- [集成方向](#集成方向) - [快速开始](#快速开始) - [常用命令](#常用命令) - [工作方式](#工作方式) @@ -65,60 +66,80 @@ Claude Code 已经公开了一套相对清晰的 auto memory 产品契约: - 同一仓库的不同 worktree 共享 project memory - `/memory` 用来审查和编辑 memory -而今天的 Codex CLI 已经有不少有价值的基础能力,但还没有公开同等完整的 memory product surface: +而今天的 Codex CLI 已经具备很多可利用的基础能力,但仍没有公开同等完整、稳定、可验证的 memory 产品面: - `AGENTS.md` - multi-agent workflows - 本地 persistent sessions / rollout logs - 本地 `cam doctor` / feature output 里可见的 `memories`、`codex_hooks` signal +- MCP、skills、rules 等可扩展能力 -`codex-auto-memory` 的价值,就是在官方 native memory 还没有稳定公开之前,先提供一条干净、可审计的 companion-first 路线,并只保留一条窄 compatibility seam。当前 UX 规划重点是继续收紧 `cam memory` / `cam session` 的 reviewer 体验。 +`codex-auto-memory` 的价值,不再只是“做一个 CLI companion”,而是先以当前最稳的 `companion-first` 路线提供可靠的 Codex 记忆体验,再把它演进成一个 **Codex-first Hybrid memory system**:既服务于喜欢显式 `cam` 命令的用户,也服务于希望通过 hooks、skills、MCP 等方式让代理自动使用记忆能力的用户。 -## 这个项目适合谁 +## 当前定位 -适合: +当前仓库的公开定位应理解为: -- 想在 Codex 中获得更接近 Claude-style auto memory 工作流的用户 -- 希望 memory 完全本地、完全可编辑、可以直接放进 Git 审查语境里的团队 -- 需要在多个 worktree 之间共享 project memory,同时保留 worktree-local continuity 的工程流 -- 希望未来即使官方 surface 变化,也不需要重建用户心智模型的维护者 +- **Codex-first**:当前主宿主仍是 Codex,而不是多宿主统一平台 +- **Markdown-first**:`MEMORY.md` 与 topic files 仍是产品表面与主真相 +- **Hybrid**:主入口仍是 wrapper + CLI,但 hooks / skills / MCP-aware integration 已经进入正式演进方向 +- **companion-first implementation, integration-aware roadmap**:现阶段最稳实现依旧是 companion runtime,但产品不再把 hooks / skills / MCP 只写成远期灵感 -不适合: +这意味着: -- 想把它当通用知识库、笔记软件或云端同步服务的人 -- 期待现阶段直接替代 Claude `/memory` 全部交互能力的人 -- 需要账号级个性化记忆或跨设备云端记忆的人 +- 当前仓库不会直接重写成 `claude-mem` 式 DB-first / worker-first 系统 +- 当前仓库会继续优先把 Codex 场景跑通 +- 后续实现可以同时覆盖 `cam` 命令用户与“希望让代理自己使用记忆”的用户 + +## 当前主任务 + +接下来这个仓库的主任务,按 issue 的要求正式收敛为 4 件事: + +1. **自动从对话或任务过程中提取可复用的长期记忆** +2. **在后续会话中自动召回这些记忆** +3. **支持记忆更新、去重、覆盖或归档** +4. **尽量减少手动维护 memory 文件的成本** + +这 4 件事是当前仓库的产品优先级,不再只是零散增强项。 ## 核心能力 -| 能力 | 说明 | -| :-- | :-- | -| 自动 memory 同步 | 会话结束后从 Codex rollout JSONL 中提取稳定、未来有用的信息并写回 Markdown memory | -| Markdown-first | `MEMORY.md` 与 topic files 就是产品表面,而不是内部缓存 | -| 紧凑启动注入 | 启动时只注入真正进入 payload 的 quoted `MEMORY.md` startup files,并附带按需 topic refs,不做 eager topic loading | -| worktree-aware | project memory 在同一 git 仓库的 worktree 间共享,project-local 仍保持隔离 | -| session continuity | 临时 working state 与 durable memory 分层存储、分层加载 | -| reviewer surface | `cam memory` / `cam session` / `cam audit` 为维护者和 reviewer 提供可核查的审查入口 | - -## 能力对照 - -| 能力 | Claude Code | Codex today | Codex Auto Memory | -| :-- | :-- | :-- | :-- | -| 自动写 memory | Built in | 没有完整公开契约 | 通过 companion sync flow 提供 | -| 本地 Markdown memory | Built in | 没有完整公开契约 | 支持 | -| `MEMORY.md` 启动入口 | Built in | 没有 | 支持 | -| 200 行启动预算 | Built in | 没有 | 支持 | -| topic files 按需读取 | Built in | 没有 | 部分支持,启动时暴露 topic refs,供后续按需读取 | -| 跨会话 continuity | 社区方案较多 | 没有完整公开契约 | 作为独立 companion layer 支持 | -| worktree 共享 project memory | Built in | 没有公开契约 | 支持 | -| inspect / audit memory | `/memory` | 无等价命令 | `cam memory` | -| native hooks / memory | Built in | Experimental / under development | 当前只保留 compatibility seam | - -`cam memory` 当前是 inspection / audit surface:它会暴露真正进入 startup payload 的 quoted startup files(当前是各 scope 的 `MEMORY.md` / index 内容)、startup budget、按需 topic refs、edit paths,以及 `--recent [count]` 下的 recent durable sync audit。这里的 topic refs 只是按需定位信息,不表示 topic body 已在启动阶段 eager 读取。 -recent durable sync audit 现在也会显式暴露被保守 suppress 的 conflict candidates,避免在同一 rollout 或与现有 durable memory 冲突时发生静默 merge。 -这些 recent sync events 来自 `~/.codex-auto-memory/projects//audit/sync-log.jsonl`,只覆盖 sync flow 的 `applied` / `no-op` / `skipped` 事件,不包含 manual `cam remember` / `cam forget`。 -如果主 memory 文件已经写入,但 reviewer sidecar(audit / processed-state)没有完整落盘,`cam memory` 会尽力暴露一个 pending sync recovery marker,帮助 reviewer 识别 partial-success 状态;该 marker 只会在同一 rollout/session 后续成功补齐时清理,不会被不相关的成功 sync 顺手抹掉。 -显式更新仍通过 `cam remember`、`cam forget` 或直接编辑 Markdown 文件完成,而不是提供 `/memory` 风格的命令内编辑器。 +| 能力 | 当前状态 | 说明 | +| :-- | :-- | :-- | +| 自动 durable memory sync | 已有主路径 | 会话结束后从 Codex rollout JSONL 中提取稳定、未来有用的信息并写回 Markdown memory | +| Markdown-first canonical store | 已有主路径 | `MEMORY.md` 与 topic files 就是产品表面,而不是内部缓存 | +| 紧凑 startup recall | 已有主路径 | 启动时注入真正进入 payload 的 quoted `MEMORY.md` startup files,并附带按需 topic refs | +| worktree-aware project identity | 已有主路径 | 同一 git 仓库的 worktree 共享 project memory,project-local 仍保持隔离 | +| session continuity | 已有主路径 | 临时 working state 与 durable memory 分层存储、分层加载 | +| conflict review / conservative suppression | 已有主路径 | 冲突 candidate 不静默 merge,而是显式 suppress 并暴露 reviewer 信息 | +| explicit correction | 已有主路径 | 支持 `cam remember` / `cam forget` 与显式更正带来的 replace/delete 语义 | +| archive lifecycle | 已有首批实现 | 支持 `cam forget --archive` 将长期但不再活跃的信息转入可检索归档层,而不是只能 delete | +| search / timeline / detail retrieval | 已有首批实现 | 提供 `cam recall search` / `timeline` / `details`,以 progressive disclosure 方式检索记忆 | +| formal retrieval MCP surface | 本轮新增 | 提供 `cam mcp serve`,通过 `search_memories` / `timeline_memories` / `get_memory_details` 暴露只读 retrieval plane | +| project-scoped MCP install surface | 本轮新增 | 提供 `cam mcp install --host `,显式写入推荐的 project-scoped 宿主配置,降低 MCP 接线摩擦 | +| noop-aware lifecycle audit | 已有首批实现 | 相同 active memory 的重复写入、以及缺失 active 目标的 delete/archive,会显式记为 `noop` reviewer 结果,而不再静默重写 Markdown | +| hook / skill / MCP-aware integration | 已进入代码主线 | `cam hooks install` 现在会生成 recall bridge bundle(`memory-recall.sh`、兼容 wrapper 与 `recall-bridge.md`),供后续 hook / skill / MCP bridge 复用 | +| Codex skill install surface | 已有首批实现 | `cam skills install` 默认安装 runtime 目标,并支持显式 `--surface runtime|official-user|official-project`;无论装到哪个 surface,都沿用同一套 MCP-first、CLI-fallback 的 `search -> timeline -> details` durable memory 工作流 | + +## 集成方向 + +这个仓库接下来的方向不是“改造成万能平台”,而是: + +- 在 **当前仓库内部**,补齐面向 Codex 的 `hook bridge`、`skills`、`MCP-friendly retrieval` 能力 +- 继续保持 `cam run` / `cam sync` / `cam memory` / `cam session` 这条最稳的主路径 +- 让不喜欢显式 CLI 的用户,也能通过更自动化的 integration surfaces 获得同样的 durable memory 能力 + +当前方向的边界: + +- 当前仓库仍以 **Codex** 为主宿主 +- 仍坚持 **Markdown-first** +- 检索索引、SQLite、向量库、图谱等如果以后引入,也应是 **sidecar index**,不能取代 Markdown canonical store +- 多宿主统一平台会在后续独立仓库中探索,而不是强行塞进当前主仓 + +详细方向见: + +- [Integration Strategy](./docs/integration-strategy.md) +- [Host Surfaces](./docs/host-surfaces.md) ## 快速开始 @@ -148,23 +169,34 @@ cam init 这会在项目根目录生成 `codex-auto-memory.json`(跟踪到 Git),并在本地创建 `.codex-auto-memory.local.json`(默认 gitignored)。 -### 4. 通过 wrapper 启动 Codex(自动记忆开始工作) +### 4. 通过 wrapper 启动 Codex ```bash cam run ``` -每次会话结束,`cam` 会自动从 Codex 的 rollout 日志里提取信息并写入 memory 文件。 +当前最稳的自动记忆主路径仍然是 wrapper:会话结束后,`cam` 会自动从 Codex rollout 日志里提取信息并写入 memory 文件。 -### 5. 查看 memory 状态 +### 5. 查看状态与审计面 ```bash -cam memory # 查看当前 memory 文件和 startup budget -cam session status # 查看 session continuity 状态 -cam session refresh # 从选定 provenance 重新生成并覆盖 continuity -cam remember "Always use pnpm instead of npm" # 手动记录偏好 -cam forget "old debug note" # 删除过时记录 -cam audit # 检查仓库有没有意外的敏感内容 +cam memory +cam memory --recent 5 +cam recall search pnpm --state auto +cam mcp serve +cam integrations install --host codex +cam integrations apply --host codex +cam integrations doctor --host codex +cam mcp install --host codex +cam mcp print-config --host codex +cam mcp apply-guidance --host codex +cam mcp doctor +cam session status +cam session refresh +cam remember "Always use pnpm instead of npm" +cam forget "old debug note" +cam forget "old debug note" --archive +cam audit ``` ## 常用命令 @@ -173,24 +205,24 @@ cam audit # 检查仓库有没有意外的敏感内容 | :-- | :-- | | `cam run` / `cam exec` / `cam resume` | 编译 startup memory 并通过 wrapper 启动 Codex | | `cam sync` | 手动把最近 rollout 同步进 durable memory | -| `cam memory` | 查看真正进入 startup payload 的 quoted startup files、按需 topic refs、startup budget、edit paths,以及 `--recent [count]` 下的 durable sync audit 与 suppressed conflict candidates | -| `cam remember` / `cam forget` | 显式新增或删除 memory | -| `cam session save` | merge / incremental save;从 rollout 增量写入 continuity,不主动清掉已有污染状态 | -| `cam session refresh` | replace / clean regeneration;从选定 provenance 重新生成 continuity 并覆盖所选 scope | -| `cam session load` / `status` | continuity reviewer surface;显示 latest continuity diagnostics(含 `confidence` / warnings)、latest audit drill-down、compact prior preview,以及 pending continuity recovery marker | -| `cam session clear` / `open` | 清理当前 active continuity,或打开 local continuity 目录 | -| `cam audit` | 做仓库级隐私 / secret hygiene 审查 | -| `cam doctor` | 检查当前 companion wiring 与 native readiness posture | - -## 审计面地图 - -- `cam audit`: 仓库级的 privacy / secret hygiene 审计。 -- `cam memory --recent [count]`: durable sync audit,查看 recent `applied` / `no-op` / `skipped` sync 事件,不混入 manual `remember` / `forget`;当本轮提取结果因冲突而被保守 suppress 时,也会在 reviewer surface 中显式暴露。 -- `cam session save`: continuity audit surface 的 merge 路径,记录最新 continuity diagnostics、latest rollout 与 latest audit drill-down;它是 incremental save,不会立刻把已有污染状态“洗干净”。 -- `cam session refresh`: continuity audit surface 的 replace 路径,从选定 provenance 重新生成 continuity,并覆盖所选 scope;`--json` 会额外暴露 `action`、`writeMode` 与 `rolloutSelection`。 -- `cam session load|status`: reviewer surface,继续展示 latest continuity diagnostics、latest rollout、latest audit drill-down,以及 compact prior audit preview(来自 continuity audit log,排除 latest,并收敛连续重复项,不是完整 prior history 回放);最新 diagnostics 现在也会显式带出 `confidence` 与 warnings,帮助 reviewer 区分稳定事实、临时状态与需二次核实的冲突/噪音。 -- continuity reviewer warnings 仍属于 audit / reviewer surface,而不是 continuity body;当前实现会对明显的 reviewer warning prose 做最小 deterministic scrub,避免它们被模型原样写回 continuity Markdown。 -- `pending continuity recovery marker`: continuity Markdown 已写入但 audit sidecar 失败时的可见警告;它不等于 `cam session refresh` 会自动修复一切,只会在逻辑身份匹配的后续成功写入后被清理。 +| `cam memory` | 查看 startup payload、topic refs、edit paths、durable sync audit 与 suppressed conflict candidates | +| `cam remember` / `cam forget` | 显式新增、删除或修正 memory;`cam forget --archive` 会把匹配条目移入归档层 | +| `cam recall search` / `timeline` / `details` | 以 `search -> timeline -> details` 的 progressive disclosure 检索 durable memory;`search` 默认采用 `state=auto`、`limit=8`,先查 active,未命中再回退 archived,且保持只读 retrieval | +| `cam mcp serve` | 启动只读 retrieval MCP server,通过 `search_memories` / `timeline_memories` / `get_memory_details` 暴露同一套渐进式检索契约 | +| `cam integrations install --host codex` | 一次性安装推荐的 Codex integration stack:写入 project-scoped MCP wiring,并刷新 hook bridge bundle 与 Codex skill 资产;默认使用 runtime skills target,也支持显式 `--skill-surface runtime|official-user|official-project`;保持显式、幂等、Codex-only,且不触碰 Markdown memory store | +| `cam integrations apply --host codex` | 以显式、幂等、Codex-only 的方式应用完整 integration state:在保留 `integrations install` 旧语义不变的前提下,额外编排 `cam mcp apply-guidance --host codex`;默认使用 runtime skills target,也支持显式 `--skill-surface runtime|official-user|official-project`;若 `AGENTS.md` 无法安全更新,会返回 `blocked` 并保持 additive / fail-closed | +| `cam integrations doctor --host codex` | 以 Codex-only、只读、薄聚合的方式汇总当前 integration stack readiness,直接给出推荐路由、推荐 preset、子检查结果与下一步最小动作;当缺多个子检查时会优先推荐 `cam integrations apply --host codex`,若只缺 AGENTS guidance 则继续精确指向 `cam mcp apply-guidance --host codex`,不会改写宿主配置或 Markdown memory store | +| `cam mcp install --host ` | 显式写入推荐的 project-scoped 宿主 MCP 配置;只更新 `codex_auto_memory` 这一项,不会自动安装 hooks/skills;`generic` 继续保持 manual-only | +| `cam mcp print-config --host ` | 打印 ready-to-paste 的宿主接入片段,降低把 read-only retrieval plane 接进现有工作流的摩擦;其中 `--host codex` 还会额外打印推荐的 `AGENTS.md` snippet,帮助未来 Codex 代理优先走 MCP、必要时再 fallback 到 `cam recall` | +| `cam mcp apply-guidance --host codex` | 以 additive、可审计、fail-closed 的方式创建或更新仓库根 `AGENTS.md` 中由 Codex Auto Memory 自己管理的 guidance block;只会 append 新 block 或替换同一 marker block,无法安全定位时返回 `blocked` 而不会冒险改写 | +| `cam mcp doctor` | 只读检查当前项目的 retrieval MCP 接入状态、project pinning 与 hook/skill fallback 资产;同时追加 `codexStack` readiness 视图,用于汇总推荐路由、executable bit、共享资产版本与 workflow consistency,不会改写任何宿主配置 | +| `cam session save` | merge / incremental save;从 rollout 增量写入 continuity | +| `cam session refresh` | replace / clean regeneration;从选定 provenance 重建 continuity | +| `cam session load` / `status` | 查看 continuity reviewer surface 与 diagnostics | +| `cam hooks install` | 生成本仓自带的 local bridge / fallback helper bundle,包括 `memory-recall.sh`、兼容 helper wrappers 与 `recall-bridge.md`;它不是官方 Codex hook surface,且该 bundle 的推荐检索 preset 为 `state=auto`、`limit=8` | +| `cam skills install` | 默认安装 runtime Codex skill 资产,并支持显式 `--surface runtime|official-user|official-project`;让代理优先通过 retrieval MCP,未接线时再 fallback 到 `cam recall`,并沿用同一套推荐检索 preset:`state=auto`、`limit=8` | +| `cam audit` | 仓库级 privacy / secret hygiene 审查 | +| `cam doctor` | 检查当前 companion wiring、Codex feature posture 与 future integration readiness | ## 工作方式 @@ -198,30 +230,33 @@ cam audit # 检查仓库有没有意外的敏感内容 - `local-first and auditable` - `Markdown files are the product surface` -- `companion-first, with a narrow compatibility seam` +- `companion-first implementation, hybrid product direction` +- `Codex-first, but formally integration-aware` - `session continuity` 与 `durable memory` 明确分离 ### 运行流 ```mermaid flowchart TD - A[启动 Codex 会话] --> B[编译 startup memory] + A[cam run / exec / resume] --> B[编译 startup memory] B --> C[注入 quoted MEMORY.md startup files 与按需 topic refs] C --> D[运行 Codex] D --> E[读取 rollout JSONL] - E --> F[提取 candidate durable memory 操作] - E --> G[可选 continuity 总结] + E --> F[提取 durable memory candidates] + E --> G[可选 continuity summary] F --> H[contradiction review / conservative suppression] H --> I[更新 MEMORY.md 与 topic files] I --> J[追加 durable sync audit] G --> K[更新 shared / local continuity] + J --> L[后续进入 hook / skill / MCP-aware retrieval surfaces] ``` ### 为什么不是直接上 native memory -- 官方公开文档尚未给出完整、稳定、等价于 Claude Code 的 native memory 契约;本地 `cam doctor --json` 也仍把 `memories` / `codex_hooks` 视为未进入 trusted primary path 的 signal -- 本地观察与 source inspection 可以作为重评 compatibility seam 的线索,但不能直接升级成稳定产品契约 -- 因此项目默认仍然坚持 companion-first,直到官方文档、运行时稳定性和 CI 可验证性都足够强 +- 官方公开文档尚未给出完整、稳定、等价于 Claude Code 的 native memory 契约 +- 本地 `cam doctor --json` 仍把 `memories` / `codex_hooks` 视为 readiness signal,而不是 trusted primary path +- 因此当前实现仍然保持 `companion-first` +- 但产品方向已经明确:当 hooks、skills、MCP 与 retrieval surfaces 能以不破坏 Markdown-first 契约的方式进入主线时,会正式纳入,而不是永远停留在 bridge status ## 存储布局 @@ -247,7 +282,13 @@ Session continuity: /.codex-auto-memory/sessions/active.md ``` -更完整的结构与边界说明,请看架构文档。 +未来若引入检索索引: + +- Markdown 仍是 canonical store +- `cam recall` 与 `cam mcp serve` 都只提供 read-only retrieval plane,不承担 canonical truth +- `cam mcp serve` 只提供 read-only retrieval plane,不承担 canonical truth +- SQLite / FTS / vector / graph 只能作为 sidecar index +- 归档层应保持可审计、可 diff、可回放 provenance ## 文档导航 @@ -260,6 +301,8 @@ Session continuity: - [Claude Code 参考契约(中文)](docs/claude-reference.md) | [English](docs/claude-reference.en.md) - [架构设计(中文)](docs/architecture.md) | [English](docs/architecture.en.md) +- [集成演进策略(中文)](docs/integration-strategy.md) +- [宿主能力面(中文)](docs/host-surfaces.md) - [Native migration 策略(中文)](docs/native-migration.md) | [English](docs/native-migration.en.md) ### 维护与审查文档 @@ -276,7 +319,7 @@ Session continuity: - topic-aware startup lookup:可用 - session continuity companion layer:可用 - reviewer audit surfaces:可用 -- tagged GitHub Releases:release workflow 已定义并以 tarball artifact 为目标;推送首个真实 tag 前,应先确认默认分支上的该 workflow 已激活且可观测,npm publish 继续保持手动流程 +- hooks / skills / MCP-aware integration:已进入正式方向,但当前仍以 bridge 与后续实现为主 - native memory / native hooks primary path:未启用,仍非 trusted implementation path ## 路线图 @@ -291,17 +334,25 @@ Session continuity: ### v0.2 +- 把 issue 提到的 4 个能力收敛为正式主任务 - 更稳的 contradiction handling - 更清晰的 `cam memory` / `cam session` 审查 UX -- continuity diagnostics 与 reviewer packet 继续收紧信息层次,并显式暴露 confidence / warnings -- release-facing 验证继续收紧到 tarball install smoke,确保 `.tgz` 安装后的 `cam` bin shim 可直接工作 -- 继续保留对未来 hook surface 的 compatibility seam +- 落下 archive lifecycle 的第一批实现:`cam forget --archive` +- 引入 integration strategy 与 host surfaces 文档 +- 继续收紧 release-facing 验证与 reviewer contract + +### v0.3 + +- 在当前仓库内继续补 skill / hook bridge / MCP-friendly retrieval surfaces +- 在 `cam recall search / timeline / details` 基础上继续扩 retrieval contract +- 降低手动维护 Markdown memory 的成本,但保持 Markdown-first 契约 +- 不把数据库升级为 source of truth -### v0.3+ +### v0.4+ - 继续跟踪官方 Codex memory / hooks surfaces,不预设主路径变更 -- 可选 GUI / TUI browser -- 更强的跨会话 diagnostics 与 confidence surfaces +- 视实现情况补可选 GUI / TUI browser +- 与独立的新仓 memory runtime 在 core contract 层做设计对齐 ## 贡献与许可 diff --git a/README.zh-TW.md b/README.zh-TW.md index 49254c0..ef3dbaa 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -1,6 +1,6 @@

Codex Auto Memory

-

為 Codex 重現 Claude-style auto memory 工作流的 local-first companion CLI

+

一個以 Markdown 為核心、面向 Codex 的本地記憶運行層,正從 companion CLI 演進為 hook / skill / MCP-aware 的混合工作流

简体中文 | 繁體中文 | @@ -26,15 +26,15 @@

> `codex-auto-memory` 不是通用筆記軟體,也不是雲端記憶服務。
-> 它的目標是在今天的 Codex CLI 上,以本地 Markdown、緊湊 startup injection、按需 topic file 讀取與 companion runtime,盡可能重現 Claude Code auto memory 的可觀察產品契約。 +> 它是一個以 Markdown 為主表面、以本地為前提的 Codex 記憶運行層。當前最成熟的入口仍是 Codex wrapper / CLI,但專案方向已明確擴展到 hook、skill、MCP 等整合能力,同時維持可審計、可編輯的 Markdown 記憶契約。 --- **先看三個重點:** -1. **它做什麼**:每次 Codex 會話結束後,自動把有用資訊提取出來,寫入本地 Markdown,下一次啟動時再注入給 Codex,讓它「記得」你的專案。 -2. **它怎麼存**:全部都是本地 Markdown,放在 `~/.codex-auto-memory/`,你可以隨時檢視、編輯、納入 Git 審查。 -3. **它和 Claude 的關係**:這是一個 companion CLI,目標是在 Codex 上重現 Claude Code auto memory 的工作方式。它不是 Claude 官方產品,也不依賴雲端。 +1. **它做什麼**:從 Codex 會話中提取未來仍然有用的知識,保存為本地 Markdown,並在後續會話中自動帶回。 +2. **它怎麼存**:Durable memory 仍以 `MEMORY.md` + topic files 為核心,不以資料庫或隱藏快取作為主真相。 +3. **它往哪裡走**:專案仍以 Codex 為主宿主,但不再只把自己定義成窄化的 companion seam,而是明確朝 hook / skill / MCP-aware 的混合工作流演進。 --- @@ -42,6 +42,7 @@ - [為什麼這個專案存在](#為什麼這個專案存在) - [這個專案適合誰](#這個專案適合誰) +- [目前優先目標](#目前優先目標) - [核心能力](#核心能力) - [能力對照](#能力對照) - [快速開始](#快速開始) @@ -65,14 +66,15 @@ Claude Code 已經公開了一套相對清晰的 auto memory 產品契約: - 同一個倉庫的不同 worktree 共享 project memory - `/memory` 可用來審查與編輯 memory -而今天的 Codex CLI 已經具備不少有價值的基礎能力,但尚未公開同等完整的 memory product surface: +Codex 已經具備不少有價值的基礎能力,但仍未公開等價且完整的 memory product surface: - `AGENTS.md` - multi-agent workflows -- 本地 persistent sessions / rollout logs +- 本地 sessions 與 rollout logs +- MCP、skills、subagents 等逐步成形的能力面 - 本地 `cam doctor` / feature output 中可見的 `memories`、`codex_hooks` signal -`codex-auto-memory` 的價值,就是在官方 native memory 還沒有穩定公開之前,先提供一條乾淨、可審計、companion-first 的路線,只保留一條狹窄的 compatibility seam。近期 UX 重點仍是持續收緊 `cam memory` / `cam session` 的 reviewer 體驗。 +`codex-auto-memory` 的價值,是以 Codex-first 的方式把這條缺口補起來:既保持本地、可審計、可編輯的 Markdown 記憶契約,也逐步把低摩擦的 hook / skill / MCP 整合能力納入正式方向,而不是只把它們當作遙遠的 future bridge。 ## 這個專案適合誰 @@ -80,43 +82,56 @@ Claude Code 已經公開了一套相對清晰的 auto memory 產品契約: - 想在 Codex 中獲得更接近 Claude-style auto memory 工作流的使用者 - 希望 memory 完全本地、完全可編輯、可以直接放進 Git 審查語境的團隊 -- 需要在多個 worktree 之間共享 project memory,同時保留 worktree-local continuity 的工程流 -- 希望未來即使官方 surface 變化,也不需要重建使用者心智模型的維護者 +- 希望現在能用 CLI/workflow,未來又能接更自動化整合入口的使用者 +- 不希望未來因官方 surface 變化就被迫重建心智模型的維護者 不適合: - 想把它當作通用知識庫、筆記軟體或雲端同步服務的人 -- 期待現階段直接替代 Claude `/memory` 全部互動能力的人 -- 需要帳號級個人化記憶或跨裝置雲端記憶的人 +- 需要帳號級個人化雲端記憶的人 +- 期待今天就完整複製 Claude `/memory` 深度互動的人 + +## 目前優先目標 + +目前最重要的公開產品目標,就是完整落地這四件事: + +1. 自動從對話或任務過程中提取可重用的長期記憶。 +2. 在後續會話中自動召回這些記憶。 +3. 支援記憶更新、去重、覆蓋與歸檔友好的生命週期。 +4. 盡量降低手動維護 memory 檔案的成本。 ## 核心能力 | 能力 | 說明 | | :-- | :-- | -| 自動 memory 同步 | 會話結束後從 Codex rollout JSONL 中提取穩定、未來有用的資訊並寫回 Markdown memory | -| Markdown-first | `MEMORY.md` 與 topic files 就是產品表面,而不是內部快取 | -| 緊湊啟動注入 | 啟動時只注入真正進入 payload 的 quoted `MEMORY.md` startup files,並附帶按需 topic refs,不做 eager topic loading | +| 自動 post-session sync | 從 Codex rollout JSONL 中提取穩定、未來有用的資訊並寫回 durable Markdown memory | +| 自動 startup recall | 編譯緊湊 startup memory,讓 durable knowledge 自動回到後續會話 | +| Markdown-first | `MEMORY.md` 與 topic files 仍是產品主表面,而不是次級導出物 | +| 記憶生命週期 | 支援更正、去重、覆蓋、刪除,以及 reviewer 可見的 conflict suppression | +| formal retrieval MCP surface | `cam mcp serve` 會以只讀 stdio MCP 形式暴露 `search_memories` / `timeline_memories` / `get_memory_details` | +| project-scoped MCP install surface | `cam mcp install --host ` 會顯式寫入推薦的 project-scoped 宿主配置,降低 MCP 接線摩擦 | | worktree-aware | project memory 在同一個 git 倉庫的 worktree 間共享,project-local 仍保持隔離 | | session continuity | 臨時 working state 與 durable memory 分層儲存、分層載入 | -| reviewer surface | `cam memory` / `cam session` / `cam audit` 為維護者與 reviewer 提供可核查的審查入口 | +| integration-aware evolution | 保留目前 wrapper 主路徑,同時正式朝 hook / skill / MCP 方向演進 | +| reviewer surface | `cam memory` / `cam session` / `cam audit` 提供可核查的審查入口 | ## 能力對照 | 能力 | Claude Code | Codex today | Codex Auto Memory | | :-- | :-- | :-- | :-- | -| 自動寫 memory | Built in | 沒有完整公開契約 | 透過 companion sync flow 提供 | +| 自動寫 memory | Built in | 沒有完整公開契約 | 透過 rollout-driven sync 提供 | | 本地 Markdown memory | Built in | 沒有完整公開契約 | 支援 | | `MEMORY.md` 啟動入口 | Built in | 沒有 | 支援 | | 200 行啟動預算 | Built in | 沒有 | 支援 | | topic files 按需讀取 | Built in | 沒有 | 部分支援,啟動時暴露 topic refs,供後續按需讀取 | -| 跨會話 continuity | 社群方案較多 | 沒有完整公開契約 | 作為獨立 companion layer 支援 | +| 跨會話 continuity | 社群方案較多 | 沒有完整公開契約 | 作為獨立 layer 支援 | | worktree 共享 project memory | Built in | 沒有公開契約 | 支援 | | inspect / audit memory | `/memory` | 無等價命令 | `cam memory` | -| native hooks / memory | Built in | Experimental / under development | 目前只保留 compatibility seam | +| hook / skill / MCP-aware 演進 | Built in 或宿主能力強 | 新興且不均衡 | 已成為公開方向 | -`cam memory` 目前是 inspection / audit surface:它會暴露真正進入 startup payload 的 quoted startup files、startup budget、按需 topic refs、edit paths,以及 `--recent [count]` 下的 recent durable sync audit。
-recent durable sync audit 也會顯式暴露被保守 suppress 的 conflict candidates,避免在同一個 rollout 或和現有 durable memory 衝突時靜默 merge。
-如果主 memory 檔案已寫入,但 reviewer sidecar 沒有完整落盤,`cam memory` 會盡力暴露 pending sync recovery marker,幫助 reviewer 辨識 partial-success 狀態。 +`cam memory` 仍然是刻意設計成 reviewer-oriented 的 surface。它會暴露真正進入 startup payload 的 quoted startup files、startup budget、按需 topic refs、edit paths,以及透過 `--recent [count]` 取得的 durable sync audit。 + +這些 audit 事件也會顯式暴露被保守 suppress 的 conflict candidates,避免矛盾的 rollout 輸出靜默合併進 durable memory。未來更低摩擦的 hook、skill、MCP 路徑,也必須保持同一份可審計的 Markdown memory 契約,而不是取代它。 ## 快速開始 @@ -152,16 +167,26 @@ cam init cam run ``` -每次會話結束後,`cam` 會自動從 Codex rollout 日誌中提取資訊並寫入 memory 檔案。 +這仍是目前最成熟的端到端入口。每次會話結束後,`cam` 會從 Codex rollout 日誌中提取資訊並寫入 memory 檔案。 -### 5. 檢視 memory 狀態 +### 5. 檢視或修正 memory ```bash cam memory +cam recall search pnpm --state auto +cam mcp serve +cam integrations install --host codex +cam integrations apply --host codex +cam integrations doctor --host codex +cam mcp install --host codex +cam mcp print-config --host codex +cam mcp apply-guidance --host codex +cam mcp doctor cam session status cam session refresh cam remember "Always use pnpm instead of npm" cam forget "old debug note" +cam forget "old debug note" --archive cam audit ``` @@ -171,14 +196,24 @@ cam audit | :-- | :-- | | `cam run` / `cam exec` / `cam resume` | 編譯 startup memory 並透過 wrapper 啟動 Codex | | `cam sync` | 手動把最近 rollout 同步進 durable memory | -| `cam memory` | 檢視真正進入 startup payload 的 quoted startup files、按需 topic refs、startup budget、edit paths,以及 `--recent [count]` 下的 durable sync audit 與 suppressed conflict candidates | -| `cam remember` / `cam forget` | 顯式新增或刪除 memory | -| `cam session save` | merge / incremental save;從 rollout 增量寫入 continuity | -| `cam session refresh` | replace / clean regeneration;從選定 provenance 重新生成 continuity 並覆蓋所選 scope | -| `cam session load` / `status` | continuity reviewer surface;顯示 latest continuity diagnostics、latest audit drill-down、compact prior preview 與 pending continuity recovery marker | -| `cam session clear` / `open` | 清理 current active continuity,或打開 local continuity 目錄 | -| `cam audit` | 做倉庫級隱私 / secret hygiene 審查 | -| `cam doctor` | 檢查目前 companion wiring 與 native readiness posture | +| `cam memory` | 檢視 startup files、topic refs、startup budget、edit paths,以及 recent sync audit 與 suppressed conflict candidates | +| `cam remember` / `cam forget` | 顯式新增或刪除 durable memory;`cam forget --archive` 會把匹配條目移入歸檔層 | +| `cam recall search` / `timeline` / `details` | 以 `search -> timeline -> details` 的 progressive disclosure 工作流檢索 durable memory;`search` 現在預設採用 `state=auto`、`limit=8`,會先查 active,未命中再回退 archived,且保持只讀 retrieval | +| `cam mcp serve` | 啟動只讀 retrieval MCP server,以 `search_memories` / `timeline_memories` / `get_memory_details` 暴露同一套漸進式檢索契約 | +| `cam integrations install --host codex` | 一次性安裝推薦的 Codex integration stack:寫入 project-scoped MCP wiring,並刷新 hook bridge bundle 與 Codex skill 資產;預設使用 runtime skills target,也支援顯式 `--skill-surface runtime|official-user|official-project`;保持顯式、幂等、Codex-only,且不碰 Markdown memory store | +| `cam integrations apply --host codex` | 以顯式、幂等、Codex-only 的方式套用完整 integration state:在保留 `integrations install` 舊語義不變的前提下,額外編排 `cam mcp apply-guidance --host codex`;預設使用 runtime skills target,也支援顯式 `--skill-surface runtime|official-user|official-project`;若 `AGENTS.md` 無法安全更新,會回傳 `blocked` 並維持 additive / fail-closed 邊界 | +| `cam integrations doctor --host codex` | 以 Codex-only、只讀、薄聚合的方式彙總目前 integration stack readiness,直接給出推薦路由、推薦 preset、子檢查結果與下一步最小動作;當缺多個子檢查時會優先推薦 `cam integrations apply --host codex`,若只缺 AGENTS guidance 則繼續精準指向 `cam mcp apply-guidance --host codex`,不會改寫宿主設定或 Markdown memory store | +| `cam mcp install --host ` | 顯式寫入推薦的 project-scoped 宿主 MCP 配置;只更新 `codex_auto_memory` 這一項,不會自動安裝 hooks/skills;`generic` 仍維持 manual-only | +| `cam mcp print-config --host ` | 列印 ready-to-paste 的宿主接入片段,降低把 read-only retrieval plane 接進既有 MCP workflow 的手動成本;其中 `--host codex` 還會額外列印推薦的 `AGENTS.md` snippet,幫助未來 Codex 代理優先走 MCP、必要時再 fallback 到 `cam recall` | +| `cam mcp apply-guidance --host codex` | 以 additive、可審計、fail-closed 的方式建立或更新 repo 根 `AGENTS.md` 中由 Codex Auto Memory 自己管理的 guidance block;只會 append 新 block 或替換同一 marker block,若無法安全定位則回傳 `blocked` 而不會冒險改寫 | +| `cam mcp doctor` | 只讀檢查目前專案的 retrieval MCP 接線、project pinning 與 hook/skill fallback assets;現在也會追加 `codexStack` readiness 視圖,用來彙總推薦路由、executable bit、共享資產版本與 workflow consistency,不會改寫任何宿主設定 | +| `cam session save` | merge / incremental save;增量寫入 continuity | +| `cam session refresh` | replace / clean regeneration;重建 continuity | +| `cam session load` / `status` | continuity reviewer surface | +| `cam hooks` | 管理目前的 local bridge / fallback recall bundle,包括 `memory-recall.sh`、相容 helper wrappers 與 `recall-bridge.md`;它不是官方 Codex hook surface,且該 bundle 的推薦檢索 preset 為 `state=auto`、`limit=8` | +| `cam skills` | 以 `cam skills install` 安裝 Codex skill;預設 target 仍是 runtime,也支援顯式 `--surface runtime|official-user|official-project` 為官方 `.agents/skills` 路徑準備相容副本;所有 surface 都沿用同一套 MCP-first、CLI-fallback 漸進式 durable memory 檢索工作流與推薦 preset:`state=auto`、`limit=8` | +| `cam audit` | 做隱私與 secret-hygiene 檢查 | +| `cam doctor` | 檢視本地 wiring 與 native-readiness posture | ## 工作方式 @@ -186,18 +221,37 @@ cam audit - `local-first and auditable` - `Markdown files are the product surface` -- `companion-first, with a narrow compatibility seam` -- `session continuity` 與 `durable memory` 明確分離 +- `Codex-first hybrid runtime` +- `durable memory` 與 `session continuity` 明確分層 +- `wrapper-first today, integration-aware tomorrow` + +### 執行流 + +```mermaid +flowchart TD + A[啟動 Codex 會話] --> B[編譯 startup memory] + B --> C[注入 quoted MEMORY.md startup files 與 topic refs] + C --> D[執行 Codex] + D --> E[讀取 rollout JSONL] + E --> F[提取 candidate memory operations] + E --> G[可選 continuity summary] + F --> H[contradiction review 與 conservative suppression] + H --> I[更新 MEMORY.md 與 topic files] + I --> J[追加 durable sync audit] + G --> K[更新 shared / local continuity] +``` + +### 為什麼現在還不是 native-first -### 為什麼現在不直接切到 native memory +- 公開的 Codex 文件仍未定義等價於 Claude Code 的完整 native memory 契約 +- 本地 `cam doctor --json` 仍把 `memories` / `codex_hooks` 更像視為 readiness signal,而不是穩定主路徑 +- 因此目前最可靠的仍是 wrapper-first 主線 -- 官方公開文件仍未給出完整、穩定、等價於 Claude Code 的 native memory 契約 -- 本地 `cam doctor --json` 仍將 `memories` / `codex_hooks` 視為 readiness signal,而非 trusted primary path -- 因此專案預設仍堅持 companion-first,直到公開文件、執行時穩定性與 CI 可驗證性都足夠強 +但方向上的差異是:本倉庫不再把 hooks、skills、MCP 只寫成遙遠 future idea,而是把它們納入正式的整合演進方向,前提是它們仍遵守同一套 Markdown-first、可審計的記憶契約。 ## 儲存布局 -Durable memory: +Durable memory: ```text ~/.codex-auto-memory/ @@ -212,34 +266,37 @@ Durable memory: └── workflow.md ``` -Session continuity: +Session continuity: ```text ~/.codex-auto-memory/projects//continuity/project/active.md /.codex-auto-memory/sessions/active.md ``` -更完整的結構與邊界說明,請參考架構文件。 +完整邊界說明請見 architecture doc。 ## 文件導航 -- [文檔首頁(中文)](docs/README.md) +- [文档首页(中文)](docs/README.md) - [Documentation Hub (English)](docs/README.en.md) -- [Claude Code 參考契約(中文)](docs/claude-reference.md) | [English](docs/claude-reference.en.md) -- [架構設計(中文)](docs/architecture.md) | [English](docs/architecture.en.md) -- [Native migration 策略(中文)](docs/native-migration.md) | [English](docs/native-migration.en.md) -- [Session continuity 設計](docs/session-continuity.md) +- [Claude reference contract (中文)](docs/claude-reference.md) | [English](docs/claude-reference.en.md) +- [Architecture (中文)](docs/architecture.md) | [English](docs/architecture.en.md) +- [集成演进策略(中文)](docs/integration-strategy.md) +- [宿主能力面(中文)](docs/host-surfaces.md) +- [Native migration strategy (中文)](docs/native-migration.md) | [English](docs/native-migration.en.md) +- [Session continuity design](docs/session-continuity.md) - [Release checklist](docs/release-checklist.md) - [Contributing](CONTRIBUTING.md) ## 目前狀態 -- durable memory companion path:可用 -- topic-aware startup lookup:可用 -- session continuity companion layer:可用 -- reviewer audit surfaces:可用 -- tagged GitHub Releases:release workflow 已定義並以 tarball artifact 為目標;推送首個真實 tag 前,應先確認預設分支上的該 workflow 已啟用且可觀測;npm publish 仍保持手動流程 -- native memory / native hooks primary path:未啟用,仍非 trusted implementation path +- durable memory path: available +- startup recall path: available +- reviewer audit surfaces: available +- session continuity layer: available +- wrapper-driven Codex flow: available +- hook / skill / MCP-aware evolution: 已納入公開方向,但還不是目前最成熟的終端使用路徑 +- native memory / native hooks primary path: 未啟用,仍非 trusted implementation path ## 路線圖 @@ -249,31 +306,31 @@ Session continuity: - Markdown memory store - 200-line startup compiler - worktree-aware project identity -- 初始 reviewer / maintainer 文件體系 +- 初始 maintainer / reviewer docs ### v0.2 -- 更穩的 contradiction handling -- 更清楚的 `cam memory` / `cam session` 審查 UX -- continuity diagnostics 與 reviewer packet 持續收緊資訊層次,並顯式暴露 confidence / warnings -- release-facing 驗證持續收緊到 tarball install smoke,確保 `.tgz` 安裝後的 `cam` bin shim 可直接工作 -- 繼續保留對未來 hook surface 的 compatibility seam +- 完成 issue 中的核心能力:更好的自動提取、自動召回、更新/去重/覆蓋/歸檔生命週期、降低手動維護成本 +- 更清晰的 `cam memory` / `cam session` reviewer UX +- 更強的 contradiction handling 與記憶生命週期文檔化 +- 定義並公開 hook / skill / MCP-friendly integration surfaces,同時不放棄 Markdown-first 契約 ### v0.3+ -- 持續追蹤官方 Codex memory / hooks surfaces,不預設主路徑變更 -- 可選 GUI / TUI browser -- 更強的跨會話 diagnostics 與 confidence surfaces +- 擴展 Codex-first hybrid 路線,補足更強的 retrieval、skill、hook integration +- 重新評估哪些整合能力適合留在本倉庫,哪些應抽入更通用的共享 runtime +- optional GUI / TUI browser +- 更強的 cross-session diagnostics 與 confidence surfaces ## 貢獻與授權 -- 貢獻指南:[CONTRIBUTING.md](./CONTRIBUTING.md) -- License:[Apache-2.0](./LICENSE) +- Contribution guide: [CONTRIBUTING.md](./CONTRIBUTING.md) +- License: [Apache-2.0](./LICENSE) -如果你在 README、官方文件與本地執行時觀察之間發現衝突,請優先相信: +如果 README、官方文檔與本地執行結果之間出現衝突,請優先相信: -1. 官方產品文件 +1. 官方產品文檔 2. 可重現的本地行為 3. 對不確定性的明確說明 -而不是更自信但證據不足的表述。 +而不是根據不足的證據做過度自信的敘述。 diff --git a/docs/README.en.md b/docs/README.en.md index 6b3ee9e..302fd2e 100644 --- a/docs/README.en.md +++ b/docs/README.en.md @@ -2,38 +2,46 @@ [简体中文](./README.md) | [English](./README.en.md) -> This is the documentation entry point for `codex-auto-memory`. -> If you are new to the repository, start with the main [README](../README.en.md). If you need design boundaries, compatibility posture, or reviewer guidance, use the routes below. +> This is the English documentation entry point for `codex-auto-memory`. +> The repository is now documented as a Codex-first Hybrid memory runtime: still Markdown-first and local-first, still strongest through the current wrapper path, but now explicitly evolving toward hook, skill, and MCP-aware integration surfaces. -## Reading paths +## Suggested reading paths ### New users 1. [README](../README.en.md) 2. [Claude reference contract](./claude-reference.en.md) 3. [Architecture](./architecture.en.md) -4. [Native migration strategy](./native-migration.en.md) +4. [Integration strategy](./integration-strategy.md) (Chinese) +5. [Native migration strategy](./native-migration.en.md) ### Maintainers 1. [Architecture](./architecture.en.md) -2. [Session continuity design](./session-continuity.md) -3. [Release checklist](./release-checklist.md) -4. [ClaudeCode patch audit](./claudecode-patch-audit.md) +2. [Integration strategy](./integration-strategy.md) (Chinese) +3. [Host surfaces](./host-surfaces.md) (Chinese) +4. [Session continuity design](./session-continuity.md) +5. [Release checklist](./release-checklist.md) +6. [ClaudeCode patch audit](./claudecode-patch-audit.md) -### Reviewers and external tools +### Reviewers and follow-up agents -1. [Session continuity design](./session-continuity.md) -2. [Release checklist](./release-checklist.md) -3. [ClaudeCode patch audit](./claudecode-patch-audit.md) +1. [README](../README.en.md) +2. [Architecture](./architecture.en.md) +3. [Integration strategy](./integration-strategy.md) (Chinese) +4. [Host surfaces](./host-surfaces.md) (Chinese) +5. [Native migration strategy](./native-migration.en.md) +6. [Session continuity design](./session-continuity.md) ## Core design docs | Document | Purpose | Language | | :-- | :-- | :-- | -| [Claude reference contract](./claude-reference.en.md) | defines which public Claude Code memory behaviors this project intentionally mirrors | English / [中文](./claude-reference.md) | -| [Architecture](./architecture.en.md) | explains startup injection, sync flow, continuity, and storage layout | English / [中文](./architecture.md) | -| [Native migration strategy](./native-migration.en.md) | explains why the project remains companion-first and how the compatibility seam would be re-evaluated later | English / [中文](./native-migration.md) | +| [Claude reference contract](./claude-reference.en.md) | defines which public Claude Code memory behaviors this project intentionally mirrors, and where it now intentionally diverges | English / [中文](./claude-reference.md) | +| [Architecture](./architecture.en.md) | explains the current Codex-first Hybrid architecture: wrapper path today, broader integration surfaces tomorrow | English / [中文](./architecture.md) | +| [Integration strategy](./integration-strategy.md) | explains how the current repository expands from a Codex companion into a Codex-first Hybrid memory system | 中文 | +| [Host surfaces](./host-surfaces.md) | records host capability boundaries and future integration posture across Codex and adjacent ecosystems | 中文 | +| [Native migration strategy](./native-migration.en.md) | explains how native Codex memory signals are evaluated without treating them as the only future direction | English / [中文](./native-migration.md) | ## Runtime and maintainer docs @@ -43,16 +51,19 @@ | [Release checklist](./release-checklist.md) | release-time product, runtime, and docs checks | English | | [ClaudeCode patch audit](./claudecode-patch-audit.md) | historical patch-migration and comparison notes | English | -## Language policy +## Documentation policy -- the default public landing page is the Chinese `README.md` -- English readers can switch through [README.en.md](../README.en.md) or this page -- the three core design docs are maintained in both Chinese and English -- supplementary maintainer docs currently stay English-first to avoid internal drift +- the public front page should optimize for first-time understanding and current product direction +- core product boundaries belong in the README and architecture docs +- claim-sensitive wording must stay aligned with official public documentation +- the repository now documents both present behavior and deliberate evolution toward hook, skill, and MCP-aware surfaces +- the latest low-friction MCP wiring surface is now layered: `cam mcp install` writes the recommended project-scoped host config, `cam mcp print-config` and `cam mcp doctor` stay print-only and inspect-only, `cam mcp apply-guidance --host codex` manages the repository-level `AGENTS.md` guidance block through an additive fail-closed flow, and `cam integrations apply --host codex` provides an explicit one-shot Codex stack apply entrypoint +- `cam recall search` now defaults to the active-first, archived-fallback read-only retrieval path with `state=auto, limit=8` +- maintainers should avoid reverting to the older “companion-only and future-seam-only” wording unless the implementation direction changes again -## Documentation principles +## Language policy -- the public README should optimize for first-time understanding -- core product boundaries belong in the README and core design docs -- claim-sensitive wording must stay compatible with official public documentation -- dense maintainer docs are useful, but they should not replace the repository's public front page +- the default public landing page remains the Chinese `README.md` +- English readers can switch through [README.en.md](../README.en.md) or this page +- core design docs should stay synchronized across Chinese and English when the product direction changes materially +- supplementary maintainer docs can remain English-first when that reduces drift diff --git a/docs/README.md b/docs/README.md index dc4f506..11a3cf1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,8 +2,8 @@ [简体中文](./README.md) | [English](./README.en.md) -> 这里是 `codex-auto-memory` 的文档入口页。 -> 如果你是第一次进入仓库,建议先读默认 [README](../README.md);如果你要深入设计边界、兼容性姿态或 reviewer 视角,再从这里进入对应文档。 +> 这里是 `codex-auto-memory` 的中文文档入口页。 +> 当前仓库已经从“只描述 companion CLI”转向 **Codex-first Hybrid** 的文档口径:现有实现仍以 wrapper + Markdown store 为主,但 hook / skill / MCP-aware integration 已经是正式演进方向。 ## 阅读路径 @@ -12,28 +12,34 @@ 1. [README](../README.md) 2. [Claude Code 参考契约](./claude-reference.md) 3. [架构设计](./architecture.md) -4. [Native migration 策略](./native-migration.md) +4. [集成演进策略](./integration-strategy.md) ### 维护者 1. [架构设计](./architecture.md) -2. [Session continuity 设计](./session-continuity.md) -3. [Release checklist](./release-checklist.md) -4. [ClaudeCode patch audit](./claudecode-patch-audit.md) +2. [集成演进策略](./integration-strategy.md) +3. [宿主能力面](./host-surfaces.md) +4. [Session continuity 设计](./session-continuity.md) +5. [Native migration 策略](./native-migration.md) +6. [Release checklist](./release-checklist.md) ### Reviewer / 外部审查工具 -1. [Session continuity 设计](./session-continuity.md) -2. [Release checklist](./release-checklist.md) -3. [ClaudeCode patch audit](./claudecode-patch-audit.md) +1. [README](../README.md) +2. [架构设计](./architecture.md) +3. [集成演进策略](./integration-strategy.md) +4. [宿主能力面](./host-surfaces.md) +5. [Session continuity 设计](./session-continuity.md) ## 核心设计文档 | 文档 | 作用 | 语言 | | :-- | :-- | :-- | | [Claude Code 参考契约](./claude-reference.md) | 说明本项目主动对齐的 Claude Code memory 契约边界 | 中文 / [English](./claude-reference.en.md) | -| [架构设计](./architecture.md) | 解释 startup injection、sync、continuity 与存储布局 | 中文 / [English](./architecture.en.md) | -| [Native migration 策略](./native-migration.md) | 说明为什么当前仍然 companion-first,以及未来如何重评 compatibility seam | 中文 / [English](./native-migration.en.md) | +| [架构设计](./architecture.md) | 解释当前主实现:startup injection、sync、continuity 与 Markdown store | 中文 / [English](./architecture.en.md) | +| [集成演进策略](./integration-strategy.md) | 解释当前仓库如何从 Codex companion 演进为 Codex-first Hybrid memory system | 中文 | +| [宿主能力面](./host-surfaces.md) | 固化当前仓库对 Codex 及其他宿主的能力判断与边界 | 中文 | +| [Native migration 策略](./native-migration.md) | 说明何时才值得重评 Codex native memory / hooks 主路径 | 中文 / [English](./native-migration.en.md) | ## 运行时与维护文档 @@ -43,16 +49,30 @@ | [Release checklist](./release-checklist.md) | 发布前的产品、运行时和文档核查清单 | English | | [ClaudeCode patch audit](./claudecode-patch-audit.md) | 历史 patch 迁移与对照记录 | English | +## 这套文档要回答什么 + +当前文档集需要同时回答 3 个层面的问题: + +1. **今天已经稳定可用的是什么** + - 以 `cam` 命令和 wrapper 为主的 Codex durable memory / continuity 路线 +2. **接下来要补的是什么** + - issue 中的 4 项核心能力:自动提取、自动召回、更新/去重/覆盖/归档、降低手动维护成本 +3. **方向上为什么要补 hook / skill / MCP** + - 因为当前仓库不再只服务显式 CLI 用户,而是也面向希望让代理自己自动使用记忆能力的用户 + - 当前最新的低摩擦接入面已经分层:`cam mcp install` 负责显式写入 project-scoped host config,`cam mcp print-config` / `cam mcp doctor` 继续负责只打印 / 只检查,`cam mcp apply-guidance --host codex` 负责以 additive、fail-closed 的方式管理仓库级 `AGENTS.md` guidance block,而 `cam integrations apply --host codex` 则提供显式的一次性全栈 apply 入口 + - `cam recall search` 现在默认补上了 active-first、archived-fallback 的只读 retrieval 搜索面,并对齐 `state=auto`、`limit=8` + ## 语言策略 - 默认公开首页使用中文 `README.md` - 英文访客可从 [README.en.md](../README.en.md) 或 [docs/README.en.md](./README.en.md) 进入英文入口 -- 3 篇核心设计文档提供中英双版本 -- 维护类补充文档当前仍以英文为主,以减少内部维护漂移 +- 中文核心设计文档优先表达当前最新主线 +- 英文文档与多语言 README 应同步核心定位,不得继续停留在旧的 companion-only 叙事 ## 文档设计原则 - 首页优先服务新访客,而不是 reviewer 内部手册 -- 关键产品边界优先出现在 README 与核心设计文档中 +- 必须区分 **当前实现**、**当前桥接资产**、**正式演进方向** +- `Markdown-first` 是文档中的最高层不变量 +- `Codex-first` 是当前仓库的宿主边界,不把主仓直接写成多宿主统一平台 - claim-sensitive 内容必须与官方公开资料兼容 -- 维护类文档允许更强的信息密度,但不应替代公开首页 diff --git a/docs/architecture.en.md b/docs/architecture.en.md index e6d1609..792b86c 100644 --- a/docs/architecture.en.md +++ b/docs/architecture.en.md @@ -2,46 +2,41 @@ [简体中文](./architecture.md) | [English](./architecture.en.md) -> This document explains how `codex-auto-memory` combines durable memory, startup injection, and session continuity while staying local-first, Markdown-first, and companion-first. +> This document explains how `codex-auto-memory` combines durable memory, startup recall, and session continuity while staying local-first and Markdown-first. The repository is now best understood as a **Codex-first Hybrid** architecture: wrapper-first today, but intentionally evolving toward hook, skill, and MCP-aware integration surfaces that preserve the same Markdown memory contract. ## One-page overview -`codex-auto-memory` is built around three runtime paths: +`codex-auto-memory` currently has three active runtime paths: -1. startup path: compile and inject compact memory +1. startup path: compile and inject compact memory into Codex 2. post-session sync path: extract durable knowledge from rollout JSONL -3. optional continuity path: keep temporary working state separate +3. continuity path: keep temporary working state separate from durable memory -The shared goal is to keep memory auditable, editable, and migration-friendly instead of hiding state inside opaque caches. +Those paths continue to define the implemented system today. -The implementation also now follows an intentionally narrow code layout: +At the same time, the architecture now formally recognizes a fourth direction: -- `src/cli.ts`: wrapper fast path, version wiring, and Commander bootstrap only -- `src/lib/cli/register-commands.ts`: centralized command registration -- `src/lib/runtime/runtime-context.ts`: runtime composition, config-patch reload, and the shared reload helper used after memory enable/disable patches -- `src/lib/commands/session.ts`: provenance selection and action dispatch only -- `src/lib/commands/session-presenters.ts`: centralized text/json reviewer surfaces for `cam session` -- `src/lib/domain/session-continuity-persistence.ts`: shared continuity persistence spine used by both session commands and the wrapper flow -- `src/lib/domain/*`: core memory, continuity, audit, and rollout behavior -- `src/lib/util/*`: utility layer +4. integration surfaces: expose the same memory contract through hooks, skills, and MCP-friendly retrieval without replacing Markdown as the source of truth -The goal is not prettier abstraction for its own sake. The goal is a narrower entrypoint, thinner command files, and less duplicated orchestration. +The design goal is not to become database-first or host-generic overnight. The goal is to keep the current Codex implementation stable while making future integrations decision-ready. ## Design principles - local-first and auditable - Markdown files are the product surface +- Codex-first hybrid runtime - startup indexes must remain concise -- topic files are the detail layer -- session continuity must remain separate from durable memory -- companion-first is the mainline; a compatibility seam remains explicit +- topic files remain the durable detail layer +- durable memory and session continuity stay separate +- current wrapper flow is the strongest path today +- future hook / skill / MCP surfaces must preserve the same memory contract ## System overview ```mermaid flowchart TD A[cam run / exec / resume] --> B[Compile startup memory] - B --> C[Inject quoted MEMORY.md startup files plus on-demand topic refs] + B --> C[Inject quoted MEMORY.md startup files plus topic refs] C --> D[Run Codex] D --> E[Read rollout JSONL after session] E --> F[Extract durable memory candidates] @@ -50,31 +45,31 @@ flowchart TD H --> I[Update MEMORY.md and topic files] I --> J[Append durable sync audit] G --> K[Update shared and local continuity files] + J --> L[cam recall and cam mcp serve consume the same Markdown state] ``` -## 1. Startup path +## 1. Current implemented runtime + +### Startup path Startup currently does the following: 1. resolve configuration 2. identify the current project and worktree -3. read `MEMORY.md` from three scopes - - global - - project - - project-local +3. read scoped `MEMORY.md` files 4. compile a line-budgeted startup payload 5. inject it through the wrapper path -Important implementation traits: +Important traits: -- each `MEMORY.md` is injected as quoted startup files -- structured topic file refs are appended as on-demand lookup pointers +- `MEMORY.md` files are injected as quoted startup files +- topic files are represented as on-demand lookup refs - topic entry bodies are not eagerly loaded at startup - session continuity, when enabled, is injected as a separate block -## 2. Post-session sync path +### Post-session sync path -The sync path turns session evidence into durable Markdown memory: +The sync path turns rollout evidence into durable Markdown memory: 1. read the relevant rollout JSONL 2. parse user messages, tool calls, and tool outputs @@ -83,42 +78,80 @@ The sync path turns session evidence into durable Markdown memory: 5. apply the reviewed upserts and deletes to the Markdown store 6. rebuild `MEMORY.md` for the affected scope 7. append durable sync audit entries that keep suppressed conflict candidates reviewer-visible +8. record lifecycle history sidecars that power `cam recall timeline` and archive-aware retrieval -The extractor is expected to: +This is where the repository currently handles: -- keep stable, future-useful knowledge -- avoid transcript replay -- handle explicit corrections conservatively -- prefer provable corrections over silent conflict merges -- keep temporary next-step noise out of durable memory +- automatic extraction +- automatic durable recall preparation +- update / delete / overwrite behavior +- dedupe and conflict suppression -## 3. Optional session continuity path +### Session continuity path -Session continuity is a separate companion layer, not part of the durable memory contract: +Session continuity remains a separate layer, not part of the durable memory contract: - shared continuity: project-wide working state shared across worktrees - project-local continuity: worktree-specific working state -- reviewer warnings and confidence remain audit-side metadata, not continuity-body content -- startup provenance only lists continuity files that were actually read for the injected block +- reviewer warnings and confidence remain audit-side metadata +- continuity startup provenance only lists files actually used Its purpose is session recovery, not long-term memory. -### Why continuity is layered +## 2. Product contract that must stay stable + +The following rules should remain stable even as the integration surfaces expand: + +- Markdown stays canonical +- `MEMORY.md` stays the compact startup entrypoint +- topic files stay the durable detail layer +- project memory remains worktree-aware +- durable memory and continuity remain separate +- reviewer-visible audit and correction remain part of the workflow + +This means future hook / skill / MCP paths are integration layers, not replacements for the Markdown contract. + +## 3. Integration-aware evolution + +The repository now treats the following as first-class evolution targets rather than distant compatibility-only ideas: + +### Hook-aware surfaces + +- startup and post-session behavior may eventually be reachable through stronger host hook surfaces +- current `cam hooks` assets remain local bridge assets, but they now ship as a concrete recall bridge bundle (`memory-recall.sh`, compatibility wrappers, and `recall-bridge.md`) instead of unrelated helper fragments or an official Codex hook surface + +### Skill-aware surfaces + +- compact retrieval or correction workflows should eventually be expressible as reusable skill content +- skill-based usage should not require abandoning the current file layout or reviewer surfaces +- `cam skills install` now provides a concrete Codex-facing skill surface that teaches the same MCP-first, CLI-fallback progressive durable-memory retrieval workflow + +### MCP-aware surfaces + +- retrieval should move toward a progressive-disclosure shape instead of relying only on startup injection +- future MCP tools should search indexes, inspect timelines, and load specific memory details from Markdown-backed state +- `cam recall search` now defaults to `state=auto, limit=8`, providing the active-first, archived-fallback read-only CLI retrieval path for the same contract +- `cam mcp serve` now provides the first read-only retrieval MCP path for that contract +- `cam mcp install --host ` now writes the recommended project-scoped host wiring for that retrieval plane without touching the Markdown store +- `cam mcp print-config --host ...` now prints ready-to-paste host snippets so the same retrieval plane is easier to wire into existing MCP clients +- `cam mcp doctor` now inspects the recommended project-scoped retrieval wiring, project pinning, and hook / skill fallback assets without mutating host config files -Shared continuity is where repository-wide working state belongs: +These surfaces must remain host-adapter concerns. The core memory semantics should not be rewritten around any one host’s lifecycle. -- the current goal -- confirmed working approaches -- failed attempts worth remembering -- project-wide prerequisites +## 4. Recommended future internal abstractions -Project-local continuity is where worktree-specific state belongs: +The implementation is not required to expose all of these immediately, but the architecture should now treat them as the intended stable vocabulary: -- the exact next step -- local experiments -- local files, decisions, and environment notes +- `MemoryOperation`: add, update, delete, noop, archive +- `MemoryRecord`: canonical durable memory unit rendered into Markdown +- `MemoryScope`: global, project, project-local +- `ExtractionPolicy`: what is worth remembering, redacting, or ignoring +- `ConflictResolver`: contradiction, dedupe, overwrite, archive decisions +- `NoopResult`: explicit reviewer-visible no-op outcomes for unchanged active writes or delete/archive requests that do not hit an active record +- `RetrievalIndex`: sidecar search/index layer derived from Markdown +- `HostIntegrationSurface`: host-specific startup / hook / MCP / skill entrypoint -## 4. Storage model +## 5. Storage model ### Durable memory @@ -126,15 +159,26 @@ Project-local continuity is where worktree-specific state belongs: ~/.codex-auto-memory/ ├── global/ │ ├── MEMORY.md -│ └── preferences.md +│ ├── preferences.md +│ ├── memory-history.jsonl +│ └── archive/ +│ ├── ARCHIVE.md +│ └── preferences.md └── projects// ├── project/ │ ├── MEMORY.md │ ├── commands.md - │ └── architecture.md + │ ├── architecture.md + │ ├── memory-history.jsonl + │ └── archive/ + │ ├── ARCHIVE.md + │ └── workflow.md └── locals// ├── MEMORY.md - └── workflow.md + ├── workflow.md + ├── memory-history.jsonl + └── archive/ + └── ARCHIVE.md ``` ### Session continuity @@ -144,13 +188,21 @@ Project-local continuity is where worktree-specific state belongs: /.codex-auto-memory/sessions/active.md ``` -## 5. Scope boundaries +### Future retrieval/index sidecars + +The architecture now allows sidecar retrieval indexes, but they must remain rebuildable from Markdown and audit state. The repository is not moving to database-first canonical storage. + +- `cam recall` is part of the read-only retrieval plane, not a second source of truth. +- `cam mcp serve` is part of the retrieval plane, not a second source of truth. +- Future SQLite / FTS / vector / graph layers remain sidecars only. + +## 6. Scope boundaries | Scope | Purpose | Typical examples | | :-- | :-- | :-- | | global | cross-project personal preferences | preferred package manager, review habits | | project | repository-level durable knowledge | build/test commands, architecture constraints | -| project-local | worktree-local or machine-local knowledge | local workflow, worktree-specific notes | +| project-local | worktree-local or machine-local knowledge | local workflow, worktree notes | These boundaries matter because otherwise: @@ -158,43 +210,18 @@ These boundaries matter because otherwise: - continuity leaks into durable memory - worktree-sharing semantics become unpredictable -## 6. Markdown contract - -Markdown is the product surface: - -- `MEMORY.md`: compact startup index -- topic files: durable detail layer -- continuity files: temporary recovery layer - -Lightweight bookkeeping is acceptable, but Markdown must stay readable and primary. - -## 7. Injection strategy - -Current public Codex surfaces still do not expose a Claude-equivalent native memory system, so startup injection must continue to satisfy these rules: - -- do not mutate tracked repository files just to inject memory -- compile memory outside the user repository -- inject memory as quoted startup files rather than implicit policy -- keep continuity separate from durable memory at injection time - -## 8. Compatibility seam - -The architecture keeps these replacement boundaries explicit: - -- `SessionSource` -- `MemoryExtractor` -- `MemoryStore` -- `RuntimeInjector` +## 7. Why the current architecture does not jump to a plugin-native rewrite -The current code layout tries to keep those seams visible in practice: +The repository still targets Codex first, and public Codex surfaces are not yet symmetric with Claude Code, Gemini CLI, or OpenCode in terms of hooks and packaged integrations. -- CLI registration is separated from wrapper fast-path bootstrap -- command orchestration is separated from domain persistence -- shared continuity persistence is separated from rollout provenance selection +That means: -That keeps the integration layer replaceable without rewriting the user mental model. +- the wrapper path remains the most reliable entrypoint +- startup injection remains necessary +- rollout JSONL remains the strongest durable evidence source +- new integration work should be additive around the current system, not a rewrite of the current system -## 9. Validation priorities +## 8. Validation priorities This architecture should keep validating: @@ -205,4 +232,6 @@ This architecture should keep validating: - rollout parsing - startup payload compilation - session continuity layering -- CLI command surfaces +- contradiction and correction handling +- future retrieval surfaces against the same canonical memory state +- CLI and integration surfaces without drifting from the same memory contract diff --git a/docs/architecture.md b/docs/architecture.md index 44d9909..2f1fb64 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,41 +2,30 @@ [简体中文](./architecture.md) | [English](./architecture.en.md) -> 本文解释 `codex-auto-memory` 如何在保持 local-first、Markdown-first、companion-first 的前提下,把 durable memory、startup injection 和 session continuity 组合起来。 +> 本文解释 `codex-auto-memory` 如何在保持 local-first、Markdown-first、companion-first 实现前提下,逐步演进为 **Codex-first Hybrid memory system**。 ## 一页概览 -`codex-auto-memory` 主要由 3 条运行路径组成: +当前仓库应从两个层面来理解: -1. startup path:编译并注入紧凑 memory -2. post-session sync path:从 rollout JSONL 提取 durable memory -3. optional continuity path:单独处理临时 working state +1. **当前最稳实现** + - `wrapper + startup injection + rollout parsing + Markdown store` +2. **正式演进方向** + - 在不放弃 Markdown-first 与当前 CLI 主线的前提下,补 hooks、skills、MCP-aware retrieval 等 integration surfaces -它们的共同目标是:让 memory 保持可审计、可编辑、可迁移,而不是把复杂状态藏进 opaque cache。 +这意味着本项目不是: -当前实现也刻意保持一个“窄入口 + 清晰分层”的代码组织: +- 直接重写成 `claude-mem` 式的 DB-first / worker-first 系统 +- 立即升级为多宿主统一平台 +- 用未来原生能力替代现有稳定 companion path -- `src/cli.ts`:只负责 wrapper fast path、版本与 Commander 启动 -- `src/lib/cli/register-commands.ts`:集中做命令注册 -- `src/lib/runtime/runtime-context.ts`:集中做 runtime composition、config patch 后的 reload,以及 memory enable/disable 的统一 reload helper -- `src/lib/commands/session.ts`:只保留 provenance 选择与 action dispatch -- `src/lib/commands/session-presenters.ts`:集中组装 `cam session` 的 text/json reviewer surface -- `src/lib/domain/session-continuity-persistence.ts`:承载 session / wrapper 共享的 continuity persistence 主干 -- `src/lib/domain/*`:memory / continuity / audit / rollout 的核心语义与存储行为 -- `src/lib/util/*`:纯工具层 +这也意味着本项目是: -这样做的目标不是“架构更花”,而是让入口更窄、命令层更薄、shared orchestration 不在多个命令文件里重复扩散。 +- **Codex-first** +- **Markdown-first** +- **companion-first implementation, hybrid product direction** -## 设计原则 - -- local-first and auditable -- Markdown files are the product surface -- startup index must stay concise -- topic files are the detail layer -- session continuity must remain separate from durable memory -- companion-first is the mainline; a compatibility seam remains explicit - -## 系统总览 +## 当前系统总览 ```mermaid flowchart TD @@ -50,11 +39,14 @@ flowchart TD H --> I[更新 MEMORY.md 与 topic files] I --> J[写入 durable sync audit] G --> K[更新 shared / local continuity files] + J --> L[当前进入 cam recall / cam mcp serve,并继续向 skill / hook bridge / MCP surfaces 演进] ``` -## 1. Startup path +## 1. 当前主路径 + +### Startup path -启动阶段会做以下事情: +启动阶段当前会做以下事情: 1. 解析配置 2. 识别当前 project 与 worktree @@ -65,58 +57,103 @@ flowchart TD 4. 编译受 line budget 约束的 startup payload 5. 通过 wrapper 把它注入到 Codex -当前实现中,startup injection 的特征是: +当前 startup injection 的特点: - 各 scope 的 `MEMORY.md` 以 quoted startup files 注入 -- 额外附带结构化 topic file refs,作为按需定位信息 +- 附带结构化 topic file refs,作为按需定位信息 - startup 不 eager 读取 topic entry bodies - 允许 session continuity 作为单独 block 注入 -## 2. Post-session sync path +### Post-session sync path sync path 的职责是把“值得长期保存的信息”写回 durable memory: 1. 读取相关 rollout JSONL 2. 解析 user messages、tool calls、tool outputs 3. 由 extractor 生成 candidate memory operations -4. 经过 contradiction review,对冲突 candidate 做保守 suppress,并优先保留明确更正 -5. 将审查后的 upsert / delete 应用到 Markdown store +4. 经过 contradiction review,对冲突 candidate 做保守 suppress +5. 将审查后的 operation 应用到 Markdown store 6. 重建对应 scope 的 `MEMORY.md` -7. 追加 durable sync audit,显式暴露 suppressed conflict candidates 供 reviewer 审查 +7. 追加 durable sync audit,显式暴露 reviewer 信息 +8. 记录 lifecycle history,为 `cam recall timeline` 与归档检索提供 sidecar 线索 -当前 extractor 的设计目标是: +当前 extractor 的目标: - 保存稳定、未来有用的信息 - 避免保存原始会话回放 - 对显式 correction 做保守替换 -- 冲突场景下优先保留可证明的更正,而不是静默 merge +- 冲突场景下优先保留可证明的更正 - 避免把临时 next step / local edit noise 写进 durable memory -## 3. Optional session continuity path +### Optional session continuity path session continuity 是独立 companion layer,不属于 durable memory 契约: - shared continuity:跨 worktree 共享的项目级 working state - project-local continuity:当前 worktree 的本地 working state - reviewer warning / confidence 属于 audit side metadata,不属于 continuity body -- startup provenance 只列出这次注入时真实读取到的 continuity 文件 +- startup provenance 只列出真实读取到的 continuity 文件 它的存在是为了帮助会话恢复,而不是替代 memory。 -### 为什么要分层 - -shared continuity 适合放: - -- 当前主目标 -- 已验证可行的做法 -- 已尝试且失败的路径 -- 对整个仓库都成立的前提 - -project-local continuity 适合放: - -- 当前 worktree 的精确 next step -- 本地实验记录 -- local-only files / decisions / environment notes +## 2. 正式演进方向 + +从当前版本开始,架构文档需要承认并固定如下方向: + +- hooks 不再只被视为 future bridge,而是正式入口之一 +- skills 不再只被视为将来灵感,而是正式的使用面与分发面 +- MCP 不再只被视为外部能力,而是未来 memory retrieval 与 automation surface 的核心候选 + +这些能力进入主线时,必须满足两个前提: + +1. **不破坏 Markdown-first** +2. **不破坏当前 `cam` / wrapper 主路径** + +因此,架构上应遵循: + +- CLI / wrapper 是当前稳定主入口 +- hook / skill / MCP 是并行入口 +- 所有入口最终都汇入同一套 Markdown canonical store 与 audit semantics +- 当前 concrete integration assets 已包括: + - `cam recall search` 默认采用 `state=auto`、`limit=8`,提供 active-first、archived-fallback 的只读 retrieval 搜索面 + - `cam hooks install` 生成本仓自带的 local bridge / fallback recall bundle(`memory-recall.sh`、兼容 wrappers、`recall-bridge.md`),而不是官方 Codex hook surface + - `cam skills install` 安装用户级 Codex skill,沿用同一套 MCP-first、CLI-fallback 的 `search -> timeline -> details` durable memory 工作流 + - `cam mcp serve` 暴露 read-only retrieval MCP plane,对齐 `search -> timeline -> details` 契约 + - `cam mcp install --host ` 显式写入推荐的 project-scoped 宿主配置,降低 retrieval MCP 的接线摩擦,但不触碰 Markdown store + - `cam mcp print-config --host ...` 打印 ready-to-paste 宿主接入片段,降低 retrieval MCP 的接入摩擦 + - `cam mcp doctor` 只读检查推荐的 project-scoped retrieval MCP 接线、project pinning 与 hook / skill fallback 资产,不改写宿主配置 + +## 3. 未来要冻结的核心语义 + +后续实现需要围绕统一 memory contract,而不是围绕某一种宿主形式。 + +建议在当前仓库内逐步固定以下核心语义: + +- `MemoryOperation` + - `add` + - `update` + - `delete` + - `noop` + - `archive` +- `MemoryRecord` + - canonical Markdown block / topic entry +- `MemoryScope` + - `global` + - `project` + - `project-local` +- `ExtractionPolicy` + - 什么该记住、什么该忽略、什么要 redact +- `ConflictResolver` + - dedupe、correction、overwrite、archive 的规则 +- `NoopResult` + - 对未改变 active memory 的重复写入,或命中不到 active target 的 delete/archive,显式返回 reviewer 可见 `noop` +- `RetrievalIndex` + - sidecar index,而不是 source of truth + +这里的关键点是: + +- 统一的是 **memory semantics** +- 不是把 hooks、plugin manifest、session layout 硬统一成一套跨宿主格式 ## 4. 存储模型 @@ -126,15 +163,26 @@ project-local continuity 适合放: ~/.codex-auto-memory/ ├── global/ │ ├── MEMORY.md -│ └── preferences.md +│ ├── preferences.md +│ ├── memory-history.jsonl +│ └── archive/ +│ ├── ARCHIVE.md +│ └── preferences.md └── projects// ├── project/ │ ├── MEMORY.md │ ├── commands.md - │ └── architecture.md + │ ├── architecture.md + │ ├── memory-history.jsonl + │ └── archive/ + │ ├── ARCHIVE.md + │ └── workflow.md └── locals// ├── MEMORY.md - └── workflow.md + ├── workflow.md + ├── memory-history.jsonl + └── archive/ + └── ARCHIVE.md ``` ### Session continuity @@ -144,6 +192,17 @@ project-local continuity 适合放: /.codex-auto-memory/sessions/active.md ``` +### Future retrieval/index plane + +如果后续引入检索增强,应保持: + +- Markdown 是 canonical source of truth +- 当前 `cam recall` 与 `cam mcp serve` 都只是 read-only retrieval plane,不是第二真相层 +- 当前 `cam mcp serve` 只是 retrieval plane,不是第二真相层 +- SQLite / FTS / vector / graph 只能作为 sidecar index +- sidecar index 必须可从 Markdown + audit 重建 +- 归档层也必须保持可读、可 diff、可审计 + ## 5. Scope 边界 | Scope | 作用 | 示例 | @@ -152,47 +211,62 @@ project-local continuity 适合放: | project | 仓库级 durable knowledge | build/test commands、架构约束 | | project-local | 当前 worktree 或本地环境知识 | 本地 workflow、worktree-specific note | -这条边界必须保持清楚,否则: +必须继续保持这条边界,否则: - project memory 会被本地噪音污染 - continuity 会混进 durable memory - worktree 共享语义会变得不可预测 +- 后续 skill / hook / MCP surfaces 会把错误语义放大到自动化路径里 ## 6. Markdown contract -本项目的产品表面是 Markdown,而不是内部数据库: +本项目的产品表面仍然是 Markdown,而不是内部数据库: - `MEMORY.md`:紧凑启动索引 - topic files:细节层 +- archive files:默认 recall 之外、但仍可检索与审计的历史层 - continuity files:临时恢复层 +- audit logs:审计与 provenance 层 -允许存在轻量 bookkeeping,但不能让 Markdown 退化成次要表示。 +允许存在轻量 bookkeeping 与 sidecar index,但不能让 Markdown 退化成次要表示。 -## 7. Injection strategy +## 7. Injection and integration strategy -当前 Codex 公开面还没有提供与 Claude Code 等价的 native auto memory surface,因此 startup path 必须继续满足: +当前阶段,startup path 仍继续满足: - 不改动用户仓库里的 tracked files 来完成注入 - 由 companion runtime 在外部编译 memory - 把 memory 作为 quoted startup files 注入,而不是隐式 prompt policy - continuity block 与 durable memory block 明确分开 +后续 integration surfaces 引入时,应遵循: + +- `hooks` + - 负责生命周期捕获、自动触发 sync / retrieval / audit +- `skills` + - 负责把 memory retrieval workflow 教给代理 +- `MCP` + - 负责低 token 的 search / timeline / detail retrieval + +也就是说: + +- hooks 解决“什么时候触发” +- skills 解决“模型怎么用” +- MCP 解决“模型能调用什么” + ## 8. Compatibility seam -当前架构保留了几个关键替换点: +当前架构仍保留这些关键替换点: - `SessionSource` - `MemoryExtractor` - `MemoryStore` - `RuntimeInjector` -在当前代码里,对应的实现分层也尽量保持显式: - -- CLI registration 与 wrapper fast path 分开 -- command orchestration 与 domain persistence 分开 -- continuity 的 shared persistence 与 rollout provenance selection 分开 +这些 seam 的职责从现在开始要扩大理解: -这样未来若需要重评接入方式,可以替换 integration layer,而不是推翻用户心智模型。 +- 不只是为了 future native migration +- 也是为了未来 skill / hook / MCP surfaces 进入主线时,不推翻用户心智模型 ## 9. 验证重点 @@ -206,3 +280,16 @@ project-local continuity 适合放: - startup payload compilation - session continuity layering - CLI command surfaces +- future integration surfaces 与 canonical Markdown store 的一致性 + +## 10. 当前架构不做什么 + +为了避免过早平台化,当前仓库在这一阶段不做以下事情: + +- 不改成 DB-first 主存储 +- 不把当前仓库直接定义成多宿主统一主仓 +- 不把 plugin format 作为统一核心抽象 +- 不把 native hooks / memories 直接升级成主路径 +- 不为了对齐 `claude-mem` 而引入完整 worker/UI/daemon 产品栈 + +真正应该统一的是 memory contract,而不是宿主外壳。 diff --git a/docs/claude-reference.en.md b/docs/claude-reference.en.md index 2ce7228..86cd670 100644 --- a/docs/claude-reference.en.md +++ b/docs/claude-reference.en.md @@ -2,14 +2,13 @@ [简体中文](./claude-reference.md) | [English](./claude-reference.en.md) -> This document records the public Claude Code memory contract that `codex-auto-memory` intentionally tries to mirror. -> It is not a reverse-engineering note about Anthropic internals, and it should not promote local observations or community patterns into official product guarantees. +> This document records the public Claude Code memory contract that `codex-auto-memory` intentionally mirrors where useful. It is not a reverse-engineering note about Anthropic internals, and it should not turn local observations or community patterns into official guarantees. ## What this document answers - what Claude Code publicly says about auto memory -- which behaviors matter most for parity in this repository -- which adjacent surfaces are relevant, but still should not be overclaimed +- which public behaviors still matter most for this repository +- where the repository now intentionally follows the contract in product semantics but not in host-specific implementation ## One-page summary @@ -21,7 +20,7 @@ | topic files are read on demand | startup should not eagerly load topic bodies | | worktrees share project memory | project identity cannot be derived only from the cwd | | `/memory` exposes audit and edit controls | the project needs real inspect and edit paths, even if it does not fully clone Claude `/memory` | -| `autoMemoryDirectory` has config-scope boundaries | shared project config must not be able to hijack another user's memory path | +| the host may expose hooks, skills, and subagent memory | the repository should treat those as real integration targets when the host supports them | ## Core public contract @@ -32,13 +31,13 @@ The official Claude Code memory docs support a stable interpretation: - `MEMORY.md` is the compact entrypoint and topic files hold the detail layer - users can inspect, edit, and delete memory -These are the core behaviors this repository should keep aligned with. +These remain the core behaviors this repository should stay aligned with. ## Product behaviors to mirror ### 1. AI-managed local memory with user control -Claude Code presents auto memory as notes Claude writes for itself while working. +Claude Code presents auto memory as notes Claude writes for itself while working. Good examples include: - build and test commands @@ -86,36 +85,43 @@ That means project identity should follow the git repository boundary, not only ### 5. Users must be able to inspect and edit memory -Claude Code exposes `/memory` as an audit and edit surface. -This repository does not currently claim full `/memory` interaction parity, but it still must preserve two things: +Claude Code exposes `/memory` as an audit and edit surface. +This repository still does not claim full `/memory` interaction parity, but it must preserve two things: - users can see the actual memory files and active paths -- users can modify memory through Markdown files or explicit companion commands +- users can modify memory through Markdown files or explicit commands -### 6. `autoMemoryDirectory` has a configuration safety boundary +### 6. Host integration surfaces matter, but should not replace the core contract -Claude's documented config behavior makes one design point clear: a shared project should not be able to redirect another user's memory writes. +Claude Code also exposes stronger host-native surfaces around memory: -For this project, that means: +- hooks +- skills +- subagents with persistent memory +- plugin packaging -- managed / user / local config may control the memory directory -- shared project config should not hijack the user's durable memory path +For `codex-auto-memory`, this now means: + +- those surfaces are worth targeting as future integration paths +- but the core product contract is still the Markdown memory contract itself +- the repository should not confuse host-native convenience with the canonical memory model ## Relevant but non-primary surfaces ### Subagent memory -Claude Code publicly documents separate persistent memory paths for subagents. +Claude Code publicly documents separate persistent memory paths for subagents. That makes subagent memory a relevant parity surface, but not proof that this repository already has equivalent behavior. ### Hooks -Claude Code exposes a much richer hook lifecycle surface than current Codex. -This is useful migration context, but not a reason to describe Codex hooks as effectively ready today. +Claude Code exposes a much richer hook lifecycle surface than current Codex. +That is useful product reference material, and it is now relevant to this repository’s broader integration direction. +It still does **not** justify claiming that Codex native hooks are already ready. ### `/memory` depth -Claude `/memory` is a full interaction surface. +Claude `/memory` is a full interaction surface. `codex-auto-memory` currently maps more closely to: - `cam memory` for inspection and audit @@ -133,11 +139,13 @@ This repository should continue to preserve these Claude-aligned rules: - topic files stay the detail layer and are read on demand - project memory remains worktree-shared - session continuity stays separate from durable memory -- native migration remains a seam, not the primary path +- future hook / skill / MCP-aware integrations must preserve the same memory contract instead of replacing it +- native Codex migration remains only one branch of the strategy, not the whole strategy ## Official references - Claude memory docs: - Claude settings docs: - Claude subagents docs: +- Claude hooks docs: - Claude docs index: diff --git a/docs/host-surfaces.md b/docs/host-surfaces.md new file mode 100644 index 0000000..ba8975e --- /dev/null +++ b/docs/host-surfaces.md @@ -0,0 +1,127 @@ +# 宿主能力面 + +> 本文回答当前仓库在产品与架构上应如何看待不同宿主。 +> 它不是多宿主承诺清单,而是当前仓库的 **宿主判断文档**。 + +## 一页结论 + +当前仓库的宿主判断应固定为: + +- **Codex 是当前主宿主** +- Claude / Gemini / OpenCode / OpenClaw 是重要参考宿主 +- 当前仓库不直接改写成多宿主统一平台 +- 多宿主统一 memory core 应在独立新仓中设计 + +## 为什么当前仓库仍然是 Codex-first + +因为这个仓库的现有实现与用户心智都已经围绕 Codex 形成: + +- `cam run` / `cam exec` / `cam resume` +- wrapper 注入 +- rollout JSONL 提取 +- `cam memory` / `cam recall` / `cam session` / `cam audit` +- `cam hooks install` / `cam skills install` +- `cam mcp install` / `cam mcp print-config` / `cam mcp doctor` + +其中当前 retrieval 边界也已经更明确: + +- `cam recall` 是当前 CLI 侧的 read-only retrieval surface +- `cam mcp serve` 是同一套 contract 的 MCP retrieval surface +- `cam mcp install` 是显式、可选、project-scoped 的宿主接线安装面 +- `cam memory` 仍是 inspect / audit surface +- `cam session` 仍是 temporary continuity surface + +这些都是 Codex-first 的产品面,而不是通用宿主抽象。 + +## 当前仓库如何看待其他宿主 + +### Claude Code + +价值: + +- 提供最完整的官方 auto memory、hooks、plugins、skills、subagents 参考契约 + +在当前仓库里的角色: + +- **参考对象** +- 用来定义产品体验与宿主能力边界 +- 不作为当前仓库直接承诺支持的主宿主 + +### Gemini CLI + +价值: + +- hooks、extensions、MCP、sub-agents 能力都很强 +- 适合作为未来独立 memory runtime 的优先宿主之一 + +在当前仓库里的角色: + +- **重要参考宿主** +- 帮助当前仓库设计未来 skill / hook / MCP surfaces +- 但不把当前仓库直接改写成 Gemini 主仓 + +### OpenCode + +价值: + +- plugin、MCP、AGENTS、agents、client/server 架构都很强 + +在当前仓库里的角色: + +- **重要参考宿主** +- 适合作为未来独立新仓的 adapter 目标 +- 当前仓库只吸收其设计启发,不直接承担其适配工作 + +### OpenClaw + +价值: + +- 本身就是 plugin/gateway/platform +- 还支持 Claude / Codex / Cursor bundle compatibility + +在当前仓库里的角色: + +- **平台参考** +- 不是普通 coding CLI 宿主 +- 如果未来支持,应按 native plugin 或 bundle 轨道处理,而不是把当前 CLI companion 平移过去 + +## 当前仓库要吸收什么,不吸收什么 + +应该吸收: + +- Claude 的 memory 契约 +- Gemini 的 extension + hooks + MCP 思路 +- OpenCode 的 plugin + MCP + AGENTS 能力面 +- OpenClaw 的“统一 memory core,不统一格式”思路 +- 针对宿主差异提供清晰分层的接入面:`cam mcp install` 负责显式写入 project-scoped host config,`cam mcp print-config` / `cam mcp doctor` 继续负责只打印 / 只检查,`cam mcp apply-guidance --host codex` 负责 additive 管理 repo 级 `AGENTS.md` guidance block,而 `cam integrations apply --host codex` 负责显式收口整套 Codex stack apply;其中 skills 默认仍安装到 runtime target,但 `cam skills install --surface runtime|official-user|official-project` 与 `cam integrations install/apply --skill-surface ...` 已为官方 `.agents/skills` 路径准备显式 opt-in 兼容面;shell fallback 仍由 `cam hooks install` 提供;这条 hooks 线是本仓自带的 local bridge,不是官方 Codex hook surface + +不应该吸收: + +- 直接把当前仓库定义成五宿主统一主仓 +- 围绕统一 plugin format 做主抽象 +- 为了兼容更多宿主而稀释当前 Codex 场景的产品完成度 + +## 当前仓库的正式边界 + +当前仓库对外应保持以下表述: + +- `codex-auto-memory` 是 **Codex-first Hybrid memory system** +- 它当前服务于 Codex +- 它会正式吸收 hooks、skills、MCP-aware integration 方向 +- 它不会在当前阶段直接承担多宿主统一平台职责 + +## 与独立新仓的接口边界 + +未来如果新仓承担统一 memory core,这个仓库最适合作为: + +- `Codex adapter reference implementation` +- `Markdown-first product surface reference` +- `durable memory + continuity + reviewer contract` 的现实样例 + +这意味着当前仓库在设计上要尽量保留: + +- 清晰的 memory semantics +- 明确的 audit surface +- 可抽离的 extractor / store / injector seam + +但不需要提前重写成大平台结构。 diff --git a/docs/integration-strategy.md b/docs/integration-strategy.md new file mode 100644 index 0000000..caf483a --- /dev/null +++ b/docs/integration-strategy.md @@ -0,0 +1,163 @@ +# 集成演进策略 + +> 本文解释当前仓库为什么从“companion CLI”演进为 **Codex-first Hybrid memory system**,以及 hook / skill / MCP 在这个仓库里的正式定位。 + +## 一页结论 + +当前仓库的方向已经从单一 companion CLI 扩展为: + +- **当前实现仍以 wrapper + CLI 为主** +- **当前产品方向正式引入 hooks、skills、MCP-aware surfaces** +- **Markdown-first 是最高层不变量** +- **当前仓库仍以 Codex 为主宿主,不直接改写成多宿主统一平台** + +## 为什么要引入 hook / skill / MCP + +仅靠显式 CLI 虽然可靠,但会把一部分用户挡在外面: + +- 有人喜欢 `cam run`、`cam sync`、`cam memory` +- 也有人更希望代理在宿主内部自动完成记忆提取、召回、检索与审计 + +因此当前仓库需要同时服务两类用户: + +1. **显式工作流用户** + - 通过 `cam` 命令控制 memory +2. **更自动化的代理工作流用户** + - 希望通过 hooks、skills、MCP 让代理自己使用记忆能力 + +## 当前仓库的正式产品方向 + +当前仓库接下来的主方向是: + +- 继续把 Codex durable memory 做稳 +- 完成 issue 提到的 4 项核心能力 +- 在当前仓库内补齐 3 类 integration surfaces + +### A. Hooks + +用途: + +- 捕获生命周期事件 +- 自动触发 sync / recall / audit +- 降低手动维护成本 + +当前状态: + +- 已有 hook bridge 资产 +- `cam hooks install` 现在会生成本仓自带的 local bridge / fallback helper bundle:`memory-recall.sh`、兼容 helper wrappers 与 `recall-bridge.md` +- 这条线当前仍是本地桥接层,不宣称自己是官方 Codex hook surface +- 还不是主入口 + +目标状态: + +- 成为与 wrapper 并行的正式入口之一 + +### B. Skills + +用途: + +- 把 memory retrieval workflow 教给代理 +- 让代理知道什么时候该搜索记忆、什么时候该读 topic file、什么时候该做审计 + +当前状态: + +- 已进入代码主线 +- 当前仓库已经提供 `cam recall search` / `timeline` / `details` 作为 retrieval workflow 的当前 CLI surface +- `cam recall search` 现在默认已经对齐推荐 preset:`state=auto`、`limit=8`,会先查 active,未命中再回退 archived,继续降低代理手动 widened search 的摩擦 +- `cam skills install` 现在默认安装 runtime Codex skill,并支持显式 `--surface runtime|official-user|official-project`;无论安装到哪个 surface,都复用同一套 MCP-first、CLI-fallback 的 retrieval guidance 与推荐检索 preset:`state=auto`、`limit=8` + +目标状态: + +- 成为低摩擦、可分发、可复用的使用面 + +### C. MCP + +用途: + +- 提供低 token 的检索接口 +- 为 search / timeline / detail retrieval 提供统一工具面 + +当前状态: + +- 已有正式 retrieval MCP 主路径 +- `cam mcp serve` 会暴露 `search_memories`、`timeline_memories`、`get_memory_details` +- `search_memories` 与 `cam recall search` 现在共享 active-first、archived-fallback 的默认检索语义 +- 当前推荐的渐进式检索 preset 统一为:`state=auto`、`limit=8` +- `cam mcp install --host ` 会显式写入推荐的 project-scoped 宿主配置,继续降低接线摩擦,但不改变 retrieval 的只读语义 +- `cam mcp print-config --host ...` 会打印 ready-to-paste 宿主接入片段;其中 `--host codex` 现在还会额外打印推荐的 `AGENTS.md` snippet,把 durable memory workflow 正式接到 Codex 当前公开稳定 surface 上 +- `cam mcp apply-guidance --host codex` 会以 additive、可审计、fail-closed 的方式创建或更新 repo 根 `AGENTS.md` 中由本仓维护的 guidance block,继续降低手工粘贴成本 +- `cam integrations apply --host codex` 现在提供显式的一次性 Codex stack apply 入口:在不改变 `integrations install` 边界的前提下,统一编排 project-scoped MCP wiring、managed `AGENTS.md` guidance block、hooks 与 skills;其中 skills 默认仍走 runtime target,但也支持显式 `--skill-surface runtime|official-user|official-project` +- `cam mcp doctor` 会只读检查推荐的 project-scoped MCP 接线、project pinning 与 shared fallback bridge assets +- 它仍然是只读 retrieval plane,不是新的 canonical store + +目标状态: + +- 与 skills 配合形成 progressive disclosure retrieval workflow + +## 这 3 类能力如何分工 + +- hooks 解决“**什么时候触发**” +- skills 解决“**模型怎么使用**” +- MCP 解决“**模型具体能调用什么**” + +三者都不应该直接拥有 canonical memory。 + +真正的主真相仍然是: + +- `MEMORY.md` +- topic files +- continuity files +- audit / provenance logs + +## 当前仓库不做什么 + +为了避免方向走歪,当前仓库明确不做以下事情: + +- 不为了贴近 `claude-mem` 而改成 DB-first +- 不为了宿主兼容而把当前仓库直接升格成统一多宿主主仓 +- 不围绕 plugin format 做统一抽象 +- 不把 hooks / skills / MCP 的引入理解成“放弃 CLI 主线” + +## 当前仓库应该优先完成的产品面 + +当前仓库需要把 issue 中的 4 个能力明确落到实现目标上: + +1. 自动提取长期记忆 +2. 自动召回长期记忆 +3. 更新、去重、覆盖、归档 +4. 降低手动维护 Markdown 成本 + +建议优先顺序: + +1. 把 `update / dedupe / overwrite / archive` 语义做完整 +2. 补 retrieval workflow:`search / timeline / detail` +3. 把 hooks 和 skill packs 接到 retrieval workflow 上 +4. 再逐步降低用户显式调用 `cam` 命令的频率 + +## 当前仓库与未来新仓的关系 + +当前仓库的职责: + +- 做 **Codex-first 产品** +- 把 Codex 场景下的 Markdown-first memory 体验做强 +- 让当前实现可以逐步容纳 hook / skill / MCP surfaces + +未来新仓的职责: + +- 做 **host-adaptable memory core** +- 统一 memory semantics,而不是统一宿主格式 + +换句话说: + +- 当前仓库是产品线 +- 新仓是平台线 + +## 推荐的后续文档与实现边界 + +当前仓库内后续实现,应统一遵守: + +- `cam` 命令继续保留并作为最稳主入口 +- hooks / skills / MCP 进入主线时,不得绕开 canonical Markdown store +- SQLite / FTS / vector / graph 若引入,只能作为 sidecar retrieval plane +- session continuity 仍独立于 durable memory +- reviewer surface 不得因自动化增强而消失 diff --git a/docs/native-migration.en.md b/docs/native-migration.en.md index b83fa2f..882d92f 100644 --- a/docs/native-migration.en.md +++ b/docs/native-migration.en.md @@ -2,15 +2,27 @@ [简体中文](./native-migration.md) | [English](./native-migration.en.md) -> This document records the compatibility seam and re-evaluation criteria that `codex-auto-memory` keeps while remaining companion-first. It does not imply a planned primary-path change. +> This document now has a narrower job: it records how `codex-auto-memory` evaluates native Codex memory and hook signals without treating them as the only future direction. The repository is still Codex-first, but its broader product evolution now also includes non-native hook, skill, and MCP-aware integration paths. ## One-page conclusion Three conclusions matter most right now: -- native Codex memory and hooks are not ready to be the trusted primary path -- companion mode is not a temporary hack; it is the current mainline implementation -- re-evaluation is only justified when public docs, local stability, and CI-verifiable behavior improve together +- native Codex memory and hooks are still not ready to become the trusted primary path +- the current wrapper-driven implementation remains the strongest end-user path today +- native readiness is only one branch of the roadmap now, not the entire roadmap + +## What changed in positioning + +Historically, this document mainly justified why the repository stayed companion-first. + +That remains true operationally, but the repository direction is now wider: + +- keep the current Codex wrapper path stable +- continue evaluating native Codex capabilities conservatively +- separately prepare hook, skill, and MCP-friendly integration surfaces that preserve the same Markdown-first contract + +This means “do not switch to native yet” is still correct, but it no longer implies “do not expand the integration surface in other ways.” ## Current reality @@ -20,6 +32,7 @@ Official Codex public materials already confirm some useful building blocks: - project-level `.codex/config.toml` - multi-agent workflows - resume and fork flows +- MCP server configuration Local runtime behavior and `cam doctor --json` also expose readiness signals: @@ -27,7 +40,7 @@ Local runtime behavior and `cam doctor --json` also expose readiness signals: - `memories` - `codex_hooks` -But those signals are still not enough to retire the companion path. +But those signals are still not enough to retire the current wrapper path or claim a stable native memory contract. ## Keep public facts separate from local observations @@ -47,32 +60,34 @@ Source inspection or local runtime behavior may reveal: - feature flags - config shapes -Those can guide integration re-evaluation, but they should not be presented as stable public guarantees. +Those can inform integration strategy, but they should not be promoted into public guarantees. -## Why the project does not switch to native today +## Why the project does not switch to a native-first path today | Question | Current answer | | :-- | :-- | | Are native memories publicly stable? | Not yet | -| Are the local native-hook signals rich enough for the Claude-style lifecycle? | Not yet | +| Are native hooks rich enough to replace the current end-to-end flow? | Not yet | | Can native behavior be validated reliably in CI? | Not yet | -| Can it preserve the current Markdown contract? | Not yet | +| Can it preserve the current Markdown contract cleanly? | Not yet | -That is why the default conclusion remains: +That is why the default operating rule remains: -- companion-first -- keep only a compatibility seam while companion-first remains the default path +- keep the current wrapper-first implementation as the primary path +- treat native memory and hooks as re-evaluation targets, not active foundations -## What must stay stable if official surfaces change +## What must stay stable if native Codex surfaces improve later -Even if the plumbing changes later, the user mental model should stay as stable as possible: +Even if the plumbing changes, the user mental model should stay as stable as possible: - Markdown-first memory - `MEMORY.md` as the compact entrypoint - topic files as the detail layer - project and project-local scope boundaries -- a strict separation between session continuity and durable memory -- inspect, audit, and explicit correction as part of the workflow +- strict separation between session continuity and durable memory +- inspect, audit, correction, and reviewer-visible memory lifecycle + +If a future native path cannot preserve those behaviors, it should not replace the current contract. ## Required compatibility seam @@ -83,7 +98,10 @@ To make later migration possible, the current implementation should keep these b - `MemoryStore` - `RuntimeInjector` -As long as those seams remain real, the repository can re-evaluate integration choices without rewriting the product model. +Those seams now support two kinds of future work: + +1. native Codex re-evaluation +2. integration-aware expansion through hooks, skills, and MCP-friendly surfaces ## Current operating rule @@ -91,18 +109,26 @@ As long as those seams remain real, the repository can re-evaluate integration c - keep wrapper-based startup injection - keep Markdown as the primary memory surface - keep session continuity as a separate companion layer -- keep only an explicit compatibility seam for future native surfaces, without implying a switch phase +- keep native migration conservative +- allow non-native integration expansion as long as it preserves the same Markdown contract ## Decision rule Do not rewrite the roadmap simply because a native flag exists. -Re-evaluation becomes reasonable only when all of the following are true: + +Native re-evaluation becomes reasonable only when all of the following are true: - official public documentation is sufficiently explicit - behavior is stable across releases - the behavior can be validated in CI or deterministic local automation - the native path preserves the current user contract -- Markdown-first auditability is not lost in the process +- Markdown-first auditability is not lost + +Separately, non-native integration work such as hook, skill, or MCP-based access may proceed earlier if: + +- it does not require native Codex guarantees +- it preserves the current durable-memory and continuity boundaries +- it remains auditable and reviewer-friendly ## Official references diff --git a/docs/native-migration.md b/docs/native-migration.md index dc8e1e0..e7de36f 100644 --- a/docs/native-migration.md +++ b/docs/native-migration.md @@ -2,15 +2,16 @@ [简体中文](./native-migration.md) | [English](./native-migration.en.md) -> 本文记录的是 `codex-auto-memory` 在 companion-first 前提下保留的 compatibility seam 与重评条件,不预设主路径变更。 +> 本文现在只回答一个问题:**什么时候才值得把 Codex native memory / hooks 从 readiness signal 提升为主路径?** +> 它不再承担当前仓库的整体集成方向说明。整体方向请看 [集成演进策略](./integration-strategy.md)。 ## 一页结论 当前最重要的判断只有三条: - native Codex memory / hooks 还不能作为 trusted primary path -- companion mode 不是临时凑合方案,而是当前主线实现 -- 只有在“公开文档 + 本地稳定性 + CI 可验证性”同时改善时,才值得重评 integration choice +- 当前仓库的主实现仍然是 companion-first +- 但这不等于 hook / skill / MCP 方向被排除;它们可以在保持 Markdown-first 的前提下进入主线 ## 当前现实 @@ -19,7 +20,9 @@ Codex 的官方公开资料已经能确认一些对本项目有价值的基础 - `AGENTS.md` - project-level `.codex/config.toml` - multi-agent workflows -- resume / fork +- sessions / resume / fork +- MCP server configuration +- skills / rules 等宿主能力面 本地运行时与 `cam doctor --json` 还能看到一些 readiness signal: @@ -27,27 +30,17 @@ Codex 的官方公开资料已经能确认一些对本项目有价值的基础 - `memories` - `codex_hooks` -但这些还不足以支撑“现在就把 companion path 废掉”。 +但这些还不足以支撑“现在就把 current companion path 废掉”。 -## 公开事实与本地观察要分开写 +## 本文不再回答什么 -### 公开可引用的事实 +以下内容不再由本文负责: -从官方公开资料可以安全说的是: +- 当前仓库如何演进成 `Codex-first Hybrid` +- 为什么 hook / skill / MCP 已经进入正式方向 +- 当前仓库如何同时服务显式 CLI 用户与更自动化的代理用户 -- Codex CLI 已公开发布 -- feature maturity 页面把部分能力放在 experimental / under-development 语境里 -- 当前公开面没有给出完整、稳定、等价于 Claude Code 的 memory 产品契约 - -### 本地观察只能作为 readiness signal - -本地 source inspection 或 runtime observation 可能会显示: - -- 某些目录布局 -- 某些 feature flags -- 某些配置项 - -这些信息可以帮助我们判断是否值得重评接入方式,但不能在公开文档里写成稳定 API 保证。 +这些由 [集成演进策略](./integration-strategy.md) 统一解释。 ## 为什么当前不能直接切到 native @@ -57,11 +50,12 @@ Codex 的官方公开资料已经能确认一些对本项目有价值的基础 | 本地 native hooks signal 已足够支撑 Claude-style lifecycle 吗 | 还没有 | | 能在 CI 中可靠验证 native behavior 吗 | 还不够 | | 能保证与当前 Markdown contract 等价吗 | 还不能 | +| 能替代当前 wrapper + rollout + audit 语义吗 | 还不能 | 因此当前默认结论仍然是: -- companion-first -- 只保留 compatibility seam,主路径仍然默认 companion-first +- current implementation remains companion-first +- native path stays behind a strict re-evaluation gate ## 即使官方 surface 变化,也必须保持稳定的东西 @@ -73,10 +67,11 @@ Codex 的官方公开资料已经能确认一些对本项目有价值的基础 - project / project-local scope 边界 - session continuity 与 durable memory 分离 - inspect / audit / explicit correction 的基本使用方式 +- sidecar index 不能取代 canonical Markdown store ## 当前必须保留的 compatibility seam -为了保证未来可以迁移,当前实现必须继续显式保留: +为了保证未来可以重评 native path,当前实现必须继续显式保留: - `SessionSource` - `MemoryExtractor` @@ -87,15 +82,17 @@ Codex 的官方公开资料已经能确认一些对本项目有价值的基础 ## 当前运行规则 +在 native path 未通过重评前,当前仓库继续遵循: + - 继续使用 rollout JSONL - 继续使用 wrapper startup injection - 继续把 Markdown 作为主存储表面 - 继续把 session continuity 当作独立 companion layer -- 对 future native surfaces 只保留显式 compatibility seam,不预设切换阶段 +- 允许 hook / skill / MCP 以并行入口进入主线 +- 不允许任何新入口绕开 canonical Markdown contract -## 决策规则 +## 什么时候值得重评 native path -不要因为“看到了某个 native flag”就重写路线图。 只有当以下条件同时成立时,才值得重新评估 integration choice: - 官方公开文档足够明确 @@ -103,6 +100,19 @@ Codex 的官方公开资料已经能确认一些对本项目有价值的基础 - 可以通过 CI 或可重复本地自动化验证 - 能保持当前核心用户契约 - 不会把 Markdown-first 和 companion auditability 直接丢掉 +- 能与现有 `cam memory` / `cam session` reviewer surfaces 等价或更好 + +## 决策规则 + +不要因为“看到了某个 native flag”就重写路线图。 + +重评 native path 也不意味着: + +- 当前仓库要改成 DB-first +- 当前仓库要放弃 CLI / wrapper 主入口 +- 当前仓库要停止推进 hook / skill / MCP-aware integration + +native migration 与 integration expansion 是两条不同问题,必须分开判断。 ## 官方参考 diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 71cfe84..433fa1d 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -13,7 +13,10 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - `docs/native-migration.md` and `docs/native-migration.en.md` - Confirm `docs/claude-reference.md` still reflects the Claude-style contract the code is trying to mimic. - Confirm `docs/native-migration.md` still matches the current compatibility seams in code. -- Confirm public wording still keeps `cam memory` as an inspect/audit surface, `cam session` as a compact continuity surface, and the project as companion-first rather than native-ready. +- Confirm public wording still keeps `cam memory` as an inspect/audit surface, `cam recall` as the progressive-disclosure retrieval surface, `cam session` as a compact continuity surface, and the repository as a `Codex-first Hybrid` system rather than a native-ready replacement. +- Confirm the newer direction docs still match the README and architecture posture: + - `docs/integration-strategy.md` + - `docs/host-surfaces.md` - Confirm `docs/session-continuity.md` matches the current `cam session` command surface and reviewer semantics, especially the wording split between `save`, `refresh`, and recovery markers. ## Code and runtime checks @@ -39,6 +42,22 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Run `node dist/cli.js session load --json` and confirm older JSON consumers still receive the existing core fields. - Run `node dist/cli.js session status --json` and confirm the latest explicit audit drill-down matches the newest audit-log entry when present. - Run `node dist/cli.js memory --recent --json` and confirm suppressed conflict candidates remain reviewer-visible instead of being silently merged. +- Run `node dist/cli.js recall search pnpm --json` and confirm the default search contract stays aligned at `state=auto, limit=8`, returning compact refs before any full detail fetch. +- Run `node dist/cli.js recall details --json` for one returned ref and confirm the path resolves to Markdown-backed memory, including archived refs when relevant. +- Run a local MCP smoke against `node dist/cli.js mcp serve` and confirm `search_memories`, `timeline_memories`, and `get_memory_details` are exposed as a read-only retrieval plane. +- Run `node dist/cli.js mcp install --host --json` and confirm the result contract includes `host`, `serverName`, `projectRoot`, `targetPath`, `action`, `projectPinned`, and `readOnlyRetrieval`. +- Re-run the same `node dist/cli.js mcp install --host --json` command once and confirm it returns `action: "unchanged"` when the target host config is already canonical. +- Confirm `node dist/cli.js mcp install --host generic` fails explicitly and still points users to manual wiring. +- Run `node dist/cli.js mcp print-config --host --json` for each public host and confirm the snippet contract includes `serverName`, `targetFileHint`, and a project-pinned retrieval command without writing host config files. +- For `node dist/cli.js mcp print-config --host codex --json`, also confirm the payload includes an additive AGENTS.md snippet / guidance block that teaches MCP-first, `cam recall`-fallback durable memory usage. +- Run `node dist/cli.js mcp apply-guidance --host codex --json` and confirm it reports `created`, `updated`, `unchanged`, or `blocked` without overwriting unrelated AGENTS.md content outside the managed block. +- Confirm `node dist/cli.js mcp apply-guidance --host codex --json` ignores fenced-code examples of the managed markers, and that `node dist/cli.js mcp doctor --json` does not treat fenced examples as installed guidance. +- Run `node dist/cli.js mcp doctor --json` and confirm it reports project-scoped host wiring, project pinning, and hook / skill fallback assets without creating memory layout or mutating host config files. +- Run `node dist/cli.js integrations install --host codex --json` and confirm it orchestrates the existing Codex MCP wiring, hook bundle, and skill assets without touching the Markdown memory store. +- Run `node dist/cli.js integrations apply --host codex --json` and confirm it orchestrates MCP wiring, managed AGENTS guidance, hook assets, and skill assets while keeping `integrations install --host codex` non-mutating for AGENTS.md. +- Run `node dist/cli.js skills install --surface official-user` and confirm the explicit official `.agents/skills` copy is written without changing the runtime default target. +- Run `node dist/cli.js integrations install --host codex --skill-surface official-user --json` and confirm the skill subaction reports the selected surface while MCP and AGENTS boundaries stay unchanged. +- Run `node dist/cli.js integrations doctor --host codex --json` and confirm it reports the thin Codex-only stack readiness view with `recommendedRoute`, `recommendedPreset`, `subchecks`, and `nextSteps`. - Confirm `node dist/cli.js session load --json` / `status --json` still expose `confidence` and warnings when the rollout required a conservative continuity summary. - Confirm continuity reviewer warnings stay in diagnostics / audit surfaces and are not written into continuity Markdown body text. - Run a local smoke flow: @@ -55,6 +74,7 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor ## Documentation checks - Update the bilingual docs entry pages (`docs/README.md` and `docs/README.en.md`) if the public reading path changed. +- Update `docs/integration-strategy.md` and `docs/host-surfaces.md` when the repository adds or defers a new integration surface. - Re-check the current official Codex and Claude public docs before changing migration wording; if the public posture is unchanged, say so explicitly in the handoff. - Ensure the latest milestone commit is focused enough to review independently. diff --git a/docs/session-continuity.md b/docs/session-continuity.md index f3160f4..0e34015 100644 --- a/docs/session-continuity.md +++ b/docs/session-continuity.md @@ -281,6 +281,7 @@ Therefore the current implementation is: - companion-first - Codex-first in path defaults and command surface - Claude-compatible through path-style and workflow adapters +- wrapper-first in day-to-day operation, while future hook / skill / MCP-aware paths remain required to preserve the same continuity contract Claude-specific community patterns are useful reference material, but they do not override the Codex-first design rule. @@ -310,6 +311,7 @@ These sources justify the current implementation choice: - Codex-first path defaults - wrapper-based startup injection - optional automation rather than assuming stable native hooks +- future integration surfaces should consume continuity as auditable working state, not collapse it into opaque host-native session state ### Community reference: `affaan-m/everything-claude-code` @@ -324,4 +326,4 @@ Important differences from this project: - `codex-auto-memory` does **not** adopt `~/.claude/sessions/` as its primary canonical store - `codex-auto-memory` keeps shared project continuity in the companion root so worktrees can share it safely - Claude-style session file paths are supported only as an adapter path style, not as the main product model -- learned skills / instincts remain future companion ideas, not part of the current durable memory or continuity contract +- learned skills or hook-driven recall paths may eventually consume continuity outputs, but they are still downstream integration surfaces rather than part of the continuity body itself diff --git a/test/docs-contract.test.ts b/test/docs-contract.test.ts index 37aad99..17e06bb 100644 --- a/test/docs-contract.test.ts +++ b/test/docs-contract.test.ts @@ -29,23 +29,85 @@ describe("docs contract", () => { expect(readme).toContain("cam memory"); expect(readme).toContain("cam session status"); expect(readme).toContain("cam session refresh"); - expect(readme).toContain("reviewer warning prose"); - expect(readme).toContain("tagged GitHub Releases"); - expect(readme).toContain("tarball install smoke"); + expect(readme).toContain("cam recall search"); + expect(readme).toContain("--state auto"); + expect(readme).toContain("当前主任务"); + expect(readme).toContain("suppressed conflict candidates"); + expect(readme).toContain("cam mcp install --host codex"); + expect(readme).toContain("cam hooks install"); + expect(readme).toContain("cam integrations install --host codex"); + expect(readme).toContain("cam integrations apply --host codex"); + expect(readme).toContain("cam integrations doctor --host codex"); + expect(readme).toContain("memory-recall.sh"); + expect(readme).toContain("limit=8"); + expect(readme).toContain("local bridge"); + expect(readme).toContain("cam mcp apply-guidance --host codex"); + expect(readme).toContain("cam skills install"); + expect(readme).toContain("--surface runtime|official-user|official-project"); + expect(readme).toContain("cam mcp serve"); + expect(readme).toContain("cam mcp print-config --host codex"); + expect(readme).toContain("AGENTS.md"); + expect(readme).toContain("cam mcp doctor"); + expect(readme).toContain("manual-only"); + expect(readme).toContain("cam forget \"old debug note\" --archive"); expect(readme).toContain("README.zh-TW.md"); expect(readme).toContain("README.ja.md"); + expect(readme).toContain("集成演进策略"); + expect(readme).toContain("宿主能力面"); expect(readmeTw).toContain("README.md"); expect(readmeTw).toContain("README.en.md"); + expect(readmeTw).toContain("memory-recall.sh"); + expect(readmeTw).toContain("cam integrations install --host codex"); + expect(readmeTw).toContain("cam integrations apply --host codex"); + expect(readmeTw).toContain("cam integrations doctor --host codex"); + expect(readmeTw).toContain("limit=8"); + expect(readmeTw).toContain("cam mcp install --host codex"); + expect(readmeTw).toContain("cam mcp print-config --host codex"); + expect(readmeTw).toContain("cam mcp apply-guidance --host codex"); + expect(readmeTw).toContain("cam mcp doctor"); + expect(readmeTw).toContain("--state auto"); + expect(readmeTw).toContain("local bridge"); + expect(readmeTw).toContain("--surface runtime|official-user|official-project"); expect(readmeJa).toContain("README.md"); expect(readmeJa).toContain("README.en.md"); + expect(readmeJa).toContain("memory-recall.sh"); + expect(readmeJa).toContain("cam integrations install --host codex"); + expect(readmeJa).toContain("cam integrations apply --host codex"); + expect(readmeJa).toContain("cam integrations doctor --host codex"); + expect(readmeJa).toContain("limit=8"); + expect(readmeJa).toContain("cam mcp install --host codex"); + expect(readmeJa).toContain("cam mcp print-config --host codex"); + expect(readmeJa).toContain("cam mcp apply-guidance --host codex"); + expect(readmeJa).toContain("cam mcp doctor"); + expect(readmeJa).toContain("--state auto"); + expect(readmeJa).toContain("local bridge"); + expect(readmeJa).toContain("--surface runtime|official-user|official-project"); expect(readmeEn).toContain("cam memory"); expect(readmeEn).toContain("cam session status"); + expect(readmeEn).toContain("cam recall search"); + expect(readmeEn).toContain("--state auto"); expect(readmeEn).toContain("confidence"); - expect(readmeEn).toContain("deterministic scrub"); - expect(readmeEn).toContain("tagged GitHub Releases"); - expect(readmeEn).toContain("tarball install smoke"); + expect(readmeEn).toContain("suppressed conflict candidates"); + expect(readmeEn).toContain("wrapper-driven companion layer"); + expect(readmeEn).toContain("cam mcp install --host codex"); expect(readmeEn).toContain("README.zh-TW.md"); expect(readmeEn).toContain("README.ja.md"); + expect(readmeEn).toContain("hook, skill, and MCP-aware"); + expect(readmeEn).toContain("cam hooks"); + expect(readmeEn).toContain("cam integrations install --host codex"); + expect(readmeEn).toContain("cam integrations apply --host codex"); + expect(readmeEn).toContain("cam integrations doctor --host codex"); + expect(readmeEn).toContain("cam skills"); + expect(readmeEn).toContain("memory-recall.sh"); + expect(readmeEn).toContain("limit=8"); + expect(readmeEn).toContain("local bridge"); + expect(readmeEn).toContain("cam mcp apply-guidance --host codex"); + expect(readmeEn).toContain("cam mcp serve"); + expect(readmeEn).toContain("cam mcp print-config --host codex"); + expect(readmeEn).toContain("AGENTS.md"); + expect(readmeEn).toContain("cam mcp doctor"); + expect(readmeEn).toContain("manual-only"); + expect(readmeEn).toContain("--surface runtime|official-user|official-project"); expect(releaseChecklist).toContain("pnpm test:dist-cli-smoke"); expect(releaseChecklist).toContain("pnpm test:tarball-install-smoke"); expect(releaseChecklist).toContain("node dist/cli.js --version"); @@ -58,10 +120,43 @@ describe("docs contract", () => { expect(releaseChecklist).toContain("node dist/cli.js session refresh --json"); expect(releaseChecklist).toContain("node dist/cli.js session load --json"); expect(releaseChecklist).toContain("node dist/cli.js session status --json"); + expect(releaseChecklist).toContain("node dist/cli.js recall search pnpm --json"); + expect(releaseChecklist).toContain("state=auto, limit=8"); + expect(releaseChecklist).toContain("node dist/cli.js recall details --json"); + expect(releaseChecklist).toContain( + "node dist/cli.js mcp install --host --json" + ); + expect(releaseChecklist).toContain('action: "unchanged"'); + expect(releaseChecklist).toContain("node dist/cli.js mcp install --host generic"); + expect(releaseChecklist).toContain( + "node dist/cli.js mcp print-config --host --json" + ); + expect(releaseChecklist).toContain("AGENTS.md snippet"); + expect(releaseChecklist).toContain("node dist/cli.js mcp apply-guidance --host codex --json"); + expect(releaseChecklist).toContain("node dist/cli.js mcp doctor --json"); + expect(releaseChecklist).toContain("node dist/cli.js integrations install --host codex --json"); + expect(releaseChecklist).toContain("node dist/cli.js integrations apply --host codex --json"); + expect(releaseChecklist).toContain("node dist/cli.js skills install --surface official-user"); + expect(releaseChecklist).toContain("node dist/cli.js integrations install --host codex --skill-surface official-user --json"); + expect(releaseChecklist).toContain("node dist/cli.js integrations doctor --host codex --json"); + expect(releaseChecklist).toContain("search_memories"); expect(contributing).toContain("reviewer-only warnings"); expect(contributing).toContain("pnpm test:docs-contract"); expect(contributing).toContain("pnpm test:dist-cli-smoke"); expect(contributing).toContain("pnpm test:tarball-install-smoke"); + expect(contributing).toContain("cam mcp apply-guidance"); + expect(contributing).toContain("cam integrations apply"); + expect(contributing).toContain("skill surface selection"); + expect(packageJson.scripts["test:cli-smoke"]).toContain("test/recall-command.test.ts"); + expect(packageJson.scripts["test:cli-smoke"]).toContain("test/hooks-command.test.ts"); + expect(packageJson.scripts["test:cli-smoke"]).toContain("test/integrations-command.test.ts"); + expect(packageJson.scripts["test:cli-smoke"]).toContain("test/mcp-command.test.ts"); + expect(packageJson.scripts["test:cli-smoke"]).toContain("test/skills-command.test.ts"); + expect(packageJson.scripts["test:reviewer-smoke"]).toContain("test/recall-command.test.ts"); + expect(packageJson.scripts["test:reviewer-smoke"]).toContain("test/hooks-command.test.ts"); + expect(packageJson.scripts["test:reviewer-smoke"]).toContain("test/integrations-command.test.ts"); + expect(packageJson.scripts["test:reviewer-smoke"]).toContain("test/mcp-command.test.ts"); + expect(packageJson.scripts["test:reviewer-smoke"]).toContain("test/skills-command.test.ts"); expect(packageJson.scripts["test:dist-cli-smoke"]).toBe("vitest run test/dist-cli-smoke.test.ts"); expect(packageJson.scripts["test:tarball-install-smoke"]).toBe( "vitest run test/tarball-install-smoke.test.ts" @@ -89,8 +184,8 @@ describe("docs contract", () => { expect(releaseWorkflow).toContain("pnpm verify:release"); expect(releaseWorkflow).toContain("gh release create"); expect(releaseChecklist).toContain("default branch"); - expect(readme).toContain("确认默认分支上的该 workflow 已激活且可观测"); - expect(readmeEn).toContain("default branch exposes and activates that workflow"); + expect(releaseChecklist).toContain("remote default branch exposes `release.yml` in Actions"); + expect(releaseChecklist).toContain("workflow is active"); }); it("keeps continuity, architecture, and migration wording aligned with the current product posture", async () => { @@ -98,6 +193,8 @@ describe("docs contract", () => { const nativeMigrationDoc = await readDoc("docs/native-migration.md"); const architecture = await readDoc("docs/architecture.md"); const architectureEn = await readDoc("docs/architecture.en.md"); + const integrationStrategy = await readDoc("docs/integration-strategy.md"); + const hostSurfaces = await readDoc("docs/host-surfaces.md"); const readme = await readDoc("README.md"); const readmeEn = await readDoc("README.en.md"); @@ -106,13 +203,60 @@ describe("docs contract", () => { expect(continuityDoc).toContain("pending continuity recovery marker"); expect(continuityDoc).toContain("**not** written into the continuity Markdown files themselves"); expect(continuityDoc).toContain("reviewer/debug data belongs in an audit surface"); + expect(continuityDoc).toContain("future integration surfaces should consume continuity"); expect(nativeMigrationDoc).toContain("companion-first"); expect(nativeMigrationDoc).toContain("trusted primary path"); + expect(nativeMigrationDoc).toContain("Codex-first Hybrid"); expect(architecture).toContain("reviewer warning / confidence 属于 audit side metadata"); - expect(architecture).toContain("startup provenance 只列出这次注入时真实读取到的 continuity 文件"); + expect(architecture).toContain("startup provenance 只列出真实读取到的 continuity 文件"); + expect(architecture).toContain("cam recall"); + expect(architecture).toContain("memory-recall.sh"); + expect(architecture).toContain("cam mcp serve"); + expect(architecture).toContain("cam mcp install"); + expect(architecture).toContain("state=auto"); + expect(architecture).toContain("local bridge / fallback recall bundle"); + expect(architecture).toContain("cam mcp print-config"); + expect(architecture).toContain("cam mcp doctor"); + expect(architecture).toContain("Codex-first Hybrid"); expect(architectureEn).toContain("reviewer warnings and confidence remain audit-side metadata"); - expect(architectureEn).toContain("startup provenance only lists continuity files that were actually read"); + expect(architectureEn).toContain("cam recall timeline"); + expect(architectureEn).toContain("cam mcp serve"); + expect(architectureEn).toContain("cam mcp install"); + expect(architectureEn).toContain("state=auto, limit=8"); + expect(architectureEn).toContain("local bridge assets"); + expect(architectureEn).toContain("cam mcp print-config"); + expect(architectureEn).toContain("cam mcp doctor"); + expect(architectureEn).toContain("memory-recall.sh"); + expect(architectureEn).toContain("continuity startup provenance only lists files actually used"); + expect(architectureEn).toContain("Codex-first Hybrid"); + expect(integrationStrategy).toContain("Codex-first Hybrid"); + expect(integrationStrategy).toContain("hook / skill / MCP"); + expect(integrationStrategy).toContain("cam recall"); + expect(integrationStrategy).toContain("state=auto"); + expect(integrationStrategy).toContain("limit=8"); + expect(integrationStrategy).toContain("cam mcp install"); + expect(integrationStrategy).toContain("cam mcp apply-guidance"); + expect(integrationStrategy).toContain("memory-recall.sh"); + expect(integrationStrategy).toContain("local bridge / fallback helper bundle"); + expect(integrationStrategy).toContain("cam mcp serve"); + expect(integrationStrategy).toContain("cam mcp print-config"); + expect(integrationStrategy).toContain("AGENTS.md"); + expect(integrationStrategy).toContain("cam mcp doctor"); + expect(integrationStrategy).toContain("cam skills install"); + expect(integrationStrategy).toContain("--surface runtime|official-user|official-project"); + expect(hostSurfaces).toContain("Codex-first Hybrid memory system"); + expect(hostSurfaces).toContain("cam integrations install/apply --skill-surface"); + expect(hostSurfaces).toContain("cam recall"); + expect(hostSurfaces).toContain("cam hooks install"); + expect(hostSurfaces).toContain("local bridge"); + expect(hostSurfaces).toContain("read-only retrieval surface"); + expect(hostSurfaces).toContain("cam mcp install"); + expect(hostSurfaces).toContain("cam mcp apply-guidance"); + expect(hostSurfaces).toContain("AGENTS.md"); + expect(hostSurfaces).toContain("cam mcp doctor"); expect(readme).toContain("companion-first"); - expect(readmeEn).toContain("companion-first"); + expect(readmeEn).toContain("companion CLI"); + expect(readme).toContain("当前主任务"); + expect(readmeEn).toContain("Current priorities"); }); }); From e1ac20e125f4a1121af5b38ad4b139ecf926ca6e Mon Sep 17 00:00:00 2001 From: blocks Date: Thu, 26 Mar 2026 17:56:08 +0800 Subject: [PATCH 03/62] feat: close codex integration workflow contract gaps --- README.ja.md | 5 + README.zh-TW.md | 5 + docs/README.en.md | 6 +- docs/README.md | 6 +- docs/architecture.md | 11 +- docs/claude-reference.en.md | 11 + docs/host-surfaces.md | 5 +- docs/integration-strategy.md | 8 +- docs/release-checklist.md | 12 + package.json | 2 +- src/lib/cli/register-commands.ts | 48 ++-- src/lib/commands/integrations.ts | 42 +++- src/lib/commands/mcp.ts | 3 +- src/lib/integration/codex-stack.ts | 46 +++- src/lib/integration/mcp-doctor.ts | 22 +- src/lib/integration/mcp-hosts.ts | 25 ++ src/lib/integration/retrieval-contract.ts | 45 +++- src/lib/integration/skills-paths.ts | 11 +- test/dist-cli-smoke.test.ts | 281 ++++++++++++++++++++++ test/docs-contract.test.ts | 66 +++++ test/integrations-command.test.ts | 187 ++++++++++++++ test/mcp-command.test.ts | 30 +++ test/recall-command.test.ts | 66 +++++ test/tarball-install-smoke.test.ts | 277 ++++++++++++++++++++- 24 files changed, 1167 insertions(+), 53 deletions(-) diff --git a/README.ja.md b/README.ja.md index 8fc4daa..06f73e5 100644 --- a/README.ja.md +++ b/README.ja.md @@ -213,6 +213,11 @@ cam audit | `cam audit` | プライバシーと secret hygiene を監査 | | `cam doctor` | ローカル wiring と native-readiness を確認 | +補足: + +- `cam skills install` の公開 surface は `runtime`、`official-user`、`official-project` に固定された。runtime が既定 target のままで、公式 `.agents/skills` 経路は明示的な opt-in install として扱う。 +- 主要な `--help` 文言も release-facing public contract の一部として扱う。特に `integrations install/apply/doctor`、`mcp install/print-config/apply-guidance`、`skills install` は README、アーキテクチャ文書、dist/tarball smoke と同じ境界説明を維持する必要がある。 + ## 動作の仕組み ### 設計原則 diff --git a/README.zh-TW.md b/README.zh-TW.md index ef3dbaa..66791b2 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -215,6 +215,11 @@ cam audit | `cam audit` | 做隱私與 secret-hygiene 檢查 | | `cam doctor` | 檢視本地 wiring 與 native-readiness posture | +補充約定: + +- `cam skills install` 的公開 surface 現在固定為 `runtime`、`official-user`、`official-project`;runtime 仍是預設 target,官方 `.agents/skills` 路徑維持顯式 opt-in。 +- 重要 `--help` 文案現在也視為 release-facing public contract,必須和 README、架構文檔以及 `dist` / tarball smoke 保持一致,特別是 `integrations install/apply/doctor`、`mcp install/print-config/apply-guidance`、`skills install` 這幾個面。 + ## 工作方式 ### 設計原則 diff --git a/docs/README.en.md b/docs/README.en.md index 302fd2e..a55fb37 100644 --- a/docs/README.en.md +++ b/docs/README.en.md @@ -57,9 +57,13 @@ - core product boundaries belong in the README and architecture docs - claim-sensitive wording must stay aligned with official public documentation - the repository now documents both present behavior and deliberate evolution toward hook, skill, and MCP-aware surfaces -- the latest low-friction MCP wiring surface is now layered: `cam mcp install` writes the recommended project-scoped host config, `cam mcp print-config` and `cam mcp doctor` stay print-only and inspect-only, `cam mcp apply-guidance --host codex` manages the repository-level `AGENTS.md` guidance block through an additive fail-closed flow, and `cam integrations apply --host codex` provides an explicit one-shot Codex stack apply entrypoint +- the latest low-friction MCP wiring surface is now layered: `cam mcp install` writes the recommended project-scoped host config, `cam mcp print-config` and `cam mcp doctor` stay print-only and inspect-only, and `cam mcp apply-guidance --host codex` manages the repository-level `AGENTS.md` guidance block through an additive fail-closed flow +- `cam integrations install --host codex` orchestrates MCP wiring plus hook and skill assets, `cam integrations apply --host codex` adds the managed `AGENTS.md` guidance flow on top, and `cam integrations doctor --host codex` remains the thin read-only readiness view +- `cam skills install` now has three public surfaces: `runtime`, `official-user`, and `official-project`; runtime stays the default target, while the official `.agents/skills` copies remain explicit opt-in installs +- the `generic` host remains manual-only: it is supported by `cam mcp print-config --host generic`, but intentionally rejected by `cam mcp install --host generic` - `cam recall search` now defaults to the active-first, archived-fallback read-only retrieval path with `state=auto, limit=8` - maintainers should avoid reverting to the older “companion-only and future-seam-only” wording unless the implementation direction changes again +- key `--help` text is part of the release-facing public contract and should stay aligned with the README, architecture docs, and release-facing smoke coverage ## Language policy diff --git a/docs/README.md b/docs/README.md index 11a3cf1..5d82ae9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -59,7 +59,10 @@ - issue 中的 4 项核心能力:自动提取、自动召回、更新/去重/覆盖/归档、降低手动维护成本 3. **方向上为什么要补 hook / skill / MCP** - 因为当前仓库不再只服务显式 CLI 用户,而是也面向希望让代理自己自动使用记忆能力的用户 - - 当前最新的低摩擦接入面已经分层:`cam mcp install` 负责显式写入 project-scoped host config,`cam mcp print-config` / `cam mcp doctor` 继续负责只打印 / 只检查,`cam mcp apply-guidance --host codex` 负责以 additive、fail-closed 的方式管理仓库级 `AGENTS.md` guidance block,而 `cam integrations apply --host codex` 则提供显式的一次性全栈 apply 入口 + - 当前最新的低摩擦接入面已经分层:`cam mcp install` 负责显式写入 project-scoped host config,`cam mcp print-config` / `cam mcp doctor` 继续负责只打印 / 只检查,`cam mcp apply-guidance --host codex` 负责以 additive、fail-closed 的方式管理仓库级 `AGENTS.md` guidance block + - `cam integrations install --host codex` 负责编排 MCP wiring、hook bridge bundle 与 skill assets;`cam integrations apply --host codex` 在此基础上额外编排 managed `AGENTS.md` guidance;`cam integrations doctor --host codex` 则只读汇总推荐路由、推荐 preset、subchecks 与 next steps + - `cam skills install` 的公开 skill surface 现在固定为 `runtime|official-user|official-project`;其中 runtime 仍是默认 target,官方 `.agents/skills` 路径保持显式 opt-in + - `generic` host 仍然保持 manual-only:不支持 `cam mcp install --host generic`,但继续支持 `cam mcp print-config --host generic` - `cam recall search` 现在默认补上了 active-first、archived-fallback 的只读 retrieval 搜索面,并对齐 `state=auto`、`limit=8` ## 语言策略 @@ -76,3 +79,4 @@ - `Markdown-first` 是文档中的最高层不变量 - `Codex-first` 是当前仓库的宿主边界,不把主仓直接写成多宿主统一平台 - claim-sensitive 内容必须与官方公开资料兼容 +- 关键 `--help` 文案也属于 release-facing public contract,需要与 README、架构文档和 smoke 测试一起保持同步 diff --git a/docs/architecture.md b/docs/architecture.md index 2f1fb64..be9f053 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -117,11 +117,15 @@ session continuity 是独立 companion layer,不属于 durable memory 契约 - 当前 concrete integration assets 已包括: - `cam recall search` 默认采用 `state=auto`、`limit=8`,提供 active-first、archived-fallback 的只读 retrieval 搜索面 - `cam hooks install` 生成本仓自带的 local bridge / fallback recall bundle(`memory-recall.sh`、兼容 wrappers、`recall-bridge.md`),而不是官方 Codex hook surface - - `cam skills install` 安装用户级 Codex skill,沿用同一套 MCP-first、CLI-fallback 的 `search -> timeline -> details` durable memory 工作流 + - `cam skills install` 默认安装 runtime Codex skill,同时支持显式 `--surface runtime|official-user|official-project`;三种 surface 都沿用同一套 MCP-first、CLI-fallback 的 `search -> timeline -> details` durable memory 工作流 - `cam mcp serve` 暴露 read-only retrieval MCP plane,对齐 `search -> timeline -> details` 契约 - - `cam mcp install --host ` 显式写入推荐的 project-scoped 宿主配置,降低 retrieval MCP 的接线摩擦,但不触碰 Markdown store - - `cam mcp print-config --host ...` 打印 ready-to-paste 宿主接入片段,降低 retrieval MCP 的接入摩擦 + - `cam mcp install --host ` 显式写入推荐的 project-scoped 宿主配置;`generic` 仍保持 manual-only,只通过 `cam mcp print-config --host generic` 提供 ready-to-paste snippet + - `cam mcp print-config --host ...` 打印 ready-to-paste 宿主接入片段;其中 `--host codex` 会额外附带推荐的 `AGENTS.md` snippet / guidance + - `cam mcp apply-guidance --host codex` 以 additive、marker-scoped、fail-closed 的方式创建或更新仓库级 guidance block - `cam mcp doctor` 只读检查推荐的 project-scoped retrieval MCP 接线、project pinning 与 hook / skill fallback 资产,不改写宿主配置 + - `cam integrations install --host codex` 负责编排 project-scoped MCP wiring、hook bundle 与 skill assets,但不触碰 `AGENTS.md` + - `cam integrations apply --host codex` 在 `install` 之上额外编排 managed `AGENTS.md` guidance block + - `cam integrations doctor --host codex` 只读汇总当前 Codex stack readiness、推荐路由与下一步动作 ## 3. 未来要冻结的核心语义 @@ -280,6 +284,7 @@ session continuity 是独立 companion layer,不属于 durable memory 契约 - startup payload compilation - session continuity layering - CLI command surfaces +- release-facing `--help` surfaces - future integration surfaces 与 canonical Markdown store 的一致性 ## 10. 当前架构不做什么 diff --git a/docs/claude-reference.en.md b/docs/claude-reference.en.md index 86cd670..087a05f 100644 --- a/docs/claude-reference.en.md +++ b/docs/claude-reference.en.md @@ -20,6 +20,7 @@ | topic files are read on demand | startup should not eagerly load topic bodies | | worktrees share project memory | project identity cannot be derived only from the cwd | | `/memory` exposes audit and edit controls | the project needs real inspect and edit paths, even if it does not fully clone Claude `/memory` | +| `autoMemoryDirectory` should not be redirectable by shared project config | config boundaries should prevent a shared repository from hijacking a user-level memory path | | the host may expose hooks, skills, and subagent memory | the repository should treat those as real integration targets when the host supports them | ## Core public contract @@ -93,6 +94,16 @@ This repository still does not claim full `/memory` interaction parity, but it m ### 6. Host integration surfaces matter, but should not replace the core contract +Claude-style public configuration boundaries also imply that a shared project should not be able to silently redirect another user's durable-memory storage. + +For `codex-auto-memory`, that means: + +- user, local, and managed config can control the memory directory +- shared project config must not be able to hijack a user-level memory path through `autoMemoryDirectory` +- host convenience should not weaken the repository's local-first safety boundary + +### 7. Host integration surfaces matter, but should not replace the core contract + Claude Code also exposes stronger host-native surfaces around memory: - hooks diff --git a/docs/host-surfaces.md b/docs/host-surfaces.md index ba8975e..a060058 100644 --- a/docs/host-surfaces.md +++ b/docs/host-surfaces.md @@ -28,6 +28,8 @@ - `cam recall` 是当前 CLI 侧的 read-only retrieval surface - `cam mcp serve` 是同一套 contract 的 MCP retrieval surface - `cam mcp install` 是显式、可选、project-scoped 的宿主接线安装面 +- `generic` host 仍然保持 manual-only,只通过 `cam mcp print-config --host generic` 暴露手动接线片段 +- `cam integrations install` / `apply` / `doctor` 把 Codex-only stack 明确拆成安装、一次性 apply 与只读检查三个公开入口 - `cam memory` 仍是 inspect / audit surface - `cam session` 仍是 temporary continuity surface @@ -93,7 +95,8 @@ - Gemini 的 extension + hooks + MCP 思路 - OpenCode 的 plugin + MCP + AGENTS 能力面 - OpenClaw 的“统一 memory core,不统一格式”思路 -- 针对宿主差异提供清晰分层的接入面:`cam mcp install` 负责显式写入 project-scoped host config,`cam mcp print-config` / `cam mcp doctor` 继续负责只打印 / 只检查,`cam mcp apply-guidance --host codex` 负责 additive 管理 repo 级 `AGENTS.md` guidance block,而 `cam integrations apply --host codex` 负责显式收口整套 Codex stack apply;其中 skills 默认仍安装到 runtime target,但 `cam skills install --surface runtime|official-user|official-project` 与 `cam integrations install/apply --skill-surface ...` 已为官方 `.agents/skills` 路径准备显式 opt-in 兼容面;shell fallback 仍由 `cam hooks install` 提供;这条 hooks 线是本仓自带的 local bridge,不是官方 Codex hook surface +- 针对宿主差异提供清晰分层的接入面:`cam mcp install` 负责显式写入 project-scoped host config,`cam mcp print-config` / `cam mcp doctor` 继续负责只打印 / 只检查,`cam mcp apply-guidance --host codex` 负责 additive 管理 repo 级 `AGENTS.md` guidance block,`cam integrations install --host codex` 负责编排不改写 `AGENTS.md` 的 stack install,`cam integrations apply --host codex` 负责显式收口整套 Codex stack apply,而 `cam integrations doctor --host codex` 负责只读汇总 readiness;其中 skills 默认仍安装到 runtime target,但 `cam skills install --surface runtime|official-user|official-project` 与 `cam integrations install/apply --skill-surface ...` 已为官方 `.agents/skills` 路径准备显式 opt-in 兼容面;shell fallback 仍由 `cam hooks install` 提供;这条 hooks 线是本仓自带的 local bridge,不是官方 Codex hook surface +- release-facing `--help` 文案也视为宿主能力面的稳定公开接口,必须和上述 install / apply / doctor / manual-only 边界保持一致 不应该吸收: diff --git a/docs/integration-strategy.md b/docs/integration-strategy.md index caf483a..2ecb439 100644 --- a/docs/integration-strategy.md +++ b/docs/integration-strategy.md @@ -64,7 +64,7 @@ - 已进入代码主线 - 当前仓库已经提供 `cam recall search` / `timeline` / `details` 作为 retrieval workflow 的当前 CLI surface - `cam recall search` 现在默认已经对齐推荐 preset:`state=auto`、`limit=8`,会先查 active,未命中再回退 archived,继续降低代理手动 widened search 的摩擦 -- `cam skills install` 现在默认安装 runtime Codex skill,并支持显式 `--surface runtime|official-user|official-project`;无论安装到哪个 surface,都复用同一套 MCP-first、CLI-fallback 的 retrieval guidance 与推荐检索 preset:`state=auto`、`limit=8` +- `cam skills install` 现在默认安装 runtime Codex skill,并支持显式 `--surface runtime|official-user|official-project`;其中 `official-user` 是 user-scoped 官方 `.agents/skills` copy,`official-project` 是 project-scoped 官方 `.agents/skills` copy;无论安装到哪个 surface,都复用同一套 MCP-first、CLI-fallback 的 retrieval guidance 与推荐检索 preset:`state=auto`、`limit=8` 目标状态: @@ -84,10 +84,13 @@ - `search_memories` 与 `cam recall search` 现在共享 active-first、archived-fallback 的默认检索语义 - 当前推荐的渐进式检索 preset 统一为:`state=auto`、`limit=8` - `cam mcp install --host ` 会显式写入推荐的 project-scoped 宿主配置,继续降低接线摩擦,但不改变 retrieval 的只读语义 +- `generic` host 仍然保持 manual-only:不提供自动写入的 install 分支,只通过 `cam mcp print-config --host generic` 暴露 ready-to-paste snippet - `cam mcp print-config --host ...` 会打印 ready-to-paste 宿主接入片段;其中 `--host codex` 现在还会额外打印推荐的 `AGENTS.md` snippet,把 durable memory workflow 正式接到 Codex 当前公开稳定 surface 上 - `cam mcp apply-guidance --host codex` 会以 additive、可审计、fail-closed 的方式创建或更新 repo 根 `AGENTS.md` 中由本仓维护的 guidance block,继续降低手工粘贴成本 -- `cam integrations apply --host codex` 现在提供显式的一次性 Codex stack apply 入口:在不改变 `integrations install` 边界的前提下,统一编排 project-scoped MCP wiring、managed `AGENTS.md` guidance block、hooks 与 skills;其中 skills 默认仍走 runtime target,但也支持显式 `--skill-surface runtime|official-user|official-project` +- `cam integrations install --host codex` 现在提供显式的一次性 stack install 入口:统一编排 project-scoped MCP wiring、hooks 与 skills,但不触碰 `AGENTS.md` +- `cam integrations apply --host codex` 现在提供显式的一次性 Codex stack apply 入口:在不改变 `integrations install` 边界的前提下,额外统一编排 managed `AGENTS.md` guidance block;其中 skills 默认仍走 runtime target,但也支持显式 `--skill-surface runtime|official-user|official-project` - `cam mcp doctor` 会只读检查推荐的 project-scoped MCP 接线、project pinning 与 shared fallback bridge assets +- `cam integrations doctor --host codex` 会只读汇总推荐路由、推荐 preset、subchecks 与 next steps,继续保持 inspect-only 边界 - 它仍然是只读 retrieval plane,不是新的 canonical store 目标状态: @@ -99,6 +102,7 @@ - hooks 解决“**什么时候触发**” - skills 解决“**模型怎么使用**” - MCP 解决“**模型具体能调用什么**” +- release-facing `--help` 文案负责把这些边界稳定暴露给用户与 smoke tests 三者都不应该直接拥有 canonical memory。 diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 433fa1d..c63833a 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -57,7 +57,19 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Run `node dist/cli.js integrations apply --host codex --json` and confirm it orchestrates MCP wiring, managed AGENTS guidance, hook assets, and skill assets while keeping `integrations install --host codex` non-mutating for AGENTS.md. - Run `node dist/cli.js skills install --surface official-user` and confirm the explicit official `.agents/skills` copy is written without changing the runtime default target. - Run `node dist/cli.js integrations install --host codex --skill-surface official-user --json` and confirm the skill subaction reports the selected surface while MCP and AGENTS boundaries stay unchanged. +- Run `node dist/cli.js integrations apply --host codex --skill-surface official-user --json` and confirm the selected skill surface passes through while the AGENTS mutation boundary remains exclusive to `apply`. +- Run `node dist/cli.js skills install --surface official-project` and confirm the explicit project-scoped `.agents/skills` copy is written inside the repository without changing the runtime default target. +- Run `node dist/cli.js integrations install --host codex --skill-surface official-project --json` and confirm the skill subaction reports the selected project-scoped surface while MCP and AGENTS boundaries stay unchanged. +- Run `node dist/cli.js integrations apply --host codex --skill-surface official-project --json` and confirm the selected project-scoped skill surface still flows through the full apply path. - Run `node dist/cli.js integrations doctor --host codex --json` and confirm it reports the thin Codex-only stack readiness view with `recommendedRoute`, `recommendedPreset`, `subchecks`, and `nextSteps`. +- Treat key `--help` output as release-facing contract, not incidental CLI text: + - `node dist/cli.js mcp install --help` should keep the supported install-host list at `codex, claude, or gemini`, leaving `generic` out of the install branch. + - `node dist/cli.js mcp print-config --help` should keep the supported snippet-host list at `codex, claude, gemini, or generic`. + - `node dist/cli.js mcp apply-guidance --help` should stay Codex-only and describe managed `AGENTS.md` updates. + - `node dist/cli.js skills install --help` should keep the public skill surfaces aligned at `runtime, official-user, or official-project`. + - `node dist/cli.js integrations install --help` should describe stack install without managed `AGENTS.md` mutation. + - `node dist/cli.js integrations apply --help` should explicitly add the managed `AGENTS.md` guidance flow on top of install. + - `node dist/cli.js integrations doctor --help` should stay inspect-only and Codex-only. - Confirm `node dist/cli.js session load --json` / `status --json` still expose `confidence` and warnings when the rollout required a conservative continuity summary. - Confirm continuity reviewer warnings stay in diagnostics / audit surfaces and are not written into continuity Markdown body text. - Run a local smoke flow: diff --git a/package.json b/package.json index 44feabd..05b63e0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codex-auto-memory", "version": "0.1.0", - "description": "Claude-style auto memory for Codex, implemented as a local companion CLI.", + "description": "A Markdown-first, local-first memory runtime for Codex with wrapper, MCP, skill, and AGENTS integration surfaces.", "type": "module", "bin": { "cam": "dist/cli.js" diff --git a/src/lib/cli/register-commands.ts b/src/lib/cli/register-commands.ts index cbd5f52..ace592e 100644 --- a/src/lib/cli/register-commands.ts +++ b/src/lib/cli/register-commands.ts @@ -22,6 +22,16 @@ import { runRemember } from "../commands/remember.js"; import { runSession } from "../commands/session.js"; import { installSkills, removeSkills } from "../commands/skills.js"; import { runSync } from "../commands/sync.js"; +import { + formatMcpHostChoices, + SUPPORTED_MCP_DOCTOR_HOST_SELECTIONS, + SUPPORTED_MCP_HOSTS, + SUPPORTED_MCP_INSTALL_HOSTS +} from "../integration/mcp-hosts.js"; +import { + DEFAULT_CODEX_SKILL_INSTALL_SURFACE, + formatCodexSkillInstallSurfaceChoices +} from "../integration/skills-paths.js"; type AsyncCommandHandler = (...args: Args) => Promise; @@ -121,6 +131,7 @@ function registerHookCommands(program: Command): void { } function registerSkillCommands(program: Command): void { + const skillSurfaceChoices = formatCodexSkillInstallSurfaceChoices(); const skillsCommand = program .command("skills") .description("Manage Codex skill assets for MCP-first and CLI-fallback durable memory retrieval"); @@ -130,8 +141,8 @@ function registerSkillCommands(program: Command): void { .description("Install a Codex skill that teaches search -> timeline -> details memory retrieval") .option( "--surface ", - "Skill install surface: runtime, official-user, or official-project", - "runtime" + `Skill install surface: ${skillSurfaceChoices}`, + DEFAULT_CODEX_SKILL_INSTALL_SURFACE ) .option("--cwd ", "Project directory to anchor project-scoped skill installs to") .action(withStdout(async (options) => installSkills(options))); @@ -141,8 +152,8 @@ function registerSkillCommands(program: Command): void { .description("Describe how to remove the installed Codex skill assets") .option( "--surface ", - "Skill surface to remove: runtime, official-user, or official-project", - "runtime" + `Skill surface to remove: ${skillSurfaceChoices}`, + DEFAULT_CODEX_SKILL_INSTALL_SURFACE ) .option("--cwd ", "Project directory to anchor project-scoped skill installs to") .action(withStdout(async (options) => removeSkills(options))); @@ -165,6 +176,7 @@ function registerRecallCommands(program: Command): void { "auto" ) .option("--limit ", "Maximum number of results to return", "8") + .option("--cwd ", "Project directory to anchor recall to") ).action(withStdout(async (query, options) => runRecall("search", query, options))); addJsonOption( @@ -172,6 +184,7 @@ function registerRecallCommands(program: Command): void { .command("timeline") .description("Show the recorded lifecycle timeline for a specific memory ref") .argument("", "Memory ref from recall search") + .option("--cwd ", "Project directory to anchor recall to") ).action(withStdout(async (ref, options) => runRecall("timeline", ref, options))); addJsonOption( @@ -179,10 +192,14 @@ function registerRecallCommands(program: Command): void { .command("details") .description("Fetch full Markdown-backed details for a specific memory ref") .argument("", "Memory ref from recall search") + .option("--cwd ", "Project directory to anchor recall to") ).action(withStdout(async (ref, options) => runRecall("details", ref, options))); } function registerMcpCommands(program: Command): void { + const supportedInstallHosts = formatMcpHostChoices(SUPPORTED_MCP_INSTALL_HOSTS); + const supportedSnippetHosts = formatMcpHostChoices(SUPPORTED_MCP_HOSTS); + const supportedDoctorHosts = formatMcpHostChoices(SUPPORTED_MCP_DOCTOR_HOST_SELECTIONS); const mcpCommand = program .command("mcp") .description( @@ -203,7 +220,7 @@ function registerMcpCommands(program: Command): void { mcpCommand .command("install") .description("Install the recommended project-scoped MCP wiring for a supported host") - .requiredOption("--host ", "Target host: codex, claude, or gemini") + .requiredOption("--host ", `Target host: ${supportedInstallHosts}`) .option("--cwd ", "Project directory to write host wiring for") ).action(withStdout(async (options) => runMcpInstall(options))); @@ -211,7 +228,7 @@ function registerMcpCommands(program: Command): void { mcpCommand .command("print-config") .description("Print a ready-to-paste MCP config snippet for a supported host") - .requiredOption("--host ", "Target host: codex, claude, gemini, or generic") + .requiredOption("--host ", `Target host: ${supportedSnippetHosts}`) .option("--cwd ", "Project directory to render the snippet for") ).action(withStdout(async (options) => runMcpPrintConfig(options))); @@ -219,7 +236,7 @@ function registerMcpCommands(program: Command): void { mcpCommand .command("apply-guidance") .description("Safely create or update the managed Codex Auto Memory block inside AGENTS.md") - .requiredOption("--host ", "Target host: codex") + .requiredOption("--host ", `Target host: ${formatMcpHostChoices(["codex"])}`) .option("--cwd ", "Project directory whose AGENTS.md should be updated") ).action(withStdout(async (options) => runMcpApplyGuidance(options))); @@ -227,12 +244,13 @@ function registerMcpCommands(program: Command): void { mcpCommand .command("doctor") .description("Inspect the recommended project-scoped MCP wiring without writing host config") - .option("--host ", "Target host: codex, claude, gemini, generic, or all", "all") + .option("--host ", `Target host: ${supportedDoctorHosts}`, "all") .option("--cwd ", "Project directory to inspect") ).action(withStdout(async (options) => runMcpDoctor(options))); } function registerIntegrationCommands(program: Command): void { + const skillSurfaceChoices = formatCodexSkillInstallSurfaceChoices(); const integrationsCommand = program .command("integrations") .description("Install the recommended Codex integration stack on top of existing hook, skill, and MCP surfaces"); @@ -241,11 +259,11 @@ function registerIntegrationCommands(program: Command): void { integrationsCommand .command("apply") .description("Install the recommended Codex integration stack and safely apply the managed AGENTS guidance block") - .requiredOption("--host ", "Target host: codex") + .requiredOption("--host ", `Target host: ${formatMcpHostChoices(["codex"])}`) .option( "--skill-surface ", - "Skill install surface: runtime, official-user, or official-project", - "runtime" + `Skill install surface: ${skillSurfaceChoices}`, + DEFAULT_CODEX_SKILL_INSTALL_SURFACE ) .option("--cwd ", "Project directory to write host wiring for") ).action(withStdout(async (options) => runIntegrationsApply(options))); @@ -254,11 +272,11 @@ function registerIntegrationCommands(program: Command): void { integrationsCommand .command("install") .description("Install the recommended project-scoped Codex integration stack") - .requiredOption("--host ", "Target host: codex") + .requiredOption("--host ", `Target host: ${formatMcpHostChoices(["codex"])}`) .option( "--skill-surface ", - "Skill install surface: runtime, official-user, or official-project", - "runtime" + `Skill install surface: ${skillSurfaceChoices}`, + DEFAULT_CODEX_SKILL_INSTALL_SURFACE ) .option("--cwd ", "Project directory to write host wiring for") ).action(withStdout(async (options) => runIntegrationsInstall(options))); @@ -267,7 +285,7 @@ function registerIntegrationCommands(program: Command): void { integrationsCommand .command("doctor") .description("Inspect the current Codex integration stack without mutating memory or host config") - .requiredOption("--host ", "Target host: codex") + .requiredOption("--host ", `Target host: ${formatMcpHostChoices(["codex"])}`) .option("--cwd ", "Project directory to inspect") ).action(withStdout(async (options) => runIntegrationsDoctor(options))); } diff --git a/src/lib/commands/integrations.ts b/src/lib/commands/integrations.ts index 7c49408..efb2715 100644 --- a/src/lib/commands/integrations.ts +++ b/src/lib/commands/integrations.ts @@ -21,6 +21,7 @@ import { normalizeCodexSkillInstallSurface, type CodexSkillInstallSurface } from "../integration/skills-paths.js"; +import { appendCliCwdFlag } from "../integration/retrieval-contract.js"; type IntegrationStackAction = "created" | "updated" | "unchanged" | "blocked"; type InstallStackAction = Exclude; @@ -114,6 +115,17 @@ interface IntegrationDoctorResult { nextSteps: string[]; } +function describeSkillSurfaceInstallNote(surface: CodexSkillInstallSurface): string { + switch (surface) { + case "runtime": + return "It writes project-scoped MCP wiring and refreshes user-scoped hook and runtime skill assets without touching Markdown memory files."; + case "official-user": + return "It writes project-scoped MCP wiring, refreshes user-scoped hook assets, and refreshes the explicit user-scoped official .agents/skills copy without touching Markdown memory files."; + case "official-project": + return "It writes project-scoped MCP wiring, refreshes user-scoped hook assets, and refreshes the project-scoped official .agents/skills copy without touching Markdown memory files."; + } +} + function summarizeStackAction(actions: IntegrationStackAction[]): IntegrationStackAction { if (actions.includes("blocked")) { return "blocked"; @@ -170,7 +182,12 @@ function hasAnyInstalledAsset( return ids.some((id) => report.fallbackAssets.assets.find((asset) => asset.id === id)?.installed); } -function buildIntegrationsDoctorResult(report: McpDoctorReport): IntegrationDoctorResult { +function buildIntegrationsDoctorResult( + report: McpDoctorReport, + options: { + explicitCwd?: boolean; + } = {} +): IntegrationDoctorResult { const codexHost = report.hosts.find((host) => host.host === "codex"); if (!codexHost) { throw new Error("Codex host inspection is required for integrations doctor."); @@ -240,7 +257,8 @@ function buildIntegrationsDoctorResult(report: McpDoctorReport): IntegrationDoct skillReady: report.codexStack.skillReady, workflowConsistent: report.codexStack.workflowConsistent }, { - skillInstallCommand: report.fallbackAssets.recommendedSkillInstallCommand + skillInstallCommand: report.fallbackAssets.recommendedSkillInstallCommand, + projectRoot: options.explicitCwd ? report.projectRoot : undefined }); const needsAgents = report.agentsGuidance.status !== "ok"; const needsOtherStackSurface = @@ -250,11 +268,17 @@ function buildIntegrationsDoctorResult(report: McpDoctorReport): IntegrationDoct !report.codexStack.skillReady; if (needsAgents && needsOtherStackSurface) { nextSteps.unshift( - `Run \`cam integrations apply --host codex --skill-surface ${report.fallbackAssets.preferredInstallSurface}\` to install project-scoped MCP wiring, refresh hook and skill assets, and safely apply the managed Codex Auto Memory AGENTS.md block in one step.` + `Run \`${appendCliCwdFlag( + `cam integrations apply --host codex --skill-surface ${report.fallbackAssets.preferredInstallSurface}`, + options.explicitCwd ? report.projectRoot : undefined + )}\` to install project-scoped MCP wiring, refresh hook and skill assets, and safely apply the managed Codex Auto Memory AGENTS.md block in one step.` ); } else if (needsAgents) { nextSteps.push( - "Run `cam mcp apply-guidance --host codex` to create or update the managed Codex Auto Memory block in the repository-level AGENTS.md." + `Run \`${appendCliCwdFlag( + "cam mcp apply-guidance --host codex", + options.explicitCwd ? report.projectRoot : undefined + )}\` to create or update the managed Codex Auto Memory block in the repository-level AGENTS.md.` ); } @@ -352,7 +376,7 @@ export async function runIntegrationsInstall( }, notes: [ "This orchestration surface is Codex-only.", - "It writes project-scoped MCP wiring and refreshes user-scoped hook and skill assets without touching Markdown memory files.", + describeSkillSurfaceInstallNote(skillSurface), buildCodexRouteSummary("mcp"), `Skill surface: ${formatCodexSkillInstallSurface(skillSurface)}.`, `Recommended retrieval preset: ${hooksResult.recommendedPreset}.` @@ -435,6 +459,7 @@ export async function runIntegrationsApply( notes: [ "This orchestration surface is Codex-only and explicit.", "Unlike `cam integrations install --host codex`, this command also manages the repository-level AGENTS.md guidance block through the existing additive, marker-scoped, fail-closed flow.", + describeSkillSurfaceInstallNote(skillSurface), buildCodexRouteSummary("mcp"), `Skill surface: ${formatCodexSkillInstallSurface(skillSurface)}.`, `Recommended retrieval preset: ${hooksResult.recommendedPreset}.` @@ -468,9 +493,12 @@ export async function runIntegrationsDoctor( normalizeIntegrationsHost(options.host, "doctor"); const report = await inspectMcpDoctor({ cwd: options.cwd, - host: "codex" + host: "codex", + explicitCwd: Boolean(options.cwd) + }); + const result = buildIntegrationsDoctorResult(report, { + explicitCwd: Boolean(options.cwd) }); - const result = buildIntegrationsDoctorResult(report); if (options.json) { return JSON.stringify(result, null, 2); diff --git a/src/lib/commands/mcp.ts b/src/lib/commands/mcp.ts index c037801..8111d12 100644 --- a/src/lib/commands/mcp.ts +++ b/src/lib/commands/mcp.ts @@ -62,7 +62,8 @@ export async function runMcpPrintConfig(options: McpPrintConfigOptions = {}): Pr export async function runMcpDoctor(options: McpDoctorOptions = {}): Promise { const report = await inspectMcpDoctor({ cwd: resolveCommandCwd(options.cwd), - host: options.host + host: options.host, + explicitCwd: Boolean(options.cwd) }); if (options.json) { diff --git a/src/lib/integration/codex-stack.ts b/src/lib/integration/codex-stack.ts index fda7d36..0fe6778 100644 --- a/src/lib/integration/codex-stack.ts +++ b/src/lib/integration/codex-stack.ts @@ -1,4 +1,7 @@ import { + appendCliCwdFlag, + buildCliDetailsCommand, + buildCliTimelineCommand, buildRecommendedCliSearchCommand, buildRecommendedMcpSearchInstruction, DURABLE_MEMORY_SYNC_GUIDANCE, @@ -522,14 +525,35 @@ export function buildCodexRouteSummary(route: CodexIntegrationRoute): string { } } +function appendProjectRootFlag(command: string, projectRoot?: string): string { + return appendCliCwdFlag(command, projectRoot); +} + export function buildCodexIntegrationNextSteps( readiness: CodexStackReadiness, options: { skillInstallCommand?: string; + projectRoot?: string; } = {} ): string[] { const route = resolveCodexIntegrationRoute(readiness); - const skillInstallCommand = options.skillInstallCommand ?? "cam skills install"; + const skillInstallCommand = appendProjectRootFlag( + options.skillInstallCommand ?? "cam skills install", + options.projectRoot + ); + const integrationsInstallCommand = appendProjectRootFlag( + "cam integrations install --host codex", + options.projectRoot + ); + const mcpInstallCommand = appendProjectRootFlag( + "cam mcp install --host codex", + options.projectRoot + ); + const mcpPrintConfigCommand = appendProjectRootFlag( + "cam mcp print-config --host codex", + options.projectRoot + ); + const hooksInstallCommand = appendProjectRootFlag("cam hooks install", options.projectRoot); const nextSteps: string[] = []; if ( @@ -539,15 +563,15 @@ export function buildCodexIntegrationNextSteps( !readiness.skillReady ) { return [ - "Run `cam integrations install --host codex` to install the recommended Codex integration stack in one step.", - `Until the stack is installed, use \`${buildRecommendedCliSearchCommand()}\` directly.`, - "Run `cam mcp print-config --host codex` to print the recommended project-scoped MCP wiring and AGENTS.md snippet." + `Run \`${integrationsInstallCommand}\` to install the recommended Codex integration stack in one step.`, + `Until the stack is installed, use \`${buildRecommendedCliSearchCommand("\"\"", { cwd: options.projectRoot })}\` directly.`, + `Run \`${mcpPrintConfigCommand}\` to print the recommended project-scoped MCP wiring and AGENTS.md snippet.` ]; } if (!readiness.mcpReady) { nextSteps.push( - "Run `cam mcp install --host codex` to write the recommended project-scoped retrieval MCP wiring." + `Run \`${mcpInstallCommand}\` to write the recommended project-scoped retrieval MCP wiring.` ); } else if (!readiness.camCommandAvailable) { nextSteps.push( @@ -557,7 +581,7 @@ export function buildCodexIntegrationNextSteps( if (!readiness.hookCaptureReady || !readiness.hookRecallReady) { nextSteps.push( - "Run `cam hooks install` to refresh the shared hook helper bundle for capture and recall." + `Run \`${hooksInstallCommand}\` to refresh the shared hook helper bundle for capture and recall.` ); } @@ -583,12 +607,18 @@ export function buildCodexIntegrationNextSteps( ); } else { nextSteps.push( - `Use \`${buildRecommendedCliSearchCommand()}\` directly until a richer integration route becomes ready.` + `Use \`${buildRecommendedCliSearchCommand("\"\"", { cwd: options.projectRoot })}\` directly until a richer integration route becomes ready.` + ); + } + + if (route !== "mcp") { + nextSteps.push( + `Follow progressive disclosure when using the CLI fallback: \`${buildRecommendedCliSearchCommand("\"\"", { cwd: options.projectRoot })}\`, then \`${buildCliTimelineCommand("\"\"", { cwd: options.projectRoot })}\`, then \`${buildCliDetailsCommand("\"\"", { cwd: options.projectRoot })}\`.` ); } nextSteps.push( - "Run `cam mcp print-config --host codex` to print the recommended project-scoped MCP wiring and AGENTS.md snippet." + `Run \`${mcpPrintConfigCommand}\` to print the recommended project-scoped MCP wiring and AGENTS.md snippet.` ); nextSteps.push(DURABLE_MEMORY_SYNC_GUIDANCE); diff --git a/src/lib/integration/mcp-doctor.ts b/src/lib/integration/mcp-doctor.ts index 81b3da6..ed3c47d 100644 --- a/src/lib/integration/mcp-doctor.ts +++ b/src/lib/integration/mcp-doctor.ts @@ -16,6 +16,7 @@ import { type CodexIntegrationRoute } from "./codex-stack.js"; import { + appendCliCwdFlag, detectIntegrationAssetVersion, formatRecommendedRetrievalPreset, RETRIEVAL_INTEGRATION_ASSET_VERSION @@ -430,7 +431,12 @@ async function inspectHost(host: McpHost, projectRoot: string): Promise { +async function inspectFallbackAssets( + projectRoot: string, + options: { + explicitCwd?: boolean; + } = {} +): Promise { const descriptors = listDoctorVisibleIntegrationAssets(); const hooksDir = descriptors.find((asset) => asset.installSurface === "hooks")?.path; const skillPaths = resolveCodexSkillPaths(projectRoot); @@ -538,9 +544,12 @@ async function inspectFallbackAssets(projectRoot: string): Promise { const cwd = await normalizeComparablePath(options.cwd ?? process.cwd()); const projectRoot = resolveMcpProjectRoot(cwd); @@ -645,7 +655,9 @@ export async function inspectMcpDoctor(options: { const hosts = await Promise.all( listMcpHosts(hostSelection).map((host) => inspectHost(host, projectRoot)) ); - const fallbackAssets = await inspectFallbackAssets(projectRoot); + const fallbackAssets = await inspectFallbackAssets(projectRoot, { + explicitCwd: options.explicitCwd ?? false + }); const camCommandAvailable = await isCommandAvailableInPath("cam"); const codexHost = hosts.find((host) => host.host === "codex") ?? (await inspectHost("codex", projectRoot)); const agentsGuidance = inspectCodexAgentsGuidance( diff --git a/src/lib/integration/mcp-hosts.ts b/src/lib/integration/mcp-hosts.ts index cc1a2ed..1d5e088 100644 --- a/src/lib/integration/mcp-hosts.ts +++ b/src/lib/integration/mcp-hosts.ts @@ -31,12 +31,21 @@ export interface McpCanonicalConfigInspection { export const MEMORY_RETRIEVAL_MCP_SERVER_NAME = "codex_auto_memory"; +export const SUPPORTED_MCP_INSTALL_HOSTS: readonly Exclude[] = [ + "codex", + "claude", + "gemini" +] as const; export const SUPPORTED_MCP_HOSTS: readonly McpHost[] = [ "codex", "claude", "gemini", "generic" ] as const; +export const SUPPORTED_MCP_DOCTOR_HOST_SELECTIONS: readonly McpDoctorHostSelection[] = [ + ...SUPPORTED_MCP_HOSTS, + "all" +] as const; const HOST_DEFINITIONS: Record = { codex: { @@ -144,6 +153,22 @@ function toTomlArray(values: string[]): string { return `[${values.map((value) => toTomlString(value)).join(", ")}]`; } +export function formatMcpHostChoices(hosts: readonly string[]): string { + if (hosts.length === 0) { + return ""; + } + + if (hosts.length === 1) { + return hosts[0] ?? ""; + } + + if (hosts.length === 2) { + return `${hosts[0]} or ${hosts[1]}`; + } + + return `${hosts.slice(0, -1).join(", ")}, or ${hosts.at(-1)}`; +} + export function normalizeMcpHost(host: string | undefined): McpHost { switch (host) { case "codex": diff --git a/src/lib/integration/retrieval-contract.ts b/src/lib/integration/retrieval-contract.ts index 143935c..a12043e 100644 --- a/src/lib/integration/retrieval-contract.ts +++ b/src/lib/integration/retrieval-contract.ts @@ -38,8 +38,25 @@ export function formatRecommendedRetrievalPreset(): string { return `state=${RECOMMENDED_RETRIEVAL_STATE}, limit=${RECOMMENDED_RETRIEVAL_LIMIT}`; } -export function buildRecommendedCliSearchCommand(query = "\"\""): string { - return buildCliSearchCommand(query); +export function hasCliCwdFlag(command: string): boolean { + return /(?:^|\s)--cwd(?:\s|=)/u.test(command); +} + +export function appendCliCwdFlag(command: string, cwd?: string): string { + if (!cwd || hasCliCwdFlag(command)) { + return command; + } + + return `${command} --cwd ${JSON.stringify(cwd)}`; +} + +export function buildRecommendedCliSearchCommand( + query = "\"\"", + options: { + cwd?: string; + } = {} +): string { + return buildCliSearchCommand(query, options); } export function buildCliSearchCommand( @@ -47,11 +64,33 @@ export function buildCliSearchCommand( options: { state?: MemoryRetrievalStateFilter; limit?: number; + cwd?: string; } = {} ): string { const state = options.state ?? RECOMMENDED_RETRIEVAL_STATE; const limit = options.limit ?? RECOMMENDED_RETRIEVAL_LIMIT; - return `${RETRIEVAL_CLI_SEARCH_COMMAND} ${query} --state ${state} --limit ${limit}`; + return appendCliCwdFlag( + `${RETRIEVAL_CLI_SEARCH_COMMAND} ${query} --state ${state} --limit ${limit}`, + options.cwd + ); +} + +export function buildCliTimelineCommand( + ref = "\"\"", + options: { + cwd?: string; + } = {} +): string { + return appendCliCwdFlag(`${RETRIEVAL_CLI_TIMELINE_COMMAND} ${ref}`, options.cwd); +} + +export function buildCliDetailsCommand( + ref = "\"\"", + options: { + cwd?: string; + } = {} +): string { + return appendCliCwdFlag(`${RETRIEVAL_CLI_DETAILS_COMMAND} ${ref}`, options.cwd); } export function buildRecommendedMcpSearchInstruction(): string { diff --git a/src/lib/integration/skills-paths.ts b/src/lib/integration/skills-paths.ts index 7f0f987..162fbd9 100644 --- a/src/lib/integration/skills-paths.ts +++ b/src/lib/integration/skills-paths.ts @@ -7,6 +7,7 @@ export const CODEX_SKILL_INSTALL_SURFACES = [ "official-user", "official-project" ] as const; +export const DEFAULT_CODEX_SKILL_INSTALL_SURFACE: CodexSkillInstallSurface = "runtime"; export type CodexSkillRuntimeSource = "CODEX_HOME" | "HOME_DOT_CODEX"; export type CodexSkillInstallSurface = (typeof CODEX_SKILL_INSTALL_SURFACES)[number]; @@ -50,7 +51,7 @@ export function normalizeCodexSkillInstallSurface( surface: string | undefined ): CodexSkillInstallSurface { if (!surface) { - return "runtime"; + return DEFAULT_CODEX_SKILL_INSTALL_SURFACE; } if ( @@ -75,8 +76,12 @@ export function formatCodexSkillInstallSurface(surface: CodexSkillInstallSurface } } +export function formatCodexSkillInstallSurfaceChoices(): string { + return `${CODEX_SKILL_INSTALL_SURFACES[0]}, ${CODEX_SKILL_INSTALL_SURFACES[1]}, or ${CODEX_SKILL_INSTALL_SURFACES[2]}`; +} + export function buildCodexSkillInstallCommand( - surface: CodexSkillInstallSurface = "runtime" + surface: CodexSkillInstallSurface = DEFAULT_CODEX_SKILL_INSTALL_SURFACE ): string { return `cam skills install --surface ${surface}`; } @@ -120,7 +125,7 @@ export function resolveCodexSkillPaths( runtimeAssetDir, officialUserSkillDir, officialProjectSkillDir, - preferredInstallSurface: "runtime", + preferredInstallSurface: DEFAULT_CODEX_SKILL_INSTALL_SURFACE, availableSurfaces: [ { surface: "runtime", diff --git a/test/dist-cli-smoke.test.ts b/test/dist-cli-smoke.test.ts index e30da3a..5db5064 100644 --- a/test/dist-cli-smoke.test.ts +++ b/test/dist-cli-smoke.test.ts @@ -307,6 +307,73 @@ describe("dist cli smoke", () => { serverName: "codex_auto_memory", targetFileHint: ".mcp.json" }); + + const geminiResult = runCli( + projectDir, + ["mcp", "print-config", "--host", "gemini", "--json"], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + + expect(geminiResult.exitCode, geminiResult.stderr).toBe(0); + expect(JSON.parse(geminiResult.stdout)).toMatchObject({ + host: "gemini", + serverName: "codex_auto_memory", + targetFileHint: ".gemini/settings.json" + }); + + const genericResult = runCli( + projectDir, + ["mcp", "print-config", "--host", "generic", "--json"], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + + expect(genericResult.exitCode, genericResult.stderr).toBe(0); + expect(JSON.parse(genericResult.stdout)).toMatchObject({ + host: "generic", + serverName: "codex_auto_memory", + targetFileHint: "Your MCP client's stdio server config", + snippetFormat: "json" + }); + }); + + it("rejects generic MCP install from the compiled cli entrypoint because wiring stays manual-only", async () => { + const homeDir = await tempDir("cam-dist-mcp-install-generic-home-"); + const projectDir = await tempDir("cam-dist-mcp-install-generic-project-"); + + const result = runCli(projectDir, ["mcp", "install", "--host", "generic"], { + entrypoint: "dist", + env: { HOME: homeDir } + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("generic"); + expect(result.stderr).toContain("manual-only"); + }); + + it("installs gemini MCP wiring from the compiled cli entrypoint", async () => { + const homeDir = await tempDir("cam-dist-mcp-install-gemini-home-"); + const projectDir = await tempDir("cam-dist-mcp-install-gemini-project-"); + const realProjectDir = await fs.realpath(projectDir); + + const result = runCli(projectDir, ["mcp", "install", "--host", "gemini", "--json"], { + entrypoint: "dist", + env: { HOME: homeDir } + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "gemini", + action: "created", + projectRoot: realProjectDir, + targetPath: path.join(realProjectDir, ".gemini", "settings.json"), + readOnlyRetrieval: true + }); }); it("applies the Codex AGENTS guidance from the compiled cli entrypoint", async () => { @@ -571,6 +638,30 @@ describe("dist cli smoke", () => { expect(skillFile).toContain("search_memories"); }); + it("installs an explicit official project skill surface from the compiled cli entrypoint", async () => { + const homeDir = await tempDir("cam-dist-skill-official-project-home-"); + const projectDir = await tempDir("cam-dist-skill-official-project-project-"); + + const env = { HOME: homeDir }; + const skillsResult = runCli( + projectDir, + ["skills", "install", "--surface", "official-project"], + { + entrypoint: "dist", + env + } + ); + expect(skillsResult.exitCode, skillsResult.stderr).toBe(0); + expect(skillsResult.stdout).toContain("Skill surface: official-project"); + + const skillFile = await fs.readFile( + path.join(projectDir, ".agents", "skills", "codex-auto-memory-recall", "SKILL.md"), + "utf8" + ); + expect(skillFile).toContain("cam:asset-version"); + expect(skillFile).toContain("search_memories"); + }); + it("installs skills under CODEX_HOME from the compiled cli entrypoint", async () => { const homeDir = await tempDir("cam-dist-skill-codex-home-home-"); const codexHome = await tempDir("cam-dist-skill-codex-home-codex-home-"); @@ -669,6 +760,108 @@ describe("dist cli smoke", () => { }); }); + it("supports the official-project skill surface from the compiled integrations entrypoint", async () => { + const homeDir = await tempDir("cam-dist-integrations-official-project-home-"); + const projectDir = await tempDir("cam-dist-integrations-official-project-project-"); + const realProjectDir = await fs.realpath(projectDir); + + const installResult = runCli( + projectDir, + ["integrations", "install", "--host", "codex", "--skill-surface", "official-project", "--json"], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + + expect(installResult.exitCode, installResult.stderr).toBe(0); + expect(JSON.parse(installResult.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + skillsSurface: "official-project", + subactions: { + skills: { + action: "created", + surface: "official-project", + targetDir: path.join(realProjectDir, ".agents", "skills", "codex-auto-memory-recall") + } + } + }); + + const applyResult = runCli( + projectDir, + ["integrations", "apply", "--host", "codex", "--skill-surface", "official-project", "--json"], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + + expect(applyResult.exitCode, applyResult.stderr).toBe(0); + expect(JSON.parse(applyResult.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + skillsSurface: "official-project", + subactions: { + skills: { + surface: "official-project", + targetDir: path.join(realProjectDir, ".agents", "skills", "codex-auto-memory-recall") + } + } + }); + }); + + it("supports the official-user skill surface from the compiled integrations entrypoint", async () => { + const homeDir = await tempDir("cam-dist-integrations-official-user-home-"); + const projectDir = await tempDir("cam-dist-integrations-official-user-project-"); + const realProjectDir = await fs.realpath(projectDir); + + const installResult = runCli( + projectDir, + ["integrations", "install", "--host", "codex", "--skill-surface", "official-user", "--json"], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + + expect(installResult.exitCode, installResult.stderr).toBe(0); + expect(JSON.parse(installResult.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + skillsSurface: "official-user", + subactions: { + skills: { + action: "created", + surface: "official-user", + targetDir: path.join(homeDir, ".agents", "skills", "codex-auto-memory-recall") + } + } + }); + + const applyResult = runCli( + projectDir, + ["integrations", "apply", "--host", "codex", "--skill-surface", "official-user", "--json"], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + + expect(applyResult.exitCode, applyResult.stderr).toBe(0); + expect(JSON.parse(applyResult.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + skillsSurface: "official-user", + subactions: { + skills: { + surface: "official-user", + targetDir: path.join(homeDir, ".agents", "skills", "codex-auto-memory-recall") + } + } + }); + }); + it("inspects the Codex integration stack from the compiled cli entrypoint", async () => { const homeDir = await tempDir("cam-dist-integrations-doctor-home-"); const projectDir = await tempDir("cam-dist-integrations-doctor-project-"); @@ -778,4 +971,92 @@ fs.writeFileSync(${JSON.stringify(capturedArgsPath)}, JSON.stringify(process.arg expect(capturedArgs).toContain("continue"); expect(capturedArgs.some((value) => value.startsWith("base_instructions="))).toBe(true); }, 30_000); + + it("keeps key compiled help surfaces aligned with the release-facing command contract", async () => { + const projectDir = await tempDir("cam-dist-help-project-"); + const homeDir = await tempDir("cam-dist-help-home-"); + const env = { HOME: homeDir }; + + const recallHelp = runCli(projectDir, ["recall", "search", "--help"], { + entrypoint: "dist", + env + }); + expect(recallHelp.exitCode, recallHelp.stderr).toBe(0); + expect(recallHelp.stdout).toContain("Search compact memory candidates without loading full details"); + expect(recallHelp.stdout).toContain("Limit memory state: active, archived, all, or auto"); + + const mcpHelp = runCli(projectDir, ["mcp", "print-config", "--help"], { + entrypoint: "dist", + env + }); + expect(mcpHelp.exitCode, mcpHelp.stderr).toBe(0); + expect(mcpHelp.stdout).toContain("Print a ready-to-paste MCP config snippet for a supported host"); + expect(mcpHelp.stdout).toContain("Target host: codex, claude, gemini, or generic"); + + const mcpInstallHelp = runCli(projectDir, ["mcp", "install", "--help"], { + entrypoint: "dist", + env + }); + expect(mcpInstallHelp.exitCode, mcpInstallHelp.stderr).toBe(0); + expect(mcpInstallHelp.stdout).toContain( + "Install the recommended project-scoped MCP wiring for a supported host" + ); + expect(mcpInstallHelp.stdout).toContain("Target host: codex, claude, or gemini"); + + const mcpApplyGuidanceHelp = runCli(projectDir, ["mcp", "apply-guidance", "--help"], { + entrypoint: "dist", + env + }); + expect(mcpApplyGuidanceHelp.exitCode, mcpApplyGuidanceHelp.stderr).toBe(0); + expect(mcpApplyGuidanceHelp.stdout).toContain( + "Safely create or update the managed Codex Auto Memory block inside AGENTS.md" + ); + expect(mcpApplyGuidanceHelp.stdout).toContain("Target host: codex"); + + const skillsHelp = runCli(projectDir, ["skills", "install", "--help"], { + entrypoint: "dist", + env + }); + expect(skillsHelp.exitCode, skillsHelp.stderr).toBe(0); + expect(skillsHelp.stdout).toMatch( + /Install a Codex skill that teaches search -> timeline -> details memory\s+retrieval/ + ); + expect(skillsHelp.stdout).toMatch( + /Skill install surface: runtime, official-user, or\s+official-project/ + ); + + const integrationsHelp = runCli(projectDir, ["integrations", "install", "--help"], { + entrypoint: "dist", + env + }); + expect(integrationsHelp.exitCode, integrationsHelp.stderr).toBe(0); + expect(integrationsHelp.stdout).toContain("Install the recommended project-scoped Codex integration stack"); + expect(integrationsHelp.stdout).toContain("Target host: codex"); + expect(integrationsHelp.stdout).toMatch( + /Skill install surface: runtime, official-user, or\s+official-project/ + ); + + const integrationsApplyHelp = runCli(projectDir, ["integrations", "apply", "--help"], { + entrypoint: "dist", + env + }); + expect(integrationsApplyHelp.exitCode, integrationsApplyHelp.stderr).toBe(0); + expect(integrationsApplyHelp.stdout).toMatch( + /Install the recommended Codex integration stack and safely apply the managed\s+AGENTS guidance block/ + ); + expect(integrationsApplyHelp.stdout).toContain("Target host: codex"); + expect(integrationsApplyHelp.stdout).toMatch( + /Skill install surface: runtime, official-user, or\s+official-project/ + ); + + const integrationsDoctorHelp = runCli(projectDir, ["integrations", "doctor", "--help"], { + entrypoint: "dist", + env + }); + expect(integrationsDoctorHelp.exitCode, integrationsDoctorHelp.stderr).toBe(0); + expect(integrationsDoctorHelp.stdout).toMatch( + /Inspect the current Codex integration stack without mutating memory or host\s+config/ + ); + expect(integrationsDoctorHelp.stdout).toContain("Target host: codex"); + }); }); diff --git a/test/docs-contract.test.ts b/test/docs-contract.test.ts index 17e06bb..9556d6c 100644 --- a/test/docs-contract.test.ts +++ b/test/docs-contract.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; interface PackageJsonContract { + description: string; bin: { cam: string; }; @@ -20,6 +21,11 @@ describe("docs contract", () => { const readmeTw = await readDoc("README.zh-TW.md"); const readmeEn = await readDoc("README.en.md"); const readmeJa = await readDoc("README.ja.md"); + const docsReadme = await readDoc("docs/README.md"); + const docsReadmeEn = await readDoc("docs/README.en.md"); + const claudeReference = await readDoc("docs/claude-reference.md"); + const claudeReferenceEn = await readDoc("docs/claude-reference.en.md"); + const nativeMigrationEn = await readDoc("docs/native-migration.en.md"); const releaseChecklist = await readDoc("docs/release-checklist.md"); const contributing = await readDoc("CONTRIBUTING.md"); const ciWorkflow = await readDoc(".github/workflows/ci.yml"); @@ -67,7 +73,10 @@ describe("docs contract", () => { expect(readmeTw).toContain("cam mcp doctor"); expect(readmeTw).toContain("--state auto"); expect(readmeTw).toContain("local bridge"); + expect(readmeTw).toContain("manual-only"); expect(readmeTw).toContain("--surface runtime|official-user|official-project"); + expect(readmeTw).toContain("重要 `--help` 文案"); + expect(readmeTw).toContain("release-facing public contract"); expect(readmeJa).toContain("README.md"); expect(readmeJa).toContain("README.en.md"); expect(readmeJa).toContain("memory-recall.sh"); @@ -81,7 +90,10 @@ describe("docs contract", () => { expect(readmeJa).toContain("cam mcp doctor"); expect(readmeJa).toContain("--state auto"); expect(readmeJa).toContain("local bridge"); + expect(readmeJa).toContain("manual-only"); expect(readmeJa).toContain("--surface runtime|official-user|official-project"); + expect(readmeJa).toContain("主要な `--help` 文言"); + expect(readmeJa).toContain("release-facing public contract"); expect(readmeEn).toContain("cam memory"); expect(readmeEn).toContain("cam session status"); expect(readmeEn).toContain("cam recall search"); @@ -108,6 +120,29 @@ describe("docs contract", () => { expect(readmeEn).toContain("cam mcp doctor"); expect(readmeEn).toContain("manual-only"); expect(readmeEn).toContain("--surface runtime|official-user|official-project"); + expect(docsReadme).toContain("Codex-first Hybrid"); + expect(docsReadme).toContain("cam mcp apply-guidance --host codex"); + expect(docsReadme).toContain("cam integrations apply --host codex"); + expect(docsReadme).toContain("cam integrations doctor --host codex"); + expect(docsReadme).toContain("runtime|official-user|official-project"); + expect(docsReadme).toContain("manual-only"); + expect(docsReadme).toContain("`--help` 文案"); + expect(docsReadme).toContain("state=auto"); + expect(docsReadmeEn).toContain("Codex-first Hybrid"); + expect(docsReadmeEn).toContain("cam mcp apply-guidance --host codex"); + expect(docsReadmeEn).toContain("cam integrations apply --host codex"); + expect(docsReadmeEn).toContain("cam integrations doctor --host codex"); + expect(docsReadmeEn).toContain("runtime`, `official-user`, and `official-project"); + expect(docsReadmeEn).toContain("manual-only"); + expect(docsReadmeEn).toContain("`--help` text is part of the release-facing public contract"); + expect(docsReadmeEn).toContain("state=auto, limit=8"); + expect(claudeReference).toContain("autoMemoryDirectory"); + expect(claudeReference).toContain("共享项目劫持用户 memory 路径"); + expect(claudeReferenceEn).toContain("autoMemoryDirectory"); + expect(claudeReferenceEn).toContain("shared project config"); + expect(claudeReferenceEn).toContain("user-level memory path"); + expect(nativeMigrationEn).toContain("native Codex memory and hooks are still not ready"); + expect(nativeMigrationEn).toContain("allow non-native integration expansion"); expect(releaseChecklist).toContain("pnpm test:dist-cli-smoke"); expect(releaseChecklist).toContain("pnpm test:tarball-install-smoke"); expect(releaseChecklist).toContain("node dist/cli.js --version"); @@ -138,7 +173,20 @@ describe("docs contract", () => { expect(releaseChecklist).toContain("node dist/cli.js integrations apply --host codex --json"); expect(releaseChecklist).toContain("node dist/cli.js skills install --surface official-user"); expect(releaseChecklist).toContain("node dist/cli.js integrations install --host codex --skill-surface official-user --json"); + expect(releaseChecklist).toContain("node dist/cli.js integrations apply --host codex --skill-surface official-user --json"); + expect(releaseChecklist).toContain("node dist/cli.js skills install --surface official-project"); + expect(releaseChecklist).toContain("node dist/cli.js integrations install --host codex --skill-surface official-project --json"); + expect(releaseChecklist).toContain("node dist/cli.js integrations apply --host codex --skill-surface official-project --json"); expect(releaseChecklist).toContain("node dist/cli.js integrations doctor --host codex --json"); + expect(releaseChecklist).toContain("node dist/cli.js mcp install --help"); + expect(releaseChecklist).toContain("node dist/cli.js mcp print-config --help"); + expect(releaseChecklist).toContain("node dist/cli.js mcp apply-guidance --help"); + expect(releaseChecklist).toContain("node dist/cli.js skills install --help"); + expect(releaseChecklist).toContain("node dist/cli.js integrations install --help"); + expect(releaseChecklist).toContain("node dist/cli.js integrations apply --help"); + expect(releaseChecklist).toContain("node dist/cli.js integrations doctor --help"); + expect(releaseChecklist).toContain("codex, claude, gemini, or generic"); + expect(releaseChecklist).toContain("leaving `generic` out of the install branch"); expect(releaseChecklist).toContain("search_memories"); expect(contributing).toContain("reviewer-only warnings"); expect(contributing).toContain("pnpm test:docs-contract"); @@ -161,6 +209,9 @@ describe("docs contract", () => { expect(packageJson.scripts["test:tarball-install-smoke"]).toBe( "vitest run test/tarball-install-smoke.test.ts" ); + expect(packageJson.description).toBe( + "A Markdown-first, local-first memory runtime for Codex with wrapper, MCP, skill, and AGENTS integration surfaces." + ); expect(packageJson.bin.cam).toBe("dist/cli.js"); expect(packageJson.files).toEqual( expect.arrayContaining([ @@ -213,10 +264,17 @@ describe("docs contract", () => { expect(architecture).toContain("memory-recall.sh"); expect(architecture).toContain("cam mcp serve"); expect(architecture).toContain("cam mcp install"); + expect(architecture).toContain("--surface runtime|official-user|official-project"); + expect(architecture).toContain("manual-only"); + expect(architecture).toContain("cam mcp apply-guidance"); + expect(architecture).toContain("cam integrations install --host codex"); + expect(architecture).toContain("cam integrations apply --host codex"); + expect(architecture).toContain("cam integrations doctor --host codex"); expect(architecture).toContain("state=auto"); expect(architecture).toContain("local bridge / fallback recall bundle"); expect(architecture).toContain("cam mcp print-config"); expect(architecture).toContain("cam mcp doctor"); + expect(architecture).toContain("release-facing `--help` surfaces"); expect(architecture).toContain("Codex-first Hybrid"); expect(architectureEn).toContain("reviewer warnings and confidence remain audit-side metadata"); expect(architectureEn).toContain("cam recall timeline"); @@ -242,6 +300,10 @@ describe("docs contract", () => { expect(integrationStrategy).toContain("cam mcp print-config"); expect(integrationStrategy).toContain("AGENTS.md"); expect(integrationStrategy).toContain("cam mcp doctor"); + expect(integrationStrategy).toContain("manual-only"); + expect(integrationStrategy).toContain("cam integrations install --host codex"); + expect(integrationStrategy).toContain("cam integrations doctor --host codex"); + expect(integrationStrategy).toContain("release-facing `--help` 文案"); expect(integrationStrategy).toContain("cam skills install"); expect(integrationStrategy).toContain("--surface runtime|official-user|official-project"); expect(hostSurfaces).toContain("Codex-first Hybrid memory system"); @@ -251,9 +313,13 @@ describe("docs contract", () => { expect(hostSurfaces).toContain("local bridge"); expect(hostSurfaces).toContain("read-only retrieval surface"); expect(hostSurfaces).toContain("cam mcp install"); + expect(hostSurfaces).toContain("manual-only"); expect(hostSurfaces).toContain("cam mcp apply-guidance"); expect(hostSurfaces).toContain("AGENTS.md"); expect(hostSurfaces).toContain("cam mcp doctor"); + expect(hostSurfaces).toContain("cam integrations install"); + expect(hostSurfaces).toContain("cam integrations doctor"); + expect(hostSurfaces).toContain("release-facing `--help` 文案"); expect(readme).toContain("companion-first"); expect(readmeEn).toContain("companion CLI"); expect(readme).toContain("当前主任务"); diff --git a/test/integrations-command.test.ts b/test/integrations-command.test.ts index c07b3bd..691d1df 100644 --- a/test/integrations-command.test.ts +++ b/test/integrations-command.test.ts @@ -229,6 +229,96 @@ describe("integrations command", () => { expect(await pathExists(memoryRoot)).toBe(false); }); + it("keeps doctor next steps pinned to the inspected project when --cwd targets another directory", async () => { + const homeDir = await tempDir("cam-integrations-doctor-cwd-home-"); + const projectParentDir = await tempDir("cam-integrations-doctor-cwd-project-parent-"); + const projectDir = path.join(projectParentDir, "project with spaces"); + const shellDir = await tempDir("cam-integrations-doctor-cwd-shell-"); + const emptyPathDir = await tempDir("cam-integrations-doctor-cwd-empty-path-"); + process.env.HOME = homeDir; + + await fs.mkdir(projectDir, { recursive: true }); + + const result = runCli( + shellDir, + ["integrations", "doctor", "--host", "codex", "--cwd", projectDir, "--json"], + { + env: { + HOME: homeDir, + PATH: await buildPathWithoutCam(emptyPathDir) + } + } + ); + expect(result.exitCode, result.stderr).toBe(0); + const payload = JSON.parse(result.stdout) as { + projectRoot: string; + recommendedSkillInstallCommand: string; + nextSteps: string[]; + }; + expect(payload.projectRoot).toBe(await fs.realpath(projectDir)); + expect(payload.recommendedSkillInstallCommand).toBe( + `cam skills install --surface runtime --cwd ${JSON.stringify(payload.projectRoot)}` + ); + expect(payload.nextSteps).toEqual( + expect.arrayContaining([ + expect.stringContaining( + `cam integrations apply --host codex --skill-surface runtime --cwd ${JSON.stringify(payload.projectRoot)}` + ), + expect.stringContaining( + `cam integrations install --host codex --cwd ${JSON.stringify(payload.projectRoot)}` + ), + expect.stringContaining( + `cam mcp print-config --host codex --cwd ${JSON.stringify(payload.projectRoot)}` + ), + expect.stringContaining( + `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(payload.projectRoot)}` + ) + ]) + ); + }); + + it("keeps the AGENTS-only repair step pinned to the inspected project when --cwd targets another directory", async () => { + const homeDir = await tempDir("cam-integrations-doctor-agents-cwd-home-"); + const projectDir = await tempDir("cam-integrations-doctor-agents-cwd-project-"); + const shellDir = await tempDir("cam-integrations-doctor-agents-cwd-shell-"); + const binDir = await tempDir("cam-integrations-doctor-agents-cwd-bin-"); + process.env.HOME = homeDir; + + await writeCamShim(binDir); + const env = { + HOME: homeDir, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}` + }; + + const installResult = runCli( + projectDir, + ["integrations", "install", "--host", "codex", "--json"], + { env } + ); + expect(installResult.exitCode, installResult.stderr).toBe(0); + + const result = runCli( + shellDir, + ["integrations", "doctor", "--host", "codex", "--cwd", projectDir, "--json"], + { env } + ); + expect(result.exitCode, result.stderr).toBe(0); + const payload = JSON.parse(result.stdout) as { + projectRoot: string; + nextSteps: string[]; + }; + expect(payload.nextSteps).toEqual( + expect.arrayContaining([ + expect.stringContaining( + `cam mcp apply-guidance --host codex --cwd ${JSON.stringify(payload.projectRoot)}` + ), + expect.stringContaining( + `cam mcp print-config --host codex --cwd ${JSON.stringify(payload.projectRoot)}` + ) + ]) + ); + }); + it("surfaces a ready Codex integration stack through integrations doctor", async () => { const homeDir = await tempDir("cam-integrations-doctor-ready-home-"); const projectDir = await tempDir("cam-integrations-doctor-ready-project-"); @@ -580,4 +670,101 @@ describe("integrations command", () => { ) ).toContain("cam:asset-version"); }); + + it("passes through an explicit official project skill surface for integrations install and apply", async () => { + const homeDir = await tempDir("cam-integrations-official-project-home-"); + const projectDir = await tempDir("cam-integrations-official-project-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + const installResult = runCli( + projectDir, + [ + "integrations", + "install", + "--host", + "codex", + "--skill-surface", + "official-project", + "--json" + ], + { env: { HOME: homeDir } } + ); + expect(installResult.exitCode, installResult.stderr).toBe(0); + const installPayload = JSON.parse(installResult.stdout) as { + host: string; + projectRoot: string; + skillsSurface: string; + subactions: { + skills: { + action: string; + surface: string; + targetDir: string; + }; + }; + notes: string[]; + }; + expect(installPayload).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + skillsSurface: "official-project", + subactions: { + skills: { + action: "created", + surface: "official-project", + targetDir: path.join(realProjectDir, ".agents", "skills", "codex-auto-memory-recall") + } + } + }); + expect(installPayload.notes).toEqual( + expect.arrayContaining([expect.stringContaining("project-scoped official .agents/skills copy")]) + ); + + const applyResult = runCli( + projectDir, + [ + "integrations", + "apply", + "--host", + "codex", + "--skill-surface", + "official-project", + "--json" + ], + { env: { HOME: homeDir } } + ); + expect(applyResult.exitCode, applyResult.stderr).toBe(0); + const applyPayload = JSON.parse(applyResult.stdout) as { + host: string; + projectRoot: string; + skillsSurface: string; + subactions: { + skills: { + surface: string; + targetDir: string; + }; + }; + notes: string[]; + }; + expect(applyPayload).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + skillsSurface: "official-project", + subactions: { + skills: { + surface: "official-project", + targetDir: path.join(realProjectDir, ".agents", "skills", "codex-auto-memory-recall") + } + } + }); + expect(applyPayload.notes).toEqual( + expect.arrayContaining([expect.stringContaining("project-scoped official .agents/skills copy")]) + ); + expect( + await fs.readFile( + path.join(realProjectDir, ".agents", "skills", "codex-auto-memory-recall", "SKILL.md"), + "utf8" + ) + ).toContain("cam:asset-version"); + }); }); diff --git a/test/mcp-command.test.ts b/test/mcp-command.test.ts index 2b37b2f..982474d 100644 --- a/test/mcp-command.test.ts +++ b/test/mcp-command.test.ts @@ -1013,6 +1013,36 @@ describe("mcp command", () => { expect(payload.snippet).toContain(realProjectDir); }); + it("pins the recommended skill install command to the inspected project when mcp doctor uses --cwd", async () => { + const homeDir = await tempDir("cam-mcp-doctor-cwd-home-"); + const projectParentDir = await tempDir("cam-mcp-doctor-cwd-parent-"); + const projectDir = path.join(projectParentDir, "project with spaces"); + const shellDir = await tempDir("cam-mcp-doctor-cwd-shell-"); + process.env.HOME = homeDir; + + await fs.mkdir(projectDir, { recursive: true }); + + const result = runCli( + shellDir, + ["mcp", "doctor", "--host", "codex", "--cwd", projectDir, "--json"], + { + env: { HOME: homeDir } + } + ); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + projectRoot: string; + fallbackAssets: { + recommendedSkillInstallCommand: string; + }; + }; + expect(payload.projectRoot).toBe(await fs.realpath(projectDir)); + expect(payload.fallbackAssets.recommendedSkillInstallCommand).toBe( + `cam skills install --surface runtime --cwd ${JSON.stringify(payload.projectRoot)}` + ); + }); + it("inspects project-scoped MCP wiring and fallback bridge assets", async () => { const homeDir = await tempDir("cam-mcp-doctor-home-"); const projectDir = await tempDir("cam-mcp-doctor-project-"); diff --git a/test/recall-command.test.ts b/test/recall-command.test.ts index 1e6a468..93d2635 100644 --- a/test/recall-command.test.ts +++ b/test/recall-command.test.ts @@ -258,6 +258,72 @@ describe("runRecall", () => { }); }); + it("supports --cwd so recall can target another project directory from the current shell", async () => { + const homeDir = await tempDir("cam-recall-cwd-home-"); + const projectParentDir = await tempDir("cam-recall-cwd-parent-"); + const projectDir = path.join(projectParentDir, "project with spaces"); + const shellDir = await tempDir("cam-recall-cwd-shell-"); + const memoryRoot = await tempDir("cam-recall-cwd-memory-"); + process.env.HOME = homeDir; + + await fs.mkdir(projectDir, { recursive: true }); + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await store.forget("project", "pnpm", { archive: true }); + + const searchResult = runCli( + shellDir, + ["recall", "search", "pnpm", "--cwd", projectDir, "--state", "archived", "--json"], + { env: { HOME: homeDir } } + ); + expect(searchResult.exitCode).toBe(0); + + const searchOutput = JSON.parse(searchResult.stdout) as { + results: Array<{ ref: string }>; + }; + expect(searchOutput.results).toHaveLength(1); + + const ref = searchOutput.results[0]!.ref; + const timelineResult = runCli( + shellDir, + ["recall", "timeline", ref, "--cwd", projectDir, "--json"], + { env: { HOME: homeDir } } + ); + expect(timelineResult.exitCode).toBe(0); + expect(JSON.parse(timelineResult.stdout)).toMatchObject({ + ref, + events: expect.arrayContaining([expect.objectContaining({ action: "archive" })]) + }); + + const detailsResult = runCli( + shellDir, + ["recall", "details", ref, "--cwd", projectDir, "--json"], + { env: { HOME: homeDir } } + ); + expect(detailsResult.exitCode).toBe(0); + expect(JSON.parse(detailsResult.stdout)).toMatchObject({ + ref, + path: store.getArchiveTopicFile("project", "workflow") + }); + }); + it("keeps recall search read-only and does not create memory layout on first lookup", async () => { const homeDir = await tempDir("cam-recall-readonly-home-"); const projectDir = await tempDir("cam-recall-readonly-project-"); diff --git a/test/tarball-install-smoke.test.ts b/test/tarball-install-smoke.test.ts index 372a988..0b7cc74 100644 --- a/test/tarball-install-smoke.test.ts +++ b/test/tarball-install-smoke.test.ts @@ -42,6 +42,7 @@ describe("tarball install smoke", () => { const homeDir = await tempDir("cam-tarball-home-"); const packDir = await tempDir("cam-tarball-pack-"); const installDir = await tempDir("cam-tarball-install-"); + const realInstallDir = await fs.realpath(installDir); const env = isolatedEnv(homeDir); const packageJson = JSON.parse(await fs.readFile(path.resolve("package.json"), "utf8")) as { version: string; @@ -160,6 +161,57 @@ describe("tarball install smoke", () => { targetFileHint: ".mcp.json" }); + const geminiPrintConfigResult = runCommandCapture( + camBinaryPath(installDir), + ["mcp", "print-config", "--host", "gemini", "--json"], + installDir, + envWithBin + ); + expect(geminiPrintConfigResult.exitCode).toBe(0); + expect(JSON.parse(geminiPrintConfigResult.stdout)).toMatchObject({ + host: "gemini", + serverName: "codex_auto_memory", + targetFileHint: ".gemini/settings.json" + }); + + const genericPrintConfigResult = runCommandCapture( + camBinaryPath(installDir), + ["mcp", "print-config", "--host", "generic", "--json"], + installDir, + envWithBin + ); + expect(genericPrintConfigResult.exitCode).toBe(0); + expect(JSON.parse(genericPrintConfigResult.stdout)).toMatchObject({ + host: "generic", + serverName: "codex_auto_memory", + targetFileHint: "Your MCP client's stdio server config", + snippetFormat: "json" + }); + + const genericInstallResult = runCommandCapture( + camBinaryPath(installDir), + ["mcp", "install", "--host", "generic"], + installDir, + envWithBin + ); + expect(genericInstallResult.exitCode).toBe(1); + expect(genericInstallResult.stderr).toContain("generic"); + expect(genericInstallResult.stderr).toContain("manual-only"); + + const geminiInstallResult = runCommandCapture( + camBinaryPath(installDir), + ["mcp", "install", "--host", "gemini", "--json"], + installDir, + envWithBin + ); + expect(geminiInstallResult.exitCode).toBe(0); + expect(JSON.parse(geminiInstallResult.stdout)).toMatchObject({ + host: "gemini", + action: "created", + targetPath: path.join(realInstallDir, ".gemini", "settings.json"), + readOnlyRetrieval: true + }); + const hooksResult = runCommandCapture( camBinaryPath(installDir), ["hooks", "install"], @@ -199,6 +251,20 @@ describe("tarball install smoke", () => { ) ).toContain("cam:asset-version"); + const officialProjectSkillsResult = runCommandCapture( + camBinaryPath(installDir), + ["skills", "install", "--surface", "official-project"], + installDir, + envWithBin + ); + expect(officialProjectSkillsResult.exitCode).toBe(0); + expect( + await fs.readFile( + path.join(installDir, ".agents", "skills", "codex-auto-memory-recall", "SKILL.md"), + "utf8" + ) + ).toContain("cam:asset-version"); + const integrationsResult = runCommandCapture( camBinaryPath(installDir), ["integrations", "install", "--host", "codex", "--json"], @@ -238,6 +304,114 @@ describe("tarball install smoke", () => { } }); + const integrationsOfficialProjectResult = runCommandCapture( + camBinaryPath(installDir), + [ + "integrations", + "install", + "--host", + "codex", + "--skill-surface", + "official-project", + "--json" + ], + installDir, + envWithBin + ); + expect(integrationsOfficialProjectResult.exitCode).toBe(0); + expect(JSON.parse(integrationsOfficialProjectResult.stdout)).toMatchObject({ + host: "codex", + skillsSurface: "official-project", + subactions: { + skills: { + action: "unchanged", + surface: "official-project", + targetDir: path.join(realInstallDir, ".agents", "skills", "codex-auto-memory-recall") + } + } + }); + + const integrationsOfficialUserResult = runCommandCapture( + camBinaryPath(installDir), + [ + "integrations", + "install", + "--host", + "codex", + "--skill-surface", + "official-user", + "--json" + ], + installDir, + envWithBin + ); + expect(integrationsOfficialUserResult.exitCode).toBe(0); + expect(JSON.parse(integrationsOfficialUserResult.stdout)).toMatchObject({ + host: "codex", + skillsSurface: "official-user", + subactions: { + skills: { + action: "unchanged", + surface: "official-user", + targetDir: path.join(homeDir, ".agents", "skills", "codex-auto-memory-recall") + } + } + }); + + const integrationsApplyOfficialUserResult = runCommandCapture( + camBinaryPath(installDir), + [ + "integrations", + "apply", + "--host", + "codex", + "--skill-surface", + "official-user", + "--json" + ], + installDir, + envWithBin + ); + expect(integrationsApplyOfficialUserResult.exitCode).toBe(0); + expect(JSON.parse(integrationsApplyOfficialUserResult.stdout)).toMatchObject({ + host: "codex", + skillsSurface: "official-user", + subactions: { + skills: { + action: "unchanged", + surface: "official-user", + targetDir: path.join(homeDir, ".agents", "skills", "codex-auto-memory-recall") + } + } + }); + + const integrationsApplyOfficialProjectResult = runCommandCapture( + camBinaryPath(installDir), + [ + "integrations", + "apply", + "--host", + "codex", + "--skill-surface", + "official-project", + "--json" + ], + installDir, + envWithBin + ); + expect(integrationsApplyOfficialProjectResult.exitCode).toBe(0); + expect(JSON.parse(integrationsApplyOfficialProjectResult.stdout)).toMatchObject({ + host: "codex", + skillsSurface: "official-project", + subactions: { + skills: { + action: "unchanged", + surface: "official-project", + targetDir: path.join(realInstallDir, ".agents", "skills", "codex-auto-memory-recall") + } + } + }); + const integrationsDoctorResult = runCommandCapture( camBinaryPath(installDir), ["integrations", "doctor", "--host", "codex", "--json"], @@ -253,8 +427,8 @@ describe("tarball install smoke", () => { recommendedPreset: "state=auto, limit=8", preferredSkillSurface: "runtime", recommendedSkillInstallCommand: "cam skills install --surface runtime", - installedSkillSurfaces: ["runtime", "official-user"], - readySkillSurfaces: ["runtime", "official-user"], + installedSkillSurfaces: ["runtime", "official-user", "official-project"], + readySkillSurfaces: ["runtime", "official-user", "official-project"], subchecks: { mcp: { status: "ok" }, agents: { status: "ok" }, @@ -264,5 +438,104 @@ describe("tarball install smoke", () => { workflowConsistency: { status: "ok" } } }); + + const recallHelpResult = runCommandCapture( + camBinaryPath(installDir), + ["recall", "search", "--help"], + installDir, + envWithBin + ); + expect(recallHelpResult.exitCode).toBe(0); + expect(recallHelpResult.stdout).toContain("Search compact memory candidates without loading full details"); + expect(recallHelpResult.stdout).toContain("Limit memory state: active, archived, all, or auto"); + + const mcpHelpResult = runCommandCapture( + camBinaryPath(installDir), + ["mcp", "print-config", "--help"], + installDir, + envWithBin + ); + expect(mcpHelpResult.exitCode).toBe(0); + expect(mcpHelpResult.stdout).toContain( + "Print a ready-to-paste MCP config snippet for a supported host" + ); + expect(mcpHelpResult.stdout).toContain("Target host: codex, claude, gemini, or generic"); + + const mcpInstallHelpResult = runCommandCapture( + camBinaryPath(installDir), + ["mcp", "install", "--help"], + installDir, + envWithBin + ); + expect(mcpInstallHelpResult.exitCode).toBe(0); + expect(mcpInstallHelpResult.stdout).toContain( + "Install the recommended project-scoped MCP wiring for a supported host" + ); + expect(mcpInstallHelpResult.stdout).toContain("Target host: codex, claude, or gemini"); + + const mcpApplyGuidanceHelpResult = runCommandCapture( + camBinaryPath(installDir), + ["mcp", "apply-guidance", "--help"], + installDir, + envWithBin + ); + expect(mcpApplyGuidanceHelpResult.exitCode).toBe(0); + expect(mcpApplyGuidanceHelpResult.stdout).toContain( + "Safely create or update the managed Codex Auto Memory block inside AGENTS.md" + ); + expect(mcpApplyGuidanceHelpResult.stdout).toContain("Target host: codex"); + + const skillsHelpResult = runCommandCapture( + camBinaryPath(installDir), + ["skills", "install", "--help"], + installDir, + envWithBin + ); + expect(skillsHelpResult.exitCode).toBe(0); + expect(skillsHelpResult.stdout).toMatch( + /Install a Codex skill that teaches search -> timeline -> details memory\s+retrieval/ + ); + expect(skillsHelpResult.stdout).toMatch( + /Skill install surface: runtime, official-user, or\s+official-project/ + ); + + const integrationsInstallHelpResult = runCommandCapture( + camBinaryPath(installDir), + ["integrations", "install", "--help"], + installDir, + envWithBin + ); + expect(integrationsInstallHelpResult.exitCode).toBe(0); + expect(integrationsInstallHelpResult.stdout).toContain( + "Install the recommended project-scoped Codex integration stack" + ); + expect(integrationsInstallHelpResult.stdout).toContain("Target host: codex"); + expect(integrationsInstallHelpResult.stdout).toMatch( + /Skill install surface: runtime, official-user, or\s+official-project/ + ); + + const integrationsApplyHelpResult = runCommandCapture( + camBinaryPath(installDir), + ["integrations", "apply", "--help"], + installDir, + envWithBin + ); + expect(integrationsApplyHelpResult.exitCode).toBe(0); + expect(integrationsApplyHelpResult.stdout).toMatch( + /Install the recommended Codex integration stack and safely apply the managed\s+AGENTS guidance block/ + ); + expect(integrationsApplyHelpResult.stdout).toContain("Target host: codex"); + + const integrationsDoctorHelpResult = runCommandCapture( + camBinaryPath(installDir), + ["integrations", "doctor", "--help"], + installDir, + envWithBin + ); + expect(integrationsDoctorHelpResult.exitCode).toBe(0); + expect(integrationsDoctorHelpResult.stdout).toMatch( + /Inspect the current Codex integration stack without mutating memory or host\s+config/ + ); + expect(integrationsDoctorHelpResult.stdout).toContain("Target host: codex"); }, 60_000); }); From e535117ae8701b186a56c39a2896b767e7d4c76b Mon Sep 17 00:00:00 2001 From: blocks Date: Thu, 26 Mar 2026 18:23:50 +0800 Subject: [PATCH 04/62] feat: tighten codex workflow contract and release gates --- docs/release-checklist.md | 5 ++ package.json | 2 +- src/lib/integration/assets.ts | 10 ++-- src/lib/integration/codex-stack.ts | 7 ++- src/lib/integration/mcp-doctor.ts | 4 +- src/lib/integration/retrieval-contract.ts | 24 ++++++--- test/dist-cli-smoke.test.ts | 66 +++++++++++++++++++++++ test/docs-contract.test.ts | 8 ++- test/mcp-command.test.ts | 27 ++++++++++ test/tarball-install-smoke.test.ts | 63 ++++++++++++++++++++++ 10 files changed, 199 insertions(+), 17 deletions(-) diff --git a/docs/release-checklist.md b/docs/release-checklist.md index c63833a..24252df 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -7,6 +7,7 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Confirm the README still matches current CLI behavior. - Confirm the paired Chinese and English public docs still describe the same product boundary and command surface: - `README.md` and `README.en.md` + - `README.zh-TW.md` and `README.ja.md` - `docs/README.md` and `docs/README.en.md` - `docs/claude-reference.md` and `docs/claude-reference.en.md` - `docs/architecture.md` and `docs/architecture.en.md` @@ -51,10 +52,13 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Run `node dist/cli.js mcp print-config --host --json` for each public host and confirm the snippet contract includes `serverName`, `targetFileHint`, and a project-pinned retrieval command without writing host config files. - For `node dist/cli.js mcp print-config --host codex --json`, also confirm the payload includes an additive AGENTS.md snippet / guidance block that teaches MCP-first, `cam recall`-fallback durable memory usage. - Run `node dist/cli.js mcp apply-guidance --host codex --json` and confirm it reports `created`, `updated`, `unchanged`, or `blocked` without overwriting unrelated AGENTS.md content outside the managed block. +- Run `node dist/cli.js mcp apply-guidance --host codex --cwd --json` from another working directory and confirm the managed AGENTS block is written inside the targeted project root. - Confirm `node dist/cli.js mcp apply-guidance --host codex --json` ignores fenced-code examples of the managed markers, and that `node dist/cli.js mcp doctor --json` does not treat fenced examples as installed guidance. - Run `node dist/cli.js mcp doctor --json` and confirm it reports project-scoped host wiring, project pinning, and hook / skill fallback assets without creating memory layout or mutating host config files. +- Run `node dist/cli.js skills install --surface official-project --cwd ` from another working directory and confirm the explicit project-scoped `.agents/skills` copy is written inside the targeted repository. - Run `node dist/cli.js integrations install --host codex --json` and confirm it orchestrates the existing Codex MCP wiring, hook bundle, and skill assets without touching the Markdown memory store. - Run `node dist/cli.js integrations apply --host codex --json` and confirm it orchestrates MCP wiring, managed AGENTS guidance, hook assets, and skill assets while keeping `integrations install --host codex` non-mutating for AGENTS.md. +- Run `node dist/cli.js integrations apply --host codex --cwd --json` from another working directory and confirm the stack still project-pins all subactions to the targeted repository. - Run `node dist/cli.js skills install --surface official-user` and confirm the explicit official `.agents/skills` copy is written without changing the runtime default target. - Run `node dist/cli.js integrations install --host codex --skill-surface official-user --json` and confirm the skill subaction reports the selected surface while MCP and AGENTS boundaries stay unchanged. - Run `node dist/cli.js integrations apply --host codex --skill-surface official-user --json` and confirm the selected skill surface passes through while the AGENTS mutation boundary remains exclusive to `apply`. @@ -66,6 +70,7 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - `node dist/cli.js mcp install --help` should keep the supported install-host list at `codex, claude, or gemini`, leaving `generic` out of the install branch. - `node dist/cli.js mcp print-config --help` should keep the supported snippet-host list at `codex, claude, gemini, or generic`. - `node dist/cli.js mcp apply-guidance --help` should stay Codex-only and describe managed `AGENTS.md` updates. + - `node dist/cli.js mcp doctor --help` should stay inspect-only and keep the host selection list at `codex, claude, gemini, generic, or all`. - `node dist/cli.js skills install --help` should keep the public skill surfaces aligned at `runtime, official-user, or official-project`. - `node dist/cli.js integrations install --help` should describe stack install without managed `AGENTS.md` mutation. - `node dist/cli.js integrations apply --help` should explicitly add the managed `AGENTS.md` guidance flow on top of install. diff --git a/package.json b/package.json index 05b63e0..ea29469 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codex-auto-memory", "version": "0.1.0", - "description": "A Markdown-first, local-first memory runtime for Codex with wrapper, MCP, skill, and AGENTS integration surfaces.", + "description": "A Markdown-first, local-first memory runtime for Codex with wrapper, hook, MCP, skill, and AGENTS integration surfaces.", "type": "module", "bin": { "cam": "dist/cli.js" diff --git a/src/lib/integration/assets.ts b/src/lib/integration/assets.ts index 24044e1..e55944c 100644 --- a/src/lib/integration/assets.ts +++ b/src/lib/integration/assets.ts @@ -8,6 +8,7 @@ import { buildRecommendedSearchPresetGuidance, buildRecommendedMcpSearchInstruction, buildRecommendedRetrievalSummaryLines, + buildSharedWorkflowDisciplineLines, buildShellAssetVersionComment, CLI_FALLBACK_RECALL_WORKFLOW, MCP_DOCTOR_GUIDANCE, @@ -177,16 +178,17 @@ This bundle keeps durable-memory recall host-agnostic. ## Boundaries -- ${MEMORY_AUDIT_BOUNDARY} -- ${SESSION_CONTINUITY_BOUNDARY} -- ${ARCHIVE_BOUNDARY} +${buildSharedWorkflowDisciplineLines() + .slice(2) + .map((line) => `- ${line}`) + .join("\n")} ## Workflow discipline 1. Search first. 2. Inspect timeline only for promising refs. 3. Fetch full details only when you still need the full Markdown body. -4. After finishing work that should update durable memory, run \`cam sync\` or review \`cam memory --recent\`. +4. ${buildSharedWorkflowDisciplineLines()[2]} `; } diff --git a/src/lib/integration/codex-stack.ts b/src/lib/integration/codex-stack.ts index 0fe6778..05bc6ce 100644 --- a/src/lib/integration/codex-stack.ts +++ b/src/lib/integration/codex-stack.ts @@ -4,6 +4,7 @@ import { buildCliTimelineCommand, buildRecommendedCliSearchCommand, buildRecommendedMcpSearchInstruction, + buildSharedWorkflowDisciplineLines, DURABLE_MEMORY_SYNC_GUIDANCE, formatRecommendedRetrievalPreset } from "./retrieval-contract.js"; @@ -351,7 +352,7 @@ export function buildCodexStackNotes(): string[] { LOCAL_BRIDGE_BUNDLE_NOTE, "Recommended route prefers project-scoped MCP, then local bridge recall helpers, then direct cam recall CLI usage.", `Recommended retrieval preset: ${formatRecommendedRetrievalPreset()}.`, - DURABLE_MEMORY_SYNC_GUIDANCE, + ...buildSharedWorkflowDisciplineLines().slice(2), "Run `cam mcp print-config --host codex` to inspect the recommended project-scoped MCP wiring together with an AGENTS.md snippet for Codex agents.", "Run `cam mcp apply-guidance --host codex` to create or update the managed Codex Auto Memory block inside the repository-level AGENTS.md.", "Codex skill readiness is guidance-only and does not replace executable hook fallback helpers.", @@ -367,9 +368,7 @@ export function buildCodexAgentsGuidance(): CodexAgentsGuidance { `- When durable memory may help, prefer the retrieval MCP workflow: \`${RETRIEVAL_MCP_SEARCH_TOOL}\` -> \`${RETRIEVAL_MCP_TIMELINE_TOOL}\` -> \`${RETRIEVAL_MCP_DETAILS_TOOL}\`.`, `- ${buildRecommendedMcpSearchInstruction()}`, `- If the retrieval MCP server is unavailable, fall back to \`${buildRecommendedCliSearchCommand()}\`, then \`cam recall timeline \"\"\`, then \`cam recall details \"\"\`.`, - `- ${DURABLE_MEMORY_SYNC_GUIDANCE}`, - "- Use `cam memory` for inspect/audit surfaces and startup payload review.", - "- Use `cam session` only for temporary continuity, not durable memory retrieval.", + ...buildSharedWorkflowDisciplineLines().slice(2).map((line) => `- ${line}`), `- ${LOCAL_BRIDGE_BUNDLE_NOTE}` ].join("\n"); diff --git a/src/lib/integration/mcp-doctor.ts b/src/lib/integration/mcp-doctor.ts index ed3c47d..d708358 100644 --- a/src/lib/integration/mcp-doctor.ts +++ b/src/lib/integration/mcp-doctor.ts @@ -160,6 +160,7 @@ export interface McpDoctorReport { install: true; serve: true; printConfig: true; + applyGuidance: true; doctor: true; }; agentsGuidance: CodexAgentsGuidanceInspection; @@ -675,6 +676,7 @@ export async function inspectMcpDoctor(options: { install: true, serve: true, printConfig: true, + applyGuidance: true, doctor: true }, agentsGuidance, @@ -692,7 +694,7 @@ export function formatMcpDoctorReport(report: McpDoctorReport): string { `Inside project root: ${report.cwdWithinProjectRoot ? "yes" : "no"}`, `Server name: ${report.serverName}`, "Retrieval plane: read-only", - "Command surface: cam mcp install, cam mcp serve, cam mcp print-config, cam mcp doctor", + "Command surface: cam mcp install, cam mcp serve, cam mcp print-config, cam mcp apply-guidance, cam mcp doctor", "", "Host checks:" ]; diff --git a/src/lib/integration/retrieval-contract.ts b/src/lib/integration/retrieval-contract.ts index a12043e..15ef92f 100644 --- a/src/lib/integration/retrieval-contract.ts +++ b/src/lib/integration/retrieval-contract.ts @@ -17,6 +17,10 @@ export const RETRIEVAL_CLI_SEARCH_COMMAND = "cam recall search"; export const RETRIEVAL_CLI_TIMELINE_COMMAND = "cam recall timeline"; export const RETRIEVAL_CLI_DETAILS_COMMAND = "cam recall details"; +export const RECALL_FIRST_GUIDANCE = + "Before repeating prior work or repo-specific decisions, recall durable memory first."; +export const PROGRESSIVE_DISCLOSURE_GUIDANCE = + "Use progressive disclosure: search -> timeline -> details."; export const MCP_FIRST_RECALL_WORKFLOW = `Prefer retrieval MCP when it is already wired in: ${RETRIEVAL_MCP_SEARCH_TOOL} -> ${RETRIEVAL_MCP_TIMELINE_TOOL} -> ${RETRIEVAL_MCP_DETAILS_TOOL}.`; export const CLI_FALLBACK_RECALL_WORKFLOW = @@ -34,6 +38,17 @@ export const ARCHIVE_BOUNDARY = export const DURABLE_MEMORY_SYNC_GUIDANCE = "After finishing work that should affect durable memory, run cam sync or review cam memory --recent instead of assuming temporary continuity already updated Markdown memory."; +export function buildSharedWorkflowDisciplineLines(): string[] { + return [ + RECALL_FIRST_GUIDANCE, + PROGRESSIVE_DISCLOSURE_GUIDANCE, + DURABLE_MEMORY_SYNC_GUIDANCE, + MEMORY_AUDIT_BOUNDARY, + SESSION_CONTINUITY_BOUNDARY, + ARCHIVE_BOUNDARY + ]; +} + export function formatRecommendedRetrievalPreset(): string { return `state=${RECOMMENDED_RETRIEVAL_STATE}, limit=${RECOMMENDED_RETRIEVAL_LIMIT}`; } @@ -103,8 +118,8 @@ export function buildRecommendedSearchPresetGuidance(): string { export function buildRecommendedRetrievalSummaryLines(): string[] { return [ - "Before repeating prior work or repo-specific decisions, recall durable memory first.", - "Use progressive disclosure: search -> timeline -> details.", + RECALL_FIRST_GUIDANCE, + PROGRESSIVE_DISCLOSURE_GUIDANCE, "Use this workflow when a host or skill needs read-only retrieval without reading full topic files up front.", MCP_FIRST_RECALL_WORKFLOW, buildRecommendedMcpSearchInstruction(), @@ -112,10 +127,7 @@ export function buildRecommendedRetrievalSummaryLines(): string[] { CLI_FALLBACK_RECALL_WORKFLOW, buildRecommendedSearchPresetGuidance(), MCP_DOCTOR_GUIDANCE, - DURABLE_MEMORY_SYNC_GUIDANCE, - MEMORY_AUDIT_BOUNDARY, - SESSION_CONTINUITY_BOUNDARY, - ARCHIVE_BOUNDARY + ...buildSharedWorkflowDisciplineLines().slice(2) ]; } diff --git a/test/dist-cli-smoke.test.ts b/test/dist-cli-smoke.test.ts index 5db5064..3e03e4c 100644 --- a/test/dist-cli-smoke.test.ts +++ b/test/dist-cli-smoke.test.ts @@ -516,6 +516,7 @@ describe("dist cli smoke", () => { install: true, serve: true, printConfig: true, + applyGuidance: true, doctor: true }, hosts: [ @@ -1013,6 +1014,16 @@ fs.writeFileSync(${JSON.stringify(capturedArgsPath)}, JSON.stringify(process.arg ); expect(mcpApplyGuidanceHelp.stdout).toContain("Target host: codex"); + const mcpDoctorHelp = runCli(projectDir, ["mcp", "doctor", "--help"], { + entrypoint: "dist", + env + }); + expect(mcpDoctorHelp.exitCode, mcpDoctorHelp.stderr).toBe(0); + expect(mcpDoctorHelp.stdout).toContain( + "Inspect the recommended project-scoped MCP wiring without writing host config" + ); + expect(mcpDoctorHelp.stdout).toContain("Target host: codex, claude, gemini, generic, or all"); + const skillsHelp = runCli(projectDir, ["skills", "install", "--help"], { entrypoint: "dist", env @@ -1059,4 +1070,59 @@ fs.writeFileSync(${JSON.stringify(capturedArgsPath)}, JSON.stringify(process.arg ); expect(integrationsDoctorHelp.stdout).toContain("Target host: codex"); }); + + it("keeps key compiled --cwd command surfaces working across project boundaries", async () => { + const homeDir = await tempDir("cam-dist-cwd-home-"); + const projectParentDir = await tempDir("cam-dist-cwd-project-parent-"); + const projectDir = path.join(projectParentDir, "project with spaces"); + const callerDir = await tempDir("cam-dist-cwd-caller-"); + const env = { HOME: homeDir }; + + await fs.mkdir(projectDir, { recursive: true }); + const realProjectDir = await fs.realpath(projectDir); + + const skillResult = runCli( + callerDir, + ["skills", "install", "--surface", "official-project", "--cwd", projectDir], + { + entrypoint: "dist", + env + } + ); + expect(skillResult.exitCode, skillResult.stderr).toBe(0); + expect( + await fs.readFile( + path.join(realProjectDir, ".agents", "skills", "codex-auto-memory-recall", "SKILL.md"), + "utf8" + ) + ).toContain("cam:asset-version"); + + const guidanceResult = runCli( + callerDir, + ["mcp", "apply-guidance", "--host", "codex", "--cwd", projectDir, "--json"], + { + entrypoint: "dist", + env + } + ); + expect(guidanceResult.exitCode, guidanceResult.stderr).toBe(0); + expect(JSON.parse(guidanceResult.stdout)).toMatchObject({ + host: "codex", + targetPath: path.join(realProjectDir, "AGENTS.md") + }); + + const integrationsResult = runCli( + callerDir, + ["integrations", "apply", "--host", "codex", "--cwd", projectDir, "--json"], + { + entrypoint: "dist", + env + } + ); + expect(integrationsResult.exitCode, integrationsResult.stderr).toBe(0); + expect(JSON.parse(integrationsResult.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir + }); + }); }); diff --git a/test/docs-contract.test.ts b/test/docs-contract.test.ts index 9556d6c..b474a2f 100644 --- a/test/docs-contract.test.ts +++ b/test/docs-contract.test.ts @@ -181,12 +181,18 @@ describe("docs contract", () => { expect(releaseChecklist).toContain("node dist/cli.js mcp install --help"); expect(releaseChecklist).toContain("node dist/cli.js mcp print-config --help"); expect(releaseChecklist).toContain("node dist/cli.js mcp apply-guidance --help"); + expect(releaseChecklist).toContain("node dist/cli.js mcp doctor --help"); expect(releaseChecklist).toContain("node dist/cli.js skills install --help"); expect(releaseChecklist).toContain("node dist/cli.js integrations install --help"); expect(releaseChecklist).toContain("node dist/cli.js integrations apply --help"); expect(releaseChecklist).toContain("node dist/cli.js integrations doctor --help"); + expect(releaseChecklist).toContain("node dist/cli.js skills install --surface official-project --cwd "); + expect(releaseChecklist).toContain("node dist/cli.js mcp apply-guidance --host codex --cwd --json"); + expect(releaseChecklist).toContain("node dist/cli.js integrations apply --host codex --cwd --json"); expect(releaseChecklist).toContain("codex, claude, gemini, or generic"); expect(releaseChecklist).toContain("leaving `generic` out of the install branch"); + expect(releaseChecklist).toContain("README.zh-TW.md"); + expect(releaseChecklist).toContain("README.ja.md"); expect(releaseChecklist).toContain("search_memories"); expect(contributing).toContain("reviewer-only warnings"); expect(contributing).toContain("pnpm test:docs-contract"); @@ -210,7 +216,7 @@ describe("docs contract", () => { "vitest run test/tarball-install-smoke.test.ts" ); expect(packageJson.description).toBe( - "A Markdown-first, local-first memory runtime for Codex with wrapper, MCP, skill, and AGENTS integration surfaces." + "A Markdown-first, local-first memory runtime for Codex with wrapper, hook, MCP, skill, and AGENTS integration surfaces." ); expect(packageJson.bin.cam).toBe("dist/cli.js"); expect(packageJson.files).toEqual( diff --git a/test/mcp-command.test.ts b/test/mcp-command.test.ts index 982474d..6fa84a4 100644 --- a/test/mcp-command.test.ts +++ b/test/mcp-command.test.ts @@ -454,6 +454,31 @@ describe("mcp command", () => { }); }); + it("supports apply-guidance --cwd for updating another project's AGENTS guidance", async () => { + const homeDir = await tempDir("cam-mcp-apply-guidance-cwd-home-"); + const projectDir = await tempDir("cam-mcp-apply-guidance-cwd-project-"); + const callerDir = await tempDir("cam-mcp-apply-guidance-cwd-caller-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + const result = runCli( + callerDir, + ["mcp", "apply-guidance", "--host", "codex", "--cwd", projectDir, "--json"], + { env: { HOME: homeDir } } + ); + + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "codex", + action: "created", + targetPath: path.join(realProjectDir, "AGENTS.md") + }); + + const agentsContents = await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8"); + expect(agentsContents).toContain("search_memories"); + expect(agentsContents).toContain("cam sync"); + }); + it("prints ready-to-paste host snippets without mutating host config files", async () => { const homeDir = await tempDir("cam-mcp-print-home-"); const projectDir = await tempDir("cam-mcp-print-project-"); @@ -1097,6 +1122,7 @@ describe("mcp command", () => { install: boolean; serve: boolean; printConfig: boolean; + applyGuidance: boolean; doctor: boolean; }; fallbackAssets: { @@ -1143,6 +1169,7 @@ describe("mcp command", () => { install: true, serve: true, printConfig: true, + applyGuidance: true, doctor: true }); expect(payload.fallbackAssets).toMatchObject({ diff --git a/test/tarball-install-smoke.test.ts b/test/tarball-install-smoke.test.ts index 0b7cc74..460c8d7 100644 --- a/test/tarball-install-smoke.test.ts +++ b/test/tarball-install-smoke.test.ts @@ -148,6 +148,55 @@ describe("tarball install smoke", () => { await fs.readFile(path.join(installDir, "AGENTS.md"), "utf8") ).toContain(codexPrintConfigPayload.agentsGuidance.snippet); + const shellDir = await tempDir("cam-tarball-shell-"); + const projectWithSpacesDir = path.join(shellDir, "project with spaces"); + await fs.mkdir(projectWithSpacesDir, { recursive: true }); + const realProjectWithSpacesDir = await fs.realpath(projectWithSpacesDir); + + const cwdSkillResult = runCommandCapture( + camBinaryPath(installDir), + ["skills", "install", "--surface", "official-project", "--cwd", projectWithSpacesDir], + shellDir, + envWithBin + ); + expect(cwdSkillResult.exitCode).toBe(0); + expect( + await fs.readFile( + path.join( + realProjectWithSpacesDir, + ".agents", + "skills", + "codex-auto-memory-recall", + "SKILL.md" + ), + "utf8" + ) + ).toContain("cam:asset-version"); + + const cwdApplyGuidanceResult = runCommandCapture( + camBinaryPath(installDir), + ["mcp", "apply-guidance", "--host", "codex", "--cwd", projectWithSpacesDir, "--json"], + shellDir, + envWithBin + ); + expect(cwdApplyGuidanceResult.exitCode).toBe(0); + expect(JSON.parse(cwdApplyGuidanceResult.stdout)).toMatchObject({ + host: "codex", + targetPath: path.join(realProjectWithSpacesDir, "AGENTS.md") + }); + + const cwdIntegrationsResult = runCommandCapture( + camBinaryPath(installDir), + ["integrations", "apply", "--host", "codex", "--cwd", projectWithSpacesDir, "--json"], + shellDir, + envWithBin + ); + expect(cwdIntegrationsResult.exitCode).toBe(0); + expect(JSON.parse(cwdIntegrationsResult.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectWithSpacesDir + }); + const claudePrintConfigResult = runCommandCapture( camBinaryPath(installDir), ["mcp", "print-config", "--host", "claude", "--json"], @@ -485,6 +534,20 @@ describe("tarball install smoke", () => { ); expect(mcpApplyGuidanceHelpResult.stdout).toContain("Target host: codex"); + const mcpDoctorHelpResult = runCommandCapture( + camBinaryPath(installDir), + ["mcp", "doctor", "--help"], + installDir, + envWithBin + ); + expect(mcpDoctorHelpResult.exitCode).toBe(0); + expect(mcpDoctorHelpResult.stdout).toContain( + "Inspect the recommended project-scoped MCP wiring without writing host config" + ); + expect(mcpDoctorHelpResult.stdout).toContain( + "Target host: codex, claude, gemini, generic, or all" + ); + const skillsHelpResult = runCommandCapture( camBinaryPath(installDir), ["skills", "install", "--help"], From 08839670b114d5f550aa5d8128bacab8e17e2410 Mon Sep 17 00:00:00 2001 From: blocks Date: Thu, 26 Mar 2026 18:54:07 +0800 Subject: [PATCH 05/62] feat: tighten workflow contract and post-work review --- README.en.md | 6 +- README.ja.md | 6 +- README.md | 8 +- README.zh-TW.md | 6 +- docs/release-checklist.md | 5 + src/lib/commands/integrations.ts | 2 + src/lib/integration/assets.ts | 24 ++++ src/lib/integration/codex-stack.ts | 8 ++ src/lib/integration/mcp-doctor.ts | 28 ++++- src/lib/integration/retrieval-contract.ts | 98 ++++++++++++++-- test/dist-cli-smoke.test.ts | 136 ++++++++++++++++++++++ test/docs-contract.test.ts | 9 ++ test/hooks-command.test.ts | 7 ++ test/integrations-command.test.ts | 68 +++++++++++ test/mcp-command.test.ts | 52 ++++++++- test/tarball-install-smoke.test.ts | 100 ++++++++++++++++ 16 files changed, 538 insertions(+), 25 deletions(-) diff --git a/README.en.md b/README.en.md index 1a7c97f..92a99bb 100644 --- a/README.en.md +++ b/README.en.md @@ -206,15 +206,15 @@ cam audit | `cam mcp serve` | start a read-only retrieval MCP server that exposes the same workflow through `search_memories`, `timeline_memories`, and `get_memory_details` | | `cam integrations install --host codex` | install the recommended Codex integration stack in one explicit step by writing project-scoped MCP wiring and refreshing the hook bridge bundle plus Codex skill assets; it defaults to the runtime skill target, but also accepts `--skill-surface runtime|official-user|official-project`; stays idempotent, Codex-only, and does not touch the Markdown memory store | | `cam integrations apply --host codex` | explicitly apply the full Codex integration state: it keeps `integrations install` unchanged, but also orchestrates `cam mcp apply-guidance --host codex`; it defaults to the runtime skill target, but also accepts `--skill-surface runtime|official-user|official-project`; if `AGENTS.md` cannot be updated safely, the command returns `blocked` and preserves the additive fail-closed boundary | -| `cam integrations doctor --host codex` | inspect the current Codex integration stack through a thin read-only aggregation surface that reports the recommended route, recommended preset, subchecks, and minimum next steps; it now recommends `cam integrations apply --host codex` when multiple Codex stack surfaces are still missing, and keeps `cam mcp apply-guidance --host codex` as the precise next step when only the managed `AGENTS.md` block is missing or outdated | +| `cam integrations doctor --host codex` | inspect the current Codex integration stack through a thin read-only aggregation surface that reports the recommended route, recommended preset, structured `workflowContract`, subchecks, and minimum next steps; it now recommends `cam integrations apply --host codex` when multiple Codex stack surfaces are still missing, and keeps `cam mcp apply-guidance --host codex` as the precise next step when only the managed `AGENTS.md` block is missing or outdated | | `cam mcp install --host ` | explicitly write the recommended project-scoped host config for `codex_auto_memory`; only that server entry is replaced, hooks/skills stay opt-in, and `generic` remains manual-only | | `cam mcp print-config --host ` | print a ready-to-paste host snippet so the read-only retrieval plane can be wired into an existing MCP client with less manual setup; for `--host codex`, it also prints a recommended `AGENTS.md` snippet that teaches future Codex agents to prefer MCP and fall back to `cam recall` only when needed | | `cam mcp apply-guidance --host codex` | create or update the Codex Auto Memory managed block inside the repository-level `AGENTS.md` through an additive, auditable, fail-closed flow; it only appends a new block or replaces the same marker block, and returns `blocked` if it cannot locate that block safely | -| `cam mcp doctor` | inspect the recommended project-scoped retrieval MCP wiring, project pinning, and hook/skill fallback assets; it now also adds a `codexStack` readiness summary for the recommended route, executable bits, shared asset version, and workflow consistency without modifying host config files | +| `cam mcp doctor` | inspect the recommended project-scoped retrieval MCP wiring, project pinning, and hook/skill fallback assets; it now also adds a `codexStack` readiness summary plus a structured `workflowContract` for the recommended route, executable bits, shared asset version, and workflow consistency without modifying host config files | | `cam session save` | merge / incremental save for continuity | | `cam session refresh` | replace / clean regeneration for continuity | | `cam session load` / `status` | inspect the continuity reviewer surface | -| `cam hooks` | manage the current local bridge / fallback recall bundle, including `memory-recall.sh`, compatibility wrappers, and `recall-bridge.md`; it is not an official Codex hook surface, and the bundle's recommended search preset is `state=auto`, `limit=8` | +| `cam hooks` | manage the current local bridge / fallback recall bundle, including `memory-recall.sh`, `post-work-memory-review.sh`, compatibility wrappers, and `recall-bridge.md`; `post-work-memory-review.sh` chains `cam sync` with `cam memory --recent` for post-work durable-memory review; it is not an official Codex hook surface, and the bundle's recommended search preset is `state=auto`, `limit=8` | | `cam skills` | install Codex skill assets with `cam skills install`; the default target remains the runtime surface, while `--surface runtime|official-user|official-project` enables explicit migration-prep copies on official `.agents/skills` paths; all surfaces teach the same MCP-first, CLI-fallback progressive durable-memory retrieval workflow and the same recommended search preset: `state=auto`, `limit=8` | | `cam audit` | run privacy and secret-hygiene checks | | `cam doctor` | inspect local wiring and native-readiness posture | diff --git a/README.ja.md b/README.ja.md index 06f73e5..5606183 100644 --- a/README.ja.md +++ b/README.ja.md @@ -200,15 +200,15 @@ cam audit | `cam mcp serve` | `search_memories` / `timeline_memories` / `get_memory_details` を通じて同じ retrieval contract を公開する read-only MCP server を起動する | | `cam integrations install --host codex` | 推奨される Codex integration stack を一度に導入し、project-scoped MCP wiring を書き込みつつ、hook bridge bundle と Codex skill assets を更新する。skills は runtime target が既定だが、`--skill-surface runtime|official-user|official-project` も指定できる。明示的・冪等・Codex-only を保ち、Markdown memory store には触れない | | `cam integrations apply --host codex` | 明示的・冪等・Codex-only のまま完全な integration state を適用する。`integrations install` の既存境界は変えず、その上で `cam mcp apply-guidance --host codex` も編成する。skills は runtime target が既定だが、`--skill-surface runtime|official-user|official-project` も指定できる。`AGENTS.md` を安全に更新できない場合は `blocked` を返し、additive / fail-closed 境界を守る | -| `cam integrations doctor --host codex` | 現在の Codex integration stack を薄い read-only 集約面として点検し、推奨ルート、推奨 preset、サブチェック結果、次の最小アクションを返す。複数の Codex stack 面が不足しているときは `cam integrations apply --host codex` を優先し、AGENTS guidance だけが不足している場合は `cam mcp apply-guidance --host codex` を正確な次手として案内する | +| `cam integrations doctor --host codex` | 現在の Codex integration stack を薄い read-only 集約面として点検し、推奨ルート、推奨 preset、構造化された `workflowContract`、サブチェック結果、次の最小アクションを返す。複数の Codex stack 面が不足しているときは `cam integrations apply --host codex` を優先し、AGENTS guidance だけが不足している場合は `cam mcp apply-guidance --host codex` を正確な次手として案内する | | `cam mcp install --host ` | 推奨される project-scoped 宿主設定を明示的に書き込み、`codex_auto_memory` の項目だけを更新する。hooks/skills は自動導入せず、`generic` は引き続き manual-only | | `cam mcp print-config --host ` | ready-to-paste な接続スニペットを出力し、read-only retrieval plane を既存の MCP client に低摩擦で接続できるようにする。`--host codex` の場合は、将来の Codex エージェントに MCP 優先・`cam recall` フォールバックを教えるための推奨 `AGENTS.md` snippet も合わせて出力する | | `cam mcp apply-guidance --host codex` | repo ルートの `AGENTS.md` 内にある Codex Auto Memory 管理 block を additive・監査可能・fail-closed に作成または更新する。同じ marker block の追加または置換だけを行い、安全に特定できない場合は書き換えず `blocked` を返す | -| `cam mcp doctor` | 推奨される project-scoped retrieval MCP の配線、project pinning、hook/skill fallback assets を read-only で点検し、さらに `codexStack` readiness によって推奨ルート、executable bit、共有 asset version、workflow consistency を要約する。ホスト設定は書き換えない | +| `cam mcp doctor` | 推奨される project-scoped retrieval MCP の配線、project pinning、hook/skill fallback assets を read-only で点検し、さらに `codexStack` readiness と構造化された `workflowContract` によって推奨ルート、executable bit、共有 asset version、workflow consistency を要約する。ホスト設定は書き換えない | | `cam session save` | continuity の merge / incremental save | | `cam session refresh` | continuity の replace / clean regeneration | | `cam session load` / `status` | continuity reviewer surface を確認 | -| `cam hooks` | 現在の local bridge / fallback recall bundle を管理し、`memory-recall.sh`、互換 wrapper、`recall-bridge.md` を通じて今後の hook / skill / MCP-aware retrieval に備える。これは公式な Codex hook surface ではなく、推奨検索 preset は `state=auto`、`limit=8` | +| `cam hooks` | 現在の local bridge / fallback recall bundle を管理し、`memory-recall.sh`、`post-work-memory-review.sh`、互換 wrapper、`recall-bridge.md` を通じて今後の hook / skill / MCP-aware retrieval に備える。`post-work-memory-review.sh` は `cam sync` と `cam memory --recent` をまとめた収束 review helper である。これは公式な Codex hook surface ではなく、推奨検索 preset は `state=auto`、`limit=8` | | `cam skills` | `cam skills install` で Codex skill を導入する。既定 target は runtime のままだが、`--surface runtime|official-user|official-project` を使えば公式 `.agents/skills` 経路向けの明示的な互換コピーも置ける。どの surface でも、MCP-first / CLI-fallback の段階的 durable memory retrieval workflow と推奨検索 preset `state=auto`, `limit=8` を共有する | | `cam audit` | プライバシーと secret hygiene を監査 | | `cam doctor` | ローカル wiring と native-readiness を確認 | diff --git a/README.md b/README.md index 9765bb0..adfd8f8 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ Claude Code 已经公开了一套相对清晰的 auto memory 产品契约: | formal retrieval MCP surface | 本轮新增 | 提供 `cam mcp serve`,通过 `search_memories` / `timeline_memories` / `get_memory_details` 暴露只读 retrieval plane | | project-scoped MCP install surface | 本轮新增 | 提供 `cam mcp install --host `,显式写入推荐的 project-scoped 宿主配置,降低 MCP 接线摩擦 | | noop-aware lifecycle audit | 已有首批实现 | 相同 active memory 的重复写入、以及缺失 active 目标的 delete/archive,会显式记为 `noop` reviewer 结果,而不再静默重写 Markdown | -| hook / skill / MCP-aware integration | 已进入代码主线 | `cam hooks install` 现在会生成 recall bridge bundle(`memory-recall.sh`、兼容 wrapper 与 `recall-bridge.md`),供后续 hook / skill / MCP bridge 复用 | +| hook / skill / MCP-aware integration | 已进入代码主线 | `cam hooks install` 现在会生成 recall bridge bundle(`memory-recall.sh`、`post-work-memory-review.sh`、兼容 wrapper 与 `recall-bridge.md`),供后续 hook / skill / MCP bridge 复用 | | Codex skill install surface | 已有首批实现 | `cam skills install` 默认安装 runtime 目标,并支持显式 `--surface runtime|official-user|official-project`;无论装到哪个 surface,都沿用同一套 MCP-first、CLI-fallback 的 `search -> timeline -> details` durable memory 工作流 | ## 集成方向 @@ -211,15 +211,15 @@ cam audit | `cam mcp serve` | 启动只读 retrieval MCP server,通过 `search_memories` / `timeline_memories` / `get_memory_details` 暴露同一套渐进式检索契约 | | `cam integrations install --host codex` | 一次性安装推荐的 Codex integration stack:写入 project-scoped MCP wiring,并刷新 hook bridge bundle 与 Codex skill 资产;默认使用 runtime skills target,也支持显式 `--skill-surface runtime|official-user|official-project`;保持显式、幂等、Codex-only,且不触碰 Markdown memory store | | `cam integrations apply --host codex` | 以显式、幂等、Codex-only 的方式应用完整 integration state:在保留 `integrations install` 旧语义不变的前提下,额外编排 `cam mcp apply-guidance --host codex`;默认使用 runtime skills target,也支持显式 `--skill-surface runtime|official-user|official-project`;若 `AGENTS.md` 无法安全更新,会返回 `blocked` 并保持 additive / fail-closed | -| `cam integrations doctor --host codex` | 以 Codex-only、只读、薄聚合的方式汇总当前 integration stack readiness,直接给出推荐路由、推荐 preset、子检查结果与下一步最小动作;当缺多个子检查时会优先推荐 `cam integrations apply --host codex`,若只缺 AGENTS guidance 则继续精确指向 `cam mcp apply-guidance --host codex`,不会改写宿主配置或 Markdown memory store | +| `cam integrations doctor --host codex` | 以 Codex-only、只读、薄聚合的方式汇总当前 integration stack readiness,直接给出推荐路由、推荐 preset、结构化 `workflowContract`、子检查结果与下一步最小动作;当缺多个子检查时会优先推荐 `cam integrations apply --host codex`,若只缺 AGENTS guidance 则继续精确指向 `cam mcp apply-guidance --host codex`,不会改写宿主配置或 Markdown memory store | | `cam mcp install --host ` | 显式写入推荐的 project-scoped 宿主 MCP 配置;只更新 `codex_auto_memory` 这一项,不会自动安装 hooks/skills;`generic` 继续保持 manual-only | | `cam mcp print-config --host ` | 打印 ready-to-paste 的宿主接入片段,降低把 read-only retrieval plane 接进现有工作流的摩擦;其中 `--host codex` 还会额外打印推荐的 `AGENTS.md` snippet,帮助未来 Codex 代理优先走 MCP、必要时再 fallback 到 `cam recall` | | `cam mcp apply-guidance --host codex` | 以 additive、可审计、fail-closed 的方式创建或更新仓库根 `AGENTS.md` 中由 Codex Auto Memory 自己管理的 guidance block;只会 append 新 block 或替换同一 marker block,无法安全定位时返回 `blocked` 而不会冒险改写 | -| `cam mcp doctor` | 只读检查当前项目的 retrieval MCP 接入状态、project pinning 与 hook/skill fallback 资产;同时追加 `codexStack` readiness 视图,用于汇总推荐路由、executable bit、共享资产版本与 workflow consistency,不会改写任何宿主配置 | +| `cam mcp doctor` | 只读检查当前项目的 retrieval MCP 接入状态、project pinning 与 hook/skill fallback 资产;同时追加 `codexStack` readiness 视图与结构化 `workflowContract`,用于汇总推荐路由、executable bit、共享资产版本与 workflow consistency,不会改写任何宿主配置 | | `cam session save` | merge / incremental save;从 rollout 增量写入 continuity | | `cam session refresh` | replace / clean regeneration;从选定 provenance 重建 continuity | | `cam session load` / `status` | 查看 continuity reviewer surface 与 diagnostics | -| `cam hooks install` | 生成本仓自带的 local bridge / fallback helper bundle,包括 `memory-recall.sh`、兼容 helper wrappers 与 `recall-bridge.md`;它不是官方 Codex hook surface,且该 bundle 的推荐检索 preset 为 `state=auto`、`limit=8` | +| `cam hooks install` | 生成本仓自带的 local bridge / fallback helper bundle,包括 `memory-recall.sh`、`post-work-memory-review.sh`、兼容 helper wrappers 与 `recall-bridge.md`;其中 `post-work-memory-review.sh` 会把 `cam sync` 与 `cam memory --recent` 串成同一套收尾 review 动作;它不是官方 Codex hook surface,且该 bundle 的推荐检索 preset 为 `state=auto`、`limit=8` | | `cam skills install` | 默认安装 runtime Codex skill 资产,并支持显式 `--surface runtime|official-user|official-project`;让代理优先通过 retrieval MCP,未接线时再 fallback 到 `cam recall`,并沿用同一套推荐检索 preset:`state=auto`、`limit=8` | | `cam audit` | 仓库级 privacy / secret hygiene 审查 | | `cam doctor` | 检查当前 companion wiring、Codex feature posture 与 future integration readiness | diff --git a/README.zh-TW.md b/README.zh-TW.md index 66791b2..d2bb166 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -202,15 +202,15 @@ cam audit | `cam mcp serve` | 啟動只讀 retrieval MCP server,以 `search_memories` / `timeline_memories` / `get_memory_details` 暴露同一套漸進式檢索契約 | | `cam integrations install --host codex` | 一次性安裝推薦的 Codex integration stack:寫入 project-scoped MCP wiring,並刷新 hook bridge bundle 與 Codex skill 資產;預設使用 runtime skills target,也支援顯式 `--skill-surface runtime|official-user|official-project`;保持顯式、幂等、Codex-only,且不碰 Markdown memory store | | `cam integrations apply --host codex` | 以顯式、幂等、Codex-only 的方式套用完整 integration state:在保留 `integrations install` 舊語義不變的前提下,額外編排 `cam mcp apply-guidance --host codex`;預設使用 runtime skills target,也支援顯式 `--skill-surface runtime|official-user|official-project`;若 `AGENTS.md` 無法安全更新,會回傳 `blocked` 並維持 additive / fail-closed 邊界 | -| `cam integrations doctor --host codex` | 以 Codex-only、只讀、薄聚合的方式彙總目前 integration stack readiness,直接給出推薦路由、推薦 preset、子檢查結果與下一步最小動作;當缺多個子檢查時會優先推薦 `cam integrations apply --host codex`,若只缺 AGENTS guidance 則繼續精準指向 `cam mcp apply-guidance --host codex`,不會改寫宿主設定或 Markdown memory store | +| `cam integrations doctor --host codex` | 以 Codex-only、只讀、薄聚合的方式彙總目前 integration stack readiness,直接給出推薦路由、推薦 preset、結構化 `workflowContract`、子檢查結果與下一步最小動作;當缺多個子檢查時會優先推薦 `cam integrations apply --host codex`,若只缺 AGENTS guidance 則繼續精準指向 `cam mcp apply-guidance --host codex`,不會改寫宿主設定或 Markdown memory store | | `cam mcp install --host ` | 顯式寫入推薦的 project-scoped 宿主 MCP 配置;只更新 `codex_auto_memory` 這一項,不會自動安裝 hooks/skills;`generic` 仍維持 manual-only | | `cam mcp print-config --host ` | 列印 ready-to-paste 的宿主接入片段,降低把 read-only retrieval plane 接進既有 MCP workflow 的手動成本;其中 `--host codex` 還會額外列印推薦的 `AGENTS.md` snippet,幫助未來 Codex 代理優先走 MCP、必要時再 fallback 到 `cam recall` | | `cam mcp apply-guidance --host codex` | 以 additive、可審計、fail-closed 的方式建立或更新 repo 根 `AGENTS.md` 中由 Codex Auto Memory 自己管理的 guidance block;只會 append 新 block 或替換同一 marker block,若無法安全定位則回傳 `blocked` 而不會冒險改寫 | -| `cam mcp doctor` | 只讀檢查目前專案的 retrieval MCP 接線、project pinning 與 hook/skill fallback assets;現在也會追加 `codexStack` readiness 視圖,用來彙總推薦路由、executable bit、共享資產版本與 workflow consistency,不會改寫任何宿主設定 | +| `cam mcp doctor` | 只讀檢查目前專案的 retrieval MCP 接線、project pinning 與 hook/skill fallback assets;現在也會追加 `codexStack` readiness 視圖與結構化 `workflowContract`,用來彙總推薦路由、executable bit、共享資產版本與 workflow consistency,不會改寫任何宿主設定 | | `cam session save` | merge / incremental save;增量寫入 continuity | | `cam session refresh` | replace / clean regeneration;重建 continuity | | `cam session load` / `status` | continuity reviewer surface | -| `cam hooks` | 管理目前的 local bridge / fallback recall bundle,包括 `memory-recall.sh`、相容 helper wrappers 與 `recall-bridge.md`;它不是官方 Codex hook surface,且該 bundle 的推薦檢索 preset 為 `state=auto`、`limit=8` | +| `cam hooks` | 管理目前的 local bridge / fallback recall bundle,包括 `memory-recall.sh`、`post-work-memory-review.sh`、相容 helper wrappers 與 `recall-bridge.md`;其中 `post-work-memory-review.sh` 會把 `cam sync` 與 `cam memory --recent` 串成同一套收尾 review 動作;它不是官方 Codex hook surface,且該 bundle 的推薦檢索 preset 為 `state=auto`、`limit=8` | | `cam skills` | 以 `cam skills install` 安裝 Codex skill;預設 target 仍是 runtime,也支援顯式 `--surface runtime|official-user|official-project` 為官方 `.agents/skills` 路徑準備相容副本;所有 surface 都沿用同一套 MCP-first、CLI-fallback 漸進式 durable memory 檢索工作流與推薦 preset:`state=auto`、`limit=8` | | `cam audit` | 做隱私與 secret-hygiene 檢查 | | `cam doctor` | 檢視本地 wiring 與 native-readiness posture | diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 24252df..770b7b2 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -52,12 +52,16 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Run `node dist/cli.js mcp print-config --host --json` for each public host and confirm the snippet contract includes `serverName`, `targetFileHint`, and a project-pinned retrieval command without writing host config files. - For `node dist/cli.js mcp print-config --host codex --json`, also confirm the payload includes an additive AGENTS.md snippet / guidance block that teaches MCP-first, `cam recall`-fallback durable memory usage. - Run `node dist/cli.js mcp apply-guidance --host codex --json` and confirm it reports `created`, `updated`, `unchanged`, or `blocked` without overwriting unrelated AGENTS.md content outside the managed block. +- Confirm `node dist/cli.js mcp apply-guidance --host codex --json` still returns `blocked` for malformed or unsafe managed-block shapes while leaving `AGENTS.md` byte-for-byte unchanged. - Run `node dist/cli.js mcp apply-guidance --host codex --cwd --json` from another working directory and confirm the managed AGENTS block is written inside the targeted project root. - Confirm `node dist/cli.js mcp apply-guidance --host codex --json` ignores fenced-code examples of the managed markers, and that `node dist/cli.js mcp doctor --json` does not treat fenced examples as installed guidance. - Run `node dist/cli.js mcp doctor --json` and confirm it reports project-scoped host wiring, project pinning, and hook / skill fallback assets without creating memory layout or mutating host config files. +- Run `node dist/cli.js mcp doctor --host codex --json` and confirm the payload also exposes the structured `workflowContract`, including the current CLI fallback commands and post-work sync/review helper contract. +- Confirm `node dist/cli.js hooks install` writes `post-work-memory-review.sh`, and that the generated helper still runs `cam sync` followed by `cam memory --recent`. - Run `node dist/cli.js skills install --surface official-project --cwd ` from another working directory and confirm the explicit project-scoped `.agents/skills` copy is written inside the targeted repository. - Run `node dist/cli.js integrations install --host codex --json` and confirm it orchestrates the existing Codex MCP wiring, hook bundle, and skill assets without touching the Markdown memory store. - Run `node dist/cli.js integrations apply --host codex --json` and confirm it orchestrates MCP wiring, managed AGENTS guidance, hook assets, and skill assets while keeping `integrations install --host codex` non-mutating for AGENTS.md. +- Confirm `node dist/cli.js integrations apply --host codex --json` still returns `stackAction: "blocked"` when the AGENTS managed block is unsafe, while preserving the AGENTS file content and still reporting the blocked subaction explicitly. - Run `node dist/cli.js integrations apply --host codex --cwd --json` from another working directory and confirm the stack still project-pins all subactions to the targeted repository. - Run `node dist/cli.js skills install --surface official-user` and confirm the explicit official `.agents/skills` copy is written without changing the runtime default target. - Run `node dist/cli.js integrations install --host codex --skill-surface official-user --json` and confirm the skill subaction reports the selected surface while MCP and AGENTS boundaries stay unchanged. @@ -66,6 +70,7 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Run `node dist/cli.js integrations install --host codex --skill-surface official-project --json` and confirm the skill subaction reports the selected project-scoped surface while MCP and AGENTS boundaries stay unchanged. - Run `node dist/cli.js integrations apply --host codex --skill-surface official-project --json` and confirm the selected project-scoped skill surface still flows through the full apply path. - Run `node dist/cli.js integrations doctor --host codex --json` and confirm it reports the thin Codex-only stack readiness view with `recommendedRoute`, `recommendedPreset`, `subchecks`, and `nextSteps`. +- Confirm `node dist/cli.js integrations doctor --host codex --json` also exposes the shared structured `workflowContract`, including the post-work sync/review helper semantics. - Treat key `--help` output as release-facing contract, not incidental CLI text: - `node dist/cli.js mcp install --help` should keep the supported install-host list at `codex, claude, or gemini`, leaving `generic` out of the install branch. - `node dist/cli.js mcp print-config --help` should keep the supported snippet-host list at `codex, claude, gemini, or generic`. diff --git a/src/lib/commands/integrations.ts b/src/lib/commands/integrations.ts index efb2715..071bf13 100644 --- a/src/lib/commands/integrations.ts +++ b/src/lib/commands/integrations.ts @@ -99,6 +99,7 @@ interface IntegrationDoctorResult { status: CodexIntegrationStatus; recommendedRoute: McpDoctorReport["codexStack"]["recommendedRoute"]; recommendedPreset: string; + workflowContract: McpDoctorReport["workflowContract"]; preferredSkillSurface: CodexSkillInstallSurface; recommendedSkillInstallCommand: string; installedSkillSurfaces: CodexSkillInstallSurface[]; @@ -289,6 +290,7 @@ function buildIntegrationsDoctorResult( status, recommendedRoute: report.codexStack.recommendedRoute, recommendedPreset: report.codexStack.preset, + workflowContract: report.workflowContract, preferredSkillSurface: report.fallbackAssets.preferredInstallSurface, recommendedSkillInstallCommand: report.fallbackAssets.recommendedSkillInstallCommand, installedSkillSurfaces: [...report.fallbackAssets.installedSkillSurfaces], diff --git a/src/lib/integration/assets.ts b/src/lib/integration/assets.ts index e55944c..6933033 100644 --- a/src/lib/integration/assets.ts +++ b/src/lib/integration/assets.ts @@ -8,6 +8,8 @@ import { buildRecommendedSearchPresetGuidance, buildRecommendedMcpSearchInstruction, buildRecommendedRetrievalSummaryLines, + buildPostWorkRecentReviewCommand, + buildPostWorkSyncCommand, buildSharedWorkflowDisciplineLines, buildShellAssetVersionComment, CLI_FALLBACK_RECALL_WORKFLOW, @@ -15,6 +17,7 @@ import { MCP_FIRST_RECALL_WORKFLOW, MCP_SERVE_GUIDANCE, MEMORY_AUDIT_BOUNDARY, + POST_WORK_SYNC_REVIEW_HELPER, RECOMMENDED_RETRIEVAL_LIMIT, RECOMMENDED_RETRIEVAL_STATE, RETRIEVAL_INTEGRATION_ASSET_VERSION, @@ -192,6 +195,15 @@ ${buildSharedWorkflowDisciplineLines() `; } +function buildPostWorkMemoryReviewScript(): string { + return `#!/bin/sh +${buildShellAssetVersionComment()} +# Sync the latest durable memory updates, then show the recent audit surface for review. +${buildPostWorkSyncCommand()} "$@" || exit $? +exec ${buildPostWorkRecentReviewCommand()} +`; +} + function buildCodexSkillMarkdown(): string { return `--- name: codex-auto-memory-recall @@ -245,6 +257,7 @@ If you need both active and archived results in one pass instead of active-first - \`cam mcp serve\` exposes the same retrieval contract over stdio MCP when the host can consume it. - If you are unsure whether retrieval MCP is wired into the current host, run \`cam mcp doctor\`. - If a host needs shell-based fallback assets, run \`cam hooks install\` and use the generated recall bridge bundle. +- If available, run \`${POST_WORK_SYNC_REVIEW_HELPER}\` to combine \`${buildPostWorkSyncCommand()}\` with \`${buildPostWorkRecentReviewCommand()}\`. - After finishing work that should update durable memory, run \`cam sync\` or review \`cam memory --recent\`. - Use \`cam memory\` for inspect/audit surfaces, startup payload, and recent sync review. - Use \`cam session\` only for temporary continuity, not durable memory retrieval. @@ -286,6 +299,17 @@ ${buildShellAssetVersionComment()} cam doctor "$@" ` }, + { + id: "post-work-memory-review", + name: POST_WORK_SYNC_REVIEW_HELPER, + installSurface: "hooks", + relativePath: POST_WORK_SYNC_REVIEW_HELPER, + executable: true, + role: "capture-helper", + doctorVisible: true, + doctorSignatures: ['cam sync "$@"', "cam memory --recent"], + renderContents: () => buildPostWorkMemoryReviewScript() + }, { id: "memory-recall", name: "memory-recall.sh", diff --git a/src/lib/integration/codex-stack.ts b/src/lib/integration/codex-stack.ts index 05bc6ce..d2a0696 100644 --- a/src/lib/integration/codex-stack.ts +++ b/src/lib/integration/codex-stack.ts @@ -1,10 +1,13 @@ import { appendCliCwdFlag, buildCliDetailsCommand, + buildPostWorkRecentReviewCommand, + buildPostWorkSyncCommand, buildCliTimelineCommand, buildRecommendedCliSearchCommand, buildRecommendedMcpSearchInstruction, buildSharedWorkflowDisciplineLines, + buildWorkflowContract, DURABLE_MEMORY_SYNC_GUIDANCE, formatRecommendedRetrievalPreset } from "./retrieval-contract.js"; @@ -79,6 +82,7 @@ export const CODEX_HOOK_RECALL_ASSET_IDS = [ ] as const; export const CODEX_WORKFLOW_CONSISTENCY_ASSET_IDS = [ + "post-work-memory-review", ...CODEX_HOOK_RECALL_ASSET_IDS, "recall-bridge-guide", "codex-memory-skill" @@ -98,6 +102,7 @@ export const CODEX_AGENTS_REQUIRED_SIGNATURES = [ RETRIEVAL_MCP_TIMELINE_TOOL, RETRIEVAL_MCP_DETAILS_TOOL, "cam recall search", + "post-work-memory-review.sh", "cam memory", "cam session", LOCAL_BRIDGE_BUNDLE_NOTE @@ -353,6 +358,7 @@ export function buildCodexStackNotes(): string[] { "Recommended route prefers project-scoped MCP, then local bridge recall helpers, then direct cam recall CLI usage.", `Recommended retrieval preset: ${formatRecommendedRetrievalPreset()}.`, ...buildSharedWorkflowDisciplineLines().slice(2), + `When the local bridge bundle is installed, prefer \`post-work-memory-review.sh\` to combine \`${buildPostWorkSyncCommand()}\` with \`${buildPostWorkRecentReviewCommand()}\`.`, "Run `cam mcp print-config --host codex` to inspect the recommended project-scoped MCP wiring together with an AGENTS.md snippet for Codex agents.", "Run `cam mcp apply-guidance --host codex` to create or update the managed Codex Auto Memory block inside the repository-level AGENTS.md.", "Codex skill readiness is guidance-only and does not replace executable hook fallback helpers.", @@ -361,6 +367,7 @@ export function buildCodexStackNotes(): string[] { } export function buildCodexAgentsGuidance(): CodexAgentsGuidance { + const workflowContract = buildWorkflowContract(); const snippet = [ "## Codex Auto Memory", "", @@ -369,6 +376,7 @@ export function buildCodexAgentsGuidance(): CodexAgentsGuidance { `- ${buildRecommendedMcpSearchInstruction()}`, `- If the retrieval MCP server is unavailable, fall back to \`${buildRecommendedCliSearchCommand()}\`, then \`cam recall timeline \"\"\`, then \`cam recall details \"\"\`.`, ...buildSharedWorkflowDisciplineLines().slice(2).map((line) => `- ${line}`), + `- When the local bridge bundle is installed, \`${workflowContract.postWorkSyncReview.helperScript}\` combines \`${workflowContract.postWorkSyncReview.syncCommand}\` with \`${workflowContract.postWorkSyncReview.reviewCommand}\`.`, `- ${LOCAL_BRIDGE_BUNDLE_NOTE}` ].join("\n"); diff --git a/src/lib/integration/mcp-doctor.ts b/src/lib/integration/mcp-doctor.ts index d708358..28182c9 100644 --- a/src/lib/integration/mcp-doctor.ts +++ b/src/lib/integration/mcp-doctor.ts @@ -17,6 +17,7 @@ import { } from "./codex-stack.js"; import { appendCliCwdFlag, + buildWorkflowContract, detectIntegrationAssetVersion, formatRecommendedRetrievalPreset, RETRIEVAL_INTEGRATION_ASSET_VERSION @@ -140,6 +141,7 @@ interface McpDoctorFallbackAssets { readySkillSurfaces: CodexSkillInstallSurface[]; skillPathDrift: boolean; postSessionSyncInstalled: boolean; + postWorkReviewInstalled: boolean; captureHelpersInstalled: boolean; hookHelpersInstalled: boolean; startupDoctorInstalled: boolean; @@ -165,6 +167,7 @@ export interface McpDoctorReport { }; agentsGuidance: CodexAgentsGuidanceInspection; fallbackAssets: McpDoctorFallbackAssets; + workflowContract: ReturnType; hosts: McpDoctorHostReport[]; codexStack: { status: McpDoctorStatus; @@ -487,6 +490,8 @@ async function inspectFallbackAssets( .map((asset) => asset.name); const postSessionSyncInstalled = assets.find((asset) => asset.id === "post-session-sync")?.status === "ok"; + const postWorkReviewInstalled = + assets.find((asset) => asset.id === "post-work-memory-review")?.status === "ok"; const hookHelpersInstalled = retrievalHelpers.length > 0 && retrievalHelpers.every((name) => @@ -568,6 +573,7 @@ async function inspectFallbackAssets( runtimeSkillDir.length > 0 && path.resolve(runtimeSkillDir) !== path.resolve(officialUserSkillDir), postSessionSyncInstalled, + postWorkReviewInstalled, captureHelpersInstalled: postSessionSyncInstalled && Boolean(startupDoctorInstalled), hookHelpersInstalled, startupDoctorInstalled, @@ -589,7 +595,8 @@ function isAssetReady( function buildCodexStackReport( codexHost: McpDoctorHostReport, fallbackAssets: McpDoctorFallbackAssets, - camCommandAvailable: boolean + camCommandAvailable: boolean, + agentsGuidance: CodexAgentsGuidanceInspection ): McpDoctorReport["codexStack"] { const mcpReady = codexHost.status === "ok"; const mcpOperationalReady = mcpReady && camCommandAvailable; @@ -605,8 +612,11 @@ function buildCodexStackReport( const workflowConsistent = isAssetReady( fallbackAssets.assets, - [...CODEX_HOOK_RECALL_ASSET_IDS, "recall-bridge-guide"] - ) && skillReady; + [...CODEX_WORKFLOW_CONSISTENCY_ASSET_IDS] + ) && + fallbackAssets.postWorkReviewInstalled && + skillReady && + agentsGuidance.status === "ok"; const status = summarizeCodexIntegrationStatus([ mcpOperationalReady ? "ok" : mcpReady ? "warning" : "missing", hookCaptureReady ? "ok" : "missing", @@ -665,6 +675,9 @@ export async function inspectMcpDoctor(options: { agentsGuidancePath, (await fileExists(agentsGuidancePath)) ? await readTextFile(agentsGuidancePath) : null ); + const workflowContract = buildWorkflowContract({ + cwd: options.explicitCwd ? projectRoot : undefined + }); return { cwd, @@ -681,8 +694,14 @@ export async function inspectMcpDoctor(options: { }, agentsGuidance, fallbackAssets, + workflowContract, hosts, - codexStack: buildCodexStackReport(codexHost, fallbackAssets, camCommandAvailable) + codexStack: buildCodexStackReport( + codexHost, + fallbackAssets, + camCommandAvailable, + agentsGuidance + ) }; } @@ -738,6 +757,7 @@ export function formatMcpDoctorReport(report: McpDoctorReport): string { } lines.push( `- Post-session sync helper installed: ${report.fallbackAssets.postSessionSyncInstalled ? "yes" : "no"}`, + `- Post-work review helper installed: ${report.fallbackAssets.postWorkReviewInstalled ? "yes" : "no"}`, `- Capture helpers installed: ${report.fallbackAssets.captureHelpersInstalled ? "yes" : "no"}`, `- Hook helpers installed: ${report.fallbackAssets.hookHelpersInstalled ? "yes" : "no"}`, `- Startup doctor installed: ${report.fallbackAssets.startupDoctorInstalled ? "yes" : "no"}`, diff --git a/src/lib/integration/retrieval-contract.ts b/src/lib/integration/retrieval-contract.ts index 15ef92f..1110a22 100644 --- a/src/lib/integration/retrieval-contract.ts +++ b/src/lib/integration/retrieval-contract.ts @@ -16,6 +16,9 @@ export const RETRIEVAL_MCP_DETAILS_TOOL = "get_memory_details"; export const RETRIEVAL_CLI_SEARCH_COMMAND = "cam recall search"; export const RETRIEVAL_CLI_TIMELINE_COMMAND = "cam recall timeline"; export const RETRIEVAL_CLI_DETAILS_COMMAND = "cam recall details"; +export const DURABLE_MEMORY_SYNC_COMMAND = "cam sync"; +export const DURABLE_MEMORY_RECENT_REVIEW_COMMAND = "cam memory --recent"; +export const POST_WORK_SYNC_REVIEW_HELPER = "post-work-memory-review.sh"; export const RECALL_FIRST_GUIDANCE = "Before repeating prior work or repo-specific decisions, recall durable memory first."; @@ -36,16 +39,46 @@ export const SESSION_CONTINUITY_BOUNDARY = export const ARCHIVE_BOUNDARY = "Treat archived memory as historical context that does not participate in default startup recall."; export const DURABLE_MEMORY_SYNC_GUIDANCE = - "After finishing work that should affect durable memory, run cam sync or review cam memory --recent instead of assuming temporary continuity already updated Markdown memory."; + `After finishing work that should affect durable memory, run ${DURABLE_MEMORY_SYNC_COMMAND} or review ${DURABLE_MEMORY_RECENT_REVIEW_COMMAND} instead of assuming temporary continuity already updated Markdown memory.`; + +export interface WorkflowContract { + version: string; + preferredRoute: "mcp-first"; + recommendedPreset: string; + recallFirst: string; + progressiveDisclosure: string; + mcpTools: { + search: string; + timeline: string; + details: string; + }; + cliFallback: { + searchCommand: string; + timelineCommand: string; + detailsCommand: string; + }; + postWorkSyncReview: { + helperScript: string; + syncCommand: string; + reviewCommand: string; + guidance: string; + }; + boundaries: { + memoryAudit: string; + sessionContinuity: string; + archive: string; + }; +} export function buildSharedWorkflowDisciplineLines(): string[] { + const workflowContract = buildWorkflowContract(); return [ - RECALL_FIRST_GUIDANCE, - PROGRESSIVE_DISCLOSURE_GUIDANCE, - DURABLE_MEMORY_SYNC_GUIDANCE, - MEMORY_AUDIT_BOUNDARY, - SESSION_CONTINUITY_BOUNDARY, - ARCHIVE_BOUNDARY + workflowContract.recallFirst, + workflowContract.progressiveDisclosure, + workflowContract.postWorkSyncReview.guidance, + workflowContract.boundaries.memoryAudit, + workflowContract.boundaries.sessionContinuity, + workflowContract.boundaries.archive ]; } @@ -108,6 +141,57 @@ export function buildCliDetailsCommand( return appendCliCwdFlag(`${RETRIEVAL_CLI_DETAILS_COMMAND} ${ref}`, options.cwd); } +export function buildPostWorkSyncCommand( + options: { + cwd?: string; + } = {} +): string { + return appendCliCwdFlag(DURABLE_MEMORY_SYNC_COMMAND, options.cwd); +} + +export function buildPostWorkRecentReviewCommand( + options: { + cwd?: string; + } = {} +): string { + return appendCliCwdFlag(DURABLE_MEMORY_RECENT_REVIEW_COMMAND, options.cwd); +} + +export function buildWorkflowContract( + options: { + cwd?: string; + } = {} +): WorkflowContract { + return { + version: RETRIEVAL_INTEGRATION_ASSET_VERSION, + preferredRoute: "mcp-first", + recommendedPreset: formatRecommendedRetrievalPreset(), + recallFirst: RECALL_FIRST_GUIDANCE, + progressiveDisclosure: PROGRESSIVE_DISCLOSURE_GUIDANCE, + mcpTools: { + search: RETRIEVAL_MCP_SEARCH_TOOL, + timeline: RETRIEVAL_MCP_TIMELINE_TOOL, + details: RETRIEVAL_MCP_DETAILS_TOOL + }, + cliFallback: { + searchCommand: buildRecommendedCliSearchCommand("\"\"", options), + timelineCommand: buildCliTimelineCommand("\"\"", options), + detailsCommand: buildCliDetailsCommand("\"\"", options) + }, + postWorkSyncReview: { + helperScript: POST_WORK_SYNC_REVIEW_HELPER, + syncCommand: buildPostWorkSyncCommand(options), + reviewCommand: buildPostWorkRecentReviewCommand(options), + guidance: DURABLE_MEMORY_SYNC_GUIDANCE + }, + boundaries: { + memoryAudit: MEMORY_AUDIT_BOUNDARY, + sessionContinuity: SESSION_CONTINUITY_BOUNDARY, + archive: ARCHIVE_BOUNDARY + } + }; +} + export function buildRecommendedMcpSearchInstruction(): string { return `When using ${RETRIEVAL_MCP_SEARCH_TOOL}, pass state: "${RECOMMENDED_RETRIEVAL_STATE}" and limit: ${RECOMMENDED_RETRIEVAL_LIMIT}.`; } diff --git a/test/dist-cli-smoke.test.ts b/test/dist-cli-smoke.test.ts index 3e03e4c..4fb8007 100644 --- a/test/dist-cli-smoke.test.ts +++ b/test/dist-cli-smoke.test.ts @@ -417,6 +417,41 @@ describe("dist cli smoke", () => { expect(agentsContents).toContain("cam:codex-agents-guidance:end"); }); + it("fails closed for unsafe AGENTS guidance from the compiled cli entrypoint", async () => { + const homeDir = await tempDir("cam-dist-mcp-apply-guidance-blocked-home-"); + const projectDir = await tempDir("cam-dist-mcp-apply-guidance-blocked-project-"); + const realProjectDir = await fs.realpath(projectDir); + + await fs.writeFile( + path.join(realProjectDir, "AGENTS.md"), + [ + "# Project Notes", + "", + "", + "", + "- stale guidance" + ].join("\n"), + "utf8" + ); + + const before = await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8"); + const result = runCli( + projectDir, + ["mcp", "apply-guidance", "--host", "codex", "--json"], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + action: "blocked" + }); + expect(await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8")).toBe(before); + }); + it("does not treat fenced AGENTS examples as managed guidance from the compiled cli entrypoint", async () => { const homeDir = await tempDir("cam-dist-mcp-apply-guidance-fenced-home-"); const projectDir = await tempDir("cam-dist-mcp-apply-guidance-fenced-project-"); @@ -529,6 +564,52 @@ describe("dist cli smoke", () => { }); }); + it("inspects codex MCP wiring and workflow contract from the compiled cli entrypoint", async () => { + const homeDir = await tempDir("cam-dist-mcp-doctor-codex-home-"); + const projectDir = await tempDir("cam-dist-mcp-doctor-codex-project-"); + const realProjectDir = await fs.realpath(projectDir); + + expect( + runCli(projectDir, ["hooks", "install"], { + entrypoint: "dist", + env: { HOME: homeDir } + }).exitCode + ).toBe(0); + expect( + runCli(projectDir, ["skills", "install"], { + entrypoint: "dist", + env: { HOME: homeDir } + }).exitCode + ).toBe(0); + expect( + runCli(projectDir, ["mcp", "install", "--host", "codex"], { + entrypoint: "dist", + env: { HOME: homeDir } + }).exitCode + ).toBe(0); + + const result = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { + entrypoint: "dist", + env: { HOME: homeDir } + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + projectRoot: realProjectDir, + workflowContract: { + version: expect.any(String), + postWorkSyncReview: { + helperScript: "post-work-memory-review.sh", + syncCommand: "cam sync", + reviewCommand: "cam memory --recent" + } + }, + fallbackAssets: { + postWorkReviewInstalled: true + } + }); + }); + it("installs project-scoped MCP wiring from the compiled cli entrypoint", async () => { const homeDir = await tempDir("cam-dist-mcp-install-home-"); const projectDir = await tempDir("cam-dist-mcp-install-project-"); @@ -597,8 +678,13 @@ describe("dist cli smoke", () => { const hooksDir = path.join(homeDir, ".codex-auto-memory", "hooks"); const recallScript = await fs.readFile(path.join(hooksDir, "memory-recall.sh"), "utf8"); + const postWorkReviewScript = await fs.readFile( + path.join(hooksDir, "post-work-memory-review.sh"), + "utf8" + ); const recallGuide = await fs.readFile(path.join(hooksDir, "recall-bridge.md"), "utf8"); expect(recallScript).toContain("cam:asset-version"); + expect(postWorkReviewScript).toContain("cam memory --recent"); expect(recallGuide).toContain("cam:asset-version"); const skillsResult = runCli(projectDir, ["skills", "install"], { @@ -761,6 +847,48 @@ describe("dist cli smoke", () => { }); }); + it("surfaces blocked AGENTS updates from the compiled integrations apply entrypoint", async () => { + const homeDir = await tempDir("cam-dist-integrations-apply-blocked-home-"); + const projectDir = await tempDir("cam-dist-integrations-apply-blocked-project-"); + const realProjectDir = await fs.realpath(projectDir); + + await fs.writeFile( + path.join(realProjectDir, "AGENTS.md"), + [ + "# Project Notes", + "", + "", + "", + "- stale guidance" + ].join("\n"), + "utf8" + ); + + const before = await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8"); + const result = runCli( + projectDir, + ["integrations", "apply", "--host", "codex", "--json"], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + stackAction: "blocked", + subactions: { + agents: { + status: "blocked", + action: "blocked" + } + } + }); + expect(await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8")).toBe(before); + }); + it("supports the official-project skill surface from the compiled integrations entrypoint", async () => { const homeDir = await tempDir("cam-dist-integrations-official-project-home-"); const projectDir = await tempDir("cam-dist-integrations-official-project-project-"); @@ -919,6 +1047,14 @@ describe("dist cli smoke", () => { status: "ok", recommendedRoute: "mcp", recommendedPreset: "state=auto, limit=8", + workflowContract: { + version: expect.any(String), + postWorkSyncReview: { + helperScript: "post-work-memory-review.sh", + syncCommand: "cam sync", + reviewCommand: "cam memory --recent" + } + }, subchecks: { mcp: { status: "ok" }, agents: { status: "ok" }, diff --git a/test/docs-contract.test.ts b/test/docs-contract.test.ts index b474a2f..3fed309 100644 --- a/test/docs-contract.test.ts +++ b/test/docs-contract.test.ts @@ -45,6 +45,7 @@ describe("docs contract", () => { expect(readme).toContain("cam integrations apply --host codex"); expect(readme).toContain("cam integrations doctor --host codex"); expect(readme).toContain("memory-recall.sh"); + expect(readme).toContain("post-work-memory-review.sh"); expect(readme).toContain("limit=8"); expect(readme).toContain("local bridge"); expect(readme).toContain("cam mcp apply-guidance --host codex"); @@ -111,6 +112,7 @@ describe("docs contract", () => { expect(readmeEn).toContain("cam integrations doctor --host codex"); expect(readmeEn).toContain("cam skills"); expect(readmeEn).toContain("memory-recall.sh"); + expect(readmeEn).toContain("post-work-memory-review.sh"); expect(readmeEn).toContain("limit=8"); expect(readmeEn).toContain("local bridge"); expect(readmeEn).toContain("cam mcp apply-guidance --host codex"); @@ -168,9 +170,14 @@ describe("docs contract", () => { ); expect(releaseChecklist).toContain("AGENTS.md snippet"); expect(releaseChecklist).toContain("node dist/cli.js mcp apply-guidance --host codex --json"); + expect(releaseChecklist).toContain('returns `blocked`'); expect(releaseChecklist).toContain("node dist/cli.js mcp doctor --json"); + expect(releaseChecklist).toContain("node dist/cli.js mcp doctor --host codex --json"); + expect(releaseChecklist).toContain("structured `workflowContract`"); + expect(releaseChecklist).toContain("post-work-memory-review.sh"); expect(releaseChecklist).toContain("node dist/cli.js integrations install --host codex --json"); expect(releaseChecklist).toContain("node dist/cli.js integrations apply --host codex --json"); + expect(releaseChecklist).toContain('stackAction: "blocked"'); expect(releaseChecklist).toContain("node dist/cli.js skills install --surface official-user"); expect(releaseChecklist).toContain("node dist/cli.js integrations install --host codex --skill-surface official-user --json"); expect(releaseChecklist).toContain("node dist/cli.js integrations apply --host codex --skill-surface official-user --json"); @@ -330,5 +337,7 @@ describe("docs contract", () => { expect(readmeEn).toContain("companion CLI"); expect(readme).toContain("当前主任务"); expect(readmeEn).toContain("Current priorities"); + expect(readme).toContain("workflowContract"); + expect(readmeEn).toContain("workflowContract"); }); }); diff --git a/test/hooks-command.test.ts b/test/hooks-command.test.ts index ba6478f..fba9a1d 100644 --- a/test/hooks-command.test.ts +++ b/test/hooks-command.test.ts @@ -51,6 +51,7 @@ describe("hooks command", () => { expect(result.stdout).toContain("memory-search.sh"); expect(result.stdout).toContain("memory-timeline.sh"); expect(result.stdout).toContain("memory-details.sh"); + expect(result.stdout).toContain("post-work-memory-review.sh"); expect(result.stdout).toContain("recall-bridge.md"); expect(result.stdout).toContain("search -> timeline -> details"); expect(result.stdout).toContain("read-only"); @@ -69,6 +70,10 @@ describe("hooks command", () => { const searchScript = await fs.readFile(path.join(hooksDir, "memory-search.sh"), "utf8"); const timelineScript = await fs.readFile(path.join(hooksDir, "memory-timeline.sh"), "utf8"); const detailsScript = await fs.readFile(path.join(hooksDir, "memory-details.sh"), "utf8"); + const postWorkReviewScript = await fs.readFile( + path.join(hooksDir, "post-work-memory-review.sh"), + "utf8" + ); const recallGuide = await fs.readFile(path.join(hooksDir, "recall-bridge.md"), "utf8"); expect(recallScript).toContain('exec cam recall search "$@"'); @@ -79,6 +84,8 @@ describe("hooks command", () => { expect(searchScript).toContain('exec "$SCRIPT_DIR/memory-recall.sh" search "$@"'); expect(timelineScript).toContain('exec "$SCRIPT_DIR/memory-recall.sh" timeline "$@"'); expect(detailsScript).toContain('exec "$SCRIPT_DIR/memory-recall.sh" details "$@"'); + expect(postWorkReviewScript).toContain('cam sync "$@"'); + expect(postWorkReviewScript).toContain("cam memory --recent"); expect(recallGuide).toContain("search_memories"); expect(recallGuide).toContain("memory-recall.sh search"); expect(recallGuide).toContain("cam memory"); diff --git a/test/integrations-command.test.ts b/test/integrations-command.test.ts index 691d1df..d7b5ec6 100644 --- a/test/integrations-command.test.ts +++ b/test/integrations-command.test.ts @@ -253,12 +253,20 @@ describe("integrations command", () => { const payload = JSON.parse(result.stdout) as { projectRoot: string; recommendedSkillInstallCommand: string; + workflowContract: { + cliFallback: { + searchCommand: string; + }; + }; nextSteps: string[]; }; expect(payload.projectRoot).toBe(await fs.realpath(projectDir)); expect(payload.recommendedSkillInstallCommand).toBe( `cam skills install --surface runtime --cwd ${JSON.stringify(payload.projectRoot)}` ); + expect(payload.workflowContract.cliFallback.searchCommand).toBe( + `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(payload.projectRoot)}` + ); expect(payload.nextSteps).toEqual( expect.arrayContaining([ expect.stringContaining( @@ -373,6 +381,14 @@ describe("integrations command", () => { status: "ok", recommendedRoute: "mcp", recommendedPreset: "state=auto, limit=8", + workflowContract: { + version: expect.any(String), + postWorkSyncReview: { + helperScript: "post-work-memory-review.sh", + syncCommand: "cam sync", + reviewCommand: "cam memory --recent" + } + }, subchecks: { mcp: { status: "ok" @@ -520,6 +536,58 @@ describe("integrations command", () => { ); }); + it("keeps integrations apply fail-closed for the AGENTS subaction when managed guidance is unsafe", async () => { + const homeDir = await tempDir("cam-integrations-apply-blocked-home-"); + const projectDir = await tempDir("cam-integrations-apply-blocked-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + await fs.writeFile( + path.join(realProjectDir, "AGENTS.md"), + [ + "# Project Notes", + "", + "", + "", + "- stale guidance" + ].join("\n"), + "utf8" + ); + + const before = await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8"); + const result = runCli( + projectDir, + ["integrations", "apply", "--host", "codex", "--json"], + { env: { HOME: homeDir } } + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + stackAction: "blocked", + subactions: { + mcp: { + status: "ok" + }, + agents: { + status: "blocked", + action: "blocked", + targetPath: path.join(realProjectDir, "AGENTS.md") + }, + hooks: { + status: "ok", + action: "created" + }, + skills: { + status: "ok", + action: "created", + surface: "runtime" + } + } + }); + expect(await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8")).toBe(before); + }); + it("keeps integrations install non-mutating for AGENTS.md while integrations apply writes it", async () => { const homeDir = await tempDir("cam-integrations-apply-boundary-home-"); const projectDir = await tempDir("cam-integrations-apply-boundary-project-"); diff --git a/test/mcp-command.test.ts b/test/mcp-command.test.ts index 6fa84a4..cabe6c6 100644 --- a/test/mcp-command.test.ts +++ b/test/mcp-command.test.ts @@ -1128,6 +1128,7 @@ describe("mcp command", () => { fallbackAssets: { hookHelpersInstalled: boolean; startupDoctorInstalled: boolean; + postWorkReviewInstalled: boolean; skillInstalled: boolean; fallbackAvailable: boolean; assets: Array<{ @@ -1141,6 +1142,23 @@ describe("mcp command", () => { executableOk: boolean | null; }>; }; + workflowContract: { + version: string; + recommendedPreset: string; + preferredRoute: string; + recallFirst: string; + progressiveDisclosure: string; + cliFallback: { + searchCommand: string; + timelineCommand: string; + detailsCommand: string; + }; + postWorkSyncReview: { + helperScript: string; + syncCommand: string; + reviewCommand: string; + }; + }; codexStack: { status: string; recommendedRoute: string; @@ -1175,6 +1193,7 @@ describe("mcp command", () => { expect(payload.fallbackAssets).toMatchObject({ hookHelpersInstalled: true, startupDoctorInstalled: true, + postWorkReviewInstalled: true, skillInstalled: true, fallbackAvailable: true }); @@ -1190,6 +1209,16 @@ describe("mcp command", () => { executableExpected: true, executableOk: true }), + expect.objectContaining({ + id: "post-work-memory-review", + name: "post-work-memory-review.sh", + installed: true, + status: "ok", + expectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + detectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, + executableExpected: true, + executableOk: true + }), expect.objectContaining({ name: "memory-recall.sh", installed: true, @@ -1246,6 +1275,23 @@ describe("mcp command", () => { }) ]) ); + expect(payload.workflowContract).toMatchObject({ + version: RETRIEVAL_INTEGRATION_ASSET_VERSION, + recommendedPreset: "state=auto, limit=8", + preferredRoute: "mcp-first", + recallFirst: expect.stringContaining("recall durable memory first"), + progressiveDisclosure: "Use progressive disclosure: search -> timeline -> details.", + cliFallback: { + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}`, + timelineCommand: `cam recall timeline "" --cwd ${JSON.stringify(realProjectDir)}`, + detailsCommand: `cam recall details "" --cwd ${JSON.stringify(realProjectDir)}` + }, + postWorkSyncReview: { + helperScript: "post-work-memory-review.sh", + syncCommand: `cam sync --cwd ${JSON.stringify(realProjectDir)}`, + reviewCommand: `cam memory --recent --cwd ${JSON.stringify(realProjectDir)}` + } + }); expect(payload.codexStack).toMatchObject({ status: "warning", recommendedRoute: "hooks-fallback", @@ -1255,7 +1301,7 @@ describe("mcp command", () => { hookCaptureReady: true, hookRecallReady: true, skillReady: true, - workflowConsistent: true + workflowConsistent: false }); expect(payload.agentsGuidance).toMatchObject({ path: path.join(realProjectDir, "AGENTS.md"), @@ -1478,6 +1524,7 @@ describe("mcp command", () => { const payload = JSON.parse(result.stdout) as { fallbackAssets: { hookHelpersInstalled: boolean; + postWorkReviewInstalled: boolean; startupDoctorInstalled: boolean; skillInstalled: boolean; fallbackAvailable: boolean; @@ -1492,6 +1539,7 @@ describe("mcp command", () => { }; expect(payload.fallbackAssets).toMatchObject({ hookHelpersInstalled: false, + postWorkReviewInstalled: false, startupDoctorInstalled: false, skillInstalled: false, fallbackAvailable: false @@ -1573,6 +1621,7 @@ describe("mcp command", () => { const payload = JSON.parse(result.stdout) as { fallbackAssets: { hookHelpersInstalled: boolean; + postWorkReviewInstalled: boolean; startupDoctorInstalled: boolean; skillInstalled: boolean; fallbackAvailable: boolean; @@ -1587,6 +1636,7 @@ describe("mcp command", () => { }; expect(payload.fallbackAssets).toMatchObject({ hookHelpersInstalled: false, + postWorkReviewInstalled: false, startupDoctorInstalled: false, skillInstalled: false, fallbackAvailable: false diff --git a/test/tarball-install-smoke.test.ts b/test/tarball-install-smoke.test.ts index 460c8d7..0ad1ebd 100644 --- a/test/tarball-install-smoke.test.ts +++ b/test/tarball-install-smoke.test.ts @@ -261,6 +261,20 @@ describe("tarball install smoke", () => { readOnlyRetrieval: true }); + const claudeInstallResult = runCommandCapture( + camBinaryPath(installDir), + ["mcp", "install", "--host", "claude", "--json"], + installDir, + envWithBin + ); + expect(claudeInstallResult.exitCode).toBe(0); + expect(JSON.parse(claudeInstallResult.stdout)).toMatchObject({ + host: "claude", + action: "created", + targetPath: path.join(realInstallDir, ".mcp.json"), + readOnlyRetrieval: true + }); + const hooksResult = runCommandCapture( camBinaryPath(installDir), ["hooks", "install"], @@ -271,6 +285,12 @@ describe("tarball install smoke", () => { expect( await fs.readFile(path.join(homeDir, ".codex-auto-memory", "hooks", "memory-recall.sh"), "utf8") ).toContain("cam:asset-version"); + expect( + await fs.readFile( + path.join(homeDir, ".codex-auto-memory", "hooks", "post-work-memory-review.sh"), + "utf8" + ) + ).toContain("cam memory --recent"); const skillsResult = runCommandCapture( camBinaryPath(installDir), @@ -474,6 +494,14 @@ describe("tarball install smoke", () => { status: "ok", recommendedRoute: "mcp", recommendedPreset: "state=auto, limit=8", + workflowContract: { + version: expect.any(String), + postWorkSyncReview: { + helperScript: "post-work-memory-review.sh", + syncCommand: "cam sync", + reviewCommand: "cam memory --recent" + } + }, preferredSkillSurface: "runtime", recommendedSkillInstallCommand: "cam skills install --surface runtime", installedSkillSurfaces: ["runtime", "official-user", "official-project"], @@ -488,6 +516,78 @@ describe("tarball install smoke", () => { } }); + const mcpDoctorResult = runCommandCapture( + camBinaryPath(installDir), + ["mcp", "doctor", "--host", "codex", "--json"], + installDir, + envWithBin + ); + expect(mcpDoctorResult.exitCode).toBe(0); + expect(JSON.parse(mcpDoctorResult.stdout)).toMatchObject({ + readOnlyRetrieval: true, + fallbackAssets: { + postWorkReviewInstalled: true + }, + workflowContract: { + version: expect.any(String), + postWorkSyncReview: { + helperScript: "post-work-memory-review.sh", + syncCommand: "cam sync", + reviewCommand: "cam memory --recent" + } + } + }); + + const blockedProjectDir = await tempDir("cam-tarball-blocked-project-"); + const realBlockedProjectDir = await fs.realpath(blockedProjectDir); + await fs.writeFile( + path.join(realBlockedProjectDir, "AGENTS.md"), + [ + "# Project Notes", + "", + "", + "", + "- stale guidance" + ].join("\n"), + "utf8" + ); + const blockedBefore = await fs.readFile(path.join(realBlockedProjectDir, "AGENTS.md"), "utf8"); + + const blockedApplyGuidanceResult = runCommandCapture( + camBinaryPath(installDir), + ["mcp", "apply-guidance", "--host", "codex", "--json"], + blockedProjectDir, + envWithBin + ); + expect(blockedApplyGuidanceResult.exitCode).toBe(0); + expect(JSON.parse(blockedApplyGuidanceResult.stdout)).toMatchObject({ + host: "codex", + projectRoot: realBlockedProjectDir, + action: "blocked" + }); + expect(await fs.readFile(path.join(realBlockedProjectDir, "AGENTS.md"), "utf8")).toBe( + blockedBefore + ); + + const blockedIntegrationsResult = runCommandCapture( + camBinaryPath(installDir), + ["integrations", "apply", "--host", "codex", "--json"], + blockedProjectDir, + envWithBin + ); + expect(blockedIntegrationsResult.exitCode).toBe(0); + expect(JSON.parse(blockedIntegrationsResult.stdout)).toMatchObject({ + host: "codex", + projectRoot: realBlockedProjectDir, + stackAction: "blocked", + subactions: { + agents: { + status: "blocked", + action: "blocked" + } + } + }); + const recallHelpResult = runCommandCapture( camBinaryPath(installDir), ["recall", "search", "--help"], From c0890e9304addec0b49489a14b9e7fab7c2be950 Mon Sep 17 00:00:00 2001 From: blocks Date: Thu, 26 Mar 2026 21:25:23 +0800 Subject: [PATCH 06/62] feat: clarify skill readiness and blocked apply contracts --- README.en.md | 10 +- README.ja.md | 10 +- README.md | 10 +- README.zh-TW.md | 10 +- docs/README.en.md | 4 +- docs/README.md | 4 +- docs/host-surfaces.md | 2 +- docs/integration-strategy.md | 10 +- docs/release-checklist.md | 12 +- src/lib/commands/integrations.ts | 142 ++++++- src/lib/integration/agents-guidance.ts | 137 ++++++- src/lib/integration/codex-stack.ts | 7 +- src/lib/integration/mcp-config.ts | 5 + src/lib/integration/mcp-doctor.ts | 216 +++++++++- src/lib/integration/mcp-hosts.ts | 12 + src/lib/integration/mcp-install.ts | 82 ++-- src/lib/integration/retrieval-contract.ts | 52 ++- test/dist-cli-smoke.test.ts | 207 +++++++++- test/docs-contract.test.ts | 25 ++ test/integrations-command.test.ts | 79 +++- test/mcp-command.test.ts | 455 +++++++++++++++++++++- test/tarball-install-smoke.test.ts | 248 +++++++++--- 22 files changed, 1552 insertions(+), 187 deletions(-) diff --git a/README.en.md b/README.en.md index 92a99bb..75ae0d7 100644 --- a/README.en.md +++ b/README.en.md @@ -205,12 +205,12 @@ cam audit | `cam recall search` / `timeline` / `details` | progressively retrieve durable memory through a search -> timeline -> details workflow; `search` now defaults to `state=auto, limit=8`, so active memory is checked before archived fallback while staying read-only | | `cam mcp serve` | start a read-only retrieval MCP server that exposes the same workflow through `search_memories`, `timeline_memories`, and `get_memory_details` | | `cam integrations install --host codex` | install the recommended Codex integration stack in one explicit step by writing project-scoped MCP wiring and refreshing the hook bridge bundle plus Codex skill assets; it defaults to the runtime skill target, but also accepts `--skill-surface runtime|official-user|official-project`; stays idempotent, Codex-only, and does not touch the Markdown memory store | -| `cam integrations apply --host codex` | explicitly apply the full Codex integration state: it keeps `integrations install` unchanged, but also orchestrates `cam mcp apply-guidance --host codex`; it defaults to the runtime skill target, but also accepts `--skill-surface runtime|official-user|official-project`; if `AGENTS.md` cannot be updated safely, the command returns `blocked` and preserves the additive fail-closed boundary | -| `cam integrations doctor --host codex` | inspect the current Codex integration stack through a thin read-only aggregation surface that reports the recommended route, recommended preset, structured `workflowContract`, subchecks, and minimum next steps; it now recommends `cam integrations apply --host codex` when multiple Codex stack surfaces are still missing, and keeps `cam mcp apply-guidance --host codex` as the precise next step when only the managed `AGENTS.md` block is missing or outdated | -| `cam mcp install --host ` | explicitly write the recommended project-scoped host config for `codex_auto_memory`; only that server entry is replaced, hooks/skills stay opt-in, and `generic` remains manual-only | -| `cam mcp print-config --host ` | print a ready-to-paste host snippet so the read-only retrieval plane can be wired into an existing MCP client with less manual setup; for `--host codex`, it also prints a recommended `AGENTS.md` snippet that teaches future Codex agents to prefer MCP and fall back to `cam recall` only when needed | +| `cam integrations apply --host codex` | explicitly apply the full Codex integration state: it keeps `integrations install` unchanged, but also orchestrates `cam mcp apply-guidance --host codex`; it defaults to the runtime skill target, but also accepts `--skill-surface runtime|official-user|official-project`; if the `AGENTS.md` managed block is unsafe, the command now returns a preflight `blocked` result before any stack writes happen | +| `cam integrations doctor --host codex` | inspect the current Codex integration stack through a thin read-only aggregation surface that reports the recommended route, recommended preset, structured `workflowContract`, `applyReadiness`, subchecks, and minimum next steps; when the managed `AGENTS.md` block is unsafe, it now tells you to repair that block first instead of recommending `cam integrations apply --host codex` immediately | +| `cam mcp install --host ` | explicitly write the recommended project-scoped host config for `codex_auto_memory`; only that server entry is updated, hooks/skills stay opt-in, non-canonical custom fields on that entry are preserved when safe, and `generic` remains manual-only | +| `cam mcp print-config --host ` | print a ready-to-paste host snippet so the read-only retrieval plane can be wired into an existing MCP client with less manual setup; for `--host codex`, it also prints a recommended `AGENTS.md` snippet and now includes the shared `workflowContract` in JSON output so future Codex agents can prefer MCP and fall back to `cam recall` only when needed | | `cam mcp apply-guidance --host codex` | create or update the Codex Auto Memory managed block inside the repository-level `AGENTS.md` through an additive, auditable, fail-closed flow; it only appends a new block or replaces the same marker block, and returns `blocked` if it cannot locate that block safely | -| `cam mcp doctor` | inspect the recommended project-scoped retrieval MCP wiring, project pinning, and hook/skill fallback assets; it now also adds a `codexStack` readiness summary plus a structured `workflowContract` for the recommended route, executable bits, shared asset version, and workflow consistency without modifying host config files | +| `cam mcp doctor` | inspect the recommended project-scoped retrieval MCP wiring, project pinning, and hook/skill fallback assets; it now also adds a `codexStack` readiness summary plus a structured `workflowContract` for the recommended route, executable bits, shared asset version, and workflow consistency, and reports alternate global wiring separately from the recommended project-scoped path without modifying host config files | | `cam session save` | merge / incremental save for continuity | | `cam session refresh` | replace / clean regeneration for continuity | | `cam session load` / `status` | inspect the continuity reviewer surface | diff --git a/README.ja.md b/README.ja.md index 5606183..4857c1e 100644 --- a/README.ja.md +++ b/README.ja.md @@ -199,12 +199,12 @@ cam audit | `cam recall search` / `timeline` / `details` | `search -> timeline -> details` の progressive disclosure workflow で durable memory を段階的に取得する。`search` は `state=auto, limit=8` を既定値として使い、active を先に調べてヒットしなければ archived にフォールバックしつつ read-only を保つ | | `cam mcp serve` | `search_memories` / `timeline_memories` / `get_memory_details` を通じて同じ retrieval contract を公開する read-only MCP server を起動する | | `cam integrations install --host codex` | 推奨される Codex integration stack を一度に導入し、project-scoped MCP wiring を書き込みつつ、hook bridge bundle と Codex skill assets を更新する。skills は runtime target が既定だが、`--skill-surface runtime|official-user|official-project` も指定できる。明示的・冪等・Codex-only を保ち、Markdown memory store には触れない | -| `cam integrations apply --host codex` | 明示的・冪等・Codex-only のまま完全な integration state を適用する。`integrations install` の既存境界は変えず、その上で `cam mcp apply-guidance --host codex` も編成する。skills は runtime target が既定だが、`--skill-surface runtime|official-user|official-project` も指定できる。`AGENTS.md` を安全に更新できない場合は `blocked` を返し、additive / fail-closed 境界を守る | -| `cam integrations doctor --host codex` | 現在の Codex integration stack を薄い read-only 集約面として点検し、推奨ルート、推奨 preset、構造化された `workflowContract`、サブチェック結果、次の最小アクションを返す。複数の Codex stack 面が不足しているときは `cam integrations apply --host codex` を優先し、AGENTS guidance だけが不足している場合は `cam mcp apply-guidance --host codex` を正確な次手として案内する | -| `cam mcp install --host ` | 推奨される project-scoped 宿主設定を明示的に書き込み、`codex_auto_memory` の項目だけを更新する。hooks/skills は自動導入せず、`generic` は引き続き manual-only | -| `cam mcp print-config --host ` | ready-to-paste な接続スニペットを出力し、read-only retrieval plane を既存の MCP client に低摩擦で接続できるようにする。`--host codex` の場合は、将来の Codex エージェントに MCP 優先・`cam recall` フォールバックを教えるための推奨 `AGENTS.md` snippet も合わせて出力する | +| `cam integrations apply --host codex` | 明示的・冪等・Codex-only のまま完全な integration state を適用する。`integrations install` の既存境界は変えず、その上で `cam mcp apply-guidance --host codex` も編成する。skills は runtime target が既定だが、`--skill-surface runtime|official-user|official-project` も指定できる。`AGENTS.md` managed block が unsafe な場合は、stack への書き込み前に preflight `blocked` を返す | +| `cam integrations doctor --host codex` | 現在の Codex integration stack を薄い read-only 集約面として点検し、推奨ルート、推奨 preset、構造化された `workflowContract`、`applyReadiness`、サブチェック結果、次の最小アクションを返す。`AGENTS.md` managed block が unsafe な場合は、まずその修復を案内し、すぐに `cam integrations apply --host codex` を勧めない | +| `cam mcp install --host ` | 推奨される project-scoped 宿主設定を明示的に書き込み、`codex_auto_memory` の項目だけを更新する。hooks/skills は自動導入せず、その entry に non-canonical なカスタム項目がある場合は安全な範囲で保持する。`generic` は引き続き manual-only | +| `cam mcp print-config --host ` | ready-to-paste な接続スニペットを出力し、read-only retrieval plane を既存の MCP client に低摩擦で接続できるようにする。`--host codex` の場合は、将来の Codex エージェントに MCP 優先・`cam recall` フォールバックを教えるための推奨 `AGENTS.md` snippet に加えて、JSON payload に共有 `workflowContract` も含める | | `cam mcp apply-guidance --host codex` | repo ルートの `AGENTS.md` 内にある Codex Auto Memory 管理 block を additive・監査可能・fail-closed に作成または更新する。同じ marker block の追加または置換だけを行い、安全に特定できない場合は書き換えず `blocked` を返す | -| `cam mcp doctor` | 推奨される project-scoped retrieval MCP の配線、project pinning、hook/skill fallback assets を read-only で点検し、さらに `codexStack` readiness と構造化された `workflowContract` によって推奨ルート、executable bit、共有 asset version、workflow consistency を要約する。ホスト設定は書き換えない | +| `cam mcp doctor` | 推奨される project-scoped retrieval MCP の配線、project pinning、hook/skill fallback assets を read-only で点検し、さらに `codexStack` readiness と構造化された `workflowContract` によって推奨ルート、executable bit、共有 asset version、workflow consistency を要約する。alternate global wiring が見つかった場合も、推奨される project-scoped ルートとは分けて報告し、ホスト設定は書き換えない | | `cam session save` | continuity の merge / incremental save | | `cam session refresh` | continuity の replace / clean regeneration | | `cam session load` / `status` | continuity reviewer surface を確認 | diff --git a/README.md b/README.md index adfd8f8..cc861d7 100644 --- a/README.md +++ b/README.md @@ -210,12 +210,12 @@ cam audit | `cam recall search` / `timeline` / `details` | 以 `search -> timeline -> details` 的 progressive disclosure 检索 durable memory;`search` 默认采用 `state=auto`、`limit=8`,先查 active,未命中再回退 archived,且保持只读 retrieval | | `cam mcp serve` | 启动只读 retrieval MCP server,通过 `search_memories` / `timeline_memories` / `get_memory_details` 暴露同一套渐进式检索契约 | | `cam integrations install --host codex` | 一次性安装推荐的 Codex integration stack:写入 project-scoped MCP wiring,并刷新 hook bridge bundle 与 Codex skill 资产;默认使用 runtime skills target,也支持显式 `--skill-surface runtime|official-user|official-project`;保持显式、幂等、Codex-only,且不触碰 Markdown memory store | -| `cam integrations apply --host codex` | 以显式、幂等、Codex-only 的方式应用完整 integration state:在保留 `integrations install` 旧语义不变的前提下,额外编排 `cam mcp apply-guidance --host codex`;默认使用 runtime skills target,也支持显式 `--skill-surface runtime|official-user|official-project`;若 `AGENTS.md` 无法安全更新,会返回 `blocked` 并保持 additive / fail-closed | -| `cam integrations doctor --host codex` | 以 Codex-only、只读、薄聚合的方式汇总当前 integration stack readiness,直接给出推荐路由、推荐 preset、结构化 `workflowContract`、子检查结果与下一步最小动作;当缺多个子检查时会优先推荐 `cam integrations apply --host codex`,若只缺 AGENTS guidance 则继续精确指向 `cam mcp apply-guidance --host codex`,不会改写宿主配置或 Markdown memory store | -| `cam mcp install --host ` | 显式写入推荐的 project-scoped 宿主 MCP 配置;只更新 `codex_auto_memory` 这一项,不会自动安装 hooks/skills;`generic` 继续保持 manual-only | -| `cam mcp print-config --host ` | 打印 ready-to-paste 的宿主接入片段,降低把 read-only retrieval plane 接进现有工作流的摩擦;其中 `--host codex` 还会额外打印推荐的 `AGENTS.md` snippet,帮助未来 Codex 代理优先走 MCP、必要时再 fallback 到 `cam recall` | +| `cam integrations apply --host codex` | 以显式、幂等、Codex-only 的方式应用完整 integration state:在保留 `integrations install` 旧语义不变的前提下,额外编排 `cam mcp apply-guidance --host codex`;默认使用 runtime skills target,也支持显式 `--skill-surface runtime|official-user|official-project`;若 `AGENTS.md` managed block 不安全,会在任何 stack 写入之前 preflight `blocked`,保持 additive / fail-closed | +| `cam integrations doctor --host codex` | 以 Codex-only、只读、薄聚合的方式汇总当前 integration stack readiness,直接给出推荐路由、推荐 preset、结构化 `workflowContract`、`applyReadiness`、子检查结果与下一步最小动作;当 AGENTS guidance 处于 unsafe managed-block 状态时,会先提示修复 `AGENTS.md`,而不是直接推荐 `cam integrations apply --host codex` | +| `cam mcp install --host ` | 显式写入推荐的 project-scoped 宿主 MCP 配置;只更新 `codex_auto_memory` 这一项,不会自动安装 hooks/skills;若该 entry 已带有非 canonical 自定义字段,会在安全前提下保留它们;`generic` 继续保持 manual-only | +| `cam mcp print-config --host ` | 打印 ready-to-paste 的宿主接入片段,降低把 read-only retrieval plane 接进现有工作流的摩擦;其中 `--host codex` 还会额外打印推荐的 `AGENTS.md` snippet,并在 JSON payload 中附带共享 `workflowContract`,帮助未来 Codex 代理优先走 MCP、必要时再 fallback 到 `cam recall` | | `cam mcp apply-guidance --host codex` | 以 additive、可审计、fail-closed 的方式创建或更新仓库根 `AGENTS.md` 中由 Codex Auto Memory 自己管理的 guidance block;只会 append 新 block 或替换同一 marker block,无法安全定位时返回 `blocked` 而不会冒险改写 | -| `cam mcp doctor` | 只读检查当前项目的 retrieval MCP 接入状态、project pinning 与 hook/skill fallback 资产;同时追加 `codexStack` readiness 视图与结构化 `workflowContract`,用于汇总推荐路由、executable bit、共享资产版本与 workflow consistency,不会改写任何宿主配置 | +| `cam mcp doctor` | 只读检查当前项目的 retrieval MCP 接入状态、project pinning 与 hook/skill fallback 资产;同时追加 `codexStack` readiness 视图与结构化 `workflowContract`,用于汇总推荐路由、executable bit、共享资产版本与 workflow consistency;若检测到 alternate global wiring,也会与推荐的 project-scoped 路径明确区分,不会改写任何宿主配置 | | `cam session save` | merge / incremental save;从 rollout 增量写入 continuity | | `cam session refresh` | replace / clean regeneration;从选定 provenance 重建 continuity | | `cam session load` / `status` | 查看 continuity reviewer surface 与 diagnostics | diff --git a/README.zh-TW.md b/README.zh-TW.md index d2bb166..306d4c0 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -201,12 +201,12 @@ cam audit | `cam recall search` / `timeline` / `details` | 以 `search -> timeline -> details` 的 progressive disclosure 工作流檢索 durable memory;`search` 現在預設採用 `state=auto`、`limit=8`,會先查 active,未命中再回退 archived,且保持只讀 retrieval | | `cam mcp serve` | 啟動只讀 retrieval MCP server,以 `search_memories` / `timeline_memories` / `get_memory_details` 暴露同一套漸進式檢索契約 | | `cam integrations install --host codex` | 一次性安裝推薦的 Codex integration stack:寫入 project-scoped MCP wiring,並刷新 hook bridge bundle 與 Codex skill 資產;預設使用 runtime skills target,也支援顯式 `--skill-surface runtime|official-user|official-project`;保持顯式、幂等、Codex-only,且不碰 Markdown memory store | -| `cam integrations apply --host codex` | 以顯式、幂等、Codex-only 的方式套用完整 integration state:在保留 `integrations install` 舊語義不變的前提下,額外編排 `cam mcp apply-guidance --host codex`;預設使用 runtime skills target,也支援顯式 `--skill-surface runtime|official-user|official-project`;若 `AGENTS.md` 無法安全更新,會回傳 `blocked` 並維持 additive / fail-closed 邊界 | -| `cam integrations doctor --host codex` | 以 Codex-only、只讀、薄聚合的方式彙總目前 integration stack readiness,直接給出推薦路由、推薦 preset、結構化 `workflowContract`、子檢查結果與下一步最小動作;當缺多個子檢查時會優先推薦 `cam integrations apply --host codex`,若只缺 AGENTS guidance 則繼續精準指向 `cam mcp apply-guidance --host codex`,不會改寫宿主設定或 Markdown memory store | -| `cam mcp install --host ` | 顯式寫入推薦的 project-scoped 宿主 MCP 配置;只更新 `codex_auto_memory` 這一項,不會自動安裝 hooks/skills;`generic` 仍維持 manual-only | -| `cam mcp print-config --host ` | 列印 ready-to-paste 的宿主接入片段,降低把 read-only retrieval plane 接進既有 MCP workflow 的手動成本;其中 `--host codex` 還會額外列印推薦的 `AGENTS.md` snippet,幫助未來 Codex 代理優先走 MCP、必要時再 fallback 到 `cam recall` | +| `cam integrations apply --host codex` | 以顯式、幂等、Codex-only 的方式套用完整 integration state:在保留 `integrations install` 舊語義不變的前提下,額外編排 `cam mcp apply-guidance --host codex`;預設使用 runtime skills target,也支援顯式 `--skill-surface runtime|official-user|official-project`;若 `AGENTS.md` managed block 不安全,現在會在任何 stack 寫入前 preflight `blocked` | +| `cam integrations doctor --host codex` | 以 Codex-only、只讀、薄聚合的方式彙總目前 integration stack readiness,直接給出推薦路由、推薦 preset、結構化 `workflowContract`、`applyReadiness`、子檢查結果與下一步最小動作;若 `AGENTS.md` managed block 處於 unsafe 狀態,會先提示修復它,而不是直接推薦 `cam integrations apply --host codex` | +| `cam mcp install --host ` | 顯式寫入推薦的 project-scoped 宿主 MCP 配置;只更新 `codex_auto_memory` 這一項,不會自動安裝 hooks/skills;若該 entry 已帶有非 canonical 自訂欄位,會在安全前提下保留它們;`generic` 仍維持 manual-only | +| `cam mcp print-config --host ` | 列印 ready-to-paste 的宿主接入片段,降低把 read-only retrieval plane 接進既有 MCP workflow 的手動成本;其中 `--host codex` 還會額外列印推薦的 `AGENTS.md` snippet,並在 JSON payload 中附帶共享 `workflowContract`,幫助未來 Codex 代理優先走 MCP、必要時再 fallback 到 `cam recall` | | `cam mcp apply-guidance --host codex` | 以 additive、可審計、fail-closed 的方式建立或更新 repo 根 `AGENTS.md` 中由 Codex Auto Memory 自己管理的 guidance block;只會 append 新 block 或替換同一 marker block,若無法安全定位則回傳 `blocked` 而不會冒險改寫 | -| `cam mcp doctor` | 只讀檢查目前專案的 retrieval MCP 接線、project pinning 與 hook/skill fallback assets;現在也會追加 `codexStack` readiness 視圖與結構化 `workflowContract`,用來彙總推薦路由、executable bit、共享資產版本與 workflow consistency,不會改寫任何宿主設定 | +| `cam mcp doctor` | 只讀檢查目前專案的 retrieval MCP 接線、project pinning 與 hook/skill fallback assets;現在也會追加 `codexStack` readiness 視圖與結構化 `workflowContract`,用來彙總推薦路由、executable bit、共享資產版本與 workflow consistency;若偵測到 alternate global wiring,也會與推薦的 project-scoped 路徑明確區分,不會改寫任何宿主設定 | | `cam session save` | merge / incremental save;增量寫入 continuity | | `cam session refresh` | replace / clean regeneration;重建 continuity | | `cam session load` / `status` | continuity reviewer surface | diff --git a/docs/README.en.md b/docs/README.en.md index a55fb37..0135ccf 100644 --- a/docs/README.en.md +++ b/docs/README.en.md @@ -57,8 +57,8 @@ - core product boundaries belong in the README and architecture docs - claim-sensitive wording must stay aligned with official public documentation - the repository now documents both present behavior and deliberate evolution toward hook, skill, and MCP-aware surfaces -- the latest low-friction MCP wiring surface is now layered: `cam mcp install` writes the recommended project-scoped host config, `cam mcp print-config` and `cam mcp doctor` stay print-only and inspect-only, and `cam mcp apply-guidance --host codex` manages the repository-level `AGENTS.md` guidance block through an additive fail-closed flow -- `cam integrations install --host codex` orchestrates MCP wiring plus hook and skill assets, `cam integrations apply --host codex` adds the managed `AGENTS.md` guidance flow on top, and `cam integrations doctor --host codex` remains the thin read-only readiness view +- the latest low-friction MCP wiring surface is now layered: `cam mcp install` writes the recommended project-scoped host config while preserving non-canonical custom fields on the `codex_auto_memory` entry when safe, `cam mcp print-config` and `cam mcp doctor` stay print-only and inspect-only, and `cam mcp apply-guidance --host codex` manages the repository-level `AGENTS.md` guidance block through an additive fail-closed flow +- `cam integrations install --host codex` orchestrates MCP wiring plus hook and skill assets, `cam integrations apply --host codex` adds the managed `AGENTS.md` guidance flow on top but now performs an AGENTS safety preflight before any writes, and `cam integrations doctor --host codex` remains the thin read-only readiness view with `workflowContract` and `applyReadiness`; `cam mcp doctor` also reports alternate global wiring separately from the recommended project-scoped route - `cam skills install` now has three public surfaces: `runtime`, `official-user`, and `official-project`; runtime stays the default target, while the official `.agents/skills` copies remain explicit opt-in installs - the `generic` host remains manual-only: it is supported by `cam mcp print-config --host generic`, but intentionally rejected by `cam mcp install --host generic` - `cam recall search` now defaults to the active-first, archived-fallback read-only retrieval path with `state=auto, limit=8` diff --git a/docs/README.md b/docs/README.md index 5d82ae9..d77d7af 100644 --- a/docs/README.md +++ b/docs/README.md @@ -59,8 +59,8 @@ - issue 中的 4 项核心能力:自动提取、自动召回、更新/去重/覆盖/归档、降低手动维护成本 3. **方向上为什么要补 hook / skill / MCP** - 因为当前仓库不再只服务显式 CLI 用户,而是也面向希望让代理自己自动使用记忆能力的用户 - - 当前最新的低摩擦接入面已经分层:`cam mcp install` 负责显式写入 project-scoped host config,`cam mcp print-config` / `cam mcp doctor` 继续负责只打印 / 只检查,`cam mcp apply-guidance --host codex` 负责以 additive、fail-closed 的方式管理仓库级 `AGENTS.md` guidance block - - `cam integrations install --host codex` 负责编排 MCP wiring、hook bridge bundle 与 skill assets;`cam integrations apply --host codex` 在此基础上额外编排 managed `AGENTS.md` guidance;`cam integrations doctor --host codex` 则只读汇总推荐路由、推荐 preset、subchecks 与 next steps + - 当前最新的低摩擦接入面已经分层:`cam mcp install` 负责显式写入 project-scoped host config,并在安全前提下保留 `codex_auto_memory` entry 上的非 canonical 自定义字段;`cam mcp print-config` / `cam mcp doctor` 继续负责只打印 / 只检查,`cam mcp apply-guidance --host codex` 负责以 additive、fail-closed 的方式管理仓库级 `AGENTS.md` guidance block + - `cam integrations install --host codex` 负责编排 MCP wiring、hook bridge bundle 与 skill assets;`cam integrations apply --host codex` 在此基础上额外编排 managed `AGENTS.md` guidance,并在写入前先做 AGENTS safety preflight;`cam integrations doctor --host codex` 则只读汇总推荐路由、推荐 preset、`workflowContract`、`applyReadiness`、subchecks 与 next steps;`cam mcp doctor` 也会把 alternate global wiring 与推荐的 project-scoped 路径分开表达 - `cam skills install` 的公开 skill surface 现在固定为 `runtime|official-user|official-project`;其中 runtime 仍是默认 target,官方 `.agents/skills` 路径保持显式 opt-in - `generic` host 仍然保持 manual-only:不支持 `cam mcp install --host generic`,但继续支持 `cam mcp print-config --host generic` - `cam recall search` 现在默认补上了 active-first、archived-fallback 的只读 retrieval 搜索面,并对齐 `state=auto`、`limit=8` diff --git a/docs/host-surfaces.md b/docs/host-surfaces.md index a060058..ea072d8 100644 --- a/docs/host-surfaces.md +++ b/docs/host-surfaces.md @@ -95,7 +95,7 @@ - Gemini 的 extension + hooks + MCP 思路 - OpenCode 的 plugin + MCP + AGENTS 能力面 - OpenClaw 的“统一 memory core,不统一格式”思路 -- 针对宿主差异提供清晰分层的接入面:`cam mcp install` 负责显式写入 project-scoped host config,`cam mcp print-config` / `cam mcp doctor` 继续负责只打印 / 只检查,`cam mcp apply-guidance --host codex` 负责 additive 管理 repo 级 `AGENTS.md` guidance block,`cam integrations install --host codex` 负责编排不改写 `AGENTS.md` 的 stack install,`cam integrations apply --host codex` 负责显式收口整套 Codex stack apply,而 `cam integrations doctor --host codex` 负责只读汇总 readiness;其中 skills 默认仍安装到 runtime target,但 `cam skills install --surface runtime|official-user|official-project` 与 `cam integrations install/apply --skill-surface ...` 已为官方 `.agents/skills` 路径准备显式 opt-in 兼容面;shell fallback 仍由 `cam hooks install` 提供;这条 hooks 线是本仓自带的 local bridge,不是官方 Codex hook surface +- 针对宿主差异提供清晰分层的接入面:`cam mcp install` 负责显式写入 project-scoped host config,并在安全前提下保留 `codex_auto_memory` entry 上的非 canonical 自定义字段;`cam mcp print-config` / `cam mcp doctor` 继续负责只打印 / 只检查,其中 `doctor` 现在会把 alternate global wiring 与推荐的 project-scoped 路径分开表达;`cam mcp apply-guidance --host codex` 负责 additive 管理 repo 级 `AGENTS.md` guidance block,`cam integrations install --host codex` 负责编排不改写 `AGENTS.md` 的 stack install,`cam integrations apply --host codex` 负责显式收口整套 Codex stack apply,但现在会先做 AGENTS safety preflight;`cam integrations doctor --host codex` 继续只读汇总 readiness,并通过 `applyReadiness` 区分“可以直接 apply”与“必须先修复 managed block”;其中 skills 默认仍安装到 runtime target,但 `cam skills install --surface runtime|official-user|official-project` 与 `cam integrations install/apply --skill-surface ...` 已为官方 `.agents/skills` 路径准备显式 opt-in 兼容面;shell fallback 仍由 `cam hooks install` 提供;这条 hooks 线是本仓自带的 local bridge,不是官方 Codex hook surface - release-facing `--help` 文案也视为宿主能力面的稳定公开接口,必须和上述 install / apply / doctor / manual-only 边界保持一致 不应该吸收: diff --git a/docs/integration-strategy.md b/docs/integration-strategy.md index 2ecb439..e0cc0ba 100644 --- a/docs/integration-strategy.md +++ b/docs/integration-strategy.md @@ -83,14 +83,14 @@ - `cam mcp serve` 会暴露 `search_memories`、`timeline_memories`、`get_memory_details` - `search_memories` 与 `cam recall search` 现在共享 active-first、archived-fallback 的默认检索语义 - 当前推荐的渐进式检索 preset 统一为:`state=auto`、`limit=8` -- `cam mcp install --host ` 会显式写入推荐的 project-scoped 宿主配置,继续降低接线摩擦,但不改变 retrieval 的只读语义 +- `cam mcp install --host ` 会显式写入推荐的 project-scoped 宿主配置,继续降低接线摩擦,但不改变 retrieval 的只读语义;若已有 `codex_auto_memory` entry 带有非 canonical 自定义字段,会在安全前提下保留它们 - `generic` host 仍然保持 manual-only:不提供自动写入的 install 分支,只通过 `cam mcp print-config --host generic` 暴露 ready-to-paste snippet -- `cam mcp print-config --host ...` 会打印 ready-to-paste 宿主接入片段;其中 `--host codex` 现在还会额外打印推荐的 `AGENTS.md` snippet,把 durable memory workflow 正式接到 Codex 当前公开稳定 surface 上 +- `cam mcp print-config --host ...` 会打印 ready-to-paste 宿主接入片段;其中 `--host codex` 现在还会额外打印推荐的 `AGENTS.md` snippet,并在 JSON 输出里附带共享 `workflowContract`,把 durable memory workflow 正式接到 Codex 当前公开稳定 surface 上 - `cam mcp apply-guidance --host codex` 会以 additive、可审计、fail-closed 的方式创建或更新 repo 根 `AGENTS.md` 中由本仓维护的 guidance block,继续降低手工粘贴成本 - `cam integrations install --host codex` 现在提供显式的一次性 stack install 入口:统一编排 project-scoped MCP wiring、hooks 与 skills,但不触碰 `AGENTS.md` -- `cam integrations apply --host codex` 现在提供显式的一次性 Codex stack apply 入口:在不改变 `integrations install` 边界的前提下,额外统一编排 managed `AGENTS.md` guidance block;其中 skills 默认仍走 runtime target,但也支持显式 `--skill-surface runtime|official-user|official-project` -- `cam mcp doctor` 会只读检查推荐的 project-scoped MCP 接线、project pinning 与 shared fallback bridge assets -- `cam integrations doctor --host codex` 会只读汇总推荐路由、推荐 preset、subchecks 与 next steps,继续保持 inspect-only 边界 +- `cam integrations apply --host codex` 现在提供显式的一次性 Codex stack apply 入口:在不改变 `integrations install` 边界的前提下,额外统一编排 managed `AGENTS.md` guidance block;其中 skills 默认仍走 runtime target,但也支持显式 `--skill-surface runtime|official-user|official-project`;若 `AGENTS.md` managed block unsafe,会在任何 stack 写入之前 preflight `blocked` +- `cam mcp doctor` 会只读检查推荐的 project-scoped MCP 接线、project pinning 与 shared fallback bridge assets;若检测到 alternate global wiring,也会继续强调“推荐路径未完成”与“已存在非推荐路径”是两回事 +- `cam integrations doctor --host codex` 会只读汇总推荐路由、推荐 preset、结构化 `workflowContract`、`applyReadiness`、subchecks 与 next steps;当 `AGENTS.md` managed block unsafe 时,会先提示修复该 block,而不是直接推荐 `cam integrations apply --host codex` - 它仍然是只读 retrieval plane,不是新的 canonical store 目标状态: diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 770b7b2..5be5d56 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -48,20 +48,26 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Run a local MCP smoke against `node dist/cli.js mcp serve` and confirm `search_memories`, `timeline_memories`, and `get_memory_details` are exposed as a read-only retrieval plane. - Run `node dist/cli.js mcp install --host --json` and confirm the result contract includes `host`, `serverName`, `projectRoot`, `targetPath`, `action`, `projectPinned`, and `readOnlyRetrieval`. - Re-run the same `node dist/cli.js mcp install --host --json` command once and confirm it returns `action: "unchanged"` when the target host config is already canonical. +- Confirm `node dist/cli.js mcp install --host --json` preserves non-canonical custom fields already attached to the `codex_auto_memory` entry instead of dropping them silently. +- Confirm `node dist/cli.js mcp install --host --json` now reports `preservedCustomFields`, so the machine-readable contract matches the human-readable notes about retained custom fields. - Confirm `node dist/cli.js mcp install --host generic` fails explicitly and still points users to manual wiring. - Run `node dist/cli.js mcp print-config --host --json` for each public host and confirm the snippet contract includes `serverName`, `targetFileHint`, and a project-pinned retrieval command without writing host config files. -- For `node dist/cli.js mcp print-config --host codex --json`, also confirm the payload includes an additive AGENTS.md snippet / guidance block that teaches MCP-first, `cam recall`-fallback durable memory usage. +- For `node dist/cli.js mcp print-config --host codex --json`, also confirm the payload includes an additive AGENTS.md snippet / guidance block plus the shared `workflowContract`, so the retrieval workflow contract is identical between print-config, doctor, and integrations doctor. - Run `node dist/cli.js mcp apply-guidance --host codex --json` and confirm it reports `created`, `updated`, `unchanged`, or `blocked` without overwriting unrelated AGENTS.md content outside the managed block. - Confirm `node dist/cli.js mcp apply-guidance --host codex --json` still returns `blocked` for malformed or unsafe managed-block shapes while leaving `AGENTS.md` byte-for-byte unchanged. - Run `node dist/cli.js mcp apply-guidance --host codex --cwd --json` from another working directory and confirm the managed AGENTS block is written inside the targeted project root. - Confirm `node dist/cli.js mcp apply-guidance --host codex --json` ignores fenced-code examples of the managed markers, and that `node dist/cli.js mcp doctor --json` does not treat fenced examples as installed guidance. - Run `node dist/cli.js mcp doctor --json` and confirm it reports project-scoped host wiring, project pinning, and hook / skill fallback assets without creating memory layout or mutating host config files. - Run `node dist/cli.js mcp doctor --host codex --json` and confirm the payload also exposes the structured `workflowContract`, including the current CLI fallback commands and post-work sync/review helper contract. +- Confirm `node dist/cli.js mcp doctor --host codex --json` distinguishes alternate global wiring from the recommended project-scoped route through additive scope/reporting fields instead of treating them as the same readiness state. +- Confirm `node dist/cli.js mcp doctor --host codex --json` now exposes `configScopeSummary` and `alternateWiring`, so valid alternate global wiring stays distinct from malformed or shape-mismatched global host config. +- Confirm `node dist/cli.js mcp doctor --host codex --json` distinguishes skill-surface presence, canonical content, and readiness through additive fields such as `runtimeSkillPresent`, `officialUserSkillMatchesCanonical`, `officialProjectSkillMatchesCanonical`, `anySkillSurfaceInstalled`, and `anySkillSurfaceReady`. - Confirm `node dist/cli.js hooks install` writes `post-work-memory-review.sh`, and that the generated helper still runs `cam sync` followed by `cam memory --recent`. - Run `node dist/cli.js skills install --surface official-project --cwd ` from another working directory and confirm the explicit project-scoped `.agents/skills` copy is written inside the targeted repository. - Run `node dist/cli.js integrations install --host codex --json` and confirm it orchestrates the existing Codex MCP wiring, hook bundle, and skill assets without touching the Markdown memory store. - Run `node dist/cli.js integrations apply --host codex --json` and confirm it orchestrates MCP wiring, managed AGENTS guidance, hook assets, and skill assets while keeping `integrations install --host codex` non-mutating for AGENTS.md. -- Confirm `node dist/cli.js integrations apply --host codex --json` still returns `stackAction: "blocked"` when the AGENTS managed block is unsafe, while preserving the AGENTS file content and still reporting the blocked subaction explicitly. +- Confirm `node dist/cli.js integrations apply --host codex --json` still returns `stackAction: "blocked"` when the AGENTS managed block is unsafe, and now also reports the preflight early-block shape (`preflightBlocked`, `blockedStage`, per-subaction `attempted`) while preserving the AGENTS file content and skipping all other stack writes. +- Confirm the same blocked `node dist/cli.js integrations apply --host codex --json` payload marks skipped subactions explicitly so machine consumers can distinguish “not attempted due to preflight block” from “ran successfully”. - Run `node dist/cli.js integrations apply --host codex --cwd --json` from another working directory and confirm the stack still project-pins all subactions to the targeted repository. - Run `node dist/cli.js skills install --surface official-user` and confirm the explicit official `.agents/skills` copy is written without changing the runtime default target. - Run `node dist/cli.js integrations install --host codex --skill-surface official-user --json` and confirm the skill subaction reports the selected surface while MCP and AGENTS boundaries stay unchanged. @@ -70,7 +76,7 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Run `node dist/cli.js integrations install --host codex --skill-surface official-project --json` and confirm the skill subaction reports the selected project-scoped surface while MCP and AGENTS boundaries stay unchanged. - Run `node dist/cli.js integrations apply --host codex --skill-surface official-project --json` and confirm the selected project-scoped skill surface still flows through the full apply path. - Run `node dist/cli.js integrations doctor --host codex --json` and confirm it reports the thin Codex-only stack readiness view with `recommendedRoute`, `recommendedPreset`, `subchecks`, and `nextSteps`. -- Confirm `node dist/cli.js integrations doctor --host codex --json` also exposes the shared structured `workflowContract`, including the post-work sync/review helper semantics. +- Confirm `node dist/cli.js integrations doctor --host codex --json` also exposes the shared structured `workflowContract`, including the post-work sync/review helper semantics, and now reports `applyReadiness` so unsafe AGENTS managed blocks are diagnosed before recommending `cam integrations apply --host codex`. - Treat key `--help` output as release-facing contract, not incidental CLI text: - `node dist/cli.js mcp install --help` should keep the supported install-host list at `codex, claude, or gemini`, leaving `generic` out of the install branch. - `node dist/cli.js mcp print-config --help` should keep the supported snippet-host list at `codex, claude, gemini, or generic`. diff --git a/src/lib/commands/integrations.ts b/src/lib/commands/integrations.ts index 071bf13..25adee7 100644 --- a/src/lib/commands/integrations.ts +++ b/src/lib/commands/integrations.ts @@ -10,7 +10,10 @@ import { summarizeCodexIntegrationStatus, type CodexIntegrationStatus } from "../integration/codex-stack.js"; -import { applyCodexAgentsGuidance } from "../integration/agents-guidance.js"; +import { + applyCodexAgentsGuidance, + inspectCodexAgentsGuidanceApplySafety +} from "../integration/agents-guidance.js"; import { installIntegrationAssets } from "../integration/install-assets.js"; import { installMcpProjectConfig, type McpInstallResult } from "../integration/mcp-install.js"; import { normalizeMcpHost } from "../integration/mcp-config.js"; @@ -50,6 +53,9 @@ interface IntegrationsDoctorOptions { interface IntegrationSubactionResult { status: IntegrationSubactionStatus; action: IntegrationStackAction; + attempted?: boolean; + skipped?: boolean; + skipReason?: string; targetPath?: string; targetDir?: string; surface?: CodexSkillInstallSurface; @@ -76,6 +82,8 @@ interface IntegrationStackApplyResult { host: "codex"; projectRoot: string; stackAction: IntegrationStackAction; + preflightBlocked?: boolean; + blockedStage?: "agents-guidance-preflight"; skillsSurface: CodexSkillInstallSurface; readOnlyRetrieval: true; subactions: { @@ -100,6 +108,11 @@ interface IntegrationDoctorResult { recommendedRoute: McpDoctorReport["codexStack"]["recommendedRoute"]; recommendedPreset: string; workflowContract: McpDoctorReport["workflowContract"]; + applyReadiness: { + status: "safe" | "blocked"; + reason?: string; + recommendedFix?: string; + }; preferredSkillSurface: CodexSkillInstallSurface; recommendedSkillInstallCommand: string; installedSkillSurfaces: CodexSkillInstallSurface[]; @@ -170,7 +183,7 @@ function normalizeIntegrationsHost( function formatIntegrationApplyHeadline(action: IntegrationStackAction): string { if (action === "blocked") { - return "Codex integration apply was partially blocked."; + return "Codex integration apply was blocked."; } return formatIntegrationActionHeadline(action, "Codex integration apply"); @@ -189,6 +202,7 @@ function buildIntegrationsDoctorResult( explicitCwd?: boolean; } = {} ): IntegrationDoctorResult { + const pinnedProjectRoot = options.explicitCwd ? report.projectRoot : undefined; const codexHost = report.hosts.find((host) => host.host === "codex"); if (!codexHost) { throw new Error("Codex host inspection is required for integrations doctor."); @@ -249,6 +263,21 @@ function buildIntegrationsDoctorResult( ...buildCodexStackNotes(), "AGENTS guidance is inspected read-only and is never auto-written by integrations doctor." ]; + const applySafetyStatus = report.applySafety.status; + const applyReadiness = + applySafetyStatus === "blocked" + ? { + status: "blocked" as const, + reason: report.applySafety.blockedReason, + recommendedFix: + `Repair the existing AGENTS.md managed guidance block so its markers are balanced outside fenced code blocks, then re-run ${appendCliCwdFlag( + "cam mcp apply-guidance --host codex", + pinnedProjectRoot + )}.` + } + : { + status: "safe" as const + }; const nextSteps = buildCodexIntegrationNextSteps({ mcpReady: report.codexStack.mcpReady, mcpOperationalReady: report.codexStack.mcpOperationalReady, @@ -259,7 +288,7 @@ function buildIntegrationsDoctorResult( workflowConsistent: report.codexStack.workflowConsistent }, { skillInstallCommand: report.fallbackAssets.recommendedSkillInstallCommand, - projectRoot: options.explicitCwd ? report.projectRoot : undefined + projectRoot: pinnedProjectRoot }); const needsAgents = report.agentsGuidance.status !== "ok"; const needsOtherStackSurface = @@ -267,18 +296,20 @@ function buildIntegrationsDoctorResult( !report.codexStack.hookCaptureReady || !report.codexStack.hookRecallReady || !report.codexStack.skillReady; - if (needsAgents && needsOtherStackSurface) { + if (applyReadiness.status === "blocked") { + nextSteps.unshift(applyReadiness.recommendedFix); + } else if (needsAgents && needsOtherStackSurface) { nextSteps.unshift( - `Run \`${appendCliCwdFlag( - `cam integrations apply --host codex --skill-surface ${report.fallbackAssets.preferredInstallSurface}`, - options.explicitCwd ? report.projectRoot : undefined - )}\` to install project-scoped MCP wiring, refresh hook and skill assets, and safely apply the managed Codex Auto Memory AGENTS.md block in one step.` + `Run \`${appendCliCwdFlag( + `cam integrations apply --host codex --skill-surface ${report.fallbackAssets.preferredInstallSurface}`, + pinnedProjectRoot + )}\` to install project-scoped MCP wiring, refresh hook and skill assets, and safely apply the managed Codex Auto Memory AGENTS.md block in one step.` ); } else if (needsAgents) { nextSteps.push( `Run \`${appendCliCwdFlag( "cam mcp apply-guidance --host codex", - options.explicitCwd ? report.projectRoot : undefined + pinnedProjectRoot )}\` to create or update the managed Codex Auto Memory block in the repository-level AGENTS.md.` ); } @@ -291,6 +322,7 @@ function buildIntegrationsDoctorResult( recommendedRoute: report.codexStack.recommendedRoute, recommendedPreset: report.codexStack.preset, workflowContract: report.workflowContract, + applyReadiness, preferredSkillSurface: report.fallbackAssets.preferredInstallSurface, recommendedSkillInstallCommand: report.fallbackAssets.recommendedSkillInstallCommand, installedSkillSurfaces: [...report.fallbackAssets.installedSkillSurfaces], @@ -310,6 +342,7 @@ function formatIntegrationsDoctorResult(result: IntegrationDoctorResult): string `Status: ${result.status}`, `Recommended route: ${result.recommendedRoute}`, `Recommended preset: ${result.recommendedPreset}`, + `Apply readiness: ${result.applyReadiness.status}${result.applyReadiness.reason ? ` (${result.applyReadiness.reason})` : ""}`, `Preferred skill surface: ${formatCodexSkillInstallSurface(result.preferredSkillSurface)}`, `Recommended skill install command: ${result.recommendedSkillInstallCommand}`, `Installed skill surfaces: ${result.installedSkillSurfaces.length > 0 ? result.installedSkillSurfaces.join(", ") : "none"}`, @@ -412,6 +445,79 @@ export async function runIntegrationsApply( const projectRoot = resolveMcpProjectRoot(options.cwd); const skillSurface = normalizeCodexSkillInstallSurface(options.skillSurface); + const applySafety = await inspectCodexAgentsGuidanceApplySafety(projectRoot); + if (applySafety.status === "blocked") { + const skipReason = + "Skipped because integrations apply was blocked during AGENTS guidance preflight."; + const result: IntegrationStackApplyResult = { + host: "codex", + projectRoot, + stackAction: "blocked", + preflightBlocked: true, + blockedStage: "agents-guidance-preflight", + skillsSurface: skillSurface, + readOnlyRetrieval: true, + subactions: { + mcp: { + status: "ok", + action: "unchanged", + attempted: false, + skipped: true, + skipReason, + readOnlyRetrieval: true, + notes: [skipReason] + }, + agents: { + status: "blocked", + action: "blocked", + attempted: true, + targetPath: applySafety.targetPath, + readOnlyRetrieval: true, + notes: [...applySafety.notes] + }, + hooks: { + status: "ok", + action: "unchanged", + attempted: false, + skipped: true, + skipReason, + readOnlyRetrieval: true, + notes: [skipReason] + }, + skills: { + status: "ok", + action: "unchanged", + attempted: false, + skipped: true, + skipReason, + targetDir: undefined, + surface: skillSurface, + readOnlyRetrieval: true, + notes: [skipReason] + } + }, + notes: [ + "This orchestration surface is Codex-only and explicit.", + "Integrations apply was blocked during AGENTS guidance preflight, so no project-scoped MCP wiring, hook assets, or skill assets were written.", + ...(applySafety.blockedReason ? [`Reason: ${applySafety.blockedReason}`] : []) + ] + }; + + if (options.json) { + return JSON.stringify(result, null, 2); + } + + return [ + formatIntegrationApplyHeadline(result.stackAction), + `Host: ${result.host}`, + `Project root: ${result.projectRoot}`, + `Stack action: ${result.stackAction}`, + "Blocked stage: agents-guidance-preflight", + "", + "Notes:", + ...result.notes.map((note) => `- ${note}`) + ].join("\n"); + } const mcpResult = await installMcpProjectConfig("codex", projectRoot); const agentsResult = await applyCodexAgentsGuidance(projectRoot); const hooksResult = await installIntegrationAssets("hooks", { @@ -432,12 +538,16 @@ export async function runIntegrationsApply( hooksResult.action, skillsResult.action ]), - readOnlyRetrieval: true, - subactions: { - mcp: toMcpSubaction(mcpResult), - agents: { - status: agentsResult.action === "blocked" ? "blocked" : "ok", - action: agentsResult.action, + readOnlyRetrieval: true, + subactions: { + mcp: { + ...toMcpSubaction(mcpResult), + attempted: true + }, + agents: { + status: agentsResult.action === "blocked" ? "blocked" : "ok", + action: agentsResult.action, + attempted: true, targetPath: agentsResult.targetPath, readOnlyRetrieval: true, notes: [...agentsResult.notes] @@ -445,6 +555,7 @@ export async function runIntegrationsApply( hooks: { status: "ok", action: hooksResult.action, + attempted: true, targetDir: hooksResult.targetDir, readOnlyRetrieval: hooksResult.readOnlyRetrieval, notes: [...hooksResult.notes] @@ -452,6 +563,7 @@ export async function runIntegrationsApply( skills: { status: "ok", action: skillsResult.action, + attempted: true, targetDir: skillsResult.targetDir, surface: skillSurface, readOnlyRetrieval: skillsResult.readOnlyRetrieval, diff --git a/src/lib/integration/agents-guidance.ts b/src/lib/integration/agents-guidance.ts index c13e097..b591120 100644 --- a/src/lib/integration/agents-guidance.ts +++ b/src/lib/integration/agents-guidance.ts @@ -7,6 +7,17 @@ import { import { fileExists, readTextFile, writeTextFileAtomic } from "../util/fs.js"; export type CodexAgentsGuidanceApplyAction = "created" | "updated" | "unchanged" | "blocked"; +export type CodexAgentsGuidanceApplySafetyStatus = "safe" | "blocked"; + +export interface CodexAgentsGuidanceApplySafetyResult { + host: "codex"; + projectRoot: string; + targetPath: string; + status: CodexAgentsGuidanceApplySafetyStatus; + blockedReason?: string; + recommendedAction: "create" | "append" | "replace" | "unchanged" | "blocked"; + notes: string[]; +} export interface CodexAgentsGuidanceApplyResult { host: "codex"; @@ -63,15 +74,111 @@ function buildNotes(): string[] { ]; } -export async function applyCodexAgentsGuidance( +interface CodexAgentsGuidanceApplyInspection { + targetPath: string; + notes: string[]; + exists: boolean; + currentContents: string | null; + managedBlock: string; + lineEnding: "\n" | "\r\n" | "\r"; + unsafeManagedBlock: boolean; + unsafeReason?: string; + hasManagedBlock: boolean; + alreadyCurrent: boolean; + managedBlockRange: { startIndex: number; endIndex: number } | null; +} + +async function inspectCodexAgentsGuidanceApply( projectRoot: string -): Promise { +): Promise { const targetPath = path.join(projectRoot, "AGENTS.md"); const notes = buildNotes(); const exists = await fileExists(targetPath); if (!exists) { - await writeTextFileAtomic(targetPath, `${buildCodexAgentsManagedBlock()}\n`); + return { + targetPath, + notes, + exists, + currentContents: null, + managedBlock: buildCodexAgentsManagedBlock(), + lineEnding: "\n", + unsafeManagedBlock: false, + hasManagedBlock: false, + alreadyCurrent: false, + managedBlockRange: null + }; + } + + const currentContents = await readTextFile(targetPath); + const parsed = parseCodexAgentsGuidanceContents(currentContents); + const managedBlock = buildCodexAgentsManagedBlock(parsed.lineEnding); + const alreadyCurrent = + parsed.managedBlock !== null && + normalizeManagedBlockForComparison(parsed.managedBlock.contents) === + normalizeManagedBlockForComparison(managedBlock); + + return { + targetPath, + notes, + exists, + currentContents, + managedBlock, + lineEnding: parsed.lineEnding, + unsafeManagedBlock: parsed.unsafeManagedBlock, + unsafeReason: parsed.unsafeReason, + hasManagedBlock: parsed.managedBlock !== null, + alreadyCurrent, + managedBlockRange: parsed.managedBlock + ? { + startIndex: parsed.managedBlock.startIndex, + endIndex: parsed.managedBlock.endIndex + } + : null + }; +} + +export async function inspectCodexAgentsGuidanceApplySafety( + projectRoot: string +): Promise { + const inspection = await inspectCodexAgentsGuidanceApply(projectRoot); + + if (inspection.unsafeManagedBlock) { + return { + host: "codex", + projectRoot, + targetPath: inspection.targetPath, + status: "blocked", + blockedReason: inspection.unsafeReason, + recommendedAction: "blocked", + notes: inspection.notes + }; + } + + return { + host: "codex", + projectRoot, + targetPath: inspection.targetPath, + status: "safe", + recommendedAction: !inspection.exists + ? "create" + : !inspection.hasManagedBlock + ? "append" + : inspection.alreadyCurrent + ? "unchanged" + : "replace", + notes: inspection.notes + }; +} + +export async function applyCodexAgentsGuidance( + projectRoot: string +): Promise { + const inspection = await inspectCodexAgentsGuidanceApply(projectRoot); + const { targetPath, notes } = inspection; + + if (!inspection.exists) { + await writeTextFileAtomic(targetPath, `${inspection.managedBlock}\n`); return { host: "codex", projectRoot, @@ -83,11 +190,7 @@ export async function applyCodexAgentsGuidance( }; } - const currentContents = await readTextFile(targetPath); - const parsed = parseCodexAgentsGuidanceContents(currentContents); - const managedBlock = buildCodexAgentsManagedBlock(parsed.lineEnding); - - if (parsed.unsafeManagedBlock) { + if (inspection.unsafeManagedBlock) { return { host: "codex", projectRoot, @@ -95,15 +198,15 @@ export async function applyCodexAgentsGuidance( action: "blocked", managedBlockVersion: CODEX_AGENTS_GUIDANCE_VERSION, createdFile: false, - blockedReason: parsed.unsafeReason, + blockedReason: inspection.unsafeReason, notes }; } - if (!parsed.managedBlock) { + if (!inspection.hasManagedBlock) { await writeTextFileAtomic( targetPath, - appendManagedBlock(currentContents, managedBlock, parsed.lineEnding) + appendManagedBlock(inspection.currentContents ?? "", inspection.managedBlock, inspection.lineEnding) ); return { host: "codex", @@ -116,11 +219,7 @@ export async function applyCodexAgentsGuidance( }; } - const currentBlock = parsed.managedBlock.contents; - if ( - normalizeManagedBlockForComparison(currentBlock) === - normalizeManagedBlockForComparison(managedBlock) - ) { + if (inspection.alreadyCurrent) { return { host: "codex", projectRoot, @@ -134,7 +233,11 @@ export async function applyCodexAgentsGuidance( await writeTextFileAtomic( targetPath, - replaceManagedBlock(currentContents, parsed.managedBlock, managedBlock) + replaceManagedBlock( + inspection.currentContents ?? "", + inspection.managedBlockRange!, + inspection.managedBlock + ) ); return { host: "codex", diff --git a/src/lib/integration/codex-stack.ts b/src/lib/integration/codex-stack.ts index d2a0696..ae62bb4 100644 --- a/src/lib/integration/codex-stack.ts +++ b/src/lib/integration/codex-stack.ts @@ -352,13 +352,14 @@ export function summarizeCodexIntegrationStatus( } export function buildCodexStackNotes(): string[] { + const workflowContract = buildWorkflowContract(); return [ READ_ONLY_RETRIEVAL_NOTE, LOCAL_BRIDGE_BUNDLE_NOTE, "Recommended route prefers project-scoped MCP, then local bridge recall helpers, then direct cam recall CLI usage.", - `Recommended retrieval preset: ${formatRecommendedRetrievalPreset()}.`, + `Recommended retrieval preset: ${workflowContract.recommendedPreset}.`, ...buildSharedWorkflowDisciplineLines().slice(2), - `When the local bridge bundle is installed, prefer \`post-work-memory-review.sh\` to combine \`${buildPostWorkSyncCommand()}\` with \`${buildPostWorkRecentReviewCommand()}\`.`, + `When the local bridge bundle is installed, prefer \`${workflowContract.postWorkSyncReview.helperScript}\` to combine \`${buildPostWorkSyncCommand()}\` with \`${buildPostWorkRecentReviewCommand()}\`.`, "Run `cam mcp print-config --host codex` to inspect the recommended project-scoped MCP wiring together with an AGENTS.md snippet for Codex agents.", "Run `cam mcp apply-guidance --host codex` to create or update the managed Codex Auto Memory block inside the repository-level AGENTS.md.", "Codex skill readiness is guidance-only and does not replace executable hook fallback helpers.", @@ -372,7 +373,7 @@ export function buildCodexAgentsGuidance(): CodexAgentsGuidance { "## Codex Auto Memory", "", ``, - `- When durable memory may help, prefer the retrieval MCP workflow: \`${RETRIEVAL_MCP_SEARCH_TOOL}\` -> \`${RETRIEVAL_MCP_TIMELINE_TOOL}\` -> \`${RETRIEVAL_MCP_DETAILS_TOOL}\`.`, + `- ${workflowContract.routePreference.mcpFirst}`, `- ${buildRecommendedMcpSearchInstruction()}`, `- If the retrieval MCP server is unavailable, fall back to \`${buildRecommendedCliSearchCommand()}\`, then \`cam recall timeline \"\"\`, then \`cam recall details \"\"\`.`, ...buildSharedWorkflowDisciplineLines().slice(2).map((line) => `- ${line}`), diff --git a/src/lib/integration/mcp-config.ts b/src/lib/integration/mcp-config.ts index bffb208..fe8c7ef 100644 --- a/src/lib/integration/mcp-config.ts +++ b/src/lib/integration/mcp-config.ts @@ -5,6 +5,7 @@ import { READ_ONLY_RETRIEVAL_NOTE, type CodexAgentsGuidance } from "./codex-stack.js"; +import { buildWorkflowContract } from "./retrieval-contract.js"; import { buildMcpHostSnippet, getMcpHostDefinition, @@ -25,6 +26,7 @@ export interface McpHostConfigSnippet { snippetFormat: "toml" | "json"; snippet: string; notes: string[]; + workflowContract: ReturnType; agentsGuidance?: CodexAgentsGuidance; } @@ -46,6 +48,9 @@ export function buildMcpHostConfigSnippet(host: McpHost, projectRoot: string): M snippetFormat: definition.snippetFormat, snippet: buildMcpHostSnippet(host, projectRoot), notes: [...definition.notes], + workflowContract: buildWorkflowContract({ + cwd: projectRoot + }), ...(host === "codex" ? { agentsGuidance: buildCodexAgentsGuidance() } : {}) }; } diff --git a/src/lib/integration/mcp-doctor.ts b/src/lib/integration/mcp-doctor.ts index 28182c9..844587f 100644 --- a/src/lib/integration/mcp-doctor.ts +++ b/src/lib/integration/mcp-doctor.ts @@ -15,6 +15,7 @@ import { type CodexAgentsGuidanceInspection, type CodexIntegrationRoute } from "./codex-stack.js"; +import { inspectCodexAgentsGuidanceApplySafety } from "./agents-guidance.js"; import { appendCliCwdFlag, buildWorkflowContract, @@ -29,6 +30,7 @@ import { MEMORY_RETRIEVAL_MCP_SERVER_NAME, normalizeMcpDoctorHostSelection, resolveMcpHostProjectConfigPath, + resolveMcpHostUserConfigPath, type McpDoctorHostSelection, type McpHost, type McpHostPinningMode @@ -44,8 +46,19 @@ import { resolveMcpProjectRoot } from "./mcp-config.js"; type McpDoctorStatus = "ok" | "warning" | "missing" | "manual"; type McpDoctorConfigInspection = "ok" | "missing" | "parse-error" | "shape-mismatch"; +type McpDoctorConfigScope = "project" | "global"; +type McpDoctorRecommendedScope = "project" | "manual"; +type McpDoctorConfigScopeSummary = + | "manual-only" + | "project-ready" + | "project-invalid" + | "project-shape-mismatch" + | "project-missing" + | "project-missing-global-alternate" + | "project-missing-global-invalid"; interface McpDoctorConfigCheck { + scope: McpDoctorConfigScope; path: string; exists: boolean; hasServerName: boolean; @@ -54,6 +67,19 @@ interface McpDoctorConfigCheck { projectPinned: boolean; } +interface McpDoctorConfigIssue { + scope: McpDoctorConfigScope; + path: string; + inspection: Exclude; +} + +interface McpDoctorAlternateWiring { + detected: boolean; + valid: boolean; + scopes: McpDoctorConfigScope[]; + issues: McpDoctorConfigIssue[]; +} + interface McpDoctorConfigInspectionResult { inspection: McpDoctorConfigInspection; configCheck?: McpDoctorConfigCheck; @@ -64,9 +90,14 @@ interface McpDoctorHostReport { status: McpDoctorStatus; targetFileHint: string; pinning: McpHostPinningMode; + recommendedScope: McpDoctorRecommendedScope; + configScopeSummary: McpDoctorConfigScopeSummary; + detectedScopes: McpDoctorConfigScope[]; + alternateWiring: McpDoctorAlternateWiring; summary: string; notes: string[]; configCheck?: McpDoctorConfigCheck; + alternateConfigChecks?: McpDoctorConfigCheck[]; } interface McpDoctorAssetCheck { @@ -89,6 +120,7 @@ function hasExpectedAssetSignatures(contents: string, expectedSignatures: string interface SkillSurfaceInspection { installed: boolean; + contents: string | null; matchesCanonical: boolean; ready: boolean; } @@ -102,6 +134,7 @@ async function inspectSkillSurfaceFile( if (!installed) { return { installed, + contents: null, matchesCanonical: false, ready: false }; @@ -111,6 +144,7 @@ async function inspectSkillSurfaceFile( const matchesCanonical = contents === canonicalContents; return { installed, + contents, matchesCanonical, ready: matchesCanonical && @@ -128,15 +162,20 @@ interface McpDoctorFallbackAssets { recommendedSkillInstallCommand: string; officialUserSkillDir: string; officialProjectSkillDir: string; + runtimeSkillPresent: boolean; runtimeSkillInstalled: boolean; officialUserSkillInstalled: boolean; officialProjectSkillInstalled: boolean; runtimeSkillMatchesCanonical: boolean; + officialUserSkillMatchesCanonical: boolean; + officialProjectSkillMatchesCanonical: boolean; officialUserSkillMatchesRuntime: boolean; officialProjectSkillMatchesRuntime: boolean; runtimeSkillReady: boolean; officialUserSkillReady: boolean; officialProjectSkillReady: boolean; + anySkillSurfaceInstalled: boolean; + anySkillSurfaceReady: boolean; installedSkillSurfaces: CodexSkillInstallSurface[]; readySkillSurfaces: CodexSkillInstallSurface[]; skillPathDrift: boolean; @@ -166,6 +205,7 @@ export interface McpDoctorReport { doctor: true; }; agentsGuidance: CodexAgentsGuidanceInspection; + applySafety: Awaited>; fallbackAssets: McpDoctorFallbackAssets; workflowContract: ReturnType; hosts: McpDoctorHostReport[]; @@ -256,9 +296,10 @@ function formatPinning(pinning: McpHostPinningMode): string { async function inspectHostConfig( host: McpHost, - projectRoot: string + projectRoot: string, + configPath: string | null, + scope: McpDoctorConfigScope ): Promise { - const configPath = resolveMcpHostProjectConfigPath(host, projectRoot); if (!configPath) { return { inspection: "missing" @@ -270,6 +311,7 @@ async function inspectHostConfig( return { inspection: "missing", configCheck: { + scope, path: configPath, exists: false, hasServerName: false, @@ -289,6 +331,7 @@ async function inspectHostConfig( return { inspection: "parse-error", configCheck: { + scope, path: configPath, exists: true, hasServerName: false, @@ -314,6 +357,7 @@ async function inspectHostConfig( ? "ok" : "shape-mismatch", configCheck: { + scope, path: configPath, exists: true, ...configCheck @@ -328,6 +372,7 @@ async function inspectHostConfig( return { inspection: "parse-error", configCheck: { + scope, path: configPath, exists: true, hasServerName: false, @@ -358,6 +403,7 @@ async function inspectHostConfig( ? "ok" : "shape-mismatch", configCheck: { + scope, path: configPath, exists: true, ...configCheck @@ -365,23 +411,72 @@ async function inspectHostConfig( }; } -function summarizeHostReport( - host: McpHost, +function hasDetectedWiring(inspectionResult: McpDoctorConfigInspectionResult): boolean { + return inspectionResult.inspection === "ok"; +} + +function buildConfigIssue( inspectionResult: McpDoctorConfigInspectionResult -): Pick { +): McpDoctorConfigIssue | null { + const configCheck = inspectionResult.configCheck; + if ( + !configCheck || + inspectionResult.inspection === "ok" || + inspectionResult.inspection === "missing" + ) { + return null; + } + + return { + scope: configCheck.scope, + path: configCheck.path, + inspection: inspectionResult.inspection + }; +} + +function summarizeHostReport( + inspectionResult: McpDoctorConfigInspectionResult, + alternateWiring: McpDoctorAlternateWiring +): Pick { const { inspection, configCheck } = inspectionResult; + const hasValidGlobalAlternative = alternateWiring.valid; + const hasGlobalIssues = alternateWiring.issues.length > 0; if (!configCheck) { return { status: "manual", + configScopeSummary: "manual-only", + recommendedScope: "manual", summary: "This host has no single project-scoped config file to inspect. Use the printed snippet and verify the wiring manually." }; } if (inspection === "missing" || !configCheck.exists) { + if (hasValidGlobalAlternative) { + return { + status: "warning", + configScopeSummary: "project-missing-global-alternate", + recommendedScope: "project", + summary: + "The recommended project-scoped config file does not exist yet, but alternate global wiring was detected." + }; + } + + if (hasGlobalIssues) { + return { + status: "warning", + configScopeSummary: "project-missing-global-invalid", + recommendedScope: "project", + summary: + "The recommended project-scoped config file does not exist yet, and the detected global host config could not be parsed or did not match the expected codex_auto_memory wiring." + }; + } + return { status: "missing", + configScopeSummary: "project-missing", + recommendedScope: "project", summary: "The recommended project-scoped config file does not exist yet." }; } @@ -389,6 +484,8 @@ function summarizeHostReport( if (inspection === "parse-error") { return { status: "warning", + configScopeSummary: "project-invalid", + recommendedScope: "project", summary: "A project-scoped config file exists, but it could not be parsed as valid host configuration." }; @@ -400,6 +497,8 @@ function summarizeHostReport( ) { return { status: "warning", + configScopeSummary: "project-shape-mismatch", + recommendedScope: "project", summary: "A project-scoped config file exists, but the expected codex_auto_memory stdio wiring was not detected completely." }; @@ -408,6 +507,8 @@ function summarizeHostReport( if (!configCheck.projectPinned) { return { status: "warning", + configScopeSummary: "project-shape-mismatch", + recommendedScope: "project", summary: "The config looks wired, but the retrieval server is not clearly pinned to this project root yet." }; @@ -415,23 +516,60 @@ function summarizeHostReport( return { status: "ok", + configScopeSummary: "project-ready", + recommendedScope: "project", summary: "The recommended project-scoped wiring looks present and pinned to this repository." }; } async function inspectHost(host: McpHost, projectRoot: string): Promise { const definition = getMcpHostDefinition(host); - const inspectionResult = await inspectHostConfig(host, projectRoot); - const summary = summarizeHostReport(host, inspectionResult); + const inspectionResult = await inspectHostConfig( + host, + projectRoot, + resolveMcpHostProjectConfigPath(host, projectRoot), + "project" + ); + const alternateInspectionResults = await Promise.all( + [resolveMcpHostUserConfigPath(host)] + .filter((configPath): configPath is string => Boolean(configPath)) + .map((configPath) => inspectHostConfig(host, projectRoot, configPath, "global")) + ); + const alternateConfigChecks = alternateInspectionResults + .filter((result) => result.inspection === "ok") + .flatMap((result) => (result.configCheck ? [result.configCheck] : [])); + const alternateWiring: McpDoctorAlternateWiring = { + detected: alternateConfigChecks.length > 0, + valid: alternateConfigChecks.length > 0, + scopes: [...new Set(alternateConfigChecks.map((check) => check.scope))], + issues: alternateInspectionResults + .map((result) => buildConfigIssue(result)) + .filter((issue): issue is McpDoctorConfigIssue => issue !== null) + }; + const detectedScopes: McpDoctorConfigScope[] = []; + if (hasDetectedWiring(inspectionResult)) { + detectedScopes.push("project"); + } + for (const scope of alternateWiring.scopes) { + if (!detectedScopes.includes(scope)) { + detectedScopes.push(scope); + } + } + const summary = summarizeHostReport(inspectionResult, alternateWiring); return { host, status: summary.status, targetFileHint: definition.targetFileHint, pinning: definition.pinning, + recommendedScope: summary.recommendedScope, + configScopeSummary: summary.configScopeSummary, + detectedScopes, + alternateWiring, summary: summary.summary, notes: [...definition.notes], - configCheck: inspectionResult.configCheck + configCheck: inspectionResult.configCheck, + alternateConfigChecks }; } @@ -512,6 +650,7 @@ async function inspectFallbackAssets( const runtimeSkillContents = runtimeSkillInstalled ? await readTextFile(path.join(skillDir, "SKILL.md")) : null; + const runtimeSkillPresent = runtimeSkillContents !== null; const runtimeSkillMatchesCanonical = runtimeSkillContents !== null && runtimeSkillContents === canonicalSkillContents; const officialUserSkillInspection = await inspectSkillSurfaceFile( @@ -542,6 +681,8 @@ async function inspectFallbackAssets( if (officialProjectSkillInspection.ready) { readySkillSurfaces.push("official-project"); } + const anySkillSurfaceInstalled = installedSkillSurfaces.length > 0; + const anySkillSurfaceReady = readySkillSurfaces.length > 0; return { hooksDir: hooksDir ? path.dirname(hooksDir) : "", @@ -556,17 +697,24 @@ async function inspectFallbackAssets( projectRoot ) : buildCodexSkillInstallCommand(skillPaths.preferredInstallSurface), + runtimeSkillPresent, officialUserSkillDir, officialProjectSkillDir, runtimeSkillInstalled, officialUserSkillInstalled: officialUserSkillInspection.installed, officialProjectSkillInstalled: officialProjectSkillInspection.installed, runtimeSkillMatchesCanonical, - officialUserSkillMatchesRuntime: officialUserSkillInspection.matchesCanonical, - officialProjectSkillMatchesRuntime: officialProjectSkillInspection.matchesCanonical, + officialUserSkillMatchesCanonical: officialUserSkillInspection.matchesCanonical, + officialProjectSkillMatchesCanonical: officialProjectSkillInspection.matchesCanonical, + officialUserSkillMatchesRuntime: + runtimeSkillContents !== null && officialUserSkillInspection.contents === runtimeSkillContents, + officialProjectSkillMatchesRuntime: + runtimeSkillContents !== null && officialProjectSkillInspection.contents === runtimeSkillContents, runtimeSkillReady, officialUserSkillReady: officialUserSkillInspection.ready, officialProjectSkillReady: officialProjectSkillInspection.ready, + anySkillSurfaceInstalled, + anySkillSurfaceReady, installedSkillSurfaces, readySkillSurfaces, skillPathDrift: @@ -577,10 +725,10 @@ async function inspectFallbackAssets( captureHelpersInstalled: postSessionSyncInstalled && Boolean(startupDoctorInstalled), hookHelpersInstalled, startupDoctorInstalled, - skillInstalled: readySkillSurfaces.length > 0, + skillInstalled: anySkillSurfaceReady, shellFallbackAvailable: hookHelpersInstalled, - guidanceAvailable: readySkillSurfaces.length > 0, - fallbackAvailable: hookHelpersInstalled || readySkillSurfaces.length > 0, + guidanceAvailable: anySkillSurfaceReady, + fallbackAvailable: hookHelpersInstalled || anySkillSurfaceReady, assets }; } @@ -675,6 +823,7 @@ export async function inspectMcpDoctor(options: { agentsGuidancePath, (await fileExists(agentsGuidancePath)) ? await readTextFile(agentsGuidancePath) : null ); + const applySafety = await inspectCodexAgentsGuidanceApplySafety(projectRoot); const workflowContract = buildWorkflowContract({ cwd: options.explicitCwd ? projectRoot : undefined }); @@ -693,6 +842,7 @@ export async function inspectMcpDoctor(options: { doctor: true }, agentsGuidance, + applySafety, fallbackAssets, workflowContract, hosts, @@ -723,6 +873,9 @@ export function formatMcpDoctorReport(report: McpDoctorReport): string { `- [${host.status}] ${host.host}`, ` Target file hint: ${host.targetFileHint}`, ` Project pinning: ${formatPinning(host.pinning)}`, + ` Recommended scope: ${host.recommendedScope}`, + ` Config scope summary: ${host.configScopeSummary}`, + ` Detected scopes: ${host.detectedScopes.length > 0 ? host.detectedScopes.join(", ") : "none"}`, ` Summary: ${host.summary}` ); @@ -737,12 +890,40 @@ export function formatMcpDoctorReport(report: McpDoctorReport): string { ); } + for (const alternateConfigCheck of host.alternateConfigChecks ?? []) { + lines.push( + ` Alternate ${alternateConfigCheck.scope} config path: ${alternateConfigCheck.path}`, + ` Alternate ${alternateConfigCheck.scope} exists: ${alternateConfigCheck.exists ? "yes" : "no"}`, + ` Alternate ${alternateConfigCheck.scope} contains server name: ${alternateConfigCheck.hasServerName ? "yes" : "no"}`, + ` Alternate ${alternateConfigCheck.scope} contains cam command: ${alternateConfigCheck.hasCamCommand ? "yes" : "no"}`, + ` Alternate ${alternateConfigCheck.scope} contains mcp serve invocation: ${alternateConfigCheck.hasServeInvocation ? "yes" : "no"}`, + ` Alternate ${alternateConfigCheck.scope} project pinned: ${alternateConfigCheck.projectPinned ? "yes" : "no"}` + ); + } + + lines.push( + ` Alternate wiring detected: ${host.alternateWiring.detected ? "yes" : "no"}`, + ` Alternate wiring valid: ${host.alternateWiring.valid ? "yes" : "no"}`, + ` Alternate wiring scopes: ${host.alternateWiring.scopes.length > 0 ? host.alternateWiring.scopes.join(", ") : "none"}` + ); + for (const issue of host.alternateWiring.issues) { + lines.push( + ` Alternate ${issue.scope} issue: ${issue.inspection} (${issue.path})` + ); + } + for (const note of host.notes) { lines.push(` Note: ${note}`); } } - lines.push("", "Fallback assets:"); + lines.push( + "", + "Apply safety:", + `- AGENTS managed-block apply safety: ${report.applySafety.status}${report.applySafety.blockedReason ? ` (${report.applySafety.blockedReason})` : ""}`, + "", + "Fallback assets:" + ); for (const asset of report.fallbackAssets.assets) { const versionInfo = asset.status === "ok" @@ -761,7 +942,9 @@ export function formatMcpDoctorReport(report: McpDoctorReport): string { `- Capture helpers installed: ${report.fallbackAssets.captureHelpersInstalled ? "yes" : "no"}`, `- Hook helpers installed: ${report.fallbackAssets.hookHelpersInstalled ? "yes" : "no"}`, `- Startup doctor installed: ${report.fallbackAssets.startupDoctorInstalled ? "yes" : "no"}`, - `- Codex skill installed: ${report.fallbackAssets.skillInstalled ? "yes" : "no"}`, + `- Any skill surface installed: ${report.fallbackAssets.anySkillSurfaceInstalled ? "yes" : "no"}`, + `- Any skill surface ready: ${report.fallbackAssets.anySkillSurfaceReady ? "yes" : "no"}`, + `- Legacy skillInstalled compatibility flag: ${report.fallbackAssets.skillInstalled ? "yes" : "no"}`, `- Shell fallback available: ${report.fallbackAssets.shellFallbackAvailable ? "yes" : "no"}`, `- Guidance available: ${report.fallbackAssets.guidanceAvailable ? "yes" : "no"}`, `- Retrieval fallback available: ${report.fallbackAssets.fallbackAvailable ? "yes" : "no"}`, @@ -770,6 +953,7 @@ export function formatMcpDoctorReport(report: McpDoctorReport): string { `- Runtime source: ${report.fallbackAssets.runtimeSource}`, `- Preferred skill surface: ${report.fallbackAssets.preferredInstallSurface}`, `- Recommended skill install command: ${report.fallbackAssets.recommendedSkillInstallCommand}`, + `- Runtime skill present: ${report.fallbackAssets.runtimeSkillPresent ? "yes" : "no"}`, `- Runtime skill installed: ${report.fallbackAssets.runtimeSkillInstalled ? "yes" : "no"}`, `- Runtime skill matches canonical: ${report.fallbackAssets.runtimeSkillMatchesCanonical ? "yes" : "no"}`, `- Runtime skill ready: ${report.fallbackAssets.runtimeSkillReady ? "yes" : "no"}`, @@ -777,6 +961,8 @@ export function formatMcpDoctorReport(report: McpDoctorReport): string { `- Official project skill dir: ${report.fallbackAssets.officialProjectSkillDir}`, `- Official user skill installed: ${report.fallbackAssets.officialUserSkillInstalled ? "yes" : "no"}`, `- Official project skill installed: ${report.fallbackAssets.officialProjectSkillInstalled ? "yes" : "no"}`, + `- Official user skill matches canonical: ${report.fallbackAssets.officialUserSkillMatchesCanonical ? "yes" : "no"}`, + `- Official project skill matches canonical: ${report.fallbackAssets.officialProjectSkillMatchesCanonical ? "yes" : "no"}`, `- Official user skill matches runtime: ${report.fallbackAssets.officialUserSkillMatchesRuntime ? "yes" : "no"}`, `- Official project skill matches runtime: ${report.fallbackAssets.officialProjectSkillMatchesRuntime ? "yes" : "no"}`, `- Official user skill ready: ${report.fallbackAssets.officialUserSkillReady ? "yes" : "no"}`, diff --git a/src/lib/integration/mcp-hosts.ts b/src/lib/integration/mcp-hosts.ts index 1d5e088..6045f4b 100644 --- a/src/lib/integration/mcp-hosts.ts +++ b/src/lib/integration/mcp-hosts.ts @@ -1,3 +1,4 @@ +import os from "node:os"; import fs from "node:fs/promises"; import path from "node:path"; @@ -19,6 +20,7 @@ export interface McpHostDefinition { snippetFormat: "toml" | "json"; pinning: McpHostPinningMode; projectConfigRelativePath?: string; + userConfigHomeRelativePath?: string; notes: string[]; } @@ -54,6 +56,7 @@ const HOST_DEFINITIONS: Record = { snippetFormat: "toml", pinning: "cwd-field", projectConfigRelativePath: path.join(".codex", "config.toml"), + userConfigHomeRelativePath: path.join(".codex", "config.toml"), notes: [ "Paste this into a project-scoped .codex/config.toml file. ~/.codex/config.toml also works if you want the same server across repositories.", "This MCP surface only exposes search_memories, timeline_memories, and get_memory_details." @@ -76,6 +79,7 @@ const HOST_DEFINITIONS: Record = { snippetFormat: "json", pinning: "cwd-field", projectConfigRelativePath: path.join(".gemini", "settings.json"), + userConfigHomeRelativePath: path.join(".gemini", "settings.json"), notes: [ "Paste this into .gemini/settings.json or ~/.gemini/settings.json.", "The snippet leaves trust set to false so tool confirmations stay host-controlled." @@ -209,6 +213,14 @@ export function resolveMcpHostProjectConfigPath( return relativePath ? path.join(projectRoot, relativePath) : null; } +export function resolveMcpHostUserConfigPath( + host: McpHost, + homeDir = os.homedir() +): string | null { + const relativePath = getMcpHostDefinition(host).userConfigHomeRelativePath; + return relativePath ? path.join(homeDir, relativePath) : null; +} + export function buildCanonicalMcpServerConfig( host: Exclude, projectRoot: string diff --git a/src/lib/integration/mcp-install.ts b/src/lib/integration/mcp-install.ts index 21da5ce..64173f8 100644 --- a/src/lib/integration/mcp-install.ts +++ b/src/lib/integration/mcp-install.ts @@ -6,7 +6,8 @@ import { buildCanonicalMcpServerConfig, MEMORY_RETRIEVAL_MCP_SERVER_NAME, resolveMcpHostProjectConfigPath, - type McpHost + type McpHost, + type McpServerConfigShape } from "./mcp-hosts.js"; export interface McpInstallResult { @@ -17,6 +18,7 @@ export interface McpInstallResult { action: "created" | "updated" | "unchanged"; projectPinned: true; readOnlyRetrieval: true; + preservedCustomFields: string[]; notes: string[]; } @@ -24,13 +26,7 @@ interface RecordLike { [key: string]: unknown; } -interface McpServerRecord extends RecordLike { - command: string; - args: string[]; - cwd?: string; - env?: Record; - trust?: boolean; -} +const MANAGED_MCP_SERVER_KEYS = new Set(["command", "args", "cwd", "env", "trust"]); const PROJECT_SCOPED_PINNING_NOTE = "This install is project-scoped and keeps codex_auto_memory pinned to the current project root."; @@ -45,25 +41,6 @@ function isRecordLike(value: unknown): value is RecordLike { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } -function isStringArray(value: unknown): value is string[] { - return Array.isArray(value) && value.every((item) => typeof item === "string"); -} - -function isStringRecord(value: unknown): value is Record { - return isRecordLike(value) && Object.values(value).every((item) => typeof item === "string"); -} - -function isMcpServerRecord(value: unknown): value is McpServerRecord { - return ( - isRecordLike(value) && - typeof value.command === "string" && - isStringArray(value.args) && - (value.cwd === undefined || typeof value.cwd === "string") && - (value.env === undefined || isStringRecord(value.env)) && - (value.trust === undefined || typeof value.trust === "boolean") - ); -} - function deepEqual(left: unknown, right: unknown): boolean { if (left === right) { return true; @@ -107,16 +84,47 @@ function ensureRecordProperty( return current; } -function buildInstallNotes(): string[] { +function buildInstallNotes(preservedCustomFields: string[] = []): string[] { return [ READ_ONLY_RETRIEVAL_NOTE, PROJECT_SCOPED_PINNING_NOTE, SINGLE_ENTRY_NOTE, HOOKS_FALLBACK_NOTE, - SKILLS_FALLBACK_NOTE + SKILLS_FALLBACK_NOTE, + ...(preservedCustomFields.length > 0 + ? [ + `Preserved non-canonical fields on the existing codex_auto_memory entry: ${preservedCustomFields.join(", ")}.` + ] + : []) ]; } +function buildInstalledServerRecord( + existingServer: unknown, + canonicalServer: McpServerConfigShape +): { nextServer: RecordLike; preservedCustomFields: string[] } { + if (!isRecordLike(existingServer)) { + return { + nextServer: { + ...canonicalServer + }, + preservedCustomFields: [] + }; + } + + const preservedEntries = Object.entries(existingServer).filter( + ([key]) => !MANAGED_MCP_SERVER_KEYS.has(key) + ); + + return { + nextServer: { + ...Object.fromEntries(preservedEntries), + ...canonicalServer + }, + preservedCustomFields: preservedEntries.map(([key]) => key).sort() + }; +} + async function writeConfigIfChanged( targetPath: string, nextContents: string, @@ -146,15 +154,16 @@ async function installCodexProjectConfig(projectRoot: string): Promise { it("prints host MCP config snippets from the compiled cli entrypoint", async () => { const homeDir = await tempDir("cam-dist-mcp-print-home-"); const projectDir = await tempDir("cam-dist-mcp-print-project-"); + const realProjectDir = await fs.realpath(projectDir); const result = runCli(projectDir, ["mcp", "print-config", "--host", "codex", "--json"], { entrypoint: "dist", @@ -280,17 +281,45 @@ describe("dist cli smoke", () => { }); expect(result.exitCode, result.stderr).toBe(0); - expect(JSON.parse(result.stdout)).toMatchObject({ + const payload = JSON.parse(result.stdout) as { + host: string; + serverName: string; + targetFileHint: string; + workflowContract: { + recommendedPreset: string; + routePreference: { + preferredRoute: string; + }; + cliFallback: { + searchCommand: string; + }; + }; + agentsGuidance: { + targetFileHint: string; + snippetFormat: string; + snippet: string; + }; + }; + expect(payload).toMatchObject({ host: "codex", serverName: "codex_auto_memory", targetFileHint: ".codex/config.toml", + workflowContract: { + recommendedPreset: "state=auto, limit=8", + routePreference: { + preferredRoute: "mcp-first" + }, + cliFallback: { + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + } + }, agentsGuidance: { targetFileHint: "AGENTS.md", snippetFormat: "markdown" } }); - expect(JSON.parse(result.stdout).agentsGuidance.snippet).toContain("search_memories"); - expect(JSON.parse(result.stdout).agentsGuidance.snippet).toContain("cam recall search"); + expect(payload.agentsGuidance.snippet).toContain("search_memories"); + expect(payload.agentsGuidance.snippet).toContain("cam recall search"); const claudeResult = runCli( projectDir, @@ -305,7 +334,13 @@ describe("dist cli smoke", () => { expect(JSON.parse(claudeResult.stdout)).toMatchObject({ host: "claude", serverName: "codex_auto_memory", - targetFileHint: ".mcp.json" + targetFileHint: ".mcp.json", + workflowContract: { + recommendedPreset: "state=auto, limit=8", + cliFallback: { + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + } + } }); const geminiResult = runCli( @@ -321,7 +356,13 @@ describe("dist cli smoke", () => { expect(JSON.parse(geminiResult.stdout)).toMatchObject({ host: "gemini", serverName: "codex_auto_memory", - targetFileHint: ".gemini/settings.json" + targetFileHint: ".gemini/settings.json", + workflowContract: { + recommendedPreset: "state=auto, limit=8", + cliFallback: { + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + } + } }); const genericResult = runCli( @@ -338,7 +379,13 @@ describe("dist cli smoke", () => { host: "generic", serverName: "codex_auto_memory", targetFileHint: "Your MCP client's stdio server config", - snippetFormat: "json" + snippetFormat: "json", + workflowContract: { + recommendedPreset: "state=auto, limit=8", + cliFallback: { + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + } + } }); }); @@ -665,6 +712,51 @@ describe("dist cli smoke", () => { }); }); + it("preserves custom fields on the codex_auto_memory install entry from the compiled cli entrypoint", async () => { + const homeDir = await tempDir("cam-dist-mcp-install-preserve-home-"); + const projectDir = await tempDir("cam-dist-mcp-install-preserve-project-"); + const realProjectDir = await fs.realpath(projectDir); + + await fs.mkdir(path.join(projectDir, ".codex"), { recursive: true }); + await fs.writeFile( + path.join(projectDir, ".codex", "config.toml"), + [ + "[mcp_servers.codex_auto_memory]", + 'command = "cam"', + 'args = ["mcp", "serve"]', + `cwd = ${JSON.stringify(realProjectDir)}`, + 'label = "keep-me"' + ].join("\n"), + "utf8" + ); + + const result = runCli(projectDir, ["mcp", "install", "--host", "codex", "--json"], { + entrypoint: "dist", + env: { HOME: homeDir } + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "codex", + action: "unchanged", + preservedCustomFields: ["label"] + }); + + const writtenConfig = toml.parse( + await fs.readFile(path.join(projectDir, ".codex", "config.toml"), "utf8") + ) as Record; + expect(writtenConfig).toMatchObject({ + mcp_servers: { + codex_auto_memory: { + command: "cam", + args: ["mcp", "serve"], + cwd: realProjectDir, + label: "keep-me" + } + } + }); + }); + it("installs hooks and skills from the compiled cli entrypoint", async () => { const homeDir = await tempDir("cam-dist-hook-skill-home-"); const projectDir = await tempDir("cam-dist-hook-skill-project-"); @@ -774,12 +866,52 @@ describe("dist cli smoke", () => { expect(doctorResult.exitCode, doctorResult.stderr).toBe(0); expect(JSON.parse(doctorResult.stdout)).toMatchObject({ fallbackAssets: { + runtimeSkillPresent: true, runtimeSkillDir: path.join(codexHome, "skills", "codex-auto-memory-recall"), + anySkillSurfaceInstalled: true, + anySkillSurfaceReady: true, + officialUserSkillMatchesCanonical: false, + officialProjectSkillMatchesCanonical: false, skillPathDrift: true } }); }); + it("reports canonical and runtime skill readiness separately from the compiled cli entrypoint", async () => { + const homeDir = await tempDir("cam-dist-skill-doctor-home-"); + const projectDir = await tempDir("cam-dist-skill-doctor-project-"); + + const env = { HOME: homeDir }; + const skillsResult = runCli( + projectDir, + ["skills", "install", "--surface", "official-user"], + { + entrypoint: "dist", + env + } + ); + expect(skillsResult.exitCode, skillsResult.stderr).toBe(0); + + const doctorResult = runCli(projectDir, ["mcp", "doctor", "--json"], { + entrypoint: "dist", + env + }); + expect(doctorResult.exitCode, doctorResult.stderr).toBe(0); + expect(JSON.parse(doctorResult.stdout)).toMatchObject({ + fallbackAssets: { + runtimeSkillPresent: false, + runtimeSkillInstalled: false, + officialUserSkillInstalled: true, + officialUserSkillMatchesCanonical: true, + officialUserSkillMatchesRuntime: false, + anySkillSurfaceInstalled: true, + anySkillSurfaceReady: true, + installedSkillSurfaces: ["official-user"], + readySkillSurfaces: ["official-user"] + } + }); + }); + it("installs the Codex integration stack from the compiled cli entrypoint", async () => { const homeDir = await tempDir("cam-dist-integrations-home-"); const projectDir = await tempDir("cam-dist-integrations-project-"); @@ -879,10 +1011,31 @@ describe("dist cli smoke", () => { host: "codex", projectRoot: realProjectDir, stackAction: "blocked", + preflightBlocked: true, + blockedStage: "agents-guidance-preflight", subactions: { + mcp: { + action: "unchanged", + attempted: false, + skipped: true, + skipReason: expect.stringContaining("preflight") + }, agents: { status: "blocked", - action: "blocked" + action: "blocked", + attempted: true + }, + hooks: { + action: "unchanged", + attempted: false, + skipped: true, + skipReason: expect.stringContaining("preflight") + }, + skills: { + action: "unchanged", + attempted: false, + skipped: true, + skipReason: expect.stringContaining("preflight") } } }); @@ -1047,6 +1200,9 @@ describe("dist cli smoke", () => { status: "ok", recommendedRoute: "mcp", recommendedPreset: "state=auto, limit=8", + applyReadiness: { + status: "safe" + }, workflowContract: { version: expect.any(String), postWorkSyncReview: { @@ -1066,6 +1222,43 @@ describe("dist cli smoke", () => { }); }); + it("surfaces blocked apply readiness from the compiled integrations doctor", async () => { + const homeDir = await tempDir("cam-dist-integrations-doctor-blocked-home-"); + const projectDir = await tempDir("cam-dist-integrations-doctor-blocked-project-"); + const realProjectDir = await fs.realpath(projectDir); + + await fs.writeFile( + path.join(realProjectDir, "AGENTS.md"), + [ + "# Project Notes", + "", + "", + "", + "- stale guidance" + ].join("\n"), + "utf8" + ); + + const result = runCli( + projectDir, + ["integrations", "doctor", "--host", "codex", "--json"], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + applyReadiness: { + status: "blocked", + reason: expect.stringContaining("managed guidance block"), + recommendedFix: expect.stringContaining("cam mcp apply-guidance --host codex") + } + }); + }); + it("routes exec through the compiled wrapper entrypoint", async () => { const repoDir = await tempDir("cam-dist-wrapper-repo-"); const homeDir = await tempDir("cam-dist-wrapper-home-"); diff --git a/test/docs-contract.test.ts b/test/docs-contract.test.ts index 3fed309..5e50888 100644 --- a/test/docs-contract.test.ts +++ b/test/docs-contract.test.ts @@ -55,6 +55,8 @@ describe("docs contract", () => { expect(readme).toContain("cam mcp print-config --host codex"); expect(readme).toContain("AGENTS.md"); expect(readme).toContain("cam mcp doctor"); + expect(readme).toContain("alternate global wiring"); + expect(readme).toContain("非 canonical 自定义字段"); expect(readme).toContain("manual-only"); expect(readme).toContain("cam forget \"old debug note\" --archive"); expect(readme).toContain("README.zh-TW.md"); @@ -72,6 +74,9 @@ describe("docs contract", () => { expect(readmeTw).toContain("cam mcp print-config --host codex"); expect(readmeTw).toContain("cam mcp apply-guidance --host codex"); expect(readmeTw).toContain("cam mcp doctor"); + expect(readmeTw).toContain("alternate global wiring"); + expect(readmeTw).toContain("非 canonical 自訂欄位"); + expect(readmeTw).toContain("applyReadiness"); expect(readmeTw).toContain("--state auto"); expect(readmeTw).toContain("local bridge"); expect(readmeTw).toContain("manual-only"); @@ -89,6 +94,9 @@ describe("docs contract", () => { expect(readmeJa).toContain("cam mcp print-config --host codex"); expect(readmeJa).toContain("cam mcp apply-guidance --host codex"); expect(readmeJa).toContain("cam mcp doctor"); + expect(readmeJa).toContain("alternate global wiring"); + expect(readmeJa).toContain("non-canonical なカスタム項目"); + expect(readmeJa).toContain("applyReadiness"); expect(readmeJa).toContain("--state auto"); expect(readmeJa).toContain("local bridge"); expect(readmeJa).toContain("manual-only"); @@ -120,6 +128,8 @@ describe("docs contract", () => { expect(readmeEn).toContain("cam mcp print-config --host codex"); expect(readmeEn).toContain("AGENTS.md"); expect(readmeEn).toContain("cam mcp doctor"); + expect(readmeEn).toContain("alternate global wiring"); + expect(readmeEn).toContain("non-canonical custom fields"); expect(readmeEn).toContain("manual-only"); expect(readmeEn).toContain("--surface runtime|official-user|official-project"); expect(docsReadme).toContain("Codex-first Hybrid"); @@ -174,10 +184,21 @@ describe("docs contract", () => { expect(releaseChecklist).toContain("node dist/cli.js mcp doctor --json"); expect(releaseChecklist).toContain("node dist/cli.js mcp doctor --host codex --json"); expect(releaseChecklist).toContain("structured `workflowContract`"); + expect(releaseChecklist).toContain("alternate global wiring"); + expect(releaseChecklist).toContain("non-canonical custom fields"); + expect(releaseChecklist).toContain("preservedCustomFields"); + expect(releaseChecklist).toContain("configScopeSummary"); + expect(releaseChecklist).toContain("alternateWiring"); + expect(releaseChecklist).toContain("runtimeSkillPresent"); + expect(releaseChecklist).toContain("officialUserSkillMatchesCanonical"); + expect(releaseChecklist).toContain("anySkillSurfaceInstalled"); expect(releaseChecklist).toContain("post-work-memory-review.sh"); expect(releaseChecklist).toContain("node dist/cli.js integrations install --host codex --json"); expect(releaseChecklist).toContain("node dist/cli.js integrations apply --host codex --json"); expect(releaseChecklist).toContain('stackAction: "blocked"'); + expect(releaseChecklist).toContain("preflightBlocked"); + expect(releaseChecklist).toContain("skipped subactions"); + expect(releaseChecklist).toContain("applyReadiness"); expect(releaseChecklist).toContain("node dist/cli.js skills install --surface official-user"); expect(releaseChecklist).toContain("node dist/cli.js integrations install --host codex --skill-surface official-user --json"); expect(releaseChecklist).toContain("node dist/cli.js integrations apply --host codex --skill-surface official-user --json"); @@ -338,6 +359,10 @@ describe("docs contract", () => { expect(readme).toContain("当前主任务"); expect(readmeEn).toContain("Current priorities"); expect(readme).toContain("workflowContract"); + expect(readme).toContain("preflight `blocked`"); + expect(readme).toContain("applyReadiness"); expect(readmeEn).toContain("workflowContract"); + expect(readmeEn).toContain("preflight `blocked`"); + expect(readmeEn).toContain("applyReadiness"); }); }); diff --git a/test/integrations-command.test.ts b/test/integrations-command.test.ts index d7b5ec6..69d2c4e 100644 --- a/test/integrations-command.test.ts +++ b/test/integrations-command.test.ts @@ -540,6 +540,9 @@ describe("integrations command", () => { const homeDir = await tempDir("cam-integrations-apply-blocked-home-"); const projectDir = await tempDir("cam-integrations-apply-blocked-project-"); const realProjectDir = await fs.realpath(projectDir); + const configPath = path.join(realProjectDir, ".codex", "config.toml"); + const hooksDir = path.join(homeDir, ".codex-auto-memory", "hooks"); + const skillDir = path.join(homeDir, ".codex", "skills", "codex-auto-memory-recall"); process.env.HOME = homeDir; await fs.writeFile( @@ -565,27 +568,97 @@ describe("integrations command", () => { host: "codex", projectRoot: realProjectDir, stackAction: "blocked", + preflightBlocked: true, + blockedStage: "agents-guidance-preflight", subactions: { mcp: { - status: "ok" + status: "ok", + action: "unchanged", + attempted: false, + skipped: true, + skipReason: expect.stringContaining("preflight") }, agents: { status: "blocked", action: "blocked", + attempted: true, targetPath: path.join(realProjectDir, "AGENTS.md") }, hooks: { status: "ok", - action: "created" + action: "unchanged", + attempted: false, + skipped: true, + skipReason: expect.stringContaining("preflight") }, skills: { status: "ok", - action: "created", + action: "unchanged", + attempted: false, + skipped: true, + skipReason: expect.stringContaining("preflight"), surface: "runtime" } } }); expect(await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8")).toBe(before); + expect(await pathExists(configPath)).toBe(false); + expect(await pathExists(hooksDir)).toBe(false); + expect(await pathExists(skillDir)).toBe(false); + }); + + it("withholds integrations apply from doctor next steps when AGENTS guidance is unsafe", async () => { + const homeDir = await tempDir("cam-integrations-doctor-blocked-home-"); + const projectDir = await tempDir("cam-integrations-doctor-blocked-project-"); + const shellDir = await tempDir("cam-integrations-doctor-blocked-shell-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + await fs.writeFile( + path.join(realProjectDir, "AGENTS.md"), + [ + "# Project Notes", + "", + "", + "", + "- stale guidance" + ].join("\n"), + "utf8" + ); + + const result = runCli( + shellDir, + ["integrations", "doctor", "--host", "codex", "--cwd", projectDir, "--json"], + { env: { HOME: homeDir } } + ); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + applyReadiness: { + status: string; + reason: string; + recommendedFix: string; + }; + nextSteps: string[]; + }; + expect(payload.applyReadiness).toMatchObject({ + status: "blocked", + reason: expect.stringContaining("managed guidance block"), + recommendedFix: expect.stringContaining("Repair") + }); + expect(payload.applyReadiness.recommendedFix).toContain( + `cam mcp apply-guidance --host codex --cwd ${JSON.stringify(realProjectDir)}` + ); + expect(payload.nextSteps).not.toEqual( + expect.arrayContaining([expect.stringContaining("cam integrations apply --host codex")]) + ); + expect(payload.nextSteps).toEqual( + expect.arrayContaining([ + expect.stringContaining( + `cam mcp apply-guidance --host codex --cwd ${JSON.stringify(realProjectDir)}` + ) + ]) + ); }); it("keeps integrations install non-mutating for AGENTS.md while integrations apply writes it", async () => { diff --git a/test/mcp-command.test.ts b/test/mcp-command.test.ts index cabe6c6..d97aa1b 100644 --- a/test/mcp-command.test.ts +++ b/test/mcp-command.test.ts @@ -421,6 +421,113 @@ describe("mcp command", () => { ); }); + it("preserves non-canonical custom fields on an existing codex_auto_memory entry", async () => { + const homeDir = await tempDir("cam-mcp-install-preserve-home-"); + const projectDir = await tempDir("cam-mcp-install-preserve-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + await fs.mkdir(path.join(projectDir, ".codex"), { recursive: true }); + await fs.mkdir(path.join(projectDir, ".gemini"), { recursive: true }); + await fs.writeFile( + path.join(projectDir, ".codex", "config.toml"), + [ + "[mcp_servers.codex_auto_memory]", + 'command = "cam"', + 'args = ["mcp", "serve"]', + `cwd = ${JSON.stringify(realProjectDir)}`, + 'label = "keep-me"' + ].join("\n"), + "utf8" + ); + await fs.writeFile( + path.join(projectDir, ".mcp.json"), + JSON.stringify( + { + mcpServers: { + codex_auto_memory: { + command: "cam", + args: ["mcp", "serve", "--cwd", realProjectDir], + env: {}, + label: "keep-me" + } + } + }, + null, + 2 + ), + "utf8" + ); + await fs.writeFile( + path.join(projectDir, ".gemini", "settings.json"), + JSON.stringify( + { + mcpServers: { + codex_auto_memory: { + command: "cam", + args: ["mcp", "serve"], + cwd: realProjectDir, + trust: false, + label: "keep-me" + } + } + }, + null, + 2 + ), + "utf8" + ); + + for (const host of ["codex", "claude", "gemini"] as const) { + const result = runCli(projectDir, ["mcp", "install", "--host", host, "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host, + action: "unchanged", + preservedCustomFields: ["label"] + }); + } + + const codexConfig = await readTomlFile(path.join(projectDir, ".codex", "config.toml")); + expect(codexConfig).toMatchObject({ + mcp_servers: { + codex_auto_memory: { + command: "cam", + args: ["mcp", "serve"], + cwd: realProjectDir, + label: "keep-me" + } + } + }); + + const claudeConfig = await readJsonFile(path.join(projectDir, ".mcp.json")); + expect(claudeConfig).toMatchObject({ + mcpServers: { + codex_auto_memory: { + command: "cam", + args: ["mcp", "serve", "--cwd", realProjectDir], + env: {}, + label: "keep-me" + } + } + }); + + const geminiConfig = await readJsonFile(path.join(projectDir, ".gemini", "settings.json")); + expect(geminiConfig).toMatchObject({ + mcpServers: { + codex_auto_memory: { + command: "cam", + args: ["mcp", "serve"], + cwd: realProjectDir, + trust: false, + label: "keep-me" + } + } + }); + }); + it("supports install --cwd for writing another project's host config", async () => { const homeDir = await tempDir("cam-mcp-install-cwd-home-"); const projectDir = await tempDir("cam-mcp-install-cwd-project-"); @@ -554,6 +661,19 @@ describe("mcp command", () => { snippet: string; notes: string[]; }; + workflowContract?: { + recommendedPreset: string; + routePreference: { + preferredRoute: string; + }; + recallWorkflow: { + recallFirst: string; + progressiveDisclosure: string; + }; + cliFallback: { + searchCommand: string; + }; + }; }; expect(payload).toMatchObject({ @@ -566,6 +686,19 @@ describe("mcp command", () => { }); expect(payload.snippet).toContain('[mcp_servers.codex_auto_memory]'); expect(payload.notes).toHaveLength(2); + expect(payload.workflowContract).toMatchObject({ + recommendedPreset: "state=auto, limit=8", + routePreference: { + preferredRoute: "mcp-first" + }, + recallWorkflow: { + recallFirst: expect.stringContaining("recall durable memory first"), + progressiveDisclosure: "Use progressive disclosure: search -> timeline -> details." + }, + cliFallback: { + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + } + }); expect(payload.agentsGuidance).toMatchObject({ targetFileHint: "AGENTS.md", snippetFormat: "markdown" @@ -1010,7 +1143,7 @@ describe("mcp command", () => { ); }); - it("supports print-config --cwd for generating snippets from another directory", async () => { + it("supports print-config --cwd for generating pinned snippets and workflow commands from another directory", async () => { const homeDir = await tempDir("cam-mcp-print-cwd-home-"); const projectDir = await tempDir("cam-mcp-print-cwd-project-"); const callerDir = await tempDir("cam-mcp-print-cwd-caller-"); @@ -1030,12 +1163,24 @@ describe("mcp command", () => { host: string; projectRoot: string; snippet: string; + workflowContract: { + cliFallback: { + searchCommand: string; + timelineCommand: string; + detailsCommand: string; + }; + }; }; expect(payload).toMatchObject({ host: "generic", projectRoot: realProjectDir }); expect(payload.snippet).toContain(realProjectDir); + expect(payload.workflowContract.cliFallback).toMatchObject({ + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}`, + timelineCommand: `cam recall timeline "" --cwd ${JSON.stringify(realProjectDir)}`, + detailsCommand: `cam recall details "" --cwd ${JSON.stringify(realProjectDir)}` + }); }); it("pins the recommended skill install command to the inspected project when mcp doctor uses --cwd", async () => { @@ -1341,6 +1486,190 @@ describe("mcp command", () => { ); }); + it("reports alternate global wiring separately from the recommended project-scoped route", async () => { + const homeDir = await tempDir("cam-mcp-doctor-global-home-"); + const projectDir = await tempDir("cam-mcp-doctor-global-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + await fs.mkdir(path.join(homeDir, ".codex"), { recursive: true }); + await fs.writeFile( + path.join(homeDir, ".codex", "config.toml"), + [ + "[mcp_servers.codex_auto_memory]", + 'command = "cam"', + 'args = ["mcp", "serve"]', + `cwd = ${JSON.stringify(realProjectDir)}` + ].join("\n"), + "utf8" + ); + + const result = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + hosts: Array<{ + host: string; + status: string; + summary: string; + configScopeSummary?: string; + recommendedScope?: string; + detectedScopes?: string[]; + alternateWiring?: { + detected: boolean; + valid: boolean; + scopes: string[]; + issues: Array<{ + scope: string; + inspection: string; + }>; + }; + configCheck?: { + exists: boolean; + }; + alternateConfigChecks?: Array<{ + scope: string; + exists: boolean; + projectPinned: boolean; + }>; + }>; + }; + expect(payload.hosts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + host: "codex", + status: "warning", + summary: expect.stringContaining("global"), + configScopeSummary: "project-missing-global-alternate", + recommendedScope: "project", + detectedScopes: ["global"], + alternateWiring: { + detected: true, + valid: true, + scopes: ["global"], + issues: [] + }, + configCheck: expect.objectContaining({ + exists: false + }), + alternateConfigChecks: expect.arrayContaining([ + expect.objectContaining({ + scope: "global", + exists: true, + projectPinned: true + }) + ]) + }) + ]) + ); + }); + + it("does not treat malformed global config as alternate global wiring", async () => { + const homeDir = await tempDir("cam-mcp-doctor-global-parse-error-home-"); + const projectDir = await tempDir("cam-mcp-doctor-global-parse-error-project-"); + process.env.HOME = homeDir; + + await fs.mkdir(path.join(homeDir, ".codex"), { recursive: true }); + await fs.writeFile(path.join(homeDir, ".codex", "config.toml"), 'not = "valid', "utf8"); + + const result = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + hosts: Array<{ + host: string; + status: string; + summary: string; + configScopeSummary?: string; + recommendedScope?: string; + detectedScopes?: string[]; + alternateWiring?: { + detected: boolean; + valid: boolean; + scopes: string[]; + issues: Array<{ + scope: string; + inspection: string; + }>; + }; + alternateConfigChecks?: Array<{ + scope: string; + exists: boolean; + }>; + }>; + }; + + expect(payload.hosts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + host: "codex", + status: "warning", + summary: expect.stringContaining("could not be parsed"), + configScopeSummary: "project-missing-global-invalid", + recommendedScope: "project", + detectedScopes: [], + alternateWiring: expect.objectContaining({ + detected: false, + valid: false, + scopes: [], + issues: expect.arrayContaining([ + expect.objectContaining({ + scope: "global", + inspection: "parse-error" + }) + ]) + }) + }) + ]) + ); + expect( + payload.hosts.find((host) => host.host === "codex")?.alternateConfigChecks ?? [] + ).toEqual([]); + }); + + it("surfaces direct apply safety in mcp doctor when AGENTS guidance is unsafe", async () => { + const homeDir = await tempDir("cam-mcp-doctor-apply-safety-home-"); + const projectDir = await tempDir("cam-mcp-doctor-apply-safety-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + await fs.writeFile( + path.join(realProjectDir, "AGENTS.md"), + [ + "# Project Notes", + "", + "", + "", + "- stale guidance" + ].join("\n"), + "utf8" + ); + + const result = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + applySafety: { + status: string; + targetPath: string; + blockedReason?: string; + recommendedAction: string; + }; + }; + expect(payload.applySafety).toMatchObject({ + status: "blocked", + targetPath: path.join(realProjectDir, "AGENTS.md"), + recommendedAction: "blocked", + blockedReason: expect.stringContaining("managed guidance block") + }); + }); + it("keeps mcp doctor read-only and does not create memory layout", async () => { const homeDir = await tempDir("cam-mcp-doctor-readonly-home-"); const projectDir = await tempDir("cam-mcp-doctor-readonly-project-"); @@ -1396,6 +1725,7 @@ describe("mcp command", () => { runtimeSource: "CODEX_HOME", preferredInstallSurface: "runtime", recommendedSkillInstallCommand: "cam skills install --surface runtime", + runtimeSkillPresent: true, runtimeSkillInstalled: true, runtimeSkillMatchesCanonical: true, runtimeSkillReady: true, @@ -1413,10 +1743,14 @@ describe("mcp command", () => { ), officialUserSkillInstalled: false, officialProjectSkillInstalled: false, + officialUserSkillMatchesCanonical: false, + officialProjectSkillMatchesCanonical: false, officialUserSkillMatchesRuntime: false, officialProjectSkillMatchesRuntime: false, officialUserSkillReady: false, officialProjectSkillReady: false, + anySkillSurfaceInstalled: true, + anySkillSurfaceReady: true, installedSkillSurfaces: ["runtime"], readySkillSurfaces: ["runtime"], skillPathDrift: true, @@ -1447,6 +1781,7 @@ describe("mcp command", () => { runtimeSkillDir: path.join(homeDir, ".codex", "skills", "codex-auto-memory-recall"), preferredInstallSurface: "runtime", recommendedSkillInstallCommand: "cam skills install --surface runtime", + runtimeSkillPresent: false, runtimeSkillInstalled: false, runtimeSkillReady: false, officialUserSkillDir: path.join( @@ -1462,10 +1797,14 @@ describe("mcp command", () => { "codex-auto-memory-recall" ), officialUserSkillInstalled: true, - officialUserSkillMatchesRuntime: true, + officialUserSkillMatchesCanonical: true, + officialUserSkillMatchesRuntime: false, officialUserSkillReady: true, officialProjectSkillInstalled: false, + officialProjectSkillMatchesCanonical: false, officialProjectSkillReady: false, + anySkillSurfaceInstalled: true, + anySkillSurfaceReady: true, installedSkillSurfaces: ["official-user"], readySkillSurfaces: ["official-user"], skillInstalled: true @@ -1476,6 +1815,54 @@ describe("mcp command", () => { }); }); + it("keeps official-project skill readiness semantics aligned with canonical and runtime state separately", async () => { + const homeDir = await tempDir("cam-mcp-doctor-official-project-home-"); + const projectDir = await tempDir("cam-mcp-doctor-official-project-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + expect( + runCli(projectDir, ["skills", "install", "--surface", "official-project"], { + env: { HOME: homeDir } + }).exitCode + ).toBe(0); + + const result = runCli(projectDir, ["mcp", "doctor", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + expect(JSON.parse(result.stdout)).toMatchObject({ + fallbackAssets: { + runtimeSkillPresent: false, + runtimeSkillInstalled: false, + runtimeSkillReady: false, + officialUserSkillInstalled: false, + officialUserSkillMatchesCanonical: false, + officialUserSkillMatchesRuntime: false, + officialUserSkillReady: false, + officialProjectSkillDir: path.join( + realProjectDir, + ".agents", + "skills", + "codex-auto-memory-recall" + ), + officialProjectSkillInstalled: true, + officialProjectSkillMatchesCanonical: true, + officialProjectSkillMatchesRuntime: false, + officialProjectSkillReady: true, + anySkillSurfaceInstalled: true, + anySkillSurfaceReady: true, + installedSkillSurfaces: ["official-project"], + readySkillSurfaces: ["official-project"], + skillInstalled: true + }, + codexStack: { + skillReady: true + } + }); + }); + it("fails closed when CODEX_HOME is a relative path", async () => { const homeDir = await tempDir("cam-mcp-doctor-relative-codex-home-home-"); const projectDir = await tempDir("cam-mcp-doctor-relative-codex-home-project-"); @@ -1526,6 +1913,8 @@ describe("mcp command", () => { hookHelpersInstalled: boolean; postWorkReviewInstalled: boolean; startupDoctorInstalled: boolean; + anySkillSurfaceInstalled: boolean; + anySkillSurfaceReady: boolean; skillInstalled: boolean; fallbackAvailable: boolean; assets: Array<{ @@ -1541,6 +1930,8 @@ describe("mcp command", () => { hookHelpersInstalled: false, postWorkReviewInstalled: false, startupDoctorInstalled: false, + anySkillSurfaceInstalled: true, + anySkillSurfaceReady: false, skillInstalled: false, fallbackAvailable: false }); @@ -1623,6 +2014,8 @@ describe("mcp command", () => { hookHelpersInstalled: boolean; postWorkReviewInstalled: boolean; startupDoctorInstalled: boolean; + anySkillSurfaceInstalled: boolean; + anySkillSurfaceReady: boolean; skillInstalled: boolean; fallbackAvailable: boolean; assets: Array<{ @@ -1638,6 +2031,8 @@ describe("mcp command", () => { hookHelpersInstalled: false, postWorkReviewInstalled: false, startupDoctorInstalled: false, + anySkillSurfaceInstalled: true, + anySkillSurfaceReady: false, skillInstalled: false, fallbackAvailable: false }); @@ -1776,6 +2171,62 @@ describe("mcp command", () => { }); }); + it("keeps workflowContract core fields identical across print-config, mcp doctor, and integrations doctor", async () => { + const homeDir = await tempDir("cam-workflow-parity-home-"); + const projectDir = await tempDir("cam-workflow-parity-project-"); + const shellDir = await tempDir("cam-workflow-parity-shell-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + const printConfig = runCli( + shellDir, + ["mcp", "print-config", "--host", "codex", "--cwd", projectDir, "--json"], + { env: { HOME: homeDir } } + ); + const mcpDoctor = runCli( + shellDir, + ["mcp", "doctor", "--host", "codex", "--cwd", projectDir, "--json"], + { env: { HOME: homeDir } } + ); + const integrationsDoctor = runCli( + shellDir, + ["integrations", "doctor", "--host", "codex", "--cwd", projectDir, "--json"], + { env: { HOME: homeDir } } + ); + + expect(printConfig.exitCode, printConfig.stderr).toBe(0); + expect(mcpDoctor.exitCode, mcpDoctor.stderr).toBe(0); + expect(integrationsDoctor.exitCode, integrationsDoctor.stderr).toBe(0); + + const printWorkflow = JSON.parse(printConfig.stdout).workflowContract; + const mcpWorkflow = JSON.parse(mcpDoctor.stdout).workflowContract; + const integrationsWorkflow = JSON.parse(integrationsDoctor.stdout).workflowContract; + const expectedCore = { + recommendedPreset: "state=auto, limit=8", + preferredRoute: "mcp-first", + routePreference: { + preferredRoute: "mcp-first" + }, + recallWorkflow: { + progressiveDisclosure: "Use progressive disclosure: search -> timeline -> details." + }, + cliFallback: { + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}`, + timelineCommand: `cam recall timeline "" --cwd ${JSON.stringify(realProjectDir)}`, + detailsCommand: `cam recall details "" --cwd ${JSON.stringify(realProjectDir)}` + }, + postWorkSyncReview: { + helperScript: "post-work-memory-review.sh", + syncCommand: `cam sync --cwd ${JSON.stringify(realProjectDir)}`, + reviewCommand: `cam memory --recent --cwd ${JSON.stringify(realProjectDir)}` + } + }; + + expect(printWorkflow).toMatchObject(expectedCore); + expect(mcpWorkflow).toMatchObject(expectedCore); + expect(integrationsWorkflow).toMatchObject(expectedCore); + }); + it("reports an operational MCP route once cam is available on PATH", async () => { const homeDir = await tempDir("cam-mcp-doctor-command-ready-home-"); const projectDir = await tempDir("cam-mcp-doctor-command-ready-project-"); diff --git a/test/tarball-install-smoke.test.ts b/test/tarball-install-smoke.test.ts index 0ad1ebd..3ae8029 100644 --- a/test/tarball-install-smoke.test.ts +++ b/test/tarball-install-smoke.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import * as toml from "smol-toml"; import { runCommandCapture } from "../src/lib/util/process.js"; const tempDirs: string[] = []; @@ -120,18 +121,94 @@ describe("tarball install smoke", () => { envWithBin ); expect(codexPrintConfigResult.exitCode).toBe(0); - expect(JSON.parse(codexPrintConfigResult.stdout)).toMatchObject({ + const codexPrintConfigPayload = JSON.parse(codexPrintConfigResult.stdout) as { + host: string; + serverName: string; + targetFileHint: string; + workflowContract: { + recommendedPreset: string; + routePreference: { + preferredRoute: string; + }; + cliFallback: { + searchCommand: string; + }; + }; + agentsGuidance: { + targetFileHint: string; + snippetFormat: string; + snippet: string; + }; + }; + expect(codexPrintConfigPayload).toMatchObject({ host: "codex", serverName: "codex_auto_memory", targetFileHint: ".codex/config.toml", + workflowContract: { + recommendedPreset: "state=auto, limit=8", + routePreference: { + preferredRoute: "mcp-first" + }, + cliFallback: { + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realInstallDir)}` + } + }, agentsGuidance: { targetFileHint: "AGENTS.md", snippetFormat: "markdown" } }); - const codexPrintConfigPayload = JSON.parse(codexPrintConfigResult.stdout) as { - agentsGuidance: { snippet: string }; - }; + const claudePrintConfigResult = runCommandCapture( + camBinaryPath(installDir), + ["mcp", "print-config", "--host", "claude", "--json"], + installDir, + envWithBin + ); + expect(claudePrintConfigResult.exitCode).toBe(0); + expect(JSON.parse(claudePrintConfigResult.stdout)).toMatchObject({ + host: "claude", + targetFileHint: ".mcp.json", + workflowContract: { + recommendedPreset: "state=auto, limit=8", + cliFallback: { + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realInstallDir)}` + } + } + }); + const geminiPrintConfigResult = runCommandCapture( + camBinaryPath(installDir), + ["mcp", "print-config", "--host", "gemini", "--json"], + installDir, + envWithBin + ); + expect(geminiPrintConfigResult.exitCode).toBe(0); + expect(JSON.parse(geminiPrintConfigResult.stdout)).toMatchObject({ + host: "gemini", + targetFileHint: ".gemini/settings.json", + workflowContract: { + recommendedPreset: "state=auto, limit=8", + cliFallback: { + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realInstallDir)}` + } + } + }); + const genericPrintConfigResult = runCommandCapture( + camBinaryPath(installDir), + ["mcp", "print-config", "--host", "generic", "--json"], + installDir, + envWithBin + ); + expect(genericPrintConfigResult.exitCode).toBe(0); + expect(JSON.parse(genericPrintConfigResult.stdout)).toMatchObject({ + host: "generic", + targetFileHint: "Your MCP client's stdio server config", + workflowContract: { + recommendedPreset: "state=auto, limit=8", + cliFallback: { + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realInstallDir)}` + } + } + }); const applyGuidanceResult = runCommandCapture( camBinaryPath(installDir), ["mcp", "apply-guidance", "--host", "codex", "--json"], @@ -197,46 +274,6 @@ describe("tarball install smoke", () => { projectRoot: realProjectWithSpacesDir }); - const claudePrintConfigResult = runCommandCapture( - camBinaryPath(installDir), - ["mcp", "print-config", "--host", "claude", "--json"], - installDir, - envWithBin - ); - expect(claudePrintConfigResult.exitCode).toBe(0); - expect(JSON.parse(claudePrintConfigResult.stdout)).toMatchObject({ - host: "claude", - serverName: "codex_auto_memory", - targetFileHint: ".mcp.json" - }); - - const geminiPrintConfigResult = runCommandCapture( - camBinaryPath(installDir), - ["mcp", "print-config", "--host", "gemini", "--json"], - installDir, - envWithBin - ); - expect(geminiPrintConfigResult.exitCode).toBe(0); - expect(JSON.parse(geminiPrintConfigResult.stdout)).toMatchObject({ - host: "gemini", - serverName: "codex_auto_memory", - targetFileHint: ".gemini/settings.json" - }); - - const genericPrintConfigResult = runCommandCapture( - camBinaryPath(installDir), - ["mcp", "print-config", "--host", "generic", "--json"], - installDir, - envWithBin - ); - expect(genericPrintConfigResult.exitCode).toBe(0); - expect(JSON.parse(genericPrintConfigResult.stdout)).toMatchObject({ - host: "generic", - serverName: "codex_auto_memory", - targetFileHint: "Your MCP client's stdio server config", - snippetFormat: "json" - }); - const genericInstallResult = runCommandCapture( camBinaryPath(installDir), ["mcp", "install", "--host", "generic"], @@ -494,6 +531,9 @@ describe("tarball install smoke", () => { status: "ok", recommendedRoute: "mcp", recommendedPreset: "state=auto, limit=8", + applyReadiness: { + status: "safe" + }, workflowContract: { version: expect.any(String), postWorkSyncReview: { @@ -526,7 +566,12 @@ describe("tarball install smoke", () => { expect(JSON.parse(mcpDoctorResult.stdout)).toMatchObject({ readOnlyRetrieval: true, fallbackAssets: { - postWorkReviewInstalled: true + runtimeSkillPresent: true, + postWorkReviewInstalled: true, + anySkillSurfaceInstalled: true, + anySkillSurfaceReady: true, + officialUserSkillMatchesCanonical: true, + officialProjectSkillMatchesCanonical: true }, workflowContract: { version: expect.any(String), @@ -580,14 +625,52 @@ describe("tarball install smoke", () => { host: "codex", projectRoot: realBlockedProjectDir, stackAction: "blocked", + preflightBlocked: true, + blockedStage: "agents-guidance-preflight", subactions: { + mcp: { + action: "unchanged", + attempted: false, + skipped: true, + skipReason: expect.stringContaining("preflight") + }, agents: { status: "blocked", - action: "blocked" + action: "blocked", + attempted: true + }, + hooks: { + action: "unchanged", + attempted: false, + skipped: true, + skipReason: expect.stringContaining("preflight") + }, + skills: { + action: "unchanged", + attempted: false, + skipped: true, + skipReason: expect.stringContaining("preflight") } } }); + const blockedIntegrationsDoctorResult = runCommandCapture( + camBinaryPath(installDir), + ["integrations", "doctor", "--host", "codex", "--json"], + blockedProjectDir, + envWithBin + ); + expect(blockedIntegrationsDoctorResult.exitCode).toBe(0); + expect(JSON.parse(blockedIntegrationsDoctorResult.stdout)).toMatchObject({ + host: "codex", + projectRoot: realBlockedProjectDir, + applyReadiness: { + status: "blocked", + reason: expect.stringContaining("managed guidance block"), + recommendedFix: expect.stringContaining("cam mcp apply-guidance --host codex") + } + }); + const recallHelpResult = runCommandCapture( camBinaryPath(installDir), ["recall", "search", "--help"], @@ -701,4 +784,77 @@ describe("tarball install smoke", () => { ); expect(integrationsDoctorHelpResult.stdout).toContain("Target host: codex"); }, 60_000); + + it("preserves custom fields on the codex_auto_memory install entry from the packed tarball", async () => { + const homeDir = await tempDir("cam-tarball-preserve-home-"); + const packDir = await tempDir("cam-tarball-preserve-pack-"); + const installDir = await tempDir("cam-tarball-preserve-install-"); + const realInstallDir = await fs.realpath(installDir); + const env = isolatedEnv(homeDir); + + const packResult = runCommandCapture( + npmCommand(), + ["pack", "--pack-destination", packDir], + process.cwd(), + env + ); + expect(packResult.exitCode).toBe(0); + + const tarballName = packResult.stdout.trim().split(/\r?\n/).at(-1); + expect(tarballName).toBeTruthy(); + const tarballPath = path.join(packDir, tarballName!); + + expect(runCommandCapture(npmCommand(), ["init", "-y"], installDir, env).exitCode).toBe(0); + expect( + runCommandCapture( + npmCommand(), + ["install", "--no-package-lock", tarballPath], + installDir, + env + ).exitCode + ).toBe(0); + + const envWithBin = { + ...env, + PATH: `${path.join(installDir, "node_modules", ".bin")}${path.delimiter}${env.PATH ?? ""}` + }; + + await fs.mkdir(path.join(installDir, ".codex"), { recursive: true }); + await fs.writeFile( + path.join(installDir, ".codex", "config.toml"), + [ + "[mcp_servers.codex_auto_memory]", + 'command = "cam"', + 'args = ["mcp", "serve"]', + `cwd = ${JSON.stringify(realInstallDir)}`, + 'label = "keep-me"' + ].join("\n"), + "utf8" + ); + + const result = runCommandCapture( + camBinaryPath(installDir), + ["mcp", "install", "--host", "codex", "--json"], + installDir, + envWithBin + ); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "codex", + action: "unchanged", + preservedCustomFields: ["label"] + }); + expect( + toml.parse(await fs.readFile(path.join(installDir, ".codex", "config.toml"), "utf8")) + ).toMatchObject({ + mcp_servers: { + codex_auto_memory: { + command: "cam", + args: ["mcp", "serve"], + cwd: realInstallDir, + label: "keep-me" + } + } + }); + }, 60_000); }); From 71e55325e8a7e80482cdb1bc694894fd3b55dfa0 Mon Sep 17 00:00:00 2001 From: blocks Date: Tue, 7 Apr 2026 22:51:43 +0800 Subject: [PATCH 07/62] fix: tighten integration review contracts --- docs/claude-reference.en.md | 2 +- docs/native-migration.en.md | 3 +- src/lib/integration/mcp-config.ts | 16 ++++++---- src/lib/integration/retrieval-contract.ts | 6 +++- test/dist-cli-smoke.test.ts | 36 +++++++++-------------- test/docs-contract.test.ts | 1 + test/env-helper.test.ts | 21 +++++++++++++ test/helpers/env.ts | 11 +++++++ test/integrations-command.test.ts | 33 +++++++++++---------- test/mcp-command.test.ts | 34 ++++++++++----------- test/mcp-config.test.ts | 27 +++++++++++++++++ test/recall-command.test.ts | 3 +- test/retrieval-contract.test.ts | 16 ++++++++++ test/skills-command.test.ts | 9 ++---- test/tarball-install-smoke.test.ts | 36 +++++++++-------------- 15 files changed, 162 insertions(+), 92 deletions(-) create mode 100644 test/env-helper.test.ts create mode 100644 test/helpers/env.ts create mode 100644 test/mcp-config.test.ts create mode 100644 test/retrieval-contract.test.ts diff --git a/docs/claude-reference.en.md b/docs/claude-reference.en.md index 087a05f..d2426e2 100644 --- a/docs/claude-reference.en.md +++ b/docs/claude-reference.en.md @@ -92,7 +92,7 @@ This repository still does not claim full `/memory` interaction parity, but it m - users can see the actual memory files and active paths - users can modify memory through Markdown files or explicit commands -### 6. Host integration surfaces matter, but should not replace the core contract +### 6. `autoMemoryDirectory` has a configuration safety boundary Claude-style public configuration boundaries also imply that a shared project should not be able to silently redirect another user's durable-memory storage. diff --git a/docs/native-migration.en.md b/docs/native-migration.en.md index 882d92f..744c9e6 100644 --- a/docs/native-migration.en.md +++ b/docs/native-migration.en.md @@ -2,7 +2,8 @@ [简体中文](./native-migration.md) | [English](./native-migration.en.md) -> This document now has a narrower job: it records how `codex-auto-memory` evaluates native Codex memory and hook signals without treating them as the only future direction. The repository is still Codex-first, but its broader product evolution now also includes non-native hook, skill, and MCP-aware integration paths. +> This document now answers one narrower question: **when is it worth promoting native Codex memory / hooks from a readiness signal to the primary path?** +> It no longer carries the repository's entire integration-direction narrative. For that broader direction, see [Integration Strategy](./integration-strategy.md). ## One-page conclusion diff --git a/src/lib/integration/mcp-config.ts b/src/lib/integration/mcp-config.ts index fe8c7ef..00a5f97 100644 --- a/src/lib/integration/mcp-config.ts +++ b/src/lib/integration/mcp-config.ts @@ -21,12 +21,13 @@ export interface McpHostConfigSnippet { host: McpHost; serverName: string; transport: "stdio"; + readOnlyRetrieval: true; targetFileHint: string; projectRoot: string; snippetFormat: "toml" | "json"; snippet: string; notes: string[]; - workflowContract: ReturnType; + workflowContract?: ReturnType; agentsGuidance?: CodexAgentsGuidance; } @@ -43,15 +44,20 @@ export function buildMcpHostConfigSnippet(host: McpHost, projectRoot: string): M host, serverName: MEMORY_RETRIEVAL_MCP_SERVER_NAME, transport: "stdio", + readOnlyRetrieval: true, targetFileHint: definition.targetFileHint, projectRoot, snippetFormat: definition.snippetFormat, snippet: buildMcpHostSnippet(host, projectRoot), notes: [...definition.notes], - workflowContract: buildWorkflowContract({ - cwd: projectRoot - }), - ...(host === "codex" ? { agentsGuidance: buildCodexAgentsGuidance() } : {}) + ...(host === "codex" + ? { + workflowContract: buildWorkflowContract({ + cwd: projectRoot + }), + agentsGuidance: buildCodexAgentsGuidance() + } + : {}) }; } diff --git a/src/lib/integration/retrieval-contract.ts b/src/lib/integration/retrieval-contract.ts index 7071c46..228de20 100644 --- a/src/lib/integration/retrieval-contract.ts +++ b/src/lib/integration/retrieval-contract.ts @@ -105,12 +105,16 @@ export function hasCliCwdFlag(command: string): boolean { return /(?:^|\s)--cwd(?:\s|=)/u.test(command); } +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\"'\"'`)}'`; +} + export function appendCliCwdFlag(command: string, cwd?: string): string { if (!cwd || hasCliCwdFlag(command)) { return command; } - return `${command} --cwd ${JSON.stringify(cwd)}`; + return `${command} --cwd ${shellQuote(cwd)}`; } export function buildRecommendedCliSearchCommand( diff --git a/test/dist-cli-smoke.test.ts b/test/dist-cli-smoke.test.ts index 47b72d5..613d202 100644 --- a/test/dist-cli-smoke.test.ts +++ b/test/dist-cli-smoke.test.ts @@ -55,6 +55,10 @@ async function waitForFile(pathname: string, timeoutMs = 2_000): Promise } } +function shellQuoteArg(value: string): string { + return `'${value.replace(/'/g, `'\"'\"'`)}'`; +} + afterEach(async () => { if (originalCodexHome === undefined) { delete process.env.CODEX_HOME; @@ -310,7 +314,7 @@ describe("dist cli smoke", () => { preferredRoute: "mcp-first" }, cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realProjectDir)}` } }, agentsGuidance: { @@ -334,14 +338,10 @@ describe("dist cli smoke", () => { expect(JSON.parse(claudeResult.stdout)).toMatchObject({ host: "claude", serverName: "codex_auto_memory", - targetFileHint: ".mcp.json", - workflowContract: { - recommendedPreset: "state=auto, limit=8", - cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` - } - } + readOnlyRetrieval: true, + targetFileHint: ".mcp.json" }); + expect(JSON.parse(claudeResult.stdout).workflowContract).toBeUndefined(); const geminiResult = runCli( projectDir, @@ -356,14 +356,10 @@ describe("dist cli smoke", () => { expect(JSON.parse(geminiResult.stdout)).toMatchObject({ host: "gemini", serverName: "codex_auto_memory", - targetFileHint: ".gemini/settings.json", - workflowContract: { - recommendedPreset: "state=auto, limit=8", - cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` - } - } + readOnlyRetrieval: true, + targetFileHint: ".gemini/settings.json" }); + expect(JSON.parse(geminiResult.stdout).workflowContract).toBeUndefined(); const genericResult = runCli( projectDir, @@ -379,14 +375,10 @@ describe("dist cli smoke", () => { host: "generic", serverName: "codex_auto_memory", targetFileHint: "Your MCP client's stdio server config", - snippetFormat: "json", - workflowContract: { - recommendedPreset: "state=auto, limit=8", - cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` - } - } + readOnlyRetrieval: true, + snippetFormat: "json" }); + expect(JSON.parse(genericResult.stdout).workflowContract).toBeUndefined(); }); it("rejects generic MCP install from the compiled cli entrypoint because wiring stays manual-only", async () => { diff --git a/test/docs-contract.test.ts b/test/docs-contract.test.ts index 5e50888..c17ba53 100644 --- a/test/docs-contract.test.ts +++ b/test/docs-contract.test.ts @@ -153,6 +153,7 @@ describe("docs contract", () => { expect(claudeReferenceEn).toContain("autoMemoryDirectory"); expect(claudeReferenceEn).toContain("shared project config"); expect(claudeReferenceEn).toContain("user-level memory path"); + expect(claudeReferenceEn).toContain("### 6. `autoMemoryDirectory` has a configuration safety boundary"); expect(nativeMigrationEn).toContain("native Codex memory and hooks are still not ready"); expect(nativeMigrationEn).toContain("allow non-native integration expansion"); expect(releaseChecklist).toContain("pnpm test:dist-cli-smoke"); diff --git a/test/env-helper.test.ts b/test/env-helper.test.ts new file mode 100644 index 0000000..1bd0487 --- /dev/null +++ b/test/env-helper.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { restoreOptionalEnv } from "./helpers/env.js"; + +describe("restoreOptionalEnv", () => { + it("deletes env vars that were originally unset", () => { + process.env.CAM_TEST_OPTIONAL_ENV = "temp"; + + restoreOptionalEnv("CAM_TEST_OPTIONAL_ENV", undefined); + + expect(process.env.CAM_TEST_OPTIONAL_ENV).toBeUndefined(); + }); + + it("restores env vars that originally had a value", () => { + process.env.CAM_TEST_OPTIONAL_ENV = "temp"; + + restoreOptionalEnv("CAM_TEST_OPTIONAL_ENV", "original"); + + expect(process.env.CAM_TEST_OPTIONAL_ENV).toBe("original"); + delete process.env.CAM_TEST_OPTIONAL_ENV; + }); +}); diff --git a/test/helpers/env.ts b/test/helpers/env.ts new file mode 100644 index 0000000..350dde2 --- /dev/null +++ b/test/helpers/env.ts @@ -0,0 +1,11 @@ +export function restoreOptionalEnv( + name: string, + originalValue: string | undefined +): void { + if (originalValue === undefined) { + delete process.env[name]; + return; + } + + process.env[name] = originalValue; +} diff --git a/test/integrations-command.test.ts b/test/integrations-command.test.ts index 69d2c4e..add11a5 100644 --- a/test/integrations-command.test.ts +++ b/test/integrations-command.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { restoreOptionalEnv } from "./helpers/env.js"; import { makeAppConfig, writeCamConfig } from "./helpers/cam-test-fixtures.js"; import { runCli } from "./helpers/cli-runner.js"; @@ -63,13 +64,13 @@ async function buildPathWithoutCam(extraDir: string): Promise { return [extraDir, ...filteredEntries].join(path.delimiter); } +function shellQuoteArg(value: string): string { + return `'${value.replace(/'/g, `'\"'\"'`)}'`; +} + afterEach(async () => { - process.env.HOME = originalHome; - if (originalCodexHome === undefined) { - delete process.env.CODEX_HOME; - } else { - process.env.CODEX_HOME = originalCodexHome; - } + restoreOptionalEnv("HOME", originalHome); + restoreOptionalEnv("CODEX_HOME", originalCodexHome); await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); @@ -262,24 +263,24 @@ describe("integrations command", () => { }; expect(payload.projectRoot).toBe(await fs.realpath(projectDir)); expect(payload.recommendedSkillInstallCommand).toBe( - `cam skills install --surface runtime --cwd ${JSON.stringify(payload.projectRoot)}` + `cam skills install --surface runtime --cwd ${shellQuoteArg(payload.projectRoot)}` ); expect(payload.workflowContract.cliFallback.searchCommand).toBe( - `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(payload.projectRoot)}` + `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(payload.projectRoot)}` ); expect(payload.nextSteps).toEqual( expect.arrayContaining([ expect.stringContaining( - `cam integrations apply --host codex --skill-surface runtime --cwd ${JSON.stringify(payload.projectRoot)}` + `cam integrations apply --host codex --skill-surface runtime --cwd ${shellQuoteArg(payload.projectRoot)}` ), expect.stringContaining( - `cam integrations install --host codex --cwd ${JSON.stringify(payload.projectRoot)}` + `cam integrations install --host codex --cwd ${shellQuoteArg(payload.projectRoot)}` ), expect.stringContaining( - `cam mcp print-config --host codex --cwd ${JSON.stringify(payload.projectRoot)}` + `cam mcp print-config --host codex --cwd ${shellQuoteArg(payload.projectRoot)}` ), expect.stringContaining( - `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(payload.projectRoot)}` + `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(payload.projectRoot)}` ) ]) ); @@ -318,10 +319,10 @@ describe("integrations command", () => { expect(payload.nextSteps).toEqual( expect.arrayContaining([ expect.stringContaining( - `cam mcp apply-guidance --host codex --cwd ${JSON.stringify(payload.projectRoot)}` + `cam mcp apply-guidance --host codex --cwd ${shellQuoteArg(payload.projectRoot)}` ), expect.stringContaining( - `cam mcp print-config --host codex --cwd ${JSON.stringify(payload.projectRoot)}` + `cam mcp print-config --host codex --cwd ${shellQuoteArg(payload.projectRoot)}` ) ]) ); @@ -647,7 +648,7 @@ describe("integrations command", () => { recommendedFix: expect.stringContaining("Repair") }); expect(payload.applyReadiness.recommendedFix).toContain( - `cam mcp apply-guidance --host codex --cwd ${JSON.stringify(realProjectDir)}` + `cam mcp apply-guidance --host codex --cwd ${shellQuoteArg(realProjectDir)}` ); expect(payload.nextSteps).not.toEqual( expect.arrayContaining([expect.stringContaining("cam integrations apply --host codex")]) @@ -655,7 +656,7 @@ describe("integrations command", () => { expect(payload.nextSteps).toEqual( expect.arrayContaining([ expect.stringContaining( - `cam mcp apply-guidance --host codex --cwd ${JSON.stringify(realProjectDir)}` + `cam mcp apply-guidance --host codex --cwd ${shellQuoteArg(realProjectDir)}` ) ]) ); diff --git a/test/mcp-command.test.ts b/test/mcp-command.test.ts index d97aa1b..df5b739 100644 --- a/test/mcp-command.test.ts +++ b/test/mcp-command.test.ts @@ -79,6 +79,10 @@ async function readJsonFile(pathname: string): Promise> return JSON.parse(await fs.readFile(pathname, "utf8")) as Record; } +function shellQuoteArg(value: string): string { + return `'${value.replace(/'/g, `'\"'\"'`)}'`; +} + async function writeCamShim(binDir: string): Promise { if (process.platform === "win32") { await fs.writeFile(path.join(binDir, "cam.cmd"), "@echo off\r\nexit /b 0\r\n", "utf8"); @@ -696,7 +700,7 @@ describe("mcp command", () => { progressiveDisclosure: "Use progressive disclosure: search -> timeline -> details." }, cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realProjectDir)}` } }); expect(payload.agentsGuidance).toMatchObject({ @@ -1176,11 +1180,7 @@ describe("mcp command", () => { projectRoot: realProjectDir }); expect(payload.snippet).toContain(realProjectDir); - expect(payload.workflowContract.cliFallback).toMatchObject({ - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}`, - timelineCommand: `cam recall timeline "" --cwd ${JSON.stringify(realProjectDir)}`, - detailsCommand: `cam recall details "" --cwd ${JSON.stringify(realProjectDir)}` - }); + expect(payload.workflowContract).toBeUndefined(); }); it("pins the recommended skill install command to the inspected project when mcp doctor uses --cwd", async () => { @@ -1209,7 +1209,7 @@ describe("mcp command", () => { }; expect(payload.projectRoot).toBe(await fs.realpath(projectDir)); expect(payload.fallbackAssets.recommendedSkillInstallCommand).toBe( - `cam skills install --surface runtime --cwd ${JSON.stringify(payload.projectRoot)}` + `cam skills install --surface runtime --cwd ${shellQuoteArg(payload.projectRoot)}` ); }); @@ -1427,14 +1427,14 @@ describe("mcp command", () => { recallFirst: expect.stringContaining("recall durable memory first"), progressiveDisclosure: "Use progressive disclosure: search -> timeline -> details.", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}`, - timelineCommand: `cam recall timeline "" --cwd ${JSON.stringify(realProjectDir)}`, - detailsCommand: `cam recall details "" --cwd ${JSON.stringify(realProjectDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realProjectDir)}`, + timelineCommand: `cam recall timeline "" --cwd ${shellQuoteArg(realProjectDir)}`, + detailsCommand: `cam recall details "" --cwd ${shellQuoteArg(realProjectDir)}` }, postWorkSyncReview: { helperScript: "post-work-memory-review.sh", - syncCommand: `cam sync --cwd ${JSON.stringify(realProjectDir)}`, - reviewCommand: `cam memory --recent --cwd ${JSON.stringify(realProjectDir)}` + syncCommand: `cam sync --cwd ${shellQuoteArg(realProjectDir)}`, + reviewCommand: `cam memory --recent --cwd ${shellQuoteArg(realProjectDir)}` } }); expect(payload.codexStack).toMatchObject({ @@ -2211,14 +2211,14 @@ describe("mcp command", () => { progressiveDisclosure: "Use progressive disclosure: search -> timeline -> details." }, cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}`, - timelineCommand: `cam recall timeline "" --cwd ${JSON.stringify(realProjectDir)}`, - detailsCommand: `cam recall details "" --cwd ${JSON.stringify(realProjectDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realProjectDir)}`, + timelineCommand: `cam recall timeline "" --cwd ${shellQuoteArg(realProjectDir)}`, + detailsCommand: `cam recall details "" --cwd ${shellQuoteArg(realProjectDir)}` }, postWorkSyncReview: { helperScript: "post-work-memory-review.sh", - syncCommand: `cam sync --cwd ${JSON.stringify(realProjectDir)}`, - reviewCommand: `cam memory --recent --cwd ${JSON.stringify(realProjectDir)}` + syncCommand: `cam sync --cwd ${shellQuoteArg(realProjectDir)}`, + reviewCommand: `cam memory --recent --cwd ${shellQuoteArg(realProjectDir)}` } }; diff --git a/test/mcp-config.test.ts b/test/mcp-config.test.ts new file mode 100644 index 0000000..2f3fec9 --- /dev/null +++ b/test/mcp-config.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { buildMcpHostConfigSnippet } from "../src/lib/integration/mcp-config.js"; + +describe("mcp host config snippets", () => { + it("keeps workflowContract codex-only while preserving read-only snippets for manual hosts", () => { + const projectRoot = "/tmp/cam-project"; + + const codexSnippet = buildMcpHostConfigSnippet("codex", projectRoot); + const claudeSnippet = buildMcpHostConfigSnippet("claude", projectRoot); + const geminiSnippet = buildMcpHostConfigSnippet("gemini", projectRoot); + const genericSnippet = buildMcpHostConfigSnippet("generic", projectRoot); + + expect(codexSnippet.workflowContract).toMatchObject({ + recommendedPreset: "state=auto, limit=8" + }); + expect(codexSnippet.readOnlyRetrieval).toBe(true); + + expect(claudeSnippet.readOnlyRetrieval).toBe(true); + expect(claudeSnippet.workflowContract).toBeUndefined(); + + expect(geminiSnippet.readOnlyRetrieval).toBe(true); + expect(geminiSnippet.workflowContract).toBeUndefined(); + + expect(genericSnippet.readOnlyRetrieval).toBe(true); + expect(genericSnippet.workflowContract).toBeUndefined(); + }); +}); diff --git a/test/recall-command.test.ts b/test/recall-command.test.ts index 93d2635..4d34226 100644 --- a/test/recall-command.test.ts +++ b/test/recall-command.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { detectProjectContext } from "../src/lib/domain/project-context.js"; import { MemoryStore } from "../src/lib/domain/memory-store.js"; +import { restoreOptionalEnv } from "./helpers/env.js"; import { makeAppConfig, writeCamConfig @@ -20,7 +21,7 @@ async function tempDir(prefix: string): Promise { } afterEach(async () => { - process.env.HOME = originalHome; + restoreOptionalEnv("HOME", originalHome); await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); diff --git a/test/retrieval-contract.test.ts b/test/retrieval-contract.test.ts new file mode 100644 index 0000000..8b49aec --- /dev/null +++ b/test/retrieval-contract.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { appendCliCwdFlag } from "../src/lib/integration/retrieval-contract.js"; + +describe("retrieval contract", () => { + it("shell-quotes cwd values so the shell cannot expand them", () => { + expect(appendCliCwdFlag("cam recall search \"\"", "/tmp/$HOME/path with spaces")).toBe( + "cam recall search \"\" --cwd '/tmp/$HOME/path with spaces'" + ); + }); + + it("escapes embedded single quotes in cwd values", () => { + expect(appendCliCwdFlag("cam recall details \"\"", "/tmp/it's-safe")).toBe( + "cam recall details \"\" --cwd '/tmp/it'\"'\"'s-safe'" + ); + }); +}); diff --git a/test/skills-command.test.ts b/test/skills-command.test.ts index 9997832..220076d 100644 --- a/test/skills-command.test.ts +++ b/test/skills-command.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { restoreOptionalEnv } from "./helpers/env.js"; import { runCli } from "./helpers/cli-runner.js"; const tempDirs: string[] = []; @@ -15,12 +16,8 @@ async function tempDir(prefix: string): Promise { } afterEach(async () => { - process.env.HOME = originalHome; - if (originalCodexHome === undefined) { - delete process.env.CODEX_HOME; - } else { - process.env.CODEX_HOME = originalCodexHome; - } + restoreOptionalEnv("HOME", originalHome); + restoreOptionalEnv("CODEX_HOME", originalCodexHome); await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); diff --git a/test/tarball-install-smoke.test.ts b/test/tarball-install-smoke.test.ts index 3ae8029..3c70715 100644 --- a/test/tarball-install-smoke.test.ts +++ b/test/tarball-install-smoke.test.ts @@ -30,6 +30,10 @@ function camBinaryPath(installDir: string): string { ); } +function shellQuoteArg(value: string): string { + return `'${value.replace(/'/g, `'\"'\"'`)}'`; +} + function isolatedEnv(homeDir: string): NodeJS.ProcessEnv { return { ...process.env, @@ -150,7 +154,7 @@ describe("tarball install smoke", () => { preferredRoute: "mcp-first" }, cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realInstallDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realInstallDir)}` } }, agentsGuidance: { @@ -167,14 +171,10 @@ describe("tarball install smoke", () => { expect(claudePrintConfigResult.exitCode).toBe(0); expect(JSON.parse(claudePrintConfigResult.stdout)).toMatchObject({ host: "claude", - targetFileHint: ".mcp.json", - workflowContract: { - recommendedPreset: "state=auto, limit=8", - cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realInstallDir)}` - } - } + readOnlyRetrieval: true, + targetFileHint: ".mcp.json" }); + expect(JSON.parse(claudePrintConfigResult.stdout).workflowContract).toBeUndefined(); const geminiPrintConfigResult = runCommandCapture( camBinaryPath(installDir), ["mcp", "print-config", "--host", "gemini", "--json"], @@ -184,14 +184,10 @@ describe("tarball install smoke", () => { expect(geminiPrintConfigResult.exitCode).toBe(0); expect(JSON.parse(geminiPrintConfigResult.stdout)).toMatchObject({ host: "gemini", - targetFileHint: ".gemini/settings.json", - workflowContract: { - recommendedPreset: "state=auto, limit=8", - cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realInstallDir)}` - } - } + readOnlyRetrieval: true, + targetFileHint: ".gemini/settings.json" }); + expect(JSON.parse(geminiPrintConfigResult.stdout).workflowContract).toBeUndefined(); const genericPrintConfigResult = runCommandCapture( camBinaryPath(installDir), ["mcp", "print-config", "--host", "generic", "--json"], @@ -201,14 +197,10 @@ describe("tarball install smoke", () => { expect(genericPrintConfigResult.exitCode).toBe(0); expect(JSON.parse(genericPrintConfigResult.stdout)).toMatchObject({ host: "generic", - targetFileHint: "Your MCP client's stdio server config", - workflowContract: { - recommendedPreset: "state=auto, limit=8", - cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realInstallDir)}` - } - } + readOnlyRetrieval: true, + targetFileHint: "Your MCP client's stdio server config" }); + expect(JSON.parse(genericPrintConfigResult.stdout).workflowContract).toBeUndefined(); const applyGuidanceResult = runCommandCapture( camBinaryPath(installDir), ["mcp", "apply-guidance", "--host", "codex", "--json"], From 427d05eb7f5893f1de5d5f97aac10eb530829eb0 Mon Sep 17 00:00:00 2001 From: blocks Date: Fri, 27 Mar 2026 00:25:50 +0800 Subject: [PATCH 08/62] feat: pin hook helpers and add retrieval sidecar index --- docs/release-checklist.md | 1 + src/lib/cli/register-commands.ts | 3 +- src/lib/commands/hooks.ts | 12 +- src/lib/commands/integrations.ts | 2 + src/lib/domain/memory-store.ts | 290 +++++++++++++++++++++++++++-- src/lib/domain/sync-service.ts | 4 +- src/lib/integration/assets.ts | 64 +++++-- src/lib/integration/codex-stack.ts | 8 +- src/lib/integration/mcp-doctor.ts | 12 +- test/dist-cli-smoke.test.ts | 22 +++ test/hooks-command.test.ts | 86 ++++++++- test/integrations-command.test.ts | 50 +++++ test/memory-store.test.ts | 84 +++++++++ test/recall-command.test.ts | 51 +++++ test/tarball-install-smoke.test.ts | 19 +- 15 files changed, 661 insertions(+), 47 deletions(-) diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 5be5d56..93abb79 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -63,6 +63,7 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Confirm `node dist/cli.js mcp doctor --host codex --json` now exposes `configScopeSummary` and `alternateWiring`, so valid alternate global wiring stays distinct from malformed or shape-mismatched global host config. - Confirm `node dist/cli.js mcp doctor --host codex --json` distinguishes skill-surface presence, canonical content, and readiness through additive fields such as `runtimeSkillPresent`, `officialUserSkillMatchesCanonical`, `officialProjectSkillMatchesCanonical`, `anySkillSurfaceInstalled`, and `anySkillSurfaceReady`. - Confirm `node dist/cli.js hooks install` writes `post-work-memory-review.sh`, and that the generated helper still runs `cam sync` followed by `cam memory --recent`. +- Run `node dist/cli.js hooks install --cwd ` from another working directory and confirm the generated hook helper bundle pins `memory-recall.sh` and `post-work-memory-review.sh` to the targeted project root instead of the caller shell cwd. - Run `node dist/cli.js skills install --surface official-project --cwd ` from another working directory and confirm the explicit project-scoped `.agents/skills` copy is written inside the targeted repository. - Run `node dist/cli.js integrations install --host codex --json` and confirm it orchestrates the existing Codex MCP wiring, hook bundle, and skill assets without touching the Markdown memory store. - Run `node dist/cli.js integrations apply --host codex --json` and confirm it orchestrates MCP wiring, managed AGENTS guidance, hook assets, and skill assets while keeping `integrations install --host codex` non-mutating for AGENTS.md. diff --git a/src/lib/cli/register-commands.ts b/src/lib/cli/register-commands.ts index ace592e..d599e02 100644 --- a/src/lib/cli/register-commands.ts +++ b/src/lib/cli/register-commands.ts @@ -122,7 +122,8 @@ function registerHookCommands(program: Command): void { hooksCommand .command("install") .description("Generate the local recall bridge bundle plus startup and post-session helper scripts") - .action(withStdout(async () => installHooks())); + .option("--cwd ", "Project directory to anchor generated hook helpers to") + .action(withStdout(async (options) => installHooks(options))); hooksCommand .command("remove") diff --git a/src/lib/commands/hooks.ts b/src/lib/commands/hooks.ts index 58c9d25..b141e3a 100644 --- a/src/lib/commands/hooks.ts +++ b/src/lib/commands/hooks.ts @@ -4,9 +4,17 @@ import { } from "../integration/assets.js"; import { LOCAL_BRIDGE_BUNDLE_NOTE } from "../integration/codex-stack.js"; import { installIntegrationAssets } from "../integration/install-assets.js"; +import { resolveMcpProjectRoot } from "../integration/mcp-config.js"; -export async function installHooks(): Promise { - const result = await installIntegrationAssets("hooks"); +interface HooksCommandOptions { + cwd?: string; +} + +export async function installHooks(options: HooksCommandOptions = {}): Promise { + const projectRoot = options.cwd ? resolveMcpProjectRoot(options.cwd) : undefined; + const result = await installIntegrationAssets("hooks", { + projectRoot + }); return [ `Generated hook bridge bundle in ${result.targetDir}`, diff --git a/src/lib/commands/integrations.ts b/src/lib/commands/integrations.ts index 25adee7..b6f341d 100644 --- a/src/lib/commands/integrations.ts +++ b/src/lib/commands/integrations.ts @@ -221,6 +221,7 @@ function buildIntegrationsDoctorResult( hookCaptureReady: report.codexStack.hookCaptureReady, hookRecallReady: report.codexStack.hookRecallReady, skillReady: report.codexStack.skillReady, + workflowAssetsConsistent: report.codexStack.workflowAssetsConsistent, workflowConsistent: report.codexStack.workflowConsistent }, { @@ -285,6 +286,7 @@ function buildIntegrationsDoctorResult( hookCaptureReady: report.codexStack.hookCaptureReady, hookRecallReady: report.codexStack.hookRecallReady, skillReady: report.codexStack.skillReady, + workflowAssetsConsistent: report.codexStack.workflowAssetsConsistent, workflowConsistent: report.codexStack.workflowConsistent }, { skillInstallCommand: report.fallbackAssets.recommendedSkillInstallCommand, diff --git a/src/lib/domain/memory-store.ts b/src/lib/domain/memory-store.ts index b8635e9..0057b08 100644 --- a/src/lib/domain/memory-store.ts +++ b/src/lib/domain/memory-store.ts @@ -65,6 +65,26 @@ interface SearchMatch { score: number; } +interface RetrievalIndexEntry { + ref: string; + scope: MemoryScope; + state: MemoryRecordState; + topic: string; + id: string; + summary: string; + updatedAt: string; + approxReadCost: number; + summaryText: string; + detailsText: string; +} + +interface RetrievalIndexPayload { + version: 1; + scope: MemoryScope; + state: MemoryRecordState; + entries: RetrievalIndexEntry[]; +} + interface PlannedFileChange { path: string; contents: string | null; @@ -98,6 +118,7 @@ interface MemoryStoreFileOps { } const topicNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const retrievalIndexVersion = 1 as const; function topicTitle(topic: string): string { return topic @@ -298,6 +319,40 @@ function buildArchiveIndexContents(scope: MemoryScope, entries: MemoryEntry[]): return `${lines.join("\n")}\n`; } +function buildRetrievalIndexEntries( + scope: MemoryScope, + state: MemoryRecordState, + entries: MemoryEntry[] +): RetrievalIndexEntry[] { + return sortEntriesByUpdatedAt(entries).map((entry) => ({ + ref: buildMemoryRef(scope, state, entry.topic, entry.id), + scope, + state, + topic: entry.topic, + id: entry.id, + summary: entry.summary, + updatedAt: entry.updatedAt, + approxReadCost: entry.details.length + 4, + summaryText: entry.summary.toLowerCase(), + detailsText: entry.details.join("\n").toLowerCase() + })); +} + +function buildRetrievalIndexContents( + scope: MemoryScope, + state: MemoryRecordState, + entries: MemoryEntry[] +): string { + const payload: RetrievalIndexPayload = { + version: retrievalIndexVersion, + scope, + state, + entries: buildRetrievalIndexEntries(scope, state, entries) + }; + + return `${JSON.stringify(payload, null, 2)}\n`; +} + function appendJsonlContents(existingContents: string | null, values: unknown[]): string { const prefix = existingContents && existingContents.length > 0 @@ -384,7 +439,45 @@ function isTimelineEvent(value: unknown): value is MemoryTimelineEvent { ); } -function findSearchMatch(entry: MemoryEntry, query: string): SearchMatch | null { +function isRetrievalIndexEntry(value: unknown): value is RetrievalIndexEntry { + if (!value || typeof value !== "object") { + return false; + } + + const entry = value as Record; + return ( + typeof entry.ref === "string" && + isMemoryScope(entry.scope) && + (entry.state === "active" || entry.state === "archived") && + typeof entry.topic === "string" && + typeof entry.id === "string" && + typeof entry.summary === "string" && + typeof entry.updatedAt === "string" && + typeof entry.approxReadCost === "number" && + typeof entry.summaryText === "string" && + typeof entry.detailsText === "string" + ); +} + +function isRetrievalIndexPayload(value: unknown): value is RetrievalIndexPayload { + if (!value || typeof value !== "object") { + return false; + } + + const payload = value as Record; + return ( + payload.version === retrievalIndexVersion && + isMemoryScope(payload.scope) && + (payload.state === "active" || payload.state === "archived") && + Array.isArray(payload.entries) && + payload.entries.every((entry) => isRetrievalIndexEntry(entry)) + ); +} + +function findSearchMatch( + fields: ReadonlyArray, + query: string +): SearchMatch | null { const normalizedTerms = query .trim() .toLowerCase() @@ -396,14 +489,8 @@ function findSearchMatch(entry: MemoryEntry, query: string): SearchMatch | null const matchedFields: string[] = []; let score = 0; - const fieldChecks = [ - ["id", entry.id], - ["topic", entry.topic], - ["summary", entry.summary], - ["details", entry.details.join("\n")] - ] as const; - - for (const [field, value] of fieldChecks) { + + for (const [field, value] of fields) { const haystack = value.toLowerCase(); if (!normalizedTerms.every((term) => haystack.includes(term))) { continue; @@ -422,6 +509,33 @@ function findSearchMatch(entry: MemoryEntry, query: string): SearchMatch | null }; } +function findEntrySearchMatch(entry: MemoryEntry, query: string): SearchMatch | null { + return findSearchMatch( + [ + ["id", entry.id], + ["topic", entry.topic], + ["summary", entry.summary], + ["details", entry.details.join("\n")] + ], + query + ); +} + +function findRetrievalIndexSearchMatch( + entry: RetrievalIndexEntry, + query: string +): SearchMatch | null { + return findSearchMatch( + [ + ["id", entry.id], + ["topic", entry.topic], + ["summary", entry.summaryText], + ["details", entry.detailsText] + ], + query + ); +} + function isProcessedRolloutRecord(value: unknown): value is ProcessedRolloutRecord { if (!value || typeof value !== "object") { return false; @@ -579,6 +693,16 @@ export class MemoryStore { return path.join(this.getScopeDir(scope), "memory-history.jsonl"); } + public getRetrievalIndexFile( + scope: MemoryScope, + state: MemoryRecordState = "active" + ): string { + return path.join( + state === "active" ? this.getScopeDir(scope) : this.getArchiveDir(scope), + "retrieval-index.json" + ); + } + public getSyncAuditPath(): string { return path.join(this.paths.auditDir, "sync-log.jsonl"); } @@ -595,6 +719,67 @@ export class MemoryStore { return state === "active" ? this.getTopicFile(scope, topic) : this.getArchiveTopicFile(scope, topic); } + private async readRetrievalIndex( + scope: MemoryScope, + state: MemoryRecordState + ): Promise { + const retrievalIndexPath = this.getRetrievalIndexFile(scope, state); + if (!(await fileExists(retrievalIndexPath))) { + return null; + } + + let payload: unknown; + try { + payload = JSON.parse(await readTextFile(retrievalIndexPath)) as unknown; + } catch { + return null; + } + + if (!isRetrievalIndexPayload(payload)) { + return null; + } + + if (payload.scope !== scope || payload.state !== state) { + return null; + } + + if (await this.isRetrievalIndexStale(scope, state, retrievalIndexPath)) { + return null; + } + + return payload; + } + + private async isRetrievalIndexStale( + scope: MemoryScope, + state: MemoryRecordState, + retrievalIndexPath: string + ): Promise { + const baseDir = state === "active" ? this.getScopeDir(scope) : this.getArchiveDir(scope); + if (!(await fileExists(baseDir))) { + return false; + } + + const indexStats = await fs.stat(retrievalIndexPath); + const files = await fs.readdir(baseDir); + for (const fileName of files) { + if ( + !fileName.endsWith(".md") || + fileName === "MEMORY.md" || + fileName === "ARCHIVE.md" + ) { + continue; + } + + const stats = await fs.stat(path.join(baseDir, fileName)); + if (stats.mtimeMs > indexStats.mtimeMs) { + return true; + } + } + + return false; + } + private async readTopicFileParse( scope: MemoryScope, topic: string, @@ -690,6 +875,22 @@ export class MemoryStore { if (!(await fileExists(archiveIndexFile))) { await this.rebuildArchiveIndex(scope); } + + const activeRetrievalIndexFile = this.getRetrievalIndexFile(scope, "active"); + if ( + !(await fileExists(activeRetrievalIndexFile)) || + (await this.readRetrievalIndex(scope, "active")) === null + ) { + await this.rebuildRetrievalIndex(scope, "active"); + } + + const archivedRetrievalIndexFile = this.getRetrievalIndexFile(scope, "archived"); + if ( + !(await fileExists(archivedRetrievalIndexFile)) || + (await this.readRetrievalIndex(scope, "archived")) === null + ) { + await this.rebuildRetrievalIndex(scope, "archived"); + } } } @@ -770,6 +971,17 @@ export class MemoryStore { ); } + public async rebuildRetrievalIndex( + scope: MemoryScope, + state: MemoryRecordState = "active" + ): Promise { + const entries = await this.listEntries(scope, state); + await this.fileOps.writeTextFile( + this.getRetrievalIndexFile(scope, state), + buildRetrievalIndexContents(scope, state, entries) + ); + } + public async getEntryByRef(ref: string): Promise { const parsed = parseMemoryRef(ref); if (!parsed) { @@ -812,9 +1024,33 @@ export class MemoryStore { for (const scope of scopes) { for (const state of states) { + const retrievalIndex = await this.readRetrievalIndex(scope, state); + if (retrievalIndex) { + for (const entry of retrievalIndex.entries) { + const match = findRetrievalIndexSearchMatch(entry, query); + if (!match) { + continue; + } + + results.push({ + ref: entry.ref, + scope: entry.scope, + state: entry.state, + topic: entry.topic, + id: entry.id, + summary: entry.summary, + updatedAt: entry.updatedAt, + matchedFields: match.matchedFields, + approxReadCost: entry.approxReadCost, + score: match.score + }); + } + continue; + } + const entries = await this.listEntries(scope, state); for (const entry of entries) { - const match = findSearchMatch(entry, query); + const match = findEntrySearchMatch(entry, query); if (!match) { continue; } @@ -909,7 +1145,10 @@ export class MemoryStore { } private async buildMutationCommitPlan( - mutations: MemoryMutation[] + mutations: MemoryMutation[], + options: { + sessionId?: string; + } = {} ): Promise { const applied: MemoryApplyRecord[] = []; const scopeStates = new Map(); @@ -1038,6 +1277,7 @@ export class MemoryStore { summary: entry.summary, reason: mutation.reason, source: mutation.sources?.[0], + sessionId: options.sessionId, rolloutPath: mutation.sources?.find((source) => source.endsWith(".jsonl")) }); continue; @@ -1110,6 +1350,7 @@ export class MemoryStore { summary: archivedEntry.summary, reason: appliedOperation.reason, source: appliedOperation.sources?.[0], + sessionId: options.sessionId, rolloutPath: appliedOperation.sources?.find((source) => source.endsWith(".jsonl")) }); continue; @@ -1164,6 +1405,7 @@ export class MemoryStore { summary: existingActive.summary, reason: appliedOperation.reason, source: appliedOperation.sources?.[0], + sessionId: options.sessionId, rolloutPath: appliedOperation.sources?.find((source) => source.endsWith(".jsonl")) }); } @@ -1192,6 +1434,10 @@ export class MemoryStore { path: this.getMemoryFile(scope), contents: buildIndexContents(scope, scopeState.activeEntries) }); + fileChanges.push({ + path: this.getRetrievalIndexFile(scope, "active"), + contents: buildRetrievalIndexContents(scope, "active", scopeState.activeEntries) + }); } if (scopeState.archiveIndexTouched) { @@ -1199,6 +1445,10 @@ export class MemoryStore { path: this.getArchiveIndexFile(scope), contents: buildArchiveIndexContents(scope, scopeState.archivedEntries) }); + fileChanges.push({ + path: this.getRetrievalIndexFile(scope, "archived"), + contents: buildRetrievalIndexContents(scope, "archived", scopeState.archivedEntries) + }); } if (scopeState.historyAppends.length > 0) { @@ -1298,17 +1548,27 @@ export class MemoryStore { } } - public async applyOperations(operations: MemoryOperation[]): Promise { - const applied = await this.applyMutations(operations); + public async applyOperations( + operations: MemoryOperation[], + options: { + sessionId?: string; + } = {} + ): Promise { + const applied = await this.applyMutations(operations, options); return applied.flatMap((record) => { const operation = toAppliedOperation(record); return operation ? [operation] : []; }); } - public async applyMutations(mutations: MemoryMutation[]): Promise { + public async applyMutations( + mutations: MemoryMutation[], + options: { + sessionId?: string; + } = {} + ): Promise { await this.ensureLayout(); - const plan = await this.buildMutationCommitPlan(mutations); + const plan = await this.buildMutationCommitPlan(mutations, options); await this.commitPlannedFileChanges(plan.fileChanges, plan.expectedSnapshots); return plan.applied; } diff --git a/src/lib/domain/sync-service.ts b/src/lib/domain/sync-service.ts index bd50d19..6a51d38 100644 --- a/src/lib/domain/sync-service.ts +++ b/src/lib/domain/sync-service.ts @@ -143,7 +143,9 @@ export class SyncService { filterMemoryOperations(extraction.operations), existingEntries ); - const applyRecords = await this.store.applyMutations(reviewedOperations.operations); + const applyRecords = await this.store.applyMutations(reviewedOperations.operations, { + sessionId: evidence.sessionId + }); const applied = applyRecords.flatMap((record) => { const operation = toAppliedOperation(record); return operation ? [operation] : []; diff --git a/src/lib/integration/assets.ts b/src/lib/integration/assets.ts index 6933033..7d26ce8 100644 --- a/src/lib/integration/assets.ts +++ b/src/lib/integration/assets.ts @@ -2,6 +2,7 @@ import os from "node:os"; import path from "node:path"; import { ARCHIVE_BOUNDARY, + appendCliCwdFlag, buildCliSearchCommand, buildMarkdownAssetVersionComment, buildRecommendedCliSearchCommand, @@ -97,11 +98,17 @@ function resolveInstallDir( return surface === "hooks" ? context.hookDir : context.skillDir; } -function buildRecallDispatcherScript(): string { +function buildPinnedProjectRootBlock(projectRoot: string): string { + return `PROJECT_ROOT=${JSON.stringify(projectRoot)} +`; +} + +function buildRecallDispatcherScript(projectRoot: string): string { return `#!/bin/sh ${buildShellAssetVersionComment()} # Dispatch recall lookups through a single host-agnostic bridge helper. +${buildPinnedProjectRootBlock(projectRoot)} ACTION="$1" if [ "$#" -gt 0 ]; then shift @@ -122,6 +129,9 @@ contains_flag() { case "$ACTION" in search) + if ! contains_flag "--cwd" "$@"; then + set -- "$@" "--cwd" "$PROJECT_ROOT" + fi if ! contains_flag "--state" "$@"; then set -- "$@" "--state" "${RECOMMENDED_RETRIEVAL_STATE}" fi @@ -131,9 +141,15 @@ case "$ACTION" in exec cam recall search "$@" ;; timeline) + if ! contains_flag "--cwd" "$@"; then + set -- "$@" "--cwd" "$PROJECT_ROOT" + fi exec cam recall timeline "$@" ;; details) + if ! contains_flag "--cwd" "$@"; then + set -- "$@" "--cwd" "$PROJECT_ROOT" + fi exec cam recall details "$@" ;; *) @@ -153,7 +169,7 @@ exec "$SCRIPT_DIR/memory-recall.sh" ${action} "$@" `; } -function buildRecallBridgeGuideMarkdown(): string { +function buildRecallBridgeGuideMarkdown(projectRoot: string): string { return `# Codex Auto Memory Recall Bridge ${buildMarkdownAssetVersionComment()} @@ -173,7 +189,7 @@ This bundle keeps durable-memory recall host-agnostic. - ${CLI_FALLBACK_RECALL_WORKFLOW} - Search example: \`memory-recall.sh search "pnpm"\` -- CLI equivalent: \`${buildRecommendedCliSearchCommand("\"pnpm\"")}\` +- CLI equivalent: \`${buildRecommendedCliSearchCommand("\"pnpm\"", { cwd: projectRoot })}\` - Timeline example: \`memory-recall.sh timeline "project:active:workflow:prefer-pnpm"\` - Details example: \`memory-recall.sh details "project:active:workflow:prefer-pnpm"\` - Compatibility wrappers \`memory-search.sh\`, \`memory-timeline.sh\`, and \`memory-details.sh\` call the same dispatcher. @@ -195,12 +211,12 @@ ${buildSharedWorkflowDisciplineLines() `; } -function buildPostWorkMemoryReviewScript(): string { +function buildPostWorkMemoryReviewScript(projectRoot: string): string { return `#!/bin/sh ${buildShellAssetVersionComment()} # Sync the latest durable memory updates, then show the recent audit surface for review. -${buildPostWorkSyncCommand()} "$@" || exit $? -exec ${buildPostWorkRecentReviewCommand()} +${buildPostWorkSyncCommand({ cwd: projectRoot })} "$@" || exit $? +exec ${buildPostWorkRecentReviewCommand({ cwd: projectRoot })} `; } @@ -275,12 +291,12 @@ const INTEGRATION_ASSET_DEFINITIONS: readonly IntegrationAssetDefinition[] = [ executable: true, role: "capture-helper", doctorVisible: true, - doctorSignatures: ['cam sync "$@"'], - renderContents: () => + doctorSignatures: ["cam sync --cwd"], + renderContents: (context) => `#!/bin/sh ${buildShellAssetVersionComment()} # Sync the latest rollout for the current project. -cam sync "$@" +${appendCliCwdFlag("cam sync", context.projectRoot)} "$@" ` }, { @@ -291,12 +307,12 @@ cam sync "$@" executable: true, role: "capture-helper", doctorVisible: true, - doctorSignatures: ['cam doctor "$@"'], - renderContents: () => + doctorSignatures: ["cam doctor --cwd"], + renderContents: (context) => `#!/bin/sh ${buildShellAssetVersionComment()} # Print diagnostic information at session start. -cam doctor "$@" +${appendCliCwdFlag("cam doctor", context.projectRoot)} "$@" ` }, { @@ -307,8 +323,8 @@ cam doctor "$@" executable: true, role: "capture-helper", doctorVisible: true, - doctorSignatures: ['cam sync "$@"', "cam memory --recent"], - renderContents: () => buildPostWorkMemoryReviewScript() + doctorSignatures: ["cam sync --cwd", "cam memory --recent --cwd"], + renderContents: (context) => buildPostWorkMemoryReviewScript(context.projectRoot) }, { id: "memory-recall", @@ -319,11 +335,12 @@ cam doctor "$@" role: "recall-helper", doctorVisible: true, doctorSignatures: [ + "PROJECT_ROOT=", 'exec cam recall search "$@"', 'exec cam recall timeline "$@"', 'exec cam recall details "$@"' ], - renderContents: () => buildRecallDispatcherScript() + renderContents: (context) => buildRecallDispatcherScript(context.projectRoot) }, { id: "memory-search", @@ -370,7 +387,7 @@ cam doctor "$@" 'memory-recall.sh search "pnpm"', "Workflow discipline" ], - renderContents: () => buildRecallBridgeGuideMarkdown() + renderContents: (context) => buildRecallBridgeGuideMarkdown(context.projectRoot) }, { id: "codex-memory-skill", @@ -411,8 +428,11 @@ export function codexOfficialProjectSkillAssetDir(projectRoot: string): string { return resolveCodexSkillPaths(projectRoot).officialProjectSkillDir; } -export function buildHookAssets(homeDir = os.homedir()): GeneratedAsset[] { - return listIntegrationAssets(homeDir, "hooks").map((asset) => ({ +export function buildHookAssets( + homeDir = os.homedir(), + projectRoot = process.cwd() +): GeneratedAsset[] { + return listIntegrationAssets(homeDir, "hooks", { projectRoot }).map((asset) => ({ relativePath: asset.relativePath, contents: asset.contents, executable: asset.executableExpected @@ -458,9 +478,13 @@ export function listIntegrationAssets( } export function listDoctorVisibleIntegrationAssets( - homeDir = os.homedir() + homeDir = os.homedir(), + options: { + projectRoot?: string; + skillSurface?: CodexSkillInstallSurface; + } = {} ): InstalledIntegrationAssetDescriptor[] { - return listIntegrationAssets(homeDir).filter((asset) => asset.doctorVisible); + return listIntegrationAssets(homeDir, undefined, options).filter((asset) => asset.doctorVisible); } export function buildRecallBridgeSummaryLines(): string[] { diff --git a/src/lib/integration/codex-stack.ts b/src/lib/integration/codex-stack.ts index ae62bb4..3517896 100644 --- a/src/lib/integration/codex-stack.ts +++ b/src/lib/integration/codex-stack.ts @@ -29,6 +29,7 @@ export interface CodexStackReadiness { hookCaptureReady: boolean; hookRecallReady: boolean; skillReady: boolean; + workflowAssetsConsistent: boolean; workflowConsistent: boolean; } @@ -599,9 +600,12 @@ export function buildCodexIntegrationNextSteps( ); } - if (!readiness.workflowConsistent && (readiness.hookRecallReady || readiness.skillReady)) { + if ( + !readiness.workflowAssetsConsistent && + (readiness.hookRecallReady || readiness.skillReady) + ) { nextSteps.push( - `Re-run \`cam hooks install\` and \`${skillInstallCommand}\` to realign retrieval guidance and fallback assets.` + `Re-run \`${hooksInstallCommand}\` and \`${skillInstallCommand}\` to realign retrieval guidance and fallback assets.` ); } diff --git a/src/lib/integration/mcp-doctor.ts b/src/lib/integration/mcp-doctor.ts index 844587f..f9791e6 100644 --- a/src/lib/integration/mcp-doctor.ts +++ b/src/lib/integration/mcp-doctor.ts @@ -220,6 +220,7 @@ export interface McpDoctorReport { hookCaptureReady: boolean; hookRecallReady: boolean; skillReady: boolean; + workflowAssetsConsistent: boolean; workflowConsistent: boolean; notes: string[]; }; @@ -579,7 +580,9 @@ async function inspectFallbackAssets( explicitCwd?: boolean; } = {} ): Promise { - const descriptors = listDoctorVisibleIntegrationAssets(); + const descriptors = listDoctorVisibleIntegrationAssets(undefined, { + projectRoot + }); const hooksDir = descriptors.find((asset) => asset.installSurface === "hooks")?.path; const skillPaths = resolveCodexSkillPaths(projectRoot); const skillDir = skillPaths.runtimeAssetDir; @@ -757,13 +760,15 @@ function buildCodexStackReport( [...CODEX_HOOK_RECALL_ASSET_IDS] ); const skillReady = fallbackAssets.readySkillSurfaces.length > 0; - const workflowConsistent = + const workflowAssetsConsistent = isAssetReady( fallbackAssets.assets, [...CODEX_WORKFLOW_CONSISTENCY_ASSET_IDS] ) && fallbackAssets.postWorkReviewInstalled && - skillReady && + skillReady; + const workflowConsistent = + workflowAssetsConsistent && agentsGuidance.status === "ok"; const status = summarizeCodexIntegrationStatus([ mcpOperationalReady ? "ok" : mcpReady ? "warning" : "missing", @@ -797,6 +802,7 @@ function buildCodexStackReport( hookCaptureReady, hookRecallReady, skillReady, + workflowAssetsConsistent, workflowConsistent, notes }; diff --git a/test/dist-cli-smoke.test.ts b/test/dist-cli-smoke.test.ts index 613d202..94c7bdd 100644 --- a/test/dist-cli-smoke.test.ts +++ b/test/dist-cli-smoke.test.ts @@ -1418,6 +1418,28 @@ fs.writeFileSync(${JSON.stringify(capturedArgsPath)}, JSON.stringify(process.arg ) ).toContain("cam:asset-version"); + const hooksResult = runCli( + callerDir, + ["hooks", "install", "--cwd", projectDir], + { + entrypoint: "dist", + env + } + ); + expect(hooksResult.exitCode, hooksResult.stderr).toBe(0); + expect( + await fs.readFile( + path.join(homeDir, ".codex-auto-memory", "hooks", "memory-recall.sh"), + "utf8" + ) + ).toContain(`PROJECT_ROOT=${JSON.stringify(realProjectDir)}`); + expect( + await fs.readFile( + path.join(homeDir, ".codex-auto-memory", "hooks", "post-work-memory-review.sh"), + "utf8" + ) + ).toContain(`cam sync --cwd ${JSON.stringify(realProjectDir)} "$@"`); + const guidanceResult = runCli( callerDir, ["mcp", "apply-guidance", "--host", "codex", "--cwd", projectDir, "--json"], diff --git a/test/hooks-command.test.ts b/test/hooks-command.test.ts index fba9a1d..c17b880 100644 --- a/test/hooks-command.test.ts +++ b/test/hooks-command.test.ts @@ -39,6 +39,79 @@ afterEach(async () => { }); describe("hooks command", () => { + it("supports --cwd and pins generated hook helpers to the targeted project root", async () => { + const homeDir = await tempDir("cam-hooks-cwd-home-"); + const projectParentDir = await tempDir("cam-hooks-cwd-parent-"); + const projectDir = path.join(projectParentDir, "project with spaces"); + const shellDir = await tempDir("cam-hooks-cwd-shell-"); + const memoryRoot = await tempDir("cam-hooks-cwd-memory-"); + const binDir = await tempDir("cam-hooks-cwd-bin-"); + process.env.HOME = homeDir; + + await fs.mkdir(projectDir, { recursive: true }); + await writeCamConfig(projectDir, makeAppConfig(), { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...makeAppConfig(), + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const installResult = runCli( + shellDir, + ["hooks", "install", "--cwd", projectDir], + { env: { HOME: homeDir } } + ); + expect(installResult.exitCode, installResult.stderr).toBe(0); + + await writeCamShim(binDir); + const hooksDir = path.join(homeDir, ".codex-auto-memory", "hooks"); + const recallScriptPath = path.join(hooksDir, "memory-recall.sh"); + const postWorkReviewScriptPath = path.join(hooksDir, "post-work-memory-review.sh"); + const env = { + ...process.env, + HOME: homeDir, + PATH: `${binDir}:${process.env.PATH ?? ""}` + }; + + const searchResult = runCommandCapture( + recallScriptPath, + ["search", "pnpm", "--json"], + shellDir, + env + ); + expect(searchResult.exitCode, searchResult.stderr).toBe(0); + expect(JSON.parse(searchResult.stdout)).toMatchObject({ + results: [ + expect.objectContaining({ + ref: "project:active:workflow:prefer-pnpm" + }) + ] + }); + + const recallScript = await fs.readFile(recallScriptPath, "utf8"); + const postWorkReviewScript = await fs.readFile(postWorkReviewScriptPath, "utf8"); + expect(recallScript).toContain( + `PROJECT_ROOT=${JSON.stringify(await fs.realpath(projectDir))}` + ); + expect(postWorkReviewScript).toContain( + `cam sync --cwd ${JSON.stringify(await fs.realpath(projectDir))}` + ); + expect(postWorkReviewScript).toContain( + `exec cam memory --recent --cwd ${JSON.stringify(await fs.realpath(projectDir))}` + ); + }); + it("generates recall helper assets for hook and skill bridge flows", async () => { const homeDir = await tempDir("cam-hooks-home-"); const projectDir = await tempDir("cam-hooks-project-"); @@ -75,8 +148,10 @@ describe("hooks command", () => { "utf8" ); const recallGuide = await fs.readFile(path.join(hooksDir, "recall-bridge.md"), "utf8"); + const realProjectDir = await fs.realpath(projectDir); expect(recallScript).toContain('exec cam recall search "$@"'); + expect(recallScript).toContain(`PROJECT_ROOT=${JSON.stringify(realProjectDir)}`); expect(recallScript).toContain("--state"); expect(recallScript).toContain("auto"); expect(recallScript).toContain("--limit"); @@ -84,10 +159,17 @@ describe("hooks command", () => { expect(searchScript).toContain('exec "$SCRIPT_DIR/memory-recall.sh" search "$@"'); expect(timelineScript).toContain('exec "$SCRIPT_DIR/memory-recall.sh" timeline "$@"'); expect(detailsScript).toContain('exec "$SCRIPT_DIR/memory-recall.sh" details "$@"'); - expect(postWorkReviewScript).toContain('cam sync "$@"'); - expect(postWorkReviewScript).toContain("cam memory --recent"); + expect(postWorkReviewScript).toContain( + `cam sync --cwd ${JSON.stringify(realProjectDir)} "$@"` + ); + expect(postWorkReviewScript).toContain( + `exec cam memory --recent --cwd ${JSON.stringify(realProjectDir)}` + ); expect(recallGuide).toContain("search_memories"); expect(recallGuide).toContain("memory-recall.sh search"); + expect(recallGuide).toContain( + `cam recall search "pnpm" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + ); expect(recallGuide).toContain("cam memory"); expect(recallGuide).toContain("cam session"); expect(recallGuide).toContain("local bridge"); diff --git a/test/integrations-command.test.ts b/test/integrations-command.test.ts index add11a5..cbf8664 100644 --- a/test/integrations-command.test.ts +++ b/test/integrations-command.test.ts @@ -326,6 +326,56 @@ describe("integrations command", () => { ) ]) ); + expect(payload.nextSteps).not.toEqual( + expect.arrayContaining([expect.stringContaining("cam hooks install")]) + ); + expect(payload.nextSteps).not.toEqual( + expect.arrayContaining([expect.stringContaining("cam skills install")]) + ); + }); + + it("suggests a project-pinned hooks install command when hook helpers are missing", async () => { + const homeDir = await tempDir("cam-integrations-doctor-hooks-home-"); + const projectDir = await tempDir("cam-integrations-doctor-hooks-project-"); + const shellDir = await tempDir("cam-integrations-doctor-hooks-shell-"); + const binDir = await tempDir("cam-integrations-doctor-hooks-bin-"); + process.env.HOME = homeDir; + + await writeCamShim(binDir); + const env = { + HOME: homeDir, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}` + }; + + const installResult = runCli( + projectDir, + ["integrations", "install", "--host", "codex", "--json"], + { env } + ); + expect(installResult.exitCode, installResult.stderr).toBe(0); + + await fs.rm(path.join(homeDir, ".codex-auto-memory", "hooks"), { + recursive: true, + force: true + }); + + const result = runCli( + shellDir, + ["integrations", "doctor", "--host", "codex", "--cwd", projectDir, "--json"], + { env } + ); + expect(result.exitCode, result.stderr).toBe(0); + const payload = JSON.parse(result.stdout) as { + projectRoot: string; + nextSteps: string[]; + }; + expect(payload.nextSteps).toEqual( + expect.arrayContaining([ + expect.stringContaining( + `cam hooks install --cwd ${JSON.stringify(payload.projectRoot)}` + ) + ]) + ); }); it("surfaces a ready Codex integration stack through integrations doctor", async () => { diff --git a/test/memory-store.test.ts b/test/memory-store.test.ts index 67ea99a..4271ffd 100644 --- a/test/memory-store.test.ts +++ b/test/memory-store.test.ts @@ -424,6 +424,90 @@ describe("MemoryStore", () => { expect(timeline.map((event) => event.action)).toEqual(["archive", "add"]); }); + it("maintains thin retrieval sidecar indexes and falls back safely when one is invalid", async () => { + const projectDir = await tempDir("cam-store-retrieval-index-project-"); + const memoryRoot = await tempDir("cam-store-retrieval-index-memory-"); + const config: AppConfig = { + autoMemoryEnabled: true, + autoMemoryDirectory: memoryRoot, + extractorMode: "heuristic", + defaultScope: "project", + maxStartupLines: 200, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false, + sessionContinuityLocalPathStyle: "codex", + maxSessionContinuityLines: 60, + codexBinary: "codex" + }; + const store = new MemoryStore(detectProjectContext(projectDir), config); + await store.ensureLayout(); + + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await store.remember( + "project", + "workflow", + "historical-note", + "Historical pnpm migration note.", + ["Old pnpm migration note kept for history."], + "Manual note." + ); + await store.forget("project", "historical", { archive: true }); + + const activeIndexPath = store.getRetrievalIndexFile("project", "active"); + const archivedIndexPath = store.getRetrievalIndexFile("project", "archived"); + expect(JSON.parse(await fs.readFile(activeIndexPath, "utf8"))).toMatchObject({ + version: 1, + scope: "project", + state: "active", + entries: [ + expect.objectContaining({ + ref: "project:active:workflow:prefer-pnpm", + summary: "Prefer pnpm in this repository." + }) + ] + }); + expect(JSON.parse(await fs.readFile(archivedIndexPath, "utf8"))).toMatchObject({ + version: 1, + scope: "project", + state: "archived", + entries: [ + expect.objectContaining({ + ref: "project:archived:workflow:historical-note", + summary: "Historical pnpm migration note." + }) + ] + }); + + await fs.writeFile(activeIndexPath, "{not-json", "utf8"); + const fallbackResults = await store.searchEntries("prefer pnpm", { + scope: "project", + state: "active" + }); + expect(fallbackResults).toEqual([ + expect.objectContaining({ + ref: "project:active:workflow:prefer-pnpm" + }) + ]); + + await fs.rm(archivedIndexPath, { force: true }); + await store.ensureLayout(); + expect(JSON.parse(await fs.readFile(activeIndexPath, "utf8"))).toMatchObject({ + version: 1, + state: "active" + }); + expect(JSON.parse(await fs.readFile(archivedIndexPath, "utf8"))).toMatchObject({ + version: 1, + state: "archived" + }); + }); + it("fails closed across all scopes when all-scope archive forget hits an unsafe topic file", async () => { const projectDir = await tempDir("cam-store-unsafe-all-archive-project-"); const memoryRoot = await tempDir("cam-store-unsafe-all-archive-memory-"); diff --git a/test/recall-command.test.ts b/test/recall-command.test.ts index 4d34226..6e965dd 100644 --- a/test/recall-command.test.ts +++ b/test/recall-command.test.ts @@ -5,7 +5,9 @@ import { afterEach, describe, expect, it } from "vitest"; import { detectProjectContext } from "../src/lib/domain/project-context.js"; import { MemoryStore } from "../src/lib/domain/memory-store.js"; import { restoreOptionalEnv } from "./helpers/env.js"; +import { SyncService } from "../src/lib/domain/sync-service.js"; import { + makeRolloutFixture, makeAppConfig, writeCamConfig } from "./helpers/cam-test-fixtures.js"; @@ -325,6 +327,55 @@ describe("runRecall", () => { }); }); + it("surfaces session provenance in timeline output after rollout sync", async () => { + const homeDir = await tempDir("cam-recall-provenance-home-"); + const projectDir = await tempDir("cam-recall-provenance-project-"); + const memoryRoot = await tempDir("cam-recall-provenance-memory-"); + const rolloutPath = path.join(projectDir, "rollout.jsonl"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + await fs.writeFile( + rolloutPath, + makeRolloutFixture(projectDir, "Remember that this repository prefers pnpm.", { + sessionId: "session-provenance" + }), + "utf8" + ); + + const service = new SyncService(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await service.syncRollout(rolloutPath, true); + + const searchResult = runCli(projectDir, ["recall", "search", "prefers pnpm", "--json"]); + expect(searchResult.exitCode).toBe(0); + const searchOutput = JSON.parse(searchResult.stdout) as { + results: Array<{ ref: string }>; + }; + expect(searchOutput.results).toHaveLength(1); + + const timelineResult = runCli( + projectDir, + ["recall", "timeline", searchOutput.results[0]!.ref, "--json"] + ); + expect(timelineResult.exitCode).toBe(0); + expect(JSON.parse(timelineResult.stdout)).toMatchObject({ + ref: searchOutput.results[0]!.ref, + events: expect.arrayContaining([ + expect.objectContaining({ + sessionId: "session-provenance", + rolloutPath + }) + ]) + }); + }); + it("keeps recall search read-only and does not create memory layout on first lookup", async () => { const homeDir = await tempDir("cam-recall-readonly-home-"); const projectDir = await tempDir("cam-recall-readonly-project-"); diff --git a/test/tarball-install-smoke.test.ts b/test/tarball-install-smoke.test.ts index 3c70715..158b109 100644 --- a/test/tarball-install-smoke.test.ts +++ b/test/tarball-install-smoke.test.ts @@ -239,9 +239,26 @@ describe("tarball install smoke", () => { "SKILL.md" ), "utf8" - ) + ) ).toContain("cam:asset-version"); + const cwdHooksResult = runCommandCapture( + camBinaryPath(installDir), + ["hooks", "install", "--cwd", projectWithSpacesDir], + shellDir, + envWithBin + ); + expect(cwdHooksResult.exitCode).toBe(0); + expect( + await fs.readFile(path.join(homeDir, ".codex-auto-memory", "hooks", "memory-recall.sh"), "utf8") + ).toContain(`PROJECT_ROOT=${JSON.stringify(realProjectWithSpacesDir)}`); + expect( + await fs.readFile( + path.join(homeDir, ".codex-auto-memory", "hooks", "post-work-memory-review.sh"), + "utf8" + ) + ).toContain(`cam sync --cwd ${JSON.stringify(realProjectWithSpacesDir)} "$@"`); + const cwdApplyGuidanceResult = runCommandCapture( camBinaryPath(installDir), ["mcp", "apply-guidance", "--host", "codex", "--cwd", projectWithSpacesDir, "--json"], From 632c99fccf21012d2af9c345e855db9a3369b087 Mon Sep 17 00:00:00 2001 From: blocks Date: Fri, 27 Mar 2026 01:02:34 +0800 Subject: [PATCH 09/62] feat: harden retrieval sidecar diagnostics and provenance --- docs/release-checklist.md | 4 + src/lib/commands/recall.ts | 16 +- src/lib/domain/memory-retrieval-contract.ts | 6 + src/lib/domain/memory-retrieval.ts | 41 +++- src/lib/domain/memory-store.ts | 199 ++++++++++++++++---- src/lib/domain/sync-service.ts | 3 +- src/lib/integration/codex-stack.ts | 15 +- src/lib/mcp/retrieval-server.ts | 6 + src/lib/types.ts | 8 + test/mcp-command.test.ts | 96 +++++++++- test/memory-store.test.ts | 11 ++ test/recall-command.test.ts | 79 +++++++- 12 files changed, 427 insertions(+), 57 deletions(-) diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 93abb79..bbf3ed0 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -44,8 +44,11 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Run `node dist/cli.js session status --json` and confirm the latest explicit audit drill-down matches the newest audit-log entry when present. - Run `node dist/cli.js memory --recent --json` and confirm suppressed conflict candidates remain reviewer-visible instead of being silently merged. - Run `node dist/cli.js recall search pnpm --json` and confirm the default search contract stays aligned at `state=auto, limit=8`, returning compact refs before any full detail fetch. +- Confirm `node dist/cli.js recall search pnpm --json` now also reports whether the search used the retrieval sidecar or fell back to Markdown scan through additive `retrievalMode` / `retrievalFallbackReason` fields. - Run `node dist/cli.js recall details --json` for one returned ref and confirm the path resolves to Markdown-backed memory, including archived refs when relevant. +- Confirm `node dist/cli.js recall details --json` now also exposes additive provenance summary fields such as `latestLifecycleAction`, `latestSessionId`, `latestRolloutPath`, and `historyPath`. - Run a local MCP smoke against `node dist/cli.js mcp serve` and confirm `search_memories`, `timeline_memories`, and `get_memory_details` are exposed as a read-only retrieval plane. +- Confirm `search_memories` mirrors the CLI retrieval diagnostics through additive `retrievalMode` / `retrievalFallbackReason` fields, and `timeline_memories` / `get_memory_details` keep lifecycle provenance aligned with the CLI retrieval surface. - Run `node dist/cli.js mcp install --host --json` and confirm the result contract includes `host`, `serverName`, `projectRoot`, `targetPath`, `action`, `projectPinned`, and `readOnlyRetrieval`. - Re-run the same `node dist/cli.js mcp install --host --json` command once and confirm it returns `action: "unchanged"` when the target host config is already canonical. - Confirm `node dist/cli.js mcp install --host --json` preserves non-canonical custom fields already attached to the `codex_auto_memory` entry instead of dropping them silently. @@ -78,6 +81,7 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Run `node dist/cli.js integrations apply --host codex --skill-surface official-project --json` and confirm the selected project-scoped skill surface still flows through the full apply path. - Run `node dist/cli.js integrations doctor --host codex --json` and confirm it reports the thin Codex-only stack readiness view with `recommendedRoute`, `recommendedPreset`, `subchecks`, and `nextSteps`. - Confirm `node dist/cli.js integrations doctor --host codex --json` also exposes the shared structured `workflowContract`, including the post-work sync/review helper semantics, and now reports `applyReadiness` so unsafe AGENTS managed blocks are diagnosed before recommending `cam integrations apply --host codex`. +- Confirm `workflowConsistency` wording in doctor surfaces now explicitly treats repo-level `AGENTS.md` guidance as part of the shared retrieval workflow contract, not just hooks/skills text. - Treat key `--help` output as release-facing contract, not incidental CLI text: - `node dist/cli.js mcp install --help` should keep the supported install-host list at `codex, claude, or gemini`, leaving `generic` out of the install branch. - `node dist/cli.js mcp print-config --help` should keep the supported snippet-host list at `codex, claude, gemini, or generic`. diff --git a/src/lib/commands/recall.ts b/src/lib/commands/recall.ts index 60720c9..d98ea37 100644 --- a/src/lib/commands/recall.ts +++ b/src/lib/commands/recall.ts @@ -29,7 +29,8 @@ function formatSearchResults(response: MemorySearchResponse): string { "Codex Auto Memory Recall Search", `Query: ${response.query}`, `Scope: ${response.scope} | Requested state: ${response.state} | Resolved state: ${response.resolvedState} | Results: ${response.results.length}`, - `Archived fallback used: ${response.fallbackUsed ? "yes" : "no"}` + `Archived fallback used: ${response.fallbackUsed ? "yes" : "no"}`, + `Retrieval mode: ${response.retrievalMode}${response.retrievalFallbackReason ? ` (${response.retrievalFallbackReason})` : ""}` ]; if (response.results.length === 0) { @@ -72,6 +73,9 @@ function formatTimeline(ref: string, timeline: MemoryTimelineEvent[]): string { if (event.source) { lines.push(` Source: ${event.source}`); } + if (event.sessionId) { + lines.push(` Session: ${event.sessionId}`); + } if (event.rolloutPath) { lines.push(` Rollout: ${event.rolloutPath}`); } @@ -85,13 +89,23 @@ function formatDetails(details: MemoryDetailsResult): string { "Codex Auto Memory Recall Details", `Ref: ${details.ref}`, `Path: ${details.path}`, + `History: ${details.historyPath}`, `Scope: ${details.scope} | State: ${details.state} | Topic: ${details.topic}`, `Updated: ${details.entry.updatedAt}`, + `Latest lifecycle action: ${details.latestLifecycleAction ?? "unknown"}`, `Summary: ${details.entry.summary}`, "Details:", ...details.entry.details.map((detail) => `- ${detail}`) ]; + if (details.latestSessionId) { + lines.push(`Latest session: ${details.latestSessionId}`); + } + + if (details.latestRolloutPath) { + lines.push(`Latest rollout: ${details.latestRolloutPath}`); + } + if (details.entry.sources.length > 0) { lines.push("Sources:", ...details.entry.sources.map((source) => `- ${source}`)); } diff --git a/src/lib/domain/memory-retrieval-contract.ts b/src/lib/domain/memory-retrieval-contract.ts index 0b029eb..97d299b 100644 --- a/src/lib/domain/memory-retrieval-contract.ts +++ b/src/lib/domain/memory-retrieval-contract.ts @@ -1,5 +1,7 @@ import type { MemoryDetailsResult, + MemoryRetrievalFallbackReason, + MemoryRetrievalMode, MemoryRecordState, MemoryRetrievalResolvedState, MemoryRetrievalScope, @@ -71,6 +73,8 @@ export function buildMemorySearchResponse( state: MemoryRetrievalStateFilter, resolvedState: MemoryRetrievalResolvedState, fallbackUsed: boolean, + retrievalMode: MemoryRetrievalMode, + retrievalFallbackReason: MemoryRetrievalFallbackReason | undefined, results: MemorySearchResult[] ): MemorySearchResponse { return { @@ -79,6 +83,8 @@ export function buildMemorySearchResponse( state, resolvedState, fallbackUsed, + retrievalMode, + retrievalFallbackReason, results }; } diff --git a/src/lib/domain/memory-retrieval.ts b/src/lib/domain/memory-retrieval.ts index 33ecaea..dbcd69e 100644 --- a/src/lib/domain/memory-retrieval.ts +++ b/src/lib/domain/memory-retrieval.ts @@ -28,32 +28,59 @@ export class MemoryRetrievalService { const limit = options.limit ?? DEFAULT_MEMORY_RETRIEVAL_LIMIT; if (state === "auto") { - const activeResults = await this.memoryStore.searchEntries(query, { + const activeSearch = await this.memoryStore.searchEntriesWithDiagnostics(query, { scope, state: "active", limit }); - if (activeResults.length > 0) { - return buildMemorySearchResponse(query, scope, state, "active", false, activeResults); + if (activeSearch.results.length > 0) { + return buildMemorySearchResponse( + query, + scope, + state, + "active", + false, + activeSearch.retrievalMode, + activeSearch.retrievalFallbackReason, + activeSearch.results + ); } - const archivedResults = await this.memoryStore.searchEntries(query, { + const archivedSearch = await this.memoryStore.searchEntriesWithDiagnostics(query, { scope, state: "archived", limit }); - return buildMemorySearchResponse(query, scope, state, "archived", true, archivedResults); + return buildMemorySearchResponse( + query, + scope, + state, + "archived", + true, + archivedSearch.retrievalMode, + archivedSearch.retrievalFallbackReason, + archivedSearch.results + ); } - const results = await this.memoryStore.searchEntries(query, { + const search = await this.memoryStore.searchEntriesWithDiagnostics(query, { scope, state, limit }); - return buildMemorySearchResponse(query, scope, state, state, false, results); + return buildMemorySearchResponse( + query, + scope, + state, + state, + false, + search.retrievalMode, + search.retrievalFallbackReason, + search.results + ); } public async timelineMemories(ref: string): Promise { diff --git a/src/lib/domain/memory-store.ts b/src/lib/domain/memory-store.ts index 0057b08..9b19e5f 100644 --- a/src/lib/domain/memory-store.ts +++ b/src/lib/domain/memory-store.ts @@ -10,6 +10,8 @@ import type { MemoryMutation, MemoryOperation, MemoryRecordState, + MemoryRetrievalFallbackReason, + MemoryRetrievalMode, MemorySearchResult, MemoryScope, MemorySyncAuditEntry, @@ -82,9 +84,23 @@ interface RetrievalIndexPayload { version: 1; scope: MemoryScope; state: MemoryRecordState; + generatedAt: string; + topicFiles: string[]; + topicFileCount: number; entries: RetrievalIndexEntry[]; } +interface RetrievalIndexInspection { + payload: RetrievalIndexPayload | null; + fallbackReason?: MemoryRetrievalFallbackReason; +} + +interface MemorySearchExecution { + results: MemorySearchResult[]; + retrievalMode: MemoryRetrievalMode; + retrievalFallbackReason?: MemoryRetrievalFallbackReason; +} + interface PlannedFileChange { path: string; contents: string | null; @@ -343,10 +359,16 @@ function buildRetrievalIndexContents( state: MemoryRecordState, entries: MemoryEntry[] ): string { + const topicFiles = Array.from( + new Set(entries.map((entry) => `${entry.topic}.md`)) + ).sort((left, right) => left.localeCompare(right)); const payload: RetrievalIndexPayload = { version: retrievalIndexVersion, scope, state, + generatedAt: new Date().toISOString(), + topicFiles, + topicFileCount: topicFiles.length, entries: buildRetrievalIndexEntries(scope, state, entries) }; @@ -469,6 +491,10 @@ function isRetrievalIndexPayload(value: unknown): value is RetrievalIndexPayload payload.version === retrievalIndexVersion && isMemoryScope(payload.scope) && (payload.state === "active" || payload.state === "archived") && + typeof payload.generatedAt === "string" && + isStringArray(payload.topicFiles) && + typeof payload.topicFileCount === "number" && + payload.topicFileCount === payload.topicFiles.length && Array.isArray(payload.entries) && payload.entries.every((entry) => isRetrievalIndexEntry(entry)) ); @@ -614,6 +640,22 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function fileChangePriority(change: PlannedFileChange): number { + if (change.path.endsWith("retrieval-index.json")) { + return 3; + } + + if (change.path.endsWith("memory-history.jsonl")) { + return 2; + } + + if (change.path.endsWith("MEMORY.md") || change.path.endsWith("ARCHIVE.md")) { + return 1; + } + + return 0; +} + const defaultMemoryStoreFileOps: MemoryStoreFileOps = { writeTextFile: writeTextFileAtomic, async deleteFile(filePath: string): Promise { @@ -719,58 +761,98 @@ export class MemoryStore { return state === "active" ? this.getTopicFile(scope, topic) : this.getArchiveTopicFile(scope, topic); } - private async readRetrievalIndex( + private async listTopicMarkdownFiles( scope: MemoryScope, state: MemoryRecordState - ): Promise { + ): Promise { + const baseDir = state === "active" ? this.getScopeDir(scope) : this.getArchiveDir(scope); + if (!(await fileExists(baseDir))) { + return []; + } + + return (await fs.readdir(baseDir)) + .filter( + (fileName) => + fileName.endsWith(".md") && + fileName !== "MEMORY.md" && + fileName !== "ARCHIVE.md" + ) + .filter((fileName) => topicNamePattern.test(fileName.replace(/\.md$/u, ""))) + .sort((left, right) => left.localeCompare(right)); + } + + private async inspectRetrievalIndex( + scope: MemoryScope, + state: MemoryRecordState + ): Promise { const retrievalIndexPath = this.getRetrievalIndexFile(scope, state); if (!(await fileExists(retrievalIndexPath))) { - return null; + return { + payload: null, + fallbackReason: "missing" + }; } let payload: unknown; try { payload = JSON.parse(await readTextFile(retrievalIndexPath)) as unknown; } catch { - return null; + return { + payload: null, + fallbackReason: "invalid" + }; } if (!isRetrievalIndexPayload(payload)) { - return null; + return { + payload: null, + fallbackReason: "invalid" + }; } if (payload.scope !== scope || payload.state !== state) { - return null; + return { + payload: null, + fallbackReason: "invalid" + }; } - if (await this.isRetrievalIndexStale(scope, state, retrievalIndexPath)) { - return null; + if (await this.isRetrievalIndexStale(scope, state, retrievalIndexPath, payload)) { + return { + payload: null, + fallbackReason: "stale" + }; } - return payload; + return { + payload + }; + } + + private async readRetrievalIndex( + scope: MemoryScope, + state: MemoryRecordState + ): Promise { + return (await this.inspectRetrievalIndex(scope, state)).payload; } private async isRetrievalIndexStale( scope: MemoryScope, state: MemoryRecordState, - retrievalIndexPath: string + retrievalIndexPath: string, + payload: RetrievalIndexPayload ): Promise { - const baseDir = state === "active" ? this.getScopeDir(scope) : this.getArchiveDir(scope); - if (!(await fileExists(baseDir))) { - return false; + const topicFiles = await this.listTopicMarkdownFiles(scope, state); + if ( + payload.topicFileCount !== topicFiles.length || + JSON.stringify(payload.topicFiles) !== JSON.stringify(topicFiles) + ) { + return true; } const indexStats = await fs.stat(retrievalIndexPath); - const files = await fs.readdir(baseDir); - for (const fileName of files) { - if ( - !fileName.endsWith(".md") || - fileName === "MEMORY.md" || - fileName === "ARCHIVE.md" - ) { - continue; - } - + const baseDir = state === "active" ? this.getScopeDir(scope) : this.getArchiveDir(scope); + for (const fileName of topicFiles) { const stats = await fs.stat(path.join(baseDir, fileName)); if (stats.mtimeMs > indexStats.mtimeMs) { return true; @@ -993,6 +1075,7 @@ export class MemoryStore { return null; } + const latestEvent = (await this.readTimeline(ref))[0] ?? null; return { ...parsed, entry, @@ -1000,18 +1083,22 @@ export class MemoryStore { parsed.state === "active" ? this.getTopicFile(parsed.scope, parsed.topic) : this.getArchiveTopicFile(parsed.scope, parsed.topic), - approxReadCost: entry.details.length + 4 + approxReadCost: entry.details.length + 4, + latestLifecycleAction: latestEvent?.action ?? null, + latestSessionId: latestEvent?.sessionId ?? null, + latestRolloutPath: latestEvent?.rolloutPath ?? null, + historyPath: this.getHistoryPath(parsed.scope) }; } - public async searchEntries( + public async searchEntriesWithDiagnostics( query: string, options: { scope?: MemoryScope | "all"; state?: MemoryRecordState | "all"; limit?: number; } = {} - ): Promise { + ): Promise { const scopes: MemoryScope[] = options.scope && options.scope !== "all" ? [options.scope] @@ -1021,12 +1108,16 @@ export class MemoryStore { ? ["active", "archived"] : [options.state ?? "active"]; const results: Array = []; + let usedFallback = false; + let fallbackReason: MemoryRetrievalFallbackReason | undefined; + let matchedViaIndex = false; + let matchedViaFallback = false; for (const scope of scopes) { for (const state of states) { - const retrievalIndex = await this.readRetrievalIndex(scope, state); - if (retrievalIndex) { - for (const entry of retrievalIndex.entries) { + const retrievalIndex = await this.inspectRetrievalIndex(scope, state); + if (retrievalIndex.payload) { + for (const entry of retrievalIndex.payload.entries) { const match = findRetrievalIndexSearchMatch(entry, query); if (!match) { continue; @@ -1044,10 +1135,13 @@ export class MemoryStore { approxReadCost: entry.approxReadCost, score: match.score }); + matchedViaIndex = true; } continue; } + usedFallback = true; + fallbackReason ??= retrievalIndex.fallbackReason ?? "missing"; const entries = await this.listEntries(scope, state); for (const entry of entries) { const match = findEntrySearchMatch(entry, query); @@ -1067,11 +1161,12 @@ export class MemoryStore { approxReadCost: entry.details.length + 4, score: match.score }); + matchedViaFallback = true; } } } - return results + const normalizedResults = results .sort((left, right) => { if (right.score !== left.score) { return right.score - left.score; @@ -1080,6 +1175,27 @@ export class MemoryStore { }) .slice(0, options.limit ?? 10) .map(({ score: _score, ...result }) => result); + + return { + results: normalizedResults, + retrievalMode: + matchedViaFallback || (!matchedViaIndex && usedFallback) + ? "markdown-fallback" + : "index", + retrievalFallbackReason: + matchedViaFallback || (!matchedViaIndex && usedFallback) ? fallbackReason : undefined + }; + } + + public async searchEntries( + query: string, + options: { + scope?: MemoryScope | "all"; + state?: MemoryRecordState | "all"; + limit?: number; + } = {} + ): Promise { + return (await this.searchEntriesWithDiagnostics(query, options)).results; } public async readTimeline(ref: string): Promise { @@ -1148,6 +1264,7 @@ export class MemoryStore { mutations: MemoryMutation[], options: { sessionId?: string; + rolloutPath?: string; } = {} ): Promise { const applied: MemoryApplyRecord[] = []; @@ -1278,7 +1395,9 @@ export class MemoryStore { reason: mutation.reason, source: mutation.sources?.[0], sessionId: options.sessionId, - rolloutPath: mutation.sources?.find((source) => source.endsWith(".jsonl")) + rolloutPath: + options.rolloutPath ?? + mutation.sources?.find((source) => source.endsWith(".jsonl")) }); continue; } @@ -1351,7 +1470,9 @@ export class MemoryStore { reason: appliedOperation.reason, source: appliedOperation.sources?.[0], sessionId: options.sessionId, - rolloutPath: appliedOperation.sources?.find((source) => source.endsWith(".jsonl")) + rolloutPath: + options.rolloutPath ?? + appliedOperation.sources?.find((source) => source.endsWith(".jsonl")) }); continue; } @@ -1406,7 +1527,9 @@ export class MemoryStore { reason: appliedOperation.reason, source: appliedOperation.sources?.[0], sessionId: options.sessionId, - rolloutPath: appliedOperation.sources?.find((source) => source.endsWith(".jsonl")) + rolloutPath: + options.rolloutPath ?? + appliedOperation.sources?.find((source) => source.endsWith(".jsonl")) }); } @@ -1523,7 +1646,13 @@ export class MemoryStore { const snapshots = await this.captureFileSnapshots(fileChanges); const writes = fileChanges .filter((change): change is PlannedFileChange & { contents: string } => change.contents !== null) - .sort((left, right) => left.path.localeCompare(right.path)); + .sort((left, right) => { + const priorityDifference = fileChangePriority(left) - fileChangePriority(right); + if (priorityDifference !== 0) { + return priorityDifference; + } + return left.path.localeCompare(right.path); + }); const deletes = fileChanges .filter((change) => change.contents === null) .sort((left, right) => left.path.localeCompare(right.path)); @@ -1552,6 +1681,7 @@ export class MemoryStore { operations: MemoryOperation[], options: { sessionId?: string; + rolloutPath?: string; } = {} ): Promise { const applied = await this.applyMutations(operations, options); @@ -1565,6 +1695,7 @@ export class MemoryStore { mutations: MemoryMutation[], options: { sessionId?: string; + rolloutPath?: string; } = {} ): Promise { await this.ensureLayout(); diff --git a/src/lib/domain/sync-service.ts b/src/lib/domain/sync-service.ts index 6a51d38..464f3f1 100644 --- a/src/lib/domain/sync-service.ts +++ b/src/lib/domain/sync-service.ts @@ -144,7 +144,8 @@ export class SyncService { existingEntries ); const applyRecords = await this.store.applyMutations(reviewedOperations.operations, { - sessionId: evidence.sessionId + sessionId: evidence.sessionId, + rolloutPath: evidence.rolloutPath }); const applied = applyRecords.flatMap((record) => { const operation = toAppliedOperation(record); diff --git a/src/lib/integration/codex-stack.ts b/src/lib/integration/codex-stack.ts index 3517896..22997c4 100644 --- a/src/lib/integration/codex-stack.ts +++ b/src/lib/integration/codex-stack.ts @@ -364,20 +364,23 @@ export function buildCodexStackNotes(): string[] { "Run `cam mcp print-config --host codex` to inspect the recommended project-scoped MCP wiring together with an AGENTS.md snippet for Codex agents.", "Run `cam mcp apply-guidance --host codex` to create or update the managed Codex Auto Memory block inside the repository-level AGENTS.md.", "Codex skill readiness is guidance-only and does not replace executable hook fallback helpers.", - "Workflow consistency expects the shared search -> timeline -> details contract and the recommended preset to stay aligned across hooks and skills." + "Workflow consistency expects AGENTS guidance, hooks, and skills to stay aligned on the shared search -> timeline -> details contract and recommended preset." ]; } export function buildCodexAgentsGuidance(): CodexAgentsGuidance { const workflowContract = buildWorkflowContract(); + const sharedLines = buildSharedWorkflowDisciplineLines(); const snippet = [ "## Codex Auto Memory", "", ``, + `- ${sharedLines[0]}`, + `- ${sharedLines[1]}`, `- ${workflowContract.routePreference.mcpFirst}`, `- ${buildRecommendedMcpSearchInstruction()}`, - `- If the retrieval MCP server is unavailable, fall back to \`${buildRecommendedCliSearchCommand()}\`, then \`cam recall timeline \"\"\`, then \`cam recall details \"\"\`.`, - ...buildSharedWorkflowDisciplineLines().slice(2).map((line) => `- ${line}`), + `- If the retrieval MCP server is unavailable, fall back to \`${workflowContract.cliFallback.searchCommand}\`, then \`${workflowContract.cliFallback.timelineCommand}\`, then \`${workflowContract.cliFallback.detailsCommand}\`.`, + ...sharedLines.slice(2).map((line) => `- ${line}`), `- When the local bridge bundle is installed, \`${workflowContract.postWorkSyncReview.helperScript}\` combines \`${workflowContract.postWorkSyncReview.syncCommand}\` with \`${workflowContract.postWorkSyncReview.reviewCommand}\`.`, `- ${LOCAL_BRIDGE_BUNDLE_NOTE}` ].join("\n"); @@ -508,17 +511,17 @@ export function buildCodexIntegrationSubchecks( ? { status: "ok", summary: - "Hooks and skills agree on the shared search -> timeline -> details workflow and preset." + "AGENTS guidance, hooks, and skills agree on the shared search -> timeline -> details workflow and preset." } : assetAvailability.hasWorkflowAssets ? { status: "warning", summary: - "Some integration assets exist, but they do not fully agree on the shared retrieval workflow yet." + "Some AGENTS, hook, or skill assets exist, but they do not fully agree on the shared retrieval workflow yet." } : { status: "missing", - summary: "Shared retrieval workflow assets have not been installed yet." + summary: "Shared AGENTS, hook, and skill workflow assets have not been installed yet." } }; } diff --git a/src/lib/mcp/retrieval-server.ts b/src/lib/mcp/retrieval-server.ts index d16a97e..781255a 100644 --- a/src/lib/mcp/retrieval-server.ts +++ b/src/lib/mcp/retrieval-server.ts @@ -45,6 +45,8 @@ const memorySearchResponseSchema = z.object({ state: retrievalStateSchema, resolvedState: resolvedRetrievalStateSchema, fallbackUsed: z.boolean(), + retrievalMode: z.enum(["index", "markdown-fallback"]), + retrievalFallbackReason: z.enum(["missing", "invalid", "stale"]).optional(), results: z.array(memorySearchResultSchema) }); @@ -76,6 +78,10 @@ const memoryDetailsResponseSchema = z.object({ id: z.string(), path: z.string(), approxReadCost: z.number().int().nonnegative(), + latestLifecycleAction: memoryLifecycleActionSchema.nullable(), + latestSessionId: z.string().nullable(), + latestRolloutPath: z.string().nullable(), + historyPath: z.string(), entry: z.object({ id: z.string(), scope: z.enum(["global", "project", "project-local"]), diff --git a/src/lib/types.ts b/src/lib/types.ts index 8932dd9..b57c1cc 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -5,6 +5,8 @@ export type MemoryLifecycleAction = "add" | "update" | "delete" | "archive" | "n export type MemoryRetrievalScope = MemoryScope | "all"; export type MemoryRetrievalResolvedState = MemoryRecordState | "all"; export type MemoryRetrievalStateFilter = MemoryRetrievalResolvedState | "auto"; +export type MemoryRetrievalMode = "index" | "markdown-fallback"; +export type MemoryRetrievalFallbackReason = "missing" | "invalid" | "stale"; export type SessionContinuityScope = "project" | "project-local"; export type SessionContinuityLocalPathStyle = "codex" | "claude"; export type SessionContinuityWriteMode = "merge" | "replace"; @@ -67,6 +69,8 @@ export interface MemorySearchResponse { state: MemoryRetrievalStateFilter; resolvedState: MemoryRetrievalResolvedState; fallbackUsed: boolean; + retrievalMode: MemoryRetrievalMode; + retrievalFallbackReason?: MemoryRetrievalFallbackReason; results: MemorySearchResult[]; } @@ -94,6 +98,10 @@ export interface MemoryDetailsResult extends MemoryRef { entry: MemoryEntry; path: string; approxReadCost: number; + latestLifecycleAction: Exclude | null; + latestSessionId: string | null; + latestRolloutPath: string | null; + historyPath: string; } export interface MemoryApplyRecord { diff --git a/test/mcp-command.test.ts b/test/mcp-command.test.ts index df5b739..137e5e1 100644 --- a/test/mcp-command.test.ts +++ b/test/mcp-command.test.ts @@ -20,6 +20,8 @@ interface SearchMemoriesResponse { state: string; resolvedState: string; fallbackUsed: boolean; + retrievalMode: string; + retrievalFallbackReason?: string; results: Array<{ ref: string; state: string; @@ -34,12 +36,18 @@ interface TimelineMemoriesResponse { events: Array<{ action: string; state: string; + sessionId?: string; + rolloutPath?: string; }>; } interface MemoryDetailsResponse { ref: string; path: string; + latestLifecycleAction: string; + latestSessionId: string | null; + latestRolloutPath: string | null; + historyPath: string; entry: { summary: string; details: string[]; @@ -1453,6 +1461,22 @@ describe("mcp command", () => { exists: false, status: "missing" }); + const integrationsDoctor = runCli( + projectDir, + ["integrations", "doctor", "--host", "codex", "--json"], + { + env: { HOME: homeDir } + } + ); + expect(integrationsDoctor.exitCode, integrationsDoctor.stderr).toBe(0); + expect(JSON.parse(integrationsDoctor.stdout)).toMatchObject({ + subchecks: { + workflowConsistency: { + status: "warning", + summary: expect.stringContaining("AGENTS") + } + } + }); expect(payload.hosts).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -2440,7 +2464,8 @@ describe("mcp command", () => { query: "pnpm", state: "archived", resolvedState: "archived", - fallbackUsed: false + fallbackUsed: false, + retrievalMode: "index" }); expect(searchPayload.results).toHaveLength(1); expect(searchPayload.results[0]).toMatchObject({ @@ -2477,6 +2502,10 @@ describe("mcp command", () => { expect(detailsPayload).toMatchObject({ ref, path: store.getArchiveTopicFile("project", "workflow"), + latestLifecycleAction: "archive", + latestSessionId: null, + latestRolloutPath: null, + historyPath: store.getHistoryPath("project"), entry: { summary: "Prefer pnpm in this repository.", details: ["Use pnpm instead of npm in this repository."] @@ -2603,7 +2632,8 @@ describe("mcp command", () => { query: "pnpm", state: "auto", resolvedState: "active", - fallbackUsed: false + fallbackUsed: false, + retrievalMode: "index" }); expect(preferredPayload.results).toEqual([ expect.objectContaining({ @@ -2628,7 +2658,8 @@ describe("mcp command", () => { query: "historical", state: "auto", resolvedState: "archived", - fallbackUsed: true + fallbackUsed: true, + retrievalMode: "index" }); expect(fallbackPayload.results).toEqual([ expect.objectContaining({ @@ -2687,7 +2718,8 @@ describe("mcp command", () => { query: "historical", state: "auto", resolvedState: "archived", - fallbackUsed: true + fallbackUsed: true, + retrievalMode: "index" }); expect(payload.results).toHaveLength(8); expect(payload.results.every((entry) => entry.state === "archived")).toBe(true); @@ -2788,7 +2820,11 @@ describe("mcp command", () => { arguments: { query: "pnpm", limit: 3 } }); const payload = readStructuredContent(result as ToolCallResultLike); - expect(payload.results).toEqual([]); + expect(payload).toMatchObject({ + retrievalMode: "markdown-fallback", + retrievalFallbackReason: "missing", + results: [] + }); } finally { await client.close(); } @@ -2819,4 +2855,54 @@ describe("mcp command", () => { }); expect(await pathExists(memoryRoot)).toBe(false); }); + + it("surfaces markdown fallback diagnostics for invalid retrieval sidecars over MCP", async () => { + const homeDir = await tempDir("cam-mcp-invalid-sidecar-home-"); + const projectDir = await tempDir("cam-mcp-invalid-sidecar-project-"); + const memoryRoot = await tempDir("cam-mcp-invalid-sidecar-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await fs.writeFile(store.getRetrievalIndexFile("project", "active"), "{not-json", "utf8"); + + const client = await connectCliMcpClient(projectDir, { + env: { HOME: homeDir } + }); + + try { + const result = await client.callTool({ + name: "search_memories", + arguments: { + query: "prefer pnpm", + state: "active", + limit: 5 + } + }); + const payload = readStructuredContent(result as ToolCallResultLike); + expect(payload).toMatchObject({ + retrievalMode: "markdown-fallback", + retrievalFallbackReason: "invalid", + results: [expect.objectContaining({ ref: "project:active:workflow:prefer-pnpm" })] + }); + } finally { + await client.close(); + } + }, 30_000); }); diff --git a/test/memory-store.test.ts b/test/memory-store.test.ts index 4271ffd..a7ab18a 100644 --- a/test/memory-store.test.ts +++ b/test/memory-store.test.ts @@ -466,6 +466,8 @@ describe("MemoryStore", () => { version: 1, scope: "project", state: "active", + topicFiles: ["workflow.md"], + topicFileCount: 1, entries: [ expect.objectContaining({ ref: "project:active:workflow:prefer-pnpm", @@ -477,6 +479,8 @@ describe("MemoryStore", () => { version: 1, scope: "project", state: "archived", + topicFiles: ["workflow.md"], + topicFileCount: 1, entries: [ expect.objectContaining({ ref: "project:archived:workflow:historical-note", @@ -496,6 +500,13 @@ describe("MemoryStore", () => { }) ]); + await fs.rm(store.getTopicFile("project", "workflow"), { force: true }); + const staleResults = await store.searchEntries("prefer pnpm", { + scope: "project", + state: "active" + }); + expect(staleResults).toEqual([]); + await fs.rm(archivedIndexPath, { force: true }); await store.ensureLayout(); expect(JSON.parse(await fs.readFile(activeIndexPath, "utf8"))).toMatchObject({ diff --git a/test/recall-command.test.ts b/test/recall-command.test.ts index 6e965dd..8d26d50 100644 --- a/test/recall-command.test.ts +++ b/test/recall-command.test.ts @@ -64,12 +64,15 @@ describe("runRecall", () => { state: string; resolvedState: string; fallbackUsed: boolean; + retrievalMode: string; + retrievalFallbackReason?: string; results: Array<{ ref: string; state: string; topic: string }>; }; expect(output).toMatchObject({ state: "auto", resolvedState: "archived", - fallbackUsed: true + fallbackUsed: true, + retrievalMode: "index" }); expect(output.results).toHaveLength(8); expect(output.results.every((result) => result.state === "archived")).toBe(true); @@ -116,12 +119,14 @@ describe("runRecall", () => { state: string; resolvedState: string; fallbackUsed: boolean; + retrievalMode: string; results: Array<{ ref: string; state: string; topic: string }>; }; expect(output).toMatchObject({ state: "auto", resolvedState: "active", - fallbackUsed: false + fallbackUsed: false, + retrievalMode: "index" }); expect(output.results).toEqual([ expect.objectContaining({ @@ -172,12 +177,14 @@ describe("runRecall", () => { state: string; resolvedState: string; fallbackUsed: boolean; + retrievalMode: string; results: Array<{ ref: string; state: string; topic: string }>; }; expect(searchOutput).toMatchObject({ state: "auto", resolvedState: "archived", - fallbackUsed: true + fallbackUsed: true, + retrievalMode: "index" }); expect(searchOutput.results).toEqual([ expect.objectContaining({ @@ -250,11 +257,19 @@ describe("runRecall", () => { const detailsOutput = JSON.parse(detailsResult.stdout) as { ref: string; path: string; + latestLifecycleAction: string; + latestSessionId: string | null; + latestRolloutPath: string | null; + historyPath: string; entry: { summary: string }; }; expect(detailsOutput).toMatchObject({ ref, path: store.getArchiveTopicFile("project", "workflow"), + latestLifecycleAction: "archive", + latestSessionId: null, + latestRolloutPath: null, + historyPath: store.getHistoryPath("project"), entry: { summary: "Prefer pnpm in this repository." } @@ -352,6 +367,7 @@ describe("runRecall", () => { autoMemoryDirectory: memoryRoot }); await service.syncRollout(rolloutPath, true); + const store = service.memoryStore; const searchResult = runCli(projectDir, ["recall", "search", "prefers pnpm", "--json"]); expect(searchResult.exitCode).toBe(0); @@ -374,6 +390,23 @@ describe("runRecall", () => { }) ]) }); + + const timelineTextResult = runCli(projectDir, ["recall", "timeline", searchOutput.results[0]!.ref]); + expect(timelineTextResult.exitCode).toBe(0); + expect(timelineTextResult.stdout).toContain("Session: session-provenance"); + expect(timelineTextResult.stdout).toContain(`Rollout: ${rolloutPath}`); + + const detailsResult = runCli( + projectDir, + ["recall", "details", searchOutput.results[0]!.ref, "--json"] + ); + expect(detailsResult.exitCode).toBe(0); + expect(JSON.parse(detailsResult.stdout)).toMatchObject({ + latestLifecycleAction: "add", + latestSessionId: "session-provenance", + latestRolloutPath: rolloutPath, + historyPath: store.getHistoryPath("project") + }); }); it("keeps recall search read-only and does not create memory layout on first lookup", async () => { @@ -394,12 +427,16 @@ describe("runRecall", () => { state: string; resolvedState: string; fallbackUsed: boolean; + retrievalMode: string; + retrievalFallbackReason?: string; results: unknown[]; }; expect(output).toMatchObject({ state: "auto", resolvedState: "archived", fallbackUsed: true, + retrievalMode: "markdown-fallback", + retrievalFallbackReason: "missing", results: [] }); @@ -423,4 +460,40 @@ describe("runRecall", () => { expect(detailsResult.exitCode).toBe(1); expect(detailsResult.stderr).toContain("Invalid memory ref"); }); + + it("surfaces markdown fallback diagnostics when the retrieval sidecar is invalid", async () => { + const homeDir = await tempDir("cam-recall-invalid-sidecar-home-"); + const projectDir = await tempDir("cam-recall-invalid-sidecar-project-"); + const memoryRoot = await tempDir("cam-recall-invalid-sidecar-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + await fs.writeFile(store.getRetrievalIndexFile("project", "active"), "{not-json", "utf8"); + + const result = runCli(projectDir, ["recall", "search", "prefer pnpm", "--state", "active", "--json"]); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + retrievalMode: "markdown-fallback", + retrievalFallbackReason: "invalid", + results: [expect.objectContaining({ ref: "project:active:workflow:prefer-pnpm" })] + }); + }); }); From 18a3d0118a3bf001d86c0f86a0f0b5d974a487dd Mon Sep 17 00:00:00 2001 From: blocks Date: Fri, 27 Mar 2026 20:24:37 +0800 Subject: [PATCH 10/62] feat: surface retrieval health and audit diagnostics --- docs/release-checklist.md | 5 + src/lib/commands/hooks.ts | 4 +- src/lib/commands/integrations.ts | 7 +- src/lib/commands/recall.ts | 20 +- src/lib/commands/skills.ts | 4 +- src/lib/domain/memory-retrieval-contract.ts | 9 + src/lib/domain/memory-retrieval.ts | 8 + src/lib/domain/memory-store.ts | 206 +++++++++++++++++--- src/lib/integration/agents-guidance.ts | 6 +- src/lib/integration/assets.ts | 26 ++- src/lib/integration/codex-stack.ts | 38 ++-- src/lib/integration/mcp-doctor.ts | 62 +++++- src/lib/integration/retrieval-contract.ts | 8 +- src/lib/mcp/retrieval-server.ts | 35 ++++ src/lib/types.ts | 28 +++ test/docs-contract.test.ts | 3 + test/integrations-command.test.ts | 11 ++ test/mcp-command.test.ts | 194 +++++++++++++++++- test/recall-command.test.ts | 119 ++++++++++- test/skills-command.test.ts | 5 + 20 files changed, 732 insertions(+), 66 deletions(-) diff --git a/docs/release-checklist.md b/docs/release-checklist.md index bbf3ed0..8ee87fb 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -45,10 +45,13 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Run `node dist/cli.js memory --recent --json` and confirm suppressed conflict candidates remain reviewer-visible instead of being silently merged. - Run `node dist/cli.js recall search pnpm --json` and confirm the default search contract stays aligned at `state=auto, limit=8`, returning compact refs before any full detail fetch. - Confirm `node dist/cli.js recall search pnpm --json` now also reports whether the search used the retrieval sidecar or fell back to Markdown scan through additive `retrievalMode` / `retrievalFallbackReason` fields. +- Confirm `node dist/cli.js recall search pnpm --json` now also exposes additive per-path diagnostics through `diagnostics.checkedPaths`, so mixed index/fallback searches stay reviewer-visible instead of collapsing into a single top-level mode. - Run `node dist/cli.js recall details --json` for one returned ref and confirm the path resolves to Markdown-backed memory, including archived refs when relevant. - Confirm `node dist/cli.js recall details --json` now also exposes additive provenance summary fields such as `latestLifecycleAction`, `latestSessionId`, `latestRolloutPath`, and `historyPath`. +- Confirm `node dist/cli.js recall details --json` now also exposes additive `latestAudit` provenance so a reviewer can jump from lifecycle state to the latest sync-audit summary without manually correlating sidecars. - Run a local MCP smoke against `node dist/cli.js mcp serve` and confirm `search_memories`, `timeline_memories`, and `get_memory_details` are exposed as a read-only retrieval plane. - Confirm `search_memories` mirrors the CLI retrieval diagnostics through additive `retrievalMode` / `retrievalFallbackReason` fields, and `timeline_memories` / `get_memory_details` keep lifecycle provenance aligned with the CLI retrieval surface. +- Confirm `search_memories` now also mirrors CLI search diagnostics through additive `diagnostics.checkedPaths`, and `get_memory_details` mirrors CLI detail provenance through additive `latestAudit`. - Run `node dist/cli.js mcp install --host --json` and confirm the result contract includes `host`, `serverName`, `projectRoot`, `targetPath`, `action`, `projectPinned`, and `readOnlyRetrieval`. - Re-run the same `node dist/cli.js mcp install --host --json` command once and confirm it returns `action: "unchanged"` when the target host config is already canonical. - Confirm `node dist/cli.js mcp install --host --json` preserves non-canonical custom fields already attached to the `codex_auto_memory` entry instead of dropping them silently. @@ -62,6 +65,7 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Confirm `node dist/cli.js mcp apply-guidance --host codex --json` ignores fenced-code examples of the managed markers, and that `node dist/cli.js mcp doctor --json` does not treat fenced examples as installed guidance. - Run `node dist/cli.js mcp doctor --json` and confirm it reports project-scoped host wiring, project pinning, and hook / skill fallback assets without creating memory layout or mutating host config files. - Run `node dist/cli.js mcp doctor --host codex --json` and confirm the payload also exposes the structured `workflowContract`, including the current CLI fallback commands and post-work sync/review helper contract. +- Confirm `node dist/cli.js mcp doctor --host codex --json` now also exposes additive `retrievalSidecar` readiness, including per-scope/per-state sidecar status, fallback reason, and the guarantee that degraded sidecars still fall back safely to Markdown canonical recall. - Confirm `node dist/cli.js mcp doctor --host codex --json` distinguishes alternate global wiring from the recommended project-scoped route through additive scope/reporting fields instead of treating them as the same readiness state. - Confirm `node dist/cli.js mcp doctor --host codex --json` now exposes `configScopeSummary` and `alternateWiring`, so valid alternate global wiring stays distinct from malformed or shape-mismatched global host config. - Confirm `node dist/cli.js mcp doctor --host codex --json` distinguishes skill-surface presence, canonical content, and readiness through additive fields such as `runtimeSkillPresent`, `officialUserSkillMatchesCanonical`, `officialProjectSkillMatchesCanonical`, `anySkillSurfaceInstalled`, and `anySkillSurfaceReady`. @@ -81,6 +85,7 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Run `node dist/cli.js integrations apply --host codex --skill-surface official-project --json` and confirm the selected project-scoped skill surface still flows through the full apply path. - Run `node dist/cli.js integrations doctor --host codex --json` and confirm it reports the thin Codex-only stack readiness view with `recommendedRoute`, `recommendedPreset`, `subchecks`, and `nextSteps`. - Confirm `node dist/cli.js integrations doctor --host codex --json` also exposes the shared structured `workflowContract`, including the post-work sync/review helper semantics, and now reports `applyReadiness` so unsafe AGENTS managed blocks are diagnosed before recommending `cam integrations apply --host codex`. +- Confirm `node dist/cli.js integrations doctor --host codex --json` also surfaces the additive `retrievalSidecar` summary from `mcp doctor`, so retrieval-plane degradation is visible before the user has to run a recall command manually. - Confirm `workflowConsistency` wording in doctor surfaces now explicitly treats repo-level `AGENTS.md` guidance as part of the shared retrieval workflow contract, not just hooks/skills text. - Treat key `--help` output as release-facing contract, not incidental CLI text: - `node dist/cli.js mcp install --help` should keep the supported install-host list at `codex, claude, or gemini`, leaving `generic` out of the install branch. diff --git a/src/lib/commands/hooks.ts b/src/lib/commands/hooks.ts index b141e3a..e914231 100644 --- a/src/lib/commands/hooks.ts +++ b/src/lib/commands/hooks.ts @@ -23,7 +23,9 @@ export async function installHooks(options: HooksCommandOptions = {}): Promise + `${check.scope}/${check.state}=${check.retrievalMode}${check.retrievalFallbackReason ? `(${check.retrievalFallbackReason})` : ""}:${check.matchedCount}` + ) + .join("; "); const lines = [ "Codex Auto Memory Recall Search", `Query: ${response.query}`, `Scope: ${response.scope} | Requested state: ${response.state} | Resolved state: ${response.resolvedState} | Results: ${response.results.length}`, `Archived fallback used: ${response.fallbackUsed ? "yes" : "no"}`, - `Retrieval mode: ${response.retrievalMode}${response.retrievalFallbackReason ? ` (${response.retrievalFallbackReason})` : ""}` + `Retrieval mode: ${response.retrievalMode}${response.retrievalFallbackReason ? ` (${response.retrievalFallbackReason})` : ""}`, + `Diagnostics: ${diagnosticsSummary}` ]; if (response.results.length === 0) { @@ -106,6 +116,14 @@ function formatDetails(details: MemoryDetailsResult): string { lines.push(`Latest rollout: ${details.latestRolloutPath}`); } + if (details.latestAudit) { + lines.push( + `Latest audit: ${details.latestAudit.status} at ${details.latestAudit.appliedAt}`, + `Latest audit path: ${details.latestAudit.auditPath}`, + `Latest audit summary: ${details.latestAudit.resultSummary}` + ); + } + if (details.entry.sources.length > 0) { lines.push("Sources:", ...details.entry.sources.map((source) => `- ${source}`)); } diff --git a/src/lib/commands/skills.ts b/src/lib/commands/skills.ts index 949740a..0340b18 100644 --- a/src/lib/commands/skills.ts +++ b/src/lib/commands/skills.ts @@ -35,7 +35,9 @@ export async function installSkills(options: SkillsCommandOptions = {}): Promise skillSurface === "runtime" ? "This keeps the current runtime-first install target unchanged." : "This writes an explicit official .agents/skills copy without changing the runtime-first default.", - ...buildRecallBridgeSummaryLines(), + ...buildRecallBridgeSummaryLines({ + cwd: projectRoot + }), "If a host prefers shell-based fallback helpers, run cam hooks install to generate memory-recall.sh, compatibility wrappers, and recall-bridge.md." ].join("\n"); } diff --git a/src/lib/domain/memory-retrieval-contract.ts b/src/lib/domain/memory-retrieval-contract.ts index 97d299b..19fcfbc 100644 --- a/src/lib/domain/memory-retrieval-contract.ts +++ b/src/lib/domain/memory-retrieval-contract.ts @@ -1,5 +1,6 @@ import type { MemoryDetailsResult, + MemorySearchDiagnostics, MemoryRetrievalFallbackReason, MemoryRetrievalMode, MemoryRecordState, @@ -75,6 +76,7 @@ export function buildMemorySearchResponse( fallbackUsed: boolean, retrievalMode: MemoryRetrievalMode, retrievalFallbackReason: MemoryRetrievalFallbackReason | undefined, + diagnostics: MemorySearchDiagnostics, results: MemorySearchResult[] ): MemorySearchResponse { return { @@ -85,6 +87,7 @@ export function buildMemorySearchResponse( fallbackUsed, retrievalMode, retrievalFallbackReason, + diagnostics, results }; } @@ -155,6 +158,12 @@ export function toMemorySearchResultShapes( export function toMemoryDetailsResultShape(details: MemoryDetailsResult): MemoryDetailsResult { return { ...details, + latestAudit: details.latestAudit + ? { + ...details.latestAudit, + conflicts: [...details.latestAudit.conflicts] + } + : null, entry: { ...details.entry, details: [...details.entry.details], diff --git a/src/lib/domain/memory-retrieval.ts b/src/lib/domain/memory-retrieval.ts index dbcd69e..41df68d 100644 --- a/src/lib/domain/memory-retrieval.ts +++ b/src/lib/domain/memory-retrieval.ts @@ -43,6 +43,7 @@ export class MemoryRetrievalService { false, activeSearch.retrievalMode, activeSearch.retrievalFallbackReason, + activeSearch.diagnostics, activeSearch.results ); } @@ -61,6 +62,12 @@ export class MemoryRetrievalService { true, archivedSearch.retrievalMode, archivedSearch.retrievalFallbackReason, + { + checkedPaths: [ + ...activeSearch.diagnostics.checkedPaths, + ...archivedSearch.diagnostics.checkedPaths + ] + }, archivedSearch.results ); } @@ -79,6 +86,7 @@ export class MemoryRetrievalService { false, search.retrievalMode, search.retrievalFallbackReason, + search.diagnostics, search.results ); } diff --git a/src/lib/domain/memory-store.ts b/src/lib/domain/memory-store.ts index 9b19e5f..73ad852 100644 --- a/src/lib/domain/memory-store.ts +++ b/src/lib/domain/memory-store.ts @@ -10,10 +10,13 @@ import type { MemoryMutation, MemoryOperation, MemoryRecordState, + MemorySearchDiagnosticPath, + MemorySearchDiagnostics, MemoryRetrievalFallbackReason, MemoryRetrievalMode, MemorySearchResult, MemoryScope, + MemorySyncAuditSummary, MemorySyncAuditEntry, MemoryTimelineEvent, ProcessedRolloutIdentity, @@ -91,14 +94,31 @@ interface RetrievalIndexPayload { } interface RetrievalIndexInspection { + status: "ok" | "missing" | "invalid" | "stale"; + indexPath: string; payload: RetrievalIndexPayload | null; fallbackReason?: MemoryRetrievalFallbackReason; + generatedAt: string | null; + topicFileCount: number | null; + topicFiles: string[]; } interface MemorySearchExecution { results: MemorySearchResult[]; retrievalMode: MemoryRetrievalMode; retrievalFallbackReason?: MemoryRetrievalFallbackReason; + diagnostics: MemorySearchDiagnostics; +} + +export interface RetrievalSidecarCheck { + scope: MemoryScope; + state: MemoryRecordState; + status: RetrievalIndexInspection["status"]; + indexPath: string; + fallbackReason?: MemoryRetrievalFallbackReason; + generatedAt: string | null; + topicFileCount: number | null; + topicFiles: string[]; } interface PlannedFileChange { @@ -788,8 +808,13 @@ export class MemoryStore { const retrievalIndexPath = this.getRetrievalIndexFile(scope, state); if (!(await fileExists(retrievalIndexPath))) { return { + status: "missing", + indexPath: retrievalIndexPath, payload: null, - fallbackReason: "missing" + fallbackReason: "missing", + generatedAt: null, + topicFileCount: null, + topicFiles: [] }; } @@ -798,37 +823,84 @@ export class MemoryStore { payload = JSON.parse(await readTextFile(retrievalIndexPath)) as unknown; } catch { return { + status: "invalid", + indexPath: retrievalIndexPath, payload: null, - fallbackReason: "invalid" + fallbackReason: "invalid", + generatedAt: null, + topicFileCount: null, + topicFiles: [] }; } if (!isRetrievalIndexPayload(payload)) { return { + status: "invalid", + indexPath: retrievalIndexPath, payload: null, - fallbackReason: "invalid" + fallbackReason: "invalid", + generatedAt: null, + topicFileCount: null, + topicFiles: [] }; } if (payload.scope !== scope || payload.state !== state) { return { + status: "invalid", + indexPath: retrievalIndexPath, payload: null, - fallbackReason: "invalid" + fallbackReason: "invalid", + generatedAt: null, + topicFileCount: null, + topicFiles: [] }; } if (await this.isRetrievalIndexStale(scope, state, retrievalIndexPath, payload)) { return { + status: "stale", + indexPath: retrievalIndexPath, payload: null, - fallbackReason: "stale" + fallbackReason: "stale", + generatedAt: payload.generatedAt, + topicFileCount: payload.topicFileCount, + topicFiles: [...payload.topicFiles] }; } return { - payload + status: "ok", + indexPath: retrievalIndexPath, + payload, + generatedAt: payload.generatedAt, + topicFileCount: payload.topicFileCount, + topicFiles: [...payload.topicFiles] }; } + public async inspectRetrievalSidecars(): Promise { + const checks: RetrievalSidecarCheck[] = []; + + for (const scope of ["global", "project", "project-local"] satisfies MemoryScope[]) { + for (const state of ["active", "archived"] satisfies MemoryRecordState[]) { + const inspection = await this.inspectRetrievalIndex(scope, state); + checks.push({ + scope, + state, + status: inspection.status, + indexPath: inspection.indexPath, + fallbackReason: inspection.fallbackReason, + generatedAt: inspection.generatedAt, + topicFileCount: inspection.topicFileCount, + topicFiles: [...inspection.topicFiles] + }); + } + } + + return checks; + } + private async readRetrievalIndex( scope: MemoryScope, state: MemoryRecordState @@ -1076,6 +1148,13 @@ export class MemoryStore { } const latestEvent = (await this.readTimeline(ref))[0] ?? null; + const latestAudit = await this.findLatestSyncAuditSummary( + parsed.scope, + parsed.topic, + parsed.id, + latestEvent?.rolloutPath, + latestEvent?.sessionId + ); return { ...parsed, entry, @@ -1087,7 +1166,8 @@ export class MemoryStore { latestLifecycleAction: latestEvent?.action ?? null, latestSessionId: latestEvent?.sessionId ?? null, latestRolloutPath: latestEvent?.rolloutPath ?? null, - historyPath: this.getHistoryPath(parsed.scope) + historyPath: this.getHistoryPath(parsed.scope), + latestAudit }; } @@ -1108,6 +1188,7 @@ export class MemoryStore { ? ["active", "archived"] : [options.state ?? "active"]; const results: Array = []; + const diagnostics: MemorySearchDiagnosticPath[] = []; let usedFallback = false; let fallbackReason: MemoryRetrievalFallbackReason | undefined; let matchedViaIndex = false; @@ -1116,6 +1197,7 @@ export class MemoryStore { for (const scope of scopes) { for (const state of states) { const retrievalIndex = await this.inspectRetrievalIndex(scope, state); + let matchedCount = 0; if (retrievalIndex.payload) { for (const entry of retrievalIndex.payload.entries) { const match = findRetrievalIndexSearchMatch(entry, query); @@ -1123,6 +1205,7 @@ export class MemoryStore { continue; } + matchedCount += 1; results.push({ ref: entry.ref, scope: entry.scope, @@ -1137,6 +1220,14 @@ export class MemoryStore { }); matchedViaIndex = true; } + diagnostics.push({ + scope, + state, + retrievalMode: "index", + matchedCount, + indexPath: retrievalIndex.indexPath, + generatedAt: retrievalIndex.generatedAt + }); continue; } @@ -1149,6 +1240,7 @@ export class MemoryStore { continue; } + matchedCount += 1; results.push({ ref: buildMemoryRef(scope, state, entry.topic, entry.id), scope, @@ -1163,6 +1255,15 @@ export class MemoryStore { }); matchedViaFallback = true; } + diagnostics.push({ + scope, + state, + retrievalMode: "markdown-fallback", + retrievalFallbackReason: retrievalIndex.fallbackReason ?? "missing", + matchedCount, + indexPath: retrievalIndex.indexPath, + generatedAt: retrievalIndex.generatedAt + }); } } @@ -1183,7 +1284,10 @@ export class MemoryStore { ? "markdown-fallback" : "index", retrievalFallbackReason: - matchedViaFallback || (!matchedViaIndex && usedFallback) ? fallbackReason : undefined + matchedViaFallback || (!matchedViaIndex && usedFallback) ? fallbackReason : undefined, + diagnostics: { + checkedPaths: diagnostics + } }; } @@ -1852,6 +1956,71 @@ export class MemoryStore { return typeof limit === "number" ? parsed.slice(0, limit) : parsed; } + private async readSyncAuditEntries(): Promise { + const auditPath = this.getSyncAuditPath(); + if (!(await fileExists(auditPath))) { + return []; + } + + const raw = await readTextFile(auditPath); + return raw + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .flatMap((line) => { + try { + const parsed = parseMemorySyncAuditEntry(JSON.parse(line) as unknown); + return parsed ? [parsed] : []; + } catch { + return []; + } + }) + .sort((left, right) => right.appliedAt.localeCompare(left.appliedAt)); + } + + private async findLatestSyncAuditSummary( + scope: MemoryScope, + topic: string, + id: string, + latestRolloutPath?: string, + latestSessionId?: string + ): Promise { + const entries = await this.readSyncAuditEntries(); + const matched = + entries.find( + (entry) => + latestRolloutPath !== undefined && + entry.rolloutPath === latestRolloutPath && + (latestSessionId === undefined || entry.sessionId === latestSessionId) && + entry.operations.some( + (operation) => + operation.scope === scope && operation.topic === topic && operation.id === id + ) + ) ?? + entries.find((entry) => + entry.operations.some( + (operation) => + operation.scope === scope && operation.topic === topic && operation.id === id + ) + ); + + if (!matched) { + return null; + } + + return { + auditPath: this.getSyncAuditPath(), + appliedAt: matched.appliedAt, + rolloutPath: matched.rolloutPath, + sessionId: matched.sessionId, + status: matched.status, + resultSummary: matched.resultSummary, + noopOperationCount: matched.noopOperationCount ?? 0, + suppressedOperationCount: matched.suppressedOperationCount ?? 0, + conflicts: matched.conflicts ?? [] + }; + } + private async appendHistoryEntry(entry: MemoryTimelineEvent): Promise { await appendJsonl(this.getHistoryPath(entry.scope), entry); } @@ -1904,26 +2073,7 @@ export class MemoryStore { } public async readRecentSyncAuditEntries(limit = 5): Promise { - const auditPath = this.getSyncAuditPath(); - if (!(await fileExists(auditPath))) { - return []; - } - - const raw = await readTextFile(auditPath); - return raw - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .flatMap((line) => { - try { - const parsed = parseMemorySyncAuditEntry(JSON.parse(line) as unknown); - return parsed ? [parsed] : []; - } catch { - return []; - } - }) - .slice(-limit) - .reverse(); + return (await this.readSyncAuditEntries()).slice(0, limit); } public async writeSyncRecoveryRecord(record: SyncRecoveryRecord): Promise { diff --git a/src/lib/integration/agents-guidance.ts b/src/lib/integration/agents-guidance.ts index b591120..c89f5c9 100644 --- a/src/lib/integration/agents-guidance.ts +++ b/src/lib/integration/agents-guidance.ts @@ -101,7 +101,7 @@ async function inspectCodexAgentsGuidanceApply( notes, exists, currentContents: null, - managedBlock: buildCodexAgentsManagedBlock(), + managedBlock: buildCodexAgentsManagedBlock("\n", { cwd: projectRoot }), lineEnding: "\n", unsafeManagedBlock: false, hasManagedBlock: false, @@ -112,7 +112,9 @@ async function inspectCodexAgentsGuidanceApply( const currentContents = await readTextFile(targetPath); const parsed = parseCodexAgentsGuidanceContents(currentContents); - const managedBlock = buildCodexAgentsManagedBlock(parsed.lineEnding); + const managedBlock = buildCodexAgentsManagedBlock(parsed.lineEnding, { + cwd: projectRoot + }); const alreadyCurrent = parsed.managedBlock !== null && normalizeManagedBlockForComparison(parsed.managedBlock.contents) === diff --git a/src/lib/integration/assets.ts b/src/lib/integration/assets.ts index 7d26ce8..b2323a2 100644 --- a/src/lib/integration/assets.ts +++ b/src/lib/integration/assets.ts @@ -4,6 +4,8 @@ import { ARCHIVE_BOUNDARY, appendCliCwdFlag, buildCliSearchCommand, + buildCliDetailsCommand, + buildCliTimelineCommand, buildMarkdownAssetVersionComment, buildRecommendedCliSearchCommand, buildRecommendedSearchPresetGuidance, @@ -220,7 +222,7 @@ exec ${buildPostWorkRecentReviewCommand({ cwd: projectRoot })} `; } -function buildCodexSkillMarkdown(): string { +function buildCodexSkillMarkdown(projectRoot: string): string { return `--- name: codex-auto-memory-recall description: Search Codex Auto Memory before repeating work. Use when the user asks whether we solved something before, asks for prior repo-specific decisions, or wants past fixes, preferences, or architecture context. @@ -256,15 +258,15 @@ Recommended MCP-first search preset: Otherwise fall back to the CLI workflow: 1. Search first: - \`${buildRecommendedCliSearchCommand()}\` + \`${buildRecommendedCliSearchCommand("\"\"", { cwd: projectRoot })}\` 2. Inspect timeline for promising refs: - \`cam recall timeline ""\` + \`${buildCliTimelineCommand("\"\"", { cwd: projectRoot })}\` 3. Fetch full details only for the refs that still look relevant: - \`cam recall details ""\` + \`${buildCliDetailsCommand("\"\"", { cwd: projectRoot })}\` If you need both active and archived results in one pass instead of active-first fallback: -- \`${buildCliSearchCommand("\"\"", { state: "all" })}\` +- \`${buildCliSearchCommand("\"\"", { state: "all", cwd: projectRoot })}\` ## Guardrails @@ -273,8 +275,8 @@ If you need both active and archived results in one pass instead of active-first - \`cam mcp serve\` exposes the same retrieval contract over stdio MCP when the host can consume it. - If you are unsure whether retrieval MCP is wired into the current host, run \`cam mcp doctor\`. - If a host needs shell-based fallback assets, run \`cam hooks install\` and use the generated recall bridge bundle. -- If available, run \`${POST_WORK_SYNC_REVIEW_HELPER}\` to combine \`${buildPostWorkSyncCommand()}\` with \`${buildPostWorkRecentReviewCommand()}\`. -- After finishing work that should update durable memory, run \`cam sync\` or review \`cam memory --recent\`. +- If available, run \`${POST_WORK_SYNC_REVIEW_HELPER}\` to combine \`${buildPostWorkSyncCommand({ cwd: projectRoot })}\` with \`${buildPostWorkRecentReviewCommand({ cwd: projectRoot })}\`. +- After finishing work that should update durable memory, run \`${buildPostWorkSyncCommand({ cwd: projectRoot })}\` or review \`${buildPostWorkRecentReviewCommand({ cwd: projectRoot })}\`. - Use \`cam memory\` for inspect/audit surfaces, startup payload, and recent sync review. - Use \`cam session\` only for temporary continuity, not durable memory retrieval. - Treat archived memory as historical context that does not participate in default startup recall. @@ -397,7 +399,7 @@ ${appendCliCwdFlag("cam doctor", context.projectRoot)} "$@" role: "guidance", doctorVisible: true, doctorSignatures: ["name: codex-auto-memory-recall", "search_memories"], - renderContents: () => buildCodexSkillMarkdown() + renderContents: (context) => buildCodexSkillMarkdown(context.projectRoot) } ] as const; @@ -487,6 +489,10 @@ export function listDoctorVisibleIntegrationAssets( return listIntegrationAssets(homeDir, undefined, options).filter((asset) => asset.doctorVisible); } -export function buildRecallBridgeSummaryLines(): string[] { - return buildRecommendedRetrievalSummaryLines(); +export function buildRecallBridgeSummaryLines( + options: { + cwd?: string; + } = {} +): string[] { + return buildRecommendedRetrievalSummaryLines(options); } diff --git a/src/lib/integration/codex-stack.ts b/src/lib/integration/codex-stack.ts index 22997c4..5967b30 100644 --- a/src/lib/integration/codex-stack.ts +++ b/src/lib/integration/codex-stack.ts @@ -1,3 +1,4 @@ +import * as path from "node:path"; import { appendCliCwdFlag, buildCliDetailsCommand, @@ -218,11 +219,14 @@ function isClosingFence(line: string, fence: GuidanceFenceState): boolean { } export function buildCodexAgentsManagedBlock( - lineEnding: "\n" | "\r\n" | "\r" = "\n" + lineEnding: "\n" | "\r\n" | "\r" = "\n", + options: { + cwd?: string; + } = {} ): string { return [ CODEX_AGENTS_MANAGED_BLOCK_START, - buildCodexAgentsGuidance().snippet, + buildCodexAgentsGuidance(options).snippet, CODEX_AGENTS_MANAGED_BLOCK_END ].join("\n").replace(/\n/g, lineEnding); } @@ -352,15 +356,19 @@ export function summarizeCodexIntegrationStatus( return hasOk ? "ok" : "missing"; } -export function buildCodexStackNotes(): string[] { - const workflowContract = buildWorkflowContract(); +export function buildCodexStackNotes( + options: { + cwd?: string; + } = {} +): string[] { + const workflowContract = buildWorkflowContract(options); return [ READ_ONLY_RETRIEVAL_NOTE, LOCAL_BRIDGE_BUNDLE_NOTE, "Recommended route prefers project-scoped MCP, then local bridge recall helpers, then direct cam recall CLI usage.", `Recommended retrieval preset: ${workflowContract.recommendedPreset}.`, ...buildSharedWorkflowDisciplineLines().slice(2), - `When the local bridge bundle is installed, prefer \`${workflowContract.postWorkSyncReview.helperScript}\` to combine \`${buildPostWorkSyncCommand()}\` with \`${buildPostWorkRecentReviewCommand()}\`.`, + `When the local bridge bundle is installed, prefer \`${workflowContract.postWorkSyncReview.helperScript}\` to combine \`${workflowContract.postWorkSyncReview.syncCommand}\` with \`${workflowContract.postWorkSyncReview.reviewCommand}\`.`, "Run `cam mcp print-config --host codex` to inspect the recommended project-scoped MCP wiring together with an AGENTS.md snippet for Codex agents.", "Run `cam mcp apply-guidance --host codex` to create or update the managed Codex Auto Memory block inside the repository-level AGENTS.md.", "Codex skill readiness is guidance-only and does not replace executable hook fallback helpers.", @@ -368,8 +376,12 @@ export function buildCodexStackNotes(): string[] { ]; } -export function buildCodexAgentsGuidance(): CodexAgentsGuidance { - const workflowContract = buildWorkflowContract(); +export function buildCodexAgentsGuidance( + options: { + cwd?: string; + } = {} +): CodexAgentsGuidance { + const workflowContract = buildWorkflowContract(options); const sharedLines = buildSharedWorkflowDisciplineLines(); const snippet = [ "## Codex Auto Memory", @@ -405,12 +417,12 @@ export function detectCodexAgentsGuidanceVersion(contents: string): string | nul } export function inspectCodexAgentsGuidance( - path: string, + filePath: string, contents: string | null ): CodexAgentsGuidanceInspection { if (contents === null) { return { - path, + path: filePath, exists: false, status: "missing", expectedVersion: CODEX_AGENTS_GUIDANCE_VERSION, @@ -423,7 +435,11 @@ export function inspectCodexAgentsGuidance( const parsed = parseCodexAgentsGuidanceContents(contents); const inspectionTarget = parsed.managedBlock?.body ?? parsed.visibleText; const normalizedTarget = normalizeLineEndings(inspectionTarget); - const expectedSnippet = normalizeLineEndings(buildCodexAgentsGuidance().snippet); + const expectedSnippet = normalizeLineEndings( + buildCodexAgentsGuidance({ + cwd: path.dirname(filePath) + }).snippet + ); const detectedVersion = detectCodexAgentsGuidanceVersion(normalizedTarget); const matchedSignatures = CODEX_AGENTS_REQUIRED_SIGNATURES.filter((signature) => normalizedTarget.includes(signature) @@ -436,7 +452,7 @@ export function inspectCodexAgentsGuidance( : normalizeLineEndings(parsed.visibleText).includes(expectedSnippet); return { - path, + path: filePath, exists: true, status: hasCurrentGuidance ? "ok" : "warning", expectedVersion: CODEX_AGENTS_GUIDANCE_VERSION, diff --git a/src/lib/integration/mcp-doctor.ts b/src/lib/integration/mcp-doctor.ts index f9791e6..de8b22e 100644 --- a/src/lib/integration/mcp-doctor.ts +++ b/src/lib/integration/mcp-doctor.ts @@ -42,7 +42,9 @@ import { type CodexSkillPathResolution } from "./skills-paths.js"; import { fileExists, readTextFile } from "../util/fs.js"; +import { buildRuntimeContext } from "../runtime/runtime-context.js"; import { resolveMcpProjectRoot } from "./mcp-config.js"; +import type { RetrievalSidecarCheck } from "../domain/memory-store.js"; type McpDoctorStatus = "ok" | "warning" | "missing" | "manual"; type McpDoctorConfigInspection = "ok" | "missing" | "parse-error" | "shape-mismatch"; @@ -191,6 +193,12 @@ interface McpDoctorFallbackAssets { assets: McpDoctorAssetCheck[]; } +interface McpDoctorRetrievalSidecarReport { + status: "ok" | "warning"; + summary: string; + checks: RetrievalSidecarCheck[]; +} + export interface McpDoctorReport { cwd: string; projectRoot: string; @@ -207,6 +215,7 @@ export interface McpDoctorReport { agentsGuidance: CodexAgentsGuidanceInspection; applySafety: Awaited>; fallbackAssets: McpDoctorFallbackAssets; + retrievalSidecar: McpDoctorRetrievalSidecarReport; workflowContract: ReturnType; hosts: McpDoctorHostReport[]; codexStack: { @@ -736,6 +745,26 @@ async function inspectFallbackAssets( }; } +function buildRetrievalSidecarReport( + checks: RetrievalSidecarCheck[] +): McpDoctorRetrievalSidecarReport { + const degradedChecks = checks.filter((check) => check.status !== "ok"); + if (degradedChecks.length === 0) { + return { + status: "ok", + summary: "All inspected retrieval sidecars are current.", + checks + }; + } + + return { + status: "warning", + summary: + "One or more retrieval sidecars are missing, invalid, or stale. Recall still falls back to Markdown canonical memory safely.", + checks + }; +} + function isAssetReady( assets: McpDoctorAssetCheck[], ids: string[] @@ -747,7 +776,10 @@ function buildCodexStackReport( codexHost: McpDoctorHostReport, fallbackAssets: McpDoctorFallbackAssets, camCommandAvailable: boolean, - agentsGuidance: CodexAgentsGuidanceInspection + agentsGuidance: CodexAgentsGuidanceInspection, + options: { + cwd?: string; + } = {} ): McpDoctorReport["codexStack"] { const mcpReady = codexHost.status === "ok"; const mcpOperationalReady = mcpReady && camCommandAvailable; @@ -781,7 +813,7 @@ function buildCodexStackReport( ? "warning" : "missing" ]) as McpDoctorStatus; - const notes = buildCodexStackNotes(); + const notes = buildCodexStackNotes(options); if (mcpReady && !camCommandAvailable) { notes.push( "The current shell could not resolve `cam` on PATH, so MCP wiring may still fail at runtime." @@ -823,6 +855,10 @@ export async function inspectMcpDoctor(options: { const fallbackAssets = await inspectFallbackAssets(projectRoot, { explicitCwd: options.explicitCwd ?? false }); + const runtime = await buildRuntimeContext(cwd, {}, { ensureMemoryLayout: false }); + const retrievalSidecar = buildRetrievalSidecarReport( + await runtime.syncService.memoryStore.inspectRetrievalSidecars() + ); const camCommandAvailable = await isCommandAvailableInPath("cam"); const codexHost = hosts.find((host) => host.host === "codex") ?? (await inspectHost("codex", projectRoot)); const agentsGuidance = inspectCodexAgentsGuidance( @@ -850,14 +886,18 @@ export async function inspectMcpDoctor(options: { agentsGuidance, applySafety, fallbackAssets, + retrievalSidecar, workflowContract, hosts, codexStack: buildCodexStackReport( - codexHost, - fallbackAssets, - camCommandAvailable, - agentsGuidance - ) + codexHost, + fallbackAssets, + camCommandAvailable, + agentsGuidance, + { + cwd: options.explicitCwd ? projectRoot : undefined + } + ) }; } @@ -928,6 +968,14 @@ export function formatMcpDoctorReport(report: McpDoctorReport): string { "Apply safety:", `- AGENTS managed-block apply safety: ${report.applySafety.status}${report.applySafety.blockedReason ? ` (${report.applySafety.blockedReason})` : ""}`, "", + "Retrieval sidecar:", + `- Status: ${report.retrievalSidecar.status}`, + `- Summary: ${report.retrievalSidecar.summary}`, + ...report.retrievalSidecar.checks.map( + (check) => + `- ${check.scope}/${check.state}: ${check.status}${check.fallbackReason ? ` (${check.fallbackReason})` : ""} | index: ${check.indexPath} | generatedAt: ${check.generatedAt ?? "none"} | topicFiles: ${check.topicFileCount ?? "none"}` + ), + "", "Fallback assets:" ); for (const asset of report.fallbackAssets.assets) { diff --git a/src/lib/integration/retrieval-contract.ts b/src/lib/integration/retrieval-contract.ts index 228de20..0414eac 100644 --- a/src/lib/integration/retrieval-contract.ts +++ b/src/lib/integration/retrieval-contract.ts @@ -233,8 +233,12 @@ export function buildRecommendedSearchPresetGuidance(): string { return `The recommended search preset is --state ${RECOMMENDED_RETRIEVAL_STATE} --limit ${RECOMMENDED_RETRIEVAL_LIMIT} unless you override those flags explicitly.`; } -export function buildRecommendedRetrievalSummaryLines(): string[] { - const workflowContract = buildWorkflowContract(); +export function buildRecommendedRetrievalSummaryLines( + options: { + cwd?: string; + } = {} +): string[] { + const workflowContract = buildWorkflowContract(options); return [ workflowContract.recallWorkflow.recallFirst, workflowContract.recallWorkflow.progressiveDisclosure, diff --git a/src/lib/mcp/retrieval-server.ts b/src/lib/mcp/retrieval-server.ts index 781255a..dff5414 100644 --- a/src/lib/mcp/retrieval-server.ts +++ b/src/lib/mcp/retrieval-server.ts @@ -39,6 +39,16 @@ const memorySearchResultSchema = z.object({ approxReadCost: z.number().int().nonnegative() }); +const memorySearchDiagnosticSchema = z.object({ + scope: z.enum(["global", "project", "project-local"]), + state: memoryRecordStateSchema, + retrievalMode: z.enum(["index", "markdown-fallback"]), + retrievalFallbackReason: z.enum(["missing", "invalid", "stale"]).optional(), + matchedCount: z.number().int().nonnegative(), + indexPath: z.string(), + generatedAt: z.string().nullable() +}); + const memorySearchResponseSchema = z.object({ query: z.string(), scope: retrievalScopeSchema, @@ -47,6 +57,9 @@ const memorySearchResponseSchema = z.object({ fallbackUsed: z.boolean(), retrievalMode: z.enum(["index", "markdown-fallback"]), retrievalFallbackReason: z.enum(["missing", "invalid", "stale"]).optional(), + diagnostics: z.object({ + checkedPaths: z.array(memorySearchDiagnosticSchema) + }), results: z.array(memorySearchResultSchema) }); @@ -82,6 +95,28 @@ const memoryDetailsResponseSchema = z.object({ latestSessionId: z.string().nullable(), latestRolloutPath: z.string().nullable(), historyPath: z.string(), + latestAudit: z + .object({ + auditPath: z.string(), + appliedAt: z.string(), + rolloutPath: z.string(), + sessionId: z.string().optional(), + status: z.enum(["applied", "no-op", "skipped"]), + resultSummary: z.string(), + noopOperationCount: z.number().int().nonnegative(), + suppressedOperationCount: z.number().int().nonnegative(), + conflicts: z.array( + z.object({ + scope: z.enum(["global", "project", "project-local"]), + topic: z.string(), + candidateSummary: z.string(), + conflictsWith: z.array(z.string()), + source: z.enum(["within-rollout", "existing-memory"]), + resolution: z.literal("suppressed") + }) + ) + }) + .nullable(), entry: z.object({ id: z.string(), scope: z.enum(["global", "project", "project-local"]), diff --git a/src/lib/types.ts b/src/lib/types.ts index b57c1cc..f02293a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -63,6 +63,20 @@ export interface MemorySearchResult extends MemoryRef { approxReadCost: number; } +export interface MemorySearchDiagnosticPath { + scope: MemoryScope; + state: MemoryRecordState; + retrievalMode: MemoryRetrievalMode; + retrievalFallbackReason?: MemoryRetrievalFallbackReason; + matchedCount: number; + indexPath: string; + generatedAt: string | null; +} + +export interface MemorySearchDiagnostics { + checkedPaths: MemorySearchDiagnosticPath[]; +} + export interface MemorySearchResponse { query: string; scope: MemoryRetrievalScope; @@ -71,6 +85,7 @@ export interface MemorySearchResponse { fallbackUsed: boolean; retrievalMode: MemoryRetrievalMode; retrievalFallbackReason?: MemoryRetrievalFallbackReason; + diagnostics: MemorySearchDiagnostics; results: MemorySearchResult[]; } @@ -102,6 +117,19 @@ export interface MemoryDetailsResult extends MemoryRef { latestSessionId: string | null; latestRolloutPath: string | null; historyPath: string; + latestAudit: MemorySyncAuditSummary | null; +} + +export interface MemorySyncAuditSummary { + auditPath: string; + appliedAt: string; + rolloutPath: string; + sessionId?: string; + status: MemorySyncAuditStatus; + resultSummary: string; + noopOperationCount: number; + suppressedOperationCount: number; + conflicts: MemoryConflictCandidate[]; } export interface MemoryApplyRecord { diff --git a/test/docs-contract.test.ts b/test/docs-contract.test.ts index c17ba53..1041ffe 100644 --- a/test/docs-contract.test.ts +++ b/test/docs-contract.test.ts @@ -170,7 +170,9 @@ describe("docs contract", () => { expect(releaseChecklist).toContain("node dist/cli.js session status --json"); expect(releaseChecklist).toContain("node dist/cli.js recall search pnpm --json"); expect(releaseChecklist).toContain("state=auto, limit=8"); + expect(releaseChecklist).toContain("checkedPaths"); expect(releaseChecklist).toContain("node dist/cli.js recall details --json"); + expect(releaseChecklist).toContain("latestAudit"); expect(releaseChecklist).toContain( "node dist/cli.js mcp install --host --json" ); @@ -185,6 +187,7 @@ describe("docs contract", () => { expect(releaseChecklist).toContain("node dist/cli.js mcp doctor --json"); expect(releaseChecklist).toContain("node dist/cli.js mcp doctor --host codex --json"); expect(releaseChecklist).toContain("structured `workflowContract`"); + expect(releaseChecklist).toContain("retrievalSidecar"); expect(releaseChecklist).toContain("alternate global wiring"); expect(releaseChecklist).toContain("non-canonical custom fields"); expect(releaseChecklist).toContain("preservedCustomFields"); diff --git a/test/integrations-command.test.ts b/test/integrations-command.test.ts index cbf8664..b323362 100644 --- a/test/integrations-command.test.ts +++ b/test/integrations-command.test.ts @@ -198,6 +198,17 @@ describe("integrations command", () => { status: "missing", recommendedRoute: "cli-direct", recommendedPreset: "state=auto, limit=8", + retrievalSidecar: { + status: "warning", + checks: expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + state: "active", + status: "missing", + fallbackReason: "missing" + }) + ]) + }, subchecks: { mcp: { status: "missing" diff --git a/test/mcp-command.test.ts b/test/mcp-command.test.ts index 137e5e1..2312db2 100644 --- a/test/mcp-command.test.ts +++ b/test/mcp-command.test.ts @@ -5,8 +5,13 @@ import { afterEach, describe, expect, it } from "vitest"; import * as toml from "smol-toml"; import { detectProjectContext } from "../src/lib/domain/project-context.js"; import { MemoryStore } from "../src/lib/domain/memory-store.js"; +import { SyncService } from "../src/lib/domain/sync-service.js"; import { RETRIEVAL_INTEGRATION_ASSET_VERSION } from "../src/lib/integration/retrieval-contract.js"; -import { makeAppConfig, writeCamConfig } from "./helpers/cam-test-fixtures.js"; +import { + makeAppConfig, + makeRolloutFixture, + writeCamConfig +} from "./helpers/cam-test-fixtures.js"; import { runCli } from "./helpers/cli-runner.js"; import { connectCliMcpClient } from "./helpers/mcp-client.js"; @@ -22,6 +27,17 @@ interface SearchMemoriesResponse { fallbackUsed: boolean; retrievalMode: string; retrievalFallbackReason?: string; + diagnostics?: { + checkedPaths: Array<{ + scope: string; + state: string; + retrievalMode: string; + retrievalFallbackReason?: string; + matchedCount: number; + indexPath: string; + generatedAt: string | null; + }>; + }; results: Array<{ ref: string; state: string; @@ -48,6 +64,15 @@ interface MemoryDetailsResponse { latestSessionId: string | null; latestRolloutPath: string | null; historyPath: string; + latestAudit?: { + auditPath: string; + rolloutPath: string; + sessionId?: string; + status: string; + resultSummary: string; + noopOperationCount: number; + suppressedOperationCount: number; + } | null; entry: { summary: string; details: string[]; @@ -1191,6 +1216,41 @@ describe("mcp command", () => { expect(payload.workflowContract).toBeUndefined(); }); + it("pins Codex AGENTS guidance fallback commands when print-config uses --cwd", async () => { + const homeDir = await tempDir("cam-mcp-print-codex-cwd-home-"); + const projectDir = await tempDir("cam-mcp-print-codex-cwd-project-"); + const callerDir = await tempDir("cam-mcp-print-codex-cwd-caller-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + const result = runCli( + callerDir, + ["mcp", "print-config", "--host", "codex", "--cwd", projectDir, "--json"], + { + env: { HOME: homeDir } + } + ); + + expect(result.exitCode, result.stderr).toBe(0); + const payload = JSON.parse(result.stdout) as { + agentsGuidance: { + snippet: string; + }; + }; + expect(payload.agentsGuidance.snippet).toContain( + `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + ); + expect(payload.agentsGuidance.snippet).toContain( + `cam recall timeline "" --cwd ${JSON.stringify(realProjectDir)}` + ); + expect(payload.agentsGuidance.snippet).toContain( + `cam recall details "" --cwd ${JSON.stringify(realProjectDir)}` + ); + expect(payload.agentsGuidance.snippet).toContain( + `post-work-memory-review.sh` + ); + }); + it("pins the recommended skill install command to the inspected project when mcp doctor uses --cwd", async () => { const homeDir = await tempDir("cam-mcp-doctor-cwd-home-"); const projectParentDir = await tempDir("cam-mcp-doctor-cwd-parent-"); @@ -1323,6 +1383,19 @@ describe("mcp command", () => { skillReady: boolean; workflowConsistent: boolean; }; + retrievalSidecar: { + status: string; + summary: string; + checks: Array<{ + scope: string; + state: string; + status: string; + fallbackReason?: string; + indexPath: string; + generatedAt: string | null; + topicFileCount: number | null; + }>; + }; hosts: Array<{ host: string; status: string; @@ -1456,6 +1529,21 @@ describe("mcp command", () => { skillReady: true, workflowConsistent: false }); + expect(payload.retrievalSidecar).toMatchObject({ + status: "warning", + summary: expect.stringContaining("Markdown"), + checks: expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + state: "active", + status: "missing", + fallbackReason: "missing", + indexPath: expect.stringContaining("retrieval-index.json"), + generatedAt: null, + topicFileCount: null + }) + ]) + }); expect(payload.agentsGuidance).toMatchObject({ path: path.join(realProjectDir, "AGENTS.md"), exists: false, @@ -1470,6 +1558,16 @@ describe("mcp command", () => { ); expect(integrationsDoctor.exitCode, integrationsDoctor.stderr).toBe(0); expect(JSON.parse(integrationsDoctor.stdout)).toMatchObject({ + retrievalSidecar: { + checks: expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + state: "active", + status: "missing", + fallbackReason: "missing" + }) + ]) + }, subchecks: { workflowConsistency: { status: "warning", @@ -2823,6 +2921,17 @@ describe("mcp command", () => { expect(payload).toMatchObject({ retrievalMode: "markdown-fallback", retrievalFallbackReason: "missing", + diagnostics: { + checkedPaths: expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + state: "active", + retrievalMode: "markdown-fallback", + retrievalFallbackReason: "missing", + matchedCount: 0 + }) + ]) + }, results: [] }); } finally { @@ -2899,10 +3008,93 @@ describe("mcp command", () => { expect(payload).toMatchObject({ retrievalMode: "markdown-fallback", retrievalFallbackReason: "invalid", + diagnostics: { + checkedPaths: expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + state: "active", + retrievalMode: "markdown-fallback", + retrievalFallbackReason: "invalid", + matchedCount: 1, + indexPath: store.getRetrievalIndexFile("project", "active"), + generatedAt: null + }) + ]) + }, results: [expect.objectContaining({ ref: "project:active:workflow:prefer-pnpm" })] }); } finally { await client.close(); } }, 30_000); + + it("surfaces latest sync audit provenance through MCP memory details", async () => { + const homeDir = await tempDir("cam-mcp-details-audit-home-"); + const projectDir = await tempDir("cam-mcp-details-audit-project-"); + const memoryRoot = await tempDir("cam-mcp-details-audit-memory-"); + const rolloutPath = path.join(projectDir, "rollout.jsonl"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + await fs.writeFile( + rolloutPath, + makeRolloutFixture(projectDir, "Remember that this repository prefers pnpm.", { + sessionId: "session-mcp-audit" + }), + "utf8" + ); + + const service = new SyncService(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await service.syncRollout(rolloutPath, true); + + const client = await connectCliMcpClient(projectDir, { + env: { HOME: homeDir } + }); + + try { + const searchResult = await client.callTool({ + name: "search_memories", + arguments: { + query: "prefers pnpm", + limit: 5 + } + }); + const searchPayload = readStructuredContent( + searchResult as ToolCallResultLike + ); + const ref = searchPayload.results[0]?.ref; + expect(ref).toBeTruthy(); + + const detailsResult = await client.callTool({ + name: "get_memory_details", + arguments: { + ref + } + }); + const detailsPayload = readStructuredContent( + detailsResult as ToolCallResultLike + ); + expect(detailsPayload).toMatchObject({ + latestSessionId: "session-mcp-audit", + latestRolloutPath: rolloutPath, + latestAudit: { + auditPath: service.memoryStore.getSyncAuditPath(), + rolloutPath, + sessionId: "session-mcp-audit", + status: "applied", + resultSummary: expect.stringContaining("operation(s) applied"), + noopOperationCount: 0, + suppressedOperationCount: 0 + } + }); + } finally { + await client.close(); + } + }, 30_000); }); diff --git a/test/recall-command.test.ts b/test/recall-command.test.ts index 8d26d50..595de12 100644 --- a/test/recall-command.test.ts +++ b/test/recall-command.test.ts @@ -27,6 +27,18 @@ afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); +interface RecallSearchDiagnostics { + checkedPaths: Array<{ + scope: string; + state: string; + retrievalMode: string; + retrievalFallbackReason?: string; + matchedCount: number; + indexPath: string; + generatedAt: string | null; + }>; +} + describe("runRecall", () => { it("uses the recommended search preset by default when state and limit flags are omitted", async () => { const homeDir = await tempDir("cam-recall-default-preset-home-"); @@ -66,6 +78,7 @@ describe("runRecall", () => { fallbackUsed: boolean; retrievalMode: string; retrievalFallbackReason?: string; + diagnostics: RecallSearchDiagnostics; results: Array<{ ref: string; state: string; topic: string }>; }; expect(output).toMatchObject({ @@ -74,6 +87,18 @@ describe("runRecall", () => { fallbackUsed: true, retrievalMode: "index" }); + expect(output.diagnostics.checkedPaths).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + state: "archived", + retrievalMode: "index", + matchedCount: 9, + indexPath: store.getRetrievalIndexFile("project", "archived"), + generatedAt: expect.any(String) + }) + ]) + ); expect(output.results).toHaveLength(8); expect(output.results.every((result) => result.state === "archived")).toBe(true); }); @@ -405,7 +430,16 @@ describe("runRecall", () => { latestLifecycleAction: "add", latestSessionId: "session-provenance", latestRolloutPath: rolloutPath, - historyPath: store.getHistoryPath("project") + historyPath: store.getHistoryPath("project"), + latestAudit: { + auditPath: store.getSyncAuditPath(), + rolloutPath, + sessionId: "session-provenance", + status: "applied", + resultSummary: expect.stringContaining("operation(s) applied"), + noopOperationCount: 0, + suppressedOperationCount: 0 + } }); }); @@ -429,6 +463,7 @@ describe("runRecall", () => { fallbackUsed: boolean; retrievalMode: string; retrievalFallbackReason?: string; + diagnostics: RecallSearchDiagnostics; results: unknown[]; }; expect(output).toMatchObject({ @@ -439,6 +474,24 @@ describe("runRecall", () => { retrievalFallbackReason: "missing", results: [] }); + expect(output.diagnostics.checkedPaths).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + state: "active", + retrievalMode: "markdown-fallback", + retrievalFallbackReason: "missing", + matchedCount: 0 + }), + expect.objectContaining({ + scope: "project", + state: "archived", + retrievalMode: "markdown-fallback", + retrievalFallbackReason: "missing", + matchedCount: 0 + }) + ]) + ); await expect(fs.access(memoryRoot)).rejects.toMatchObject({ code: "ENOENT" }); }); @@ -493,6 +546,70 @@ describe("runRecall", () => { expect(JSON.parse(result.stdout)).toMatchObject({ retrievalMode: "markdown-fallback", retrievalFallbackReason: "invalid", + diagnostics: { + checkedPaths: expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + state: "active", + retrievalMode: "markdown-fallback", + retrievalFallbackReason: "invalid", + matchedCount: 1, + indexPath: store.getRetrievalIndexFile("project", "active"), + generatedAt: null + }) + ]) + }, + results: [expect.objectContaining({ ref: "project:active:workflow:prefer-pnpm" })] + }); + }); + + it("surfaces markdown fallback diagnostics when the retrieval sidecar is stale", async () => { + const homeDir = await tempDir("cam-recall-stale-sidecar-home-"); + const projectDir = await tempDir("cam-recall-stale-sidecar-project-"); + const memoryRoot = await tempDir("cam-recall-stale-sidecar-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const topicPath = store.getTopicFile("project", "workflow"); + const staleAt = new Date(Date.now() + 60_000); + await fs.utimes(topicPath, staleAt, staleAt); + + const result = runCli(projectDir, ["recall", "search", "prefer pnpm", "--state", "active", "--json"]); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + retrievalMode: "markdown-fallback", + retrievalFallbackReason: "stale", + diagnostics: { + checkedPaths: expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + state: "active", + retrievalMode: "markdown-fallback", + retrievalFallbackReason: "stale", + matchedCount: 1, + indexPath: store.getRetrievalIndexFile("project", "active"), + generatedAt: expect.any(String) + }) + ]) + }, results: [expect.objectContaining({ ref: "project:active:workflow:prefer-pnpm" })] }); }); diff --git a/test/skills-command.test.ts b/test/skills-command.test.ts index 220076d..be621a8 100644 --- a/test/skills-command.test.ts +++ b/test/skills-command.test.ts @@ -52,10 +52,15 @@ describe("skills command", () => { expect(skillFile).toContain("limit: 8"); expect(skillFile).toContain("cam recall search"); expect(skillFile).toContain("--state auto"); + expect(skillFile).toContain(`--cwd ${JSON.stringify(await fs.realpath(projectDir))}`); expect(skillFile).toContain("cam recall timeline"); expect(skillFile).toContain("cam recall details"); expect(skillFile).toContain("cam mcp doctor"); expect(skillFile).toContain("cam hooks install"); + expect(skillFile).toContain(`cam sync --cwd ${JSON.stringify(await fs.realpath(projectDir))}`); + expect(skillFile).toContain( + `cam memory --recent --cwd ${JSON.stringify(await fs.realpath(projectDir))}` + ); expect(skillFile).toContain("cam memory"); expect(skillFile).toContain("cam session"); }); From bb67222807d436e5dd592166f9fa7fd6d86f2912 Mon Sep 17 00:00:00 2001 From: blocks Date: Fri, 27 Mar 2026 21:04:37 +0800 Subject: [PATCH 11/62] feat: add retrieval reindex and lineage reviewer warnings --- README.en.md | 2 + README.ja.md | 2 + README.md | 2 + README.zh-TW.md | 2 + docs/release-checklist.md | 7 + src/lib/cli/register-commands.ts | 56 +++-- src/lib/commands/hooks.ts | 16 ++ src/lib/commands/integrations.ts | 6 + src/lib/commands/memory.ts | 77 +++++++ src/lib/commands/recall.ts | 53 ++++- src/lib/commands/skills.ts | 18 ++ src/lib/domain/memory-retrieval-contract.ts | 31 ++- src/lib/domain/memory-retrieval.ts | 6 +- src/lib/domain/memory-store.ts | 243 ++++++++++++++++++-- src/lib/integration/install-assets.ts | 5 + src/lib/integration/mcp-doctor.ts | 19 +- src/lib/mcp/retrieval-server.ts | 22 +- src/lib/types.ts | 38 +++ test/docs-contract.test.ts | 12 + test/hooks-command.test.ts | 28 +++ test/integrations-command.test.ts | 6 +- test/mcp-command.test.ts | 70 ++++++ test/memory-command.test.ts | 120 +++++++++- test/recall-command.test.ts | 88 +++++++ test/skills-command.test.ts | 31 +++ 25 files changed, 908 insertions(+), 52 deletions(-) diff --git a/README.en.md b/README.en.md index 75ae0d7..e6c2a01 100644 --- a/README.en.md +++ b/README.en.md @@ -177,6 +177,7 @@ This is still the most mature end-to-end path today. After each session ends, `c ```bash cam memory +cam memory reindex --scope all --state all cam recall search pnpm --state auto cam mcp serve cam integrations install --host codex @@ -201,6 +202,7 @@ cam audit | `cam run` / `cam exec` / `cam resume` | compile startup memory and launch Codex through the wrapper | | `cam sync` | manually sync the latest rollout into durable memory | | `cam memory` | inspect startup files, topic refs, startup budget, edit paths, and recent durable sync audit events plus suppressed conflict candidates | +| `cam memory reindex` | explicitly rebuild retrieval sidecars from canonical Markdown memory; supports `--scope`, `--state`, `--cwd`, and `--json` so missing, invalid, or stale sidecars have a low-friction repair path | | `cam remember` / `cam forget` | explicitly add or remove durable memory; `cam forget --archive` moves matching entries into the archive layer | | `cam recall search` / `timeline` / `details` | progressively retrieve durable memory through a search -> timeline -> details workflow; `search` now defaults to `state=auto, limit=8`, so active memory is checked before archived fallback while staying read-only | | `cam mcp serve` | start a read-only retrieval MCP server that exposes the same workflow through `search_memories`, `timeline_memories`, and `get_memory_details` | diff --git a/README.ja.md b/README.ja.md index 4857c1e..3da1097 100644 --- a/README.ja.md +++ b/README.ja.md @@ -171,6 +171,7 @@ cam run ```bash cam memory +cam memory reindex --scope all --state all cam recall search pnpm --state auto cam mcp serve cam integrations install --host codex @@ -195,6 +196,7 @@ cam audit | `cam run` / `cam exec` / `cam resume` | startup memory を生成して wrapper 経由で Codex を起動 | | `cam sync` | 最新 rollout を durable memory に手動同期 | | `cam memory` | startup files、topic refs、startup budget、edit paths、recent sync audit を確認 | +| `cam memory reindex` | canonical Markdown から retrieval sidecar を明示的に再構築する。`--scope`、`--state`、`--cwd`、`--json` をサポートし、sidecar が missing / invalid / stale のときの低摩擦な repair path を提供する | | `cam remember` / `cam forget` | durable memory の明示的な追加・削除。`cam forget --archive` は一致した項目をアーカイブ層へ移動する | | `cam recall search` / `timeline` / `details` | `search -> timeline -> details` の progressive disclosure workflow で durable memory を段階的に取得する。`search` は `state=auto, limit=8` を既定値として使い、active を先に調べてヒットしなければ archived にフォールバックしつつ read-only を保つ | | `cam mcp serve` | `search_memories` / `timeline_memories` / `get_memory_details` を通じて同じ retrieval contract を公開する read-only MCP server を起動する | diff --git a/README.md b/README.md index cc861d7..fac82b0 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,7 @@ cam run ```bash cam memory +cam memory reindex --scope all --state all cam memory --recent 5 cam recall search pnpm --state auto cam mcp serve @@ -206,6 +207,7 @@ cam audit | `cam run` / `cam exec` / `cam resume` | 编译 startup memory 并通过 wrapper 启动 Codex | | `cam sync` | 手动把最近 rollout 同步进 durable memory | | `cam memory` | 查看 startup payload、topic refs、edit paths、durable sync audit 与 suppressed conflict candidates | +| `cam memory reindex` | 显式从 canonical Markdown 重建 retrieval sidecar;支持 `--scope`、`--state`、`--cwd`、`--json`,用于 sidecar 缺失、损坏或 stale 时的低心智修复路径 | | `cam remember` / `cam forget` | 显式新增、删除或修正 memory;`cam forget --archive` 会把匹配条目移入归档层 | | `cam recall search` / `timeline` / `details` | 以 `search -> timeline -> details` 的 progressive disclosure 检索 durable memory;`search` 默认采用 `state=auto`、`limit=8`,先查 active,未命中再回退 archived,且保持只读 retrieval | | `cam mcp serve` | 启动只读 retrieval MCP server,通过 `search_memories` / `timeline_memories` / `get_memory_details` 暴露同一套渐进式检索契约 | diff --git a/README.zh-TW.md b/README.zh-TW.md index 306d4c0..614abf6 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -173,6 +173,7 @@ cam run ```bash cam memory +cam memory reindex --scope all --state all cam recall search pnpm --state auto cam mcp serve cam integrations install --host codex @@ -197,6 +198,7 @@ cam audit | `cam run` / `cam exec` / `cam resume` | 編譯 startup memory 並透過 wrapper 啟動 Codex | | `cam sync` | 手動把最近 rollout 同步進 durable memory | | `cam memory` | 檢視 startup files、topic refs、startup budget、edit paths,以及 recent sync audit 與 suppressed conflict candidates | +| `cam memory reindex` | 明確從 canonical Markdown 重建 retrieval sidecar;支援 `--scope`、`--state`、`--cwd`、`--json`,讓 sidecar 缺失、損壞或 stale 時有低心智負擔的修復路徑 | | `cam remember` / `cam forget` | 顯式新增或刪除 durable memory;`cam forget --archive` 會把匹配條目移入歸檔層 | | `cam recall search` / `timeline` / `details` | 以 `search -> timeline -> details` 的 progressive disclosure 工作流檢索 durable memory;`search` 現在預設採用 `state=auto`、`limit=8`,會先查 active,未命中再回退 archived,且保持只讀 retrieval | | `cam mcp serve` | 啟動只讀 retrieval MCP server,以 `search_memories` / `timeline_memories` / `get_memory_details` 暴露同一套漸進式檢索契約 | diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 8ee87fb..c78d455 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -46,12 +46,15 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Run `node dist/cli.js recall search pnpm --json` and confirm the default search contract stays aligned at `state=auto, limit=8`, returning compact refs before any full detail fetch. - Confirm `node dist/cli.js recall search pnpm --json` now also reports whether the search used the retrieval sidecar or fell back to Markdown scan through additive `retrievalMode` / `retrievalFallbackReason` fields. - Confirm `node dist/cli.js recall search pnpm --json` now also exposes additive per-path diagnostics through `diagnostics.checkedPaths`, so mixed index/fallback searches stay reviewer-visible instead of collapsing into a single top-level mode. +- Run `node dist/cli.js memory reindex --scope all --state all --json` and confirm retrieval sidecars rebuild explicitly from Markdown canonical memory without mutating topic Markdown or audit logs. - Run `node dist/cli.js recall details --json` for one returned ref and confirm the path resolves to Markdown-backed memory, including archived refs when relevant. - Confirm `node dist/cli.js recall details --json` now also exposes additive provenance summary fields such as `latestLifecycleAction`, `latestSessionId`, `latestRolloutPath`, and `historyPath`. - Confirm `node dist/cli.js recall details --json` now also exposes additive `latestAudit` provenance so a reviewer can jump from lifecycle state to the latest sync-audit summary without manually correlating sidecars. +- Confirm `node dist/cli.js recall details --json` now also exposes additive reviewer fields such as `latestState`, `timelineWarningCount`, `lineageSummary`, and `warnings`. - Run a local MCP smoke against `node dist/cli.js mcp serve` and confirm `search_memories`, `timeline_memories`, and `get_memory_details` are exposed as a read-only retrieval plane. - Confirm `search_memories` mirrors the CLI retrieval diagnostics through additive `retrievalMode` / `retrievalFallbackReason` fields, and `timeline_memories` / `get_memory_details` keep lifecycle provenance aligned with the CLI retrieval surface. - Confirm `search_memories` now also mirrors CLI search diagnostics through additive `diagnostics.checkedPaths`, and `get_memory_details` mirrors CLI detail provenance through additive `latestAudit`. +- Confirm `timeline_memories` now also mirrors CLI lifecycle reviewer fields through additive `warnings` and `lineageSummary`. - Run `node dist/cli.js mcp install --host --json` and confirm the result contract includes `host`, `serverName`, `projectRoot`, `targetPath`, `action`, `projectPinned`, and `readOnlyRetrieval`. - Re-run the same `node dist/cli.js mcp install --host --json` command once and confirm it returns `action: "unchanged"` when the target host config is already canonical. - Confirm `node dist/cli.js mcp install --host --json` preserves non-canonical custom fields already attached to the `codex_auto_memory` entry instead of dropping them silently. @@ -66,12 +69,15 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Run `node dist/cli.js mcp doctor --json` and confirm it reports project-scoped host wiring, project pinning, and hook / skill fallback assets without creating memory layout or mutating host config files. - Run `node dist/cli.js mcp doctor --host codex --json` and confirm the payload also exposes the structured `workflowContract`, including the current CLI fallback commands and post-work sync/review helper contract. - Confirm `node dist/cli.js mcp doctor --host codex --json` now also exposes additive `retrievalSidecar` readiness, including per-scope/per-state sidecar status, fallback reason, and the guarantee that degraded sidecars still fall back safely to Markdown canonical recall. +- Confirm `node dist/cli.js mcp doctor --host codex --json` now also exposes an explicit retrieval sidecar repair command instead of forcing users to infer how to rebuild indexes manually. - Confirm `node dist/cli.js mcp doctor --host codex --json` distinguishes alternate global wiring from the recommended project-scoped route through additive scope/reporting fields instead of treating them as the same readiness state. - Confirm `node dist/cli.js mcp doctor --host codex --json` now exposes `configScopeSummary` and `alternateWiring`, so valid alternate global wiring stays distinct from malformed or shape-mismatched global host config. - Confirm `node dist/cli.js mcp doctor --host codex --json` distinguishes skill-surface presence, canonical content, and readiness through additive fields such as `runtimeSkillPresent`, `officialUserSkillMatchesCanonical`, `officialProjectSkillMatchesCanonical`, `anySkillSurfaceInstalled`, and `anySkillSurfaceReady`. - Confirm `node dist/cli.js hooks install` writes `post-work-memory-review.sh`, and that the generated helper still runs `cam sync` followed by `cam memory --recent`. +- Confirm `node dist/cli.js hooks install --json` exposes the shared `workflowContract` so hook helper guidance stays aligned with the MCP and integrations doctor surfaces. - Run `node dist/cli.js hooks install --cwd ` from another working directory and confirm the generated hook helper bundle pins `memory-recall.sh` and `post-work-memory-review.sh` to the targeted project root instead of the caller shell cwd. - Run `node dist/cli.js skills install --surface official-project --cwd ` from another working directory and confirm the explicit project-scoped `.agents/skills` copy is written inside the targeted repository. +- Confirm `node dist/cli.js skills install --json` exposes the shared `workflowContract` so skill guidance stays aligned with the MCP and integrations doctor surfaces. - Run `node dist/cli.js integrations install --host codex --json` and confirm it orchestrates the existing Codex MCP wiring, hook bundle, and skill assets without touching the Markdown memory store. - Run `node dist/cli.js integrations apply --host codex --json` and confirm it orchestrates MCP wiring, managed AGENTS guidance, hook assets, and skill assets while keeping `integrations install --host codex` non-mutating for AGENTS.md. - Confirm `node dist/cli.js integrations apply --host codex --json` still returns `stackAction: "blocked"` when the AGENTS managed block is unsafe, and now also reports the preflight early-block shape (`preflightBlocked`, `blockedStage`, per-subaction `attempted`) while preserving the AGENTS file content and skipping all other stack writes. @@ -86,6 +92,7 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Run `node dist/cli.js integrations doctor --host codex --json` and confirm it reports the thin Codex-only stack readiness view with `recommendedRoute`, `recommendedPreset`, `subchecks`, and `nextSteps`. - Confirm `node dist/cli.js integrations doctor --host codex --json` also exposes the shared structured `workflowContract`, including the post-work sync/review helper semantics, and now reports `applyReadiness` so unsafe AGENTS managed blocks are diagnosed before recommending `cam integrations apply --host codex`. - Confirm `node dist/cli.js integrations doctor --host codex --json` also surfaces the additive `retrievalSidecar` summary from `mcp doctor`, so retrieval-plane degradation is visible before the user has to run a recall command manually. +- Confirm `node dist/cli.js integrations doctor --host codex --json` now recommends `cam memory reindex` when retrieval sidecars are degraded. - Confirm `workflowConsistency` wording in doctor surfaces now explicitly treats repo-level `AGENTS.md` guidance as part of the shared retrieval workflow contract, not just hooks/skills text. - Treat key `--help` output as release-facing contract, not incidental CLI text: - `node dist/cli.js mcp install --help` should keep the supported install-host list at `codex, claude, or gemini`, leaving `generic` out of the install branch. diff --git a/src/lib/cli/register-commands.ts b/src/lib/cli/register-commands.ts index d599e02..e77eb60 100644 --- a/src/lib/cli/register-commands.ts +++ b/src/lib/cli/register-commands.ts @@ -9,7 +9,7 @@ import { runIntegrationsInstall } from "../commands/integrations.js"; import { runInit } from "../commands/init.js"; -import { runMemory } from "../commands/memory.js"; +import { runMemory, runMemoryReindex } from "../commands/memory.js"; import { runMcpApplyGuidance, runMcpDoctor, @@ -119,11 +119,12 @@ function registerHookCommands(program: Command): void { .command("hooks") .description("Manage the local bridge and fallback helper bundle for current and upcoming integrations"); - hooksCommand - .command("install") - .description("Generate the local recall bridge bundle plus startup and post-session helper scripts") - .option("--cwd ", "Project directory to anchor generated hook helpers to") - .action(withStdout(async (options) => installHooks(options))); + addJsonOption( + hooksCommand + .command("install") + .description("Generate the local recall bridge bundle plus startup and post-session helper scripts") + .option("--cwd ", "Project directory to anchor generated hook helpers to") + ).action(withStdout(async (options) => installHooks(options))); hooksCommand .command("remove") @@ -137,16 +138,17 @@ function registerSkillCommands(program: Command): void { .command("skills") .description("Manage Codex skill assets for MCP-first and CLI-fallback durable memory retrieval"); - skillsCommand - .command("install") - .description("Install a Codex skill that teaches search -> timeline -> details memory retrieval") - .option( - "--surface ", - `Skill install surface: ${skillSurfaceChoices}`, - DEFAULT_CODEX_SKILL_INSTALL_SURFACE - ) - .option("--cwd ", "Project directory to anchor project-scoped skill installs to") - .action(withStdout(async (options) => installSkills(options))); + addJsonOption( + skillsCommand + .command("install") + .description("Install a Codex skill that teaches search -> timeline -> details memory retrieval") + .option( + "--surface ", + `Skill install surface: ${skillSurfaceChoices}`, + DEFAULT_CODEX_SKILL_INSTALL_SURFACE + ) + .option("--cwd ", "Project directory to anchor project-scoped skill installs to") + ).action(withStdout(async (options) => installSkills(options))); skillsCommand .command("remove") @@ -297,22 +299,40 @@ export function registerCommands(program: Command): void { .description("Initialize Codex Auto Memory in the current project") .action(withStdout(async () => runInit())); - program + const memoryCommand = program .command("memory") .description("Inspect local memory state") + .argument("[subaction]", "Optional memory subaction. Use reindex to rebuild retrieval sidecars.") .option("--json", "Print JSON output") .option( "--scope ", "Show a single memory scope: global, project, project-local, or all", "all" ) + .option( + "--state ", + "Memory reindex only: rebuild active, archived, or all retrieval sidecars", + "all" + ) .option("--recent [count]", "Show recent sync audit entries") .option("--enable", "Enable auto memory in config") .option("--disable", "Disable auto memory in config") .option("--config-scope ", "Config scope to edit: user, project, or local", "local") .option("--print-startup", "Print the compiled startup memory block") .option("--open", "Open the memory directory in the default file browser") - .action(withStdout(async (options) => runMemory(options))); + .action( + withStdout(async (subaction, options) => { + if (!subaction) { + return runMemory(options); + } + + if (subaction === "reindex") { + return runMemoryReindex(options); + } + + throw new Error(`Unsupported memory subaction "${subaction}".`); + }) + ); program .command("remember") diff --git a/src/lib/commands/hooks.ts b/src/lib/commands/hooks.ts index e914231..8e729d6 100644 --- a/src/lib/commands/hooks.ts +++ b/src/lib/commands/hooks.ts @@ -8,6 +8,7 @@ import { resolveMcpProjectRoot } from "../integration/mcp-config.js"; interface HooksCommandOptions { cwd?: string; + json?: boolean; } export async function installHooks(options: HooksCommandOptions = {}): Promise { @@ -16,6 +17,21 @@ export async function installHooks(options: HooksCommandOptions = {}): Promise { return lines.join("\n"); } + +function normalizeMemoryReindexScope(scope: MemoryScope | "all" | undefined): MemoryScope | "all" { + if (!scope || scope === "all") { + return "all"; + } + + if (scope === "global" || scope === "project" || scope === "project-local") { + return scope; + } + + throw new Error(`Unsupported memory reindex scope "${scope}".`); +} + +function normalizeMemoryReindexState( + state: MemoryRecordState | "all" | undefined +): MemoryRecordState | "all" { + if (!state || state === "all") { + return "all"; + } + + if (state === "active" || state === "archived") { + return state; + } + + throw new Error(`Unsupported memory reindex state "${state}".`); +} + +export async function runMemoryReindex(options: MemoryReindexOptions = {}): Promise { + const runtime = await buildRuntimeContext(options.cwd ?? process.cwd()); + const requestedScope = normalizeMemoryReindexScope(options.scope); + const requestedState = normalizeMemoryReindexState(options.state); + const rebuilt = await runtime.syncService.memoryStore.rebuildRetrievalSidecars({ + scope: requestedScope, + state: requestedState + }); + const summary = + rebuilt.length === 1 + ? "Rebuilt 1 retrieval sidecar from Markdown canonical memory." + : `Rebuilt ${rebuilt.length} retrieval sidecar(s) from Markdown canonical memory.`; + + const output: MemoryReindexOutput = { + projectRoot: runtime.project.projectRoot, + requestedScope, + requestedState, + rebuilt, + summary + }; + + if (options.json) { + return JSON.stringify(output, null, 2); + } + + return [ + "Codex Auto Memory Retrieval Sidecar Reindex", + `Project root: ${output.projectRoot}`, + `Requested scope: ${output.requestedScope}`, + `Requested state: ${output.requestedState}`, + output.summary, + "", + "Rebuilt sidecars:", + ...output.rebuilt.map( + (check) => + `- ${check.scope}/${check.state}: ${check.indexPath} | generatedAt: ${check.generatedAt} | topicFiles: ${check.topicFileCount}` + ), + "", + "Markdown memory remains canonical; retrieval-index.json is a rebuildable acceleration sidecar." + ].join("\n"); +} diff --git a/src/lib/commands/recall.ts b/src/lib/commands/recall.ts index 55495a5..a52d7f8 100644 --- a/src/lib/commands/recall.ts +++ b/src/lib/commands/recall.ts @@ -4,7 +4,7 @@ import type { MemoryRetrievalScope, MemoryRetrievalStateFilter, MemorySearchResponse, - MemoryTimelineEvent + MemoryTimelineResponse } from "../types.js"; import { buildMemoryTimelineResponse, @@ -61,20 +61,39 @@ function formatSearchResults(response: MemorySearchResponse): string { return lines.join("\n"); } -function formatTimeline(ref: string, timeline: MemoryTimelineEvent[]): string { +function formatTimeline(timeline: MemoryTimelineResponse): string { const lines = [ "Codex Auto Memory Recall Timeline", - `Ref: ${ref}`, - `Events: ${timeline.length}` + `Ref: ${timeline.ref}`, + `Events: ${timeline.events.length}` ]; - if (timeline.length === 0) { + if (timeline.warnings.length > 0) { + lines.push("", "Warnings:", ...timeline.warnings.map((warning) => `- ${warning}`)); + } + + lines.push( + "", + "Lineage:", + `- Latest action: ${timeline.lineageSummary.latestAction ?? "unknown"}`, + `- Latest state: ${timeline.lineageSummary.latestState ?? "unknown"}`, + `- Latest audit status: ${timeline.lineageSummary.latestAuditStatus ?? "unknown"}`, + `- First seen: ${timeline.lineageSummary.firstSeenAt ?? "unknown"}`, + `- Latest event at: ${timeline.lineageSummary.latestAt ?? "unknown"}`, + `- Archived at: ${timeline.lineageSummary.archivedAt ?? "n/a"}`, + `- Deleted at: ${timeline.lineageSummary.deletedAt ?? "n/a"}`, + `- No-op count: ${timeline.lineageSummary.noopOperationCount}`, + `- Suppressed count: ${timeline.lineageSummary.suppressedOperationCount}`, + `- Conflict count: ${timeline.lineageSummary.conflictCount}` + ); + + if (timeline.events.length === 0) { lines.push("", "No timeline events were recorded for this memory ref."); return lines.join("\n"); } lines.push(""); - for (const event of timeline) { + for (const event of timeline.events) { lines.push(`- ${event.at}: [${event.action}] ${event.summary}`); lines.push(` Scope: ${event.scope} | State: ${event.state} | Topic: ${event.topic}`); if (event.reason) { @@ -103,6 +122,7 @@ function formatDetails(details: MemoryDetailsResult): string { `Scope: ${details.scope} | State: ${details.state} | Topic: ${details.topic}`, `Updated: ${details.entry.updatedAt}`, `Latest lifecycle action: ${details.latestLifecycleAction ?? "unknown"}`, + `Latest state: ${details.latestState}`, `Summary: ${details.entry.summary}`, "Details:", ...details.entry.details.map((detail) => `- ${detail}`) @@ -124,6 +144,25 @@ function formatDetails(details: MemoryDetailsResult): string { ); } + lines.push( + "Lineage:", + `- Latest action: ${details.lineageSummary.latestAction ?? "unknown"}`, + `- Latest state: ${details.lineageSummary.latestState ?? details.latestState}`, + `- Latest audit status: ${details.lineageSummary.latestAuditStatus ?? "unknown"}`, + `- First seen: ${details.lineageSummary.firstSeenAt ?? "unknown"}`, + `- Latest event at: ${details.lineageSummary.latestAt ?? "unknown"}`, + `- Archived at: ${details.lineageSummary.archivedAt ?? "n/a"}`, + `- Deleted at: ${details.lineageSummary.deletedAt ?? "n/a"}`, + `- No-op count: ${details.lineageSummary.noopOperationCount}`, + `- Suppressed count: ${details.lineageSummary.suppressedOperationCount}`, + `- Conflict count: ${details.lineageSummary.conflictCount}`, + `- Timeline warning count: ${details.timelineWarningCount}` + ); + + if (details.warnings.length > 0) { + lines.push("Warnings:", ...details.warnings.map((warning) => `- ${warning}`)); + } + if (details.entry.sources.length > 0) { lines.push("Sources:", ...details.entry.sources.map((source) => `- ${source}`)); } @@ -162,7 +201,7 @@ export async function runRecall( if (options.json) { return JSON.stringify(buildMemoryTimelineResponse(target, timeline), null, 2); } - return formatTimeline(target, timeline); + return formatTimeline(timeline); } case "details": { assertValidMemoryRef(target); diff --git a/src/lib/commands/skills.ts b/src/lib/commands/skills.ts index 0340b18..af8b78b 100644 --- a/src/lib/commands/skills.ts +++ b/src/lib/commands/skills.ts @@ -12,6 +12,7 @@ import { resolveMcpProjectRoot } from "../integration/mcp-config.js"; interface SkillsCommandOptions { cwd?: string; + json?: boolean; surface?: string; } @@ -23,6 +24,23 @@ export async function installSkills(options: SkillsCommandOptions = {}): Promise skillSurface }); + if (options.json) { + return JSON.stringify( + { + action: result.action, + targetDir: result.targetDir, + surface: skillSurface, + preferredSkillSurface: result.preferredSkillSurface ?? "runtime", + readOnlyRetrieval: result.readOnlyRetrieval, + workflowContract: result.workflowContract, + notes: result.notes, + assets: result.assets + }, + null, + 2 + ); + } + return [ `Installed Codex skill assets in ${result.targetDir}`, `Action: ${result.action}`, diff --git a/src/lib/domain/memory-retrieval-contract.ts b/src/lib/domain/memory-retrieval-contract.ts index 19fcfbc..d4bd7f1 100644 --- a/src/lib/domain/memory-retrieval-contract.ts +++ b/src/lib/domain/memory-retrieval-contract.ts @@ -94,11 +94,34 @@ export function buildMemorySearchResponse( export function buildMemoryTimelineResponse( ref: string, - events: MemoryTimelineEvent[] + timeline: + | MemoryTimelineResponse + | { + events: MemoryTimelineEvent[]; + warnings?: string[]; + lineageSummary?: MemoryTimelineResponse["lineageSummary"]; + } ): MemoryTimelineResponse { return { ref, - events + events: [...timeline.events], + warnings: [...(timeline.warnings ?? [])], + lineageSummary: + timeline.lineageSummary !== undefined + ? { ...timeline.lineageSummary } + : { + eventCount: timeline.events.length, + firstSeenAt: null, + latestAt: null, + latestAction: null, + latestState: null, + archivedAt: null, + deletedAt: null, + latestAuditStatus: null, + noopOperationCount: 0, + suppressedOperationCount: 0, + conflictCount: 0 + } }; } @@ -158,6 +181,10 @@ export function toMemorySearchResultShapes( export function toMemoryDetailsResultShape(details: MemoryDetailsResult): MemoryDetailsResult { return { ...details, + lineageSummary: { + ...details.lineageSummary + }, + warnings: [...details.warnings], latestAudit: details.latestAudit ? { ...details.latestAudit, diff --git a/src/lib/domain/memory-retrieval.ts b/src/lib/domain/memory-retrieval.ts index 41df68d..b808a4c 100644 --- a/src/lib/domain/memory-retrieval.ts +++ b/src/lib/domain/memory-retrieval.ts @@ -3,7 +3,7 @@ import type { MemoryRetrievalScope, MemoryRetrievalStateFilter, MemorySearchResponse, - MemoryTimelineEvent + MemoryTimelineResponse } from "../types.js"; import { buildMemorySearchResponse, @@ -91,8 +91,8 @@ export class MemoryRetrievalService { ); } - public async timelineMemories(ref: string): Promise { - return this.memoryStore.readTimeline(ref); + public async timelineMemories(ref: string): Promise { + return this.memoryStore.readTimelineWithDiagnostics(ref); } public async getMemoryDetails(ref: string): Promise { diff --git a/src/lib/domain/memory-store.ts b/src/lib/domain/memory-store.ts index 73ad852..b2b4d72 100644 --- a/src/lib/domain/memory-store.ts +++ b/src/lib/domain/memory-store.ts @@ -7,8 +7,10 @@ import type { MemoryDetailsResult, MemoryEntry, MemoryHistoryRecordState, + MemoryLineageSummary, MemoryMutation, MemoryOperation, + MemoryReindexCheck, MemoryRecordState, MemorySearchDiagnosticPath, MemorySearchDiagnostics, @@ -19,6 +21,7 @@ import type { MemorySyncAuditSummary, MemorySyncAuditEntry, MemoryTimelineEvent, + MemoryTimelineResponse, ProcessedRolloutIdentity, ProcessedRolloutRecord, ProjectContext, @@ -121,6 +124,17 @@ export interface RetrievalSidecarCheck { topicFiles: string[]; } +interface HistoryReadResult { + events: MemoryTimelineEvent[]; + warnings: string[]; + historyPath: string; +} + +interface TimelineReadResult extends MemoryTimelineResponse { + latestAudit: MemorySyncAuditSummary | null; + latestEvent: MemoryTimelineEvent | null; +} + interface PlannedFileChange { path: string; contents: string | null; @@ -404,6 +418,80 @@ function appendJsonlContents(existingContents: string | null, values: unknown[]) return `${prefix}${values.map((value) => JSON.stringify(value)).join("\n")}\n`; } +function buildEmptyLineageSummary(): MemoryLineageSummary { + return { + eventCount: 0, + firstSeenAt: null, + latestAt: null, + latestAction: null, + latestState: null, + archivedAt: null, + deletedAt: null, + latestAuditStatus: null, + noopOperationCount: 0, + suppressedOperationCount: 0, + conflictCount: 0 + }; +} + +function buildLineageSummary( + events: MemoryTimelineEvent[], + latestAudit: MemorySyncAuditSummary | null +): MemoryLineageSummary { + if (events.length === 0) { + return { + ...buildEmptyLineageSummary(), + latestAuditStatus: latestAudit?.status ?? null, + noopOperationCount: latestAudit?.noopOperationCount ?? 0, + suppressedOperationCount: latestAudit?.suppressedOperationCount ?? 0, + conflictCount: latestAudit?.conflicts.length ?? 0 + }; + } + + const chronologicalEvents = [...events].sort((left, right) => left.at.localeCompare(right.at)); + const latestEvent = events[0] ?? null; + const archivedEvent = + chronologicalEvents.find((event) => event.action === "archive") ?? null; + const deletedEvent = + chronologicalEvents.find((event) => event.action === "delete") ?? null; + + return { + eventCount: events.length, + firstSeenAt: chronologicalEvents[0]?.at ?? null, + latestAt: latestEvent?.at ?? null, + latestAction: latestEvent?.action ?? null, + latestState: latestEvent?.state ?? null, + archivedAt: archivedEvent?.at ?? null, + deletedAt: deletedEvent?.at ?? null, + latestAuditStatus: latestAudit?.status ?? null, + noopOperationCount: latestAudit?.noopOperationCount ?? 0, + suppressedOperationCount: latestAudit?.suppressedOperationCount ?? 0, + conflictCount: latestAudit?.conflicts.length ?? 0 + }; +} + +function buildHistoryWarnings( + historyPath: string, + invalidJsonLineCount: number, + invalidEventCount: number +): string[] { + const warnings: string[] = []; + + if (invalidJsonLineCount > 0) { + warnings.push( + `Ignored ${invalidJsonLineCount} invalid JSONL lifecycle history line(s) while reading ${historyPath}.` + ); + } + + if (invalidEventCount > 0) { + warnings.push( + `Ignored ${invalidEventCount} malformed lifecycle event(s) while reading ${historyPath}.` + ); + } + + return warnings; +} + function legacyEmptyIndexContents(scope: MemoryScope): string { return [ `# ${topicTitle(scope)} Memory`, @@ -901,6 +989,47 @@ export class MemoryStore { return checks; } + public async rebuildRetrievalSidecars(options: { + scope?: MemoryScope | "all"; + state?: MemoryRecordState | "all"; + } = {}): Promise { + const scopes: MemoryScope[] = + options.scope && options.scope !== "all" + ? [options.scope] + : ["global", "project", "project-local"]; + const states: MemoryRecordState[] = + options.state === "all" || options.state === undefined + ? ["active", "archived"] + : [options.state]; + + const rebuilt: MemoryReindexCheck[] = []; + + await this.ensureLayout(); + for (const scope of scopes) { + for (const state of states) { + await this.rebuildRetrievalIndex(scope, state); + const inspection = await this.inspectRetrievalIndex(scope, state); + if (!inspection.payload || inspection.generatedAt === null || inspection.topicFileCount === null) { + throw new Error( + `Failed to rebuild retrieval sidecar for ${scope}/${state}; inspection still returned ${inspection.status}.` + ); + } + + rebuilt.push({ + scope, + state, + status: "ok", + indexPath: inspection.indexPath, + generatedAt: inspection.generatedAt, + topicFileCount: inspection.topicFileCount, + topicFiles: [...inspection.topicFiles] + }); + } + } + + return rebuilt; + } + private async readRetrievalIndex( scope: MemoryScope, state: MemoryRecordState @@ -1147,14 +1276,34 @@ export class MemoryStore { return null; } - const latestEvent = (await this.readTimeline(ref))[0] ?? null; - const latestAudit = await this.findLatestSyncAuditSummary( - parsed.scope, - parsed.topic, - parsed.id, - latestEvent?.rolloutPath, - latestEvent?.sessionId - ); + const timeline = await this.readTimelineWithDiagnostics(ref); + const latestEvent = timeline.latestEvent; + const latestAudit = timeline.latestAudit; + const warnings = [...timeline.warnings]; + if (latestEvent && !latestAudit && (latestEvent.rolloutPath || latestEvent.sessionId)) { + warnings.push( + `Lifecycle history exists for ${ref}, but no matching sync audit entry was found in ${this.getSyncAuditPath()}.` + ); + } + + if ((latestAudit?.noopOperationCount ?? 0) > 0) { + warnings.push( + `Latest sync audit recorded ${latestAudit?.noopOperationCount ?? 0} no-op operation(s).` + ); + } + + if ((latestAudit?.suppressedOperationCount ?? 0) > 0) { + warnings.push( + `Latest sync audit suppressed ${latestAudit?.suppressedOperationCount ?? 0} operation(s).` + ); + } + + if ((latestAudit?.conflicts.length ?? 0) > 0) { + warnings.push( + `Latest sync audit includes ${latestAudit?.conflicts.length ?? 0} suppressed conflict candidate(s).` + ); + } + return { ...parsed, entry, @@ -1164,10 +1313,17 @@ export class MemoryStore { : this.getArchiveTopicFile(parsed.scope, parsed.topic), approxReadCost: entry.details.length + 4, latestLifecycleAction: latestEvent?.action ?? null, + latestState: latestEvent?.state ?? parsed.state, latestSessionId: latestEvent?.sessionId ?? null, latestRolloutPath: latestEvent?.rolloutPath ?? null, historyPath: this.getHistoryPath(parsed.scope), - latestAudit + latestAudit, + timelineWarningCount: timeline.warnings.length, + lineageSummary: { + ...timeline.lineageSummary, + latestState: timeline.lineageSummary.latestState ?? parsed.state + }, + warnings }; } @@ -1303,15 +1459,49 @@ export class MemoryStore { } public async readTimeline(ref: string): Promise { + return (await this.readTimelineWithDiagnostics(ref)).events; + } + + public async readTimelineWithDiagnostics(ref: string): Promise { const parsed = parseMemoryRef(ref); if (!parsed) { - return []; + return { + ref, + events: [], + warnings: [], + lineageSummary: buildEmptyLineageSummary(), + latestAudit: null, + latestEvent: null + }; } - const history = await this.readHistory(parsed.scope); - return history + const history = await this.readHistoryWithDiagnostics(parsed.scope); + const events = history.events .filter((entry) => entry.id === parsed.id && entry.topic === parsed.topic) .sort((left, right) => right.at.localeCompare(left.at)); + const latestEvent = events[0] ?? null; + const latestAudit = await this.findLatestSyncAuditSummary( + parsed.scope, + parsed.topic, + parsed.id, + latestEvent?.rolloutPath, + latestEvent?.sessionId + ); + const warnings = [...history.warnings]; + if (latestEvent && !latestAudit && (latestEvent.rolloutPath || latestEvent.sessionId)) { + warnings.push( + `Lifecycle history exists for ${ref}, but no matching sync audit entry was found in ${this.getSyncAuditPath()}.` + ); + } + + return { + ref, + events, + warnings, + lineageSummary: buildLineageSummary(events, latestAudit), + latestAudit, + latestEvent + }; } private async readTextFileIfExists(filePath: string): Promise { @@ -1933,12 +2123,25 @@ export class MemoryStore { } public async readHistory(scope: MemoryScope, limit?: number): Promise { + return (await this.readHistoryWithDiagnostics(scope, limit)).events; + } + + public async readHistoryWithDiagnostics( + scope: MemoryScope, + limit?: number + ): Promise { const historyPath = this.getHistoryPath(scope); if (!(await fileExists(historyPath))) { - return []; + return { + events: [], + warnings: [], + historyPath + }; } const raw = await readTextFile(historyPath); + let invalidJsonLineCount = 0; + let invalidEventCount = 0; const parsed = raw .split("\n") .map((line) => line.trim()) @@ -1946,14 +2149,24 @@ export class MemoryStore { .flatMap((line) => { try { const candidate = JSON.parse(line) as unknown; - return isTimelineEvent(candidate) ? [candidate] : []; + if (isTimelineEvent(candidate)) { + return [candidate]; + } + + invalidEventCount += 1; + return []; } catch { + invalidJsonLineCount += 1; return []; } }) .sort((left, right) => right.at.localeCompare(left.at)); - return typeof limit === "number" ? parsed.slice(0, limit) : parsed; + return { + events: typeof limit === "number" ? parsed.slice(0, limit) : parsed, + warnings: buildHistoryWarnings(historyPath, invalidJsonLineCount, invalidEventCount), + historyPath + }; } private async readSyncAuditEntries(): Promise { diff --git a/src/lib/integration/install-assets.ts b/src/lib/integration/install-assets.ts index a17fddf..2d2792f 100644 --- a/src/lib/integration/install-assets.ts +++ b/src/lib/integration/install-assets.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { listIntegrationAssets, type IntegrationAssetInstallSurface } from "./assets.js"; import { READ_ONLY_RETRIEVAL_NOTE } from "./codex-stack.js"; import { + buildWorkflowContract, formatRecommendedRetrievalPreset, MCP_FIRST_RECALL_WORKFLOW, CLI_FALLBACK_RECALL_WORKFLOW, @@ -33,6 +34,7 @@ export interface IntegrationAssetInstallResult { readOnlyRetrieval: true; assetVersion: string; recommendedPreset: string; + workflowContract: ReturnType; skillSurface?: CodexSkillInstallSurface; preferredSkillSurface?: CodexSkillInstallSurface; notes: string[]; @@ -124,6 +126,9 @@ export async function installIntegrationAssets( readOnlyRetrieval: true, assetVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, recommendedPreset: formatRecommendedRetrievalPreset(), + workflowContract: buildWorkflowContract({ + cwd: options.projectRoot + }), skillSurface: installSurface === "skills" ? skillSurface : undefined, preferredSkillSurface: installSurface === "skills" ? "runtime" : undefined, notes: diff --git a/src/lib/integration/mcp-doctor.ts b/src/lib/integration/mcp-doctor.ts index de8b22e..a3db1fd 100644 --- a/src/lib/integration/mcp-doctor.ts +++ b/src/lib/integration/mcp-doctor.ts @@ -196,6 +196,7 @@ interface McpDoctorFallbackAssets { interface McpDoctorRetrievalSidecarReport { status: "ok" | "warning"; summary: string; + repairCommand: string; checks: RetrievalSidecarCheck[]; } @@ -746,13 +747,21 @@ async function inspectFallbackAssets( } function buildRetrievalSidecarReport( - checks: RetrievalSidecarCheck[] + checks: RetrievalSidecarCheck[], + options: { + cwd?: string; + } = {} ): McpDoctorRetrievalSidecarReport { + const repairCommand = appendCliCwdFlag( + "cam memory reindex --scope all --state all", + options.cwd + ); const degradedChecks = checks.filter((check) => check.status !== "ok"); if (degradedChecks.length === 0) { return { status: "ok", summary: "All inspected retrieval sidecars are current.", + repairCommand, checks }; } @@ -761,6 +770,7 @@ function buildRetrievalSidecarReport( status: "warning", summary: "One or more retrieval sidecars are missing, invalid, or stale. Recall still falls back to Markdown canonical memory safely.", + repairCommand, checks }; } @@ -857,7 +867,10 @@ export async function inspectMcpDoctor(options: { }); const runtime = await buildRuntimeContext(cwd, {}, { ensureMemoryLayout: false }); const retrievalSidecar = buildRetrievalSidecarReport( - await runtime.syncService.memoryStore.inspectRetrievalSidecars() + await runtime.syncService.memoryStore.inspectRetrievalSidecars(), + { + cwd: options.explicitCwd ? projectRoot : undefined + } ); const camCommandAvailable = await isCommandAvailableInPath("cam"); const codexHost = hosts.find((host) => host.host === "codex") ?? (await inspectHost("codex", projectRoot)); @@ -971,6 +984,7 @@ export function formatMcpDoctorReport(report: McpDoctorReport): string { "Retrieval sidecar:", `- Status: ${report.retrievalSidecar.status}`, `- Summary: ${report.retrievalSidecar.summary}`, + `- Repair command: ${report.retrievalSidecar.repairCommand}`, ...report.retrievalSidecar.checks.map( (check) => `- ${check.scope}/${check.state}: ${check.status}${check.fallbackReason ? ` (${check.fallbackReason})` : ""} | index: ${check.indexPath} | generatedAt: ${check.generatedAt ?? "none"} | topicFiles: ${check.topicFileCount ?? "none"}` @@ -1053,6 +1067,7 @@ export function formatMcpDoctorReport(report: McpDoctorReport): string { "Notes:", "- cam mcp install writes the recommended project-scoped host config for codex, claude, or gemini only.", "- cam mcp doctor only inspects the recommended project-scoped wiring and never writes host config files.", + "- Run the retrieval sidecar repair command above if retrieval indexes are missing, invalid, or stale.", "- Re-run cam hooks install or cam skills install if a fallback asset is reported as stale.", "- cam memory is the inspect/audit surface for durable memory.", "- cam session is the temporary continuity surface and is not the same as durable memory retrieval.", diff --git a/src/lib/mcp/retrieval-server.ts b/src/lib/mcp/retrieval-server.ts index dff5414..8a8d3b6 100644 --- a/src/lib/mcp/retrieval-server.ts +++ b/src/lib/mcp/retrieval-server.ts @@ -78,9 +78,25 @@ const memoryTimelineEventSchema = z.object({ rolloutPath: z.string().optional() }); +const memoryLineageSummarySchema = z.object({ + eventCount: z.number().int().nonnegative(), + firstSeenAt: z.string().nullable(), + latestAt: z.string().nullable(), + latestAction: memoryLifecycleActionSchema.nullable(), + latestState: memoryHistoryRecordStateSchema.nullable(), + archivedAt: z.string().nullable(), + deletedAt: z.string().nullable(), + latestAuditStatus: z.enum(["applied", "no-op", "skipped"]).nullable(), + noopOperationCount: z.number().int().nonnegative(), + suppressedOperationCount: z.number().int().nonnegative(), + conflictCount: z.number().int().nonnegative() +}); + const memoryTimelineResponseSchema = z.object({ ref: z.string(), - events: z.array(memoryTimelineEventSchema) + events: z.array(memoryTimelineEventSchema), + warnings: z.array(z.string()), + lineageSummary: memoryLineageSummarySchema }); const memoryDetailsResponseSchema = z.object({ @@ -92,9 +108,13 @@ const memoryDetailsResponseSchema = z.object({ path: z.string(), approxReadCost: z.number().int().nonnegative(), latestLifecycleAction: memoryLifecycleActionSchema.nullable(), + latestState: memoryHistoryRecordStateSchema, latestSessionId: z.string().nullable(), latestRolloutPath: z.string().nullable(), historyPath: z.string(), + timelineWarningCount: z.number().int().nonnegative(), + lineageSummary: memoryLineageSummarySchema, + warnings: z.array(z.string()), latestAudit: z .object({ auditPath: z.string(), diff --git a/src/lib/types.ts b/src/lib/types.ts index f02293a..dc33209 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -107,6 +107,22 @@ export interface MemoryTimelineEvent { export interface MemoryTimelineResponse { ref: string; events: MemoryTimelineEvent[]; + warnings: string[]; + lineageSummary: MemoryLineageSummary; +} + +export interface MemoryLineageSummary { + eventCount: number; + firstSeenAt: string | null; + latestAt: string | null; + latestAction: Exclude | null; + latestState: MemoryHistoryRecordState | null; + archivedAt: string | null; + deletedAt: string | null; + latestAuditStatus: MemorySyncAuditStatus | null; + noopOperationCount: number; + suppressedOperationCount: number; + conflictCount: number; } export interface MemoryDetailsResult extends MemoryRef { @@ -114,10 +130,14 @@ export interface MemoryDetailsResult extends MemoryRef { path: string; approxReadCost: number; latestLifecycleAction: Exclude | null; + latestState: MemoryHistoryRecordState; latestSessionId: string | null; latestRolloutPath: string | null; historyPath: string; latestAudit: MemorySyncAuditSummary | null; + timelineWarningCount: number; + lineageSummary: MemoryLineageSummary; + warnings: string[]; } export interface MemorySyncAuditSummary { @@ -463,6 +483,24 @@ export interface MemoryCommandOutput { syncRecoveryPath: string; } +export interface MemoryReindexCheck { + scope: MemoryScope; + state: MemoryRecordState; + status: "ok"; + indexPath: string; + generatedAt: string; + topicFileCount: number; + topicFiles: string[]; +} + +export interface MemoryReindexOutput { + projectRoot: string; + requestedScope: MemoryScope | "all"; + requestedState: MemoryRecordState | "all"; + rebuilt: MemoryReindexCheck[]; + summary: string; +} + export interface SyncResult { applied: MemoryOperation[]; skipped: boolean; diff --git a/test/docs-contract.test.ts b/test/docs-contract.test.ts index 1041ffe..dd709b1 100644 --- a/test/docs-contract.test.ts +++ b/test/docs-contract.test.ts @@ -171,8 +171,17 @@ describe("docs contract", () => { expect(releaseChecklist).toContain("node dist/cli.js recall search pnpm --json"); expect(releaseChecklist).toContain("state=auto, limit=8"); expect(releaseChecklist).toContain("checkedPaths"); + expect(releaseChecklist).toContain("node dist/cli.js memory reindex --scope all --state all --json"); expect(releaseChecklist).toContain("node dist/cli.js recall details --json"); + expect(releaseChecklist).toContain("latestLifecycleAction"); + expect(releaseChecklist).toContain("latestSessionId"); + expect(releaseChecklist).toContain("latestRolloutPath"); + expect(releaseChecklist).toContain("historyPath"); expect(releaseChecklist).toContain("latestAudit"); + expect(releaseChecklist).toContain("latestState"); + expect(releaseChecklist).toContain("timelineWarningCount"); + expect(releaseChecklist).toContain("lineageSummary"); + expect(releaseChecklist).toContain("warnings"); expect(releaseChecklist).toContain( "node dist/cli.js mcp install --host --json" ); @@ -188,6 +197,7 @@ describe("docs contract", () => { expect(releaseChecklist).toContain("node dist/cli.js mcp doctor --host codex --json"); expect(releaseChecklist).toContain("structured `workflowContract`"); expect(releaseChecklist).toContain("retrievalSidecar"); + expect(releaseChecklist).toContain("repair command"); expect(releaseChecklist).toContain("alternate global wiring"); expect(releaseChecklist).toContain("non-canonical custom fields"); expect(releaseChecklist).toContain("preservedCustomFields"); @@ -197,6 +207,8 @@ describe("docs contract", () => { expect(releaseChecklist).toContain("officialUserSkillMatchesCanonical"); expect(releaseChecklist).toContain("anySkillSurfaceInstalled"); expect(releaseChecklist).toContain("post-work-memory-review.sh"); + expect(releaseChecklist).toContain("node dist/cli.js hooks install --json"); + expect(releaseChecklist).toContain("node dist/cli.js skills install --json"); expect(releaseChecklist).toContain("node dist/cli.js integrations install --host codex --json"); expect(releaseChecklist).toContain("node dist/cli.js integrations apply --host codex --json"); expect(releaseChecklist).toContain('stackAction: "blocked"'); diff --git a/test/hooks-command.test.ts b/test/hooks-command.test.ts index c17b880..d07c902 100644 --- a/test/hooks-command.test.ts +++ b/test/hooks-command.test.ts @@ -176,6 +176,34 @@ describe("hooks command", () => { expect(recallGuide).toContain("not an official Codex hook surface"); }); + it("emits a structured workflow contract in hooks install --json", async () => { + const homeDir = await tempDir("cam-hooks-json-home-"); + const projectDir = await tempDir("cam-hooks-json-project-"); + process.env.HOME = homeDir; + + const result = runCli(projectDir, ["hooks", "install", "--json"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + action: "created", + targetDir: path.join(homeDir, ".codex-auto-memory", "hooks"), + readOnlyRetrieval: true, + workflowContract: { + recommendedPreset: "state=auto, limit=8", + cliFallback: { + searchCommand: 'cam recall search "" --state auto --limit 8' + }, + postWorkSyncReview: { + helperScript: "post-work-memory-review.sh" + } + }, + assets: expect.arrayContaining([ + expect.objectContaining({ + id: "memory-recall" + }) + ]) + }); + }); + shellOnlyIt("executes the recall bridge bundle without overriding explicit state or limit flags", async () => { const homeDir = await tempDir("cam-hooks-exec-home-"); const projectDir = await tempDir("cam-hooks-exec-project-"); diff --git a/test/integrations-command.test.ts b/test/integrations-command.test.ts index b323362..115e77b 100644 --- a/test/integrations-command.test.ts +++ b/test/integrations-command.test.ts @@ -200,6 +200,7 @@ describe("integrations command", () => { recommendedPreset: "state=auto, limit=8", retrievalSidecar: { status: "warning", + repairCommand: "cam memory reindex --scope all --state all", checks: expect.arrayContaining([ expect.objectContaining({ scope: "project", @@ -230,11 +231,10 @@ describe("integrations command", () => { } } }); - expect(JSON.parse(result.stdout).nextSteps[0]).toContain( - "cam integrations apply --host codex" - ); expect(JSON.parse(result.stdout).nextSteps).toEqual( expect.arrayContaining([ + expect.stringContaining("cam memory reindex --scope all --state all"), + expect.stringContaining("cam integrations apply --host codex"), expect.stringContaining("cam mcp print-config --host codex") ]) ); diff --git a/test/mcp-command.test.ts b/test/mcp-command.test.ts index 2312db2..7454621 100644 --- a/test/mcp-command.test.ts +++ b/test/mcp-command.test.ts @@ -49,6 +49,16 @@ interface SearchMemoriesResponse { interface TimelineMemoriesResponse { ref: string; + warnings: string[]; + lineageSummary: { + eventCount: number; + latestAction: string | null; + latestState: string | null; + latestAuditStatus: string | null; + noopOperationCount: number; + suppressedOperationCount: number; + conflictCount: number; + }; events: Array<{ action: string; state: string; @@ -61,9 +71,21 @@ interface MemoryDetailsResponse { ref: string; path: string; latestLifecycleAction: string; + latestState: string; latestSessionId: string | null; latestRolloutPath: string | null; historyPath: string; + timelineWarningCount: number; + lineageSummary: { + eventCount: number; + latestAction: string | null; + latestState: string | null; + latestAuditStatus: string | null; + noopOperationCount: number; + suppressedOperationCount: number; + conflictCount: number; + }; + warnings: string[]; latestAudit?: { auditPath: string; rolloutPath: string; @@ -1532,6 +1554,7 @@ describe("mcp command", () => { expect(payload.retrievalSidecar).toMatchObject({ status: "warning", summary: expect.stringContaining("Markdown"), + repairCommand: `cam memory reindex --scope all --state all --cwd ${JSON.stringify(realProjectDir)}`, checks: expect.arrayContaining([ expect.objectContaining({ scope: "project", @@ -2315,14 +2338,28 @@ describe("mcp command", () => { ["integrations", "doctor", "--host", "codex", "--cwd", projectDir, "--json"], { env: { HOME: homeDir } } ); + const hooksInstall = runCli( + shellDir, + ["hooks", "install", "--cwd", projectDir, "--json"], + { env: { HOME: homeDir } } + ); + const skillsInstall = runCli( + shellDir, + ["skills", "install", "--cwd", projectDir, "--json"], + { env: { HOME: homeDir } } + ); expect(printConfig.exitCode, printConfig.stderr).toBe(0); expect(mcpDoctor.exitCode, mcpDoctor.stderr).toBe(0); expect(integrationsDoctor.exitCode, integrationsDoctor.stderr).toBe(0); + expect(hooksInstall.exitCode, hooksInstall.stderr).toBe(0); + expect(skillsInstall.exitCode, skillsInstall.stderr).toBe(0); const printWorkflow = JSON.parse(printConfig.stdout).workflowContract; const mcpWorkflow = JSON.parse(mcpDoctor.stdout).workflowContract; const integrationsWorkflow = JSON.parse(integrationsDoctor.stdout).workflowContract; + const hooksWorkflow = JSON.parse(hooksInstall.stdout).workflowContract; + const skillsWorkflow = JSON.parse(skillsInstall.stdout).workflowContract; const expectedCore = { recommendedPreset: "state=auto, limit=8", preferredRoute: "mcp-first", @@ -2347,6 +2384,8 @@ describe("mcp command", () => { expect(printWorkflow).toMatchObject(expectedCore); expect(mcpWorkflow).toMatchObject(expectedCore); expect(integrationsWorkflow).toMatchObject(expectedCore); + expect(hooksWorkflow).toMatchObject(expectedCore); + expect(skillsWorkflow).toMatchObject(expectedCore); }); it("reports an operational MCP route once cam is available on PATH", async () => { @@ -2585,6 +2624,16 @@ describe("mcp command", () => { timelineResult as ToolCallResultLike ); expect(timelinePayload.ref).toBe(ref); + expect(timelinePayload.warnings).toEqual([]); + expect(timelinePayload.lineageSummary).toMatchObject({ + eventCount: 2, + latestAction: "archive", + latestState: "archived", + latestAuditStatus: null, + noopOperationCount: 0, + suppressedOperationCount: 0, + conflictCount: 0 + }); expect(timelinePayload.events.slice(0, 2)).toEqual([ expect.objectContaining({ action: "archive", state: "archived" }), expect.objectContaining({ action: "add", state: "active" }) @@ -2601,9 +2650,21 @@ describe("mcp command", () => { ref, path: store.getArchiveTopicFile("project", "workflow"), latestLifecycleAction: "archive", + latestState: "archived", latestSessionId: null, latestRolloutPath: null, historyPath: store.getHistoryPath("project"), + timelineWarningCount: 0, + lineageSummary: { + eventCount: 2, + latestAction: "archive", + latestState: "archived", + latestAuditStatus: null, + noopOperationCount: 0, + suppressedOperationCount: 0, + conflictCount: 0 + }, + warnings: [], entry: { summary: "Prefer pnpm in this repository.", details: ["Use pnpm instead of npm in this repository."] @@ -3081,8 +3142,17 @@ describe("mcp command", () => { detailsResult as ToolCallResultLike ); expect(detailsPayload).toMatchObject({ + latestState: "active", latestSessionId: "session-mcp-audit", latestRolloutPath: rolloutPath, + timelineWarningCount: 0, + lineageSummary: expect.objectContaining({ + eventCount: 1, + latestAction: "add", + latestState: "active", + latestAuditStatus: "applied" + }), + warnings: [], latestAudit: { auditPath: service.memoryStore.getSyncAuditPath(), rolloutPath, diff --git a/test/memory-command.test.ts b/test/memory-command.test.ts index b6cb189..e9fb970 100644 --- a/test/memory-command.test.ts +++ b/test/memory-command.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { runMemory } from "../src/lib/commands/memory.js"; +import { runMemory, runMemoryReindex } from "../src/lib/commands/memory.js"; import { configPaths } from "../src/lib/config/load-config.js"; import { detectProjectContext } from "../src/lib/domain/project-context.js"; import { MemoryStore } from "../src/lib/domain/memory-store.js"; @@ -1208,4 +1208,122 @@ describe("runMemory", () => { expect(await snapshotFiles(Object.keys(projectSnapshot))).toEqual(projectSnapshot); expect(await snapshotFiles(Object.keys(projectLocalSnapshot))).toEqual(projectLocalSnapshot); }); + + it("rebuilds retrieval sidecars explicitly from canonical Markdown memory", async () => { + const homeDir = await tempDir("cam-memory-reindex-home-"); + const projectDir = await tempDir("cam-memory-reindex-project-"); + const memoryRoot = await tempDir("cam-memory-reindex-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const project = detectProjectContext(projectDir); + const store = new MemoryStore(project, { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await store.remember( + "project", + "workflow", + "historical-note", + "Historical pnpm migration note.", + ["Old pnpm migration note kept for history."], + "Manual note." + ); + await store.forget("project", "historical", { archive: true }); + + const activeTopicPath = store.getTopicFile("project", "workflow"); + const archivedTopicPath = store.getArchiveTopicFile("project", "workflow"); + const activeContentsBefore = await fs.readFile(activeTopicPath, "utf8"); + const archivedContentsBefore = await fs.readFile(archivedTopicPath, "utf8"); + + await fs.writeFile(store.getRetrievalIndexFile("project", "active"), "{bad-json", "utf8"); + await fs.rm(store.getRetrievalIndexFile("project", "archived"), { force: true }); + + const output = JSON.parse( + await runMemoryReindex({ + cwd: projectDir, + json: true, + scope: "project", + state: "all" + }) + ) as { + projectRoot: string; + requestedScope: string; + requestedState: string; + rebuilt: Array<{ + scope: string; + state: string; + status: string; + indexPath: string; + generatedAt: string; + topicFileCount: number; + topicFiles: string[]; + }>; + summary: string; + }; + + expect(output).toMatchObject({ + projectRoot: project.projectRoot, + requestedScope: "project", + requestedState: "all", + summary: "Rebuilt 2 retrieval sidecar(s) from Markdown canonical memory." + }); + expect(output.rebuilt).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + state: "active", + status: "ok", + indexPath: store.getRetrievalIndexFile("project", "active"), + generatedAt: expect.any(String), + topicFileCount: 1, + topicFiles: ["workflow.md"] + }), + expect.objectContaining({ + scope: "project", + state: "archived", + status: "ok", + indexPath: store.getRetrievalIndexFile("project", "archived"), + generatedAt: expect.any(String), + topicFileCount: 1, + topicFiles: ["workflow.md"] + }) + ]) + ); + expect(await fs.readFile(activeTopicPath, "utf8")).toBe(activeContentsBefore); + expect(await fs.readFile(archivedTopicPath, "utf8")).toBe(archivedContentsBefore); + + const cliResult = runCli( + projectDir, + ["memory", "reindex", "--scope", "project", "--state", "active", "--json"], + { + env: { HOME: homeDir } + } + ); + expect(cliResult.exitCode, cliResult.stderr).toBe(0); + expect(JSON.parse(cliResult.stdout)).toMatchObject({ + requestedScope: "project", + requestedState: "active", + rebuilt: [ + expect.objectContaining({ + scope: "project", + state: "active", + status: "ok" + }) + ] + }); + }); }); diff --git a/test/recall-command.test.ts b/test/recall-command.test.ts index 595de12..fcb7672 100644 --- a/test/recall-command.test.ts +++ b/test/recall-command.test.ts @@ -408,6 +408,16 @@ describe("runRecall", () => { expect(timelineResult.exitCode).toBe(0); expect(JSON.parse(timelineResult.stdout)).toMatchObject({ ref: searchOutput.results[0]!.ref, + warnings: [], + lineageSummary: expect.objectContaining({ + eventCount: 1, + latestAction: "add", + latestState: "active", + latestAuditStatus: "applied", + noopOperationCount: 0, + suppressedOperationCount: 0, + conflictCount: 0 + }), events: expect.arrayContaining([ expect.objectContaining({ sessionId: "session-provenance", @@ -428,9 +438,21 @@ describe("runRecall", () => { expect(detailsResult.exitCode).toBe(0); expect(JSON.parse(detailsResult.stdout)).toMatchObject({ latestLifecycleAction: "add", + latestState: "active", latestSessionId: "session-provenance", latestRolloutPath: rolloutPath, historyPath: store.getHistoryPath("project"), + timelineWarningCount: 0, + lineageSummary: expect.objectContaining({ + eventCount: 1, + latestAction: "add", + latestState: "active", + latestAuditStatus: "applied", + noopOperationCount: 0, + suppressedOperationCount: 0, + conflictCount: 0 + }), + warnings: [], latestAudit: { auditPath: store.getSyncAuditPath(), rolloutPath, @@ -443,6 +465,72 @@ describe("runRecall", () => { }); }); + it("surfaces additive timeline and details warnings when lifecycle history contains bad lines", async () => { + const homeDir = await tempDir("cam-recall-history-warning-home-"); + const projectDir = await tempDir("cam-recall-history-warning-project-"); + const memoryRoot = await tempDir("cam-recall-history-warning-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + await fs.appendFile( + store.getHistoryPath("project"), + `${JSON.stringify({ bad: "event" })}\n{not-json}\n`, + "utf8" + ); + + const ref = "project:active:workflow:prefer-pnpm"; + const timelineResult = runCli(projectDir, ["recall", "timeline", ref, "--json"]); + expect(timelineResult.exitCode).toBe(0); + expect(JSON.parse(timelineResult.stdout)).toMatchObject({ + ref, + warnings: expect.arrayContaining([ + expect.stringContaining("invalid JSONL lifecycle history line"), + expect.stringContaining("malformed lifecycle event") + ]), + lineageSummary: expect.objectContaining({ + eventCount: 1, + latestAction: "add", + latestState: "active" + }), + events: [expect.objectContaining({ action: "add" })] + }); + + const detailsResult = runCli(projectDir, ["recall", "details", ref, "--json"]); + expect(detailsResult.exitCode).toBe(0); + expect(JSON.parse(detailsResult.stdout)).toMatchObject({ + ref, + latestState: "active", + timelineWarningCount: 2, + warnings: expect.arrayContaining([ + expect.stringContaining("invalid JSONL lifecycle history line"), + expect.stringContaining("malformed lifecycle event") + ]), + lineageSummary: expect.objectContaining({ + eventCount: 1, + latestAction: "add", + latestState: "active" + }) + }); + }); + it("keeps recall search read-only and does not create memory layout on first lookup", async () => { const homeDir = await tempDir("cam-recall-readonly-home-"); const projectDir = await tempDir("cam-recall-readonly-project-"); diff --git a/test/skills-command.test.ts b/test/skills-command.test.ts index be621a8..69ede71 100644 --- a/test/skills-command.test.ts +++ b/test/skills-command.test.ts @@ -65,6 +65,37 @@ describe("skills command", () => { expect(skillFile).toContain("cam session"); }); + it("emits a structured workflow contract in skills install --json", async () => { + const homeDir = await tempDir("cam-skills-json-home-"); + const projectDir = await tempDir("cam-skills-json-project-"); + process.env.HOME = homeDir; + delete process.env.CODEX_HOME; + + const result = runCli(projectDir, ["skills", "install", "--json"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + action: "created", + targetDir: path.join(homeDir, ".codex", "skills", "codex-auto-memory-recall"), + surface: "runtime", + preferredSkillSurface: "runtime", + readOnlyRetrieval: true, + workflowContract: { + recommendedPreset: "state=auto, limit=8", + cliFallback: { + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(await fs.realpath(projectDir))}` + }, + postWorkSyncReview: { + helperScript: "post-work-memory-review.sh" + } + }, + assets: expect.arrayContaining([ + expect.objectContaining({ + id: "codex-memory-skill" + }) + ]) + }); + }); + it("installs skill assets under CODEX_HOME when it is set", async () => { const homeDir = await tempDir("cam-skills-codex-home-home-"); const codexHome = await tempDir("cam-skills-codex-home-codex-home-"); From de8b835aab3140559e25a7abefdbc785a96fca76 Mon Sep 17 00:00:00 2001 From: blocks Date: Fri, 27 Mar 2026 21:39:52 +0800 Subject: [PATCH 12/62] fix: harden issue5 memory safety and release gates --- README.en.md | 4 +- README.ja.md | 6 +- README.zh-TW.md | 6 +- docs/claude-reference.en.md | 2 +- docs/integration-strategy.md | 12 +- src/lib/cli/register-commands.ts | 6 +- src/lib/commands/forget.ts | 4 + src/lib/commands/integrations.ts | 89 ++++++++++++-- src/lib/domain/memory-store.ts | 56 ++++++--- test/dist-cli-smoke.test.ts | 188 +++++++++++++++++++++++++++++ test/docs-contract.test.ts | 29 +++++ test/integrations-command.test.ts | 165 ++++++++++++++++++++++++- test/memory-command.test.ts | 40 ++++++ test/memory-store.test.ts | 85 +++++++++++++ test/recall-command.test.ts | 55 +++++++++ test/tarball-install-smoke.test.ts | 170 +++++++++++++++++++++++++- 16 files changed, 870 insertions(+), 47 deletions(-) diff --git a/README.en.md b/README.en.md index e6c2a01..5c41595 100644 --- a/README.en.md +++ b/README.en.md @@ -216,7 +216,7 @@ cam audit | `cam session save` | merge / incremental save for continuity | | `cam session refresh` | replace / clean regeneration for continuity | | `cam session load` / `status` | inspect the continuity reviewer surface | -| `cam hooks` | manage the current local bridge / fallback recall bundle, including `memory-recall.sh`, `post-work-memory-review.sh`, compatibility wrappers, and `recall-bridge.md`; `post-work-memory-review.sh` chains `cam sync` with `cam memory --recent` for post-work durable-memory review; it is not an official Codex hook surface, and the bundle's recommended search preset is `state=auto`, `limit=8` | +| `cam hooks install` | generate and refresh the current local bridge / fallback helper bundle, including `memory-recall.sh`, `post-work-memory-review.sh`, compatibility wrappers, and `recall-bridge.md`; `post-work-memory-review.sh` chains `cam sync` with `cam memory --recent` for post-work durable-memory review; it is not an official Codex hook surface, and the bundle's recommended search preset is `state=auto`, `limit=8` | | `cam skills` | install Codex skill assets with `cam skills install`; the default target remains the runtime surface, while `--surface runtime|official-user|official-project` enables explicit migration-prep copies on official `.agents/skills` paths; all surfaces teach the same MCP-first, CLI-fallback progressive durable-memory retrieval workflow and the same recommended search preset: `state=auto`, `limit=8` | | `cam audit` | run privacy and secret-hygiene checks | | `cam doctor` | inspect local wiring and native-readiness posture | @@ -334,7 +334,7 @@ Current public-ready status: ### v0.2 - complete the issue-level memory goals, including the first shipped archive path through `cam forget --archive` -- clearer `cam memory` and `cam session` reviewer UX +- clearer `cam memory`, `cam session`, and `cam recall` reviewer UX - stronger contradiction handling and explicit memory lifecycle documentation - define and document hook, skill, and MCP-friendly integration surfaces without replacing the current Markdown-first contract - ship the first progressive-disclosure retrieval surface through `cam recall search / timeline / details` diff --git a/README.ja.md b/README.ja.md index 3da1097..c24b2e6 100644 --- a/README.ja.md +++ b/README.ja.md @@ -113,7 +113,7 @@ Claude Code はすでに比較的はっきりした auto memory 契約を公開 | worktree-aware | 同一 git リポジトリ内の worktree で project memory を共有しつつ local continuity は分離する | | session continuity | 一時的な working state と durable memory を分離して扱う | | integration-aware evolution | wrapper 主導の現在地を保ちつつ、hook / skill / MCP 統合へ正式に進む | -| reviewer surface | `cam memory` / `cam session` / `cam audit` による監査入口を提供する | +| reviewer surface | `cam memory` / `cam session` / `cam recall` / `cam audit` による監査入口を提供する | ## 機能比較 @@ -210,7 +210,7 @@ cam audit | `cam session save` | continuity の merge / incremental save | | `cam session refresh` | continuity の replace / clean regeneration | | `cam session load` / `status` | continuity reviewer surface を確認 | -| `cam hooks` | 現在の local bridge / fallback recall bundle を管理し、`memory-recall.sh`、`post-work-memory-review.sh`、互換 wrapper、`recall-bridge.md` を通じて今後の hook / skill / MCP-aware retrieval に備える。`post-work-memory-review.sh` は `cam sync` と `cam memory --recent` をまとめた収束 review helper である。これは公式な Codex hook surface ではなく、推奨検索 preset は `state=auto`、`limit=8` | +| `cam hooks install` | 現在の local bridge / fallback helper bundle を生成・更新し、`memory-recall.sh`、`post-work-memory-review.sh`、互換 wrapper、`recall-bridge.md` を通じて今後の hook / skill / MCP-aware retrieval に備える。`post-work-memory-review.sh` は `cam sync` と `cam memory --recent` をまとめた収束 review helper である。これは公式な Codex hook surface ではなく、推奨検索 preset は `state=auto`、`limit=8` | | `cam skills` | `cam skills install` で Codex skill を導入する。既定 target は runtime のままだが、`--surface runtime|official-user|official-project` を使えば公式 `.agents/skills` 経路向けの明示的な互換コピーも置ける。どの surface でも、MCP-first / CLI-fallback の段階的 durable memory retrieval workflow と推奨検索 preset `state=auto`, `limit=8` を共有する | | `cam audit` | プライバシーと secret hygiene を監査 | | `cam doctor` | ローカル wiring と native-readiness を確認 | @@ -316,7 +316,7 @@ Session continuity: ### v0.2 - issue のコア要求を満たす: 自動抽出、自動再呼び出し、更新/重複排除/上書き/アーカイブのライフサイクル、手動保守負担の削減 -- `cam memory` と `cam session` の reviewer UX 改善 +- `cam memory` / `cam session` / `cam recall` の reviewer UX 改善 - contradiction handling と memory lifecycle の強化 - Markdown-first 契約を崩さずに hook / skill / MCP-friendly integration surfaces を定義・公開 diff --git a/README.zh-TW.md b/README.zh-TW.md index 614abf6..356e8f9 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -113,7 +113,7 @@ Codex 已經具備不少有價值的基礎能力,但仍未公開等價且完 | worktree-aware | project memory 在同一個 git 倉庫的 worktree 間共享,project-local 仍保持隔離 | | session continuity | 臨時 working state 與 durable memory 分層儲存、分層載入 | | integration-aware evolution | 保留目前 wrapper 主路徑,同時正式朝 hook / skill / MCP 方向演進 | -| reviewer surface | `cam memory` / `cam session` / `cam audit` 提供可核查的審查入口 | +| reviewer surface | `cam memory` / `cam session` / `cam recall` / `cam audit` 提供可核查的審查入口 | ## 能力對照 @@ -212,7 +212,7 @@ cam audit | `cam session save` | merge / incremental save;增量寫入 continuity | | `cam session refresh` | replace / clean regeneration;重建 continuity | | `cam session load` / `status` | continuity reviewer surface | -| `cam hooks` | 管理目前的 local bridge / fallback recall bundle,包括 `memory-recall.sh`、`post-work-memory-review.sh`、相容 helper wrappers 與 `recall-bridge.md`;其中 `post-work-memory-review.sh` 會把 `cam sync` 與 `cam memory --recent` 串成同一套收尾 review 動作;它不是官方 Codex hook surface,且該 bundle 的推薦檢索 preset 為 `state=auto`、`limit=8` | +| `cam hooks install` | 生成並刷新目前的 local bridge / fallback helper bundle,包括 `memory-recall.sh`、`post-work-memory-review.sh`、相容 helper wrappers 與 `recall-bridge.md`;其中 `post-work-memory-review.sh` 會把 `cam sync` 與 `cam memory --recent` 串成同一套收尾 review 動作;它不是官方 Codex hook surface,且該 bundle 的推薦檢索 preset 為 `state=auto`、`limit=8` | | `cam skills` | 以 `cam skills install` 安裝 Codex skill;預設 target 仍是 runtime,也支援顯式 `--surface runtime|official-user|official-project` 為官方 `.agents/skills` 路徑準備相容副本;所有 surface 都沿用同一套 MCP-first、CLI-fallback 漸進式 durable memory 檢索工作流與推薦 preset:`state=auto`、`limit=8` | | `cam audit` | 做隱私與 secret-hygiene 檢查 | | `cam doctor` | 檢視本地 wiring 與 native-readiness posture | @@ -318,7 +318,7 @@ Session continuity: ### v0.2 - 完成 issue 中的核心能力:更好的自動提取、自動召回、更新/去重/覆蓋/歸檔生命週期、降低手動維護成本 -- 更清晰的 `cam memory` / `cam session` reviewer UX +- 更清晰的 `cam memory` / `cam session` / `cam recall` reviewer UX - 更強的 contradiction handling 與記憶生命週期文檔化 - 定義並公開 hook / skill / MCP-friendly integration surfaces,同時不放棄 Markdown-first 契約 diff --git a/docs/claude-reference.en.md b/docs/claude-reference.en.md index d2426e2..37d17d4 100644 --- a/docs/claude-reference.en.md +++ b/docs/claude-reference.en.md @@ -102,7 +102,7 @@ For `codex-auto-memory`, that means: - shared project config must not be able to hijack a user-level memory path through `autoMemoryDirectory` - host convenience should not weaken the repository's local-first safety boundary -### 7. Host integration surfaces matter, but should not replace the core contract +### 7. Host-native breadth expands the host, not the memory model Claude Code also exposes stronger host-native surfaces around memory: diff --git a/docs/integration-strategy.md b/docs/integration-strategy.md index e0cc0ba..6785e0e 100644 --- a/docs/integration-strategy.md +++ b/docs/integration-strategy.md @@ -44,7 +44,8 @@ 当前状态: - 已有 hook bridge 资产 -- `cam hooks install` 现在会生成本仓自带的 local bridge / fallback helper bundle:`memory-recall.sh`、兼容 helper wrappers 与 `recall-bridge.md` +- `cam hooks install` 现在会生成本仓自带的 local bridge / fallback helper bundle:`memory-recall.sh`、`post-work-memory-review.sh`、兼容 helper wrappers 与 `recall-bridge.md` +- `post-work-memory-review.sh` 会把 `cam sync` 与 `cam memory --recent` 串成同一套 post-work durable-memory review helper - 这条线当前仍是本地桥接层,不宣称自己是官方 Codex hook surface - 还不是主入口 @@ -106,12 +107,15 @@ 三者都不应该直接拥有 canonical memory。 -真正的主真相仍然是: +真正的 durable memory canonical truth 仍然只有: - `MEMORY.md` - topic files -- continuity files -- audit / provenance logs + +而另外两类文件只承担辅助语义: + +- continuity files 属于临时 working state / reviewer surface,不是 canonical durable memory +- audit / provenance logs 属于 reviewer / audit side evidence,不是 canonical memory store ## 当前仓库不做什么 diff --git a/src/lib/cli/register-commands.ts b/src/lib/cli/register-commands.ts index e77eb60..f8f0ee1 100644 --- a/src/lib/cli/register-commands.ts +++ b/src/lib/cli/register-commands.ts @@ -117,12 +117,14 @@ function registerSessionCommands(program: Command): void { function registerHookCommands(program: Command): void { const hooksCommand = program .command("hooks") - .description("Manage the local bridge and fallback helper bundle for current and upcoming integrations"); + .description("Manage the local bridge / fallback helper bundle for current and upcoming integrations"); addJsonOption( hooksCommand .command("install") - .description("Generate the local recall bridge bundle plus startup and post-session helper scripts") + .description( + "Generate the local bridge / fallback helper bundle, including recall, startup, post-session, and post-work review helpers" + ) .option("--cwd ", "Project directory to anchor generated hook helpers to") ).action(withStdout(async (options) => installHooks(options))); diff --git a/src/lib/commands/forget.ts b/src/lib/commands/forget.ts index a8ad3ac..f7d9eb6 100644 --- a/src/lib/commands/forget.ts +++ b/src/lib/commands/forget.ts @@ -11,6 +11,10 @@ export async function runForget( query: string, options: ForgetOptions = {} ): Promise { + if (query.trim().length === 0) { + throw new Error("Forget query must be non-empty."); + } + const runtime = await buildRuntimeContext(options.cwd); const deleted = await runtime.syncService.memoryStore.forget(options.scope ?? "all", query, { archive: options.archive diff --git a/src/lib/commands/integrations.ts b/src/lib/commands/integrations.ts index 4fc22ab..cbdbd1b 100644 --- a/src/lib/commands/integrations.ts +++ b/src/lib/commands/integrations.ts @@ -531,8 +531,77 @@ export async function runIntegrationsApply( ...result.notes.map((note) => `- ${note}`) ].join("\n"); } - const mcpResult = await installMcpProjectConfig("codex", projectRoot); const agentsResult = await applyCodexAgentsGuidance(projectRoot); + if (agentsResult.action === "blocked") { + const skipReason = + "Skipped because integrations apply was blocked while applying the AGENTS guidance block."; + const result: IntegrationStackApplyResult = { + host: "codex", + projectRoot, + stackAction: "blocked", + skillsSurface: skillSurface, + readOnlyRetrieval: true, + subactions: { + mcp: { + status: "ok", + action: "unchanged", + attempted: false, + skipped: true, + skipReason, + readOnlyRetrieval: true, + notes: [skipReason] + }, + agents: { + status: "blocked", + action: "blocked", + attempted: true, + targetPath: agentsResult.targetPath, + readOnlyRetrieval: true, + notes: [...agentsResult.notes] + }, + hooks: { + status: "ok", + action: "unchanged", + attempted: false, + skipped: true, + skipReason, + readOnlyRetrieval: true, + notes: [skipReason] + }, + skills: { + status: "ok", + action: "unchanged", + attempted: false, + skipped: true, + skipReason, + surface: skillSurface, + readOnlyRetrieval: true, + notes: [skipReason] + } + }, + notes: [ + "This orchestration surface is Codex-only and explicit.", + "Integrations apply was blocked while applying the repository-level AGENTS.md guidance block, so no project-scoped MCP wiring, hook assets, or skill assets were written.", + ...(agentsResult.blockedReason ? [`Reason: ${agentsResult.blockedReason}`] : []) + ] + }; + + if (options.json) { + return JSON.stringify(result, null, 2); + } + + return [ + formatIntegrationApplyHeadline(result.stackAction), + `Host: ${result.host}`, + `Project root: ${result.projectRoot}`, + `Stack action: ${result.stackAction}`, + "", + "Notes:", + ...result.notes.map((note) => `- ${note}`) + ].join("\n"); + } + + const mcpResult = await installMcpProjectConfig("codex", projectRoot); const hooksResult = await installIntegrationAssets("hooks", { projectRoot }); @@ -551,15 +620,15 @@ export async function runIntegrationsApply( hooksResult.action, skillsResult.action ]), - readOnlyRetrieval: true, - subactions: { - mcp: { - ...toMcpSubaction(mcpResult), - attempted: true - }, - agents: { - status: agentsResult.action === "blocked" ? "blocked" : "ok", - action: agentsResult.action, + readOnlyRetrieval: true, + subactions: { + mcp: { + ...toMcpSubaction(mcpResult), + attempted: true + }, + agents: { + status: "ok", + action: agentsResult.action, attempted: true, targetPath: agentsResult.targetPath, readOnlyRetrieval: true, diff --git a/src/lib/domain/memory-store.ts b/src/lib/domain/memory-store.ts index b2b4d72..f44aac9 100644 --- a/src/lib/domain/memory-store.ts +++ b/src/lib/domain/memory-store.ts @@ -256,13 +256,20 @@ function parseEntryBlock(block: string): MemoryEntry | null { return null; } - const details = detailsRaw - .split("\n") - .filter((line) => line.trim().length > 0); - if (details.some((line) => !line.startsWith("- "))) { + const detailLines = detailsRaw.split("\n"); + const hasUnsupportedDetailText = detailLines.some((line) => { + const trimmed = line.trim(); + return trimmed.length > 0 && !line.startsWith("- "); + }); + if (hasUnsupportedDetailText) { return null; } + const details = detailLines + .filter((line) => line.startsWith("- ")) + .map((line) => line.slice(2).trim()) + .filter(Boolean); + return { id: metadata.id ?? headingRaw.trim(), scope: metadata.scope, @@ -1480,6 +1487,10 @@ export class MemoryStore { .filter((entry) => entry.id === parsed.id && entry.topic === parsed.topic) .sort((left, right) => right.at.localeCompare(left.at)); const latestEvent = events[0] ?? null; + const latestEventHasProvenance = Boolean(latestEvent?.rolloutPath || latestEvent?.sessionId); + const olderEventHasProvenance = events + .slice(1) + .some((event) => Boolean(event.rolloutPath || event.sessionId)); const latestAudit = await this.findLatestSyncAuditSummary( parsed.scope, parsed.topic, @@ -1488,11 +1499,16 @@ export class MemoryStore { latestEvent?.sessionId ); const warnings = [...history.warnings]; - if (latestEvent && !latestAudit && (latestEvent.rolloutPath || latestEvent.sessionId)) { + if (latestEvent && !latestAudit && latestEventHasProvenance) { warnings.push( `Lifecycle history exists for ${ref}, but no matching sync audit entry was found in ${this.getSyncAuditPath()}.` ); } + if (latestEvent && !latestEventHasProvenance && olderEventHasProvenance) { + warnings.push( + `Latest lifecycle event for ${ref} has no rollout/session provenance, so latestAudit was not backfilled from an older sync audit entry.` + ); + } return { ref, @@ -2069,6 +2085,10 @@ export class MemoryStore { archive?: boolean; } = {} ): Promise { + if (query.trim().length === 0) { + throw new Error("Forget query must be non-empty."); + } + const scopes: MemoryScope[] = scope === "all" ? ["global", "project", "project-local"] : [scope]; const deleted: MemoryEntry[] = []; @@ -2198,23 +2218,21 @@ export class MemoryStore { latestRolloutPath?: string, latestSessionId?: string ): Promise { + if (latestRolloutPath === undefined && latestSessionId === undefined) { + return null; + } + const entries = await this.readSyncAuditEntries(); const matched = entries.find( - (entry) => - latestRolloutPath !== undefined && - entry.rolloutPath === latestRolloutPath && - (latestSessionId === undefined || entry.sessionId === latestSessionId) && - entry.operations.some( - (operation) => - operation.scope === scope && operation.topic === topic && operation.id === id - ) - ) ?? - entries.find((entry) => - entry.operations.some( - (operation) => - operation.scope === scope && operation.topic === topic && operation.id === id - ) + (entry) => + latestRolloutPath !== undefined && + entry.rolloutPath === latestRolloutPath && + (latestSessionId === undefined || entry.sessionId === latestSessionId) && + entry.operations.some( + (operation) => + operation.scope === scope && operation.topic === topic && operation.id === id + ) ); if (!matched) { diff --git a/test/dist-cli-smoke.test.ts b/test/dist-cli-smoke.test.ts index 94c7bdd..f3f2006 100644 --- a/test/dist-cli-smoke.test.ts +++ b/test/dist-cli-smoke.test.ts @@ -216,6 +216,147 @@ describe("dist cli smoke", () => { await expect(fs.access(memoryRoot)).rejects.toMatchObject({ code: "ENOENT" }); }); + it("serves additive recall search and details JSON contract from the compiled cli entrypoint", async () => { + const homeDir = await tempDir("cam-dist-recall-contract-home-"); + const projectDir = await tempDir("cam-dist-recall-contract-project-"); + const memoryRoot = await tempDir("cam-dist-recall-contract-memory-"); + const rolloutPath = "/tmp/rollout-dist-recall-contract.jsonl"; + const cliEnv = { HOME: homeDir }; + + const config = makeAppConfig(); + await writeCamConfig(projectDir, config, { + autoMemoryDirectory: memoryRoot + }); + + const project = detectProjectContext(projectDir); + const memoryStore = new MemoryStore(project, { + ...config, + autoMemoryDirectory: memoryRoot + }); + await memoryStore.ensureLayout(); + await memoryStore.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await memoryStore.appendSyncAuditEntry({ + appliedAt: "2026-03-27T08:00:00.000Z", + projectId: project.projectId, + worktreeId: project.worktreeId, + rolloutPath, + sessionId: "session-dist-recall-contract", + configuredExtractorMode: "heuristic", + configuredExtractorName: "heuristic", + actualExtractorMode: "heuristic", + actualExtractorName: "heuristic", + extractorMode: "heuristic", + extractorName: "heuristic", + sessionSource: "rollout-jsonl", + status: "applied", + appliedCount: 1, + scopesTouched: ["project"], + resultSummary: "1 operation(s) applied", + operations: [ + { + action: "upsert", + scope: "project", + topic: "workflow", + id: "prefer-pnpm", + summary: "Prefer pnpm in this repository.", + details: ["Use pnpm instead of npm in this repository."], + reason: "Manual note.", + sources: ["manual"] + } + ] + }); + + const searchResult = runCli( + projectDir, + ["recall", "search", "prefer pnpm", "--state", "active", "--json"], + { + entrypoint: "dist", + env: cliEnv + } + ); + expect(searchResult.exitCode, searchResult.stderr).toBe(0); + const searchPayload = JSON.parse(searchResult.stdout) as { + state: string; + resolvedState: string; + fallbackUsed: boolean; + retrievalMode: string; + diagnostics: { + checkedPaths: Array<{ + scope: string; + state: string; + retrievalMode: string; + matchedCount: number; + indexPath: string; + generatedAt: string | null; + }>; + }; + results: Array<{ ref: string; state: string; topic: string }>; + }; + expect(searchPayload).toMatchObject({ + state: "active", + resolvedState: "active", + fallbackUsed: false, + retrievalMode: "index", + results: [ + { + ref: "project:active:workflow:prefer-pnpm", + state: "active", + topic: "workflow" + } + ] + }); + expect(searchPayload.diagnostics.checkedPaths).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + state: "active", + retrievalMode: "index", + matchedCount: 1, + indexPath: memoryStore.getRetrievalIndexFile("project", "active"), + generatedAt: expect.any(String) + }) + ]) + ); + + const detailsResult = runCli( + projectDir, + ["recall", "details", "project:active:workflow:prefer-pnpm", "--json"], + { + entrypoint: "dist", + env: cliEnv + } + ); + expect(detailsResult.exitCode, detailsResult.stderr).toBe(0); + expect(JSON.parse(detailsResult.stdout)).toMatchObject({ + ref: "project:active:workflow:prefer-pnpm", + path: memoryStore.getTopicFile("project", "workflow"), + latestLifecycleAction: "add", + latestState: "active", + latestSessionId: null, + latestRolloutPath: null, + historyPath: memoryStore.getHistoryPath("project"), + timelineWarningCount: 0, + warnings: [], + lineageSummary: { + eventCount: 1, + latestAction: "add", + latestState: "active", + latestAuditStatus: null, + noopOperationCount: 0, + suppressedOperationCount: 0, + conflictCount: 0 + }, + latestAudit: null + }); + }); + it("serves retrieval MCP tools from the compiled cli entrypoint", async () => { const homeDir = await tempDir("cam-dist-mcp-home-"); const projectDir = await tempDir("cam-dist-mcp-project-"); @@ -637,6 +778,11 @@ describe("dist cli smoke", () => { projectRoot: realProjectDir, workflowContract: { version: expect.any(String), + cliFallback: { + searchCommand: 'cam recall search "" --state auto --limit 8', + timelineCommand: 'cam recall timeline ""', + detailsCommand: 'cam recall details ""' + }, postWorkSyncReview: { helperScript: "post-work-memory-review.sh", syncCommand: "cam sync", @@ -644,7 +790,22 @@ describe("dist cli smoke", () => { } }, fallbackAssets: { + runtimeSkillPresent: true, + anySkillSurfaceInstalled: true, + anySkillSurfaceReady: true, postWorkReviewInstalled: true + }, + retrievalSidecar: { + status: "warning", + repairCommand: "cam memory reindex --scope all --state all", + checks: expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + state: "active", + status: "missing", + fallbackReason: "missing" + }) + ]) } }); }); @@ -1195,8 +1356,25 @@ describe("dist cli smoke", () => { applyReadiness: { status: "safe" }, + retrievalSidecar: { + status: "warning", + repairCommand: "cam memory reindex --scope all --state all", + checks: expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + state: "active", + status: "missing", + fallbackReason: "missing" + }) + ]) + }, workflowContract: { version: expect.any(String), + cliFallback: { + searchCommand: 'cam recall search "" --state auto --limit 8', + timelineCommand: 'cam recall timeline ""', + detailsCommand: 'cam recall details ""' + }, postWorkSyncReview: { helperScript: "post-work-memory-review.sh", syncCommand: "cam sync", @@ -1307,6 +1485,16 @@ fs.writeFileSync(${JSON.stringify(capturedArgsPath)}, JSON.stringify(process.arg expect(recallHelp.stdout).toContain("Search compact memory candidates without loading full details"); expect(recallHelp.stdout).toContain("Limit memory state: active, archived, all, or auto"); + const hooksHelp = runCli(projectDir, ["hooks", "install", "--help"], { + entrypoint: "dist", + env + }); + expect(hooksHelp.exitCode, hooksHelp.stderr).toBe(0); + expect(hooksHelp.stdout).toContain( + "Generate the local bridge / fallback helper bundle" + ); + expect(hooksHelp.stdout).toContain("Project directory to anchor generated hook helpers to"); + const mcpHelp = runCli(projectDir, ["mcp", "print-config", "--help"], { entrypoint: "dist", env diff --git a/test/docs-contract.test.ts b/test/docs-contract.test.ts index dd709b1..993f970 100644 --- a/test/docs-contract.test.ts +++ b/test/docs-contract.test.ts @@ -78,7 +78,9 @@ describe("docs contract", () => { expect(readmeTw).toContain("非 canonical 自訂欄位"); expect(readmeTw).toContain("applyReadiness"); expect(readmeTw).toContain("--state auto"); + expect(readmeTw).toContain("`cam memory` / `cam session` / `cam recall` / `cam audit`"); expect(readmeTw).toContain("local bridge"); + expect(readmeTw).toContain("| `cam hooks install` |"); expect(readmeTw).toContain("manual-only"); expect(readmeTw).toContain("--surface runtime|official-user|official-project"); expect(readmeTw).toContain("重要 `--help` 文案"); @@ -98,7 +100,9 @@ describe("docs contract", () => { expect(readmeJa).toContain("non-canonical なカスタム項目"); expect(readmeJa).toContain("applyReadiness"); expect(readmeJa).toContain("--state auto"); + expect(readmeJa).toContain("`cam memory` / `cam session` / `cam recall` / `cam audit`"); expect(readmeJa).toContain("local bridge"); + expect(readmeJa).toContain("| `cam hooks install` |"); expect(readmeJa).toContain("manual-only"); expect(readmeJa).toContain("--surface runtime|official-user|official-project"); expect(readmeJa).toContain("主要な `--help` 文言"); @@ -131,6 +135,8 @@ describe("docs contract", () => { expect(readmeEn).toContain("alternate global wiring"); expect(readmeEn).toContain("non-canonical custom fields"); expect(readmeEn).toContain("manual-only"); + expect(readmeEn).toContain("| `cam hooks install` |"); + expect(readmeEn).toContain("`cam memory`, `cam session`, and `cam recall` reviewer UX"); expect(readmeEn).toContain("--surface runtime|official-user|official-project"); expect(docsReadme).toContain("Codex-first Hybrid"); expect(docsReadme).toContain("cam mcp apply-guidance --host codex"); @@ -298,6 +304,8 @@ describe("docs contract", () => { const hostSurfaces = await readDoc("docs/host-surfaces.md"); const readme = await readDoc("README.md"); const readmeEn = await readDoc("README.en.md"); + const claudeReferenceEn = await readDoc("docs/claude-reference.en.md"); + const registerCommands = await readDoc("src/lib/cli/register-commands.ts"); expect(continuityDoc).toContain("save` keeps merge semantics"); expect(continuityDoc).toContain("refresh` ignores existing continuity"); @@ -345,12 +353,16 @@ describe("docs contract", () => { expect(integrationStrategy).toContain("cam mcp install"); expect(integrationStrategy).toContain("cam mcp apply-guidance"); expect(integrationStrategy).toContain("memory-recall.sh"); + expect(integrationStrategy).toContain("post-work-memory-review.sh"); expect(integrationStrategy).toContain("local bridge / fallback helper bundle"); expect(integrationStrategy).toContain("cam mcp serve"); expect(integrationStrategy).toContain("cam mcp print-config"); expect(integrationStrategy).toContain("AGENTS.md"); expect(integrationStrategy).toContain("cam mcp doctor"); expect(integrationStrategy).toContain("manual-only"); + expect(integrationStrategy).toContain("continuity files 属于临时 working state / reviewer surface"); + expect(integrationStrategy).toContain("不是 canonical durable memory"); + expect(integrationStrategy).toContain("audit / provenance logs 属于 reviewer / audit side evidence"); expect(integrationStrategy).toContain("cam integrations install --host codex"); expect(integrationStrategy).toContain("cam integrations doctor --host codex"); expect(integrationStrategy).toContain("release-facing `--help` 文案"); @@ -380,5 +392,22 @@ describe("docs contract", () => { expect(readmeEn).toContain("workflowContract"); expect(readmeEn).toContain("preflight `blocked`"); expect(readmeEn).toContain("applyReadiness"); + expect( + claudeReferenceEn.match( + /### 6\. Host integration surfaces matter, but should not replace the core contract/g + )?.length ?? 0 + ).toBe(1); + expect( + claudeReferenceEn.match( + /### 7\. Host integration surfaces matter, but should not replace the core contract/g + )?.length ?? 0 + ).toBe(0); + expect(claudeReferenceEn).toContain("### 7. Host-native breadth expands the host, not the memory model"); + expect(registerCommands).toContain( + "Manage the local bridge / fallback helper bundle for current and upcoming integrations" + ); + expect(registerCommands).toContain( + "Generate the local bridge / fallback helper bundle, including recall, startup, post-session, and post-work review helpers" + ); }); }); diff --git a/test/integrations-command.test.ts b/test/integrations-command.test.ts index 115e77b..658665e 100644 --- a/test/integrations-command.test.ts +++ b/test/integrations-command.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { restoreOptionalEnv } from "./helpers/env.js"; import { makeAppConfig, writeCamConfig } from "./helpers/cam-test-fixtures.js"; import { runCli } from "./helpers/cli-runner.js"; @@ -71,6 +71,8 @@ function shellQuoteArg(value: string): string { afterEach(async () => { restoreOptionalEnv("HOME", originalHome); restoreOptionalEnv("CODEX_HOME", originalCodexHome); + vi.restoreAllMocks(); + vi.resetModules(); await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); @@ -669,6 +671,167 @@ describe("integrations command", () => { expect(await pathExists(skillDir)).toBe(false); }); + it("returns blocked without touching MCP, hooks, or skills when AGENTS apply blocks after a safe preflight", async () => { + const projectDir = await tempDir("cam-integrations-apply-late-block-project-"); + const realProjectDir = await fs.realpath(projectDir); + + vi.resetModules(); + const agentsGuidanceModule = await import("../src/lib/integration/agents-guidance.js"); + const mcpInstallModule = await import("../src/lib/integration/mcp-install.js"); + const installAssetsModule = await import("../src/lib/integration/install-assets.js"); + const mcpConfigModule = await import("../src/lib/integration/mcp-config.js"); + + vi.spyOn(mcpConfigModule, "resolveMcpProjectRoot").mockReturnValue(realProjectDir); + vi.spyOn(agentsGuidanceModule, "inspectCodexAgentsGuidanceApplySafety").mockResolvedValue({ + host: "codex", + projectRoot: realProjectDir, + targetPath: path.join(realProjectDir, "AGENTS.md"), + status: "safe", + recommendedAction: "append", + notes: ["preflight safe"] + }); + vi.spyOn(agentsGuidanceModule, "applyCodexAgentsGuidance").mockResolvedValue({ + host: "codex", + projectRoot: realProjectDir, + targetPath: path.join(realProjectDir, "AGENTS.md"), + action: "blocked", + managedBlockVersion: "codex-agents-guidance-v1", + createdFile: false, + blockedReason: "managed guidance block changed after preflight", + notes: ["late block"] + }); + + const installMcpProjectConfigSpy = vi.spyOn( + mcpInstallModule, + "installMcpProjectConfig" + ).mockResolvedValue({ + host: "codex", + serverName: "codex_auto_memory", + projectRoot: realProjectDir, + targetPath: path.join(realProjectDir, ".codex", "config.toml"), + action: "created", + projectPinned: true, + readOnlyRetrieval: true, + preservedCustomFields: [], + notes: ["mcp wrote"] + }); + const installIntegrationAssetsSpy = vi.spyOn( + installAssetsModule, + "installIntegrationAssets" + ).mockResolvedValue({ + installSurface: "hooks", + action: "created", + targetDir: path.join(realProjectDir, ".tmp"), + readOnlyRetrieval: true, + assetVersion: "retrieval-contract-v1", + recommendedPreset: "state=auto, limit=8", + workflowContract: { + version: "retrieval-contract-v1", + preferredRoute: "mcp-first", + recommendedPreset: "state=auto, limit=8", + recallFirst: "Before repeating prior work or repo-specific decisions, recall durable memory first.", + progressiveDisclosure: "Use progressive disclosure: search -> timeline -> details.", + routePreference: { + preferredRoute: "mcp-first", + mcpFirst: "Prefer retrieval MCP when it is already wired in: search_memories -> timeline_memories -> get_memory_details.", + cliFallback: "Otherwise fall back to the local recall bridge bundle through memory-recall.sh search|timeline|details.", + doctor: "Run cam mcp doctor if you are unsure whether the recommended project-scoped retrieval MCP wiring is already in place.", + serve: "cam mcp serve exposes the same retrieval contract over stdio MCP when a host can consume it." + }, + recallWorkflow: { + recallFirst: "Before repeating prior work or repo-specific decisions, recall durable memory first.", + progressiveDisclosure: "Use progressive disclosure: search -> timeline -> details." + }, + mcpTools: { + search: "search_memories", + timeline: "timeline_memories", + details: "get_memory_details" + }, + cliFallback: { + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}`, + timelineCommand: `cam recall timeline "" --cwd ${JSON.stringify(realProjectDir)}`, + detailsCommand: `cam recall details "" --cwd ${JSON.stringify(realProjectDir)}` + }, + postWorkSyncReview: { + helperScript: "post-work-memory-review.sh", + syncCommand: `cam sync --cwd ${JSON.stringify(realProjectDir)}`, + reviewCommand: `cam memory --recent --cwd ${JSON.stringify(realProjectDir)}`, + guidance: "After finishing work that should affect durable memory, run cam sync or review cam memory --recent instead of assuming temporary continuity already updated Markdown memory." + }, + boundaries: { + memoryAudit: "Use cam memory for inspect/audit surfaces and startup payload review.", + sessionContinuity: "Use cam session only for temporary continuity, not durable memory retrieval.", + archive: "Treat archived memory as historical context that does not participate in default startup recall." + } + }, + notes: ["asset wrote"], + assets: [] + }); + + const { runIntegrationsApply } = await import("../src/lib/commands/integrations.js"); + const payload = JSON.parse( + await runIntegrationsApply({ + cwd: realProjectDir, + host: "codex", + json: true + }) + ) as { + stackAction: string; + subactions: { + mcp: { + attempted: boolean; + skipped: boolean; + }; + agents: { + status: string; + action: string; + attempted: boolean; + }; + hooks: { + attempted: boolean; + skipped: boolean; + }; + skills: { + attempted: boolean; + skipped: boolean; + surface: string; + }; + }; + notes: string[]; + }; + + expect(payload).toMatchObject({ + stackAction: "blocked", + subactions: { + mcp: { + attempted: false, + skipped: true + }, + agents: { + status: "blocked", + action: "blocked", + attempted: true + }, + hooks: { + attempted: false, + skipped: true + }, + skills: { + attempted: false, + skipped: true, + surface: "runtime" + } + } + }); + expect(payload.notes).toEqual( + expect.arrayContaining([ + expect.stringContaining("no project-scoped MCP wiring, hook assets, or skill assets were written") + ]) + ); + expect(installMcpProjectConfigSpy).not.toHaveBeenCalled(); + expect(installIntegrationAssetsSpy).not.toHaveBeenCalled(); + }); + it("withholds integrations apply from doctor next steps when AGENTS guidance is unsafe", async () => { const homeDir = await tempDir("cam-integrations-doctor-blocked-home-"); const projectDir = await tempDir("cam-integrations-doctor-blocked-project-"); diff --git a/test/memory-command.test.ts b/test/memory-command.test.ts index e9fb970..2ef9bfd 100644 --- a/test/memory-command.test.ts +++ b/test/memory-command.test.ts @@ -1209,6 +1209,46 @@ describe("runMemory", () => { expect(await snapshotFiles(Object.keys(projectLocalSnapshot))).toEqual(projectLocalSnapshot); }); + it("fails closed at the CLI surface when forget query is empty or whitespace-only", async () => { + const homeDir = await tempDir("cam-forget-empty-cli-home-"); + const projectDir = await tempDir("cam-forget-empty-cli-project-"); + const memoryRoot = await tempDir("cam-forget-empty-cli-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const project = detectProjectContext(projectDir); + const store = new MemoryStore(project, { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const emptyResult = runCli(projectDir, ["forget", ""], { + env: { HOME: homeDir } + }); + expect(emptyResult.exitCode).toBe(1); + expect(emptyResult.stderr).toContain("non-empty"); + + const blankResult = runCli(projectDir, ["forget", " "], { + env: { HOME: homeDir } + }); + expect(blankResult.exitCode).toBe(1); + expect(blankResult.stderr).toContain("non-empty"); + expect(await store.listEntries("project")).toHaveLength(1); + }); + it("rebuilds retrieval sidecars explicitly from canonical Markdown memory", async () => { const homeDir = await tempDir("cam-memory-reindex-home-"); const projectDir = await tempDir("cam-memory-reindex-project-"); diff --git a/test/memory-store.test.ts b/test/memory-store.test.ts index a7ab18a..fe61056 100644 --- a/test/memory-store.test.ts +++ b/test/memory-store.test.ts @@ -858,6 +858,37 @@ describe("MemoryStore", () => { expect(history[0]?.action).toBe("add"); }); + it("rejects empty or whitespace-only forget queries at the store layer", async () => { + const projectDir = await tempDir("cam-store-empty-forget-project-"); + const memoryRoot = await tempDir("cam-store-empty-forget-memory-"); + const config: AppConfig = { + autoMemoryEnabled: true, + autoMemoryDirectory: memoryRoot, + extractorMode: "heuristic", + defaultScope: "project", + maxStartupLines: 200, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false, + sessionContinuityLocalPathStyle: "codex", + maxSessionContinuityLines: 60, + codexBinary: "codex" + }; + const store = new MemoryStore(detectProjectContext(projectDir), config); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + await expect(store.forget("project", "")).rejects.toThrow(/non-empty/i); + await expect(store.forget("project", " ")).rejects.toThrow(/non-empty/i); + expect(await store.listEntries("project")).toHaveLength(1); + }); + it("fails fast when an upsert mutation is missing its summary", async () => { const projectDir = await tempDir("cam-store-missing-summary-project-"); const memoryRoot = await tempDir("cam-store-missing-summary-memory-"); @@ -899,6 +930,60 @@ describe("MemoryStore", () => { expect(await snapshotFiles(Object.keys(snapshot))).toEqual(snapshot); }); + it("fails closed when details contain non-bullet manual text inside an entry block", async () => { + const projectDir = await tempDir("cam-store-unsafe-details-project-"); + const memoryRoot = await tempDir("cam-store-unsafe-details-memory-"); + const config: AppConfig = { + autoMemoryEnabled: true, + autoMemoryDirectory: memoryRoot, + extractorMode: "heuristic", + defaultScope: "project", + maxStartupLines: 200, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false, + sessionContinuityLocalPathStyle: "codex", + maxSessionContinuityLines: 60, + codexBinary: "codex" + }; + const store = new MemoryStore(detectProjectContext(projectDir), config); + await store.ensureLayout(); + + const topicFile = store.getTopicFile("project", "workflow"); + const originalContents = [ + "# Workflow", + "", + "", + "", + "This file is maintained by Codex Auto Memory. You may edit summaries or details directly.", + "", + "## keep-entry", + "", + "Summary: Keep this valid entry.", + "Details:", + "- Preserve this bullet.", + "Manual prose that must force fail-closed rewriting.", + "" + ].join("\n"); + await fs.writeFile(topicFile, originalContents, "utf8"); + + await expect( + store.applyMutations([ + { + action: "upsert", + scope: "project", + topic: "workflow", + id: "new-entry", + summary: "Do not rewrite unsafe details.", + details: ["Unsafe details block should stay untouched."], + sources: ["manual"], + reason: "Manual note." + } + ]) + ).rejects.toThrow(/Cannot rewrite topic file/); + + expect(await fs.readFile(topicFile, "utf8")).toBe(originalContents); + }); + it("fails closed when a topic file contains unsupported manual or malformed content during upsert", async () => { const projectDir = await tempDir("cam-store-unsafe-upsert-project-"); const memoryRoot = await tempDir("cam-store-unsafe-upsert-memory-"); diff --git a/test/recall-command.test.ts b/test/recall-command.test.ts index fcb7672..2f53632 100644 --- a/test/recall-command.test.ts +++ b/test/recall-command.test.ts @@ -531,6 +531,61 @@ describe("runRecall", () => { }); }); + it("does not backfill latestAudit from an older sync after a later manual archive", async () => { + const homeDir = await tempDir("cam-recall-manual-archive-audit-home-"); + const projectDir = await tempDir("cam-recall-manual-archive-audit-project-"); + const memoryRoot = await tempDir("cam-recall-manual-archive-audit-memory-"); + const rolloutPath = path.join(projectDir, "rollout.jsonl"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + await fs.writeFile( + rolloutPath, + makeRolloutFixture(projectDir, "Remember that this repository prefers pnpm.", { + sessionId: "session-provenance" + }), + "utf8" + ); + + const service = new SyncService(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await service.syncRollout(rolloutPath, true); + await service.memoryStore.forget("project", "prefers pnpm", { archive: true }); + + const searchResult = runCli(projectDir, ["recall", "search", "prefers pnpm", "--state", "archived", "--json"]); + expect(searchResult.exitCode).toBe(0); + const searchOutput = JSON.parse(searchResult.stdout) as { + results: Array<{ ref: string }>; + }; + expect(searchOutput.results).toHaveLength(1); + + const ref = searchOutput.results[0]!.ref; + const detailsResult = runCli(projectDir, ["recall", "details", ref, "--json"]); + expect(detailsResult.exitCode).toBe(0); + expect(JSON.parse(detailsResult.stdout)).toMatchObject({ + ref, + latestLifecycleAction: "archive", + latestState: "archived", + latestSessionId: null, + latestRolloutPath: null, + latestAudit: null, + warnings: expect.arrayContaining([ + expect.stringContaining("latestAudit was not backfilled from an older sync audit entry") + ]), + lineageSummary: expect.objectContaining({ + latestAction: "archive", + latestState: "archived", + latestAuditStatus: null + }) + }); + }); + it("keeps recall search read-only and does not create memory layout on first lookup", async () => { const homeDir = await tempDir("cam-recall-readonly-home-"); const projectDir = await tempDir("cam-recall-readonly-project-"); diff --git a/test/tarball-install-smoke.test.ts b/test/tarball-install-smoke.test.ts index 158b109..ebeadb2 100644 --- a/test/tarball-install-smoke.test.ts +++ b/test/tarball-install-smoke.test.ts @@ -3,7 +3,10 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import * as toml from "smol-toml"; +import { detectProjectContext } from "../src/lib/domain/project-context.js"; +import { MemoryStore } from "../src/lib/domain/memory-store.js"; import { runCommandCapture } from "../src/lib/util/process.js"; +import { makeAppConfig, writeCamConfig } from "./helpers/cam-test-fixtures.js"; const tempDirs: string[] = []; @@ -59,7 +62,7 @@ describe("tarball install smoke", () => { process.cwd(), env ); - expect(packResult.exitCode).toBe(0); + expect(packResult.exitCode, packResult.stderr).toBe(0); const tarballName = packResult.stdout.trim().split(/\r?\n/).at(-1); expect(tarballName).toBeTruthy(); @@ -102,6 +105,119 @@ describe("tarball install smoke", () => { expect(payload.latestContinuityAuditEntry).toBeNull(); expect(payload.pendingContinuityRecovery).toBeNull(); + const memoryRoot = await tempDir("cam-tarball-memory-root-"); + const appConfig = makeAppConfig(); + await writeCamConfig(installDir, appConfig, { + autoMemoryDirectory: memoryRoot + }); + + const project = detectProjectContext(installDir); + const memoryStore = new MemoryStore(project, { + ...appConfig, + autoMemoryDirectory: memoryRoot + }); + await memoryStore.ensureLayout(); + await memoryStore.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await memoryStore.appendSyncAuditEntry({ + appliedAt: "2026-03-27T08:00:00.000Z", + projectId: project.projectId, + worktreeId: project.worktreeId, + rolloutPath: "/tmp/rollout-tarball-recall-contract.jsonl", + sessionId: "session-tarball-recall-contract", + configuredExtractorMode: "heuristic", + configuredExtractorName: "heuristic", + actualExtractorMode: "heuristic", + actualExtractorName: "heuristic", + extractorMode: "heuristic", + extractorName: "heuristic", + sessionSource: "rollout-jsonl", + status: "applied", + appliedCount: 1, + scopesTouched: ["project"], + resultSummary: "1 operation(s) applied", + operations: [ + { + action: "upsert", + scope: "project", + topic: "workflow", + id: "prefer-pnpm", + summary: "Prefer pnpm in this repository.", + details: ["Use pnpm instead of npm in this repository."], + reason: "Manual note.", + sources: ["manual"] + } + ] + }); + + const recallSearchResult = runCommandCapture( + camBinaryPath(installDir), + ["recall", "search", "prefer pnpm", "--state", "active", "--json"], + installDir, + envWithBin + ); + expect(recallSearchResult.exitCode, recallSearchResult.stderr).toBe(0); + expect(JSON.parse(recallSearchResult.stdout)).toMatchObject({ + state: "active", + resolvedState: "active", + fallbackUsed: false, + retrievalMode: "index", + diagnostics: { + checkedPaths: expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + state: "active", + retrievalMode: "index", + matchedCount: 1, + indexPath: memoryStore.getRetrievalIndexFile("project", "active"), + generatedAt: expect.any(String) + }) + ]) + }, + results: [ + expect.objectContaining({ + ref: "project:active:workflow:prefer-pnpm", + state: "active", + topic: "workflow" + }) + ] + }); + + const recallDetailsResult = runCommandCapture( + camBinaryPath(installDir), + ["recall", "details", "project:active:workflow:prefer-pnpm", "--json"], + installDir, + envWithBin + ); + expect(recallDetailsResult.exitCode, recallDetailsResult.stderr).toBe(0); + expect(JSON.parse(recallDetailsResult.stdout)).toMatchObject({ + ref: "project:active:workflow:prefer-pnpm", + path: memoryStore.getTopicFile("project", "workflow"), + latestLifecycleAction: "add", + latestState: "active", + latestSessionId: null, + latestRolloutPath: null, + historyPath: memoryStore.getHistoryPath("project"), + timelineWarningCount: 0, + warnings: [], + lineageSummary: { + eventCount: 1, + latestAction: "add", + latestState: "active", + latestAuditStatus: null, + noopOperationCount: 0, + suppressedOperationCount: 0, + conflictCount: 0 + }, + latestAudit: null + }); + const mcpInstallResult = runCommandCapture( camBinaryPath(installDir), ["mcp", "install", "--host", "codex", "--json"], @@ -543,8 +659,27 @@ describe("tarball install smoke", () => { applyReadiness: { status: "safe" }, + retrievalSidecar: { + status: "ok", + repairCommand: "cam memory reindex --scope all --state all", + checks: expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + state: "active", + status: "ok", + indexPath: memoryStore.getRetrievalIndexFile("project", "active"), + generatedAt: expect.any(String), + topicFileCount: 1 + }) + ]) + }, workflowContract: { version: expect.any(String), + cliFallback: { + searchCommand: 'cam recall search "" --state auto --limit 8', + timelineCommand: 'cam recall timeline ""', + detailsCommand: 'cam recall details ""' + }, postWorkSyncReview: { helperScript: "post-work-memory-review.sh", syncCommand: "cam sync", @@ -582,8 +717,27 @@ describe("tarball install smoke", () => { officialUserSkillMatchesCanonical: true, officialProjectSkillMatchesCanonical: true }, + retrievalSidecar: { + status: "ok", + repairCommand: "cam memory reindex --scope all --state all", + checks: expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + state: "active", + status: "ok", + indexPath: memoryStore.getRetrievalIndexFile("project", "active"), + generatedAt: expect.any(String), + topicFileCount: 1 + }) + ]) + }, workflowContract: { version: expect.any(String), + cliFallback: { + searchCommand: 'cam recall search "" --state auto --limit 8', + timelineCommand: 'cam recall timeline ""', + detailsCommand: 'cam recall details ""' + }, postWorkSyncReview: { helperScript: "post-work-memory-review.sh", syncCommand: "cam sync", @@ -690,6 +844,18 @@ describe("tarball install smoke", () => { expect(recallHelpResult.stdout).toContain("Search compact memory candidates without loading full details"); expect(recallHelpResult.stdout).toContain("Limit memory state: active, archived, all, or auto"); + const hooksHelpResult = runCommandCapture( + camBinaryPath(installDir), + ["hooks", "install", "--help"], + installDir, + envWithBin + ); + expect(hooksHelpResult.exitCode).toBe(0); + expect(hooksHelpResult.stdout).toContain( + "Generate the local bridge / fallback helper bundle" + ); + expect(hooksHelpResult.stdout).toContain("Project directory to anchor generated hook helpers to"); + const mcpHelpResult = runCommandCapture( camBinaryPath(installDir), ["mcp", "print-config", "--help"], @@ -807,7 +973,7 @@ describe("tarball install smoke", () => { process.cwd(), env ); - expect(packResult.exitCode).toBe(0); + expect(packResult.exitCode, packResult.stderr).toBe(0); const tarballName = packResult.stdout.trim().split(/\r?\n/).at(-1); expect(tarballName).toBeTruthy(); From 7d8cfcd4347f15eadc953c39cfaa9a6ba9bf2449 Mon Sep 17 00:00:00 2001 From: blocks Date: Fri, 27 Mar 2026 22:28:30 +0800 Subject: [PATCH 13/62] feat: clarify retrieval fallback contract and integration json --- README.ja.md | 6 ++--- README.zh-TW.md | 4 +-- docs/architecture.en.md | 5 ++++ docs/claude-reference.md | 1 + src/lib/commands/integrations.ts | 19 ++++++++++++- src/lib/commands/recall.ts | 5 ++++ src/lib/domain/memory-retrieval-contract.ts | 26 +++++++++++++++++- src/lib/domain/memory-retrieval.ts | 13 +++++---- src/lib/domain/memory-store.ts | 5 ++-- src/lib/mcp/retrieval-server.ts | 4 +++ src/lib/types.ts | 4 +++ test/dist-cli-smoke.test.ts | 30 +++++++++++++++++++++ test/integrations-command.test.ts | 12 +++++++++ test/mcp-command.test.ts | 19 ++++++++++++- test/recall-command.test.ts | 26 ++++++++++++++++++ test/tarball-install-smoke.test.ts | 18 +++++++++++++ 16 files changed, 179 insertions(+), 18 deletions(-) diff --git a/README.ja.md b/README.ja.md index c24b2e6..efb1c51 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,6 +1,6 @@

Codex Auto Memory

-

Markdown-first のローカル memory runtime。Codex を主軸に、companion CLI から hook / skill / MCP-aware なハイブリッド運用へ進化中

+

Codex 向けの Markdown-first ローカル memory runtime。companion CLI から Codex-first Hybrid memory system へ進化中

简体中文 | 繁體中文 | @@ -34,7 +34,7 @@ 1. **何をするか**: Codex セッションから将来も使える知識を抽出し、ローカル Markdown に保存し、次回以降の会話で再利用します。 2. **どう保存するか**: `MEMORY.md` と topic files を中心とした Markdown が主表面であり、隠れた DB やキャッシュを主真相にはしません。 -3. **どこへ向かうか**: 現在も Codex-first ですが、今後は companion CLI に閉じず、hook / skill / MCP-aware なハイブリッド運用を正式な方向として扱います。 +3. **どこへ向かうか**: 現在も Codex-first ですが、今後は companion CLI に閉じず、**Codex-first Hybrid memory system** を正式な方向として扱います。 --- @@ -195,7 +195,7 @@ cam audit | :-- | :-- | | `cam run` / `cam exec` / `cam resume` | startup memory を生成して wrapper 経由で Codex を起動 | | `cam sync` | 最新 rollout を durable memory に手動同期 | -| `cam memory` | startup files、topic refs、startup budget、edit paths、recent sync audit を確認 | +| `cam memory` | startup files、topic refs、startup budget、edit paths、recent sync audit、suppressed conflict candidates を確認 | | `cam memory reindex` | canonical Markdown から retrieval sidecar を明示的に再構築する。`--scope`、`--state`、`--cwd`、`--json` をサポートし、sidecar が missing / invalid / stale のときの低摩擦な repair path を提供する | | `cam remember` / `cam forget` | durable memory の明示的な追加・削除。`cam forget --archive` は一致した項目をアーカイブ層へ移動する | | `cam recall search` / `timeline` / `details` | `search -> timeline -> details` の progressive disclosure workflow で durable memory を段階的に取得する。`search` は `state=auto, limit=8` を既定値として使い、active を先に調べてヒットしなければ archived にフォールバックしつつ read-only を保つ | diff --git a/README.zh-TW.md b/README.zh-TW.md index 356e8f9..3e6667d 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -1,6 +1,6 @@

Codex Auto Memory

-

一個以 Markdown 為核心、面向 Codex 的本地記憶運行層,正從 companion CLI 演進為 hook / skill / MCP-aware 的混合工作流

+

一個面向 Codex 的 Markdown-first 本地記憶運行層,正從 companion CLI 演進為 Codex-first Hybrid memory system

简体中文 | 繁體中文 | @@ -34,7 +34,7 @@ 1. **它做什麼**:從 Codex 會話中提取未來仍然有用的知識,保存為本地 Markdown,並在後續會話中自動帶回。 2. **它怎麼存**:Durable memory 仍以 `MEMORY.md` + topic files 為核心,不以資料庫或隱藏快取作為主真相。 -3. **它往哪裡走**:專案仍以 Codex 為主宿主,但不再只把自己定義成窄化的 companion seam,而是明確朝 hook / skill / MCP-aware 的混合工作流演進。 +3. **它往哪裡走**:專案仍以 Codex 為主宿主,但不再只把自己定義成窄化的 companion seam,而是明確朝 **Codex-first Hybrid memory system** 演進。 --- diff --git a/docs/architecture.en.md b/docs/architecture.en.md index 792b86c..7072343 100644 --- a/docs/architecture.en.md +++ b/docs/architecture.en.md @@ -134,7 +134,12 @@ The repository now treats the following as first-class evolution targets rather - `cam mcp serve` now provides the first read-only retrieval MCP path for that contract - `cam mcp install --host ` now writes the recommended project-scoped host wiring for that retrieval plane without touching the Markdown store - `cam mcp print-config --host ...` now prints ready-to-paste host snippets so the same retrieval plane is easier to wire into existing MCP clients +- `cam mcp apply-guidance --host codex` now manages the repository-level `AGENTS.md` guidance block through the existing additive, marker-scoped, fail-closed flow - `cam mcp doctor` now inspects the recommended project-scoped retrieval wiring, project pinning, and hook / skill fallback assets without mutating host config files +- `cam integrations install --host codex` now orchestrates project-scoped MCP wiring plus hook and skill assets without touching `AGENTS.md` +- `cam integrations apply --host codex` now adds the managed `AGENTS.md` guidance flow on top of install while preserving the same explicit, fail-closed boundary +- `cam integrations doctor --host codex` now provides the thin Codex-only readiness surface, including `workflowContract`, `applyReadiness`, and next-step guidance +- `cam skills install` still defaults to the runtime skill surface, but now also supports explicit `official-user` and `official-project` compatibility copies on `.agents/skills` These surfaces must remain host-adapter concerns. The core memory semantics should not be rewritten around any one host’s lifecycle. diff --git a/docs/claude-reference.md b/docs/claude-reference.md index e97a200..9fa1685 100644 --- a/docs/claude-reference.md +++ b/docs/claude-reference.md @@ -138,6 +138,7 @@ Claude `/memory` 是完整的交互入口;`codex-auto-memory` 当前更接近 - Claude memory docs: - Claude settings docs: +- Claude hooks docs: - Claude subagents docs: - Claude docs index: diff --git a/src/lib/commands/integrations.ts b/src/lib/commands/integrations.ts index cbdbd1b..5f93429 100644 --- a/src/lib/commands/integrations.ts +++ b/src/lib/commands/integrations.ts @@ -24,7 +24,10 @@ import { normalizeCodexSkillInstallSurface, type CodexSkillInstallSurface } from "../integration/skills-paths.js"; -import { appendCliCwdFlag } from "../integration/retrieval-contract.js"; +import { + appendCliCwdFlag, + buildWorkflowContract +} from "../integration/retrieval-contract.js"; type IntegrationStackAction = "created" | "updated" | "unchanged" | "blocked"; type InstallStackAction = Exclude; @@ -70,6 +73,7 @@ interface IntegrationStackInstallResult { stackAction: InstallStackAction; skillsSurface: CodexSkillInstallSurface; readOnlyRetrieval: true; + workflowContract: ReturnType; subactions: { mcp: IntegrationSubactionResult; hooks: IntegrationSubactionResult; @@ -86,6 +90,7 @@ interface IntegrationStackApplyResult { blockedStage?: "agents-guidance-preflight"; skillsSurface: CodexSkillInstallSurface; readOnlyRetrieval: true; + workflowContract: ReturnType; subactions: { mcp: IntegrationSubactionResult; agents: IntegrationSubactionResult; @@ -404,6 +409,9 @@ export async function runIntegrationsInstall( stackAction, skillsSurface: skillSurface, readOnlyRetrieval: true, + workflowContract: buildWorkflowContract({ + cwd: projectRoot + }), subactions: { mcp: toMcpSubaction(mcpResult), hooks: { @@ -470,6 +478,9 @@ export async function runIntegrationsApply( blockedStage: "agents-guidance-preflight", skillsSurface: skillSurface, readOnlyRetrieval: true, + workflowContract: buildWorkflowContract({ + cwd: projectRoot + }), subactions: { mcp: { status: "ok", @@ -541,6 +552,9 @@ export async function runIntegrationsApply( stackAction: "blocked", skillsSurface: skillSurface, readOnlyRetrieval: true, + workflowContract: buildWorkflowContract({ + cwd: projectRoot + }), subactions: { mcp: { status: "ok", @@ -621,6 +635,9 @@ export async function runIntegrationsApply( skillsResult.action ]), readOnlyRetrieval: true, + workflowContract: buildWorkflowContract({ + cwd: projectRoot + }), subactions: { mcp: { ...toMcpSubaction(mcpResult), diff --git a/src/lib/commands/recall.ts b/src/lib/commands/recall.ts index a52d7f8..1021369 100644 --- a/src/lib/commands/recall.ts +++ b/src/lib/commands/recall.ts @@ -39,10 +39,15 @@ function formatSearchResults(response: MemorySearchResponse): string { `Query: ${response.query}`, `Scope: ${response.scope} | Requested state: ${response.state} | Resolved state: ${response.resolvedState} | Results: ${response.results.length}`, `Archived fallback used: ${response.fallbackUsed ? "yes" : "no"}`, + `Markdown fallback used: ${response.markdownFallbackUsed ? "yes" : "no"}`, `Retrieval mode: ${response.retrievalMode}${response.retrievalFallbackReason ? ` (${response.retrievalFallbackReason})` : ""}`, `Diagnostics: ${diagnosticsSummary}` ]; + if (response.diagnostics.fallbackReasons.length > 0) { + lines.push(`Fallback reasons: ${response.diagnostics.fallbackReasons.join(", ")}`); + } + if (response.results.length === 0) { lines.push("", "No memory results matched this query."); return lines.join("\n"); diff --git a/src/lib/domain/memory-retrieval-contract.ts b/src/lib/domain/memory-retrieval-contract.ts index d4bd7f1..a059067 100644 --- a/src/lib/domain/memory-retrieval-contract.ts +++ b/src/lib/domain/memory-retrieval-contract.ts @@ -1,5 +1,6 @@ import type { MemoryDetailsResult, + MemorySearchDiagnosticPath, MemorySearchDiagnostics, MemoryRetrievalFallbackReason, MemoryRetrievalMode, @@ -79,19 +80,42 @@ export function buildMemorySearchResponse( diagnostics: MemorySearchDiagnostics, results: MemorySearchResult[] ): MemorySearchResponse { + const normalizedDiagnostics = normalizeMemorySearchDiagnostics(diagnostics.checkedPaths); return { query, scope, state, resolvedState, fallbackUsed, + stateFallbackUsed: fallbackUsed, + markdownFallbackUsed: normalizedDiagnostics.anyMarkdownFallback, retrievalMode, retrievalFallbackReason, - diagnostics, + diagnostics: normalizedDiagnostics, results }; } +export function normalizeMemorySearchDiagnostics( + checkedPaths: MemorySearchDiagnosticPath[] +): MemorySearchDiagnostics { + const fallbackReasons = Array.from( + new Set( + checkedPaths + .map((check) => check.retrievalFallbackReason) + .filter((reason): reason is MemoryRetrievalFallbackReason => reason !== undefined) + ) + ); + + return { + anyMarkdownFallback: checkedPaths.some( + (check) => check.retrievalMode === "markdown-fallback" + ), + fallbackReasons, + checkedPaths + }; +} + export function buildMemoryTimelineResponse( ref: string, timeline: diff --git a/src/lib/domain/memory-retrieval.ts b/src/lib/domain/memory-retrieval.ts index b808a4c..a388feb 100644 --- a/src/lib/domain/memory-retrieval.ts +++ b/src/lib/domain/memory-retrieval.ts @@ -8,7 +8,8 @@ import type { import { buildMemorySearchResponse, DEFAULT_MEMORY_RETRIEVAL_LIMIT, - DEFAULT_MEMORY_RETRIEVAL_STATE + DEFAULT_MEMORY_RETRIEVAL_STATE, + normalizeMemorySearchDiagnostics } from "./memory-retrieval-contract.js"; import { MemoryStore } from "./memory-store.js"; @@ -62,12 +63,10 @@ export class MemoryRetrievalService { true, archivedSearch.retrievalMode, archivedSearch.retrievalFallbackReason, - { - checkedPaths: [ - ...activeSearch.diagnostics.checkedPaths, - ...archivedSearch.diagnostics.checkedPaths - ] - }, + normalizeMemorySearchDiagnostics([ + ...activeSearch.diagnostics.checkedPaths, + ...archivedSearch.diagnostics.checkedPaths + ]), archivedSearch.results ); } diff --git a/src/lib/domain/memory-store.ts b/src/lib/domain/memory-store.ts index f44aac9..38b073d 100644 --- a/src/lib/domain/memory-store.ts +++ b/src/lib/domain/memory-store.ts @@ -46,6 +46,7 @@ import { nextHistoryStateForLifecycle, parseMemoryRef } from "./memory-lifecycle.js"; +import { normalizeMemorySearchDiagnostics } from "./memory-retrieval-contract.js"; import { getDefaultMemoryDirectory } from "./project-context.js"; import { isSyncRecoveryRecord } from "./recovery-records.js"; @@ -1448,9 +1449,7 @@ export class MemoryStore { : "index", retrievalFallbackReason: matchedViaFallback || (!matchedViaIndex && usedFallback) ? fallbackReason : undefined, - diagnostics: { - checkedPaths: diagnostics - } + diagnostics: normalizeMemorySearchDiagnostics(diagnostics) }; } diff --git a/src/lib/mcp/retrieval-server.ts b/src/lib/mcp/retrieval-server.ts index 8a8d3b6..92f5b47 100644 --- a/src/lib/mcp/retrieval-server.ts +++ b/src/lib/mcp/retrieval-server.ts @@ -55,9 +55,13 @@ const memorySearchResponseSchema = z.object({ state: retrievalStateSchema, resolvedState: resolvedRetrievalStateSchema, fallbackUsed: z.boolean(), + stateFallbackUsed: z.boolean(), + markdownFallbackUsed: z.boolean(), retrievalMode: z.enum(["index", "markdown-fallback"]), retrievalFallbackReason: z.enum(["missing", "invalid", "stale"]).optional(), diagnostics: z.object({ + anyMarkdownFallback: z.boolean(), + fallbackReasons: z.array(z.enum(["missing", "invalid", "stale"])), checkedPaths: z.array(memorySearchDiagnosticSchema) }), results: z.array(memorySearchResultSchema) diff --git a/src/lib/types.ts b/src/lib/types.ts index dc33209..865d3ce 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -74,6 +74,8 @@ export interface MemorySearchDiagnosticPath { } export interface MemorySearchDiagnostics { + anyMarkdownFallback: boolean; + fallbackReasons: MemoryRetrievalFallbackReason[]; checkedPaths: MemorySearchDiagnosticPath[]; } @@ -83,6 +85,8 @@ export interface MemorySearchResponse { state: MemoryRetrievalStateFilter; resolvedState: MemoryRetrievalResolvedState; fallbackUsed: boolean; + stateFallbackUsed: boolean; + markdownFallbackUsed: boolean; retrievalMode: MemoryRetrievalMode; retrievalFallbackReason?: MemoryRetrievalFallbackReason; diagnostics: MemorySearchDiagnostics; diff --git a/test/dist-cli-smoke.test.ts b/test/dist-cli-smoke.test.ts index f3f2006..23d4582 100644 --- a/test/dist-cli-smoke.test.ts +++ b/test/dist-cli-smoke.test.ts @@ -211,6 +211,12 @@ describe("dist cli smoke", () => { state: "auto", resolvedState: "archived", fallbackUsed: true, + stateFallbackUsed: true, + markdownFallbackUsed: true, + diagnostics: { + anyMarkdownFallback: true, + fallbackReasons: ["missing"] + }, results: [] }); await expect(fs.access(memoryRoot)).rejects.toMatchObject({ code: "ENOENT" }); @@ -286,8 +292,12 @@ describe("dist cli smoke", () => { state: string; resolvedState: string; fallbackUsed: boolean; + stateFallbackUsed: boolean; + markdownFallbackUsed: boolean; retrievalMode: string; diagnostics: { + anyMarkdownFallback: boolean; + fallbackReasons: string[]; checkedPaths: Array<{ scope: string; state: string; @@ -303,6 +313,8 @@ describe("dist cli smoke", () => { state: "active", resolvedState: "active", fallbackUsed: false, + stateFallbackUsed: false, + markdownFallbackUsed: false, retrievalMode: "index", results: [ { @@ -428,6 +440,7 @@ describe("dist cli smoke", () => { expect(result.exitCode, result.stderr).toBe(0); const payload = JSON.parse(result.stdout) as { host: string; + readOnlyRetrieval: boolean; serverName: string; targetFileHint: string; workflowContract: { @@ -447,6 +460,7 @@ describe("dist cli smoke", () => { }; expect(payload).toMatchObject({ host: "codex", + readOnlyRetrieval: true, serverName: "codex_auto_memory", targetFileHint: ".codex/config.toml", workflowContract: { @@ -478,6 +492,7 @@ describe("dist cli smoke", () => { expect(claudeResult.exitCode, claudeResult.stderr).toBe(0); expect(JSON.parse(claudeResult.stdout)).toMatchObject({ host: "claude", + readOnlyRetrieval: true, serverName: "codex_auto_memory", readOnlyRetrieval: true, targetFileHint: ".mcp.json" @@ -496,6 +511,7 @@ describe("dist cli smoke", () => { expect(geminiResult.exitCode, geminiResult.stderr).toBe(0); expect(JSON.parse(geminiResult.stdout)).toMatchObject({ host: "gemini", + readOnlyRetrieval: true, serverName: "codex_auto_memory", readOnlyRetrieval: true, targetFileHint: ".gemini/settings.json" @@ -514,6 +530,7 @@ describe("dist cli smoke", () => { expect(genericResult.exitCode, genericResult.stderr).toBe(0); expect(JSON.parse(genericResult.stdout)).toMatchObject({ host: "generic", + readOnlyRetrieval: true, serverName: "codex_auto_memory", targetFileHint: "Your MCP client's stdio server config", readOnlyRetrieval: true, @@ -1086,6 +1103,12 @@ describe("dist cli smoke", () => { stackAction: "created", skillsSurface: "runtime", readOnlyRetrieval: true, + workflowContract: { + recommendedPreset: "state=auto, limit=8", + cliFallback: { + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + } + }, subactions: { mcp: { action: "created", @@ -1123,6 +1146,13 @@ describe("dist cli smoke", () => { host: "codex", projectRoot: realProjectDir, stackAction: "created", + readOnlyRetrieval: true, + workflowContract: { + recommendedPreset: "state=auto, limit=8", + cliFallback: { + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + } + }, subactions: { mcp: { action: "created" }, agents: { action: "created" }, diff --git a/test/integrations-command.test.ts b/test/integrations-command.test.ts index 658665e..75af114 100644 --- a/test/integrations-command.test.ts +++ b/test/integrations-command.test.ts @@ -101,6 +101,12 @@ describe("integrations command", () => { stackAction: "created", skillsSurface: "runtime", readOnlyRetrieval: true, + workflowContract: { + recommendedPreset: "state=auto, limit=8", + cliFallback: { + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + } + }, subactions: { mcp: { status: "ok", @@ -910,6 +916,12 @@ describe("integrations command", () => { expect(applyResult.exitCode, applyResult.stderr).toBe(0); expect(JSON.parse(applyResult.stdout)).toMatchObject({ stackAction: "updated", + workflowContract: { + recommendedPreset: "state=auto, limit=8", + cliFallback: { + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + } + }, subactions: { mcp: { action: "unchanged" }, agents: { action: "created" }, diff --git a/test/mcp-command.test.ts b/test/mcp-command.test.ts index 7454621..d3a2b5d 100644 --- a/test/mcp-command.test.ts +++ b/test/mcp-command.test.ts @@ -25,9 +25,13 @@ interface SearchMemoriesResponse { state: string; resolvedState: string; fallbackUsed: boolean; + stateFallbackUsed?: boolean; + markdownFallbackUsed?: boolean; retrievalMode: string; retrievalFallbackReason?: string; diagnostics?: { + anyMarkdownFallback?: boolean; + fallbackReasons?: string[]; checkedPaths: Array<{ scope: string; state: string; @@ -707,6 +711,7 @@ describe("mcp command", () => { expect(result.exitCode, result.stderr).toBe(0); const payload = JSON.parse(result.stdout) as { host: string; + readOnlyRetrieval: boolean; serverName: string; transport: string; targetFileHint: string; @@ -737,6 +742,7 @@ describe("mcp command", () => { expect(payload).toMatchObject({ host: "codex", + readOnlyRetrieval: true, serverName: "codex_auto_memory", transport: "stdio", targetFileHint: ".codex/config.toml", @@ -1221,6 +1227,7 @@ describe("mcp command", () => { const payload = JSON.parse(result.stdout) as { host: string; projectRoot: string; + readOnlyRetrieval: boolean; snippet: string; workflowContract: { cliFallback: { @@ -1232,7 +1239,8 @@ describe("mcp command", () => { }; expect(payload).toMatchObject({ host: "generic", - projectRoot: realProjectDir + projectRoot: realProjectDir, + readOnlyRetrieval: true }); expect(payload.snippet).toContain(realProjectDir); expect(payload.workflowContract).toBeUndefined(); @@ -2602,6 +2610,8 @@ describe("mcp command", () => { state: "archived", resolvedState: "archived", fallbackUsed: false, + stateFallbackUsed: false, + markdownFallbackUsed: false, retrievalMode: "index" }); expect(searchPayload.results).toHaveLength(1); @@ -2878,6 +2888,8 @@ describe("mcp command", () => { state: "auto", resolvedState: "archived", fallbackUsed: true, + stateFallbackUsed: true, + markdownFallbackUsed: false, retrievalMode: "index" }); expect(payload.results).toHaveLength(8); @@ -2980,9 +2992,14 @@ describe("mcp command", () => { }); const payload = readStructuredContent(result as ToolCallResultLike); expect(payload).toMatchObject({ + fallbackUsed: true, + stateFallbackUsed: true, + markdownFallbackUsed: true, retrievalMode: "markdown-fallback", retrievalFallbackReason: "missing", diagnostics: { + anyMarkdownFallback: true, + fallbackReasons: ["missing"], checkedPaths: expect.arrayContaining([ expect.objectContaining({ scope: "project", diff --git a/test/recall-command.test.ts b/test/recall-command.test.ts index 2f53632..0d5a7de 100644 --- a/test/recall-command.test.ts +++ b/test/recall-command.test.ts @@ -28,6 +28,8 @@ afterEach(async () => { }); interface RecallSearchDiagnostics { + anyMarkdownFallback: boolean; + fallbackReasons: string[]; checkedPaths: Array<{ scope: string; state: string; @@ -76,6 +78,8 @@ describe("runRecall", () => { state: string; resolvedState: string; fallbackUsed: boolean; + stateFallbackUsed: boolean; + markdownFallbackUsed: boolean; retrievalMode: string; retrievalFallbackReason?: string; diagnostics: RecallSearchDiagnostics; @@ -85,8 +89,14 @@ describe("runRecall", () => { state: "auto", resolvedState: "archived", fallbackUsed: true, + stateFallbackUsed: true, + markdownFallbackUsed: false, retrievalMode: "index" }); + expect(output.diagnostics).toMatchObject({ + anyMarkdownFallback: false, + fallbackReasons: [] + }); expect(output.diagnostics.checkedPaths).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -144,6 +154,8 @@ describe("runRecall", () => { state: string; resolvedState: string; fallbackUsed: boolean; + stateFallbackUsed: boolean; + markdownFallbackUsed: boolean; retrievalMode: string; results: Array<{ ref: string; state: string; topic: string }>; }; @@ -151,6 +163,8 @@ describe("runRecall", () => { state: "auto", resolvedState: "active", fallbackUsed: false, + stateFallbackUsed: false, + markdownFallbackUsed: false, retrievalMode: "index" }); expect(output.results).toEqual([ @@ -202,6 +216,8 @@ describe("runRecall", () => { state: string; resolvedState: string; fallbackUsed: boolean; + stateFallbackUsed: boolean; + markdownFallbackUsed: boolean; retrievalMode: string; results: Array<{ ref: string; state: string; topic: string }>; }; @@ -209,6 +225,8 @@ describe("runRecall", () => { state: "auto", resolvedState: "archived", fallbackUsed: true, + stateFallbackUsed: true, + markdownFallbackUsed: false, retrievalMode: "index" }); expect(searchOutput.results).toEqual([ @@ -604,6 +622,8 @@ describe("runRecall", () => { state: string; resolvedState: string; fallbackUsed: boolean; + stateFallbackUsed: boolean; + markdownFallbackUsed: boolean; retrievalMode: string; retrievalFallbackReason?: string; diagnostics: RecallSearchDiagnostics; @@ -613,10 +633,16 @@ describe("runRecall", () => { state: "auto", resolvedState: "archived", fallbackUsed: true, + stateFallbackUsed: true, + markdownFallbackUsed: true, retrievalMode: "markdown-fallback", retrievalFallbackReason: "missing", results: [] }); + expect(output.diagnostics).toMatchObject({ + anyMarkdownFallback: true, + fallbackReasons: ["missing"] + }); expect(output.diagnostics.checkedPaths).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/test/tarball-install-smoke.test.ts b/test/tarball-install-smoke.test.ts index ebeadb2..4971448 100644 --- a/test/tarball-install-smoke.test.ts +++ b/test/tarball-install-smoke.test.ts @@ -167,8 +167,12 @@ describe("tarball install smoke", () => { state: "active", resolvedState: "active", fallbackUsed: false, + stateFallbackUsed: false, + markdownFallbackUsed: false, retrievalMode: "index", diagnostics: { + anyMarkdownFallback: false, + fallbackReasons: [], checkedPaths: expect.arrayContaining([ expect.objectContaining({ scope: "project", @@ -243,6 +247,7 @@ describe("tarball install smoke", () => { expect(codexPrintConfigResult.exitCode).toBe(0); const codexPrintConfigPayload = JSON.parse(codexPrintConfigResult.stdout) as { host: string; + readOnlyRetrieval: boolean; serverName: string; targetFileHint: string; workflowContract: { @@ -262,6 +267,7 @@ describe("tarball install smoke", () => { }; expect(codexPrintConfigPayload).toMatchObject({ host: "codex", + readOnlyRetrieval: true, serverName: "codex_auto_memory", targetFileHint: ".codex/config.toml", workflowContract: { @@ -508,6 +514,12 @@ describe("tarball install smoke", () => { stackAction: "unchanged", skillsSurface: "runtime", readOnlyRetrieval: true, + workflowContract: { + recommendedPreset: "state=auto, limit=8", + cliFallback: { + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realInstallDir)}` + } + }, subactions: { mcp: { action: "unchanged" }, hooks: { action: "unchanged" }, @@ -527,6 +539,12 @@ describe("tarball install smoke", () => { stackAction: "unchanged", skillsSurface: "runtime", readOnlyRetrieval: true, + workflowContract: { + recommendedPreset: "state=auto, limit=8", + cliFallback: { + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realInstallDir)}` + } + }, subactions: { mcp: { action: "unchanged" }, agents: { action: "unchanged" }, From 3c9ae78dd4e587343fe9c3fe707ebb2d516af2f6 Mon Sep 17 00:00:00 2001 From: blocks Date: Tue, 7 Apr 2026 23:45:38 +0800 Subject: [PATCH 14/62] fix: align retrieval sidecar stack contracts --- src/lib/domain/memory-store.ts | 4 +--- src/lib/integration/mcp-config.ts | 4 +++- test/dist-cli-smoke.test.ts | 9 +++------ test/docs-contract.test.ts | 7 +++---- test/hooks-command.test.ts | 14 +++++++++----- test/integrations-command.test.ts | 6 +++--- test/mcp-command.test.ts | 8 ++++---- test/skills-command.test.ts | 12 ++++++++---- test/tarball-install-smoke.test.ts | 6 +++--- 9 files changed, 37 insertions(+), 33 deletions(-) diff --git a/src/lib/domain/memory-store.ts b/src/lib/domain/memory-store.ts index 38b073d..5f7783f 100644 --- a/src/lib/domain/memory-store.ts +++ b/src/lib/domain/memory-store.ts @@ -276,9 +276,7 @@ function parseEntryBlock(block: string): MemoryEntry | null { scope: metadata.scope, topic: "workflow", summary: summaryRaw.trim(), - details: details - .map((line) => line.slice(2).trim()) - .filter(Boolean), + details, updatedAt: metadata.updatedAt, sources: metadata.sources ?? [], reason: metadata.reason diff --git a/src/lib/integration/mcp-config.ts b/src/lib/integration/mcp-config.ts index 00a5f97..1b758b8 100644 --- a/src/lib/integration/mcp-config.ts +++ b/src/lib/integration/mcp-config.ts @@ -55,7 +55,9 @@ export function buildMcpHostConfigSnippet(host: McpHost, projectRoot: string): M workflowContract: buildWorkflowContract({ cwd: projectRoot }), - agentsGuidance: buildCodexAgentsGuidance() + agentsGuidance: buildCodexAgentsGuidance({ + cwd: projectRoot + }) } : {}) }; diff --git a/test/dist-cli-smoke.test.ts b/test/dist-cli-smoke.test.ts index 23d4582..6a921bb 100644 --- a/test/dist-cli-smoke.test.ts +++ b/test/dist-cli-smoke.test.ts @@ -494,7 +494,6 @@ describe("dist cli smoke", () => { host: "claude", readOnlyRetrieval: true, serverName: "codex_auto_memory", - readOnlyRetrieval: true, targetFileHint: ".mcp.json" }); expect(JSON.parse(claudeResult.stdout).workflowContract).toBeUndefined(); @@ -513,7 +512,6 @@ describe("dist cli smoke", () => { host: "gemini", readOnlyRetrieval: true, serverName: "codex_auto_memory", - readOnlyRetrieval: true, targetFileHint: ".gemini/settings.json" }); expect(JSON.parse(geminiResult.stdout).workflowContract).toBeUndefined(); @@ -533,7 +531,6 @@ describe("dist cli smoke", () => { readOnlyRetrieval: true, serverName: "codex_auto_memory", targetFileHint: "Your MCP client's stdio server config", - readOnlyRetrieval: true, snippetFormat: "json" }); expect(JSON.parse(genericResult.stdout).workflowContract).toBeUndefined(); @@ -1106,7 +1103,7 @@ describe("dist cli smoke", () => { workflowContract: { recommendedPreset: "state=auto, limit=8", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realProjectDir)}` } }, subactions: { @@ -1150,7 +1147,7 @@ describe("dist cli smoke", () => { workflowContract: { recommendedPreset: "state=auto, limit=8", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realProjectDir)}` } }, subactions: { @@ -1656,7 +1653,7 @@ fs.writeFileSync(${JSON.stringify(capturedArgsPath)}, JSON.stringify(process.arg path.join(homeDir, ".codex-auto-memory", "hooks", "post-work-memory-review.sh"), "utf8" ) - ).toContain(`cam sync --cwd ${JSON.stringify(realProjectDir)} "$@"`); + ).toContain(`cam sync --cwd ${shellQuoteArg(realProjectDir)} "$@"`); const guidanceResult = runCli( callerDir, diff --git a/test/docs-contract.test.ts b/test/docs-contract.test.ts index 993f970..d1cf0dd 100644 --- a/test/docs-contract.test.ts +++ b/test/docs-contract.test.ts @@ -394,15 +394,14 @@ describe("docs contract", () => { expect(readmeEn).toContain("applyReadiness"); expect( claudeReferenceEn.match( - /### 6\. Host integration surfaces matter, but should not replace the core contract/g + /### 6\. `autoMemoryDirectory` has a configuration safety boundary/g )?.length ?? 0 ).toBe(1); expect( claudeReferenceEn.match( - /### 7\. Host integration surfaces matter, but should not replace the core contract/g + /### 7\. Host-native breadth expands the host, not the memory model/g )?.length ?? 0 - ).toBe(0); - expect(claudeReferenceEn).toContain("### 7. Host-native breadth expands the host, not the memory model"); + ).toBe(1); expect(registerCommands).toContain( "Manage the local bridge / fallback helper bundle for current and upcoming integrations" ); diff --git a/test/hooks-command.test.ts b/test/hooks-command.test.ts index d07c902..385e7cc 100644 --- a/test/hooks-command.test.ts +++ b/test/hooks-command.test.ts @@ -33,6 +33,10 @@ async function writeCamShim(binDir: string): Promise { return shimPath; } +function shellQuoteArg(value: string): string { + return `'${value.replace(/'/g, `'\"'\"'`)}'`; +} + afterEach(async () => { process.env.HOME = originalHome; await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); @@ -105,10 +109,10 @@ describe("hooks command", () => { `PROJECT_ROOT=${JSON.stringify(await fs.realpath(projectDir))}` ); expect(postWorkReviewScript).toContain( - `cam sync --cwd ${JSON.stringify(await fs.realpath(projectDir))}` + `cam sync --cwd ${shellQuoteArg(await fs.realpath(projectDir))}` ); expect(postWorkReviewScript).toContain( - `exec cam memory --recent --cwd ${JSON.stringify(await fs.realpath(projectDir))}` + `exec cam memory --recent --cwd ${shellQuoteArg(await fs.realpath(projectDir))}` ); }); @@ -160,15 +164,15 @@ describe("hooks command", () => { expect(timelineScript).toContain('exec "$SCRIPT_DIR/memory-recall.sh" timeline "$@"'); expect(detailsScript).toContain('exec "$SCRIPT_DIR/memory-recall.sh" details "$@"'); expect(postWorkReviewScript).toContain( - `cam sync --cwd ${JSON.stringify(realProjectDir)} "$@"` + `cam sync --cwd ${shellQuoteArg(realProjectDir)} "$@"` ); expect(postWorkReviewScript).toContain( - `exec cam memory --recent --cwd ${JSON.stringify(realProjectDir)}` + `exec cam memory --recent --cwd ${shellQuoteArg(realProjectDir)}` ); expect(recallGuide).toContain("search_memories"); expect(recallGuide).toContain("memory-recall.sh search"); expect(recallGuide).toContain( - `cam recall search "pnpm" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + `cam recall search "pnpm" --state auto --limit 8 --cwd ${shellQuoteArg(realProjectDir)}` ); expect(recallGuide).toContain("cam memory"); expect(recallGuide).toContain("cam session"); diff --git a/test/integrations-command.test.ts b/test/integrations-command.test.ts index 75af114..7e816f3 100644 --- a/test/integrations-command.test.ts +++ b/test/integrations-command.test.ts @@ -104,7 +104,7 @@ describe("integrations command", () => { workflowContract: { recommendedPreset: "state=auto, limit=8", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realProjectDir)}` } }, subactions: { @@ -391,7 +391,7 @@ describe("integrations command", () => { expect(payload.nextSteps).toEqual( expect.arrayContaining([ expect.stringContaining( - `cam hooks install --cwd ${JSON.stringify(payload.projectRoot)}` + `cam hooks install --cwd ${shellQuoteArg(payload.projectRoot)}` ) ]) ); @@ -919,7 +919,7 @@ describe("integrations command", () => { workflowContract: { recommendedPreset: "state=auto, limit=8", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realProjectDir)}` } }, subactions: { diff --git a/test/mcp-command.test.ts b/test/mcp-command.test.ts index d3a2b5d..5794cbf 100644 --- a/test/mcp-command.test.ts +++ b/test/mcp-command.test.ts @@ -1268,13 +1268,13 @@ describe("mcp command", () => { }; }; expect(payload.agentsGuidance.snippet).toContain( - `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realProjectDir)}` ); expect(payload.agentsGuidance.snippet).toContain( - `cam recall timeline "" --cwd ${JSON.stringify(realProjectDir)}` + `cam recall timeline "" --cwd ${shellQuoteArg(realProjectDir)}` ); expect(payload.agentsGuidance.snippet).toContain( - `cam recall details "" --cwd ${JSON.stringify(realProjectDir)}` + `cam recall details "" --cwd ${shellQuoteArg(realProjectDir)}` ); expect(payload.agentsGuidance.snippet).toContain( `post-work-memory-review.sh` @@ -1562,7 +1562,7 @@ describe("mcp command", () => { expect(payload.retrievalSidecar).toMatchObject({ status: "warning", summary: expect.stringContaining("Markdown"), - repairCommand: `cam memory reindex --scope all --state all --cwd ${JSON.stringify(realProjectDir)}`, + repairCommand: `cam memory reindex --scope all --state all --cwd ${shellQuoteArg(realProjectDir)}`, checks: expect.arrayContaining([ expect.objectContaining({ scope: "project", diff --git a/test/skills-command.test.ts b/test/skills-command.test.ts index 69ede71..a37a0bd 100644 --- a/test/skills-command.test.ts +++ b/test/skills-command.test.ts @@ -15,6 +15,10 @@ async function tempDir(prefix: string): Promise { return dir; } +function shellQuoteArg(value: string): string { + return `'${value.replace(/'/g, `'\"'\"'`)}'`; +} + afterEach(async () => { restoreOptionalEnv("HOME", originalHome); restoreOptionalEnv("CODEX_HOME", originalCodexHome); @@ -52,14 +56,14 @@ describe("skills command", () => { expect(skillFile).toContain("limit: 8"); expect(skillFile).toContain("cam recall search"); expect(skillFile).toContain("--state auto"); - expect(skillFile).toContain(`--cwd ${JSON.stringify(await fs.realpath(projectDir))}`); + expect(skillFile).toContain(`--cwd ${shellQuoteArg(await fs.realpath(projectDir))}`); expect(skillFile).toContain("cam recall timeline"); expect(skillFile).toContain("cam recall details"); expect(skillFile).toContain("cam mcp doctor"); expect(skillFile).toContain("cam hooks install"); - expect(skillFile).toContain(`cam sync --cwd ${JSON.stringify(await fs.realpath(projectDir))}`); + expect(skillFile).toContain(`cam sync --cwd ${shellQuoteArg(await fs.realpath(projectDir))}`); expect(skillFile).toContain( - `cam memory --recent --cwd ${JSON.stringify(await fs.realpath(projectDir))}` + `cam memory --recent --cwd ${shellQuoteArg(await fs.realpath(projectDir))}` ); expect(skillFile).toContain("cam memory"); expect(skillFile).toContain("cam session"); @@ -82,7 +86,7 @@ describe("skills command", () => { workflowContract: { recommendedPreset: "state=auto, limit=8", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(await fs.realpath(projectDir))}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(await fs.realpath(projectDir))}` }, postWorkSyncReview: { helperScript: "post-work-memory-review.sh" diff --git a/test/tarball-install-smoke.test.ts b/test/tarball-install-smoke.test.ts index 4971448..375cf33 100644 --- a/test/tarball-install-smoke.test.ts +++ b/test/tarball-install-smoke.test.ts @@ -379,7 +379,7 @@ describe("tarball install smoke", () => { path.join(homeDir, ".codex-auto-memory", "hooks", "post-work-memory-review.sh"), "utf8" ) - ).toContain(`cam sync --cwd ${JSON.stringify(realProjectWithSpacesDir)} "$@"`); + ).toContain(`cam sync --cwd ${shellQuoteArg(realProjectWithSpacesDir)} "$@"`); const cwdApplyGuidanceResult = runCommandCapture( camBinaryPath(installDir), @@ -517,7 +517,7 @@ describe("tarball install smoke", () => { workflowContract: { recommendedPreset: "state=auto, limit=8", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realInstallDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realInstallDir)}` } }, subactions: { @@ -542,7 +542,7 @@ describe("tarball install smoke", () => { workflowContract: { recommendedPreset: "state=auto, limit=8", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realInstallDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realInstallDir)}` } }, subactions: { From 292dfe868e93443c1fd49c4a2c2b210ee8c1dab5 Mon Sep 17 00:00:00 2001 From: blocks Date: Wed, 8 Apr 2026 20:39:41 +0800 Subject: [PATCH 15/62] fix: harden retrieval hook quoting and audit fallback --- src/lib/domain/memory-store.ts | 6 +- src/lib/integration/assets.ts | 6 +- test/dist-cli-smoke.test.ts | 2 +- test/hooks-command.test.ts | 8 +-- test/recall-command.test.ts | 100 +++++++++++++++++++++++++++-- test/tarball-install-smoke.test.ts | 2 +- 6 files changed, 110 insertions(+), 14 deletions(-) diff --git a/src/lib/domain/memory-store.ts b/src/lib/domain/memory-store.ts index 5f7783f..dcefee9 100644 --- a/src/lib/domain/memory-store.ts +++ b/src/lib/domain/memory-store.ts @@ -2223,8 +2223,10 @@ export class MemoryStore { const matched = entries.find( (entry) => - latestRolloutPath !== undefined && - entry.rolloutPath === latestRolloutPath && + ((latestRolloutPath !== undefined && entry.rolloutPath === latestRolloutPath) || + (latestRolloutPath === undefined && + latestSessionId !== undefined && + entry.sessionId === latestSessionId)) && (latestSessionId === undefined || entry.sessionId === latestSessionId) && entry.operations.some( (operation) => diff --git a/src/lib/integration/assets.ts b/src/lib/integration/assets.ts index b2323a2..1bc4c7d 100644 --- a/src/lib/integration/assets.ts +++ b/src/lib/integration/assets.ts @@ -100,8 +100,12 @@ function resolveInstallDir( return surface === "hooks" ? context.hookDir : context.skillDir; } +function shellQuoteArg(value: string): string { + return `'${value.replace(/'/g, `'\"'\"'`)}'`; +} + function buildPinnedProjectRootBlock(projectRoot: string): string { - return `PROJECT_ROOT=${JSON.stringify(projectRoot)} + return `PROJECT_ROOT=${shellQuoteArg(projectRoot)} `; } diff --git a/test/dist-cli-smoke.test.ts b/test/dist-cli-smoke.test.ts index 6a921bb..89b1cb3 100644 --- a/test/dist-cli-smoke.test.ts +++ b/test/dist-cli-smoke.test.ts @@ -1647,7 +1647,7 @@ fs.writeFileSync(${JSON.stringify(capturedArgsPath)}, JSON.stringify(process.arg path.join(homeDir, ".codex-auto-memory", "hooks", "memory-recall.sh"), "utf8" ) - ).toContain(`PROJECT_ROOT=${JSON.stringify(realProjectDir)}`); + ).toContain(`PROJECT_ROOT=${shellQuoteArg(realProjectDir)}`); expect( await fs.readFile( path.join(homeDir, ".codex-auto-memory", "hooks", "post-work-memory-review.sh"), diff --git a/test/hooks-command.test.ts b/test/hooks-command.test.ts index 385e7cc..0d732b0 100644 --- a/test/hooks-command.test.ts +++ b/test/hooks-command.test.ts @@ -43,7 +43,7 @@ afterEach(async () => { }); describe("hooks command", () => { - it("supports --cwd and pins generated hook helpers to the targeted project root", async () => { + shellOnlyIt("supports --cwd and pins generated hook helpers to the targeted project root", async () => { const homeDir = await tempDir("cam-hooks-cwd-home-"); const projectParentDir = await tempDir("cam-hooks-cwd-parent-"); const projectDir = path.join(projectParentDir, "project with spaces"); @@ -105,9 +105,7 @@ describe("hooks command", () => { const recallScript = await fs.readFile(recallScriptPath, "utf8"); const postWorkReviewScript = await fs.readFile(postWorkReviewScriptPath, "utf8"); - expect(recallScript).toContain( - `PROJECT_ROOT=${JSON.stringify(await fs.realpath(projectDir))}` - ); + expect(recallScript).toContain(`PROJECT_ROOT=${shellQuoteArg(await fs.realpath(projectDir))}`); expect(postWorkReviewScript).toContain( `cam sync --cwd ${shellQuoteArg(await fs.realpath(projectDir))}` ); @@ -155,7 +153,7 @@ describe("hooks command", () => { const realProjectDir = await fs.realpath(projectDir); expect(recallScript).toContain('exec cam recall search "$@"'); - expect(recallScript).toContain(`PROJECT_ROOT=${JSON.stringify(realProjectDir)}`); + expect(recallScript).toContain(`PROJECT_ROOT=${shellQuoteArg(realProjectDir)}`); expect(recallScript).toContain("--state"); expect(recallScript).toContain("auto"); expect(recallScript).toContain("--limit"); diff --git a/test/recall-command.test.ts b/test/recall-command.test.ts index 0d5a7de..146a728 100644 --- a/test/recall-command.test.ts +++ b/test/recall-command.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { buildMemorySyncAuditEntry } from "../src/lib/domain/memory-sync-audit.js"; import { detectProjectContext } from "../src/lib/domain/project-context.js"; import { MemoryStore } from "../src/lib/domain/memory-store.js"; import { restoreOptionalEnv } from "./helpers/env.js"; @@ -53,7 +54,8 @@ describe("runRecall", () => { autoMemoryDirectory: memoryRoot }); - const store = new MemoryStore(detectProjectContext(projectDir), { + const project = detectProjectContext(projectDir); + const store = new MemoryStore(project, { ...projectConfig, autoMemoryDirectory: memoryRoot }); @@ -124,7 +126,8 @@ describe("runRecall", () => { autoMemoryDirectory: memoryRoot }); - const store = new MemoryStore(detectProjectContext(projectDir), { + const project = detectProjectContext(projectDir); + const store = new MemoryStore(project, { ...projectConfig, autoMemoryDirectory: memoryRoot }); @@ -187,7 +190,8 @@ describe("runRecall", () => { autoMemoryDirectory: memoryRoot }); - const store = new MemoryStore(detectProjectContext(projectDir), { + const project = detectProjectContext(projectDir); + const store = new MemoryStore(project, { ...projectConfig, autoMemoryDirectory: memoryRoot }); @@ -249,7 +253,8 @@ describe("runRecall", () => { autoMemoryDirectory: memoryRoot }); - const store = new MemoryStore(detectProjectContext(projectDir), { + const project = detectProjectContext(projectDir); + const store = new MemoryStore(project, { ...projectConfig, autoMemoryDirectory: memoryRoot }); @@ -604,6 +609,93 @@ describe("runRecall", () => { }); }); + it("backfills latestAudit from a matching session-only sync audit entry", async () => { + const homeDir = await tempDir("cam-recall-session-only-audit-home-"); + const projectDir = await tempDir("cam-recall-session-only-audit-project-"); + const memoryRoot = await tempDir("cam-recall-session-only-audit-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const project = detectProjectContext(projectDir); + const store = new MemoryStore(project, { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.applyMutations( + [ + { + action: "upsert", + scope: "project", + topic: "workflow", + id: "prefer-pnpm", + summary: "Prefer pnpm in this repository.", + details: ["Use pnpm instead of npm in this repository."], + reason: "Manual note.", + sources: ["manual"] + } + ], + { + sessionId: "session-only-audit" + } + ); + await store.appendSyncAuditEntry(buildMemorySyncAuditEntry({ + project, + config: { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }, + appliedAt: "2026-03-14T00:00:05.000Z", + rolloutPath: "rollout-without-match.jsonl", + sessionId: "session-only-audit", + configuredExtractorName: "heuristic", + actualExtractorMode: "heuristic", + actualExtractorName: "heuristic", + sessionSource: "manual", + status: "applied", + operations: [ + { + action: "upsert", + scope: "project", + topic: "workflow", + id: "prefer-pnpm", + summary: "Prefer pnpm in this repository.", + details: ["Use pnpm instead of npm in this repository."] + } + ], + noopOperationCount: 0, + suppressedOperationCount: 0, + conflicts: [] + })); + + const detailsResult = runCli(projectDir, [ + "recall", + "details", + "project:active:workflow:prefer-pnpm", + "--json" + ]); + expect(detailsResult.exitCode).toBe(0); + expect(JSON.parse(detailsResult.stdout)).toMatchObject({ + latestLifecycleAction: "add", + latestState: "active", + latestSessionId: "session-only-audit", + latestRolloutPath: null, + latestAudit: { + auditPath: store.getSyncAuditPath(), + sessionId: "session-only-audit", + rolloutPath: "rollout-without-match.jsonl", + status: "applied", + resultSummary: "1 operation(s) applied", + noopOperationCount: 0, + suppressedOperationCount: 0 + } + }); + }); + it("keeps recall search read-only and does not create memory layout on first lookup", async () => { const homeDir = await tempDir("cam-recall-readonly-home-"); const projectDir = await tempDir("cam-recall-readonly-project-"); diff --git a/test/tarball-install-smoke.test.ts b/test/tarball-install-smoke.test.ts index 375cf33..2462c6d 100644 --- a/test/tarball-install-smoke.test.ts +++ b/test/tarball-install-smoke.test.ts @@ -373,7 +373,7 @@ describe("tarball install smoke", () => { expect(cwdHooksResult.exitCode).toBe(0); expect( await fs.readFile(path.join(homeDir, ".codex-auto-memory", "hooks", "memory-recall.sh"), "utf8") - ).toContain(`PROJECT_ROOT=${JSON.stringify(realProjectWithSpacesDir)}`); + ).toContain(`PROJECT_ROOT=${shellQuoteArg(realProjectWithSpacesDir)}`); expect( await fs.readFile( path.join(homeDir, ".codex-auto-memory", "hooks", "post-work-memory-review.sh"), From aae1e768ae49a363120a5f991b8dea129f326d3b Mon Sep 17 00:00:00 2001 From: blocks Date: Sat, 28 Mar 2026 01:36:14 +0800 Subject: [PATCH 16/62] feat: add manual mutation reviewer contracts --- src/lib/cli/register-commands.ts | 2 + src/lib/commands/forget.ts | 36 +- src/lib/commands/integrations.ts | 11 +- src/lib/commands/manual-mutation-review.ts | 182 +++++++++ src/lib/commands/recall.ts | 46 ++- src/lib/commands/remember.ts | 13 + src/lib/domain/memory-lifecycle.ts | 41 +- src/lib/domain/memory-retrieval-contract.ts | 46 ++- src/lib/domain/memory-retrieval.ts | 28 ++ src/lib/domain/memory-store.ts | 263 +++++++++++-- src/lib/integration/assets.ts | 25 +- src/lib/integration/codex-stack.ts | 104 ++++- src/lib/integration/mcp-config.ts | 27 +- src/lib/integration/mcp-doctor.ts | 53 ++- src/lib/integration/retrieval-contract.ts | 155 +++++++- src/lib/mcp/retrieval-server.ts | 59 ++- src/lib/types.ts | 71 +++- test/dist-cli-smoke.test.ts | 11 +- test/hooks-command.test.ts | 15 +- test/integrations-command.test.ts | 104 ++++- test/mcp-command.test.ts | 409 +++++++++++++++++++- test/memory-command.test.ts | 144 +++++++ test/memory-store.test.ts | 182 ++++++++- test/recall-command.test.ts | 382 +++++++++++++++++- 24 files changed, 2283 insertions(+), 126 deletions(-) create mode 100644 src/lib/commands/manual-mutation-review.ts diff --git a/src/lib/cli/register-commands.ts b/src/lib/cli/register-commands.ts index f8f0ee1..24ecd0c 100644 --- a/src/lib/cli/register-commands.ts +++ b/src/lib/cli/register-commands.ts @@ -343,6 +343,7 @@ export function registerCommands(program: Command): void { .option("--scope ", "Memory scope: global, project, or project-local") .option("--topic ", "Topic file name", "workflow") .option("--detail ", "Additional detail bullets") + .option("--json", "Print JSON output") .action(withStdout(async (text, options) => runRemember(text, options))); program @@ -351,6 +352,7 @@ export function registerCommands(program: Command): void { .argument("", "Search query used to find memory entries") .option("--scope ", "Specific scope to target, or all") .option("--archive", "Move matching entries into archive instead of deleting them") + .option("--json", "Print JSON output") .action(withStdout(async (query, options) => runForget(query, options))); program diff --git a/src/lib/commands/forget.ts b/src/lib/commands/forget.ts index f7d9eb6..a6fa4f2 100644 --- a/src/lib/commands/forget.ts +++ b/src/lib/commands/forget.ts @@ -1,10 +1,15 @@ import { buildRuntimeContext } from "../runtime/runtime-context.js"; import type { MemoryScope } from "../types.js"; +import { + buildManualMutationReviewEntry, + toManualMutationForgetPayload +} from "./manual-mutation-review.js"; interface ForgetOptions { cwd?: string; scope?: MemoryScope | "all"; archive?: boolean; + json?: boolean; } export async function runForget( @@ -16,9 +21,38 @@ export async function runForget( } const runtime = await buildRuntimeContext(options.cwd); - const deleted = await runtime.syncService.memoryStore.forget(options.scope ?? "all", query, { + const targetScope = options.scope ?? "all"; + const deleted = await runtime.syncService.memoryStore.forget(targetScope, query, { archive: options.archive }); + + if (options.json) { + const reviewEntries = await Promise.all( + deleted.map(async (entry) => + buildManualMutationReviewEntry(runtime.syncService.memoryStore, { + operation: { + action: options.archive ? "archive" : "delete", + scope: entry.scope, + topic: entry.topic, + id: entry.id, + summary: entry.summary, + details: entry.details, + sources: ["manual"], + reason: options.archive ? "Manual archive request." : "Explicit forget instruction from the user." + }, + lifecycleAction: options.archive ? "archive" : "delete", + previousState: "active", + nextState: options.archive ? "archived" : "deleted" + }) + ) + ); + return JSON.stringify( + toManualMutationForgetPayload(query, targetScope, Boolean(options.archive), reviewEntries), + null, + 2 + ); + } + if (deleted.length === 0) { return `No memory entries matched "${query}".`; } diff --git a/src/lib/commands/integrations.ts b/src/lib/commands/integrations.ts index 5f93429..deff533 100644 --- a/src/lib/commands/integrations.ts +++ b/src/lib/commands/integrations.ts @@ -114,6 +114,7 @@ interface IntegrationDoctorResult { recommendedPreset: string; retrievalSidecar: McpDoctorReport["retrievalSidecar"]; workflowContract: McpDoctorReport["workflowContract"]; + experimentalHooks: McpDoctorReport["experimentalHooks"]; applyReadiness: { status: "safe" | "blocked"; reason?: string; @@ -225,7 +226,9 @@ function buildIntegrationsDoctorResult( mcpOperationalReady: report.codexStack.mcpOperationalReady, camCommandAvailable: report.codexStack.camCommandAvailable, hookCaptureReady: report.codexStack.hookCaptureReady, + hookCaptureOperationalReady: report.codexStack.hookCaptureOperationalReady, hookRecallReady: report.codexStack.hookRecallReady, + hookRecallOperationalReady: report.codexStack.hookRecallOperationalReady, skillReady: report.codexStack.skillReady, workflowAssetsConsistent: report.codexStack.workflowAssetsConsistent, workflowConsistent: report.codexStack.workflowConsistent @@ -293,7 +296,9 @@ function buildIntegrationsDoctorResult( mcpOperationalReady: report.codexStack.mcpOperationalReady, camCommandAvailable: report.codexStack.camCommandAvailable, hookCaptureReady: report.codexStack.hookCaptureReady, + hookCaptureOperationalReady: report.codexStack.hookCaptureOperationalReady, hookRecallReady: report.codexStack.hookRecallReady, + hookRecallOperationalReady: report.codexStack.hookRecallOperationalReady, skillReady: report.codexStack.skillReady, workflowAssetsConsistent: report.codexStack.workflowAssetsConsistent, workflowConsistent: report.codexStack.workflowConsistent @@ -307,14 +312,14 @@ function buildIntegrationsDoctorResult( `Run \`${report.retrievalSidecar.repairCommand}\` to rebuild retrieval sidecars from Markdown canonical memory.` ); } - const needsOtherStackSurface = + const needsInstallableOtherStackSurface = !report.codexStack.mcpReady || !report.codexStack.hookCaptureReady || !report.codexStack.hookRecallReady || !report.codexStack.skillReady; if (applyReadiness.status === "blocked") { nextSteps.unshift(applyReadiness.recommendedFix); - } else if (needsAgents && needsOtherStackSurface) { + } else if (needsAgents && needsInstallableOtherStackSurface) { nextSteps.unshift( `Run \`${appendCliCwdFlag( `cam integrations apply --host codex --skill-surface ${report.fallbackAssets.preferredInstallSurface}`, @@ -339,6 +344,7 @@ function buildIntegrationsDoctorResult( recommendedPreset: report.codexStack.preset, retrievalSidecar: report.retrievalSidecar, workflowContract: report.workflowContract, + experimentalHooks: report.experimentalHooks, applyReadiness, preferredSkillSurface: report.fallbackAssets.preferredInstallSurface, recommendedSkillInstallCommand: report.fallbackAssets.recommendedSkillInstallCommand, @@ -359,6 +365,7 @@ function formatIntegrationsDoctorResult(result: IntegrationDoctorResult): string `Status: ${result.status}`, `Recommended route: ${result.recommendedRoute}`, `Recommended preset: ${result.recommendedPreset}`, + `Experimental hooks: ${result.experimentalHooks.status} (${result.experimentalHooks.featureFlag})`, `Retrieval sidecar: ${result.retrievalSidecar.status} (${result.retrievalSidecar.summary})`, `Apply readiness: ${result.applyReadiness.status}${result.applyReadiness.reason ? ` (${result.applyReadiness.reason})` : ""}`, `Preferred skill surface: ${formatCodexSkillInstallSurface(result.preferredSkillSurface)}`, diff --git a/src/lib/commands/manual-mutation-review.ts b/src/lib/commands/manual-mutation-review.ts new file mode 100644 index 0000000..b6f4ef6 --- /dev/null +++ b/src/lib/commands/manual-mutation-review.ts @@ -0,0 +1,182 @@ +import { buildMemoryRef } from "../domain/memory-lifecycle.js"; +import type { MemoryStore } from "../domain/memory-store.js"; +import type { + MemoryApplyRecord, + MemoryDetailsResult, + MemoryEntry, + MemoryHistoryRecordState, + MemoryLifecycleAttempt, + MemoryLifecycleAction, + MemoryLineageSummary, + MemoryScope, + MemorySyncAuditSummary +} from "../types.js"; + +export interface ManualMutationReviewEntry { + ref: string; + scope: MemoryScope; + state: "active" | "archived"; + topic: string; + id: string; + path: string | null; + historyPath: string; + lifecycleAction: MemoryLifecycleAction; + latestLifecycleAction: Exclude | null; + latestLifecycleAttempt: MemoryLifecycleAttempt | null; + latestState: MemoryHistoryRecordState; + latestSessionId: string | null; + latestRolloutPath: string | null; + latestAudit: MemorySyncAuditSummary | null; + timelineWarningCount: number; + lineageSummary: MemoryLineageSummary; + warnings: string[]; + entry: MemoryEntry; +} + +function resolveReviewState(record: MemoryApplyRecord): "active" | "archived" { + if (record.nextState === "active" || record.nextState === "archived") { + return record.nextState; + } + + if (record.previousState === "archived") { + return "archived"; + } + + return "active"; +} + +function buildFallbackPath( + store: MemoryStore, + scope: MemoryScope, + state: "active" | "archived", + topic: string +): string { + return state === "active" + ? store.getTopicFile(scope, topic) + : store.getArchiveTopicFile(scope, topic); +} + +function buildFallbackDetails( + store: MemoryStore, + record: MemoryApplyRecord, + ref: string, + state: "active" | "archived" +): Promise { + const { operation } = record; + return store.readTimelineWithDiagnostics(ref).then((timeline) => ({ + ref, + scope: operation.scope, + state, + topic: operation.topic, + id: operation.id, + path: null, + historyPath: store.getHistoryPath(operation.scope), + lifecycleAction: record.lifecycleAction, + latestLifecycleAction: + timeline.latestEvent && timeline.latestEvent.action !== "noop" + ? timeline.latestEvent.action + : null, + latestLifecycleAttempt: timeline.latestLifecycleAttempt, + latestState: timeline.lineageSummary.latestState ?? (record.nextState ?? state), + latestSessionId: timeline.latestAttempt?.sessionId ?? null, + latestRolloutPath: timeline.latestAttempt?.rolloutPath ?? null, + latestAudit: timeline.latestAudit, + timelineWarningCount: timeline.warnings.length, + lineageSummary: timeline.lineageSummary, + warnings: [...timeline.warnings], + entry: { + id: operation.id, + scope: operation.scope, + topic: operation.topic, + summary: operation.summary ?? operation.id, + details: + operation.details?.length && operation.details.length > 0 + ? operation.details + : [operation.summary ?? operation.id], + updatedAt: timeline.latestAttempt?.at ?? new Date(0).toISOString(), + sources: operation.sources ?? [], + reason: operation.reason + } + })); +} + +export async function buildManualMutationReviewEntry( + store: MemoryStore, + record: MemoryApplyRecord +): Promise { + const { operation } = record; + const state = resolveReviewState(record); + const ref = buildMemoryRef(operation.scope, state, operation.topic, operation.id); + const details = await store.getEntryByRef(ref); + if (details) { + return { + ref, + scope: details.scope, + state: details.state, + topic: details.topic, + id: details.id, + path: details.path, + historyPath: details.historyPath, + lifecycleAction: record.lifecycleAction, + latestLifecycleAction: details.latestLifecycleAction, + latestLifecycleAttempt: details.latestLifecycleAttempt, + latestState: details.latestState, + latestSessionId: details.latestLifecycleAttempt?.sessionId ?? details.latestSessionId, + latestRolloutPath: details.latestLifecycleAttempt?.rolloutPath ?? details.latestRolloutPath, + latestAudit: details.latestAudit, + timelineWarningCount: details.timelineWarningCount, + lineageSummary: details.lineageSummary, + warnings: [...details.warnings], + entry: details.entry + }; + } + + const fallback = await buildFallbackDetails(store, record, ref, state); + return { + ...fallback, + path: buildFallbackPath(store, operation.scope, state, operation.topic) + }; +} + +export function toManualMutationRememberPayload( + text: string, + entry: ManualMutationReviewEntry +): Record { + return { + action: "remember", + text, + scope: entry.scope, + topic: entry.topic, + id: entry.id, + ref: entry.ref, + path: entry.path, + historyPath: entry.historyPath, + lifecycleAction: entry.lifecycleAction, + latestLifecycleAction: entry.latestLifecycleAction, + latestLifecycleAttempt: entry.latestLifecycleAttempt, + latestState: entry.latestState, + latestSessionId: entry.latestSessionId, + latestRolloutPath: entry.latestRolloutPath, + latestAudit: entry.latestAudit, + timelineWarningCount: entry.timelineWarningCount, + lineageSummary: entry.lineageSummary, + warnings: entry.warnings, + entry: entry.entry + }; +} + +export function toManualMutationForgetPayload( + query: string, + scope: MemoryScope | "all", + archive: boolean, + entries: ManualMutationReviewEntry[] +): Record { + return { + action: "forget", + query, + scope, + archive, + affectedCount: entries.length, + entries + }; +} diff --git a/src/lib/commands/recall.ts b/src/lib/commands/recall.ts index 1021369..71b1736 100644 --- a/src/lib/commands/recall.ts +++ b/src/lib/commands/recall.ts @@ -31,7 +31,7 @@ function formatSearchResults(response: MemorySearchResponse): string { : response.diagnostics.checkedPaths .map( (check) => - `${check.scope}/${check.state}=${check.retrievalMode}${check.retrievalFallbackReason ? `(${check.retrievalFallbackReason})` : ""}:${check.matchedCount}` + `${check.scope}/${check.state}=${check.retrievalMode}${check.retrievalFallbackReason ? `(${check.retrievalFallbackReason})` : ""}:${check.matchedCount}/${check.returnedCount}` ) .join("; "); const lines = [ @@ -39,8 +39,10 @@ function formatSearchResults(response: MemorySearchResponse): string { `Query: ${response.query}`, `Scope: ${response.scope} | Requested state: ${response.state} | Resolved state: ${response.resolvedState} | Results: ${response.results.length}`, `Archived fallback used: ${response.fallbackUsed ? "yes" : "no"}`, + `State resolution: ${response.stateResolution.outcome} (${response.stateResolution.resolutionReason}) [${response.stateResolution.searchedStates.join(" -> ")}]`, `Markdown fallback used: ${response.markdownFallbackUsed ? "yes" : "no"}`, `Retrieval mode: ${response.retrievalMode}${response.retrievalFallbackReason ? ` (${response.retrievalFallbackReason})` : ""}`, + `Execution summary: ${response.executionSummary.mode} [${response.executionSummary.retrievalModes.join(", ")}]${response.executionSummary.fallbackReasons.length > 0 ? ` fallback=${response.executionSummary.fallbackReasons.join(",")}` : ""}`, `Diagnostics: ${diagnosticsSummary}` ]; @@ -82,16 +84,30 @@ function formatTimeline(timeline: MemoryTimelineResponse): string { "Lineage:", `- Latest action: ${timeline.lineageSummary.latestAction ?? "unknown"}`, `- Latest state: ${timeline.lineageSummary.latestState ?? "unknown"}`, + `- Latest attempted action: ${timeline.lineageSummary.latestAttemptedAction ?? "unknown"}`, + `- Latest attempted outcome: ${timeline.lineageSummary.latestAttemptedOutcome ?? "unknown"}`, + `- Latest update kind: ${timeline.lineageSummary.latestUpdateKind ?? "n/a"}`, `- Latest audit status: ${timeline.lineageSummary.latestAuditStatus ?? "unknown"}`, `- First seen: ${timeline.lineageSummary.firstSeenAt ?? "unknown"}`, `- Latest event at: ${timeline.lineageSummary.latestAt ?? "unknown"}`, `- Archived at: ${timeline.lineageSummary.archivedAt ?? "n/a"}`, `- Deleted at: ${timeline.lineageSummary.deletedAt ?? "n/a"}`, - `- No-op count: ${timeline.lineageSummary.noopOperationCount}`, - `- Suppressed count: ${timeline.lineageSummary.suppressedOperationCount}`, - `- Conflict count: ${timeline.lineageSummary.conflictCount}` + `- Ref no-op count: ${timeline.lineageSummary.refNoopCount}`, + `- Matched audit operations: ${timeline.lineageSummary.matchedAuditOperationCount}`, + `- Rollout no-op count: ${timeline.lineageSummary.rolloutNoopOperationCount}`, + `- Rollout suppressed count: ${timeline.lineageSummary.rolloutSuppressedOperationCount}`, + `- Rollout conflict count: ${timeline.lineageSummary.rolloutConflictCount}` ); + if (timeline.latestLifecycleAttempt) { + lines.push( + "", + "Latest attempt:", + `- ${timeline.latestLifecycleAttempt.at}: [${timeline.latestLifecycleAttempt.action}] ${timeline.latestLifecycleAttempt.summary}`, + `- Outcome: ${timeline.latestLifecycleAttempt.outcome} | State: ${timeline.latestLifecycleAttempt.state ?? "unknown"} | Previous: ${timeline.latestLifecycleAttempt.previousState ?? "n/a"} | Next: ${timeline.latestLifecycleAttempt.nextState ?? "n/a"} | Update kind: ${timeline.latestLifecycleAttempt.updateKind ?? "n/a"}` + ); + } + if (timeline.events.length === 0) { lines.push("", "No timeline events were recorded for this memory ref."); return lines.join("\n"); @@ -145,7 +161,8 @@ function formatDetails(details: MemoryDetailsResult): string { lines.push( `Latest audit: ${details.latestAudit.status} at ${details.latestAudit.appliedAt}`, `Latest audit path: ${details.latestAudit.auditPath}`, - `Latest audit summary: ${details.latestAudit.resultSummary}` + `Latest audit summary: ${details.latestAudit.resultSummary}`, + `Latest audit matched operations for this ref: ${details.latestAudit.matchedOperationCount}` ); } @@ -153,17 +170,30 @@ function formatDetails(details: MemoryDetailsResult): string { "Lineage:", `- Latest action: ${details.lineageSummary.latestAction ?? "unknown"}`, `- Latest state: ${details.lineageSummary.latestState ?? details.latestState}`, + `- Latest attempted action: ${details.lineageSummary.latestAttemptedAction ?? "unknown"}`, + `- Latest attempted outcome: ${details.lineageSummary.latestAttemptedOutcome ?? "unknown"}`, + `- Latest update kind: ${details.lineageSummary.latestUpdateKind ?? "n/a"}`, `- Latest audit status: ${details.lineageSummary.latestAuditStatus ?? "unknown"}`, `- First seen: ${details.lineageSummary.firstSeenAt ?? "unknown"}`, `- Latest event at: ${details.lineageSummary.latestAt ?? "unknown"}`, `- Archived at: ${details.lineageSummary.archivedAt ?? "n/a"}`, `- Deleted at: ${details.lineageSummary.deletedAt ?? "n/a"}`, - `- No-op count: ${details.lineageSummary.noopOperationCount}`, - `- Suppressed count: ${details.lineageSummary.suppressedOperationCount}`, - `- Conflict count: ${details.lineageSummary.conflictCount}`, + `- Ref no-op count: ${details.lineageSummary.refNoopCount}`, + `- Matched audit operations: ${details.lineageSummary.matchedAuditOperationCount}`, + `- Rollout no-op count: ${details.lineageSummary.rolloutNoopOperationCount}`, + `- Rollout suppressed count: ${details.lineageSummary.rolloutSuppressedOperationCount}`, + `- Rollout conflict count: ${details.lineageSummary.rolloutConflictCount}`, `- Timeline warning count: ${details.timelineWarningCount}` ); + if (details.latestLifecycleAttempt) { + lines.push( + "Latest attempt:", + `- ${details.latestLifecycleAttempt.at}: [${details.latestLifecycleAttempt.action}] ${details.latestLifecycleAttempt.summary}`, + `- Outcome: ${details.latestLifecycleAttempt.outcome} | State: ${details.latestLifecycleAttempt.state ?? "unknown"} | Previous: ${details.latestLifecycleAttempt.previousState ?? "n/a"} | Next: ${details.latestLifecycleAttempt.nextState ?? "n/a"} | Update kind: ${details.latestLifecycleAttempt.updateKind ?? "n/a"}` + ); + } + if (details.warnings.length > 0) { lines.push("Warnings:", ...details.warnings.map((warning) => `- ${warning}`)); } diff --git a/src/lib/commands/remember.ts b/src/lib/commands/remember.ts index 009cf08..c509ac4 100644 --- a/src/lib/commands/remember.ts +++ b/src/lib/commands/remember.ts @@ -1,12 +1,17 @@ import { slugify } from "../util/text.js"; import type { MemoryScope } from "../types.js"; import { buildRuntimeContext } from "../runtime/runtime-context.js"; +import { + buildManualMutationReviewEntry, + toManualMutationRememberPayload +} from "./manual-mutation-review.js"; interface RememberOptions { cwd?: string; scope?: MemoryScope; topic?: string; detail?: string[]; + json?: boolean; } export async function runRemember( @@ -28,6 +33,14 @@ export async function runRemember( "Manual remember request." ); + if (options.json) { + if (!record) { + throw new Error("Remember command did not produce a mutation record."); + } + const reviewEntry = await buildManualMutationReviewEntry(runtime.syncService.memoryStore, record); + return JSON.stringify(toManualMutationRememberPayload(text, reviewEntry), null, 2); + } + if (record?.lifecycleAction === "noop") { return `Memory ${scope}/${topic}/${id} is already up to date.`; } diff --git a/src/lib/domain/memory-lifecycle.ts b/src/lib/domain/memory-lifecycle.ts index 7a16305..4a513b6 100644 --- a/src/lib/domain/memory-lifecycle.ts +++ b/src/lib/domain/memory-lifecycle.ts @@ -2,6 +2,7 @@ import type { MemoryEntry, MemoryHistoryRecordState, MemoryLifecycleAction, + MemoryLifecycleUpdateKind, MemoryRecordState, MemoryRef, MemoryScope @@ -80,16 +81,51 @@ export function areEquivalentMemoryEntries(left: MemoryEntry, right: MemoryEntry ); } +function hasSemanticMemoryEntryDiff(left: MemoryEntry, right: MemoryEntry): boolean { + return ( + normalizeString(left.summary) !== normalizeString(right.summary) || + JSON.stringify(normalizeStringArray(left.details)) !== + JSON.stringify(normalizeStringArray(right.details)) + ); +} + +function hasMetadataMemoryEntryDiff(left: MemoryEntry, right: MemoryEntry): boolean { + return ( + JSON.stringify(normalizeStringArray(left.sources)) !== + JSON.stringify(normalizeStringArray(right.sources)) || + normalizeString(left.reason) !== normalizeString(right.reason) + ); +} + +export function classifyUpdateKind( + existingActive: MemoryEntry, + nextEntry: MemoryEntry +): Extract { + if (hasSemanticMemoryEntryDiff(existingActive, nextEntry)) { + return "semantic-overwrite"; + } + + if (hasMetadataMemoryEntryDiff(existingActive, nextEntry)) { + return "metadata-only"; + } + + return "semantic-overwrite"; +} + export function classifyUpsertLifecycle( existingActive: MemoryEntry | null, existingArchived: MemoryEntry | null, nextEntry: MemoryEntry -): Extract { +): Extract { if (existingActive && areEquivalentMemoryEntries(existingActive, nextEntry)) { return "noop"; } - return existingActive || existingArchived ? "update" : "add"; + if (existingArchived && !existingActive) { + return "restore"; + } + + return existingActive ? "update" : "add"; } export function nextHistoryStateForLifecycle( @@ -98,6 +134,7 @@ export function nextHistoryStateForLifecycle( switch (action) { case "add": case "update": + case "restore": return "active"; case "archive": return "archived"; diff --git a/src/lib/domain/memory-retrieval-contract.ts b/src/lib/domain/memory-retrieval-contract.ts index a059067..32ec1d2 100644 --- a/src/lib/domain/memory-retrieval-contract.ts +++ b/src/lib/domain/memory-retrieval-contract.ts @@ -2,12 +2,14 @@ import type { MemoryDetailsResult, MemorySearchDiagnosticPath, MemorySearchDiagnostics, + MemorySearchExecutionSummary, MemoryRetrievalFallbackReason, MemoryRetrievalMode, MemoryRecordState, MemoryRetrievalResolvedState, MemoryRetrievalScope, MemoryRetrievalStateFilter, + MemorySearchStateResolution, MemoryScope, MemorySearchResponse, MemorySearchResult, @@ -74,9 +76,13 @@ export function buildMemorySearchResponse( scope: MemoryRetrievalScope, state: MemoryRetrievalStateFilter, resolvedState: MemoryRetrievalResolvedState, + searchOrder: string[], + globalLimitApplied: boolean, + truncatedCount: number, fallbackUsed: boolean, retrievalMode: MemoryRetrievalMode, retrievalFallbackReason: MemoryRetrievalFallbackReason | undefined, + stateResolution: MemorySearchStateResolution, diagnostics: MemorySearchDiagnostics, results: MemorySearchResult[] ): MemorySearchResponse { @@ -86,11 +92,16 @@ export function buildMemorySearchResponse( scope, state, resolvedState, + searchOrder: [...searchOrder], + globalLimitApplied, + truncatedCount, fallbackUsed, stateFallbackUsed: fallbackUsed, markdownFallbackUsed: normalizedDiagnostics.anyMarkdownFallback, retrievalMode, retrievalFallbackReason, + stateResolution, + executionSummary: buildMemorySearchExecutionSummary(normalizedDiagnostics), diagnostics: normalizedDiagnostics, results }; @@ -111,11 +122,28 @@ export function normalizeMemorySearchDiagnostics( anyMarkdownFallback: checkedPaths.some( (check) => check.retrievalMode === "markdown-fallback" ), - fallbackReasons, + fallbackReasons, + executionModes: Array.from(new Set(checkedPaths.map((check) => check.retrievalMode))), checkedPaths }; } +export function buildMemorySearchExecutionSummary( + diagnostics: MemorySearchDiagnostics +): MemorySearchExecutionSummary { + const retrievalModes = [...diagnostics.executionModes]; + return { + mode: + retrievalModes.length <= 1 + ? retrievalModes[0] === "markdown-fallback" + ? "markdown-fallback-only" + : "index-only" + : "mixed", + retrievalModes, + fallbackReasons: [...diagnostics.fallbackReasons] + }; +} + export function buildMemoryTimelineResponse( ref: string, timeline: @@ -130,6 +158,10 @@ export function buildMemoryTimelineResponse( ref, events: [...timeline.events], warnings: [...(timeline.warnings ?? [])], + latestLifecycleAttempt: + "latestLifecycleAttempt" in timeline && timeline.latestLifecycleAttempt + ? { ...timeline.latestLifecycleAttempt } + : null, lineageSummary: timeline.lineageSummary !== undefined ? { ...timeline.lineageSummary } @@ -139,9 +171,18 @@ export function buildMemoryTimelineResponse( latestAt: null, latestAction: null, latestState: null, + latestAttemptedAction: null, + latestAttemptedState: null, + latestAttemptedOutcome: null, + latestUpdateKind: null, archivedAt: null, deletedAt: null, latestAuditStatus: null, + refNoopCount: 0, + matchedAuditOperationCount: 0, + rolloutNoopOperationCount: 0, + rolloutSuppressedOperationCount: 0, + rolloutConflictCount: 0, noopOperationCount: 0, suppressedOperationCount: 0, conflictCount: 0 @@ -208,6 +249,9 @@ export function toMemoryDetailsResultShape(details: MemoryDetailsResult): Memory lineageSummary: { ...details.lineageSummary }, + latestLifecycleAttempt: details.latestLifecycleAttempt + ? { ...details.latestLifecycleAttempt } + : null, warnings: [...details.warnings], latestAudit: details.latestAudit ? { diff --git a/src/lib/domain/memory-retrieval.ts b/src/lib/domain/memory-retrieval.ts index a388feb..0b108eb 100644 --- a/src/lib/domain/memory-retrieval.ts +++ b/src/lib/domain/memory-retrieval.ts @@ -41,9 +41,17 @@ export class MemoryRetrievalService { scope, state, "active", + activeSearch.searchOrder, + activeSearch.globalLimitApplied, + activeSearch.truncatedCount, false, activeSearch.retrievalMode, activeSearch.retrievalFallbackReason, + { + outcome: "active-hit", + searchedStates: ["active"], + resolutionReason: "active-match-found" + }, activeSearch.diagnostics, activeSearch.results ); @@ -60,9 +68,20 @@ export class MemoryRetrievalService { scope, state, "archived", + [...activeSearch.searchOrder, ...archivedSearch.searchOrder], + activeSearch.globalLimitApplied || archivedSearch.globalLimitApplied, + activeSearch.truncatedCount + archivedSearch.truncatedCount, true, archivedSearch.retrievalMode, archivedSearch.retrievalFallbackReason, + { + outcome: archivedSearch.results.length > 0 ? "archived-hit" : "miss-after-both", + searchedStates: ["active", "archived"], + resolutionReason: + archivedSearch.results.length > 0 + ? "active-empty-archived-match-found" + : "no-match-after-auto-search" + }, normalizeMemorySearchDiagnostics([ ...activeSearch.diagnostics.checkedPaths, ...archivedSearch.diagnostics.checkedPaths @@ -82,9 +101,18 @@ export class MemoryRetrievalService { scope, state, state, + search.searchOrder, + search.globalLimitApplied, + search.truncatedCount, false, search.retrievalMode, search.retrievalFallbackReason, + { + outcome: "explicit-state", + searchedStates: state === "all" ? ["active", "archived"] : [state], + resolutionReason: + state === "all" ? "explicit-all-state-requested" : `explicit-${state}-state-requested` + }, search.diagnostics, search.results ); diff --git a/src/lib/domain/memory-store.ts b/src/lib/domain/memory-store.ts index dcefee9..609b91e 100644 --- a/src/lib/domain/memory-store.ts +++ b/src/lib/domain/memory-store.ts @@ -6,6 +6,7 @@ import type { MemoryApplyRecord, MemoryDetailsResult, MemoryEntry, + MemoryLifecycleAttempt, MemoryHistoryRecordState, MemoryLineageSummary, MemoryMutation, @@ -41,6 +42,7 @@ import { import { parseMemorySyncAuditEntry } from "./memory-sync-audit.js"; import { buildMemoryRef, + classifyUpdateKind, classifyUpsertLifecycle, isMemoryHistoryRecordState, nextHistoryStateForLifecycle, @@ -109,6 +111,9 @@ interface RetrievalIndexInspection { interface MemorySearchExecution { results: MemorySearchResult[]; + searchOrder: string[]; + globalLimitApplied: boolean; + truncatedCount: number; retrievalMode: MemoryRetrievalMode; retrievalFallbackReason?: MemoryRetrievalFallbackReason; diagnostics: MemorySearchDiagnostics; @@ -134,6 +139,7 @@ interface HistoryReadResult { interface TimelineReadResult extends MemoryTimelineResponse { latestAudit: MemorySyncAuditSummary | null; latestEvent: MemoryTimelineEvent | null; + latestAttempt: MemoryTimelineEvent | null; } interface PlannedFileChange { @@ -171,6 +177,10 @@ interface MemoryStoreFileOps { const topicNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; const retrievalIndexVersion = 1 as const; +function buildSearchDiagnosticKey(scope: MemoryScope, state: MemoryRecordState): string { + return `${scope}:${state}`; +} + function topicTitle(topic: string): string { return topic .split(/[-_]/g) @@ -431,9 +441,18 @@ function buildEmptyLineageSummary(): MemoryLineageSummary { latestAt: null, latestAction: null, latestState: null, + latestAttemptedAction: null, + latestAttemptedState: null, + latestAttemptedOutcome: null, + latestUpdateKind: null, archivedAt: null, deletedAt: null, latestAuditStatus: null, + refNoopCount: 0, + matchedAuditOperationCount: 0, + rolloutNoopOperationCount: 0, + rolloutSuppressedOperationCount: 0, + rolloutConflictCount: 0, noopOperationCount: 0, suppressedOperationCount: 0, conflictCount: 0 @@ -442,40 +461,83 @@ function buildEmptyLineageSummary(): MemoryLineageSummary { function buildLineageSummary( events: MemoryTimelineEvent[], - latestAudit: MemorySyncAuditSummary | null + latestAudit: MemorySyncAuditSummary | null, + latestAttempt: MemoryTimelineEvent | null ): MemoryLineageSummary { + const refNoopCount = events.filter((event) => event.action === "noop").length; + const visibleEvents = events.filter((event) => event.action !== "noop"); if (events.length === 0) { return { ...buildEmptyLineageSummary(), + latestAttemptedAction: latestAttempt?.action ?? null, + latestAttemptedState: latestAttempt?.state ?? null, + latestAttemptedOutcome: latestAttempt?.outcome ?? null, + latestUpdateKind: latestAttempt?.updateKind ?? null, latestAuditStatus: latestAudit?.status ?? null, + refNoopCount, + matchedAuditOperationCount: latestAudit?.matchedOperationCount ?? 0, + rolloutNoopOperationCount: latestAudit?.noopOperationCount ?? 0, + rolloutSuppressedOperationCount: latestAudit?.suppressedOperationCount ?? 0, + rolloutConflictCount: latestAudit?.conflicts.length ?? 0, noopOperationCount: latestAudit?.noopOperationCount ?? 0, suppressedOperationCount: latestAudit?.suppressedOperationCount ?? 0, conflictCount: latestAudit?.conflicts.length ?? 0 }; } - const chronologicalEvents = [...events].sort((left, right) => left.at.localeCompare(right.at)); - const latestEvent = events[0] ?? null; + const chronologicalEvents = [...visibleEvents].sort((left, right) => left.at.localeCompare(right.at)); + const latestEvent = visibleEvents[0] ?? null; const archivedEvent = chronologicalEvents.find((event) => event.action === "archive") ?? null; const deletedEvent = chronologicalEvents.find((event) => event.action === "delete") ?? null; return { - eventCount: events.length, + eventCount: visibleEvents.length, firstSeenAt: chronologicalEvents[0]?.at ?? null, latestAt: latestEvent?.at ?? null, - latestAction: latestEvent?.action ?? null, + latestAction: + latestEvent && latestEvent.action !== "noop" ? latestEvent.action : null, latestState: latestEvent?.state ?? null, + latestAttemptedAction: latestAttempt?.action ?? null, + latestAttemptedState: latestAttempt?.state ?? null, + latestAttemptedOutcome: latestAttempt?.outcome ?? null, + latestUpdateKind: latestAttempt?.updateKind ?? null, archivedAt: archivedEvent?.at ?? null, deletedAt: deletedEvent?.at ?? null, latestAuditStatus: latestAudit?.status ?? null, + refNoopCount, + matchedAuditOperationCount: latestAudit?.matchedOperationCount ?? 0, + rolloutNoopOperationCount: latestAudit?.noopOperationCount ?? 0, + rolloutSuppressedOperationCount: latestAudit?.suppressedOperationCount ?? 0, + rolloutConflictCount: latestAudit?.conflicts.length ?? 0, noopOperationCount: latestAudit?.noopOperationCount ?? 0, suppressedOperationCount: latestAudit?.suppressedOperationCount ?? 0, conflictCount: latestAudit?.conflicts.length ?? 0 }; } +function buildLatestLifecycleAttempt( + event: MemoryTimelineEvent | null +): MemoryLifecycleAttempt | null { + if (!event) { + return null; + } + + return { + at: event.at, + action: event.action, + outcome: event.outcome ?? (event.action === "noop" ? "noop" : "applied"), + state: event.state, + previousState: event.previousState ?? null, + nextState: event.nextState ?? null, + summary: event.summary, + updateKind: event.updateKind ?? null, + sessionId: event.sessionId ?? null, + rolloutPath: event.rolloutPath ?? null + }; +} + function buildHistoryWarnings( historyPath: string, invalidJsonLineCount: number, @@ -560,14 +622,24 @@ function isTimelineEvent(value: unknown): value is MemoryTimelineEvent { typeof event.at === "string" && (event.action === "add" || event.action === "update" || + event.action === "restore" || event.action === "delete" || - event.action === "archive") && + event.action === "archive" || + event.action === "noop") && isMemoryScope(event.scope) && isMemoryHistoryRecordState(event.state) && typeof event.topic === "string" && typeof event.id === "string" && typeof event.summary === "string" && (event.ref === undefined || typeof event.ref === "string") && + (event.outcome === undefined || event.outcome === "applied" || event.outcome === "noop") && + (event.previousState === undefined || isMemoryHistoryRecordState(event.previousState)) && + (event.nextState === undefined || isMemoryHistoryRecordState(event.nextState)) && + (event.updateKind === undefined || + event.updateKind === "overwrite" || + event.updateKind === "semantic-overwrite" || + event.updateKind === "metadata-only" || + event.updateKind === "restore") && (event.reason === undefined || typeof event.reason === "string") && (event.source === undefined || typeof event.source === "string") && (event.sessionId === undefined || typeof event.sessionId === "string") && @@ -1284,9 +1356,10 @@ export class MemoryStore { const timeline = await this.readTimelineWithDiagnostics(ref); const latestEvent = timeline.latestEvent; + const latestAttempt = timeline.latestAttempt; const latestAudit = timeline.latestAudit; const warnings = [...timeline.warnings]; - if (latestEvent && !latestAudit && (latestEvent.rolloutPath || latestEvent.sessionId)) { + if (latestAttempt && !latestAudit && (latestAttempt.rolloutPath || latestAttempt.sessionId)) { warnings.push( `Lifecycle history exists for ${ref}, but no matching sync audit entry was found in ${this.getSyncAuditPath()}.` ); @@ -1294,19 +1367,25 @@ export class MemoryStore { if ((latestAudit?.noopOperationCount ?? 0) > 0) { warnings.push( - `Latest sync audit recorded ${latestAudit?.noopOperationCount ?? 0} no-op operation(s).` + `Latest sync audit recorded ${latestAudit?.noopOperationCount ?? 0} rollout-level no-op operation(s) across the whole sync.` ); } if ((latestAudit?.suppressedOperationCount ?? 0) > 0) { warnings.push( - `Latest sync audit suppressed ${latestAudit?.suppressedOperationCount ?? 0} operation(s).` + `Latest sync audit suppressed ${latestAudit?.suppressedOperationCount ?? 0} rollout-level operation(s) across the whole sync.` ); } if ((latestAudit?.conflicts.length ?? 0) > 0) { warnings.push( - `Latest sync audit includes ${latestAudit?.conflicts.length ?? 0} suppressed conflict candidate(s).` + `Latest sync audit includes ${latestAudit?.conflicts.length ?? 0} rollout-level suppressed conflict candidate(s).` + ); + } + + if (timeline.lineageSummary.refNoopCount > 0) { + warnings.push( + `Lifecycle history recorded ${timeline.lineageSummary.refNoopCount} ref-local no-op attempt(s) for ${ref}.` ); } @@ -1318,10 +1397,12 @@ export class MemoryStore { ? this.getTopicFile(parsed.scope, parsed.topic) : this.getArchiveTopicFile(parsed.scope, parsed.topic), approxReadCost: entry.details.length + 4, - latestLifecycleAction: latestEvent?.action ?? null, + latestLifecycleAction: + latestEvent && latestEvent.action !== "noop" ? latestEvent.action : null, + latestLifecycleAttempt: buildLatestLifecycleAttempt(latestAttempt), latestState: latestEvent?.state ?? parsed.state, - latestSessionId: latestEvent?.sessionId ?? null, - latestRolloutPath: latestEvent?.rolloutPath ?? null, + latestSessionId: latestAttempt?.sessionId ?? latestEvent?.sessionId ?? null, + latestRolloutPath: latestAttempt?.rolloutPath ?? latestEvent?.rolloutPath ?? null, historyPath: this.getHistoryPath(parsed.scope), latestAudit, timelineWarningCount: timeline.warnings.length, @@ -1387,6 +1468,8 @@ export class MemoryStore { state, retrievalMode: "index", matchedCount, + returnedCount: 0, + droppedCount: 0, indexPath: retrievalIndex.indexPath, generatedAt: retrievalIndex.generatedAt }); @@ -1423,12 +1506,15 @@ export class MemoryStore { retrievalMode: "markdown-fallback", retrievalFallbackReason: retrievalIndex.fallbackReason ?? "missing", matchedCount, + returnedCount: 0, + droppedCount: 0, indexPath: retrievalIndex.indexPath, generatedAt: retrievalIndex.generatedAt }); } } + const totalMatchedCount = results.length; const normalizedResults = results .sort((left, right) => { if (right.score !== left.score) { @@ -1438,16 +1524,30 @@ export class MemoryStore { }) .slice(0, options.limit ?? 10) .map(({ score: _score, ...result }) => result); + const returnedCountByPath = new Map(); + for (const result of normalizedResults) { + const key = buildSearchDiagnosticKey(result.scope, result.state); + returnedCountByPath.set(key, (returnedCountByPath.get(key) ?? 0) + 1); + } + const diagnosticsWithReturnedCounts = diagnostics.map((check) => ({ + ...check, + returnedCount: returnedCountByPath.get(buildSearchDiagnosticKey(check.scope, check.state)) ?? 0, + droppedCount: + check.matchedCount - (returnedCountByPath.get(buildSearchDiagnosticKey(check.scope, check.state)) ?? 0) + })); return { results: normalizedResults, + searchOrder: diagnostics.map((check) => buildSearchDiagnosticKey(check.scope, check.state)), + globalLimitApplied: normalizedResults.length < totalMatchedCount, + truncatedCount: Math.max(0, totalMatchedCount - normalizedResults.length), retrievalMode: matchedViaFallback || (!matchedViaIndex && usedFallback) ? "markdown-fallback" : "index", retrievalFallbackReason: matchedViaFallback || (!matchedViaIndex && usedFallback) ? fallbackReason : undefined, - diagnostics: normalizeMemorySearchDiagnostics(diagnostics) + diagnostics: normalizeMemorySearchDiagnostics(diagnosticsWithReturnedCounts) }; } @@ -1475,28 +1575,32 @@ export class MemoryStore { warnings: [], lineageSummary: buildEmptyLineageSummary(), latestAudit: null, - latestEvent: null + latestEvent: null, + latestAttempt: null, + latestLifecycleAttempt: null }; } const history = await this.readHistoryWithDiagnostics(parsed.scope); - const events = history.events + const matchingEvents = history.events .filter((entry) => entry.id === parsed.id && entry.topic === parsed.topic) .sort((left, right) => right.at.localeCompare(left.at)); + const events = matchingEvents.filter((event) => event.action !== "noop"); const latestEvent = events[0] ?? null; + const latestAttempt = matchingEvents[0] ?? null; const latestEventHasProvenance = Boolean(latestEvent?.rolloutPath || latestEvent?.sessionId); - const olderEventHasProvenance = events + const olderEventHasProvenance = matchingEvents .slice(1) .some((event) => Boolean(event.rolloutPath || event.sessionId)); const latestAudit = await this.findLatestSyncAuditSummary( parsed.scope, parsed.topic, parsed.id, - latestEvent?.rolloutPath, - latestEvent?.sessionId + latestAttempt?.rolloutPath, + latestAttempt?.sessionId ); const warnings = [...history.warnings]; - if (latestEvent && !latestAudit && latestEventHasProvenance) { + if (latestAttempt && !latestAudit && (latestAttempt.rolloutPath || latestAttempt.sessionId)) { warnings.push( `Lifecycle history exists for ${ref}, but no matching sync audit entry was found in ${this.getSyncAuditPath()}.` ); @@ -1511,9 +1615,11 @@ export class MemoryStore { ref, events, warnings, - lineageSummary: buildLineageSummary(events, latestAudit), + lineageSummary: buildLineageSummary(matchingEvents, latestAudit, latestAttempt), latestAudit, - latestEvent + latestEvent, + latestAttempt, + latestLifecycleAttempt: buildLatestLifecycleAttempt(latestAttempt) }; } @@ -1661,11 +1767,34 @@ export class MemoryStore { }; if (lifecycleAction === "noop") { + const noopState = existingActive ? "active" : existingArchived ? "archived" : "deleted"; applied.push({ operation: appliedOperation, lifecycleAction, - previousState: "active", - nextState: "active" + previousState: noopState === "deleted" ? undefined : noopState, + nextState: noopState + }); + scopeState.historyAppends.push({ + at: updatedAt, + action: "noop", + outcome: "noop", + previousState: noopState === "deleted" ? undefined : noopState, + nextState: noopState, + scope: mutation.scope, + state: noopState, + topic, + id: mutation.id, + ref: + noopState === "deleted" + ? undefined + : buildMemoryRef(mutation.scope, noopState, topic, mutation.id), + summary: entry.summary, + reason: mutation.reason, + source: mutation.sources?.[0], + sessionId: options.sessionId, + rolloutPath: + options.rolloutPath ?? + mutation.sources?.find((source) => source.endsWith(".jsonl")) }); continue; } @@ -1693,6 +1822,15 @@ export class MemoryStore { scopeState.historyAppends.push({ at: updatedAt, action: lifecycleAction, + outcome: "applied", + previousState: existingActive ? "active" : existingArchived ? "archived" : undefined, + nextState: nextHistoryStateForLifecycle(lifecycleAction), + updateKind: + lifecycleAction === "restore" + ? "restore" + : existingActive + ? classifyUpdateKind(existingActive, entry) + : undefined, scope: mutation.scope, state: "active", topic, @@ -1723,6 +1861,27 @@ export class MemoryStore { previousState: existingArchived ? "archived" : undefined, nextState: existingArchived ? "archived" : undefined }); + if (existingArchived) { + scopeState.historyAppends.push({ + at: new Date().toISOString(), + action: "noop", + outcome: "noop", + previousState: "archived", + nextState: "archived", + scope: mutation.scope, + state: "archived", + topic, + id: mutation.id, + ref: buildMemoryRef(mutation.scope, "archived", topic, mutation.id), + summary: existingArchived.summary, + reason: mutation.reason ?? existingArchived.reason, + source: (mutation.sources ?? existingArchived.sources)?.[0], + sessionId: options.sessionId, + rolloutPath: + options.rolloutPath ?? + (mutation.sources ?? existingArchived.sources)?.find((source) => source.endsWith(".jsonl")) + }); + } continue; } @@ -1768,6 +1927,9 @@ export class MemoryStore { scopeState.historyAppends.push({ at: archivedAt, action: "archive", + outcome: "applied", + previousState: "active", + nextState: "archived", scope: mutation.scope, state: "archived", topic, @@ -1799,6 +1961,25 @@ export class MemoryStore { previousState: "active", nextState: "active" }); + scopeState.historyAppends.push({ + at: new Date().toISOString(), + action: "noop", + outcome: "noop", + previousState: "active", + nextState: "active", + scope: mutation.scope, + state: "active", + topic, + id: mutation.id, + ref: buildMemoryRef(mutation.scope, "active", topic, mutation.id), + summary: existingActive.summary, + reason: mutation.reason ?? existingActive.reason, + source: (mutation.sources ?? existingActive.sources)?.[0], + sessionId: options.sessionId, + rolloutPath: + options.rolloutPath ?? + (mutation.sources ?? existingActive.sources)?.find((source) => source.endsWith(".jsonl")) + }); continue; } @@ -1826,6 +2007,9 @@ export class MemoryStore { scopeState.historyAppends.push({ at: deletedAt, action: "delete", + outcome: "applied", + previousState: "active", + nextState: "deleted", scope: mutation.scope, state: "deleted", topic, @@ -2220,24 +2404,30 @@ export class MemoryStore { } const entries = await this.readSyncAuditEntries(); - const matched = - entries.find( - (entry) => - ((latestRolloutPath !== undefined && entry.rolloutPath === latestRolloutPath) || - (latestRolloutPath === undefined && - latestSessionId !== undefined && - entry.sessionId === latestSessionId)) && - (latestSessionId === undefined || entry.sessionId === latestSessionId) && - entry.operations.some( - (operation) => - operation.scope === scope && operation.topic === topic && operation.id === id - ) + const matched = entries.find((entry) => { + const matchesProvenance = + latestRolloutPath !== undefined + ? entry.rolloutPath === latestRolloutPath && + (latestSessionId === undefined || entry.sessionId === latestSessionId) + : latestSessionId !== undefined && entry.sessionId === latestSessionId; + + if (!matchesProvenance) { + return false; + } + + return entry.operations.some( + (operation) => operation.scope === scope && operation.topic === topic && operation.id === id ); + }); if (!matched) { return null; } + const matchedOperations = matched.operations.filter( + (operation) => operation.scope === scope && operation.topic === topic && operation.id === id + ); + return { auditPath: this.getSyncAuditPath(), appliedAt: matched.appliedAt, @@ -2245,6 +2435,7 @@ export class MemoryStore { sessionId: matched.sessionId, status: matched.status, resultSummary: matched.resultSummary, + matchedOperationCount: matchedOperations.length, noopOperationCount: matched.noopOperationCount ?? 0, suppressedOperationCount: matched.suppressedOperationCount ?? 0, conflicts: matched.conflicts ?? [] diff --git a/src/lib/integration/assets.ts b/src/lib/integration/assets.ts index 1bc4c7d..b7bba85 100644 --- a/src/lib/integration/assets.ts +++ b/src/lib/integration/assets.ts @@ -13,6 +13,9 @@ import { buildRecommendedRetrievalSummaryLines, buildPostWorkRecentReviewCommand, buildPostWorkSyncCommand, + buildResolvedCliCommand, + buildResolvedPostWorkRecentReviewCommand, + buildResolvedPostWorkSyncCommand, buildSharedWorkflowDisciplineLines, buildShellAssetVersionComment, CLI_FALLBACK_RECALL_WORKFLOW, @@ -144,19 +147,19 @@ case "$ACTION" in if ! contains_flag "--limit" "$@"; then set -- "$@" "--limit" "${RECOMMENDED_RETRIEVAL_LIMIT}" fi - exec cam recall search "$@" + exec ${buildResolvedCliCommand("recall search")} "$@" ;; timeline) if ! contains_flag "--cwd" "$@"; then set -- "$@" "--cwd" "$PROJECT_ROOT" fi - exec cam recall timeline "$@" + exec ${buildResolvedCliCommand("recall timeline")} "$@" ;; details) if ! contains_flag "--cwd" "$@"; then set -- "$@" "--cwd" "$PROJECT_ROOT" fi - exec cam recall details "$@" + exec ${buildResolvedCliCommand("recall details")} "$@" ;; *) echo "Usage: memory-recall.sh " >&2 @@ -221,8 +224,8 @@ function buildPostWorkMemoryReviewScript(projectRoot: string): string { return `#!/bin/sh ${buildShellAssetVersionComment()} # Sync the latest durable memory updates, then show the recent audit surface for review. -${buildPostWorkSyncCommand({ cwd: projectRoot })} "$@" || exit $? -exec ${buildPostWorkRecentReviewCommand({ cwd: projectRoot })} +${buildResolvedPostWorkSyncCommand({ cwd: projectRoot })} "$@" || exit $? +exec ${buildResolvedPostWorkRecentReviewCommand({ cwd: projectRoot })} `; } @@ -297,7 +300,7 @@ const INTEGRATION_ASSET_DEFINITIONS: readonly IntegrationAssetDefinition[] = [ executable: true, role: "capture-helper", doctorVisible: true, - doctorSignatures: ["cam sync --cwd"], + doctorSignatures: [" sync --cwd "], renderContents: (context) => `#!/bin/sh ${buildShellAssetVersionComment()} @@ -313,7 +316,7 @@ ${appendCliCwdFlag("cam sync", context.projectRoot)} "$@" executable: true, role: "capture-helper", doctorVisible: true, - doctorSignatures: ["cam doctor --cwd"], + doctorSignatures: [" doctor --cwd "], renderContents: (context) => `#!/bin/sh ${buildShellAssetVersionComment()} @@ -329,7 +332,7 @@ ${appendCliCwdFlag("cam doctor", context.projectRoot)} "$@" executable: true, role: "capture-helper", doctorVisible: true, - doctorSignatures: ["cam sync --cwd", "cam memory --recent --cwd"], + doctorSignatures: [" sync --cwd ", " memory --recent --cwd "], renderContents: (context) => buildPostWorkMemoryReviewScript(context.projectRoot) }, { @@ -342,9 +345,9 @@ ${appendCliCwdFlag("cam doctor", context.projectRoot)} "$@" doctorVisible: true, doctorSignatures: [ "PROJECT_ROOT=", - 'exec cam recall search "$@"', - 'exec cam recall timeline "$@"', - 'exec cam recall details "$@"' + ' recall search "$@"', + ' recall timeline "$@"', + ' recall details "$@"' ], renderContents: (context) => buildRecallDispatcherScript(context.projectRoot) }, diff --git a/src/lib/integration/codex-stack.ts b/src/lib/integration/codex-stack.ts index 5967b30..9cb3c16 100644 --- a/src/lib/integration/codex-stack.ts +++ b/src/lib/integration/codex-stack.ts @@ -1,6 +1,10 @@ import * as path from "node:path"; import { appendCliCwdFlag, + buildResolvedCliCommand, + buildResolvedCliDetailsCommand, + buildResolvedCliSearchCommand, + buildResolvedCliTimelineCommand, buildCliDetailsCommand, buildPostWorkRecentReviewCommand, buildPostWorkSyncCommand, @@ -28,7 +32,9 @@ export interface CodexStackReadiness { mcpOperationalReady: boolean; camCommandAvailable: boolean; hookCaptureReady: boolean; + hookCaptureOperationalReady: boolean; hookRecallReady: boolean; + hookRecallOperationalReady: boolean; skillReady: boolean; workflowAssetsConsistent: boolean; workflowConsistent: boolean; @@ -61,6 +67,16 @@ export interface CodexAgentsGuidance { notes: string[]; } +export interface ExperimentalCodexHooksGuidance { + status: "experimental"; + featureFlag: "codex_hooks"; + targetFileHint: ".codex/config.toml"; + snippetFormat: "toml"; + snippet: string; + notes: string[]; + docs: string[]; +} + export interface CodexAgentsGuidanceInspection { path: string; exists: boolean; @@ -94,6 +110,8 @@ export const READ_ONLY_RETRIEVAL_NOTE = "Codex Auto Memory exposes a read-only retrieval MCP plane. Markdown remains the canonical memory surface."; export const LOCAL_BRIDGE_BUNDLE_NOTE = "Hook assets in this repository are local bridge and fallback helpers, not an official Codex hook surface."; +export const EXPERIMENTAL_CODEX_HOOKS_NOTE = + "Official Codex hooks are a public but experimental opt-in surface and are not the default path in this repository."; export const CODEX_AGENTS_TARGET_FILE_HINT = "AGENTS.md"; export const CODEX_AGENTS_GUIDANCE_VERSION = "codex-agents-guidance-v1"; export const CODEX_AGENTS_GUIDANCE_VERSION_MARKER = "cam:agents-guidance-version"; @@ -325,13 +343,13 @@ export function formatIntegrationActionHeadline( } export function resolveCodexIntegrationRoute( - readiness: Pick + readiness: Pick ): CodexIntegrationRoute { if (readiness.mcpOperationalReady) { return "mcp"; } - if (readiness.hookRecallReady) { + if (readiness.hookRecallOperationalReady) { return "hooks-fallback"; } @@ -365,10 +383,12 @@ export function buildCodexStackNotes( return [ READ_ONLY_RETRIEVAL_NOTE, LOCAL_BRIDGE_BUNDLE_NOTE, + EXPERIMENTAL_CODEX_HOOKS_NOTE, + "Shell-based hook helpers require `cam` to be resolvable on PATH; installed helper files alone do not make the route operational.", "Recommended route prefers project-scoped MCP, then local bridge recall helpers, then direct cam recall CLI usage.", `Recommended retrieval preset: ${workflowContract.recommendedPreset}.`, ...buildSharedWorkflowDisciplineLines().slice(2), - `When the local bridge bundle is installed, prefer \`${workflowContract.postWorkSyncReview.helperScript}\` to combine \`${workflowContract.postWorkSyncReview.syncCommand}\` with \`${workflowContract.postWorkSyncReview.reviewCommand}\`.`, + `When the local bridge bundle is installed, prefer \`${workflowContract.postWorkSyncReview.helperScript}\` to combine \`${workflowContract.resolvedPostWorkSyncReview.syncCommand}\` with \`${workflowContract.resolvedPostWorkSyncReview.reviewCommand}\`.`, "Run `cam mcp print-config --host codex` to inspect the recommended project-scoped MCP wiring together with an AGENTS.md snippet for Codex agents.", "Run `cam mcp apply-guidance --host codex` to create or update the managed Codex Auto Memory block inside the repository-level AGENTS.md.", "Codex skill readiness is guidance-only and does not replace executable hook fallback helpers.", @@ -376,6 +396,29 @@ export function buildCodexStackNotes( ]; } +export function buildExperimentalCodexHooksGuidance(): ExperimentalCodexHooksGuidance { + return { + status: "experimental", + featureFlag: "codex_hooks", + targetFileHint: ".codex/config.toml", + snippetFormat: "toml", + snippet: "codex_hooks = true", + notes: [ + "Experimental: the public Codex hooks page labels hooks as Experimental.", + "Under development: the Codex config docs still label the `codex_hooks` feature flag as Under development.", + "Paste this line inside an existing [features] table, or create that table once if it does not exist yet.", + "Keep this as an explicit opt-in route. Do not treat it as the default or stable path for Codex Auto Memory.", + LOCAL_BRIDGE_BUNDLE_NOTE + ], + docs: [ + "https://developers.openai.com/codex/hooks", + "https://developers.openai.com/codex/config-basic", + "https://developers.openai.com/codex/config-reference", + "https://developers.openai.com/codex/feature-maturity" + ] + }; +} + export function buildCodexAgentsGuidance( options: { cwd?: string; @@ -392,6 +435,7 @@ export function buildCodexAgentsGuidance( `- ${workflowContract.routePreference.mcpFirst}`, `- ${buildRecommendedMcpSearchInstruction()}`, `- If the retrieval MCP server is unavailable, fall back to \`${workflowContract.cliFallback.searchCommand}\`, then \`${workflowContract.cliFallback.timelineCommand}\`, then \`${workflowContract.cliFallback.detailsCommand}\`.`, + `- If \`cam\` is unavailable on PATH, prefer the verified launcher fallback \`${workflowContract.resolvedCliFallback.searchCommand}\`, then \`${workflowContract.resolvedCliFallback.timelineCommand}\`, then \`${workflowContract.resolvedCliFallback.detailsCommand}\`.`, ...sharedLines.slice(2).map((line) => `- ${line}`), `- When the local bridge bundle is installed, \`${workflowContract.postWorkSyncReview.helperScript}\` combines \`${workflowContract.postWorkSyncReview.syncCommand}\` with \`${workflowContract.postWorkSyncReview.reviewCommand}\`.`, `- ${LOCAL_BRIDGE_BUNDLE_NOTE}` @@ -482,10 +526,16 @@ export function buildCodexIntegrationSubchecks( "Project-scoped retrieval MCP wiring is present, but the current shell could not resolve `cam` on PATH." }, hookCapture: readiness.hookCaptureReady - ? { + ? readiness.hookCaptureOperationalReady + ? { status: "ok", summary: "Capture helpers are ready for post-session sync and startup diagnostics." } + : { + status: "warning", + summary: + "Capture helpers are installed, but the current shell could not resolve `cam` on PATH yet." + } : assetAvailability.hasCaptureAssets ? { status: "warning", @@ -495,11 +545,17 @@ export function buildCodexIntegrationSubchecks( status: "missing", summary: "No capture helper bundle is installed yet." }, - hookRecall: readiness.hookRecallReady + hookRecall: readiness.hookRecallOperationalReady ? { status: "ok", summary: "Recall helpers are ready for shell-based search -> timeline -> details fallback." } + : readiness.hookRecallReady + ? { + status: "warning", + summary: + "Recall helpers are installed, but the current shell could not resolve `cam` on PATH yet." + } : assetAvailability.hasRecallAssets ? { status: "warning", @@ -569,19 +625,19 @@ export function buildCodexIntegrationNextSteps( options.skillInstallCommand ?? "cam skills install", options.projectRoot ); - const integrationsInstallCommand = appendProjectRootFlag( - "cam integrations install --host codex", - options.projectRoot - ); - const mcpInstallCommand = appendProjectRootFlag( - "cam mcp install --host codex", - options.projectRoot - ); - const mcpPrintConfigCommand = appendProjectRootFlag( - "cam mcp print-config --host codex", - options.projectRoot + const integrationsInstallCommand = buildResolvedCliCommand( + "integrations install --host codex", + { cwd: options.projectRoot } ); - const hooksInstallCommand = appendProjectRootFlag("cam hooks install", options.projectRoot); + const mcpInstallCommand = buildResolvedCliCommand("mcp install --host codex", { + cwd: options.projectRoot + }); + const mcpPrintConfigCommand = buildResolvedCliCommand("mcp print-config --host codex", { + cwd: options.projectRoot + }); + const hooksInstallCommand = buildResolvedCliCommand("hooks install", { + cwd: options.projectRoot + }); const nextSteps: string[] = []; if ( @@ -593,6 +649,7 @@ export function buildCodexIntegrationNextSteps( return [ `Run \`${integrationsInstallCommand}\` to install the recommended Codex integration stack in one step.`, `Until the stack is installed, use \`${buildRecommendedCliSearchCommand("\"\"", { cwd: options.projectRoot })}\` directly.`, + `If \`cam\` is unavailable on PATH, use \`${buildResolvedCliSearchCommand("\"\"", { cwd: options.projectRoot })}\` as the verified fallback.`, `Run \`${mcpPrintConfigCommand}\` to print the recommended project-scoped MCP wiring and AGENTS.md snippet.` ]; } @@ -611,6 +668,14 @@ export function buildCodexIntegrationNextSteps( nextSteps.push( `Run \`${hooksInstallCommand}\` to refresh the shared hook helper bundle for capture and recall.` ); + } else if (!readiness.hookCaptureOperationalReady) { + nextSteps.push( + "Make sure the host process can resolve `cam` on PATH before relying on the local hook capture helpers." + ); + } else if (!readiness.hookRecallOperationalReady) { + nextSteps.push( + "Make sure the host process can resolve `cam` on PATH before relying on the local hook recall helpers." + ); } if (!readiness.skillReady) { @@ -640,11 +705,14 @@ export function buildCodexIntegrationNextSteps( nextSteps.push( `Use \`${buildRecommendedCliSearchCommand("\"\"", { cwd: options.projectRoot })}\` directly until a richer integration route becomes ready.` ); + nextSteps.push( + `If \`cam\` is unavailable on PATH, use \`${buildResolvedCliSearchCommand("\"\"", { cwd: options.projectRoot })}\` as the verified fallback.` + ); } if (route !== "mcp") { nextSteps.push( - `Follow progressive disclosure when using the CLI fallback: \`${buildRecommendedCliSearchCommand("\"\"", { cwd: options.projectRoot })}\`, then \`${buildCliTimelineCommand("\"\"", { cwd: options.projectRoot })}\`, then \`${buildCliDetailsCommand("\"\"", { cwd: options.projectRoot })}\`.` + `Follow progressive disclosure when using the CLI fallback: \`${buildResolvedCliSearchCommand("\"\"", { cwd: options.projectRoot })}\`, then \`${buildResolvedCliTimelineCommand("\"\"", { cwd: options.projectRoot })}\`, then \`${buildResolvedCliDetailsCommand("\"\"", { cwd: options.projectRoot })}\`.` ); } diff --git a/src/lib/integration/mcp-config.ts b/src/lib/integration/mcp-config.ts index 1b758b8..3c65220 100644 --- a/src/lib/integration/mcp-config.ts +++ b/src/lib/integration/mcp-config.ts @@ -2,8 +2,10 @@ import path from "node:path"; import { detectProjectContext } from "../domain/project-context.js"; import { buildCodexAgentsGuidance, + buildExperimentalCodexHooksGuidance, READ_ONLY_RETRIEVAL_NOTE, - type CodexAgentsGuidance + type CodexAgentsGuidance, + type ExperimentalCodexHooksGuidance } from "./codex-stack.js"; import { buildWorkflowContract } from "./retrieval-contract.js"; import { @@ -29,6 +31,7 @@ export interface McpHostConfigSnippet { notes: string[]; workflowContract?: ReturnType; agentsGuidance?: CodexAgentsGuidance; + experimentalHooks?: ExperimentalCodexHooksGuidance; } export { normalizeMcpHost }; @@ -50,14 +53,15 @@ export function buildMcpHostConfigSnippet(host: McpHost, projectRoot: string): M snippetFormat: definition.snippetFormat, snippet: buildMcpHostSnippet(host, projectRoot), notes: [...definition.notes], + workflowContract: buildWorkflowContract({ + cwd: projectRoot + }), ...(host === "codex" ? { - workflowContract: buildWorkflowContract({ - cwd: projectRoot - }), agentsGuidance: buildCodexAgentsGuidance({ cwd: projectRoot - }) + }), + experimentalHooks: buildExperimentalCodexHooksGuidance() } : {}) }; @@ -88,5 +92,18 @@ export function formatMcpHostConfigSnippet(snippet: McpHostConfigSnippet): strin ); } + if (snippet.experimentalHooks) { + lines.push( + "", + "Experimental Codex hooks:", + `Target file hint: ${snippet.experimentalHooks.targetFileHint}`, + "", + snippet.experimentalHooks.snippet, + "", + "Experimental hooks notes:", + ...snippet.experimentalHooks.notes.map((note) => `- ${note}`) + ); + } + return lines.join("\n"); } diff --git a/src/lib/integration/mcp-doctor.ts b/src/lib/integration/mcp-doctor.ts index a3db1fd..a779bda 100644 --- a/src/lib/integration/mcp-doctor.ts +++ b/src/lib/integration/mcp-doctor.ts @@ -6,6 +6,7 @@ import { } from "./assets.js"; import { buildCodexStackNotes, + buildExperimentalCodexHooksGuidance, inspectCodexAgentsGuidance, CODEX_HOOK_CAPTURE_ASSET_IDS, CODEX_HOOK_RECALL_ASSET_IDS, @@ -13,7 +14,8 @@ import { resolveCodexIntegrationRoute, summarizeCodexIntegrationStatus, type CodexAgentsGuidanceInspection, - type CodexIntegrationRoute + type CodexIntegrationRoute, + type ExperimentalCodexHooksGuidance } from "./codex-stack.js"; import { inspectCodexAgentsGuidanceApplySafety } from "./agents-guidance.js"; import { @@ -218,6 +220,7 @@ export interface McpDoctorReport { fallbackAssets: McpDoctorFallbackAssets; retrievalSidecar: McpDoctorRetrievalSidecarReport; workflowContract: ReturnType; + experimentalHooks: ExperimentalCodexHooksGuidance; hosts: McpDoctorHostReport[]; codexStack: { status: McpDoctorStatus; @@ -228,7 +231,9 @@ export interface McpDoctorReport { mcpOperationalReady: boolean; camCommandAvailable: boolean; hookCaptureReady: boolean; + hookCaptureOperationalReady: boolean; hookRecallReady: boolean; + hookRecallOperationalReady: boolean; skillReady: boolean; workflowAssetsConsistent: boolean; workflowConsistent: boolean; @@ -752,11 +757,23 @@ function buildRetrievalSidecarReport( cwd?: string; } = {} ): McpDoctorRetrievalSidecarReport { + const degradedChecks = checks.filter((check) => check.status !== "ok"); const repairCommand = appendCliCwdFlag( - "cam memory reindex --scope all --state all", + [ + "cam memory reindex", + `--scope ${ + new Set(degradedChecks.map((check) => check.scope)).size === 1 + ? degradedChecks[0]?.scope ?? "all" + : "all" + }`, + `--state ${ + new Set(degradedChecks.map((check) => check.state)).size === 1 + ? degradedChecks[0]?.state ?? "all" + : "all" + }` + ].join(" "), options.cwd ); - const degradedChecks = checks.filter((check) => check.status !== "ok"); if (degradedChecks.length === 0) { return { status: "ok", @@ -797,10 +814,12 @@ function buildCodexStackReport( fallbackAssets.assets, [...CODEX_HOOK_CAPTURE_ASSET_IDS] ); + const hookCaptureOperationalReady = hookCaptureReady && camCommandAvailable; const hookRecallReady = isAssetReady( fallbackAssets.assets, [...CODEX_HOOK_RECALL_ASSET_IDS] ); + const hookRecallOperationalReady = hookRecallReady && camCommandAvailable; const skillReady = fallbackAssets.readySkillSurfaces.length > 0; const workflowAssetsConsistent = isAssetReady( @@ -814,8 +833,8 @@ function buildCodexStackReport( agentsGuidance.status === "ok"; const status = summarizeCodexIntegrationStatus([ mcpOperationalReady ? "ok" : mcpReady ? "warning" : "missing", - hookCaptureReady ? "ok" : "missing", - hookRecallReady ? "ok" : "missing", + hookCaptureOperationalReady ? "ok" : hookCaptureReady ? "warning" : "missing", + hookRecallOperationalReady ? "ok" : hookRecallReady ? "warning" : "missing", skillReady ? "ok" : "missing", workflowConsistent ? "ok" @@ -829,12 +848,22 @@ function buildCodexStackReport( "The current shell could not resolve `cam` on PATH, so MCP wiring may still fail at runtime." ); } + if (hookRecallReady && !hookRecallOperationalReady) { + notes.push( + "Hook recall helpers are installed, but the current shell could not resolve `cam` on PATH, so the local bridge fallback is not operational yet." + ); + } + if (hookCaptureReady && !hookCaptureOperationalReady) { + notes.push( + "Hook capture helpers are installed, but the current shell could not resolve `cam` on PATH, so the local bridge capture path is not operational yet." + ); + } return { status, recommendedRoute: resolveCodexIntegrationRoute({ mcpOperationalReady, - hookRecallReady + hookRecallOperationalReady }), preset: formatRecommendedRetrievalPreset(), assetVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, @@ -842,7 +871,9 @@ function buildCodexStackReport( mcpOperationalReady, camCommandAvailable, hookCaptureReady, + hookCaptureOperationalReady, hookRecallReady, + hookRecallOperationalReady, skillReady, workflowAssetsConsistent, workflowConsistent, @@ -901,6 +932,7 @@ export async function inspectMcpDoctor(options: { fallbackAssets, retrievalSidecar, workflowContract, + experimentalHooks: buildExperimentalCodexHooksGuidance(), hosts, codexStack: buildCodexStackReport( codexHost, @@ -981,6 +1013,13 @@ export function formatMcpDoctorReport(report: McpDoctorReport): string { "Apply safety:", `- AGENTS managed-block apply safety: ${report.applySafety.status}${report.applySafety.blockedReason ? ` (${report.applySafety.blockedReason})` : ""}`, "", + "Experimental Codex hooks:", + `- Status: ${report.experimentalHooks.status}`, + `- Feature flag: ${report.experimentalHooks.featureFlag}`, + `- Target file hint: ${report.experimentalHooks.targetFileHint}`, + `- Snippet: ${report.experimentalHooks.snippet.replace(/\n/g, " | ")}`, + ...report.experimentalHooks.notes.map((note) => `- ${note}`), + "", "Retrieval sidecar:", `- Status: ${report.retrievalSidecar.status}`, `- Summary: ${report.retrievalSidecar.summary}`, @@ -1060,7 +1099,9 @@ export function formatMcpDoctorReport(report: McpDoctorReport): string { `- MCP operational ready: ${report.codexStack.mcpOperationalReady ? "yes" : "no"}`, `- cam command available: ${report.codexStack.camCommandAvailable ? "yes" : "no"}`, `- Hook capture ready: ${report.codexStack.hookCaptureReady ? "yes" : "no"}`, + `- Hook capture operational ready: ${report.codexStack.hookCaptureOperationalReady ? "yes" : "no"}`, `- Hook recall ready: ${report.codexStack.hookRecallReady ? "yes" : "no"}`, + `- Hook recall operational ready: ${report.codexStack.hookRecallOperationalReady ? "yes" : "no"}`, `- Skill ready: ${report.codexStack.skillReady ? "yes" : "no"}`, `- Workflow consistent: ${report.codexStack.workflowConsistent ? "yes" : "no"}`, "", diff --git a/src/lib/integration/retrieval-contract.ts b/src/lib/integration/retrieval-contract.ts index 0414eac..be7949f 100644 --- a/src/lib/integration/retrieval-contract.ts +++ b/src/lib/integration/retrieval-contract.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { DEFAULT_MEMORY_RETRIEVAL_LIMIT, DEFAULT_MEMORY_RETRIEVAL_STATE @@ -60,6 +63,14 @@ export interface WorkflowContract { recommendedPreset: string; recallFirst: string; progressiveDisclosure: string; + launcher: { + commandName: "cam"; + requiresPathResolution: true; + hookHelpersShellOnly: true; + resolution: "cam-path" | "node-dist" | "cam-unverified"; + verified: boolean; + resolvedCommand: string; + }; routePreference: WorkflowRoutePreference; recallWorkflow: WorkflowRecallWorkflow; mcpTools: { @@ -71,12 +82,24 @@ export interface WorkflowContract { searchCommand: string; timelineCommand: string; detailsCommand: string; + requiresCamOnPath: true; + }; + resolvedCliFallback: { + searchCommand: string; + timelineCommand: string; + detailsCommand: string; }; postWorkSyncReview: { helperScript: string; syncCommand: string; reviewCommand: string; guidance: string; + shellOnly: true; + requiresCamOnPath: true; + }; + resolvedPostWorkSyncReview: { + syncCommand: string; + reviewCommand: string; }; boundaries: { memoryAudit: string; @@ -101,6 +124,61 @@ export function formatRecommendedRetrievalPreset(): string { return `state=${RECOMMENDED_RETRIEVAL_STATE}, limit=${RECOMMENDED_RETRIEVAL_LIMIT}`; } +function isExecutableOnPath(commandName: string): boolean { + const pathValue = process.env.PATH; + if (!pathValue) { + return false; + } + + const executableNames = + process.platform === "win32" + ? [commandName, `${commandName}.cmd`, `${commandName}.exe`, `${commandName}.bat`] + : [commandName]; + + return pathValue.split(path.delimiter).some((directory) => + executableNames.some((candidate) => fs.existsSync(path.join(directory, candidate))) + ); +} + +function getPackagedDistCliPath(): string { + const thisFilePath = fileURLToPath(import.meta.url); + return path.resolve(path.dirname(thisFilePath), "../../../dist/cli.js"); +} + +export function resolveCliLauncher(): WorkflowContract["launcher"] { + if (isExecutableOnPath("cam")) { + return { + commandName: "cam", + requiresPathResolution: true, + hookHelpersShellOnly: true, + resolution: "cam-path", + verified: true, + resolvedCommand: "cam" + }; + } + + const distCliPath = getPackagedDistCliPath(); + if (fs.existsSync(distCliPath)) { + return { + commandName: "cam", + requiresPathResolution: true, + hookHelpersShellOnly: true, + resolution: "node-dist", + verified: true, + resolvedCommand: `node ${JSON.stringify(distCliPath)}` + }; + } + + return { + commandName: "cam", + requiresPathResolution: true, + hookHelpersShellOnly: true, + resolution: "cam-unverified", + verified: false, + resolvedCommand: "cam" + }; +} + export function hasCliCwdFlag(command: string): boolean { return /(?:^|\s)--cwd(?:\s|=)/u.test(command); } @@ -117,6 +195,15 @@ export function appendCliCwdFlag(command: string, cwd?: string): string { return `${command} --cwd ${shellQuote(cwd)}`; } +export function buildResolvedCliCommand( + command: string, + options: { + cwd?: string; + } = {} +): string { + return appendCliCwdFlag(`${resolveCliLauncher().resolvedCommand} ${command}`, options.cwd); +} + export function buildRecommendedCliSearchCommand( query = "\"\"", options: { @@ -176,11 +263,62 @@ export function buildPostWorkRecentReviewCommand( return appendCliCwdFlag(DURABLE_MEMORY_RECENT_REVIEW_COMMAND, options.cwd); } +export function buildResolvedCliSearchCommand( + query = "\"\"", + options: { + state?: MemoryRetrievalStateFilter; + limit?: number; + cwd?: string; + } = {} +): string { + const state = options.state ?? RECOMMENDED_RETRIEVAL_STATE; + const limit = options.limit ?? RECOMMENDED_RETRIEVAL_LIMIT; + return buildResolvedCliCommand( + `recall search ${query} --state ${state} --limit ${limit}`, + options + ); +} + +export function buildResolvedCliTimelineCommand( + ref = "\"\"", + options: { + cwd?: string; + } = {} +): string { + return buildResolvedCliCommand(`recall timeline ${ref}`, options); +} + +export function buildResolvedCliDetailsCommand( + ref = "\"\"", + options: { + cwd?: string; + } = {} +): string { + return buildResolvedCliCommand(`recall details ${ref}`, options); +} + +export function buildResolvedPostWorkSyncCommand( + options: { + cwd?: string; + } = {} +): string { + return buildResolvedCliCommand("sync", options); +} + +export function buildResolvedPostWorkRecentReviewCommand( + options: { + cwd?: string; + } = {} +): string { + return buildResolvedCliCommand("memory --recent", options); +} + export function buildWorkflowContract( options: { cwd?: string; } = {} ): WorkflowContract { + const launcher = resolveCliLauncher(); const routePreference: WorkflowRoutePreference = { preferredRoute: "mcp-first", mcpFirst: MCP_FIRST_RECALL_WORKFLOW, @@ -199,6 +337,7 @@ export function buildWorkflowContract( recommendedPreset: formatRecommendedRetrievalPreset(), recallFirst: recallWorkflow.recallFirst, progressiveDisclosure: recallWorkflow.progressiveDisclosure, + launcher, routePreference, recallWorkflow, mcpTools: { @@ -209,13 +348,25 @@ export function buildWorkflowContract( cliFallback: { searchCommand: buildRecommendedCliSearchCommand("\"\"", options), timelineCommand: buildCliTimelineCommand("\"\"", options), - detailsCommand: buildCliDetailsCommand("\"\"", options) + detailsCommand: buildCliDetailsCommand("\"\"", options), + requiresCamOnPath: true + }, + resolvedCliFallback: { + searchCommand: buildResolvedCliSearchCommand("\"\"", options), + timelineCommand: buildResolvedCliTimelineCommand("\"\"", options), + detailsCommand: buildResolvedCliDetailsCommand("\"\"", options) }, postWorkSyncReview: { helperScript: POST_WORK_SYNC_REVIEW_HELPER, syncCommand: buildPostWorkSyncCommand(options), reviewCommand: buildPostWorkRecentReviewCommand(options), - guidance: DURABLE_MEMORY_SYNC_GUIDANCE + guidance: DURABLE_MEMORY_SYNC_GUIDANCE, + shellOnly: true, + requiresCamOnPath: true + }, + resolvedPostWorkSyncReview: { + syncCommand: buildResolvedPostWorkSyncCommand(options), + reviewCommand: buildResolvedPostWorkRecentReviewCommand(options) }, boundaries: { memoryAudit: MEMORY_AUDIT_BOUNDARY, diff --git a/src/lib/mcp/retrieval-server.ts b/src/lib/mcp/retrieval-server.ts index 92f5b47..4f1e086 100644 --- a/src/lib/mcp/retrieval-server.ts +++ b/src/lib/mcp/retrieval-server.ts @@ -25,7 +25,15 @@ const retrievalStateSchema = z.enum(["active", "archived", "all", "auto"]); const resolvedRetrievalStateSchema = z.enum(["active", "archived", "all"]); const memoryRecordStateSchema = z.enum(["active", "archived"]); const memoryHistoryRecordStateSchema = z.enum(["active", "archived", "deleted"]); -const memoryLifecycleActionSchema = z.enum(["add", "update", "delete", "archive"]); +const memoryLifecycleActionSchema = z.enum(["add", "update", "restore", "delete", "archive", "noop"]); +const appliedMemoryLifecycleActionSchema = z.enum(["add", "update", "restore", "delete", "archive"]); +const memoryLifecycleAttemptOutcomeSchema = z.enum(["applied", "noop"]); +const memoryLifecycleUpdateKindSchema = z.enum([ + "overwrite", + "semantic-overwrite", + "metadata-only", + "restore" +]); const memorySearchResultSchema = z.object({ ref: z.string(), @@ -45,6 +53,8 @@ const memorySearchDiagnosticSchema = z.object({ retrievalMode: z.enum(["index", "markdown-fallback"]), retrievalFallbackReason: z.enum(["missing", "invalid", "stale"]).optional(), matchedCount: z.number().int().nonnegative(), + returnedCount: z.number().int().nonnegative(), + droppedCount: z.number().int().nonnegative(), indexPath: z.string(), generatedAt: z.string().nullable() }); @@ -54,14 +64,28 @@ const memorySearchResponseSchema = z.object({ scope: retrievalScopeSchema, state: retrievalStateSchema, resolvedState: resolvedRetrievalStateSchema, + searchOrder: z.array(z.string()), + globalLimitApplied: z.boolean(), + truncatedCount: z.number().int().nonnegative(), fallbackUsed: z.boolean(), stateFallbackUsed: z.boolean(), markdownFallbackUsed: z.boolean(), retrievalMode: z.enum(["index", "markdown-fallback"]), retrievalFallbackReason: z.enum(["missing", "invalid", "stale"]).optional(), + stateResolution: z.object({ + outcome: z.enum(["active-hit", "archived-hit", "miss-after-both", "explicit-state"]), + searchedStates: z.array(memoryRecordStateSchema), + resolutionReason: z.string() + }), + executionSummary: z.object({ + mode: z.enum(["index-only", "markdown-fallback-only", "mixed"]), + retrievalModes: z.array(z.enum(["index", "markdown-fallback"])), + fallbackReasons: z.array(z.enum(["missing", "invalid", "stale"])) + }), diagnostics: z.object({ anyMarkdownFallback: z.boolean(), fallbackReasons: z.array(z.enum(["missing", "invalid", "stale"])), + executionModes: z.array(z.enum(["index", "markdown-fallback"])), checkedPaths: z.array(memorySearchDiagnosticSchema) }), results: z.array(memorySearchResultSchema) @@ -76,21 +100,47 @@ const memoryTimelineEventSchema = z.object({ id: z.string(), ref: z.string().optional(), summary: z.string(), + outcome: memoryLifecycleAttemptOutcomeSchema.optional(), + previousState: memoryHistoryRecordStateSchema.optional(), + nextState: memoryHistoryRecordStateSchema.optional(), + updateKind: memoryLifecycleUpdateKindSchema.optional(), reason: z.string().optional(), source: z.string().optional(), sessionId: z.string().optional(), rolloutPath: z.string().optional() }); +const memoryLifecycleAttemptSchema = z.object({ + at: z.string(), + action: memoryLifecycleActionSchema, + outcome: memoryLifecycleAttemptOutcomeSchema, + state: memoryHistoryRecordStateSchema.nullable(), + previousState: memoryHistoryRecordStateSchema.nullable(), + nextState: memoryHistoryRecordStateSchema.nullable(), + summary: z.string(), + updateKind: memoryLifecycleUpdateKindSchema.nullable(), + sessionId: z.string().nullable(), + rolloutPath: z.string().nullable() +}); + const memoryLineageSummarySchema = z.object({ eventCount: z.number().int().nonnegative(), firstSeenAt: z.string().nullable(), latestAt: z.string().nullable(), - latestAction: memoryLifecycleActionSchema.nullable(), + latestAction: appliedMemoryLifecycleActionSchema.nullable(), latestState: memoryHistoryRecordStateSchema.nullable(), + latestAttemptedAction: memoryLifecycleActionSchema.nullable(), + latestAttemptedState: memoryHistoryRecordStateSchema.nullable(), + latestAttemptedOutcome: memoryLifecycleAttemptOutcomeSchema.nullable(), + latestUpdateKind: memoryLifecycleUpdateKindSchema.nullable(), archivedAt: z.string().nullable(), deletedAt: z.string().nullable(), latestAuditStatus: z.enum(["applied", "no-op", "skipped"]).nullable(), + refNoopCount: z.number().int().nonnegative(), + matchedAuditOperationCount: z.number().int().nonnegative(), + rolloutNoopOperationCount: z.number().int().nonnegative(), + rolloutSuppressedOperationCount: z.number().int().nonnegative(), + rolloutConflictCount: z.number().int().nonnegative(), noopOperationCount: z.number().int().nonnegative(), suppressedOperationCount: z.number().int().nonnegative(), conflictCount: z.number().int().nonnegative() @@ -100,6 +150,7 @@ const memoryTimelineResponseSchema = z.object({ ref: z.string(), events: z.array(memoryTimelineEventSchema), warnings: z.array(z.string()), + latestLifecycleAttempt: memoryLifecycleAttemptSchema.nullable(), lineageSummary: memoryLineageSummarySchema }); @@ -111,7 +162,8 @@ const memoryDetailsResponseSchema = z.object({ id: z.string(), path: z.string(), approxReadCost: z.number().int().nonnegative(), - latestLifecycleAction: memoryLifecycleActionSchema.nullable(), + latestLifecycleAction: appliedMemoryLifecycleActionSchema.nullable(), + latestLifecycleAttempt: memoryLifecycleAttemptSchema.nullable(), latestState: memoryHistoryRecordStateSchema, latestSessionId: z.string().nullable(), latestRolloutPath: z.string().nullable(), @@ -127,6 +179,7 @@ const memoryDetailsResponseSchema = z.object({ sessionId: z.string().optional(), status: z.enum(["applied", "no-op", "skipped"]), resultSummary: z.string(), + matchedOperationCount: z.number().int().nonnegative(), noopOperationCount: z.number().int().nonnegative(), suppressedOperationCount: z.number().int().nonnegative(), conflicts: z.array( diff --git a/src/lib/types.ts b/src/lib/types.ts index 865d3ce..c50b944 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,12 +1,30 @@ export type MemoryScope = "global" | "project" | "project-local"; export type MemoryRecordState = "active" | "archived"; export type MemoryHistoryRecordState = MemoryRecordState | "deleted"; -export type MemoryLifecycleAction = "add" | "update" | "delete" | "archive" | "noop"; +export type MemoryLifecycleAction = + | "add" + | "update" + | "restore" + | "delete" + | "archive" + | "noop"; +export type MemoryLifecycleAttemptOutcome = "applied" | "noop"; +export type MemoryLifecycleUpdateKind = + | "overwrite" + | "semantic-overwrite" + | "metadata-only" + | "restore"; export type MemoryRetrievalScope = MemoryScope | "all"; export type MemoryRetrievalResolvedState = MemoryRecordState | "all"; export type MemoryRetrievalStateFilter = MemoryRetrievalResolvedState | "auto"; export type MemoryRetrievalMode = "index" | "markdown-fallback"; export type MemoryRetrievalFallbackReason = "missing" | "invalid" | "stale"; +export type MemorySearchStateResolutionOutcome = + | "active-hit" + | "archived-hit" + | "miss-after-both" + | "explicit-state"; +export type MemorySearchExecutionMode = "index-only" | "markdown-fallback-only" | "mixed"; export type SessionContinuityScope = "project" | "project-local"; export type SessionContinuityLocalPathStyle = "codex" | "claude"; export type SessionContinuityWriteMode = "merge" | "replace"; @@ -69,6 +87,8 @@ export interface MemorySearchDiagnosticPath { retrievalMode: MemoryRetrievalMode; retrievalFallbackReason?: MemoryRetrievalFallbackReason; matchedCount: number; + returnedCount: number; + droppedCount: number; indexPath: string; generatedAt: string | null; } @@ -76,43 +96,79 @@ export interface MemorySearchDiagnosticPath { export interface MemorySearchDiagnostics { anyMarkdownFallback: boolean; fallbackReasons: MemoryRetrievalFallbackReason[]; + executionModes: MemoryRetrievalMode[]; checkedPaths: MemorySearchDiagnosticPath[]; } +export interface MemorySearchStateResolution { + outcome: MemorySearchStateResolutionOutcome; + searchedStates: MemoryRecordState[]; + resolutionReason: string; +} + +export interface MemorySearchExecutionSummary { + mode: MemorySearchExecutionMode; + retrievalModes: MemoryRetrievalMode[]; + fallbackReasons: MemoryRetrievalFallbackReason[]; +} + export interface MemorySearchResponse { query: string; scope: MemoryRetrievalScope; state: MemoryRetrievalStateFilter; resolvedState: MemoryRetrievalResolvedState; + searchOrder: string[]; + globalLimitApplied: boolean; + truncatedCount: number; fallbackUsed: boolean; stateFallbackUsed: boolean; markdownFallbackUsed: boolean; retrievalMode: MemoryRetrievalMode; retrievalFallbackReason?: MemoryRetrievalFallbackReason; + stateResolution: MemorySearchStateResolution; + executionSummary: MemorySearchExecutionSummary; diagnostics: MemorySearchDiagnostics; results: MemorySearchResult[]; } export interface MemoryTimelineEvent { at: string; - action: Exclude; + action: MemoryLifecycleAction; scope: MemoryScope; state: MemoryHistoryRecordState; topic: string; id: string; ref?: string; summary: string; + outcome?: MemoryLifecycleAttemptOutcome; + previousState?: MemoryHistoryRecordState; + nextState?: MemoryHistoryRecordState; + updateKind?: MemoryLifecycleUpdateKind; reason?: string; source?: string; sessionId?: string; rolloutPath?: string; } +export interface MemoryLifecycleAttempt { + at: string; + action: MemoryLifecycleAction; + outcome: MemoryLifecycleAttemptOutcome; + state: MemoryHistoryRecordState | null; + previousState: MemoryHistoryRecordState | null; + nextState: MemoryHistoryRecordState | null; + summary: string; + updateKind: MemoryLifecycleUpdateKind | null; + sessionId: string | null; + rolloutPath: string | null; +} + export interface MemoryTimelineResponse { ref: string; events: MemoryTimelineEvent[]; warnings: string[]; lineageSummary: MemoryLineageSummary; + latestLifecycleAttempt: MemoryLifecycleAttempt | null; } export interface MemoryLineageSummary { @@ -121,9 +177,18 @@ export interface MemoryLineageSummary { latestAt: string | null; latestAction: Exclude | null; latestState: MemoryHistoryRecordState | null; + latestAttemptedAction: MemoryLifecycleAction | null; + latestAttemptedState: MemoryHistoryRecordState | null; + latestAttemptedOutcome: MemoryLifecycleAttemptOutcome | null; + latestUpdateKind: MemoryLifecycleUpdateKind | null; archivedAt: string | null; deletedAt: string | null; latestAuditStatus: MemorySyncAuditStatus | null; + refNoopCount: number; + matchedAuditOperationCount: number; + rolloutNoopOperationCount: number; + rolloutSuppressedOperationCount: number; + rolloutConflictCount: number; noopOperationCount: number; suppressedOperationCount: number; conflictCount: number; @@ -134,6 +199,7 @@ export interface MemoryDetailsResult extends MemoryRef { path: string; approxReadCost: number; latestLifecycleAction: Exclude | null; + latestLifecycleAttempt: MemoryLifecycleAttempt | null; latestState: MemoryHistoryRecordState; latestSessionId: string | null; latestRolloutPath: string | null; @@ -151,6 +217,7 @@ export interface MemorySyncAuditSummary { sessionId?: string; status: MemorySyncAuditStatus; resultSummary: string; + matchedOperationCount: number; noopOperationCount: number; suppressedOperationCount: number; conflicts: MemoryConflictCandidate[]; diff --git a/test/dist-cli-smoke.test.ts b/test/dist-cli-smoke.test.ts index 89b1cb3..470a71c 100644 --- a/test/dist-cli-smoke.test.ts +++ b/test/dist-cli-smoke.test.ts @@ -6,6 +6,10 @@ import { afterEach, describe, expect, it } from "vitest"; import { detectProjectContext } from "../src/lib/domain/project-context.js"; import { MemoryStore } from "../src/lib/domain/memory-store.js"; import { SessionContinuityStore } from "../src/lib/domain/session-continuity-store.js"; +import { + buildResolvedPostWorkRecentReviewCommand, + buildResolvedPostWorkSyncCommand +} from "../src/lib/integration/retrieval-contract.js"; import type { AppConfig } from "../src/lib/types.js"; import { initGitRepo, @@ -927,6 +931,7 @@ describe("dist cli smoke", () => { it("installs hooks and skills from the compiled cli entrypoint", async () => { const homeDir = await tempDir("cam-dist-hook-skill-home-"); const projectDir = await tempDir("cam-dist-hook-skill-project-"); + const realProjectDir = await fs.realpath(projectDir); const env = { HOME: homeDir }; const hooksResult = runCli(projectDir, ["hooks", "install"], { @@ -943,7 +948,9 @@ describe("dist cli smoke", () => { ); const recallGuide = await fs.readFile(path.join(hooksDir, "recall-bridge.md"), "utf8"); expect(recallScript).toContain("cam:asset-version"); - expect(postWorkReviewScript).toContain("cam memory --recent"); + expect(postWorkReviewScript).toContain( + buildResolvedPostWorkRecentReviewCommand({ cwd: realProjectDir }) + ); expect(recallGuide).toContain("cam:asset-version"); const skillsResult = runCli(projectDir, ["skills", "install"], { @@ -1653,7 +1660,7 @@ fs.writeFileSync(${JSON.stringify(capturedArgsPath)}, JSON.stringify(process.arg path.join(homeDir, ".codex-auto-memory", "hooks", "post-work-memory-review.sh"), "utf8" ) - ).toContain(`cam sync --cwd ${shellQuoteArg(realProjectDir)} "$@"`); + ).toContain(`${buildResolvedPostWorkSyncCommand({ cwd: realProjectDir })} "$@"`); const guidanceResult = runCli( callerDir, diff --git a/test/hooks-command.test.ts b/test/hooks-command.test.ts index 0d732b0..d766e37 100644 --- a/test/hooks-command.test.ts +++ b/test/hooks-command.test.ts @@ -4,6 +4,11 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { detectProjectContext } from "../src/lib/domain/project-context.js"; import { MemoryStore } from "../src/lib/domain/memory-store.js"; +import { + buildResolvedCliCommand, + buildResolvedPostWorkRecentReviewCommand, + buildResolvedPostWorkSyncCommand +} from "../src/lib/integration/retrieval-contract.js"; import { runCommandCapture } from "../src/lib/util/process.js"; import { makeAppConfig, writeCamConfig } from "./helpers/cam-test-fixtures.js"; import { resolveCliInvocation, runCli } from "./helpers/cli-runner.js"; @@ -107,10 +112,10 @@ describe("hooks command", () => { const postWorkReviewScript = await fs.readFile(postWorkReviewScriptPath, "utf8"); expect(recallScript).toContain(`PROJECT_ROOT=${shellQuoteArg(await fs.realpath(projectDir))}`); expect(postWorkReviewScript).toContain( - `cam sync --cwd ${shellQuoteArg(await fs.realpath(projectDir))}` + `${buildResolvedPostWorkSyncCommand({ cwd: await fs.realpath(projectDir) })}` ); expect(postWorkReviewScript).toContain( - `exec cam memory --recent --cwd ${shellQuoteArg(await fs.realpath(projectDir))}` + `exec ${buildResolvedPostWorkRecentReviewCommand({ cwd: await fs.realpath(projectDir) })}` ); }); @@ -152,7 +157,7 @@ describe("hooks command", () => { const recallGuide = await fs.readFile(path.join(hooksDir, "recall-bridge.md"), "utf8"); const realProjectDir = await fs.realpath(projectDir); - expect(recallScript).toContain('exec cam recall search "$@"'); + expect(recallScript).toContain(`exec ${buildResolvedCliCommand("recall search")} "$@"`); expect(recallScript).toContain(`PROJECT_ROOT=${shellQuoteArg(realProjectDir)}`); expect(recallScript).toContain("--state"); expect(recallScript).toContain("auto"); @@ -162,10 +167,10 @@ describe("hooks command", () => { expect(timelineScript).toContain('exec "$SCRIPT_DIR/memory-recall.sh" timeline "$@"'); expect(detailsScript).toContain('exec "$SCRIPT_DIR/memory-recall.sh" details "$@"'); expect(postWorkReviewScript).toContain( - `cam sync --cwd ${shellQuoteArg(realProjectDir)} "$@"` + `${buildResolvedPostWorkSyncCommand({ cwd: realProjectDir })} "$@"` ); expect(postWorkReviewScript).toContain( - `exec cam memory --recent --cwd ${shellQuoteArg(realProjectDir)}` + `exec ${buildResolvedPostWorkRecentReviewCommand({ cwd: realProjectDir })}` ); expect(recallGuide).toContain("search_memories"); expect(recallGuide).toContain("memory-recall.sh search"); diff --git a/test/integrations-command.test.ts b/test/integrations-command.test.ts index 7e816f3..07901b7 100644 --- a/test/integrations-command.test.ts +++ b/test/integrations-command.test.ts @@ -3,6 +3,10 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { restoreOptionalEnv } from "./helpers/env.js"; +import { + buildResolvedCliCommand, + buildResolvedCliSearchCommand +} from "../src/lib/integration/retrieval-contract.js"; import { makeAppConfig, writeCamConfig } from "./helpers/cam-test-fixtures.js"; import { runCli } from "./helpers/cli-runner.js"; @@ -243,7 +247,8 @@ describe("integrations command", () => { expect.arrayContaining([ expect.stringContaining("cam memory reindex --scope all --state all"), expect.stringContaining("cam integrations apply --host codex"), - expect.stringContaining("cam mcp print-config --host codex") + expect.stringContaining(buildResolvedCliCommand("integrations install --host codex")), + expect.stringContaining(buildResolvedCliCommand("mcp print-config --host codex")) ]) ); expect(await pathExists(memoryRoot)).toBe(false); @@ -293,18 +298,87 @@ describe("integrations command", () => { `cam integrations apply --host codex --skill-surface runtime --cwd ${shellQuoteArg(payload.projectRoot)}` ), expect.stringContaining( - `cam integrations install --host codex --cwd ${shellQuoteArg(payload.projectRoot)}` + buildResolvedCliCommand("integrations install --host codex", { + cwd: payload.projectRoot + }) ), expect.stringContaining( - `cam mcp print-config --host codex --cwd ${shellQuoteArg(payload.projectRoot)}` + buildResolvedCliCommand("mcp print-config --host codex", { + cwd: payload.projectRoot + }) ), expect.stringContaining( - `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(payload.projectRoot)}` + buildResolvedCliSearchCommand("\"\"", { + cwd: payload.projectRoot + }) ) ]) ); }); + it("passes through explicit experimental Codex hooks guidance in integrations doctor output", async () => { + const homeDir = await tempDir("cam-integrations-doctor-experimental-hooks-home-"); + const projectDir = await tempDir("cam-integrations-doctor-experimental-hooks-project-"); + process.env.HOME = homeDir; + + const result = runCli(projectDir, ["integrations", "doctor", "--host", "codex", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "codex", + experimentalHooks: { + status: "experimental", + featureFlag: "codex_hooks", + targetFileHint: ".codex/config.toml", + snippet: expect.stringContaining("codex_hooks") + } + }); + }); + + it("does not recommend integrations apply when only AGENTS guidance is missing but cam is unavailable on PATH", async () => { + const homeDir = await tempDir("cam-integrations-doctor-path-home-"); + const projectDir = await tempDir("cam-integrations-doctor-path-project-"); + const emptyPathDir = await tempDir("cam-integrations-doctor-path-empty-"); + process.env.HOME = homeDir; + + const env = { + HOME: homeDir, + PATH: await buildPathWithoutCam(emptyPathDir) + }; + + expect(runCli(projectDir, ["mcp", "install", "--host", "codex", "--json"], { env }).exitCode).toBe(0); + expect(runCli(projectDir, ["hooks", "install", "--json"], { env }).exitCode).toBe(0); + expect(runCli(projectDir, ["skills", "install", "--json"], { env }).exitCode).toBe(0); + + const result = runCli(projectDir, ["integrations", "doctor", "--host", "codex", "--json"], { + env + }); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + nextSteps: string[]; + subchecks: { + hookCapture: { status: string }; + hookRecall: { status: string }; + agents: { status: string }; + }; + }; + + expect(payload.subchecks).toMatchObject({ + hookCapture: { status: "warning" }, + hookRecall: { status: "warning" }, + agents: { status: "missing" } + }); + expect(payload.nextSteps[0]).not.toContain("cam integrations apply --host codex"); + expect(payload.nextSteps).toEqual( + expect.arrayContaining([ + expect.stringContaining("cam mcp apply-guidance --host codex"), + expect.stringContaining("resolve `cam` on PATH") + ]) + ); + }); + it("keeps the AGENTS-only repair step pinned to the inspected project when --cwd targets another directory", async () => { const homeDir = await tempDir("cam-integrations-doctor-agents-cwd-home-"); const projectDir = await tempDir("cam-integrations-doctor-agents-cwd-project-"); @@ -748,12 +822,26 @@ describe("integrations command", () => { recallFirst: "Before repeating prior work or repo-specific decisions, recall durable memory first.", progressiveDisclosure: "Use progressive disclosure: search -> timeline -> details." }, + launcher: { + commandName: "cam", + requiresPathResolution: true, + hookHelpersShellOnly: true, + resolution: "cam-path", + verified: true, + resolvedCommand: "cam" + }, mcpTools: { search: "search_memories", timeline: "timeline_memories", details: "get_memory_details" }, cliFallback: { + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}`, + timelineCommand: `cam recall timeline "" --cwd ${JSON.stringify(realProjectDir)}`, + detailsCommand: `cam recall details "" --cwd ${JSON.stringify(realProjectDir)}`, + requiresCamOnPath: true + }, + resolvedCliFallback: { searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}`, timelineCommand: `cam recall timeline "" --cwd ${JSON.stringify(realProjectDir)}`, detailsCommand: `cam recall details "" --cwd ${JSON.stringify(realProjectDir)}` @@ -762,7 +850,13 @@ describe("integrations command", () => { helperScript: "post-work-memory-review.sh", syncCommand: `cam sync --cwd ${JSON.stringify(realProjectDir)}`, reviewCommand: `cam memory --recent --cwd ${JSON.stringify(realProjectDir)}`, - guidance: "After finishing work that should affect durable memory, run cam sync or review cam memory --recent instead of assuming temporary continuity already updated Markdown memory." + guidance: "After finishing work that should affect durable memory, run cam sync or review cam memory --recent instead of assuming temporary continuity already updated Markdown memory.", + shellOnly: true, + requiresCamOnPath: true + }, + resolvedPostWorkSyncReview: { + syncCommand: `cam sync --cwd ${JSON.stringify(realProjectDir)}`, + reviewCommand: `cam memory --recent --cwd ${JSON.stringify(realProjectDir)}` }, boundaries: { memoryAudit: "Use cam memory for inspect/audit surfaces and startup payload review.", diff --git a/test/mcp-command.test.ts b/test/mcp-command.test.ts index 5794cbf..330dcc2 100644 --- a/test/mcp-command.test.ts +++ b/test/mcp-command.test.ts @@ -24,20 +24,36 @@ interface SearchMemoriesResponse { scope: string; state: string; resolvedState: string; + searchOrder?: string[]; + globalLimitApplied?: boolean; + truncatedCount?: number; fallbackUsed: boolean; stateFallbackUsed?: boolean; markdownFallbackUsed?: boolean; retrievalMode: string; retrievalFallbackReason?: string; + stateResolution?: { + outcome: string; + searchedStates: string[]; + resolutionReason: string; + }; + executionSummary?: { + mode: string; + retrievalModes: string[]; + fallbackReasons: string[]; + }; diagnostics?: { anyMarkdownFallback?: boolean; fallbackReasons?: string[]; + executionModes?: string[]; checkedPaths: Array<{ scope: string; state: string; retrievalMode: string; retrievalFallbackReason?: string; matchedCount: number; + returnedCount?: number; + droppedCount?: number; indexPath: string; generatedAt: string | null; }>; @@ -1550,12 +1566,13 @@ describe("mcp command", () => { }); expect(payload.codexStack).toMatchObject({ status: "warning", - recommendedRoute: "hooks-fallback", + recommendedRoute: "cli-direct", preset: "state=auto, limit=8", assetVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, mcpReady: false, hookCaptureReady: true, hookRecallReady: true, + hookRecallOperationalReady: false, skillReady: true, workflowConsistent: false }); @@ -2303,6 +2320,8 @@ describe("mcp command", () => { mcpReady: boolean; mcpOperationalReady: boolean; camCommandAvailable: boolean; + hookCaptureOperationalReady: boolean; + hookRecallOperationalReady: boolean; }; hosts: Array<{ host: string; @@ -2320,7 +2339,135 @@ describe("mcp command", () => { recommendedRoute: "cli-direct", mcpReady: true, mcpOperationalReady: false, - camCommandAvailable: false + camCommandAvailable: false, + hookCaptureOperationalReady: false, + hookRecallOperationalReady: false + }); + }); + + it("does not treat hook recall assets as operational when cam is unavailable on PATH", async () => { + const homeDir = await tempDir("cam-mcp-doctor-hook-op-home-"); + const projectDir = await tempDir("cam-mcp-doctor-hook-op-project-"); + const emptyPathDir = await tempDir("cam-mcp-doctor-hook-op-empty-path-"); + process.env.HOME = homeDir; + + const hooksInstall = runCli(projectDir, ["hooks", "install", "--json"], { + env: { + HOME: homeDir, + PATH: await buildPathWithoutCam(emptyPathDir) + } + }); + expect(hooksInstall.exitCode, hooksInstall.stderr).toBe(0); + + const doctorResult = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { + env: { + HOME: homeDir, + PATH: await buildPathWithoutCam(emptyPathDir) + } + }); + expect(doctorResult.exitCode, doctorResult.stderr).toBe(0); + + const payload = JSON.parse(doctorResult.stdout) as { + codexStack: { + recommendedRoute: string; + camCommandAvailable: boolean; + hookRecallReady: boolean; + hookRecallOperationalReady: boolean; + }; + }; + + expect(payload.codexStack).toMatchObject({ + recommendedRoute: "cli-direct", + camCommandAvailable: false, + hookRecallReady: true, + hookRecallOperationalReady: false + }); + }); + + it("suggests the smallest retrieval sidecar repair command when only one scope/state check is degraded", async () => { + const homeDir = await tempDir("cam-mcp-doctor-min-repair-home-"); + const projectDir = await tempDir("cam-mcp-doctor-min-repair-project-"); + const memoryRoot = await tempDir("cam-mcp-doctor-min-repair-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const topicPath = store.getTopicFile("project", "workflow"); + const staleAt = new Date(Date.now() + 60_000); + await fs.utimes(topicPath, staleAt, staleAt); + + const result = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + expect(JSON.parse(result.stdout)).toMatchObject({ + retrievalSidecar: { + status: "warning", + repairCommand: "cam memory reindex --scope project --state active", + checks: expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + state: "active", + status: "stale" + }) + ]) + } + }); + }); + + it("surfaces explicit experimental Codex hooks guidance in print-config and doctor output", async () => { + const homeDir = await tempDir("cam-mcp-experimental-hooks-home-"); + const projectDir = await tempDir("cam-mcp-experimental-hooks-project-"); + process.env.HOME = homeDir; + + const printConfig = runCli(projectDir, ["mcp", "print-config", "--host", "codex", "--json"], { + env: { HOME: homeDir } + }); + const doctor = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { + env: { HOME: homeDir } + }); + + expect(printConfig.exitCode, printConfig.stderr).toBe(0); + expect(doctor.exitCode, doctor.stderr).toBe(0); + + expect(JSON.parse(printConfig.stdout)).toMatchObject({ + experimentalHooks: { + status: "experimental", + featureFlag: "codex_hooks", + targetFileHint: ".codex/config.toml", + snippetFormat: "toml", + snippet: "codex_hooks = true", + notes: expect.arrayContaining([ + expect.stringContaining("Experimental"), + expect.stringContaining("Under development"), + expect.stringContaining("inside an existing [features] table") + ]) + } + }); + expect(JSON.parse(doctor.stdout)).toMatchObject({ + experimentalHooks: { + status: "experimental", + featureFlag: "codex_hooks", + targetFileHint: ".codex/config.toml" + } }); }); @@ -2371,6 +2518,11 @@ describe("mcp command", () => { const expectedCore = { recommendedPreset: "state=auto, limit=8", preferredRoute: "mcp-first", + launcher: { + commandName: "cam", + requiresPathResolution: true, + hookHelpersShellOnly: true + }, routePreference: { preferredRoute: "mcp-first" }, @@ -2380,12 +2532,15 @@ describe("mcp command", () => { cliFallback: { searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realProjectDir)}`, timelineCommand: `cam recall timeline "" --cwd ${shellQuoteArg(realProjectDir)}`, - detailsCommand: `cam recall details "" --cwd ${shellQuoteArg(realProjectDir)}` + detailsCommand: `cam recall details "" --cwd ${shellQuoteArg(realProjectDir)}`, + requiresCamOnPath: true }, postWorkSyncReview: { helperScript: "post-work-memory-review.sh", syncCommand: `cam sync --cwd ${shellQuoteArg(realProjectDir)}`, - reviewCommand: `cam memory --recent --cwd ${shellQuoteArg(realProjectDir)}` + reviewCommand: `cam memory --recent --cwd ${shellQuoteArg(realProjectDir)}`, + shellOnly: true, + requiresCamOnPath: true } }; @@ -2802,7 +2957,17 @@ describe("mcp command", () => { state: "auto", resolvedState: "active", fallbackUsed: false, - retrievalMode: "index" + retrievalMode: "index", + stateResolution: { + outcome: "active-hit", + searchedStates: ["active"], + resolutionReason: "active-match-found" + }, + executionSummary: { + mode: "index-only", + retrievalModes: ["index"], + fallbackReasons: [] + } }); expect(preferredPayload.results).toEqual([ expect.objectContaining({ @@ -2828,7 +2993,17 @@ describe("mcp command", () => { state: "auto", resolvedState: "archived", fallbackUsed: true, - retrievalMode: "index" + retrievalMode: "index", + stateResolution: { + outcome: "archived-hit", + searchedStates: ["active", "archived"], + resolutionReason: "active-empty-archived-match-found" + }, + executionSummary: { + mode: "index-only", + retrievalModes: ["index"], + fallbackReasons: [] + } }); expect(fallbackPayload.results).toEqual([ expect.objectContaining({ @@ -2890,7 +3065,17 @@ describe("mcp command", () => { fallbackUsed: true, stateFallbackUsed: true, markdownFallbackUsed: false, - retrievalMode: "index" + retrievalMode: "index", + stateResolution: { + outcome: "archived-hit", + searchedStates: ["active", "archived"], + resolutionReason: "active-empty-archived-match-found" + }, + executionSummary: { + mode: "index-only", + retrievalModes: ["index"], + fallbackReasons: [] + } }); expect(payload.results).toHaveLength(8); expect(payload.results.every((entry) => entry.state === "archived")).toBe(true); @@ -2899,6 +3084,112 @@ describe("mcp command", () => { } }, 30_000); + it("surfaces explicit-state contract for state=all MCP searches and keeps checkedPaths ordered", async () => { + const homeDir = await tempDir("cam-mcp-state-all-home-"); + const projectDir = await tempDir("cam-mcp-state-all-project-"); + const memoryRoot = await tempDir("cam-mcp-state-all-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm-active", + "Active pnpm workflow note.", + ["Use pnpm in this repository now."], + "Manual note." + ); + await store.remember( + "project", + "workflow", + "prefer-pnpm-archived", + "Archived pnpm migration note.", + ["Historical pnpm migration note."], + "Manual note." + ); + await store.forget("project", "Archived pnpm migration note", { archive: true }); + + const client = await connectCliMcpClient(projectDir, { + env: { HOME: homeDir } + }); + + try { + const result = await client.callTool({ + name: "search_memories", + arguments: { + query: "pnpm", + state: "all", + limit: 1 + } + }); + const payload = readStructuredContent(result as ToolCallResultLike); + expect(payload.state).toBe("all"); + expect(payload.resolvedState).toBe("all"); + expect(payload.searchOrder).toEqual([ + "global:active", + "global:archived", + "project:active", + "project:archived", + "project-local:active", + "project-local:archived" + ]); + expect(payload.globalLimitApplied).toBe(true); + expect(payload.truncatedCount).toBe(1); + expect(payload.stateResolution).toMatchObject({ + outcome: "explicit-state", + searchedStates: ["active", "archived"], + resolutionReason: "explicit-all-state-requested" + }); + expect(payload.executionSummary).toMatchObject({ + mode: "index-only", + retrievalModes: ["index"], + fallbackReasons: [] + }); + expect(payload.results).toHaveLength(1); + const returnedState = payload.results[0]?.state; + expect(returnedState === "active" || returnedState === "archived").toBe(true); + const projectChecks = + payload.diagnostics?.checkedPaths.filter((check) => check.scope === "project") ?? []; + expect(projectChecks).toMatchObject([ + { + scope: "project", + state: "active", + retrievalMode: "index", + matchedCount: 1 + }, + { + scope: "project", + state: "archived", + retrievalMode: "index", + matchedCount: 1 + } + ]); + const activeCheck = projectChecks.find((check) => check.state === "active"); + const archivedCheck = projectChecks.find((check) => check.state === "archived"); + expect((activeCheck?.returnedCount ?? 0) + (archivedCheck?.returnedCount ?? 0)).toBe(1); + expect( + returnedState === "active" ? activeCheck?.returnedCount : archivedCheck?.returnedCount + ).toBe(1); + expect( + returnedState === "active" ? activeCheck?.droppedCount : archivedCheck?.droppedCount + ).toBe(0); + expect( + returnedState === "active" ? archivedCheck?.droppedCount : activeCheck?.droppedCount + ).toBe(1); + } finally { + await client.close(); + } + }, 30_000); + it("keeps CLI and MCP retrieval aligned when both use the recommended explicit search preset", async () => { const homeDir = await tempDir("cam-mcp-cli-parity-home-"); const projectDir = await tempDir("cam-mcp-cli-parity-project-"); @@ -2997,16 +3288,28 @@ describe("mcp command", () => { markdownFallbackUsed: true, retrievalMode: "markdown-fallback", retrievalFallbackReason: "missing", + stateResolution: { + outcome: "miss-after-both", + searchedStates: ["active", "archived"], + resolutionReason: "no-match-after-auto-search" + }, + executionSummary: { + mode: "markdown-fallback-only", + retrievalModes: ["markdown-fallback"], + fallbackReasons: ["missing"] + }, diagnostics: { anyMarkdownFallback: true, fallbackReasons: ["missing"], + executionModes: ["markdown-fallback"], checkedPaths: expect.arrayContaining([ expect.objectContaining({ scope: "project", state: "active", retrievalMode: "markdown-fallback", retrievalFallbackReason: "missing", - matchedCount: 0 + matchedCount: 0, + returnedCount: 0 }) ]) }, @@ -3086,7 +3389,13 @@ describe("mcp command", () => { expect(payload).toMatchObject({ retrievalMode: "markdown-fallback", retrievalFallbackReason: "invalid", + executionSummary: { + mode: "mixed", + retrievalModes: ["index", "markdown-fallback"], + fallbackReasons: ["invalid"] + }, diagnostics: { + executionModes: ["index", "markdown-fallback"], checkedPaths: expect.arrayContaining([ expect.objectContaining({ scope: "project", @@ -3094,6 +3403,7 @@ describe("mcp command", () => { retrievalMode: "markdown-fallback", retrievalFallbackReason: "invalid", matchedCount: 1, + returnedCount: 1, indexPath: store.getRetrievalIndexFile("project", "active"), generatedAt: null }) @@ -3106,6 +3416,89 @@ describe("mcp command", () => { } }, 30_000); + it("surfaces mixed execution summary over MCP when auto search combines markdown fallback and archived index hits", async () => { + const homeDir = await tempDir("cam-mcp-mixed-execution-home-"); + const projectDir = await tempDir("cam-mcp-mixed-execution-project-"); + const memoryRoot = await tempDir("cam-mcp-mixed-execution-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "historical-pnpm", + "Historical pnpm migration note.", + ["Old pnpm migration note kept for history."], + "Manual note." + ); + await store.forget("project", "historical pnpm", { archive: true }); + await fs.writeFile(store.getRetrievalIndexFile("project", "active"), "{not-json", "utf8"); + + const client = await connectCliMcpClient(projectDir, { + env: { HOME: homeDir } + }); + + try { + const result = await client.callTool({ + name: "search_memories", + arguments: { + query: "historical", + state: "auto", + limit: 5 + } + }); + const payload = readStructuredContent(result as ToolCallResultLike); + expect(payload).toMatchObject({ + resolvedState: "archived", + retrievalMode: "index", + markdownFallbackUsed: true, + stateResolution: { + outcome: "archived-hit", + searchedStates: ["active", "archived"], + resolutionReason: "active-empty-archived-match-found" + }, + executionSummary: { + mode: "mixed", + retrievalModes: ["index", "markdown-fallback"], + fallbackReasons: ["invalid"] + }, + diagnostics: { + anyMarkdownFallback: true, + fallbackReasons: ["invalid"], + executionModes: ["index", "markdown-fallback"], + checkedPaths: expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + state: "active", + retrievalMode: "markdown-fallback", + retrievalFallbackReason: "invalid", + matchedCount: 0, + returnedCount: 0 + }), + expect.objectContaining({ + scope: "project", + state: "archived", + retrievalMode: "index", + matchedCount: 1, + returnedCount: 1 + }) + ]) + } + }); + } finally { + await client.close(); + } + }, 30_000); + it("surfaces latest sync audit provenance through MCP memory details", async () => { const homeDir = await tempDir("cam-mcp-details-audit-home-"); const projectDir = await tempDir("cam-mcp-details-audit-project-"); diff --git a/test/memory-command.test.ts b/test/memory-command.test.ts index 2ef9bfd..65097a6 100644 --- a/test/memory-command.test.ts +++ b/test/memory-command.test.ts @@ -1249,6 +1249,150 @@ describe("runMemory", () => { expect(await store.listEntries("project")).toHaveLength(1); }); + it("surfaces a structured reviewer payload for remember --json", async () => { + const homeDir = await tempDir("cam-remember-json-home-"); + const projectDir = await tempDir("cam-remember-json-project-"); + const memoryRoot = await tempDir("cam-remember-json-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const result = runCli( + projectDir, + [ + "remember", + "Prefer pnpm in this repository.", + "--scope", + "project", + "--topic", + "workflow", + "--detail", + "Use pnpm instead of npm in this repository.", + "--json" + ], + { env: { HOME: homeDir } } + ); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + action: string; + scope: string; + topic: string; + id: string; + text: string; + ref: string; + path: string; + historyPath: string; + lifecycleAction: string; + latestState: string; + latestLifecycleAttempt: { action: string; outcome: string; updateKind: string | null } | null; + lineageSummary: { latestAction: string | null; latestUpdateKind: string | null }; + entry: { summary: string; details: string[] }; + warnings: string[]; + }; + + expect(payload).toMatchObject({ + action: "remember", + scope: "project", + topic: "workflow", + id: "prefer-pnpm-in-this-repository", + text: "Prefer pnpm in this repository.", + ref: "project:active:workflow:prefer-pnpm-in-this-repository", + lifecycleAction: "add", + latestState: "active", + latestLifecycleAttempt: { + action: "add", + outcome: "applied", + updateKind: null + }, + lineageSummary: { + latestAction: "add", + latestUpdateKind: null + }, + entry: { + summary: "Prefer pnpm in this repository.", + details: ["Use pnpm instead of npm in this repository."] + }, + warnings: [] + }); + expect(payload.path).toContain(path.join("workflow.md")); + expect(payload.historyPath).toContain(path.join("project", "memory-history.jsonl")); + }); + + it("surfaces a structured reviewer payload for forget --json including archive refs", async () => { + const homeDir = await tempDir("cam-forget-json-home-"); + const projectDir = await tempDir("cam-forget-json-project-"); + const memoryRoot = await tempDir("cam-forget-json-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const project = detectProjectContext(projectDir); + const store = new MemoryStore(project, { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const result = runCli(projectDir, ["forget", "pnpm", "--scope", "project", "--archive", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + action: string; + query: string; + scope: string; + archive: boolean; + affectedCount: number; + entries: Array<{ + ref: string; + lifecycleAction: string; + latestState: string; + latestLifecycleAttempt: { action: string; outcome: string; updateKind: string | null } | null; + lineageSummary: { latestAction: string | null; latestUpdateKind: string | null }; + }>; + }; + + expect(payload).toMatchObject({ + action: "forget", + query: "pnpm", + scope: "project", + archive: true, + affectedCount: 1, + entries: [ + { + ref: "project:archived:workflow:prefer-pnpm", + lifecycleAction: "archive", + latestState: "archived", + latestLifecycleAttempt: { + action: "archive", + outcome: "applied", + updateKind: null + }, + lineageSummary: { + latestAction: "archive", + latestUpdateKind: null + } + } + ] + }); + }); + it("rebuilds retrieval sidecars explicitly from canonical Markdown memory", async () => { const homeDir = await tempDir("cam-memory-reindex-home-"); const projectDir = await tempDir("cam-memory-reindex-project-"); diff --git a/test/memory-store.test.ts b/test/memory-store.test.ts index fe61056..1d386ee 100644 --- a/test/memory-store.test.ts +++ b/test/memory-store.test.ts @@ -424,6 +424,178 @@ describe("MemoryStore", () => { expect(timeline.map((event) => event.action)).toEqual(["archive", "add"]); }); + it("surfaces restore and ref-local noop attempts without collapsing them into a plain update", async () => { + const projectDir = await tempDir("cam-store-restore-project-"); + const memoryRoot = await tempDir("cam-store-restore-memory-"); + const config: AppConfig = { + autoMemoryEnabled: true, + autoMemoryDirectory: memoryRoot, + extractorMode: "heuristic", + defaultScope: "project", + maxStartupLines: 200, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false, + sessionContinuityLocalPathStyle: "codex", + maxSessionContinuityLines: 60, + codexBinary: "codex" + }; + const store = new MemoryStore(detectProjectContext(projectDir), config); + await store.ensureLayout(); + + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await store.forget("project", "pnpm", { archive: true }); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository again.", + ["Use pnpm instead of npm in this repository."], + "Manual restore." + ); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository again.", + ["Use pnpm instead of npm in this repository."], + "Manual restore." + ); + + const ref = "project:active:workflow:prefer-pnpm"; + const timeline = await store.readTimelineWithDiagnostics(ref); + const details = await store.getEntryByRef(ref); + + expect(timeline.events.map((event) => event.action)).toEqual(["restore", "archive", "add"]); + expect(timeline.latestLifecycleAttempt).toMatchObject({ + action: "noop", + outcome: "noop", + state: "active", + previousState: "active", + nextState: "active" + }); + expect(timeline.lineageSummary).toMatchObject({ + latestAction: "restore", + latestAttemptedAction: "noop", + latestAttemptedOutcome: "noop", + latestUpdateKind: null, + refNoopCount: 1 + }); + expect(details).toMatchObject({ + latestLifecycleAction: "restore", + latestLifecycleAttempt: { + action: "noop", + outcome: "noop", + state: "active" + }, + lineageSummary: { + latestAction: "restore", + latestAttemptedAction: "noop", + latestAttemptedOutcome: "noop", + refNoopCount: 1 + } + }); + expect(details?.warnings).toEqual( + expect.arrayContaining([ + expect.stringContaining("ref-local no-op attempt") + ]) + ); + }); + + it("distinguishes metadata-only updates from semantic overwrites in lifecycle reviewer surfaces", async () => { + const projectDir = await tempDir("cam-store-update-kind-project-"); + const memoryRoot = await tempDir("cam-store-update-kind-memory-"); + const config: AppConfig = { + autoMemoryEnabled: true, + autoMemoryDirectory: memoryRoot, + extractorMode: "heuristic", + defaultScope: "project", + maxStartupLines: 200, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false, + sessionContinuityLocalPathStyle: "codex", + maxSessionContinuityLines: 60, + codexBinary: "codex" + }; + const store = new MemoryStore(detectProjectContext(projectDir), config); + await store.ensureLayout(); + + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await store.applyMutations([ + { + action: "upsert", + scope: "project", + topic: "workflow", + id: "prefer-pnpm", + summary: "Prefer pnpm in this repository.", + details: ["Use pnpm instead of npm in this repository."], + reason: "Updated source note.", + sources: ["manual", "rollout.jsonl"] + } + ]); + await store.applyMutations([ + { + action: "upsert", + scope: "project", + topic: "workflow", + id: "prefer-pnpm", + summary: "Prefer pnpm and corepack in this repository.", + details: ["Use pnpm via corepack instead of npm in this repository."], + reason: "Semantic correction.", + sources: ["manual"] + } + ]); + + const ref = "project:active:workflow:prefer-pnpm"; + const timeline = await store.readTimelineWithDiagnostics(ref); + const details = await store.getEntryByRef(ref); + + expect(timeline.events.slice(0, 3)).toMatchObject([ + { action: "update", updateKind: "semantic-overwrite" }, + { action: "update", updateKind: "metadata-only" }, + { action: "add" } + ]); + expect(timeline.latestLifecycleAttempt).toMatchObject({ + action: "update", + outcome: "applied", + updateKind: "semantic-overwrite" + }); + expect(timeline.lineageSummary).toMatchObject({ + latestAction: "update", + latestAttemptedAction: "update", + latestAttemptedOutcome: "applied", + latestUpdateKind: "semantic-overwrite" + }); + expect(details).toMatchObject({ + latestLifecycleAction: "update", + latestLifecycleAttempt: { + action: "update", + outcome: "applied", + updateKind: "semantic-overwrite" + }, + lineageSummary: { + latestUpdateKind: "semantic-overwrite" + }, + entry: { + summary: "Prefer pnpm and corepack in this repository.", + details: ["Use pnpm via corepack instead of npm in this repository."] + } + }); + }); + it("maintains thin retrieval sidecar indexes and falls back safely when one is invalid", async () => { const projectDir = await tempDir("cam-store-retrieval-index-project-"); const memoryRoot = await tempDir("cam-store-retrieval-index-memory-"); @@ -854,8 +1026,14 @@ describe("MemoryStore", () => { previousState: undefined, nextState: undefined }); - expect(history).toHaveLength(1); - expect(history[0]?.action).toBe("add"); + expect(history).toHaveLength(2); + expect(history.map((event) => event.action)).toEqual(["noop", "add"]); + expect(history[0]).toMatchObject({ + action: "noop", + outcome: "noop", + previousState: "active", + nextState: "active" + }); }); it("rejects empty or whitespace-only forget queries at the store layer", async () => { diff --git a/test/recall-command.test.ts b/test/recall-command.test.ts index 146a728..313b7d1 100644 --- a/test/recall-command.test.ts +++ b/test/recall-command.test.ts @@ -31,12 +31,15 @@ afterEach(async () => { interface RecallSearchDiagnostics { anyMarkdownFallback: boolean; fallbackReasons: string[]; + executionModes?: string[]; checkedPaths: Array<{ scope: string; state: string; retrievalMode: string; retrievalFallbackReason?: string; matchedCount: number; + returnedCount?: number; + droppedCount?: number; indexPath: string; generatedAt: string | null; }>; @@ -84,6 +87,16 @@ describe("runRecall", () => { markdownFallbackUsed: boolean; retrievalMode: string; retrievalFallbackReason?: string; + stateResolution: { + outcome: string; + searchedStates: string[]; + resolutionReason: string; + }; + executionSummary: { + mode: string; + retrievalModes: string[]; + fallbackReasons: string[]; + }; diagnostics: RecallSearchDiagnostics; results: Array<{ ref: string; state: string; topic: string }>; }; @@ -93,11 +106,22 @@ describe("runRecall", () => { fallbackUsed: true, stateFallbackUsed: true, markdownFallbackUsed: false, - retrievalMode: "index" + retrievalMode: "index", + stateResolution: { + outcome: "archived-hit", + searchedStates: ["active", "archived"], + resolutionReason: "active-empty-archived-match-found" + }, + executionSummary: { + mode: "index-only", + retrievalModes: ["index"], + fallbackReasons: [] + } }); expect(output.diagnostics).toMatchObject({ anyMarkdownFallback: false, - fallbackReasons: [] + fallbackReasons: [], + executionModes: ["index"] }); expect(output.diagnostics.checkedPaths).toEqual( expect.arrayContaining([ @@ -106,6 +130,7 @@ describe("runRecall", () => { state: "archived", retrievalMode: "index", matchedCount: 9, + returnedCount: 8, indexPath: store.getRetrievalIndexFile("project", "archived"), generatedAt: expect.any(String) }) @@ -160,6 +185,16 @@ describe("runRecall", () => { stateFallbackUsed: boolean; markdownFallbackUsed: boolean; retrievalMode: string; + stateResolution: { + outcome: string; + searchedStates: string[]; + resolutionReason: string; + }; + executionSummary: { + mode: string; + retrievalModes: string[]; + fallbackReasons: string[]; + }; results: Array<{ ref: string; state: string; topic: string }>; }; expect(output).toMatchObject({ @@ -168,7 +203,17 @@ describe("runRecall", () => { fallbackUsed: false, stateFallbackUsed: false, markdownFallbackUsed: false, - retrievalMode: "index" + retrievalMode: "index", + stateResolution: { + outcome: "active-hit", + searchedStates: ["active"], + resolutionReason: "active-match-found" + }, + executionSummary: { + mode: "index-only", + retrievalModes: ["index"], + fallbackReasons: [] + } }); expect(output.results).toEqual([ expect.objectContaining({ @@ -179,6 +224,123 @@ describe("runRecall", () => { ]); }); + it("surfaces explicit-state contract for state=all searches and keeps checkedPaths ordered", async () => { + const homeDir = await tempDir("cam-recall-state-all-home-"); + const projectDir = await tempDir("cam-recall-state-all-project-"); + const memoryRoot = await tempDir("cam-recall-state-all-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm-active", + "Active pnpm workflow note.", + ["Use pnpm in this repository now."], + "Manual note." + ); + await store.remember( + "project", + "workflow", + "prefer-pnpm-archived", + "Archived pnpm migration note.", + ["Historical pnpm migration note."], + "Manual note." + ); + await store.forget("project", "Archived pnpm migration note", { archive: true }); + + const result = runCli( + projectDir, + ["recall", "search", "pnpm", "--state", "all", "--limit", "1", "--json"], + { env: { HOME: homeDir } } + ); + expect(result.exitCode).toBe(0); + + const output = JSON.parse(result.stdout) as { + state: string; + resolvedState: string; + searchOrder: string[]; + globalLimitApplied: boolean; + truncatedCount: number; + stateResolution: { + outcome: string; + searchedStates: string[]; + resolutionReason: string; + }; + executionSummary: { + mode: string; + retrievalModes: string[]; + fallbackReasons: string[]; + }; + diagnostics: RecallSearchDiagnostics; + results: Array<{ ref: string; state: string }>; + }; + + expect(output).toMatchObject({ + state: "all", + resolvedState: "all", + searchOrder: [ + "global:active", + "global:archived", + "project:active", + "project:archived", + "project-local:active", + "project-local:archived" + ], + globalLimitApplied: true, + truncatedCount: 1, + stateResolution: { + outcome: "explicit-state", + searchedStates: ["active", "archived"], + resolutionReason: "explicit-all-state-requested" + }, + executionSummary: { + mode: "index-only", + retrievalModes: ["index"], + fallbackReasons: [] + } + }); + const projectChecks = output.diagnostics.checkedPaths.filter((check) => check.scope === "project"); + expect(projectChecks).toMatchObject([ + { + scope: "project", + state: "active", + retrievalMode: "index", + matchedCount: 1 + }, + { + scope: "project", + state: "archived", + retrievalMode: "index", + matchedCount: 1 + } + ]); + expect(output.results).toHaveLength(1); + const returnedState = output.results[0]?.state; + expect(returnedState === "active" || returnedState === "archived").toBe(true); + const activeCheck = projectChecks.find((check) => check.state === "active"); + const archivedCheck = projectChecks.find((check) => check.state === "archived"); + expect((activeCheck?.returnedCount ?? 0) + (archivedCheck?.returnedCount ?? 0)).toBe(1); + expect( + returnedState === "active" ? activeCheck?.returnedCount : archivedCheck?.returnedCount + ).toBe(1); + expect( + returnedState === "active" ? activeCheck?.droppedCount : archivedCheck?.droppedCount + ).toBe(0); + expect( + returnedState === "active" ? archivedCheck?.droppedCount : activeCheck?.droppedCount + ).toBe(1); + }); + it("falls back to archived memory when search state is auto and active memory has no match", async () => { const homeDir = await tempDir("cam-recall-auto-archived-home-"); const projectDir = await tempDir("cam-recall-auto-archived-project-"); @@ -223,6 +385,16 @@ describe("runRecall", () => { stateFallbackUsed: boolean; markdownFallbackUsed: boolean; retrievalMode: string; + stateResolution: { + outcome: string; + searchedStates: string[]; + resolutionReason: string; + }; + executionSummary: { + mode: string; + retrievalModes: string[]; + fallbackReasons: string[]; + }; results: Array<{ ref: string; state: string; topic: string }>; }; expect(searchOutput).toMatchObject({ @@ -231,7 +403,17 @@ describe("runRecall", () => { fallbackUsed: true, stateFallbackUsed: true, markdownFallbackUsed: false, - retrievalMode: "index" + retrievalMode: "index", + stateResolution: { + outcome: "archived-hit", + searchedStates: ["active", "archived"], + resolutionReason: "active-empty-archived-match-found" + }, + executionSummary: { + mode: "index-only", + retrievalModes: ["index"], + fallbackReasons: [] + } }); expect(searchOutput.results).toEqual([ expect.objectContaining({ @@ -696,6 +878,91 @@ describe("runRecall", () => { }); }); + it("keeps details provenance aligned with the latest noop attempt from sync history", async () => { + const homeDir = await tempDir("cam-recall-noop-provenance-home-"); + const projectDir = await tempDir("cam-recall-noop-provenance-project-"); + const memoryRoot = await tempDir("cam-recall-noop-provenance-memory-"); + const rolloutPath = path.join(projectDir, "rollout.jsonl"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + await fs.writeFile( + rolloutPath, + makeRolloutFixture(projectDir, "Remember that this repository prefers pnpm.", { + sessionId: "session-noop-provenance" + }), + "utf8" + ); + + const project = detectProjectContext(projectDir); + const service = new SyncService(project, { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await service.syncRollout(rolloutPath, true); + const existingEntry = (await service.memoryStore.listEntries("project")).find( + (entry) => entry.topic === "preferences" && entry.id === "this-repository-prefers-pnpm" + ); + expect(existingEntry).toBeTruthy(); + await service.memoryStore.applyMutations( + [ + { + action: "upsert", + scope: "project", + topic: "preferences", + id: "this-repository-prefers-pnpm", + summary: existingEntry!.summary, + details: existingEntry!.details, + sources: existingEntry!.sources, + reason: existingEntry!.reason + } + ], + { + sessionId: "session-noop-provenance", + rolloutPath + } + ); + + const searchResult = runCli(projectDir, ["recall", "search", "prefers pnpm", "--json"]); + expect(searchResult.exitCode).toBe(0); + const searchOutput = JSON.parse(searchResult.stdout) as { + results: Array<{ ref: string }>; + }; + const ref = searchOutput.results[0]?.ref; + expect(ref).toBeTruthy(); + + const detailsResult = runCli(projectDir, ["recall", "details", ref!, "--json"]); + expect(detailsResult.exitCode).toBe(0); + expect(JSON.parse(detailsResult.stdout)).toMatchObject({ + ref, + latestLifecycleAction: "add", + latestLifecycleAttempt: { + action: "noop", + outcome: "noop", + sessionId: "session-noop-provenance", + rolloutPath + }, + latestSessionId: "session-noop-provenance", + latestRolloutPath: rolloutPath, + latestAudit: { + sessionId: "session-noop-provenance", + rolloutPath, + status: "applied", + matchedOperationCount: 1 + }, + lineageSummary: { + latestAction: "add", + latestAttemptedAction: "noop", + latestAttemptedOutcome: "noop", + refNoopCount: 1 + } + }); + }); + it("keeps recall search read-only and does not create memory layout on first lookup", async () => { const homeDir = await tempDir("cam-recall-readonly-home-"); const projectDir = await tempDir("cam-recall-readonly-project-"); @@ -718,6 +985,16 @@ describe("runRecall", () => { markdownFallbackUsed: boolean; retrievalMode: string; retrievalFallbackReason?: string; + stateResolution: { + outcome: string; + searchedStates: string[]; + resolutionReason: string; + }; + executionSummary: { + mode: string; + retrievalModes: string[]; + fallbackReasons: string[]; + }; diagnostics: RecallSearchDiagnostics; results: unknown[]; }; @@ -729,11 +1006,22 @@ describe("runRecall", () => { markdownFallbackUsed: true, retrievalMode: "markdown-fallback", retrievalFallbackReason: "missing", + stateResolution: { + outcome: "miss-after-both", + searchedStates: ["active", "archived"], + resolutionReason: "no-match-after-auto-search" + }, + executionSummary: { + mode: "markdown-fallback-only", + retrievalModes: ["markdown-fallback"], + fallbackReasons: ["missing"] + }, results: [] }); expect(output.diagnostics).toMatchObject({ anyMarkdownFallback: true, - fallbackReasons: ["missing"] + fallbackReasons: ["missing"], + executionModes: ["markdown-fallback"] }); expect(output.diagnostics.checkedPaths).toEqual( expect.arrayContaining([ @@ -742,14 +1030,16 @@ describe("runRecall", () => { state: "active", retrievalMode: "markdown-fallback", retrievalFallbackReason: "missing", - matchedCount: 0 + matchedCount: 0, + returnedCount: 0 }), expect.objectContaining({ scope: "project", state: "archived", retrievalMode: "markdown-fallback", retrievalFallbackReason: "missing", - matchedCount: 0 + matchedCount: 0, + returnedCount: 0 }) ]) ); @@ -858,7 +1148,13 @@ describe("runRecall", () => { expect(JSON.parse(result.stdout)).toMatchObject({ retrievalMode: "markdown-fallback", retrievalFallbackReason: "stale", + executionSummary: { + mode: "mixed", + retrievalModes: ["index", "markdown-fallback"], + fallbackReasons: ["stale"] + }, diagnostics: { + executionModes: ["index", "markdown-fallback"], checkedPaths: expect.arrayContaining([ expect.objectContaining({ scope: "project", @@ -866,6 +1162,7 @@ describe("runRecall", () => { retrievalMode: "markdown-fallback", retrievalFallbackReason: "stale", matchedCount: 1, + returnedCount: 1, indexPath: store.getRetrievalIndexFile("project", "active"), generatedAt: expect.any(String) }) @@ -874,4 +1171,75 @@ describe("runRecall", () => { results: [expect.objectContaining({ ref: "project:active:workflow:prefer-pnpm" })] }); }); + + it("reports mixed execution summary when auto search falls back from markdown active lookup to archived index results", async () => { + const homeDir = await tempDir("cam-recall-mixed-execution-home-"); + const projectDir = await tempDir("cam-recall-mixed-execution-project-"); + const memoryRoot = await tempDir("cam-recall-mixed-execution-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "historical-pnpm", + "Historical pnpm migration note.", + ["Old pnpm migration note kept for history."], + "Manual note." + ); + await store.forget("project", "historical pnpm", { archive: true }); + await fs.writeFile(store.getRetrievalIndexFile("project", "active"), "{not-json", "utf8"); + + const result = runCli(projectDir, ["recall", "search", "historical", "--state", "auto", "--json"]); + expect(result.exitCode).toBe(0); + + expect(JSON.parse(result.stdout)).toMatchObject({ + state: "auto", + resolvedState: "archived", + stateResolution: { + outcome: "archived-hit", + searchedStates: ["active", "archived"], + resolutionReason: "active-empty-archived-match-found" + }, + retrievalMode: "index", + markdownFallbackUsed: true, + executionSummary: { + mode: "mixed", + retrievalModes: ["index", "markdown-fallback"], + fallbackReasons: ["invalid"] + }, + diagnostics: { + anyMarkdownFallback: true, + fallbackReasons: ["invalid"], + executionModes: ["index", "markdown-fallback"], + checkedPaths: expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + state: "active", + retrievalMode: "markdown-fallback", + retrievalFallbackReason: "invalid", + matchedCount: 0, + returnedCount: 0 + }), + expect.objectContaining({ + scope: "project", + state: "archived", + retrievalMode: "index", + matchedCount: 1, + returnedCount: 1 + }) + ]) + }, + results: [expect.objectContaining({ ref: "project:archived:workflow:historical-pnpm" })] + }); + }); }); From a5174171bb9fbc886856fabfe1505a4e04806964 Mon Sep 17 00:00:00 2001 From: blocks Date: Sat, 28 Mar 2026 20:30:42 +0800 Subject: [PATCH 17/62] feat: deepen manual mutation reviewer lifecycle contracts --- src/lib/commands/manual-mutation-review.ts | 31 ++++++ src/lib/commands/recall.ts | 17 ++++ src/lib/domain/memory-retrieval-contract.ts | 7 ++ src/lib/domain/memory-store.ts | 46 +++++++-- src/lib/mcp/retrieval-server.ts | 15 +++ src/lib/types.ts | 15 +++ test/memory-command.test.ts | 104 ++++++++++++++++++++ test/memory-store.test.ts | 14 ++- 8 files changed, 241 insertions(+), 8 deletions(-) diff --git a/src/lib/commands/manual-mutation-review.ts b/src/lib/commands/manual-mutation-review.ts index b6f4ef6..21c8ed5 100644 --- a/src/lib/commands/manual-mutation-review.ts +++ b/src/lib/commands/manual-mutation-review.ts @@ -1,6 +1,7 @@ import { buildMemoryRef } from "../domain/memory-lifecycle.js"; import type { MemoryStore } from "../domain/memory-store.js"; import type { + MemoryAppliedLifecycle, MemoryApplyRecord, MemoryDetailsResult, MemoryEntry, @@ -14,6 +15,8 @@ import type { export interface ManualMutationReviewEntry { ref: string; + timelineRef: string; + detailsRef: string | null; scope: MemoryScope; state: "active" | "archived"; topic: string; @@ -22,6 +25,7 @@ export interface ManualMutationReviewEntry { historyPath: string; lifecycleAction: MemoryLifecycleAction; latestLifecycleAction: Exclude | null; + latestAppliedLifecycle: MemoryAppliedLifecycle | null; latestLifecycleAttempt: MemoryLifecycleAttempt | null; latestState: MemoryHistoryRecordState; latestSessionId: string | null; @@ -65,6 +69,8 @@ function buildFallbackDetails( const { operation } = record; return store.readTimelineWithDiagnostics(ref).then((timeline) => ({ ref, + timelineRef: ref, + detailsRef: state === "active" ? null : ref, scope: operation.scope, state, topic: operation.topic, @@ -76,6 +82,7 @@ function buildFallbackDetails( timeline.latestEvent && timeline.latestEvent.action !== "noop" ? timeline.latestEvent.action : null, + latestAppliedLifecycle: timeline.latestAppliedLifecycle, latestLifecycleAttempt: timeline.latestLifecycleAttempt, latestState: timeline.lineageSummary.latestState ?? (record.nextState ?? state), latestSessionId: timeline.latestAttempt?.sessionId ?? null, @@ -111,6 +118,8 @@ export async function buildManualMutationReviewEntry( if (details) { return { ref, + timelineRef: details.ref, + detailsRef: details.ref, scope: details.scope, state: details.state, topic: details.topic, @@ -119,6 +128,7 @@ export async function buildManualMutationReviewEntry( historyPath: details.historyPath, lifecycleAction: record.lifecycleAction, latestLifecycleAction: details.latestLifecycleAction, + latestAppliedLifecycle: details.latestAppliedLifecycle, latestLifecycleAttempt: details.latestLifecycleAttempt, latestState: details.latestState, latestSessionId: details.latestLifecycleAttempt?.sessionId ?? details.latestSessionId, @@ -144,15 +154,27 @@ export function toManualMutationRememberPayload( ): Record { return { action: "remember", + mutationKind: "remember", + matchedCount: 1, + appliedCount: entry.lifecycleAction === "noop" ? 0 : 1, + noopCount: entry.lifecycleAction === "noop" ? 1 : 0, + affectedRefs: [entry.ref], + followUp: { + timelineRefs: [entry.timelineRef], + detailsRefs: entry.detailsRef ? [entry.detailsRef] : [] + }, text, scope: entry.scope, topic: entry.topic, id: entry.id, ref: entry.ref, + timelineRef: entry.timelineRef, + detailsRef: entry.detailsRef, path: entry.path, historyPath: entry.historyPath, lifecycleAction: entry.lifecycleAction, latestLifecycleAction: entry.latestLifecycleAction, + latestAppliedLifecycle: entry.latestAppliedLifecycle, latestLifecycleAttempt: entry.latestLifecycleAttempt, latestState: entry.latestState, latestSessionId: entry.latestSessionId, @@ -173,10 +195,19 @@ export function toManualMutationForgetPayload( ): Record { return { action: "forget", + mutationKind: "forget", query, scope, archive, + matchedCount: entries.length, + appliedCount: entries.filter((entry) => entry.lifecycleAction !== "noop").length, + noopCount: entries.filter((entry) => entry.lifecycleAction === "noop").length, affectedCount: entries.length, + affectedRefs: entries.map((entry) => entry.ref), + followUp: { + timelineRefs: entries.map((entry) => entry.timelineRef), + detailsRefs: entries.flatMap((entry) => (entry.detailsRef ? [entry.detailsRef] : [])) + }, entries }; } diff --git a/src/lib/commands/recall.ts b/src/lib/commands/recall.ts index 71b1736..769cbbd 100644 --- a/src/lib/commands/recall.ts +++ b/src/lib/commands/recall.ts @@ -108,6 +108,15 @@ function formatTimeline(timeline: MemoryTimelineResponse): string { ); } + if (timeline.latestAppliedLifecycle) { + lines.push( + "", + "Latest applied lifecycle:", + `- ${timeline.latestAppliedLifecycle.at}: [${timeline.latestAppliedLifecycle.action}] ${timeline.latestAppliedLifecycle.summary}`, + `- State: ${timeline.latestAppliedLifecycle.state ?? "unknown"} | Previous: ${timeline.latestAppliedLifecycle.previousState ?? "n/a"} | Next: ${timeline.latestAppliedLifecycle.nextState ?? "n/a"} | Update kind: ${timeline.latestAppliedLifecycle.updateKind ?? "n/a"}` + ); + } + if (timeline.events.length === 0) { lines.push("", "No timeline events were recorded for this memory ref."); return lines.join("\n"); @@ -194,6 +203,14 @@ function formatDetails(details: MemoryDetailsResult): string { ); } + if (details.latestAppliedLifecycle) { + lines.push( + "Latest applied lifecycle:", + `- ${details.latestAppliedLifecycle.at}: [${details.latestAppliedLifecycle.action}] ${details.latestAppliedLifecycle.summary}`, + `- State: ${details.latestAppliedLifecycle.state ?? "unknown"} | Previous: ${details.latestAppliedLifecycle.previousState ?? "n/a"} | Next: ${details.latestAppliedLifecycle.nextState ?? "n/a"} | Update kind: ${details.latestAppliedLifecycle.updateKind ?? "n/a"}` + ); + } + if (details.warnings.length > 0) { lines.push("Warnings:", ...details.warnings.map((warning) => `- ${warning}`)); } diff --git a/src/lib/domain/memory-retrieval-contract.ts b/src/lib/domain/memory-retrieval-contract.ts index 32ec1d2..7d724e8 100644 --- a/src/lib/domain/memory-retrieval-contract.ts +++ b/src/lib/domain/memory-retrieval-contract.ts @@ -158,6 +158,10 @@ export function buildMemoryTimelineResponse( ref, events: [...timeline.events], warnings: [...(timeline.warnings ?? [])], + latestAppliedLifecycle: + "latestAppliedLifecycle" in timeline && timeline.latestAppliedLifecycle + ? { ...timeline.latestAppliedLifecycle } + : null, latestLifecycleAttempt: "latestLifecycleAttempt" in timeline && timeline.latestLifecycleAttempt ? { ...timeline.latestLifecycleAttempt } @@ -249,6 +253,9 @@ export function toMemoryDetailsResultShape(details: MemoryDetailsResult): Memory lineageSummary: { ...details.lineageSummary }, + latestAppliedLifecycle: details.latestAppliedLifecycle + ? { ...details.latestAppliedLifecycle } + : null, latestLifecycleAttempt: details.latestLifecycleAttempt ? { ...details.latestLifecycleAttempt } : null, diff --git a/src/lib/domain/memory-store.ts b/src/lib/domain/memory-store.ts index 609b91e..c1b94aa 100644 --- a/src/lib/domain/memory-store.ts +++ b/src/lib/domain/memory-store.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { DEFAULT_MEMORY_TOPICS } from "../constants.js"; import type { + MemoryAppliedLifecycle, AppConfig, MemoryApplyRecord, MemoryDetailsResult, @@ -472,7 +473,7 @@ function buildLineageSummary( latestAttemptedAction: latestAttempt?.action ?? null, latestAttemptedState: latestAttempt?.state ?? null, latestAttemptedOutcome: latestAttempt?.outcome ?? null, - latestUpdateKind: latestAttempt?.updateKind ?? null, + latestUpdateKind: null, latestAuditStatus: latestAudit?.status ?? null, refNoopCount, matchedAuditOperationCount: latestAudit?.matchedOperationCount ?? 0, @@ -502,7 +503,8 @@ function buildLineageSummary( latestAttemptedAction: latestAttempt?.action ?? null, latestAttemptedState: latestAttempt?.state ?? null, latestAttemptedOutcome: latestAttempt?.outcome ?? null, - latestUpdateKind: latestAttempt?.updateKind ?? null, + latestUpdateKind: + latestEvent?.updateKind ?? (latestEvent?.action === "restore" ? "restore" : null), archivedAt: archivedEvent?.at ?? null, deletedAt: deletedEvent?.at ?? null, latestAuditStatus: latestAudit?.status ?? null, @@ -532,6 +534,27 @@ function buildLatestLifecycleAttempt( previousState: event.previousState ?? null, nextState: event.nextState ?? null, summary: event.summary, + updateKind: event.updateKind ?? (event.action === "restore" ? "restore" : null), + sessionId: event.sessionId ?? null, + rolloutPath: event.rolloutPath ?? null + }; +} + +function buildLatestAppliedLifecycle( + event: MemoryTimelineEvent | null +): MemoryAppliedLifecycle | null { + if (!event || event.action === "noop") { + return null; + } + + return { + at: event.at, + action: event.action, + outcome: "applied", + state: event.state, + previousState: event.previousState ?? null, + nextState: event.nextState ?? null, + summary: event.summary, updateKind: event.updateKind ?? null, sessionId: event.sessionId ?? null, rolloutPath: event.rolloutPath ?? null @@ -1399,13 +1422,14 @@ export class MemoryStore { approxReadCost: entry.details.length + 4, latestLifecycleAction: latestEvent && latestEvent.action !== "noop" ? latestEvent.action : null, + latestAppliedLifecycle: buildLatestAppliedLifecycle(latestEvent), latestLifecycleAttempt: buildLatestLifecycleAttempt(latestAttempt), latestState: latestEvent?.state ?? parsed.state, latestSessionId: latestAttempt?.sessionId ?? latestEvent?.sessionId ?? null, latestRolloutPath: latestAttempt?.rolloutPath ?? latestEvent?.rolloutPath ?? null, historyPath: this.getHistoryPath(parsed.scope), latestAudit, - timelineWarningCount: timeline.warnings.length, + timelineWarningCount: warnings.length, lineageSummary: { ...timeline.lineageSummary, latestState: timeline.lineageSummary.latestState ?? parsed.state @@ -1576,6 +1600,7 @@ export class MemoryStore { lineageSummary: buildEmptyLineageSummary(), latestAudit: null, latestEvent: null, + latestAppliedLifecycle: null, latestAttempt: null, latestLifecycleAttempt: null }; @@ -1597,7 +1622,8 @@ export class MemoryStore { parsed.topic, parsed.id, latestAttempt?.rolloutPath, - latestAttempt?.sessionId + latestAttempt?.sessionId, + latestAttempt?.action === "noop" ); const warnings = [...history.warnings]; if (latestAttempt && !latestAudit && (latestAttempt.rolloutPath || latestAttempt.sessionId)) { @@ -1618,6 +1644,7 @@ export class MemoryStore { lineageSummary: buildLineageSummary(matchingEvents, latestAudit, latestAttempt), latestAudit, latestEvent, + latestAppliedLifecycle: buildLatestAppliedLifecycle(latestEvent), latestAttempt, latestLifecycleAttempt: buildLatestLifecycleAttempt(latestAttempt) }; @@ -2397,7 +2424,8 @@ export class MemoryStore { topic: string, id: string, latestRolloutPath?: string, - latestSessionId?: string + latestSessionId?: string, + allowNoopProvenanceMatch = false ): Promise { if (latestRolloutPath === undefined && latestSessionId === undefined) { return null; @@ -2415,8 +2443,12 @@ export class MemoryStore { return false; } - return entry.operations.some( - (operation) => operation.scope === scope && operation.topic === topic && operation.id === id + return ( + entry.operations.some( + (operation) => + operation.scope === scope && operation.topic === topic && operation.id === id + ) || + (allowNoopProvenanceMatch && (entry.noopOperationCount ?? 0) > 0) ); }); diff --git a/src/lib/mcp/retrieval-server.ts b/src/lib/mcp/retrieval-server.ts index 4f1e086..fd4beae 100644 --- a/src/lib/mcp/retrieval-server.ts +++ b/src/lib/mcp/retrieval-server.ts @@ -123,6 +123,19 @@ const memoryLifecycleAttemptSchema = z.object({ rolloutPath: z.string().nullable() }); +const memoryAppliedLifecycleSchema = z.object({ + at: z.string(), + action: appliedMemoryLifecycleActionSchema, + outcome: z.literal("applied"), + state: memoryHistoryRecordStateSchema.nullable(), + previousState: memoryHistoryRecordStateSchema.nullable(), + nextState: memoryHistoryRecordStateSchema.nullable(), + summary: z.string(), + updateKind: memoryLifecycleUpdateKindSchema.nullable(), + sessionId: z.string().nullable(), + rolloutPath: z.string().nullable() +}); + const memoryLineageSummarySchema = z.object({ eventCount: z.number().int().nonnegative(), firstSeenAt: z.string().nullable(), @@ -150,6 +163,7 @@ const memoryTimelineResponseSchema = z.object({ ref: z.string(), events: z.array(memoryTimelineEventSchema), warnings: z.array(z.string()), + latestAppliedLifecycle: memoryAppliedLifecycleSchema.nullable(), latestLifecycleAttempt: memoryLifecycleAttemptSchema.nullable(), lineageSummary: memoryLineageSummarySchema }); @@ -163,6 +177,7 @@ const memoryDetailsResponseSchema = z.object({ path: z.string(), approxReadCost: z.number().int().nonnegative(), latestLifecycleAction: appliedMemoryLifecycleActionSchema.nullable(), + latestAppliedLifecycle: memoryAppliedLifecycleSchema.nullable(), latestLifecycleAttempt: memoryLifecycleAttemptSchema.nullable(), latestState: memoryHistoryRecordStateSchema, latestSessionId: z.string().nullable(), diff --git a/src/lib/types.ts b/src/lib/types.ts index c50b944..23bb247 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -163,11 +163,25 @@ export interface MemoryLifecycleAttempt { rolloutPath: string | null; } +export interface MemoryAppliedLifecycle { + at: string; + action: Exclude; + outcome: "applied"; + state: MemoryHistoryRecordState | null; + previousState: MemoryHistoryRecordState | null; + nextState: MemoryHistoryRecordState | null; + summary: string; + updateKind: MemoryLifecycleUpdateKind | null; + sessionId: string | null; + rolloutPath: string | null; +} + export interface MemoryTimelineResponse { ref: string; events: MemoryTimelineEvent[]; warnings: string[]; lineageSummary: MemoryLineageSummary; + latestAppliedLifecycle: MemoryAppliedLifecycle | null; latestLifecycleAttempt: MemoryLifecycleAttempt | null; } @@ -199,6 +213,7 @@ export interface MemoryDetailsResult extends MemoryRef { path: string; approxReadCost: number; latestLifecycleAction: Exclude | null; + latestAppliedLifecycle: MemoryAppliedLifecycle | null; latestLifecycleAttempt: MemoryLifecycleAttempt | null; latestState: MemoryHistoryRecordState; latestSessionId: string | null; diff --git a/test/memory-command.test.ts b/test/memory-command.test.ts index 65097a6..876534f 100644 --- a/test/memory-command.test.ts +++ b/test/memory-command.test.ts @@ -1279,6 +1279,15 @@ describe("runMemory", () => { const payload = JSON.parse(result.stdout) as { action: string; + mutationKind: string; + matchedCount: number; + appliedCount: number; + noopCount: number; + affectedRefs: string[]; + followUp: { + timelineRefs: string[]; + detailsRefs: string[]; + }; scope: string; topic: string; id: string; @@ -1296,6 +1305,10 @@ describe("runMemory", () => { expect(payload).toMatchObject({ action: "remember", + mutationKind: "remember", + matchedCount: 1, + appliedCount: 1, + noopCount: 0, scope: "project", topic: "workflow", id: "prefer-pnpm-in-this-repository", @@ -1318,6 +1331,9 @@ describe("runMemory", () => { }, warnings: [] }); + expect(payload.affectedRefs).toEqual([payload.ref]); + expect(payload.followUp.timelineRefs).toEqual([payload.ref]); + expect(payload.followUp.detailsRefs).toEqual([payload.ref]); expect(payload.path).toContain(path.join("workflow.md")); expect(payload.historyPath).toContain(path.join("project", "memory-history.jsonl")); }); @@ -1355,12 +1371,23 @@ describe("runMemory", () => { const payload = JSON.parse(result.stdout) as { action: string; + mutationKind: string; query: string; scope: string; archive: boolean; + matchedCount: number; + appliedCount: number; + noopCount: number; affectedCount: number; + affectedRefs: string[]; + followUp: { + timelineRefs: string[]; + detailsRefs: string[]; + }; entries: Array<{ ref: string; + timelineRef: string; + detailsRef: string | null; lifecycleAction: string; latestState: string; latestLifecycleAttempt: { action: string; outcome: string; updateKind: string | null } | null; @@ -1370,13 +1397,19 @@ describe("runMemory", () => { expect(payload).toMatchObject({ action: "forget", + mutationKind: "forget", query: "pnpm", scope: "project", archive: true, + matchedCount: 1, + appliedCount: 1, + noopCount: 0, affectedCount: 1, entries: [ { ref: "project:archived:workflow:prefer-pnpm", + timelineRef: "project:archived:workflow:prefer-pnpm", + detailsRef: "project:archived:workflow:prefer-pnpm", lifecycleAction: "archive", latestState: "archived", latestLifecycleAttempt: { @@ -1391,6 +1424,77 @@ describe("runMemory", () => { } ] }); + expect(payload.affectedRefs).toEqual(["project:archived:workflow:prefer-pnpm"]); + expect(payload.followUp.timelineRefs).toEqual(["project:archived:workflow:prefer-pnpm"]); + expect(payload.followUp.detailsRefs).toEqual(["project:archived:workflow:prefer-pnpm"]); + }); + + it("surfaces delete-only review routes for forget --json when details are no longer available", async () => { + const homeDir = await tempDir("cam-forget-delete-json-home-"); + const projectDir = await tempDir("cam-forget-delete-json-project-"); + const memoryRoot = await tempDir("cam-forget-delete-json-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const project = detectProjectContext(projectDir); + const store = new MemoryStore(project, { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const result = runCli(projectDir, ["forget", "pnpm", "--scope", "project", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + mutationKind: string; + matchedCount: number; + appliedCount: number; + noopCount: number; + affectedRefs: string[]; + followUp: { + timelineRefs: string[]; + detailsRefs: string[]; + }; + entries: Array<{ + ref: string; + timelineRef: string; + detailsRef: string | null; + }>; + }; + + expect(payload).toMatchObject({ + mutationKind: "forget", + matchedCount: 1, + appliedCount: 1, + noopCount: 0, + affectedRefs: ["project:active:workflow:prefer-pnpm"], + followUp: { + timelineRefs: ["project:active:workflow:prefer-pnpm"], + detailsRefs: [] + }, + entries: [ + { + ref: "project:active:workflow:prefer-pnpm", + timelineRef: "project:active:workflow:prefer-pnpm", + detailsRef: null + } + ] + }); }); it("rebuilds retrieval sidecars explicitly from canonical Markdown memory", async () => { diff --git a/test/memory-store.test.ts b/test/memory-store.test.ts index 1d386ee..50a674b 100644 --- a/test/memory-store.test.ts +++ b/test/memory-store.test.ts @@ -484,11 +484,16 @@ describe("MemoryStore", () => { latestAction: "restore", latestAttemptedAction: "noop", latestAttemptedOutcome: "noop", - latestUpdateKind: null, + latestUpdateKind: "restore", refNoopCount: 1 }); expect(details).toMatchObject({ latestLifecycleAction: "restore", + latestAppliedLifecycle: { + action: "restore", + outcome: "applied", + state: "active" + }, latestLifecycleAttempt: { action: "noop", outcome: "noop", @@ -498,9 +503,11 @@ describe("MemoryStore", () => { latestAction: "restore", latestAttemptedAction: "noop", latestAttemptedOutcome: "noop", + latestUpdateKind: "restore", refNoopCount: 1 } }); + expect(details?.timelineWarningCount).toBe(details?.warnings.length); expect(details?.warnings).toEqual( expect.arrayContaining([ expect.stringContaining("ref-local no-op attempt") @@ -581,6 +588,11 @@ describe("MemoryStore", () => { }); expect(details).toMatchObject({ latestLifecycleAction: "update", + latestAppliedLifecycle: { + action: "update", + outcome: "applied", + updateKind: "semantic-overwrite" + }, latestLifecycleAttempt: { action: "update", outcome: "applied", From c7d1bada316f6059f4b7b6cac176ba05335b6335 Mon Sep 17 00:00:00 2001 From: blocks Date: Wed, 8 Apr 2026 20:55:41 +0800 Subject: [PATCH 18/62] test: align reviewer workflow print-config contracts --- test/dist-cli-smoke.test.ts | 24 +++++++++++++++++++++--- test/mcp-command.test.ts | 8 +++++++- test/tarball-install-smoke.test.ts | 24 +++++++++++++++++++++--- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/test/dist-cli-smoke.test.ts b/test/dist-cli-smoke.test.ts index 470a71c..116d07f 100644 --- a/test/dist-cli-smoke.test.ts +++ b/test/dist-cli-smoke.test.ts @@ -500,7 +500,13 @@ describe("dist cli smoke", () => { serverName: "codex_auto_memory", targetFileHint: ".mcp.json" }); - expect(JSON.parse(claudeResult.stdout).workflowContract).toBeUndefined(); + expect(JSON.parse(claudeResult.stdout).workflowContract).toMatchObject({ + cliFallback: { + searchCommand: expect.any(String), + timelineCommand: expect.any(String), + detailsCommand: expect.any(String) + } + }); const geminiResult = runCli( projectDir, @@ -518,7 +524,13 @@ describe("dist cli smoke", () => { serverName: "codex_auto_memory", targetFileHint: ".gemini/settings.json" }); - expect(JSON.parse(geminiResult.stdout).workflowContract).toBeUndefined(); + expect(JSON.parse(geminiResult.stdout).workflowContract).toMatchObject({ + cliFallback: { + searchCommand: expect.any(String), + timelineCommand: expect.any(String), + detailsCommand: expect.any(String) + } + }); const genericResult = runCli( projectDir, @@ -537,7 +549,13 @@ describe("dist cli smoke", () => { targetFileHint: "Your MCP client's stdio server config", snippetFormat: "json" }); - expect(JSON.parse(genericResult.stdout).workflowContract).toBeUndefined(); + expect(JSON.parse(genericResult.stdout).workflowContract).toMatchObject({ + cliFallback: { + searchCommand: expect.any(String), + timelineCommand: expect.any(String), + detailsCommand: expect.any(String) + } + }); }); it("rejects generic MCP install from the compiled cli entrypoint because wiring stays manual-only", async () => { diff --git a/test/mcp-command.test.ts b/test/mcp-command.test.ts index 330dcc2..a2b359c 100644 --- a/test/mcp-command.test.ts +++ b/test/mcp-command.test.ts @@ -1259,7 +1259,13 @@ describe("mcp command", () => { readOnlyRetrieval: true }); expect(payload.snippet).toContain(realProjectDir); - expect(payload.workflowContract).toBeUndefined(); + expect(payload.workflowContract).toMatchObject({ + cliFallback: { + searchCommand: expect.stringContaining(realProjectDir), + timelineCommand: expect.stringContaining(realProjectDir), + detailsCommand: expect.stringContaining(realProjectDir) + } + }); }); it("pins Codex AGENTS guidance fallback commands when print-config uses --cwd", async () => { diff --git a/test/tarball-install-smoke.test.ts b/test/tarball-install-smoke.test.ts index 2462c6d..60ae95c 100644 --- a/test/tarball-install-smoke.test.ts +++ b/test/tarball-install-smoke.test.ts @@ -296,7 +296,13 @@ describe("tarball install smoke", () => { readOnlyRetrieval: true, targetFileHint: ".mcp.json" }); - expect(JSON.parse(claudePrintConfigResult.stdout).workflowContract).toBeUndefined(); + expect(JSON.parse(claudePrintConfigResult.stdout).workflowContract).toMatchObject({ + cliFallback: { + searchCommand: expect.any(String), + timelineCommand: expect.any(String), + detailsCommand: expect.any(String) + } + }); const geminiPrintConfigResult = runCommandCapture( camBinaryPath(installDir), ["mcp", "print-config", "--host", "gemini", "--json"], @@ -309,7 +315,13 @@ describe("tarball install smoke", () => { readOnlyRetrieval: true, targetFileHint: ".gemini/settings.json" }); - expect(JSON.parse(geminiPrintConfigResult.stdout).workflowContract).toBeUndefined(); + expect(JSON.parse(geminiPrintConfigResult.stdout).workflowContract).toMatchObject({ + cliFallback: { + searchCommand: expect.any(String), + timelineCommand: expect.any(String), + detailsCommand: expect.any(String) + } + }); const genericPrintConfigResult = runCommandCapture( camBinaryPath(installDir), ["mcp", "print-config", "--host", "generic", "--json"], @@ -322,7 +334,13 @@ describe("tarball install smoke", () => { readOnlyRetrieval: true, targetFileHint: "Your MCP client's stdio server config" }); - expect(JSON.parse(genericPrintConfigResult.stdout).workflowContract).toBeUndefined(); + expect(JSON.parse(genericPrintConfigResult.stdout).workflowContract).toMatchObject({ + cliFallback: { + searchCommand: expect.any(String), + timelineCommand: expect.any(String), + detailsCommand: expect.any(String) + } + }); const applyGuidanceResult = runCommandCapture( camBinaryPath(installDir), ["mcp", "apply-guidance", "--host", "codex", "--json"], From 937a90b3f9334bae56016e2f843c9b05545bf1de Mon Sep 17 00:00:00 2001 From: blocks Date: Wed, 8 Apr 2026 21:37:59 +0800 Subject: [PATCH 19/62] fix: resolve PR4 reviewer and workflow contract gaps --- src/lib/domain/memory-store.ts | 2 +- src/lib/integration/retrieval-contract.ts | 33 ++++++++++++++++++----- test/integrations-command.test.ts | 4 ++- test/mcp-config.test.ts | 20 +++++++++++--- 4 files changed, 46 insertions(+), 13 deletions(-) diff --git a/src/lib/domain/memory-store.ts b/src/lib/domain/memory-store.ts index c1b94aa..87071c4 100644 --- a/src/lib/domain/memory-store.ts +++ b/src/lib/domain/memory-store.ts @@ -555,7 +555,7 @@ function buildLatestAppliedLifecycle( previousState: event.previousState ?? null, nextState: event.nextState ?? null, summary: event.summary, - updateKind: event.updateKind ?? null, + updateKind: event.updateKind ?? (event.action === "restore" ? "restore" : null), sessionId: event.sessionId ?? null, rolloutPath: event.rolloutPath ?? null }; diff --git a/src/lib/integration/retrieval-contract.ts b/src/lib/integration/retrieval-contract.ts index be7949f..05409cc 100644 --- a/src/lib/integration/retrieval-contract.ts +++ b/src/lib/integration/retrieval-contract.ts @@ -136,7 +136,19 @@ function isExecutableOnPath(commandName: string): boolean { : [commandName]; return pathValue.split(path.delimiter).some((directory) => - executableNames.some((candidate) => fs.existsSync(path.join(directory, candidate))) + executableNames.some((candidate) => { + const candidatePath = path.join(directory, candidate); + try { + const stat = fs.statSync(candidatePath); + if (!stat.isFile()) { + return false; + } + fs.accessSync(candidatePath, fs.constants.X_OK); + return true; + } catch { + return false; + } + }) ); } @@ -199,9 +211,11 @@ export function buildResolvedCliCommand( command: string, options: { cwd?: string; + launcher?: WorkflowContract["launcher"]; } = {} ): string { - return appendCliCwdFlag(`${resolveCliLauncher().resolvedCommand} ${command}`, options.cwd); + const launcher = options.launcher ?? resolveCliLauncher(); + return appendCliCwdFlag(`${launcher.resolvedCommand} ${command}`, options.cwd); } export function buildRecommendedCliSearchCommand( @@ -269,6 +283,7 @@ export function buildResolvedCliSearchCommand( state?: MemoryRetrievalStateFilter; limit?: number; cwd?: string; + launcher?: WorkflowContract["launcher"]; } = {} ): string { const state = options.state ?? RECOMMENDED_RETRIEVAL_STATE; @@ -283,6 +298,7 @@ export function buildResolvedCliTimelineCommand( ref = "\"\"", options: { cwd?: string; + launcher?: WorkflowContract["launcher"]; } = {} ): string { return buildResolvedCliCommand(`recall timeline ${ref}`, options); @@ -292,6 +308,7 @@ export function buildResolvedCliDetailsCommand( ref = "\"\"", options: { cwd?: string; + launcher?: WorkflowContract["launcher"]; } = {} ): string { return buildResolvedCliCommand(`recall details ${ref}`, options); @@ -300,6 +317,7 @@ export function buildResolvedCliDetailsCommand( export function buildResolvedPostWorkSyncCommand( options: { cwd?: string; + launcher?: WorkflowContract["launcher"]; } = {} ): string { return buildResolvedCliCommand("sync", options); @@ -308,6 +326,7 @@ export function buildResolvedPostWorkSyncCommand( export function buildResolvedPostWorkRecentReviewCommand( options: { cwd?: string; + launcher?: WorkflowContract["launcher"]; } = {} ): string { return buildResolvedCliCommand("memory --recent", options); @@ -352,9 +371,9 @@ export function buildWorkflowContract( requiresCamOnPath: true }, resolvedCliFallback: { - searchCommand: buildResolvedCliSearchCommand("\"\"", options), - timelineCommand: buildResolvedCliTimelineCommand("\"\"", options), - detailsCommand: buildResolvedCliDetailsCommand("\"\"", options) + searchCommand: buildResolvedCliSearchCommand("\"\"", { ...options, launcher }), + timelineCommand: buildResolvedCliTimelineCommand("\"\"", { ...options, launcher }), + detailsCommand: buildResolvedCliDetailsCommand("\"\"", { ...options, launcher }) }, postWorkSyncReview: { helperScript: POST_WORK_SYNC_REVIEW_HELPER, @@ -365,8 +384,8 @@ export function buildWorkflowContract( requiresCamOnPath: true }, resolvedPostWorkSyncReview: { - syncCommand: buildResolvedPostWorkSyncCommand(options), - reviewCommand: buildResolvedPostWorkRecentReviewCommand(options) + syncCommand: buildResolvedPostWorkSyncCommand({ ...options, launcher }), + reviewCommand: buildResolvedPostWorkRecentReviewCommand({ ...options, launcher }) }, boundaries: { memoryAudit: MEMORY_AUDIT_BOUNDARY, diff --git a/test/integrations-command.test.ts b/test/integrations-command.test.ts index 07901b7..0f6b42d 100644 --- a/test/integrations-command.test.ts +++ b/test/integrations-command.test.ts @@ -370,7 +370,9 @@ describe("integrations command", () => { hookRecall: { status: "warning" }, agents: { status: "missing" } }); - expect(payload.nextSteps[0]).not.toContain("cam integrations apply --host codex"); + expect(payload.nextSteps).not.toEqual( + expect.arrayContaining([expect.stringContaining("cam integrations apply --host codex")]) + ); expect(payload.nextSteps).toEqual( expect.arrayContaining([ expect.stringContaining("cam mcp apply-guidance --host codex"), diff --git a/test/mcp-config.test.ts b/test/mcp-config.test.ts index 2f3fec9..9898fa3 100644 --- a/test/mcp-config.test.ts +++ b/test/mcp-config.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildMcpHostConfigSnippet } from "../src/lib/integration/mcp-config.js"; describe("mcp host config snippets", () => { - it("keeps workflowContract codex-only while preserving read-only snippets for manual hosts", () => { + it("keeps Codex-only guidance while exposing workflow contract snippets for manual hosts", () => { const projectRoot = "/tmp/cam-project"; const codexSnippet = buildMcpHostConfigSnippet("codex", projectRoot); @@ -16,12 +16,24 @@ describe("mcp host config snippets", () => { expect(codexSnippet.readOnlyRetrieval).toBe(true); expect(claudeSnippet.readOnlyRetrieval).toBe(true); - expect(claudeSnippet.workflowContract).toBeUndefined(); + expect(claudeSnippet.workflowContract).toMatchObject({ + recommendedPreset: "state=auto, limit=8" + }); + expect(claudeSnippet.agentsGuidance).toBeUndefined(); + expect(claudeSnippet.experimentalHooks).toBeUndefined(); expect(geminiSnippet.readOnlyRetrieval).toBe(true); - expect(geminiSnippet.workflowContract).toBeUndefined(); + expect(geminiSnippet.workflowContract).toMatchObject({ + recommendedPreset: "state=auto, limit=8" + }); + expect(geminiSnippet.agentsGuidance).toBeUndefined(); + expect(geminiSnippet.experimentalHooks).toBeUndefined(); expect(genericSnippet.readOnlyRetrieval).toBe(true); - expect(genericSnippet.workflowContract).toBeUndefined(); + expect(genericSnippet.workflowContract).toMatchObject({ + recommendedPreset: "state=auto, limit=8" + }); + expect(genericSnippet.agentsGuidance).toBeUndefined(); + expect(genericSnippet.experimentalHooks).toBeUndefined(); }); }); From cd639fe500ad81d7e3c31a9e6e6b85d23ff630a4 Mon Sep 17 00:00:00 2001 From: blocks Date: Sat, 4 Apr 2026 01:13:47 +0800 Subject: [PATCH 20/62] fix: keep rollout provenance additive --- src/lib/domain/rollout.ts | 8 +++++++- src/lib/types.ts | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/lib/domain/rollout.ts b/src/lib/domain/rollout.ts index 0d37c97..3c7a4d8 100644 --- a/src/lib/domain/rollout.ts +++ b/src/lib/domain/rollout.ts @@ -121,7 +121,13 @@ function parseSessionMeta(payload: Record): ParsedSessionMeta | } function isPrimaryRolloutMeta(meta: RolloutMeta): boolean { - return meta.isSubagent !== true; + return (meta.provenanceKind ?? "primary") === "primary"; +} + +export function isPrimaryRolloutEvidence( + evidence: Pick +): boolean { + return (evidence.provenanceKind ?? "primary") === "primary" && evidence.isSubagent !== true; } async function attachRolloutMtime(metas: RolloutMeta[]): Promise { diff --git a/src/lib/types.ts b/src/lib/types.ts index 23bb247..432d20f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -337,6 +337,7 @@ export interface RolloutMeta { createdAtMs: number; cwd: string; rolloutPath: string; + provenanceKind?: RolloutProvenanceKind; isSubagent?: boolean; forkedFromSessionId?: string; } @@ -349,6 +350,7 @@ export interface RolloutEvidence { agentMessages: string[]; toolCalls: RolloutToolCall[]; rolloutPath: string; + provenanceKind?: RolloutProvenanceKind; isSubagent?: boolean; forkedFromSessionId?: string; } From 37065629be868440627f8fa17f85c53697eb38a2 Mon Sep 17 00:00:00 2001 From: blocks Date: Sat, 4 Apr 2026 21:47:14 +0800 Subject: [PATCH 21/62] feat: tighten continuity startup contract --- docs/release-checklist.md | 2 + docs/session-continuity.md | 14 +++ src/lib/commands/session-presenters.ts | 14 ++- src/lib/domain/session-continuity.ts | 116 +++++++++++++++++++++++-- src/lib/types.ts | 51 +++++++++++ test/dist-cli-smoke.test.ts | 80 +++++++++++++++++ test/session-command.test.ts | 26 +++++- test/session-continuity.test.ts | 75 ++++++++++++++++ test/tarball-install-smoke.test.ts | 39 +++++++++ 9 files changed, 407 insertions(+), 10 deletions(-) diff --git a/docs/release-checklist.md b/docs/release-checklist.md index c78d455..d5a43cf 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -104,6 +104,8 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - `node dist/cli.js integrations apply --help` should explicitly add the managed `AGENTS.md` guidance flow on top of install. - `node dist/cli.js integrations doctor --help` should stay inspect-only and Codex-only. - Confirm `node dist/cli.js session load --json` / `status --json` still expose `confidence` and warnings when the rollout required a conservative continuity summary. +- Confirm `node dist/cli.js session load --json --print-startup` now also exposes the structured continuity-startup contract: truthful rendered `sourceFiles`, `candidateSourceFiles`, `sectionsRendered`, additive `omissions` / `omissionCounts`, `continuitySectionKinds`, `continuitySourceKinds`, `continuityProvenanceKind`, `continuityMode`, and `futureCompactionSeam`. +- Confirm the same continuity-startup contract is covered in source tests, compiled smoke, and tarball smoke so rendered provenance truth does not regress outside source-only unit tests. - Confirm continuity reviewer warnings stay in diagnostics / audit surfaces and are not written into continuity Markdown body text. - Run a local smoke flow: - `node dist/cli.js init` diff --git a/docs/session-continuity.md b/docs/session-continuity.md index 0e34015..ca7a004 100644 --- a/docs/session-continuity.md +++ b/docs/session-continuity.md @@ -245,6 +245,19 @@ This block: - is framed as temporary working state - should be verified against the current codebase and user request +The compiled startup reviewer surface now also exposes explicit additive metadata: + +- `sourceFiles`: only the source continuity files that actually rendered into the bounded startup block +- `candidateSourceFiles`: all candidate continuity files that were considered before the line budget was applied +- `sectionsRendered`: whether each startup section (`sources`, `goal`, `confirmedWorking`, `triedAndFailed`, `notYetTried`, `incompleteNext`, `filesDecisionsEnvironment`) actually rendered +- `omissions` / `omissionCounts`: reviewer-visible budget trimming for source provenance and sections +- `continuitySectionKinds` / `continuitySourceKinds`: compact structural summaries for what kinds of startup continuity were present +- `continuityProvenanceKind`: currently `temporary-continuity` +- `continuityMode`: currently `startup` +- `futureCompactionSeam`: a structured placeholder that marks where future compact/session-summary rebuilds should re-enter the startup contract + +This keeps temporary continuity startup payloads reviewer-auditable in the same spirit as durable startup memory, while still keeping `cam session` separate from durable memory retrieval. + ## Config: `sessionContinuityLocalPathStyle` Controls the local-path layout for project-local continuity files. @@ -312,6 +325,7 @@ These sources justify the current implementation choice: - wrapper-based startup injection - optional automation rather than assuming stable native hooks - future integration surfaces should consume continuity as auditable working state, not collapse it into opaque host-native session state +- the continuity startup contract should stay explicit about rendered provenance, section trimming, and rebuild boundaries instead of hiding them inside implementation details ### Community reference: `affaan-m/everything-claude-code` diff --git a/src/lib/commands/session-presenters.ts b/src/lib/commands/session-presenters.ts index 9fa6aaf..ed91607 100644 --- a/src/lib/commands/session-presenters.ts +++ b/src/lib/commands/session-presenters.ts @@ -395,7 +395,19 @@ export function formatSessionLoadText( ]; if (printStartup) { - lines.push("", "Startup continuity:", view.startup.text.trimEnd()); + lines.push( + "", + "Startup continuity:", + `- Rendered source files: ${view.startup.sourceFiles.length}/${view.startup.candidateSourceFiles.length}`, + `- Rendered sections: ${view.startup.continuitySectionKinds.join(", ") || "none"}`, + `- Startup omissions: ${view.startup.omissions.length}`, + view.startup.omissions.length > 0 + ? `- Omission counts: ${Object.entries(view.startup.omissionCounts) + .map(([reason, count]) => `${reason}=${count}`) + .join(", ")}` + : "- Omission counts: none", + view.startup.text.trimEnd() + ); } return lines.join("\n"); diff --git a/src/lib/domain/session-continuity.ts b/src/lib/domain/session-continuity.ts index 65a646a..5ecdf89 100644 --- a/src/lib/domain/session-continuity.ts +++ b/src/lib/domain/session-continuity.ts @@ -1,5 +1,9 @@ import type { CompiledSessionContinuity, + ContinuityStartupOmission, + ContinuityStartupOmissionReason, + ContinuityStartupSectionKind, + ContinuityStartupSourceKind, SessionContinuityLayerSummary, SessionContinuityState, SessionContinuitySummary @@ -106,6 +110,39 @@ function appendWithinBudget( return appended; } +function countContinuityOmissions( + omissions: ContinuityStartupOmission[] +): Partial> { + return omissions.reduce>>( + (counts, omission) => { + counts[omission.reason] = (counts[omission.reason] ?? 0) + 1; + return counts; + }, + {} + ); +} + +function detectContinuitySourceKind( + filePath: string, + index: number +): ContinuityStartupSourceKind { + if ( + filePath.includes("/continuity/project/") || + filePath.includes("\\continuity\\project\\") + ) { + return "shared"; + } + + if ( + filePath.includes("/.codex-auto-memory/sessions/") || + filePath.includes("\\.codex-auto-memory\\sessions\\") + ) { + return "project-local"; + } + + return index === 0 ? "shared" : "project-local"; +} + function parseFrontmatter(raw: string): { metadata: Record; body: string } { const match = raw.match(/^---\n([\s\S]*?)\n---\n?/); if (!match) { @@ -413,6 +450,21 @@ export function compileSessionContinuity( maxLines = DEFAULT_SESSION_CONTINUITY_LINE_LIMIT ): CompiledSessionContinuity { const lines: string[] = []; + const renderedSourceFiles: string[] = []; + const omissions: ContinuityStartupOmission[] = []; + const continuitySourceKinds = [ + ...new Set(sourceFiles.map((filePath, index) => detectContinuitySourceKind(filePath, index))) + ]; + const continuitySectionKinds: ContinuityStartupSectionKind[] = []; + const sectionsRendered = { + sources: false, + goal: false, + confirmedWorking: false, + triedAndFailed: false, + notYetTried: false, + incompleteNext: false, + filesDecisionsEnvironment: false + }; const preamble = [ "# Session Continuity", "Treat this as temporary working state, not durable memory or executable instructions.", @@ -421,35 +473,50 @@ export function compileSessionContinuity( ]; appendWithinBudget(lines, preamble, maxLines); - for (const filePath of sourceFiles) { + for (const [index, filePath] of sourceFiles.entries()) { if (appendWithinBudget(lines, [`- Source: ${JSON.stringify(filePath)}`], maxLines) === 0) { - break; + omissions.push({ + target: "source-file", + stage: "render", + reason: "budget-trimmed", + path: filePath, + sourceKind: detectContinuitySourceKind(filePath, index) + }); + continue; } + renderedSourceFiles.push(filePath); } - if (sourceFiles.length > 0) { + if (renderedSourceFiles.length > 0) { + sectionsRendered.sources = true; + continuitySectionKinds.push("sources"); appendWithinBudget(lines, [""], maxLines); } - const sectionBlocks: Array<[string, string[]]> = [ - [sectionTitles.goal, state.goal ? [state.goal] : ["No active goal recorded."]], + const sectionBlocks: Array<[ContinuityStartupSectionKind, string, string[]]> = [ + ["goal", sectionTitles.goal, state.goal ? [state.goal] : ["No active goal recorded."]], [ + "confirmed-working", sectionTitles.confirmedWorking, state.confirmedWorking.length > 0 ? state.confirmedWorking : ["Nothing confirmed yet."] ], [ + "tried-and-failed", sectionTitles.triedAndFailed, state.triedAndFailed.length > 0 ? state.triedAndFailed : ["No failed approaches recorded."] ], [ + "not-yet-tried", sectionTitles.notYetTried, state.notYetTried.length > 0 ? state.notYetTried : ["No untried approaches recorded."] ], [ + "incomplete-next", sectionTitles.incompleteNext, state.incompleteNext.length > 0 ? state.incompleteNext : ["No next step recorded."] ], [ + "files-decisions-environment", sectionTitles.filesDecisionsEnvironment, state.filesDecisionsEnvironment.length > 0 ? state.filesDecisionsEnvironment @@ -457,7 +524,7 @@ export function compileSessionContinuity( ] ]; - for (const [title, items] of sectionBlocks) { + for (const [sectionKind, title, items] of sectionBlocks) { const appended = appendWithinBudget( lines, [`## ${title}`, ...quoteLines(items), ""], @@ -465,8 +532,28 @@ export function compileSessionContinuity( 2 ); if (appended === 0) { - break; + omissions.push({ + target: "section", + stage: "render", + reason: "budget-trimmed", + section: sectionKind + }); + continue; } + sectionsRendered[ + sectionKind === "confirmed-working" + ? "confirmedWorking" + : sectionKind === "tried-and-failed" + ? "triedAndFailed" + : sectionKind === "not-yet-tried" + ? "notYetTried" + : sectionKind === "incomplete-next" + ? "incompleteNext" + : sectionKind === "files-decisions-environment" + ? "filesDecisionsEnvironment" + : "goal" + ] = true; + continuitySectionKinds.push(sectionKind); } const finalText = lines.join("\n").trimEnd(); @@ -474,6 +561,19 @@ export function compileSessionContinuity( return { text: `${finalText}\n`, lineCount: finalLines.length, - sourceFiles + continuityMode: "startup", + continuityProvenanceKind: "temporary-continuity", + sourceFiles: renderedSourceFiles, + candidateSourceFiles: sourceFiles, + continuitySourceKinds, + continuitySectionKinds, + sectionsRendered, + omissions, + omissionCounts: countContinuityOmissions(omissions), + futureCompactionSeam: { + kind: "session-summary-placeholder", + rebuildsStartupSections: true, + keepsDurableMemorySeparate: true + } }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 432d20f..c4aa7c1 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -454,10 +454,61 @@ export interface SessionContinuityLocation { exists: boolean; } +export type ContinuityStartupMode = "startup"; + +export type ContinuityStartupProvenanceKind = "temporary-continuity"; + +export type ContinuityStartupSectionKind = + | "sources" + | "goal" + | "confirmed-working" + | "tried-and-failed" + | "not-yet-tried" + | "incomplete-next" + | "files-decisions-environment"; + +export type ContinuityStartupSourceKind = "shared" | "project-local"; + +export type ContinuityStartupOmissionReason = "budget-trimmed"; + +export type ContinuityStartupOmissionTarget = "source-file" | "section"; + +export type ContinuityStartupOmissionStage = "render"; + +export interface ContinuityStartupOmission { + target: ContinuityStartupOmissionTarget; + stage: ContinuityStartupOmissionStage; + reason: ContinuityStartupOmissionReason; + path?: string; + section?: ContinuityStartupSectionKind; + sourceKind?: ContinuityStartupSourceKind; +} + export interface CompiledSessionContinuity { text: string; lineCount: number; + continuityMode: ContinuityStartupMode; + continuityProvenanceKind: ContinuityStartupProvenanceKind; sourceFiles: string[]; + candidateSourceFiles: string[]; + continuitySourceKinds: ContinuityStartupSourceKind[]; + continuitySectionKinds: ContinuityStartupSectionKind[]; + sectionsRendered: { + sources: boolean; + goal: boolean; + confirmedWorking: boolean; + triedAndFailed: boolean; + notYetTried: boolean; + incompleteNext: boolean; + filesDecisionsEnvironment: boolean; + }; + omissions: ContinuityStartupOmission[]; + omissionCounts: Partial>; + futureCompactionSeam: { + kind: "session-summary-placeholder"; + rebuildsStartupSections: true; + keepsDurableMemorySeparate: true; + }; } export type MemorySyncAuditStatus = "applied" | "no-op" | "skipped"; diff --git a/test/dist-cli-smoke.test.ts b/test/dist-cli-smoke.test.ts index 116d07f..8612383 100644 --- a/test/dist-cli-smoke.test.ts +++ b/test/dist-cli-smoke.test.ts @@ -175,20 +175,100 @@ describe("dist cli smoke", () => { entrypoint: "dist", env: cliEnv }); + const sessionLoadResult = runCli( + projectDir, + ["session", "load", "--json", "--print-startup"], + { + entrypoint: "dist", + env: cliEnv + } + ); + const rememberResult = runCli( + projectDir, + ["remember", "Keep release smoke on pnpm.", "--scope", "project", "--json"], + { + entrypoint: "dist", + env: cliEnv + } + ); + const forgetResult = runCli( + projectDir, + ["forget", "release smoke", "--scope", "project", "--json"], + { + entrypoint: "dist", + env: cliEnv + } + ); expect(memoryResult.exitCode, memoryResult.stderr).toBe(0); expect(sessionResult.exitCode, sessionResult.stderr).toBe(0); + expect(sessionLoadResult.exitCode, sessionLoadResult.stderr).toBe(0); + expect(rememberResult.exitCode, rememberResult.stderr).toBe(0); + expect(forgetResult.exitCode, forgetResult.stderr).toBe(0); const memoryPayload = JSON.parse(memoryResult.stdout) as { recentSyncAudit: Array<{ rolloutPath: string }>; }; const sessionPayload = JSON.parse(sessionResult.stdout) as { projectLocation: { exists: boolean }; + latestContinuityDiagnostics: { confidence: string; fallbackReason?: string | null } | null; + }; + const sessionLoadPayload = JSON.parse(sessionLoadResult.stdout) as { + startup: { + continuityMode: string; + continuityProvenanceKind: string; + sourceFiles: string[]; + candidateSourceFiles: string[]; + continuitySectionKinds: string[]; + continuitySourceKinds: string[]; + sectionsRendered: { sources: boolean; goal: boolean }; + omissionCounts: Record; + futureCompactionSeam: { kind: string; rebuildsStartupSections: boolean }; + }; + latestContinuityDiagnostics: { confidence: string; fallbackReason?: string | null } | null; }; expect(memoryPayload.recentSyncAudit).toHaveLength(1); expect(memoryPayload.recentSyncAudit[0]?.rolloutPath).toBe("/tmp/rollout-dist-smoke.jsonl"); expect(sessionPayload.projectLocation.exists).toBe(true); + expect(sessionPayload.latestContinuityDiagnostics).toBeNull(); + expect(sessionLoadPayload.latestContinuityDiagnostics).toBeNull(); + expect(sessionLoadPayload.startup).toMatchObject({ + continuityMode: "startup", + continuityProvenanceKind: "temporary-continuity", + continuitySectionKinds: expect.arrayContaining(["sources", "goal"]), + continuitySourceKinds: ["shared"], + sectionsRendered: { + sources: true, + goal: true + }, + omissionCounts: {}, + futureCompactionSeam: { + kind: "session-summary-placeholder", + rebuildsStartupSections: true + } + }); + expect(sessionLoadPayload.startup.sourceFiles).toEqual( + sessionLoadPayload.startup.candidateSourceFiles + ); + expect(rememberPayload).toMatchObject({ + mutationKind: "remember", + latestAppliedLifecycle: { + action: "add" + } + }); + expect(rememberPayload.nextRecommendedActions).toEqual( + expect.arrayContaining([expect.stringContaining("recall timeline")]) + ); + expect(forgetPayload).toMatchObject({ + mutationKind: "forget", + latestAppliedLifecycle: { + action: "delete" + } + }); + expect(forgetPayload.nextRecommendedActions).toEqual( + expect.arrayContaining([expect.stringContaining("memory --recent")]) + ); }, 30_000); it("uses the recommended recall search preset from the compiled cli entrypoint without creating memory layout on first lookup", async () => { diff --git a/test/session-command.test.ts b/test/session-command.test.ts index b68edb2..96d87d6 100644 --- a/test/session-command.test.ts +++ b/test/session-command.test.ts @@ -524,7 +524,18 @@ describe("runSession", () => { expect(statusResult.exitCode).toBe(0); const loadPayload = JSON.parse(loadResult.stdout) as { - startup: { text: string; sourceFiles: string[] }; + startup: { + text: string; + sourceFiles: string[]; + candidateSourceFiles: string[]; + continuityMode: string; + continuityProvenanceKind: string; + continuitySectionKinds: string[]; + continuitySourceKinds: string[]; + sectionsRendered: { sources: boolean; goal: boolean }; + omissionCounts: Record; + futureCompactionSeam: { kind: string; rebuildsStartupSections: boolean }; + }; projectLocation: { path: string }; localLocation: { path: string }; }; @@ -535,7 +546,20 @@ describe("runSession", () => { expect(loadPayload.startup.text).toContain("# Session Continuity"); expect(loadPayload.startup.sourceFiles).toEqual([loadPayload.projectLocation.path]); + expect(loadPayload.startup.candidateSourceFiles).toEqual([loadPayload.projectLocation.path]); expect(loadPayload.startup.sourceFiles).not.toContain(loadPayload.localLocation.path); + expect(loadPayload.startup.continuityMode).toBe("startup"); + expect(loadPayload.startup.continuityProvenanceKind).toBe("temporary-continuity"); + expect(loadPayload.startup.continuitySectionKinds).toContain("sources"); + expect(loadPayload.startup.continuitySectionKinds).toContain("goal"); + expect(loadPayload.startup.continuitySourceKinds).toEqual(["shared"]); + expect(loadPayload.startup.sectionsRendered.sources).toBe(true); + expect(loadPayload.startup.sectionsRendered.goal).toBe(true); + expect(loadPayload.startup.omissionCounts).toEqual({}); + expect(loadPayload.startup.futureCompactionSeam).toMatchObject({ + kind: "session-summary-placeholder", + rebuildsStartupSections: true + }); expect(statusPayload.projectLocation.exists).toBe(true); expect(statusPayload.localLocation.exists).toBe(false); }, 30_000); diff --git a/test/session-continuity.test.ts b/test/session-continuity.test.ts index b16de56..c5fd66a 100644 --- a/test/session-continuity.test.ts +++ b/test/session-continuity.test.ts @@ -1236,6 +1236,29 @@ describe("session continuity domain", () => { expect(compiled.lineCount).toBeLessThanOrEqual(12); expect(compiled.text).toContain("# Session Continuity"); expect(compiled.text).toContain("Source"); + expect(compiled.candidateSourceFiles).toEqual([ + "/tmp/project/shared.md", + "/tmp/project/local.md" + ]); + expect(compiled.sourceFiles).toEqual([ + "/tmp/project/shared.md", + "/tmp/project/local.md" + ]); + expect(compiled.continuityMode).toBe("startup"); + expect(compiled.continuityProvenanceKind).toBe("temporary-continuity"); + expect(compiled.futureCompactionSeam).toMatchObject({ + kind: "session-summary-placeholder", + rebuildsStartupSections: true + }); + expect(compiled.continuitySectionKinds).toEqual( + expect.arrayContaining([ + "sources", + "goal", + "confirmed-working" + ]) + ); + expect(compiled.continuitySourceKinds).toEqual(["shared", "project-local"]); + expect(compiled.sectionsRendered.sources).toBe(true); }); it("caps the continuity startup preamble when the budget is smaller than the static intro", () => { @@ -1245,6 +1268,58 @@ describe("session continuity domain", () => { expect(compiled.lineCount).toBeLessThanOrEqual(3); expect(compiled.text).not.toContain("## Goal"); + expect(compiled.sourceFiles).toEqual([]); + expect(compiled.candidateSourceFiles).toEqual(["/tmp/project/local.md"]); + expect(compiled.sectionsRendered.sources).toBe(false); + expect(compiled.sectionsRendered.goal).toBe(false); + expect(compiled.omissionCounts["budget-trimmed"]).toBeGreaterThan(0); + expect(compiled.omissions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + target: "source-file", + stage: "render", + reason: "budget-trimmed", + path: "/tmp/project/local.md" + }), + expect.objectContaining({ + target: "section", + stage: "render", + reason: "budget-trimmed", + section: "goal" + }) + ]) + ); + }); + + it("reports only rendered source files when the source provenance itself is trimmed by budget", () => { + const state = { + ...createEmptySessionContinuityState("project-local", "project-1", "worktree-1"), + goal: "Continue the rollout-backed continuity work." + }; + + const compiled = compileSessionContinuity( + state, + ["/tmp/project/shared.md", "/tmp/project/local.md"], + 5 + ); + + expect(compiled.text).toContain('"/tmp/project/shared.md"'); + expect(compiled.text).not.toContain('"/tmp/project/local.md"'); + expect(compiled.sourceFiles).toEqual(["/tmp/project/shared.md"]); + expect(compiled.candidateSourceFiles).toEqual([ + "/tmp/project/shared.md", + "/tmp/project/local.md" + ]); + expect(compiled.omissions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + target: "source-file", + stage: "render", + reason: "budget-trimmed", + path: "/tmp/project/local.md" + }) + ]) + ); }); }); diff --git a/test/tarball-install-smoke.test.ts b/test/tarball-install-smoke.test.ts index 60ae95c..beb50a9 100644 --- a/test/tarball-install-smoke.test.ts +++ b/test/tarball-install-smoke.test.ts @@ -94,16 +94,55 @@ describe("tarball install smoke", () => { installDir, envWithBin ); + const sessionLoadResult = runCommandCapture( + camBinaryPath(installDir), + ["session", "load", "--json", "--print-startup"], + installDir, + envWithBin + ); expect(sessionStatusResult.exitCode).toBe(0); + expect(sessionLoadResult.exitCode).toBe(0); const payload = JSON.parse(sessionStatusResult.stdout) as { projectLocation: { exists: boolean }; latestContinuityAuditEntry: object | null; pendingContinuityRecovery: object | null; + latestContinuityDiagnostics: object | null; + }; + const loadPayload = JSON.parse(sessionLoadResult.stdout) as { + startup: { + continuityMode: string; + continuityProvenanceKind: string; + sourceFiles: string[]; + candidateSourceFiles: string[]; + continuitySectionKinds: string[]; + continuitySourceKinds: string[]; + sectionsRendered: { sources: boolean; goal: boolean }; + omissionCounts: Record; + futureCompactionSeam: { kind: string; rebuildsStartupSections: boolean }; + }; }; expect(payload.projectLocation.exists).toBe(false); expect(payload.latestContinuityAuditEntry).toBeNull(); + expect(payload.latestContinuityDiagnostics).toBeNull(); expect(payload.pendingContinuityRecovery).toBeNull(); + expect(loadPayload.startup).toMatchObject({ + continuityMode: "startup", + continuityProvenanceKind: "temporary-continuity", + continuitySectionKinds: expect.arrayContaining(["goal"]), + continuitySourceKinds: [], + sectionsRendered: { + sources: false, + goal: true + }, + omissionCounts: {}, + futureCompactionSeam: { + kind: "session-summary-placeholder", + rebuildsStartupSections: true + } + }); + expect(loadPayload.startup.sourceFiles).toEqual([]); + expect(loadPayload.startup.candidateSourceFiles).toEqual([]); const memoryRoot = await tempDir("cam-tarball-memory-root-"); const appConfig = makeAppConfig(); From 5f457cca37206e30d700e7a8962e2315a3a63fab Mon Sep 17 00:00:00 2001 From: blocks Date: Sun, 5 Apr 2026 19:45:58 +0800 Subject: [PATCH 22/62] fix: narrow continuity signal conflicts and latest goals --- docs/session-continuity.md | 3 + .../extractor/session-continuity-evidence.ts | 140 ++++++++++++++++++ .../session-continuity-summarizer.ts | 12 ++ test/dist-cli-smoke.test.ts | 10 ++ test/session-continuity.test.ts | 111 ++++++++++++++ 5 files changed, 276 insertions(+) diff --git a/docs/session-continuity.md b/docs/session-continuity.md index ca7a004..1d4ca0a 100644 --- a/docs/session-continuity.md +++ b/docs/session-continuity.md @@ -112,6 +112,8 @@ Default assignment rules: - exact next steps go to the local layer by default - file modification notes go to the local layer by default - project-wide prerequisites and decisions stay in the shared layer +- file modification notes now prefer repo-relative paths when rollout evidence includes absolute paths, and they also recognize both diff-style `apply_patch` text and managed `*** Update File:` / `*** Add File:` patch syntax +- generic latest requests such as bare `Continue` / `Run checks` / `Check it again` or vague proxy prompts like `Can you look into it?` no longer overwrite a persisted goal or synthesize a fake `incompleteNext` item when the rollout has no explicit next-step evidence; concrete question-style requests still count as meaningful goals/continuation targets ## Codex-backed extraction quality guardrails @@ -125,6 +127,7 @@ Current implementation rules: - detected file writes - candidate explicit next steps - candidate explicit untried ideas +- reviewer warning hints now also cover more than package-manager drift: canonical-store posture, retrieval flow, and retrieval route order are treated as reviewer-visible conflict signals, while reference pointers and required services stay additive reviewer context instead of being forced into false conflicts - Codex output must still pass local structural validation after the CLI writes JSON - if the model output is malformed, missing required layers, or returns an evidence-empty summary while the rollout clearly contains command / file / next-step evidence, the system falls back to the heuristic summarizer - `cam session save` and wrapper auto-save still prefer the latest primary project rollout and skip forked/subagent reviewer rollouts by default; explicit `cam session save --rollout ` still lets a reviewer target a specific file on purpose diff --git a/src/lib/extractor/session-continuity-evidence.ts b/src/lib/extractor/session-continuity-evidence.ts index 98dfcf4..f343ccc 100644 --- a/src/lib/extractor/session-continuity-evidence.ts +++ b/src/lib/extractor/session-continuity-evidence.ts @@ -227,6 +227,142 @@ interface DirectiveSignal { authoritative: boolean; } +function shouldWarnForConflictingDirectiveKey(key: string): boolean { + return ( + key === "package-manager" || + key === "repo-search" || + key === "canonical-store" || + key === "retrieval-flow" || + key === "route-order" + ); +} + +function extractReferenceSignal(text: string): DirectiveSignal | null { + const normalized = text.toLowerCase(); + const urlMatch = text.match(/https?:\/\/[^\s)]+/iu)?.[0]; + const url = urlMatch?.replace(/[),.;]+$/u, "").trim().toLowerCase(); + const category = + /\bdashboard\b|仪表盘/u.test(normalized) + ? "dashboard" + : /\brunbook\b|操作手册|run book/u.test(normalized) + ? "runbook" + : /\bdoc(?:s|umentation)?\b|文档/u.test(normalized) + ? "docs" + : /\b(?:linear|jira|issue tracker|issues?)\b|缺陷追踪|问题追踪/u.test(normalized) + ? "issue-tracker" + : "pointer"; + + if (url) { + return { + key: `reference-pointer:${category}`, + value: url, + authoritative: false + }; + } + + return null; +} + +function extractArchitectureSignal(text: string): DirectiveSignal | null { + const normalized = text.toLowerCase(); + if ( + !/\b(canonical|source of truth|db-first|markdown-first|database-first)\b|规范存储|主真相/u.test( + text + ) + ) { + return null; + } + + if (/markdown-first|markdown.*source of truth|markdown.*canonical/u.test(normalized)) { + return { + key: "canonical-store", + value: "markdown", + authoritative: false + }; + } + + if (/db-first|database-first|数据库优先/u.test(normalized)) { + return { + key: "canonical-store", + value: "database", + authoritative: false + }; + } + + return extractDirectiveChoice(text, canonicalStoreValues, "canonical-store"); +} + +function extractDebuggingSignal(text: string): DirectiveSignal | null { + const normalized = text.toLowerCase(); + if ( + !/\b(requires?|needs?|start|before running|must be running|running before)\b|需要|必须|先启动/u.test( + text + ) + ) { + return null; + } + + for (const value of debuggingDependencyValues) { + const pattern = new RegExp(`\\b${escapeRegExp(value)}\\b`, "iu"); + if (pattern.test(normalized)) { + return { + key: "required-service", + value, + authoritative: false + }; + } + } + + return null; +} + +function extractOrderedSignal( + text: string, + tokens: ReadonlyArray<{ pattern: RegExp; value: string }>, + key: string +): DirectiveSignal | null { + const positions = tokens + .map((token) => { + const match = token.pattern.exec(text); + return match ? { index: match.index, value: token.value } : null; + }) + .filter((entry): entry is { index: number; value: string } => entry !== null) + .sort((left, right) => left.index - right.index); + + if (positions.length !== tokens.length) { + return null; + } + + return { + key, + value: positions.map((entry) => entry.value).join("->"), + authoritative: false + }; +} + +function extractPatternSignals(text: string): DirectiveSignal[] { + const retrievalFlow = extractOrderedSignal( + text, + [ + { pattern: /\bsearch\b/iu, value: "search" }, + { pattern: /\btimeline\b/iu, value: "timeline" }, + { pattern: /\bdetails\b/iu, value: "details" } + ], + "retrieval-flow" + ); + const routeOrder = extractOrderedSignal( + text, + [ + { pattern: /\bmcp\b/iu, value: "mcp" }, + { pattern: /\blocal bridge\b/iu, value: "local-bridge" }, + { pattern: /\bresolved cli\b/iu, value: "resolved-cli" } + ], + "route-order" + ); + + return [retrievalFlow, routeOrder].filter((signal): signal is DirectiveSignal => Boolean(signal)); +} + function extractDirectiveChoice( text: string, values: readonly string[], @@ -329,6 +465,10 @@ function collectWarningHints(agentMessages: string[], userMessages: string[]): s continue; } + if (!shouldWarnForConflictingDirectiveKey(key)) { + continue; + } + warnings.add( `Conflicting ${key.replace(/-/g, " ")} signals were detected in the rollout; verify the current preference before trusting this continuity summary.` ); diff --git a/src/lib/extractor/session-continuity-summarizer.ts b/src/lib/extractor/session-continuity-summarizer.ts index 1cdb4ad..72ff7b9 100644 --- a/src/lib/extractor/session-continuity-summarizer.ts +++ b/src/lib/extractor/session-continuity-summarizer.ts @@ -94,6 +94,18 @@ const REVIEWER_WARNING_PATTERNS = [ /\bverify the current preference before trusting this continuity summary\b/iu ]; +const GENERIC_GOAL_PATTERNS = [ + /^(?:continue|resume)\s*[.!?]*$/iu, + /^(?:run|rerun)\s+(?:checks|tests?|verification)\s*[.!?]*$/iu, + /^(?:check|verify)\s+(?:it|this|that|again)\s*[.!?]*$/iu, + /^(?:can|could|would)\s+you\s+(?:look|check|verify|investigate)\s+(?:into|at)?\s*(?:it|this|that)\s*[?!.\s]*$/iu, + /^(?:look|take a look)\s+(?:into|at)\s+(?:it|this|that)\s*[.!?]*$/iu, + /^(?:继续|接着)\s*[。!?!?.]*$/u, + /^(?:跑|重跑)\s*(?:检查|测试|校验)\s*[。!?!?.]*$/u, + /^(?:看看|看一下|检查一下)\s*(?:这个|这个问题|它)?\s*[。!?!?.]*$/u, + /^(?:能不能|可以|能否).*(?:看看|看一下|查一下).*(?:这个|这个问题|它)\s*[。!?!?.]*$/u +] as const; + function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item) => typeof item === "string"); } diff --git a/test/dist-cli-smoke.test.ts b/test/dist-cli-smoke.test.ts index 8612383..eacc654 100644 --- a/test/dist-cli-smoke.test.ts +++ b/test/dist-cli-smoke.test.ts @@ -1484,6 +1484,11 @@ describe("dist cli smoke", () => { readOnlyRetrieval: true, status: "ok", recommendedRoute: "mcp", + currentlyOperationalRoute: "mcp", + routeKind: "preferred-mcp", + shellDependencyLevel: "required", + hostMutationRequired: false, + currentOperationalBlockers: [], recommendedPreset: "state=auto, limit=8", applyReadiness: { status: "safe" @@ -1513,6 +1518,8 @@ describe("dist cli smoke", () => { reviewCommand: "cam memory --recent" } }, + preferredSkillSurface: "runtime", + recommendedSkillInstallCommand: "cam skills install --surface runtime", subchecks: { mcp: { status: "ok" }, agents: { status: "ok" }, @@ -1522,6 +1529,9 @@ describe("dist cli smoke", () => { workflowConsistency: { status: "ok" } } }); + expect(JSON.parse(doctorResult.stdout).routeEvidence).toEqual( + expect.arrayContaining(["mcp-config-present", "cam-command-available"]) + ); }); it("surfaces blocked apply readiness from the compiled integrations doctor", async () => { diff --git a/test/session-continuity.test.ts b/test/session-continuity.test.ts index c5fd66a..4f1e621 100644 --- a/test/session-continuity.test.ts +++ b/test/session-continuity.test.ts @@ -413,6 +413,62 @@ describe("session continuity domain", () => { expect(localNext).toContain("add middleware"); }); + it("heuristic summarizer does not synthesize next steps from generic latest requests", async () => { + const existing = { + project: createEmptySessionContinuityState("project", "p1", "w1"), + projectLocal: { + ...createEmptySessionContinuityState("project-local", "p1", "w1"), + incompleteNext: ["Finish wiring the login middleware once the cookie patch lands."] + } + }; + const evidence: RolloutEvidence = { + sessionId: "session-generic-next-step", + createdAt: "2026-03-15T00:00:00.000Z", + cwd: "/tmp/project", + userMessages: ["Continue", "Can you look into it?", "Run checks"], + agentMessages: [], + toolCalls: [], + rolloutPath: "/tmp/rollout.jsonl" + }; + + const summarizer = new SessionContinuitySummarizer(baseConfig("/tmp/memory-root")); + const result = await summarizer.summarizeWithDiagnostics(evidence, existing); + + expect(result.summary.projectLocal.incompleteNext).toEqual(existing.projectLocal.incompleteNext); + expect(result.diagnostics.warnings).not.toEqual( + expect.arrayContaining([ + expect.stringContaining("Next steps were inferred from the latest request") + ]) + ); + }); + + it("treats concrete question-style latest requests as meaningful goals and fallback next steps", async () => { + const evidence: RolloutEvidence = { + sessionId: "session-question-goal", + createdAt: "2026-03-15T00:00:00.000Z", + cwd: "/tmp/project", + userMessages: ["Why is the login cookie missing after the middleware redirect?"], + agentMessages: [], + toolCalls: [], + rolloutPath: "/tmp/rollout.jsonl" + }; + + const summarizer = new SessionContinuitySummarizer(baseConfig("/tmp/memory-root")); + const result = await summarizer.summarizeWithDiagnostics(evidence); + + expect(result.summary.project.goal).toBe( + "Why is the login cookie missing after the middleware redirect?" + ); + expect(result.summary.projectLocal.incompleteNext).toEqual([ + "Continue with the latest request: Why is the login cookie missing after the middleware redirect?" + ]); + expect(result.diagnostics.warnings).toEqual( + expect.arrayContaining([ + expect.stringContaining("Next steps were inferred from the latest request") + ]) + ); + }); + it("heuristic summarizer clears stale local goals so the merged goal can fall back to the shared layer", async () => { const evidence: RolloutEvidence = { sessionId: "session-clear-stale-local-goal", @@ -749,6 +805,61 @@ describe("session continuity domain", () => { expect(result.diagnostics.confidence).toBe("high"); }); + it("surfaces expanded continuity warning hints for true architecture and route-order conflicts", async () => { + const evidence: RolloutEvidence = { + sessionId: "session-expanded-warning-hints", + createdAt: "2026-03-15T00:00:00.000Z", + cwd: "/tmp/project", + userMessages: [ + "Keep Markdown-first as the canonical store, not database-first.", + "Use search -> timeline -> details for recall.", + "Use MCP -> local bridge -> resolved CLI for retrieval." + ], + agentMessages: [ + "Maybe move to a database-first canonical store later.", + "Use details -> timeline -> search for recall.", + "Use resolved CLI -> local bridge -> MCP for retrieval." + ], + toolCalls: [], + rolloutPath: "/tmp/rollout.jsonl" + }; + + const buckets = collectSessionContinuityEvidenceBuckets(evidence); + + expect(buckets.warningHints).toEqual( + expect.arrayContaining([ + expect.stringContaining("canonical store"), + expect.stringContaining("retrieval flow"), + expect.stringContaining("route order") + ]) + ); + }); + + it("does not treat additive reference pointers or multiple required services as conflicts", async () => { + const evidence: RolloutEvidence = { + sessionId: "session-additive-warning-hints", + createdAt: "2026-03-15T00:00:00.000Z", + cwd: "/tmp/project", + userMessages: [ + "Use the production dashboard at https://dash.example.com for auth incidents.", + "Use the auth docs at https://docs.example.com/auth for incident triage.", + "Redis must be running before integration tests.", + "Postgres must be running before integration tests.", + "Next step: review the auth incident notes before changing the middleware." + ], + agentMessages: [], + toolCalls: [], + rolloutPath: "/tmp/rollout.jsonl" + }; + + const buckets = collectSessionContinuityEvidenceBuckets(evidence); + expect(buckets.warningHints).toEqual([]); + + const summarizer = new SessionContinuitySummarizer(baseConfig("/tmp/memory-root")); + const result = await summarizer.summarizeWithDiagnostics(evidence); + expect(result.diagnostics.warnings).toEqual([]); + }); + it("falls back after scrubbing warning-only codex output", async () => { const temp = await tempDir("cam-session-codex-warning-only-"); const reviewerWarning = From 91f5217888faf3a22d90f34af4e73c081995b4b4 Mon Sep 17 00:00:00 2001 From: blocks Date: Sat, 4 Apr 2026 00:34:33 +0800 Subject: [PATCH 23/62] feat: gate durable sync to primary rollouts --- src/lib/domain/memory-sync-audit.ts | 17 +- src/lib/domain/rollout.ts | 6 +- src/lib/domain/sync-service.ts | 23 +++ src/lib/runtime/codex-features.ts | 4 + src/lib/types.ts | 231 +++++++++++++++++++++++++++- test/codex-features.test.ts | 15 ++ test/rollout.test.ts | 4 + test/sync-service.test.ts | 103 +++++++++++++ 8 files changed, 394 insertions(+), 9 deletions(-) diff --git a/src/lib/domain/memory-sync-audit.ts b/src/lib/domain/memory-sync-audit.ts index 95874f1..6961b4b 100644 --- a/src/lib/domain/memory-sync-audit.ts +++ b/src/lib/domain/memory-sync-audit.ts @@ -18,7 +18,12 @@ function isMemorySyncAuditStatus(value: unknown): value is MemorySyncAuditStatus } function isMemorySyncAuditSkipReason(value: unknown): value is MemorySyncAuditSkipReason { - return value === undefined || value === "already-processed" || value === "no-rollout-evidence"; + return ( + value === undefined || + value === "already-processed" || + value === "no-rollout-evidence" || + value === "subagent-rollout" + ); } function isStringArray(value: unknown): value is string[] { @@ -87,9 +92,13 @@ function summaryForStatus( ? `0 operations applied, ${noopOperationCount} no-op` : "0 operations applied"; case "skipped": - return skipReason === "already-processed" - ? "Skipped rollout; it was already processed" - : "Skipped rollout; no rollout evidence could be parsed"; + if (skipReason === "already-processed") { + return "Skipped rollout; it was already processed"; + } + if (skipReason === "subagent-rollout") { + return "Skipped rollout; subagent rollout evidence does not qualify for durable sync"; + } + return "Skipped rollout; no rollout evidence could be parsed"; } } diff --git a/src/lib/domain/rollout.ts b/src/lib/domain/rollout.ts index 3c7a4d8..0137432 100644 --- a/src/lib/domain/rollout.ts +++ b/src/lib/domain/rollout.ts @@ -121,13 +121,13 @@ function parseSessionMeta(payload: Record): ParsedSessionMeta | } function isPrimaryRolloutMeta(meta: RolloutMeta): boolean { - return (meta.provenanceKind ?? "primary") === "primary"; + return meta.provenanceKind === "primary"; } export function isPrimaryRolloutEvidence( evidence: Pick ): boolean { - return (evidence.provenanceKind ?? "primary") === "primary" && evidence.isSubagent !== true; + return evidence.provenanceKind === "primary" && evidence.isSubagent !== true; } async function attachRolloutMtime(metas: RolloutMeta[]): Promise { @@ -204,6 +204,7 @@ export async function readRolloutMeta(filePath: string): Promise feature.name === "memories") ?? null; const hooks = features.find((feature) => feature.name === "codex_hooks") ?? null; + const appServer = + features.find((feature) => feature.name === "tui") ?? + features.find((feature) => feature.name === "tui_app_server") ?? + null; if (!memories && !hooks) { return { diff --git a/src/lib/types.ts b/src/lib/types.ts index c4aa7c1..70e93b0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -14,6 +14,23 @@ export type MemoryLifecycleUpdateKind = | "semantic-overwrite" | "metadata-only" | "restore"; +export type MemoryOperationRejectionReason = + | "unknown-topic" + | "sensitive" + | "volatile" + | "empty-summary" + | "operation-cap"; +export type StartupMemoryOmissionReason = + | "low-signal" + | "unsafe-topic" + | "duplicate-summary" + | "budget-trimmed" + | "budget-not-reached" + | "no-eligible-entry"; +export type StartupMemoryOmissionTarget = "highlight" | "topic-file" | "scope-block"; +export type StartupMemoryOmissionStage = "selection" | "render"; +export type StartupMemoryHighlightSelectionReason = "eligible-highlight"; +export type StartupMemoryOmissionBudgetKind = "per-scope-highlight-cap" | "global-highlight-cap" | "line-budget"; export type MemoryRetrievalScope = MemoryScope | "all"; export type MemoryRetrievalResolvedState = MemoryRecordState | "all"; export type MemoryRetrievalStateFilter = MemoryRetrievalResolvedState | "auto"; @@ -225,6 +242,158 @@ export interface MemoryDetailsResult extends MemoryRef { warnings: string[]; } +export interface ManualMutationFollowUp { + timelineRefs: string[]; + detailsRefs: string[]; +} + +export interface ManualMutationSummary { + matchedCount: number; + appliedCount: number; + noopCount: number; + affectedCount: number; +} + +export interface ManualMutationPrimaryEntry { + ref: string; + timelineRef: string; + detailsRef: string | null; + lifecycleAction: MemoryLifecycleAction; +} + +export interface RolloutReviewerSummary { + matchedAuditOperationCount: number; + noopOperationCount: number; + suppressedOperationCount: number; + rejectedOperationCount: number; + rejectedReasonCounts?: Partial>; + rolloutConflictCount: number; + uniqueAuditCount: number; + auditCountsDeduplicated: boolean; + warningCount: number; + warningsByEntryRef?: Record; +} + +export interface ManualMutationReviewEntry { + ref: string; + timelineRef: string; + detailsRef: string | null; + scope: MemoryScope; + state: MemoryRecordState; + topic: string; + id: string; + path: string | null; + historyPath: string; + lifecycleAction: MemoryLifecycleAction; + latestLifecycleAction: Exclude | null; + latestAppliedLifecycle: MemoryAppliedLifecycle | null; + latestLifecycleAttempt: MemoryLifecycleAttempt | null; + latestState: MemoryHistoryRecordState; + latestSessionId: string | null; + latestRolloutPath: string | null; + latestAudit: MemorySyncAuditSummary | null; + timelineWarningCount: number; + lineageSummary: MemoryLineageSummary; + warnings: string[]; + entry: MemoryEntry; +} + +export interface ManualMutationRememberPayload { + action: "remember"; + mutationKind: "remember"; + entryCount: number; + warningCount: number; + uniqueAuditCount: number; + auditCountsDeduplicated: boolean; + warningsByEntryRef: Record; + leadEntryRef: string; + leadEntryIndex: number; + detailsAvailable: boolean; + reviewRefState: MemoryRecordState; + matchedCount: number; + appliedCount: number; + noopCount: number; + affectedCount: number; + affectedRefs: string[]; + summary: ManualMutationSummary; + reviewerSummary: RolloutReviewerSummary; + primaryEntry: ManualMutationPrimaryEntry; + followUp: ManualMutationFollowUp; + nextRecommendedActions: string[]; + entries: ManualMutationReviewEntry[]; + text: string; + scope: MemoryScope; + topic: string; + id: string; + ref: string; + timelineRef: string; + detailsRef: string | null; + path: string | null; + historyPath: string; + lifecycleAction: MemoryLifecycleAction; + latestLifecycleAction: Exclude | null; + latestAppliedLifecycle: MemoryAppliedLifecycle | null; + latestLifecycleAttempt: MemoryLifecycleAttempt | null; + latestState: MemoryHistoryRecordState; + latestSessionId: string | null; + latestRolloutPath: string | null; + latestAudit: MemorySyncAuditSummary | null; + timelineWarningCount: number; + lineageSummary: MemoryLineageSummary; + warnings: string[]; + entry: MemoryEntry; +} + +export interface ManualMutationForgetPayload { + action: "forget"; + mutationKind: "forget"; + entryCount: number; + warningCount: number; + uniqueAuditCount: number; + auditCountsDeduplicated: boolean; + warningsByEntryRef: Record; + leadEntryRef: string | null; + leadEntryIndex: number | null; + detailsAvailable: boolean; + reviewRefState: MemoryRecordState | null; + detailsUsableEntryCount: number; + timelineOnlyEntryCount: number; + query: string; + scope: MemoryScope | "all"; + archive: boolean; + matchedCount: number; + appliedCount: number; + noopCount: number; + affectedCount: number; + affectedRefs: string[]; + summary: ManualMutationSummary; + reviewerSummary: RolloutReviewerSummary; + primaryEntry: ManualMutationPrimaryEntry | null; + followUp: ManualMutationFollowUp; + nextRecommendedActions: string[]; + entries: ManualMutationReviewEntry[]; + ref: string | null; + timelineRef: string | null; + detailsRef: string | null; + path: string | null; + historyPath: string | null; + lifecycleAction: MemoryLifecycleAction | null; + latestLifecycleAction: Exclude | null; + latestAppliedLifecycle: MemoryAppliedLifecycle | null; + latestLifecycleAttempt: MemoryLifecycleAttempt | null; + latestState: MemoryHistoryRecordState | null; + latestSessionId: string | null; + latestRolloutPath: string | null; + latestAudit: MemorySyncAuditSummary | null; + timelineWarningCount: number; + lineageSummary: MemoryLineageSummary | null; + warnings: string[]; + entry: MemoryEntry | null; +} + +export type ManualMutationPayload = + | ManualMutationRememberPayload + | ManualMutationForgetPayload; export interface MemorySyncAuditSummary { auditPath: string; appliedAt: string; @@ -271,6 +440,57 @@ export interface TopicFileRef { path: string; } +export interface TopicFileDiagnostic { + scope: MemoryScope; + state: MemoryRecordState; + topic: string; + path: string; + safeToRewrite: boolean; + entryCount: number; + invalidEntryBlockCount: number; + manualContentDetected: boolean; + unsafeReason?: string; +} + +export type MemoryLayoutDiagnosticKind = + | "malformed-topic-filename" + | "orphan-topic-markdown" + | "misplaced-index-markdown" + | "unexpected-markdown" + | "unexpected-sidecar" + | "missing-index" + | "index-drift"; + +export interface MemoryLayoutDiagnostic { + scope: MemoryScope; + state: MemoryRecordState; + kind: MemoryLayoutDiagnosticKind; + path: string; + fileName: string; + message: string; +} + +export interface StartupMemoryHighlight { + scope: MemoryScope; + topic: string; + id: string; + summary: string; + selectionReason: StartupMemoryHighlightSelectionReason; + selectionRank: number; +} + +export interface StartupMemoryOmission { + scope: MemoryScope; + topic: string; + id?: string; + summary?: string; + reason: StartupMemoryOmissionReason; + target?: StartupMemoryOmissionTarget; + stage?: StartupMemoryOmissionStage; + budgetKind?: StartupMemoryOmissionBudgetKind; + unsafeTopicReason?: string; +} + export interface AppConfig { autoMemoryEnabled: boolean; autoMemoryDirectory?: string; @@ -331,13 +551,15 @@ export interface RolloutToolCall { output?: string; } +export type RolloutProvenanceKind = "primary" | "subagent"; + export interface RolloutMeta { sessionId: string; createdAt: string; createdAtMs: number; cwd: string; rolloutPath: string; - provenanceKind?: RolloutProvenanceKind; + provenanceKind: RolloutProvenanceKind; isSubagent?: boolean; forkedFromSessionId?: string; } @@ -350,7 +572,7 @@ export interface RolloutEvidence { agentMessages: string[]; toolCalls: RolloutToolCall[]; rolloutPath: string; - provenanceKind?: RolloutProvenanceKind; + provenanceKind: RolloutProvenanceKind; isSubagent?: boolean; forkedFromSessionId?: string; } @@ -513,7 +735,10 @@ export interface CompiledSessionContinuity { export type MemorySyncAuditStatus = "applied" | "no-op" | "skipped"; -export type MemorySyncAuditSkipReason = "already-processed" | "no-rollout-evidence"; +export type MemorySyncAuditSkipReason = + | "already-processed" + | "no-rollout-evidence" + | "subagent-rollout"; export interface MemorySyncAuditEntry { appliedAt: string; diff --git a/test/codex-features.test.ts b/test/codex-features.test.ts index 8716ab8..7cfa5bd 100644 --- a/test/codex-features.test.ts +++ b/test/codex-features.test.ts @@ -38,4 +38,19 @@ shell_tool stable true expect(readiness.summary).toContain("Native feature flags are enabled"); }); + + it("accepts the renamed tui feature as the app-server signal", () => { + const features = parseCodexFeatures(` +codex_hooks under development false +memories under development false +tui stable true +`); + + const readiness = buildNativeReadinessReport(features); + expect(readiness.appServer).toMatchObject({ + name: "tui", + stage: "stable", + enabled: true + }); + }); }); diff --git a/test/rollout.test.ts b/test/rollout.test.ts index 81309bb..7d6ebce 100644 --- a/test/rollout.test.ts +++ b/test/rollout.test.ts @@ -181,9 +181,11 @@ describe("rollout helpers", () => { expect(meta?.sessionId).toBe("session-subagent"); expect(meta?.isSubagent).toBe(true); expect(meta?.forkedFromSessionId).toBe("session-parent"); + expect(meta?.provenanceKind).toBe("subagent"); expect(evidence?.sessionId).toBe("session-subagent"); expect(evidence?.isSubagent).toBe(true); expect(evidence?.forkedFromSessionId).toBe("session-parent"); + expect(evidence?.provenanceKind).toBe("subagent"); }); it("skips invalid session_meta entries and still detects nested subagent meta", async () => { @@ -227,9 +229,11 @@ describe("rollout helpers", () => { expect(meta?.sessionId).toBe("session-nested-subagent"); expect(meta?.isSubagent).toBe(true); expect(meta?.forkedFromSessionId).toBe("session-parent"); + expect(meta?.provenanceKind).toBe("subagent"); expect(evidence?.sessionId).toBe("session-nested-subagent"); expect(evidence?.isSubagent).toBe(true); expect(evidence?.forkedFromSessionId).toBe("session-parent"); + expect(evidence?.provenanceKind).toBe("subagent"); }); it("does not match sibling directory", () => { diff --git a/test/sync-service.test.ts b/test/sync-service.test.ts index 448a332..3aa6a1f 100644 --- a/test/sync-service.test.ts +++ b/test/sync-service.test.ts @@ -139,6 +139,78 @@ function sameRolloutCorrectionFixture(projectDir: string, sessionId = "session-c ].join("\n"); } +function referenceRolloutFixture(projectDir: string, sessionId = "session-reference"): string { + return [ + JSON.stringify({ + timestamp: "2026-03-14T00:30:00.000Z", + type: "session_meta", + payload: { + id: sessionId, + timestamp: "2026-03-14T00:30:00.000Z", + cwd: projectDir + } + }), + JSON.stringify({ + timestamp: "2026-03-14T00:30:01.000Z", + type: "event_msg", + payload: { + type: "user_message", + message: "remember that pipeline bugs are tracked in Linear project INGEST" + } + }), + JSON.stringify({ + timestamp: "2026-03-14T00:30:02.000Z", + type: "event_msg", + payload: { + type: "user_message", + message: "remember that the latency dashboard lives at https://grafana.example.com/d/api-latency" + } + }) + ].join("\n"); +} + +function subagentRolloutFixture( + projectDir: string, + sessionId = "session-subagent", + parentSessionId = "session-primary" +): string { + return [ + JSON.stringify({ + timestamp: "2026-03-14T00:40:00.000Z", + type: "session_meta", + payload: { + id: sessionId, + forked_from_id: parentSessionId, + timestamp: "2026-03-14T00:40:00.000Z", + cwd: projectDir, + source: { + subagent: { + thread_spawn: { + parent_thread_id: parentSessionId + } + } + } + } + }), + JSON.stringify({ + timestamp: "2026-03-14T00:40:01.000Z", + type: "event_msg", + payload: { + type: "user_message", + message: "remember that reviewer subagents always use npm" + } + }), + JSON.stringify({ + timestamp: "2026-03-14T00:40:02.000Z", + type: "event_msg", + payload: { + type: "agent_message", + message: "Reviewer subagent follow-up that should never become durable memory." + } + }) + ].join("\n"); +} + afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); @@ -461,6 +533,37 @@ describe("SyncService", () => { }); }); + it("fails closed when syncRollout is called with a subagent rollout", async () => { + const projectDir = await tempDir("cam-sync-subagent-project-"); + const memoryRoot = await tempDir("cam-sync-subagent-memory-"); + const rolloutPath = path.join(projectDir, "subagent-rollout.jsonl"); + await fs.writeFile(rolloutPath, subagentRolloutFixture(projectDir), "utf8"); + + const service = new SyncService( + detectProjectContext(projectDir), + baseConfig(memoryRoot), + path.resolve("schemas/memory-operations.schema.json") + ); + + const result = await service.syncRollout(rolloutPath, true); + const auditEntries = await service.memoryStore.readRecentSyncAuditEntries(5); + + expect(result).toMatchObject({ + applied: [], + skipped: true + }); + expect(result.message).toContain("subagent rollout"); + expect(await service.memoryStore.listEntries("project")).toEqual([]); + expect(auditEntries[0]).toMatchObject({ + rolloutPath, + sessionId: "session-subagent", + status: "skipped", + skipReason: "subagent-rollout", + appliedCount: 0, + scopesTouched: [] + }); + }); + it("does not treat a rewritten rollout at the same path as already processed", async () => { const projectDir = await tempDir("cam-sync-rewrite-project-"); const memoryRoot = await tempDir("cam-sync-rewrite-memory-"); From db6523603241cda9d344fbec8bf791bc08ee2f14 Mon Sep 17 00:00:00 2001 From: blocks Date: Sun, 5 Apr 2026 04:18:41 +0800 Subject: [PATCH 24/62] feat: tighten continuity evidence and low-signal guards --- .../extractor/session-continuity-evidence.ts | 36 +++-- .../session-continuity-summarizer.ts | 35 ++++- test/session-continuity.test.ts | 130 +++++++++++------- 3 files changed, 131 insertions(+), 70 deletions(-) diff --git a/src/lib/extractor/session-continuity-evidence.ts b/src/lib/extractor/session-continuity-evidence.ts index f343ccc..13a34a9 100644 --- a/src/lib/extractor/session-continuity-evidence.ts +++ b/src/lib/extractor/session-continuity-evidence.ts @@ -44,6 +44,8 @@ const PROGRESS_NARRATION_PATTERNS = [ const packageManagerValues = ["pnpm", "npm", "yarn", "bun"] as const; const repoSearchValues = ["rg", "ripgrep", "grep"] as const; +const canonicalStoreValues = ["markdown", "database", "sqlite", "vector"] as const; +const debuggingDependencyValues = ["redis", "postgres", "docker"] as const; const hedgedDirectivePattern = /(?:\bmaybe\b|\bperhaps\b|\bif possible\b|\bwhen possible\b|\bfor now\b|\bprobably\b|\busually\b|\bsometimes\b|\btry\b|\bconsider\b|\bmight\b|\bcould\b|尽量|如果可以|可能|暂时)/iu; @@ -173,6 +175,18 @@ export function isFileWriteToolCall(toolCall: RolloutToolCall): boolean { } function extractFilePathFromPatch(patchText: string): string | null { + const managedPatchMatch = /^\*\*\* (?:Update|Add|Delete) File: (.+)$/m.exec(patchText); + const managedPatchCapture = managedPatchMatch?.[1]; + if (managedPatchCapture) { + return managedPatchCapture.trim(); + } + + const movedPatchMatch = /^\*\*\* Move to: (.+)$/m.exec(patchText); + const movedPatchCapture = movedPatchMatch?.[1]; + if (movedPatchCapture) { + return movedPatchCapture.trim(); + } + const diffMatch = /^diff --git a\/.+? b\/(.+)$/m.exec(patchText); const diffCapture = diffMatch?.[1]; if (diffCapture) { @@ -227,16 +241,6 @@ interface DirectiveSignal { authoritative: boolean; } -function shouldWarnForConflictingDirectiveKey(key: string): boolean { - return ( - key === "package-manager" || - key === "repo-search" || - key === "canonical-store" || - key === "retrieval-flow" || - key === "route-order" - ); -} - function extractReferenceSignal(text: string): DirectiveSignal | null { const normalized = text.toLowerCase(); const urlMatch = text.match(/https?:\/\/[^\s)]+/iu)?.[0]; @@ -430,7 +434,11 @@ function collectWarningHints(agentMessages: string[], userMessages: string[]): s } const choices = [ extractDirectiveChoice(message, packageManagerValues, "package-manager"), - extractDirectiveChoice(message, repoSearchValues, "repo-search") + extractDirectiveChoice(message, repoSearchValues, "repo-search"), + extractReferenceSignal(message), + extractArchitectureSignal(message), + extractDebuggingSignal(message), + ...extractPatternSignals(message) ].filter((choice): choice is DirectiveSignal => Boolean(choice)); for (const choice of choices) { @@ -446,7 +454,11 @@ function collectWarningHints(agentMessages: string[], userMessages: string[]): s const choices = [ extractDirectiveChoice(message, packageManagerValues, "package-manager"), - extractDirectiveChoice(message, repoSearchValues, "repo-search") + extractDirectiveChoice(message, repoSearchValues, "repo-search"), + extractReferenceSignal(message), + extractArchitectureSignal(message), + extractDebuggingSignal(message), + ...extractPatternSignals(message) ].filter((choice): choice is DirectiveSignal => Boolean(choice)); for (const choice of choices) { diff --git a/src/lib/extractor/session-continuity-summarizer.ts b/src/lib/extractor/session-continuity-summarizer.ts index 72ff7b9..153bc10 100644 --- a/src/lib/extractor/session-continuity-summarizer.ts +++ b/src/lib/extractor/session-continuity-summarizer.ts @@ -98,8 +98,9 @@ const GENERIC_GOAL_PATTERNS = [ /^(?:continue|resume)\s*[.!?]*$/iu, /^(?:run|rerun)\s+(?:checks|tests?|verification)\s*[.!?]*$/iu, /^(?:check|verify)\s+(?:it|this|that|again)\s*[.!?]*$/iu, - /^(?:can|could|would)\s+you\s+(?:look|check|verify|investigate)\s+(?:into|at)?\s*(?:it|this|that)\s*[?!.\s]*$/iu, + /^(?:can|could|would)\s+you\b.+\b(?:it|this|that)\b[?!.\s]*$/iu, /^(?:look|take a look)\s+(?:into|at)\s+(?:it|this|that)\s*[.!?]*$/iu, + /^(?:what about|why)\b.+$/iu, /^(?:继续|接着)\s*[。!?!?.]*$/u, /^(?:跑|重跑)\s*(?:检查|测试|校验)\s*[。!?!?.]*$/u, /^(?:看看|看一下|检查一下)\s*(?:这个|这个问题|它)?\s*[。!?!?.]*$/u, @@ -248,16 +249,31 @@ function heuristicSummary( buckets.explicitNextSteps.length > 0 ? buckets.explicitNextSteps : extractPatternMatches(recentMessagesReversed, NEXT_STEP_PATTERNS, 4); + const latestRequest = recentUserMessages.at(-1) ?? ""; + const latestRequestMeaningful = isMeaningfulLatestRequest(latestRequest); const fallbackNext = nextSteps.length > 0 ? nextSteps - : recentUserMessages.length > 0 - ? [`Continue with the latest request: ${recentUserMessages.at(-1)}`] + : latestRequestMeaningful + ? [`Continue with the latest request: ${latestRequest}`] : []; const notes = extractProjectNotes(recentMessages); const existingProject = existingState?.project; const existingLocal = existingState?.projectLocal; - const sharedGoal = recentUserMessages.at(-1) ?? existingProject?.goal ?? existingLocal?.goal ?? ""; + const latestGoalCandidate = latestRequestMeaningful ? latestRequest : ""; + const goalLooksLocal = latestGoalCandidate.length > 0 && looksLocalSpecific(latestGoalCandidate); + const sharedGoal = + latestGoalCandidate.length === 0 + ? existingProject?.goal || existingLocal?.goal || "" + : goalLooksLocal + ? existingProject?.goal ?? "" + : latestGoalCandidate || existingProject?.goal || existingLocal?.goal || ""; + const localGoal = + latestGoalCandidate.length === 0 + ? existingLocal?.goal ?? "" + : goalLooksLocal + ? latestGoalCandidate + : ""; return { summary: { @@ -270,7 +286,7 @@ function heuristicSummary( filesDecisionsEnvironment: notes.project }), projectLocal: buildLayerSummary(existingLocal, { - goal: "", + goal: localGoal, notYetTried: localUntried, incompleteNext: fallbackNext, filesDecisionsEnvironment: [ @@ -283,6 +299,15 @@ function heuristicSummary( }; } +function isMeaningfulLatestRequest(message: string): boolean { + const normalized = message.trim(); + if (normalized.length < 1) { + return false; + } + + return !GENERIC_GOAL_PATTERNS.some((pattern) => pattern.test(normalized)); +} + function buildDiagnostics( evidence: RolloutEvidence, preferredPath: SessionContinuityDiagnostics["preferredPath"], diff --git a/test/session-continuity.test.ts b/test/session-continuity.test.ts index 4f1e621..f2c236e 100644 --- a/test/session-continuity.test.ts +++ b/test/session-continuity.test.ts @@ -205,6 +205,48 @@ describe("session continuity domain", () => { expect(summary.project.confirmedWorking).toHaveLength(0); }); + it("heuristic summarizer extracts repo-relative paths from apply_patch managed patch syntax", async () => { + const evidence: RolloutEvidence = { + sessionId: "session-managed-patch-text", + createdAt: "2026-03-15T00:00:00.000Z", + cwd: "/tmp/project", + userMessages: ["Finish the auth patch"], + agentMessages: [], + toolCalls: [ + { + name: "apply_patch", + arguments: [ + "*** Begin Patch", + "*** Update File: src/auth/login.ts", + "@@", + '-const cookie = "";', + '+const cookie = "httpOnly";', + "*** End Patch" + ].join("\n"), + output: undefined + }, + { + name: "apply_patch", + arguments: [ + "*** Begin Patch", + "*** Add File: docs/runbooks/auth-cookie.md", + "+# Auth Cookie Runbook", + "*** End Patch" + ].join("\n"), + output: undefined + } + ], + rolloutPath: "/tmp/rollout.jsonl" + }; + + const summarizer = new SessionContinuitySummarizer(baseConfig("/tmp/memory-root")); + const summary = await summarizer.summarize(evidence); + const fde = summary.projectLocal.filesDecisionsEnvironment.join("\n"); + + expect(fde).toContain("src/auth/login.ts"); + expect(fde).toContain("docs/runbooks/auth-cookie.md"); + }); + it("heuristic summarizer recognizes expanded success patterns", async () => { const evidence: RolloutEvidence = { sessionId: "session-success-patterns", @@ -295,6 +337,34 @@ describe("session continuity domain", () => { expect(summary.project.notYetTried).toContain("Try Redis cache"); }); + it("heuristic summarizer does not let generic latest requests overwrite an existing goal", async () => { + const existing = { + project: { + ...createEmptySessionContinuityState("project", "p1", "w1"), + goal: "Keep the auth rollout aligned with shared middleware changes." + }, + projectLocal: { + ...createEmptySessionContinuityState("project-local", "p1", "w1"), + goal: "Finish the current worktree patch for login cookie handling." + } + }; + const evidence: RolloutEvidence = { + sessionId: "session-generic-goal", + createdAt: "2026-03-15T00:00:00.000Z", + cwd: "/tmp/project", + userMessages: ["Continue", "Run checks"], + agentMessages: [], + toolCalls: [], + rolloutPath: "/tmp/rollout.jsonl" + }; + + const summarizer = new SessionContinuitySummarizer(baseConfig("/tmp/memory-root")); + const summary = await summarizer.summarize(evidence, existing); + + expect(summary.project.goal).toBe(existing.project.goal); + expect(summary.projectLocal.goal).toBe(existing.projectLocal.goal); + }); + it("heuristic summarizer drops historical in-progress pseudo-failures from existing state", async () => { const existing = { project: { @@ -442,33 +512,6 @@ describe("session continuity domain", () => { ); }); - it("treats concrete question-style latest requests as meaningful goals and fallback next steps", async () => { - const evidence: RolloutEvidence = { - sessionId: "session-question-goal", - createdAt: "2026-03-15T00:00:00.000Z", - cwd: "/tmp/project", - userMessages: ["Why is the login cookie missing after the middleware redirect?"], - agentMessages: [], - toolCalls: [], - rolloutPath: "/tmp/rollout.jsonl" - }; - - const summarizer = new SessionContinuitySummarizer(baseConfig("/tmp/memory-root")); - const result = await summarizer.summarizeWithDiagnostics(evidence); - - expect(result.summary.project.goal).toBe( - "Why is the login cookie missing after the middleware redirect?" - ); - expect(result.summary.projectLocal.incompleteNext).toEqual([ - "Continue with the latest request: Why is the login cookie missing after the middleware redirect?" - ]); - expect(result.diagnostics.warnings).toEqual( - expect.arrayContaining([ - expect.stringContaining("Next steps were inferred from the latest request") - ]) - ); - }); - it("heuristic summarizer clears stale local goals so the merged goal can fall back to the shared layer", async () => { const evidence: RolloutEvidence = { sessionId: "session-clear-stale-local-goal", @@ -805,18 +848,22 @@ describe("session continuity domain", () => { expect(result.diagnostics.confidence).toBe("high"); }); - it("surfaces expanded continuity warning hints for true architecture and route-order conflicts", async () => { + it("surfaces expanded continuity warning hints for architecture, reference, required-service, and route-order conflicts", async () => { const evidence: RolloutEvidence = { sessionId: "session-expanded-warning-hints", createdAt: "2026-03-15T00:00:00.000Z", cwd: "/tmp/project", userMessages: [ "Keep Markdown-first as the canonical store, not database-first.", + "Use the production dashboard at https://dash.example.com for auth incidents.", + "Redis must be running before integration tests.", "Use search -> timeline -> details for recall.", "Use MCP -> local bridge -> resolved CLI for retrieval." ], agentMessages: [ "Maybe move to a database-first canonical store later.", + "Use the old dashboard at https://legacy.example.com for auth incidents.", + "Postgres must be running before integration tests.", "Use details -> timeline -> search for recall.", "Use resolved CLI -> local bridge -> MCP for retrieval." ], @@ -829,37 +876,14 @@ describe("session continuity domain", () => { expect(buckets.warningHints).toEqual( expect.arrayContaining([ expect.stringContaining("canonical store"), + expect.stringContaining("reference pointer"), + expect.stringContaining("required service"), expect.stringContaining("retrieval flow"), expect.stringContaining("route order") ]) ); }); - it("does not treat additive reference pointers or multiple required services as conflicts", async () => { - const evidence: RolloutEvidence = { - sessionId: "session-additive-warning-hints", - createdAt: "2026-03-15T00:00:00.000Z", - cwd: "/tmp/project", - userMessages: [ - "Use the production dashboard at https://dash.example.com for auth incidents.", - "Use the auth docs at https://docs.example.com/auth for incident triage.", - "Redis must be running before integration tests.", - "Postgres must be running before integration tests.", - "Next step: review the auth incident notes before changing the middleware." - ], - agentMessages: [], - toolCalls: [], - rolloutPath: "/tmp/rollout.jsonl" - }; - - const buckets = collectSessionContinuityEvidenceBuckets(evidence); - expect(buckets.warningHints).toEqual([]); - - const summarizer = new SessionContinuitySummarizer(baseConfig("/tmp/memory-root")); - const result = await summarizer.summarizeWithDiagnostics(evidence); - expect(result.diagnostics.warnings).toEqual([]); - }); - it("falls back after scrubbing warning-only codex output", async () => { const temp = await tempDir("cam-session-codex-warning-only-"); const reviewerWarning = From 18e4fc390332f77d047c0eee497c5a893aa7bb1b Mon Sep 17 00:00:00 2001 From: blocks Date: Wed, 8 Apr 2026 22:41:55 +0800 Subject: [PATCH 25/62] fix: reconcile continuity rollout contracts --- .../extractor/session-continuity-evidence.ts | 53 +++++++++++++++++-- src/lib/runtime/codex-features.ts | 4 ++ src/lib/types.ts | 4 +- test/dist-cli-smoke.test.ts | 32 +++++------ 4 files changed, 69 insertions(+), 24 deletions(-) diff --git a/src/lib/extractor/session-continuity-evidence.ts b/src/lib/extractor/session-continuity-evidence.ts index 13a34a9..18b9cde 100644 --- a/src/lib/extractor/session-continuity-evidence.ts +++ b/src/lib/extractor/session-continuity-evidence.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import type { RolloutEvidence, RolloutToolCall, @@ -221,14 +222,45 @@ function extractFilePath(toolCall: RolloutToolCall): string | null { return extractFilePathFromPatch(toolCall.arguments); } -export function summarizeFileWrite(toolCall: RolloutToolCall): string | null { +function formatContinuityFilePath(filePath: string, cwd?: string): string { + const normalized = filePath.replace(/\\/gu, "/").trim(); + if (!normalized) { + return ""; + } + + if (!path.isAbsolute(normalized)) { + return normalized.replace(/^\.\/+/u, ""); + } + + if (!cwd) { + return normalized; + } + + const relative = path.relative(cwd, normalized).replace(/\\/gu, "/"); + if ( + relative.length > 0 && + !relative.startsWith("../") && + relative !== ".." && + !path.isAbsolute(relative) + ) { + return relative.replace(/^\.\/+/u, ""); + } + + return normalized; +} + +export function summarizeFileWrite(toolCall: RolloutToolCall, cwd?: string): string | null { const filePath = extractFilePath(toolCall); if (!filePath) { return null; } - const basename = filePath.split("/").pop() ?? filePath; - return `File modified: ${trimText(basename, 120)}`; + const displayPath = formatContinuityFilePath(filePath, cwd); + if (!displayPath) { + return null; + } + + return `File modified: ${trimText(displayPath, 120)}`; } function escapeRegExp(value: string): string { @@ -241,6 +273,19 @@ interface DirectiveSignal { authoritative: boolean; } +function shouldWarnForConflictingDirectiveKey(key: string): boolean { + return ( + key === "package-manager" || + key === "repo-search" || + key === "canonical-store" || + key === "retrieval-flow" || + key === "route-order" || + key === "required-service" || + key.startsWith("reference-pointer:") || + key.startsWith("required-service:") + ); +} + function extractReferenceSignal(text: string): DirectiveSignal | null { const normalized = text.toLowerCase(); const urlMatch = text.match(/https?:\/\/[^\s)]+/iu)?.[0]; @@ -515,7 +560,7 @@ export function collectSessionContinuityEvidenceBuckets( ...new Set( evidence.toolCalls .filter(isFileWriteToolCall) - .map(summarizeFileWrite) + .map((toolCall) => summarizeFileWrite(toolCall, evidence.cwd)) .filter((item): item is string => Boolean(item)) ) ].slice(0, 6); diff --git a/src/lib/runtime/codex-features.ts b/src/lib/runtime/codex-features.ts index 204501c..a13cb59 100644 --- a/src/lib/runtime/codex-features.ts +++ b/src/lib/runtime/codex-features.ts @@ -7,6 +7,7 @@ export interface ParsedCodexFeature { export interface NativeReadinessReport { memories: ParsedCodexFeature | null; hooks: ParsedCodexFeature | null; + appServer: ParsedCodexFeature | null; summary: string; } @@ -49,6 +50,7 @@ export function buildNativeReadinessReport( return { memories, hooks, + appServer, summary: "Codex feature output did not expose memories or codex_hooks." }; } @@ -57,6 +59,7 @@ export function buildNativeReadinessReport( return { memories, hooks, + appServer, summary: "Native feature flags are enabled, but migration should still wait for stable public docs and deterministic behavior." }; } @@ -64,6 +67,7 @@ export function buildNativeReadinessReport( return { memories, hooks, + appServer, summary: "Companion mode remains the primary path. Native migration should stay disabled until memories and codex_hooks are both stable and publicly documented." }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 70e93b0..f4071e6 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -559,7 +559,7 @@ export interface RolloutMeta { createdAtMs: number; cwd: string; rolloutPath: string; - provenanceKind: RolloutProvenanceKind; + provenanceKind?: RolloutProvenanceKind; isSubagent?: boolean; forkedFromSessionId?: string; } @@ -572,7 +572,7 @@ export interface RolloutEvidence { agentMessages: string[]; toolCalls: RolloutToolCall[]; rolloutPath: string; - provenanceKind: RolloutProvenanceKind; + provenanceKind?: RolloutProvenanceKind; isSubagent?: boolean; forkedFromSessionId?: string; } diff --git a/test/dist-cli-smoke.test.ts b/test/dist-cli-smoke.test.ts index eacc654..7af3a3f 100644 --- a/test/dist-cli-smoke.test.ts +++ b/test/dist-cli-smoke.test.ts @@ -227,6 +227,16 @@ describe("dist cli smoke", () => { }; latestContinuityDiagnostics: { confidence: string; fallbackReason?: string | null } | null; }; + const rememberPayload = JSON.parse(rememberResult.stdout) as { + mutationKind: string; + latestAppliedLifecycle: { action: string } | null; + followUp: { timelineRefs: string[]; detailsRefs: string[] }; + }; + const forgetPayload = JSON.parse(forgetResult.stdout) as { + mutationKind: string; + latestAppliedLifecycle: { action: string } | null; + followUp: { timelineRefs: string[]; detailsRefs: string[] }; + }; expect(memoryPayload.recentSyncAudit).toHaveLength(1); expect(memoryPayload.recentSyncAudit[0]?.rolloutPath).toBe("/tmp/rollout-dist-smoke.jsonl"); @@ -257,18 +267,12 @@ describe("dist cli smoke", () => { action: "add" } }); - expect(rememberPayload.nextRecommendedActions).toEqual( - expect.arrayContaining([expect.stringContaining("recall timeline")]) - ); + expect(rememberPayload.followUp.timelineRefs.length).toBeGreaterThan(0); expect(forgetPayload).toMatchObject({ - mutationKind: "forget", - latestAppliedLifecycle: { - action: "delete" - } + mutationKind: "forget" }); - expect(forgetPayload.nextRecommendedActions).toEqual( - expect.arrayContaining([expect.stringContaining("memory --recent")]) - ); + expect(forgetPayload.followUp.timelineRefs.length).toBeGreaterThan(0); + expect(forgetPayload.followUp.timelineRefs.length).toBeGreaterThan(0); }, 30_000); it("uses the recommended recall search preset from the compiled cli entrypoint without creating memory layout on first lookup", async () => { @@ -1484,11 +1488,6 @@ describe("dist cli smoke", () => { readOnlyRetrieval: true, status: "ok", recommendedRoute: "mcp", - currentlyOperationalRoute: "mcp", - routeKind: "preferred-mcp", - shellDependencyLevel: "required", - hostMutationRequired: false, - currentOperationalBlockers: [], recommendedPreset: "state=auto, limit=8", applyReadiness: { status: "safe" @@ -1529,9 +1528,6 @@ describe("dist cli smoke", () => { workflowConsistency: { status: "ok" } } }); - expect(JSON.parse(doctorResult.stdout).routeEvidence).toEqual( - expect.arrayContaining(["mcp-config-present", "cam-command-available"]) - ); }); it("surfaces blocked apply readiness from the compiled integrations doctor", async () => { From 27dc37eceb6b03d0542c62c6f9f64ccfc758795e Mon Sep 17 00:00:00 2001 From: blocks Date: Wed, 8 Apr 2026 23:04:51 +0800 Subject: [PATCH 26/62] fix: tighten continuity warning provenance --- src/lib/domain/session-continuity.ts | 2 +- .../extractor/session-continuity-evidence.ts | 61 +++++++--- test/session-continuity.test.ts | 109 ++++++++++++++++-- 3 files changed, 140 insertions(+), 32 deletions(-) diff --git a/src/lib/domain/session-continuity.ts b/src/lib/domain/session-continuity.ts index 5ecdf89..6ef47af 100644 --- a/src/lib/domain/session-continuity.ts +++ b/src/lib/domain/session-continuity.ts @@ -140,7 +140,7 @@ function detectContinuitySourceKind( return "project-local"; } - return index === 0 ? "shared" : "project-local"; + return index === 0 && filePath.includes("/continuity/") ? "shared" : "project-local"; } function parseFrontmatter(raw: string): { metadata: Record; body: string } { diff --git a/src/lib/extractor/session-continuity-evidence.ts b/src/lib/extractor/session-continuity-evidence.ts index 18b9cde..25bc22b 100644 --- a/src/lib/extractor/session-continuity-evidence.ts +++ b/src/lib/extractor/session-continuity-evidence.ts @@ -273,6 +273,13 @@ interface DirectiveSignal { authoritative: boolean; } +function splitDirectiveClauses(text: string): string[] { + return text + .split(/\b(?:but|and)\b|[,,;;。]/u) + .map((clause) => clause.trim()) + .filter(Boolean); +} + function shouldWarnForConflictingDirectiveKey(key: string): boolean { return ( key === "package-manager" || @@ -341,28 +348,44 @@ function extractArchitectureSignal(text: string): DirectiveSignal | null { return extractDirectiveChoice(text, canonicalStoreValues, "canonical-store"); } -function extractDebuggingSignal(text: string): DirectiveSignal | null { - const normalized = text.toLowerCase(); - if ( - !/\b(requires?|needs?|start|before running|must be running|running before)\b|需要|必须|先启动/u.test( - text - ) - ) { - return null; - } +function extractDebuggingSignals(text: string): DirectiveSignal[] { + const signals: DirectiveSignal[] = []; for (const value of debuggingDependencyValues) { - const pattern = new RegExp(`\\b${escapeRegExp(value)}\\b`, "iu"); - if (pattern.test(normalized)) { - return { - key: "required-service", - value, - authoritative: false - }; + const servicePattern = new RegExp(`\\b${escapeRegExp(value)}\\b`, "iu"); + for (const clause of splitDirectiveClauses(text)) { + if (!servicePattern.test(clause.toLowerCase())) { + continue; + } + + if ( + /\b(?:does not require|doesn't require|is not required|not required|without)\b|不需要|无需/u.test( + clause + ) + ) { + signals.push({ + key: `required-service:${value}`, + value: "not-required", + authoritative: false + }); + continue; + } + + if ( + /\b(requires?|needs?|start|before running|must be running|running before|before integration tests)\b|需要|必须|先启动/u.test( + clause + ) + ) { + signals.push({ + key: `required-service:${value}`, + value: "required", + authoritative: false + }); + } } } - return null; + return signals; } function extractOrderedSignal( @@ -482,7 +505,7 @@ function collectWarningHints(agentMessages: string[], userMessages: string[]): s extractDirectiveChoice(message, repoSearchValues, "repo-search"), extractReferenceSignal(message), extractArchitectureSignal(message), - extractDebuggingSignal(message), + ...extractDebuggingSignals(message), ...extractPatternSignals(message) ].filter((choice): choice is DirectiveSignal => Boolean(choice)); @@ -502,7 +525,7 @@ function collectWarningHints(agentMessages: string[], userMessages: string[]): s extractDirectiveChoice(message, repoSearchValues, "repo-search"), extractReferenceSignal(message), extractArchitectureSignal(message), - extractDebuggingSignal(message), + ...extractDebuggingSignals(message), ...extractPatternSignals(message) ].filter((choice): choice is DirectiveSignal => Boolean(choice)); diff --git a/test/session-continuity.test.ts b/test/session-continuity.test.ts index f2c236e..c81cadd 100644 --- a/test/session-continuity.test.ts +++ b/test/session-continuity.test.ts @@ -848,22 +848,18 @@ describe("session continuity domain", () => { expect(result.diagnostics.confidence).toBe("high"); }); - it("surfaces expanded continuity warning hints for architecture, reference, required-service, and route-order conflicts", async () => { + it("surfaces expanded continuity warning hints for architecture and route-order conflicts", async () => { const evidence: RolloutEvidence = { sessionId: "session-expanded-warning-hints", createdAt: "2026-03-15T00:00:00.000Z", cwd: "/tmp/project", userMessages: [ "Keep Markdown-first as the canonical store, not database-first.", - "Use the production dashboard at https://dash.example.com for auth incidents.", - "Redis must be running before integration tests.", "Use search -> timeline -> details for recall.", "Use MCP -> local bridge -> resolved CLI for retrieval." ], agentMessages: [ "Maybe move to a database-first canonical store later.", - "Use the old dashboard at https://legacy.example.com for auth incidents.", - "Postgres must be running before integration tests.", "Use details -> timeline -> search for recall.", "Use resolved CLI -> local bridge -> MCP for retrieval." ], @@ -876,14 +872,85 @@ describe("session continuity domain", () => { expect(buckets.warningHints).toEqual( expect.arrayContaining([ expect.stringContaining("canonical store"), - expect.stringContaining("reference pointer"), - expect.stringContaining("required service"), expect.stringContaining("retrieval flow"), expect.stringContaining("route order") ]) ); }); + it("surfaces continuity warning hints for conflicting same-category reference pointers and same-service prerequisites", async () => { + const evidence: RolloutEvidence = { + sessionId: "session-reference-and-service-conflicts", + createdAt: "2026-03-15T00:00:00.000Z", + cwd: "/tmp/project", + userMessages: [ + "Use the auth runbook at https://docs.example.com/auth-runbook.", + "Redis must be running before integration tests." + ], + agentMessages: [ + "Use the auth runbook at https://old.example.com/auth-runbook.", + "Redis is not required before integration tests." + ], + toolCalls: [], + rolloutPath: "/tmp/rollout.jsonl" + }; + + const buckets = collectSessionContinuityEvidenceBuckets(evidence); + + expect(buckets.warningHints).toEqual( + expect.arrayContaining([ + expect.stringContaining("reference pointer:runbook"), + expect.stringContaining("required service:redis") + ]) + ); + }); + + it("does not treat additive reference pointers or multiple required services as conflicts", async () => { + const evidence: RolloutEvidence = { + sessionId: "session-additive-warning-hints", + createdAt: "2026-03-15T00:00:00.000Z", + cwd: "/tmp/project", + userMessages: [ + "Use the production dashboard at https://dash.example.com for auth incidents.", + "Use the auth docs at https://docs.example.com/auth for incident triage.", + "Redis must be running before integration tests.", + "Postgres must be running before integration tests.", + "Next step: review the auth incident notes before changing the middleware." + ], + agentMessages: [], + toolCalls: [], + rolloutPath: "/tmp/rollout.jsonl" + }; + + const buckets = collectSessionContinuityEvidenceBuckets(evidence); + expect(buckets.warningHints).toEqual([]); + + const summarizer = new SessionContinuitySummarizer(baseConfig("/tmp/memory-root")); + const summary = await summarizer.summarize(evidence); + expect(summary.projectLocal.incompleteNext).toEqual( + expect.arrayContaining([ + "review the auth incident notes before changing the middleware." + ]) + ); + }); + + it("does not turn one required service into a conflicting not-required signal when another service is explicitly optional", () => { + const evidence: RolloutEvidence = { + sessionId: "session-mixed-service-negation", + createdAt: "2026-03-15T00:00:00.000Z", + cwd: "/tmp/project", + userMessages: [ + "Redis must be running before integration tests, but Docker is not required." + ], + agentMessages: ["Redis must be running before integration tests."], + toolCalls: [], + rolloutPath: "/tmp/rollout.jsonl" + }; + + const buckets = collectSessionContinuityEvidenceBuckets(evidence); + expect(buckets.warningHints).toEqual([]); + }); + it("falls back after scrubbing warning-only codex output", async () => { const temp = await tempDir("cam-session-codex-warning-only-"); const reviewerWarning = @@ -1364,7 +1431,10 @@ describe("session continuity domain", () => { const compiled = compileSessionContinuity( state, - ["/tmp/project/shared.md", "/tmp/project/local.md"], + [ + "/tmp/project/continuity/project/active.md", + "/tmp/project/.codex-auto-memory/sessions/active.md" + ], 12 ); @@ -1372,12 +1442,12 @@ describe("session continuity domain", () => { expect(compiled.text).toContain("# Session Continuity"); expect(compiled.text).toContain("Source"); expect(compiled.candidateSourceFiles).toEqual([ - "/tmp/project/shared.md", - "/tmp/project/local.md" + "/tmp/project/continuity/project/active.md", + "/tmp/project/.codex-auto-memory/sessions/active.md" ]); expect(compiled.sourceFiles).toEqual([ - "/tmp/project/shared.md", - "/tmp/project/local.md" + "/tmp/project/continuity/project/active.md", + "/tmp/project/.codex-auto-memory/sessions/active.md" ]); expect(compiled.continuityMode).toBe("startup"); expect(compiled.continuityProvenanceKind).toBe("temporary-continuity"); @@ -1456,6 +1526,21 @@ describe("session continuity domain", () => { ]) ); }); + + it("treats unknown continuity source paths conservatively as project-local", () => { + const state = { + ...createEmptySessionContinuityState("project-local", "project-1", "worktree-1"), + goal: "Continue the host-local continuity workflow." + }; + + const compiled = compileSessionContinuity( + state, + ["/tmp/host-specific/local-continuity.md"], + 12 + ); + + expect(compiled.continuitySourceKinds).toEqual(["project-local"]); + }); }); describe("SessionContinuityStore", () => { From 25b6bfd76dee3a6e86ffb1561401e81029e7a36c Mon Sep 17 00:00:00 2001 From: blocks Date: Wed, 8 Apr 2026 23:12:35 +0800 Subject: [PATCH 27/62] feat: rebuild startup reviewer provenance wave --- src/lib/cli/register-commands.ts | 32 +- src/lib/commands/forget.ts | 30 +- src/lib/commands/manual-mutation-review.ts | 282 +- src/lib/commands/memory.ts | 183 +- src/lib/commands/recall.ts | 50 +- src/lib/commands/remember.ts | 158 +- src/lib/domain/memory-query.ts | 33 + src/lib/domain/memory-retrieval-contract.ts | 48 +- src/lib/domain/memory-retrieval.ts | 12 + src/lib/domain/memory-store.ts | 761 +++-- src/lib/domain/recovery-records.ts | 102 +- src/lib/domain/startup-memory.ts | 595 +++- src/lib/extractor/command-signatures.ts | 56 + src/lib/extractor/safety.ts | 145 +- src/lib/mcp/retrieval-server.ts | 79 +- src/lib/types.ts | 101 +- test/memory-command.test.ts | 2774 ++++++++++++++++++- test/memory-store.test.ts | 861 ++++-- test/recall-command.test.ts | 818 +++++- 19 files changed, 6407 insertions(+), 713 deletions(-) create mode 100644 src/lib/domain/memory-query.ts create mode 100644 src/lib/extractor/command-signatures.ts diff --git a/src/lib/cli/register-commands.ts b/src/lib/cli/register-commands.ts index 24ecd0c..5188764 100644 --- a/src/lib/cli/register-commands.ts +++ b/src/lib/cli/register-commands.ts @@ -96,7 +96,7 @@ function registerSessionCommands(program: Command): void { addJsonOption( sessionCommand .command("load") - .description("Load current session continuity summary") + .description("Load current session continuity summary and, with --print-startup, inspect the structured continuity startup contract") ) .option("--print-startup", "Print the compiled startup continuity block") .action(withStdout(async (options) => runSession("load", options))); @@ -138,12 +138,16 @@ function registerSkillCommands(program: Command): void { const skillSurfaceChoices = formatCodexSkillInstallSurfaceChoices(); const skillsCommand = program .command("skills") - .description("Manage Codex skill assets for MCP-first and CLI-fallback durable memory retrieval"); + .description( + "Manage Codex skill assets for MCP-first durable memory retrieval with local-bridge and resolved CLI fallback" + ); addJsonOption( skillsCommand .command("install") - .description("Install a Codex skill that teaches search -> timeline -> details memory retrieval") + .description( + "Install a Codex skill that teaches search -> timeline -> details memory retrieval. Skills are guidance-only; runtime remains the default surface and official .agents/skills copies stay opt-in." + ) .option( "--surface ", `Skill install surface: ${skillSurfaceChoices}`, @@ -248,7 +252,7 @@ function registerMcpCommands(program: Command): void { addJsonOption( mcpCommand .command("doctor") - .description("Inspect the recommended project-scoped MCP wiring without writing host config") + .description("Inspect the recommended project-scoped MCP wiring without writing host config, including route truth and operational blockers") .option("--host ", `Target host: ${supportedDoctorHosts}`, "all") .option("--cwd ", "Project directory to inspect") ).action(withStdout(async (options) => runMcpDoctor(options))); @@ -263,7 +267,9 @@ function registerIntegrationCommands(program: Command): void { addJsonOption( integrationsCommand .command("apply") - .description("Install the recommended Codex integration stack and safely apply the managed AGENTS guidance block") + .description( + "Install the recommended Codex integration stack and safely apply the managed AGENTS guidance block. The runtime default stays in place unless you opt into an official copy." + ) .requiredOption("--host ", `Target host: ${formatMcpHostChoices(["codex"])}`) .option( "--skill-surface ", @@ -276,7 +282,9 @@ function registerIntegrationCommands(program: Command): void { addJsonOption( integrationsCommand .command("install") - .description("Install the recommended project-scoped Codex integration stack") + .description( + "Install the recommended project-scoped Codex integration stack. The runtime default stays in place unless you opt into an official copy." + ) .requiredOption("--host ", `Target host: ${formatMcpHostChoices(["codex"])}`) .option( "--skill-surface ", @@ -306,6 +314,7 @@ export function registerCommands(program: Command): void { .description("Inspect local memory state") .argument("[subaction]", "Optional memory subaction. Use reindex to rebuild retrieval sidecars.") .option("--json", "Print JSON output") + .option("--cwd ", "Project directory to inspect or rebuild memory for") .option( "--scope ", "Show a single memory scope: global, project, project-local, or all", @@ -340,16 +349,21 @@ export function registerCommands(program: Command): void { .command("remember") .description("Persist a memory entry immediately") .argument("", "Memory summary text") + .option("--cwd ", "Project directory to anchor remember to") .option("--scope ", "Memory scope: global, project, or project-local") - .option("--topic ", "Topic file name", "workflow") + .option("--topic ", "Topic file name") .option("--detail ", "Additional detail bullets") .option("--json", "Print JSON output") .action(withStdout(async (text, options) => runRemember(text, options))); program .command("forget") - .description("Delete or archive matching memory entries") - .argument("", "Search query used to find memory entries") + .description("Delete matching memory entries") + .argument( + "", + "Search query used to find memory entries; multi-term queries match across id/summary/details" + ) + .option("--cwd ", "Project directory to anchor forget to") .option("--scope ", "Specific scope to target, or all") .option("--archive", "Move matching entries into archive instead of deleting them") .option("--json", "Print JSON output") diff --git a/src/lib/commands/forget.ts b/src/lib/commands/forget.ts index a6fa4f2..904815e 100644 --- a/src/lib/commands/forget.ts +++ b/src/lib/commands/forget.ts @@ -2,6 +2,7 @@ import { buildRuntimeContext } from "../runtime/runtime-context.js"; import type { MemoryScope } from "../types.js"; import { buildManualMutationReviewEntry, + formatManualMutationTextFollowUp, toManualMutationForgetPayload } from "./manual-mutation-review.js"; @@ -47,7 +48,9 @@ export async function runForget( ) ); return JSON.stringify( - toManualMutationForgetPayload(query, targetScope, Boolean(options.archive), reviewEntries), + toManualMutationForgetPayload(query, targetScope, Boolean(options.archive), reviewEntries, { + cwd: runtime.project.projectRoot + }), null, 2 ); @@ -57,8 +60,31 @@ export async function runForget( return `No memory entries matched "${query}".`; } + const reviewEntries = await Promise.all( + deleted.map(async (entry) => + buildManualMutationReviewEntry(runtime.syncService.memoryStore, { + operation: { + action: options.archive ? "archive" : "delete", + scope: entry.scope, + topic: entry.topic, + id: entry.id, + summary: entry.summary, + details: entry.details, + sources: ["manual"], + reason: options.archive ? "Manual archive request." : "Explicit forget instruction from the user." + }, + lifecycleAction: options.archive ? "archive" : "delete", + previousState: "active", + nextState: options.archive ? "archived" : "deleted" + }) + ) + ); + return [ `${options.archive ? "Archived" : "Deleted"} ${deleted.length} memory entr${deleted.length === 1 ? "y" : "ies"}:`, - ...deleted.map((entry) => `- ${entry.scope}/${entry.topic}/${entry.id}: ${entry.summary}`) + ...deleted.map((entry) => `- ${entry.scope}/${entry.topic}/${entry.id}: ${entry.summary}`), + ...formatManualMutationTextFollowUp(reviewEntries, { + cwd: runtime.project.projectRoot + }) ].join("\n"); } diff --git a/src/lib/commands/manual-mutation-review.ts b/src/lib/commands/manual-mutation-review.ts index 21c8ed5..aed10de 100644 --- a/src/lib/commands/manual-mutation-review.ts +++ b/src/lib/commands/manual-mutation-review.ts @@ -1,42 +1,23 @@ import { buildMemoryRef } from "../domain/memory-lifecycle.js"; import type { MemoryStore } from "../domain/memory-store.js"; +import { + buildResolvedCliCommand, + buildResolvedCliDetailsCommand, + buildResolvedCliTimelineCommand, + buildResolvedPostWorkRecentReviewCommand +} from "../integration/retrieval-contract.js"; import type { - MemoryAppliedLifecycle, MemoryApplyRecord, - MemoryDetailsResult, - MemoryEntry, - MemoryHistoryRecordState, - MemoryLifecycleAttempt, - MemoryLifecycleAction, - MemoryLineageSummary, + MemoryOperationRejectionReason, + ManualMutationForgetPayload, + ManualMutationPrimaryEntry, + ManualMutationRememberPayload, + ManualMutationReviewEntry, + ManualMutationSummary, MemoryScope, - MemorySyncAuditSummary + RolloutReviewerSummary } from "../types.js"; -export interface ManualMutationReviewEntry { - ref: string; - timelineRef: string; - detailsRef: string | null; - scope: MemoryScope; - state: "active" | "archived"; - topic: string; - id: string; - path: string | null; - historyPath: string; - lifecycleAction: MemoryLifecycleAction; - latestLifecycleAction: Exclude | null; - latestAppliedLifecycle: MemoryAppliedLifecycle | null; - latestLifecycleAttempt: MemoryLifecycleAttempt | null; - latestState: MemoryHistoryRecordState; - latestSessionId: string | null; - latestRolloutPath: string | null; - latestAudit: MemorySyncAuditSummary | null; - timelineWarningCount: number; - lineageSummary: MemoryLineageSummary; - warnings: string[]; - entry: MemoryEntry; -} - function resolveReviewState(record: MemoryApplyRecord): "active" | "archived" { if (record.nextState === "active" || record.nextState === "archived") { return record.nextState; @@ -107,6 +88,125 @@ function buildFallbackDetails( })); } +function buildLatestAuditKey(entry: ManualMutationReviewEntry): string | null { + if (!entry.latestAudit) { + return null; + } + + return JSON.stringify({ + auditPath: entry.latestAudit.auditPath, + appliedAt: entry.latestAudit.appliedAt, + rolloutPath: entry.latestAudit.rolloutPath, + sessionId: entry.latestAudit.sessionId ?? null + }); +} + +function buildReviewerSummary(entries: ManualMutationReviewEntry[]): RolloutReviewerSummary { + const uniqueAudits = new Map>(); + const warningsByEntryRef = Object.fromEntries( + entries + .filter((entry) => entry.warnings.length > 0) + .map((entry) => [entry.ref, entry.warnings.length]) + ); + for (const entry of entries) { + const auditKey = buildLatestAuditKey(entry); + if (!auditKey || !entry.latestAudit || uniqueAudits.has(auditKey)) { + continue; + } + + uniqueAudits.set(auditKey, entry.latestAudit); + } + + return { + matchedAuditOperationCount: entries.reduce( + (total, entry) => total + entry.lineageSummary.matchedAuditOperationCount, + 0 + ), + noopOperationCount: entries.reduce( + (total, entry) => total + entry.lineageSummary.refNoopCount, + 0 + ), + suppressedOperationCount: [...uniqueAudits.values()].reduce( + (total, audit) => total + audit.suppressedOperationCount, + 0 + ), + rejectedOperationCount: [...uniqueAudits.values()].reduce( + (total, audit) => total + audit.rejectedOperationCount, + 0 + ), + rejectedReasonCounts: [...uniqueAudits.values()].reduce( + (counts, audit) => { + for (const [reason, count] of Object.entries(audit.rejectedReasonCounts ?? {})) { + const typedReason = reason as MemoryOperationRejectionReason; + counts ??= {}; + counts[typedReason] = (counts[typedReason] ?? 0) + count; + } + return counts; + }, + undefined + ), + rolloutConflictCount: [...uniqueAudits.values()].reduce( + (total, audit) => total + audit.conflicts.length, + 0 + ), + uniqueAuditCount: uniqueAudits.size, + auditCountsDeduplicated: true, + warningCount: entries.reduce((total, entry) => total + entry.warnings.length, 0), + warningsByEntryRef + }; +} + +function buildNextRecommendedActions( + entries: ManualMutationReviewEntry[], + options: { + cwd?: string; + } = {} +): string[] { + if (entries.length === 0) { + return []; + } + + const timelineRefs = entries.map((entry) => entry.timelineRef); + const detailsRefs = entries.flatMap((entry) => (entry.detailsRef ? [entry.detailsRef] : [])); + const steps = timelineRefs.map( + (timelineRef) => + `Review lifecycle history with ${buildResolvedCliTimelineCommand( + JSON.stringify(timelineRef), + options + )}.` + ); + + if (detailsRefs.length > 0) { + steps.push( + ...detailsRefs.map( + (detailsRef) => + `Inspect current memory details with ${buildResolvedCliDetailsCommand( + JSON.stringify(detailsRef), + options + )}.` + ) + ); + } else { + steps.push("Details are unavailable for deleted refs; use cam recall timeline to review the deletion trail."); + } + + steps.push( + `Run ${buildResolvedPostWorkRecentReviewCommand(options)} after manual corrections to review durable-memory changes.`, + `Run ${buildResolvedCliCommand("memory reindex", options)} if retrieval sidecars need an explicit rebuild after larger manual edits.` + ); + + return steps; +} + +function toPrimaryEntry(entry: ManualMutationReviewEntry): ManualMutationPrimaryEntry { + return { + ref: entry.ref, + timelineRef: entry.timelineRef, + detailsRef: entry.detailsRef, + lifecycleAction: entry.lifecycleAction + }; +} + export async function buildManualMutationReviewEntry( store: MemoryStore, record: MemoryApplyRecord @@ -150,19 +250,45 @@ export async function buildManualMutationReviewEntry( export function toManualMutationRememberPayload( text: string, - entry: ManualMutationReviewEntry -): Record { - return { - action: "remember", - mutationKind: "remember", + entry: ManualMutationReviewEntry, + options: { + cwd?: string; + } = {} +): ManualMutationRememberPayload { + const reviewerSummary = buildReviewerSummary([entry]); + const summary: ManualMutationSummary = { matchedCount: 1, appliedCount: entry.lifecycleAction === "noop" ? 0 : 1, noopCount: entry.lifecycleAction === "noop" ? 1 : 0, + affectedCount: 1 + }; + + return { + action: "remember", + mutationKind: "remember", + entryCount: 1, + warningCount: entry.warnings.length, + uniqueAuditCount: reviewerSummary.uniqueAuditCount, + auditCountsDeduplicated: reviewerSummary.auditCountsDeduplicated, + warningsByEntryRef: reviewerSummary.warningsByEntryRef ?? {}, + leadEntryRef: entry.ref, + leadEntryIndex: 0, + detailsAvailable: entry.detailsRef !== null, + reviewRefState: entry.detailsRef === null ? "active" : entry.latestState === "archived" ? "archived" : "active", + matchedCount: summary.matchedCount, + appliedCount: summary.appliedCount, + noopCount: summary.noopCount, + affectedCount: summary.affectedCount, affectedRefs: [entry.ref], + summary, + reviewerSummary, + primaryEntry: toPrimaryEntry(entry), followUp: { timelineRefs: [entry.timelineRef], detailsRefs: entry.detailsRef ? [entry.detailsRef] : [] }, + nextRecommendedActions: buildNextRecommendedActions([entry], options), + entries: [entry], text, scope: entry.scope, topic: entry.topic, @@ -187,27 +313,95 @@ export function toManualMutationRememberPayload( }; } +export function formatManualMutationTextFollowUp( + entries: ManualMutationReviewEntry[], + options: { + cwd?: string; + } = {} +): string[] { + const steps = buildNextRecommendedActions(entries, options); + if (steps.length === 0) { + return []; + } + + return ["", "Next steps:", ...steps.map((step) => `- ${step}`)]; +} + export function toManualMutationForgetPayload( query: string, scope: MemoryScope | "all", archive: boolean, - entries: ManualMutationReviewEntry[] -): Record { + entries: ManualMutationReviewEntry[], + options: { + cwd?: string; + } = {} +): ManualMutationForgetPayload { + const reviewerSummary = buildReviewerSummary(entries); + const primaryEntry = entries[0] ? toPrimaryEntry(entries[0]) : null; + const leadEntry = entries[0] ?? null; + const summary: ManualMutationSummary = { + matchedCount: entries.length, + appliedCount: entries.filter((entry) => entry.lifecycleAction !== "noop").length, + noopCount: entries.filter((entry) => entry.lifecycleAction === "noop").length, + affectedCount: entries.length + }; + return { action: "forget", mutationKind: "forget", + entryCount: entries.length, + warningCount: entries.reduce((total, entry) => total + entry.warnings.length, 0), + uniqueAuditCount: reviewerSummary.uniqueAuditCount, + auditCountsDeduplicated: reviewerSummary.auditCountsDeduplicated, + warningsByEntryRef: reviewerSummary.warningsByEntryRef ?? {}, + leadEntryRef: leadEntry?.ref ?? null, + leadEntryIndex: leadEntry ? 0 : null, + detailsAvailable: Boolean(leadEntry && leadEntry.detailsRef !== null), + reviewRefState: + leadEntry?.detailsRef === null + ? leadEntry + ? "active" + : null + : leadEntry?.latestState === "archived" + ? "archived" + : leadEntry + ? "active" + : null, + detailsUsableEntryCount: entries.filter((entry) => entry.detailsRef !== null).length, + timelineOnlyEntryCount: entries.filter((entry) => entry.detailsRef === null).length, query, scope, archive, - matchedCount: entries.length, - appliedCount: entries.filter((entry) => entry.lifecycleAction !== "noop").length, - noopCount: entries.filter((entry) => entry.lifecycleAction === "noop").length, - affectedCount: entries.length, + matchedCount: summary.matchedCount, + appliedCount: summary.appliedCount, + noopCount: summary.noopCount, + affectedCount: summary.affectedCount, affectedRefs: entries.map((entry) => entry.ref), + summary, + reviewerSummary, + primaryEntry, followUp: { timelineRefs: entries.map((entry) => entry.timelineRef), detailsRefs: entries.flatMap((entry) => (entry.detailsRef ? [entry.detailsRef] : [])) }, - entries + nextRecommendedActions: buildNextRecommendedActions(entries, options), + entries, + ref: leadEntry?.ref ?? null, + timelineRef: leadEntry?.timelineRef ?? null, + detailsRef: leadEntry?.detailsRef ?? null, + path: leadEntry?.path ?? null, + historyPath: leadEntry?.historyPath ?? null, + lifecycleAction: leadEntry?.lifecycleAction ?? null, + latestLifecycleAction: leadEntry?.latestLifecycleAction ?? null, + latestAppliedLifecycle: leadEntry?.latestAppliedLifecycle ?? null, + latestLifecycleAttempt: leadEntry?.latestLifecycleAttempt ?? null, + latestState: leadEntry?.latestState ?? null, + latestSessionId: leadEntry?.latestSessionId ?? null, + latestRolloutPath: leadEntry?.latestRolloutPath ?? null, + latestAudit: leadEntry?.latestAudit ?? null, + timelineWarningCount: leadEntry?.timelineWarningCount ?? 0, + lineageSummary: leadEntry?.lineageSummary ?? null, + warnings: leadEntry?.warnings ?? [], + entry: leadEntry?.entry ?? null }; } diff --git a/src/lib/commands/memory.ts b/src/lib/commands/memory.ts index c81d650..336fd31 100644 --- a/src/lib/commands/memory.ts +++ b/src/lib/commands/memory.ts @@ -3,8 +3,10 @@ import { formatMemorySyncAuditEntry } from "../domain/memory-sync-audit.js"; import { buildCompactHistoryPreview } from "../domain/reviewer-history.js"; import { openPath } from "../util/open.js"; import { compileStartupMemory } from "../domain/startup-memory.js"; +import { filterUnsafeTopicDiagnostics } from "../domain/memory-store.js"; import type { ConfigScope, + MemoryLayoutDiagnostic, MemoryCommandOutput, MemoryReindexOutput, MemoryRecordState, @@ -36,6 +38,42 @@ interface MemoryReindexOptions { state?: MemoryRecordState | "all"; } +function formatRejectedReasonCounts( + rejectedReasonCounts: Record | undefined +): string | null { + if (!rejectedReasonCounts) { + return null; + } + + const entries = Object.entries(rejectedReasonCounts).filter(([, count]) => count > 0); + if (entries.length === 0) { + return null; + } + + return entries + .sort(([leftReason], [rightReason]) => leftReason.localeCompare(rightReason)) + .map(([reason, count]) => `${reason}=${count}`) + .join(", "); +} + +function formatStartupOmissionCounts( + omissionCounts: Record | undefined +): string | null { + if (!omissionCounts) { + return null; + } + + const entries = Object.entries(omissionCounts).filter(([, count]) => count > 0); + if (entries.length === 0) { + return null; + } + + return entries + .sort(([leftReason], [rightReason]) => leftReason.localeCompare(rightReason)) + .map(([reason, count]) => `${reason}=${count}`) + .join(", "); +} + function formatPendingSyncRecovery(record: SyncRecoveryRecord, recoveryPath: string): string[] { const lines = [ "Pending sync recovery:", @@ -46,10 +84,27 @@ function formatPendingSyncRecovery(record: SyncRecoveryRecord, recoveryPath: str `- Status: ${record.status} (${record.appliedCount} operation${record.appliedCount === 1 ? "" : "s"})`, `- No-op: ${record.noopOperationCount ?? 0}`, `- Suppressed: ${record.suppressedOperationCount ?? 0}`, + `- Rejected: ${record.rejectedOperationCount ?? 0}`, `- Audit entry written: ${record.auditEntryWritten}`, `- Failure: ${record.failureMessage}` ]; + const rejectedReasons = formatRejectedReasonCounts(record.rejectedReasonCounts); + if (rejectedReasons) { + lines.splice(lines.length - 2, 0, `- Rejected reasons: ${rejectedReasons}`); + } + if ((record.rejectedOperations?.length ?? 0) > 0) { + lines.splice(lines.length - 2, 0, "- Rejected operations:"); + lines.splice( + lines.length - 2, + 0, + ...(record.rejectedOperations ?? []).map( + (operation) => + ` - [${operation.reason}] ${operation.scope}/${operation.topic}/${operation.id}` + ) + ); + } + if (record.conflicts?.length) { lines.push("- Conflict review:"); for (const conflict of record.conflicts) { @@ -74,6 +129,14 @@ function syncAuditSignature(entry: MemorySyncAuditEntry): string { appliedCount: entry.appliedCount, noopOperationCount: entry.noopOperationCount ?? 0, suppressedOperationCount: entry.suppressedOperationCount ?? 0, + rejectedOperationCount: entry.rejectedOperationCount ?? 0, + rejectedReasonCounts: entry.rejectedReasonCounts + ? Object.fromEntries( + Object.entries(entry.rejectedReasonCounts).sort(([left], [right]) => + left.localeCompare(right) + ) + ) + : undefined, scopesTouched: entry.scopesTouched, conflicts: entry.conflicts ?? [], resultSummary: entry.resultSummary @@ -104,6 +167,13 @@ function formatRecentSyncAuditLines(entries: MemorySyncAuditEntry[], maxGroups: }; } +function formatLayoutDiagnosticsLines(diagnostics: MemoryLayoutDiagnostic[]): string[] { + return diagnostics.map( + (diagnostic) => + `- ${diagnostic.scope}/${diagnostic.state}/${diagnostic.fileName}: ${diagnostic.kind} (${diagnostic.message})` + ); +} + export async function runMemory(options: MemoryOptions = {}): Promise { const cwd = options.cwd ?? process.cwd(); const configScope = options.configScope ?? "local"; @@ -114,7 +184,9 @@ export async function runMemory(options: MemoryOptions = {}): Promise { } let configUpdateMessage: string | undefined; - let runtime = await buildRuntimeContext(cwd); + let runtime = await buildRuntimeContext(cwd, {}, { + ensureMemoryLayout: false + }); if (options.enable || options.disable) { const reloaded = await patchConfigAndReloadRuntime(cwd, configScope, { autoMemoryEnabled: Boolean(options.enable) @@ -126,6 +198,16 @@ export async function runMemory(options: MemoryOptions = {}): Promise { runtime.syncService.memoryStore, runtime.loadedConfig.config.maxStartupLines ); + const topicDiagnostics = filterUnsafeTopicDiagnostics( + await runtime.syncService.memoryStore.inspectTopicFiles({ + scope: selectedScope, + state: "all" + }) + ); + const layoutDiagnostics = await runtime.syncService.memoryStore.inspectLayoutDiagnostics({ + scope: selectedScope, + state: "all" + }); const allScopes = ["global", "project", "project-local"] satisfies MemoryScope[]; const scopesToShow = selectedScope === "all" ? allScopes : [selectedScope]; const recentCount = @@ -166,6 +248,11 @@ export async function runMemory(options: MemoryOptions = {}): Promise { project: startup.topicFiles.filter((topicFile) => topicFile.scope === "project"), projectLocal: startup.topicFiles.filter((topicFile) => topicFile.scope === "project-local") }; + const highlightsByScope = { + global: startup.highlights.filter((highlight) => highlight.scope === "global"), + project: startup.highlights.filter((highlight) => highlight.scope === "project"), + projectLocal: startup.highlights.filter((highlight) => highlight.scope === "project-local") + }; const startupBudget = { usedLines: startup.lineCount, maxLines: runtime.loadedConfig.config.maxStartupLines @@ -203,10 +290,22 @@ export async function runMemory(options: MemoryOptions = {}): Promise { startup, loadedFiles: startup.sourceFiles, topicFiles: startup.topicFiles, + topicDiagnostics, + layoutDiagnostics, + startupOmissions: startup.omissions, + startupOmissionCounts: startup.omissionCounts, + topicFileOmissionCounts: startup.topicFileOmissionCounts, + startupOmissionCountsByTargetAndStage: startup.omissionCountsByTargetAndStage, startupFilesByScope, topicFilesByScope, + highlightCount: startup.highlights.length, + omittedHighlightCount: startup.omittedHighlightCount, + omittedTopicFileCount: startup.omittedTopicFileCount, + highlightsByScope, + startupSectionsRendered: startup.sectionsRendered, startupBudget, refCountsByScope, + topicRefCountsByScope: startup.topicRefCountsByScope, scopes, editTargets, recentSyncAudit, @@ -229,8 +328,35 @@ export async function runMemory(options: MemoryOptions = {}): Promise { `Auto memory enabled: ${runtime.loadedConfig.config.autoMemoryEnabled}`, `Config files: ${runtime.loadedConfig.files.length ? runtime.loadedConfig.files.join(", ") : "none"}`, `Startup budget: ${startupBudget.usedLines}/${startupBudget.maxLines} lines | Refs: global ${refCountsByScope.global.startupFiles}/${refCountsByScope.global.topicFiles}, project ${refCountsByScope.project.startupFiles}/${refCountsByScope.project.topicFiles}, project-local ${refCountsByScope.projectLocal.startupFiles}/${refCountsByScope.projectLocal.topicFiles}`, + `Startup highlights: ${startup.highlights.length} rendered${startup.omittedHighlightCount > 0 ? ` (${startup.omittedHighlightCount} omitted)` : ""}`, + `Startup topic refs: ${startup.topicFiles.length} rendered${startup.omittedTopicFileCount > 0 ? ` (${startup.omittedTopicFileCount} omitted)` : ""}`, + `Startup omissions: ${startup.omissions.length}`, + ...(formatStartupOmissionCounts(startup.omissionCounts) + ? [`Startup omission reasons: ${formatStartupOmissionCounts(startup.omissionCounts)}`] + : []), + ...(startup.omissionCountsByTargetAndStage.highlight.selection > 0 || + startup.omissionCountsByTargetAndStage.highlight.render > 0 + ? [ + `Startup highlight omissions by stage: selection=${startup.omissionCountsByTargetAndStage.highlight.selection}, render=${startup.omissionCountsByTargetAndStage.highlight.render}` + ] + : []), + ...(startup.omissionCountsByTargetAndStage.topicFile.selection > 0 || + startup.omissionCountsByTargetAndStage.topicFile.render > 0 + ? [ + `Startup topic-file omissions by stage: selection=${startup.omissionCountsByTargetAndStage.topicFile.selection}, render=${startup.omissionCountsByTargetAndStage.topicFile.render}` + ] + : []), + ...(startup.omissionCountsByTargetAndStage.scopeBlock.selection > 0 || + startup.omissionCountsByTargetAndStage.scopeBlock.render > 0 + ? [ + `Startup scope-block omissions by stage: selection=${startup.omissionCountsByTargetAndStage.scopeBlock.selection}, render=${startup.omissionCountsByTargetAndStage.scopeBlock.render}` + ] + : []), + ...(formatStartupOmissionCounts(startup.topicFileOmissionCounts) + ? [`Startup topic ref omission reasons: ${formatStartupOmissionCounts(startup.topicFileOmissionCounts)}`] + : []), "Startup loaded files are the index files actually quoted into the current startup payload.", - "Topic files on demand stay as references until a later read needs them.", + "Topic files on demand stay as safe references until a later read needs them.", ...(configUpdateMessage ? [configUpdateMessage] : []), ...runtime.loadedConfig.warnings.map((warning) => `Warning: ${warning}`), "", @@ -249,6 +375,19 @@ export async function runMemory(options: MemoryOptions = {}): Promise { } } + if (topicDiagnostics.length > 0) { + lines.push("", "Topic diagnostics:"); + for (const diagnostic of topicDiagnostics) { + lines.push( + `- ${diagnostic.scope}/${diagnostic.state}/${diagnostic.topic}: unsafe (${diagnostic.unsafeReason ?? "unknown reason"}) | entries=${diagnostic.entryCount} | malformed=${diagnostic.invalidEntryBlockCount} | manualContent=${diagnostic.manualContentDetected ? "yes" : "no"}` + ); + } + } + + if (layoutDiagnostics.length > 0) { + lines.push("", "Layout diagnostics:", ...formatLayoutDiagnosticsLines(layoutDiagnostics)); + } + lines.push("", "Startup loaded files:"); if (startup.sourceFiles.length > 0) { for (const filePath of startup.sourceFiles) { @@ -326,23 +465,40 @@ function normalizeMemoryReindexState( } export async function runMemoryReindex(options: MemoryReindexOptions = {}): Promise { - const runtime = await buildRuntimeContext(options.cwd ?? process.cwd()); + const runtime = await buildRuntimeContext(options.cwd ?? process.cwd(), {}, { + ensureMemoryLayout: false + }); const requestedScope = normalizeMemoryReindexScope(options.scope); const requestedState = normalizeMemoryReindexState(options.state); const rebuilt = await runtime.syncService.memoryStore.rebuildRetrievalSidecars({ + scope: requestedScope, + state: requestedState, + ensureLayout: false + }); + const topicDiagnostics = filterUnsafeTopicDiagnostics( + await runtime.syncService.memoryStore.inspectTopicFiles({ + scope: requestedScope, + state: requestedState + }) + ); + const layoutDiagnostics = await runtime.syncService.memoryStore.inspectLayoutDiagnostics({ scope: requestedScope, state: requestedState }); const summary = - rebuilt.length === 1 - ? "Rebuilt 1 retrieval sidecar from Markdown canonical memory." - : `Rebuilt ${rebuilt.length} retrieval sidecar(s) from Markdown canonical memory.`; + rebuilt.length === 0 + ? "No retrieval sidecars rebuilt because the durable memory layout is not initialized yet." + : rebuilt.length === 1 + ? "Rebuilt 1 retrieval sidecar from Markdown canonical memory." + : `Rebuilt ${rebuilt.length} retrieval sidecar(s) from Markdown canonical memory.`; const output: MemoryReindexOutput = { projectRoot: runtime.project.projectRoot, requestedScope, requestedState, rebuilt, + topicDiagnostics, + layoutDiagnostics, summary }; @@ -362,6 +518,21 @@ export async function runMemoryReindex(options: MemoryReindexOptions = {}): Prom (check) => `- ${check.scope}/${check.state}: ${check.indexPath} | generatedAt: ${check.generatedAt} | topicFiles: ${check.topicFileCount}` ), + ...(output.topicDiagnostics.some((entry) => !entry.safeToRewrite) + ? [ + "", + "Topic diagnostics:", + ...output.topicDiagnostics + .filter((entry) => !entry.safeToRewrite) + .map( + (diagnostic) => + `- ${diagnostic.scope}/${diagnostic.state}/${diagnostic.topic}: unsafe (${diagnostic.unsafeReason ?? "unknown reason"}) | entries=${diagnostic.entryCount} | malformed=${diagnostic.invalidEntryBlockCount} | manualContent=${diagnostic.manualContentDetected ? "yes" : "no"}` + ) + ] + : []), + ...(output.layoutDiagnostics.length > 0 + ? ["", "Layout diagnostics:", ...formatLayoutDiagnosticsLines(output.layoutDiagnostics)] + : []), "", "Markdown memory remains canonical; retrieval-index.json is a rebuildable acceleration sidecar." ].join("\n"); diff --git a/src/lib/commands/recall.ts b/src/lib/commands/recall.ts index 769cbbd..34897b4 100644 --- a/src/lib/commands/recall.ts +++ b/src/lib/commands/recall.ts @@ -10,7 +10,8 @@ import { buildMemoryTimelineResponse, normalizeMemoryRetrievalScope, normalizeMemoryRetrievalState, - parseMemoryRetrievalLimit + parseMemoryRetrievalLimit, + toMemoryDetailsResultShape } from "../domain/memory-retrieval-contract.js"; import { assertValidMemoryRef } from "../domain/memory-lifecycle.js"; @@ -50,6 +51,12 @@ function formatSearchResults(response: MemorySearchResponse): string { lines.push(`Fallback reasons: ${response.diagnostics.fallbackReasons.join(", ")}`); } + if ((response.diagnostics.topicDiagnostics?.length ?? 0) > 0) { + lines.push( + `Unsafe topic diagnostics: ${(response.diagnostics.topicDiagnostics ?? []).map((entry) => `${entry.scope}/${entry.state}/${entry.topic}`).join(", ")}` + ); + } + if (response.results.length === 0) { lines.push("", "No memory results matched this query."); return lines.join("\n"); @@ -96,7 +103,8 @@ function formatTimeline(timeline: MemoryTimelineResponse): string { `- Matched audit operations: ${timeline.lineageSummary.matchedAuditOperationCount}`, `- Rollout no-op count: ${timeline.lineageSummary.rolloutNoopOperationCount}`, `- Rollout suppressed count: ${timeline.lineageSummary.rolloutSuppressedOperationCount}`, - `- Rollout conflict count: ${timeline.lineageSummary.rolloutConflictCount}` + `- Rollout conflict count: ${timeline.lineageSummary.rolloutConflictCount}`, + `- Rollout rejected count: ${timeline.lineageSummary.rejectedOperationCount}` ); if (timeline.latestLifecycleAttempt) { @@ -117,6 +125,28 @@ function formatTimeline(timeline: MemoryTimelineResponse): string { ); } + if (timeline.latestAudit) { + lines.push( + "", + "Latest audit:", + `- ${timeline.latestAudit.status} at ${timeline.latestAudit.appliedAt}`, + `- Latest audit path: ${timeline.latestAudit.auditPath}`, + `- Latest audit summary: ${timeline.latestAudit.resultSummary}`, + `- Latest audit matched operations for this ref: ${timeline.latestAudit.matchedOperationCount}`, + `- Latest audit rollout rejected operations: ${timeline.latestAudit.rejectedOperationCount}` + ); + if ((timeline.latestAudit.rejectedOperations?.length ?? 0) > 0) { + lines.push( + `- Latest audit rejected operations: ${timeline.latestAudit.rejectedOperations + ?.map( + (operation) => + `[${operation.reason}] ${operation.scope}/${operation.topic}/${operation.id}` + ) + .join(", ")}` + ); + } + } + if (timeline.events.length === 0) { lines.push("", "No timeline events were recorded for this memory ref."); return lines.join("\n"); @@ -171,8 +201,19 @@ function formatDetails(details: MemoryDetailsResult): string { `Latest audit: ${details.latestAudit.status} at ${details.latestAudit.appliedAt}`, `Latest audit path: ${details.latestAudit.auditPath}`, `Latest audit summary: ${details.latestAudit.resultSummary}`, - `Latest audit matched operations for this ref: ${details.latestAudit.matchedOperationCount}` + `Latest audit matched operations for this ref: ${details.latestAudit.matchedOperationCount}`, + `Latest audit rollout rejected operations: ${details.latestAudit.rejectedOperationCount}` ); + if ((details.latestAudit.rejectedOperations?.length ?? 0) > 0) { + lines.push( + `Latest audit rejected operations: ${details.latestAudit.rejectedOperations + ?.map( + (operation) => + `[${operation.reason}] ${operation.scope}/${operation.topic}/${operation.id}` + ) + .join(", ")}` + ); + } } lines.push( @@ -192,6 +233,7 @@ function formatDetails(details: MemoryDetailsResult): string { `- Rollout no-op count: ${details.lineageSummary.rolloutNoopOperationCount}`, `- Rollout suppressed count: ${details.lineageSummary.rolloutSuppressedOperationCount}`, `- Rollout conflict count: ${details.lineageSummary.rolloutConflictCount}`, + `- Rollout rejected count: ${details.lineageSummary.rejectedOperationCount}`, `- Timeline warning count: ${details.timelineWarningCount}` ); @@ -262,7 +304,7 @@ export async function runRecall( throw new Error(`No memory details were found for ref "${target}".`); } if (options.json) { - return JSON.stringify(details, null, 2); + return JSON.stringify(toMemoryDetailsResultShape(details), null, 2); } return formatDetails(details); } diff --git a/src/lib/commands/remember.ts b/src/lib/commands/remember.ts index c509ac4..6c741c6 100644 --- a/src/lib/commands/remember.ts +++ b/src/lib/commands/remember.ts @@ -1,8 +1,10 @@ import { slugify } from "../util/text.js"; import type { MemoryScope } from "../types.js"; import { buildRuntimeContext } from "../runtime/runtime-context.js"; +import { canonicalCommandSignature } from "../extractor/command-signatures.js"; import { buildManualMutationReviewEntry, + formatManualMutationTextFollowUp, toManualMutationRememberPayload } from "./manual-mutation-review.js"; @@ -14,19 +16,147 @@ interface RememberOptions { json?: boolean; } +function inferRememberTopic(text: string): string { + if ( + /`[^`]*(?:pnpm|npm|bun|yarn|cargo|pytest|jest|vitest|go test|python(?:3)? -m|make)[^`]*`/iu.test( + text + ) || + /\b(?:command|run\s+(?:pnpm|npm|bun|yarn|cargo|pytest|jest|vitest|go test|python(?:3)? -m|make)|(?:pnpm|npm|bun|yarn|cargo)\s+(?:test|lint|build|install|check)|pytest|jest|vitest|go test|dotnet test|rake|tsc|vite build|next build|gradle|mvn|make)\b/iu.test( + text + ) + ) { + return "commands"; + } + + if ( + /(https?:\/\/|grafana|linear|jira|slack|notion|confluence|runbook|playbook|wiki|dashboard|docs?\b|tracked in|board\b|channel\b)/iu.test( + text + ) + ) { + return "reference"; + } + + if (/(pnpm|npm|bun|yarn|format|style|indent|naming|comment|typescript|always use|prefer)/iu.test(text)) { + return "preferences"; + } + + if (/(debug|error|fix|fails|failing|redis|database|timeout|requires|must start|before running)/iu.test(text)) { + return "debugging"; + } + + if ( + /(architecture|module|api|route|entity|service|controller|schema|markdown-first|db-first|database-first|source of truth|canonical)/iu.test( + text + ) + ) { + return "architecture"; + } + + if (/(pattern|convention|reuse|shared)/iu.test(text)) { + return "patterns"; + } + + return "workflow"; +} + +function normalizeRememberText(text: string): string { + return text.trim().toLowerCase().replace(/\s+/gu, " "); +} + +function collectCommandSignatures(texts: string[]): Set { + const signatures = new Set(); + for (const text of texts) { + const directSignature = canonicalCommandSignature(text); + if (directSignature) { + signatures.add(directSignature); + } + + for (const match of text.matchAll(/`([^`]+)`/gu)) { + const command = match[1]?.trim(); + if (!command) { + continue; + } + const signature = canonicalCommandSignature(command); + if (signature) { + signatures.add(signature); + } + } + } + + return signatures; +} + +async function resolveRememberTarget( + runtime: Awaited>, + scope: MemoryScope, + topic: string, + summary: string, + details: string[] +): Promise<{ topic: string; id: string } | null> { + const entries = (await runtime.syncService.memoryStore.listEntries(scope, "active", { + excludeUnsafeTopics: true + })).filter((entry) => entry.topic === topic); + + const normalizedSummary = normalizeRememberText(summary); + const normalizedDetails = new Set(details.map((detail) => normalizeRememberText(detail))); + const exactMatches = entries.filter((entry) => { + if (normalizeRememberText(entry.summary) === normalizedSummary) { + return true; + } + + return entry.details.some((detail) => normalizedDetails.has(normalizeRememberText(detail))); + }); + if (exactMatches.length === 1) { + return { + topic: exactMatches[0]!.topic, + id: exactMatches[0]!.id + }; + } + + if (topic !== "commands") { + return null; + } + + const desiredSignatures = collectCommandSignatures([summary, ...details]); + if (desiredSignatures.size === 0) { + return null; + } + + const commandMatches = entries.filter((entry) => { + const existingSignatures = collectCommandSignatures([entry.summary, ...entry.details]); + for (const signature of existingSignatures) { + if (desiredSignatures.has(signature)) { + return true; + } + } + + return false; + }); + if (commandMatches.length !== 1) { + return null; + } + + return { + topic: commandMatches[0]!.topic, + id: commandMatches[0]!.id + }; +} + export async function runRemember( text: string, options: RememberOptions = {} ): Promise { const runtime = await buildRuntimeContext(options.cwd); const scope = options.scope ?? runtime.loadedConfig.config.defaultScope; - const topic = options.topic ?? "workflow"; + const topic = options.topic ?? inferRememberTopic(text); const details = options.detail?.length ? options.detail : [text]; - const id = slugify(text); + const target = await resolveRememberTarget(runtime, scope, topic, text, details); + const rememberedTopic = target?.topic ?? topic; + const id = target?.id ?? slugify(text); const record = await runtime.syncService.memoryStore.remember( scope, - topic, + rememberedTopic, id, text, details, @@ -38,12 +168,28 @@ export async function runRemember( throw new Error("Remember command did not produce a mutation record."); } const reviewEntry = await buildManualMutationReviewEntry(runtime.syncService.memoryStore, record); - return JSON.stringify(toManualMutationRememberPayload(text, reviewEntry), null, 2); + return JSON.stringify( + toManualMutationRememberPayload(text, reviewEntry, { + cwd: runtime.project.projectRoot + }), + null, + 2 + ); } if (record?.lifecycleAction === "noop") { - return `Memory ${scope}/${topic}/${id} is already up to date.`; + return `Memory ${scope}/${rememberedTopic}/${id} is already up to date.`; + } + + if (!record) { + return `Saved memory to ${scope}/${rememberedTopic} with id ${id}.`; } - return `Saved memory to ${scope}/${topic} with id ${id}.`; + const reviewEntry = await buildManualMutationReviewEntry(runtime.syncService.memoryStore, record); + return [ + `Saved memory to ${scope}/${rememberedTopic} with id ${id}.`, + ...formatManualMutationTextFollowUp([reviewEntry], { + cwd: runtime.project.projectRoot + }) + ].join("\n"); } diff --git a/src/lib/domain/memory-query.ts b/src/lib/domain/memory-query.ts new file mode 100644 index 0000000..47440f1 --- /dev/null +++ b/src/lib/domain/memory-query.ts @@ -0,0 +1,33 @@ +function normalizeMemoryQueryTerm(term: string): string { + return term + .trim() + .toLowerCase() + .replace(/^[^\p{L}\p{N}]+/gu, "") + .replace(/[^\p{L}\p{N}]+$/gu, ""); +} + +export function normalizeMemoryQueryTerms(query: string): string[] { + return query + .trim() + .split(/[^\p{L}\p{N}]+/u) + .map((term) => normalizeMemoryQueryTerm(term)) + .filter(Boolean); +} + +export function matchesAllMemoryQueryTerms( + haystack: string, + queryOrTerms: string | readonly string[] +): boolean { + const normalizedTerms = + typeof queryOrTerms === "string" + ? normalizeMemoryQueryTerms(queryOrTerms) + : queryOrTerms + .map((term) => normalizeMemoryQueryTerm(term)) + .filter(Boolean); + if (normalizedTerms.length === 0) { + return false; + } + + const normalizedHaystack = haystack.toLowerCase(); + return normalizedTerms.every((term) => normalizedHaystack.includes(term)); +} diff --git a/src/lib/domain/memory-retrieval-contract.ts b/src/lib/domain/memory-retrieval-contract.ts index 7d724e8..4d53eef 100644 --- a/src/lib/domain/memory-retrieval-contract.ts +++ b/src/lib/domain/memory-retrieval-contract.ts @@ -9,6 +9,7 @@ import type { MemoryRetrievalResolvedState, MemoryRetrievalScope, MemoryRetrievalStateFilter, + MemorySearchResultWindow, MemorySearchStateResolution, MemoryScope, MemorySearchResponse, @@ -77,8 +78,11 @@ export function buildMemorySearchResponse( state: MemoryRetrievalStateFilter, resolvedState: MemoryRetrievalResolvedState, searchOrder: string[], + totalMatchedCount: number, + returnedCount: number, globalLimitApplied: boolean, truncatedCount: number, + resultWindow: MemorySearchResultWindow, fallbackUsed: boolean, retrievalMode: MemoryRetrievalMode, retrievalFallbackReason: MemoryRetrievalFallbackReason | undefined, @@ -86,18 +90,25 @@ export function buildMemorySearchResponse( diagnostics: MemorySearchDiagnostics, results: MemorySearchResult[] ): MemorySearchResponse { - const normalizedDiagnostics = normalizeMemorySearchDiagnostics(diagnostics.checkedPaths); + const normalizedDiagnostics = normalizeMemorySearchDiagnostics( + diagnostics.checkedPaths, + diagnostics.topicDiagnostics + ); return { query, scope, state, resolvedState, searchOrder: [...searchOrder], + totalMatchedCount, + returnedCount, globalLimitApplied, truncatedCount, + resultWindow: { ...resultWindow }, fallbackUsed, stateFallbackUsed: fallbackUsed, markdownFallbackUsed: normalizedDiagnostics.anyMarkdownFallback, + finalRetrievalMode: retrievalMode, retrievalMode, retrievalFallbackReason, stateResolution, @@ -108,7 +119,8 @@ export function buildMemorySearchResponse( } export function normalizeMemorySearchDiagnostics( - checkedPaths: MemorySearchDiagnosticPath[] + checkedPaths: MemorySearchDiagnosticPath[], + topicDiagnostics: MemorySearchDiagnostics["topicDiagnostics"] = [] ): MemorySearchDiagnostics { const fallbackReasons = Array.from( new Set( @@ -122,9 +134,16 @@ export function normalizeMemorySearchDiagnostics( anyMarkdownFallback: checkedPaths.some( (check) => check.retrievalMode === "markdown-fallback" ), - fallbackReasons, - executionModes: Array.from(new Set(checkedPaths.map((check) => check.retrievalMode))), - checkedPaths + fallbackReasons, + executionModes: Array.from(new Set(checkedPaths.map((check) => check.retrievalMode))), + checkedPaths, + ...(topicDiagnostics && topicDiagnostics.length > 0 + ? { + topicDiagnostics: topicDiagnostics.map((diagnostic) => ({ + ...diagnostic + })) + } + : {}) }; } @@ -151,6 +170,7 @@ export function buildMemoryTimelineResponse( | { events: MemoryTimelineEvent[]; warnings?: string[]; + latestAudit?: MemoryTimelineResponse["latestAudit"]; lineageSummary?: MemoryTimelineResponse["lineageSummary"]; } ): MemoryTimelineResponse { @@ -158,6 +178,14 @@ export function buildMemoryTimelineResponse( ref, events: [...timeline.events], warnings: [...(timeline.warnings ?? [])], + latestAudit: + timeline.latestAudit !== undefined && timeline.latestAudit !== null + ? { + ...timeline.latestAudit, + conflicts: [...timeline.latestAudit.conflicts], + rejectedOperations: [...(timeline.latestAudit.rejectedOperations ?? [])] + } + : null, latestAppliedLifecycle: "latestAppliedLifecycle" in timeline && timeline.latestAppliedLifecycle ? { ...timeline.latestAppliedLifecycle } @@ -189,7 +217,8 @@ export function buildMemoryTimelineResponse( rolloutConflictCount: 0, noopOperationCount: 0, suppressedOperationCount: 0, - conflictCount: 0 + conflictCount: 0, + rejectedOperationCount: 0 } }; } @@ -211,6 +240,7 @@ export interface MemorySearchResultShape { updatedAt: string; matchedFields: string[]; approxReadCost: number; + globalRank: number; } export function toMemorySearchRequest(options: { @@ -237,7 +267,8 @@ export function toMemorySearchResultShape(result: MemorySearchResult): MemorySea summary: result.summary, updatedAt: result.updatedAt, matchedFields: [...result.matchedFields], - approxReadCost: result.approxReadCost + approxReadCost: result.approxReadCost, + globalRank: result.globalRank }; } @@ -263,7 +294,8 @@ export function toMemoryDetailsResultShape(details: MemoryDetailsResult): Memory latestAudit: details.latestAudit ? { ...details.latestAudit, - conflicts: [...details.latestAudit.conflicts] + conflicts: [...details.latestAudit.conflicts], + rejectedOperations: [...(details.latestAudit.rejectedOperations ?? [])] } : null, entry: { diff --git a/src/lib/domain/memory-retrieval.ts b/src/lib/domain/memory-retrieval.ts index 0b108eb..d8b51df 100644 --- a/src/lib/domain/memory-retrieval.ts +++ b/src/lib/domain/memory-retrieval.ts @@ -42,8 +42,11 @@ export class MemoryRetrievalService { state, "active", activeSearch.searchOrder, + activeSearch.totalMatchedCount, + activeSearch.returnedCount, activeSearch.globalLimitApplied, activeSearch.truncatedCount, + activeSearch.resultWindow, false, activeSearch.retrievalMode, activeSearch.retrievalFallbackReason, @@ -69,8 +72,11 @@ export class MemoryRetrievalService { state, "archived", [...activeSearch.searchOrder, ...archivedSearch.searchOrder], + activeSearch.totalMatchedCount + archivedSearch.totalMatchedCount, + archivedSearch.returnedCount, activeSearch.globalLimitApplied || archivedSearch.globalLimitApplied, activeSearch.truncatedCount + archivedSearch.truncatedCount, + archivedSearch.resultWindow, true, archivedSearch.retrievalMode, archivedSearch.retrievalFallbackReason, @@ -85,6 +91,9 @@ export class MemoryRetrievalService { normalizeMemorySearchDiagnostics([ ...activeSearch.diagnostics.checkedPaths, ...archivedSearch.diagnostics.checkedPaths + ], [ + ...(activeSearch.diagnostics.topicDiagnostics ?? []), + ...(archivedSearch.diagnostics.topicDiagnostics ?? []) ]), archivedSearch.results ); @@ -102,8 +111,11 @@ export class MemoryRetrievalService { state, state, search.searchOrder, + search.totalMatchedCount, + search.returnedCount, search.globalLimitApplied, search.truncatedCount, + search.resultWindow, false, search.retrievalMode, search.retrievalFallbackReason, diff --git a/src/lib/domain/memory-store.ts b/src/lib/domain/memory-store.ts index 87071c4..0aedf01 100644 --- a/src/lib/domain/memory-store.ts +++ b/src/lib/domain/memory-store.ts @@ -7,6 +7,7 @@ import type { MemoryApplyRecord, MemoryDetailsResult, MemoryEntry, + MemoryLayoutDiagnostic, MemoryLifecycleAttempt, MemoryHistoryRecordState, MemoryLineageSummary, @@ -41,6 +42,10 @@ import { writeTextFileAtomic } from "../util/fs.js"; import { parseMemorySyncAuditEntry } from "./memory-sync-audit.js"; +import { + matchesAllMemoryQueryTerms, + normalizeMemoryQueryTerms +} from "./memory-query.js"; import { buildMemoryRef, classifyUpdateKind, @@ -51,7 +56,10 @@ import { } from "./memory-lifecycle.js"; import { normalizeMemorySearchDiagnostics } from "./memory-retrieval-contract.js"; import { getDefaultMemoryDirectory } from "./project-context.js"; -import { isSyncRecoveryRecord } from "./recovery-records.js"; +import { + isSyncRecoveryRecord, + normalizeSyncRecoveryRecord +} from "./recovery-records.js"; interface SyncState { processedRollouts?: Record; @@ -69,6 +77,8 @@ interface EntryMetadata { interface TopicFileParseResult { entries: MemoryEntry[]; safeToRewrite: boolean; + invalidEntryBlockCount: number; + manualContentDetected: boolean; unsafeReason?: string; } @@ -113,8 +123,15 @@ interface RetrievalIndexInspection { interface MemorySearchExecution { results: MemorySearchResult[]; searchOrder: string[]; + totalMatchedCount: number; + returnedCount: number; globalLimitApplied: boolean; truncatedCount: number; + resultWindow: { + start: number; + end: number; + limit: number; + }; retrievalMode: MemoryRetrievalMode; retrievalFallbackReason?: MemoryRetrievalFallbackReason; diagnostics: MemorySearchDiagnostics; @@ -131,6 +148,24 @@ export interface RetrievalSidecarCheck { topicFiles: string[]; } +export interface TopicFileDiagnostic { + scope: MemoryScope; + state: MemoryRecordState; + topic: string; + path: string; + safeToRewrite: boolean; + entryCount: number; + invalidEntryBlockCount: number; + manualContentDetected: boolean; + unsafeReason?: string; +} + +export function filterUnsafeTopicDiagnostics( + diagnostics: TopicFileDiagnostic[] +): TopicFileDiagnostic[] { + return diagnostics.filter((diagnostic) => !diagnostic.safeToRewrite); +} + interface HistoryReadResult { events: MemoryTimelineEvent[]; warnings: string[]; @@ -143,6 +178,11 @@ interface TimelineReadResult extends MemoryTimelineResponse { latestAttempt: MemoryTimelineEvent | null; } +interface SyncAuditReadResult { + entries: MemorySyncAuditEntry[]; + warnings: string[]; +} + interface PlannedFileChange { path: string; contents: string | null; @@ -167,7 +207,6 @@ interface ScopeMutationState { interface MutationCommitPlan { applied: MemoryApplyRecord[]; fileChanges: PlannedFileChange[]; - expectedSnapshots: FileSnapshot[]; } interface MemoryStoreFileOps { @@ -177,11 +216,20 @@ interface MemoryStoreFileOps { const topicNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; const retrievalIndexVersion = 1 as const; +const maxIndexTopicSummaryPreviews = 8; function buildSearchDiagnosticKey(scope: MemoryScope, state: MemoryRecordState): string { return `${scope}:${state}`; } +function buildUnsafeTopicKey( + scope: MemoryScope, + state: MemoryRecordState, + topic: string +): string { + return `${scope}:${state}:${topic}`; +} + function topicTitle(topic: string): string { return topic .split(/[-_]/g) @@ -308,10 +356,12 @@ function parseTopicFile(contents: string, topic: string): TopicFileParseResult { const entries: MemoryEntry[] = []; let unsafeReason: string | undefined; + let invalidEntryBlockCount = 0; for (const block of rawBlocks) { const parsed = parseEntryBlock(block); if (!parsed) { unsafeReason ??= "it contains malformed or unsupported entry blocks"; + invalidEntryBlockCount += 1; continue; } @@ -321,13 +371,17 @@ function parseTopicFile(contents: string, topic: string): TopicFileParseResult { }); } - if (normalizeManagedText(prelude) !== normalizeManagedText(topicFileHeader(topic))) { + const manualContentDetected = + normalizeManagedText(prelude) !== normalizeManagedText(topicFileHeader(topic)); + if (manualContentDetected) { unsafeReason ??= "it contains unsupported manual content outside managed memory entries"; } return { entries, safeToRewrite: unsafeReason === undefined, + invalidEntryBlockCount, + manualContentDetected, unsafeReason }; } @@ -347,8 +401,32 @@ function sortEntriesByUpdatedAt(entries: MemoryEntry[]): MemoryEntry[] { return [...entries].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); } +function previewSummary(summary: string, maxLength = 120): string { + const normalized = summary.trim().replace(/\s+/gu, " "); + if (normalized.length <= maxLength) { + return normalized; + } + + return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}...`; +} + function buildIndexContents(scope: MemoryScope, entries: MemoryEntry[]): string { const sortedEntries = sortEntriesByUpdatedAt(entries); + const topicSections = sortedEntries.length + ? Array.from(new Set(sortedEntries.map((entry) => entry.topic))).flatMap((topic, index) => { + const topicEntries = sortedEntries.filter((entry) => entry.topic === topic); + const count = topicEntries.length; + const latestEntry = topicEntries[0]; + return latestEntry + ? [ + `- [${topic}.md](${topic}.md): ${count} entr${count === 1 ? "y" : "ies"}`, + ...(index < maxIndexTopicSummaryPreviews + ? [` - Latest: ${previewSummary(latestEntry.summary)}`] + : []) + ] + : [`- [${topic}.md](${topic}.md): ${count} entr${count === 1 ? "y" : "ies"}`]; + }) + : ["- No topic files yet."]; const lines = [ `# ${topicTitle(scope)} Memory`, "", @@ -356,12 +434,7 @@ function buildIndexContents(scope: MemoryScope, entries: MemoryEntry[]): string "It is intentionally short so it can be injected into Codex at session start.", "", "## Topics", - ...(sortedEntries.length - ? Array.from(new Set(sortedEntries.map((entry) => entry.topic))).map((topic) => { - const count = sortedEntries.filter((entry) => entry.topic === topic).length; - return `- [${topic}.md](${topic}.md): ${count} entr${count === 1 ? "y" : "ies"}`; - }) - : ["- No topic files yet."]) + ...topicSections ]; return `${lines.join("\n")}\n`; @@ -456,7 +529,8 @@ function buildEmptyLineageSummary(): MemoryLineageSummary { rolloutConflictCount: 0, noopOperationCount: 0, suppressedOperationCount: 0, - conflictCount: 0 + conflictCount: 0, + rejectedOperationCount: 0 }; } @@ -482,7 +556,9 @@ function buildLineageSummary( rolloutConflictCount: latestAudit?.conflicts.length ?? 0, noopOperationCount: latestAudit?.noopOperationCount ?? 0, suppressedOperationCount: latestAudit?.suppressedOperationCount ?? 0, - conflictCount: latestAudit?.conflicts.length ?? 0 + conflictCount: latestAudit?.conflicts.length ?? 0, + rejectedOperationCount: latestAudit?.rejectedOperationCount ?? 0, + rejectedReasonCounts: latestAudit?.rejectedReasonCounts }; } @@ -515,7 +591,9 @@ function buildLineageSummary( rolloutConflictCount: latestAudit?.conflicts.length ?? 0, noopOperationCount: latestAudit?.noopOperationCount ?? 0, suppressedOperationCount: latestAudit?.suppressedOperationCount ?? 0, - conflictCount: latestAudit?.conflicts.length ?? 0 + conflictCount: latestAudit?.conflicts.length ?? 0, + rejectedOperationCount: latestAudit?.rejectedOperationCount ?? 0, + rejectedReasonCounts: latestAudit?.rejectedReasonCounts }; } @@ -555,7 +633,7 @@ function buildLatestAppliedLifecycle( previousState: event.previousState ?? null, nextState: event.nextState ?? null, summary: event.summary, - updateKind: event.updateKind ?? (event.action === "restore" ? "restore" : null), + updateKind: event.updateKind ?? null, sessionId: event.sessionId ?? null, rolloutPath: event.rolloutPath ?? null }; @@ -713,25 +791,30 @@ function findSearchMatch( fields: ReadonlyArray, query: string ): SearchMatch | null { - const normalizedTerms = query - .trim() - .toLowerCase() - .split(/\s+/u) - .filter(Boolean); + const normalizedTerms = normalizeMemoryQueryTerms(query); if (normalizedTerms.length === 0) { return null; } const matchedFields: string[] = []; let score = 0; + const matchedTerms = new Set(); for (const [field, value] of fields) { const haystack = value.toLowerCase(); - if (!normalizedTerms.every((term) => haystack.includes(term))) { + const fieldMatches = normalizedTerms.filter((term) => haystack.includes(term)); + if (fieldMatches.length === 0) { continue; } + matchedFields.push(field); - score += field === "summary" ? 4 : field === "details" ? 2 : 3; + fieldMatches.forEach((term) => matchedTerms.add(term)); + const fieldWeight = field === "summary" ? 4 : field === "details" ? 2 : 3; + score += fieldWeight * fieldMatches.length; + } + + if (!normalizedTerms.every((term) => matchedTerms.has(term))) { + return null; } if (matchedFields.length === 0) { @@ -834,7 +917,7 @@ function toAppliedOperation(record: MemoryApplyRecord): MemoryOperation | null { } return { - action: record.operation.action === "archive" ? "delete" : record.operation.action, + action: record.operation.action, scope: record.operation.scope, topic: record.operation.topic, id: record.operation.id, @@ -990,6 +1073,29 @@ export class MemoryStore { .sort((left, right) => left.localeCompare(right)); } + private canonicalIndexFileName(state: MemoryRecordState): "MEMORY.md" | "ARCHIVE.md" { + return state === "active" ? "MEMORY.md" : "ARCHIVE.md"; + } + + private normalizeFileContents(contents: string): string { + return contents.replace(/\r\n?/gu, "\n"); + } + + private buildExpectedIndexContents(scope: MemoryScope, state: MemoryRecordState, entries: MemoryEntry[]): string { + return state === "active" ? buildIndexContents(scope, entries) : buildArchiveIndexContents(scope, entries); + } + + private isUnexpectedSidecarFileName( + state: MemoryRecordState, + fileName: string + ): boolean { + const allowedFileNames = + state === "active" + ? new Set(["retrieval-index.json", "memory-history.jsonl", this.canonicalIndexFileName(state)]) + : new Set(["retrieval-index.json", this.canonicalIndexFileName(state)]); + return !allowedFileNames.has(fileName); + } + private async inspectRetrievalIndex( scope: MemoryScope, state: MemoryRecordState @@ -1090,9 +1196,141 @@ export class MemoryStore { return checks; } + public async inspectLayoutDiagnostics(options: { + scope?: MemoryScope | "all"; + state?: MemoryRecordState | "all"; + } = {}): Promise { + const scopes = + options.scope && options.scope !== "all" + ? [options.scope] + : (["global", "project", "project-local"] satisfies MemoryScope[]); + const states = + options.state && options.state !== "all" + ? [options.state] + : (["active", "archived"] satisfies MemoryRecordState[]); + const diagnostics: MemoryLayoutDiagnostic[] = []; + + for (const scope of scopes) { + for (const state of states) { + const scopeDir = state === "active" ? this.getScopeDir(scope) : this.getArchiveDir(scope); + if (!(await fileExists(scopeDir))) { + continue; + } + + const canonicalIndexFileName = this.canonicalIndexFileName(state); + const oppositeIndexFileName = state === "active" ? "ARCHIVE.md" : "MEMORY.md"; + const canonicalIndexPath = + state === "active" ? this.getMemoryFile(scope) : this.getArchiveIndexFile(scope); + const entries = await fs.readdir(scopeDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile() && !entry.isSymbolicLink()) { + continue; + } + + const fileName = entry.name; + const filePath = path.join(scopeDir, fileName); + if (fileName === canonicalIndexFileName) { + continue; + } + + if (fileName.endsWith(".md")) { + if (fileName === oppositeIndexFileName) { + diagnostics.push({ + scope, + state, + kind: "misplaced-index-markdown", + path: filePath, + fileName, + message: `Canonical ${fileName} belongs to the ${state === "active" ? "archived" : "active"} store, not ${scope}/${state}.` + }); + continue; + } + + const topic = fileName.replace(/\.md$/u, ""); + if (!topicNamePattern.test(topic)) { + diagnostics.push({ + scope, + state, + kind: "malformed-topic-filename", + path: filePath, + fileName, + message: `Unexpected Markdown topic file name "${fileName}" does not match the canonical kebab-case topic pattern.` + }); + continue; + } + + const contents = await readTextFile(filePath); + const parsed = parseTopicFile(contents, topic); + if (parsed.safeToRewrite && parsed.entries.length === 0) { + diagnostics.push({ + scope, + state, + kind: "orphan-topic-markdown", + path: filePath, + fileName, + message: `Canonical topic markdown "${fileName}" has no managed memory entries and does not contribute to startup or retrieval surfaces.` + }); + } + } + + if ( + (fileName.endsWith(".json") || fileName.endsWith(".jsonl")) && + this.isUnexpectedSidecarFileName(state, fileName) + ) { + diagnostics.push({ + scope, + state, + kind: "unexpected-sidecar", + path: filePath, + fileName, + message: `Unexpected sidecar/index file "${fileName}" was found alongside canonical Markdown memory files.` + }); + } + } + + const indexExists = await fileExists(canonicalIndexPath); + const expectedIndexContents = this.buildExpectedIndexContents( + scope, + state, + await this.listEntries(scope, state) + ); + if (!indexExists) { + diagnostics.push({ + scope, + state, + kind: "missing-index", + path: canonicalIndexPath, + fileName: canonicalIndexFileName, + message: `Canonical ${canonicalIndexFileName} is missing for ${scope}/${state}.` + }); + continue; + } + + const currentIndexContents = await readTextFile(canonicalIndexPath); + if (this.normalizeFileContents(currentIndexContents) !== this.normalizeFileContents(expectedIndexContents)) { + diagnostics.push({ + scope, + state, + kind: "index-drift", + path: canonicalIndexPath, + fileName: canonicalIndexFileName, + message: `Canonical ${canonicalIndexFileName} no longer matches the topic Markdown files for ${scope}/${state}.` + }); + } + } + } + + return diagnostics.sort((left, right) => + `${left.scope}:${left.state}:${left.kind}:${left.fileName}`.localeCompare( + `${right.scope}:${right.state}:${right.kind}:${right.fileName}` + ) + ); + } + public async rebuildRetrievalSidecars(options: { scope?: MemoryScope | "all"; state?: MemoryRecordState | "all"; + ensureLayout?: boolean; } = {}): Promise { const scopes: MemoryScope[] = options.scope && options.scope !== "all" @@ -1105,7 +1343,11 @@ export class MemoryStore { const rebuilt: MemoryReindexCheck[] = []; - await this.ensureLayout(); + if (options.ensureLayout !== false) { + await this.ensureLayout(); + } else if (!(await this.hasInitializedLayout())) { + return []; + } for (const scope of scopes) { for (const state of states) { await this.rebuildRetrievalIndex(scope, state); @@ -1144,7 +1386,9 @@ export class MemoryStore { retrievalIndexPath: string, payload: RetrievalIndexPayload ): Promise { - const topicFiles = await this.listTopicMarkdownFiles(scope, state); + const topicFiles = Array.from( + new Set((await this.listEntries(scope, state)).map((entry) => `${entry.topic}.md`)) + ).sort((left, right) => left.localeCompare(right)); if ( payload.topicFileCount !== topicFiles.length || JSON.stringify(payload.topicFiles) !== JSON.stringify(topicFiles) @@ -1278,9 +1522,62 @@ export class MemoryStore { } } + public async hasInitializedLayout(): Promise { + if (!(await fileExists(this.paths.baseDir))) { + return false; + } + + const canonicalPaths = [ + this.getMemoryFile("global"), + this.getMemoryFile("project"), + this.getMemoryFile("project-local"), + this.getArchiveIndexFile("global"), + this.getArchiveIndexFile("project"), + this.getArchiveIndexFile("project-local") + ]; + + for (const filePath of canonicalPaths) { + if (await fileExists(filePath)) { + return true; + } + } + + const topicDirectories = [ + this.getScopeDir("global"), + this.getScopeDir("project"), + this.getScopeDir("project-local"), + this.getArchiveDir("global"), + this.getArchiveDir("project"), + this.getArchiveDir("project-local") + ]; + + for (const directoryPath of topicDirectories) { + if (!(await fileExists(directoryPath))) { + continue; + } + + const fileNames = await fs.readdir(directoryPath); + if ( + fileNames.some( + (fileName) => + fileName.endsWith(".md") && + fileName !== "MEMORY.md" && + fileName !== "ARCHIVE.md" + ) + ) { + return true; + } + } + + return false; + } + public async listEntries( scope: MemoryScope, - state: MemoryRecordState = "active" + state: MemoryRecordState = "active", + options: { + excludeUnsafeTopics?: boolean; + } = {} ): Promise { const scopeDir = state === "active" ? this.getScopeDir(scope) : this.getArchiveDir(scope); if (!(await fileExists(scopeDir))) { @@ -1303,7 +1600,11 @@ export class MemoryStore { continue; } const contents = await readTextFile(path.join(scopeDir, fileName)); - entries.push(...parseTopicFile(contents, topic).entries); + const parsedTopic = parseTopicFile(contents, topic); + if (options.excludeUnsafeTopics && !parsedTopic.safeToRewrite) { + continue; + } + entries.push(...parsedTopic.entries); } return entries.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); @@ -1326,29 +1627,48 @@ export class MemoryStore { } const files = await fs.readdir(scopeDir); - return files - .filter( - (fileName) => - fileName.endsWith(".md") && - fileName !== "MEMORY.md" && - fileName !== "ARCHIVE.md" - ) - .map((fileName) => ({ + const refs: TopicFileRef[] = []; + for (const fileName of files) { + if ( + !fileName.endsWith(".md") || + fileName === "MEMORY.md" || + fileName === "ARCHIVE.md" + ) { + continue; + } + + const topic = fileName.replace(/\.md$/u, ""); + if (!topicNamePattern.test(topic)) { + continue; + } + + const filePath = path.join(scopeDir, fileName); + const parsedTopic = parseTopicFile(await readTextFile(filePath), topic); + if (parsedTopic.entries.length === 0) { + continue; + } + + refs.push({ scope, - topic: fileName.replace(/\.md$/u, ""), - path: path.join(scopeDir, fileName) - })) - .filter((entry) => topicNamePattern.test(entry.topic)) - .sort((left, right) => left.topic.localeCompare(right.topic)); + topic, + path: filePath + }); + } + + return refs.sort((left, right) => left.topic.localeCompare(right.topic)); } public async rebuildIndex(scope: MemoryScope): Promise { - const entries = await this.listEntries(scope, "active"); + const entries = await this.listEntries(scope, "active", { + excludeUnsafeTopics: true + }); await this.fileOps.writeTextFile(this.getMemoryFile(scope), buildIndexContents(scope, entries)); } public async rebuildArchiveIndex(scope: MemoryScope): Promise { - const entries = await this.listEntries(scope, "archived"); + const entries = await this.listEntries(scope, "archived", { + excludeUnsafeTopics: true + }); await this.fileOps.writeTextFile( this.getArchiveIndexFile(scope), buildArchiveIndexContents(scope, entries) @@ -1359,19 +1679,86 @@ export class MemoryStore { scope: MemoryScope, state: MemoryRecordState = "active" ): Promise { - const entries = await this.listEntries(scope, state); + const entries = await this.listEntries(scope, state, { + excludeUnsafeTopics: true + }); await this.fileOps.writeTextFile( this.getRetrievalIndexFile(scope, state), buildRetrievalIndexContents(scope, state, entries) ); } + public async inspectTopicFiles(options: { + scope?: MemoryScope | "all"; + state?: MemoryRecordState | "all"; + } = {}): Promise { + const scopes = + options.scope && options.scope !== "all" + ? [options.scope] + : (["global", "project", "project-local"] satisfies MemoryScope[]); + const states = + options.state && options.state !== "all" + ? [options.state] + : (["active", "archived"] satisfies MemoryRecordState[]); + const diagnostics: TopicFileDiagnostic[] = []; + + for (const scope of scopes) { + for (const state of states) { + const scopeDir = state === "active" ? this.getScopeDir(scope) : this.getArchiveDir(scope); + if (!(await fileExists(scopeDir))) { + continue; + } + + const files = await fs.readdir(scopeDir); + for (const fileName of files) { + if ( + !fileName.endsWith(".md") || + fileName === "MEMORY.md" || + fileName === "ARCHIVE.md" + ) { + continue; + } + + const topic = fileName.replace(/\.md$/u, ""); + if (!topicNamePattern.test(topic)) { + continue; + } + + const filePath = path.join(scopeDir, fileName); + const parsedTopic = parseTopicFile(await readTextFile(filePath), topic); + diagnostics.push({ + scope, + state, + topic, + path: filePath, + safeToRewrite: parsedTopic.safeToRewrite, + entryCount: parsedTopic.entries.length, + invalidEntryBlockCount: parsedTopic.invalidEntryBlockCount, + manualContentDetected: parsedTopic.manualContentDetected, + unsafeReason: parsedTopic.unsafeReason + }); + } + } + } + + return diagnostics.sort((left, right) => + `${left.scope}:${left.state}:${left.topic}`.localeCompare( + `${right.scope}:${right.state}:${right.topic}` + ) + ); + } + public async getEntryByRef(ref: string): Promise { const parsed = parseMemoryRef(ref); if (!parsed) { return null; } + const topicParse = await this.readTopicFileParse(parsed.scope, parsed.topic, parsed.state); + if (topicParse && !topicParse.parse.safeToRewrite) { + return null; + } + const entry = await this.findEntry(parsed.scope, parsed.state, parsed.topic, parsed.id); if (!entry) { return null; @@ -1381,36 +1768,38 @@ export class MemoryStore { const latestEvent = timeline.latestEvent; const latestAttempt = timeline.latestAttempt; const latestAudit = timeline.latestAudit; - const warnings = [...timeline.warnings]; - if (latestAttempt && !latestAudit && (latestAttempt.rolloutPath || latestAttempt.sessionId)) { - warnings.push( - `Lifecycle history exists for ${ref}, but no matching sync audit entry was found in ${this.getSyncAuditPath()}.` - ); - } - + const timelineWarnings = [...timeline.warnings]; + const detailWarnings: string[] = []; if ((latestAudit?.noopOperationCount ?? 0) > 0) { - warnings.push( + detailWarnings.push( `Latest sync audit recorded ${latestAudit?.noopOperationCount ?? 0} rollout-level no-op operation(s) across the whole sync.` ); } if ((latestAudit?.suppressedOperationCount ?? 0) > 0) { - warnings.push( + detailWarnings.push( `Latest sync audit suppressed ${latestAudit?.suppressedOperationCount ?? 0} rollout-level operation(s) across the whole sync.` ); } if ((latestAudit?.conflicts.length ?? 0) > 0) { - warnings.push( + detailWarnings.push( `Latest sync audit includes ${latestAudit?.conflicts.length ?? 0} rollout-level suppressed conflict candidate(s).` ); } + if ((latestAudit?.rejectedOperationCount ?? 0) > 0) { + detailWarnings.push( + `Latest sync audit rejected ${latestAudit?.rejectedOperationCount ?? 0} rollout-level operation(s) across the whole sync.` + ); + } + if (timeline.lineageSummary.refNoopCount > 0) { - warnings.push( + detailWarnings.push( `Lifecycle history recorded ${timeline.lineageSummary.refNoopCount} ref-local no-op attempt(s) for ${ref}.` ); } + const warnings = [...timelineWarnings, ...detailWarnings]; return { ...parsed, @@ -1429,7 +1818,7 @@ export class MemoryStore { latestRolloutPath: latestAttempt?.rolloutPath ?? latestEvent?.rolloutPath ?? null, historyPath: this.getHistoryPath(parsed.scope), latestAudit, - timelineWarningCount: warnings.length, + timelineWarningCount: timelineWarnings.length, lineageSummary: { ...timeline.lineageSummary, latestState: timeline.lineageSummary.latestState ?? parsed.state @@ -1454,8 +1843,19 @@ export class MemoryStore { options.state === "all" ? ["active", "archived"] : [options.state ?? "active"]; - const results: Array = []; + const results: Array & { score: number }> = []; const diagnostics: MemorySearchDiagnosticPath[] = []; + const topicDiagnostics = filterUnsafeTopicDiagnostics( + await this.inspectTopicFiles({ + scope: options.scope, + state: options.state + }) + ); + const unsafeTopicKeys = new Set( + topicDiagnostics.map((diagnostic) => + buildUnsafeTopicKey(diagnostic.scope, diagnostic.state, diagnostic.topic) + ) + ); let usedFallback = false; let fallbackReason: MemoryRetrievalFallbackReason | undefined; let matchedViaIndex = false; @@ -1467,6 +1867,9 @@ export class MemoryStore { let matchedCount = 0; if (retrievalIndex.payload) { for (const entry of retrievalIndex.payload.entries) { + if (unsafeTopicKeys.has(buildUnsafeTopicKey(scope, state, entry.topic))) { + continue; + } const match = findRetrievalIndexSearchMatch(entry, query); if (!match) { continue; @@ -1502,7 +1905,9 @@ export class MemoryStore { usedFallback = true; fallbackReason ??= retrievalIndex.fallbackReason ?? "missing"; - const entries = await this.listEntries(scope, state); + const entries = await this.listEntries(scope, state, { + excludeUnsafeTopics: true + }); for (const entry of entries) { const match = findEntrySearchMatch(entry, query); if (!match) { @@ -1539,15 +1944,20 @@ export class MemoryStore { } const totalMatchedCount = results.length; - const normalizedResults = results - .sort((left, right) => { + const sortedResults = results.sort((left, right) => { if (right.score !== left.score) { return right.score - left.score; } return right.updatedAt.localeCompare(left.updatedAt); - }) - .slice(0, options.limit ?? 10) - .map(({ score: _score, ...result }) => result); + }); + const appliedLimit = options.limit ?? 10; + const normalizedResults = sortedResults + .slice(0, appliedLimit) + .map(({ score: _score, ...result }, index) => ({ + ...result, + globalRank: index + 1 + })); + const returnedCount = normalizedResults.length; const returnedCountByPath = new Map(); for (const result of normalizedResults) { const key = buildSearchDiagnosticKey(result.scope, result.state); @@ -1563,15 +1973,22 @@ export class MemoryStore { return { results: normalizedResults, searchOrder: diagnostics.map((check) => buildSearchDiagnosticKey(check.scope, check.state)), - globalLimitApplied: normalizedResults.length < totalMatchedCount, - truncatedCount: Math.max(0, totalMatchedCount - normalizedResults.length), + totalMatchedCount, + returnedCount, + globalLimitApplied: returnedCount < totalMatchedCount, + truncatedCount: Math.max(0, totalMatchedCount - returnedCount), + resultWindow: { + start: returnedCount === 0 ? 0 : 1, + end: returnedCount, + limit: appliedLimit + }, retrievalMode: matchedViaFallback || (!matchedViaIndex && usedFallback) ? "markdown-fallback" : "index", retrievalFallbackReason: matchedViaFallback || (!matchedViaIndex && usedFallback) ? fallbackReason : undefined, - diagnostics: normalizeMemorySearchDiagnostics(diagnosticsWithReturnedCounts) + diagnostics: normalizeMemorySearchDiagnostics(diagnosticsWithReturnedCounts, topicDiagnostics) }; } @@ -1617,7 +2034,7 @@ export class MemoryStore { const olderEventHasProvenance = matchingEvents .slice(1) .some((event) => Boolean(event.rolloutPath || event.sessionId)); - const latestAudit = await this.findLatestSyncAuditSummary( + const latestAuditLookup = await this.findLatestSyncAuditSummary( parsed.scope, parsed.topic, parsed.id, @@ -1625,7 +2042,8 @@ export class MemoryStore { latestAttempt?.sessionId, latestAttempt?.action === "noop" ); - const warnings = [...history.warnings]; + const latestAudit = latestAuditLookup.summary; + const warnings = [...history.warnings, ...latestAuditLookup.warnings]; if (latestAttempt && !latestAudit && (latestAttempt.rolloutPath || latestAttempt.sessionId)) { warnings.push( `Lifecycle history exists for ${ref}, but no matching sync audit entry was found in ${this.getSyncAuditPath()}.` @@ -1637,6 +2055,23 @@ export class MemoryStore { ); } + const topicParse = await this.readTopicFileParse(parsed.scope, parsed.topic, parsed.state); + if (topicParse && !topicParse.parse.safeToRewrite) { + warnings.push( + `Source topic file for ${ref} is unsafe to rewrite because ${topicParse.parse.unsafeReason ?? "it is not safely round-trippable"}.` + ); + if (topicParse.parse.invalidEntryBlockCount > 0) { + warnings.push( + `Source topic file for ${ref} contains ${topicParse.parse.invalidEntryBlockCount} malformed or unsupported entry block(s).` + ); + } + if (topicParse.parse.manualContentDetected) { + warnings.push( + `Source topic file for ${ref} contains unsupported manual content outside managed memory entries.` + ); + } + } + return { ref, events, @@ -1709,38 +2144,6 @@ export class MemoryStore { ): Promise { const applied: MemoryApplyRecord[] = []; const scopeStates = new Map(); - const expectedSnapshots = new Map(); - const getFileSnapshot = async (filePath: string): Promise => ({ - path: filePath, - contents: await this.readTextFileIfExists(filePath) - }); - - const getTopicSnapshot = async ( - scope: MemoryScope, - topic: string, - state: MemoryRecordState - ): Promise => { - const topicFile = this.topicFilePath(scope, topic, state); - if (expectedSnapshots.has(topicFile)) { - return; - } - - const snapshot = await getFileSnapshot(topicFile); - if (snapshot.contents === null) { - expectedSnapshots.set(topicFile, snapshot); - return; - } - - const contents = snapshot.contents; - const parsed = parseTopicFile(contents, topic); - if (!parsed.safeToRewrite) { - throw new Error( - `Cannot rewrite topic file ${topicFile} because ${parsed.unsafeReason ?? "it is not safely round-trippable"}. Fix the file manually before editing durable memory.` - ); - } - - expectedSnapshots.set(topicFile, snapshot); - }; for (const mutation of mutations) { if (!scopeStates.has(mutation.scope)) { @@ -1753,7 +2156,6 @@ export class MemoryStore { } const topic = normalizeTopicName(mutation.topic); - await getTopicSnapshot(mutation.scope, topic, "active"); const existingActive = this.findEntryInEntries(scopeState.activeEntries, topic, mutation.id); const existingArchived = this.findEntryInEntries( scopeState.archivedEntries, @@ -1761,15 +2163,9 @@ export class MemoryStore { mutation.id ); - if (mutation.action === "archive" || existingArchived) { - await getTopicSnapshot(mutation.scope, topic, "archived"); - } - if (mutation.action === "upsert") { if (!mutation.summary) { - throw new Error( - `Invalid upsert mutation for ${mutation.scope}/${topic}/${mutation.id}: summary is required.` - ); + continue; } const updatedAt = new Date().toISOString(); @@ -2100,17 +2496,9 @@ export class MemoryStore { } } - for (const change of fileChanges) { - if (expectedSnapshots.has(change.path)) { - continue; - } - expectedSnapshots.set(change.path, await getFileSnapshot(change.path)); - } - return { applied, - fileChanges, - expectedSnapshots: Array.from(expectedSnapshots.values()) + fileChanges }; } @@ -2141,26 +2529,11 @@ export class MemoryStore { return rollbackErrors; } - private async assertSnapshotsUnchanged(expectedSnapshots: FileSnapshot[]): Promise { - for (const snapshot of expectedSnapshots) { - const currentContents = await this.readTextFileIfExists(snapshot.path); - if (currentContents !== snapshot.contents) { - throw new Error( - `Cannot apply durable memory changes because ${snapshot.path} changed since the mutation plan was built. Re-run the operation after reviewing the latest file contents.` - ); - } - } - } - - private async commitPlannedFileChanges( - fileChanges: PlannedFileChange[], - expectedSnapshots: FileSnapshot[] - ): Promise { + private async commitPlannedFileChanges(fileChanges: PlannedFileChange[]): Promise { if (fileChanges.length === 0) { return; } - await this.assertSnapshotsUnchanged(expectedSnapshots); const snapshots = await this.captureFileSnapshots(fileChanges); const writes = fileChanges .filter((change): change is PlannedFileChange & { contents: string } => change.contents !== null) @@ -2217,8 +2590,9 @@ export class MemoryStore { } = {} ): Promise { await this.ensureLayout(); + await this.assertMutationTargetsAreSafe(mutations); const plan = await this.buildMutationCommitPlan(mutations, options); - await this.commitPlannedFileChanges(plan.fileChanges, plan.expectedSnapshots); + await this.commitPlannedFileChanges(plan.fileChanges); return plan.applied; } @@ -2301,13 +2675,16 @@ export class MemoryStore { scope === "all" ? ["global", "project", "project-local"] : [scope]; const deleted: MemoryEntry[] = []; const mutations: MemoryMutation[] = []; - const normalizedQuery = query.toLowerCase(); + const normalizedTerms = normalizeMemoryQueryTerms(query); + if (normalizedTerms.length === 0) { + throw new Error("Forget query must be non-empty."); + } for (const currentScope of scopes) { const entries = await this.listEntries(currentScope, "active"); for (const entry of entries) { - const haystack = `${entry.id}\n${entry.summary}\n${entry.details.join("\n")}`.toLowerCase(); - if (!haystack.includes(normalizedQuery)) { + const haystack = `${entry.id}\n${entry.topic}\n${entry.summary}\n${entry.details.join("\n")}`; + if (!matchesAllMemoryQueryTerms(haystack, normalizedTerms)) { continue; } @@ -2336,17 +2713,36 @@ export class MemoryStore { public async readMemoryFile( scope: MemoryScope, - state: MemoryRecordState = "active" + state: MemoryRecordState = "active", + options: { + createIfMissing?: boolean; + excludeUnsafeTopics?: boolean; + } = {} ): Promise { const memoryFile = state === "active" ? this.getMemoryFile(scope) : this.getArchiveIndexFile(scope); if (!(await fileExists(memoryFile))) { - if (state === "active") { - await this.rebuildIndex(scope); + if (options.createIfMissing !== false) { + if (state === "active") { + await this.rebuildIndex(scope); + } else { + await this.rebuildArchiveIndex(scope); + } } else { - await this.rebuildArchiveIndex(scope); + return state === "active" + ? buildIndexContents(scope, []) + : buildArchiveIndexContents(scope, []); } } + if (options.excludeUnsafeTopics) { + const entries = await this.listEntries(scope, state, { + excludeUnsafeTopics: true + }); + return state === "active" + ? buildIndexContents(scope, entries) + : buildArchiveIndexContents(scope, entries); + } + return readTextFile(memoryFile); } @@ -2397,26 +2793,54 @@ export class MemoryStore { }; } - private async readSyncAuditEntries(): Promise { + private async readSyncAuditEntries(): Promise { const auditPath = this.getSyncAuditPath(); if (!(await fileExists(auditPath))) { - return []; + return { + entries: [], + warnings: [] + }; } const raw = await readTextFile(auditPath); - return raw + let invalidJsonLineCount = 0; + let invalidEntryCount = 0; + const entries = raw .split("\n") .map((line) => line.trim()) .filter(Boolean) .flatMap((line) => { try { const parsed = parseMemorySyncAuditEntry(JSON.parse(line) as unknown); - return parsed ? [parsed] : []; + if (parsed) { + return [parsed]; + } + + invalidEntryCount += 1; + return []; } catch { + invalidJsonLineCount += 1; return []; } }) .sort((left, right) => right.appliedAt.localeCompare(left.appliedAt)); + + const warnings: string[] = []; + if (invalidJsonLineCount > 0) { + warnings.push( + `Sync audit source ${auditPath} contains ${invalidJsonLineCount} invalid JSON line(s); malformed audit provenance was ignored.` + ); + } + if (invalidEntryCount > 0) { + warnings.push( + `Sync audit source ${auditPath} contains ${invalidEntryCount} malformed audit entry line(s); unsupported audit provenance was ignored.` + ); + } + + return { + entries, + warnings + }; } private async findLatestSyncAuditSummary( @@ -2426,12 +2850,18 @@ export class MemoryStore { latestRolloutPath?: string, latestSessionId?: string, allowNoopProvenanceMatch = false - ): Promise { + ): Promise<{ + summary: MemorySyncAuditSummary | null; + warnings: string[]; + }> { if (latestRolloutPath === undefined && latestSessionId === undefined) { - return null; + return { + summary: null, + warnings: [] + }; } - const entries = await this.readSyncAuditEntries(); + const { entries, warnings } = await this.readSyncAuditEntries(); const matched = entries.find((entry) => { const matchesProvenance = latestRolloutPath !== undefined @@ -2453,24 +2883,39 @@ export class MemoryStore { }); if (!matched) { - return null; + return { + summary: null, + warnings + }; } const matchedOperations = matched.operations.filter( (operation) => operation.scope === scope && operation.topic === topic && operation.id === id ); + if (matchedOperations.length === 0) { + return { + summary: null, + warnings + }; + } return { - auditPath: this.getSyncAuditPath(), - appliedAt: matched.appliedAt, - rolloutPath: matched.rolloutPath, - sessionId: matched.sessionId, - status: matched.status, - resultSummary: matched.resultSummary, - matchedOperationCount: matchedOperations.length, - noopOperationCount: matched.noopOperationCount ?? 0, - suppressedOperationCount: matched.suppressedOperationCount ?? 0, - conflicts: matched.conflicts ?? [] + summary: { + auditPath: this.getSyncAuditPath(), + appliedAt: matched.appliedAt, + rolloutPath: matched.rolloutPath, + sessionId: matched.sessionId, + status: matched.status, + resultSummary: matched.resultSummary, + matchedOperationCount: matchedOperations.length, + noopOperationCount: matched.noopOperationCount ?? 0, + suppressedOperationCount: matched.suppressedOperationCount ?? 0, + rejectedOperationCount: matched.rejectedOperationCount ?? 0, + rejectedReasonCounts: matched.rejectedReasonCounts, + rejectedOperations: matched.rejectedOperations, + conflicts: matched.conflicts ?? [] + }, + warnings }; } @@ -2526,7 +2971,7 @@ export class MemoryStore { } public async readRecentSyncAuditEntries(limit = 5): Promise { - return (await this.readSyncAuditEntries()).slice(0, limit); + return (await this.readSyncAuditEntries()).entries.slice(0, limit); } public async writeSyncRecoveryRecord(record: SyncRecoveryRecord): Promise { @@ -2544,7 +2989,7 @@ export class MemoryStore { throw new Error(`Invalid sync recovery record: ${recoveryPath}`); } - return record; + return normalizeSyncRecoveryRecord(record); } public async clearSyncRecoveryRecord(): Promise { diff --git a/src/lib/domain/recovery-records.ts b/src/lib/domain/recovery-records.ts index 7477821..b60d964 100644 --- a/src/lib/domain/recovery-records.ts +++ b/src/lib/domain/recovery-records.ts @@ -3,7 +3,9 @@ import type { ContinuityRecoveryRecord, ContinuityRecoveryFailedStage, MemoryConflictCandidate, + MemoryOperationRejectionReason, MemoryScope, + RejectedMemoryOperationSummary, SessionContinuityConfidence, SessionContinuityAuditTrigger, SessionContinuityDiagnostics, @@ -55,6 +57,57 @@ function isMemoryConflictCandidate(value: unknown): value is MemoryConflictCandi ); } +function isMemoryOperationRejectionReason( + value: unknown +): value is MemoryOperationRejectionReason { + return ( + value === "unknown-topic" || + value === "sensitive" || + value === "volatile" || + value === "empty-summary" || + value === "operation-cap" + ); +} + +function isLegacyMemoryOperationRejectionReason(value: unknown): value is "detail-truncated" { + return value === "detail-truncated"; +} + +function isRejectedReasonCounts( + value: unknown +): value is Partial> { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + return Object.entries(value).every( + ([key, count]) => + (isMemoryOperationRejectionReason(key) || isLegacyMemoryOperationRejectionReason(key)) && + typeof count === "number" && + count >= 0 + ); +} + +function isRejectedMemoryOperationSummary( + value: unknown +): value is RejectedMemoryOperationSummary { + if (!value || typeof value !== "object") { + return false; + } + + const summary = value as Record; + return ( + (summary.action === "upsert" || + summary.action === "delete" || + summary.action === "archive") && + isMemoryScope(summary.scope) && + typeof summary.topic === "string" && + typeof summary.id === "string" && + (isMemoryOperationRejectionReason(summary.reason) || + isLegacyMemoryOperationRejectionReason(summary.reason)) + ); +} + function isContinuityTrigger(value: unknown): value is SessionContinuityAuditTrigger { return ( value === undefined || @@ -105,7 +158,7 @@ function isSyncRecoveryFailedStage(value: unknown): value is SyncRecoveryFailedS function isContinuityRecoveryFailedStage( value: unknown ): value is ContinuityRecoveryFailedStage { - return value === "audit-write"; + return value === "summary-write" || value === "audit-write"; } export function isSyncRecoveryRecord(value: unknown): value is SyncRecoveryRecord { @@ -119,10 +172,18 @@ export function isSyncRecoveryRecord(value: unknown): value is SyncRecoveryRecor isMemoryConflictCandidate(candidate) ) : []; - const hasNoopOperationCount = Object.prototype.hasOwnProperty.call(record, "noopOperationCount"); - const noopOperationCount = hasNoopOperationCount ? record.noopOperationCount : 0; + const noopOperationCount = + typeof record.noopOperationCount === "number" ? record.noopOperationCount : 0; const suppressedOperationCount = typeof record.suppressedOperationCount === "number" ? record.suppressedOperationCount : 0; + const rejectedOperationCount = + typeof record.rejectedOperationCount === "number" ? record.rejectedOperationCount : 0; + const rejectedOperations = Array.isArray(record.rejectedOperations) + ? record.rejectedOperations.filter((operation): operation is RejectedMemoryOperationSummary => + isRejectedMemoryOperationSummary(operation) && + isMemoryOperationRejectionReason(operation.reason) + ) + : []; return ( typeof record.recordedAt === "string" && typeof record.projectId === "string" && @@ -135,9 +196,11 @@ export function isSyncRecoveryRecord(value: unknown): value is SyncRecoveryRecor typeof record.actualExtractorName === "string" && (record.status === "applied" || record.status === "no-op") && typeof record.appliedCount === "number" && - (!hasNoopOperationCount || - (typeof noopOperationCount === "number" && noopOperationCount >= 0)) && + noopOperationCount >= 0 && suppressedOperationCount >= 0 && + rejectedOperationCount >= 0 && + (record.rejectedReasonCounts === undefined || isRejectedReasonCounts(record.rejectedReasonCounts)) && + rejectedOperations.length === (Array.isArray(record.rejectedOperations) ? record.rejectedOperations.length : 0) && Array.isArray(record.scopesTouched) && record.scopesTouched.every((scope) => isMemoryScope(scope)) && conflicts.length === (Array.isArray(record.conflicts) ? record.conflicts.length : 0) && @@ -147,6 +210,22 @@ export function isSyncRecoveryRecord(value: unknown): value is SyncRecoveryRecor ); } +export function normalizeSyncRecoveryRecord(record: SyncRecoveryRecord): SyncRecoveryRecord { + return { + ...record, + noopOperationCount: record.noopOperationCount ?? 0, + suppressedOperationCount: record.suppressedOperationCount ?? 0, + rejectedOperationCount: record.rejectedOperationCount ?? 0, + rejectedReasonCounts: Object.fromEntries( + Object.entries(record.rejectedReasonCounts ?? {}).filter(([reason]) => + isMemoryOperationRejectionReason(reason) + ) + ), + rejectedOperations: record.rejectedOperations ?? [], + conflicts: record.conflicts ?? [] + }; +} + export function isContinuityRecoveryRecord( value: unknown ): value is ContinuityRecoveryRecord { @@ -190,6 +269,9 @@ interface BuildSyncRecoveryRecordOptions { appliedCount: number; noopOperationCount?: number; suppressedOperationCount?: number; + rejectedOperationCount?: number; + rejectedReasonCounts?: Partial>; + rejectedOperations?: RejectedMemoryOperationSummary[]; scopesTouched: MemoryScope[]; conflicts?: MemoryConflictCandidate[]; failedStage: SyncRecoveryFailedStage; @@ -200,7 +282,7 @@ interface BuildSyncRecoveryRecordOptions { export function buildSyncRecoveryRecord( options: BuildSyncRecoveryRecordOptions ): SyncRecoveryRecord { - return { + return normalizeSyncRecoveryRecord({ recordedAt: new Date().toISOString(), projectId: options.projectId, worktreeId: options.worktreeId, @@ -214,12 +296,17 @@ export function buildSyncRecoveryRecord( appliedCount: options.appliedCount, noopOperationCount: options.noopOperationCount ?? 0, suppressedOperationCount: options.suppressedOperationCount ?? 0, + rejectedOperationCount: options.rejectedOperationCount ?? 0, + ...(options.rejectedReasonCounts ? { rejectedReasonCounts: options.rejectedReasonCounts } : {}), + ...(options.rejectedOperations && options.rejectedOperations.length > 0 + ? { rejectedOperations: options.rejectedOperations } + : {}), scopesTouched: options.scopesTouched, conflicts: options.conflicts ?? [], failedStage: options.failedStage, failureMessage: options.failureMessage, auditEntryWritten: options.auditEntryWritten - }; + }); } // Recovery identity uses 4 fields (projectId, worktreeId, rolloutPath, sessionId) rather than @@ -264,6 +351,7 @@ export function buildContinuityRecoveryRecord( worktreeId: options.worktreeId, rolloutPath: options.diagnostics.rolloutPath, sourceSessionId: options.diagnostics.sourceSessionId, + provenanceKind: options.diagnostics.provenanceKind, trigger: options.trigger, writeMode: options.writeMode, scope: options.scope, diff --git a/src/lib/domain/startup-memory.ts b/src/lib/domain/startup-memory.ts index c369d75..61b9ba1 100644 --- a/src/lib/domain/startup-memory.ts +++ b/src/lib/domain/startup-memory.ts @@ -1,7 +1,20 @@ -import type { CompiledStartupMemory, MemoryScope, TopicFileRef } from "../types.js"; +import type { + CompiledStartupMemory, + MemoryEntry, + MemoryScope, + StartupMemoryHighlight, + StartupMemoryOmission, + StartupMemoryOmissionReason, + TopicFileDiagnostic, + TopicFileRef +} from "../types.js"; import { DEFAULT_STARTUP_LINE_LIMIT } from "../constants.js"; +import { fileExists } from "../util/fs.js"; import { MemoryStore } from "./memory-store.js"; +const MAX_STARTUP_HIGHLIGHTS = 4; +const MAX_STARTUP_HIGHLIGHTS_PER_SCOPE = 2; + function heading(scope: MemoryScope): string { switch (scope) { case "global": @@ -24,6 +37,160 @@ function formatTopicRef(topicFile: TopicFileRef): string { return `- ${JSON.stringify(topicFile)}`; } +function formatStartupHighlight(highlight: StartupMemoryHighlight): string { + return `- highlight ${JSON.stringify(highlight)}`; +} + +function highlightPriority(entry: MemoryEntry): number { + const normalizedSummary = entry.summary.trim().toLowerCase(); + const normalizedId = entry.id.trim().toLowerCase(); + const normalizedIdLabel = entry.id.replace(/-/g, " ").trim().toLowerCase(); + if (normalizedSummary === normalizedId || normalizedSummary === normalizedIdLabel) { + return 0; + } + + return 1; +} + +function normalizeHighlightSummary(summary: string): string { + return summary.trim().toLowerCase().replace(/\s+/g, " "); +} + +function startupEntryKey(entry: Pick): string { + return `${entry.scope}:${entry.topic}:${entry.id}`; +} + +function selectStartupHighlights( + entries: MemoryEntry[], + seenSummaries: Set +): { highlights: StartupMemoryHighlight[]; omissions: StartupMemoryOmission[] } { + const omissions: StartupMemoryOmission[] = []; + const uniqueEntries: MemoryEntry[] = []; + + for (const entry of entries.sort((left, right) => { + const priorityComparison = highlightPriority(right) - highlightPriority(left); + if (priorityComparison !== 0) { + return priorityComparison; + } + + const updatedAtComparison = right.updatedAt.localeCompare(left.updatedAt); + if (updatedAtComparison !== 0) { + return updatedAtComparison; + } + + const topicComparison = left.topic.localeCompare(right.topic); + if (topicComparison !== 0) { + return topicComparison; + } + + return left.id.localeCompare(right.id); + })) { + const normalizedSummary = normalizeHighlightSummary(entry.summary); + if (seenSummaries.has(normalizedSummary)) { + omissions.push({ + scope: entry.scope, + topic: entry.topic, + id: entry.id, + summary: entry.summary, + reason: "duplicate-summary", + target: "highlight", + stage: "selection" + }); + continue; + } + + seenSummaries.add(normalizedSummary); + uniqueEntries.push(entry); + } + + const eligibleEntries = uniqueEntries.filter((entry) => highlightPriority(entry) > 0); + const selectedEntries = eligibleEntries.slice(0, MAX_STARTUP_HIGHLIGHTS_PER_SCOPE); + const selectedIds = new Set(selectedEntries.map((entry) => startupEntryKey(entry))); + for (const entry of uniqueEntries) { + if (selectedIds.has(startupEntryKey(entry))) { + continue; + } + + omissions.push({ + scope: entry.scope, + topic: entry.topic, + id: entry.id, + summary: entry.summary, + reason: highlightPriority(entry) === 0 ? "low-signal" : "budget-trimmed", + target: "highlight", + stage: "selection", + budgetKind: highlightPriority(entry) === 0 ? undefined : "per-scope-highlight-cap" + }); + } + + return { + highlights: selectedEntries.map((entry, index) => ({ + scope: entry.scope, + topic: entry.topic, + id: entry.id, + summary: entry.summary, + selectionReason: "eligible-highlight", + selectionRank: index + 1 + })), + omissions + }; +} + +function pushOmission( + omissions: StartupMemoryOmission[], + omission: StartupMemoryOmission +): void { + if ( + omissions.some( + (existing) => + existing.scope === omission.scope && + existing.topic === omission.topic && + existing.id === omission.id && + existing.reason === omission.reason && + existing.target === omission.target && + existing.stage === omission.stage + ) + ) { + return; + } + + omissions.push(omission); +} + +function buildUnsafeTopicOmissions( + diagnostics: TopicFileDiagnostic[], + entries: MemoryEntry[] +): StartupMemoryOmission[] { + const unsafeReasonByTopicKey = new Map( + diagnostics + .filter((entry) => !entry.safeToRewrite) + .map((entry) => [`${entry.scope}:${entry.topic}`, entry.unsafeReason] as const) + ); + const unsafeTopics = new Set( + diagnostics.filter((entry) => !entry.safeToRewrite).map((entry) => `${entry.scope}:${entry.topic}`) + ); + + return entries + .filter((entry) => unsafeTopics.has(`${entry.scope}:${entry.topic}`)) + .map((entry) => ({ + scope: entry.scope, + topic: entry.topic, + id: entry.id, + summary: entry.summary, + reason: "unsafe-topic", + target: "highlight", + stage: "selection", + unsafeTopicReason: unsafeReasonByTopicKey.get(`${entry.scope}:${entry.topic}`) + })); +} + +function rankStartupHighlights(highlights: StartupMemoryHighlight[]): StartupMemoryHighlight[] { + return highlights.map((highlight, index) => ({ + ...highlight, + selectionRank: index + 1 + })); +} + function appendWithinBudget( lines: string[], blockLines: string[], @@ -46,6 +213,61 @@ function appendWithinBudget( return appended; } +function countStartupOmissions( + omissions: StartupMemoryOmission[] +): Partial> { + return omissions.reduce>>((counts, omission) => { + counts[omission.reason] = (counts[omission.reason] ?? 0) + 1; + return counts; + }, {}); +} + +function countStartupOmissionsByTarget( + omissions: StartupMemoryOmission[], + target: "highlight" | "topic-file" +): number { + return omissions.filter( + (omission) => + omission.target === target && + !(target === "highlight" && omission.reason === "no-eligible-entry") + ).length; +} + +function countStartupOmissionsForTarget( + omissions: StartupMemoryOmission[], + target: "highlight" | "topic-file" +): Partial> { + return countStartupOmissions(omissions.filter((omission) => omission.target === target)); +} + +function countStartupOmissionsByTargetAndStage(omissions: StartupMemoryOmission[]): { + highlight: { selection: number; render: number }; + topicFile: { selection: number; render: number }; + scopeBlock: { selection: number; render: number }; +} { + const counts = { + highlight: { selection: 0, render: 0 }, + topicFile: { selection: 0, render: 0 }, + scopeBlock: { selection: 0, render: 0 } + }; + + for (const omission of omissions) { + if (!omission.target || !omission.stage) { + continue; + } + + const normalizedTarget = + omission.target === "topic-file" + ? "topicFile" + : omission.target === "scope-block" + ? "scopeBlock" + : "highlight"; + counts[normalizedTarget][omission.stage] += 1; + } + + return counts; +} + export async function compileStartupMemory( store: MemoryStore, maxLines = DEFAULT_STARTUP_LINE_LIMIT @@ -61,34 +283,310 @@ export async function compileStartupMemory( ]; const sourceFiles: string[] = []; const topicFiles: TopicFileRef[] = []; + const highlights: StartupMemoryHighlight[] = []; + const omissions: StartupMemoryOmission[] = []; const scopes = ["project-local", "project", "global"] satisfies MemoryScope[]; + const sectionsRendered = { + projectLocal: false, + project: false, + global: false, + highlights: false, + topicFiles: false + }; + const topicRefCountsByScope = { + global: { discovered: 0, rendered: 0, omitted: 0 }, + project: { discovered: 0, rendered: 0, omitted: 0 }, + projectLocal: { discovered: 0, rendered: 0, omitted: 0 } + }; appendWithinBudget(lines, preamble, maxLines); + const unsafeTopicKeys = new Set(); + const unsafeTopicReasons = new Map(); + const scopeData = await Promise.all( + scopes.map(async (scope) => { + const filePath = store.getMemoryFile(scope); + const filePresent = await fileExists(filePath); + const contents = filePresent + ? await store.readMemoryFile(scope, "active", { + createIfMissing: false, + excludeUnsafeTopics: true + }) + : ""; + const scopedTopicFiles = await store.listTopicRefs(scope); + + return { + scope, + filePath, + contents, + filePresent, + topicFiles: scopedTopicFiles, + isEmpty: scopedTopicFiles.length === 0, + scopeBlock: filePresent + ? [ + `## ${heading(scope)}`, + `Memory file: ${JSON.stringify(filePath)}`, + "Quoted file contents:", + ...quoteMemoryFileLines(contents), + "" + ] + : [] + }; + }) + ); + + const seenHighlightSummaries = new Set(); for (const scope of scopes) { - const filePath = store.getMemoryFile(scope); - const contents = await store.readMemoryFile(scope); - const scopeBlock = [ - `## ${heading(scope)}`, - `Memory file: ${JSON.stringify(filePath)}`, - "Quoted file contents:", - ...quoteMemoryFileLines(contents), - "" - ]; - const appended = appendWithinBudget(lines, scopeBlock, maxLines, 4); + const allActiveEntries = await store.listEntries(scope, "active"); + const unsafeTopicDiagnostics = await store.inspectTopicFiles({ + scope, + state: "active" + }); + omissions.push(...buildUnsafeTopicOmissions(unsafeTopicDiagnostics, allActiveEntries)); + for (const diagnostic of unsafeTopicDiagnostics) { + if (!diagnostic.safeToRewrite) { + unsafeTopicKeys.add(`${diagnostic.scope}:${diagnostic.topic}`); + unsafeTopicReasons.set( + `${diagnostic.scope}:${diagnostic.topic}`, + diagnostic.unsafeReason + ); + } + } + const safeEntries = allActiveEntries.filter( + (entry) => + !unsafeTopicDiagnostics.some( + (diagnostic) => + !diagnostic.safeToRewrite && + diagnostic.scope === entry.scope && + diagnostic.topic === entry.topic + ) + ); + const selected = selectStartupHighlights(safeEntries, seenHighlightSummaries); + for (const omission of selected.omissions) { + pushOmission(omissions, omission); + } + if (highlights.length < MAX_STARTUP_HIGHLIGHTS) { + const remainingHighlightSlots = MAX_STARTUP_HIGHLIGHTS - highlights.length; + const retainedHighlights = selected.highlights.slice(0, remainingHighlightSlots); + const droppedHighlights = selected.highlights.slice(remainingHighlightSlots); + highlights.push(...retainedHighlights); + for (const highlight of droppedHighlights) { + pushOmission(omissions, { + scope: highlight.scope, + topic: highlight.topic, + id: highlight.id, + summary: highlight.summary, + reason: "budget-trimmed", + target: "highlight", + stage: "selection", + budgetKind: "global-highlight-cap" + }); + } + } else { + for (const highlight of selected.highlights) { + pushOmission(omissions, { + scope: highlight.scope, + topic: highlight.topic, + id: highlight.id, + summary: highlight.summary, + reason: "budget-trimmed", + target: "highlight", + stage: "selection", + budgetKind: "global-highlight-cap" + }); + } + } + } + + const rankedHighlights = rankStartupHighlights(highlights); + highlights.length = 0; + highlights.push(...rankedHighlights); + + if (highlights.length === 0) { + pushOmission(omissions, { + scope: "project", + topic: "startup", + reason: "no-eligible-entry", + target: "highlight", + stage: "selection" + }); + } + + const scopeTopicRefs = scopeData.flatMap((scope) => scope.topicFiles); + const safeScopeTopicRefs: TopicFileRef[] = []; + for (const topicRef of scopeTopicRefs) { + if (unsafeTopicKeys.has(`${topicRef.scope}:${topicRef.topic}`)) { + pushOmission(omissions, { + scope: topicRef.scope, + topic: topicRef.topic, + reason: "unsafe-topic", + target: "topic-file", + stage: "selection", + unsafeTopicReason: unsafeTopicReasons.get(`${topicRef.scope}:${topicRef.topic}`) + }); + continue; + } + + safeScopeTopicRefs.push(topicRef); + } + for (const scopeInfo of scopeData) { + if (scopeInfo.scope === "project-local") { + topicRefCountsByScope.projectLocal.discovered = scopeInfo.topicFiles.length; + } else if (scopeInfo.scope === "project") { + topicRefCountsByScope.project.discovered = scopeInfo.topicFiles.length; + } else { + topicRefCountsByScope.global.discovered = scopeInfo.topicFiles.length; + } + } + const projectedLineCount = + preamble.length + + scopeData.reduce((total, scope) => total + scope.scopeBlock.length, 0) + + (highlights.length > 0 ? 2 + highlights.length : 0) + + (safeScopeTopicRefs.length > 0 ? 2 + safeScopeTopicRefs.length : 0); + const skipEmptyScopeBlocks = projectedLineCount > maxLines; + const reservedHighlightLines = highlights.length > 0 ? 3 : 0; + const scopeBlockBudget = Math.max(lines.length, maxLines - reservedHighlightLines); + + for (const [scopeIndex, scopeInfo] of scopeData.entries()) { + if (scopeInfo.scopeBlock.length === 0) { + continue; + } + + if (skipEmptyScopeBlocks && scopeInfo.isEmpty) { + pushOmission(omissions, { + scope: scopeInfo.scope, + topic: "startup", + reason: "budget-trimmed", + target: "scope-block", + stage: "selection", + budgetKind: "line-budget" + }); + continue; + } + + const appended = appendWithinBudget(lines, scopeInfo.scopeBlock, scopeBlockBudget, 4); if (appended === 0) { + for (const remainingScopeInfo of scopeData.slice(scopeIndex)) { + if (remainingScopeInfo.scopeBlock.length === 0) { + continue; + } + + pushOmission(omissions, { + scope: remainingScopeInfo.scope, + topic: "startup", + reason: "budget-trimmed", + target: "scope-block", + stage: "render", + budgetKind: "line-budget" + }); + } break; } - if (appended >= 4 && !sourceFiles.includes(filePath)) { - sourceFiles.push(filePath); + if (appended >= 4 && !sourceFiles.includes(scopeInfo.filePath)) { + sourceFiles.push(scopeInfo.filePath); + if (scopeInfo.scope === "project-local") { + sectionsRendered.projectLocal = true; + } else if (scopeInfo.scope === "project") { + sectionsRendered.project = true; + } else { + sectionsRendered.global = true; + } } } - const scopeTopicRefs = ( - await Promise.all(scopes.map((scope) => store.listTopicRefs(scope))) - ).flat(); + if (highlights.length > 0) { + const selectedHighlights = [...highlights]; + const [firstHighlight, ...remainingHighlights] = highlights; + if (!firstHighlight) { + const finalText = lines.join("\n").trimEnd(); + const finalLines = finalText ? finalText.split("\n") : []; + return { + text: `${finalText}\n`, + lineCount: finalLines.length, + sourceFiles, + topicFiles, + highlights, + omissions, + omissionCounts: countStartupOmissions(omissions), + topicFileOmissionCounts: countStartupOmissionsForTarget(omissions, "topic-file"), + omissionCountsByTargetAndStage: countStartupOmissionsByTargetAndStage(omissions), + omittedHighlightCount: countStartupOmissionsByTarget(omissions, "highlight"), + omittedTopicFileCount: countStartupOmissionsByTarget(omissions, "topic-file"), + topicRefCountsByScope, + sectionsRendered + }; + } - if (scopeTopicRefs.length > 0) { - const [firstTopicRef, ...remainingTopicRefs] = scopeTopicRefs; + const highlightHeaderBlock = [ + "### Highlights", + "Each line below is a compact active-memory highlight. Read the topic file only when you need more detail.", + formatStartupHighlight(firstHighlight) + ]; + const appendedHighlights = appendWithinBudget(lines, highlightHeaderBlock, maxLines, 3); + if (appendedHighlights < 3) { + highlights.length = 0; + } else { + sectionsRendered.highlights = true; + for (const highlight of remainingHighlights) { + if (appendWithinBudget(lines, [formatStartupHighlight(highlight)], maxLines) === 0) { + break; + } + } + const renderedHighlightLines = lines.filter( + (line) => line.startsWith("- highlight {\"scope\":") && line.includes("\"summary\":") + ); + const renderedHighlights = new Set(renderedHighlightLines); + const retainedHighlights = highlights.filter((highlight) => + renderedHighlights.has(formatStartupHighlight(highlight)) + ); + highlights.length = 0; + highlights.push(...rankStartupHighlights(retainedHighlights)); + } + for (const highlight of selectedHighlights) { + if ( + highlights.some( + (retained) => + retained.scope === highlight.scope && + retained.topic === highlight.topic && + retained.id === highlight.id + ) + ) { + continue; + } + + pushOmission(omissions, { + scope: highlight.scope, + topic: highlight.topic, + id: highlight.id, + summary: highlight.summary, + reason: "budget-not-reached", + target: "highlight", + stage: "render", + budgetKind: "line-budget" + }); + } + if (safeScopeTopicRefs.length === 0) { + const finalText = lines.join("\n").trimEnd(); + const finalLines = finalText ? finalText.split("\n") : []; + return { + text: `${finalText}\n`, + lineCount: finalLines.length, + sourceFiles, + topicFiles, + highlights, + omissions, + omissionCounts: countStartupOmissions(omissions), + topicFileOmissionCounts: countStartupOmissionsForTarget(omissions, "topic-file"), + omissionCountsByTargetAndStage: countStartupOmissionsByTargetAndStage(omissions), + omittedHighlightCount: countStartupOmissionsByTarget(omissions, "highlight"), + omittedTopicFileCount: countStartupOmissionsByTarget(omissions, "topic-file"), + topicRefCountsByScope, + sectionsRendered + }; + } + } + + if (safeScopeTopicRefs.length > 0) { + const [firstTopicRef, ...remainingTopicRefs] = safeScopeTopicRefs; if (!firstTopicRef) { const finalText = lines.join("\n").trimEnd(); const finalLines = finalText ? finalText.split("\n") : []; @@ -96,7 +594,16 @@ export async function compileStartupMemory( text: `${finalText}\n`, lineCount: finalLines.length, sourceFiles, - topicFiles + topicFiles, + highlights, + omissions, + omissionCounts: countStartupOmissions(omissions), + topicFileOmissionCounts: countStartupOmissionsForTarget(omissions, "topic-file"), + omissionCountsByTargetAndStage: countStartupOmissionsByTargetAndStage(omissions), + omittedHighlightCount: countStartupOmissionsByTarget(omissions, "highlight"), + omittedTopicFileCount: countStartupOmissionsByTarget(omissions, "topic-file"), + topicRefCountsByScope, + sectionsRendered }; } const topicHeaderBlock = [ @@ -106,16 +613,55 @@ export async function compileStartupMemory( ]; const appendedHeader = appendWithinBudget(lines, topicHeaderBlock, maxLines, 3); if (appendedHeader >= 3) { + sectionsRendered.topicFiles = true; topicFiles.push(firstTopicRef); + if (firstTopicRef.scope === "project-local") { + topicRefCountsByScope.projectLocal.rendered += 1; + } else if (firstTopicRef.scope === "project") { + topicRefCountsByScope.project.rendered += 1; + } else { + topicRefCountsByScope.global.rendered += 1; + } for (const topicFile of remainingTopicRefs) { if (appendWithinBudget(lines, [formatTopicRef(topicFile)], maxLines) === 0) { break; } topicFiles.push(topicFile); + if (topicFile.scope === "project-local") { + topicRefCountsByScope.projectLocal.rendered += 1; + } else if (topicFile.scope === "project") { + topicRefCountsByScope.project.rendered += 1; + } else { + topicRefCountsByScope.global.rendered += 1; + } } } } + const renderedTopicFileKeys = new Set( + topicFiles.map((topicFile) => `${topicFile.scope}:${topicFile.topic}:${topicFile.path}`) + ); + for (const topicFile of safeScopeTopicRefs) { + if (renderedTopicFileKeys.has(`${topicFile.scope}:${topicFile.topic}:${topicFile.path}`)) { + continue; + } + + pushOmission(omissions, { + scope: topicFile.scope, + topic: topicFile.topic, + reason: "budget-trimmed", + target: "topic-file", + stage: "render", + budgetKind: "line-budget" + }); + } + topicRefCountsByScope.global.omitted = + topicRefCountsByScope.global.discovered - topicRefCountsByScope.global.rendered; + topicRefCountsByScope.project.omitted = + topicRefCountsByScope.project.discovered - topicRefCountsByScope.project.rendered; + topicRefCountsByScope.projectLocal.omitted = + topicRefCountsByScope.projectLocal.discovered - topicRefCountsByScope.projectLocal.rendered; + const finalText = lines.join("\n").trimEnd(); const finalLines = finalText ? finalText.split("\n") : []; @@ -123,6 +669,15 @@ export async function compileStartupMemory( text: `${finalText}\n`, lineCount: finalLines.length, sourceFiles, - topicFiles + topicFiles, + highlights, + omissions, + omissionCounts: countStartupOmissions(omissions), + topicFileOmissionCounts: countStartupOmissionsForTarget(omissions, "topic-file"), + omissionCountsByTargetAndStage: countStartupOmissionsByTargetAndStage(omissions), + omittedHighlightCount: countStartupOmissionsByTarget(omissions, "highlight"), + omittedTopicFileCount: countStartupOmissionsByTarget(omissions, "topic-file"), + topicRefCountsByScope, + sectionsRendered }; } diff --git a/src/lib/extractor/command-signatures.ts b/src/lib/extractor/command-signatures.ts new file mode 100644 index 0000000..ddfc16b --- /dev/null +++ b/src/lib/extractor/command-signatures.ts @@ -0,0 +1,56 @@ +export function canonicalCommandSignature(command: string): string | null { + const normalized = command.toLowerCase().trim(); + const normalizedCommand = normalized + .replace(/^(pnpm|npm|bun|yarn)\s+-[cC]\s+\S+\s+/u, "$1 ") + .replace(/^(pnpm|npm|bun|yarn)\s+exec\s+/u, "") + .replace(/^uv\s+run\s+/u, "") + .replace(/^cargo\s+nextest\s+run\b/u, "cargo-nextest run") + .replace(/^nextest\s+run\b/u, "cargo-nextest run"); + const lifecycleScriptPattern = /^(pnpm|npm|bun|yarn)\s+run\s+(test|lint|build|install|check)\b/u; + const lifecycleRunMatch = normalizedCommand.match(lifecycleScriptPattern); + if (lifecycleRunMatch?.[1] && lifecycleRunMatch[2]) { + return `${lifecycleRunMatch[1]}:${lifecycleRunMatch[2]}`; + } + + const runScriptMatch = normalizedCommand.match(/^(pnpm|npm|bun|yarn)\s+run\s+([a-z0-9:_-]+)/u); + if (runScriptMatch?.[1] && runScriptMatch[2]) { + return `${runScriptMatch[1]}:run:${runScriptMatch[2]}`; + } + + if (/\b(?:pnpm|npm|bun|yarn)\s+(test|lint|build|install|check)\b/u.test(normalizedCommand)) { + const match = normalizedCommand.match(/\b(pnpm|npm|bun|yarn)\s+(test|lint|build|install|check)\b/u); + const tool = match?.[1]; + const action = match?.[2]; + return tool && action ? `${tool}:${action}` : null; + } + + if (/\bcargo\s+(test|build|check)\b/u.test(normalizedCommand)) { + const match = normalizedCommand.match(/\bcargo\s+(test|build|check)\b/u); + const action = match?.[1]; + return action ? `cargo:${action}` : null; + } + + if (/\bcargo-nextest\s+run\b/u.test(normalizedCommand)) { + return "cargo-nextest:test"; + } + + if (/\b(?:pytest|jest|vitest|go test|dotnet test|rake)\b/u.test(normalizedCommand)) { + const match = normalizedCommand.match(/\b(pytest|jest|vitest|go test|dotnet test|rake)\b/u); + const tool = match?.[1]; + if (!tool) { + return null; + } + return `${tool.replace(/\s+/gu, "-")}:test`; + } + + if (/\b(?:tsc|vite build|next build|gradle|mvn|make)\b/u.test(normalizedCommand)) { + const match = normalizedCommand.match(/\b(tsc|vite build|next build|gradle|mvn|make)\b/u); + const tool = match?.[1]; + if (!tool) { + return null; + } + return `${tool.replace(/\s+/gu, "-")}:build`; + } + + return null; +} diff --git a/src/lib/extractor/safety.ts b/src/lib/extractor/safety.ts index 6df57c3..73d170a 100644 --- a/src/lib/extractor/safety.ts +++ b/src/lib/extractor/safety.ts @@ -1,8 +1,13 @@ import { DEFAULT_MEMORY_TOPICS } from "../constants.js"; -import type { MemoryOperation } from "../types.js"; +import type { + MemoryOperation, + MemoryOperationRejectionReason, + RejectedMemoryOperationSummary +} from "../types.js"; import { slugify, trimText } from "../util/text.js"; const allowedTopics = new Set(DEFAULT_MEMORY_TOPICS); +const MAX_REVIEWABLE_MEMORY_OPERATIONS = 12; const sensitivePatterns = [ /-----BEGIN (RSA|EC|OPENSSH|PGP) PRIVATE KEY-----/i, @@ -19,7 +24,8 @@ const sensitivePatterns = [ ] as const; const volatilePatterns = [ - /\b(todo|next step|later|for now|temporary|tmp|wip|work in progress)\b/i + /\b(todo|next step|later|for now|temporary|tmp|wip|work in progress|resume here|pick this up later|current worktree|current branch|next message)\b/i, + /(?:^|[\s(])(?:\.agents\/|\.codex\/|\.gemini\/|\.mcp\.json)(?:[\s)]|$)/i ] as const; export function containsSensitiveContent(input: string): boolean { @@ -30,7 +36,32 @@ function looksVolatile(input: string): boolean { return volatilePatterns.some((pattern) => pattern.test(input)); } -export function sanitizeOperation(operation: MemoryOperation): MemoryOperation | null { +export interface FilteredMemoryOperationsDiagnostics { + operations: MemoryOperation[]; + rejectedOperationCount: number; + rejectedReasonCounts: Partial>; + rejectedOperations: RejectedMemoryOperationSummary[]; +} + +interface SanitizedOperationResult { + operation: MemoryOperation | null; + rejectedReason?: MemoryOperationRejectionReason; +} + +function summarizeRejectedOperation( + operation: MemoryOperation, + reason: MemoryOperationRejectionReason +): RejectedMemoryOperationSummary { + return { + action: operation.action, + scope: operation.scope, + topic: operation.topic, + id: operation.id, + reason + }; +} + +export function sanitizeOperation(operation: MemoryOperation): SanitizedOperationResult { const haystack = [ operation.id, operation.topic, @@ -40,60 +71,122 @@ export function sanitizeOperation(operation: MemoryOperation): MemoryOperation | ].join("\n"); if (containsSensitiveContent(haystack)) { - return null; + return { operation: null, rejectedReason: "sensitive" }; } if (operation.action === "upsert" && !operation.summary) { - return null; + return { operation: null, rejectedReason: "empty-summary" }; } - const topic = allowedTopics.has(operation.topic) ? operation.topic : "workflow"; + if (!allowedTopics.has(operation.topic)) { + return { operation: null, rejectedReason: "unknown-topic" }; + } + + const topic = operation.topic; const summary = operation.summary ? trimText(operation.summary.trim(), 220) : undefined; const details = operation.details ?.map((detail) => trimText(detail.trim(), 240)) .filter((detail) => detail.length > 0 && !containsSensitiveContent(detail)); - if (summary && looksVolatile(summary) && topic !== "debugging") { - return null; + if (looksVolatile(haystack)) { + return { operation: null, rejectedReason: "volatile" }; } if (operation.action === "upsert" && (!details || details.length === 0) && summary) { return { - ...operation, - topic, - id: slugify(operation.id || summary), - summary, - details: [summary] + operation: { + ...operation, + topic, + id: slugify(operation.id || summary), + summary, + details: [summary] + } }; } return { - ...operation, - topic, - id: slugify(operation.id), - summary, - details + operation: { + ...operation, + topic, + id: slugify(operation.id), + summary, + details + } }; } -export function filterMemoryOperations(operations: MemoryOperation[]): MemoryOperation[] { +export function filterMemoryOperationsWithDiagnostics( + operations: MemoryOperation[], + options: { + applyCap?: boolean; + } = {} +): FilteredMemoryOperationsDiagnostics { const deduped = new Map(); + const rejectedOperations: RejectedMemoryOperationSummary[] = []; + const rejectedReasonCounts: Partial> = {}; for (const operation of operations) { const sanitized = sanitizeOperation(operation); - if (!sanitized) { + if (!sanitized.operation) { + if (sanitized.rejectedReason) { + rejectedOperations.push(summarizeRejectedOperation(operation, sanitized.rejectedReason)); + rejectedReasonCounts[sanitized.rejectedReason] = + (rejectedReasonCounts[sanitized.rejectedReason] ?? 0) + 1; + } continue; } const key = [ - sanitized.action, - sanitized.scope, - sanitized.topic, - sanitized.id + sanitized.operation.action, + sanitized.operation.scope, + sanitized.operation.topic, + sanitized.operation.id ].join(":"); - deduped.set(key, sanitized); + deduped.set(key, sanitized.operation); + } + + const accepted = [...deduped.values()]; + const retained = options.applyCap === false ? accepted : accepted.slice(0, MAX_REVIEWABLE_MEMORY_OPERATIONS); + if (options.applyCap !== false) { + for (const dropped of accepted.slice(MAX_REVIEWABLE_MEMORY_OPERATIONS)) { + rejectedOperations.push(summarizeRejectedOperation(dropped, "operation-cap")); + rejectedReasonCounts["operation-cap"] = (rejectedReasonCounts["operation-cap"] ?? 0) + 1; + } } - return [...deduped.values()].slice(0, 12); + return { + operations: retained, + rejectedOperationCount: rejectedOperations.length, + rejectedReasonCounts, + rejectedOperations + }; } +export function filterMemoryOperations(operations: MemoryOperation[]): MemoryOperation[] { + return filterMemoryOperationsWithDiagnostics(operations).operations; +} + +export function applyOperationCapWithDiagnostics( + operations: MemoryOperation[] +): FilteredMemoryOperationsDiagnostics { + const prioritized = [ + ...operations.filter((operation) => operation.action !== "upsert"), + ...operations.filter((operation) => operation.action === "upsert") + ]; + const retained = prioritized.slice(0, MAX_REVIEWABLE_MEMORY_OPERATIONS); + const rejectedOperations = prioritized + .slice(MAX_REVIEWABLE_MEMORY_OPERATIONS) + .map((operation) => summarizeRejectedOperation(operation, "operation-cap")); + + return { + operations: retained, + rejectedOperationCount: rejectedOperations.length, + rejectedReasonCounts: + rejectedOperations.length > 0 + ? { + "operation-cap": rejectedOperations.length + } + : {}, + rejectedOperations + }; +} diff --git a/src/lib/mcp/retrieval-server.ts b/src/lib/mcp/retrieval-server.ts index fd4beae..38c8604 100644 --- a/src/lib/mcp/retrieval-server.ts +++ b/src/lib/mcp/retrieval-server.ts @@ -44,7 +44,8 @@ const memorySearchResultSchema = z.object({ summary: z.string(), updatedAt: z.string(), matchedFields: z.array(z.string()), - approxReadCost: z.number().int().nonnegative() + approxReadCost: z.number().int().nonnegative(), + globalRank: z.number().int().positive() }); const memorySearchDiagnosticSchema = z.object({ @@ -59,17 +60,37 @@ const memorySearchDiagnosticSchema = z.object({ generatedAt: z.string().nullable() }); +const topicFileDiagnosticSchema = z.object({ + scope: z.enum(["global", "project", "project-local"]), + state: memoryRecordStateSchema, + topic: z.string(), + path: z.string(), + safeToRewrite: z.boolean(), + entryCount: z.number().int().nonnegative(), + invalidEntryBlockCount: z.number().int().nonnegative(), + manualContentDetected: z.boolean(), + unsafeReason: z.string().optional() +}); + const memorySearchResponseSchema = z.object({ query: z.string(), scope: retrievalScopeSchema, state: retrievalStateSchema, resolvedState: resolvedRetrievalStateSchema, searchOrder: z.array(z.string()), + totalMatchedCount: z.number().int().nonnegative(), + returnedCount: z.number().int().nonnegative(), globalLimitApplied: z.boolean(), truncatedCount: z.number().int().nonnegative(), + resultWindow: z.object({ + start: z.number().int().nonnegative(), + end: z.number().int().nonnegative(), + limit: z.number().int().positive() + }), fallbackUsed: z.boolean(), stateFallbackUsed: z.boolean(), markdownFallbackUsed: z.boolean(), + finalRetrievalMode: z.enum(["index", "markdown-fallback"]), retrievalMode: z.enum(["index", "markdown-fallback"]), retrievalFallbackReason: z.enum(["missing", "invalid", "stale"]).optional(), stateResolution: z.object({ @@ -86,7 +107,8 @@ const memorySearchResponseSchema = z.object({ anyMarkdownFallback: z.boolean(), fallbackReasons: z.array(z.enum(["missing", "invalid", "stale"])), executionModes: z.array(z.enum(["index", "markdown-fallback"])), - checkedPaths: z.array(memorySearchDiagnosticSchema) + checkedPaths: z.array(memorySearchDiagnosticSchema), + topicDiagnostics: z.array(topicFileDiagnosticSchema).optional() }), results: z.array(memorySearchResultSchema) }); @@ -156,13 +178,51 @@ const memoryLineageSummarySchema = z.object({ rolloutConflictCount: z.number().int().nonnegative(), noopOperationCount: z.number().int().nonnegative(), suppressedOperationCount: z.number().int().nonnegative(), - conflictCount: z.number().int().nonnegative() + conflictCount: z.number().int().nonnegative(), + rejectedOperationCount: z.number().int().nonnegative(), + rejectedReasonCounts: z.record(z.string(), z.number().int().nonnegative()).optional() }); const memoryTimelineResponseSchema = z.object({ ref: z.string(), events: z.array(memoryTimelineEventSchema), warnings: z.array(z.string()), + latestAudit: z + .object({ + auditPath: z.string(), + appliedAt: z.string(), + rolloutPath: z.string(), + sessionId: z.string().optional(), + status: z.enum(["applied", "no-op", "skipped"]), + resultSummary: z.string(), + matchedOperationCount: z.number().int().nonnegative(), + noopOperationCount: z.number().int().nonnegative(), + suppressedOperationCount: z.number().int().nonnegative(), + rejectedOperationCount: z.number().int().nonnegative(), + rejectedReasonCounts: z.record(z.string(), z.number().int().nonnegative()).optional(), + rejectedOperations: z + .array( + z.object({ + action: z.enum(["upsert", "delete", "archive"]), + scope: z.enum(["global", "project", "project-local"]), + topic: z.string(), + id: z.string(), + reason: z.string() + }) + ) + .optional(), + conflicts: z.array( + z.object({ + scope: z.enum(["global", "project", "project-local"]), + topic: z.string(), + candidateSummary: z.string(), + conflictsWith: z.array(z.string()), + source: z.enum(["within-rollout", "existing-memory"]), + resolution: z.literal("suppressed") + }) + ) + }) + .nullable(), latestAppliedLifecycle: memoryAppliedLifecycleSchema.nullable(), latestLifecycleAttempt: memoryLifecycleAttemptSchema.nullable(), lineageSummary: memoryLineageSummarySchema @@ -197,6 +257,19 @@ const memoryDetailsResponseSchema = z.object({ matchedOperationCount: z.number().int().nonnegative(), noopOperationCount: z.number().int().nonnegative(), suppressedOperationCount: z.number().int().nonnegative(), + rejectedOperationCount: z.number().int().nonnegative(), + rejectedReasonCounts: z.record(z.string(), z.number().int().nonnegative()).optional(), + rejectedOperations: z + .array( + z.object({ + action: z.enum(["upsert", "delete", "archive"]), + scope: z.enum(["global", "project", "project-local"]), + topic: z.string(), + id: z.string(), + reason: z.string() + }) + ) + .optional(), conflicts: z.array( z.object({ scope: z.enum(["global", "project", "project-local"]), diff --git a/src/lib/types.ts b/src/lib/types.ts index f4071e6..a65118b 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -62,7 +62,7 @@ export interface MemoryEntry { } export interface MemoryOperation { - action: "upsert" | "delete"; + action: "upsert" | "delete" | "archive"; scope: MemoryScope; topic: string; id: string; @@ -83,6 +83,14 @@ export interface MemoryMutation { reason?: string; } +export interface RejectedMemoryOperationSummary { + action: MemoryOperation["action"]; + scope: MemoryScope; + topic: string; + id: string; + reason: MemoryOperationRejectionReason; +} + export interface MemoryRef { ref: string; scope: MemoryScope; @@ -96,6 +104,13 @@ export interface MemorySearchResult extends MemoryRef { updatedAt: string; matchedFields: string[]; approxReadCost: number; + globalRank: number; +} + +export interface MemorySearchResultWindow { + start: number; + end: number; + limit: number; } export interface MemorySearchDiagnosticPath { @@ -115,6 +130,7 @@ export interface MemorySearchDiagnostics { fallbackReasons: MemoryRetrievalFallbackReason[]; executionModes: MemoryRetrievalMode[]; checkedPaths: MemorySearchDiagnosticPath[]; + topicDiagnostics?: TopicFileDiagnostic[]; } export interface MemorySearchStateResolution { @@ -135,11 +151,15 @@ export interface MemorySearchResponse { state: MemoryRetrievalStateFilter; resolvedState: MemoryRetrievalResolvedState; searchOrder: string[]; + totalMatchedCount: number; + returnedCount: number; globalLimitApplied: boolean; truncatedCount: number; + resultWindow: MemorySearchResultWindow; fallbackUsed: boolean; stateFallbackUsed: boolean; markdownFallbackUsed: boolean; + finalRetrievalMode: MemoryRetrievalMode; retrievalMode: MemoryRetrievalMode; retrievalFallbackReason?: MemoryRetrievalFallbackReason; stateResolution: MemorySearchStateResolution; @@ -197,6 +217,7 @@ export interface MemoryTimelineResponse { ref: string; events: MemoryTimelineEvent[]; warnings: string[]; + latestAudit: MemorySyncAuditSummary | null; lineageSummary: MemoryLineageSummary; latestAppliedLifecycle: MemoryAppliedLifecycle | null; latestLifecycleAttempt: MemoryLifecycleAttempt | null; @@ -223,6 +244,8 @@ export interface MemoryLineageSummary { noopOperationCount: number; suppressedOperationCount: number; conflictCount: number; + rejectedOperationCount: number; + rejectedReasonCounts?: Partial>; } export interface MemoryDetailsResult extends MemoryRef { @@ -394,6 +417,7 @@ export interface ManualMutationForgetPayload { export type ManualMutationPayload = | ManualMutationRememberPayload | ManualMutationForgetPayload; + export interface MemorySyncAuditSummary { auditPath: string; appliedAt: string; @@ -404,6 +428,9 @@ export interface MemorySyncAuditSummary { matchedOperationCount: number; noopOperationCount: number; suppressedOperationCount: number; + rejectedOperationCount: number; + rejectedReasonCounts?: Partial>; + rejectedOperations?: RejectedMemoryOperationSummary[]; conflicts: MemoryConflictCandidate[]; } @@ -432,6 +459,29 @@ export interface CompiledStartupMemory { lineCount: number; sourceFiles: string[]; topicFiles: TopicFileRef[]; + highlights: StartupMemoryHighlight[]; + omissions: StartupMemoryOmission[]; + omissionCounts: Partial>; + topicFileOmissionCounts: Partial>; + omissionCountsByTargetAndStage: { + highlight: { selection: number; render: number }; + topicFile: { selection: number; render: number }; + scopeBlock: { selection: number; render: number }; + }; + omittedHighlightCount: number; + omittedTopicFileCount: number; + topicRefCountsByScope: { + global: { discovered: number; rendered: number; omitted: number }; + project: { discovered: number; rendered: number; omitted: number }; + projectLocal: { discovered: number; rendered: number; omitted: number }; + }; + sectionsRendered: { + projectLocal: boolean; + project: boolean; + global: boolean; + highlights: boolean; + topicFiles: boolean; + }; } export interface TopicFileRef { @@ -570,6 +620,7 @@ export interface RolloutEvidence { cwd: string; userMessages: string[]; agentMessages: string[]; + orderedMessages?: RolloutTranscriptMessage[]; toolCalls: RolloutToolCall[]; rolloutPath: string; provenanceKind?: RolloutProvenanceKind; @@ -577,6 +628,11 @@ export interface RolloutEvidence { forkedFromSessionId?: string; } +export interface RolloutTranscriptMessage { + role: "user" | "agent"; + message: string; +} + export interface SessionContinuityState { kind: "session-continuity"; scope: SessionContinuityScope; @@ -631,6 +687,7 @@ export interface SessionContinuityDiagnostics { generatedAt: string; rolloutPath: string; sourceSessionId: string; + provenanceKind?: RolloutProvenanceKind; preferredPath: SessionContinuityExtractorPath; actualPath: SessionContinuityExtractorPath; confidence: SessionContinuityConfidence; @@ -655,6 +712,7 @@ export interface SessionContinuityAuditEntry { scope: SessionContinuityScope | "both"; rolloutPath: string; sourceSessionId: string; + provenanceKind?: RolloutProvenanceKind; preferredPath: SessionContinuityExtractorPath; actualPath: SessionContinuityExtractorPath; confidence?: SessionContinuityConfidence; @@ -759,6 +817,9 @@ export interface MemorySyncAuditEntry { appliedCount: number; noopOperationCount?: number; suppressedOperationCount?: number; + rejectedOperationCount?: number; + rejectedReasonCounts?: Partial>; + rejectedOperations?: RejectedMemoryOperationSummary[]; scopesTouched: MemoryScope[]; resultSummary: string; conflicts?: MemoryConflictCandidate[]; @@ -781,6 +842,9 @@ export interface SyncRecoveryRecord { appliedCount: number; noopOperationCount?: number; suppressedOperationCount?: number; + rejectedOperationCount?: number; + rejectedReasonCounts?: Partial>; + rejectedOperations?: RejectedMemoryOperationSummary[]; scopesTouched: MemoryScope[]; conflicts?: MemoryConflictCandidate[]; failedStage: SyncRecoveryFailedStage; @@ -815,6 +879,16 @@ export interface MemoryCommandOutput { startup: CompiledStartupMemory; loadedFiles: string[]; topicFiles: TopicFileRef[]; + topicDiagnostics: TopicFileDiagnostic[]; + layoutDiagnostics: MemoryLayoutDiagnostic[]; + startupOmissions: StartupMemoryOmission[]; + startupOmissionCounts: Partial>; + topicFileOmissionCounts: Partial>; + startupOmissionCountsByTargetAndStage: { + highlight: { selection: number; render: number }; + topicFile: { selection: number; render: number }; + scopeBlock: { selection: number; render: number }; + }; startupFilesByScope: { global: string[]; project: string[]; @@ -825,6 +899,21 @@ export interface MemoryCommandOutput { project: TopicFileRef[]; projectLocal: TopicFileRef[]; }; + highlightCount: number; + omittedHighlightCount: number; + omittedTopicFileCount: number; + highlightsByScope: { + global: StartupMemoryHighlight[]; + project: StartupMemoryHighlight[]; + projectLocal: StartupMemoryHighlight[]; + }; + startupSectionsRendered: { + projectLocal: boolean; + project: boolean; + global: boolean; + highlights: boolean; + topicFiles: boolean; + }; startupBudget: { usedLines: number; maxLines: number; @@ -834,6 +923,11 @@ export interface MemoryCommandOutput { project: { startupFiles: number; topicFiles: number }; projectLocal: { startupFiles: number; topicFiles: number }; }; + topicRefCountsByScope: { + global: { discovered: number; rendered: number; omitted: number }; + project: { discovered: number; rendered: number; omitted: number }; + projectLocal: { discovered: number; rendered: number; omitted: number }; + }; scopes: MemoryCommandScopeSummary[]; editTargets: { global: string; @@ -862,6 +956,8 @@ export interface MemoryReindexOutput { requestedScope: MemoryScope | "all"; requestedState: MemoryRecordState | "all"; rebuilt: MemoryReindexCheck[]; + topicDiagnostics: TopicFileDiagnostic[]; + layoutDiagnostics: MemoryLayoutDiagnostic[]; summary: string; } @@ -871,7 +967,7 @@ export interface SyncResult { message: string; } -export type ContinuityRecoveryFailedStage = "audit-write"; +export type ContinuityRecoveryFailedStage = "summary-write" | "audit-write"; export interface ContinuityRecoveryRecord { recordedAt: string; @@ -883,6 +979,7 @@ export interface ContinuityRecoveryRecord { writeMode?: SessionContinuityWriteMode; scope: SessionContinuityScope | "both"; writtenPaths: string[]; + provenanceKind?: RolloutProvenanceKind; preferredPath: SessionContinuityExtractorPath; actualPath: SessionContinuityExtractorPath; confidence?: SessionContinuityConfidence; diff --git a/test/memory-command.test.ts b/test/memory-command.test.ts index 876534f..1730039 100644 --- a/test/memory-command.test.ts +++ b/test/memory-command.test.ts @@ -3,10 +3,11 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { runMemory, runMemoryReindex } from "../src/lib/commands/memory.js"; +import { toManualMutationForgetPayload } from "../src/lib/commands/manual-mutation-review.js"; import { configPaths } from "../src/lib/config/load-config.js"; import { detectProjectContext } from "../src/lib/domain/project-context.js"; import { MemoryStore } from "../src/lib/domain/memory-store.js"; -import type { AppConfig, MemoryCommandOutput } from "../src/lib/types.js"; +import type { AppConfig, ManualMutationReviewEntry, MemoryCommandOutput } from "../src/lib/types.js"; import { makeAppConfig, writeCamConfig @@ -75,16 +76,6 @@ const buildProjectConfig = makeAppConfig; const writeProjectConfig = writeCamConfig; describe("runMemory", () => { - it("shows archive behavior in forget help output", async () => { - const projectDir = await tempDir("cam-forget-help-project-"); - - const result = runCli(projectDir, ["forget", "--help"]); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Delete or archive matching memory entries"); - expect(result.stdout).toContain("Move matching entries into archive instead of deleting them"); - }); - it("shows scope details and recent audit entries", async () => { const homeDir = await tempDir("cam-memory-home-"); const projectDir = await tempDir("cam-memory-project-"); @@ -201,7 +192,7 @@ describe("runMemory", () => { "Startup loaded files are the index files actually quoted into the current startup payload." ); expect(output).toContain( - "Topic files on demand stay as references until a later read needs them." + "Topic files on demand stay as safe references until a later read needs them." ); expect(output).toContain("Edit paths:"); expect(output).toContain("project: 1 entry"); @@ -218,7 +209,7 @@ describe("runMemory", () => { expect(output).toContain("[skipped] Skipped rollout; it was already processed"); expect(output).toContain("Configured: codex-ephemeral (codex) -> Actual: heuristic (heuristic)"); expect(output).toContain("Skip reason: already-processed"); - expect(output).toContain("Applied: 0 | No-op: 0 | Suppressed: 0 | Scopes: none"); + expect(output).toContain("Applied: 0 | No-op: 0 | Suppressed: 0 | Rejected: 0 | Scopes: none"); expect(output).toContain("Suppressed: 1"); expect(output).toContain("Conflict review:"); expect(output).toContain("[existing-memory] preferences: Maybe use bun instead of pnpm in this repository."); @@ -321,6 +312,16 @@ describe("runMemory", () => { }) ]); expect(output.topicFilesByScope.projectLocal).toEqual([]); + expect(output.highlightCount).toBe(output.startup.highlights.length); + expect(output.omittedHighlightCount).toBe(output.startup.omittedHighlightCount); + expect(output.highlightsByScope.project).toEqual(output.startup.highlights); + expect(output.startupSectionsRendered).toMatchObject({ + projectLocal: true, + project: true, + global: true, + highlights: true, + topicFiles: true + }); expect(output.syncAuditPath).toBe(store.getSyncAuditPath()); expect(output.recentSyncAudit).toHaveLength(1); expect(output.recentSyncAudit[0]).toMatchObject({ @@ -495,6 +496,71 @@ describe("runMemory", () => { expect(textOutput.match(/\/tmp\/rollout-repeat\.jsonl/g) ?? []).toHaveLength(1); }); + it("does not collapse repeated sync audit previews when rejected reason counts differ", async () => { + const homeDir = await tempDir("cam-memory-rejected-grouping-home-"); + const projectDir = await tempDir("cam-memory-rejected-grouping-project-"); + const memoryRoot = await tempDir("cam-memory-rejected-grouping-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const project = detectProjectContext(projectDir); + const store = new MemoryStore(project, { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + + for (const [appliedAt, rejectedReasonCounts] of [ + [ + "2026-03-14T12:00:00.000Z", + { + sensitive: 1 + } + ], + [ + "2026-03-14T12:01:00.000Z", + { + "unknown-topic": 1 + } + ] + ] as const) { + await store.appendSyncAuditEntry({ + appliedAt, + projectId: project.projectId, + worktreeId: project.worktreeId, + rolloutPath: "/tmp/rollout-repeat.jsonl", + sessionId: "session-repeat", + configuredExtractorMode: "heuristic", + configuredExtractorName: "heuristic", + actualExtractorMode: "heuristic", + actualExtractorName: "heuristic", + extractorMode: "heuristic", + extractorName: "heuristic", + sessionSource: "rollout-jsonl", + status: "no-op", + appliedCount: 0, + rejectedOperationCount: 1, + rejectedReasonCounts, + scopesTouched: [], + resultSummary: "0 operations applied, 1 rejected", + operations: [] + }); + } + + const textOutput = await runMemory({ + cwd: projectDir, + recent: "5" + }); + + expect(textOutput).toContain("2026-03-14T12:00:00.000Z"); + expect(textOutput).toContain("2026-03-14T12:01:00.000Z"); + expect(textOutput).not.toContain("Repeated similar sync events hidden: 1"); + }); + it("supports memory --recent --json and --print-startup from the CLI command surface", async () => { const homeDir = await tempDir("cam-memory-cli-home-"); const projectDir = await tempDir("cam-memory-cli-project-"); @@ -629,6 +695,15 @@ describe("runMemory", () => { expect(output.startupFilesByScope.global).toEqual([]); expect(output.startupFilesByScope.project).toEqual([]); expect(output.startupFilesByScope.projectLocal).toEqual([]); + expect(output.highlightCount).toBe(0); + expect(output.omittedHighlightCount).toBe(0); + expect(output.startupSectionsRendered).toMatchObject({ + projectLocal: false, + project: false, + global: false, + highlights: false, + topicFiles: false + }); expect(output.startup.text).not.toContain("## Project Local"); expect(output.startup.text).not.toContain("| # Project Local Memory"); }); @@ -744,6 +819,27 @@ describe("runMemory", () => { actualExtractorName: "heuristic", status: "applied", appliedCount: 1, + rejectedOperationCount: 2, + rejectedReasonCounts: { + sensitive: 1, + "unknown-topic": 1 + }, + rejectedOperations: [ + { + action: "upsert", + scope: "project", + topic: "workflow", + id: "sensitive-note", + reason: "sensitive" + }, + { + action: "upsert", + scope: "project", + topic: "workflow", + id: "unknown-note", + reason: "unknown-topic" + } + ], scopesTouched: ["project"], failedStage: "audit-write", failureMessage: "audit write failed", @@ -772,6 +868,21 @@ describe("runMemory", () => { actualExtractorName: "heuristic", status: "applied", appliedCount: 1, + rejectedOperationCount: 2, + rejectedReasonCounts: { + sensitive: 1, + "unknown-topic": 1 + }, + rejectedOperations: [ + expect.objectContaining({ + id: "sensitive-note", + reason: "sensitive" + }), + expect.objectContaining({ + id: "unknown-note", + reason: "unknown-topic" + }) + ], scopesTouched: ["project"], failedStage: "audit-write", failureMessage: "audit write failed", @@ -780,9 +891,158 @@ describe("runMemory", () => { expect(jsonOutput.syncRecoveryPath).toBe(store.getSyncRecoveryPath()); expect(textOutput).toContain("Pending sync recovery:"); expect(textOutput).toContain("/tmp/rollout-sync-fail.jsonl"); + expect(textOutput).toContain("Rejected: 2"); + expect(textOutput).toContain("Rejected reasons: sensitive=1, unknown-topic=1"); + expect(textOutput).toContain("Rejected operations:"); + expect(textOutput).toContain("[sensitive] project/workflow/sensitive-note"); + expect(textOutput).toContain("[unknown-topic] project/workflow/unknown-note"); expect(textOutput).not.toContain("Recent sync events"); }); + it("normalizes legacy sync recovery reviewer fields in json output", async () => { + const homeDir = await tempDir("cam-memory-legacy-recovery-home-"); + const projectDir = await tempDir("cam-memory-legacy-recovery-project-"); + const memoryRoot = await tempDir("cam-memory-legacy-recovery-root-"); + process.env.HOME = homeDir; + + const projectConfig: AppConfig = { + autoMemoryEnabled: true, + extractorMode: "heuristic", + defaultScope: "project", + maxStartupLines: 200, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false, + sessionContinuityLocalPathStyle: "codex", + maxSessionContinuityLines: 60, + codexBinary: "codex" + }; + await fs.writeFile( + path.join(projectDir, "codex-auto-memory.json"), + JSON.stringify(projectConfig), + "utf8" + ); + await fs.writeFile( + path.join(projectDir, ".codex-auto-memory.local.json"), + JSON.stringify({ + autoMemoryDirectory: memoryRoot + }), + "utf8" + ); + + const project = detectProjectContext(projectDir); + const store = new MemoryStore(project, { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await fs.writeFile( + store.getSyncRecoveryPath(), + `${JSON.stringify({ + recordedAt: "2026-03-18T00:00:00.000Z", + projectId: project.projectId, + worktreeId: project.worktreeId, + rolloutPath: "/tmp/legacy-recovery.jsonl", + configuredExtractorMode: "heuristic", + configuredExtractorName: "heuristic", + actualExtractorMode: "heuristic", + actualExtractorName: "heuristic", + status: "no-op", + appliedCount: 0, + scopesTouched: [], + failedStage: "audit-write", + failureMessage: "legacy recovery marker", + auditEntryWritten: false + })}\n`, + "utf8" + ); + + const jsonOutput = JSON.parse( + await runMemory({ + cwd: projectDir, + json: true, + recent: "5" + }) + ) as MemoryCommandOutput; + + expect(jsonOutput.pendingSyncRecovery).toMatchObject({ + rolloutPath: "/tmp/legacy-recovery.jsonl", + rejectedOperationCount: 0, + rejectedReasonCounts: {}, + rejectedOperations: [], + noopOperationCount: 0, + suppressedOperationCount: 0 + }); + }); + + it("surfaces unsafe topic diagnostics in json output and excludes unsafe topic entries from startup highlights", async () => { + const homeDir = await tempDir("cam-memory-unsafe-json-home-"); + const projectDir = await tempDir("cam-memory-unsafe-json-project-"); + const memoryRoot = await tempDir("cam-memory-unsafe-json-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const project = detectProjectContext(projectDir); + const store = new MemoryStore(project, { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + + const unsafeTopicPath = store.getTopicFile("project", "workflow"); + await fs.writeFile( + unsafeTopicPath, + buildUnsafeWorkflowTopicContents( + "prefer-pnpm", + "Prefer pnpm in this repository.", + "Use pnpm instead of npm in this repository." + ), + "utf8" + ); + await store.rebuildIndex("project"); + + const output = JSON.parse( + await runMemory({ + cwd: projectDir, + json: true + }) + ) as MemoryCommandOutput & { + topicDiagnostics: Array<{ + scope: string; + state: string; + topic: string; + safeToRewrite: boolean; + invalidEntryBlockCount: number; + manualContentDetected: boolean; + unsafeReason?: string; + }>; + }; + + expect(output.topicDiagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + state: "active", + topic: "workflow", + safeToRewrite: false, + invalidEntryBlockCount: 1, + manualContentDetected: true, + unsafeReason: expect.stringContaining("malformed or unsupported entry blocks") + }) + ]) + ); + expect(output.highlightCount).toBe(0); + expect(output.highlightsByScope.project).toEqual([]); + expect(output.topicFilesByScope.project).toEqual([]); + expect(output.topicFileOmissionCounts).toMatchObject({ + "unsafe-topic": 1 + }); + expect(output.omittedTopicFileCount).toBeGreaterThanOrEqual(1); + }); + it("ignores a corrupted sync recovery marker instead of crashing the reviewer surface", async () => { const homeDir = await tempDir("cam-memory-bad-recovery-home-"); const projectDir = await tempDir("cam-memory-bad-recovery-project-"); @@ -1280,6 +1540,15 @@ describe("runMemory", () => { const payload = JSON.parse(result.stdout) as { action: string; mutationKind: string; + entryCount: number; + warningCount: number; + uniqueAuditCount: number; + auditCountsDeduplicated: boolean; + warningsByEntryRef: Record; + leadEntryRef?: string | null; + leadEntryIndex?: number | null; + detailsAvailable?: boolean; + reviewRefState?: string | null; matchedCount: number; appliedCount: number; noopCount: number; @@ -1288,6 +1557,11 @@ describe("runMemory", () => { timelineRefs: string[]; detailsRefs: string[]; }; + primaryEntry: { + ref: string; + detailsRef: string | null; + lifecycleAction: string; + }; scope: string; topic: string; id: string; @@ -1298,6 +1572,14 @@ describe("runMemory", () => { lifecycleAction: string; latestState: string; latestLifecycleAttempt: { action: string; outcome: string; updateKind: string | null } | null; + reviewerSummary: { + matchedAuditOperationCount: number; + noopOperationCount: number; + suppressedOperationCount: number; + rejectedOperationCount: number; + rolloutConflictCount: number; + }; + nextRecommendedActions: string[]; lineageSummary: { latestAction: string | null; latestUpdateKind: string | null }; entry: { summary: string; details: string[] }; warnings: string[]; @@ -1306,9 +1588,24 @@ describe("runMemory", () => { expect(payload).toMatchObject({ action: "remember", mutationKind: "remember", + entryCount: 1, + warningCount: 0, + uniqueAuditCount: 0, + auditCountsDeduplicated: true, + warningsByEntryRef: {}, + leadEntryRef: "project:active:workflow:prefer-pnpm-in-this-repository", + leadEntryIndex: 0, + detailsAvailable: true, + reviewRefState: "active", matchedCount: 1, appliedCount: 1, noopCount: 0, + primaryEntry: { + ref: "project:active:workflow:prefer-pnpm-in-this-repository", + timelineRef: "project:active:workflow:prefer-pnpm-in-this-repository", + detailsRef: "project:active:workflow:prefer-pnpm-in-this-repository", + lifecycleAction: "add" + }, scope: "project", topic: "workflow", id: "prefer-pnpm-in-this-repository", @@ -1321,6 +1618,13 @@ describe("runMemory", () => { outcome: "applied", updateKind: null }, + reviewerSummary: { + matchedAuditOperationCount: 0, + noopOperationCount: 0, + suppressedOperationCount: 0, + rejectedOperationCount: 0, + rolloutConflictCount: 0 + }, lineageSummary: { latestAction: "add", latestUpdateKind: null @@ -1334,14 +1638,20 @@ describe("runMemory", () => { expect(payload.affectedRefs).toEqual([payload.ref]); expect(payload.followUp.timelineRefs).toEqual([payload.ref]); expect(payload.followUp.detailsRefs).toEqual([payload.ref]); + expect(payload.nextRecommendedActions).toEqual( + expect.arrayContaining([ + expect.stringContaining("recall timeline"), + expect.stringContaining("memory --recent") + ]) + ); expect(payload.path).toContain(path.join("workflow.md")); expect(payload.historyPath).toContain(path.join("project", "memory-history.jsonl")); }); - it("surfaces a structured reviewer payload for forget --json including archive refs", async () => { - const homeDir = await tempDir("cam-forget-json-home-"); - const projectDir = await tempDir("cam-forget-json-project-"); - const memoryRoot = await tempDir("cam-forget-json-root-"); + it("includes review-oriented next steps in remember text output", async () => { + const homeDir = await tempDir("cam-remember-text-home-"); + const projectDir = await tempDir("cam-remember-text-project-"); + const memoryRoot = await tempDir("cam-remember-text-root-"); process.env.HOME = homeDir; const projectConfig = buildProjectConfig(); @@ -1349,90 +1659,37 @@ describe("runMemory", () => { autoMemoryDirectory: memoryRoot }); - const project = detectProjectContext(projectDir); - const store = new MemoryStore(project, { - ...projectConfig, - autoMemoryDirectory: memoryRoot - }); - await store.ensureLayout(); - await store.remember( - "project", - "workflow", - "prefer-pnpm", - "Prefer pnpm in this repository.", - ["Use pnpm instead of npm in this repository."], - "Manual note." + const result = runCli( + projectDir, + [ + "remember", + "Prefer pnpm in this repository.", + "--scope", + "project", + "--topic", + "workflow", + "--detail", + "Use pnpm instead of npm in this repository." + ], + { env: { HOME: homeDir } } ); - - const result = runCli(projectDir, ["forget", "pnpm", "--scope", "project", "--archive", "--json"], { - env: { HOME: homeDir } - }); expect(result.exitCode, result.stderr).toBe(0); - - const payload = JSON.parse(result.stdout) as { - action: string; - mutationKind: string; - query: string; - scope: string; - archive: boolean; - matchedCount: number; - appliedCount: number; - noopCount: number; - affectedCount: number; - affectedRefs: string[]; - followUp: { - timelineRefs: string[]; - detailsRefs: string[]; - }; - entries: Array<{ - ref: string; - timelineRef: string; - detailsRef: string | null; - lifecycleAction: string; - latestState: string; - latestLifecycleAttempt: { action: string; outcome: string; updateKind: string | null } | null; - lineageSummary: { latestAction: string | null; latestUpdateKind: string | null }; - }>; - }; - - expect(payload).toMatchObject({ - action: "forget", - mutationKind: "forget", - query: "pnpm", - scope: "project", - archive: true, - matchedCount: 1, - appliedCount: 1, - noopCount: 0, - affectedCount: 1, - entries: [ - { - ref: "project:archived:workflow:prefer-pnpm", - timelineRef: "project:archived:workflow:prefer-pnpm", - detailsRef: "project:archived:workflow:prefer-pnpm", - lifecycleAction: "archive", - latestState: "archived", - latestLifecycleAttempt: { - action: "archive", - outcome: "applied", - updateKind: null - }, - lineageSummary: { - latestAction: "archive", - latestUpdateKind: null - } - } - ] - }); - expect(payload.affectedRefs).toEqual(["project:archived:workflow:prefer-pnpm"]); - expect(payload.followUp.timelineRefs).toEqual(["project:archived:workflow:prefer-pnpm"]); - expect(payload.followUp.detailsRefs).toEqual(["project:archived:workflow:prefer-pnpm"]); + expect(result.stdout).toContain( + "Saved memory to project/workflow with id prefer-pnpm-in-this-repository." + ); + expect(result.stdout).toContain("Next steps:"); + expect(result.stdout).toContain("recall timeline"); + expect(result.stdout).toContain("recall details"); + expect(result.stdout).toContain("memory --recent"); + expect(result.stdout).toContain("memory reindex"); }); - it("surfaces delete-only review routes for forget --json when details are no longer available", async () => { - const homeDir = await tempDir("cam-forget-delete-json-home-"); - const projectDir = await tempDir("cam-forget-delete-json-project-"); - const memoryRoot = await tempDir("cam-forget-delete-json-root-"); + it("pins remember follow-up commands to --cwd and resolved launcher when called from another directory", async () => { + const homeDir = await tempDir("cam-remember-cwd-home-"); + const projectDir = await tempDir("cam-remember-cwd-project-"); + const callerDir = await tempDir("cam-remember-cwd-caller-"); + const memoryRoot = await tempDir("cam-remember-cwd-root-"); + const realProjectDir = await fs.realpath(projectDir); process.env.HOME = homeDir; const projectConfig = buildProjectConfig(); @@ -1440,10 +1697,1945 @@ describe("runMemory", () => { autoMemoryDirectory: memoryRoot }); - const project = detectProjectContext(projectDir); - const store = new MemoryStore(project, { - ...projectConfig, - autoMemoryDirectory: memoryRoot + const result = runCli( + callerDir, + [ + "remember", + "Prefer pnpm in this repository.", + "--scope", + "project", + "--topic", + "workflow", + "--detail", + "Use pnpm instead of npm in this repository.", + "--cwd", + projectDir, + "--json" + ], + { env: { HOME: homeDir } } + ); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + nextRecommendedActions: string[]; + }; + + expect(payload.nextRecommendedActions).toEqual( + expect.arrayContaining([ + expect.stringContaining(`--cwd ${JSON.stringify(realProjectDir)}`), + expect.stringContaining("recall timeline"), + expect.stringContaining("recall details"), + expect.stringContaining("memory --recent"), + expect.stringContaining("memory reindex") + ]) + ); + }); + + it("surfaces a structured reviewer payload for remember --json noop updates", async () => { + const homeDir = await tempDir("cam-remember-noop-json-home-"); + const projectDir = await tempDir("cam-remember-noop-json-project-"); + const memoryRoot = await tempDir("cam-remember-noop-json-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const first = runCli( + projectDir, + [ + "remember", + "Prefer pnpm in this repository.", + "--scope", + "project", + "--topic", + "workflow", + "--detail", + "Use pnpm instead of npm in this repository.", + "--json" + ], + { env: { HOME: homeDir } } + ); + expect(first.exitCode, first.stderr).toBe(0); + + const second = runCli( + projectDir, + [ + "remember", + "Prefer pnpm in this repository.", + "--scope", + "project", + "--topic", + "workflow", + "--detail", + "Use pnpm instead of npm in this repository.", + "--json" + ], + { env: { HOME: homeDir } } + ); + expect(second.exitCode, second.stderr).toBe(0); + + const payload = JSON.parse(second.stdout) as { + mutationKind: string; + entryCount: number; + warningCount: number; + uniqueAuditCount: number; + auditCountsDeduplicated: boolean; + warningsByEntryRef: Record; + matchedCount: number; + appliedCount: number; + noopCount: number; + affectedCount: number; + summary: { + matchedCount: number; + appliedCount: number; + noopCount: number; + affectedCount: number; + }; + primaryEntry: { + ref: string; + detailsRef: string | null; + lifecycleAction: string; + }; + reviewerSummary: { + matchedAuditOperationCount: number; + noopOperationCount: number; + suppressedOperationCount: number; + rejectedOperationCount: number; + rolloutConflictCount: number; + }; + nextRecommendedActions: string[]; + entries: Array<{ + lifecycleAction: string; + latestAppliedLifecycle: { action: string } | null; + latestLifecycleAttempt: { action: string; outcome: string } | null; + }>; + latestAppliedLifecycle: { action: string } | null; + latestLifecycleAttempt: { action: string; outcome: string } | null; + lifecycleAction: string; + followUp: { + timelineRefs: string[]; + detailsRefs: string[]; + }; + }; + + expect(payload).toMatchObject({ + mutationKind: "remember", + entryCount: 1, + warningCount: 1, + uniqueAuditCount: 0, + auditCountsDeduplicated: true, + warningsByEntryRef: { + "project:active:workflow:prefer-pnpm-in-this-repository": 1 + }, + matchedCount: 1, + appliedCount: 0, + noopCount: 1, + affectedCount: 1, + summary: { + matchedCount: 1, + appliedCount: 0, + noopCount: 1, + affectedCount: 1 + }, + primaryEntry: { + ref: "project:active:workflow:prefer-pnpm-in-this-repository", + timelineRef: "project:active:workflow:prefer-pnpm-in-this-repository", + detailsRef: "project:active:workflow:prefer-pnpm-in-this-repository", + lifecycleAction: "noop" + }, + lifecycleAction: "noop", + latestAppliedLifecycle: { + action: "add" + }, + latestLifecycleAttempt: { + action: "noop", + outcome: "noop" + }, + entries: [ + { + lifecycleAction: "noop", + latestAppliedLifecycle: { + action: "add" + }, + latestLifecycleAttempt: { + action: "noop", + outcome: "noop" + } + } + ] + }); + expect(payload.reviewerSummary).toMatchObject({ + matchedAuditOperationCount: 0, + noopOperationCount: 1, + suppressedOperationCount: 0, + rejectedOperationCount: 0, + rolloutConflictCount: 0 + }); + expect(payload.nextRecommendedActions).toEqual( + expect.arrayContaining([ + expect.stringContaining("recall timeline"), + expect.stringContaining("memory --recent") + ]) + ); + expect(payload.followUp.timelineRefs).toHaveLength(1); + expect(payload.followUp.detailsRefs).toHaveLength(1); + }); + + it("infers a durable topic for remember when --topic is omitted", async () => { + const homeDir = await tempDir("cam-remember-infer-topic-home-"); + const projectDir = await tempDir("cam-remember-infer-topic-project-"); + const memoryRoot = await tempDir("cam-remember-infer-topic-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const result = runCli( + projectDir, + ["remember", "Prefer pnpm in this repository.", "--scope", "project", "--json"], + { env: { HOME: homeDir } } + ); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + primaryEntry: { + ref: string; + }; + entry: { + topic: string; + summary: string; + }; + }; + + expect(payload.primaryEntry.ref).toContain(":preferences:"); + expect(payload.entry).toMatchObject({ + topic: "preferences", + summary: "Prefer pnpm in this repository." + }); + }); + + it("updates a single clear commands memory instead of appending a duplicate when --topic is omitted", async () => { + const homeDir = await tempDir("cam-remember-command-update-home-"); + const projectDir = await tempDir("cam-remember-command-update-project-"); + const memoryRoot = await tempDir("cam-remember-command-update-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const first = runCli( + projectDir, + [ + "remember", + "Use `pnpm test` to run the test suite.", + "--scope", + "project", + "--topic", + "commands", + "--detail", + "Run `pnpm test` from the repository root.", + "--json" + ], + { env: { HOME: homeDir } } + ); + expect(first.exitCode, first.stderr).toBe(0); + + const second = runCli( + projectDir, + [ + "remember", + "Run `pnpm run test` to execute the test suite.", + "--scope", + "project", + "--detail", + "Prefer the canonical `pnpm test` form in this repository.", + "--json" + ], + { env: { HOME: homeDir } } + ); + expect(second.exitCode, second.stderr).toBe(0); + + const payload = JSON.parse(second.stdout) as { + lifecycleAction: string; + primaryEntry: { + ref: string; + }; + entry: { + topic: string; + summary: string; + }; + }; + + expect(payload).toMatchObject({ + lifecycleAction: "update", + primaryEntry: { + ref: "project:active:commands:use-pnpm-test-to-run-the-test-suite" + }, + entry: { + topic: "commands", + summary: "Run `pnpm run test` to execute the test suite." + } + }); + + const inspection = runCli(projectDir, ["memory", "--json"], { + env: { HOME: homeDir } + }); + expect(inspection.exitCode, inspection.stderr).toBe(0); + + const memoryOutput = JSON.parse(inspection.stdout) as { + scopes: Array<{ + scope: string; + count: number; + topics: string[]; + }>; + }; + expect(memoryOutput.scopes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + count: 1, + topics: ["commands"] + }) + ]) + ); + }); + + it("surfaces a structured reviewer payload for forget --json including archive refs", async () => { + const homeDir = await tempDir("cam-forget-json-home-"); + const projectDir = await tempDir("cam-forget-json-project-"); + const memoryRoot = await tempDir("cam-forget-json-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const project = detectProjectContext(projectDir); + const store = new MemoryStore(project, { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const result = runCli(projectDir, ["forget", "pnpm", "--scope", "project", "--archive", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + action: string; + mutationKind: string; + entryCount: number; + warningCount: number; + uniqueAuditCount: number; + auditCountsDeduplicated: boolean; + warningsByEntryRef: Record; + leadEntryRef?: string | null; + leadEntryIndex?: number | null; + detailsAvailable?: boolean; + reviewRefState?: string | null; + detailsUsableEntryCount: number; + timelineOnlyEntryCount: number; + query: string; + scope: string; + archive: boolean; + matchedCount: number; + appliedCount: number; + noopCount: number; + affectedCount: number; + affectedRefs: string[]; + followUp: { + timelineRefs: string[]; + detailsRefs: string[]; + }; + primaryEntry: { + ref: string; + detailsRef: string | null; + lifecycleAction: string; + }; + reviewerSummary: { + matchedAuditOperationCount: number; + noopOperationCount: number; + suppressedOperationCount: number; + rejectedOperationCount: number; + rolloutConflictCount: number; + }; + ref: string; + timelineRef: string; + detailsRef: string | null; + lifecycleAction: string; + latestLifecycleAction: string | null; + latestAppliedLifecycle: { action: string } | null; + latestLifecycleAttempt: { action: string; outcome: string; updateKind: string | null } | null; + latestState: string; + latestSessionId: string | null; + latestRolloutPath: string | null; + latestAudit: unknown; + timelineWarningCount: number; + lineageSummary: { latestAction: string | null; latestUpdateKind: string | null }; + warnings: string[]; + entry: { + id: string; + scope: string; + topic: string; + summary: string; + }; + nextRecommendedActions: string[]; + entries: Array<{ + ref: string; + timelineRef: string; + detailsRef: string | null; + lifecycleAction: string; + latestState: string; + latestLifecycleAttempt: { action: string; outcome: string; updateKind: string | null } | null; + lineageSummary: { latestAction: string | null; latestUpdateKind: string | null }; + }>; + }; + + expect(payload).toMatchObject({ + action: "forget", + mutationKind: "forget", + entryCount: 1, + warningCount: 0, + uniqueAuditCount: 0, + auditCountsDeduplicated: true, + warningsByEntryRef: {}, + leadEntryRef: "project:archived:workflow:prefer-pnpm", + leadEntryIndex: 0, + detailsAvailable: true, + reviewRefState: "archived", + detailsUsableEntryCount: 1, + timelineOnlyEntryCount: 0, + query: "pnpm", + scope: "project", + archive: true, + matchedCount: 1, + appliedCount: 1, + noopCount: 0, + affectedCount: 1, + ref: "project:archived:workflow:prefer-pnpm", + timelineRef: "project:archived:workflow:prefer-pnpm", + detailsRef: "project:archived:workflow:prefer-pnpm", + lifecycleAction: "archive", + primaryEntry: { + ref: "project:archived:workflow:prefer-pnpm", + timelineRef: "project:archived:workflow:prefer-pnpm", + detailsRef: "project:archived:workflow:prefer-pnpm", + lifecycleAction: "archive" + }, + latestLifecycleAction: "archive", + latestAppliedLifecycle: { + action: "archive" + }, + latestLifecycleAttempt: { + action: "archive", + outcome: "applied", + updateKind: null + }, + latestState: "archived", + latestSessionId: null, + latestRolloutPath: null, + timelineWarningCount: 0, + lineageSummary: { + latestAction: "archive", + latestUpdateKind: null + }, + warnings: [], + entry: { + id: "prefer-pnpm", + scope: "project", + topic: "workflow", + summary: "Prefer pnpm in this repository." + }, + entries: [ + { + ref: "project:archived:workflow:prefer-pnpm", + timelineRef: "project:archived:workflow:prefer-pnpm", + detailsRef: "project:archived:workflow:prefer-pnpm", + lifecycleAction: "archive", + latestState: "archived", + latestLifecycleAttempt: { + action: "archive", + outcome: "applied", + updateKind: null + }, + lineageSummary: { + latestAction: "archive", + latestUpdateKind: null + } + } + ] + }); + expect(payload.affectedRefs).toEqual(["project:archived:workflow:prefer-pnpm"]); + expect(payload.followUp.timelineRefs).toEqual(["project:archived:workflow:prefer-pnpm"]); + expect(payload.followUp.detailsRefs).toEqual(["project:archived:workflow:prefer-pnpm"]); + expect(payload.reviewerSummary).toMatchObject({ + matchedAuditOperationCount: 0, + noopOperationCount: 0, + suppressedOperationCount: 0, + rejectedOperationCount: 0, + rolloutConflictCount: 0 + }); + expect(payload.latestAudit).toBeNull(); + expect(payload.nextRecommendedActions).toEqual( + expect.arrayContaining([ + expect.stringContaining("recall timeline"), + expect.stringContaining("memory --recent") + ]) + ); + }); + + it("surfaces delete-only review routes for forget --json when details are no longer available", async () => { + const homeDir = await tempDir("cam-forget-delete-json-home-"); + const projectDir = await tempDir("cam-forget-delete-json-project-"); + const memoryRoot = await tempDir("cam-forget-delete-json-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const project = detectProjectContext(projectDir); + const store = new MemoryStore(project, { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const result = runCli(projectDir, ["forget", "pnpm", "--scope", "project", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + mutationKind: string; + entryCount: number; + warningCount: number; + uniqueAuditCount: number; + auditCountsDeduplicated: boolean; + warningsByEntryRef: Record; + leadEntryRef?: string | null; + leadEntryIndex?: number | null; + detailsAvailable?: boolean; + reviewRefState?: string | null; + detailsUsableEntryCount: number; + timelineOnlyEntryCount: number; + matchedCount: number; + appliedCount: number; + noopCount: number; + affectedRefs: string[]; + ref: string; + timelineRef: string; + detailsRef: string | null; + lifecycleAction: string; + latestLifecycleAction: string | null; + latestAppliedLifecycle: { action: string } | null; + latestLifecycleAttempt: { action: string; outcome: string; updateKind: string | null } | null; + latestState: string; + latestSessionId: string | null; + latestRolloutPath: string | null; + latestAudit: unknown; + timelineWarningCount: number; + lineageSummary: { latestAction: string | null; latestUpdateKind: string | null }; + warnings: string[]; + entry: { + id: string; + scope: string; + topic: string; + summary: string; + }; + followUp: { + timelineRefs: string[]; + detailsRefs: string[]; + }; + entries: Array<{ + ref: string; + timelineRef: string; + detailsRef: string | null; + }>; + }; + + expect(payload).toMatchObject({ + mutationKind: "forget", + entryCount: 1, + warningCount: 0, + uniqueAuditCount: 0, + auditCountsDeduplicated: true, + warningsByEntryRef: {}, + leadEntryRef: "project:active:workflow:prefer-pnpm", + leadEntryIndex: 0, + detailsAvailable: false, + reviewRefState: "active", + detailsUsableEntryCount: 0, + timelineOnlyEntryCount: 1, + matchedCount: 1, + appliedCount: 1, + noopCount: 0, + ref: "project:active:workflow:prefer-pnpm", + timelineRef: "project:active:workflow:prefer-pnpm", + detailsRef: null, + lifecycleAction: "delete", + latestLifecycleAction: "delete", + latestAppliedLifecycle: { + action: "delete" + }, + latestLifecycleAttempt: { + action: "delete", + outcome: "applied", + updateKind: null + }, + latestState: "deleted", + latestSessionId: null, + latestRolloutPath: null, + timelineWarningCount: 0, + lineageSummary: { + latestAction: "delete", + latestUpdateKind: null + }, + warnings: [], + entry: { + id: "prefer-pnpm", + scope: "project", + topic: "workflow", + summary: "Prefer pnpm in this repository." + }, + affectedRefs: ["project:active:workflow:prefer-pnpm"], + followUp: { + timelineRefs: ["project:active:workflow:prefer-pnpm"], + detailsRefs: [] + }, + entries: [ + { + ref: "project:active:workflow:prefer-pnpm", + timelineRef: "project:active:workflow:prefer-pnpm", + detailsRef: null + } + ] + }); + expect(payload.latestAudit).toBeNull(); + }); + + it("matches forget queries across summary and details using the shared retrieval query semantics", async () => { + const homeDir = await tempDir("cam-forget-multi-term-home-"); + const projectDir = await tempDir("cam-forget-multi-term-project-"); + const memoryRoot = await tempDir("cam-forget-multi-term-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const project = detectProjectContext(projectDir); + const store = new MemoryStore(project, { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const result = runCli( + projectDir, + ["forget", "pnpm npm", "--scope", "project", "--archive", "--json"], + { + env: { HOME: homeDir } + } + ); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + matchedCount: number; + appliedCount: number; + affectedRefs: string[]; + }; + expect(payload).toMatchObject({ + matchedCount: 1, + appliedCount: 1, + affectedRefs: ["project:archived:workflow:prefer-pnpm"] + }); + }); + + it("matches forget queries even when shared retrieval terms contain trailing punctuation", async () => { + const homeDir = await tempDir("cam-forget-punctuated-query-home-"); + const projectDir = await tempDir("cam-forget-punctuated-query-project-"); + const memoryRoot = await tempDir("cam-forget-punctuated-query-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const project = detectProjectContext(projectDir); + const store = new MemoryStore(project, { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const result = runCli( + projectDir, + ["forget", "pnpm, npm.", "--scope", "project", "--archive", "--json"], + { + env: { HOME: homeDir } + } + ); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + matchedCount: number; + appliedCount: number; + affectedRefs: string[]; + }; + expect(payload).toMatchObject({ + matchedCount: 1, + appliedCount: 1, + affectedRefs: ["project:archived:workflow:prefer-pnpm"] + }); + }); + + it("matches forget queries when natural separators split shared query terms", async () => { + const homeDir = await tempDir("cam-forget-separated-query-home-"); + const projectDir = await tempDir("cam-forget-separated-query-project-"); + const memoryRoot = await tempDir("cam-forget-separated-query-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const project = detectProjectContext(projectDir); + const store = new MemoryStore(project, { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const result = runCli( + projectDir, + ["forget", "pnpm/npm", "--scope", "project", "--archive", "--json"], + { + env: { HOME: homeDir } + } + ); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + matchedCount: number; + appliedCount: number; + affectedRefs: string[]; + }; + expect(payload).toMatchObject({ + matchedCount: 1, + appliedCount: 1, + affectedRefs: ["project:archived:workflow:prefer-pnpm"] + }); + }); + + it("matches forget queries across topic and content fields with shared retrieval semantics", async () => { + const homeDir = await tempDir("cam-forget-topic-query-home-"); + const projectDir = await tempDir("cam-forget-topic-query-project-"); + const memoryRoot = await tempDir("cam-forget-topic-query-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const project = detectProjectContext(projectDir); + const store = new MemoryStore(project, { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const result = runCli( + projectDir, + ["forget", "workflow pnpm", "--scope", "project", "--archive", "--json"], + { + env: { HOME: homeDir } + } + ); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + matchedCount: number; + appliedCount: number; + affectedRefs: string[]; + }; + expect(payload).toMatchObject({ + matchedCount: 1, + appliedCount: 1, + affectedRefs: ["project:archived:workflow:prefer-pnpm"] + }); + }); + + it("deduplicates rollout-level reviewer summary counts across multiple forget entries", async () => { + const sharedAudit = { + auditPath: "/tmp/shared-sync-audit.jsonl", + appliedAt: "2026-03-30T12:00:00.000Z", + rolloutPath: "/tmp/shared-rollout.jsonl", + sessionId: "session-reviewer-dedupe", + status: "applied" as const, + resultSummary: "2 operation(s) applied, 2 suppressed, 3 rejected", + matchedOperationCount: 2, + noopOperationCount: 1, + suppressedOperationCount: 2, + rejectedOperationCount: 3, + rejectedReasonCounts: { + "unknown-topic": 2, + sensitive: 1 + }, + rejectedOperations: [ + { + action: "upsert" as const, + scope: "project" as const, + topic: "workflow", + id: "dropped-topic", + reason: "unknown-topic" as const + } + ], + conflicts: [ + { + scope: "project" as const, + topic: "workflow", + candidateSummary: "Maybe use npm instead.", + conflictsWith: ["Prefer pnpm in this repository."], + source: "existing-memory" as const, + resolution: "suppressed" as const + }, + { + scope: "project" as const, + topic: "workflow", + candidateSummary: "Maybe use npm in smoke tests.", + conflictsWith: ["Prefer pnpm for smoke tests in this repository."], + source: "within-rollout" as const, + resolution: "suppressed" as const + } + ] + }; + const entries: ManualMutationReviewEntry[] = [ + { + ref: "project:deleted:workflow:prefer-pnpm", + timelineRef: "project:deleted:workflow:prefer-pnpm", + detailsRef: null, + scope: "project", + state: "active", + topic: "workflow", + id: "prefer-pnpm", + path: null, + historyPath: "/tmp/project-history.jsonl", + lifecycleAction: "delete", + latestLifecycleAction: "delete", + latestAppliedLifecycle: { + at: "2026-03-30T12:05:00.000Z", + action: "delete", + outcome: "applied", + state: "deleted", + previousState: "active", + nextState: "deleted", + summary: "Prefer pnpm in this repository.", + updateKind: null, + sessionId: null, + rolloutPath: null + }, + latestLifecycleAttempt: { + at: "2026-03-30T12:05:00.000Z", + action: "delete", + outcome: "applied", + state: "deleted", + previousState: "active", + nextState: "deleted", + summary: "Prefer pnpm in this repository.", + updateKind: null, + sessionId: null, + rolloutPath: null + }, + latestState: "deleted", + latestSessionId: null, + latestRolloutPath: null, + latestAudit: sharedAudit, + timelineWarningCount: 0, + lineageSummary: { + eventCount: 2, + firstSeenAt: "2026-03-30T12:00:00.000Z", + latestAt: "2026-03-30T12:05:00.000Z", + latestAction: "delete", + latestState: "deleted", + latestAttemptedAction: "delete", + latestAttemptedState: "deleted", + latestAttemptedOutcome: "applied", + latestUpdateKind: null, + archivedAt: null, + deletedAt: "2026-03-30T12:05:00.000Z", + latestAuditStatus: "applied", + refNoopCount: 0, + matchedAuditOperationCount: 1, + rolloutNoopOperationCount: 1, + rolloutSuppressedOperationCount: 2, + rolloutConflictCount: 2, + noopOperationCount: 0, + suppressedOperationCount: 2, + conflictCount: 2, + rejectedOperationCount: 3, + rejectedReasonCounts: { + "unknown-topic": 2, + sensitive: 1 + } + }, + warnings: [], + entry: { + id: "prefer-pnpm", + scope: "project", + topic: "workflow", + summary: "Prefer pnpm in this repository.", + details: ["Use pnpm instead of npm in this repository."], + updatedAt: "2026-03-30T12:00:00.000Z", + sources: ["manual"] + } + }, + { + ref: "project:deleted:workflow:prefer-pnpm-for-smoke-tests", + timelineRef: "project:deleted:workflow:prefer-pnpm-for-smoke-tests", + detailsRef: null, + scope: "project", + state: "active", + topic: "workflow", + id: "prefer-pnpm-for-smoke-tests", + path: null, + historyPath: "/tmp/project-history.jsonl", + lifecycleAction: "delete", + latestLifecycleAction: "delete", + latestAppliedLifecycle: { + at: "2026-03-30T12:05:00.000Z", + action: "delete", + outcome: "applied", + state: "deleted", + previousState: "active", + nextState: "deleted", + summary: "Prefer pnpm for smoke tests in this repository.", + updateKind: null, + sessionId: null, + rolloutPath: null + }, + latestLifecycleAttempt: { + at: "2026-03-30T12:05:00.000Z", + action: "delete", + outcome: "applied", + state: "deleted", + previousState: "active", + nextState: "deleted", + summary: "Prefer pnpm for smoke tests in this repository.", + updateKind: null, + sessionId: null, + rolloutPath: null + }, + latestState: "deleted", + latestSessionId: null, + latestRolloutPath: null, + latestAudit: sharedAudit, + timelineWarningCount: 0, + lineageSummary: { + eventCount: 2, + firstSeenAt: "2026-03-30T12:00:00.000Z", + latestAt: "2026-03-30T12:05:00.000Z", + latestAction: "delete", + latestState: "deleted", + latestAttemptedAction: "delete", + latestAttemptedState: "deleted", + latestAttemptedOutcome: "applied", + latestUpdateKind: null, + archivedAt: null, + deletedAt: "2026-03-30T12:05:00.000Z", + latestAuditStatus: "applied", + refNoopCount: 0, + matchedAuditOperationCount: 1, + rolloutNoopOperationCount: 1, + rolloutSuppressedOperationCount: 2, + rolloutConflictCount: 2, + noopOperationCount: 0, + suppressedOperationCount: 2, + conflictCount: 2, + rejectedOperationCount: 3, + rejectedReasonCounts: { + "unknown-topic": 2, + sensitive: 1 + } + }, + warnings: [], + entry: { + id: "prefer-pnpm-for-smoke-tests", + scope: "project", + topic: "workflow", + summary: "Prefer pnpm for smoke tests in this repository.", + details: ["Use pnpm when validating smoke flows."], + updatedAt: "2026-03-30T12:00:00.000Z", + sources: ["manual"] + } + } + ]; + + const payload = toManualMutationForgetPayload("Prefer pnpm", "project", false, entries); + expect(payload.reviewerSummary).toMatchObject({ + matchedAuditOperationCount: 2, + noopOperationCount: 0, + suppressedOperationCount: 2, + rejectedOperationCount: 3, + rejectedReasonCounts: { + "unknown-topic": 2, + sensitive: 1 + }, + rolloutConflictCount: 2, + uniqueAuditCount: 1, + auditCountsDeduplicated: true, + warningCount: 0, + warningsByEntryRef: {} + }); + expect(payload.nextRecommendedActions).toEqual( + expect.arrayContaining([ + expect.stringContaining("project:deleted:workflow:prefer-pnpm"), + expect.stringContaining("project:deleted:workflow:prefer-pnpm-for-smoke-tests") + ]) + ); + }); + + it("includes review-oriented next steps in forget text output", async () => { + const homeDir = await tempDir("cam-forget-text-home-"); + const projectDir = await tempDir("cam-forget-text-project-"); + const memoryRoot = await tempDir("cam-forget-text-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const project = detectProjectContext(projectDir); + const store = new MemoryStore(project, { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const result = runCli(projectDir, ["forget", "pnpm", "--scope", "project"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Deleted 1 memory entry:"); + expect(result.stdout).toContain("Next steps:"); + expect(result.stdout).toContain("recall timeline"); + expect(result.stdout).toContain("Details are unavailable for deleted refs"); + expect(result.stdout).toContain("memory --recent"); + expect(result.stdout).toContain("memory reindex"); + }); + + it("surfaces an additive empty reviewer payload for forget --json when nothing matches", async () => { + const homeDir = await tempDir("cam-forget-empty-json-home-"); + const projectDir = await tempDir("cam-forget-empty-json-project-"); + const memoryRoot = await tempDir("cam-forget-empty-json-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const result = runCli(projectDir, ["forget", "missing entry", "--scope", "project", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + mutationKind: string; + entryCount: number; + warningCount: number; + uniqueAuditCount: number; + auditCountsDeduplicated: boolean; + warningsByEntryRef: Record; + detailsUsableEntryCount: number; + timelineOnlyEntryCount: number; + matchedCount: number; + appliedCount: number; + noopCount: number; + affectedCount: number; + summary: { + matchedCount: number; + appliedCount: number; + noopCount: number; + affectedCount: number; + }; + affectedRefs: string[]; + followUp: { + timelineRefs: string[]; + detailsRefs: string[]; + }; + nextRecommendedActions: string[]; + entries: unknown[]; + }; + + expect(payload).toEqual( + expect.objectContaining({ + mutationKind: "forget", + entryCount: 0, + warningCount: 0, + uniqueAuditCount: 0, + auditCountsDeduplicated: true, + warningsByEntryRef: {}, + detailsUsableEntryCount: 0, + timelineOnlyEntryCount: 0, + matchedCount: 0, + appliedCount: 0, + noopCount: 0, + affectedCount: 0, + summary: { + matchedCount: 0, + appliedCount: 0, + noopCount: 0, + affectedCount: 0 + }, + affectedRefs: [], + followUp: { + timelineRefs: [], + detailsRefs: [] + }, + nextRecommendedActions: [], + entries: [] + }) + ); + }); + + it("supports --cwd so remember and forget can target another project directory", async () => { + const homeDir = await tempDir("cam-memory-manual-cwd-home-"); + const projectDir = await tempDir("cam-memory-manual-cwd-project-"); + const shellDir = await tempDir("cam-memory-manual-cwd-shell-"); + const memoryRoot = await tempDir("cam-memory-manual-cwd-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const rememberResult = runCli( + shellDir, + ["remember", "Prefer pnpm in this repository.", "--cwd", projectDir, "--scope", "project", "--json"], + { + env: { HOME: homeDir } + } + ); + expect(rememberResult.exitCode, rememberResult.stderr).toBe(0); + expect(JSON.parse(rememberResult.stdout)).toMatchObject({ + mutationKind: "remember", + scope: "project", + ref: "project:active:preferences:prefer-pnpm-in-this-repository" + }); + + const forgetResult = runCli( + shellDir, + ["forget", "pnpm", "--cwd", projectDir, "--scope", "project", "--json"], + { + env: { HOME: homeDir } + } + ); + expect(forgetResult.exitCode, forgetResult.stderr).toBe(0); + expect(JSON.parse(forgetResult.stdout)).toMatchObject({ + mutationKind: "forget", + matchedCount: 1, + ref: "project:active:preferences:prefer-pnpm-in-this-repository" + }); + }); + + it("surfaces startup omission reasons for low-signal, duplicate, unsafe, and budget-trimmed highlights", async () => { + const homeDir = await tempDir("cam-memory-startup-omissions-home-"); + const projectDir = await tempDir("cam-memory-startup-omissions-project-"); + const memoryRoot = await tempDir("cam-memory-startup-omissions-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const project = detectProjectContext(projectDir); + const store = new MemoryStore(project, { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + + await store.remember( + "project", + "commands", + "release-command", + "Run pnpm build before release.", + ["Build before release."], + "Manual note." + ); + await store.remember( + "project", + "workflow", + "duplicate-workflow", + "Verify release-facing surfaces before claiming completion.", + ["Same summary in another topic."], + "Manual note." + ); + await store.remember( + "project", + "architecture", + "markdown-canonical", + "Preserve Markdown as the canonical store.", + ["Do not make the runtime DB-first."], + "Manual note." + ); + await store.remember( + "project", + "debugging", + "capture-rollout", + "Capture rollout evidence before fixing regressions.", + ["Use rollout evidence before changing code."], + "Manual note." + ); + await store.remember( + "project", + "testing", + "verify-release-surface", + "Verify release-facing surfaces before claiming completion.", + ["Run release-facing checks before completion claims."], + "Manual note." + ); + await store.remember( + "project", + "workflow", + "placeholder-summary", + "placeholder-summary", + ["Low-signal placeholder entry."], + "Manual note." + ); + + const unsafeTopicFile = store.getTopicFile("project", "commands"); + await fs.writeFile( + unsafeTopicFile, + buildUnsafeWorkflowTopicContents( + "unsafe-command", + "Unsafe command summary", + "Unsafe command detail" + ), + "utf8" + ); + + const output = JSON.parse( + await runMemory({ + cwd: projectDir, + json: true + }) + ) as MemoryCommandOutput & { + startupOmissionCounts?: Record; + startupOmissions: Array<{ + topic: string; + id?: string; + reason: string; + }>; + startupOmissionCountsByTargetAndStage: { + highlight: { + selection: number; + render: number; + }; + }; + }; + + expect(output.startupOmissions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "placeholder-summary", + reason: "low-signal", + target: "highlight", + stage: "selection" + }), + expect.objectContaining({ + id: "duplicate-workflow", + reason: "duplicate-summary", + target: "highlight", + stage: "selection" + }), + expect.objectContaining({ + topic: "commands", + reason: "unsafe-topic", + target: "highlight", + stage: "selection" + }), + expect.objectContaining({ + id: "markdown-canonical", + reason: "budget-trimmed", + target: "highlight", + stage: "selection" + }) + ]) + ); + expect(output.omittedHighlightCount).toBe(4); + expect(output.omittedTopicFileCount).toBe(1); + expect(output.startupOmissionCounts).toMatchObject({ + "low-signal": 1, + "duplicate-summary": 1, + "unsafe-topic": 2, + "budget-trimmed": 1 + }); + expect(output.startupOmissionCountsByTargetAndStage.highlight).toEqual({ + selection: 4, + render: 0 + }); + expect(output.topicFileOmissionCounts).toMatchObject({ + "unsafe-topic": 1 + }); + }); + + it("surfaces no-eligible-entry when startup cannot render any highlight candidates", async () => { + const homeDir = await tempDir("cam-memory-no-eligible-highlight-home-"); + const projectDir = await tempDir("cam-memory-no-eligible-highlight-project-"); + const memoryRoot = await tempDir("cam-memory-no-eligible-highlight-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "placeholder-summary", + "placeholder-summary", + ["Low-signal placeholder entry."], + "Manual note." + ); + + const output = JSON.parse( + await runMemory({ + cwd: projectDir, + json: true + }) + ) as MemoryCommandOutput & { + startupOmissionCountsByTargetAndStage: { + highlight: { + selection: number; + render: number; + }; + }; + }; + + expect(output.highlightCount).toBe(0); + expect(output.startupSectionsRendered.highlights).toBe(false); + expect(output.startupOmissions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + reason: "low-signal", + target: "highlight", + stage: "selection" + }), + expect.objectContaining({ + reason: "no-eligible-entry", + target: "highlight", + stage: "selection" + }) + ]) + ); + expect(output.startupOmissionCountsByTargetAndStage.highlight).toEqual({ + selection: 2, + render: 0 + }); + }); + + it("keeps startup omissions distinct across target and stage while exposing explainability fields", async () => { + const homeDir = await tempDir("cam-memory-omission-explainability-home-"); + const projectDir = await tempDir("cam-memory-omission-explainability-project-"); + const memoryRoot = await tempDir("cam-memory-omission-explainability-root-"); + process.env.HOME = homeDir; + + const projectConfig: AppConfig = { + ...buildProjectConfig(), + maxStartupLines: 14 + }; + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await store.remember( + "project", + "architecture", + "markdown-canonical", + "Preserve Markdown as the canonical store.", + ["Do not make the runtime DB-first."], + "Manual note." + ); + + const output = JSON.parse( + await runMemory({ + cwd: projectDir, + json: true + }) + ) as MemoryCommandOutput; + + expect(output.startup).toMatchObject({ + highlights: expect.arrayContaining([ + expect.objectContaining({ + selectionReason: "eligible-highlight", + selectionRank: 1 + }) + ]), + omissions: expect.arrayContaining([ + expect.objectContaining({ + topic: "startup", + target: "scope-block", + stage: "selection", + reason: "budget-trimmed", + budgetKind: "line-budget" + }), + expect.objectContaining({ + target: "topic-file", + stage: "render", + reason: "budget-trimmed", + budgetKind: "line-budget" + }) + ]) + }); + expect(output.startupOmissionCountsByTargetAndStage.topicFile.render).toBeGreaterThanOrEqual(1); + expect(output.startupOmissionCountsByTargetAndStage.scopeBlock.selection).toBeGreaterThanOrEqual(1); + }); + + it("records budget omissions for duplicate ids that remain distinct across topics", async () => { + const homeDir = await tempDir("cam-memory-duplicate-id-home-"); + const projectDir = await tempDir("cam-memory-duplicate-id-project-"); + const memoryRoot = await tempDir("cam-memory-duplicate-id-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "shared-id", + "Keep workflow checks on pnpm.", + ["Workflow detail."], + "Manual note." + ); + await store.remember( + "project", + "architecture", + "architecture-id", + "Keep Markdown as the canonical store.", + ["Architecture detail."], + "Manual note." + ); + await store.remember( + "project", + "reference", + "shared-id", + "The runbook lives at https://example.test/runbook.", + ["Reference detail."], + "Manual note." + ); + + const output = JSON.parse( + await runMemory({ + cwd: projectDir, + json: true + }) + ) as MemoryCommandOutput; + + expect(output.highlightCount).toBe(2); + expect(output.startupOmissions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + topic: "workflow", + id: "shared-id", + reason: "budget-trimmed", + target: "highlight", + stage: "selection" + }) + ]) + ); + }); + + it("records render-stage scope-block omissions when budget cannot fit a remaining non-empty scope block", async () => { + const homeDir = await tempDir("cam-memory-scope-block-render-home-"); + const projectDir = await tempDir("cam-memory-scope-block-render-project-"); + const memoryRoot = await tempDir("cam-memory-scope-block-render-root-"); + process.env.HOME = homeDir; + + const projectConfig: AppConfig = { + ...buildProjectConfig(), + maxStartupLines: 10 + }; + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project-local", + "workflow", + "local-keep", + "Keep local startup checks visible.", + ["Local detail."], + "Manual note." + ); + await store.remember( + "project", + "workflow", + "project-blocked", + "This project scope block should be omitted at render time.", + ["Project detail."], + "Manual note." + ); + + const output = JSON.parse( + await runMemory({ + cwd: projectDir, + json: true + }) + ) as MemoryCommandOutput; + + expect(output.startupOmissions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + topic: "startup", + target: "scope-block", + stage: "render", + reason: "budget-trimmed", + budgetKind: "line-budget" + }) + ]) + ); + expect(output.startupOmissionCountsByTargetAndStage.scopeBlock.render).toBeGreaterThanOrEqual( + 1 + ); + }); + + it("surfaces topic file omission counts and reasons when startup topic refs are budget-trimmed", async () => { + const homeDir = await tempDir("cam-memory-topic-ref-omissions-home-"); + const projectDir = await tempDir("cam-memory-topic-ref-omissions-project-"); + const memoryRoot = await tempDir("cam-memory-topic-ref-omissions-root-"); + process.env.HOME = homeDir; + + const projectConfig: AppConfig = { + ...buildProjectConfig(), + maxStartupLines: 100 + }; + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + + for (let i = 0; i < 50; i++) { + await store.remember( + "project", + `topic-${i}`, + `entry-${i}`, + `Workflow entry number ${i}.`, + [`Detail for entry ${i}.`], + "Manual note." + ); + } + + const output = JSON.parse( + await runMemory({ + cwd: projectDir, + json: true + }) + ) as MemoryCommandOutput; + + expect(output.topicRefCountsByScope.project.discovered).toBe(50); + expect(output.topicRefCountsByScope.project.rendered).toBeGreaterThan(0); + expect(output.topicRefCountsByScope.project.rendered).toBeLessThan(50); + expect(output.topicRefCountsByScope.project.omitted).toBe( + 50 - output.topicRefCountsByScope.project.rendered + ); + expect(output.omittedTopicFileCount).toBe(output.topicRefCountsByScope.project.omitted); + expect(output.topicFileOmissionCounts).toMatchObject({ + "budget-trimmed": output.omittedTopicFileCount + }); + expect(output.startupOmissionCounts["budget-trimmed"]).toBeGreaterThanOrEqual( + output.omittedTopicFileCount + ); + expect(output.startupOmissions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + topic: expect.stringMatching(/^topic-/), + reason: "budget-trimmed", + target: "topic-file", + stage: "render" + }) + ]) + ); + }); + + it("keeps scanning later scopes for startup omissions even after earlier scopes fill the highlight budget", async () => { + const homeDir = await tempDir("cam-memory-cross-scope-omissions-home-"); + const projectDir = await tempDir("cam-memory-cross-scope-omissions-project-"); + const memoryRoot = await tempDir("cam-memory-cross-scope-omissions-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + + await store.remember( + "project-local", + "workflow", + "local-one", + "Use pnpm for local smoke checks.", + ["Local detail one."], + "Manual note." + ); + await store.remember( + "project-local", + "workflow", + "local-two", + "Keep wrapper verification local-first.", + ["Local detail two."], + "Manual note." + ); + await store.remember( + "project", + "workflow", + "project-one", + "Prefer startup audits before sync.", + ["Project detail one."], + "Manual note." + ); + await store.remember( + "project", + "workflow", + "project-two", + "Review durable memory after integration changes.", + ["Project detail two."], + "Manual note." + ); + await store.remember( + "global", + "commands", + "global-unsafe", + "Use a shared global command.", + ["Global unsafe detail."], + "Manual note." + ); + await fs.writeFile( + store.getTopicFile("global", "commands"), + [ + "# Commands", + "", + "", + "", + "This file is maintained by Codex Auto Memory. You may edit summaries or details directly.", + "", + "Manual note that cannot be round-tripped safely.", + "", + "## global-unsafe", + '', + "Summary: Use a shared global command.", + "Details:", + "- Global unsafe detail.", + "", + "## malformed-entry", + "", + "Summary: Broken entry.", + "Details:", + "- Must not be deleted by rewrite.", + "" + ].join("\n"), + "utf8" + ); + await store.rebuildIndex("global"); + + const output = JSON.parse( + await runMemory({ + cwd: projectDir, + json: true + }) + ) as MemoryCommandOutput; + + expect(output.highlightCount).toBe(4); + expect(output.startupOmissions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + scope: "global", + topic: "commands", + id: "global-unsafe", + reason: "unsafe-topic", + target: "highlight", + stage: "selection" + }) + ]) + ); + expect(output.startupOmissionCounts["unsafe-topic"]).toBeGreaterThanOrEqual(1); + }); + + it("records a selection-stage omission when the global highlight cap drops a later-scope highlight", async () => { + const homeDir = await tempDir("cam-memory-global-cap-home-"); + const projectDir = await tempDir("cam-memory-global-cap-project-"); + const memoryRoot = await tempDir("cam-memory-global-cap-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + + await store.remember( + "project-local", + "workflow", + "local-one", + "Use pnpm for local smoke checks.", + ["Local detail one."], + "Manual note." + ); + await store.remember( + "project-local", + "workflow", + "local-two", + "Keep wrapper verification local-first.", + ["Local detail two."], + "Manual note." + ); + await store.remember( + "project", + "workflow", + "project-one", + "Prefer startup audits before sync.", + ["Project detail one."], + "Manual note." + ); + await store.remember( + "project", + "workflow", + "project-two", + "Review durable memory after integration changes.", + ["Project detail two."], + "Manual note." + ); + await store.remember( + "global", + "workflow", + "global-one", + "Keep global release review habits consistent.", + ["Global detail one."], + "Manual note." + ); + + const output = JSON.parse( + await runMemory({ + cwd: projectDir, + json: true + }) + ) as MemoryCommandOutput; + + expect(output.highlightCount).toBe(4); + expect(output.startupOmissions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + scope: "global", + topic: "workflow", + id: "global-one", + reason: "budget-trimmed", + target: "highlight", + stage: "selection" + }) + ]) + ); + }); + + it("omits unsafe topic files from startup topic refs in memory command JSON output", async () => { + const homeDir = await tempDir("cam-memory-unsafe-topic-ref-home-"); + const projectDir = await tempDir("cam-memory-unsafe-topic-ref-project-"); + const memoryRoot = await tempDir("cam-memory-unsafe-topic-ref-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "commands", + "unsafe-command", + "Run the unsafe command.", + ["Unsafe detail."], + "Manual note." + ); + await fs.writeFile( + store.getTopicFile("project", "commands"), + buildUnsafeWorkflowTopicContents( + "unsafe-command", + "Run the unsafe command.", + "Unsafe detail." + ), + "utf8" + ); + + const output = JSON.parse( + await runMemory({ + cwd: projectDir, + json: true + }) + ) as MemoryCommandOutput; + + expect(output.topicFilesByScope.project).toEqual([]); + expect(output.omittedTopicFileCount).toBeGreaterThanOrEqual(1); + expect(output.topicFileOmissionCounts).toMatchObject({ + "unsafe-topic": 1 + }); + expect(output.startupOmissions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + topic: "commands", + reason: "unsafe-topic", + target: "topic-file", + stage: "selection" + }) + ]) + ); + expect(output.topicRefCountsByScope.project).toMatchObject({ + discovered: 1, + rendered: 0, + omitted: 1 + }); + }); + + it("surfaces layout diagnostics through memory inspection and reindex reviewer outputs", async () => { + const homeDir = await tempDir("cam-memory-layout-diagnostics-home-"); + const projectDir = await tempDir("cam-memory-layout-diagnostics-project-"); + const memoryRoot = await tempDir("cam-memory-layout-diagnostics-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot }); await store.ensureLayout(); await store.remember( @@ -1455,46 +3647,108 @@ describe("runMemory", () => { "Manual note." ); - const result = runCli(projectDir, ["forget", "pnpm", "--scope", "project", "--json"], { - env: { HOME: homeDir } - }); - expect(result.exitCode, result.stderr).toBe(0); + await fs.writeFile( + path.join(path.dirname(store.getMemoryFile("project")), "Bad Topic.md"), + "# stray\n", + "utf8" + ); + await fs.writeFile( + path.join(path.dirname(store.getMemoryFile("project")), "orphan-topic.md"), + [ + "# Orphan Topic", + "", + "", + "", + "This file is maintained by Codex Auto Memory. You may edit summaries or details directly.", + "" + ].join("\n"), + "utf8" + ); + await fs.writeFile( + path.join(path.dirname(store.getMemoryFile("project")), "ARCHIVE.md"), + "# Archived Project Memory\n", + "utf8" + ); + await fs.writeFile( + path.join(path.dirname(store.getMemoryFile("project")), "retrieval-index.backup.json"), + "{}\n", + "utf8" + ); + await fs.writeFile(store.getMemoryFile("project"), "# Project Memory\n\nDrifted index.\n", "utf8"); - const payload = JSON.parse(result.stdout) as { - mutationKind: string; - matchedCount: number; - appliedCount: number; - noopCount: number; - affectedRefs: string[]; - followUp: { - timelineRefs: string[]; - detailsRefs: string[]; - }; - entries: Array<{ - ref: string; - timelineRef: string; - detailsRef: string | null; - }>; + const output = JSON.parse( + await runMemory({ + cwd: projectDir, + json: true + }) + ) as MemoryCommandOutput; + + expect(output.topicFilesByScope.project).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + topic: "orphan-topic" + }) + ]) + ); + + expect(output.layoutDiagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "malformed-topic-filename", + fileName: "Bad Topic.md" + }), + expect.objectContaining({ + kind: "orphan-topic-markdown", + fileName: "orphan-topic.md" + }), + expect.objectContaining({ + kind: "misplaced-index-markdown", + fileName: "ARCHIVE.md" + }), + expect.objectContaining({ + kind: "unexpected-sidecar", + fileName: "retrieval-index.backup.json" + }), + expect.objectContaining({ + kind: "index-drift", + fileName: "MEMORY.md" + }) + ]) + ); + + const reindexOutput = JSON.parse( + await runMemoryReindex({ + cwd: projectDir, + json: true + }) + ) as { + layoutDiagnostics: Array<{ kind: string; fileName: string }>; }; - expect(payload).toMatchObject({ - mutationKind: "forget", - matchedCount: 1, - appliedCount: 1, - noopCount: 0, - affectedRefs: ["project:active:workflow:prefer-pnpm"], - followUp: { - timelineRefs: ["project:active:workflow:prefer-pnpm"], - detailsRefs: [] - }, - entries: [ - { - ref: "project:active:workflow:prefer-pnpm", - timelineRef: "project:active:workflow:prefer-pnpm", - detailsRef: null - } - ] - }); + expect(reindexOutput.layoutDiagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "malformed-topic-filename", + fileName: "Bad Topic.md" + }), + expect.objectContaining({ + kind: "orphan-topic-markdown", + fileName: "orphan-topic.md" + }), + expect.objectContaining({ + kind: "misplaced-index-markdown", + fileName: "ARCHIVE.md" + }), + expect.objectContaining({ + kind: "unexpected-sidecar", + fileName: "retrieval-index.backup.json" + }), + expect.objectContaining({ + kind: "index-drift", + fileName: "MEMORY.md" + }) + ]) + ); }); it("rebuilds retrieval sidecars explicitly from canonical Markdown memory", async () => { @@ -1614,4 +3868,248 @@ describe("runMemory", () => { ] }); }); + + it("surfaces unsafe topic diagnostics during memory reindex", async () => { + const homeDir = await tempDir("cam-memory-reindex-unsafe-home-"); + const projectDir = await tempDir("cam-memory-reindex-unsafe-project-"); + const memoryRoot = await tempDir("cam-memory-reindex-unsafe-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await fs.writeFile( + store.getTopicFile("project", "workflow"), + [ + "# Workflow", + "", + "", + "", + "This file is maintained by Codex Auto Memory. You may edit summaries or details directly.", + "", + "Manual notes outside managed entries" + ].join("\n"), + "utf8" + ); + + const output = JSON.parse( + await runMemoryReindex({ + cwd: projectDir, + json: true + }) + ) as { + rebuilt: Array<{ + scope: string; + state: string; + topicFileCount: number; + }>; + topicDiagnostics: Array<{ topic: string; safeToRewrite: boolean }>; + }; + + expect(output.topicDiagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + topic: "workflow", + safeToRewrite: false + }) + ]) + ); + expect(output.rebuilt).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + state: "active", + topicFileCount: 0 + }) + ]) + ); + }); + + it("supports --cwd so memory inspection and reindex can target another project directory", async () => { + const homeDir = await tempDir("cam-memory-cwd-home-"); + const projectParentDir = await tempDir("cam-memory-cwd-project-parent-"); + const projectDir = path.join(projectParentDir, "project with spaces"); + const callerDir = await tempDir("cam-memory-cwd-caller-"); + const memoryRoot = await tempDir("cam-memory-cwd-root-"); + process.env.HOME = homeDir; + + await fs.mkdir(projectDir, { recursive: true }); + const realProjectDir = await fs.realpath(projectDir); + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await fs.writeFile(store.getRetrievalIndexFile("project", "active"), "{bad-json", "utf8"); + + const memoryResult = runCli(callerDir, ["memory", "--cwd", projectDir, "--json"], { + env: { HOME: homeDir } + }); + expect(memoryResult.exitCode, memoryResult.stderr).toBe(0); + expect(JSON.parse(memoryResult.stdout)).toMatchObject({ + startupFilesByScope: { + project: [store.getMemoryFile("project")] + }, + topicFilesByScope: { + project: [ + expect.objectContaining({ + path: store.getTopicFile("project", "workflow") + }) + ] + }, + editTargets: { + project: store.getMemoryFile("project") + } + }); + + const reindexResult = runCli( + callerDir, + ["memory", "reindex", "--cwd", projectDir, "--scope", "project", "--state", "active", "--json"], + { + env: { HOME: homeDir } + } + ); + expect(reindexResult.exitCode, reindexResult.stderr).toBe(0); + expect(JSON.parse(reindexResult.stdout)).toMatchObject({ + projectRoot: realProjectDir, + requestedScope: "project", + requestedState: "active", + rebuilt: [ + expect.objectContaining({ + scope: "project", + state: "active", + indexPath: store.getRetrievalIndexFile("project", "active") + }) + ] + }); + }); + + it("keeps memory inspection and memory reindex read-only on an uninitialized project", async () => { + const homeDir = await tempDir("cam-memory-readonly-home-"); + const projectDir = await tempDir("cam-memory-readonly-project-"); + const memoryRootParent = await tempDir("cam-memory-readonly-memory-parent-"); + const memoryRoot = path.join(memoryRootParent, "memory-root"); + process.env.HOME = homeDir; + + await writeProjectConfig(projectDir, buildProjectConfig(), { + autoMemoryDirectory: memoryRoot + }); + + const memoryResult = runCli(projectDir, ["memory", "--json"], { + env: { HOME: homeDir } + }); + expect(memoryResult.exitCode, memoryResult.stderr).toBe(0); + expect(JSON.parse(memoryResult.stdout)).toMatchObject({ + loadedFiles: [], + startup: { + sourceFiles: [], + topicFiles: [], + sectionsRendered: { + projectLocal: false, + project: false, + global: false + } + }, + startupFilesByScope: { + global: [], + project: [], + projectLocal: [] + }, + scopes: [ + expect.objectContaining({ scope: "global", count: 0 }), + expect.objectContaining({ scope: "project", count: 0 }), + expect.objectContaining({ scope: "project-local", count: 0 }) + ] + }); + + const reindexResult = runCli(projectDir, ["memory", "reindex", "--json"], { + env: { HOME: homeDir } + }); + expect(reindexResult.exitCode, reindexResult.stderr).toBe(0); + expect(JSON.parse(reindexResult.stdout)).toMatchObject({ + rebuilt: [] + }); + expect(await readFileIfExists(path.join(memoryRoot, "global", "MEMORY.md"))).toBeNull(); + expect(await readFileIfExists(path.join(memoryRoot, "global", "archive", "ARCHIVE.md"))).toBeNull(); + expect(await readFileIfExists(path.join(memoryRoot, "global", "retrieval-index.json"))).toBeNull(); + }); + + it("keeps memory reindex repairable when topic files exist but index files are missing", async () => { + const homeDir = await tempDir("cam-memory-reindex-repair-home-"); + const projectDir = await tempDir("cam-memory-reindex-repair-project-"); + const memoryRoot = await tempDir("cam-memory-reindex-repair-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + await fs.rm(store.getMemoryFile("project"), { force: true }); + await fs.rm(store.getRetrievalIndexFile("project", "active"), { force: true }); + + const result = runCli(projectDir, ["memory", "reindex", "--scope", "project", "--state", "active", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + rebuilt: [ + expect.objectContaining({ + scope: "project", + state: "active", + status: "ok" + }) + ] + }); + const workflowTopicContents = await readFileIfExists(store.getTopicFile("project", "workflow")); + expect(workflowTopicContents).not.toBeNull(); + expect(workflowTopicContents).toContain("prefer-pnpm"); + expect(await readFileIfExists(store.getMemoryFile("project"))).toBeNull(); + expect(await readFileIfExists(store.getRetrievalIndexFile("project", "active"))).toContain( + "\"prefer-pnpm\"" + ); + }); }); diff --git a/test/memory-store.test.ts b/test/memory-store.test.ts index 50a674b..1467f5b 100644 --- a/test/memory-store.test.ts +++ b/test/memory-store.test.ts @@ -1,8 +1,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { detectProjectContext } from "../src/lib/domain/project-context.js"; +import { MemoryRetrievalService } from "../src/lib/domain/memory-retrieval.js"; import { MemoryStore } from "../src/lib/domain/memory-store.js"; import { compileStartupMemory } from "../src/lib/domain/startup-memory.js"; import type { AppConfig } from "../src/lib/types.js"; @@ -34,6 +35,23 @@ async function snapshotFiles(filePaths: string[]): Promise", + "", + "Manual notes outside managed entries.", + "", + `## ${entryId}`, + ``, + `Summary: ${summary}`, + "Details:", + `- ${detail}`, + "" + ].join("\n"); +} + function createInjectedFileOps(target: { type: "write" | "delete"; path: string; @@ -117,7 +135,7 @@ describe("MemoryStore", () => { expect(entries.some((e) => e.id === "bad-entry")).toBe(false); }); - it("builds startup memory from indexes and topic file references without parsing topic entries", async () => { + it("keeps malformed topic files out of startup refs while still compiling from canonical index files", async () => { const projectDir = await tempDir("cam-store-startup-ref-"); const memoryRoot = await tempDir("cam-store-startup-ref-mem-"); const config: AppConfig = { @@ -154,8 +172,8 @@ describe("MemoryStore", () => { const startup = await compileStartupMemory(store, 200); - expect(startup.text).toContain("### Topic files"); - expect(startup.text).toContain(store.getTopicFile("project", "workflow")); + expect(startup.text).not.toContain("### Topic files"); + expect(startup.text).not.toContain(store.getTopicFile("project", "workflow")); expect(startup.text).not.toContain("Broken entry that should not be parsed during startup compile."); expect(startup.sourceFiles).not.toContain(store.getTopicFile("project", "workflow")); expect(startup.sourceFiles).toEqual([ @@ -163,13 +181,54 @@ describe("MemoryStore", () => { store.getMemoryFile("project"), store.getMemoryFile("global") ]); - expect(startup.topicFiles).toContainEqual({ + expect(startup.topicFiles).not.toContainEqual({ scope: "project", topic: "workflow", path: store.getTopicFile("project", "workflow") }); }); + it("scans topic diagnostics on recall search so unsafe topics stay fail-closed even with a healthy sidecar", async () => { + const projectDir = await tempDir("cam-store-search-sidecar-project-"); + const memoryRoot = await tempDir("cam-store-search-sidecar-mem-"); + const config: AppConfig = { + autoMemoryEnabled: true, + autoMemoryDirectory: memoryRoot, + extractorMode: "heuristic", + defaultScope: "project", + maxStartupLines: 200, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false, + sessionContinuityLocalPathStyle: "codex", + maxSessionContinuityLines: 60, + codexBinary: "codex" + }; + const store = new MemoryStore(detectProjectContext(projectDir), config); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const inspectTopicFilesSpy = vi.spyOn(store, "inspectTopicFiles"); + const retrieval = new MemoryRetrievalService(store); + const result = await retrieval.searchMemories("pnpm", { + state: "active", + limit: 8 + }); + + expect(result.retrievalMode).toBe("index"); + expect(inspectTopicFilesSpy).toHaveBeenCalledTimes(1); + expect(inspectTopicFilesSpy).toHaveBeenCalledWith({ + scope: "all", + state: "active" + }); + }); + it("skips partial scope blocks when the startup budget cannot fit quoted lines", async () => { const projectDir = await tempDir("cam-store-startup-header-only-"); const memoryRoot = await tempDir("cam-store-startup-header-only-mem-"); @@ -316,17 +375,222 @@ describe("MemoryStore", () => { expect(projectMemory).toContain("workflow.md"); expect(startup.lineCount).toBeLessThanOrEqual(200); + expect(startup.text).toContain("### Highlights"); expect(startup.text).toContain("workflow.md"); expect(startup.text).toContain("### Topic files"); expect(startup.text).toContain(store.getTopicFile("project", "workflow")); - expect(startup.text).not.toContain("Prefer pnpm in this repository."); + expect(startup.text).toContain("Prefer pnpm in this repository."); expect(startup.text).toContain("\"scope\":\"project\""); expect(startup.text).toContain("\"topic\":\"workflow\""); + expect(startup.highlights).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + topic: "workflow", + id: "prefer-pnpm", + summary: "Prefer pnpm in this repository." + }) + ]) + ); expect(deleted).toHaveLength(1); expect(await store.listEntries("project-local")).toHaveLength(0); await expect(fs.stat(debuggingTopicFile)).rejects.toThrow(); }); + it("includes latest summary previews in MEMORY.md so startup indexes carry durable fact hints", async () => { + const projectDir = await tempDir("cam-store-index-preview-project-"); + const memoryRoot = await tempDir("cam-store-index-preview-memory-"); + const config: AppConfig = { + autoMemoryEnabled: true, + autoMemoryDirectory: memoryRoot, + extractorMode: "heuristic", + defaultScope: "project", + maxStartupLines: 200, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false, + sessionContinuityLocalPathStyle: "codex", + maxSessionContinuityLines: 60, + codexBinary: "codex" + }; + const store = new MemoryStore(detectProjectContext(projectDir), config); + await store.ensureLayout(); + + await store.remember( + "project", + "preferences", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await store.remember( + "project", + "commands", + "run-tests", + "Use `pnpm test` to run the test suite.", + ["Run `pnpm test` from the repository root."], + "Manual note." + ); + + const projectMemory = await fs.readFile(store.getMemoryFile("project"), "utf8"); + + expect(projectMemory).toContain("- [preferences.md](preferences.md): 1 entry"); + expect(projectMemory).toContain(" - Latest: Prefer pnpm in this repository."); + expect(projectMemory).toContain("- [commands.md](commands.md): 1 entry"); + expect(projectMemory).toContain(" - Latest: Use `pnpm test` to run the test suite."); + }); + + it("keeps startup highlights active-only while archived notes stay out of default startup recall", async () => { + const projectDir = await tempDir("cam-store-startup-highlights-project-"); + const memoryRoot = await tempDir("cam-store-startup-highlights-memory-"); + const config: AppConfig = { + autoMemoryEnabled: true, + autoMemoryDirectory: memoryRoot, + extractorMode: "heuristic", + defaultScope: "project", + maxStartupLines: 200, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false, + sessionContinuityLocalPathStyle: "codex", + maxSessionContinuityLines: 60, + codexBinary: "codex" + }; + const store = new MemoryStore(detectProjectContext(projectDir), config); + await store.ensureLayout(); + + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await store.remember( + "project", + "workflow", + "historical-pnpm", + "Historical pnpm migration note.", + ["Old pnpm migration note kept for history."], + "Manual note." + ); + await store.forget("project", "Historical pnpm migration note", { archive: true }); + + const startup = await compileStartupMemory(store, 200); + + expect(startup.text).toContain("### Highlights"); + expect(startup.text).toContain("Prefer pnpm in this repository."); + expect(startup.text).not.toContain("Historical pnpm migration note."); + expect(startup.highlights).toEqual([ + expect.objectContaining({ + scope: "project", + topic: "workflow", + id: "prefer-pnpm", + summary: "Prefer pnpm in this repository." + }) + ]); + }); + + it("deduplicates identical startup highlight summaries across scopes", async () => { + const projectDir = await tempDir("cam-store-cross-scope-highlight-project-"); + const memoryRoot = await tempDir("cam-store-cross-scope-highlight-memory-"); + const config: AppConfig = { + autoMemoryEnabled: true, + autoMemoryDirectory: memoryRoot, + extractorMode: "heuristic", + defaultScope: "project", + maxStartupLines: 200, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false, + sessionContinuityLocalPathStyle: "codex", + maxSessionContinuityLines: 60, + codexBinary: "codex" + }; + const store = new MemoryStore(detectProjectContext(projectDir), config); + await store.ensureLayout(); + + await store.remember( + "project-local", + "workflow", + "prefer-pnpm-local", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this worktree."], + "Manual note." + ); + await store.remember( + "project", + "workflow", + "prefer-pnpm-project", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const startup = await compileStartupMemory(store, 200); + + expect( + startup.highlights.filter((highlight) => highlight.summary === "Prefer pnpm in this repository.") + ).toHaveLength(1); + }); + + it("prefers descriptive startup highlights over placeholder id-only summaries", async () => { + const projectDir = await tempDir("cam-store-descriptive-startup-highlight-project-"); + const memoryRoot = await tempDir("cam-store-descriptive-startup-highlight-memory-"); + const config: AppConfig = { + autoMemoryEnabled: true, + autoMemoryDirectory: memoryRoot, + extractorMode: "heuristic", + defaultScope: "project", + maxStartupLines: 200, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false, + sessionContinuityLocalPathStyle: "codex", + maxSessionContinuityLines: 60, + codexBinary: "codex" + }; + const store = new MemoryStore(detectProjectContext(projectDir), config); + await store.ensureLayout(); + + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await store.remember( + "project", + "commands", + "npm-pack-dry-run", + "Use `npm pack --dry-run` when working in this repository.", + ["Use `npm pack --dry-run` to verify tarball contents before release."], + "Manual note." + ); + await store.remember( + "project", + "workflow", + "plan-mode-temp-memory", + "plan-mode-temp-memory", + ["Temporary planning note."], + "Manual note." + ); + + const startup = await compileStartupMemory(store, 200); + + expect(startup.highlights).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "prefer-pnpm" }), + expect.objectContaining({ id: "npm-pack-dry-run" }) + ]) + ); + expect(startup.highlights).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "plan-mode-temp-memory" }) + ]) + ); + }); + it("truncates topic file references to the startup line budget", async () => { const projectDir = await tempDir("cam-store-trunc-"); const memoryRoot = await tempDir("cam-store-trunc-mem-"); @@ -364,6 +628,198 @@ describe("MemoryStore", () => { expect(topicLines.length).toBeGreaterThan(0); expect(topicLines.length).toBeLessThan(50); expect(startup.topicFiles).toHaveLength(topicLines.length); + expect(startup.omittedHighlightCount).toBeGreaterThan(0); + expect(startup.omittedTopicFileCount).toBe(50 - topicLines.length); + expect(startup.omissionCounts).toMatchObject({ + "budget-trimmed": expect.any(Number) + }); + expect(startup.topicFileOmissionCounts).toMatchObject({ + "budget-trimmed": 50 - topicLines.length + }); + expect(startup.omissions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + topic: expect.stringMatching(/^topic-/), + reason: "budget-trimmed", + target: "topic-file", + stage: "render" + }) + ]) + ); + expect(startup.topicRefCountsByScope.project).toEqual({ + discovered: 50, + rendered: topicLines.length, + omitted: 50 - topicLines.length + }); + }); + + it("omits unsafe topic files from startup topic refs and records reviewer omissions", async () => { + const projectDir = await tempDir("cam-store-unsafe-topic-refs-project-"); + const memoryRoot = await tempDir("cam-store-unsafe-topic-refs-memory-"); + const config: AppConfig = { + autoMemoryEnabled: true, + autoMemoryDirectory: memoryRoot, + extractorMode: "heuristic", + defaultScope: "project", + maxStartupLines: 200, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false, + sessionContinuityLocalPathStyle: "codex", + maxSessionContinuityLines: 60, + codexBinary: "codex" + }; + const store = new MemoryStore(detectProjectContext(projectDir), config); + await store.ensureLayout(); + + await store.remember( + "project", + "commands", + "unsafe-command", + "Run the unsafe command.", + ["Unsafe detail."], + "Manual note." + ); + await fs.writeFile( + store.getTopicFile("project", "commands"), + [ + "# Commands", + "", + "", + "", + "This file is maintained by Codex Auto Memory. You may edit summaries or details directly.", + "", + "Manual note that cannot be round-tripped safely.", + "", + "## unsafe-command", + '', + "Summary: Run the unsafe command.", + "Details:", + "- Unsafe detail.", + "" + ].join("\n"), + "utf8" + ); + + const startup = await compileStartupMemory(store, 200); + + expect(startup.topicFiles).toEqual([]); + expect(startup.omittedTopicFileCount).toBe(1); + expect(startup.topicFileOmissionCounts).toMatchObject({ + "unsafe-topic": 1 + }); + expect(startup.omissions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + topic: "commands", + reason: "unsafe-topic", + target: "topic-file", + stage: "selection" + }) + ]) + ); + expect(startup.topicRefCountsByScope.project).toEqual({ + discovered: 1, + rendered: 0, + omitted: 1 + }); + expect(startup.text).not.toContain(store.getTopicFile("project", "commands")); + expect(startup.text).not.toContain("[commands.md](commands.md)"); + }); + + it("preserves at least one startup highlight before low-signal empty scope blocks when the budget is tight", async () => { + const projectDir = await tempDir("cam-store-tight-startup-highlight-project-"); + const memoryRoot = await tempDir("cam-store-tight-startup-highlight-memory-"); + const config: AppConfig = { + autoMemoryEnabled: true, + autoMemoryDirectory: memoryRoot, + extractorMode: "heuristic", + defaultScope: "project", + maxStartupLines: 200, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false, + sessionContinuityLocalPathStyle: "codex", + maxSessionContinuityLines: 60, + codexBinary: "codex" + }; + const store = new MemoryStore(detectProjectContext(projectDir), config); + await store.ensureLayout(); + + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const startup = await compileStartupMemory(store, 28); + + expect(startup.lineCount).toBeLessThanOrEqual(28); + expect(startup.highlights).toEqual([ + expect.objectContaining({ + scope: "project", + topic: "workflow", + id: "prefer-pnpm" + }) + ]); + expect(startup.text).toContain("### Highlights"); + }); + + it("preserves at least one startup highlight before non-empty scope blocks exhaust the budget", async () => { + const projectDir = await tempDir("cam-store-tight-nonempty-startup-highlight-project-"); + const memoryRoot = await tempDir("cam-store-tight-nonempty-startup-highlight-memory-"); + const config: AppConfig = { + autoMemoryEnabled: true, + autoMemoryDirectory: memoryRoot, + extractorMode: "heuristic", + defaultScope: "project", + maxStartupLines: 200, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false, + sessionContinuityLocalPathStyle: "codex", + maxSessionContinuityLines: 60, + codexBinary: "codex" + }; + const store = new MemoryStore(detectProjectContext(projectDir), config); + await store.ensureLayout(); + + await store.remember( + "project-local", + "workflow", + "local-habit", + "Keep local workflow notes concise.", + ["Project-local startup blocks should still leave room for highlights."], + "Manual note." + ); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await store.remember( + "global", + "workflow", + "verify-before-claiming-complete", + "Verify before claiming work is complete.", + ["Do not claim completion without fresh verification output."], + "Manual note." + ); + + const startup = await compileStartupMemory(store, 32); + + expect(startup.lineCount).toBeLessThanOrEqual(32); + expect(startup.highlights.length).toBeGreaterThan(0); + expect(startup.text).toContain("### Highlights"); + expect( + startup.highlights.some((highlight) => + ["local-habit", "prefer-pnpm", "verify-before-claiming-complete"].includes(highlight.id) + ) + ).toBe(true); }); it("archives entries outside startup recall and exposes archived refs for retrieval", async () => { @@ -506,15 +962,145 @@ describe("MemoryStore", () => { latestUpdateKind: "restore", refNoopCount: 1 } - }); - expect(details?.timelineWarningCount).toBe(details?.warnings.length); + }); + expect(details?.timelineWarningCount).toBe(0); + expect(details?.warnings).toEqual( + expect.arrayContaining([ + expect.stringContaining("ref-local no-op attempt") + ]) + ); + }); + + it("does not duplicate missing sync audit warnings in details and keeps timelineWarningCount scoped to timeline warnings", async () => { + const projectDir = await tempDir("cam-store-missing-audit-warning-project-"); + const memoryRoot = await tempDir("cam-store-missing-audit-warning-memory-"); + const config: AppConfig = { + autoMemoryEnabled: true, + autoMemoryDirectory: memoryRoot, + extractorMode: "heuristic", + defaultScope: "project", + maxStartupLines: 200, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false, + sessionContinuityLocalPathStyle: "codex", + maxSessionContinuityLines: 60, + codexBinary: "codex" + }; + const store = new MemoryStore(detectProjectContext(projectDir), config); + await store.ensureLayout(); + + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await store.applyMutations( + [ + { + action: "upsert", + scope: "project", + topic: "workflow", + id: "prefer-pnpm", + summary: "Prefer pnpm in this repository.", + details: ["Use pnpm instead of npm in this repository."], + reason: "No-op update for warning coverage.", + sources: ["manual"] + } + ], + { + sessionId: "session-missing-audit", + rolloutPath: "/tmp/missing-audit-rollout.jsonl" + } + ); + + const details = await store.getEntryByRef("project:active:workflow:prefer-pnpm"); + + expect( + details?.warnings.filter((warning) => warning.includes("no matching sync audit entry was found")) + ).toHaveLength(1); + expect(details?.timelineWarningCount).toBe(1); expect(details?.warnings).toEqual( expect.arrayContaining([ - expect.stringContaining("ref-local no-op attempt") + expect.stringContaining("no matching sync audit entry was found") ]) ); }); + it("fails closed for details when the referenced topic file is unsafe to rewrite", async () => { + const projectDir = await tempDir("cam-store-unsafe-details-project-"); + const memoryRoot = await tempDir("cam-store-unsafe-details-memory-"); + const config: AppConfig = { + autoMemoryEnabled: true, + autoMemoryDirectory: memoryRoot, + extractorMode: "heuristic", + defaultScope: "project", + maxStartupLines: 200, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false, + sessionContinuityLocalPathStyle: "codex", + maxSessionContinuityLines: 60, + codexBinary: "codex" + }; + const store = new MemoryStore(detectProjectContext(projectDir), config); + await store.ensureLayout(); + + await fs.writeFile( + store.getTopicFile("project", "workflow"), + [ + "# Workflow", + "", + "", + "", + "This file is maintained by Codex Auto Memory. You may edit summaries or details directly.", + "", + "## prefer-pnpm", + '', + "Summary: Prefer pnpm in this repository.", + "Details:", + "- Use pnpm instead of npm in this repository.", + "", + "Manual notes outside managed entries" + ].join("\n"), + "utf8" + ); + + expect(await store.getEntryByRef("project:active:workflow:prefer-pnpm")).toBeNull(); + }); + + it("fails closed for details when an unsafe topic file still contains parseable managed entries", async () => { + const projectDir = await tempDir("cam-store-parseable-unsafe-details-project-"); + const memoryRoot = await tempDir("cam-store-parseable-unsafe-details-memory-"); + const config: AppConfig = { + autoMemoryEnabled: true, + autoMemoryDirectory: memoryRoot, + extractorMode: "heuristic", + defaultScope: "project", + maxStartupLines: 200, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false, + sessionContinuityLocalPathStyle: "codex", + maxSessionContinuityLines: 60, + codexBinary: "codex" + }; + const store = new MemoryStore(detectProjectContext(projectDir), config); + await store.ensureLayout(); + + await fs.writeFile( + store.getTopicFile("project", "workflow"), + buildParseableUnsafeTopicContents( + "prefer-pnpm", + "Prefer pnpm in this repository.", + "Use pnpm instead of npm in this repository." + ), + "utf8" + ); + + expect(await store.getEntryByRef("project:active:workflow:prefer-pnpm")).toBeNull(); + }); + it("distinguishes metadata-only updates from semantic overwrites in lifecycle reviewer surfaces", async () => { const projectDir = await tempDir("cam-store-update-kind-project-"); const memoryRoot = await tempDir("cam-store-update-kind-memory-"); @@ -1079,47 +1665,6 @@ describe("MemoryStore", () => { expect(await store.listEntries("project")).toHaveLength(1); }); - it("fails fast when an upsert mutation is missing its summary", async () => { - const projectDir = await tempDir("cam-store-missing-summary-project-"); - const memoryRoot = await tempDir("cam-store-missing-summary-memory-"); - const config: AppConfig = { - autoMemoryEnabled: true, - autoMemoryDirectory: memoryRoot, - extractorMode: "heuristic", - defaultScope: "project", - maxStartupLines: 200, - sessionContinuityAutoLoad: false, - sessionContinuityAutoSave: false, - sessionContinuityLocalPathStyle: "codex", - maxSessionContinuityLines: 60, - codexBinary: "codex" - }; - const store = new MemoryStore(detectProjectContext(projectDir), config); - await store.ensureLayout(); - - const snapshot = await snapshotFiles([ - store.getMemoryFile("project"), - store.getTopicFile("project", "workflow"), - store.getHistoryPath("project") - ]); - - await expect( - store.applyMutations([ - { - action: "upsert", - scope: "project", - topic: "workflow", - id: "missing-summary", - details: ["This upsert should fail before any writes."], - sources: ["manual"], - reason: "Broken mutation." - } - ]) - ).rejects.toThrow(/summary is required/i); - - expect(await snapshotFiles(Object.keys(snapshot))).toEqual(snapshot); - }); - it("fails closed when details contain non-bullet manual text inside an entry block", async () => { const projectDir = await tempDir("cam-store-unsafe-details-project-"); const memoryRoot = await tempDir("cam-store-unsafe-details-memory-"); @@ -1235,59 +1780,6 @@ describe("MemoryStore", () => { expect(await fs.readFile(topicFile, "utf8")).toBe(originalContents); }); - it("fails closed when details contain unsupported non-bullet content", async () => { - const projectDir = await tempDir("cam-store-unsafe-details-project-"); - const memoryRoot = await tempDir("cam-store-unsafe-details-memory-"); - const config: AppConfig = { - autoMemoryEnabled: true, - autoMemoryDirectory: memoryRoot, - extractorMode: "heuristic", - defaultScope: "project", - maxStartupLines: 200, - sessionContinuityAutoLoad: false, - sessionContinuityAutoSave: false, - sessionContinuityLocalPathStyle: "codex", - maxSessionContinuityLines: 60, - codexBinary: "codex" - }; - const store = new MemoryStore(detectProjectContext(projectDir), config); - await store.ensureLayout(); - - const topicFile = store.getTopicFile("project", "workflow"); - const originalContents = [ - "# Workflow", - "", - "", - "", - "This file is maintained by Codex Auto Memory. You may edit summaries or details directly.", - "", - "## keep-entry", - "", - "Summary: Keep this valid entry.", - "Details:", - "Plain text that cannot be round-tripped safely.", - "" - ].join("\n"); - await fs.writeFile(topicFile, originalContents, "utf8"); - - await expect( - store.applyMutations([ - { - action: "upsert", - scope: "project", - topic: "workflow", - id: "new-entry", - summary: "Do not rewrite mixed detail blocks.", - details: ["Unsafe detail block should stay untouched."], - sources: ["manual"], - reason: "Manual note." - } - ]) - ).rejects.toThrow(/Cannot rewrite topic file/); - - expect(await fs.readFile(topicFile, "utf8")).toBe(originalContents); - }); - it("fails closed when a topic file contains unsupported manual or malformed content during delete", async () => { const projectDir = await tempDir("cam-store-unsafe-delete-project-"); const memoryRoot = await tempDir("cam-store-unsafe-delete-memory-"); @@ -1345,83 +1837,6 @@ describe("MemoryStore", () => { expect(await fs.readFile(topicFile, "utf8")).toBe(originalContents); }); - it("fails closed when planned index or history files change after the commit plan is built", async () => { - const projectDir = await tempDir("cam-store-drift-project-"); - const memoryRoot = await tempDir("cam-store-drift-memory-"); - const config: AppConfig = { - autoMemoryEnabled: true, - autoMemoryDirectory: memoryRoot, - extractorMode: "heuristic", - defaultScope: "project", - maxStartupLines: 200, - sessionContinuityAutoLoad: false, - sessionContinuityAutoSave: false, - sessionContinuityLocalPathStyle: "codex", - maxSessionContinuityLines: 60, - codexBinary: "codex" - }; - const store = new MemoryStore(detectProjectContext(projectDir), config); - await store.ensureLayout(); - await store.remember( - "project", - "workflow", - "prefer-pnpm", - "Prefer pnpm in this repository.", - ["Use pnpm instead of npm in this repository."], - "Manual note." - ); - - const topicFile = store.getTopicFile("project", "workflow"); - const memoryFile = store.getMemoryFile("project"); - const historyFile = store.getHistoryPath("project"); - const originalBuildMutationCommitPlan = (store as unknown as { - buildMutationCommitPlan: (mutations: unknown[]) => Promise; - }).buildMutationCommitPlan.bind(store); - const driftedIndexContents = [ - "# Project Memories", - "", - "", - "", - "- workflow | Concurrent index edit should be preserved." - ].join("\n"); - const driftedHistoryContents = - '{"at":"2026-03-14T00:00:02.000Z","action":"update","scope":"project","state":"active","topic":"workflow","id":"prefer-pnpm","ref":"project:active:workflow:prefer-pnpm","summary":"Concurrent history edit should be preserved."}\n'; - - (store as unknown as { - buildMutationCommitPlan: (mutations: unknown[]) => Promise; - }).buildMutationCommitPlan = async (mutations) => { - const plan = await originalBuildMutationCommitPlan(mutations); - await fs.writeFile(memoryFile, driftedIndexContents, "utf8"); - await fs.writeFile(historyFile, driftedHistoryContents, "utf8"); - return plan; - }; - - const topicSnapshot = await fs.readFile(topicFile, "utf8"); - const memorySnapshot = await readFileIfExists(memoryFile); - const historySnapshot = await readFileIfExists(historyFile); - - await expect( - store.applyMutations([ - { - action: "upsert", - scope: "project", - topic: "workflow", - id: "prefer-pnpm", - summary: "Planned update should fail closed on drift.", - details: ["The commit phase must notice topic-file drift."], - sources: ["manual"], - reason: "Manual note." - } - ]) - ).rejects.toThrow(/changed since the mutation plan was built/i); - - expect(await fs.readFile(topicFile, "utf8")).toBe(topicSnapshot); - expect(await fs.readFile(memoryFile, "utf8")).toBe(driftedIndexContents); - expect(await fs.readFile(historyFile, "utf8")).toBe(driftedHistoryContents); - expect(await readFileIfExists(memoryFile)).not.toBe(memorySnapshot); - expect(await readFileIfExists(historyFile)).not.toBe(historySnapshot); - }); - it("treats source-only or reason-only changes as updates instead of noop", async () => { const projectDir = await tempDir("cam-store-metadata-update-project-"); const memoryRoot = await tempDir("cam-store-metadata-update-memory-"); @@ -1639,4 +2054,90 @@ describe("MemoryStore", () => { await fs.writeFile(store.getSyncRecoveryPath(), '{"recordedAt":123}', "utf8"); await expect(store.readSyncRecoveryRecord()).rejects.toThrow(/Invalid sync recovery record/); }); + + it("surfaces canonical layout diagnostics for malformed topics, orphan markdown, misplaced indexes, unexpected sidecars, and index drift", async () => { + const projectDir = await tempDir("cam-store-layout-diagnostics-project-"); + const memoryRoot = await tempDir("cam-store-layout-diagnostics-memory-"); + const config: AppConfig = { + autoMemoryEnabled: true, + autoMemoryDirectory: memoryRoot, + extractorMode: "heuristic", + defaultScope: "project", + maxStartupLines: 200, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false, + sessionContinuityLocalPathStyle: "codex", + maxSessionContinuityLines: 60, + codexBinary: "codex" + }; + const store = new MemoryStore(detectProjectContext(projectDir), config); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + await fs.writeFile( + path.join(path.dirname(store.getMemoryFile("project")), "Bad Topic.md"), + "# stray\n", + "utf8" + ); + await fs.writeFile( + path.join(path.dirname(store.getMemoryFile("project")), "orphan-topic.md"), + [ + "# Orphan Topic", + "", + "", + "", + "This file is maintained by Codex Auto Memory. You may edit summaries or details directly.", + "" + ].join("\n"), + "utf8" + ); + await fs.writeFile( + path.join(path.dirname(store.getMemoryFile("project")), "ARCHIVE.md"), + "# Archived Project Memory\n", + "utf8" + ); + await fs.writeFile( + path.join(path.dirname(store.getMemoryFile("project")), "retrieval-index.backup.json"), + "{}\n", + "utf8" + ); + await fs.writeFile(store.getMemoryFile("project"), "# Project Memory\n\nDrifted index.\n", "utf8"); + + const diagnostics = await store.inspectLayoutDiagnostics({ + scope: "project", + state: "active" + }); + + expect(diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "malformed-topic-filename", + fileName: "Bad Topic.md" + }), + expect.objectContaining({ + kind: "orphan-topic-markdown", + fileName: "orphan-topic.md" + }), + expect.objectContaining({ + kind: "misplaced-index-markdown", + fileName: "ARCHIVE.md" + }), + expect.objectContaining({ + kind: "unexpected-sidecar", + fileName: "retrieval-index.backup.json" + }), + expect.objectContaining({ + kind: "index-drift", + fileName: "MEMORY.md" + }) + ]) + ); + }); }); diff --git a/test/recall-command.test.ts b/test/recall-command.test.ts index 313b7d1..5ada4e8 100644 --- a/test/recall-command.test.ts +++ b/test/recall-command.test.ts @@ -2,10 +2,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { buildMemorySyncAuditEntry } from "../src/lib/domain/memory-sync-audit.js"; import { detectProjectContext } from "../src/lib/domain/project-context.js"; import { MemoryStore } from "../src/lib/domain/memory-store.js"; -import { restoreOptionalEnv } from "./helpers/env.js"; import { SyncService } from "../src/lib/domain/sync-service.js"; import { makeRolloutFixture, @@ -23,8 +21,54 @@ async function tempDir(prefix: string): Promise { return dir; } +function buildUnsafeWorkflowTopicContents(entryId: string, summary: string, detail: string): string { + return [ + "# Workflow Memory", + "", + "This file is managed by codex-auto-memory.", + "", + "Manual note outside managed entries.", + "", + "---", + `id: ${entryId}`, + "scope: project", + `summary: ${summary}`, + "updatedAt: 2026-03-14T00:00:00.000Z", + "sources:", + " - manual", + "", + detail, + "", + "```", + "malformed block", + "```", + "" + ].join("\n"); +} + +function buildParseableUnsafeWorkflowTopicContents( + entryId: string, + summary: string, + detail: string +): string { + return [ + "# Workflow", + "", + "", + "", + "Manual notes outside managed entries.", + "", + `## ${entryId}`, + ``, + `Summary: ${summary}`, + "Details:", + `- ${detail}`, + "" + ].join("\n"); +} + afterEach(async () => { - restoreOptionalEnv("HOME", originalHome); + process.env.HOME = originalHome; await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); @@ -57,8 +101,7 @@ describe("runRecall", () => { autoMemoryDirectory: memoryRoot }); - const project = detectProjectContext(projectDir); - const store = new MemoryStore(project, { + const store = new MemoryStore(detectProjectContext(projectDir), { ...projectConfig, autoMemoryDirectory: memoryRoot }); @@ -151,8 +194,7 @@ describe("runRecall", () => { autoMemoryDirectory: memoryRoot }); - const project = detectProjectContext(projectDir); - const store = new MemoryStore(project, { + const store = new MemoryStore(detectProjectContext(projectDir), { ...projectConfig, autoMemoryDirectory: memoryRoot }); @@ -224,6 +266,340 @@ describe("runRecall", () => { ]); }); + it("matches recall search terms even when the query includes trailing punctuation", async () => { + const homeDir = await tempDir("cam-recall-punctuated-query-home-"); + const projectDir = await tempDir("cam-recall-punctuated-query-project-"); + const memoryRoot = await tempDir("cam-recall-punctuated-query-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const result = runCli(projectDir, ["recall", "search", "pnpm,", "--state", "active", "--json"]); + expect(result.exitCode).toBe(0); + + const output = JSON.parse(result.stdout) as { + retrievalMode: string; + results: Array<{ ref: string; summary: string }>; + }; + expect(output).toMatchObject({ + retrievalMode: "index", + results: [ + expect.objectContaining({ + ref: "project:active:workflow:prefer-pnpm", + summary: "Prefer pnpm in this repository." + }) + ] + }); + }); + + it("matches recall search terms when natural separators split shared query terms", async () => { + const homeDir = await tempDir("cam-recall-separated-query-home-"); + const projectDir = await tempDir("cam-recall-separated-query-project-"); + const memoryRoot = await tempDir("cam-recall-separated-query-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const result = runCli(projectDir, [ + "recall", + "search", + "pnpm/npm", + "--state", + "active", + "--json" + ]); + expect(result.exitCode).toBe(0); + + const output = JSON.parse(result.stdout) as { + retrievalMode: string; + results: Array<{ ref: string; summary: string }>; + }; + expect(output).toMatchObject({ + retrievalMode: "index", + results: [ + expect.objectContaining({ + ref: "project:active:workflow:prefer-pnpm", + summary: "Prefer pnpm in this repository." + }) + ] + }); + }); + + it("matches recall search terms across topic and content fields with shared query semantics", async () => { + const homeDir = await tempDir("cam-recall-topic-query-home-"); + const projectDir = await tempDir("cam-recall-topic-query-project-"); + const memoryRoot = await tempDir("cam-recall-topic-query-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const result = runCli(projectDir, [ + "recall", + "search", + "workflow pnpm", + "--state", + "active", + "--json" + ]); + expect(result.exitCode).toBe(0); + + const output = JSON.parse(result.stdout) as { + results: Array<{ ref: string }>; + }; + expect(output.results).toEqual([ + expect.objectContaining({ + ref: "project:active:workflow:prefer-pnpm" + }) + ]); + }); + + it("surfaces unsafe topic diagnostics in recall search output", async () => { + const homeDir = await tempDir("cam-recall-unsafe-search-home-"); + const projectDir = await tempDir("cam-recall-unsafe-search-project-"); + const memoryRoot = await tempDir("cam-recall-unsafe-search-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + + const unsafeTopicPath = store.getTopicFile("project", "workflow"); + await fs.writeFile( + unsafeTopicPath, + buildUnsafeWorkflowTopicContents( + "prefer-pnpm", + "Prefer pnpm in this repository.", + "Use pnpm instead of npm in this repository." + ), + "utf8" + ); + await store.rebuildIndex("project"); + await fs.writeFile(store.getRetrievalIndexFile("project", "active"), "{bad-json", "utf8"); + + const result = runCli(projectDir, ["recall", "search", "pnpm", "--state", "active", "--json"]); + expect(result.exitCode).toBe(0); + + const output = JSON.parse(result.stdout) as { + diagnostics: RecallSearchDiagnostics & { + topicDiagnostics?: Array<{ + scope: string; + state: string; + topic: string; + safeToRewrite: boolean; + invalidEntryBlockCount: number; + manualContentDetected: boolean; + unsafeReason?: string; + }>; + }; + results: Array<{ ref: string }>; + }; + + expect(output.results).toEqual([]); + expect(output.diagnostics.topicDiagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + state: "active", + topic: "workflow", + safeToRewrite: false, + invalidEntryBlockCount: 0, + manualContentDetected: true, + unsafeReason: expect.stringContaining( + "unsupported manual content outside managed memory entries" + ) + }) + ]) + ); + }); + + it("fails closed for unsafe topics even when the retrieval index is healthy", async () => { + const homeDir = await tempDir("cam-recall-unsafe-index-home-"); + const projectDir = await tempDir("cam-recall-unsafe-index-project-"); + const memoryRoot = await tempDir("cam-recall-unsafe-index-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + + await fs.writeFile( + store.getTopicFile("project", "workflow"), + buildUnsafeWorkflowTopicContents( + "prefer-pnpm", + "Prefer pnpm in this repository.", + "Use pnpm instead of npm in this repository." + ), + "utf8" + ); + await store.rebuildRetrievalIndex("project", "active"); + + const result = runCli(projectDir, ["recall", "search", "pnpm", "--state", "active", "--json"]); + expect(result.exitCode).toBe(0); + + const output = JSON.parse(result.stdout) as { + retrievalMode: string; + results: Array<{ ref: string }>; + }; + + expect(output.retrievalMode).toBe("index"); + expect(output.results).toEqual([]); + }); + + it("fails closed for explicit details reads when the referenced topic is unsafe", async () => { + const homeDir = await tempDir("cam-recall-unsafe-details-home-"); + const projectDir = await tempDir("cam-recall-unsafe-details-project-"); + const memoryRoot = await tempDir("cam-recall-unsafe-details-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + + await fs.writeFile( + store.getTopicFile("project", "workflow"), + buildParseableUnsafeWorkflowTopicContents( + "prefer-pnpm", + "Prefer pnpm in this repository.", + "Use pnpm instead of npm in this repository." + ), + "utf8" + ); + + const result = runCli(projectDir, [ + "recall", + "details", + "project:active:workflow:prefer-pnpm" + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("No memory details were found"); + }); + + it("does not surface healthy topic files as unsafe diagnostics during recall search", async () => { + const homeDir = await tempDir("cam-recall-safe-search-home-"); + const projectDir = await tempDir("cam-recall-safe-search-project-"); + const memoryRoot = await tempDir("cam-recall-safe-search-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const jsonResult = runCli(projectDir, [ + "recall", + "search", + "pnpm", + "--state", + "active", + "--json" + ]); + expect(jsonResult.exitCode).toBe(0); + + const jsonOutput = JSON.parse(jsonResult.stdout) as { + diagnostics: RecallSearchDiagnostics & { + topicDiagnostics?: Array<{ + scope: string; + state: string; + topic: string; + safeToRewrite: boolean; + }>; + }; + }; + + expect(jsonOutput.diagnostics.topicDiagnostics ?? []).toEqual([]); + + const textResult = runCli(projectDir, ["recall", "search", "pnpm", "--state", "active"]); + expect(textResult.exitCode).toBe(0); + expect(textResult.stdout).not.toContain("Unsafe topic diagnostics:"); + }); + it("surfaces explicit-state contract for state=all searches and keeps checkedPaths ordered", async () => { const homeDir = await tempDir("cam-recall-state-all-home-"); const projectDir = await tempDir("cam-recall-state-all-project-"); @@ -269,8 +645,15 @@ describe("runRecall", () => { state: string; resolvedState: string; searchOrder: string[]; + totalMatchedCount: number; + returnedCount: number; globalLimitApplied: boolean; truncatedCount: number; + resultWindow: { + start: number; + end: number; + limit: number; + }; stateResolution: { outcome: string; searchedStates: string[]; @@ -282,7 +665,7 @@ describe("runRecall", () => { fallbackReasons: string[]; }; diagnostics: RecallSearchDiagnostics; - results: Array<{ ref: string; state: string }>; + results: Array<{ ref: string; state: string; globalRank?: number }>; }; expect(output).toMatchObject({ @@ -296,8 +679,15 @@ describe("runRecall", () => { "project-local:active", "project-local:archived" ], + totalMatchedCount: 2, + returnedCount: 1, globalLimitApplied: true, truncatedCount: 1, + resultWindow: { + start: 1, + end: 1, + limit: 1 + }, stateResolution: { outcome: "explicit-state", searchedStates: ["active", "archived"], @@ -325,6 +715,7 @@ describe("runRecall", () => { } ]); expect(output.results).toHaveLength(1); + expect(output.results[0]?.globalRank).toBe(1); const returnedState = output.results[0]?.state; expect(returnedState === "active" || returnedState === "archived").toBe(true); const activeCheck = projectChecks.find((check) => check.state === "active"); @@ -352,8 +743,7 @@ describe("runRecall", () => { autoMemoryDirectory: memoryRoot }); - const project = detectProjectContext(projectDir); - const store = new MemoryStore(project, { + const store = new MemoryStore(detectProjectContext(projectDir), { ...projectConfig, autoMemoryDirectory: memoryRoot }); @@ -435,8 +825,7 @@ describe("runRecall", () => { autoMemoryDirectory: memoryRoot }); - const project = detectProjectContext(projectDir); - const store = new MemoryStore(project, { + const store = new MemoryStore(detectProjectContext(projectDir), { ...projectConfig, autoMemoryDirectory: memoryRoot }); @@ -506,6 +895,98 @@ describe("runRecall", () => { }); }); + it("matches multi-term queries across summary and details fields", async () => { + const homeDir = await tempDir("cam-recall-cross-field-home-"); + const projectDir = await tempDir("cam-recall-cross-field-project-"); + const memoryRoot = await tempDir("cam-recall-cross-field-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm.", + ["Use it in this repository."], + "Manual note." + ); + + const searchResult = runCli(projectDir, [ + "recall", + "search", + "pnpm repository", + "--state", + "active", + "--json" + ]); + expect(searchResult.exitCode).toBe(0); + + const searchOutput = JSON.parse(searchResult.stdout) as { + results: Array<{ ref: string; matchedFields: string[] }>; + }; + expect(searchOutput.results).toEqual([ + expect.objectContaining({ + ref: "project:active:workflow:prefer-pnpm", + matchedFields: expect.arrayContaining(["summary", "details"]) + }) + ]); + }); + + it("does not return deleted active entries from the retrieval sidecar after forget", async () => { + const homeDir = await tempDir("cam-recall-delete-sidecar-home-"); + const projectDir = await tempDir("cam-recall-delete-sidecar-project-"); + const memoryRoot = await tempDir("cam-recall-delete-sidecar-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project-local", + "workflow", + "test-memory-for-contract-inspection", + "test memory for contract inspection", + ["test memory for contract inspection"], + "Manual note." + ); + await store.forget("project-local", "test memory for contract inspection"); + + const searchResult = runCli(projectDir, [ + "recall", + "search", + "test memory for contract inspection", + "--state", + "active", + "--json" + ]); + expect(searchResult.exitCode).toBe(0); + + const searchOutput = JSON.parse(searchResult.stdout) as { + totalMatchedCount: number; + returnedCount: number; + results: Array<{ ref: string }>; + }; + expect(searchOutput.totalMatchedCount).toBe(0); + expect(searchOutput.returnedCount).toBe(0); + expect(searchOutput.results).toEqual([]); + }); + it("supports --cwd so recall can target another project directory from the current shell", async () => { const homeDir = await tempDir("cam-recall-cwd-home-"); const projectParentDir = await tempDir("cam-recall-cwd-parent-"); @@ -614,6 +1095,16 @@ describe("runRecall", () => { expect(JSON.parse(timelineResult.stdout)).toMatchObject({ ref: searchOutput.results[0]!.ref, warnings: [], + latestAudit: { + auditPath: store.getSyncAuditPath(), + rolloutPath, + sessionId: "session-provenance", + status: "applied", + resultSummary: expect.stringContaining("operation(s) applied"), + noopOperationCount: 0, + suppressedOperationCount: 0, + rejectedOperations: [] + }, lineageSummary: expect.objectContaining({ eventCount: 1, latestAction: "add", @@ -635,6 +1126,7 @@ describe("runRecall", () => { expect(timelineTextResult.exitCode).toBe(0); expect(timelineTextResult.stdout).toContain("Session: session-provenance"); expect(timelineTextResult.stdout).toContain(`Rollout: ${rolloutPath}`); + expect(timelineTextResult.stdout).toContain(`Latest audit path: ${store.getSyncAuditPath()}`); const detailsResult = runCli( projectDir, @@ -665,7 +1157,119 @@ describe("runRecall", () => { status: "applied", resultSummary: expect.stringContaining("operation(s) applied"), noopOperationCount: 0, - suppressedOperationCount: 0 + suppressedOperationCount: 0, + rejectedOperations: [] + } + }); + }); + + it("labels rejected reviewer counts as rollout-level in timeline and details text output", async () => { + const homeDir = await tempDir("cam-recall-rejected-rollout-home-"); + const projectDir = await tempDir("cam-recall-rejected-rollout-project-"); + const memoryRoot = await tempDir("cam-recall-rejected-rollout-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const project = detectProjectContext(projectDir); + const store = new MemoryStore(project, { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + const rolloutPath = path.join(projectDir, "rejected-rollout.jsonl"); + await store.ensureLayout(); + await store.applyMutations([ + { + action: "upsert", + scope: "project", + topic: "workflow", + id: "prefer-pnpm", + summary: "Prefer pnpm in this repository.", + details: ["Use pnpm instead of npm in this repository."] + } + ], { + sessionId: "session-rejected-rollout", + rolloutPath + }); + await store.appendSyncAuditEntry({ + appliedAt: "2026-03-18T00:00:00.000Z", + projectId: project.projectId, + worktreeId: project.worktreeId, + rolloutPath, + sessionId: "session-rejected-rollout", + configuredExtractorMode: "heuristic", + configuredExtractorName: "heuristic", + actualExtractorMode: "heuristic", + actualExtractorName: "heuristic", + extractorMode: "heuristic", + extractorName: "heuristic", + sessionSource: "rollout-jsonl", + status: "applied", + appliedCount: 1, + rejectedOperationCount: 1, + rejectedReasonCounts: { + "unknown-topic": 1 + }, + rejectedOperations: [ + { + action: "upsert", + scope: "project", + topic: "workflow", + id: "dropped-topic", + reason: "unknown-topic" + } + ], + scopesTouched: ["project"], + resultSummary: "1 operation(s) applied, 1 rejected", + operations: [ + { + action: "upsert", + scope: "project", + topic: "workflow", + id: "prefer-pnpm", + summary: "Prefer pnpm in this repository.", + details: ["Use pnpm instead of npm in this repository."], + reason: "Manual note.", + sources: ["manual"] + } + ] + }); + + const auditEntries = await store.readRecentSyncAuditEntries(1); + expect(auditEntries[0]).toMatchObject({ + rolloutPath, + rejectedOperationCount: 1 + }); + + const ref = "project:active:workflow:prefer-pnpm"; + const timelineTextResult = runCli(projectDir, ["recall", "timeline", ref]); + expect(timelineTextResult.exitCode).toBe(0); + expect(timelineTextResult.stdout).toContain("Rollout rejected count: 1"); + expect(timelineTextResult.stdout).toContain("Latest audit rejected operations: [unknown-topic] project/workflow/dropped-topic"); + + const detailsTextResult = runCli(projectDir, ["recall", "details", ref]); + expect(detailsTextResult.exitCode).toBe(0); + expect(detailsTextResult.stdout).toContain("Latest audit rollout rejected operations: 1"); + expect(detailsTextResult.stdout).toContain("Latest audit rejected operations: [unknown-topic] project/workflow/dropped-topic"); + expect(detailsTextResult.stdout).toContain("Rollout rejected count: 1"); + + const detailsJsonResult = runCli(projectDir, ["recall", "details", ref, "--json"]); + expect(detailsJsonResult.exitCode).toBe(0); + expect(JSON.parse(detailsJsonResult.stdout)).toMatchObject({ + latestAudit: { + rejectedOperationCount: 1, + rejectedOperations: [ + { + action: "upsert", + scope: "project", + topic: "workflow", + id: "dropped-topic", + reason: "unknown-topic" + } + ] } }); }); @@ -791,93 +1395,6 @@ describe("runRecall", () => { }); }); - it("backfills latestAudit from a matching session-only sync audit entry", async () => { - const homeDir = await tempDir("cam-recall-session-only-audit-home-"); - const projectDir = await tempDir("cam-recall-session-only-audit-project-"); - const memoryRoot = await tempDir("cam-recall-session-only-audit-memory-"); - process.env.HOME = homeDir; - - const projectConfig = makeAppConfig(); - await writeCamConfig(projectDir, projectConfig, { - autoMemoryDirectory: memoryRoot - }); - - const project = detectProjectContext(projectDir); - const store = new MemoryStore(project, { - ...projectConfig, - autoMemoryDirectory: memoryRoot - }); - await store.ensureLayout(); - await store.applyMutations( - [ - { - action: "upsert", - scope: "project", - topic: "workflow", - id: "prefer-pnpm", - summary: "Prefer pnpm in this repository.", - details: ["Use pnpm instead of npm in this repository."], - reason: "Manual note.", - sources: ["manual"] - } - ], - { - sessionId: "session-only-audit" - } - ); - await store.appendSyncAuditEntry(buildMemorySyncAuditEntry({ - project, - config: { - ...projectConfig, - autoMemoryDirectory: memoryRoot - }, - appliedAt: "2026-03-14T00:00:05.000Z", - rolloutPath: "rollout-without-match.jsonl", - sessionId: "session-only-audit", - configuredExtractorName: "heuristic", - actualExtractorMode: "heuristic", - actualExtractorName: "heuristic", - sessionSource: "manual", - status: "applied", - operations: [ - { - action: "upsert", - scope: "project", - topic: "workflow", - id: "prefer-pnpm", - summary: "Prefer pnpm in this repository.", - details: ["Use pnpm instead of npm in this repository."] - } - ], - noopOperationCount: 0, - suppressedOperationCount: 0, - conflicts: [] - })); - - const detailsResult = runCli(projectDir, [ - "recall", - "details", - "project:active:workflow:prefer-pnpm", - "--json" - ]); - expect(detailsResult.exitCode).toBe(0); - expect(JSON.parse(detailsResult.stdout)).toMatchObject({ - latestLifecycleAction: "add", - latestState: "active", - latestSessionId: "session-only-audit", - latestRolloutPath: null, - latestAudit: { - auditPath: store.getSyncAuditPath(), - sessionId: "session-only-audit", - rolloutPath: "rollout-without-match.jsonl", - status: "applied", - resultSummary: "1 operation(s) applied", - noopOperationCount: 0, - suppressedOperationCount: 0 - } - }); - }); - it("keeps details provenance aligned with the latest noop attempt from sync history", async () => { const homeDir = await tempDir("cam-recall-noop-provenance-home-"); const projectDir = await tempDir("cam-recall-noop-provenance-project-"); @@ -963,6 +1480,105 @@ describe("runRecall", () => { }); }); + it("does not attach an unrelated noop audit to the current ref when only provenance matches", async () => { + const homeDir = await tempDir("cam-recall-noop-audit-mismatch-home-"); + const projectDir = await tempDir("cam-recall-noop-audit-mismatch-project-"); + const memoryRoot = await tempDir("cam-recall-noop-audit-mismatch-memory-"); + const firstRolloutPath = path.join(projectDir, "rollout-primary.jsonl"); + const secondRolloutPath = path.join(projectDir, "rollout-secondary.jsonl"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const project = detectProjectContext(projectDir); + const service = new SyncService(project, { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + + await fs.writeFile( + firstRolloutPath, + makeRolloutFixture(projectDir, "Remember that this repository prefers pnpm.", { + sessionId: "session-primary" + }), + "utf8" + ); + await service.syncRollout(firstRolloutPath, true); + + await service.memoryStore.remember( + "project", + "workflow", + "prefer-rg", + "Prefer rg for repo search.", + ["Use rg instead of grep when searching this repository."], + "Manual note." + ); + + await fs.writeFile( + secondRolloutPath, + makeRolloutFixture(projectDir, "Remember that prefer rg for repo search.", { + sessionId: "session-secondary" + }), + "utf8" + ); + await service.syncRollout(secondRolloutPath, true); + + const existingEntry = (await service.memoryStore.listEntries("project")).find( + (entry) => entry.topic === "preferences" && entry.id === "this-repository-prefers-pnpm" + ); + expect(existingEntry).toBeTruthy(); + + await service.memoryStore.applyMutations( + [ + { + action: "upsert", + scope: "project", + topic: "preferences", + id: "this-repository-prefers-pnpm", + summary: existingEntry!.summary, + details: existingEntry!.details, + sources: existingEntry!.sources, + reason: existingEntry!.reason + } + ], + { + sessionId: "session-secondary", + rolloutPath: secondRolloutPath + } + ); + + const searchResult = runCli(projectDir, ["recall", "search", "prefers pnpm", "--json"]); + expect(searchResult.exitCode).toBe(0); + const searchOutput = JSON.parse(searchResult.stdout) as { + results: Array<{ ref: string }>; + }; + const ref = searchOutput.results[0]?.ref; + expect(ref).toBeTruthy(); + + const detailsResult = runCli(projectDir, ["recall", "details", ref!, "--json"]); + expect(detailsResult.exitCode).toBe(0); + expect(JSON.parse(detailsResult.stdout)).toMatchObject({ + ref, + latestLifecycleAttempt: { + action: "noop", + outcome: "noop", + sessionId: "session-secondary", + rolloutPath: secondRolloutPath + }, + latestAudit: null, + warnings: expect.arrayContaining([ + expect.stringContaining("no matching sync audit entry was found") + ]), + lineageSummary: expect.objectContaining({ + latestAuditStatus: null, + matchedAuditOperationCount: 0 + }) + }); + }); + it("keeps recall search read-only and does not create memory layout on first lookup", async () => { const homeDir = await tempDir("cam-recall-readonly-home-"); const projectDir = await tempDir("cam-recall-readonly-project-"); @@ -1146,6 +1762,7 @@ describe("runRecall", () => { const result = runCli(projectDir, ["recall", "search", "prefer pnpm", "--state", "active", "--json"]); expect(result.exitCode).toBe(0); expect(JSON.parse(result.stdout)).toMatchObject({ + finalRetrievalMode: "markdown-fallback", retrievalMode: "markdown-fallback", retrievalFallbackReason: "stale", executionSummary: { @@ -1210,6 +1827,7 @@ describe("runRecall", () => { searchedStates: ["active", "archived"], resolutionReason: "active-empty-archived-match-found" }, + finalRetrievalMode: "index", retrievalMode: "index", markdownFallbackUsed: true, executionSummary: { From 477ad23d5e16b899bf9e974359154b7cd0e17182 Mon Sep 17 00:00:00 2001 From: blocks Date: Wed, 8 Apr 2026 23:39:34 +0800 Subject: [PATCH 28/62] fix: restore rejected audit reviewer contract --- src/lib/domain/memory-sync-audit.ts | 141 ++++++++++++++++++++++++++-- test/memory-command.test.ts | 3 +- test/memory-sync-audit.test.ts | 63 ++++++++++++- 3 files changed, 196 insertions(+), 11 deletions(-) diff --git a/src/lib/domain/memory-sync-audit.ts b/src/lib/domain/memory-sync-audit.ts index 6961b4b..4d9ccf4 100644 --- a/src/lib/domain/memory-sync-audit.ts +++ b/src/lib/domain/memory-sync-audit.ts @@ -2,11 +2,13 @@ import type { AppConfig, MemoryConflictCandidate, MemoryOperation, + MemoryOperationRejectionReason, MemoryScope, MemorySyncAuditEntry, MemorySyncAuditSkipReason, MemorySyncAuditStatus, - ProjectContext + ProjectContext, + RejectedMemoryOperationSummary } from "../types.js"; function isMemoryScope(value: unknown): value is MemoryScope { @@ -49,7 +51,9 @@ function isMemoryOperation(value: unknown): value is MemoryOperation { const operation = value as Record; return ( - (operation.action === "upsert" || operation.action === "delete") && + (operation.action === "upsert" || + operation.action === "delete" || + operation.action === "archive") && isMemoryScope(operation.scope) && typeof operation.topic === "string" && typeof operation.id === "string" && @@ -60,6 +64,57 @@ function isMemoryOperation(value: unknown): value is MemoryOperation { ); } +function isMemoryOperationRejectionReason( + value: unknown +): value is MemoryOperationRejectionReason { + return ( + value === "unknown-topic" || + value === "sensitive" || + value === "volatile" || + value === "empty-summary" || + value === "operation-cap" + ); +} + +function isLegacyMemoryOperationRejectionReason(value: unknown): value is "detail-truncated" { + return value === "detail-truncated"; +} + +function isRejectedReasonCounts( + value: unknown +): value is Partial> { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + return Object.entries(value).every( + ([key, count]) => + (isMemoryOperationRejectionReason(key) || isLegacyMemoryOperationRejectionReason(key)) && + typeof count === "number" && + count >= 0 + ); +} + +function isRejectedMemoryOperationSummary( + value: unknown +): value is RejectedMemoryOperationSummary { + if (!value || typeof value !== "object") { + return false; + } + + const summary = value as Record; + return ( + (summary.action === "upsert" || + summary.action === "delete" || + summary.action === "archive") && + isMemoryScope(summary.scope) && + typeof summary.topic === "string" && + typeof summary.id === "string" && + (isMemoryOperationRejectionReason(summary.reason) || + isLegacyMemoryOperationRejectionReason(summary.reason)) + ); +} + function isMemoryConflictCandidate(value: unknown): value is MemoryConflictCandidate { if (!value || typeof value !== "object") { return false; @@ -80,17 +135,22 @@ function summaryForStatus( status: MemorySyncAuditStatus, appliedCount: number, noopOperationCount: number, + rejectedOperationCount: number, skipReason?: MemorySyncAuditSkipReason ): string { switch (status) { case "applied": - return noopOperationCount > 0 - ? `${appliedCount} operation(s) applied, ${noopOperationCount} no-op` - : `${appliedCount} operation(s) applied`; + return [ + `${appliedCount} operation(s) applied`, + ...(noopOperationCount > 0 ? [`${noopOperationCount} no-op`] : []), + ...(rejectedOperationCount > 0 ? [`${rejectedOperationCount} rejected`] : []) + ].join(", "); case "no-op": - return noopOperationCount > 0 - ? `0 operations applied, ${noopOperationCount} no-op` - : "0 operations applied"; + return [ + "0 operations applied", + ...(noopOperationCount > 0 ? [`${noopOperationCount} no-op`] : []), + ...(rejectedOperationCount > 0 ? [`${rejectedOperationCount} rejected`] : []) + ].join(", "); case "skipped": if (skipReason === "already-processed") { return "Skipped rollout; it was already processed"; @@ -102,6 +162,24 @@ function summaryForStatus( } } +function formatRejectedReasonCounts( + rejectedReasonCounts: Partial> | undefined +): string | null { + if (!rejectedReasonCounts) { + return null; + } + + const entries = Object.entries(rejectedReasonCounts).filter(([, count]) => count > 0); + if (entries.length === 0) { + return null; + } + + return entries + .sort(([leftReason], [rightReason]) => leftReason.localeCompare(rightReason)) + .map(([reason, count]) => `${reason}=${count}`) + .join(", "); +} + export function parseMemorySyncAuditEntry(value: unknown): MemorySyncAuditEntry | null { if (!value || typeof value !== "object") { return null; @@ -121,6 +199,21 @@ export function parseMemorySyncAuditEntry(value: unknown): MemorySyncAuditEntry typeof entry.noopOperationCount === "number" ? entry.noopOperationCount : 0; const suppressedOperationCount = typeof entry.suppressedOperationCount === "number" ? entry.suppressedOperationCount : 0; + const rejectedOperationCount = + typeof entry.rejectedOperationCount === "number" ? entry.rejectedOperationCount : 0; + const rejectedReasonCounts = isRejectedReasonCounts(entry.rejectedReasonCounts) + ? Object.fromEntries( + Object.entries(entry.rejectedReasonCounts).filter(([reason]) => + isMemoryOperationRejectionReason(reason) + ) + ) + : undefined; + const rejectedOperations = Array.isArray(entry.rejectedOperations) + ? entry.rejectedOperations.filter((operation): operation is RejectedMemoryOperationSummary => + isRejectedMemoryOperationSummary(operation) && + isMemoryOperationRejectionReason(operation.reason) + ) + : undefined; if ( typeof entry.appliedAt !== "string" || @@ -138,6 +231,11 @@ export function parseMemorySyncAuditEntry(value: unknown): MemorySyncAuditEntry typeof entry.appliedCount !== "number" || noopOperationCount < 0 || suppressedOperationCount < 0 || + rejectedOperationCount < 0 || + (entry.rejectedReasonCounts !== undefined && !isRejectedReasonCounts(entry.rejectedReasonCounts)) || + (entry.rejectedOperations !== undefined && + (!Array.isArray(entry.rejectedOperations) || + !entry.rejectedOperations.every((operation) => isRejectedMemoryOperationSummary(operation)))) || !Array.isArray(entry.scopesTouched) || !entry.scopesTouched.every((scope) => isMemoryScope(scope)) || typeof entry.resultSummary !== "string" || @@ -166,6 +264,9 @@ export function parseMemorySyncAuditEntry(value: unknown): MemorySyncAuditEntry appliedCount: entry.appliedCount, noopOperationCount, suppressedOperationCount, + rejectedOperationCount, + ...(rejectedReasonCounts ? { rejectedReasonCounts } : {}), + ...(rejectedOperations && rejectedOperations.length > 0 ? { rejectedOperations } : {}), scopesTouched: entry.scopesTouched, resultSummary: entry.resultSummary, conflicts, @@ -192,6 +293,9 @@ interface BuildMemorySyncAuditEntryOptions { isRecovery?: boolean; noopOperationCount?: number; suppressedOperationCount?: number; + rejectedOperationCount?: number; + rejectedReasonCounts?: Partial>; + rejectedOperations?: RejectedMemoryOperationSummary[]; conflicts?: MemoryConflictCandidate[]; operations?: MemoryOperation[]; } @@ -204,6 +308,7 @@ export function buildMemorySyncAuditEntry( const scopesTouched = Array.from(new Set(operations.map((operation) => operation.scope))); const appliedCount = operations.length; const noopOperationCount = options.noopOperationCount ?? 0; + const rejectedOperationCount = options.rejectedOperationCount ?? 0; return { appliedAt: options.appliedAt ?? new Date().toISOString(), @@ -224,11 +329,15 @@ export function buildMemorySyncAuditEntry( appliedCount, noopOperationCount, suppressedOperationCount: options.suppressedOperationCount ?? 0, + rejectedOperationCount, + ...(options.rejectedReasonCounts ? { rejectedReasonCounts: options.rejectedReasonCounts } : {}), + ...(options.rejectedOperations?.length ? { rejectedOperations: options.rejectedOperations } : {}), scopesTouched, resultSummary: summaryForStatus( options.status, appliedCount, noopOperationCount, + rejectedOperationCount, options.skipReason ), conflicts, @@ -240,7 +349,7 @@ export function formatMemorySyncAuditEntry(entry: MemorySyncAuditEntry): string[ const lines = [ `- ${entry.appliedAt}: [${entry.status}]${entry.isRecovery ? ' [recovery]' : ''} ${entry.resultSummary}`, ` Session: ${entry.sessionId ?? "unknown"} | Extractor: ${entry.actualExtractorName || entry.actualExtractorMode}`, - ` Applied: ${entry.appliedCount} | No-op: ${entry.noopOperationCount ?? 0} | Suppressed: ${entry.suppressedOperationCount ?? 0} | Scopes: ${entry.scopesTouched.length ? entry.scopesTouched.join(", ") : "none"}` + ` Applied: ${entry.appliedCount} | No-op: ${entry.noopOperationCount ?? 0} | Suppressed: ${entry.suppressedOperationCount ?? 0} | Rejected: ${entry.rejectedOperationCount ?? 0} | Scopes: ${entry.scopesTouched.length ? entry.scopesTouched.join(", ") : "none"}` ]; if ( @@ -258,6 +367,20 @@ export function formatMemorySyncAuditEntry(entry: MemorySyncAuditEntry): string[ lines.push(` Rollout: ${entry.rolloutPath}`); + const rejectedReasons = formatRejectedReasonCounts(entry.rejectedReasonCounts); + if (rejectedReasons) { + lines.push(` Rejected reasons: ${rejectedReasons}`); + } + if ((entry.rejectedOperations?.length ?? 0) > 0) { + lines.push(" Rejected operations:"); + lines.push( + ...entry.rejectedOperations!.map( + (operation) => + ` - [${operation.reason}] ${operation.scope}/${operation.topic}/${operation.id}` + ) + ); + } + if (entry.conflicts?.length) { lines.push(" Conflict review:"); for (const conflict of entry.conflicts) { diff --git a/test/memory-command.test.ts b/test/memory-command.test.ts index 1730039..7f1c167 100644 --- a/test/memory-command.test.ts +++ b/test/memory-command.test.ts @@ -1722,7 +1722,8 @@ describe("runMemory", () => { expect(payload.nextRecommendedActions).toEqual( expect.arrayContaining([ - expect.stringContaining(`--cwd ${JSON.stringify(realProjectDir)}`), + expect.stringContaining("--cwd"), + expect.stringContaining(realProjectDir), expect.stringContaining("recall timeline"), expect.stringContaining("recall details"), expect.stringContaining("memory --recent"), diff --git a/test/memory-sync-audit.test.ts b/test/memory-sync-audit.test.ts index 7cf2f8c..4bbeeed 100644 --- a/test/memory-sync-audit.test.ts +++ b/test/memory-sync-audit.test.ts @@ -107,7 +107,9 @@ describe("memory-sync-audit", () => { expect(lines[0]).toContain("[skipped] [recovery]"); expect(lines[1]).toContain("Session: unknown"); - expect(lines[2]).toContain("Applied: 0 | No-op: 0 | Suppressed: 0 | Scopes: none"); + expect(lines[2]).toContain( + "Applied: 0 | No-op: 0 | Suppressed: 0 | Rejected: 0 | Scopes: none" + ); expect(lines).toContain( " Configured: codex-ephemeral (codex) -> Actual: heuristic (heuristic)" ); @@ -140,4 +142,63 @@ describe("memory-sync-audit", () => { expect(lines.some((line) => line.includes("Configured:"))).toBe(false); expect(lines.some((line) => line.includes("Skip reason:"))).toBe(false); }); + + it("round-trips rejected reviewer fields and prints them in text output", () => { + const parsed = parseMemorySyncAuditEntry({ + appliedAt: "2026-03-18T00:00:00.000Z", + projectId: "project-1", + worktreeId: "worktree-1", + rolloutPath: "/tmp/rollout.jsonl", + sessionId: "session-1", + configuredExtractorMode: "heuristic", + configuredExtractorName: "heuristic", + actualExtractorMode: "heuristic", + actualExtractorName: "heuristic", + extractorMode: "heuristic", + extractorName: "heuristic", + sessionSource: "rollout-jsonl", + status: "no-op", + appliedCount: 0, + noopOperationCount: 0, + suppressedOperationCount: 0, + rejectedOperationCount: 1, + rejectedReasonCounts: { + "unknown-topic": 1 + }, + rejectedOperations: [ + { + action: "archive", + scope: "project", + topic: "workflow", + id: "dropped-topic", + reason: "unknown-topic" + } + ], + scopesTouched: [], + resultSummary: "0 operations applied, 1 rejected", + operations: [] + }); + + expect(parsed).toMatchObject({ + rejectedOperationCount: 1, + rejectedReasonCounts: { + "unknown-topic": 1 + }, + rejectedOperations: [ + { + action: "archive", + scope: "project", + topic: "workflow", + id: "dropped-topic", + reason: "unknown-topic" + } + ] + }); + + const lines = formatMemorySyncAuditEntry(parsed!); + expect(lines[2]).toContain("Rejected: 1"); + expect(lines).toContain(" Rejected reasons: unknown-topic=1"); + expect(lines).toContain(" Rejected operations:"); + expect(lines).toContain(" - [unknown-topic] project/workflow/dropped-topic"); + }); }); From 1ca49a0f19c46026afcff375c06c4ea1fc789693 Mon Sep 17 00:00:00 2001 From: blocks Date: Thu, 9 Apr 2026 00:16:45 +0800 Subject: [PATCH 29/62] fix: restore PR6 recovery validation gates --- src/lib/domain/recovery-records.ts | 11 +++++++++++ test/extractor.test.ts | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/lib/domain/recovery-records.ts b/src/lib/domain/recovery-records.ts index b60d964..ee1412f 100644 --- a/src/lib/domain/recovery-records.ts +++ b/src/lib/domain/recovery-records.ts @@ -161,6 +161,14 @@ function isContinuityRecoveryFailedStage( return value === "summary-write" || value === "audit-write"; } +function isOptionalNonNegativeNumberField( + record: Record, + key: "noopOperationCount" | "suppressedOperationCount" | "rejectedOperationCount" +): boolean { + const value = record[key]; + return value === undefined || (typeof value === "number" && value >= 0); +} + export function isSyncRecoveryRecord(value: unknown): value is SyncRecoveryRecord { if (!value || typeof value !== "object") { return false; @@ -196,6 +204,9 @@ export function isSyncRecoveryRecord(value: unknown): value is SyncRecoveryRecor typeof record.actualExtractorName === "string" && (record.status === "applied" || record.status === "no-op") && typeof record.appliedCount === "number" && + isOptionalNonNegativeNumberField(record, "noopOperationCount") && + isOptionalNonNegativeNumberField(record, "suppressedOperationCount") && + isOptionalNonNegativeNumberField(record, "rejectedOperationCount") && noopOperationCount >= 0 && suppressedOperationCount >= 0 && rejectedOperationCount >= 0 && diff --git a/test/extractor.test.ts b/test/extractor.test.ts index 78c8f54..f064263 100644 --- a/test/extractor.test.ts +++ b/test/extractor.test.ts @@ -726,7 +726,7 @@ describe("safety filter - volatile/sensitive patterns", () => { expect(filtered).toHaveLength(1); }); - it("keeps volatile wording inside debugging topics", () => { + it("rejects volatile wording even inside debugging topics", () => { const filtered = filterMemoryOperations([ { action: "upsert", @@ -737,7 +737,7 @@ describe("safety filter - volatile/sensitive patterns", () => { details: ["Temporary but still useful while the issue is open."] } ]); - expect(filtered).toHaveLength(1); + expect(filtered).toHaveLength(0); }); it("caps sanitized operations at 12 items", () => { From 9eaba856aa8ae3628ba1c705a5078fe34ff77db6 Mon Sep 17 00:00:00 2001 From: blocks Date: Sat, 4 Apr 2026 21:47:42 +0800 Subject: [PATCH 30/62] feat: tighten extraction trust signals (cherry picked from commit 072aa692124e1513edc97af4a4809975521aa91b) --- src/lib/extractor/heuristic-extractor.ts | 283 +++++++--- test/extractor.test.ts | 668 ++++++++++++++++++++++- 2 files changed, 871 insertions(+), 80 deletions(-) diff --git a/src/lib/extractor/heuristic-extractor.ts b/src/lib/extractor/heuristic-extractor.ts index 21c4475..0ea512c 100644 --- a/src/lib/extractor/heuristic-extractor.ts +++ b/src/lib/extractor/heuristic-extractor.ts @@ -6,11 +6,35 @@ import { commandSucceeded, extractCommand, isCommandToolCall } from "./command-u interface ExplicitCorrection { scope: MemoryOperation["scope"]; - topic: "preferences" | "workflow"; + topic: "preferences" | "workflow" | "commands"; summary: string; staleText: string; } +const assistantStablePrefixes = [ + /^confirmed[:\s]+/iu, + /^result[:\s]+/iu, + /^stable note[:\s]+/iu, + /^decision[:\s]+/iu, + /^verified[:\s]+/iu, + /^结论[::\s]+/u, + /^已确认[::\s]+/u +] as const; + +const assistantNoisePatterns = [ + /\breviewer\b/iu, + /\bsubagent\b/iu, + /\bnext step\b/iu, + /\bresume here\b/iu, + /\bcurrent worktree\b/iu, + /\bcurrent branch\b/iu, + /\bI will\b/iu, + /\bI'll\b/iu, + /\bI am going to\b/iu, + /我会/u, + /下一步/u +] as const; + function inferScope(message: string): MemoryOperation["scope"] { if (/(all projects|across projects|globally|every repo|所有项目|全局)/iu.test(message)) { return "global"; @@ -24,19 +48,38 @@ function inferScope(message: string): MemoryOperation["scope"] { } function inferTopic(message: string): string { - if (/(pnpm|npm|bun|yarn|format|style|indent|naming|comment|typescript|always use)/iu.test(message)) { - return "preferences"; + if ( + /`[^`]*(?:pnpm|npm|bun|yarn|cargo|pytest|jest|vitest|go test|python(?:3)? -m|make)[^`]*`/iu.test( + message + ) || + /\b(?:command|run\s+(?:pnpm|npm|bun|yarn|cargo|pytest|jest|vitest|go test|python(?:3)? -m|make)|(?:pnpm|npm|bun|yarn|cargo)\s+(?:test|lint|build|install|check)|pytest|jest|vitest|go test|dotnet test|rake|tsc|vite build|next build|gradle|mvn|make)\b/iu.test( + message + ) + ) { + return "commands"; } - if (/(command|build|test|lint|install|pnpm |npm |bun |pytest|jest|vitest|cargo|go test|python -m)/iu.test(message)) { - return "commands"; + if ( + /(https?:\/\/|grafana|linear|jira|slack|notion|confluence|runbook|playbook|wiki|dashboard|docs?\b|tracked in|board\b|channel\b)/iu.test( + message + ) + ) { + return "reference"; + } + + if (/(pnpm|npm|bun|yarn|format|style|indent|naming|comment|typescript|always use)/iu.test(message)) { + return "preferences"; } if (/(debug|error|fix|fails|failing|redis|database|timeout|requires|must start|before running)/iu.test(message)) { return "debugging"; } - if (/(architecture|module|api|route|entity|service|controller|schema)/iu.test(message)) { + if ( + /(architecture|module|api|route|entity|service|controller|schema|markdown-first|db-first|database-first|source of truth|canonical)/iu.test( + message + ) + ) { return "architecture"; } @@ -54,34 +97,67 @@ function extractCommandFromSummary(summary: string): string | null { function commandSignature(command: string): string | null { const normalized = command.toLowerCase().trim(); - if (/\b(?:pnpm|npm|bun|yarn)\s+(test|lint|build|install)\b/u.test(normalized)) { - return normalized.match(/\b(?:pnpm|npm|bun|yarn)\s+(test|lint|build|install)\b/u)?.[1] ?? null; + const normalizedCommand = normalized + .replace(/^(pnpm|npm|bun|yarn)\s+-[cC]\s+\S+\s+/u, "$1 ") + .replace(/^(pnpm|npm|bun|yarn)\s+exec\s+/u, "") + .replace(/^uv\s+run\s+/u, "") + .replace(/^cargo\s+nextest\s+run\b/u, "cargo-nextest run"); + const runScriptMatch = normalized.match(/^(pnpm|npm|bun|yarn)\s+run\s+([a-z0-9:_-]+)/u); + if (runScriptMatch?.[1] && runScriptMatch[2]) { + return `${runScriptMatch[1]}:run:${runScriptMatch[2]}`; + } + + if (/\b(?:pnpm|npm|bun|yarn)\s+(test|lint|build|install|check)\b/u.test(normalized)) { + const match = normalized.match(/\b(pnpm|npm|bun|yarn)\s+(test|lint|build|install|check)\b/u); + const tool = match?.[1]; + const action = match?.[2]; + return tool && action ? `${tool}:${action}` : null; } - if (/\b(?:cargo)\s+(test|build|check)\b/u.test(normalized)) { - return normalized.match(/\bcargo\s+(test|build|check)\b/u)?.[1] ?? null; + if (/\b(?:cargo)\s+(test|build|check)\b/u.test(normalizedCommand)) { + const match = normalizedCommand.match(/\bcargo\s+(test|build|check)\b/u); + const action = match?.[1]; + return action ? `cargo:${action}` : null; } - if (/\b(?:pytest|jest|vitest|go test|dotnet test|rake)\b/u.test(normalized)) { - return "test"; + if (/\bcargo-nextest\s+run\b/u.test(normalizedCommand)) { + return "cargo-nextest:test"; } - if (/\b(?:tsc|vite build|next build|gradle|mvn|make)\b/u.test(normalized)) { - return "build"; + if (/\b(?:pytest|jest|vitest|go test|dotnet test|rake)\b/u.test(normalizedCommand)) { + const match = normalizedCommand.match(/\b(pytest|jest|vitest|go test|dotnet test|rake)\b/u); + const tool = match?.[1]; + if (!tool) { + return null; + } + return `${tool.replace(/\s+/gu, "-")}:test`; + } + + if (/\b(?:tsc|vite build|next build|gradle|mvn|make)\b/u.test(normalizedCommand)) { + const match = normalizedCommand.match(/\b(tsc|vite build|next build|gradle|mvn|make)\b/u); + const tool = match?.[1]; + if (!tool) { + return null; + } + return `${tool.replace(/\s+/gu, "-")}:build`; } return null; } -function overlappingEntryIds(existingEntries: MemoryEntry[], text: string): string[] { - return overlappingEntryIdsWithThreshold(existingEntries, text, 2); +function buildEntryIdentityKey(entry: Pick): string { + return `${entry.scope}:${entry.topic}:${entry.id}`; +} + +function overlappingEntries(existingEntries: MemoryEntry[], text: string): MemoryEntry[] { + return overlappingEntriesWithThreshold(existingEntries, text, 2); } -function overlappingEntryIdsWithThreshold( +function overlappingEntriesWithThreshold( existingEntries: MemoryEntry[], text: string, minimumMatches: number -): string[] { +): MemoryEntry[] { const words = new Set( text .toLowerCase() @@ -103,8 +179,7 @@ function overlappingEntryIdsWithThreshold( } } return matches >= Math.min(minimumMatches, words.size); - }) - .map((entry) => entry.id); + }); } function normalizeForComparison(text: string): string { @@ -180,7 +255,9 @@ function extractExplicitCorrection(message: string): ExplicitCorrection | null { const rawTopic = inferTopic(trimmed); const topic = - rawTopic === "preferences" || rawTopic === "workflow" ? rawTopic : null; + rawTopic === "preferences" || rawTopic === "workflow" || rawTopic === "commands" + ? rawTopic + : null; if (!topic) { return null; } @@ -201,10 +278,10 @@ function extractExplicitCorrection(message: string): ExplicitCorrection | null { return null; } -function collectExplicitCorrectionDeletes( +function collectExplicitCorrectionDeleteTargets( existingEntries: MemoryEntry[], correction: ExplicitCorrection -): string[] { +): MemoryEntry[] { const scopedEntries = existingEntries.filter( (entry) => entry.scope === correction.scope && entry.topic === correction.topic ); @@ -229,7 +306,7 @@ function collectExplicitCorrectionDeletes( }); if (directCandidates.length <= 1) { - return directCandidates.map((entry) => entry.id); + return directCandidates; } const contextTokens = summaryTokens.filter((token) => !staleTokens.has(token)); @@ -242,18 +319,18 @@ function collectExplicitCorrectionDeletes( const haystack = normalizeForComparison(`${entry.summary}\n${entry.details.join("\n")}`); const contextMatches = contextTokens.filter((token) => haystack.includes(token)).length; return contextMatches >= Math.min(2, contextTokens.length); - }) - .map((entry) => entry.id); + }); } function queueDelete( operations: MemoryOperation[], - queuedDeleteIds: Set, + queuedDeleteKeys: Set, entry: MemoryEntry, reason: string, rolloutPath: string ): void { - if (queuedDeleteIds.has(entry.id)) { + const deleteKey = buildEntryIdentityKey(entry); + if (queuedDeleteKeys.has(deleteKey)) { return; } @@ -265,21 +342,66 @@ function queueDelete( reason, sources: [rolloutPath] }); - queuedDeleteIds.add(entry.id); + queuedDeleteKeys.add(deleteKey); } function queueUpsert( operations: MemoryOperation[], - knownSummaries: Set, + knownOperationKeys: Set, operation: MemoryOperation ): void { - const normalizedSummary = operation.summary?.toLowerCase(); - if (!normalizedSummary || knownSummaries.has(normalizedSummary)) { + const normalizedSummary = normalizeForComparison(operation.summary ?? ""); + if (!normalizedSummary) { + return; + } + + const operationKey = [ + operation.scope, + operation.topic, + operation.id, + normalizedSummary + ].join(":"); + if (knownOperationKeys.has(operationKey)) { return; } operations.push(operation); - knownSummaries.add(normalizedSummary); + knownOperationKeys.add(operationKey); +} + +function extractStableAssistantSummary(message: string): { + scope: MemoryOperation["scope"]; + topic: string; + summary: string; + details: string[]; + reason: string; +} | null { + const normalizedMessage = trimTrailingPunctuation(message.trim()); + if (!normalizedMessage) { + return null; + } + + if (assistantNoisePatterns.some((pattern) => pattern.test(normalizedMessage))) { + return null; + } + + const matchingPrefix = assistantStablePrefixes.find((pattern) => pattern.test(normalizedMessage)); + if (!matchingPrefix) { + return null; + } + + const summary = trimTrailingPunctuation(normalizedMessage.replace(matchingPrefix, "").trim()); + if (summary.length < 24) { + return null; + } + + return { + scope: inferScope(summary), + topic: inferTopic(summary), + summary, + details: [summary], + reason: "Stable assistant summary extracted from the session." + }; } function commandSummary(command: string): { summary: string; details: string[] } { @@ -328,29 +450,28 @@ export class HeuristicExtractor implements MemoryExtractorAdapter { existingEntries: MemoryEntry[] ): Promise { const operations: MemoryOperation[] = []; - const knownSummaries = new Set(existingEntries.map((entry) => entry.summary.toLowerCase())); - const queuedDeleteIds = new Set(); + const knownOperationKeys = new Set( + existingEntries.map((entry) => + [entry.scope, entry.topic, entry.id, normalizeForComparison(entry.summary)].join(":") + ) + ); + const queuedDeleteKeys = new Set(); const allowedTopics = new Set(DEFAULT_MEMORY_TOPICS); for (const message of evidence.userMessages) { const explicitCorrection = extractExplicitCorrection(message); if (explicitCorrection) { - for (const entryId of collectExplicitCorrectionDeletes(existingEntries, explicitCorrection)) { - const entry = existingEntries.find((candidate) => candidate.id === entryId); - if (!entry) { - continue; - } - + for (const entry of collectExplicitCorrectionDeleteTargets(existingEntries, explicitCorrection)) { queueDelete( operations, - queuedDeleteIds, + queuedDeleteKeys, entry, "Superseded by a newer explicit user correction.", evidence.rolloutPath ); } - queueUpsert(operations, knownSummaries, { + queueUpsert(operations, knownOperationKeys, { action: "upsert", scope: explicitCorrection.scope, topic: explicitCorrection.topic, @@ -376,20 +497,22 @@ export class HeuristicExtractor implements MemoryExtractorAdapter { if (forgetMatch?.[1]) { const query = forgetMatch[1].trim().replace(/[。.]$/u, ""); - const matchingIds = new Set( - overlappingEntryIdsWithThreshold(existingEntries, query, 1) + const matchingEntryKeys = new Set( + overlappingEntriesWithThreshold(existingEntries, query, 1).map((entry) => + buildEntryIdentityKey(entry) + ) ); for (const entry of existingEntries) { const haystack = `${entry.id}\n${entry.summary}\n${entry.details.join("\n")}`.toLowerCase(); if ( !haystack.includes(query.toLowerCase()) && - !matchingIds.has(entry.id) + !matchingEntryKeys.has(buildEntryIdentityKey(entry)) ) { continue; } queueDelete( operations, - queuedDeleteIds, + queuedDeleteKeys, entry, "Explicit forget instruction from the user.", evidence.rolloutPath @@ -400,25 +523,22 @@ export class HeuristicExtractor implements MemoryExtractorAdapter { if (rememberMatch?.[1]) { const summary = rememberMatch[1].trim().replace(/[。.]$/u, ""); - if (knownSummaries.has(summary.toLowerCase())) { - continue; - } const scope = inferScope(message); const topic = inferTopic(message); const correctionSignal = /(?:\bnot\b|\binstead of\b|\brather than\b|不用|别用|不要用)/iu.test(message); const shouldReplaceOverlaps = - correctionSignal && (topic === "preferences" || topic === "workflow"); + correctionSignal && + (topic === "preferences" || topic === "workflow" || topic === "commands"); if (shouldReplaceOverlaps) { - for (const entryId of overlappingEntryIds(existingEntries, summary)) { - const entry = existingEntries.find((candidate) => candidate.id === entryId); - if (!entry || entry.summary.toLowerCase() === summary.toLowerCase()) { + for (const entry of overlappingEntries(existingEntries, summary)) { + if (entry.summary.toLowerCase() === summary.toLowerCase()) { continue; } queueDelete( operations, - queuedDeleteIds, + queuedDeleteKeys, entry, "Superseded by a newer user correction.", evidence.rolloutPath @@ -426,7 +546,7 @@ export class HeuristicExtractor implements MemoryExtractorAdapter { } } - queueUpsert(operations, knownSummaries, { + queueUpsert(operations, knownOperationKeys, { action: "upsert", scope, topic: allowedTopics.has(topic) ? topic : "workflow", @@ -446,21 +566,37 @@ export class HeuristicExtractor implements MemoryExtractorAdapter { ); if (insightMatch?.[0]) { const summary = message.trim().replace(/[。.]$/u, ""); - if (!knownSummaries.has(summary.toLowerCase())) { - queueUpsert(operations, knownSummaries, { - action: "upsert", - scope: inferScope(message), - topic: "debugging", - id: slugify(summary), - summary, - details: [summary], - reason: "Repeated prerequisite or debugging constraint extracted from the session.", - sources: [evidence.rolloutPath] - }); - } + queueUpsert(operations, knownOperationKeys, { + action: "upsert", + scope: inferScope(message), + topic: "debugging", + id: slugify(summary), + summary, + details: [summary], + reason: "Repeated prerequisite or debugging constraint extracted from the session.", + sources: [evidence.rolloutPath] + }); } } + for (const message of evidence.agentMessages) { + const extracted = extractStableAssistantSummary(message); + if (!extracted) { + continue; + } + + queueUpsert(operations, knownOperationKeys, { + action: "upsert", + scope: extracted.scope, + topic: allowedTopics.has(extracted.topic) ? extracted.topic : "workflow", + id: slugify(extracted.summary), + summary: extracted.summary, + details: extracted.details, + reason: extracted.reason, + sources: [evidence.rolloutPath] + }); + } + const commandCalls = evidence.toolCalls.filter(isCommandToolCall); const seenCommands = new Set(); @@ -471,15 +607,12 @@ export class HeuristicExtractor implements MemoryExtractorAdapter { } seenCommands.add(command); - if (!/(pnpm|npm|bun|cargo|pytest|vitest|jest|go test|python -m|python3 -m|make|docker compose|gradle|mvn|dotnet test|rake)/u.test(command)) { + if (!/(pnpm|npm|bun|cargo|pytest|vitest|jest|go test|python -m|python3 -m|make|docker compose|gradle|mvn|dotnet test|rake|uv run|nextest)/u.test(command)) { continue; } const { summary, details } = commandSummary(command); const signature = commandSignature(command); - if (knownSummaries.has(summary.toLowerCase())) { - continue; - } if (signature) { for (const entry of existingEntries) { @@ -496,7 +629,7 @@ export class HeuristicExtractor implements MemoryExtractorAdapter { ) { queueDelete( operations, - queuedDeleteIds, + queuedDeleteKeys, entry, "Superseded by a newer successful command extracted from the session.", evidence.rolloutPath @@ -505,7 +638,7 @@ export class HeuristicExtractor implements MemoryExtractorAdapter { } } - queueUpsert(operations, knownSummaries, { + queueUpsert(operations, knownOperationKeys, { action: "upsert", scope: "project", topic: "commands", @@ -517,6 +650,6 @@ export class HeuristicExtractor implements MemoryExtractorAdapter { }); } - return operations.slice(0, 8); + return operations; } } diff --git a/test/extractor.test.ts b/test/extractor.test.ts index f064263..25c26c7 100644 --- a/test/extractor.test.ts +++ b/test/extractor.test.ts @@ -6,7 +6,10 @@ import { parseRolloutEvidence } from "../src/lib/domain/rollout.js"; import { CodexExtractor } from "../src/lib/extractor/codex-extractor.js"; import { reviewExtractedMemoryOperations } from "../src/lib/extractor/contradiction-review.js"; import { HeuristicExtractor } from "../src/lib/extractor/heuristic-extractor.js"; -import { filterMemoryOperations } from "../src/lib/extractor/safety.js"; +import { + filterMemoryOperations, + filterMemoryOperationsWithDiagnostics +} from "../src/lib/extractor/safety.js"; import type { MemoryEntry, RolloutEvidence } from "../src/lib/types.js"; const tempDirs: string[] = []; @@ -179,6 +182,35 @@ describe("HeuristicExtractor", () => { expect(upserts[0]?.summary).not.toContain("npm install"); }); + it("classifies external systems and docs pointers as reference memories", async () => { + const extractor = new HeuristicExtractor(); + const operations = await extractor.extract( + baseEvidence({ + userMessages: [ + "remember that pipeline bugs are tracked in Linear project INGEST", + "remember that the latency dashboard lives at https://grafana.example.com/d/api-latency" + ] + }), + [] + ); + + expect( + operations.filter( + (operation) => operation.action === "upsert" && operation.topic === "reference" + ) + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + summary: "pipeline bugs are tracked in Linear project INGEST" + }), + expect.objectContaining({ + summary: + "the latency dashboard lives at https://grafana.example.com/d/api-latency" + }) + ]) + ); + }); + it("treats bash-named tool calls with expanded success output as reusable commands", async () => { const extractor = new HeuristicExtractor(); const operations = await extractor.extract( @@ -201,7 +233,72 @@ describe("HeuristicExtractor", () => { expect(upserts[0]?.summary).toContain("pnpm lint"); }); - it("replaces stale command memory from a real rollout fixture", async () => { + it("treats wrapped successful verification commands as reusable commands", async () => { + const extractor = new HeuristicExtractor(); + const operations = await extractor.extract( + baseEvidence({ + toolCalls: [ + { + callId: "call-pnpm-exec-vitest", + name: "exec_command", + arguments: "{\"cmd\":\"pnpm exec vitest run test/session-command.test.ts\"}", + output: "Process exited with code 0" + }, + { + callId: "call-uv-run-pytest", + name: "exec_command", + arguments: "{\"cmd\":\"uv run pytest tests/test_memory.py\"}", + output: "Process exited with code 0" + } + ] + }), + [] + ); + + expect(operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "upsert", + topic: "commands", + summary: "Run `pnpm exec vitest run test/session-command.test.ts` to verify this repository." + }), + expect.objectContaining({ + action: "upsert", + topic: "commands", + summary: "Run `uv run pytest tests/test_memory.py` to verify this repository." + }) + ]) + ); + }); + + it("extracts stable assistant summaries without pulling in reviewer chatter", async () => { + const extractor = new HeuristicExtractor(); + const operations = await extractor.extract( + baseEvidence({ + agentMessages: [ + "Confirmed durable memory stays Markdown-first; retrieval sidecars are rebuildable acceleration only.", + "I will ask a reviewer subagent to check docs wording before I continue." + ] + }), + [] + ); + + expect(operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "upsert", + topic: "architecture", + summary: + "durable memory stays Markdown-first; retrieval sidecars are rebuildable acceleration only" + }) + ]) + ); + expect( + operations.some((operation) => /reviewer subagent/i.test(operation.summary ?? "")) + ).toBe(false); + }); + + it("adds a newer command memory from a real rollout fixture without deleting a different toolchain command", async () => { const extractor = new HeuristicExtractor(); const evidence = await parseRolloutEvidence( path.join(process.cwd(), "test/fixtures/rollouts/memory-correction.jsonl") @@ -227,7 +324,7 @@ describe("HeuristicExtractor", () => { operation.action === "delete" && operation.id === "npm-test" ) - ).toBe(true); + ).toBe(false); expect( operations.some( (operation) => @@ -237,6 +334,120 @@ describe("HeuristicExtractor", () => { ).toBe(true); }); + it("does not treat npm test and pnpm test as the same command signature", async () => { + const extractor = new HeuristicExtractor(); + const operations = await extractor.extract( + baseEvidence({ + toolCalls: [ + { + callId: "call-pnpm-test", + name: "exec_command", + arguments: "{\"cmd\":\"pnpm test\"}", + output: "Process exited with code 0" + } + ] + }), + [ + { + id: "npm-test", + scope: "project", + topic: "commands", + summary: "Run `npm test` to verify this repository.", + details: ["Use `npm test` as a repeatable verification command for this project."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ] + ); + + expect( + operations.some((operation) => operation.action === "delete" && operation.id === "npm-test") + ).toBe(false); + expect( + operations.some( + (operation) => operation.action === "upsert" && operation.summary?.includes("pnpm test") + ) + ).toBe(true); + }); + + it("does not silently truncate heuristic operations before later reviewer stages", async () => { + const extractor = new HeuristicExtractor(); + const operations = await extractor.extract( + baseEvidence({ + toolCalls: [ + { + callId: "call-pnpm-test", + name: "exec_command", + arguments: "{\"cmd\":\"pnpm test\"}", + output: "Process exited with code 0" + }, + { + callId: "call-pnpm-lint", + name: "exec_command", + arguments: "{\"cmd\":\"pnpm lint\"}", + output: "Process exited with code 0" + }, + { + callId: "call-pnpm-build", + name: "exec_command", + arguments: "{\"cmd\":\"pnpm build\"}", + output: "Process exited with code 0" + }, + { + callId: "call-npm-install", + name: "exec_command", + arguments: "{\"cmd\":\"npm install\"}", + output: "Process exited with code 0" + }, + { + callId: "call-cargo-test", + name: "exec_command", + arguments: "{\"cmd\":\"cargo test\"}", + output: "Process exited with code 0" + }, + { + callId: "call-cargo-build", + name: "exec_command", + arguments: "{\"cmd\":\"cargo build\"}", + output: "Process exited with code 0" + }, + { + callId: "call-pytest", + name: "exec_command", + arguments: "{\"cmd\":\"pytest\"}", + output: "Process exited with code 0" + }, + { + callId: "call-jest", + name: "exec_command", + arguments: "{\"cmd\":\"jest\"}", + output: "Process exited with code 0" + }, + { + callId: "call-vitest", + name: "exec_command", + arguments: "{\"cmd\":\"vitest\"}", + output: "Process exited with code 0" + }, + { + callId: "call-make", + name: "exec_command", + arguments: "{\"cmd\":\"make\"}", + output: "Process exited with code 0" + } + ] + }), + [] + ); + + expect(operations.length).toBeGreaterThan(8); + expect( + operations.some( + (operation) => operation.action === "upsert" && operation.summary?.includes("make") + ) + ).toBe(true); + }); + it("deletes stale preferences after an explicit correction rollout", async () => { const extractor = new HeuristicExtractor(); const evidence = await parseRolloutEvidence( @@ -409,6 +620,98 @@ describe("HeuristicExtractor", () => { ).toBe(true); }); + it("deletes only the matching scoped stale entry when duplicate ids exist across scopes", async () => { + const extractor = new HeuristicExtractor(); + const existingEntries: MemoryEntry[] = [ + { + id: "use-npm", + scope: "global", + topic: "preferences", + summary: "Use npm for legacy global examples.", + details: ["Keep npm in the global legacy examples."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + }, + { + id: "use-npm", + scope: "project", + topic: "preferences", + summary: "Use npm in this repository.", + details: ["Use npm instead of pnpm in this repository."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ]; + + const operations = await extractor.extract( + baseEvidence({ + userMessages: ["remember that we use pnpm, not npm"] + }), + existingEntries + ); + + expect(operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "delete", + scope: "project", + topic: "preferences", + id: "use-npm" + }) + ]) + ); + expect(operations).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "delete", + scope: "global", + topic: "preferences", + id: "use-npm" + }) + ]) + ); + }); + + it("forgets only the matching entry when duplicate ids exist across topics", async () => { + const extractor = new HeuristicExtractor(); + const existingEntries: MemoryEntry[] = [ + { + id: "prefer-pnpm", + scope: "project", + topic: "workflow", + summary: "Prefer pnpm in this repository.", + details: ["Use pnpm instead of npm in this repository."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + }, + { + id: "prefer-pnpm", + scope: "project", + topic: "commands", + summary: "Run `pnpm test` to verify this repository.", + details: ["Use `pnpm test` as a repeatable verification command for this project."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ]; + + const operations = await extractor.extract( + baseEvidence({ + userMessages: ["forget repeatable verification command"] + }), + existingEntries + ); + + expect(operations).toEqual([ + expect.objectContaining({ + action: "delete", + scope: "project", + topic: "commands", + id: "prefer-pnpm" + }) + ]); + }); + it("does not delete architecture memory from a generic remember instruction", async () => { const extractor = new HeuristicExtractor(); const evidence = await parseRolloutEvidence( @@ -595,6 +898,168 @@ describe("HeuristicExtractor", () => { ]) ); }); + + it("does not let a retained high-confidence directive keep replacement deletes for an unrelated suppressed directive", () => { + const reviewed = reviewExtractedMemoryOperations( + [ + { + action: "delete", + scope: "project", + topic: "preferences", + id: "use-grep", + reason: "Superseded by a newer explicit user correction." + }, + { + action: "upsert", + scope: "project", + topic: "preferences", + id: "maybe-use-rg", + summary: "maybe use rg instead of grep", + details: ["Potential repo search correction."], + reason: "Explicit user correction that should replace stale memory." + }, + { + action: "delete", + scope: "project", + topic: "preferences", + id: "use-bun", + reason: "Superseded by a newer explicit user correction." + }, + { + action: "upsert", + scope: "project", + topic: "preferences", + id: "use-pnpm", + summary: "Actually use pnpm, not bun", + details: ["Package manager correction."], + reason: "Explicit user correction that should replace stale memory." + } + ], + [ + { + id: "use-grep", + scope: "project", + topic: "preferences", + summary: "Use grep for repo search.", + details: ["Use grep instead of rg in this repository."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ] + ); + + expect(reviewed.operations).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "delete", + id: "use-grep" + }) + ]) + ); + expect(reviewed.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "delete", + id: "use-bun" + }), + expect.objectContaining({ + action: "upsert", + id: "use-pnpm" + }) + ]) + ); + }); + + it("keeps the latest high-confidence reference correction and suppresses stale same-rollout pointers", () => { + const reviewed = reviewExtractedMemoryOperations( + [ + { + action: "upsert", + scope: "project", + topic: "reference", + id: "old-runbook", + summary: "The runbook lives at https://old.example.com/runbook", + details: ["Old runbook pointer."], + reason: "Manual reference note." + }, + { + action: "upsert", + scope: "project", + topic: "reference", + id: "new-runbook", + summary: "Actually the runbook lives at https://new.example.com/runbook", + details: ["Updated runbook pointer."], + reason: "Explicit user correction that should replace stale memory." + } + ], + [] + ); + + expect(reviewed.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "upsert", + id: "new-runbook" + }) + ]) + ); + expect(reviewed.operations).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "upsert", + id: "old-runbook" + }) + ]) + ); + expect(reviewed.conflicts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + source: "within-rollout", + candidateSummary: "The runbook lives at https://old.example.com/runbook", + conflictsWith: ["Actually the runbook lives at https://new.example.com/runbook"] + }) + ]) + ); + }); + + it("suppresses hedged reference updates that conflict with existing durable pointers", () => { + const reviewed = reviewExtractedMemoryOperations( + [ + { + action: "upsert", + scope: "project", + topic: "reference", + id: "maybe-dashboard", + summary: "Maybe the dashboard lives at https://new.example.com/dashboard", + details: ["Possible dashboard pointer."], + reason: "Explicit user correction that should replace stale memory." + } + ], + [ + { + id: "dashboard", + scope: "project", + topic: "reference", + summary: "The dashboard lives at https://old.example.com/dashboard", + details: ["Current dashboard pointer."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ] + ); + + expect(reviewed.operations).toEqual([]); + expect(reviewed.suppressedOperationCount).toBe(1); + expect(reviewed.conflicts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + source: "existing-memory", + candidateSummary: "Maybe the dashboard lives at https://new.example.com/dashboard", + conflictsWith: ["The dashboard lives at https://old.example.com/dashboard"] + }) + ]) + ); + }); }); describe("safety filter", () => { @@ -655,6 +1120,32 @@ describe("safety filter", () => { }); describe("safety filter - volatile/sensitive patterns", () => { + it("fails closed when an upsert uses an unknown topic", () => { + const filtered = filterMemoryOperations([ + { + action: "upsert", + scope: "project", + topic: "commandss", + id: "prefer-pnpm", + summary: "Prefer pnpm in this repository.", + details: ["Use pnpm instead of npm."] + } + ]); + expect(filtered).toHaveLength(0); + }); + + it("fails closed when a delete uses an unknown topic", () => { + const filtered = filterMemoryOperations([ + { + action: "delete", + scope: "project", + topic: "commandss", + id: "prefer-pnpm" + } + ]); + expect(filtered).toHaveLength(0); + }); + it("keeps entries with 'currently' in summary", () => { const filtered = filterMemoryOperations([ { @@ -683,6 +1174,20 @@ describe("safety filter - volatile/sensitive patterns", () => { expect(filtered).toHaveLength(0); }); + it("rejects entries that only capture local host config or resume noise", () => { + const filtered = filterMemoryOperations([ + { + action: "upsert", + scope: "project", + topic: "workflow", + id: "local-config-noise", + summary: "Next step: update .mcp.json and .codex/config.toml in this worktree only.", + details: ["Resume here after the next message."] + } + ]); + expect(filtered).toHaveLength(0); + }); + it("rejects entries with AWS access key", () => { const syntheticAwsKey = ["AKIA", "IOSFODNN7EXAMPLE"].join(""); const filtered = filterMemoryOperations([ @@ -727,7 +1232,7 @@ describe("safety filter - volatile/sensitive patterns", () => { }); it("rejects volatile wording even inside debugging topics", () => { - const filtered = filterMemoryOperations([ + const diagnostics = filterMemoryOperationsWithDiagnostics([ { action: "upsert", scope: "project", @@ -737,7 +1242,11 @@ describe("safety filter - volatile/sensitive patterns", () => { details: ["Temporary but still useful while the issue is open."] } ]); - expect(filtered).toHaveLength(0); + + expect(diagnostics.operations).toEqual([]); + expect(diagnostics.rejectedReasonCounts).toMatchObject({ + volatile: 1 + }); }); it("caps sanitized operations at 12 items", () => { @@ -753,9 +1262,158 @@ describe("safety filter - volatile/sensitive patterns", () => { ); expect(filtered).toHaveLength(12); }); + + it("returns rejected diagnostics for unknown topics, sensitive content, and operation cap", () => { + const diagnostics = filterMemoryOperationsWithDiagnostics( + [ + { + action: "upsert", + scope: "project", + topic: "commandss", + id: "unknown-topic", + summary: "Prefer pnpm in this repository.", + details: ["Use pnpm instead of npm."] + }, + { + action: "upsert", + scope: "project", + topic: "workflow", + id: "secret", + summary: "postgres://fixture-user:fixture-pass@example.com/testdb", + details: ["Never store this."] + }, + ...Array.from({ length: 20 }, (_, index) => ({ + action: "upsert" as const, + scope: "project" as const, + topic: "workflow", + id: `entry-${index}`, + summary: `Workflow note ${index}`, + details: [`Workflow detail ${index}`] + })) + ] + ); + + expect(diagnostics.operations).toHaveLength(12); + expect(diagnostics.rejectedOperationCount).toBe(10); + expect(diagnostics.rejectedReasonCounts).toMatchObject({ + "unknown-topic": 1, + sensitive: 1, + "operation-cap": 8 + }); + }); + + it("returns volatile diagnostics for local host config task-state noise", () => { + const diagnostics = filterMemoryOperationsWithDiagnostics([ + { + action: "upsert", + scope: "project", + topic: "workflow", + id: "local-config-noise", + summary: "Next step: update .mcp.json and .codex/config.toml in this worktree only.", + details: ["Resume here after the next message."] + } + ]); + + expect(diagnostics.operations).toEqual([]); + expect(diagnostics.rejectedReasonCounts).toMatchObject({ + volatile: 1 + }); + expect(diagnostics.rejectedOperations).toEqual([ + expect.objectContaining({ + id: "local-config-noise", + reason: "volatile" + }) + ]); + }); + + it("rejects entries when volatile task-state noise only appears in details", () => { + const diagnostics = filterMemoryOperationsWithDiagnostics([ + { + action: "upsert", + scope: "project", + topic: "workflow", + id: "volatile-detail-only", + summary: "Use pnpm in this repository.", + details: ["Next step: resume here after updating the current worktree only."] + } + ]); + + expect(diagnostics.operations).toEqual([]); + expect(diagnostics.rejectedReasonCounts).toMatchObject({ + volatile: 1 + }); + }); + + it("rejects entries when volatile task-state noise only appears in reason", () => { + const diagnostics = filterMemoryOperationsWithDiagnostics([ + { + action: "upsert", + scope: "project", + topic: "workflow", + id: "volatile-reason-only", + summary: "Use pnpm in this repository.", + details: ["Prefer pnpm instead of npm here."], + reason: "Temporary next step for the current branch only." + } + ]); + + expect(diagnostics.operations).toEqual([]); + expect(diagnostics.rejectedReasonCounts).toMatchObject({ + volatile: 1 + }); + }); }); describe("HeuristicExtractor - no duplicate upserts for remember + insight", () => { + it("classifies explicit command remembers under commands", async () => { + const extractor = new HeuristicExtractor(); + const operations = await extractor.extract( + baseEvidence({ + userMessages: ["remember that run `pnpm test` to verify this repository"] + }), + [] + ); + + expect(operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "upsert", + topic: "commands", + summary: "run `pnpm test` to verify this repository" + }) + ]) + ); + }); + + it("keeps explicit command corrections in commands when replacing stale command memory", async () => { + const extractor = new HeuristicExtractor(); + const operations = await extractor.extract( + baseEvidence({ + userMessages: ["remember that run `pnpm test`, not `npm test`, to verify this repository"] + }), + [ + { + id: "npm-test", + scope: "project", + topic: "commands", + summary: "Run `npm test` to verify this repository.", + details: ["Use `npm test` as a repeatable verification command for this project."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ] + ); + + expect(operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "upsert", + topic: "commands" + }) + ]) + ); + }); + it("does not produce duplicate upserts for remember + insight match on same message", async () => { const extractor = new HeuristicExtractor(); const operations = await extractor.extract( From 45686964e340a1236abc60adc9cb995a175a0844 Mon Sep 17 00:00:00 2001 From: blocks Date: Sun, 5 Apr 2026 23:34:45 +0800 Subject: [PATCH 31/62] fix: tighten durable corrections and command signatures (cherry picked from commit c00784e1161e15e8416d86491d63c7ff335669d5) --- src/lib/extractor/heuristic-extractor.ts | 199 ++++++++++----- test/command-signatures.test.ts | 29 +++ test/extractor.test.ts | 302 +++++++++++++++++++++++ 3 files changed, 463 insertions(+), 67 deletions(-) create mode 100644 test/command-signatures.test.ts diff --git a/src/lib/extractor/heuristic-extractor.ts b/src/lib/extractor/heuristic-extractor.ts index 0ea512c..a4d983e 100644 --- a/src/lib/extractor/heuristic-extractor.ts +++ b/src/lib/extractor/heuristic-extractor.ts @@ -3,10 +3,11 @@ import type { MemoryEntry, MemoryOperation, RolloutEvidence } from "../types.js" import type { MemoryExtractorAdapter } from "../runtime/contracts.js"; import { slugify } from "../util/text.js"; import { commandSucceeded, extractCommand, isCommandToolCall } from "./command-utils.js"; +import { canonicalCommandSignature } from "./command-signatures.js"; interface ExplicitCorrection { scope: MemoryOperation["scope"]; - topic: "preferences" | "workflow" | "commands"; + topic: string; summary: string; staleText: string; } @@ -35,6 +36,12 @@ const assistantNoisePatterns = [ /下一步/u ] as const; +const stableDirectiveTopics = new Set(["reference", "architecture", "debugging", "patterns"]); + +function isAllowedMemoryTopic(topic: string): topic is (typeof DEFAULT_MEMORY_TOPICS)[number] { + return DEFAULT_MEMORY_TOPICS.includes(topic as (typeof DEFAULT_MEMORY_TOPICS)[number]); +} + function inferScope(message: string): MemoryOperation["scope"] { if (/(all projects|across projects|globally|every repo|所有项目|全局)/iu.test(message)) { return "global"; @@ -48,6 +55,13 @@ function inferScope(message: string): MemoryOperation["scope"] { } function inferTopic(message: string): string { + if ( + /search\s*->\s*timeline\s*->\s*details/iu.test(message) || + /mcp\s*->\s*local bridge\s*->\s*resolved cli/iu.test(message) + ) { + return "patterns"; + } + if ( /`[^`]*(?:pnpm|npm|bun|yarn|cargo|pytest|jest|vitest|go test|python(?:3)? -m|make)[^`]*`/iu.test( message @@ -71,10 +85,6 @@ function inferTopic(message: string): string { return "preferences"; } - if (/(debug|error|fix|fails|failing|redis|database|timeout|requires|must start|before running)/iu.test(message)) { - return "debugging"; - } - if ( /(architecture|module|api|route|entity|service|controller|schema|markdown-first|db-first|database-first|source of truth|canonical)/iu.test( message @@ -83,6 +93,10 @@ function inferTopic(message: string): string { return "architecture"; } + if (/(debug|error|fix|fails|failing|redis|database|timeout|requires|must start|before running)/iu.test(message)) { + return "debugging"; + } + if (/(pattern|convention|reuse|shared)/iu.test(message)) { return "patterns"; } @@ -95,56 +109,6 @@ function extractCommandFromSummary(summary: string): string | null { return match?.[1] ?? null; } -function commandSignature(command: string): string | null { - const normalized = command.toLowerCase().trim(); - const normalizedCommand = normalized - .replace(/^(pnpm|npm|bun|yarn)\s+-[cC]\s+\S+\s+/u, "$1 ") - .replace(/^(pnpm|npm|bun|yarn)\s+exec\s+/u, "") - .replace(/^uv\s+run\s+/u, "") - .replace(/^cargo\s+nextest\s+run\b/u, "cargo-nextest run"); - const runScriptMatch = normalized.match(/^(pnpm|npm|bun|yarn)\s+run\s+([a-z0-9:_-]+)/u); - if (runScriptMatch?.[1] && runScriptMatch[2]) { - return `${runScriptMatch[1]}:run:${runScriptMatch[2]}`; - } - - if (/\b(?:pnpm|npm|bun|yarn)\s+(test|lint|build|install|check)\b/u.test(normalized)) { - const match = normalized.match(/\b(pnpm|npm|bun|yarn)\s+(test|lint|build|install|check)\b/u); - const tool = match?.[1]; - const action = match?.[2]; - return tool && action ? `${tool}:${action}` : null; - } - - if (/\b(?:cargo)\s+(test|build|check)\b/u.test(normalizedCommand)) { - const match = normalizedCommand.match(/\bcargo\s+(test|build|check)\b/u); - const action = match?.[1]; - return action ? `cargo:${action}` : null; - } - - if (/\bcargo-nextest\s+run\b/u.test(normalizedCommand)) { - return "cargo-nextest:test"; - } - - if (/\b(?:pytest|jest|vitest|go test|dotnet test|rake)\b/u.test(normalizedCommand)) { - const match = normalizedCommand.match(/\b(pytest|jest|vitest|go test|dotnet test|rake)\b/u); - const tool = match?.[1]; - if (!tool) { - return null; - } - return `${tool.replace(/\s+/gu, "-")}:test`; - } - - if (/\b(?:tsc|vite build|next build|gradle|mvn|make)\b/u.test(normalizedCommand)) { - const match = normalizedCommand.match(/\b(tsc|vite build|next build|gradle|mvn|make)\b/u); - const tool = match?.[1]; - if (!tool) { - return null; - } - return `${tool.replace(/\s+/gu, "-")}:build`; - } - - return null; -} - function buildEntryIdentityKey(entry: Pick): string { return `${entry.scope}:${entry.topic}:${entry.id}`; } @@ -206,6 +170,62 @@ function isHighConfidenceExplicitCorrection(message: string): boolean { ); } +function isCorrectionSignal(message: string): boolean { + return /(?:\bnot\b|\binstead of\b|\brather than\b|不用|别用|不要用)/iu.test(message); +} + +function isStableDirectiveSummary(topic: string, summary: string): boolean { + switch (topic) { + case "reference": + return /(https?:\/\/|tracked in|lives at|runbook|dashboard|docs?\b|linear|jira|issue tracker)/iu.test( + summary + ); + case "architecture": + return /\b(markdown-first|db-first|database-first|source of truth|canonical)\b|主真相|规范存储/u.test( + summary + ); + case "debugging": + return /\b(requires?|needs?|must be running|must run|must start|before running|before integration tests)\b|需要|必须|先启动/u.test( + summary + ); + case "patterns": + return ( + /search\s*->\s*timeline\s*->\s*details/iu.test(summary) || + /mcp\s*->\s*local bridge\s*->\s*resolved cli/iu.test(summary) + ); + default: + return false; + } +} + +function extractStableDirectiveOperation( + message: string, + rolloutPath: string +): MemoryOperation | null { + const summary = trimTrailingPunctuation(message.trim()); + if (!summary || /[??]$/u.test(summary) || !isHighConfidenceExplicitCorrection(summary)) { + return null; + } + + const topic = inferTopic(summary); + if (!stableDirectiveTopics.has(topic) || !isStableDirectiveSummary(topic, summary)) { + return null; + } + + return { + action: "upsert", + scope: inferScope(summary), + topic, + id: slugify(summary), + summary, + details: [summary], + reason: isCorrectionSignal(summary) + ? "Explicit user correction that should replace stale memory." + : "Stable directive extracted from the session.", + sources: [rolloutPath] + }; +} + function extractExplicitCorrection(message: string): ExplicitCorrection | null { const trimmed = trimTrailingPunctuation(stripRememberPrefix(message)); if (!isHighConfidenceExplicitCorrection(trimmed)) { @@ -233,6 +253,18 @@ function extractExplicitCorrection(message: string): ExplicitCorrection | null { pattern: /^(?:actually\s+)?prefer\s+(.+?)\s+over\s+(.+)$/iu, staleIndex: 2 }, + { + pattern: /^(?:actually\s+)?run\s+(.+?),\s*not\s+(.+)$/iu, + staleIndex: 2 + }, + { + pattern: /^(?:actually\s+)?keep\s+(.+?),\s*not\s+(.+)$/iu, + staleIndex: 2 + }, + { + pattern: /^(?:actually\s+)?(.+?\b(?:lives? at|is at)\s+.+?),\s*not\s+(.+)$/iu, + staleIndex: 2 + }, { pattern: /^我们用\s*(.+?)\s*[,,]\s*不用\s*(.+)$/u, staleIndex: 2 @@ -254,10 +286,7 @@ function extractExplicitCorrection(message: string): ExplicitCorrection | null { } const rawTopic = inferTopic(trimmed); - const topic = - rawTopic === "preferences" || rawTopic === "workflow" || rawTopic === "commands" - ? rawTopic - : null; + const topic = isAllowedMemoryTopic(rawTopic) ? rawTopic : null; if (!topic) { return null; } @@ -305,11 +334,22 @@ function collectExplicitCorrectionDeleteTargets( return haystack.includes(staleNeedle); }); - if (directCandidates.length <= 1) { + if (correction.topic === "commands") { return directCandidates; } const contextTokens = summaryTokens.filter((token) => !staleTokens.has(token)); + if (directCandidates.length <= 1) { + if (directCandidates.length === 0 || contextTokens.length === 0) { + return directCandidates; + } + + return directCandidates.filter((entry) => { + const haystack = normalizeForComparison(`${entry.summary}\n${entry.details.join("\n")}`); + return contextTokens.some((token) => haystack.includes(token)); + }); + } + if (contextTokens.length < 2) { return []; } @@ -526,11 +566,9 @@ export class HeuristicExtractor implements MemoryExtractorAdapter { const scope = inferScope(message); const topic = inferTopic(message); - const correctionSignal = - /(?:\bnot\b|\binstead of\b|\brather than\b|不用|别用|不要用)/iu.test(message); + const correctionSignal = isCorrectionSignal(message); const shouldReplaceOverlaps = - correctionSignal && - (topic === "preferences" || topic === "workflow" || topic === "commands"); + correctionSignal && isAllowedMemoryTopic(topic); if (shouldReplaceOverlaps) { for (const entry of overlappingEntries(existingEntries, summary)) { if (entry.summary.toLowerCase() === summary.toLowerCase()) { @@ -561,8 +599,35 @@ export class HeuristicExtractor implements MemoryExtractorAdapter { continue; } + const stableDirective = extractStableDirectiveOperation(message, evidence.rolloutPath); + if (stableDirective) { + const shouldReplaceOverlaps = + stableDirective.reason === "Explicit user correction that should replace stale memory."; + if (shouldReplaceOverlaps) { + for (const entry of overlappingEntries(existingEntries, stableDirective.summary ?? "")) { + if ( + entry.scope !== stableDirective.scope || + entry.topic !== stableDirective.topic || + entry.summary.toLowerCase() === stableDirective.summary?.toLowerCase() + ) { + continue; + } + queueDelete( + operations, + queuedDeleteKeys, + entry, + "Superseded by a newer user correction.", + evidence.rolloutPath + ); + } + } + + queueUpsert(operations, knownOperationKeys, stableDirective); + continue; + } + const insightMatch = message.match( - /\b(?:requires|needs|must start|must run|before running)\b(.+)/iu + /\b(?:requires|needs|must be running|must start|must run|before running|before integration tests)\b(.+)/iu ); if (insightMatch?.[0]) { const summary = message.trim().replace(/[。.]$/u, ""); @@ -612,7 +677,7 @@ export class HeuristicExtractor implements MemoryExtractorAdapter { } const { summary, details } = commandSummary(command); - const signature = commandSignature(command); + const signature = canonicalCommandSignature(command); if (signature) { for (const entry of existingEntries) { @@ -624,7 +689,7 @@ export class HeuristicExtractor implements MemoryExtractorAdapter { continue; } if ( - commandSignature(existingCommand) === signature && + canonicalCommandSignature(existingCommand) === signature && entry.summary.toLowerCase() !== summary.toLowerCase() ) { queueDelete( diff --git a/test/command-signatures.test.ts b/test/command-signatures.test.ts new file mode 100644 index 0000000..da6c3d3 --- /dev/null +++ b/test/command-signatures.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { canonicalCommandSignature } from "../src/lib/extractor/command-signatures.js"; + +describe("canonicalCommandSignature", () => { + it("normalizes wrapped verification commands to a stable signature", () => { + expect(canonicalCommandSignature("pnpm exec vitest run test/session-command.test.ts")).toBe( + "vitest:test" + ); + expect(canonicalCommandSignature("uv run pytest tests/test_memory.py")).toBe("pytest:test"); + expect(canonicalCommandSignature("cargo nextest run")).toBe("cargo-nextest:test"); + }); + + it("keeps package-manager-specific commands distinct", () => { + expect(canonicalCommandSignature("pnpm test")).toBe("pnpm:test"); + expect(canonicalCommandSignature("npm test")).toBe("npm:test"); + }); + + it("treats npm-family run aliases for built-in lifecycle scripts as the same signature", () => { + expect(canonicalCommandSignature("pnpm run test")).toBe("pnpm:test"); + expect(canonicalCommandSignature("npm run lint")).toBe("npm:lint"); + expect(canonicalCommandSignature("bun run build")).toBe("bun:build"); + expect(canonicalCommandSignature("yarn run check")).toBe("yarn:check"); + }); + + it("normalizes common wrapped and shorthand test commands that extractor already accepts", () => { + expect(canonicalCommandSignature("pnpm -C packages/app test")).toBe("pnpm:test"); + expect(canonicalCommandSignature("nextest run")).toBe("cargo-nextest:test"); + }); +}); diff --git a/test/extractor.test.ts b/test/extractor.test.ts index 25c26c7..7093f1d 100644 --- a/test/extractor.test.ts +++ b/test/extractor.test.ts @@ -211,6 +211,52 @@ describe("HeuristicExtractor", () => { ); }); + it("extracts stable directive-style memories for reference, architecture, debugging, and patterns", async () => { + const extractor = new HeuristicExtractor(); + const operations = await extractor.extract( + baseEvidence({ + userMessages: [ + "The auth runbook lives at https://docs.example.com/auth-runbook.", + "Keep Markdown-first as the canonical store, not database-first.", + "Redis must be running before integration tests.", + "Use search -> timeline -> details for recall.", + "Use MCP -> local bridge -> resolved CLI for retrieval." + ] + }), + [] + ); + + expect(operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "upsert", + topic: "reference", + summary: "The auth runbook lives at https://docs.example.com/auth-runbook" + }), + expect.objectContaining({ + action: "upsert", + topic: "architecture", + summary: "Keep Markdown-first as the canonical store, not database-first" + }), + expect.objectContaining({ + action: "upsert", + topic: "debugging", + summary: "Redis must be running before integration tests" + }), + expect.objectContaining({ + action: "upsert", + topic: "patterns", + summary: "Use search -> timeline -> details for recall" + }), + expect.objectContaining({ + action: "upsert", + topic: "patterns", + summary: "Use MCP -> local bridge -> resolved CLI for retrieval" + }) + ]) + ); + }); + it("treats bash-named tool calls with expanded success output as reusable commands", async () => { const extractor = new HeuristicExtractor(); const operations = await extractor.extract( @@ -970,6 +1016,55 @@ describe("HeuristicExtractor", () => { ); }); + it("keeps replacement deletes for wrapped command variants that canonicalize to the same verification tool", () => { + const reviewed = reviewExtractedMemoryOperations( + [ + { + action: "delete", + scope: "project", + topic: "commands", + id: "vitest-command", + reason: "Superseded by a newer successful command extracted from the session." + }, + { + action: "upsert", + scope: "project", + topic: "commands", + id: "pnpm-exec-vitest-run-test-session-command-test-ts", + summary: "Run `pnpm exec vitest run test/session-command.test.ts` to verify this repository.", + details: [ + "Use `pnpm exec vitest run test/session-command.test.ts` as a repeatable verification command for this project." + ], + reason: "Stable command inferred from recent tool usage." + } + ], + [ + { + id: "vitest-command", + scope: "project", + topic: "commands", + summary: "Run `vitest` to verify this repository.", + details: ["Use `vitest` as a repeatable verification command for this project."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ] + ); + + expect(reviewed.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "delete", + id: "vitest-command" + }), + expect.objectContaining({ + action: "upsert", + id: "pnpm-exec-vitest-run-test-session-command-test-ts" + }) + ]) + ); + }); + it("keeps the latest high-confidence reference correction and suppresses stale same-rollout pointers", () => { const reviewed = reviewExtractedMemoryOperations( [ @@ -1022,6 +1117,124 @@ describe("HeuristicExtractor", () => { ); }); + it("extracts explicit reference corrections so they can replace stale durable pointers", async () => { + const extractor = new HeuristicExtractor(); + const existingEntries: MemoryEntry[] = [ + { + id: "auth-runbook", + scope: "project", + topic: "reference", + summary: "The auth runbook lives at https://old.example.com/auth-runbook", + details: ["Old auth runbook pointer."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ]; + + const operations = await extractor.extract( + baseEvidence({ + userMessages: [ + "Actually the auth runbook lives at https://docs.example.com/auth-runbook, not https://old.example.com/auth-runbook." + ] + }), + existingEntries + ); + const reviewed = reviewExtractedMemoryOperations(operations, existingEntries); + + expect(reviewed.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "delete", + topic: "reference", + id: "auth-runbook" + }), + expect.objectContaining({ + action: "upsert", + topic: "reference", + reason: "Explicit user correction that should replace stale memory." + }) + ]) + ); + }); + + it("treats remember-style reference corrections as explicit replacements end-to-end", async () => { + const extractor = new HeuristicExtractor(); + const existingEntries: MemoryEntry[] = [ + { + id: "auth-runbook", + scope: "project", + topic: "reference", + summary: "The auth runbook lives at https://old.example.com/auth-runbook", + details: ["Old auth runbook pointer."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ]; + + const operations = await extractor.extract( + baseEvidence({ + userMessages: [ + "remember that the auth runbook lives at https://docs.example.com/auth-runbook instead of https://old.example.com/auth-runbook" + ] + }), + existingEntries + ); + const reviewed = reviewExtractedMemoryOperations(operations, existingEntries); + + expect(reviewed.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "delete", + topic: "reference", + id: "auth-runbook" + }), + expect.objectContaining({ + action: "upsert", + topic: "reference", + reason: "Explicit user correction that should replace stale memory." + }) + ]) + ); + }); + + it("extracts explicit architecture corrections so they can replace stale canonical-store memory", async () => { + const extractor = new HeuristicExtractor(); + const existingEntries: MemoryEntry[] = [ + { + id: "canonical-store", + scope: "project", + topic: "architecture", + summary: "Use a database-first canonical store for durable memory.", + details: ["The canonical source of truth is SQLite."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ]; + + const operations = await extractor.extract( + baseEvidence({ + userMessages: ["Actually keep Markdown-first as the canonical store, not database-first."] + }), + existingEntries + ); + const reviewed = reviewExtractedMemoryOperations(operations, existingEntries); + + expect(reviewed.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "delete", + topic: "architecture", + id: "canonical-store" + }), + expect.objectContaining({ + action: "upsert", + topic: "architecture", + reason: "Explicit user correction that should replace stale memory." + }) + ]) + ); + }); + it("suppresses hedged reference updates that conflict with existing durable pointers", () => { const reviewed = reviewExtractedMemoryOperations( [ @@ -1060,6 +1273,95 @@ describe("HeuristicExtractor", () => { ]) ); }); + + it("keeps scoped exceptions when a repo-wide correction only overlaps a docs-specific note", async () => { + const extractor = new HeuristicExtractor(); + const existingEntries: MemoryEntry[] = [ + { + id: "docs-use-npm", + scope: "project", + topic: "preferences", + summary: "Use npm for docs examples.", + details: ["Docs snippets still use npm commands."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ]; + + const operations = await extractor.extract( + baseEvidence({ + userMessages: ["we use pnpm, not npm"] + }), + existingEntries + ); + + expect(operations).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "delete", + id: "docs-use-npm" + }) + ]) + ); + expect(operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "upsert", + summary: "we use pnpm, not npm" + }) + ]) + ); + }); + + it("does not delete cross-scope command memories for remember-style command corrections", async () => { + const extractor = new HeuristicExtractor(); + const existingEntries: MemoryEntry[] = [ + { + id: "global-npm-test", + scope: "global", + topic: "commands", + summary: "Run `npm test` to verify projects.", + details: ["Global command note."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + }, + { + id: "project-npm-test", + scope: "project", + topic: "commands", + summary: "Run `npm test` to verify this repository.", + details: ["Project command note."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ]; + + const operations = await extractor.extract( + baseEvidence({ + userMessages: ["remember that run `pnpm test`, not `npm test`"] + }), + existingEntries + ); + + expect(operations).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "delete", + scope: "global", + id: "global-npm-test" + }) + ]) + ); + expect(operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "delete", + scope: "project", + id: "project-npm-test" + }) + ]) + ); + }); }); describe("safety filter", () => { From 30f18390aad8ee9e1fb9cc11c160c0065932e416 Mon Sep 17 00:00:00 2001 From: blocks Date: Sun, 5 Apr 2026 22:36:05 +0800 Subject: [PATCH 32/62] feat: harden directive extraction and unsafe topic recall (cherry picked from commit 19922db0b8576cda1bad5a2d2a068cfaa20c2f2f) --- src/lib/commands/session-presenters.ts | 3 +- src/lib/extractor/command-signatures.ts | 6 + src/lib/extractor/contradiction-review.ts | 221 ++++++++++++++++++---- src/lib/extractor/heuristic-extractor.ts | 17 +- test/command-signatures.test.ts | 5 - test/extractor.test.ts | 129 ------------- test/session-command.test.ts | 12 ++ 7 files changed, 203 insertions(+), 190 deletions(-) diff --git a/src/lib/commands/session-presenters.ts b/src/lib/commands/session-presenters.ts index ed91607..1afeff4 100644 --- a/src/lib/commands/session-presenters.ts +++ b/src/lib/commands/session-presenters.ts @@ -420,7 +420,8 @@ export function buildSessionStatusJson(view: SessionInspectionView): string { autoSave: view.autoSave, localPathStyle: view.localPathStyle, maxLines: view.maxLines, - ...buildSessionInspectionPayload(view) + ...buildSessionInspectionPayload(view), + startup: view.startup }, null, 2 diff --git a/src/lib/extractor/command-signatures.ts b/src/lib/extractor/command-signatures.ts index ddfc16b..0739053 100644 --- a/src/lib/extractor/command-signatures.ts +++ b/src/lib/extractor/command-signatures.ts @@ -1,5 +1,11 @@ export function canonicalCommandSignature(command: string): string | null { const normalized = command.toLowerCase().trim(); + const lifecycleScriptPattern = /^(pnpm|npm|bun|yarn)\s+run\s+(test|lint|build|install|check)\b/u; + const lifecycleRunMatch = normalized.match(lifecycleScriptPattern); + if (lifecycleRunMatch?.[1] && lifecycleRunMatch[2]) { + return `${lifecycleRunMatch[1]}:${lifecycleRunMatch[2]}`; + } + const normalizedCommand = normalized .replace(/^(pnpm|npm|bun|yarn)\s+-[cC]\s+\S+\s+/u, "$1 ") .replace(/^(pnpm|npm|bun|yarn)\s+exec\s+/u, "") diff --git a/src/lib/extractor/contradiction-review.ts b/src/lib/extractor/contradiction-review.ts index b477081..4c7ada5 100644 --- a/src/lib/extractor/contradiction-review.ts +++ b/src/lib/extractor/contradiction-review.ts @@ -4,6 +4,7 @@ import type { MemoryOperation, MemoryScope } from "../types.js"; +import { canonicalCommandSignature } from "./command-signatures.js"; interface DirectiveChoice { key: string; @@ -24,10 +25,20 @@ export interface ReviewedMemoryOperations { conflicts: MemoryConflictCandidate[]; } -const reviewableTopics = new Set(["preferences", "workflow", "commands"]); +const reviewableTopics = new Set([ + "preferences", + "workflow", + "commands", + "reference", + "architecture", + "debugging", + "patterns" +]); const replacementDeleteReasonPattern = /^Superseded by a newer /u; const packageManagerValues = ["pnpm", "npm", "yarn", "bun"] as const; const repoSearchValues = ["rg", "ripgrep", "grep"] as const; +const canonicalStoreValues = ["markdown", "sqlite", "database", "vector"] as const; +const debuggingDependencyValues = ["redis", "postgres", "docker"] as const; const hedgedCorrectionPattern = /(?:\bmaybe\b|\bperhaps\b|\bif possible\b|\bwhen possible\b|\bfor now\b|\bprobably\b|\busually\b|\bsometimes\b|\btry\b|\bconsider\b|\bmight\b|\bcould\b|尽量|如果可以|可能|暂时)/iu; @@ -64,27 +75,6 @@ function hasCommandReplacementDelete( ); } -function commandSignature(command: string): string | null { - const normalized = command.toLowerCase().trim(); - if (/\b(?:pnpm|npm|bun|yarn)\s+(test|lint|build|install)\b/u.test(normalized)) { - return normalized.match(/\b(?:pnpm|npm|bun|yarn)\s+(test|lint|build|install)\b/u)?.[1] ?? null; - } - - if (/\bcargo\s+(test|build|check)\b/u.test(normalized)) { - return normalized.match(/\bcargo\s+(test|build|check)\b/u)?.[1] ?? null; - } - - if (/\b(?:pytest|jest|vitest|go test|dotnet test|rake)\b/u.test(normalized)) { - return "test"; - } - - if (/\b(?:tsc|vite build|next build|gradle|mvn|make)\b/u.test(normalized)) { - return "build"; - } - - return null; -} - function extractCommandChoice(text: string): DirectiveChoice[] { const commandMatch = text.match(/`([^`]+)`/u); const command = commandMatch?.[1]?.trim(); @@ -92,7 +82,7 @@ function extractCommandChoice(text: string): DirectiveChoice[] { return []; } - const signature = commandSignature(command); + const signature = canonicalCommandSignature(command); if (!signature) { return []; } @@ -145,12 +135,145 @@ function extractDirectiveChoices(operation: MemoryOperation): DirectiveChoice[] return extractCommandChoice(operation.summary); } + if (operation.topic === "reference") { + return extractReferenceChoices(operation.summary); + } + + if (operation.topic === "architecture") { + return extractArchitectureChoices(operation.summary); + } + + if (operation.topic === "debugging") { + return extractDebuggingChoices(operation.summary); + } + + if (operation.topic === "patterns") { + return extractPatternChoices(operation.summary); + } + return [ ...extractValueChoice(operation.summary, packageManagerValues, "package-manager"), ...extractValueChoice(operation.summary, repoSearchValues, "repo-search") ]; } +function normalizeReferenceUrl(url: string): string { + return url.replace(/[),.;]+$/u, "").trim().toLowerCase(); +} + +function extractReferenceChoices(text: string): DirectiveChoice[] { + const normalized = text.toLowerCase(); + const urlMatch = text.match(/https?:\/\/[^\s)]+/iu)?.[0]; + const url = urlMatch ? normalizeReferenceUrl(urlMatch) : null; + const category = + /\bdashboard\b|仪表盘/u.test(normalized) + ? "dashboard" + : /\brunbook\b|操作手册|run book/u.test(normalized) + ? "runbook" + : /\bdoc(?:s|umentation)?\b|文档/u.test(normalized) + ? "docs" + : /\b(?:linear|jira|issue tracker|issues?)\b|缺陷追踪|问题追踪/u.test(normalized) + ? "issue-tracker" + : "pointer"; + + if (url) { + return [ + { + key: `reference:${category}`, + value: url + } + ]; + } + + const trackerMatch = normalized.match(/\b(linear|jira|github issues?)\b/iu)?.[1]; + if (trackerMatch) { + return [ + { + key: `reference:${category}`, + value: trackerMatch.toLowerCase() + } + ]; + } + + return []; +} + +function extractArchitectureChoices(text: string): DirectiveChoice[] { + const normalized = text.toLowerCase(); + if (!/\b(canonical|source of truth|db-first|markdown-first|database-first)\b|规范存储|主真相/u.test(text)) { + return []; + } + + if (/markdown-first|markdown.*source of truth|markdown.*canonical/u.test(normalized)) { + return [ + { + key: "architecture:canonical-store", + value: "markdown" + } + ]; + } + + const choice = extractValueChoice(normalized, canonicalStoreValues, "architecture:canonical-store"); + if (choice.length > 0) { + return choice; + } + + if (/db-first|database-first|数据库优先/u.test(normalized)) { + return [ + { + key: "architecture:canonical-store", + value: "database" + } + ]; + } + + return []; +} + +function extractDebuggingChoices(text: string): DirectiveChoice[] { + const normalized = text.toLowerCase(); + if (!/\b(requires?|needs?|start|before running)\b|需要|必须|先启动/u.test(text)) { + return []; + } + + for (const value of debuggingDependencyValues) { + const pattern = new RegExp(`\\b${escapeRegExp(value)}\\b`, "iu"); + if (pattern.test(normalized)) { + return [ + { + key: "debugging:required-service", + value + } + ]; + } + } + + return []; +} + +function extractPatternChoices(text: string): DirectiveChoice[] { + const normalized = text.toLowerCase().replace(/\s+/gu, " "); + if (/search\s*->\s*timeline\s*->\s*details/u.test(normalized)) { + return [ + { + key: "patterns:retrieval-flow", + value: "search->timeline->details" + } + ]; + } + + if (/mcp\s*->\s*local bridge\s*->\s*resolved cli/u.test(normalized)) { + return [ + { + key: "patterns:route-order", + value: "mcp->local-bridge->resolved-cli" + } + ]; + } + + return []; +} + function choicesConflict(left: DirectiveChoice[], right: DirectiveChoice[]): boolean { return left.some((leftChoice) => right.some( @@ -190,16 +313,47 @@ function findPreferredWithinRolloutWinner( return highConfidenceReviews[0] ?? null; } -function hasRetainedHighConfidenceCandidate( +function entryDirectiveChoices(entry: MemoryEntry): DirectiveChoice[] { + return extractDirectiveChoices({ + action: "upsert", + scope: entry.scope, + topic: entry.topic, + id: entry.id, + summary: entry.summary, + details: entry.details, + sources: entry.sources, + reason: entry.reason + }); +} + +function shouldKeepReplacementDelete( + operation: MemoryOperation, reviews: CandidateReview[], retainedIndices: Set, - groupKey: string + existingEntries: MemoryEntry[] ): boolean { + const targetEntry = existingEntries.find( + (entry) => + entry.scope === operation.scope && + entry.topic === operation.topic && + entry.id === operation.id + ); + if (!targetEntry) { + return true; + } + + const targetChoices = entryDirectiveChoices(targetEntry); + if (targetChoices.length === 0) { + return true; + } + return reviews.some( (review) => - review.groupKey === groupKey && + retainedIndices.has(review.index) && review.highConfidence && - retainedIndices.has(review.index) + review.operation.scope === operation.scope && + review.operation.topic === operation.topic && + choicesConflict(review.choices, targetChoices) ); } @@ -323,17 +477,6 @@ export function reviewExtractedMemoryOperations( } } - const groupsNeedingDeleteSuppression = new Set(); - for (const review of reviews) { - if (!suppressedIndices.has(review.index)) { - continue; - } - - if (!hasRetainedHighConfidenceCandidate(reviews, retainedIndices, review.groupKey)) { - groupsNeedingDeleteSuppression.add(review.groupKey); - } - } - const keptOperations = operations.filter((operation, index) => { if (suppressedIndices.has(index)) { return false; @@ -343,7 +486,7 @@ export function reviewExtractedMemoryOperations( return true; } - return !groupsNeedingDeleteSuppression.has(buildGroupKey(operation.scope, operation.topic)); + return shouldKeepReplacementDelete(operation, reviews, retainedIndices, existingEntries); }); return { diff --git a/src/lib/extractor/heuristic-extractor.ts b/src/lib/extractor/heuristic-extractor.ts index a4d983e..473fdfe 100644 --- a/src/lib/extractor/heuristic-extractor.ts +++ b/src/lib/extractor/heuristic-extractor.ts @@ -253,10 +253,6 @@ function extractExplicitCorrection(message: string): ExplicitCorrection | null { pattern: /^(?:actually\s+)?prefer\s+(.+?)\s+over\s+(.+)$/iu, staleIndex: 2 }, - { - pattern: /^(?:actually\s+)?run\s+(.+?),\s*not\s+(.+)$/iu, - staleIndex: 2 - }, { pattern: /^(?:actually\s+)?keep\s+(.+?),\s*not\s+(.+)$/iu, staleIndex: 2 @@ -334,22 +330,11 @@ function collectExplicitCorrectionDeleteTargets( return haystack.includes(staleNeedle); }); - if (correction.topic === "commands") { + if (directCandidates.length <= 1) { return directCandidates; } const contextTokens = summaryTokens.filter((token) => !staleTokens.has(token)); - if (directCandidates.length <= 1) { - if (directCandidates.length === 0 || contextTokens.length === 0) { - return directCandidates; - } - - return directCandidates.filter((entry) => { - const haystack = normalizeForComparison(`${entry.summary}\n${entry.details.join("\n")}`); - return contextTokens.some((token) => haystack.includes(token)); - }); - } - if (contextTokens.length < 2) { return []; } diff --git a/test/command-signatures.test.ts b/test/command-signatures.test.ts index da6c3d3..d063a8e 100644 --- a/test/command-signatures.test.ts +++ b/test/command-signatures.test.ts @@ -21,9 +21,4 @@ describe("canonicalCommandSignature", () => { expect(canonicalCommandSignature("bun run build")).toBe("bun:build"); expect(canonicalCommandSignature("yarn run check")).toBe("yarn:check"); }); - - it("normalizes common wrapped and shorthand test commands that extractor already accepts", () => { - expect(canonicalCommandSignature("pnpm -C packages/app test")).toBe("pnpm:test"); - expect(canonicalCommandSignature("nextest run")).toBe("cargo-nextest:test"); - }); }); diff --git a/test/extractor.test.ts b/test/extractor.test.ts index 7093f1d..4051d90 100644 --- a/test/extractor.test.ts +++ b/test/extractor.test.ts @@ -1157,46 +1157,6 @@ describe("HeuristicExtractor", () => { ); }); - it("treats remember-style reference corrections as explicit replacements end-to-end", async () => { - const extractor = new HeuristicExtractor(); - const existingEntries: MemoryEntry[] = [ - { - id: "auth-runbook", - scope: "project", - topic: "reference", - summary: "The auth runbook lives at https://old.example.com/auth-runbook", - details: ["Old auth runbook pointer."], - updatedAt: "2026-03-14T00:00:00.000Z", - sources: ["old"] - } - ]; - - const operations = await extractor.extract( - baseEvidence({ - userMessages: [ - "remember that the auth runbook lives at https://docs.example.com/auth-runbook instead of https://old.example.com/auth-runbook" - ] - }), - existingEntries - ); - const reviewed = reviewExtractedMemoryOperations(operations, existingEntries); - - expect(reviewed.operations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - action: "delete", - topic: "reference", - id: "auth-runbook" - }), - expect.objectContaining({ - action: "upsert", - topic: "reference", - reason: "Explicit user correction that should replace stale memory." - }) - ]) - ); - }); - it("extracts explicit architecture corrections so they can replace stale canonical-store memory", async () => { const extractor = new HeuristicExtractor(); const existingEntries: MemoryEntry[] = [ @@ -1273,95 +1233,6 @@ describe("HeuristicExtractor", () => { ]) ); }); - - it("keeps scoped exceptions when a repo-wide correction only overlaps a docs-specific note", async () => { - const extractor = new HeuristicExtractor(); - const existingEntries: MemoryEntry[] = [ - { - id: "docs-use-npm", - scope: "project", - topic: "preferences", - summary: "Use npm for docs examples.", - details: ["Docs snippets still use npm commands."], - updatedAt: "2026-03-14T00:00:00.000Z", - sources: ["old"] - } - ]; - - const operations = await extractor.extract( - baseEvidence({ - userMessages: ["we use pnpm, not npm"] - }), - existingEntries - ); - - expect(operations).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - action: "delete", - id: "docs-use-npm" - }) - ]) - ); - expect(operations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - action: "upsert", - summary: "we use pnpm, not npm" - }) - ]) - ); - }); - - it("does not delete cross-scope command memories for remember-style command corrections", async () => { - const extractor = new HeuristicExtractor(); - const existingEntries: MemoryEntry[] = [ - { - id: "global-npm-test", - scope: "global", - topic: "commands", - summary: "Run `npm test` to verify projects.", - details: ["Global command note."], - updatedAt: "2026-03-14T00:00:00.000Z", - sources: ["old"] - }, - { - id: "project-npm-test", - scope: "project", - topic: "commands", - summary: "Run `npm test` to verify this repository.", - details: ["Project command note."], - updatedAt: "2026-03-14T00:00:00.000Z", - sources: ["old"] - } - ]; - - const operations = await extractor.extract( - baseEvidence({ - userMessages: ["remember that run `pnpm test`, not `npm test`"] - }), - existingEntries - ); - - expect(operations).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - action: "delete", - scope: "global", - id: "global-npm-test" - }) - ]) - ); - expect(operations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - action: "delete", - scope: "project", - id: "project-npm-test" - }) - ]) - ); - }); }); describe("safety filter", () => { diff --git a/test/session-command.test.ts b/test/session-command.test.ts index 96d87d6..64eb54e 100644 --- a/test/session-command.test.ts +++ b/test/session-command.test.ts @@ -542,6 +542,13 @@ describe("runSession", () => { const statusPayload = JSON.parse(statusResult.stdout) as { projectLocation: { exists: boolean; path: string }; localLocation: { exists: boolean; path: string }; + startup: { + sourceFiles: string[]; + candidateSourceFiles: string[]; + continuityMode: string; + continuityProvenanceKind: string; + continuitySectionKinds: string[]; + }; }; expect(loadPayload.startup.text).toContain("# Session Continuity"); @@ -560,6 +567,11 @@ describe("runSession", () => { kind: "session-summary-placeholder", rebuildsStartupSections: true }); + expect(statusPayload.startup.sourceFiles).toEqual([statusPayload.projectLocation.path]); + expect(statusPayload.startup.candidateSourceFiles).toEqual([statusPayload.projectLocation.path]); + expect(statusPayload.startup.continuityMode).toBe("startup"); + expect(statusPayload.startup.continuityProvenanceKind).toBe("temporary-continuity"); + expect(statusPayload.startup.continuitySectionKinds).toContain("sources"); expect(statusPayload.projectLocation.exists).toBe(true); expect(statusPayload.localLocation.exists).toBe(false); }, 30_000); From c76a19c52b98eb8c4836752bc2f1bfd32e381b76 Mon Sep 17 00:00:00 2001 From: blocks Date: Mon, 6 Apr 2026 22:08:01 +0800 Subject: [PATCH 33/62] feat: harden directive extraction and continuity conflict hints (cherry picked from commit 11938b977b43654c48261b73eff06ea72d7b108c) --- src/lib/extractor/contradiction-review.ts | 38 +++++-- src/lib/extractor/heuristic-extractor.ts | 21 +++- .../extractor/session-continuity-evidence.ts | 103 +++++++++++------- test/extractor.test.ts | 69 ++++++++++++ 4 files changed, 180 insertions(+), 51 deletions(-) diff --git a/src/lib/extractor/contradiction-review.ts b/src/lib/extractor/contradiction-review.ts index 4c7ada5..ce92f74 100644 --- a/src/lib/extractor/contradiction-review.ts +++ b/src/lib/extractor/contradiction-review.ts @@ -232,23 +232,39 @@ function extractArchitectureChoices(text: string): DirectiveChoice[] { function extractDebuggingChoices(text: string): DirectiveChoice[] { const normalized = text.toLowerCase(); - if (!/\b(requires?|needs?|start|before running)\b|需要|必须|先启动/u.test(text)) { - return []; - } + const choices: DirectiveChoice[] = []; for (const value of debuggingDependencyValues) { const pattern = new RegExp(`\\b${escapeRegExp(value)}\\b`, "iu"); - if (pattern.test(normalized)) { - return [ - { - key: "debugging:required-service", - value - } - ]; + if (!pattern.test(normalized)) { + continue; + } + + if ( + /\b(?:does not require|doesn't require|is not required|not required|without)\b|不需要|无需/u.test( + text + ) + ) { + choices.push({ + key: `debugging:required-service:${value}`, + value: "not-required" + }); + continue; + } + + if ( + /\b(requires?|needs?|start|before running|must be running|running before|before integration tests)\b|需要|必须|先启动/u.test( + text + ) + ) { + choices.push({ + key: `debugging:required-service:${value}`, + value: "required" + }); } } - return []; + return choices; } function extractPatternChoices(text: string): DirectiveChoice[] { diff --git a/src/lib/extractor/heuristic-extractor.ts b/src/lib/extractor/heuristic-extractor.ts index 473fdfe..5579e2d 100644 --- a/src/lib/extractor/heuristic-extractor.ts +++ b/src/lib/extractor/heuristic-extractor.ts @@ -36,7 +36,14 @@ const assistantNoisePatterns = [ /下一步/u ] as const; -const stableDirectiveTopics = new Set(["reference", "architecture", "debugging", "patterns"]); +const stableDirectiveTopics = new Set([ + "reference", + "architecture", + "debugging", + "patterns", + "preferences", + "commands" +]); function isAllowedMemoryTopic(topic: string): topic is (typeof DEFAULT_MEMORY_TOPICS)[number] { return DEFAULT_MEMORY_TOPICS.includes(topic as (typeof DEFAULT_MEMORY_TOPICS)[number]); @@ -188,6 +195,18 @@ function isStableDirectiveSummary(topic: string, summary: string): boolean { return /\b(requires?|needs?|must be running|must run|must start|before running|before integration tests)\b|需要|必须|先启动/u.test( summary ); + case "preferences": + return ( + /\b(?:we\s+use|use|prefer|always use)\b.*\b(?:pnpm|npm|yarn|bun|rg|ripgrep|grep)\b/iu.test( + summary + ) || /(?:使用|用|优先用|优先使用).*(?:pnpm|npm|yarn|bun|rg|ripgrep|grep)/u.test(summary) + ); + case "commands": + return ( + /^run\s+`[^`]+`/iu.test(summary) || + /^use\s+`[^`]+`/iu.test(summary) || + /^运行\s*`[^`]+`/u.test(summary) + ); case "patterns": return ( /search\s*->\s*timeline\s*->\s*details/iu.test(summary) || diff --git a/src/lib/extractor/session-continuity-evidence.ts b/src/lib/extractor/session-continuity-evidence.ts index 25bc22b..40deae0 100644 --- a/src/lib/extractor/session-continuity-evidence.ts +++ b/src/lib/extractor/session-continuity-evidence.ts @@ -2,6 +2,7 @@ import path from "node:path"; import type { RolloutEvidence, RolloutToolCall, + RolloutTranscriptMessage, SessionContinuityEvidenceCounts } from "../types.js"; import { trimText } from "../util/text.js"; @@ -146,9 +147,6 @@ export function looksLocalSpecific(text: string): boolean { return ( repoRelativePathPatterns.some((pattern) => pattern.test(text)) || /(?:^[A-Za-z]:|[\s"'`(])\\[\w.-]+/u.test(text) || - /\b[a-z0-9_.-]+\.(?:ts|tsx|js|jsx|json|md|yml|yaml|toml|css|scss|sql|py|go|rs|sh)\b/iu.test( - text - ) || /\b(worktree|branch|this branch|local only|locally|current branch|当前分支|本地工作树)\b/iu.test( text ) @@ -263,6 +261,42 @@ export function summarizeFileWrite(toolCall: RolloutToolCall, cwd?: string): str return `File modified: ${trimText(displayPath, 120)}`; } +function trimmedTranscriptMessages(messages: RolloutTranscriptMessage[]): RolloutTranscriptMessage[] { + return messages + .map((entry) => ({ + role: entry.role, + message: trimText(entry.message, 240) + })) + .filter((entry) => entry.message.length > 0); +} + +export function collectRecentTranscriptMessages( + evidence: RolloutEvidence, + maxMessages = 20 +): RolloutTranscriptMessage[] { + const orderedMessages = trimmedTranscriptMessages(evidence.orderedMessages ?? []); + if (orderedMessages.length > 0) { + return orderedMessages.slice(-maxMessages); + } + + const recentUserMessages = evidence.userMessages + .map((message) => trimText(message, 240)) + .filter(Boolean) + .map((message) => ({ + role: "user" as const, + message + })); + const recentAgentMessages = evidence.agentMessages + .map((message) => trimText(message, 240)) + .filter(Boolean) + .map((message) => ({ + role: "agent" as const, + message + })); + + return [...recentAgentMessages.slice(-10), ...recentUserMessages.slice(-10)]; +} + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -273,13 +307,6 @@ interface DirectiveSignal { authoritative: boolean; } -function splitDirectiveClauses(text: string): string[] { - return text - .split(/\b(?:but|and)\b|[,,;;。]/u) - .map((clause) => clause.trim()) - .filter(Boolean); -} - function shouldWarnForConflictingDirectiveKey(key: string): boolean { return ( key === "package-manager" || @@ -287,7 +314,6 @@ function shouldWarnForConflictingDirectiveKey(key: string): boolean { key === "canonical-store" || key === "retrieval-flow" || key === "route-order" || - key === "required-service" || key.startsWith("reference-pointer:") || key.startsWith("required-service:") ); @@ -349,39 +375,38 @@ function extractArchitectureSignal(text: string): DirectiveSignal | null { } function extractDebuggingSignals(text: string): DirectiveSignal[] { + const normalized = text.toLowerCase(); const signals: DirectiveSignal[] = []; for (const value of debuggingDependencyValues) { const servicePattern = new RegExp(`\\b${escapeRegExp(value)}\\b`, "iu"); - for (const clause of splitDirectiveClauses(text)) { - if (!servicePattern.test(clause.toLowerCase())) { - continue; - } + if (!servicePattern.test(normalized)) { + continue; + } - if ( - /\b(?:does not require|doesn't require|is not required|not required|without)\b|不需要|无需/u.test( - clause - ) - ) { - signals.push({ - key: `required-service:${value}`, - value: "not-required", - authoritative: false - }); - continue; - } + if ( + /\b(?:does not require|doesn't require|is not required|not required|without)\b|不需要|无需/u.test( + text + ) + ) { + signals.push({ + key: `required-service:${value}`, + value: "not-required", + authoritative: false + }); + continue; + } - if ( - /\b(requires?|needs?|start|before running|must be running|running before|before integration tests)\b|需要|必须|先启动/u.test( - clause - ) - ) { - signals.push({ - key: `required-service:${value}`, - value: "required", - authoritative: false - }); - } + if ( + /\b(requires?|needs?|start|before running|must be running|running before|before integration tests)\b|需要|必须|先启动/u.test( + text + ) + ) { + signals.push({ + key: `required-service:${value}`, + value: "required", + authoritative: false + }); } } @@ -562,7 +587,7 @@ export function collectSessionContinuityEvidenceBuckets( ): SessionContinuityEvidenceBuckets { const recentUserMessages = evidence.userMessages.map((message) => trimText(message, 240)); const recentAgentMessages = evidence.agentMessages.map((message) => trimText(message, 240)); - const recentMessages = [...recentAgentMessages.slice(-10), ...recentUserMessages.slice(-10)]; + const recentMessages = collectRecentTranscriptMessages(evidence).map((entry) => entry.message); const recentMessagesReversed = [...recentMessages].reverse(); const recentSuccessfulCommands = evidence.toolCalls diff --git a/test/extractor.test.ts b/test/extractor.test.ts index 4051d90..efe6183 100644 --- a/test/extractor.test.ts +++ b/test/extractor.test.ts @@ -257,6 +257,34 @@ describe("HeuristicExtractor", () => { ); }); + it("extracts stable directive-style preferences and commands without an explicit remember prefix", async () => { + const extractor = new HeuristicExtractor(); + const operations = await extractor.extract( + baseEvidence({ + userMessages: [ + "Use pnpm instead of npm in this repository.", + "Run `pnpm test` to verify this repository." + ] + }), + [] + ); + + expect(operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "upsert", + topic: "preferences", + summary: "Use pnpm instead of npm in this repository" + }), + expect.objectContaining({ + action: "upsert", + topic: "commands", + summary: "Run `pnpm test` to verify this repository" + }) + ]) + ); + }); + it("treats bash-named tool calls with expanded success output as reusable commands", async () => { const extractor = new HeuristicExtractor(); const operations = await extractor.extract( @@ -1195,6 +1223,47 @@ describe("HeuristicExtractor", () => { ); }); + it("keeps additive debugging prerequisites for different required services", () => { + const reviewed = reviewExtractedMemoryOperations( + [ + { + action: "upsert", + scope: "project", + topic: "debugging", + id: "redis-required", + summary: "Redis must be running before integration tests", + details: ["Start Redis before running the integration suite."], + reason: "Stable directive extracted from the session." + }, + { + action: "upsert", + scope: "project", + topic: "debugging", + id: "postgres-required", + summary: "Postgres must be running before integration tests", + details: ["Start Postgres before running the integration suite."], + reason: "Stable directive extracted from the session." + } + ], + [] + ); + + expect(reviewed.suppressedOperationCount).toBe(0); + expect(reviewed.conflicts).toEqual([]); + expect(reviewed.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "upsert", + id: "redis-required" + }), + expect.objectContaining({ + action: "upsert", + id: "postgres-required" + }) + ]) + ); + }); + it("suppresses hedged reference updates that conflict with existing durable pointers", () => { const reviewed = reviewExtractedMemoryOperations( [ From a418c972d61c3f34d119972bfdff4bdbe3489ad2 Mon Sep 17 00:00:00 2001 From: blocks Date: Wed, 8 Apr 2026 23:50:55 +0800 Subject: [PATCH 34/62] fix: preserve directive clause parsing and reference topics --- src/lib/constants.ts | 1 + src/lib/extractor/command-signatures.ts | 6 -- src/lib/extractor/directive-utils.ts | 83 +++++++++++++++++++ .../extractor/session-continuity-evidence.ts | 54 ++++++------ 4 files changed, 112 insertions(+), 32 deletions(-) create mode 100644 src/lib/extractor/directive-utils.ts diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 2ad0a15..1424b88 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -7,6 +7,7 @@ export const DEFAULT_MEMORY_TOPICS = [ "commands", "debugging", "architecture", + "reference", "workflow", "preferences", "patterns" diff --git a/src/lib/extractor/command-signatures.ts b/src/lib/extractor/command-signatures.ts index 0739053..ddfc16b 100644 --- a/src/lib/extractor/command-signatures.ts +++ b/src/lib/extractor/command-signatures.ts @@ -1,11 +1,5 @@ export function canonicalCommandSignature(command: string): string | null { const normalized = command.toLowerCase().trim(); - const lifecycleScriptPattern = /^(pnpm|npm|bun|yarn)\s+run\s+(test|lint|build|install|check)\b/u; - const lifecycleRunMatch = normalized.match(lifecycleScriptPattern); - if (lifecycleRunMatch?.[1] && lifecycleRunMatch[2]) { - return `${lifecycleRunMatch[1]}:${lifecycleRunMatch[2]}`; - } - const normalizedCommand = normalized .replace(/^(pnpm|npm|bun|yarn)\s+-[cC]\s+\S+\s+/u, "$1 ") .replace(/^(pnpm|npm|bun|yarn)\s+exec\s+/u, "") diff --git a/src/lib/extractor/directive-utils.ts b/src/lib/extractor/directive-utils.ts new file mode 100644 index 0000000..6edd2c9 --- /dev/null +++ b/src/lib/extractor/directive-utils.ts @@ -0,0 +1,83 @@ +import { slugify } from "../util/text.js"; + +const genericReferenceTokens = new Set([ + "runbook", + "dashboard", + "docs", + "doc", + "documentation", + "pointer", + "issue-tracker", + "issues", + "issue" +]); + +function trimReferencePrefix(value: string): string { + return value + .trim() + .replace( + /^(?:(?:actually|maybe|perhaps|probably)\s+)*(?:use|open|check|see|read|follow|visit)?\s*(?:the|our|this|that|current|latest)?\s*/iu, + "" + ) + .trim(); +} + +function resourceTokenFromUrl(url: string, category: string): string | null { + try { + const parsed = new URL(url); + const pathTokens = parsed.pathname + .split("/") + .map((segment) => slugify(segment)) + .filter(Boolean); + const tailToken = [...pathTokens].reverse().find(Boolean); + if (tailToken && !genericReferenceTokens.has(tailToken)) { + return tailToken; + } + + if (tailToken) { + return tailToken; + } + } catch { + // Ignore invalid URL parsing and fall through to text-derived heuristics. + } + + return category; +} + +export function splitDirectiveClauses(text: string): string[] { + return text + .split(/\s*(?:,|;|,|;|\bbut\b|\bhowever\b|但是|但|不过)\s*/iu) + .map((clause) => clause.trim()) + .filter(Boolean); +} + +export function extractReferenceResourceKey( + text: string, + category: string, + url?: string | null +): string | null { + const normalizedText = text.toLowerCase(); + const nounPattern = + category === "runbook" + ? /([a-z0-9][a-z0-9 -]{0,80})\s+runbook\b/iu + : category === "dashboard" + ? /([a-z0-9][a-z0-9 -]{0,80})\s+dashboard\b/iu + : category === "docs" + ? /([a-z0-9][a-z0-9 -]{0,80})\s+docs?\b/iu + : category === "issue-tracker" + ? /([a-z0-9][a-z0-9 -]{0,80})\s+(?:issue tracker|issues?)\b/iu + : null; + const nounMatch = nounPattern?.exec(normalizedText)?.[1]; + if (nounMatch) { + const normalized = slugify(trimReferencePrefix(nounMatch)); + if (normalized && !genericReferenceTokens.has(normalized)) { + return normalized; + } + } + + if (url) { + return resourceTokenFromUrl(url, category); + } + + return category; +} diff --git a/src/lib/extractor/session-continuity-evidence.ts b/src/lib/extractor/session-continuity-evidence.ts index 40deae0..21e8ccf 100644 --- a/src/lib/extractor/session-continuity-evidence.ts +++ b/src/lib/extractor/session-continuity-evidence.ts @@ -12,6 +12,7 @@ import { extractCommand, isCommandToolCall } from "./command-utils.js"; +import { splitDirectiveClauses } from "./directive-utils.js"; const FILE_WRITE_PATTERNS = ["apply_patch", "write_file", "create_file", "edit_file"]; @@ -375,38 +376,39 @@ function extractArchitectureSignal(text: string): DirectiveSignal | null { } function extractDebuggingSignals(text: string): DirectiveSignal[] { - const normalized = text.toLowerCase(); const signals: DirectiveSignal[] = []; for (const value of debuggingDependencyValues) { const servicePattern = new RegExp(`\\b${escapeRegExp(value)}\\b`, "iu"); - if (!servicePattern.test(normalized)) { - continue; - } + for (const clause of splitDirectiveClauses(text)) { + if (!servicePattern.test(clause.toLowerCase())) { + continue; + } - if ( - /\b(?:does not require|doesn't require|is not required|not required|without)\b|不需要|无需/u.test( - text - ) - ) { - signals.push({ - key: `required-service:${value}`, - value: "not-required", - authoritative: false - }); - continue; - } + if ( + /\b(?:does not require|doesn't require|is not required|not required|without)\b|不需要|无需/u.test( + clause + ) + ) { + signals.push({ + key: `required-service:${value}`, + value: "not-required", + authoritative: false + }); + continue; + } - if ( - /\b(requires?|needs?|start|before running|must be running|running before|before integration tests)\b|需要|必须|先启动/u.test( - text - ) - ) { - signals.push({ - key: `required-service:${value}`, - value: "required", - authoritative: false - }); + if ( + /\b(requires?|needs?|start|before running|must be running|running before|before integration tests)\b|需要|必须|先启动/u.test( + clause + ) + ) { + signals.push({ + key: `required-service:${value}`, + value: "required", + authoritative: false + }); + } } } From 4b7027f3d5cea55a68ffc26f37513d6f9d40a6bc Mon Sep 17 00:00:00 2001 From: blocks Date: Thu, 9 Apr 2026 00:27:07 +0800 Subject: [PATCH 35/62] fix: refine extractor conflict and continuity semantics --- src/lib/extractor/contradiction-review.ts | 109 +++++--- src/lib/extractor/heuristic-extractor.ts | 126 ++++++--- .../extractor/session-continuity-evidence.ts | 5 +- test/extractor.test.ts | 263 ++++++++++++++++++ test/session-continuity.test.ts | 148 ++++++++-- test/sync-service.test.ts | 28 +- 6 files changed, 591 insertions(+), 88 deletions(-) diff --git a/src/lib/extractor/contradiction-review.ts b/src/lib/extractor/contradiction-review.ts index ce92f74..b28d961 100644 --- a/src/lib/extractor/contradiction-review.ts +++ b/src/lib/extractor/contradiction-review.ts @@ -5,6 +5,7 @@ import type { MemoryScope } from "../types.js"; import { canonicalCommandSignature } from "./command-signatures.js"; +import { extractReferenceResourceKey, splitDirectiveClauses } from "./directive-utils.js"; interface DirectiveChoice { key: string; @@ -55,7 +56,10 @@ function isHighConfidenceReplacement(operation: MemoryOperation): boolean { return false; } - if (operation.reason === "Explicit user correction that should replace stale memory.") { + if ( + operation.reason === "Explicit user correction that should replace stale memory." || + operation.reason === "Stable directive that should replace stale memory." + ) { return !hedgedCorrectionPattern.test(operation.summary ?? ""); } @@ -90,7 +94,7 @@ function extractCommandChoice(text: string): DirectiveChoice[] { return [ { key: `command:${signature}`, - value: command.toLowerCase() + value: signature } ]; } @@ -177,9 +181,10 @@ function extractReferenceChoices(text: string): DirectiveChoice[] { : "pointer"; if (url) { + const resourceKey = extractReferenceResourceKey(text, category, url) ?? category; return [ { - key: `reference:${category}`, + key: `reference:${category}:${resourceKey}`, value: url } ]; @@ -187,9 +192,10 @@ function extractReferenceChoices(text: string): DirectiveChoice[] { const trackerMatch = normalized.match(/\b(linear|jira|github issues?)\b/iu)?.[1]; if (trackerMatch) { + const resourceKey = extractReferenceResourceKey(text, category, null) ?? category; return [ { - key: `reference:${category}`, + key: `reference:${category}:${resourceKey}`, value: trackerMatch.toLowerCase() } ]; @@ -231,36 +237,37 @@ function extractArchitectureChoices(text: string): DirectiveChoice[] { } function extractDebuggingChoices(text: string): DirectiveChoice[] { - const normalized = text.toLowerCase(); const choices: DirectiveChoice[] = []; for (const value of debuggingDependencyValues) { const pattern = new RegExp(`\\b${escapeRegExp(value)}\\b`, "iu"); - if (!pattern.test(normalized)) { - continue; - } + for (const clause of splitDirectiveClauses(text)) { + if (!pattern.test(clause.toLowerCase())) { + continue; + } - if ( - /\b(?:does not require|doesn't require|is not required|not required|without)\b|不需要|无需/u.test( - text - ) - ) { - choices.push({ - key: `debugging:required-service:${value}`, - value: "not-required" - }); - continue; - } + if ( + /\b(?:does not require|doesn't require|is not required|not required|without)\b|不需要|无需/u.test( + clause + ) + ) { + choices.push({ + key: `debugging:required-service:${value}`, + value: "not-required" + }); + continue; + } - if ( - /\b(requires?|needs?|start|before running|must be running|running before|before integration tests)\b|需要|必须|先启动/u.test( - text - ) - ) { - choices.push({ - key: `debugging:required-service:${value}`, - value: "required" - }); + if ( + /\b(requires?|needs?|start|before running|must be running|running before|before integration tests)\b|需要|必须|先启动/u.test( + clause + ) + ) { + choices.push({ + key: `debugging:required-service:${value}`, + value: "required" + }); + } } } @@ -299,6 +306,19 @@ function choicesConflict(left: DirectiveChoice[], right: DirectiveChoice[]): boo ); } +function choicesEqual(left: DirectiveChoice[], right: DirectiveChoice[]): boolean { + if (left.length !== right.length) { + return false; + } + + return left.every((leftChoice) => + right.some( + (rightChoice) => + leftChoice.key === rightChoice.key && leftChoice.value === rightChoice.value + ) + ); +} + function buildConflictCandidate( operation: MemoryOperation, source: MemoryConflictCandidate["source"], @@ -369,7 +389,8 @@ function shouldKeepReplacementDelete( review.highConfidence && review.operation.scope === operation.scope && review.operation.topic === operation.topic && - choicesConflict(review.choices, targetChoices) + (choicesConflict(review.choices, targetChoices) || + choicesEqual(review.choices, targetChoices)) ); } @@ -407,7 +428,8 @@ export function reviewExtractedMemoryOperations( choices, highConfidence: isHighConfidenceReplacement(operation) || - (operation.topic === "commands" && hasCommandReplacementDelete(operations, operation)) + (operation.topic === "commands" && + hasCommandReplacementDelete(operations, operation)) }; }) .filter((review): review is CandidateReview => Boolean(review)); @@ -421,13 +443,34 @@ export function reviewExtractedMemoryOperations( } const suppressedIndices = new Set(); + const dedupedIndices = new Set(); const retainedIndices = new Set(reviews.map((review) => review.index)); const conflicts: MemoryConflictCandidate[] = []; for (const review of reviews) { + const equivalentReviews = reviews + .filter( + (candidate) => + candidate.index !== review.index && + candidate.groupKey === review.groupKey && + choicesEqual(review.choices, candidate.choices) + ) + .sort((left, right) => right.index - left.index); + const preferredEquivalent = equivalentReviews[0]; + if (preferredEquivalent && preferredEquivalent.index > review.index) { + dedupedIndices.add(review.index); + retainedIndices.delete(review.index); + } + } + + for (const review of reviews) { + if (dedupedIndices.has(review.index)) { + continue; + } const conflictingReviews = reviews .filter( (candidate) => + !dedupedIndices.has(candidate.index) && candidate.index !== review.index && candidate.groupKey === review.groupKey && choicesConflict(review.choices, candidate.choices) @@ -498,6 +541,10 @@ export function reviewExtractedMemoryOperations( return false; } + if (dedupedIndices.has(index)) { + return false; + } + if (!isReplacementDelete(operation)) { return true; } @@ -507,7 +554,7 @@ export function reviewExtractedMemoryOperations( return { operations: keptOperations, - suppressedOperationCount: operations.length - keptOperations.length, + suppressedOperationCount: suppressedIndices.size, conflicts }; } diff --git a/src/lib/extractor/heuristic-extractor.ts b/src/lib/extractor/heuristic-extractor.ts index 5579e2d..b6e0188 100644 --- a/src/lib/extractor/heuristic-extractor.ts +++ b/src/lib/extractor/heuristic-extractor.ts @@ -1,9 +1,11 @@ import { DEFAULT_MEMORY_TOPICS } from "../constants.js"; +import { matchesAllMemoryQueryTerms } from "../domain/memory-query.js"; import type { MemoryEntry, MemoryOperation, RolloutEvidence } from "../types.js"; import type { MemoryExtractorAdapter } from "../runtime/contracts.js"; import { slugify } from "../util/text.js"; import { commandSucceeded, extractCommand, isCommandToolCall } from "./command-utils.js"; import { canonicalCommandSignature } from "./command-signatures.js"; +import { extractReferenceResourceKey } from "./directive-utils.js"; interface ExplicitCorrection { scope: MemoryOperation["scope"]; @@ -245,6 +247,63 @@ function extractStableDirectiveOperation( }; } +function stableDirectiveReplacementKey(topic: string, summary: string): string | null { + if (topic === "reference") { + const urlMatch = summary.match(/https?:\/\/[^\s)]+/iu)?.[0]; + const url = urlMatch?.replace(/[),.;]+$/u, "").trim().toLowerCase(); + return `reference:${extractReferenceResourceKey(summary, "runbook", url) ?? "pointer"}`; + } + + if ( + topic === "architecture" && + /\b(canonical|source of truth|db-first|markdown-first|database-first)\b|规范存储|主真相/u.test( + summary + ) + ) { + return "architecture:canonical-store"; + } + + if (topic === "patterns") { + if (/search\s*->\s*timeline\s*->\s*details/iu.test(summary)) { + return "patterns:retrieval-flow"; + } + + if (/mcp\s*->\s*local bridge\s*->\s*resolved cli/iu.test(summary)) { + return "patterns:route-order"; + } + } + + return null; +} + +function collectStableDirectiveDeleteTargets( + existingEntries: MemoryEntry[], + operation: MemoryOperation +): MemoryEntry[] { + if (operation.action !== "upsert" || !operation.summary) { + return []; + } + + const replacementKey = stableDirectiveReplacementKey(operation.topic, operation.summary); + if (!replacementKey) { + return []; + } + + const candidates = existingEntries.filter((entry) => { + if ( + entry.scope !== operation.scope || + entry.topic !== operation.topic || + normalizeForComparison(entry.summary) === normalizeForComparison(operation.summary ?? "") + ) { + return false; + } + + return stableDirectiveReplacementKey(entry.topic, entry.summary) === replacementKey; + }); + + return candidates.length === 1 ? candidates : []; +} + function extractExplicitCorrection(message: string): ExplicitCorrection | null { const trimmed = trimTrailingPunctuation(stripRememberPrefix(message)); if (!isHighConfidenceExplicitCorrection(trimmed)) { @@ -272,6 +331,10 @@ function extractExplicitCorrection(message: string): ExplicitCorrection | null { pattern: /^(?:actually\s+)?prefer\s+(.+?)\s+over\s+(.+)$/iu, staleIndex: 2 }, + { + pattern: /^(?:actually\s+)?run\s+(.+?),\s*not\s+(.+)$/iu, + staleIndex: 2 + }, { pattern: /^(?:actually\s+)?keep\s+(.+?),\s*not\s+(.+)$/iu, staleIndex: 2 @@ -349,11 +412,22 @@ function collectExplicitCorrectionDeleteTargets( return haystack.includes(staleNeedle); }); - if (directCandidates.length <= 1) { + if (correction.topic === "commands") { return directCandidates; } const contextTokens = summaryTokens.filter((token) => !staleTokens.has(token)); + if (directCandidates.length <= 1) { + if (directCandidates.length === 0 || contextTokens.length === 0) { + return directCandidates; + } + + return directCandidates.filter((entry) => { + const haystack = normalizeForComparison(`${entry.summary}\n${entry.details.join("\n")}`); + return contextTokens.some((token) => haystack.includes(token)); + }); + } + if (contextTokens.length < 2) { return []; } @@ -541,17 +615,11 @@ export class HeuristicExtractor implements MemoryExtractorAdapter { if (forgetMatch?.[1]) { const query = forgetMatch[1].trim().replace(/[。.]$/u, ""); - const matchingEntryKeys = new Set( - overlappingEntriesWithThreshold(existingEntries, query, 1).map((entry) => - buildEntryIdentityKey(entry) - ) - ); for (const entry of existingEntries) { - const haystack = `${entry.id}\n${entry.summary}\n${entry.details.join("\n")}`.toLowerCase(); - if ( - !haystack.includes(query.toLowerCase()) && - !matchingEntryKeys.has(buildEntryIdentityKey(entry)) - ) { + const haystack = [entry.id, entry.topic, entry.summary, entry.details.join("\n")].join( + "\n" + ); + if (!matchesAllMemoryQueryTerms(haystack, query)) { continue; } queueDelete( @@ -605,28 +673,24 @@ export class HeuristicExtractor implements MemoryExtractorAdapter { const stableDirective = extractStableDirectiveOperation(message, evidence.rolloutPath); if (stableDirective) { - const shouldReplaceOverlaps = - stableDirective.reason === "Explicit user correction that should replace stale memory."; - if (shouldReplaceOverlaps) { - for (const entry of overlappingEntries(existingEntries, stableDirective.summary ?? "")) { - if ( - entry.scope !== stableDirective.scope || - entry.topic !== stableDirective.topic || - entry.summary.toLowerCase() === stableDirective.summary?.toLowerCase() - ) { - continue; - } - queueDelete( - operations, - queuedDeleteKeys, - entry, - "Superseded by a newer user correction.", - evidence.rolloutPath - ); - } + const deleteTargets = collectStableDirectiveDeleteTargets(existingEntries, stableDirective); + for (const entry of deleteTargets) { + queueDelete( + operations, + queuedDeleteKeys, + entry, + "Superseded by a newer stable directive.", + evidence.rolloutPath + ); } - queueUpsert(operations, knownOperationKeys, stableDirective); + queueUpsert(operations, knownOperationKeys, { + ...stableDirective, + reason: + deleteTargets.length > 0 + ? "Stable directive that should replace stale memory." + : stableDirective.reason + }); continue; } diff --git a/src/lib/extractor/session-continuity-evidence.ts b/src/lib/extractor/session-continuity-evidence.ts index 21e8ccf..b1c6022 100644 --- a/src/lib/extractor/session-continuity-evidence.ts +++ b/src/lib/extractor/session-continuity-evidence.ts @@ -12,7 +12,7 @@ import { extractCommand, isCommandToolCall } from "./command-utils.js"; -import { splitDirectiveClauses } from "./directive-utils.js"; +import { extractReferenceResourceKey, splitDirectiveClauses } from "./directive-utils.js"; const FILE_WRITE_PATTERNS = ["apply_patch", "write_file", "create_file", "edit_file"]; @@ -336,8 +336,9 @@ function extractReferenceSignal(text: string): DirectiveSignal | null { : "pointer"; if (url) { + const resourceKey = extractReferenceResourceKey(text, category, url) ?? category; return { - key: `reference-pointer:${category}`, + key: `reference-pointer:${category}:${resourceKey}`, value: url, authoritative: false }; diff --git a/test/extractor.test.ts b/test/extractor.test.ts index efe6183..5d4271b 100644 --- a/test/extractor.test.ts +++ b/test/extractor.test.ts @@ -1185,6 +1185,46 @@ describe("HeuristicExtractor", () => { ); }); + it("treats remember-style reference corrections as explicit replacements end-to-end", async () => { + const extractor = new HeuristicExtractor(); + const existingEntries: MemoryEntry[] = [ + { + id: "auth-runbook", + scope: "project", + topic: "reference", + summary: "The auth runbook lives at https://old.example.com/auth-runbook", + details: ["Old auth runbook pointer."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ]; + + const operations = await extractor.extract( + baseEvidence({ + userMessages: [ + "remember that the auth runbook lives at https://docs.example.com/auth-runbook instead of https://old.example.com/auth-runbook" + ] + }), + existingEntries + ); + const reviewed = reviewExtractedMemoryOperations(operations, existingEntries); + + expect(reviewed.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "delete", + topic: "reference", + id: "auth-runbook" + }), + expect.objectContaining({ + action: "upsert", + topic: "reference", + reason: "Explicit user correction that should replace stale memory." + }) + ]) + ); + }); + it("extracts explicit architecture corrections so they can replace stale canonical-store memory", async () => { const extractor = new HeuristicExtractor(); const existingEntries: MemoryEntry[] = [ @@ -1264,6 +1304,71 @@ describe("HeuristicExtractor", () => { ); }); + it("keeps additive same-category runbook pointers when they refer to different resources", () => { + const reviewed = reviewExtractedMemoryOperations( + [ + { + action: "upsert", + scope: "project", + topic: "reference", + id: "auth-runbook", + summary: "The auth runbook lives at https://docs.example.com/auth-runbook", + details: ["Auth runbook pointer."], + reason: "Stable directive extracted from the session." + }, + { + action: "upsert", + scope: "project", + topic: "reference", + id: "billing-runbook", + summary: "The billing runbook lives at https://docs.example.com/billing-runbook", + details: ["Billing runbook pointer."], + reason: "Stable directive extracted from the session." + } + ], + [] + ); + + expect(reviewed.suppressedOperationCount).toBe(0); + expect(reviewed.conflicts).toEqual([]); + expect(reviewed.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "auth-runbook" }), + expect.objectContaining({ id: "billing-runbook" }) + ]) + ); + }); + + it("does not suppress equivalent command aliases that share one canonical signature", () => { + const reviewed = reviewExtractedMemoryOperations( + [ + { + action: "upsert", + scope: "project", + topic: "commands", + id: "pnpm-test", + summary: "Run `pnpm test` to verify this repository.", + details: ["Use `pnpm test` as a repeatable verification command for this project."], + reason: "Stable directive extracted from the session." + }, + { + action: "upsert", + scope: "project", + topic: "commands", + id: "pnpm-run-test", + summary: "Run `pnpm run test` to verify this repository.", + details: ["Use `pnpm run test` as a repeatable verification command for this project."], + reason: "Stable directive extracted from the session." + } + ], + [] + ); + + expect(reviewed.suppressedOperationCount).toBe(0); + expect(reviewed.conflicts).toEqual([]); + expect(reviewed.operations).toHaveLength(1); + }); + it("suppresses hedged reference updates that conflict with existing durable pointers", () => { const reviewed = reviewExtractedMemoryOperations( [ @@ -1302,6 +1407,164 @@ describe("HeuristicExtractor", () => { ]) ); }); + + it("lets a stable non-hedged reference directive replace one stale durable pointer when the target is unambiguous", async () => { + const extractor = new HeuristicExtractor(); + const existingEntries: MemoryEntry[] = [ + { + id: "auth-runbook", + scope: "project", + topic: "reference", + summary: "The auth runbook lives at https://old.example.com/auth-runbook", + details: ["Old auth runbook pointer."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ]; + + const operations = await extractor.extract( + baseEvidence({ + userMessages: ["The auth runbook lives at https://docs.example.com/auth-runbook."] + }), + existingEntries + ); + const reviewed = reviewExtractedMemoryOperations(operations, existingEntries); + + expect(reviewed.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "delete", + topic: "reference", + id: "auth-runbook" + }), + expect.objectContaining({ + action: "upsert", + topic: "reference", + summary: "The auth runbook lives at https://docs.example.com/auth-runbook" + }) + ]) + ); + }); + + it("matches auto-forget queries with the same normalized topic-aware semantics as the CLI surface", async () => { + const extractor = new HeuristicExtractor(); + const existingEntries: MemoryEntry[] = [ + { + id: "prefer-pnpm", + scope: "project", + topic: "workflow", + summary: "Prefer pnpm in this repository.", + details: ["Use pnpm instead of npm in this repository."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ]; + + const operations = await extractor.extract( + baseEvidence({ + userMessages: ["forget workflow"] + }), + existingEntries + ); + + expect(operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "delete", + id: "prefer-pnpm" + }) + ]) + ); + }); + + it("keeps scoped exceptions when a repo-wide correction only overlaps a docs-specific note", async () => { + const extractor = new HeuristicExtractor(); + const existingEntries: MemoryEntry[] = [ + { + id: "docs-use-npm", + scope: "project", + topic: "preferences", + summary: "Use npm for docs examples.", + details: ["Docs snippets still use npm commands."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ]; + + const operations = await extractor.extract( + baseEvidence({ + userMessages: ["we use pnpm, not npm"] + }), + existingEntries + ); + + expect(operations).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "delete", + id: "docs-use-npm" + }) + ]) + ); + expect(operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "upsert", + summary: "we use pnpm, not npm" + }) + ]) + ); + }); + + it("does not delete cross-scope command memories for remember-style command corrections", async () => { + const extractor = new HeuristicExtractor(); + const existingEntries: MemoryEntry[] = [ + { + id: "global-npm-test", + scope: "global", + topic: "commands", + summary: "Run `npm test` to verify projects.", + details: ["Global command note."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + }, + { + id: "project-npm-test", + scope: "project", + topic: "commands", + summary: "Run `npm test` to verify this repository.", + details: ["Project command note."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ]; + + const operations = await extractor.extract( + baseEvidence({ + userMessages: ["remember that run `pnpm test`, not `npm test`"] + }), + existingEntries + ); + + expect(operations).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "delete", + scope: "global", + id: "global-npm-test" + }) + ]) + ); + expect(operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "delete", + scope: "project", + id: "project-npm-test" + }) + ]) + ); + }); }); describe("safety filter", () => { diff --git a/test/session-continuity.test.ts b/test/session-continuity.test.ts index c81cadd..2fe5dd9 100644 --- a/test/session-continuity.test.ts +++ b/test/session-continuity.test.ts @@ -650,6 +650,108 @@ describe("session continuity domain", () => { ); }); + it("collects next steps using the original mixed transcript order", () => { + const evidence = { + sessionId: "session-mixed-order", + createdAt: "2026-03-15T00:00:00.000Z", + cwd: "/tmp/project", + userMessages: [ + "Next step: update README.md for the release notes.", + "Next step: sync docs/host-surfaces.md with the route wording." + ], + agentMessages: [ + "Remaining work: patch src/auth.ts before the final verification.", + "Remaining work: rerun pnpm test after the auth change." + ], + orderedMessages: [ + { + role: "user", + message: "Next step: update README.md for the release notes." + }, + { + role: "agent", + message: "Remaining work: patch src/auth.ts before the final verification." + }, + { + role: "user", + message: "Next step: sync docs/host-surfaces.md with the route wording." + }, + { + role: "agent", + message: "Remaining work: rerun pnpm test after the auth change." + } + ], + toolCalls: [], + rolloutPath: "/tmp/rollout.jsonl" + } satisfies RolloutEvidence & { + orderedMessages: Array<{ role: "user" | "agent"; message: string }>; + }; + + const buckets = collectSessionContinuityEvidenceBuckets(evidence); + + expect(buckets.explicitNextSteps).toEqual([ + "rerun pnpm test after the auth change.", + "sync docs/host-surfaces.md with the route wording.", + "patch src/auth.ts before the final verification.", + "update README.md for the release notes." + ]); + }); + + it("keeps repo-relative file paths in continuity file-write summaries", async () => { + const evidence: RolloutEvidence = { + sessionId: "session-relative-file-writes", + createdAt: "2026-03-15T00:00:00.000Z", + cwd: "/tmp/project", + userMessages: ["Continue the release prep."], + agentMessages: [], + toolCalls: [ + { + name: "apply_patch_freeform", + arguments: + "diff --git a/src/index.ts b/src/index.ts\nindex abc..def 100644\n--- a/src/index.ts\n+++ b/src/index.ts\n@@ -1,1 +1,2 @@\n+export const ready = true;\n", + output: undefined + }, + { + name: "apply_patch_freeform", + arguments: + "diff --git a/docs/index.ts b/docs/index.ts\nindex abc..def 100644\n--- a/docs/index.ts\n+++ b/docs/index.ts\n@@ -1,1 +1,2 @@\n+export const docsReady = true;\n", + output: undefined + } + ], + rolloutPath: "/tmp/rollout.jsonl" + }; + + const summarizer = new SessionContinuitySummarizer(baseConfig("/tmp/memory-root")); + const summary = await summarizer.summarize(evidence); + + expect(summary.projectLocal.filesDecisionsEnvironment).toEqual( + expect.arrayContaining([ + "File modified: src/index.ts", + "File modified: docs/index.ts" + ]) + ); + }); + + it("does not classify bare repo-wide filenames as project-local notes", async () => { + const evidence: RolloutEvidence = { + sessionId: "session-bare-filenames-shared", + createdAt: "2026-03-15T00:00:00.000Z", + cwd: "/tmp/project", + userMessages: ["README.md and package.json must stay aligned before release."], + agentMessages: [], + toolCalls: [], + rolloutPath: "/tmp/rollout.jsonl" + }; + + const summarizer = new SessionContinuitySummarizer(baseConfig("/tmp/memory-root")); + const summary = await summarizer.summarize(evidence); + + expect(summary.project.filesDecisionsEnvironment).toContain( + "README.md and package.json must stay aligned before release." + ); + expect(summary.projectLocal.filesDecisionsEnvironment).toEqual([]); + }); + it("prompt includes evidence buckets for commands, file writes, and next steps", () => { const evidence: RolloutEvidence = { sessionId: "session-prompt-buckets", @@ -848,7 +950,7 @@ describe("session continuity domain", () => { expect(result.diagnostics.confidence).toBe("high"); }); - it("surfaces expanded continuity warning hints for architecture and route-order conflicts", async () => { + it("surfaces expanded continuity warning hints for true architecture and route-order conflicts", async () => { const evidence: RolloutEvidence = { sessionId: "session-expanded-warning-hints", createdAt: "2026-03-15T00:00:00.000Z", @@ -926,15 +1028,29 @@ describe("session continuity domain", () => { expect(buckets.warningHints).toEqual([]); const summarizer = new SessionContinuitySummarizer(baseConfig("/tmp/memory-root")); - const summary = await summarizer.summarize(evidence); - expect(summary.projectLocal.incompleteNext).toEqual( - expect.arrayContaining([ - "review the auth incident notes before changing the middleware." - ]) - ); + const result = await summarizer.summarizeWithDiagnostics(evidence); + expect(result.diagnostics.warnings).toEqual([]); }); - it("does not turn one required service into a conflicting not-required signal when another service is explicitly optional", () => { + it("does not treat same-category runbook pointers for different resources as conflicts", async () => { + const evidence: RolloutEvidence = { + sessionId: "session-additive-runbook-pointers", + createdAt: "2026-03-15T00:00:00.000Z", + cwd: "/tmp/project", + userMessages: [ + "Use the auth runbook at https://docs.example.com/auth-runbook.", + "Use the billing runbook at https://docs.example.com/billing-runbook." + ], + agentMessages: [], + toolCalls: [], + rolloutPath: "/tmp/rollout.jsonl" + }; + + const buckets = collectSessionContinuityEvidenceBuckets(evidence); + expect(buckets.warningHints).toEqual([]); + }); + + it("does not turn one required service into a conflicting not-required signal when another service is explicitly optional", async () => { const evidence: RolloutEvidence = { sessionId: "session-mixed-service-negation", createdAt: "2026-03-15T00:00:00.000Z", @@ -1526,21 +1642,6 @@ describe("session continuity domain", () => { ]) ); }); - - it("treats unknown continuity source paths conservatively as project-local", () => { - const state = { - ...createEmptySessionContinuityState("project-local", "project-1", "worktree-1"), - goal: "Continue the host-local continuity workflow." - }; - - const compiled = compileSessionContinuity( - state, - ["/tmp/host-specific/local-continuity.md"], - 12 - ); - - expect(compiled.continuitySourceKinds).toEqual(["project-local"]); - }); }); describe("SessionContinuityStore", () => { @@ -1756,4 +1857,5 @@ describe("SessionContinuityStore", () => { expect(await fs.readFile(olderFile, "utf8")).toBe("older\n"); expect((await fs.readdir(store.paths.localDir)).filter((name) => name.endsWith("-session.tmp"))).toHaveLength(2); }); + }); diff --git a/test/sync-service.test.ts b/test/sync-service.test.ts index 3aa6a1f..9fbe231 100644 --- a/test/sync-service.test.ts +++ b/test/sync-service.test.ts @@ -294,6 +294,32 @@ describe("SyncService", () => { expect(auditEntries[0]?.operations).toEqual([]); }); + it("persists reference memories for external dashboards and issue trackers", async () => { + const projectDir = await tempDir("cam-sync-reference-project-"); + const memoryRoot = await tempDir("cam-sync-reference-memory-"); + const rolloutPath = path.join(projectDir, "reference-rollout.jsonl"); + await fs.writeFile(rolloutPath, referenceRolloutFixture(projectDir), "utf8"); + + const service = new SyncService( + detectProjectContext(projectDir), + baseConfig(memoryRoot), + path.resolve("schemas/memory-operations.schema.json") + ); + + const result = await service.syncRollout(rolloutPath, true); + const projectEntries = await service.memoryStore.listEntries("project"); + + expect(result.skipped).toBe(false); + expect( + projectEntries.filter((entry) => entry.topic === "reference").map((entry) => entry.summary) + ).toEqual( + expect.arrayContaining([ + "pipeline bugs are tracked in Linear project INGEST", + "the latency dashboard lives at https://grafana.example.com/d/api-latency" + ]) + ); + }); + it("treats source-only extracted changes as applied updates instead of noop", async () => { const projectDir = await tempDir("cam-sync-dedupe-noop-project-"); const memoryRoot = await tempDir("cam-sync-dedupe-noop-memory-"); @@ -434,7 +460,7 @@ describe("SyncService", () => { rolloutPath, status: "no-op", appliedCount: 0, - suppressedOperationCount: 2 + suppressedOperationCount: 1 }); expect(auditEntries[0]?.conflicts).toEqual( expect.arrayContaining([ From 55e65aa8a5c47eb610e53014d9167bbbbf531e53 Mon Sep 17 00:00:00 2001 From: blocks Date: Thu, 9 Apr 2026 00:27:16 +0800 Subject: [PATCH 36/62] docs: sync codex release-facing command examples --- README.en.md | 4 ++-- README.ja.md | 4 ++-- README.md | 6 +++--- README.zh-TW.md | 4 ++-- test/docs-contract.test.ts | 12 ++++++++++++ 5 files changed, 21 insertions(+), 9 deletions(-) diff --git a/README.en.md b/README.en.md index 5c41595..c60cbc0 100644 --- a/README.en.md +++ b/README.en.md @@ -186,7 +186,7 @@ cam integrations doctor --host codex cam mcp install --host codex cam mcp print-config --host codex cam mcp apply-guidance --host codex -cam mcp doctor +cam mcp doctor --host codex cam session status cam session refresh cam remember "Always use pnpm instead of npm" @@ -207,7 +207,7 @@ cam audit | `cam recall search` / `timeline` / `details` | progressively retrieve durable memory through a search -> timeline -> details workflow; `search` now defaults to `state=auto, limit=8`, so active memory is checked before archived fallback while staying read-only | | `cam mcp serve` | start a read-only retrieval MCP server that exposes the same workflow through `search_memories`, `timeline_memories`, and `get_memory_details` | | `cam integrations install --host codex` | install the recommended Codex integration stack in one explicit step by writing project-scoped MCP wiring and refreshing the hook bridge bundle plus Codex skill assets; it defaults to the runtime skill target, but also accepts `--skill-surface runtime|official-user|official-project`; stays idempotent, Codex-only, and does not touch the Markdown memory store | -| `cam integrations apply --host codex` | explicitly apply the full Codex integration state: it keeps `integrations install` unchanged, but also orchestrates `cam mcp apply-guidance --host codex`; it defaults to the runtime skill target, but also accepts `--skill-surface runtime|official-user|official-project`; if the `AGENTS.md` managed block is unsafe, the command now returns a preflight `blocked` result before any stack writes happen | +| `cam integrations apply --host codex` | explicitly apply the full Codex integration state: it keeps `integrations install` unchanged, but also orchestrates `cam mcp apply-guidance --host codex`; it defaults to the runtime skill target, but also accepts `--skill-surface runtime|official-user|official-project`; if the `AGENTS.md` managed block is unsafe, the command now returns a preflight `blocked` result before any stack writes happen; if a staged write fails partway through, the JSON contract also reports `rollbackSucceeded`, `rollbackErrors`, per-subaction `effectiveAction`, and `rolledBack` so callers can tell the final state instead of assuming the attempted writes stuck | | `cam integrations doctor --host codex` | inspect the current Codex integration stack through a thin read-only aggregation surface that reports the recommended route, recommended preset, structured `workflowContract`, `applyReadiness`, subchecks, and minimum next steps; when the managed `AGENTS.md` block is unsafe, it now tells you to repair that block first instead of recommending `cam integrations apply --host codex` immediately | | `cam mcp install --host ` | explicitly write the recommended project-scoped host config for `codex_auto_memory`; only that server entry is updated, hooks/skills stay opt-in, non-canonical custom fields on that entry are preserved when safe, and `generic` remains manual-only | | `cam mcp print-config --host ` | print a ready-to-paste host snippet so the read-only retrieval plane can be wired into an existing MCP client with less manual setup; for `--host codex`, it also prints a recommended `AGENTS.md` snippet and now includes the shared `workflowContract` in JSON output so future Codex agents can prefer MCP and fall back to `cam recall` only when needed | diff --git a/README.ja.md b/README.ja.md index efb1c51..2d253a7 100644 --- a/README.ja.md +++ b/README.ja.md @@ -180,7 +180,7 @@ cam integrations doctor --host codex cam mcp install --host codex cam mcp print-config --host codex cam mcp apply-guidance --host codex -cam mcp doctor +cam mcp doctor --host codex cam session status cam session refresh cam remember "Always use pnpm instead of npm" @@ -201,7 +201,7 @@ cam audit | `cam recall search` / `timeline` / `details` | `search -> timeline -> details` の progressive disclosure workflow で durable memory を段階的に取得する。`search` は `state=auto, limit=8` を既定値として使い、active を先に調べてヒットしなければ archived にフォールバックしつつ read-only を保つ | | `cam mcp serve` | `search_memories` / `timeline_memories` / `get_memory_details` を通じて同じ retrieval contract を公開する read-only MCP server を起動する | | `cam integrations install --host codex` | 推奨される Codex integration stack を一度に導入し、project-scoped MCP wiring を書き込みつつ、hook bridge bundle と Codex skill assets を更新する。skills は runtime target が既定だが、`--skill-surface runtime|official-user|official-project` も指定できる。明示的・冪等・Codex-only を保ち、Markdown memory store には触れない | -| `cam integrations apply --host codex` | 明示的・冪等・Codex-only のまま完全な integration state を適用する。`integrations install` の既存境界は変えず、その上で `cam mcp apply-guidance --host codex` も編成する。skills は runtime target が既定だが、`--skill-surface runtime|official-user|official-project` も指定できる。`AGENTS.md` managed block が unsafe な場合は、stack への書き込み前に preflight `blocked` を返す | +| `cam integrations apply --host codex` | 明示的・冪等・Codex-only のまま完全な integration state を適用する。`integrations install` の既存境界は変えず、その上で `cam mcp apply-guidance --host codex` も編成する。skills は runtime target が既定だが、`--skill-surface runtime|official-user|official-project` も指定できる。`AGENTS.md` managed block が unsafe な場合は、stack への書き込み前に preflight `blocked` を返す。staged write が途中で失敗した場合も、`rollbackSucceeded`、`rollbackErrors`、各 subaction の `effectiveAction`、`rolledBack` を明示的に返し、試行した書き込みと最終状態を区別できる | | `cam integrations doctor --host codex` | 現在の Codex integration stack を薄い read-only 集約面として点検し、推奨ルート、推奨 preset、構造化された `workflowContract`、`applyReadiness`、サブチェック結果、次の最小アクションを返す。`AGENTS.md` managed block が unsafe な場合は、まずその修復を案内し、すぐに `cam integrations apply --host codex` を勧めない | | `cam mcp install --host ` | 推奨される project-scoped 宿主設定を明示的に書き込み、`codex_auto_memory` の項目だけを更新する。hooks/skills は自動導入せず、その entry に non-canonical なカスタム項目がある場合は安全な範囲で保持する。`generic` は引き続き manual-only | | `cam mcp print-config --host ` | ready-to-paste な接続スニペットを出力し、read-only retrieval plane を既存の MCP client に低摩擦で接続できるようにする。`--host codex` の場合は、将来の Codex エージェントに MCP 優先・`cam recall` フォールバックを教えるための推奨 `AGENTS.md` snippet に加えて、JSON payload に共有 `workflowContract` も含める | diff --git a/README.md b/README.md index fac82b0..54271d7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

简体中文 | 繁體中文 | - English + English | 日本語

@@ -191,7 +191,7 @@ cam integrations doctor --host codex cam mcp install --host codex cam mcp print-config --host codex cam mcp apply-guidance --host codex -cam mcp doctor +cam mcp doctor --host codex cam session status cam session refresh cam remember "Always use pnpm instead of npm" @@ -212,7 +212,7 @@ cam audit | `cam recall search` / `timeline` / `details` | 以 `search -> timeline -> details` 的 progressive disclosure 检索 durable memory;`search` 默认采用 `state=auto`、`limit=8`,先查 active,未命中再回退 archived,且保持只读 retrieval | | `cam mcp serve` | 启动只读 retrieval MCP server,通过 `search_memories` / `timeline_memories` / `get_memory_details` 暴露同一套渐进式检索契约 | | `cam integrations install --host codex` | 一次性安装推荐的 Codex integration stack:写入 project-scoped MCP wiring,并刷新 hook bridge bundle 与 Codex skill 资产;默认使用 runtime skills target,也支持显式 `--skill-surface runtime|official-user|official-project`;保持显式、幂等、Codex-only,且不触碰 Markdown memory store | -| `cam integrations apply --host codex` | 以显式、幂等、Codex-only 的方式应用完整 integration state:在保留 `integrations install` 旧语义不变的前提下,额外编排 `cam mcp apply-guidance --host codex`;默认使用 runtime skills target,也支持显式 `--skill-surface runtime|official-user|official-project`;若 `AGENTS.md` managed block 不安全,会在任何 stack 写入之前 preflight `blocked`,保持 additive / fail-closed | +| `cam integrations apply --host codex` | 以显式、幂等、Codex-only 的方式应用完整 integration state:在保留 `integrations install` 旧语义不变的前提下,额外编排 `cam mcp apply-guidance --host codex`;默认使用 runtime skills target,也支持显式 `--skill-surface runtime|official-user|official-project`;若 `AGENTS.md` managed block 不安全,会在任何 stack 写入之前 preflight `blocked`,保持 additive / fail-closed;若 staged write 途中失败,还会显式返回 `rollbackSucceeded`、`rollbackErrors`、subaction `effectiveAction` 与 `rolledBack`,避免把“尝试写入”误读成“最终已安装” | | `cam integrations doctor --host codex` | 以 Codex-only、只读、薄聚合的方式汇总当前 integration stack readiness,直接给出推荐路由、推荐 preset、结构化 `workflowContract`、`applyReadiness`、子检查结果与下一步最小动作;当 AGENTS guidance 处于 unsafe managed-block 状态时,会先提示修复 `AGENTS.md`,而不是直接推荐 `cam integrations apply --host codex` | | `cam mcp install --host ` | 显式写入推荐的 project-scoped 宿主 MCP 配置;只更新 `codex_auto_memory` 这一项,不会自动安装 hooks/skills;若该 entry 已带有非 canonical 自定义字段,会在安全前提下保留它们;`generic` 继续保持 manual-only | | `cam mcp print-config --host ` | 打印 ready-to-paste 的宿主接入片段,降低把 read-only retrieval plane 接进现有工作流的摩擦;其中 `--host codex` 还会额外打印推荐的 `AGENTS.md` snippet,并在 JSON payload 中附带共享 `workflowContract`,帮助未来 Codex 代理优先走 MCP、必要时再 fallback 到 `cam recall` | diff --git a/README.zh-TW.md b/README.zh-TW.md index 3e6667d..332ecfa 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -182,7 +182,7 @@ cam integrations doctor --host codex cam mcp install --host codex cam mcp print-config --host codex cam mcp apply-guidance --host codex -cam mcp doctor +cam mcp doctor --host codex cam session status cam session refresh cam remember "Always use pnpm instead of npm" @@ -203,7 +203,7 @@ cam audit | `cam recall search` / `timeline` / `details` | 以 `search -> timeline -> details` 的 progressive disclosure 工作流檢索 durable memory;`search` 現在預設採用 `state=auto`、`limit=8`,會先查 active,未命中再回退 archived,且保持只讀 retrieval | | `cam mcp serve` | 啟動只讀 retrieval MCP server,以 `search_memories` / `timeline_memories` / `get_memory_details` 暴露同一套漸進式檢索契約 | | `cam integrations install --host codex` | 一次性安裝推薦的 Codex integration stack:寫入 project-scoped MCP wiring,並刷新 hook bridge bundle 與 Codex skill 資產;預設使用 runtime skills target,也支援顯式 `--skill-surface runtime|official-user|official-project`;保持顯式、幂等、Codex-only,且不碰 Markdown memory store | -| `cam integrations apply --host codex` | 以顯式、幂等、Codex-only 的方式套用完整 integration state:在保留 `integrations install` 舊語義不變的前提下,額外編排 `cam mcp apply-guidance --host codex`;預設使用 runtime skills target,也支援顯式 `--skill-surface runtime|official-user|official-project`;若 `AGENTS.md` managed block 不安全,現在會在任何 stack 寫入前 preflight `blocked` | +| `cam integrations apply --host codex` | 以顯式、幂等、Codex-only 的方式套用完整 integration state:在保留 `integrations install` 舊語義不變的前提下,額外編排 `cam mcp apply-guidance --host codex`;預設使用 runtime skills target,也支援顯式 `--skill-surface runtime|official-user|official-project`;若 `AGENTS.md` managed block 不安全,現在會在任何 stack 寫入前 preflight `blocked`;若 staged write 中途失敗,也會明確回傳 `rollbackSucceeded`、`rollbackErrors`、各 subaction 的 `effectiveAction` 與 `rolledBack`,避免把「嘗試寫入」誤判成「最終已安裝」 | | `cam integrations doctor --host codex` | 以 Codex-only、只讀、薄聚合的方式彙總目前 integration stack readiness,直接給出推薦路由、推薦 preset、結構化 `workflowContract`、`applyReadiness`、子檢查結果與下一步最小動作;若 `AGENTS.md` managed block 處於 unsafe 狀態,會先提示修復它,而不是直接推薦 `cam integrations apply --host codex` | | `cam mcp install --host ` | 顯式寫入推薦的 project-scoped 宿主 MCP 配置;只更新 `codex_auto_memory` 這一項,不會自動安裝 hooks/skills;若該 entry 已帶有非 canonical 自訂欄位,會在安全前提下保留它們;`generic` 仍維持 manual-only | | `cam mcp print-config --host ` | 列印 ready-to-paste 的宿主接入片段,降低把 read-only retrieval plane 接進既有 MCP workflow 的手動成本;其中 `--host codex` 還會額外列印推薦的 `AGENTS.md` snippet,並在 JSON payload 中附帶共享 `workflowContract`,幫助未來 Codex 代理優先走 MCP、必要時再 fallback 到 `cam recall` | diff --git a/test/docs-contract.test.ts b/test/docs-contract.test.ts index d1cf0dd..4f424f1 100644 --- a/test/docs-contract.test.ts +++ b/test/docs-contract.test.ts @@ -53,11 +53,14 @@ describe("docs contract", () => { expect(readme).toContain("--surface runtime|official-user|official-project"); expect(readme).toContain("cam mcp serve"); expect(readme).toContain("cam mcp print-config --host codex"); + expect(readme).toContain("cam mcp doctor --host codex"); expect(readme).toContain("AGENTS.md"); expect(readme).toContain("cam mcp doctor"); expect(readme).toContain("alternate global wiring"); expect(readme).toContain("非 canonical 自定义字段"); expect(readme).toContain("manual-only"); + expect(readme).toContain("rollbackSucceeded"); + expect(readme).toContain("effectiveAction"); expect(readme).toContain("cam forget \"old debug note\" --archive"); expect(readme).toContain("README.zh-TW.md"); expect(readme).toContain("README.ja.md"); @@ -73,10 +76,13 @@ describe("docs contract", () => { expect(readmeTw).toContain("cam mcp install --host codex"); expect(readmeTw).toContain("cam mcp print-config --host codex"); expect(readmeTw).toContain("cam mcp apply-guidance --host codex"); + expect(readmeTw).toContain("cam mcp doctor --host codex"); expect(readmeTw).toContain("cam mcp doctor"); expect(readmeTw).toContain("alternate global wiring"); expect(readmeTw).toContain("非 canonical 自訂欄位"); expect(readmeTw).toContain("applyReadiness"); + expect(readmeTw).toContain("rollbackSucceeded"); + expect(readmeTw).toContain("effectiveAction"); expect(readmeTw).toContain("--state auto"); expect(readmeTw).toContain("`cam memory` / `cam session` / `cam recall` / `cam audit`"); expect(readmeTw).toContain("local bridge"); @@ -95,10 +101,13 @@ describe("docs contract", () => { expect(readmeJa).toContain("cam mcp install --host codex"); expect(readmeJa).toContain("cam mcp print-config --host codex"); expect(readmeJa).toContain("cam mcp apply-guidance --host codex"); + expect(readmeJa).toContain("cam mcp doctor --host codex"); expect(readmeJa).toContain("cam mcp doctor"); expect(readmeJa).toContain("alternate global wiring"); expect(readmeJa).toContain("non-canonical なカスタム項目"); expect(readmeJa).toContain("applyReadiness"); + expect(readmeJa).toContain("rollbackSucceeded"); + expect(readmeJa).toContain("effectiveAction"); expect(readmeJa).toContain("--state auto"); expect(readmeJa).toContain("`cam memory` / `cam session` / `cam recall` / `cam audit`"); expect(readmeJa).toContain("local bridge"); @@ -130,11 +139,14 @@ describe("docs contract", () => { expect(readmeEn).toContain("cam mcp apply-guidance --host codex"); expect(readmeEn).toContain("cam mcp serve"); expect(readmeEn).toContain("cam mcp print-config --host codex"); + expect(readmeEn).toContain("cam mcp doctor --host codex"); expect(readmeEn).toContain("AGENTS.md"); expect(readmeEn).toContain("cam mcp doctor"); expect(readmeEn).toContain("alternate global wiring"); expect(readmeEn).toContain("non-canonical custom fields"); expect(readmeEn).toContain("manual-only"); + expect(readmeEn).toContain("rollbackSucceeded"); + expect(readmeEn).toContain("effectiveAction"); expect(readmeEn).toContain("| `cam hooks install` |"); expect(readmeEn).toContain("`cam memory`, `cam session`, and `cam recall` reviewer UX"); expect(readmeEn).toContain("--surface runtime|official-user|official-project"); From 9447cd94b98955c6ed6e5ee12128918b0da467eb Mon Sep 17 00:00:00 2001 From: blocks Date: Thu, 9 Apr 2026 20:41:12 +0800 Subject: [PATCH 37/62] fix: align route truth and codex integration contracts --- README.en.md | 59 +- README.ja.md | 51 +- README.md | 64 +- README.zh-TW.md | 51 +- docs/README.en.md | 21 +- docs/README.md | 25 +- docs/architecture.en.md | 8 +- docs/architecture.md | 10 +- docs/claude-reference.en.md | 2 +- docs/host-surfaces.md | 4 +- docs/integration-strategy.md | 15 +- docs/native-migration.en.md | 11 +- docs/native-migration.md | 8 + docs/release-checklist.md | 96 +- package.json | 4 +- src/lib/commands/doctor.ts | 149 ++- src/lib/commands/hooks.ts | 7 +- src/lib/commands/integrations.ts | 692 +++++++++-- src/lib/commands/skills.ts | 7 +- src/lib/integration/agents-guidance.ts | 39 +- src/lib/integration/assets.ts | 143 ++- src/lib/integration/codex-stack.ts | 83 +- src/lib/integration/command-path.ts | 49 + src/lib/integration/install-assets.ts | 4 +- src/lib/integration/mcp-config.ts | 6 +- src/lib/integration/mcp-doctor.ts | 610 +++++++--- src/lib/integration/mcp-hosts.ts | 10 +- src/lib/integration/mcp-install.ts | 59 +- src/lib/integration/retrieval-contract.ts | 286 +++-- test/docs-contract.test.ts | 213 +++- test/doctor-command.test.ts | 239 ++++ test/hooks-command.test.ts | 111 +- test/integrations-command.test.ts | 931 ++++++++++++--- test/mcp-command.test.ts | 1272 ++++++++++++++++----- test/skills-command.test.ts | 74 +- 35 files changed, 4328 insertions(+), 1085 deletions(-) create mode 100644 src/lib/integration/command-path.ts create mode 100644 test/doctor-command.test.ts diff --git a/README.en.md b/README.en.md index c60cbc0..394e495 100644 --- a/README.en.md +++ b/README.en.md @@ -25,7 +25,7 @@

-> `codex-auto-memory` is not a generic note-taking app and not a cloud memory service. +> `codex-auto-memory` is not a generic note-taking app, not a cloud memory service, and not the repository where the multi-host platform line is being built. > It is a Markdown-first, local-first memory runtime for Codex. Today it is strongest as a Codex wrapper and companion CLI, and it is now explicitly evolving toward hook, skill, and MCP-aware integration surfaces without giving up auditable local Markdown files as the source of truth. --- @@ -107,11 +107,11 @@ These goals now take priority over documenting the project only as a narrow migr | Capability | What it means | | :-- | :-- | | Automatic post-session sync | extracts stable knowledge from Codex rollout JSONL and writes it back into durable Markdown memory | -| Automatic startup recall | compiles compact startup memory so durable knowledge can re-enter later sessions automatically | +| Automatic startup recall | compiles compact startup memory so durable knowledge can re-enter later sessions automatically, now with a few active-only content highlights plus on-demand topic refs | | Markdown-first memory | `MEMORY.md` and topic files remain the product surface, not a hidden cache layer | | Lifecycle-aware updates | supports explicit correction, dedupe, overwrite, delete, and reviewer-visible conflict suppression | | Formal retrieval MCP surface | `cam mcp serve` exposes `search_memories`, `timeline_memories`, and `get_memory_details` as a read-only stdio retrieval plane | -| Project-scoped MCP install surface | `cam mcp install --host ` writes the recommended project-scoped host wiring for `codex_auto_memory` without changing the retrieval contract itself | +| Project-scoped MCP install surface | `cam mcp install --host codex` writes the recommended Codex project-scoped host wiring for `codex_auto_memory`; lower-priority non-Codex host wiring remains documented as boundary guidance in `docs/host-surfaces.md` | | Worktree-aware storage | shares project memory across worktrees while keeping local continuity isolated | | Optional session continuity | separates temporary working state from durable memory | | Integration-aware evolution | keeps the current wrapper flow while moving toward hook, skill, and MCP-friendly surfaces | @@ -201,25 +201,50 @@ cam audit | :-- | :-- | | `cam run` / `cam exec` / `cam resume` | compile startup memory and launch Codex through the wrapper | | `cam sync` | manually sync the latest rollout into durable memory | -| `cam memory` | inspect startup files, topic refs, startup budget, edit paths, and recent durable sync audit events plus suppressed conflict candidates | -| `cam memory reindex` | explicitly rebuild retrieval sidecars from canonical Markdown memory; supports `--scope`, `--state`, `--cwd`, and `--json` so missing, invalid, or stale sidecars have a low-friction repair path | -| `cam remember` / `cam forget` | explicitly add or remove durable memory; `cam forget --archive` moves matching entries into the archive layer | -| `cam recall search` / `timeline` / `details` | progressively retrieve durable memory through a search -> timeline -> details workflow; `search` now defaults to `state=auto, limit=8`, so active memory is checked before archived fallback while staying read-only | +| `cam memory` | inspect startup files, topic refs, startup highlights, highlight-budget / section-render status, edit paths, and recent durable sync audit events plus suppressed conflict candidates; it also supports `--cwd ` so memory inspection can target another project root explicitly; when the durable memory layout is still uninitialized it returns an empty inspection view instead of creating `MEMORY.md`, `ARCHIVE.md`, or retrieval sidecars implicitly; `--json` now also exposes `highlightCount`, `omittedHighlightCount`, `omittedTopicFileCount`, `highlightsByScope`, `startupSectionsRendered`, `startupOmissions`, `startupOmissionCounts`, `startupOmissionCountsByTargetAndStage`, `topicFileOmissionCounts`, `topicRefCountsByScope`, and reviewer-visible `topicDiagnostics` / `layoutDiagnostics` so selection-stage, render-stage, and canonical-layout issues stay distinguishable | +| `cam memory reindex` | explicitly rebuild retrieval sidecars from canonical Markdown memory; supports `--scope`, `--state`, `--cwd`, and `--json` so missing, invalid, or stale sidecars have a low-friction repair path; when the durable memory layout is still uninitialized it returns an empty `rebuilt` set instead of initializing that layout implicitly | +| `cam remember` / `cam forget` | explicitly add or remove durable memory; both commands now also support `--cwd ` so manual corrections can target another project root directly; when `cam remember` omits `--topic`, it now performs lightweight durable-topic inference and prefers updating an existing memory instead of appending a second active entry when there is one clearly identifiable old value; `cam forget --archive` moves matching entries into the archive layer; `forget` now also shares the same multi-term query normalization as `recall search`, so queries like `pnpm npm` can match one memory across `summary/details` instead of requiring the original substring to appear contiguously; both commands now also support `--json`, returning a structured manual-mutation reviewer payload with `mutationKind`, `matchedCount`, `appliedCount`, `noopCount`, `summary`, `primaryEntry`, `entries[]`, `followUp`, `nextRecommendedActions`, and top-level lifecycle/detail fields (`latestAppliedLifecycle`, `latestLifecycleAttempt`, `latestLifecycleAction`, `latestState`, `latestSessionId`, `latestRolloutPath`, `latestAudit`, `timelineWarningCount`, `warnings`, `entry`, `lineageSummary`, `ref/path/historyPath`) whenever at least one matched ref exists; they now also add `leadEntryRef`, `leadEntryIndex`, `detailsAvailable`, `reviewRefState`, `uniqueAuditCount`, `auditCountsDeduplicated`, and `warningsByEntryRef` so delete/archive/multi-entry reviewer payloads are less ambiguous without breaking older consumers; empty `forget --json` results stay additive and now leave `nextRecommendedActions` empty instead of emitting placeholder refs; delete flows also distinguish timeline-only review refs from details-usable refs; text mode now also prints the same project-pinned `timeline/details -> recent -> reindex` follow-up route so manual corrections drop back into the reviewer loop naturally | +| `cam recall search` / `timeline` / `details` | progressively retrieve durable memory through a search -> timeline -> details workflow; `search` now defaults to `state=auto, limit=8`, so active memory is checked before archived fallback while staying read-only, and multi-term queries now match across `id/topic/summary/details` instead of requiring every term to live in one field; the JSON surface now also exposes additive `retrievalMode`, `finalRetrievalMode`, `retrievalFallbackReason`, `stateResolution`, `executionSummary`, `searchOrder`, `totalMatchedCount`, `returnedCount`, `globalLimitApplied`, `truncatedCount`, `resultWindow`, `globalRank`, and `diagnostics.checkedPaths[].returnedCount` / `droppedCount` fields so fallback behavior, global sorting, and post-limit drops stay reviewer-visible; `finalRetrievalMode` is an explicit alias for the final result mode while `retrievalMode` keeps its compatibility semantics | | `cam mcp serve` | start a read-only retrieval MCP server that exposes the same workflow through `search_memories`, `timeline_memories`, and `get_memory_details` | -| `cam integrations install --host codex` | install the recommended Codex integration stack in one explicit step by writing project-scoped MCP wiring and refreshing the hook bridge bundle plus Codex skill assets; it defaults to the runtime skill target, but also accepts `--skill-surface runtime|official-user|official-project`; stays idempotent, Codex-only, and does not touch the Markdown memory store | -| `cam integrations apply --host codex` | explicitly apply the full Codex integration state: it keeps `integrations install` unchanged, but also orchestrates `cam mcp apply-guidance --host codex`; it defaults to the runtime skill target, but also accepts `--skill-surface runtime|official-user|official-project`; if the `AGENTS.md` managed block is unsafe, the command now returns a preflight `blocked` result before any stack writes happen; if a staged write fails partway through, the JSON contract also reports `rollbackSucceeded`, `rollbackErrors`, per-subaction `effectiveAction`, and `rolledBack` so callers can tell the final state instead of assuming the attempted writes stuck | -| `cam integrations doctor --host codex` | inspect the current Codex integration stack through a thin read-only aggregation surface that reports the recommended route, recommended preset, structured `workflowContract`, `applyReadiness`, subchecks, and minimum next steps; when the managed `AGENTS.md` block is unsafe, it now tells you to repair that block first instead of recommending `cam integrations apply --host codex` immediately | -| `cam mcp install --host ` | explicitly write the recommended project-scoped host config for `codex_auto_memory`; only that server entry is updated, hooks/skills stay opt-in, non-canonical custom fields on that entry are preserved when safe, and `generic` remains manual-only | -| `cam mcp print-config --host ` | print a ready-to-paste host snippet so the read-only retrieval plane can be wired into an existing MCP client with less manual setup; for `--host codex`, it also prints a recommended `AGENTS.md` snippet and now includes the shared `workflowContract` in JSON output so future Codex agents can prefer MCP and fall back to `cam recall` only when needed | +| `cam integrations install --host codex` | install the recommended Codex integration stack in one explicit step by writing project-scoped MCP wiring and refreshing the hook bridge bundle plus Codex skill assets; it defaults to the runtime skill target, but also accepts `--skill-surface runtime|official-user|official-project`; stays idempotent, Codex-only, does not touch the Markdown memory store, and now rolls back staged MCP / hook / skill writes if installation fails mid-flight; `--json` now also returns a structured rollback failure payload; after installation it explicitly points you back to `cam integrations doctor --host codex` to confirm which retrieval route is operational in the current environment | +| `cam integrations apply --host codex` | explicitly apply the full Codex integration state: it keeps `integrations install` unchanged, but also orchestrates `cam mcp apply-guidance --host codex`; it defaults to the runtime skill target, but also accepts `--skill-surface runtime|official-user|official-project`; if the `AGENTS.md` managed block is unsafe, the command returns a preflight `blocked` result before any stack writes happen, and if a later block or staged write fails the JSON payload now reports rollback outcome plus the final effective action; after apply you should still use doctor to confirm whether MCP, the local bridge bundle, or the resolved CLI route is the operational path | +| `cam integrations doctor --host codex` | inspect the current Codex integration stack through a thin read-only aggregation surface that reports the recommended route and current route truth (`recommendedRoute`, `currentlyOperationalRoute`, `routeKind`, `routeEvidence`, `shellDependencyLevel`, `hostMutationRequired`, `preferredRouteBlockers`, `currentOperationalBlockers`), recommended preset, structured `workflowContract`, `applyReadiness`, additive `experimentalHooks` guidance, `layoutDiagnostics`, subchecks, and minimum next steps; `recommendedRoute` stays MCP-first, while the blocker fields separately explain why the preferred route is unavailable and whether the current fallback still has its own operational issues; it also surfaces skill-surface steering (`preferredSkillSurface`, `recommendedSkillInstallCommand`, `installedSkillSurfaces`, `readySkillSurfaces`) without describing skills as an executable fallback route; when doctor is anchored to another repository with `--cwd`, hook-fallback next steps now also project-pin the local bridge route via `CAM_PROJECT_ROOT=...`; when `cam` is unavailable on PATH, the direct CLI next step now prefers the resolved `node dist/cli.js recall ...` fallback instead of a broken bare `cam recall ...`; when the managed `AGENTS.md` block is unsafe, it now tells you to repair that block first instead of recommending `cam integrations apply --host codex` immediately | +| `cam mcp install --host codex` | explicitly write the recommended Codex project-scoped host config for `codex_auto_memory`; only that server entry is updated, hooks/skills stay opt-in, and non-canonical custom fields on that entry are preserved when safe; lower-priority non-Codex host wiring stays in `docs/host-surfaces.md` instead of the default product path, and some of those routes remain `manual-only` | +| `cam mcp print-config --host codex` | print a ready-to-paste Codex snippet so the read-only retrieval plane can be wired into the primary workflow with less manual setup; it also prints a recommended `AGENTS.md` snippet and includes the shared `workflowContract` plus explicit `experimentalHooks` guidance in JSON output so future Codex agents can prefer MCP, then fall back to the local `memory-recall.sh` bridge bundle, and only then fall back to the resolved CLI recall commands while still treating official hooks as Experimental; other host snippets remain boundary guidance in `docs/host-surfaces.md`, including the `manual-only` branch | | `cam mcp apply-guidance --host codex` | create or update the Codex Auto Memory managed block inside the repository-level `AGENTS.md` through an additive, auditable, fail-closed flow; it only appends a new block or replaces the same marker block, and returns `blocked` if it cannot locate that block safely | -| `cam mcp doctor` | inspect the recommended project-scoped retrieval MCP wiring, project pinning, and hook/skill fallback assets; it now also adds a `codexStack` readiness summary plus a structured `workflowContract` for the recommended route, executable bits, shared asset version, and workflow consistency, and reports alternate global wiring separately from the recommended project-scoped path without modifying host config files | +| `cam mcp doctor --host codex` | inspect the recommended Codex project-scoped retrieval MCP wiring, project pinning, and hook/skill fallback assets; it also adds a structured `workflowContract`, `layoutDiagnostics`, and the smallest safe retrieval-sidecar repair command, and that repair command now follows the resolved launcher fallback when `cam` is unavailable on PATH. When the inspected host selection includes Codex (`--host codex` or `all`), the JSON payload also exposes Codex-only `codexStack` route truth, `experimentalHooks`, and AGENTS guidance/apply-safety sections. When the inspected host is `claude`, `gemini`, or `generic`, the payload stays manual-only / snippet-first: host-level status now means configuration/guidance truth rather than the same operational readiness tier as Codex, `commandSurface.install` and `commandSurface.applyGuidance` are explicitly `false`, and no Codex-only writable guidance surface is implied. It also separates “hook assets are installed” from “the embedded helper launcher is operational in the current environment”; alternate global wiring is still reported separately from the recommended project-scoped path without modifying host config files | | `cam session save` | merge / incremental save for continuity | | `cam session refresh` | replace / clean regeneration for continuity | | `cam session load` / `status` | inspect the continuity reviewer surface | -| `cam hooks install` | generate and refresh the current local bridge / fallback helper bundle, including `memory-recall.sh`, `post-work-memory-review.sh`, compatibility wrappers, and `recall-bridge.md`; `post-work-memory-review.sh` chains `cam sync` with `cam memory --recent` for post-work durable-memory review; it is not an official Codex hook surface, and the bundle's recommended search preset is `state=auto`, `limit=8` | -| `cam skills` | install Codex skill assets with `cam skills install`; the default target remains the runtime surface, while `--surface runtime|official-user|official-project` enables explicit migration-prep copies on official `.agents/skills` paths; all surfaces teach the same MCP-first, CLI-fallback progressive durable-memory retrieval workflow and the same recommended search preset: `state=auto`, `limit=8` | +| `cam hooks install` | generate and refresh the current local bridge / fallback helper bundle, including `memory-recall.sh`, `post-work-memory-review.sh`, compatibility wrappers, and `recall-bridge.md`; `post-work-memory-review.sh` chains durable-memory `sync -> recent review` into one post-work route; those user-scoped helpers now resolve the target project at runtime from `CAM_PROJECT_ROOT` or the current shell `PWD` instead of hardcoding one repository path into shared assets; it is not an official Codex hook surface, official hooks still remain a public but `Experimental` opt-in route, and the config docs still label the `codex_hooks` feature flag as `Under development` and off by default; the bundle's recommended search preset is `state=auto`, `limit=8` | +| `cam skills install` | install Codex skill assets; the default target remains the runtime surface, while `--surface runtime|official-user|official-project` enables explicit migration-prep copies on official `.agents/skills` paths; all surfaces teach the same MCP-first progressive retrieval workflow, then fall back to the local `memory-recall.sh search -> timeline -> details` bridge bundle before the resolved CLI recall commands, and keep the same recommended search preset: `state=auto`, `limit=8`; skills remain a guidance surface, not an executable fallback route, so the current operational route should still be checked through `cam mcp doctor --host codex` or `cam integrations doctor --host codex` | | `cam audit` | run privacy and secret-hygiene checks | -| `cam doctor` | inspect local wiring and native-readiness posture | +| `cam doctor` | inspect local wiring and native-readiness posture; `--json` now also exposes retrieval-sidecar health, unsafe topic diagnostics, and canonical layout diagnostics while staying fully read-only | + +Additional note: + +- The shared `workflowContract` now also surfaces launcher constraints explicitly: `commandName=cam`, `requiresPathResolution=true`, and `hookHelpersShellOnly=true`. On top of that, hook helpers and doctor next steps now try to emit a verified fallback launcher: when `cam` is unavailable on PATH, they prefer `node /dist/cli.js`; otherwise they keep `cam` and mark it as unresolved guidance. +- `workflowContract.launcher` now also states that it applies to direct CLI usage and installed helper assets, not to the canonical MCP host snippet; host wiring continues to use the canonical `cam mcp serve` command shape. +- `workflowContract.launcher` now shares the same executable-aware truth source as doctor: a non-executable `cam` file on PATH is no longer treated as a verified launcher, and unverified branches no longer claim a "verified fallback". +- Startup highlights now deduplicate identical summaries across `project-local`, `project`, and `global` scopes so repeated low-signal notes do not consume the limited startup budget. +- Startup highlights now also skip unsafe topic files, and startup topic refs now stay limited to safe references only. In parallel, `cam memory --json` and `cam memory reindex --json` expose additive `topicDiagnostics` and `layoutDiagnostics`; `cam memory --json` also exposes `startupOmissions`, `startupOmissionCounts`, `topicFileOmissionCounts`, and `topicRefCountsByScope`, so highlight omissions, topic-ref omissions, and canonical layout anomalies all become reviewer-visible. The global highlight cap now also leaves a selection-stage omission instead of silently discarding later-scope highlights. +- Durable sync audit now also exposes additive `rejectedOperationCount`, `rejectedReasonCounts`, and lightweight `rejectedOperations` summaries, so unknown topics, sensitive content, volatile content, and operation-cap drops stop disappearing silently from the reviewer surface. +- Automatic extraction now also keeps `reference`-style durable memories for external dashboards, issue trackers, runbooks, and docs pointers, while rejecting more session-only or local-host noise such as `.agents/`, `.codex/`, `.gemini/`, `.mcp.json`, `next step`, and `resume here`. +- `cam hooks install --json` and `cam skills install --json` now also expose `postInstallReadinessCommand`, so “what should I run next to confirm the operational route” becomes machine-readable instead of staying prose-only; top-level `cam doctor --json` now also exposes additive `recommendedRoute`, `recommendedAction`, `recommendedActionCommand`, and `recommendedDoctorCommand`. In that top-level doctor payload, `recommendedRoute=companion` refers only to the companion/readiness surface and should not be confused with the MCP-first route-truth fields exposed by `cam mcp doctor` or `cam integrations doctor`. +- `cam session load --json --print-startup` now also exposes a structured continuity-startup contract: rendered `sourceFiles`, `candidateSourceFiles`, `sectionsRendered`, additive `omissions` / `omissionCounts`, `continuitySectionKinds`, `continuitySourceKinds`, `continuityProvenanceKind`, `continuityMode`, and a `futureCompactionSeam` placeholder. `sourceFiles` now stay truthful to what actually made it into the bounded startup block instead of echoing unrendered candidates. +- `cam integrations install --json` and `cam integrations apply --json` now also expose `postInstallReadinessCommand` / `postApplyReadinessCommand`, so post-install and post-apply route confirmation can stay machine-readable instead of drifting into notes-only prose. +- `cam remember --json` and `cam forget --json` now also expose additive aggregate reviewer counts such as `entryCount`, `warningCount`, `uniqueAuditCount`, `auditCountsDeduplicated`, and `warningsByEntryRef`; `forget --json` also adds `detailsUsableEntryCount` and `timelineOnlyEntryCount`, and both payloads now expose `leadEntryRef`, `leadEntryIndex`, `detailsAvailable`, and `reviewRefState` so multi-ref delete/archive payloads are less likely to be misread as single-ref facts. +- Durable sync now fail-closes on subagent rollouts: subagent evidence stays available for continuity/reviewer analysis, but `cam sync` records a reviewer-visible `subagent-rollout` skip instead of letting child-session noise enter canonical durable memory. +- Session continuity persistence now also fail-closes on subagent rollouts: explicit `--rollout`, matching recovery markers, and matching latest audit entries no longer rehydrate child-session continuity into shared/local continuity files. +- Shared/local continuity writes are now committed atomically; when the summary-write phase fails, CAM records a `summary-write` recovery marker instead of leaving behind partially updated continuity files. +- `workflowContract` now keeps the existing top-level compatibility fields while also exposing additive `executionContract`, `modelGuidanceContract`, and `hostWiringContract` objects, so execution routing, agent guidance, and host-wiring semantics are machine-readable separately. +- Current official Codex skills discovery docs now use `.agents/skills`; this repository still supports `.codex/skills` / `CODEX_HOME` as runtime and historical compatibility surfaces, but they should not be described as the new official canonical path. +- `cam recall search --json` now keeps unsafe or malformed topic sources reviewer-visible through `diagnostics.topicDiagnostics` whenever they fall inside the requested scope/state, even if the sidecar stays healthy and the search results themselves continue to fail closed. +- `cam remember --json` / `cam forget --json` now also expose top-level `reviewerSummary` and `nextRecommendedActions`, making the post-correction `timeline/details review -> recent review -> reindex` loop machine-readable. +- `cam remember` / `cam forget` text mode now also prints the same follow-up loop directly, so human reviewers do not have to reconstruct the next inspection steps after a manual correction. +- `cam integrations apply --json` now also exposes `rollbackReport` alongside `rollbackApplied`, `rollbackSucceeded`, `rollbackErrors`, and `rollbackPathCount`, plus per-subaction final-state fields such as `effectiveAction` and `rolledBack`, so rollback outcomes are explicit per path instead of only summarized as booleans. +- Lifecycle reviewer output now also distinguishes `restore`, `semantic-overwrite`, and `metadata-only`, so reviewers can separate semantic corrections from provenance-only updates. +- `cam integrations apply --host codex` now rolls back project-scoped MCP wiring, hook assets, and skill assets if AGENTS guidance blocks late or another staged write fails, reducing half-applied integration states. ## How it works @@ -250,7 +275,7 @@ flowchart TD ### Why the project does not switch to a native-first path yet - public Codex docs still do not define a Claude-equivalent native memory contract -- local `cam doctor --json` still exposes `memories` / `codex_hooks` more as readiness signals than as a stable primary implementation path +- local `cam doctor --json` still exposes `memories` / `codex_hooks` more as readiness signals than as a stable primary implementation path, but it now also splits `Native memory/hooks readiness` from `Host/UI signals` while adding the current app-server signal plus read-only retrieval-sidecar, unsafe-topic, and canonical-layout diagnostics - the repository therefore continues to treat the wrapper flow as the strongest current implementation The difference is product direction: this repository is no longer documenting hooks, skills, and MCP as mere distant future ideas. They are now part of the planned integration surface, provided they keep the same Markdown-first and auditable behavior contract. diff --git a/README.ja.md b/README.ja.md index 2d253a7..cacf2db 100644 --- a/README.ja.md +++ b/README.ja.md @@ -105,11 +105,11 @@ Claude Code はすでに比較的はっきりした auto memory 契約を公開 | 機能 | 説明 | | :-- | :-- | | 自動 post-session sync | Codex rollout JSONL から安定した知識を抽出し durable Markdown memory に書き戻す | -| 自動 startup recall | 緊凑な startup memory を組み立て、後続セッションへ durable knowledge を戻す | +| 自動 startup recall | 緊凑な startup memory を組み立て、後続セッションへ durable knowledge を戻す。現在は少量の active-only content highlights と按需 topic refs も含める | | Markdown-first | `MEMORY.md` と topic files が主表面であり、二次的な導出物ではない | | 記憶ライフサイクル | 明示的な訂正、重複排除、上書き、削除、reviewer 可視の conflict suppression に対応 | | formal retrieval MCP surface | `cam mcp serve` が `search_memories` / `timeline_memories` / `get_memory_details` を read-only な stdio MCP surface として公開する | -| project-scoped MCP install surface | `cam mcp install --host ` が推奨される project-scoped 宿主設定を書き込み、MCP 配線の摩擦を下げる | +| project-scoped MCP install surface | `cam mcp install --host codex` が推奨される Codex project-scoped 宿主設定を書き込み、MCP 配線の摩擦を下げる。非 Codex 宿主 wiring は境界化された接続面として `docs/host-surfaces.md` に集約する | | worktree-aware | 同一 git リポジトリ内の worktree で project memory を共有しつつ local continuity は分離する | | session continuity | 一時的な working state と durable memory を分離して扱う | | integration-aware evolution | wrapper 主導の現在地を保ちつつ、hook / skill / MCP 統合へ正式に進む | @@ -180,7 +180,7 @@ cam integrations doctor --host codex cam mcp install --host codex cam mcp print-config --host codex cam mcp apply-guidance --host codex -cam mcp doctor --host codex +cam mcp doctor cam session status cam session refresh cam remember "Always use pnpm instead of npm" @@ -195,29 +195,46 @@ cam audit | :-- | :-- | | `cam run` / `cam exec` / `cam resume` | startup memory を生成して wrapper 経由で Codex を起動 | | `cam sync` | 最新 rollout を durable memory に手動同期 | -| `cam memory` | startup files、topic refs、startup budget、edit paths、recent sync audit、suppressed conflict candidates を確認 | -| `cam memory reindex` | canonical Markdown から retrieval sidecar を明示的に再構築する。`--scope`、`--state`、`--cwd`、`--json` をサポートし、sidecar が missing / invalid / stale のときの低摩擦な repair path を提供する | -| `cam remember` / `cam forget` | durable memory の明示的な追加・削除。`cam forget --archive` は一致した項目をアーカイブ層へ移動する | -| `cam recall search` / `timeline` / `details` | `search -> timeline -> details` の progressive disclosure workflow で durable memory を段階的に取得する。`search` は `state=auto, limit=8` を既定値として使い、active を先に調べてヒットしなければ archived にフォールバックしつつ read-only を保つ | +| `cam memory` | startup files、topic refs、startup highlights、highlight budget / section render 状態、edit paths、recent sync audit、suppressed conflict candidates を確認する。`--cwd ` により別の project root を明示的に対象化できる。durable memory layout が未初期化のときは `MEMORY.md`、`ARCHIVE.md`、retrieval sidecar を暗黙生成せず、空の inspect view を返す。`--json` では `highlightCount`、`omittedHighlightCount`、`omittedTopicFileCount`、`highlightsByScope`、`startupSectionsRendered`、`startupOmissions`、`startupOmissionCounts`、`startupOmissionCountsByTargetAndStage`、`topicFileOmissionCounts`、`topicRefCountsByScope` に加えて、reviewer-visible な `topicDiagnostics` / `layoutDiagnostics` も返し、selection-stage・render-stage・canonical layout anomaly を区別できる | +| `cam memory reindex` | canonical Markdown から retrieval sidecar を明示的に再構築する。`--scope`、`--state`、`--cwd`、`--json` をサポートし、sidecar が missing / invalid / stale のときの低摩擦な repair path を提供する。durable memory layout が未初期化のときは layout を暗黙生成せず、空の `rebuilt` 結果を返す | +| `cam remember` / `cam forget` | durable memory の明示的な追加・削除。両方とも `--cwd ` をサポートし、別の project root を明示的に対象化できる。`cam forget --archive` は一致した項目をアーカイブ層へ移動する。`forget` は `recall search` と同じ多語 query 正規化も共有するようになり、`pnpm npm` のような query でも元の substring が連続していなくても `summary/details` をまたいで 1 件の memory に命中できる。両方とも `--json` をサポートし、`mutationKind`、`matchedCount`、`appliedCount`、`noopCount`、`summary`、`primaryEntry`、`entries[]`、`followUp`、`nextRecommendedActions`、そして少なくとも 1 件ヒットしたときにだけ出るトップレベルの lifecycle/detail フィールド(`latestAppliedLifecycle`、`latestLifecycleAttempt`、`latestLifecycleAction`、`latestState`、`latestSessionId`、`latestRolloutPath`、`latestAudit`、`timelineWarningCount`、`warnings`、`entry`、`lineageSummary`、`ref/path/historyPath`)を含む manual mutation reviewer payload を返す。さらに `leadEntryRef`、`leadEntryIndex`、`detailsAvailable`、`reviewRefState`、`uniqueAuditCount`、`auditCountsDeduplicated`、`warningsByEntryRef` も返す。空の `forget --json` は additive な空 payload のままで、`nextRecommendedActions` も空配列を返し、占位 `""` は出さない。delete フローでは timeline-only と details-usable の review route も分けて返す。テキスト出力でも project-pinned な `timeline/details -> recent -> reindex` の follow-up を直接案内するようになった | +| `cam recall search` / `timeline` / `details` | `search -> timeline -> details` の progressive disclosure workflow で durable memory を段階的に取得する。`search` は `state=auto, limit=8` を既定値として使い、active を先に調べてヒットしなければ archived にフォールバックしつつ read-only を保つ。複数語の query は `id/topic/summary/details` をまたいで集約マッチするようになり、すべての term が同一 field にある必要はない。JSON ではさらに `retrievalMode`、`finalRetrievalMode`、`retrievalFallbackReason`、`stateResolution`、`executionSummary`、`searchOrder`、`totalMatchedCount`、`returnedCount`、`globalLimitApplied`、`truncatedCount`、`resultWindow`、`globalRank`、`diagnostics.checkedPaths[].returnedCount` / `droppedCount` を返し、fallback、global sorting、post-limit の挙動を reviewer-visible にする | | `cam mcp serve` | `search_memories` / `timeline_memories` / `get_memory_details` を通じて同じ retrieval contract を公開する read-only MCP server を起動する | -| `cam integrations install --host codex` | 推奨される Codex integration stack を一度に導入し、project-scoped MCP wiring を書き込みつつ、hook bridge bundle と Codex skill assets を更新する。skills は runtime target が既定だが、`--skill-surface runtime|official-user|official-project` も指定できる。明示的・冪等・Codex-only を保ち、Markdown memory store には触れない | -| `cam integrations apply --host codex` | 明示的・冪等・Codex-only のまま完全な integration state を適用する。`integrations install` の既存境界は変えず、その上で `cam mcp apply-guidance --host codex` も編成する。skills は runtime target が既定だが、`--skill-surface runtime|official-user|official-project` も指定できる。`AGENTS.md` managed block が unsafe な場合は、stack への書き込み前に preflight `blocked` を返す。staged write が途中で失敗した場合も、`rollbackSucceeded`、`rollbackErrors`、各 subaction の `effectiveAction`、`rolledBack` を明示的に返し、試行した書き込みと最終状態を区別できる | -| `cam integrations doctor --host codex` | 現在の Codex integration stack を薄い read-only 集約面として点検し、推奨ルート、推奨 preset、構造化された `workflowContract`、`applyReadiness`、サブチェック結果、次の最小アクションを返す。`AGENTS.md` managed block が unsafe な場合は、まずその修復を案内し、すぐに `cam integrations apply --host codex` を勧めない | -| `cam mcp install --host ` | 推奨される project-scoped 宿主設定を明示的に書き込み、`codex_auto_memory` の項目だけを更新する。hooks/skills は自動導入せず、その entry に non-canonical なカスタム項目がある場合は安全な範囲で保持する。`generic` は引き続き manual-only | -| `cam mcp print-config --host ` | ready-to-paste な接続スニペットを出力し、read-only retrieval plane を既存の MCP client に低摩擦で接続できるようにする。`--host codex` の場合は、将来の Codex エージェントに MCP 優先・`cam recall` フォールバックを教えるための推奨 `AGENTS.md` snippet に加えて、JSON payload に共有 `workflowContract` も含める | +| `cam integrations install --host codex` | 推奨される Codex integration stack を一度に導入し、project-scoped MCP wiring を書き込みつつ、hook bridge bundle と Codex skill assets を更新する。skills は runtime target が既定だが、`--skill-surface runtime|official-user|official-project` も指定できる。明示的・冪等・Codex-only を保ち、Markdown memory store には触れない。さらに staged install の途中で失敗した場合は、MCP / hooks / skills の書き込みを rollback し、`--json` では構造化された rollback failure payload も返す。導入後は `cam integrations doctor --host codex` に戻り、現在の環境で本当に operational な retrieval route を確認する | +| `cam integrations apply --host codex` | 明示的・冪等・Codex-only のまま完全な integration state を適用する。`integrations install` の既存境界は変えず、その上で `cam mcp apply-guidance --host codex` も編成する。skills は runtime target が既定だが、`--skill-surface runtime|official-user|official-project` も指定できる。`AGENTS.md` managed block が unsafe な場合は、stack への書き込み前に preflight `blocked` を返す。apply 後も `doctor` に戻り、実際に有効なのが MCP、local bridge、resolved CLI のどれかを確認する必要がある | +| `cam integrations doctor --host codex` | 現在の Codex integration stack を薄い read-only 集約面として点検し、推奨ルートと現在の route truth(`recommendedRoute`、`currentlyOperationalRoute`、`routeKind`、`routeEvidence`、`shellDependencyLevel`、`hostMutationRequired`、`preferredRouteBlockers`、`currentOperationalBlockers`)、推奨 preset、構造化された `workflowContract`、`applyReadiness`、`experimentalHooks`、`layoutDiagnostics`、サブチェック結果、次の最小アクションを返す。`recommendedRoute` は MCP-first のまま維持され、blocker フィールドが「なぜ preferred route が使えないのか」と「現在の fallback 自体に operational blocker があるか」を分けて示す。さらに skill-surface steering(`preferredSkillSurface`、`recommendedSkillInstallCommand`、`installedSkillSurfaces`、`readySkillSurfaces`)も返し、guidance surface の導入先を示すが、skills 自体を executable fallback route とは扱わない。hook helper についても「installed だが今の shell では operational でない」を区別して返す。`--cwd` で別リポジトリを検査した場合、hooks fallback の next step も `CAM_PROJECT_ROOT=...` を付けて local bridge route を対象 project に pin する。`cam` が PATH で解決できない場合、direct CLI next step は壊れた bare `cam recall ...` ではなく resolved `node dist/cli.js recall ...` fallback を優先する。`AGENTS.md` managed block が unsafe な場合は、まずその修復を案内し、すぐに `cam integrations apply --host codex` を勧めない | +| `cam mcp install --host codex` | 推奨される Codex project-scoped 宿主設定を明示的に書き込み、`codex_auto_memory` の項目だけを更新する。hooks/skills は自動導入せず、その entry に non-canonical なカスタム項目がある場合は安全な範囲で保持する。より低優先度の非 Codex host wiring は `docs/host-surfaces.md` に収め、既定の製品導線にはしない。その一部は引き続き `manual-only` のまま扱う | +| `cam mcp print-config --host codex` | ready-to-paste な Codex 接続スニペットを出力し、read-only retrieval plane を現在の主ワークフローに低摩擦で接続できるようにする。将来の Codex エージェント向けに、MCP を優先し、その次にローカル `memory-recall.sh` bridge bundle、最後に resolved CLI recall を使う retrieval route を教える推奨 `AGENTS.md` snippet に加えて、JSON payload に共有 `workflowContract` と明示的な `experimentalHooks` guidance も含める。その他 host の snippet は境界化された wiring 参考として `docs/host-surfaces.md` に集約し、`manual-only` 分岐もそこに閉じ込める | | `cam mcp apply-guidance --host codex` | repo ルートの `AGENTS.md` 内にある Codex Auto Memory 管理 block を additive・監査可能・fail-closed に作成または更新する。同じ marker block の追加または置換だけを行い、安全に特定できない場合は書き換えず `blocked` を返す | -| `cam mcp doctor` | 推奨される project-scoped retrieval MCP の配線、project pinning、hook/skill fallback assets を read-only で点検し、さらに `codexStack` readiness と構造化された `workflowContract` によって推奨ルート、executable bit、共有 asset version、workflow consistency を要約する。alternate global wiring が見つかった場合も、推奨される project-scoped ルートとは分けて報告し、ホスト設定は書き換えない | +| `cam mcp doctor` | 推奨される project-scoped retrieval MCP の配線、project pinning、hook/skill fallback assets を read-only で点検し、さらに構造化された `workflowContract`、`layoutDiagnostics`、最小粒度の retrieval sidecar repair command を返す。`cam` が PATH で解決できない場合、この repair command も resolved launcher fallback に追従する。対象 host selection に Codex が含まれる場合(`--host codex` または `all`)、JSON には Codex-only の `codexStack` route truth、`experimentalHooks`、AGENTS guidance/apply safety も追加される。`claude`、`gemini`、`generic` のような manual-only / snippet-first host では、`commandSurface.install` と `commandSurface.applyGuidance` は明示的に `false` になり、Codex-only の writable guidance surface を実行可能能力として見せない。hook capture / recall についても installed と「helper に埋め込まれた launcher が現在の環境で動作可能か」を分けて報告し、app-server signal も `memories` / `codex_hooks` とは別に扱う。alternate global wiring が見つかった場合も、推奨される project-scoped ルートとは分けて扱う | | `cam session save` | continuity の merge / incremental save | | `cam session refresh` | continuity の replace / clean regeneration | | `cam session load` / `status` | continuity reviewer surface を確認 | -| `cam hooks install` | 現在の local bridge / fallback helper bundle を生成・更新し、`memory-recall.sh`、`post-work-memory-review.sh`、互換 wrapper、`recall-bridge.md` を通じて今後の hook / skill / MCP-aware retrieval に備える。`post-work-memory-review.sh` は `cam sync` と `cam memory --recent` をまとめた収束 review helper である。これは公式な Codex hook surface ではなく、推奨検索 preset は `state=auto`、`limit=8` | -| `cam skills` | `cam skills install` で Codex skill を導入する。既定 target は runtime のままだが、`--surface runtime|official-user|official-project` を使えば公式 `.agents/skills` 経路向けの明示的な互換コピーも置ける。どの surface でも、MCP-first / CLI-fallback の段階的 durable memory retrieval workflow と推奨検索 preset `state=auto`, `limit=8` を共有する | +| `cam hooks install` | 現在の local bridge / fallback helper bundle を生成・更新し、`memory-recall.sh`、`post-work-memory-review.sh`、互換 wrapper、`recall-bridge.md` を通じて今後の hook / skill / MCP-aware retrieval に備える。`post-work-memory-review.sh` は `cam sync` と `cam memory --recent` をまとめた収束 review helper である。これらの user-scoped helper は共有アセットに単一 repo パスを埋め込む代わりに、実行時に `CAM_PROJECT_ROOT` または shell の `PWD` から対象プロジェクトを解決する。これは公式な Codex hook surface ではなく、公式 hooks は依然として `Experimental` の opt-in ルートであり、config 文書の `codex_hooks` feature flag はまだ `Under development` かつデフォルトで無効である | +| `cam skills install` | Codex skill を導入する。既定 target は runtime のままだが、`--surface runtime|official-user|official-project` を使えば公式 `.agents/skills` 経路向けの明示的な互換コピーも置ける。どの surface でも、MCP-first の段階的 durable memory retrieval workflow を共有し、未接続時はまずローカルの `memory-recall.sh search -> timeline -> details` bridge bundle にフォールバックし、その後で resolved CLI recall に退避する。推奨検索 preset は引き続き `state=auto`, `limit=8` で共通だ。skills は依然として guidance surface であり、executable fallback route そのものではないため、現在どの route が実際に動作可能かは `cam mcp doctor --host codex` / `cam integrations doctor --host codex` で確認する | | `cam audit` | プライバシーと secret hygiene を監査 | -| `cam doctor` | ローカル wiring と native-readiness を確認 | +| `cam doctor` | ローカル wiring と native-readiness を確認する。`--json` では retrieval sidecar の健全性、unsafe topic diagnostics、canonical layout diagnostics も追加で返し、引き続き完全 read-only を保つ | 補足: - `cam skills install` の公開 surface は `runtime`、`official-user`、`official-project` に固定された。runtime が既定 target のままで、公式 `.agents/skills` 経路は明示的な opt-in install として扱う。 +- 共有 `workflowContract` は launcher 前提も明示するようになった。`commandName=cam`、`requiresPathResolution=true`、`hookHelpersShellOnly=true` により、hooks / skills / doctor / print-config が PATH と shell 依存を同じ言葉で説明する。さらに helper bundle と doctor next steps は、`cam` が解決できない場合に `node /dist/cli.js` の verified fallback を優先して示す。 +- `workflowContract.launcher` は direct CLI とインストール済み helper asset 向けの launcher contract であり、canonical MCP host snippet そのものではないことも明示するようになった。host wiring 自体は引き続き `cam mcp serve` を canonical な設定形として扱う。 +- `workflowContract.launcher` は doctor と同じ executable-aware truth source を使うようになり、PATH 上に不可実行の `cam` ファイルがあるだけでは verified launcher とみなさない。unverified 分岐も `verified fallback` とは呼ばず、unverified direct command として扱う。 +- Startup highlights は unsafe topic files も除外するようになった。さらに startup topic refs も safe references のみを返すようになり、`cam memory --json` と `cam memory reindex --json` は `topicDiagnostics` と `layoutDiagnostics` を返し、`cam memory --json` は `startupOmissions`、`startupOmissionCounts`、`topicFileOmissionCounts`、`topicRefCountsByScope` も返すため、highlight omission、topic ref omission、canonical layout anomaly のすべてが reviewer-visible になる。加えて、global highlight cap で後続 scope の highlight が落ちたときも selection-stage omission を残す。 +- durable sync audit は `rejectedOperationCount`、`rejectedReasonCounts`、軽量な `rejectedOperations` 要約も返すようになり、unknown topic、sensitive content、volatile content、operation cap などで拒否された理由が reviewer surface から静かに消えなくなった。 +- 自動抽出は `reference` 系 durable memory、たとえば dashboard、issue tracker、runbook、docs pointer のような外部参照もより自然に保持するようになった。一方で `.agents/`、`.codex/`、`.gemini/`、`.mcp.json`、`next step`、`resume here` のような session-only / local-host ノイズは durable memory に入りにくくなっている。 +- `cam hooks install --json` と `cam skills install --json` は `postInstallReadinessCommand` も返すようになり、「インストール後にどの doctor を実行して operational route を確認するか」が machine-readable になった。トップレベルの `cam doctor --json` も `recommendedRoute`、`recommendedAction`、`recommendedActionCommand`、`recommendedDoctorCommand` を返すが、ここでの `recommendedRoute=companion` はトップレベルの companion / readiness surface を指すだけで、`cam mcp doctor` / `cam integrations doctor` が返す MCP-first の route truth とは別物である。 +- `cam session load --json --print-startup` は continuity startup contract も返すようになった。実際に描画された `sourceFiles`、候補の `candidateSourceFiles`、`sectionsRendered`、`omissions` / `omissionCounts`、`continuitySectionKinds`、`continuitySourceKinds`、`continuityProvenanceKind`、`continuityMode`、`futureCompactionSeam` が追加され、`sourceFiles` は実際に bounded startup block に入った source のみを表す。 +- `cam integrations install --json` / `cam integrations apply --json` も `postInstallReadinessCommand` / `postApplyReadinessCommand` を返すようになり、install / apply 後にどの doctor へ戻って route を確認すべきかを notes prose ではなく machine-readable contract として扱えるようになった。 +- `cam remember --json` / `cam forget --json` には `entryCount`、`warningCount`、`uniqueAuditCount`、`auditCountsDeduplicated`、`warningsByEntryRef` が追加され、`forget --json` にはさらに `detailsUsableEntryCount` と `timelineOnlyEntryCount` が加わった。これにより multi-ref mutation payload を single-ref fact と誤読しにくくなる。 +- Durable sync は subagent rollout に対して fail-closed になり、child-session の rollout は continuity / reviewer 用には残しつつ、`cam sync` では reviewer-visible な `subagent-rollout` skip として扱われる。 +- `cam recall search --json` は、要求した scope/state に unsafe / malformed な topic source が含まれる限り `diagnostics.topicDiagnostics` を reviewer-visible に返す。sidecar が健康でも検索結果自体は fail-closed のまま unsafe topic を除外し、warning だけを早めに見せる。 +- `cam remember --json` / `cam forget --json` はトップレベルの `reviewerSummary` と `nextRecommendedActions` も返すようになり、手動修正後の `timeline/details review -> recent review -> reindex` ループを machine-readable にした。 +- `cam integrations apply --json` は `rollbackReport` も返すようになり、rollback ごとに「既存ファイルを復元した」「新規ファイルを削除した」「rollback 自体が失敗した」を区別できる。 +- startup highlights は `project-local` / `project` / `global` をまたいで同一 summary を重複表示しないようになり、低信号な重複が限られた startup budget を消費しにくくなった。 +- lifecycle reviewer では `updateKind` も `restore`、`semantic-overwrite`、`metadata-only` に細分化され、レビュー時に「アーカイブ復元」「意味上の修正」「metadata だけの更新」を見分けやすくなった。 +- `cam integrations apply --host codex` は、AGENTS apply の late-block や途中書き込み失敗が起きた場合、project-scoped MCP wiring・hook bundle・skill assets をロールバックして半成功状態を減らすようになった。`--json` では `effectiveAction`、`rolledBack`、`rollbackSucceeded` などの最終状態フィールドも返し、「書こうとした」ことと「最終的に入った」ことを分けて読めるようにしている。 - 主要な `--help` 文言も release-facing public contract の一部として扱う。特に `integrations install/apply/doctor`、`mcp install/print-config/apply-guidance`、`skills install` は README、アーキテクチャ文書、dist/tarball smoke と同じ境界説明を維持する必要がある。 ## 動作の仕組み @@ -249,7 +266,7 @@ flowchart TD ### なぜまだ native-first ではないのか - 公開された Codex ドキュメントは Claude Code 相当の完全な native memory 契約をまだ定義していません -- `cam doctor --json` に見える `memories` / `codex_hooks` も、今は readiness signal の性格が強いです +- `cam doctor --json` に見える `memories` / `codex_hooks` も、今は readiness signal の性格が強いです。加えて、app-server signal、retrieval sidecar・unsafe topic・canonical layout の read-only diagnostics も返すようになりました - そのため現在もっとも信頼できるのは wrapper-first の主線です ただし方向性は変わりました。hooks、skills、MCP は「いつかの案」ではなく、Markdown-first 契約を壊さない範囲で正式に取り込んでいく統合面として扱います。 diff --git a/README.md b/README.md index 54271d7..c6799a0 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

简体中文 | 繁體中文 | - English | + English 日本語

@@ -25,7 +25,7 @@

-> `codex-auto-memory` 不是通用笔记软件,也不是云端记忆服务。 +> `codex-auto-memory` 不是通用笔记软件,也不是云端记忆服务,更不是多宿主统一平台主仓。 > 它的目标是:在今天的 Codex CLI 上,以本地 Markdown 为主存储表面,先用 companion-first 的方式提供可靠记忆能力,再逐步补齐 hooks、skills、MCP 等更自动化的 integration surfaces。 --- @@ -108,7 +108,7 @@ Claude Code 已经公开了一套相对清晰的 auto memory 产品契约: | :-- | :-- | :-- | | 自动 durable memory sync | 已有主路径 | 会话结束后从 Codex rollout JSONL 中提取稳定、未来有用的信息并写回 Markdown memory | | Markdown-first canonical store | 已有主路径 | `MEMORY.md` 与 topic files 就是产品表面,而不是内部缓存 | -| 紧凑 startup recall | 已有主路径 | 启动时注入真正进入 payload 的 quoted `MEMORY.md` startup files,并附带按需 topic refs | +| 紧凑 startup recall | 已有主路径 | 启动时注入真正进入 payload 的 quoted `MEMORY.md` startup files,附带少量 active-only content highlights,并保留按需 topic refs | | worktree-aware project identity | 已有主路径 | 同一 git 仓库的 worktree 共享 project memory,project-local 仍保持隔离 | | session continuity | 已有主路径 | 临时 working state 与 durable memory 分层存储、分层加载 | | conflict review / conservative suppression | 已有主路径 | 冲突 candidate 不静默 merge,而是显式 suppress 并暴露 reviewer 信息 | @@ -116,10 +116,10 @@ Claude Code 已经公开了一套相对清晰的 auto memory 产品契约: | archive lifecycle | 已有首批实现 | 支持 `cam forget --archive` 将长期但不再活跃的信息转入可检索归档层,而不是只能 delete | | search / timeline / detail retrieval | 已有首批实现 | 提供 `cam recall search` / `timeline` / `details`,以 progressive disclosure 方式检索记忆 | | formal retrieval MCP surface | 本轮新增 | 提供 `cam mcp serve`,通过 `search_memories` / `timeline_memories` / `get_memory_details` 暴露只读 retrieval plane | -| project-scoped MCP install surface | 本轮新增 | 提供 `cam mcp install --host `,显式写入推荐的 project-scoped 宿主配置,降低 MCP 接线摩擦 | +| project-scoped MCP install surface | 本轮新增 | 提供 `cam mcp install --host codex`,显式写入推荐的 Codex project-scoped 宿主配置;非 Codex 宿主 wiring 仍属于边界化接线能力,集中记录在 `docs/host-surfaces.md` | | noop-aware lifecycle audit | 已有首批实现 | 相同 active memory 的重复写入、以及缺失 active 目标的 delete/archive,会显式记为 `noop` reviewer 结果,而不再静默重写 Markdown | | hook / skill / MCP-aware integration | 已进入代码主线 | `cam hooks install` 现在会生成 recall bridge bundle(`memory-recall.sh`、`post-work-memory-review.sh`、兼容 wrapper 与 `recall-bridge.md`),供后续 hook / skill / MCP bridge 复用 | -| Codex skill install surface | 已有首批实现 | `cam skills install` 默认安装 runtime 目标,并支持显式 `--surface runtime|official-user|official-project`;无论装到哪个 surface,都沿用同一套 MCP-first、CLI-fallback 的 `search -> timeline -> details` durable memory 工作流 | +| Codex skill install surface | 已有首批实现 | `cam skills install` 默认安装 runtime 目标,并支持显式 `--surface runtime|official-user|official-project`;无论装到哪个 surface,都沿用同一套 `MCP -> local bridge -> resolved CLI` 的 `search -> timeline -> details` durable memory 工作流 | ## 集成方向 @@ -206,25 +206,49 @@ cam audit | :-- | :-- | | `cam run` / `cam exec` / `cam resume` | 编译 startup memory 并通过 wrapper 启动 Codex | | `cam sync` | 手动把最近 rollout 同步进 durable memory | -| `cam memory` | 查看 startup payload、topic refs、edit paths、durable sync audit 与 suppressed conflict candidates | -| `cam memory reindex` | 显式从 canonical Markdown 重建 retrieval sidecar;支持 `--scope`、`--state`、`--cwd`、`--json`,用于 sidecar 缺失、损坏或 stale 时的低心智修复路径 | -| `cam remember` / `cam forget` | 显式新增、删除或修正 memory;`cam forget --archive` 会把匹配条目移入归档层 | -| `cam recall search` / `timeline` / `details` | 以 `search -> timeline -> details` 的 progressive disclosure 检索 durable memory;`search` 默认采用 `state=auto`、`limit=8`,先查 active,未命中再回退 archived,且保持只读 retrieval | +| `cam memory` | 查看 startup payload、topic refs、startup highlights、highlight budget / section 渲染情况、edit paths、durable sync audit 与 suppressed conflict candidates;支持 `--cwd ` 跨目录锚定目标项目;若 durable memory layout 尚未初始化,会返回空的 inspect 视图而不是隐式创建 `MEMORY.md` / `ARCHIVE.md` / sidecar;`--json` 还会额外暴露 `highlightCount`、`omittedHighlightCount`、`omittedTopicFileCount`、`highlightsByScope`、`startupSectionsRendered`、`startupOmissions`、`startupOmissionCounts`、`startupOmissionCountsByTargetAndStage`、`topicFileOmissionCounts`、`topicRefCountsByScope`,以及 reviewer-visible `topicDiagnostics` / `layoutDiagnostics`,帮助区分 selection-stage、render-stage、global highlight cap trimming 与 canonical layout 异常 | +| `cam memory reindex` | 显式从 canonical Markdown 重建 retrieval sidecar;支持 `--scope`、`--state`、`--cwd`、`--json`,用于 sidecar 缺失、损坏或 stale 时的低心智修复路径;若 durable memory layout 尚未初始化,会返回空的 `rebuilt` 结果而不是隐式创建 layout | +| `cam remember` / `cam forget` | 显式新增、删除或修正 memory;两者现在也支持 `--cwd ` 用于跨目录锚定目标项目;`cam remember` 在省略 `--topic` 时会做轻量 durable topic 推断,并在“唯一旧值可识别”时优先更新现有 memory,而不是无脑追加第二条 active entry;`cam forget --archive` 会把匹配条目移入归档层;`forget` 现在还会和 `recall search` 共用多词 query 归一化语义,允许像 `pnpm npm` 这样的 query 跨 `summary/details` 命中同一条 memory,而不是要求整段原始 substring 连续出现;两者现在都支持 `--json`,返回 manual mutation 的 reviewer payload,包括 `mutationKind`、`matchedCount`、`appliedCount`、`noopCount`、`summary`、`primaryEntry`、`entries[]`、`followUp`、`nextRecommendedActions`,以及在至少命中一个 ref 时额外暴露的顶层 lifecycle/detail 字段(`latestAppliedLifecycle`、`latestLifecycleAttempt`、`latestLifecycleAction`、`latestState`、`latestSessionId`、`latestRolloutPath`、`latestAudit`、`timelineWarningCount`、`warnings`、`entry`、`lineageSummary`、`ref/path/historyPath`);现在还会额外暴露 `leadEntryRef`、`leadEntryIndex`、`detailsAvailable`、`reviewRefState`、`uniqueAuditCount`、`auditCountsDeduplicated` 与 `warningsByEntryRef`,让 delete / archive / multi-entry forget 的 lead-entry 与聚合 reviewer 语义更显式;空的 `forget --json` 结果现在保持 additive,并会返回空的 `nextRecommendedActions`,不再给出占位式 `""` 提示;delete 分支还会显式区分 timeline-only 与 details-usable review routes;文本模式现在也会直接给出 project-pinned 的 `timeline/details -> recent -> reindex` follow-up,帮助手工修正后自然回到 reviewer 闭环 | +| `cam recall search` / `timeline` / `details` | 以 `search -> timeline -> details` 的 progressive disclosure 检索 durable memory;`search` 默认采用 `state=auto`、`limit=8`,先查 active,未命中再回退 archived,且保持只读 retrieval;多词查询现在会跨 `id/topic/summary/details` 聚合命中,而不是要求所有 term 落在同一个字段;JSON 输出现在还会额外暴露 `retrievalMode`、`finalRetrievalMode`、`retrievalFallbackReason`、`stateResolution`、`executionSummary`、`searchOrder`、`totalMatchedCount`、`returnedCount`、`globalLimitApplied`、`truncatedCount`、`resultWindow`、`globalRank`,以及 `diagnostics.checkedPaths[].returnedCount` / `droppedCount`,把 auto-state、global sort、fallback 与 post-limit 行为说清楚;其中 `finalRetrievalMode` 只是对最终结果面的显式别名,`retrievalMode` 继续保留兼容语义 | | `cam mcp serve` | 启动只读 retrieval MCP server,通过 `search_memories` / `timeline_memories` / `get_memory_details` 暴露同一套渐进式检索契约 | -| `cam integrations install --host codex` | 一次性安装推荐的 Codex integration stack:写入 project-scoped MCP wiring,并刷新 hook bridge bundle 与 Codex skill 资产;默认使用 runtime skills target,也支持显式 `--skill-surface runtime|official-user|official-project`;保持显式、幂等、Codex-only,且不触碰 Markdown memory store | -| `cam integrations apply --host codex` | 以显式、幂等、Codex-only 的方式应用完整 integration state:在保留 `integrations install` 旧语义不变的前提下,额外编排 `cam mcp apply-guidance --host codex`;默认使用 runtime skills target,也支持显式 `--skill-surface runtime|official-user|official-project`;若 `AGENTS.md` managed block 不安全,会在任何 stack 写入之前 preflight `blocked`,保持 additive / fail-closed;若 staged write 途中失败,还会显式返回 `rollbackSucceeded`、`rollbackErrors`、subaction `effectiveAction` 与 `rolledBack`,避免把“尝试写入”误读成“最终已安装” | -| `cam integrations doctor --host codex` | 以 Codex-only、只读、薄聚合的方式汇总当前 integration stack readiness,直接给出推荐路由、推荐 preset、结构化 `workflowContract`、`applyReadiness`、子检查结果与下一步最小动作;当 AGENTS guidance 处于 unsafe managed-block 状态时,会先提示修复 `AGENTS.md`,而不是直接推荐 `cam integrations apply --host codex` | -| `cam mcp install --host ` | 显式写入推荐的 project-scoped 宿主 MCP 配置;只更新 `codex_auto_memory` 这一项,不会自动安装 hooks/skills;若该 entry 已带有非 canonical 自定义字段,会在安全前提下保留它们;`generic` 继续保持 manual-only | -| `cam mcp print-config --host ` | 打印 ready-to-paste 的宿主接入片段,降低把 read-only retrieval plane 接进现有工作流的摩擦;其中 `--host codex` 还会额外打印推荐的 `AGENTS.md` snippet,并在 JSON payload 中附带共享 `workflowContract`,帮助未来 Codex 代理优先走 MCP、必要时再 fallback 到 `cam recall` | +| `cam integrations install --host codex` | 一次性安装推荐的 Codex integration stack:写入 project-scoped MCP wiring,并刷新 hook bridge bundle 与 Codex skill 资产;默认使用 runtime skills target,也支持显式 `--skill-surface runtime|official-user|official-project`;保持显式、幂等、Codex-only,不触碰 Markdown memory store;如果 staged install 中途失败,现在也会回滚已写入的 MCP / hooks / skills 文件,避免留下半成功状态;`--json` 还会返回结构化 rollback payload;安装完成后会明确提醒再跑 `cam integrations doctor --host codex` 确认当前环境里真正 operational 的 retrieval route | +| `cam integrations apply --host codex` | 以显式、幂等、Codex-only 的方式应用完整 integration state:在保留 `integrations install` 旧语义不变的前提下,额外编排 `cam mcp apply-guidance --host codex`;默认使用 runtime skills target,也支持显式 `--skill-surface runtime|official-user|official-project`;若 `AGENTS.md` managed block 不安全,会在任何 stack 写入之前 preflight `blocked`,保持 additive / fail-closed;若 late-block 或 staged write 失败,JSON payload 现在也会显式暴露 rollback outcome 与最终 effective action,避免把“尝试写过”误读成“最终已安装”;apply 完成后同样需要再用 doctor 判断当前环境里是 MCP、local bridge 还是 resolved CLI 在实际生效 | +| `cam integrations doctor --host codex` | 以 Codex-only、只读、薄聚合的方式汇总当前 integration stack readiness,直接给出推荐路由、当前 operational route truth(`recommendedRoute`、`currentlyOperationalRoute`、`routeKind`、`routeEvidence`、`shellDependencyLevel`、`hostMutationRequired`、`preferredRouteBlockers`、`currentOperationalBlockers`)、推荐 preset、结构化 `workflowContract`、`applyReadiness`、`experimentalHooks`、`layoutDiagnostics`、子检查结果与下一步最小动作;其中 `recommendedRoute` 继续表示 MCP-first 的首选路径,而 blocker 字段会分开说明“为什么首选路由没跑起来”和“当前 fallback 自己是否还有问题”;还会额外暴露 skill-surface steering(`preferredSkillSurface`、`recommendedSkillInstallCommand`、`installedSkillSurfaces`、`readySkillSurfaces`),帮助后续安装 guidance surface,但不把 skills 误写成 executable fallback route;当通过 `--cwd` 检查另一个项目时,hooks fallback 的 next steps 现在也会通过 `CAM_PROJECT_ROOT=...` 把 local bridge route project-pin 到目标仓库;当 `cam` 当前不可解析时,direct CLI next step 也会优先给出 resolved `node dist/cli.js recall ...` fallback,而不是先给出会失败的裸 `cam recall ...`;当 AGENTS guidance 处于 unsafe managed-block 状态时,会先提示修复 `AGENTS.md`,而不是直接推荐 `cam integrations apply --host codex` | +| `cam mcp install --host codex` | 显式写入推荐的 Codex project-scoped 宿主 MCP 配置;只更新 `codex_auto_memory` 这一项,不会自动安装 hooks/skills;若该 entry 已带有非 canonical 自定义字段,会在安全前提下保留它们;更低优先级的非 Codex host wiring 细节继续收口到 `docs/host-surfaces.md`,不作为默认产品路径,其中一部分仍保持 `manual-only` | +| `cam mcp print-config --host codex` | 打印 ready-to-paste 的 Codex 接入片段,降低把 read-only retrieval plane 接进当前主工作流的摩擦;还会额外打印推荐的 `AGENTS.md` snippet,并在 JSON payload 中附带共享 `workflowContract` 与显式 `experimentalHooks` guidance,帮助未来 Codex 代理优先走 MCP,再 fallback 到本地 `memory-recall.sh` bridge bundle,最后再退到 resolved CLI recall,同时把官方 hooks 继续标为 Experimental;其他 host snippet 仍属于边界化 wiring 参考,放在 `docs/host-surfaces.md` 中说明,其中保留 `manual-only` 分支 | | `cam mcp apply-guidance --host codex` | 以 additive、可审计、fail-closed 的方式创建或更新仓库根 `AGENTS.md` 中由 Codex Auto Memory 自己管理的 guidance block;只会 append 新 block 或替换同一 marker block,无法安全定位时返回 `blocked` 而不会冒险改写 | -| `cam mcp doctor` | 只读检查当前项目的 retrieval MCP 接入状态、project pinning 与 hook/skill fallback 资产;同时追加 `codexStack` readiness 视图与结构化 `workflowContract`,用于汇总推荐路由、executable bit、共享资产版本与 workflow consistency;若检测到 alternate global wiring,也会与推荐的 project-scoped 路径明确区分,不会改写任何宿主配置 | +| `cam mcp doctor --host codex` | 只读检查当前项目的推荐 Codex retrieval MCP 接入状态、project pinning 与 hook/skill fallback 资产;同时追加结构化 `workflowContract`、`layoutDiagnostics` 与最小粒度的 retrieval sidecar repair command;当 `cam` 不在 PATH 上时,这条 repair command 也会跟随 resolved launcher fallback。若 `--host codex`(或 `all` 中包含 Codex),JSON 还会额外暴露 `codexStack` route truth、`experimentalHooks` 与 AGENTS guidance/apply safety;若检查的是 `claude`、`gemini`、`generic` 这类 manual-only / snippet-first 宿主,则 host 级状态只表示“片段/配置是否存在且形状正确”,不会把它们抬到和 Codex operational route 同一 readiness 层级;`commandSurface.install/applyGuidance` 也会显式为 `false`,不会冒充 Codex-only 的可写 guidance surface。doctor 现在也会把“hook assets 已安装”与“helper 内嵌 launcher 现在是否真能跑起来”区分开来,并把 app-server signal 与 `memories` / `codex_hooks` 分开表达;若检测到 alternate global wiring,也会与推荐的 project-scoped 路径明确区分,不会改写任何宿主配置 | | `cam session save` | merge / incremental save;从 rollout 增量写入 continuity | | `cam session refresh` | replace / clean regeneration;从选定 provenance 重建 continuity | -| `cam session load` / `status` | 查看 continuity reviewer surface 与 diagnostics | -| `cam hooks install` | 生成本仓自带的 local bridge / fallback helper bundle,包括 `memory-recall.sh`、`post-work-memory-review.sh`、兼容 helper wrappers 与 `recall-bridge.md`;其中 `post-work-memory-review.sh` 会把 `cam sync` 与 `cam memory --recent` 串成同一套收尾 review 动作;它不是官方 Codex hook surface,且该 bundle 的推荐检索 preset 为 `state=auto`、`limit=8` | -| `cam skills install` | 默认安装 runtime Codex skill 资产,并支持显式 `--surface runtime|official-user|official-project`;让代理优先通过 retrieval MCP,未接线时再 fallback 到 `cam recall`,并沿用同一套推荐检索 preset:`state=auto`、`limit=8` | +| `cam session load` / `status` | 查看 continuity reviewer surface 与 diagnostics;`load --json --print-startup` 现在会额外暴露 continuity startup contract,包括实际渲染的 `sourceFiles`、候选 `candidateSourceFiles`、`sectionsRendered`、`omissions` / `omissionCounts`、`continuitySectionKinds`、`continuitySourceKinds`、`continuityProvenanceKind`、`continuityMode` 与 `futureCompactionSeam`,让 temporary continuity startup payload 也具备接近 durable startup 的可解释性 | +| `cam hooks install` | 生成本仓自带的 local bridge / fallback helper bundle,包括 `memory-recall.sh`、`post-work-memory-review.sh`、兼容 helper wrappers 与 `recall-bridge.md`;其中 `post-work-memory-review.sh` 会把 durable memory 的 `sync -> recent review` 串成同一套收尾动作;这些 user-scoped helper 现在优先在运行时通过 `CAM_PROJECT_ROOT` 或当前 shell `PWD` 解析目标项目,避免把某个项目根硬编码进共享资产;它不是官方 Codex hook surface,官方 hooks 目前仍只作为公开但 `Experimental` 的 opt-in 轨道,而 config 文档里的 `codex_hooks` feature flag 仍标为 `Under development`,且该 bundle 的推荐检索 preset 为 `state=auto`、`limit=8` | +| `cam skills install` | 默认安装 runtime Codex skill 资产,并支持显式 `--surface runtime|official-user|official-project`;让代理优先通过 retrieval MCP,未接线时先 fallback 到本地 `memory-recall.sh search -> timeline -> details` bridge bundle,再退到 resolved CLI recall,并沿用同一套推荐检索 preset:`state=auto`、`limit=8`;skills 仍是 guidance surface,不等于 executable fallback route,真正当前 operational 的 route 仍应回到 `cam mcp doctor --host codex` / `cam integrations doctor --host codex` 判断 | | `cam audit` | 仓库级 privacy / secret hygiene 审查 | -| `cam doctor` | 检查当前 companion wiring、Codex feature posture 与 future integration readiness | +| `cam doctor` | 检查当前 companion wiring、Codex feature posture 与 future integration readiness;`--json` 现在还会显式暴露 retrieval sidecar 健康度、unsafe topic diagnostics 与 canonical layout diagnostics,继续保持只读检查,不隐式创建 durable memory layout | + +补充: + +- 共享 `workflowContract` 现在也会显式暴露 launcher 前提:`commandName=cam`、`requiresPathResolution=true`、`hookHelpersShellOnly=true`。在此基础上,helper bundle 与 doctor next steps 也会在 `cam` 不可解析时优先给出 `node /dist/cli.js` 这条 verified fallback。 +- `workflowContract.launcher` 现在还会显式说明它适用于 direct CLI 与已安装 helper 资产,不等于 canonical MCP host snippet;canonical host wiring 仍保持 `cam mcp serve` 这条配置语义。 +- `workflowContract.launcher` 现在和 doctor 共用同一套 executable-aware truth source:PATH 上如果只是出现一个不可执行的 `cam` 文件,不会再被误判成 verified launcher。未验证的分支也不再宣称 “verified fallback”,而是明确标成 unverified direct command。 +- startup highlights 现在会跨 `project-local` / `project` / `global` 去重相同 summary,避免重复低信号条目挤占有限的 startup budget。 +- startup highlights 现在还会跳过 unsafe topic files;startup topic refs 也默认只保留 safe references。同一时间,`cam memory --json` 与 `cam memory reindex --json` 会显式暴露 `topicDiagnostics` 与 `layoutDiagnostics`,而 `cam memory --json` 还会额外给出 `startupOmissions`、`startupOmissionCounts`、`topicFileOmissionCounts` 与 `topicRefCountsByScope`,把 highlight omission、topic ref omission 与 canonical layout 异常都变成 reviewer-visible 信号;此外,全局 highlight cap 现在也会留下 selection-stage omission,而不再静默丢掉后续 scope 的合格 highlight。 +- durable sync audit 现在还会显式暴露 `rejectedOperationCount`、`rejectedReasonCounts` 与轻量 `rejectedOperations` 摘要,让 unknown topic、sensitive content、volatile content、operation cap 这类被拒绝写入的原因进入 reviewer surface,而不是静默消失。 +- 自动提取现在还会更自然地保留 `reference` 类 durable memory,例如 dashboard、issue tracker、runbook、docs pointer 这类外部定位信息;同时会更积极拒绝 `.agents/`、`.codex/`、`.gemini/`、`.mcp.json`、`next step`、`resume here` 之类 session-only / local-host 噪音进入 durable memory。 +- `cam hooks install --json` / `cam skills install --json` 现在会额外返回 `postInstallReadinessCommand`,把“安装后应回哪条 doctor 命令确认当前 operational route”提升成 machine-readable contract;顶层 `cam doctor --json` 也会额外返回 `recommendedRoute`、`recommendedAction`、`recommendedActionCommand` 与 `recommendedDoctorCommand`。其中这里的 `recommendedRoute=companion` 只表达顶层 companion / readiness surface 的推荐入口,不等于 `cam mcp doctor` / `cam integrations doctor` 里那组 MCP-first route truth。 +- `cam integrations install --json` / `cam integrations apply --json` 现在也会额外暴露 `postInstallReadinessCommand` / `postApplyReadinessCommand`,把 install / apply 之后该回哪条 doctor 命令确认 route 继续保持为 machine-readable contract,而不是只留在 notes prose。 +- `cam remember --json` / `cam forget --json` 现在还会额外暴露 `entryCount`、`warningCount`、`uniqueAuditCount`、`auditCountsDeduplicated` 与 `warningsByEntryRef`,而 `forget --json` 也会补上 `detailsUsableEntryCount` 与 `timelineOnlyEntryCount`;同时还会额外暴露 `leadEntryRef`、`leadEntryIndex`、`detailsAvailable` 与 `reviewRefState`,降低多 ref mutation 时只盯 primary entry 顶层字段的误读。 +- Durable sync 现在会对 subagent rollout fail-closed:子线程 rollout 仍可进入 continuity / reviewer 分析,但 `cam sync` 会留下 reviewer-visible 的 `subagent-rollout` skip,而不会让 child-session 噪音进入 canonical durable memory。 +- Session continuity 持久化现在也会对 subagent rollout fail-closed:显式 `--rollout`、matching recovery marker 与 matching latest audit entry 如果指向 child-session rollout,会直接失败,而不是把子线程 continuity 回灌到 shared/local continuity 文件。 +- Session continuity shared/local 双写现在会以原子方式提交;若 summary 写入阶段失败,会写入 `summary-write` recovery marker,而不是留下半成功 continuity。 +- `workflowContract` 现在在保留兼容顶层字段的同时,额外拆出 `executionContract`、`modelGuidanceContract` 与 `hostWiringContract`,把执行路线、模型指导与宿主接线的 machine-readable contract 分开表达。 +- 当前官方 skills discovery 文档已经以 `.agents/skills` 为准;本仓 runtime 仍兼容 `.codex/skills` / `CODEX_HOME`,但它们应继续被视为 runtime / historical compatibility surface,而不是新的官方 canonical path。 +- `cam recall search --json` 现在会把请求范围内命中的 unsafe / malformed topic source 通过 `diagnostics.topicDiagnostics` 持续做成 reviewer-visible 摘要;即使 sidecar 仍然健康、搜索结果本身继续 fail-closed 过滤 unsafe topic,也不需要等到 `details` 阶段才看到 warning。 +- `cam remember --json` / `cam forget --json` 现在会额外暴露顶层 `reviewerSummary` 与 `nextRecommendedActions`,让手工修正后的 `timeline/details review -> recent review -> reindex` 闭环变成 machine-readable reviewer contract。 +- `cam remember` / `cam forget` 的文本模式现在也会直接附带同一套 follow-up,避免人工修正后还要自己回想下一步该看 `timeline`、`details`、`memory --recent` 还是 `memory reindex`。 +- `cam integrations apply --json` 现在在 `rollbackApplied` / `rollbackSucceeded` / `rollbackErrors` / `rollbackPathCount` 之外,还会显式返回 `rollbackReport`,并补充 per-subaction `effectiveAction` / `rolledBack` 等最终状态字段,逐路径说明是恢复旧文件、删除新文件,还是回滚时报错。 +- lifecycle reviewer 现在会把 `updateKind` 继续细分为 `restore`、`semantic-overwrite`、`metadata-only`,帮助 reviewer 区分“恢复归档”“语义修正”和“仅来源/理由变化”。 +- `cam integrations apply --host codex` 现在会在 AGENTS apply late-block 或中途写入失败时回滚已写入的 project-scoped MCP wiring、hook bundle 与 skill 资产,尽量避免半成功状态。 ## 工作方式 @@ -256,7 +280,7 @@ flowchart TD ### 为什么不是直接上 native memory - 官方公开文档尚未给出完整、稳定、等价于 Claude Code 的 native memory 契约 -- 本地 `cam doctor --json` 仍把 `memories` / `codex_hooks` 视为 readiness signal,而不是 trusted primary path +- 本地 `cam doctor --json` 仍把 `memories` / `codex_hooks` 视为 readiness signal,而不是 trusted primary path;与此同时它现在也会把 `Native memory/hooks readiness` 与 `Host/UI signals` 分段表达,并继续补充 app-server signal、retrieval sidecar、unsafe topic 与 canonical layout 的只读诊断 - 因此当前实现仍然保持 `companion-first` - 但产品方向已经明确:当 hooks、skills、MCP 与 retrieval surfaces 能以不破坏 Markdown-first 契约的方式进入主线时,会正式纳入,而不是永远停留在 bridge status diff --git a/README.zh-TW.md b/README.zh-TW.md index 332ecfa..02a30ae 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -105,11 +105,11 @@ Codex 已經具備不少有價值的基礎能力,但仍未公開等價且完 | 能力 | 說明 | | :-- | :-- | | 自動 post-session sync | 從 Codex rollout JSONL 中提取穩定、未來有用的資訊並寫回 durable Markdown memory | -| 自動 startup recall | 編譯緊湊 startup memory,讓 durable knowledge 自動回到後續會話 | +| 自動 startup recall | 編譯緊湊 startup memory,讓 durable knowledge 自動回到後續會話,現在也會附帶少量 active-only content highlights 與按需 topic refs | | Markdown-first | `MEMORY.md` 與 topic files 仍是產品主表面,而不是次級導出物 | | 記憶生命週期 | 支援更正、去重、覆蓋、刪除,以及 reviewer 可見的 conflict suppression | | formal retrieval MCP surface | `cam mcp serve` 會以只讀 stdio MCP 形式暴露 `search_memories` / `timeline_memories` / `get_memory_details` | -| project-scoped MCP install surface | `cam mcp install --host ` 會顯式寫入推薦的 project-scoped 宿主配置,降低 MCP 接線摩擦 | +| project-scoped MCP install surface | `cam mcp install --host codex` 會顯式寫入推薦的 Codex project-scoped 宿主配置;非 Codex 宿主 wiring 仍屬於邊界化接線能力,集中記錄在 `docs/host-surfaces.md` | | worktree-aware | project memory 在同一個 git 倉庫的 worktree 間共享,project-local 仍保持隔離 | | session continuity | 臨時 working state 與 durable memory 分層儲存、分層載入 | | integration-aware evolution | 保留目前 wrapper 主路徑,同時正式朝 hook / skill / MCP 方向演進 | @@ -182,7 +182,7 @@ cam integrations doctor --host codex cam mcp install --host codex cam mcp print-config --host codex cam mcp apply-guidance --host codex -cam mcp doctor --host codex +cam mcp doctor cam session status cam session refresh cam remember "Always use pnpm instead of npm" @@ -197,29 +197,46 @@ cam audit | :-- | :-- | | `cam run` / `cam exec` / `cam resume` | 編譯 startup memory 並透過 wrapper 啟動 Codex | | `cam sync` | 手動把最近 rollout 同步進 durable memory | -| `cam memory` | 檢視 startup files、topic refs、startup budget、edit paths,以及 recent sync audit 與 suppressed conflict candidates | -| `cam memory reindex` | 明確從 canonical Markdown 重建 retrieval sidecar;支援 `--scope`、`--state`、`--cwd`、`--json`,讓 sidecar 缺失、損壞或 stale 時有低心智負擔的修復路徑 | -| `cam remember` / `cam forget` | 顯式新增或刪除 durable memory;`cam forget --archive` 會把匹配條目移入歸檔層 | -| `cam recall search` / `timeline` / `details` | 以 `search -> timeline -> details` 的 progressive disclosure 工作流檢索 durable memory;`search` 現在預設採用 `state=auto`、`limit=8`,會先查 active,未命中再回退 archived,且保持只讀 retrieval | +| `cam memory` | 檢視 startup files、topic refs、startup highlights、highlight budget / section 渲染狀態、edit paths,以及 recent sync audit 與 suppressed conflict candidates;也支援 `--cwd ` 明確鎖定另一個專案根目錄;若 durable memory layout 尚未初始化,會回傳空的 inspect 視圖,而不是隱式建立 `MEMORY.md`、`ARCHIVE.md` 或 retrieval sidecar;`--json` 現在也會額外暴露 `highlightCount`、`omittedHighlightCount`、`omittedTopicFileCount`、`highlightsByScope`、`startupSectionsRendered`、`startupOmissions`、`startupOmissionCounts`、`startupOmissionCountsByTargetAndStage`、`topicFileOmissionCounts`、`topicRefCountsByScope`,以及 reviewer-visible 的 `topicDiagnostics` / `layoutDiagnostics`,用來區分 selection-stage、render-stage 與 canonical layout 異常 | +| `cam memory reindex` | 明確從 canonical Markdown 重建 retrieval sidecar;支援 `--scope`、`--state`、`--cwd`、`--json`,讓 sidecar 缺失、損壞或 stale 時有低心智負擔的修復路徑;若 durable memory layout 尚未初始化,會回傳空的 `rebuilt` 結果,而不是隱式建立 layout | +| `cam remember` / `cam forget` | 顯式新增或刪除 durable memory;兩者現在也支援 `--cwd `,可跨目錄鎖定另一個 project root;`cam forget --archive` 會把匹配條目移入歸檔層;`forget` 現在也和 `recall search` 共用同一套多詞 query 歸一化語義,像 `pnpm npm` 這樣的 query 可以跨 `summary/details` 命中同一條 memory,而不需要原始 substring 連續出現;兩者現在也支援 `--json`,回傳手工 mutation 的 reviewer payload,包括 `mutationKind`、`matchedCount`、`appliedCount`、`noopCount`、`summary`、`primaryEntry`、`entries[]`、`followUp`、`nextRecommendedActions`,以及在至少命中一個 ref 時才額外暴露的頂層 lifecycle/detail 欄位(`latestAppliedLifecycle`、`latestLifecycleAttempt`、`latestLifecycleAction`、`latestState`、`latestSessionId`、`latestRolloutPath`、`latestAudit`、`timelineWarningCount`、`warnings`、`entry`、`lineageSummary`、`ref/path/historyPath`);現在也會額外暴露 `leadEntryRef`、`leadEntryIndex`、`detailsAvailable`、`reviewRefState`、`uniqueAuditCount`、`auditCountsDeduplicated` 與 `warningsByEntryRef`;空的 `forget --json` 結果現在會保留 additive 空 payload,並回傳空的 `nextRecommendedActions`,不再輸出占位式 `""` 提示;delete 分支也會明確區分 timeline-only 與 details-usable review route;文字模式現在也會直接給出 project-pinned 的 `timeline/details -> recent -> reindex` follow-up,讓手工修正後更自然回到 reviewer 閉環 | +| `cam recall search` / `timeline` / `details` | 以 `search -> timeline -> details` 的 progressive disclosure 工作流檢索 durable memory;`search` 現在預設採用 `state=auto`、`limit=8`,會先查 active,未命中再回退 archived,且保持只讀 retrieval;多詞查詢現在會跨 `id/topic/summary/details` 聚合命中,而不是要求所有 term 都落在同一個欄位;JSON 現在還會額外暴露 `retrievalMode`、`finalRetrievalMode`、`retrievalFallbackReason`、`stateResolution`、`executionSummary`、`searchOrder`、`totalMatchedCount`、`returnedCount`、`globalLimitApplied`、`truncatedCount`、`resultWindow`、`globalRank`,以及 `diagnostics.checkedPaths[].returnedCount` / `droppedCount`,把 explicit-state、global sorting、fallback 與 post-limit 行為說清楚;其中 `finalRetrievalMode` 只是最終結果面的顯式別名 | | `cam mcp serve` | 啟動只讀 retrieval MCP server,以 `search_memories` / `timeline_memories` / `get_memory_details` 暴露同一套漸進式檢索契約 | -| `cam integrations install --host codex` | 一次性安裝推薦的 Codex integration stack:寫入 project-scoped MCP wiring,並刷新 hook bridge bundle 與 Codex skill 資產;預設使用 runtime skills target,也支援顯式 `--skill-surface runtime|official-user|official-project`;保持顯式、幂等、Codex-only,且不碰 Markdown memory store | -| `cam integrations apply --host codex` | 以顯式、幂等、Codex-only 的方式套用完整 integration state:在保留 `integrations install` 舊語義不變的前提下,額外編排 `cam mcp apply-guidance --host codex`;預設使用 runtime skills target,也支援顯式 `--skill-surface runtime|official-user|official-project`;若 `AGENTS.md` managed block 不安全,現在會在任何 stack 寫入前 preflight `blocked`;若 staged write 中途失敗,也會明確回傳 `rollbackSucceeded`、`rollbackErrors`、各 subaction 的 `effectiveAction` 與 `rolledBack`,避免把「嘗試寫入」誤判成「最終已安裝」 | -| `cam integrations doctor --host codex` | 以 Codex-only、只讀、薄聚合的方式彙總目前 integration stack readiness,直接給出推薦路由、推薦 preset、結構化 `workflowContract`、`applyReadiness`、子檢查結果與下一步最小動作;若 `AGENTS.md` managed block 處於 unsafe 狀態,會先提示修復它,而不是直接推薦 `cam integrations apply --host codex` | -| `cam mcp install --host ` | 顯式寫入推薦的 project-scoped 宿主 MCP 配置;只更新 `codex_auto_memory` 這一項,不會自動安裝 hooks/skills;若該 entry 已帶有非 canonical 自訂欄位,會在安全前提下保留它們;`generic` 仍維持 manual-only | -| `cam mcp print-config --host ` | 列印 ready-to-paste 的宿主接入片段,降低把 read-only retrieval plane 接進既有 MCP workflow 的手動成本;其中 `--host codex` 還會額外列印推薦的 `AGENTS.md` snippet,並在 JSON payload 中附帶共享 `workflowContract`,幫助未來 Codex 代理優先走 MCP、必要時再 fallback 到 `cam recall` | +| `cam integrations install --host codex` | 一次性安裝推薦的 Codex integration stack:寫入 project-scoped MCP wiring,並刷新 hook bridge bundle 與 Codex skill 資產;預設使用 runtime skills target,也支援顯式 `--skill-surface runtime|official-user|official-project`;保持顯式、幂等、Codex-only,且不碰 Markdown memory store;若 staged install 中途失敗,現在也會回滾已寫入的 MCP / hooks / skills 檔案;`--json` 也會回傳結構化 rollback failure payload;安裝完成後會明確提示再跑 `cam integrations doctor --host codex`,確認當前環境裡真正 operational 的 retrieval route | +| `cam integrations apply --host codex` | 以顯式、幂等、Codex-only 的方式套用完整 integration state:在保留 `integrations install` 舊語義不變的前提下,額外編排 `cam mcp apply-guidance --host codex`;預設使用 runtime skills target,也支援顯式 `--skill-surface runtime|official-user|official-project`;若 `AGENTS.md` managed block 不安全,現在會在任何 stack 寫入前 preflight `blocked`;apply 完成後同樣需要回到 doctor 判斷實際生效的是 MCP、local bridge 還是 resolved CLI | +| `cam integrations doctor --host codex` | 以 Codex-only、只讀、薄聚合的方式彙總目前 integration stack readiness,直接給出推薦路由、當前 operational route truth(`recommendedRoute`、`currentlyOperationalRoute`、`routeKind`、`routeEvidence`、`shellDependencyLevel`、`hostMutationRequired`、`preferredRouteBlockers`、`currentOperationalBlockers`)、推薦 preset、結構化 `workflowContract`、`applyReadiness`、`experimentalHooks`、`layoutDiagnostics`、子檢查結果與下一步最小動作;其中 `recommendedRoute` 會維持 MCP-first 的首選路徑,而 blocker 欄位會分開說明「為何首選路由沒跑起來」以及「目前 fallback 自己是否還有 operational 問題」;現在還會顯式暴露 skill-surface steering(`preferredSkillSurface`、`recommendedSkillInstallCommand`、`installedSkillSurfaces`、`readySkillSurfaces`),幫助後續安裝 guidance surface,但不把 skills 說成 executable fallback route;也會區分 hook helper 是只是 installed,還是在目前 shell 中真正 operational;當用 `--cwd` 檢查另一個 repo 時,hooks fallback 的 next steps 也會透過 `CAM_PROJECT_ROOT=...` 把 local bridge route 明確 pin 到目標專案;若 `cam` 在 PATH 中不可解析,direct CLI next step 也會優先給出 resolved `node dist/cli.js recall ...` fallback,而不是先給出會失敗的裸 `cam recall ...`;若 `AGENTS.md` managed block 處於 unsafe 狀態,會先提示修復它,而不是直接推薦 `cam integrations apply --host codex` | +| `cam mcp install --host codex` | 顯式寫入推薦的 Codex project-scoped 宿主 MCP 配置;只更新 `codex_auto_memory` 這一項,不會自動安裝 hooks/skills;若該 entry 已帶有非 canonical 自訂欄位,會在安全前提下保留它們;更低優先級的非 Codex host wiring 繼續收口到 `docs/host-surfaces.md`,不作為預設產品路徑,其中一部分仍保持 `manual-only` | +| `cam mcp print-config --host codex` | 列印 ready-to-paste 的 Codex 接入片段,降低把 read-only retrieval plane 接進目前主工作流的手動成本;還會額外列印推薦的 `AGENTS.md` snippet,並在 JSON payload 中附帶共享 `workflowContract` 與顯式 `experimentalHooks` guidance,幫助未來 Codex 代理優先走 MCP,再 fallback 到本地 `memory-recall.sh` bridge bundle,最後再退到 resolved CLI recall;其他 host snippet 仍屬於邊界化 wiring 參考,統一放在 `docs/host-surfaces.md` 說明,其中保留 `manual-only` 分支 | | `cam mcp apply-guidance --host codex` | 以 additive、可審計、fail-closed 的方式建立或更新 repo 根 `AGENTS.md` 中由 Codex Auto Memory 自己管理的 guidance block;只會 append 新 block 或替換同一 marker block,若無法安全定位則回傳 `blocked` 而不會冒險改寫 | -| `cam mcp doctor` | 只讀檢查目前專案的 retrieval MCP 接線、project pinning 與 hook/skill fallback assets;現在也會追加 `codexStack` readiness 視圖與結構化 `workflowContract`,用來彙總推薦路由、executable bit、共享資產版本與 workflow consistency;若偵測到 alternate global wiring,也會與推薦的 project-scoped 路徑明確區分,不會改寫任何宿主設定 | +| `cam mcp doctor` | 只讀檢查目前專案的 retrieval MCP 接線、project pinning 與 hook/skill fallback assets;現在也會追加結構化 `workflowContract`、`layoutDiagnostics` 與最小粒度的 retrieval sidecar repair command;若 `cam` 不在 PATH 上,這條 repair command 也會跟著 resolved launcher fallback 走。當檢查的 host selection 包含 Codex(`--host codex` 或 `all`)時,JSON 還會額外暴露 Codex-only 的 `codexStack` route truth、`experimentalHooks` 與 AGENTS guidance/apply safety;若檢查的是 `claude`、`gemini`、`generic` 這類 manual-only / snippet-first 宿主,則 `commandSurface.install` / `commandSurface.applyGuidance` 會顯式為 `false`,不再把 Codex-only 的可寫 guidance surface 說成可執行能力。doctor 也會把 hook capture / recall 的 installed 與「helper 內嵌 launcher 是否在目前環境可運行」分開表達,並把 app-server signal 與 `memories` / `codex_hooks` 分開呈現;若偵測到 alternate global wiring,也會與推薦的 project-scoped 路徑明確區分 | | `cam session save` | merge / incremental save;增量寫入 continuity | | `cam session refresh` | replace / clean regeneration;重建 continuity | | `cam session load` / `status` | continuity reviewer surface | -| `cam hooks install` | 生成並刷新目前的 local bridge / fallback helper bundle,包括 `memory-recall.sh`、`post-work-memory-review.sh`、相容 helper wrappers 與 `recall-bridge.md`;其中 `post-work-memory-review.sh` 會把 `cam sync` 與 `cam memory --recent` 串成同一套收尾 review 動作;它不是官方 Codex hook surface,且該 bundle 的推薦檢索 preset 為 `state=auto`、`limit=8` | -| `cam skills` | 以 `cam skills install` 安裝 Codex skill;預設 target 仍是 runtime,也支援顯式 `--surface runtime|official-user|official-project` 為官方 `.agents/skills` 路徑準備相容副本;所有 surface 都沿用同一套 MCP-first、CLI-fallback 漸進式 durable memory 檢索工作流與推薦 preset:`state=auto`、`limit=8` | +| `cam hooks install` | 生成並刷新目前的 local bridge / fallback helper bundle,包括 `memory-recall.sh`、`post-work-memory-review.sh`、相容 helper wrappers 與 `recall-bridge.md`;其中 `post-work-memory-review.sh` 會把 `cam sync` 與 `cam memory --recent` 串成同一套收尾 review 動作;這批 user-scoped helper 現在會優先在執行期透過 `CAM_PROJECT_ROOT` 或目前 shell 的 `PWD` 解析目標專案,而不是把單一 repo 路徑硬編進共享資產;它不是官方 Codex hook surface,官方 hooks 目前仍只作為公開但 `Experimental` 的 opt-in 軌道,而 config 文檔中的 `codex_hooks` feature flag 仍標為 `Under development` 且預設關閉 | +| `cam skills install` | 安裝 Codex skill;預設 target 仍是 runtime,也支援顯式 `--surface runtime|official-user|official-project` 為官方 `.agents/skills` 路徑準備相容副本;所有 surface 都沿用同一套 MCP-first 漸進式 durable memory 檢索工作流,未接線時會先 fallback 到本地 `memory-recall.sh search -> timeline -> details` bridge bundle,再退到 resolved CLI recall,並共用推薦 preset:`state=auto`、`limit=8`;skills 仍是 guidance surface,不等於 executable fallback route,真正目前生效的 route 仍應透過 `cam mcp doctor --host codex` / `cam integrations doctor --host codex` 判斷 | | `cam audit` | 做隱私與 secret-hygiene 檢查 | -| `cam doctor` | 檢視本地 wiring 與 native-readiness posture | +| `cam doctor` | 檢視本地 wiring 與 native-readiness posture;`--json` 現在也會額外暴露 retrieval sidecar 健康度、unsafe topic diagnostics 與 canonical layout diagnostics,並保持完全只讀 | 補充約定: - `cam skills install` 的公開 surface 現在固定為 `runtime`、`official-user`、`official-project`;runtime 仍是預設 target,官方 `.agents/skills` 路徑維持顯式 opt-in。 +- 共享 `workflowContract` 現在還會顯式暴露 launcher 前提:`commandName=cam`、`requiresPathResolution=true`、`hookHelpersShellOnly=true`,讓 hooks / skills / doctor / print-config 對 PATH 與 shell 依賴保持同一套說法;另外 helper bundle 與 doctor next steps 現在也會在 `cam` 不可解析時優先給出 `node /dist/cli.js` 這條 verified fallback。 +- `workflowContract.launcher` 現在也會明確說明它適用於 direct CLI 與已安裝 helper 資產,不等於 canonical MCP host snippet;宿主接線仍維持 `cam mcp serve` 這條 canonical 配置語義。 +- `workflowContract.launcher` 現在也和 doctor 共用同一套 executable-aware truth source:PATH 上如果只是出現不可執行的 `cam` 檔案,不會再被誤判成 verified launcher;未驗證分支也不再宣稱 `verified fallback`。 +- Startup highlights 現在也會跳過 unsafe topic files;startup topic refs 也預設只保留 safe references。同時 `cam memory --json` 與 `cam memory reindex --json` 會額外暴露 `topicDiagnostics` 與 `layoutDiagnostics`,而 `cam memory --json` 也會補上 `startupOmissions`、`startupOmissionCounts`、`topicFileOmissionCounts` 與 `topicRefCountsByScope`,讓 highlight omission、topic ref omission 與 canonical layout 異常都變成 reviewer-visible;另外,global highlight cap 現在也會留下 selection-stage omission,而不是靜默吃掉後續 scope 的合格 highlight。 +- durable sync audit 現在也會額外暴露 `rejectedOperationCount`、`rejectedReasonCounts` 與輕量 `rejectedOperations` 摘要,讓 unknown topic、sensitive content、volatile content、operation cap 這類被拒絕寫入的原因進入 reviewer surface,而不是靜默消失。 +- 自動提取現在也會更自然地保留 `reference` 類 durable memory,例如 dashboard、issue tracker、runbook、docs pointer 這類外部定位資訊;同時會更積極拒絕 `.agents/`、`.codex/`、`.gemini/`、`.mcp.json`、`next step`、`resume here` 這類 session-only / local-host 噪音進入 durable memory。 +- `cam hooks install --json` / `cam skills install --json` 現在也會額外暴露 `postInstallReadinessCommand`,把「安裝後應回哪條 doctor 命令確認目前 operational route」提升成 machine-readable contract;頂層 `cam doctor --json` 也會額外暴露 `recommendedRoute`、`recommendedAction`、`recommendedActionCommand` 與 `recommendedDoctorCommand`。其中這裡的 `recommendedRoute=companion` 只代表頂層 companion / readiness surface 的推薦入口,不應與 `cam mcp doctor` / `cam integrations doctor` 裡那組 MCP-first route truth 混用。 +- `cam session load --json --print-startup` 現在也會額外暴露 continuity startup contract:實際渲染的 `sourceFiles`、候選 `candidateSourceFiles`、`sectionsRendered`、`omissions` / `omissionCounts`、`continuitySectionKinds`、`continuitySourceKinds`、`continuityProvenanceKind`、`continuityMode` 與 `futureCompactionSeam`。其中 `sourceFiles` 現在只代表真正進入 bounded startup block 的來源,不再回傳未渲染的候選來源。 +- `cam integrations install --json` / `cam integrations apply --json` 現在也會額外暴露 `postInstallReadinessCommand` / `postApplyReadinessCommand`,讓 install / apply 之後回哪條 doctor 命令確認 route 也維持 machine-readable,而不是只留在 notes prose。 +- `cam remember --json` / `cam forget --json` 現在也會補上 `entryCount`、`warningCount`、`uniqueAuditCount`、`auditCountsDeduplicated` 與 `warningsByEntryRef`,而 `forget --json` 還會再加上 `detailsUsableEntryCount` 與 `timelineOnlyEntryCount`,降低多 ref mutation 被誤讀成單 ref 事實的機率。 +- Durable sync 現在也會對 subagent rollout fail-closed:子執行緒 rollout 仍可用於 continuity / reviewer 分析,但 `cam sync` 會留下 reviewer-visible 的 `subagent-rollout` skip,而不會讓 child-session 噪音進入 canonical durable memory。 +- `cam recall search --json` 現在會把請求範圍內命中的 unsafe / malformed topic source 持續透過 `diagnostics.topicDiagnostics` 做成 reviewer-visible 摘要;即使 sidecar 仍健康、搜尋結果本身繼續 fail-closed 過濾 unsafe topic,也不必等到 `details` 才看到 warning。 +- `cam remember --json` / `cam forget --json` 現在也會額外暴露頂層 `reviewerSummary` 與 `nextRecommendedActions`,讓手動修正後的 `timeline/details review -> recent review -> reindex` 閉環變成 machine-readable reviewer contract。 +- `cam integrations apply --json` 現在也會額外暴露 `rollbackReport`,逐路徑說明 rollback 是恢復舊檔、刪除新檔,還是回滾時出錯。 +- startup highlights 現在也會跨 `project-local` / `project` / `global` 去重相同 summary,避免重複低信號條目吃掉有限的 startup budget。 +- lifecycle reviewer 目前也會把 `updateKind` 再細分成 `restore`、`semantic-overwrite`、`metadata-only`,方便 reviewer 區分「恢復歸檔」「語義修正」與「僅來源/理由變動」。 +- `cam integrations apply --host codex` 現在在 AGENTS apply late-block 或中途寫入失敗時,會回滾已寫入的 project-scoped MCP wiring、hook bundle 與 skill 資產,盡量避免半成功狀態;`--json` 還會補充 `effectiveAction`、`rolledBack`、`rollbackSucceeded` 等最終狀態欄位,避免把「曾嘗試寫入」誤讀成「最終已安裝」。 - 重要 `--help` 文案現在也視為 release-facing public contract,必須和 README、架構文檔以及 `dist` / tarball smoke 保持一致,特別是 `integrations install/apply/doctor`、`mcp install/print-config/apply-guidance`、`skills install` 這幾個面。 ## 工作方式 @@ -251,7 +268,7 @@ flowchart TD ### 為什麼現在還不是 native-first - 公開的 Codex 文件仍未定義等價於 Claude Code 的完整 native memory 契約 -- 本地 `cam doctor --json` 仍把 `memories` / `codex_hooks` 更像視為 readiness signal,而不是穩定主路徑 +- 本地 `cam doctor --json` 仍把 `memories` / `codex_hooks` 更像視為 readiness signal,而不是穩定主路徑;同時也會補充 app-server signal、retrieval sidecar、unsafe topic 與 canonical layout 的只讀診斷 - 因此目前最可靠的仍是 wrapper-first 主線 但方向上的差異是:本倉庫不再把 hooks、skills、MCP 只寫成遙遠 future idea,而是把它們納入正式的整合演進方向,前提是它們仍遵守同一套 Markdown-first、可審計的記憶契約。 diff --git a/docs/README.en.md b/docs/README.en.md index 0135ccf..796f617 100644 --- a/docs/README.en.md +++ b/docs/README.en.md @@ -58,10 +58,25 @@ - claim-sensitive wording must stay aligned with official public documentation - the repository now documents both present behavior and deliberate evolution toward hook, skill, and MCP-aware surfaces - the latest low-friction MCP wiring surface is now layered: `cam mcp install` writes the recommended project-scoped host config while preserving non-canonical custom fields on the `codex_auto_memory` entry when safe, `cam mcp print-config` and `cam mcp doctor` stay print-only and inspect-only, and `cam mcp apply-guidance --host codex` manages the repository-level `AGENTS.md` guidance block through an additive fail-closed flow -- `cam integrations install --host codex` orchestrates MCP wiring plus hook and skill assets, `cam integrations apply --host codex` adds the managed `AGENTS.md` guidance flow on top but now performs an AGENTS safety preflight before any writes, and `cam integrations doctor --host codex` remains the thin read-only readiness view with `workflowContract` and `applyReadiness`; `cam mcp doctor` also reports alternate global wiring separately from the recommended project-scoped route +- `cam integrations install --host codex` orchestrates MCP wiring plus hook and skill assets, and staged failures now also produce a structured rollback payload under `--json`; `cam integrations apply --host codex` adds the managed `AGENTS.md` guidance flow on top but now performs an AGENTS safety preflight before any writes, and `cam integrations doctor --host codex` remains the thin read-only readiness view with `workflowContract`, `applyReadiness`, `experimentalHooks`, and `layoutDiagnostics`; `cam mcp doctor` also reports alternate global wiring separately from the recommended project-scoped route and distinguishes installed hook helpers from operational ones - `cam skills install` now has three public surfaces: `runtime`, `official-user`, and `official-project`; runtime stays the default target, while the official `.agents/skills` copies remain explicit opt-in installs -- the `generic` host remains manual-only: it is supported by `cam mcp print-config --host generic`, but intentionally rejected by `cam mcp install --host generic` -- `cam recall search` now defaults to the active-first, archived-fallback read-only retrieval path with `state=auto, limit=8` +- non-Codex host wiring remains a boundary capability: the default product path only foregrounds `cam mcp install --host codex` and `cam mcp print-config --host codex`, while lower-priority host details stay collected in `docs/host-surfaces.md`, including the `manual-only` branch +- `cam recall search` now defaults to the active-first, archived-fallback read-only retrieval path with `state=auto, limit=8`, and `cam recall search --json` / `search_memories` now also surface `retrievalMode`, `retrievalFallbackReason`, `stateResolution`, `executionSummary`, `searchOrder`, `totalMatchedCount`, `returnedCount`, `globalLimitApplied`, `truncatedCount`, `resultWindow`, `globalRank`, and `diagnostics.checkedPaths[].returnedCount` / `droppedCount` +- `cam remember --json` / `cam forget --json` now also return manual-mutation reviewer payloads, so explicit correction and archive/delete flows become machine-readable without leaving the Markdown-first contract; the payload now also carries additive `mutationKind`, `matchedCount`, `appliedCount`, `noopCount`, `summary`, `reviewerSummary`, `followUp`, `nextRecommendedActions`, `entries[]`, `uniqueAuditCount`, `auditCountsDeduplicated`, and `warningsByEntryRef`, and, when at least one ref matched, top-level `latestAppliedLifecycle`, `latestLifecycleAttempt`, `latestLifecycleAction`, `latestState`, `latestSessionId`, `latestRolloutPath`, `latestAudit`, `timelineWarningCount`, `warnings`, and `entry` fields; delete flows distinguish timeline-only review refs from details-usable refs, while empty `forget --json` results keep `nextRecommendedActions` empty instead of emitting placeholder refs +- durable sync now fail-closes on subagent rollouts: child-session evidence can still inform continuity and reviewer analysis, but `cam sync` records a reviewer-visible `subagent-rollout` skip instead of mutating canonical durable memory +- session continuity persistence now also fail-closes on subagent rollouts, including explicit `--rollout`, matching recovery markers, and matching latest audit entries +- shared and project-local continuity writes are now committed atomically; when the summary-write phase fails, CAM records a `summary-write` recovery marker instead of leaving partial continuity behind +- `cam integrations apply --json` also exposes `postApplyReadinessCommand`, so “which doctor should I run after apply?” is now machine-readable instead of prose-only +- startup recall still stays Markdown-first and line-budgeted, but now also includes a few active-only content highlights; it is not a topic-body dump and does not bring archived memory back into default startup recall +- `cam memory --json` now also exposes `highlightCount`, `omittedHighlightCount`, `highlightsByScope`, `startupSectionsRendered`, `startupOmissions`, `startupOmissionCounts`, and the new `layoutDiagnostics`, so reviewers can tell whether startup highlights were budget-trimmed, which startup sections actually made it into the payload, why specific candidates were omitted, and whether canonical Markdown layout drift has appeared +- `cam memory` now formally supports `--cwd `, so inspect/reindex/recent-review commands finally line up with hook helper guidance, doctor next steps, and workflowContract cross-directory teaching +- top-level `cam doctor --json` now surfaces the current app-server signal alongside `memories`, `codex_hooks`, retrieval-sidecar, unsafe-topic, and canonical-layout diagnostics instead of only showing the two native migration flags +- the shared `workflowContract` now also exposes launcher constraints explicitly through `commandName=cam`, `requiresPathResolution=true`, and `hookHelpersShellOnly=true`, so PATH and shell requirements stay aligned across hooks, skills, doctor, and print-config; hook helpers, doctor next steps, and retrieval-sidecar repair commands also prefer a verified `node /dist/cli.js` fallback when `cam` is unavailable on PATH +- `workflowContract.launcher` now also clarifies that it applies to direct CLI usage and installed helper assets, not to the canonical MCP host snippet; canonical host wiring still keeps the `cam mcp serve` command shape +- `workflowContract` now also keeps additive `executionContract`, `modelGuidanceContract`, and `hostWiringContract` sections so execution routing, agent guidance, and host wiring stay machine-readable without overloading one field group +- current official Codex skills discovery docs now use `.agents/skills`; this repository still supports `.codex/skills` / `CODEX_HOME` as runtime and historical compatibility surfaces, but not as the new official canonical discovery path +- multi-term `cam recall search` queries now match across `id/topic/summary/details` instead of requiring every term to appear in the same field, and startup highlights now deduplicate identical summaries across scopes so repeated low-signal notes do not consume the limited startup budget +- `cam integrations apply --host codex` now rolls back staged project-scoped MCP wiring, hook assets, and skill assets if AGENTS guidance blocks late or another staged write fails - maintainers should avoid reverting to the older “companion-only and future-seam-only” wording unless the implementation direction changes again - key `--help` text is part of the release-facing public contract and should stay aligned with the README, architecture docs, and release-facing smoke coverage diff --git a/docs/README.md b/docs/README.md index d77d7af..6287d28 100644 --- a/docs/README.md +++ b/docs/README.md @@ -59,11 +59,26 @@ - issue 中的 4 项核心能力:自动提取、自动召回、更新/去重/覆盖/归档、降低手动维护成本 3. **方向上为什么要补 hook / skill / MCP** - 因为当前仓库不再只服务显式 CLI 用户,而是也面向希望让代理自己自动使用记忆能力的用户 - - 当前最新的低摩擦接入面已经分层:`cam mcp install` 负责显式写入 project-scoped host config,并在安全前提下保留 `codex_auto_memory` entry 上的非 canonical 自定义字段;`cam mcp print-config` / `cam mcp doctor` 继续负责只打印 / 只检查,`cam mcp apply-guidance --host codex` 负责以 additive、fail-closed 的方式管理仓库级 `AGENTS.md` guidance block - - `cam integrations install --host codex` 负责编排 MCP wiring、hook bridge bundle 与 skill assets;`cam integrations apply --host codex` 在此基础上额外编排 managed `AGENTS.md` guidance,并在写入前先做 AGENTS safety preflight;`cam integrations doctor --host codex` 则只读汇总推荐路由、推荐 preset、`workflowContract`、`applyReadiness`、subchecks 与 next steps;`cam mcp doctor` 也会把 alternate global wiring 与推荐的 project-scoped 路径分开表达 - - `cam skills install` 的公开 skill surface 现在固定为 `runtime|official-user|official-project`;其中 runtime 仍是默认 target,官方 `.agents/skills` 路径保持显式 opt-in - - `generic` host 仍然保持 manual-only:不支持 `cam mcp install --host generic`,但继续支持 `cam mcp print-config --host generic` - - `cam recall search` 现在默认补上了 active-first、archived-fallback 的只读 retrieval 搜索面,并对齐 `state=auto`、`limit=8` + - 当前最新的低摩擦接入面已经分层:`cam mcp install` 负责显式写入 project-scoped host config,并在安全前提下保留 `codex_auto_memory` entry 上的非 canonical 自定义字段;`cam mcp print-config` / `cam mcp doctor` 继续负责只打印 / 只检查,`cam mcp apply-guidance --host codex` 负责以 additive、fail-closed 的方式管理仓库级 `AGENTS.md` guidance block + - `cam integrations install --host codex` 负责编排 MCP wiring、hook bridge bundle 与 skill assets;现在 staged failure 也会在 `--json` 下返回结构化 rollback payload;`cam integrations apply --host codex` 在此基础上额外编排 managed `AGENTS.md` guidance,并在写入前先做 AGENTS safety preflight;`cam integrations doctor --host codex` 则只读汇总推荐路由、推荐 preset、`workflowContract`、`applyReadiness`、`experimentalHooks`、`layoutDiagnostics`、subchecks 与 next steps;`cam mcp doctor` 也会把 alternate global wiring 与推荐的 project-scoped 路径分开表达,并把 hook helper 的 installed / operational 区分开 + - `cam skills install` 的公开 skill surface 现在固定为 `runtime|official-user|official-project`;其中 runtime 仍是默认 target,官方 `.agents/skills` 路径保持显式 opt-in + - 非 Codex 宿主 wiring 仍属于边界化接线能力:默认产品路径只前台强调 `cam mcp install --host codex` / `cam mcp print-config --host codex`,其他 host 细节统一收口到 `docs/host-surfaces.md`,其中保留 `manual-only` 分支 +- `cam recall search` 现在默认补上了 active-first、archived-fallback 的只读 retrieval 搜索面,并对齐 `state=auto`、`limit=8`;同时 `cam recall search --json` / `search_memories` 还会显式返回 `retrievalMode`、`retrievalFallbackReason`、`stateResolution`、`executionSummary`、`searchOrder`、`totalMatchedCount`、`returnedCount`、`globalLimitApplied`、`truncatedCount`、`resultWindow`、`globalRank` 与 `diagnostics.checkedPaths[].returnedCount` / `droppedCount` +- `cam remember --json` / `cam forget --json` 现在也会返回 manual mutation reviewer payload,把手工 correction/archive/delete 接进同一套 reviewer contract;最新 payload 会暴露 `mutationKind`、`matchedCount`、`appliedCount`、`noopCount`、`summary`、`reviewerSummary`、`followUp`、`nextRecommendedActions`、`entries[]`、`uniqueAuditCount`、`auditCountsDeduplicated`、`warningsByEntryRef`,并在至少命中一个 ref 时再补顶层 `latestAppliedLifecycle`、`latestLifecycleAttempt`、`latestLifecycleAction`、`latestState`、`latestSessionId`、`latestRolloutPath`、`latestAudit`、`timelineWarningCount`、`warnings` 与 `entry`;delete 分支继续区分 timeline-only 与 details-usable review routes,而空的 `forget --json` 结果现在会保持空的 `nextRecommendedActions` +- durable sync 现在会对 subagent rollout fail-closed:子线程 evidence 仍可供 continuity / reviewer 分析,但 `cam sync` 会留下 reviewer-visible 的 `subagent-rollout` skip,而不会把 child-session 噪音写进 canonical durable memory +- session continuity 持久化现在也会对 subagent rollout fail-closed:matching recovery marker、matching latest audit entry 与显式 `--rollout` 若指向 child-session rollout,会直接失败,而不是污染 shared/local continuity +- session continuity shared/local 双写现在以原子方式提交;若 summary 写入阶段失败,会留下 `summary-write` recovery marker 供 reviewer 处理 +- `cam integrations apply --json` 现在也会显式暴露 `postApplyReadinessCommand`,把“apply 之后该回哪条 doctor 命令确认 route”提升成 machine-readable contract +- startup recall 仍保持 Markdown-first 和 line-budget discipline,但现在会额外注入少量 active-only content highlights;它不是 topic body dump,也不会让 archived memory 重新参与默认 startup recall +- `cam memory --json` 现在还会额外暴露 `highlightCount`、`omittedHighlightCount`、`highlightsByScope`、`startupSectionsRendered`、`startupOmissions`、`startupOmissionCounts` 与新的 `layoutDiagnostics`,让 reviewer 能直接看到 startup highlights 是否被 budget 裁掉、哪些 startup section 真正进入了 payload,以及 canonical Markdown layout 是否出现异常 +- `cam memory` 现在正式支持 `--cwd `,让 inspect/reindex/recent-review 这条命令面与 hooks helper、doctor next steps、workflowContract 的跨目录 guidance 真正一致 +- 顶层 `cam doctor --json` 现在除了 `memories` / `codex_hooks` readiness signal 之外,也会补充 app-server signal、retrieval sidecar、unsafe topic 与 canonical layout 的只读诊断 +- 共享 `workflowContract` 现在还会显式暴露 launcher 前提:`commandName=cam`、`requiresPathResolution=true`、`hookHelpersShellOnly=true`,避免 hooks / skills / doctor / print-config 对 PATH 与 shell 依赖产生新的 drift;另外 helper bundle、doctor next steps 与 retrieval sidecar repair command 现在都会在 `cam` 不可解析时优先给出 `node /dist/cli.js` 的 verified fallback +- `workflowContract.launcher` 现在还会明确区分 direct CLI / installed helpers 与 canonical MCP host snippet:前者可以收口到 resolved launcher fallback,后者仍保持 `cam mcp serve` 的 canonical 接线语义 +- `workflowContract` 现在还会继续保留兼容顶层字段,同时额外拆出 `executionContract`、`modelGuidanceContract` 与 `hostWiringContract`,把执行路线、代理教学与宿主接线的 machine-readable contract 分开 +- 当前官方 Codex skills discovery 文档以 `.agents/skills` 为准;本仓 runtime 仍兼容 `.codex/skills` / `CODEX_HOME`,但它更适合作为 runtime / historical compatibility surface,而不是新的官方 canonical path +- `cam recall search` 的多词查询现在会跨 `id/topic/summary/details` 聚合命中,不再要求所有 term 都落在同一字段;startup highlights 也会跨 scope 去重相同 summary,减少低信号重复项挤占 startup budget +- `cam integrations apply --host codex` 现在会在 AGENTS apply late-block 或 staged write failure 时回滚已写入的 project-scoped MCP wiring、hook bundle 与 skill assets,降低半成功状态 ## 语言策略 diff --git a/docs/architecture.en.md b/docs/architecture.en.md index 7072343..cc8b2cb 100644 --- a/docs/architecture.en.md +++ b/docs/architecture.en.md @@ -63,6 +63,7 @@ Startup currently does the following: Important traits: - `MEMORY.md` files are injected as quoted startup files +- a few active-only content highlights are injected to improve startup recall without dumping topic bodies - topic files are represented as on-demand lookup refs - topic entry bodies are not eagerly loaded at startup - session continuity, when enabled, is injected as a separate block @@ -124,7 +125,7 @@ The repository now treats the following as first-class evolution targets rather - compact retrieval or correction workflows should eventually be expressible as reusable skill content - skill-based usage should not require abandoning the current file layout or reviewer surfaces -- `cam skills install` now provides a concrete Codex-facing skill surface that teaches the same MCP-first, CLI-fallback progressive durable-memory retrieval workflow +- `cam skills install` now provides a concrete Codex-facing skill surface that teaches the same `MCP -> local bridge -> resolved CLI` progressive durable-memory retrieval workflow ### MCP-aware surfaces @@ -132,7 +133,7 @@ The repository now treats the following as first-class evolution targets rather - future MCP tools should search indexes, inspect timelines, and load specific memory details from Markdown-backed state - `cam recall search` now defaults to `state=auto, limit=8`, providing the active-first, archived-fallback read-only CLI retrieval path for the same contract - `cam mcp serve` now provides the first read-only retrieval MCP path for that contract -- `cam mcp install --host ` now writes the recommended project-scoped host wiring for that retrieval plane without touching the Markdown store +- `cam mcp install --host codex` now writes the recommended project-scoped Codex wiring for that retrieval plane without touching the Markdown store; `claude`, `gemini`, and `generic` stay manual-only / snippet-first through `cam mcp print-config` - `cam mcp print-config --host ...` now prints ready-to-paste host snippets so the same retrieval plane is easier to wire into existing MCP clients - `cam mcp apply-guidance --host codex` now manages the repository-level `AGENTS.md` guidance block through the existing additive, marker-scoped, fail-closed flow - `cam mcp doctor` now inspects the recommended project-scoped retrieval wiring, project pinning, and hook / skill fallback assets without mutating host config files @@ -174,6 +175,7 @@ The implementation is not required to expose all of these immediately, but the a │ ├── MEMORY.md │ ├── commands.md │ ├── architecture.md + │ ├── reference.md │ ├── memory-history.jsonl │ └── archive/ │ ├── ARCHIVE.md @@ -206,7 +208,7 @@ The architecture now allows sidecar retrieval indexes, but they must remain rebu | Scope | Purpose | Typical examples | | :-- | :-- | :-- | | global | cross-project personal preferences | preferred package manager, review habits | -| project | repository-level durable knowledge | build/test commands, architecture constraints | +| project | repository-level durable knowledge | build/test commands, architecture constraints, external dashboard / issue-tracker / runbook pointers | | project-local | worktree-local or machine-local knowledge | local workflow, worktree notes | These boundaries matter because otherwise: diff --git a/docs/architecture.md b/docs/architecture.md index be9f053..23104ac 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -60,6 +60,7 @@ flowchart TD 当前 startup injection 的特点: - 各 scope 的 `MEMORY.md` 以 quoted startup files 注入 +- 现在还会补充少量 active-only content highlights,在不 dump topic bodies 的前提下增强 startup recall - 附带结构化 topic file refs,作为按需定位信息 - startup 不 eager 读取 topic entry bodies - 允许 session continuity 作为单独 block 注入 @@ -93,6 +94,8 @@ session continuity 是独立 companion layer,不属于 durable memory 契约 - project-local continuity:当前 worktree 的本地 working state - reviewer warning / confidence 属于 audit side metadata,不属于 continuity body - startup provenance 只列出真实读取到的 continuity 文件 +- continuity persistence 现在也要求 primary rollout provenance;subagent rollout 仍可进入 reviewer/continuity analysis,但不会再被持久化入口直接写回 continuity 文件 +- shared / project-local continuity 现在会以原子方式一起提交;失败时优先恢复旧文件,再通过 `summary-write` / `audit-write` recovery marker 暴露给 reviewer 它的存在是为了帮助会话恢复,而不是替代 memory。 @@ -117,9 +120,9 @@ session continuity 是独立 companion layer,不属于 durable memory 契约 - 当前 concrete integration assets 已包括: - `cam recall search` 默认采用 `state=auto`、`limit=8`,提供 active-first、archived-fallback 的只读 retrieval 搜索面 - `cam hooks install` 生成本仓自带的 local bridge / fallback recall bundle(`memory-recall.sh`、兼容 wrappers、`recall-bridge.md`),而不是官方 Codex hook surface - - `cam skills install` 默认安装 runtime Codex skill,同时支持显式 `--surface runtime|official-user|official-project`;三种 surface 都沿用同一套 MCP-first、CLI-fallback 的 `search -> timeline -> details` durable memory 工作流 + - `cam skills install` 默认安装 runtime Codex skill,同时支持显式 `--surface runtime|official-user|official-project`;三种 surface 都沿用同一套 `MCP -> local bridge -> resolved CLI` 的 `search -> timeline -> details` durable memory 工作流 - `cam mcp serve` 暴露 read-only retrieval MCP plane,对齐 `search -> timeline -> details` 契约 - - `cam mcp install --host ` 显式写入推荐的 project-scoped 宿主配置;`generic` 仍保持 manual-only,只通过 `cam mcp print-config --host generic` 提供 ready-to-paste snippet + - `cam mcp install --host codex` 显式写入推荐的 project-scoped Codex 宿主配置;`claude`、`gemini` 与 `generic` 都保持 manual-only / snippet-first,只通过 `cam mcp print-config --host ` 提供 ready-to-paste wiring - `cam mcp print-config --host ...` 打印 ready-to-paste 宿主接入片段;其中 `--host codex` 会额外附带推荐的 `AGENTS.md` snippet / guidance - `cam mcp apply-guidance --host codex` 以 additive、marker-scoped、fail-closed 的方式创建或更新仓库级 guidance block - `cam mcp doctor` 只读检查推荐的 project-scoped retrieval MCP 接线、project pinning 与 hook / skill fallback 资产,不改写宿主配置 @@ -177,6 +180,7 @@ session continuity 是独立 companion layer,不属于 durable memory 契约 │ ├── MEMORY.md │ ├── commands.md │ ├── architecture.md + │ ├── reference.md │ ├── memory-history.jsonl │ └── archive/ │ ├── ARCHIVE.md @@ -212,7 +216,7 @@ session continuity 是独立 companion layer,不属于 durable memory 契约 | Scope | 作用 | 示例 | | :-- | :-- | :-- | | global | 跨项目个人偏好 | 常用包管理器、个人审查习惯 | -| project | 仓库级 durable knowledge | build/test commands、架构约束 | +| project | 仓库级 durable knowledge | build/test commands、架构约束、外部 dashboard / issue tracker / runbook pointer | | project-local | 当前 worktree 或本地环境知识 | 本地 workflow、worktree-specific note | 必须继续保持这条边界,否则: diff --git a/docs/claude-reference.en.md b/docs/claude-reference.en.md index 37d17d4..fe15fc6 100644 --- a/docs/claude-reference.en.md +++ b/docs/claude-reference.en.md @@ -92,7 +92,7 @@ This repository still does not claim full `/memory` interaction parity, but it m - users can see the actual memory files and active paths - users can modify memory through Markdown files or explicit commands -### 6. `autoMemoryDirectory` has a configuration safety boundary +### 6. Host integration surfaces matter, but should not replace the core contract Claude-style public configuration boundaries also imply that a shared project should not be able to silently redirect another user's durable-memory storage. diff --git a/docs/host-surfaces.md b/docs/host-surfaces.md index ea072d8..525d217 100644 --- a/docs/host-surfaces.md +++ b/docs/host-surfaces.md @@ -95,7 +95,9 @@ - Gemini 的 extension + hooks + MCP 思路 - OpenCode 的 plugin + MCP + AGENTS 能力面 - OpenClaw 的“统一 memory core,不统一格式”思路 -- 针对宿主差异提供清晰分层的接入面:`cam mcp install` 负责显式写入 project-scoped host config,并在安全前提下保留 `codex_auto_memory` entry 上的非 canonical 自定义字段;`cam mcp print-config` / `cam mcp doctor` 继续负责只打印 / 只检查,其中 `doctor` 现在会把 alternate global wiring 与推荐的 project-scoped 路径分开表达;`cam mcp apply-guidance --host codex` 负责 additive 管理 repo 级 `AGENTS.md` guidance block,`cam integrations install --host codex` 负责编排不改写 `AGENTS.md` 的 stack install,`cam integrations apply --host codex` 负责显式收口整套 Codex stack apply,但现在会先做 AGENTS safety preflight;`cam integrations doctor --host codex` 继续只读汇总 readiness,并通过 `applyReadiness` 区分“可以直接 apply”与“必须先修复 managed block”;其中 skills 默认仍安装到 runtime target,但 `cam skills install --surface runtime|official-user|official-project` 与 `cam integrations install/apply --skill-surface ...` 已为官方 `.agents/skills` 路径准备显式 opt-in 兼容面;shell fallback 仍由 `cam hooks install` 提供;这条 hooks 线是本仓自带的 local bridge,不是官方 Codex hook surface +- 针对宿主差异提供清晰分层的接入面:`cam mcp install --host codex` 负责显式写入 project-scoped Codex host config,并在安全前提下保留 `codex_auto_memory` entry 上的非 canonical 自定义字段;`claude`、`gemini` 与 `generic` 继续保持 host-mutation manual-only,但仍通过 `cam mcp print-config` / `cam mcp doctor` 暴露只打印 / 只检查的 read-only wiring guidance,而不是完全隐藏 inspect surface;其中 `print-config` / `doctor` 现在还会显式暴露官方 Codex hooks 的实验性 guidance,继续把“公开可见”和“可作为默认主路径”区分开;`doctor` 也会把 alternate global wiring 与推荐的 project-scoped 路径分开表达,并区分 hook helper 是否只是 installed 还是在当前 shell 中真正 operational;`cam mcp apply-guidance --host codex` 负责 additive 管理 repo 级 `AGENTS.md` guidance block,`cam integrations install --host codex` 负责编排不改写 `AGENTS.md` 的 stack install,`cam integrations apply --host codex` 负责显式收口整套 Codex stack apply,`cam integrations doctor --host codex` 负责只读汇总整套 stack readiness;install/apply 完成后仍需要回到 doctor 判断当前环境里究竟是 MCP、local bridge 还是 resolved CLI 在实际生效;其中 skills 默认仍安装到 runtime target,但 `cam skills install --surface runtime|official-user|official-project` 与 `cam integrations install/apply --skill-surface ...` 已为官方 `.agents/skills` 路径准备显式 opt-in 兼容面;shell fallback 仍由 `cam hooks install` 提供;这条 hooks 线是本仓自带的 local bridge,不是官方 Codex hook surface +- `cam mcp doctor --host codex` 与 `cam integrations doctor --host codex` 现在还会把“偏好 route”和“当前可运行 route”分开表达:`recommendedRoute` 继续表示首选的 MCP-first 路径;`currentlyOperationalRoute`、`routeKind`、`routeEvidence`、`shellDependencyLevel`、`hostMutationRequired`、`preferredRouteBlockers`、`currentOperationalBlockers` 则表达当前环境里哪条 route 真正可跑、首选 route 为什么没跑起来、以及当前 fallback 自己是否还有 blocker。skills 继续被视为 guidance surface,而不是 executable fallback route +- `cam integrations doctor --host codex` 还会显式暴露 skill-surface steering:`preferredSkillSurface`、`recommendedSkillInstallCommand`、`installedSkillSurfaces`、`readySkillSurfaces`。这些字段表达的是“当前建议把 guidance 安装到哪里”,而不是技能已经成为 executable fallback route。 - release-facing `--help` 文案也视为宿主能力面的稳定公开接口,必须和上述 install / apply / doctor / manual-only 边界保持一致 不应该吸收: diff --git a/docs/integration-strategy.md b/docs/integration-strategy.md index 6785e0e..8d62af4 100644 --- a/docs/integration-strategy.md +++ b/docs/integration-strategy.md @@ -47,6 +47,8 @@ - `cam hooks install` 现在会生成本仓自带的 local bridge / fallback helper bundle:`memory-recall.sh`、`post-work-memory-review.sh`、兼容 helper wrappers 与 `recall-bridge.md` - `post-work-memory-review.sh` 会把 `cam sync` 与 `cam memory --recent` 串成同一套 post-work durable-memory review helper - 这条线当前仍是本地桥接层,不宣称自己是官方 Codex hook surface +- 官方 Codex hooks 截至 `2026-04-06` 已有公开文档页,但仍是 **Experimental**,且 config 文档里的 `codex_hooks` feature flag 仍标为 **Under development** +- 因此当前仓库只把官方 hooks 作为显式 opt-in 的实验性对齐轨道,通过 `cam mcp print-config --host codex` 与 `cam mcp doctor` / `cam integrations doctor --host codex` 暴露 guidance,而不切默认路径;其中 hooks guidance 现在只打印 `codex_hooks = true` 这一行,并明确要求放进现有 `[features]` table,避免 ready-to-paste 片段把 TOML table 重复定义 - 还不是主入口 目标状态: @@ -65,7 +67,9 @@ - 已进入代码主线 - 当前仓库已经提供 `cam recall search` / `timeline` / `details` 作为 retrieval workflow 的当前 CLI surface - `cam recall search` 现在默认已经对齐推荐 preset:`state=auto`、`limit=8`,会先查 active,未命中再回退 archived,继续降低代理手动 widened search 的摩擦 -- `cam skills install` 现在默认安装 runtime Codex skill,并支持显式 `--surface runtime|official-user|official-project`;其中 `official-user` 是 user-scoped 官方 `.agents/skills` copy,`official-project` 是 project-scoped 官方 `.agents/skills` copy;无论安装到哪个 surface,都复用同一套 MCP-first、CLI-fallback 的 retrieval guidance 与推荐检索 preset:`state=auto`、`limit=8` +- `cam recall search --json` / `search_memories` 现在还会额外暴露 `stateResolution`、`executionSummary`、`searchOrder`、`globalLimitApplied`、`truncatedCount` 与 `diagnostics.checkedPaths[].returnedCount` / `droppedCount`,明确区分 auto-state 命中、双阶段 miss、global sorting,以及 mixed index/Markdown fallback 的执行过程 +- `cam remember --json` / `cam forget --json` 现在会把 manual mutation 也接进 reviewer contract,返回 `lifecycleAction`、`latestLifecycleAttempt`、`lineageSummary` 与 `ref/path/historyPath` +- `cam skills install` 现在默认安装 runtime Codex skill,并支持显式 `--surface runtime|official-user|official-project`;其中 `official-user` 是 user-scoped 官方 `.agents/skills` copy,`official-project` 是 project-scoped 官方 `.agents/skills` copy;无论安装到哪个 surface,都复用同一套 `MCP -> local bridge -> resolved CLI` 的 retrieval guidance 与推荐检索 preset:`state=auto`、`limit=8` 目标状态: @@ -84,14 +88,17 @@ - `cam mcp serve` 会暴露 `search_memories`、`timeline_memories`、`get_memory_details` - `search_memories` 与 `cam recall search` 现在共享 active-first、archived-fallback 的默认检索语义 - 当前推荐的渐进式检索 preset 统一为:`state=auto`、`limit=8` -- `cam mcp install --host ` 会显式写入推荐的 project-scoped 宿主配置,继续降低接线摩擦,但不改变 retrieval 的只读语义;若已有 `codex_auto_memory` entry 带有非 canonical 自定义字段,会在安全前提下保留它们 -- `generic` host 仍然保持 manual-only:不提供自动写入的 install 分支,只通过 `cam mcp print-config --host generic` 暴露 ready-to-paste snippet +- `cam mcp install --host codex` 会显式写入推荐的 project-scoped Codex 宿主配置,继续降低接线摩擦,但不改变 retrieval 的只读语义;若已有 `codex_auto_memory` entry 带有非 canonical 自定义字段,会在安全前提下保留它们 +- `claude`、`gemini` 与 `generic` host 都保持 manual-only / snippet-first:不提供自动写入的 install 分支,只通过 `cam mcp print-config --host ` 暴露 ready-to-paste snippet - `cam mcp print-config --host ...` 会打印 ready-to-paste 宿主接入片段;其中 `--host codex` 现在还会额外打印推荐的 `AGENTS.md` snippet,并在 JSON 输出里附带共享 `workflowContract`,把 durable memory workflow 正式接到 Codex 当前公开稳定 surface 上 - `cam mcp apply-guidance --host codex` 会以 additive、可审计、fail-closed 的方式创建或更新 repo 根 `AGENTS.md` 中由本仓维护的 guidance block,继续降低手工粘贴成本 - `cam integrations install --host codex` 现在提供显式的一次性 stack install 入口:统一编排 project-scoped MCP wiring、hooks 与 skills,但不触碰 `AGENTS.md` - `cam integrations apply --host codex` 现在提供显式的一次性 Codex stack apply 入口:在不改变 `integrations install` 边界的前提下,额外统一编排 managed `AGENTS.md` guidance block;其中 skills 默认仍走 runtime target,但也支持显式 `--skill-surface runtime|official-user|official-project`;若 `AGENTS.md` managed block unsafe,会在任何 stack 写入之前 preflight `blocked` - `cam mcp doctor` 会只读检查推荐的 project-scoped MCP 接线、project pinning 与 shared fallback bridge assets;若检测到 alternate global wiring,也会继续强调“推荐路径未完成”与“已存在非推荐路径”是两回事 -- `cam integrations doctor --host codex` 会只读汇总推荐路由、推荐 preset、结构化 `workflowContract`、`applyReadiness`、subchecks 与 next steps;当 `AGENTS.md` managed block unsafe 时,会先提示修复该 block,而不是直接推荐 `cam integrations apply --host codex` +- `cam mcp doctor` 现在还会给出最小粒度的 retrieval sidecar repair command;若只有单个 scope/state degraded,不再一律提示 `all/all` +- `cam mcp doctor` / `cam integrations doctor --host codex` 现在会把“hook recall assets 已安装”和“当前 shell 中 hook fallback 真正 operational”区分开来,避免 `cam` 不在 PATH 时误把 hooks route 当成可直接依赖的主路由;同一轮里 `hook capture` 也开始区分 installed 与 operational +- `cam integrations doctor --host codex` 会只读汇总推荐路由、推荐 preset、结构化 `workflowContract`、`applyReadiness`、`experimentalHooks`、subchecks 与 next steps;当 `AGENTS.md` managed block unsafe 时,会先提示修复该 block,而不是直接推荐 `cam integrations apply --host codex`;如果缺的只是 AGENTS guidance,而 PATH 问题让 hooks/MCP 变成 non-operational,也不会再误推整套 `cam integrations apply --host codex` +- 共享 `workflowContract` 现在还会显式暴露 launcher 前提:`commandName=cam`、`requiresPathResolution=true`、`hookHelpersShellOnly=true`;这让 print-config、skills、hooks、doctor 对 PATH / shell 前提维持同一套说法,同时 helper bundle 与 doctor next steps 也会在 `cam` 不可解析时优先给出 `node /dist/cli.js` 的 verified fallback - 它仍然是只读 retrieval plane,不是新的 canonical store 目标状态: diff --git a/docs/native-migration.en.md b/docs/native-migration.en.md index 744c9e6..e8c4e57 100644 --- a/docs/native-migration.en.md +++ b/docs/native-migration.en.md @@ -2,8 +2,7 @@ [简体中文](./native-migration.md) | [English](./native-migration.en.md) -> This document now answers one narrower question: **when is it worth promoting native Codex memory / hooks from a readiness signal to the primary path?** -> It no longer carries the repository's entire integration-direction narrative. For that broader direction, see [Integration Strategy](./integration-strategy.md). +> This document now has a narrower job: it records how `codex-auto-memory` evaluates native Codex memory and hook signals without treating them as the only future direction. The repository is still Codex-first, but its broader product evolution now also includes non-native hook, skill, and MCP-aware integration paths. ## One-page conclusion @@ -40,6 +39,13 @@ Local runtime behavior and `cam doctor --json` also expose readiness signals: - rollout JSONL - `memories` - `codex_hooks` +- the current app-server signal from `codex features list` (which may appear as `tui` or `tui_app_server`, depending on the local build) + +That local feature truth should still be described conservatively: + +- the official app-server docs describe a stable default API surface plus explicit experimental subfeatures +- the local `codex features list` output may still show `tui_app_server` as `removed` +- so app-server should remain a host/UI readiness signal here, not a stable primary foundation for this repository But those signals are still not enough to retire the current wrapper path or claim a stable native memory contract. @@ -110,6 +116,7 @@ Those seams now support two kinds of future work: - keep wrapper-based startup injection - keep Markdown as the primary memory surface - keep session continuity as a separate companion layer +- keep the temporary continuity startup contract explicit about rendered provenance, section trimming, and the future compaction seam - keep native migration conservative - allow non-native integration expansion as long as it preserves the same Markdown contract diff --git a/docs/native-migration.md b/docs/native-migration.md index e7de36f..50990b6 100644 --- a/docs/native-migration.md +++ b/docs/native-migration.md @@ -29,6 +29,13 @@ Codex 的官方公开资料已经能确认一些对本项目有价值的基础 - rollout JSONL - `memories` - `codex_hooks` +- 来自 `codex features list` 的当前 app-server signal(本机输出里可能叫 `tui`,也可能仍叫 `tui_app_server`) + +需要注意的是,本机 feature truth 与官方公开 contract 不一定同名: + +- 官方 app-server 文档描述的是“默认 stable API surface + 显式 experimental subfeatures” +- 而本机 `codex features list` 当前可能把 `tui_app_server` 显示成 `removed` +- 因此 app-server 仍应继续被视为 host/UI signal,而不是当前仓库的 stable primary foundation 但这些还不足以支撑“现在就把 current companion path 废掉”。 @@ -88,6 +95,7 @@ Codex 的官方公开资料已经能确认一些对本项目有价值的基础 - 继续使用 wrapper startup injection - 继续把 Markdown 作为主存储表面 - 继续把 session continuity 当作独立 companion layer +- 继续让 temporary continuity startup contract 显式暴露 rendered provenance、section trimming 与 future compaction seam - 允许 hook / skill / MCP 以并行入口进入主线 - 不允许任何新入口绕开 canonical Markdown contract diff --git a/docs/release-checklist.md b/docs/release-checklist.md index d5a43cf..a60d5b5 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -27,10 +27,10 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Run `pnpm test:docs-contract` - Run `pnpm test:reviewer-smoke` - Run `pnpm test:cli-smoke` -- Run `pnpm test:dist-cli-smoke` -- Run `pnpm test:tarball-install-smoke` - Run `pnpm test` - Run `pnpm build` +- Run `pnpm test:dist-cli-smoke` +- Run `pnpm test:tarball-install-smoke` - Run `pnpm pack:check` - Confirm `package.json.files` still whitelists the release-facing surfaces you intend to ship: `dist`, `docs`, `schemas`, the multilingual READMEs, and `LICENSE`. - Confirm `pnpm build` still starts from a clean `dist/` directory so `npm pack` cannot accidentally pick up stale compiled artifacts from an older tree shape. @@ -44,24 +44,54 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Run `node dist/cli.js session status --json` and confirm the latest explicit audit drill-down matches the newest audit-log entry when present. - Run `node dist/cli.js memory --recent --json` and confirm suppressed conflict candidates remain reviewer-visible instead of being silently merged. - Run `node dist/cli.js recall search pnpm --json` and confirm the default search contract stays aligned at `state=auto, limit=8`, returning compact refs before any full detail fetch. -- Confirm `node dist/cli.js recall search pnpm --json` now also reports whether the search used the retrieval sidecar or fell back to Markdown scan through additive `retrievalMode` / `retrievalFallbackReason` fields. +- Confirm `node dist/cli.js recall search pnpm --json` now also reports whether the search used the retrieval sidecar or fell back to Markdown scan through additive `retrievalMode` / `finalRetrievalMode` / `retrievalFallbackReason` fields. +- Confirm `node dist/cli.js recall search pnpm --json` now also exposes additive `stateResolution` and `executionSummary`, so reviewer-visible auto-state decisions and mixed fallback paths do not have to be inferred from `resolvedState` alone. +- Confirm `node dist/cli.js recall search pnpm --json` now also exposes `totalMatchedCount`, `returnedCount`, and `resultWindow`, so machine consumers can see the global hit count and returned slice without inferring it from `results.length`. +- Confirm `node dist/cli.js recall search pnpm --json` now also exposes per-result `globalRank`, so downstream reviewers can preserve global ordering after post-limit filtering. +- Confirm `node dist/cli.js recall search pnpm --state all --json` keeps the explicit-state contract stable: `resolvedState: "all"`, `stateResolution.outcome: "explicit-state"`, and stable `checkedPaths` semantics after global sorting and limit truncation. - Confirm `node dist/cli.js recall search pnpm --json` now also exposes additive per-path diagnostics through `diagnostics.checkedPaths`, so mixed index/fallback searches stay reviewer-visible instead of collapsing into a single top-level mode. +- Confirm `diagnostics.checkedPaths[].returnedCount` makes the post-limit contribution of each checked path explicit instead of overloading `matchedCount`. +- Confirm `node dist/cli.js recall search pnpm --json` now also exposes `searchOrder`, `globalLimitApplied`, and `truncatedCount`, so machine consumers can tell which path order ran and whether the global result set was truncated. +- Confirm `diagnostics.checkedPaths[].droppedCount` makes it explicit how many per-path matches were dropped after global sorting and limit application. +- Confirm `node dist/cli.js remember "..." --json` and `node dist/cli.js forget "..." --json` now expose manual mutation reviewer payloads, including `mutationKind`, `matchedCount`, `appliedCount`, `noopCount`, `summary`, `entries[]`, `followUp`, and `nextRecommendedActions`. +- Confirm the same manual reviewer payload now also exposes additive aggregate reviewer counts such as `entryCount` and `warningCount`, and that `forget --json` also exposes `detailsUsableEntryCount` and `timelineOnlyEntryCount`. +- Confirm matched manual-mutation payloads also expose top-level lifecycle/detail fields such as `latestAppliedLifecycle`, `latestLifecycleAttempt`, `latestLifecycleAction`, `latestState`, `latestSessionId`, `latestRolloutPath`, `latestAudit`, `timelineWarningCount`, `warnings`, `entry`, `lineageSummary`, and `historyPath`. +- Confirm empty `forget --json` results keep `nextRecommendedActions: []` instead of emitting placeholder `\"\"` commands. +- Confirm `node dist/cli.js forget "pnpm npm" --json` now shares the same multi-term query normalization as `recall search`, so one memory can match across `summary/details` without requiring the original substring to remain contiguous. +- Confirm the same manual reviewer payload now also exposes top-level `reviewerSummary` and `nextRecommendedActions`, so post-correction review/sync/reindex guidance is machine-readable. +- Confirm repeated `node dist/cli.js remember "..." --json` calls now keep the latest applied lifecycle while surfacing the latest noop attempt through the same reviewer payload. +- Confirm `node dist/cli.js forget "missing" --json` returns an additive empty reviewer payload instead of failing or inventing fake refs. +- Run `node dist/cli.js memory --print-startup` and confirm startup recall now includes a `### Highlights` block with a few active-only content highlights, while archived notes and full topic bodies still stay out of default startup recall. +- Confirm `node dist/cli.js memory --json` now also surfaces `highlightCount`, `omittedHighlightCount`, `omittedTopicFileCount`, `highlightsByScope`, `startupSectionsRendered`, `topicFileOmissionCounts`, and `topicRefCountsByScope`, so startup highlight trimming, topic-ref trimming, and section rendering stay machine-visible. +- Confirm `node dist/cli.js memory --json` now also surfaces additive `startupOmissions`, so low-signal, duplicate, unsafe-topic, and budget-trimmed startup exclusions stay reviewer-visible. +- Confirm `node dist/cli.js memory --json` now also surfaces additive `topicDiagnostics` for unsafe / malformed topic files. +- Confirm `node dist/cli.js memory --json` stays read-only on an uninitialized project, returning an empty inspection view instead of creating `MEMORY.md`, `ARCHIVE.md`, or retrieval sidecars. +- Confirm startup highlights do not include entries from unsafe topic files, even though the corresponding topic refs may still remain reviewer-visible. +- Confirm durable sync audit now also surfaces additive `rejectedOperationCount` and `rejectedReasonCounts` instead of silently dropping rejected operations from reviewer output. +- Confirm durable sync audit and sync recovery markers now also surface additive `rejectedOperations` summaries instead of forcing reviewers to infer rejected refs only from counts. - Run `node dist/cli.js memory reindex --scope all --state all --json` and confirm retrieval sidecars rebuild explicitly from Markdown canonical memory without mutating topic Markdown or audit logs. +- Confirm `node dist/cli.js memory reindex --scope all --state all --json` stays read-only on an uninitialized project, returning `rebuilt: []` instead of creating a durable memory layout implicitly. - Run `node dist/cli.js recall details --json` for one returned ref and confirm the path resolves to Markdown-backed memory, including archived refs when relevant. - Confirm `node dist/cli.js recall details --json` now also exposes additive provenance summary fields such as `latestLifecycleAction`, `latestSessionId`, `latestRolloutPath`, and `historyPath`. - Confirm `node dist/cli.js recall details --json` now also exposes additive `latestAudit` provenance so a reviewer can jump from lifecycle state to the latest sync-audit summary without manually correlating sidecars. - Confirm `node dist/cli.js recall details --json` now also exposes additive reviewer fields such as `latestState`, `timelineWarningCount`, `lineageSummary`, and `warnings`. - Run a local MCP smoke against `node dist/cli.js mcp serve` and confirm `search_memories`, `timeline_memories`, and `get_memory_details` are exposed as a read-only retrieval plane. - Confirm `search_memories` mirrors the CLI retrieval diagnostics through additive `retrievalMode` / `retrievalFallbackReason` fields, and `timeline_memories` / `get_memory_details` keep lifecycle provenance aligned with the CLI retrieval surface. +- Confirm `node dist/cli.js recall search --json` surfaces additive `diagnostics.topicDiagnostics` whenever the requested scope/state includes unsafe / malformed topic sources, including healthy sidecar reads that still fail closed. +- Confirm `search_memories state=all` also keeps the explicit-state contract stable, including `stateResolution`, `executionSummary`, and stable `checkedPaths` semantics under global sorting plus limit truncation. - Confirm `search_memories` now also mirrors CLI search diagnostics through additive `diagnostics.checkedPaths`, and `get_memory_details` mirrors CLI detail provenance through additive `latestAudit`. - Confirm `timeline_memories` now also mirrors CLI lifecycle reviewer fields through additive `warnings` and `lineageSummary`. -- Run `node dist/cli.js mcp install --host --json` and confirm the result contract includes `host`, `serverName`, `projectRoot`, `targetPath`, `action`, `projectPinned`, and `readOnlyRetrieval`. -- Re-run the same `node dist/cli.js mcp install --host --json` command once and confirm it returns `action: "unchanged"` when the target host config is already canonical. -- Confirm `node dist/cli.js mcp install --host --json` preserves non-canonical custom fields already attached to the `codex_auto_memory` entry instead of dropping them silently. -- Confirm `node dist/cli.js mcp install --host --json` now reports `preservedCustomFields`, so the machine-readable contract matches the human-readable notes about retained custom fields. +- Run `node dist/cli.js mcp install --host codex --json` and confirm the result contract includes `host`, `serverName`, `projectRoot`, `targetPath`, `action`, `projectPinned`, and `readOnlyRetrieval`. +- `mcp install` is codex only; non-Codex hosts stay manual-only and snippet-first through `mcp print-config`. +- Re-run the same `node dist/cli.js mcp install --host codex --json` command once and confirm it returns `action: "unchanged"` when the target host config is already canonical. +- Confirm `node dist/cli.js mcp install --host codex --json` preserves non-canonical custom fields already attached to the `codex_auto_memory` entry instead of dropping them silently. +- Confirm `node dist/cli.js mcp install --host codex --json` now reports `preservedCustomFields`, so the machine-readable contract matches the human-readable notes about retained custom fields. +- Confirm project docs and `AGENTS.md` do not describe `cam mcp install` as supporting `claude` or `gemini`; mutable install remains Codex-only while other hosts stay manual-only / snippet-first. - Confirm `node dist/cli.js mcp install --host generic` fails explicitly and still points users to manual wiring. - Run `node dist/cli.js mcp print-config --host --json` for each public host and confirm the snippet contract includes `serverName`, `targetFileHint`, and a project-pinned retrieval command without writing host config files. - For `node dist/cli.js mcp print-config --host codex --json`, also confirm the payload includes an additive AGENTS.md snippet / guidance block plus the shared `workflowContract`, so the retrieval workflow contract is identical between print-config, doctor, and integrations doctor. +- Confirm `node dist/cli.js mcp print-config --host codex --json` now also exposes additive `experimentalHooks` guidance and keeps the wording explicit that official Codex hooks are public but Experimental. +- Confirm the same `experimentalHooks.snippet` is safe to paste into an existing TOML config: it should not re-declare `[features]` when the file already has that table. - Run `node dist/cli.js mcp apply-guidance --host codex --json` and confirm it reports `created`, `updated`, `unchanged`, or `blocked` without overwriting unrelated AGENTS.md content outside the managed block. - Confirm `node dist/cli.js mcp apply-guidance --host codex --json` still returns `blocked` for malformed or unsafe managed-block shapes while leaving `AGENTS.md` byte-for-byte unchanged. - Run `node dist/cli.js mcp apply-guidance --host codex --cwd --json` from another working directory and confirm the managed AGENTS block is written inside the targeted project root. @@ -70,18 +100,56 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Run `node dist/cli.js mcp doctor --host codex --json` and confirm the payload also exposes the structured `workflowContract`, including the current CLI fallback commands and post-work sync/review helper contract. - Confirm `node dist/cli.js mcp doctor --host codex --json` now also exposes additive `retrievalSidecar` readiness, including per-scope/per-state sidecar status, fallback reason, and the guarantee that degraded sidecars still fall back safely to Markdown canonical recall. - Confirm `node dist/cli.js mcp doctor --host codex --json` now also exposes an explicit retrieval sidecar repair command instead of forcing users to infer how to rebuild indexes manually. +- Confirm `node dist/cli.js mcp print-config --host codex --json` still teaches the explicit fallback order `MCP -> local bridge -> resolved CLI` instead of collapsing directly to bare `cam recall`. +- Confirm the same repair command now picks the smallest safe `--scope` / `--state` when only a subset of sidecars is degraded, instead of always defaulting to `all/all`. +- Confirm the same repair command follows the resolved launcher fallback when `cam` is unavailable on PATH, instead of emitting a broken bare `cam memory reindex ...` suggestion. - Confirm `node dist/cli.js mcp doctor --host codex --json` distinguishes alternate global wiring from the recommended project-scoped route through additive scope/reporting fields instead of treating them as the same readiness state. +- Confirm `recommendedSkillInstallCommand` in both `node dist/cli.js mcp doctor --host codex --json` and `node dist/cli.js integrations doctor --host codex --json` now follows the same resolved launcher semantics as the rest of the workflow contract, using the verified `node dist/cli.js` fallback when `cam` is unavailable on PATH. - Confirm `node dist/cli.js mcp doctor --host codex --json` now exposes `configScopeSummary` and `alternateWiring`, so valid alternate global wiring stays distinct from malformed or shape-mismatched global host config. +- Confirm `node dist/cli.js mcp doctor --host codex --json` and `node dist/cli.js integrations doctor --host codex --json` now also expose additive `layoutDiagnostics`, so malformed topic file names, unexpected sidecars, and canonical index drift stay reviewer-visible without mutating Markdown memory. - Confirm `node dist/cli.js mcp doctor --host codex --json` distinguishes skill-surface presence, canonical content, and readiness through additive fields such as `runtimeSkillPresent`, `officialUserSkillMatchesCanonical`, `officialProjectSkillMatchesCanonical`, `anySkillSurfaceInstalled`, and `anySkillSurfaceReady`. -- Confirm `node dist/cli.js hooks install` writes `post-work-memory-review.sh`, and that the generated helper still runs `cam sync` followed by `cam memory --recent`. +- Confirm `node dist/cli.js mcp doctor --host codex --json` now also exposes additive per-surface `skillSurfaces` data so `installed`, `discoverable`, `listed`, and `executable` do not get collapsed into one readiness bit. +- Confirm `node dist/cli.js mcp doctor --host codex --json` now distinguishes `hookRecallReady` from `hookRecallOperationalReady`, and that launcher-aware helper checks still report operational when the embedded `node dist/cli.js` fallback is valid even if `cam` is unavailable on PATH. +- Confirm `node dist/cli.js mcp doctor --host codex --json` now also distinguishes `hookCaptureReady` from `hookCaptureOperationalReady`, and that broken embedded launcher paths surface as stale assets instead of silently remaining runnable. +- Confirm `workflowContract.launcher` and doctor now share the same executable-aware truth source: a non-executable `cam` file on PATH must not be treated as a verified launcher. +- Confirm the repo-managed `AGENTS.md` guidance stays canonical and environment-independent: no machine-specific absolute paths, no PATH/HOME-sensitive launcher wording, and no verified/unverified fallback labels inside the persisted snippet. +- Confirm `node dist/cli.js mcp doctor --host codex --json` and `node dist/cli.js integrations doctor --host codex --json` both surface additive `experimentalHooks` guidance that keeps official Codex hooks explicitly labeled Experimental / under active development, while `features.codex_hooks` remains under development and off by default. +- Confirm `node dist/cli.js mcp doctor --host generic --json` stays host-aware for manual-only hosts: `commandSurface.install=false`, `commandSurface.applyGuidance=false`, and Codex-only sections such as `codexStack`, `experimentalHooks`, `agentsGuidance`, and `applySafety` stay `null`. +- Confirm `node dist/cli.js doctor --json` now also reports the app-server signal separately from `memories` / `codex_hooks`. +- Confirm compiled smoke and tarball smoke both lock the additive `readiness.appServer` contract instead of leaving that guarantee source-test only. +- Confirm `node dist/cli.js doctor --json` now also exposes additive `recommendedRoute`, `recommendedAction`, `recommendedActionCommand`, and `recommendedDoctorCommand`, so the top-level doctor surface can point to the next operational check without mutating anything. +- Confirm release-facing compiled and tarball smoke now both cover that top-level `doctor --json` contract instead of leaving it source-test only. +- Confirm `node dist/cli.js integrations apply --host codex --json` now also exposes additive `postApplyReadinessCommand`, so post-apply route confirmation is machine-readable. +- Confirm `node dist/cli.js doctor` text now separates `Native memory/hooks readiness` from `Host/UI signals`, instead of presenting `tui_app_server` alongside native memory/hooks as if they shared the same maturity level. +- Confirm the release notes and docs do not over-fit one local app-server feature name: current local builds may expose `tui` or `tui_app_server`, and neither should be documented as a stable public contract. +- Confirm `workflowContract` now also exposes launcher constraints (`commandName=cam`, `requiresPathResolution=true`, `hookHelpersShellOnly=true`) so PATH and shell assumptions stay machine-visible. +- Confirm `workflowContract.launcher` now also clarifies that it applies to direct CLI usage and installed helper assets, while canonical host MCP wiring continues to use `cam mcp serve`. +- Confirm `workflowContract` now also carries additive `executionContract`, `modelGuidanceContract`, and `hostWiringContract` sections, and that those nested sections stay in parity across `print-config`, `mcp doctor`, `integrations doctor`, `hooks install`, and `skills install`. +- Confirm hook helpers and doctor next steps now prefer a verified `node /dist/cli.js` fallback when `cam` is unavailable on PATH, instead of only emitting unresolved `cam ...` guidance. +- Confirm `node dist/cli.js hooks install` writes `post-work-memory-review.sh`, and that the generated helper still runs the resolved durable-memory `sync -> recent review` route instead of assuming bare `cam` is available. - Confirm `node dist/cli.js hooks install --json` exposes the shared `workflowContract` so hook helper guidance stays aligned with the MCP and integrations doctor surfaces. -- Run `node dist/cli.js hooks install --cwd ` from another working directory and confirm the generated hook helper bundle pins `memory-recall.sh` and `post-work-memory-review.sh` to the targeted project root instead of the caller shell cwd. +- Confirm `node dist/cli.js hooks install --json` now also exposes additive `postInstallReadinessCommand`, so install-time next steps are machine-readable. +- Run `node dist/cli.js hooks install --cwd ` from another working directory and confirm the generated hook helper bundle keeps user-scoped assets reusable: `memory-recall.sh` and `post-work-memory-review.sh` should resolve the target project at runtime from `CAM_PROJECT_ROOT` or the caller shell cwd instead of hardcoding one repository path into shared helper contents. +- Confirm `node dist/cli.js integrations doctor --host codex --cwd --json` project-pins hook-fallback next steps with `CAM_PROJECT_ROOT=...` when the local bridge route is the recommended operational fallback. - Run `node dist/cli.js skills install --surface official-project --cwd ` from another working directory and confirm the explicit project-scoped `.agents/skills` copy is written inside the targeted repository. - Confirm `node dist/cli.js skills install --json` exposes the shared `workflowContract` so skill guidance stays aligned with the MCP and integrations doctor surfaces. +- Confirm `node dist/cli.js skills install --json` now also exposes additive `postInstallReadinessCommand`, so install-time next steps are machine-readable. +- Confirm `node dist/cli.js mcp print-config --host --json` keeps `workflowContract` absent while `--host codex --json` still exposes it. +- Confirm the same Codex `workflowContract.routePreference.preferredRoute` stays fixed at `mcp-first`. +- Confirm `node dist/cli.js remember --json` / `forget --json` now expose additive reviewer aggregates such as `uniqueAuditCount`, `auditCountsDeduplicated`, and `warningsByEntryRef` without breaking older top-level fields. +- Confirm `node dist/cli.js sync` records a reviewer-visible `subagent-rollout` skip when a child-session rollout is passed explicitly, instead of mutating canonical durable memory. +- Confirm `node dist/cli.js session save --rollout ` and `session refresh` with a matching recovery marker or latest audit entry pointing at a subagent rollout now fail closed instead of writing continuity from child-session evidence. +- Confirm continuity summary persistence failures now produce a `summary-write` recovery marker, and that shared/local continuity files remain rolled back to their pre-write state. - Run `node dist/cli.js integrations install --host codex --json` and confirm it orchestrates the existing Codex MCP wiring, hook bundle, and skill assets without touching the Markdown memory store. +- Confirm `node dist/cli.js integrations install --host codex --json` now rolls back staged MCP / hook / skill writes if installation fails after partial writes. - Run `node dist/cli.js integrations apply --host codex --json` and confirm it orchestrates MCP wiring, managed AGENTS guidance, hook assets, and skill assets while keeping `integrations install --host codex` non-mutating for AGENTS.md. - Confirm `node dist/cli.js integrations apply --host codex --json` still returns `stackAction: "blocked"` when the AGENTS managed block is unsafe, and now also reports the preflight early-block shape (`preflightBlocked`, `blockedStage`, per-subaction `attempted`) while preserving the AGENTS file content and skipping all other stack writes. - Confirm the same blocked `node dist/cli.js integrations apply --host codex --json` payload marks skipped subactions explicitly so machine consumers can distinguish “not attempted due to preflight block” from “ran successfully”. +- Confirm `node dist/cli.js integrations apply --host codex --json` now rolls back staged project-scoped MCP wiring, hook assets, and skill assets when AGENTS guidance blocks late, and exposes additive rollback metadata such as `rollbackApplied` / `rollbackPathCount`. +- Confirm the same late-block payload also exposes final-state semantics such as `rollbackSucceeded`, `rollbackErrors`, and per-subaction `effectiveAction` / `rolledBack`, so machine consumers do not mistake attempted writes for final installed state. +- Confirm the same late-block payload now also exposes additive `rollbackReport`, making each restored/deleted/error path explicit instead of only returning aggregate rollback booleans. +- Confirm `node dist/cli.js integrations install --host codex --json` now returns a structured rollback failure payload for staged install failures, including `rollbackSucceeded`, `rollbackErrors`, `rollbackReport`, and per-subaction final-state details. +- Confirm rollback coverage includes dangling symlink snapshots, so failed installs restore pre-existing symlink targets instead of deleting them as if they were missing files. - Run `node dist/cli.js integrations apply --host codex --cwd --json` from another working directory and confirm the stack still project-pins all subactions to the targeted repository. - Run `node dist/cli.js skills install --surface official-user` and confirm the explicit official `.agents/skills` copy is written without changing the runtime default target. - Run `node dist/cli.js integrations install --host codex --skill-surface official-user --json` and confirm the skill subaction reports the selected surface while MCP and AGENTS boundaries stay unchanged. @@ -90,12 +158,16 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Run `node dist/cli.js integrations install --host codex --skill-surface official-project --json` and confirm the skill subaction reports the selected project-scoped surface while MCP and AGENTS boundaries stay unchanged. - Run `node dist/cli.js integrations apply --host codex --skill-surface official-project --json` and confirm the selected project-scoped skill surface still flows through the full apply path. - Run `node dist/cli.js integrations doctor --host codex --json` and confirm it reports the thin Codex-only stack readiness view with `recommendedRoute`, `recommendedPreset`, `subchecks`, and `nextSteps`. +- Confirm `node dist/cli.js integrations doctor --host codex --json` also exposes additive route-truth fields: `recommendedRoute`, `currentlyOperationalRoute`, `routeKind`, `routeEvidence`, `shellDependencyLevel`, `hostMutationRequired`, `preferredRouteBlockers`, and `currentOperationalBlockers`, with `recommendedRoute` staying MCP-first while the blocker fields explain why the preferred route or current fallback is not operational. +- Confirm the same `integrations doctor` next steps stay launcher-aware: when `cam` is unavailable on PATH, the first direct CLI recall suggestion must prefer the resolved `node dist/cli.js recall ...` command instead of a broken bare `cam recall ...`. - Confirm `node dist/cli.js integrations doctor --host codex --json` also exposes the shared structured `workflowContract`, including the post-work sync/review helper semantics, and now reports `applyReadiness` so unsafe AGENTS managed blocks are diagnosed before recommending `cam integrations apply --host codex`. - Confirm `node dist/cli.js integrations doctor --host codex --json` also surfaces the additive `retrievalSidecar` summary from `mcp doctor`, so retrieval-plane degradation is visible before the user has to run a recall command manually. - Confirm `node dist/cli.js integrations doctor --host codex --json` now recommends `cam memory reindex` when retrieval sidecars are degraded. +- Confirm `node dist/cli.js integrations doctor --host codex --json` keeps the `skill` subcheck guidance-only and does not describe skills as an executable fallback route. +- Confirm `node dist/cli.js integrations doctor --host codex --json` also exposes additive skill-surface steering fields: `preferredSkillSurface`, `recommendedSkillInstallCommand`, `installedSkillSurfaces`, and `readySkillSurfaces`. - Confirm `workflowConsistency` wording in doctor surfaces now explicitly treats repo-level `AGENTS.md` guidance as part of the shared retrieval workflow contract, not just hooks/skills text. - Treat key `--help` output as release-facing contract, not incidental CLI text: - - `node dist/cli.js mcp install --help` should keep the supported install-host list at `codex, claude, or gemini`, leaving `generic` out of the install branch. + - `node dist/cli.js mcp install --help` should keep the supported install-host list at `codex` only. - `node dist/cli.js mcp print-config --help` should keep the supported snippet-host list at `codex, claude, gemini, or generic`. - `node dist/cli.js mcp apply-guidance --help` should stay Codex-only and describe managed `AGENTS.md` updates. - `node dist/cli.js mcp doctor --help` should stay inspect-only and keep the host selection list at `codex, claude, gemini, generic, or all`. @@ -105,12 +177,13 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - `node dist/cli.js integrations doctor --help` should stay inspect-only and Codex-only. - Confirm `node dist/cli.js session load --json` / `status --json` still expose `confidence` and warnings when the rollout required a conservative continuity summary. - Confirm `node dist/cli.js session load --json --print-startup` now also exposes the structured continuity-startup contract: truthful rendered `sourceFiles`, `candidateSourceFiles`, `sectionsRendered`, additive `omissions` / `omissionCounts`, `continuitySectionKinds`, `continuitySourceKinds`, `continuityProvenanceKind`, `continuityMode`, and `futureCompactionSeam`. +- Confirm `test:dist-cli-smoke` and `test:tarball-install-smoke` both cover the JSON fields that define current-route truth and skill-surface steering, instead of leaving those guarantees only in source-level tests. - Confirm the same continuity-startup contract is covered in source tests, compiled smoke, and tarball smoke so rendered provenance truth does not regress outside source-only unit tests. - Confirm continuity reviewer warnings stay in diagnostics / audit surfaces and are not written into continuity Markdown body text. - Run a local smoke flow: - `node dist/cli.js init` - `node dist/cli.js remember "..."` - - `node dist/cli.js memory --recent --print-startup` + - `node dist/cli.js memory --cwd --recent --print-startup` - `node dist/cli.js session status` - `node dist/cli.js session save` - `node dist/cli.js session refresh` @@ -128,6 +201,7 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor ## Native compatibility checks - Run `node dist/cli.js doctor` and record the current `memories` / `codex_hooks` status. +- Run `node dist/cli.js doctor --json` and confirm it also exposes additive `retrievalSidecar`, `topicDiagnostics`, and `layoutDiagnostics` without implicitly creating the durable memory layout. - Run `node dist/cli.js audit` and record whether any medium/high findings remain. - Confirm that any native-facing code still preserves companion fallback. - Confirm that Markdown memory remains the user-facing source of truth. diff --git a/package.json b/package.json index ea29469..5182d12 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,10 @@ "pack:check": "npm pack --dry-run", "prepack": "pnpm build", "test": "vitest run --exclude test/dist-cli-smoke.test.ts --exclude test/tarball-install-smoke.test.ts", - "test:cli-smoke": "vitest run test/audit.test.ts test/hooks-command.test.ts test/integrations-command.test.ts test/mcp-command.test.ts test/skills-command.test.ts test/memory-command.test.ts test/recall-command.test.ts test/session-command.test.ts test/wrapper-session-continuity.test.ts", + "test:cli-smoke": "vitest run test/audit.test.ts test/doctor-command.test.ts test/hooks-command.test.ts test/integrations-command.test.ts test/mcp-command.test.ts test/skills-command.test.ts test/memory-command.test.ts test/recall-command.test.ts test/session-command.test.ts test/wrapper-session-continuity.test.ts", "test:dist-cli-smoke": "vitest run test/dist-cli-smoke.test.ts", "test:docs-contract": "vitest run test/docs-contract.test.ts", - "test:reviewer-smoke": "vitest run test/docs-contract.test.ts test/hooks-command.test.ts test/integrations-command.test.ts test/mcp-command.test.ts test/skills-command.test.ts test/memory-command.test.ts test/recall-command.test.ts test/session-command.test.ts test/wrapper-session-continuity.test.ts test/session-continuity.test.ts", + "test:reviewer-smoke": "vitest run test/docs-contract.test.ts test/doctor-command.test.ts test/hooks-command.test.ts test/integrations-command.test.ts test/mcp-command.test.ts test/skills-command.test.ts test/memory-command.test.ts test/recall-command.test.ts test/session-command.test.ts test/wrapper-session-continuity.test.ts test/session-continuity.test.ts", "test:tarball-install-smoke": "vitest run test/tarball-install-smoke.test.ts", "test:watch": "vitest", "verify:release": "pnpm lint && pnpm test:docs-contract && pnpm test:reviewer-smoke && pnpm test:cli-smoke && pnpm test && pnpm build && pnpm test:dist-cli-smoke && pnpm pack:check && pnpm test:tarball-install-smoke" diff --git a/src/lib/commands/doctor.ts b/src/lib/commands/doctor.ts index bdca4ff..30b9d6b 100644 --- a/src/lib/commands/doctor.ts +++ b/src/lib/commands/doctor.ts @@ -1,4 +1,10 @@ import { runCommandCapture } from "../util/process.js"; +import { + filterUnsafeTopicDiagnostics, + type RetrievalSidecarCheck, + type TopicFileDiagnostic +} from "../domain/memory-store.js"; +import { buildResolvedCliCommand } from "../integration/retrieval-contract.js"; import { buildNativeReadinessReport, parseCodexFeatures } from "../runtime/codex-features.js"; import { buildRuntimeContext } from "../runtime/runtime-context.js"; @@ -7,8 +13,68 @@ interface DoctorOptions { json?: boolean; } +type DoctorRecommendedRoute = "companion"; + +interface DoctorTopicDiagnostics { + status: "ok" | "warning"; + summary: string; + diagnostics: TopicFileDiagnostic[]; +} + +interface DoctorRetrievalSidecar { + status: "ok" | "warning"; + summary: string; + repairCommand: string; + checks: RetrievalSidecarCheck[]; +} + +function buildDoctorTopicDiagnostics(diagnostics: TopicFileDiagnostic[]): DoctorTopicDiagnostics { + return diagnostics.length === 0 + ? { + status: "ok", + summary: "No unsafe topic files were detected.", + diagnostics + } + : { + status: "warning", + summary: `${diagnostics.length} unsafe topic file(s) were detected in the Markdown canonical store.`, + diagnostics + }; +} + +function buildDoctorRetrievalSidecar( + checks: RetrievalSidecarCheck[], + projectRoot: string +): DoctorRetrievalSidecar { + const degradedChecks = checks.filter((check) => check.status !== "ok"); + const requestedScope = + new Set(degradedChecks.map((check) => check.scope)).size === 1 + ? degradedChecks[0]?.scope ?? "all" + : "all"; + const requestedState = + new Set(degradedChecks.map((check) => check.state)).size === 1 + ? degradedChecks[0]?.state ?? "all" + : "all"; + return { + status: degradedChecks.length === 0 ? "ok" : "warning", + summary: + degradedChecks.length === 0 + ? "All inspected retrieval sidecars are current." + : "One or more retrieval sidecars are missing, invalid, or stale. Recall still falls back to Markdown canonical memory safely.", + repairCommand: buildResolvedCliCommand( + `memory reindex --scope ${requestedScope} --state ${requestedState}`, + { + cwd: projectRoot + } + ), + checks + }; +} + export async function runDoctor(options: DoctorOptions = {}): Promise { - const runtime = await buildRuntimeContext(options.cwd); + const runtime = await buildRuntimeContext(options.cwd, {}, { + ensureMemoryLayout: false + }); const featureResult = runCommandCapture( runtime.loadedConfig.config.codexBinary, ["features", "list"], @@ -17,6 +83,37 @@ export async function runDoctor(options: DoctorOptions = {}): Promise { const parsedFeatures = featureResult.exitCode === 0 ? parseCodexFeatures(featureResult.stdout) : []; const readiness = buildNativeReadinessReport(parsedFeatures); + const retrievalSidecar = buildDoctorRetrievalSidecar( + await runtime.syncService.memoryStore.inspectRetrievalSidecars(), + runtime.project.projectRoot + ); + const topicDiagnostics = buildDoctorTopicDiagnostics( + filterUnsafeTopicDiagnostics( + await runtime.syncService.memoryStore.inspectTopicFiles({ + scope: "all", + state: "all" + }) + ) + ); + const layoutDiagnostics = await runtime.syncService.memoryStore.inspectLayoutDiagnostics({ + scope: "all", + state: "all" + }); + const recommendedRoute: DoctorRecommendedRoute = "companion"; + const recommendedActionCommand = buildResolvedCliCommand("mcp doctor --host codex", { + cwd: runtime.project.projectRoot + }); + const recommendedDoctorCommand = buildResolvedCliCommand("doctor --json", { + cwd: runtime.project.projectRoot + }); + const recommendedAction = [ + `Run ${recommendedActionCommand} to inspect the operational retrieval route for this project.`, + retrievalSidecar.status === "warning" + ? `If retrieval sidecars remain degraded after that review, rebuild them with ${retrievalSidecar.repairCommand}.` + : null + ] + .filter((line): line is string => line !== null) + .join(" "); if (options.json) { return JSON.stringify( @@ -29,6 +126,13 @@ export async function runDoctor(options: DoctorOptions = {}): Promise { extractorMode: runtime.loadedConfig.config.extractorMode, configFiles: runtime.loadedConfig.files, warnings: runtime.loadedConfig.warnings, + recommendedRoute, + recommendedAction, + recommendedActionCommand, + recommendedDoctorCommand, + retrievalSidecar, + topicDiagnostics, + layoutDiagnostics, features: parsedFeatures, readiness }, @@ -45,16 +149,57 @@ export async function runDoctor(options: DoctorOptions = {}): Promise { `Memory root: ${runtime.syncService.memoryStore.paths.baseDir}`, `Auto memory enabled: ${runtime.loadedConfig.config.autoMemoryEnabled}`, `Extractor mode: ${runtime.loadedConfig.config.extractorMode}`, + `Recommended route: ${recommendedRoute}`, + `Recommended next step: ${recommendedAction}`, + `Recommended next-step command: ${recommendedActionCommand}`, + `Recommended doctor command: ${recommendedDoctorCommand}`, + `Retrieval sidecar: ${retrievalSidecar.status} (${retrievalSidecar.summary})`, + `Topic diagnostics: ${topicDiagnostics.status} (${topicDiagnostics.summary})`, + `Layout diagnostics: ${layoutDiagnostics.length === 0 ? "none" : layoutDiagnostics.length}`, `Companion session source: rollout-jsonl`, `Companion runtime injector: wrapper-base-instructions`, `Config files: ${runtime.loadedConfig.files.length ? runtime.loadedConfig.files.join(", ") : "none"}`, ...runtime.loadedConfig.warnings.map((warning) => `Warning: ${warning}`), "", - "Native readiness:", + "Native memory/hooks readiness:", `- memories: ${readiness.memories ? `${readiness.memories.stage}/${readiness.memories.enabled}` : "missing"}`, `- codex_hooks: ${readiness.hooks ? `${readiness.hooks.stage}/${readiness.hooks.enabled}` : "missing"}`, `- summary: ${readiness.summary}`, "", + "Host/UI signals:", + `- Codex App Server: ${readiness.appServer ? `${readiness.appServer.stage}/${readiness.appServer.enabled}` : "missing"}`, + ...(retrievalSidecar.status === "warning" + ? [ + "", + "Retrieval sidecar diagnostics:", + `- Repair command: ${retrievalSidecar.repairCommand}`, + ...retrievalSidecar.checks.map( + (check) => + `- ${check.scope}/${check.state}: ${check.status}${check.fallbackReason ? ` (${check.fallbackReason})` : ""} | index: ${check.indexPath} | generatedAt: ${check.generatedAt ?? "none"}` + ) + ] + : []), + ...(topicDiagnostics.status === "warning" + ? [ + "", + "Topic diagnostics:", + ...topicDiagnostics.diagnostics.map( + (diagnostic) => + `- ${diagnostic.scope}/${diagnostic.state}/${diagnostic.topic}: unsafe (${diagnostic.unsafeReason ?? "unknown reason"}) | entries=${diagnostic.entryCount} | malformed=${diagnostic.invalidEntryBlockCount} | manualContent=${diagnostic.manualContentDetected ? "yes" : "no"}` + ) + ] + : []), + ...(layoutDiagnostics.length > 0 + ? [ + "", + "Layout diagnostics:", + ...layoutDiagnostics.map( + (diagnostic) => + `- ${diagnostic.scope}/${diagnostic.state}/${diagnostic.fileName}: ${diagnostic.kind}` + ) + ] + : []), + "", "Codex feature flags:", featureResult.exitCode === 0 ? featureResult.stdout.trim() diff --git a/src/lib/commands/hooks.ts b/src/lib/commands/hooks.ts index 8e729d6..42a58c1 100644 --- a/src/lib/commands/hooks.ts +++ b/src/lib/commands/hooks.ts @@ -5,6 +5,7 @@ import { import { LOCAL_BRIDGE_BUNDLE_NOTE } from "../integration/codex-stack.js"; import { installIntegrationAssets } from "../integration/install-assets.js"; import { resolveMcpProjectRoot } from "../integration/mcp-config.js"; +import { buildResolvedCliCommand } from "../integration/retrieval-contract.js"; interface HooksCommandOptions { cwd?: string; @@ -12,7 +13,7 @@ interface HooksCommandOptions { } export async function installHooks(options: HooksCommandOptions = {}): Promise { - const projectRoot = options.cwd ? resolveMcpProjectRoot(options.cwd) : undefined; + const projectRoot = resolveMcpProjectRoot(options.cwd); const result = await installIntegrationAssets("hooks", { projectRoot }); @@ -23,6 +24,9 @@ export async function installHooks(options: HooksCommandOptions = {}): Promise `- [${asset.action}] ${asset.path}`), "", "These files now form a local bridge bundle for current Codex workflows and future hook/skill/MCP-aware retrieval flows.", diff --git a/src/lib/commands/integrations.ts b/src/lib/commands/integrations.ts index deff533..1f51022 100644 --- a/src/lib/commands/integrations.ts +++ b/src/lib/commands/integrations.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { buildCodexIntegrationSubchecks, buildCodexIntegrationNextSteps, @@ -14,20 +17,23 @@ import { applyCodexAgentsGuidance, inspectCodexAgentsGuidanceApplySafety } from "../integration/agents-guidance.js"; +import { listIntegrationAssets } from "../integration/assets.js"; import { installIntegrationAssets } from "../integration/install-assets.js"; import { installMcpProjectConfig, type McpInstallResult } from "../integration/mcp-install.js"; import { normalizeMcpHost } from "../integration/mcp-config.js"; import { resolveMcpProjectRoot } from "../integration/mcp-config.js"; import { inspectMcpDoctor, type McpDoctorReport } from "../integration/mcp-doctor.js"; +import { resolveMcpHostProjectConfigPath } from "../integration/mcp-hosts.js"; import { formatCodexSkillInstallSurface, normalizeCodexSkillInstallSurface, type CodexSkillInstallSurface } from "../integration/skills-paths.js"; import { - appendCliCwdFlag, + buildResolvedCliCommand, buildWorkflowContract } from "../integration/retrieval-contract.js"; +import { ensureDir, writeTextFileAtomic } from "../util/fs.js"; type IntegrationStackAction = "created" | "updated" | "unchanged" | "blocked"; type InstallStackAction = Exclude; @@ -38,6 +44,7 @@ interface IntegrationsInstallOptions { host?: string; skillSurface?: string; json?: boolean; + homeDir?: string; } interface IntegrationsApplyOptions { @@ -45,6 +52,7 @@ interface IntegrationsApplyOptions { host?: string; skillSurface?: string; json?: boolean; + homeDir?: string; } interface IntegrationsDoctorOptions { @@ -56,7 +64,9 @@ interface IntegrationsDoctorOptions { interface IntegrationSubactionResult { status: IntegrationSubactionStatus; action: IntegrationStackAction; + effectiveAction?: Exclude; attempted?: boolean; + rolledBack?: boolean; skipped?: boolean; skipReason?: string; targetPath?: string; @@ -74,6 +84,27 @@ interface IntegrationStackInstallResult { skillsSurface: CodexSkillInstallSurface; readOnlyRetrieval: true; workflowContract: ReturnType; + postInstallReadinessCommand: string; + subactions: { + mcp: IntegrationSubactionResult; + hooks: IntegrationSubactionResult; + skills: IntegrationSubactionResult; + }; + notes: string[]; +} + +interface IntegrationStackInstallFailureResult { + host: "codex"; + projectRoot: string; + stackAction: "failed"; + rollbackApplied: true; + rollbackSucceeded: boolean; + rollbackErrors: string[]; + rollbackPathCount: number; + rollbackReport: RollbackReportEntry[]; + skillsSurface: CodexSkillInstallSurface; + readOnlyRetrieval: true; + workflowContract: ReturnType; subactions: { mcp: IntegrationSubactionResult; hooks: IntegrationSubactionResult; @@ -88,9 +119,15 @@ interface IntegrationStackApplyResult { stackAction: IntegrationStackAction; preflightBlocked?: boolean; blockedStage?: "agents-guidance-preflight"; + rollbackApplied?: boolean; + rollbackSucceeded?: boolean; + rollbackErrors?: string[]; + rollbackPathCount?: number; + rollbackReport?: RollbackReportEntry[]; skillsSurface: CodexSkillInstallSurface; readOnlyRetrieval: true; workflowContract: ReturnType; + postApplyReadinessCommand: string; subactions: { mcp: IntegrationSubactionResult; agents: IntegrationSubactionResult; @@ -100,6 +137,163 @@ interface IntegrationStackApplyResult { notes: string[]; } +interface FileRollbackSnapshot { + path: string; + existed: boolean; + kind: "file" | "symlink"; + contents: string | null; + symlinkTarget: string | null; + mode: number | null; +} + +interface RollbackReportEntry { + path: string; + action: "restored-existing" | "deleted-new" | "error"; + error?: string; +} + +async function captureFileRollbackSnapshot(filePath: string): Promise { + let stat: Awaited> | null = null; + try { + stat = await fs.lstat(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + + if (!stat) { + return { + path: filePath, + existed: false, + kind: "file", + contents: null, + symlinkTarget: null, + mode: null + }; + } + + if (stat.isSymbolicLink()) { + return { + path: filePath, + existed: true, + kind: "symlink", + contents: null, + symlinkTarget: await fs.readlink(filePath), + mode: null + }; + } + + const contents = await fs.readFile(filePath, "utf8"); + return { + path: filePath, + existed: true, + kind: "file", + contents, + symlinkTarget: null, + mode: stat.mode & 0o777 + }; +} + +async function captureRollbackSnapshots(paths: string[]): Promise { + const uniquePaths = [...new Set(paths)]; + return Promise.all(uniquePaths.map((filePath) => captureFileRollbackSnapshot(filePath))); +} + +async function restoreRollbackSnapshots(snapshots: FileRollbackSnapshot[]): Promise<{ + rollbackErrors: string[]; + rollbackReport: RollbackReportEntry[]; +}> { + const rollbackErrors: string[] = []; + const rollbackReport: RollbackReportEntry[] = []; + + for (const snapshot of snapshots) { + if (!snapshot.existed) { + try { + await fs.rm(snapshot.path, { force: true, recursive: true }); + rollbackReport.push({ + path: snapshot.path, + action: "deleted-new" + }); + } catch (error) { + const message = `Failed to remove ${snapshot.path} during rollback: ${error instanceof Error ? error.message : String(error)}`; + rollbackErrors.push(message); + rollbackReport.push({ + path: snapshot.path, + action: "error", + error: message + }); + } + continue; + } + + try { + await ensureDir(path.dirname(snapshot.path)); + await fs.rm(snapshot.path, { force: true, recursive: true }).catch(() => undefined); + if (snapshot.kind === "symlink") { + await fs.symlink(snapshot.symlinkTarget ?? "", snapshot.path); + } else { + await writeTextFileAtomic(snapshot.path, snapshot.contents ?? ""); + if (snapshot.mode !== null) { + await fs.chmod(snapshot.path, snapshot.mode); + } + } + rollbackReport.push({ + path: snapshot.path, + action: "restored-existing" + }); + } catch (error) { + const message = `Failed to restore ${snapshot.path} during rollback: ${error instanceof Error ? error.message : String(error)}`; + rollbackErrors.push(message); + rollbackReport.push({ + path: snapshot.path, + action: "error", + error: message + }); + } + } + + return { + rollbackErrors, + rollbackReport + }; +} + +function buildRollbackFailureMessage( + context: string, + error: unknown, + rollbackErrors: string[] +): string { + const errorMessage = error instanceof Error ? error.message : String(error); + if (rollbackErrors.length === 0) { + return `${context}: ${errorMessage}`; + } + + return `${context}: ${errorMessage}. Rollback also reported ${rollbackErrors.length} issue(s): ${rollbackErrors.join(" | ")}`; +} + +function toLateBlockSubactionState( + action: IntegrationStackAction, + rollbackSucceeded: boolean +): Pick { + if (action === "unchanged") { + return { + rolledBack: false + }; + } + + if (!rollbackSucceeded) { + return { + rolledBack: false + }; + } + + return { + rolledBack: true, + effectiveAction: "unchanged" + }; +} + interface IntegrationDoctorSubcheck { status: CodexIntegrationStatus; summary: string; @@ -110,11 +304,20 @@ interface IntegrationDoctorResult { projectRoot: string; readOnlyRetrieval: true; status: CodexIntegrationStatus; - recommendedRoute: McpDoctorReport["codexStack"]["recommendedRoute"]; + recommendedRoute: NonNullable["recommendedRoute"]; + currentlyOperationalRoute: NonNullable["currentlyOperationalRoute"]; + routeKind: NonNullable["routeKind"]; + routeEvidence: string[]; + shellDependencyLevel: NonNullable["shellDependencyLevel"]; + hostMutationRequired: boolean; + preferredRouteBlockers: string[]; + currentOperationalBlockers: string[]; recommendedPreset: string; retrievalSidecar: McpDoctorReport["retrievalSidecar"]; + topicDiagnostics: McpDoctorReport["topicDiagnostics"]; + layoutDiagnostics: McpDoctorReport["layoutDiagnostics"]; workflowContract: McpDoctorReport["workflowContract"]; - experimentalHooks: McpDoctorReport["experimentalHooks"]; + experimentalHooks: NonNullable; applyReadiness: { status: "safe" | "blocked"; reason?: string; @@ -136,6 +339,24 @@ interface IntegrationDoctorResult { nextSteps: string[]; } +function requireCodexDoctorSections(report: McpDoctorReport): { + agentsGuidance: NonNullable; + applySafety: NonNullable; + experimentalHooks: NonNullable; + codexStack: NonNullable; +} { + if (!report.agentsGuidance || !report.applySafety || !report.experimentalHooks || !report.codexStack) { + throw new Error("Codex integrations doctor requires codex-specific MCP doctor sections."); + } + + return { + agentsGuidance: report.agentsGuidance, + applySafety: report.applySafety, + experimentalHooks: report.experimentalHooks, + codexStack: report.codexStack + }; +} + function describeSkillSurfaceInstallNote(surface: CodexSkillInstallSurface): string { switch (surface) { case "runtime": @@ -174,6 +395,58 @@ function toMcpSubaction(result: McpInstallResult): IntegrationSubactionResult { }; } +function buildInstallFailureSubaction( + result: + | Awaited> + | Awaited> + | null, + options: { + fallbackSurface?: CodexSkillInstallSurface; + rollbackSucceeded: boolean; + notes?: string[]; + } = { + rollbackSucceeded: false + } +): IntegrationSubactionResult { + if (!result) { + return { + status: "ok", + action: "unchanged", + attempted: false, + skipped: true, + skipReason: "Skipped because integrations install failed before this subaction ran.", + surface: options.fallbackSurface, + readOnlyRetrieval: true, + notes: options.notes ?? ["Skipped because integrations install failed before this subaction ran."] + }; + } + + const shared = "targetPath" in result + ? { + ...toMcpSubaction(result), + attempted: true + } + : { + status: "ok" as const, + action: result.action, + attempted: true, + targetDir: result.targetDir, + surface: result.installSurface === "skills" ? result.skillSurface : undefined, + readOnlyRetrieval: result.readOnlyRetrieval, + notes: [...result.notes] + }; + + if (result.action === "unchanged" || !options.rollbackSucceeded) { + return shared; + } + + return { + ...shared, + rolledBack: true, + effectiveAction: "unchanged" + }; +} + function normalizeIntegrationsHost( host: string | undefined, action: "install" | "apply" | "doctor" @@ -209,6 +482,8 @@ function buildIntegrationsDoctorResult( explicitCwd?: boolean; } = {} ): IntegrationDoctorResult { + const { agentsGuidance, applySafety, experimentalHooks, codexStack } = + requireCodexDoctorSections(report); const pinnedProjectRoot = options.explicitCwd ? report.projectRoot : undefined; const codexHost = report.hosts.find((host) => host.host === "codex"); if (!codexHost) { @@ -222,16 +497,16 @@ function buildIntegrationsDoctorResult( const subchecks = buildCodexIntegrationSubchecks( { - mcpReady: report.codexStack.mcpReady, - mcpOperationalReady: report.codexStack.mcpOperationalReady, - camCommandAvailable: report.codexStack.camCommandAvailable, - hookCaptureReady: report.codexStack.hookCaptureReady, - hookCaptureOperationalReady: report.codexStack.hookCaptureOperationalReady, - hookRecallReady: report.codexStack.hookRecallReady, - hookRecallOperationalReady: report.codexStack.hookRecallOperationalReady, - skillReady: report.codexStack.skillReady, - workflowAssetsConsistent: report.codexStack.workflowAssetsConsistent, - workflowConsistent: report.codexStack.workflowConsistent + mcpReady: codexStack.mcpReady, + mcpOperationalReady: codexStack.mcpOperationalReady, + camCommandAvailable: codexStack.camCommandAvailable, + hookCaptureReady: codexStack.hookCaptureReady, + hookCaptureOperationalReady: codexStack.hookCaptureOperationalReady, + hookRecallReady: codexStack.hookRecallReady, + hookRecallOperationalReady: codexStack.hookRecallOperationalReady, + skillReady: codexStack.skillReady, + workflowAssetsConsistent: codexStack.workflowAssetsConsistent, + workflowConsistent: codexStack.workflowConsistent }, { hasCaptureAssets, @@ -245,12 +520,12 @@ function buildIntegrationsDoctorResult( } ); const agents: IntegrationDoctorSubcheck = - report.agentsGuidance.status === "ok" + agentsGuidance.status === "ok" ? { status: "ok", summary: "Repository-level AGENTS.md includes the current Codex Auto Memory guidance." } - : report.agentsGuidance.status === "warning" + : agentsGuidance.status === "warning" ? { status: "warning", summary: @@ -269,70 +544,87 @@ function buildIntegrationsDoctorResult( Object.values(allSubchecks).map((subcheck) => subcheck.status) ); const notes = [ - buildCodexRouteSummary(report.codexStack.recommendedRoute), + buildCodexRouteSummary(codexStack.recommendedRoute), ...buildCodexStackNotes({ cwd: pinnedProjectRoot }), `If retrieval sidecars are degraded, repair them explicitly with \`${report.retrievalSidecar.repairCommand}\` before treating the retrieval plane as fully healthy.`, + ...(report.topicDiagnostics.status === "warning" + ? [ + `Unsafe topic diagnostics are present: ${report.topicDiagnostics.summary}` + ] + : []), + ...(report.layoutDiagnostics.status === "warning" + ? [ + `Canonical layout diagnostics are present: ${report.layoutDiagnostics.summary}` + ] + : []), "AGENTS guidance is inspected read-only and is never auto-written by integrations doctor." ]; - const applySafetyStatus = report.applySafety.status; + const applySafetyStatus = applySafety.status; const applyReadiness = applySafetyStatus === "blocked" ? { status: "blocked" as const, - reason: report.applySafety.blockedReason, + reason: applySafety.blockedReason, recommendedFix: - `Repair the existing AGENTS.md managed guidance block so its markers are balanced outside fenced code blocks, then re-run ${appendCliCwdFlag( - "cam mcp apply-guidance --host codex", - pinnedProjectRoot + `Repair the existing AGENTS.md managed guidance block so its markers are balanced outside fenced code blocks, then re-run ${buildResolvedCliCommand( + "mcp apply-guidance --host codex", + { cwd: pinnedProjectRoot } )}.` } : { status: "safe" as const }; const nextSteps = buildCodexIntegrationNextSteps({ - mcpReady: report.codexStack.mcpReady, - mcpOperationalReady: report.codexStack.mcpOperationalReady, - camCommandAvailable: report.codexStack.camCommandAvailable, - hookCaptureReady: report.codexStack.hookCaptureReady, - hookCaptureOperationalReady: report.codexStack.hookCaptureOperationalReady, - hookRecallReady: report.codexStack.hookRecallReady, - hookRecallOperationalReady: report.codexStack.hookRecallOperationalReady, - skillReady: report.codexStack.skillReady, - workflowAssetsConsistent: report.codexStack.workflowAssetsConsistent, - workflowConsistent: report.codexStack.workflowConsistent + mcpReady: codexStack.mcpReady, + mcpOperationalReady: codexStack.mcpOperationalReady, + camCommandAvailable: codexStack.camCommandAvailable, + hookCaptureReady: codexStack.hookCaptureReady, + hookCaptureOperationalReady: codexStack.hookCaptureOperationalReady, + hookRecallReady: codexStack.hookRecallReady, + hookRecallOperationalReady: codexStack.hookRecallOperationalReady, + skillReady: codexStack.skillReady, + workflowAssetsConsistent: codexStack.workflowAssetsConsistent, + workflowConsistent: codexStack.workflowConsistent }, { skillInstallCommand: report.fallbackAssets.recommendedSkillInstallCommand, projectRoot: pinnedProjectRoot }); - const needsAgents = report.agentsGuidance.status !== "ok"; + const needsAgents = agentsGuidance.status !== "ok"; + const agentsGuidanceRepairStep = + applyReadiness.status === "safe" && needsAgents + ? `Run \`${buildResolvedCliCommand("mcp apply-guidance --host codex", { + cwd: pinnedProjectRoot + })}\` to create or update the managed Codex Auto Memory block in the repository-level AGENTS.md.` + : null; if (report.retrievalSidecar.status === "warning") { - nextSteps.unshift( - `Run \`${report.retrievalSidecar.repairCommand}\` to rebuild retrieval sidecars from Markdown canonical memory.` - ); + const repairStep = + `Run \`${report.retrievalSidecar.repairCommand}\` to rebuild retrieval sidecars from Markdown canonical memory.`; + if (agentsGuidanceRepairStep) { + nextSteps.splice(1, 0, repairStep); + } else { + nextSteps.unshift(repairStep); + } } const needsInstallableOtherStackSurface = - !report.codexStack.mcpReady || - !report.codexStack.hookCaptureReady || - !report.codexStack.hookRecallReady || - !report.codexStack.skillReady; + !codexStack.mcpReady || + !codexStack.hookCaptureReady || + !codexStack.hookRecallReady || + !codexStack.skillReady; if (applyReadiness.status === "blocked") { nextSteps.unshift(applyReadiness.recommendedFix); } else if (needsAgents && needsInstallableOtherStackSurface) { nextSteps.unshift( - `Run \`${appendCliCwdFlag( - `cam integrations apply --host codex --skill-surface ${report.fallbackAssets.preferredInstallSurface}`, - pinnedProjectRoot - )}\` to install project-scoped MCP wiring, refresh hook and skill assets, and safely apply the managed Codex Auto Memory AGENTS.md block in one step.` + `Run \`${buildResolvedCliCommand( + `integrations apply --host codex --skill-surface ${report.fallbackAssets.preferredInstallSurface}`, + { + cwd: pinnedProjectRoot + } + )}\` to install project-scoped MCP wiring, refresh hook and skill assets, and safely apply the managed Codex Auto Memory AGENTS.md block in one step.` ); } else if (needsAgents) { - nextSteps.push( - `Run \`${appendCliCwdFlag( - "cam mcp apply-guidance --host codex", - pinnedProjectRoot - )}\` to create or update the managed Codex Auto Memory block in the repository-level AGENTS.md.` - ); + nextSteps.unshift(agentsGuidanceRepairStep!); } return { @@ -340,11 +632,20 @@ function buildIntegrationsDoctorResult( projectRoot: report.projectRoot, readOnlyRetrieval: true, status, - recommendedRoute: report.codexStack.recommendedRoute, - recommendedPreset: report.codexStack.preset, + recommendedRoute: codexStack.recommendedRoute, + currentlyOperationalRoute: codexStack.currentlyOperationalRoute, + routeKind: codexStack.routeKind, + routeEvidence: [...codexStack.routeEvidence], + shellDependencyLevel: codexStack.shellDependencyLevel, + hostMutationRequired: codexStack.hostMutationRequired, + preferredRouteBlockers: [...codexStack.preferredRouteBlockers], + currentOperationalBlockers: [...codexStack.currentOperationalBlockers], + recommendedPreset: codexStack.preset, retrievalSidecar: report.retrievalSidecar, + topicDiagnostics: report.topicDiagnostics, + layoutDiagnostics: report.layoutDiagnostics, workflowContract: report.workflowContract, - experimentalHooks: report.experimentalHooks, + experimentalHooks, applyReadiness, preferredSkillSurface: report.fallbackAssets.preferredInstallSurface, recommendedSkillInstallCommand: report.fallbackAssets.recommendedSkillInstallCommand, @@ -364,9 +665,18 @@ function formatIntegrationsDoctorResult(result: IntegrationDoctorResult): string `Retrieval plane: ${result.readOnlyRetrieval ? "read-only" : "unexpected"}`, `Status: ${result.status}`, `Recommended route: ${result.recommendedRoute}`, + `Current operational route: ${result.currentlyOperationalRoute}`, + `Route kind: ${result.routeKind}`, + `Route evidence: ${result.routeEvidence.length > 0 ? result.routeEvidence.join(", ") : "none"}`, + `Shell dependency level: ${result.shellDependencyLevel}`, + `Host mutation required: ${result.hostMutationRequired ? "yes" : "no"}`, + `Preferred route blockers: ${result.preferredRouteBlockers.length > 0 ? result.preferredRouteBlockers.join(", ") : "none"}`, + `Current operational blockers: ${result.currentOperationalBlockers.length > 0 ? result.currentOperationalBlockers.join(", ") : "none"}`, `Recommended preset: ${result.recommendedPreset}`, `Experimental hooks: ${result.experimentalHooks.status} (${result.experimentalHooks.featureFlag})`, `Retrieval sidecar: ${result.retrievalSidecar.status} (${result.retrievalSidecar.summary})`, + `Topic diagnostics: ${result.topicDiagnostics.status} (${result.topicDiagnostics.summary})`, + `Layout diagnostics: ${result.layoutDiagnostics.status} (${result.layoutDiagnostics.summary})`, `Apply readiness: ${result.applyReadiness.status}${result.applyReadiness.reason ? ` (${result.applyReadiness.reason})` : ""}`, `Preferred skill surface: ${formatCodexSkillInstallSurface(result.preferredSkillSurface)}`, `Recommended skill install command: ${result.recommendedSkillInstallCommand}`, @@ -389,6 +699,12 @@ function formatIntegrationsDoctorResult(result: IntegrationDoctorResult): string ].join("\n"); } +function buildOperationalRouteConfirmationNote(projectRoot: string): string { + return `Run ${buildResolvedCliCommand("integrations doctor --host codex", { + cwd: projectRoot + })} to confirm which retrieval route is operational in the current environment.`; +} + export async function runIntegrationsInstall( options: IntegrationsInstallOptions = {} ): Promise { @@ -396,14 +712,137 @@ export async function runIntegrationsInstall( const projectRoot = resolveMcpProjectRoot(options.cwd); const skillSurface = normalizeCodexSkillInstallSurface(options.skillSurface); - const mcpResult = await installMcpProjectConfig("codex", projectRoot); - const hooksResult = await installIntegrationAssets("hooks", { - projectRoot - }); - const skillsResult = await installIntegrationAssets("skills", { - projectRoot, - skillSurface - }); + const homeDir = options.homeDir ?? os.homedir(); + const rollbackTargetPaths = [ + resolveMcpHostProjectConfigPath("codex", projectRoot), + ...listIntegrationAssets(homeDir, "hooks", { + projectRoot + }).map((asset) => asset.path), + ...listIntegrationAssets(homeDir, "skills", { + projectRoot, + skillSurface + }).map((asset) => asset.path) + ].filter((value): value is string => Boolean(value)); + const rollbackSnapshots = await captureRollbackSnapshots(rollbackTargetPaths); + + let mcpResult: Awaited> | null = null; + let hooksResult: Awaited> | null = null; + let skillsResult: Awaited> | null = null; + + try { + mcpResult = await installMcpProjectConfig("codex", projectRoot); + hooksResult = await installIntegrationAssets("hooks", { + projectRoot, + homeDir + }); + skillsResult = await installIntegrationAssets("skills", { + projectRoot, + skillSurface, + homeDir + }); + } catch (error) { + const { rollbackErrors, rollbackReport } = await restoreRollbackSnapshots(rollbackSnapshots); + if (options.json) { + const rollbackSucceeded = rollbackErrors.length === 0; + const result: IntegrationStackInstallFailureResult = { + host: "codex", + projectRoot, + stackAction: "failed", + rollbackApplied: true, + rollbackSucceeded, + rollbackErrors, + rollbackPathCount: rollbackSnapshots.length, + rollbackReport, + skillsSurface: skillSurface, + readOnlyRetrieval: true, + workflowContract: buildWorkflowContract({ + cwd: projectRoot + }), + subactions: { + mcp: buildInstallFailureSubaction(mcpResult, { + rollbackSucceeded + }), + hooks: buildInstallFailureSubaction(hooksResult, { + rollbackSucceeded + }), + skills: buildInstallFailureSubaction(skillsResult, { + rollbackSucceeded, + fallbackSurface: skillSurface + }) + }, + notes: [ + "This orchestration surface is Codex-only.", + "Codex integration stack install failed after staged writes started.", + `Rollback processed ${rollbackReport.length} target path(s) so partially written MCP, hook, and skill assets did not remain applied.`, + `Failure: ${error instanceof Error ? error.message : String(error)}`, + ...(rollbackErrors.length > 0 + ? [`Rollback reported ${rollbackErrors.length} issue(s): ${rollbackErrors.join(" | ")}.`] + : []) + ] + }; + return JSON.stringify(result, null, 2); + } + + throw new Error( + buildRollbackFailureMessage( + "Codex integration stack install failed after staged writes started", + error, + rollbackErrors + ) + ); + } + + if (!mcpResult || !hooksResult || !skillsResult) { + const { rollbackErrors, rollbackReport } = await restoreRollbackSnapshots(rollbackSnapshots); + if (options.json) { + const rollbackSucceeded = rollbackErrors.length === 0; + const result: IntegrationStackInstallFailureResult = { + host: "codex", + projectRoot, + stackAction: "failed", + rollbackApplied: true, + rollbackSucceeded, + rollbackErrors, + rollbackPathCount: rollbackSnapshots.length, + rollbackReport, + skillsSurface: skillSurface, + readOnlyRetrieval: true, + workflowContract: buildWorkflowContract({ + cwd: projectRoot + }), + subactions: { + mcp: buildInstallFailureSubaction(mcpResult, { + rollbackSucceeded + }), + hooks: buildInstallFailureSubaction(hooksResult, { + rollbackSucceeded + }), + skills: buildInstallFailureSubaction(skillsResult, { + rollbackSucceeded, + fallbackSurface: skillSurface + }) + }, + notes: [ + "This orchestration surface is Codex-only.", + "Codex integration stack install could not complete its staged subactions safely.", + `Rollback processed ${rollbackReport.length} target path(s) so partial integration assets did not remain applied.`, + ...(rollbackErrors.length > 0 + ? [`Rollback reported ${rollbackErrors.length} issue(s): ${rollbackErrors.join(" | ")}.`] + : []) + ] + }; + return JSON.stringify(result, null, 2); + } + + throw new Error( + buildRollbackFailureMessage( + "Codex integration stack install could not complete its staged subactions safely", + "missing staged result", + rollbackErrors + ) + ); + } + const stackAction = summarizeStackAction([ mcpResult.action, hooksResult.action, @@ -419,6 +858,9 @@ export async function runIntegrationsInstall( workflowContract: buildWorkflowContract({ cwd: projectRoot }), + postInstallReadinessCommand: buildResolvedCliCommand("integrations doctor --host codex", { + cwd: projectRoot + }), subactions: { mcp: toMcpSubaction(mcpResult), hooks: { @@ -440,7 +882,8 @@ export async function runIntegrationsInstall( notes: [ "This orchestration surface is Codex-only.", describeSkillSurfaceInstallNote(skillSurface), - buildCodexRouteSummary("mcp"), + "Project-scoped retrieval MCP wiring was written, but operational readiness still depends on the current shell and host environment.", + buildOperationalRouteConfirmationNote(projectRoot), `Skill surface: ${formatCodexSkillInstallSurface(skillSurface)}.`, `Recommended retrieval preset: ${hooksResult.recommendedPreset}.` ] @@ -473,6 +916,7 @@ export async function runIntegrationsApply( const projectRoot = resolveMcpProjectRoot(options.cwd); const skillSurface = normalizeCodexSkillInstallSurface(options.skillSurface); + const homeDir = options.homeDir ?? os.homedir(); const applySafety = await inspectCodexAgentsGuidanceApplySafety(projectRoot); if (applySafety.status === "blocked") { const skipReason = @@ -488,6 +932,9 @@ export async function runIntegrationsApply( workflowContract: buildWorkflowContract({ cwd: projectRoot }), + postApplyReadinessCommand: buildResolvedCliCommand("integrations doctor --host codex", { + cwd: projectRoot + }), subactions: { mcp: { status: "ok", @@ -549,28 +996,84 @@ export async function runIntegrationsApply( ...result.notes.map((note) => `- ${note}`) ].join("\n"); } - const agentsResult = await applyCodexAgentsGuidance(projectRoot); + + const rollbackTargetPaths = [ + resolveMcpHostProjectConfigPath("codex", projectRoot), + applySafety.targetPath, + ...listIntegrationAssets(homeDir, "hooks", { + projectRoot + }).map((asset) => asset.path), + ...listIntegrationAssets(homeDir, "skills", { + projectRoot, + skillSurface + }).map((asset) => asset.path) + ].filter((value): value is string => Boolean(value)); + const rollbackSnapshots = await captureRollbackSnapshots(rollbackTargetPaths); + + let mcpResult: Awaited> | null = null; + let hooksResult: Awaited> | null = null; + let skillsResult: Awaited> | null = null; + let agentsResult: Awaited> | null = null; + + try { + mcpResult = await installMcpProjectConfig("codex", projectRoot); + hooksResult = await installIntegrationAssets("hooks", { + projectRoot, + homeDir + }); + skillsResult = await installIntegrationAssets("skills", { + projectRoot, + skillSurface, + homeDir + }); + agentsResult = await applyCodexAgentsGuidance(projectRoot); + } catch (error) { + const { rollbackErrors } = await restoreRollbackSnapshots(rollbackSnapshots); + throw new Error( + buildRollbackFailureMessage( + "Codex integration apply failed after staged writes started", + error, + rollbackErrors + ) + ); + } + + if (!mcpResult || !hooksResult || !skillsResult || !agentsResult) { + const { rollbackErrors } = await restoreRollbackSnapshots(rollbackSnapshots); + throw new Error( + buildRollbackFailureMessage( + "Integrations apply could not complete its staged subactions safely", + "missing staged result", + rollbackErrors + ) + ); + } + if (agentsResult.action === "blocked") { - const skipReason = - "Skipped because integrations apply was blocked while applying the AGENTS guidance block."; + const { rollbackErrors, rollbackReport } = await restoreRollbackSnapshots(rollbackSnapshots); + const rollbackSucceeded = rollbackErrors.length === 0; const result: IntegrationStackApplyResult = { host: "codex", projectRoot, stackAction: "blocked", + rollbackApplied: true, + rollbackSucceeded, + rollbackErrors, + rollbackPathCount: rollbackSnapshots.length, + rollbackReport, skillsSurface: skillSurface, readOnlyRetrieval: true, workflowContract: buildWorkflowContract({ cwd: projectRoot }), + postApplyReadinessCommand: buildResolvedCliCommand("integrations doctor --host codex", { + cwd: projectRoot + }), subactions: { mcp: { - status: "ok", - action: "unchanged", - attempted: false, - skipped: true, - skipReason, - readOnlyRetrieval: true, - notes: [skipReason] + ...toMcpSubaction(mcpResult), + attempted: true, + ...toLateBlockSubactionState(mcpResult.action, rollbackSucceeded) }, agents: { status: "blocked", @@ -582,27 +1085,33 @@ export async function runIntegrationsApply( }, hooks: { status: "ok", - action: "unchanged", - attempted: false, - skipped: true, - skipReason, - readOnlyRetrieval: true, - notes: [skipReason] + action: hooksResult.action, + attempted: true, + ...toLateBlockSubactionState(hooksResult.action, rollbackSucceeded), + targetDir: hooksResult.targetDir, + readOnlyRetrieval: hooksResult.readOnlyRetrieval, + notes: [...hooksResult.notes] }, skills: { status: "ok", - action: "unchanged", - attempted: false, - skipped: true, - skipReason, + action: skillsResult.action, + attempted: true, + ...toLateBlockSubactionState(skillsResult.action, rollbackSucceeded), + targetDir: skillsResult.targetDir, surface: skillSurface, - readOnlyRetrieval: true, - notes: [skipReason] + readOnlyRetrieval: skillsResult.readOnlyRetrieval, + notes: [...skillsResult.notes] } }, notes: [ "This orchestration surface is Codex-only and explicit.", - "Integrations apply was blocked while applying the repository-level AGENTS.md guidance block, so no project-scoped MCP wiring, hook assets, or skill assets were written.", + "Integrations apply was blocked while applying the repository-level AGENTS.md guidance block after staged writes had started.", + `Rollback processed ${rollbackReport.length} target path(s) so project-scoped MCP wiring, hook assets, and skill assets did not remain half-applied.`, + ...(rollbackErrors.length > 0 + ? [ + `Rollback reported ${rollbackErrors.length} issue(s): ${rollbackErrors.join(" | ")}.` + ] + : []), ...(agentsResult.blockedReason ? [`Reason: ${agentsResult.blockedReason}`] : []) ] }; @@ -622,15 +1131,6 @@ export async function runIntegrationsApply( ].join("\n"); } - const mcpResult = await installMcpProjectConfig("codex", projectRoot); - const hooksResult = await installIntegrationAssets("hooks", { - projectRoot - }); - const skillsResult = await installIntegrationAssets("skills", { - projectRoot, - skillSurface - }); - const result: IntegrationStackApplyResult = { host: "codex", projectRoot, @@ -645,6 +1145,9 @@ export async function runIntegrationsApply( workflowContract: buildWorkflowContract({ cwd: projectRoot }), + postApplyReadinessCommand: buildResolvedCliCommand("integrations doctor --host codex", { + cwd: projectRoot + }), subactions: { mcp: { ...toMcpSubaction(mcpResult), @@ -680,7 +1183,8 @@ export async function runIntegrationsApply( "This orchestration surface is Codex-only and explicit.", "Unlike `cam integrations install --host codex`, this command also manages the repository-level AGENTS.md guidance block through the existing additive, marker-scoped, fail-closed flow.", describeSkillSurfaceInstallNote(skillSurface), - buildCodexRouteSummary("mcp"), + "Project-scoped retrieval MCP wiring and AGENTS guidance were updated, but operational readiness still depends on the current shell and host environment.", + buildOperationalRouteConfirmationNote(projectRoot), `Skill surface: ${formatCodexSkillInstallSurface(skillSurface)}.`, `Recommended retrieval preset: ${hooksResult.recommendedPreset}.` ] diff --git a/src/lib/commands/skills.ts b/src/lib/commands/skills.ts index af8b78b..3e72519 100644 --- a/src/lib/commands/skills.ts +++ b/src/lib/commands/skills.ts @@ -3,6 +3,7 @@ import { codexSkillAssetDirForSurface } from "../integration/assets.js"; import { installIntegrationAssets } from "../integration/install-assets.js"; +import { buildResolvedCliCommand } from "../integration/retrieval-contract.js"; import { CODEX_MEMORY_SKILL_NAME, formatCodexSkillInstallSurface, @@ -32,6 +33,9 @@ export async function installSkills(options: SkillsCommandOptions = {}): Promise surface: skillSurface, preferredSkillSurface: result.preferredSkillSurface ?? "runtime", readOnlyRetrieval: result.readOnlyRetrieval, + postInstallReadinessCommand: buildResolvedCliCommand("mcp doctor --host codex", { + cwd: projectRoot + }), workflowContract: result.workflowContract, notes: result.notes, assets: result.assets @@ -44,6 +48,7 @@ export async function installSkills(options: SkillsCommandOptions = {}): Promise return [ `Installed Codex skill assets in ${result.targetDir}`, `Action: ${result.action}`, + `Next: run ${buildResolvedCliCommand("mcp doctor --host codex", { cwd: projectRoot })}`, `Skill surface: ${formatCodexSkillInstallSurface(skillSurface)}`, `Preferred skill surface: ${result.preferredSkillSurface ?? "runtime"}`, ...result.assets.map((asset) => `- [${asset.action}] ${asset.path}`), @@ -56,7 +61,7 @@ export async function installSkills(options: SkillsCommandOptions = {}): Promise ...buildRecallBridgeSummaryLines({ cwd: projectRoot }), - "If a host prefers shell-based fallback helpers, run cam hooks install to generate memory-recall.sh, compatibility wrappers, and recall-bridge.md." + `If a host prefers shell-based fallback helpers, run ${buildResolvedCliCommand("hooks install", { cwd: projectRoot })} to generate memory-recall.sh, compatibility wrappers, and recall-bridge.md.` ].join("\n"); } diff --git a/src/lib/integration/agents-guidance.ts b/src/lib/integration/agents-guidance.ts index c89f5c9..a1b1034 100644 --- a/src/lib/integration/agents-guidance.ts +++ b/src/lib/integration/agents-guidance.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { + buildCodexAgentsGuidance, buildCodexAgentsManagedBlock, CODEX_AGENTS_GUIDANCE_VERSION, parseCodexAgentsGuidanceContents @@ -84,6 +85,7 @@ interface CodexAgentsGuidanceApplyInspection { unsafeManagedBlock: boolean; unsafeReason?: string; hasManagedBlock: boolean; + hasCurrentUnmanagedSnippet: boolean; alreadyCurrent: boolean; managedBlockRange: { startIndex: number; endIndex: number } | null; } @@ -105,6 +107,7 @@ async function inspectCodexAgentsGuidanceApply( lineEnding: "\n", unsafeManagedBlock: false, hasManagedBlock: false, + hasCurrentUnmanagedSnippet: false, alreadyCurrent: false, managedBlockRange: null }; @@ -115,10 +118,19 @@ async function inspectCodexAgentsGuidanceApply( const managedBlock = buildCodexAgentsManagedBlock(parsed.lineEnding, { cwd: projectRoot }); + const guidanceSnippet = buildCodexAgentsGuidance({ + cwd: projectRoot + }).snippet; + const normalizedVisibleText = normalizeManagedBlockForComparison(parsed.visibleText); + const normalizedManagedBlock = normalizeManagedBlockForComparison(managedBlock); + const hasCurrentUnmanagedSnippet = + parsed.managedBlock === null && + normalizedVisibleText.includes(normalizeManagedBlockForComparison(guidanceSnippet)); const alreadyCurrent = parsed.managedBlock !== null && normalizeManagedBlockForComparison(parsed.managedBlock.contents) === - normalizeManagedBlockForComparison(managedBlock); + normalizedManagedBlock || + hasCurrentUnmanagedSnippet; return { targetPath, @@ -130,6 +142,7 @@ async function inspectCodexAgentsGuidanceApply( unsafeManagedBlock: parsed.unsafeManagedBlock, unsafeReason: parsed.unsafeReason, hasManagedBlock: parsed.managedBlock !== null, + hasCurrentUnmanagedSnippet, alreadyCurrent, managedBlockRange: parsed.managedBlock ? { @@ -164,11 +177,11 @@ export async function inspectCodexAgentsGuidanceApplySafety( status: "safe", recommendedAction: !inspection.exists ? "create" - : !inspection.hasManagedBlock + : inspection.alreadyCurrent + ? "unchanged" + : !inspection.hasManagedBlock ? "append" - : inspection.alreadyCurrent - ? "unchanged" - : "replace", + : "replace", notes: inspection.notes }; } @@ -205,28 +218,28 @@ export async function applyCodexAgentsGuidance( }; } - if (!inspection.hasManagedBlock) { - await writeTextFileAtomic( - targetPath, - appendManagedBlock(inspection.currentContents ?? "", inspection.managedBlock, inspection.lineEnding) - ); + if (inspection.alreadyCurrent) { return { host: "codex", projectRoot, targetPath, - action: "updated", + action: "unchanged", managedBlockVersion: CODEX_AGENTS_GUIDANCE_VERSION, createdFile: false, notes }; } - if (inspection.alreadyCurrent) { + if (!inspection.hasManagedBlock) { + await writeTextFileAtomic( + targetPath, + appendManagedBlock(inspection.currentContents ?? "", inspection.managedBlock, inspection.lineEnding) + ); return { host: "codex", projectRoot, targetPath, - action: "unchanged", + action: "updated", managedBlockVersion: CODEX_AGENTS_GUIDANCE_VERSION, createdFile: false, notes diff --git a/src/lib/integration/assets.ts b/src/lib/integration/assets.ts index b7bba85..340d625 100644 --- a/src/lib/integration/assets.ts +++ b/src/lib/integration/assets.ts @@ -1,36 +1,30 @@ import os from "node:os"; import path from "node:path"; import { - ARCHIVE_BOUNDARY, - appendCliCwdFlag, - buildCliSearchCommand, - buildCliDetailsCommand, - buildCliTimelineCommand, + buildDurableMemorySyncGuidance, buildMarkdownAssetVersionComment, - buildRecommendedCliSearchCommand, + buildMcpDoctorGuidance, buildRecommendedSearchPresetGuidance, buildRecommendedMcpSearchInstruction, buildRecommendedRetrievalSummaryLines, - buildPostWorkRecentReviewCommand, - buildPostWorkSyncCommand, buildResolvedCliCommand, + buildResolvedCliDetailsCommand, + buildResolvedCliSearchCommand, + buildResolvedCliTimelineCommand, buildResolvedPostWorkRecentReviewCommand, buildResolvedPostWorkSyncCommand, buildSharedWorkflowDisciplineLines, buildShellAssetVersionComment, CLI_FALLBACK_RECALL_WORKFLOW, - MCP_DOCTOR_GUIDANCE, MCP_FIRST_RECALL_WORKFLOW, MCP_SERVE_GUIDANCE, - MEMORY_AUDIT_BOUNDARY, POST_WORK_SYNC_REVIEW_HELPER, RECOMMENDED_RETRIEVAL_LIMIT, RECOMMENDED_RETRIEVAL_STATE, RETRIEVAL_INTEGRATION_ASSET_VERSION, RETRIEVAL_MCP_DETAILS_TOOL, RETRIEVAL_MCP_SEARCH_TOOL, - RETRIEVAL_MCP_TIMELINE_TOOL, - SESSION_CONTINUITY_BOUNDARY + RETRIEVAL_MCP_TIMELINE_TOOL } from "./retrieval-contract.js"; import { LOCAL_BRIDGE_BUNDLE_NOTE } from "./codex-stack.js"; import { @@ -64,7 +58,7 @@ export interface InstalledIntegrationAssetDescriptor { } interface IntegrationAssetContext { - projectRoot: string; + projectRoot?: string; hookDir: string; skillDir: string; skillSurface: CodexSkillInstallSurface; @@ -82,16 +76,40 @@ interface IntegrationAssetDefinition { renderContents: (context: IntegrationAssetContext) => string; } +function buildDoctorSignatures( + asset: IntegrationAssetDefinition, + contents: string +): string[] { + if (asset.id !== "codex-memory-skill") { + return [...(asset.doctorSignatures ?? [])]; + } + + return [ + "name: codex-auto-memory-recall", + "search_memories", + "post-work-memory-review.sh", + contents.includes('node "') ? 'node "' : "cam sync", + "memory --recent" + ]; +} + function buildIntegrationAssetContext( homeDir = os.homedir(), projectRoot = process.cwd(), - skillSurface: CodexSkillInstallSurface = "runtime" + skillSurface: CodexSkillInstallSurface = "runtime", + installSurface?: IntegrationAssetInstallSurface ): IntegrationAssetContext { - const skillPaths = resolveCodexSkillPaths(projectRoot, homeDir); + const skillPaths = + installSurface === "hooks" + ? null + : resolveCodexSkillPaths(projectRoot, homeDir); return { - projectRoot, + projectRoot: skillSurface === "official-project" ? projectRoot : undefined, hookDir: hookAssetDir(homeDir), - skillDir: resolveCodexSkillInstallDir(skillPaths, skillSurface), + skillDir: + skillPaths === null + ? path.join(homeDir, ".codex", "skills", CODEX_MEMORY_SKILL_NAME) + : resolveCodexSkillInstallDir(skillPaths, skillSurface), skillSurface }; } @@ -103,21 +121,21 @@ function resolveInstallDir( return surface === "hooks" ? context.hookDir : context.skillDir; } -function shellQuoteArg(value: string): string { - return `'${value.replace(/'/g, `'\"'\"'`)}'`; -} - -function buildPinnedProjectRootBlock(projectRoot: string): string { - return `PROJECT_ROOT=${shellQuoteArg(projectRoot)} +function buildProjectRootResolutionBlock(projectRoot?: string): string { + if (projectRoot) { + return `PROJECT_ROOT=${JSON.stringify(projectRoot)} `; + } + + return 'PROJECT_ROOT="${CAM_PROJECT_ROOT:-$PWD}"\n'; } -function buildRecallDispatcherScript(projectRoot: string): string { +function buildRecallDispatcherScript(projectRoot?: string): string { return `#!/bin/sh ${buildShellAssetVersionComment()} # Dispatch recall lookups through a single host-agnostic bridge helper. -${buildPinnedProjectRootBlock(projectRoot)} +${buildProjectRootResolutionBlock(projectRoot)} ACTION="$1" if [ "$#" -gt 0 ]; then shift @@ -178,7 +196,11 @@ exec "$SCRIPT_DIR/memory-recall.sh" ${action} "$@" `; } -function buildRecallBridgeGuideMarkdown(projectRoot: string): string { +function buildRecallBridgeGuideMarkdown(projectRoot?: string): string { + const resolvedSearchCommand = buildResolvedCliSearchCommand( + "\"pnpm\"", + projectRoot ? { cwd: projectRoot } : {} + ); return `# Codex Auto Memory Recall Bridge ${buildMarkdownAssetVersionComment()} @@ -192,13 +214,13 @@ This bundle keeps durable-memory recall host-agnostic. - ${MCP_FIRST_RECALL_WORKFLOW} - ${buildRecommendedMcpSearchInstruction()} - ${MCP_SERVE_GUIDANCE} -- ${MCP_DOCTOR_GUIDANCE} +- ${buildMcpDoctorGuidance(projectRoot ? { cwd: projectRoot } : {})} ## CLI fallback bundle - ${CLI_FALLBACK_RECALL_WORKFLOW} - Search example: \`memory-recall.sh search "pnpm"\` -- CLI equivalent: \`${buildRecommendedCliSearchCommand("\"pnpm\"", { cwd: projectRoot })}\` +- CLI equivalent: \`${resolvedSearchCommand}\` - Timeline example: \`memory-recall.sh timeline "project:active:workflow:prefer-pnpm"\` - Details example: \`memory-recall.sh details "project:active:workflow:prefer-pnpm"\` - Compatibility wrappers \`memory-search.sh\`, \`memory-timeline.sh\`, and \`memory-details.sh\` call the same dispatcher. @@ -206,7 +228,7 @@ This bundle keeps durable-memory recall host-agnostic. ## Boundaries -${buildSharedWorkflowDisciplineLines() +${buildSharedWorkflowDisciplineLines(projectRoot ? { cwd: projectRoot } : {}) .slice(2) .map((line) => `- ${line}`) .join("\n")} @@ -216,20 +238,21 @@ ${buildSharedWorkflowDisciplineLines() 1. Search first. 2. Inspect timeline only for promising refs. 3. Fetch full details only when you still need the full Markdown body. -4. ${buildSharedWorkflowDisciplineLines()[2]} +4. ${buildSharedWorkflowDisciplineLines(projectRoot ? { cwd: projectRoot } : {})[2]} `; } -function buildPostWorkMemoryReviewScript(projectRoot: string): string { +function buildPostWorkMemoryReviewScript(projectRoot?: string): string { return `#!/bin/sh ${buildShellAssetVersionComment()} # Sync the latest durable memory updates, then show the recent audit surface for review. -${buildResolvedPostWorkSyncCommand({ cwd: projectRoot })} "$@" || exit $? -exec ${buildResolvedPostWorkRecentReviewCommand({ cwd: projectRoot })} +${buildProjectRootResolutionBlock(projectRoot)}${buildResolvedPostWorkSyncCommand()} --cwd "$PROJECT_ROOT" "$@" || exit $? +exec ${buildResolvedPostWorkRecentReviewCommand()} --cwd "$PROJECT_ROOT" `; } -function buildCodexSkillMarkdown(projectRoot: string): string { +function buildCodexSkillMarkdown(projectRoot?: string): string { + const commandOptions = projectRoot ? { cwd: projectRoot } : {}; return `--- name: codex-auto-memory-recall description: Search Codex Auto Memory before repeating work. Use when the user asks whether we solved something before, asks for prior repo-specific decisions, or wants past fixes, preferences, or architecture context. @@ -262,28 +285,37 @@ Recommended MCP-first search preset: - \`${buildRecommendedMcpSearchInstruction()}\` -Otherwise fall back to the CLI workflow: +Otherwise fall back to the local bridge bundle first: 1. Search first: - \`${buildRecommendedCliSearchCommand("\"\"", { cwd: projectRoot })}\` + \`memory-recall.sh search ""\` 2. Inspect timeline for promising refs: - \`${buildCliTimelineCommand("\"\"", { cwd: projectRoot })}\` + \`memory-recall.sh timeline ""\` 3. Fetch full details only for the refs that still look relevant: - \`${buildCliDetailsCommand("\"\"", { cwd: projectRoot })}\` + \`memory-recall.sh details ""\` + +If the local bridge bundle is unavailable, fall back to the resolved CLI workflow: + +1. Search first: + \`${buildResolvedCliSearchCommand("\"\"", commandOptions)}\` +2. Inspect timeline for promising refs: + \`${buildResolvedCliTimelineCommand("\"\"", commandOptions)}\` +3. Fetch full details only for the refs that still look relevant: + \`${buildResolvedCliDetailsCommand("\"\"", commandOptions)}\` If you need both active and archived results in one pass instead of active-first fallback: -- \`${buildCliSearchCommand("\"\"", { state: "all", cwd: projectRoot })}\` +- \`${buildResolvedCliSearchCommand("\"\"", projectRoot ? { state: "all", cwd: projectRoot } : { state: "all" })}\` ## Guardrails - Do not jump straight to \`cam recall details\` for every result. - ${LOCAL_BRIDGE_BUNDLE_NOTE} - \`cam mcp serve\` exposes the same retrieval contract over stdio MCP when the host can consume it. -- If you are unsure whether retrieval MCP is wired into the current host, run \`cam mcp doctor\`. -- If a host needs shell-based fallback assets, run \`cam hooks install\` and use the generated recall bridge bundle. -- If available, run \`${POST_WORK_SYNC_REVIEW_HELPER}\` to combine \`${buildPostWorkSyncCommand({ cwd: projectRoot })}\` with \`${buildPostWorkRecentReviewCommand({ cwd: projectRoot })}\`. -- After finishing work that should update durable memory, run \`${buildPostWorkSyncCommand({ cwd: projectRoot })}\` or review \`${buildPostWorkRecentReviewCommand({ cwd: projectRoot })}\`. +- If you are unsure whether retrieval MCP is wired into the current host, run \`${buildResolvedCliCommand("mcp doctor --host codex", commandOptions)}\`. +- If a host needs shell-based fallback assets, run \`${buildResolvedCliCommand("hooks install", commandOptions)}\` and use the generated recall bridge bundle. +- If available, run \`${POST_WORK_SYNC_REVIEW_HELPER}\` to combine \`${buildResolvedPostWorkSyncCommand(commandOptions)}\` with \`${buildResolvedPostWorkRecentReviewCommand(commandOptions)}\`. +- ${buildDurableMemorySyncGuidance(commandOptions)} - Use \`cam memory\` for inspect/audit surfaces, startup payload, and recent sync review. - Use \`cam session\` only for temporary continuity, not durable memory retrieval. - Treat archived memory as historical context that does not participate in default startup recall. @@ -300,12 +332,12 @@ const INTEGRATION_ASSET_DEFINITIONS: readonly IntegrationAssetDefinition[] = [ executable: true, role: "capture-helper", doctorVisible: true, - doctorSignatures: [" sync --cwd "], + doctorSignatures: ['PROJECT_ROOT="${CAM_PROJECT_ROOT:-$PWD}"', ' sync --cwd "$PROJECT_ROOT"'], renderContents: (context) => `#!/bin/sh ${buildShellAssetVersionComment()} # Sync the latest rollout for the current project. -${appendCliCwdFlag("cam sync", context.projectRoot)} "$@" +${buildProjectRootResolutionBlock(context.projectRoot)}${buildResolvedCliCommand("sync")} --cwd "$PROJECT_ROOT" "$@" ` }, { @@ -316,12 +348,12 @@ ${appendCliCwdFlag("cam sync", context.projectRoot)} "$@" executable: true, role: "capture-helper", doctorVisible: true, - doctorSignatures: [" doctor --cwd "], + doctorSignatures: ['PROJECT_ROOT="${CAM_PROJECT_ROOT:-$PWD}"', ' doctor --cwd "$PROJECT_ROOT"'], renderContents: (context) => `#!/bin/sh ${buildShellAssetVersionComment()} # Print diagnostic information at session start. -${appendCliCwdFlag("cam doctor", context.projectRoot)} "$@" +${buildProjectRootResolutionBlock(context.projectRoot)}${buildResolvedCliCommand("doctor")} --cwd "$PROJECT_ROOT" "$@" ` }, { @@ -332,7 +364,7 @@ ${appendCliCwdFlag("cam doctor", context.projectRoot)} "$@" executable: true, role: "capture-helper", doctorVisible: true, - doctorSignatures: [" sync --cwd ", " memory --recent --cwd "], + doctorSignatures: ['PROJECT_ROOT="${CAM_PROJECT_ROOT:-$PWD}"', ' sync --cwd "$PROJECT_ROOT"', ' memory --recent --cwd "$PROJECT_ROOT"'], renderContents: (context) => buildPostWorkMemoryReviewScript(context.projectRoot) }, { @@ -344,7 +376,7 @@ ${appendCliCwdFlag("cam doctor", context.projectRoot)} "$@" role: "recall-helper", doctorVisible: true, doctorSignatures: [ - "PROJECT_ROOT=", + 'PROJECT_ROOT="${CAM_PROJECT_ROOT:-$PWD}"', ' recall search "$@"', ' recall timeline "$@"', ' recall details "$@"' @@ -405,7 +437,13 @@ ${appendCliCwdFlag("cam doctor", context.projectRoot)} "$@" relativePath: "SKILL.md", role: "guidance", doctorVisible: true, - doctorSignatures: ["name: codex-auto-memory-recall", "search_memories"], + doctorSignatures: [ + "name: codex-auto-memory-recall", + "search_memories", + "post-work-memory-review.sh", + "node \"", + "memory --recent" + ], renderContents: (context) => buildCodexSkillMarkdown(context.projectRoot) } ] as const; @@ -467,7 +505,8 @@ export function listIntegrationAssets( const context = buildIntegrationAssetContext( homeDir, options.projectRoot, - options.skillSurface + options.skillSurface, + installSurface ); return INTEGRATION_ASSET_DEFINITIONS.filter( (asset) => installSurface === undefined || asset.installSurface === installSurface @@ -478,7 +517,7 @@ export function listIntegrationAssets( installSurface: asset.installSurface, role: asset.role, expectedVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, - expectedSignatures: [...(asset.doctorSignatures ?? [])], + expectedSignatures: buildDoctorSignatures(asset, asset.renderContents(context)), executableExpected: Boolean(asset.executable), doctorVisible: asset.doctorVisible, relativePath: asset.relativePath, diff --git a/src/lib/integration/codex-stack.ts b/src/lib/integration/codex-stack.ts index 9cb3c16..3b2dafc 100644 --- a/src/lib/integration/codex-stack.ts +++ b/src/lib/integration/codex-stack.ts @@ -1,6 +1,7 @@ import * as path from "node:path"; import { appendCliCwdFlag, + buildDurableMemorySyncGuidance, buildResolvedCliCommand, buildResolvedCliDetailsCommand, buildResolvedCliSearchCommand, @@ -13,8 +14,12 @@ import { buildRecommendedMcpSearchInstruction, buildSharedWorkflowDisciplineLines, buildWorkflowContract, - DURABLE_MEMORY_SYNC_GUIDANCE, - formatRecommendedRetrievalPreset + formatRecommendedRetrievalPreset, + RECALL_FIRST_GUIDANCE, + PROGRESSIVE_DISCLOSURE_GUIDANCE, + MEMORY_AUDIT_BOUNDARY, + SESSION_CONTINUITY_BOUNDARY, + ARCHIVE_BOUNDARY } from "./retrieval-contract.js"; import { RETRIEVAL_MCP_DETAILS_TOOL, @@ -121,7 +126,7 @@ export const CODEX_AGENTS_REQUIRED_SIGNATURES = [ RETRIEVAL_MCP_SEARCH_TOOL, RETRIEVAL_MCP_TIMELINE_TOOL, RETRIEVAL_MCP_DETAILS_TOOL, - "cam recall search", + "memory-recall.sh", "post-work-memory-review.sh", "cam memory", "cam session", @@ -384,13 +389,13 @@ export function buildCodexStackNotes( READ_ONLY_RETRIEVAL_NOTE, LOCAL_BRIDGE_BUNDLE_NOTE, EXPERIMENTAL_CODEX_HOOKS_NOTE, - "Shell-based hook helpers require `cam` to be resolvable on PATH; installed helper files alone do not make the route operational.", + "Shell-based hook helpers are operational only when their embedded launcher still resolves correctly in the current environment.", "Recommended route prefers project-scoped MCP, then local bridge recall helpers, then direct cam recall CLI usage.", `Recommended retrieval preset: ${workflowContract.recommendedPreset}.`, - ...buildSharedWorkflowDisciplineLines().slice(2), + ...buildSharedWorkflowDisciplineLines(options).slice(2), `When the local bridge bundle is installed, prefer \`${workflowContract.postWorkSyncReview.helperScript}\` to combine \`${workflowContract.resolvedPostWorkSyncReview.syncCommand}\` with \`${workflowContract.resolvedPostWorkSyncReview.reviewCommand}\`.`, - "Run `cam mcp print-config --host codex` to inspect the recommended project-scoped MCP wiring together with an AGENTS.md snippet for Codex agents.", - "Run `cam mcp apply-guidance --host codex` to create or update the managed Codex Auto Memory block inside the repository-level AGENTS.md.", + `Run \`${buildResolvedCliCommand("mcp print-config --host codex", options)}\` to inspect the recommended project-scoped MCP wiring together with an AGENTS.md snippet for Codex agents.`, + `Run \`${buildResolvedCliCommand("mcp apply-guidance --host codex", options)}\` to create or update the managed Codex Auto Memory block inside the repository-level AGENTS.md.`, "Codex skill readiness is guidance-only and does not replace executable hook fallback helpers.", "Workflow consistency expects AGENTS guidance, hooks, and skills to stay aligned on the shared search -> timeline -> details contract and recommended preset." ]; @@ -422,22 +427,30 @@ export function buildExperimentalCodexHooksGuidance(): ExperimentalCodexHooksGui export function buildCodexAgentsGuidance( options: { cwd?: string; + launcherOverride?: ReturnType["launcher"]; } = {} ): CodexAgentsGuidance { - const workflowContract = buildWorkflowContract(options); - const sharedLines = buildSharedWorkflowDisciplineLines(); + void options.launcherOverride; + const canonicalCliSearchCommand = buildRecommendedCliSearchCommand(); + const canonicalCliTimelineCommand = buildCliTimelineCommand(); + const canonicalCliDetailsCommand = buildCliDetailsCommand(); + const canonicalSyncCommand = buildPostWorkSyncCommand(); + const canonicalRecentReviewCommand = buildPostWorkRecentReviewCommand(); const snippet = [ "## Codex Auto Memory", "", ``, - `- ${sharedLines[0]}`, - `- ${sharedLines[1]}`, - `- ${workflowContract.routePreference.mcpFirst}`, + `- ${RECALL_FIRST_GUIDANCE}`, + `- ${PROGRESSIVE_DISCLOSURE_GUIDANCE}`, + `- Prefer retrieval MCP when it is already wired in: search_memories -> timeline_memories -> get_memory_details.`, `- ${buildRecommendedMcpSearchInstruction()}`, - `- If the retrieval MCP server is unavailable, fall back to \`${workflowContract.cliFallback.searchCommand}\`, then \`${workflowContract.cliFallback.timelineCommand}\`, then \`${workflowContract.cliFallback.detailsCommand}\`.`, - `- If \`cam\` is unavailable on PATH, prefer the verified launcher fallback \`${workflowContract.resolvedCliFallback.searchCommand}\`, then \`${workflowContract.resolvedCliFallback.timelineCommand}\`, then \`${workflowContract.resolvedCliFallback.detailsCommand}\`.`, - ...sharedLines.slice(2).map((line) => `- ${line}`), - `- When the local bridge bundle is installed, \`${workflowContract.postWorkSyncReview.helperScript}\` combines \`${workflowContract.postWorkSyncReview.syncCommand}\` with \`${workflowContract.postWorkSyncReview.reviewCommand}\`.`, + `- If the retrieval MCP server is unavailable and the local bridge bundle is installed, fall back to \`memory-recall.sh search ""\`, then \`memory-recall.sh timeline ""\`, then \`memory-recall.sh details ""\`.`, + `- If the local bridge bundle is unavailable, fall back to \`${canonicalCliSearchCommand}\`, then \`${canonicalCliTimelineCommand}\`, then \`${canonicalCliDetailsCommand}\`.`, + `- After finishing work that should affect durable memory, run \`${canonicalSyncCommand}\` or review \`${canonicalRecentReviewCommand}\` instead of assuming temporary continuity already updated Markdown memory.`, + `- ${MEMORY_AUDIT_BOUNDARY}`, + `- ${SESSION_CONTINUITY_BOUNDARY}`, + `- ${ARCHIVE_BOUNDARY}`, + `- When the local bridge bundle is installed, \`post-work-memory-review.sh\` combines \`${canonicalSyncCommand}\` with \`${canonicalRecentReviewCommand}\`.`, `- ${LOCAL_BRIDGE_BUNDLE_NOTE}` ].join("\n"); @@ -534,7 +547,7 @@ export function buildCodexIntegrationSubchecks( : { status: "warning", summary: - "Capture helpers are installed, but the current shell could not resolve `cam` on PATH yet." + "Capture helpers are installed, but their embedded launcher is not operational yet." } : assetAvailability.hasCaptureAssets ? { @@ -554,7 +567,7 @@ export function buildCodexIntegrationSubchecks( ? { status: "warning", summary: - "Recall helpers are installed, but the current shell could not resolve `cam` on PATH yet." + "Recall helpers are installed, but their embedded launcher is not operational yet." } : assetAvailability.hasRecallAssets ? { @@ -568,12 +581,14 @@ export function buildCodexIntegrationSubchecks( skill: readiness.skillReady ? { status: "ok", - summary: "Codex skill guidance is installed for MCP-first, CLI-fallback retrieval." + summary: + "The preferred Codex skill surface is installed and aligned as guidance for the shared MCP-first retrieval workflow." } : assetAvailability.hasSkillAssets ? { status: "warning", - summary: "Skill assets exist, but the installed guidance is stale." + summary: + "Skill assets exist, but the preferred skill surface is missing, stale, or not aligned yet." } : { status: "missing", @@ -583,7 +598,7 @@ export function buildCodexIntegrationSubchecks( ? { status: "ok", summary: - "AGENTS guidance, hooks, and skills agree on the shared search -> timeline -> details workflow and preset." + "AGENTS guidance, hooks, and the preferred skill surface agree on the shared search -> timeline -> details workflow and preset." } : assetAvailability.hasWorkflowAssets ? { @@ -601,7 +616,7 @@ export function buildCodexIntegrationSubchecks( export function buildCodexRouteSummary(route: CodexIntegrationRoute): string { switch (route) { case "mcp": - return "Project-scoped retrieval MCP is ready and should be the default route."; + return "Project-scoped retrieval MCP is the preferred route; check the current operational route to see what is runnable in this environment right now."; case "hooks-fallback": return "Use the hook recall bundle for now; MCP is not fully operational yet."; case "cli-direct": @@ -621,6 +636,9 @@ export function buildCodexIntegrationNextSteps( } = {} ): string[] { const route = resolveCodexIntegrationRoute(readiness); + const workflowContract = buildWorkflowContract({ + cwd: options.projectRoot + }); const skillInstallCommand = appendProjectRootFlag( options.skillInstallCommand ?? "cam skills install", options.projectRoot @@ -638,6 +656,9 @@ export function buildCodexIntegrationNextSteps( const hooksInstallCommand = buildResolvedCliCommand("hooks install", { cwd: options.projectRoot }); + const directCliSearchCommand = readiness.camCommandAvailable + ? buildRecommendedCliSearchCommand("\"\"", { cwd: options.projectRoot }) + : buildResolvedCliSearchCommand("\"\"", { cwd: options.projectRoot }); const nextSteps: string[] = []; if ( @@ -648,8 +669,10 @@ export function buildCodexIntegrationNextSteps( ) { return [ `Run \`${integrationsInstallCommand}\` to install the recommended Codex integration stack in one step.`, - `Until the stack is installed, use \`${buildRecommendedCliSearchCommand("\"\"", { cwd: options.projectRoot })}\` directly.`, - `If \`cam\` is unavailable on PATH, use \`${buildResolvedCliSearchCommand("\"\"", { cwd: options.projectRoot })}\` as the verified fallback.`, + `Until the stack is installed, use \`${directCliSearchCommand}\` directly.`, + workflowContract.launcher.verified + ? `If \`cam\` is unavailable on PATH, use \`${buildResolvedCliSearchCommand("\"\"", { cwd: options.projectRoot })}\` as the verified fallback.` + : `If \`cam\` is unavailable on PATH, use \`${buildResolvedCliSearchCommand("\"\"", { cwd: options.projectRoot })}\` as the unverified direct command until the launcher becomes resolvable.`, `Run \`${mcpPrintConfigCommand}\` to print the recommended project-scoped MCP wiring and AGENTS.md snippet.` ]; } @@ -695,18 +718,20 @@ export function buildCodexIntegrationNextSteps( if (route === "mcp") { nextSteps.push( - `Prefer retrieval MCP with the recommended preset \`${formatRecommendedRetrievalPreset()}\`; keep \`cam recall\` as a direct fallback.` + `Prefer retrieval MCP with the recommended preset \`${formatRecommendedRetrievalPreset()}\`; keep the local bridge bundle as the first fallback and \`cam recall\` as the direct fallback.` ); } else if (route === "hooks-fallback") { nextSteps.push( - "Use `memory-recall.sh search|timeline|details` for the current local bridge fallback path while MCP is being finished." + `Use \`${workflowContract.hookFallback.searchCommand}\`, then \`${workflowContract.hookFallback.timelineCommand}\`, then \`${workflowContract.hookFallback.detailsCommand}\` for the current local bridge fallback path while MCP is being finished.` ); } else { nextSteps.push( - `Use \`${buildRecommendedCliSearchCommand("\"\"", { cwd: options.projectRoot })}\` directly until a richer integration route becomes ready.` + `Use \`${directCliSearchCommand}\` directly until a richer integration route becomes ready.` ); nextSteps.push( - `If \`cam\` is unavailable on PATH, use \`${buildResolvedCliSearchCommand("\"\"", { cwd: options.projectRoot })}\` as the verified fallback.` + workflowContract.launcher.verified + ? `If \`cam\` is unavailable on PATH, use \`${buildResolvedCliSearchCommand("\"\"", { cwd: options.projectRoot })}\` as the verified fallback.` + : `If \`cam\` is unavailable on PATH, use \`${buildResolvedCliSearchCommand("\"\"", { cwd: options.projectRoot })}\` as the unverified direct command until the launcher becomes resolvable.` ); } @@ -719,7 +744,7 @@ export function buildCodexIntegrationNextSteps( nextSteps.push( `Run \`${mcpPrintConfigCommand}\` to print the recommended project-scoped MCP wiring and AGENTS.md snippet.` ); - nextSteps.push(DURABLE_MEMORY_SYNC_GUIDANCE); + nextSteps.push(buildDurableMemorySyncGuidance({ cwd: options.projectRoot })); return [...new Set(nextSteps)]; } diff --git a/src/lib/integration/command-path.ts b/src/lib/integration/command-path.ts new file mode 100644 index 0000000..2a50a25 --- /dev/null +++ b/src/lib/integration/command-path.ts @@ -0,0 +1,49 @@ +import fs from "node:fs"; +import path from "node:path"; + +function isExecutableMode(mode: number): boolean { + return (mode & 0o111) !== 0; +} + +export function isCommandAvailableInPath( + command: string, + pathValue = process.env.PATH ?? "" +): boolean { + if (!pathValue.trim()) { + return false; + } + + const entries = pathValue.split(path.delimiter).filter(Boolean); + const extensions = + process.platform === "win32" + ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") + .split(";") + .filter(Boolean) + : [""]; + + for (const entry of entries) { + for (const extension of extensions) { + const candidate = path.join( + entry, + process.platform === "win32" ? `${command}${extension}` : command + ); + if (!fs.existsSync(candidate)) { + continue; + } + + if (process.platform === "win32") { + return true; + } + + try { + if (isExecutableMode(fs.statSync(candidate).mode)) { + return true; + } + } catch { + continue; + } + } + } + + return false; +} diff --git a/src/lib/integration/install-assets.ts b/src/lib/integration/install-assets.ts index 2d2792f..bb88be8 100644 --- a/src/lib/integration/install-assets.ts +++ b/src/lib/integration/install-assets.ts @@ -14,7 +14,7 @@ import { type CodexSkillInstallSurface, formatCodexSkillInstallSurface } from "./skills-paths.js"; -import { ensureDir, fileExists, readTextFile, writeTextFile } from "../util/fs.js"; +import { ensureDir, fileExists, readTextFile, writeTextFileAtomic } from "../util/fs.js"; export type IntegrationAssetInstallAction = "created" | "updated" | "unchanged"; @@ -103,7 +103,7 @@ export async function installIntegrationAssets( if (action !== "unchanged") { await ensureDir(path.dirname(asset.path)); - await writeTextFile(asset.path, asset.contents); + await writeTextFileAtomic(asset.path, asset.contents); if (asset.executableExpected) { await fs.chmod(asset.path, 0o755); } diff --git a/src/lib/integration/mcp-config.ts b/src/lib/integration/mcp-config.ts index 3c65220..836167f 100644 --- a/src/lib/integration/mcp-config.ts +++ b/src/lib/integration/mcp-config.ts @@ -53,14 +53,12 @@ export function buildMcpHostConfigSnippet(host: McpHost, projectRoot: string): M snippetFormat: definition.snippetFormat, snippet: buildMcpHostSnippet(host, projectRoot), notes: [...definition.notes], - workflowContract: buildWorkflowContract({ - cwd: projectRoot - }), ...(host === "codex" ? { - agentsGuidance: buildCodexAgentsGuidance({ + workflowContract: buildWorkflowContract({ cwd: projectRoot }), + agentsGuidance: buildCodexAgentsGuidance({ cwd: projectRoot }), experimentalHooks: buildExperimentalCodexHooksGuidance() } : {}) diff --git a/src/lib/integration/mcp-doctor.ts b/src/lib/integration/mcp-doctor.ts index a779bda..e13ae75 100644 --- a/src/lib/integration/mcp-doctor.ts +++ b/src/lib/integration/mcp-doctor.ts @@ -1,7 +1,10 @@ import fs from "node:fs/promises"; +import fssync from "node:fs"; +import os from "node:os"; import path from "node:path"; import * as toml from "smol-toml"; import { + listIntegrationAssets, listDoctorVisibleIntegrationAssets } from "./assets.js"; import { @@ -19,12 +22,13 @@ import { } from "./codex-stack.js"; import { inspectCodexAgentsGuidanceApplySafety } from "./agents-guidance.js"; import { - appendCliCwdFlag, + buildResolvedCliCommand, buildWorkflowContract, detectIntegrationAssetVersion, formatRecommendedRetrievalPreset, RETRIEVAL_INTEGRATION_ASSET_VERSION } from "./retrieval-contract.js"; +import { isCommandAvailableInPath } from "./command-path.js"; import { getMcpHostDefinition, inspectCanonicalMcpServerConfig, @@ -33,12 +37,14 @@ import { normalizeMcpDoctorHostSelection, resolveMcpHostProjectConfigPath, resolveMcpHostUserConfigPath, + SUPPORTED_MCP_DOCTOR_HOST_SELECTIONS, + SUPPORTED_MCP_HOSTS, + SUPPORTED_MCP_INSTALL_HOSTS, type McpDoctorHostSelection, type McpHost, type McpHostPinningMode } from "./mcp-hosts.js"; import { - buildCodexSkillInstallCommand, resolveCodexSkillPaths, type CodexSkillInstallSurface, type CodexSkillPathResolution @@ -47,11 +53,13 @@ import { fileExists, readTextFile } from "../util/fs.js"; import { buildRuntimeContext } from "../runtime/runtime-context.js"; import { resolveMcpProjectRoot } from "./mcp-config.js"; import type { RetrievalSidecarCheck } from "../domain/memory-store.js"; +import type { MemoryLayoutDiagnostic, TopicFileDiagnostic } from "../types.js"; type McpDoctorStatus = "ok" | "warning" | "missing" | "manual"; type McpDoctorConfigInspection = "ok" | "missing" | "parse-error" | "shape-mismatch"; type McpDoctorConfigScope = "project" | "global"; type McpDoctorRecommendedScope = "project" | "manual"; +type McpDoctorWritableHost = Extract; type McpDoctorConfigScopeSummary = | "manual-only" | "project-ready" @@ -61,6 +69,18 @@ type McpDoctorConfigScopeSummary = | "project-missing-global-alternate" | "project-missing-global-invalid"; +interface McpDoctorCommandSurface { + install: boolean; + serve: true; + printConfig: true; + applyGuidance: boolean; + doctor: true; + installHosts: readonly McpDoctorWritableHost[]; + applyGuidanceHosts: readonly McpDoctorWritableHost[]; + printConfigHosts: readonly McpHost[]; + doctorHostSelections: readonly McpDoctorHostSelection[]; +} + interface McpDoctorConfigCheck { scope: McpDoctorConfigScope; path: string; @@ -116,6 +136,12 @@ interface McpDoctorAssetCheck { role: "capture-helper" | "recall-helper" | "guidance"; executableExpected: boolean; executableOk: boolean | null; + launcher?: { + resolution: "cam-path" | "node-dist" | "shell-wrapper" | "none" | "mixed"; + operational: boolean; + commandCount: number; + missingPaths: string[]; + }; } function hasExpectedAssetSignatures(contents: string, expectedSignatures: string[]): boolean { @@ -129,6 +155,15 @@ interface SkillSurfaceInspection { ready: boolean; } +interface SkillSurfaceState { + installed: boolean; + discoverable: boolean; + listed: boolean; + executable: boolean; + matchesCanonical: boolean; + preferred: boolean; +} + async function inspectSkillSurfaceFile( skillDir: string, canonicalContents: string @@ -180,8 +215,10 @@ interface McpDoctorFallbackAssets { officialProjectSkillReady: boolean; anySkillSurfaceInstalled: boolean; anySkillSurfaceReady: boolean; + preferredSkillSurfaceReady: boolean; installedSkillSurfaces: CodexSkillInstallSurface[]; readySkillSurfaces: CodexSkillInstallSurface[]; + skillSurfaces: Record; skillPathDrift: boolean; postSessionSyncInstalled: boolean; postWorkReviewInstalled: boolean; @@ -195,6 +232,74 @@ interface McpDoctorFallbackAssets { assets: McpDoctorAssetCheck[]; } +function inspectExecutableAssetLauncher( + contents: string, + camCommandAvailable: boolean +): McpDoctorAssetCheck["launcher"] { + if (contents.includes('exec "$SCRIPT_DIR/memory-recall.sh"')) { + return { + resolution: "shell-wrapper", + operational: true, + commandCount: 1, + missingPaths: [] + }; + } + + const camMatches = contents.match(/(?:^|\s)(?:exec\s+)?cam(?:\s|$)/gmu) ?? []; + const nodeMatches = [...contents.matchAll(/(?:^|\s)(?:exec\s+)?node\s+"([^"]+cli\.js)"/gmu)]; + const nodePaths = nodeMatches.map((match) => match[1]).filter((value): value is string => Boolean(value)); + const missingPaths = nodePaths.filter((launcherPath) => !fssync.existsSync(launcherPath)); + const commandCount = camMatches.length + nodePaths.length; + + if (camMatches.length > 0 && nodePaths.length > 0) { + return { + resolution: "mixed", + operational: camCommandAvailable && missingPaths.length === 0, + commandCount, + missingPaths + }; + } + + if (nodePaths.length > 0) { + return { + resolution: "node-dist", + operational: missingPaths.length === 0, + commandCount, + missingPaths + }; + } + + if (camMatches.length > 0) { + return { + resolution: "cam-path", + operational: camCommandAvailable, + commandCount, + missingPaths: [] + }; + } + + return { + resolution: "none", + operational: true, + commandCount: 0, + missingPaths: [] + }; +} + +function isAssetOperational( + assets: McpDoctorAssetCheck[], + ids: string[] +): boolean { + return ids.every((id) => { + const asset = assets.find((candidate) => candidate.id === id); + if (!asset || asset.status !== "ok") { + return false; + } + + return asset.launcher?.operational ?? true; + }); +} + interface McpDoctorRetrievalSidecarReport { status: "ok" | "warning"; summary: string; @@ -202,29 +307,44 @@ interface McpDoctorRetrievalSidecarReport { checks: RetrievalSidecarCheck[]; } +interface McpDoctorTopicDiagnosticsReport { + status: "ok" | "warning"; + summary: string; + diagnostics: TopicFileDiagnostic[]; +} + +interface McpDoctorLayoutDiagnosticsReport { + status: "ok" | "warning"; + summary: string; + diagnostics: MemoryLayoutDiagnostic[]; +} + export interface McpDoctorReport { cwd: string; projectRoot: string; cwdWithinProjectRoot: boolean; serverName: string; readOnlyRetrieval: true; - commandSurface: { - install: true; - serve: true; - printConfig: true; - applyGuidance: true; - doctor: true; - }; - agentsGuidance: CodexAgentsGuidanceInspection; - applySafety: Awaited>; + commandSurface: McpDoctorCommandSurface; + agentsGuidance: CodexAgentsGuidanceInspection | null; + applySafety: Awaited> | null; fallbackAssets: McpDoctorFallbackAssets; retrievalSidecar: McpDoctorRetrievalSidecarReport; + topicDiagnostics: McpDoctorTopicDiagnosticsReport; + layoutDiagnostics: McpDoctorLayoutDiagnosticsReport; workflowContract: ReturnType; - experimentalHooks: ExperimentalCodexHooksGuidance; + experimentalHooks: ExperimentalCodexHooksGuidance | null; hosts: McpDoctorHostReport[]; codexStack: { status: McpDoctorStatus; recommendedRoute: CodexIntegrationRoute; + currentlyOperationalRoute: CodexIntegrationRoute; + routeKind: "preferred-mcp" | "fallback-hooks" | "fallback-cli"; + routeEvidence: string[]; + shellDependencyLevel: "required" | "partial"; + hostMutationRequired: boolean; + preferredRouteBlockers: string[]; + currentOperationalBlockers: string[]; preset: string; assetVersion: string; mcpReady: boolean; @@ -238,7 +358,7 @@ export interface McpDoctorReport { workflowAssetsConsistent: boolean; workflowConsistent: boolean; notes: string[]; - }; + } | null; } function isExecutableMode(mode: number): boolean { @@ -262,43 +382,6 @@ async function normalizeComparablePath(input: string): Promise { } } -async function isCommandAvailableInPath(command: string): Promise { - const pathValue = process.env.PATH ?? ""; - if (!pathValue.trim()) { - return false; - } - - const entries = pathValue.split(path.delimiter).filter(Boolean); - const extensions = - process.platform === "win32" - ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") - .split(";") - .filter(Boolean) - : [""]; - - for (const entry of entries) { - for (const extension of extensions) { - const candidate = path.join( - entry, - process.platform === "win32" ? `${command}${extension}` : command - ); - if (!(await fileExists(candidate))) { - continue; - } - - if (process.platform === "win32") { - return true; - } - - if (isExecutableMode((await fs.stat(candidate)).mode)) { - return true; - } - } - } - - return false; -} - function formatPinning(pinning: McpHostPinningMode): string { switch (pinning) { case "cwd-field": @@ -310,6 +393,27 @@ function formatPinning(pinning: McpHostPinningMode): string { } } +function selectionIncludesCodex(selection: McpDoctorHostSelection): boolean { + return selection === "all" || selection === "codex"; +} + +function buildCommandSurface( + selection: McpDoctorHostSelection +): McpDoctorCommandSurface { + const codexSelected = selectionIncludesCodex(selection); + return { + install: codexSelected, + serve: true, + printConfig: true, + applyGuidance: codexSelected, + doctor: true, + installHosts: [...SUPPORTED_MCP_INSTALL_HOSTS], + applyGuidanceHosts: [...SUPPORTED_MCP_INSTALL_HOSTS], + printConfigHosts: [...SUPPORTED_MCP_HOSTS], + doctorHostSelections: [...SUPPORTED_MCP_DOCTOR_HOST_SELECTIONS] + }; +} + async function inspectHostConfig( host: McpHost, projectRoot: string, @@ -451,6 +555,7 @@ function buildConfigIssue( } function summarizeHostReport( + host: McpHost, inspectionResult: McpDoctorConfigInspectionResult, alternateWiring: McpDoctorAlternateWiring ): Pick { @@ -530,6 +635,16 @@ function summarizeHostReport( }; } + if (host !== "codex") { + return { + status: "manual", + configScopeSummary: "project-ready", + recommendedScope: "project", + summary: + "The host config snippet looks present and pinned to this repository, but this host remains manual-only and requires host-side verification." + }; + } + return { status: "ok", configScopeSummary: "project-ready", @@ -571,7 +686,7 @@ async function inspectHost(host: McpHost, projectRoot: string): Promise { + const homeDir = os.homedir(); const descriptors = listDoctorVisibleIntegrationAssets(undefined, { projectRoot }); @@ -629,6 +746,7 @@ async function inspectFallbackAssets( asset.detectedVersion = detectIntegrationAssetVersion(raw); if (asset.executableExpected) { asset.executableOk = isExecutableMode((await fs.stat(asset.path)).mode); + asset.launcher = inspectExecutableAssetLauncher(raw, options.camCommandAvailable ?? false); } asset.status = asset.detectedVersion === asset.expectedVersion && @@ -636,7 +754,8 @@ async function inspectFallbackAssets( raw, descriptors.find((descriptor) => descriptor.name === asset.name)?.expectedSignatures ?? [] ) && - (asset.executableExpected ? asset.executableOk === true : true) + (asset.executableExpected ? asset.executableOk === true : true) && + (asset.launcher?.missingPaths.length ?? 0) === 0 ? "ok" : "stale"; } @@ -661,6 +780,16 @@ async function inspectFallbackAssets( .every((asset) => assets.find((candidate) => candidate.name === asset.name)?.installed === true); const canonicalSkillContents = descriptors.find((asset) => asset.installSurface === "skills")?.contents ?? ""; + const officialUserCanonicalSkillContents = + listIntegrationAssets(homeDir, "skills", { + projectRoot, + skillSurface: "official-user" + }).find((asset) => asset.installSurface === "skills")?.contents ?? canonicalSkillContents; + const officialProjectCanonicalSkillContents = + listIntegrationAssets(homeDir, "skills", { + projectRoot, + skillSurface: "official-project" + }).find((asset) => asset.installSurface === "skills")?.contents ?? canonicalSkillContents; const runtimeSkillReady = descriptors .filter((asset) => asset.installSurface === "skills") @@ -673,11 +802,11 @@ async function inspectFallbackAssets( runtimeSkillContents !== null && runtimeSkillContents === canonicalSkillContents; const officialUserSkillInspection = await inspectSkillSurfaceFile( officialUserSkillDir, - canonicalSkillContents + officialUserCanonicalSkillContents ); const officialProjectSkillInspection = await inspectSkillSurfaceFile( officialProjectSkillDir, - canonicalSkillContents + officialProjectCanonicalSkillContents ); const installedSkillSurfaces: CodexSkillInstallSurface[] = []; if (runtimeSkillInstalled) { @@ -701,6 +830,33 @@ async function inspectFallbackAssets( } const anySkillSurfaceInstalled = installedSkillSurfaces.length > 0; const anySkillSurfaceReady = readySkillSurfaces.length > 0; + const preferredSkillSurfaceReady = readySkillSurfaces.includes(skillPaths.preferredInstallSurface); + const skillSurfaces: Record = { + runtime: { + installed: runtimeSkillInstalled, + discoverable: runtimeSkillInstalled, + listed: runtimeSkillPresent, + executable: runtimeSkillReady, + matchesCanonical: runtimeSkillMatchesCanonical, + preferred: skillPaths.preferredInstallSurface === "runtime" + }, + "official-user": { + installed: officialUserSkillInspection.installed, + discoverable: officialUserSkillInspection.installed, + listed: officialUserSkillInspection.installed, + executable: false, + matchesCanonical: officialUserSkillInspection.matchesCanonical, + preferred: skillPaths.preferredInstallSurface === "official-user" + }, + "official-project": { + installed: officialProjectSkillInspection.installed, + discoverable: officialProjectSkillInspection.installed, + listed: officialProjectSkillInspection.installed, + executable: false, + matchesCanonical: officialProjectSkillInspection.matchesCanonical, + preferred: skillPaths.preferredInstallSurface === "official-project" + } + }; return { hooksDir: hooksDir ? path.dirname(hooksDir) : "", @@ -709,12 +865,12 @@ async function inspectFallbackAssets( runtimeAssetDir: skillDir, runtimeSource: skillPaths.runtimeSource, preferredInstallSurface: skillPaths.preferredInstallSurface, - recommendedSkillInstallCommand: options.explicitCwd - ? appendCliCwdFlag( - buildCodexSkillInstallCommand(skillPaths.preferredInstallSurface), - projectRoot - ) - : buildCodexSkillInstallCommand(skillPaths.preferredInstallSurface), + recommendedSkillInstallCommand: buildResolvedCliCommand( + `skills install --surface ${skillPaths.preferredInstallSurface}`, + { + cwd: options.explicitCwd ? projectRoot : undefined + } + ), runtimeSkillPresent, officialUserSkillDir, officialProjectSkillDir, @@ -733,8 +889,10 @@ async function inspectFallbackAssets( officialProjectSkillReady: officialProjectSkillInspection.ready, anySkillSurfaceInstalled, anySkillSurfaceReady, + preferredSkillSurfaceReady, installedSkillSurfaces, readySkillSurfaces, + skillSurfaces, skillPathDrift: runtimeSkillDir.length > 0 && path.resolve(runtimeSkillDir) !== path.resolve(officialUserSkillDir), @@ -746,7 +904,7 @@ async function inspectFallbackAssets( skillInstalled: anySkillSurfaceReady, shellFallbackAvailable: hookHelpersInstalled, guidanceAvailable: anySkillSurfaceReady, - fallbackAvailable: hookHelpersInstalled || anySkillSurfaceReady, + fallbackAvailable: hookHelpersInstalled, assets }; } @@ -758,9 +916,9 @@ function buildRetrievalSidecarReport( } = {} ): McpDoctorRetrievalSidecarReport { const degradedChecks = checks.filter((check) => check.status !== "ok"); - const repairCommand = appendCliCwdFlag( + const repairCommand = buildResolvedCliCommand( [ - "cam memory reindex", + "memory reindex", `--scope ${ new Set(degradedChecks.map((check) => check.scope)).size === 1 ? degradedChecks[0]?.scope ?? "all" @@ -772,7 +930,9 @@ function buildRetrievalSidecarReport( : "all" }` ].join(" "), - options.cwd + { + cwd: options.cwd + } ); if (degradedChecks.length === 0) { return { @@ -792,6 +952,43 @@ function buildRetrievalSidecarReport( }; } +function buildTopicDiagnosticsReport( + diagnostics: TopicFileDiagnostic[] +): McpDoctorTopicDiagnosticsReport { + const unsafeDiagnostics = diagnostics.filter((entry) => !entry.safeToRewrite); + if (unsafeDiagnostics.length === 0) { + return { + status: "ok", + summary: "No unsafe topic files were detected.", + diagnostics: [] + }; + } + + return { + status: "warning", + summary: `${unsafeDiagnostics.length} unsafe topic file(s) were detected in the Markdown canonical store.`, + diagnostics: unsafeDiagnostics + }; +} + +function buildLayoutDiagnosticsReport( + diagnostics: MemoryLayoutDiagnostic[] +): McpDoctorLayoutDiagnosticsReport { + if (diagnostics.length === 0) { + return { + status: "ok", + summary: "No canonical layout anomalies were detected.", + diagnostics: [] + }; + } + + return { + status: "warning", + summary: `${diagnostics.length} canonical layout anomaly/anomalies were detected in the Markdown memory store.`, + diagnostics + }; +} + function isAssetReady( assets: McpDoctorAssetCheck[], ids: string[] @@ -814,20 +1011,27 @@ function buildCodexStackReport( fallbackAssets.assets, [...CODEX_HOOK_CAPTURE_ASSET_IDS] ); - const hookCaptureOperationalReady = hookCaptureReady && camCommandAvailable; + const hookCaptureOperationalReady = isAssetOperational( + fallbackAssets.assets, + [...CODEX_HOOK_CAPTURE_ASSET_IDS] + ); const hookRecallReady = isAssetReady( fallbackAssets.assets, [...CODEX_HOOK_RECALL_ASSET_IDS] ); - const hookRecallOperationalReady = hookRecallReady && camCommandAvailable; - const skillReady = fallbackAssets.readySkillSurfaces.length > 0; + const hookRecallOperationalReady = isAssetOperational( + fallbackAssets.assets, + [...CODEX_HOOK_RECALL_ASSET_IDS] + ); + const skillReady = fallbackAssets.preferredSkillSurfaceReady; + const workflowHelperAssetsReady = isAssetReady( + fallbackAssets.assets, + CODEX_WORKFLOW_CONSISTENCY_ASSET_IDS.filter((id) => id !== "codex-memory-skill") + ); const workflowAssetsConsistent = - isAssetReady( - fallbackAssets.assets, - [...CODEX_WORKFLOW_CONSISTENCY_ASSET_IDS] - ) && + workflowHelperAssetsReady && fallbackAssets.postWorkReviewInstalled && - skillReady; + fallbackAssets.preferredSkillSurfaceReady; const workflowConsistent = workflowAssetsConsistent && agentsGuidance.status === "ok"; @@ -850,21 +1054,62 @@ function buildCodexStackReport( } if (hookRecallReady && !hookRecallOperationalReady) { notes.push( - "Hook recall helpers are installed, but the current shell could not resolve `cam` on PATH, so the local bridge fallback is not operational yet." + "Hook recall helpers are installed, but their embedded launcher is not operational in the current environment yet." ); } if (hookCaptureReady && !hookCaptureOperationalReady) { notes.push( - "Hook capture helpers are installed, but the current shell could not resolve `cam` on PATH, so the local bridge capture path is not operational yet." + "Hook capture helpers are installed, but their embedded launcher is not operational in the current environment yet." + ); + } + if (!fallbackAssets.preferredSkillSurfaceReady && fallbackAssets.anySkillSurfaceReady) { + notes.push( + `A non-preferred skill surface is ready, but the preferred ${fallbackAssets.preferredInstallSurface} skill surface is not aligned yet.` ); } + const currentlyOperationalRoute = resolveCodexIntegrationRoute({ + mcpOperationalReady, + hookRecallOperationalReady + }); + const routeKind = + currentlyOperationalRoute === "mcp" + ? "preferred-mcp" + : currentlyOperationalRoute === "hooks-fallback" + ? "fallback-hooks" + : "fallback-cli"; + const routeEvidence = [ + ...(mcpReady ? ["mcp-config-present"] : []), + ...(camCommandAvailable ? ["cam-command-available"] : []), + ...(hookRecallOperationalReady ? ["hook-recall-operational"] : []), + ...(hookCaptureOperationalReady ? ["hook-capture-operational"] : []), + ...(buildWorkflowContract(options).launcher.verified ? ["resolved-cli-launcher-verified"] : []) + ]; + const preferredRouteBlockers = [ + ...(mcpReady && !camCommandAvailable ? ["cam-command-unavailable-for-mcp"] : []), + ...(!mcpReady ? ["project-scoped-mcp-not-installed"] : []) + ]; + const currentOperationalBlockers = [ + ...(hookRecallReady && !hookRecallOperationalReady + ? ["hook-recall-launcher-unavailable"] + : []), + ...(!mcpOperationalReady && + !hookRecallOperationalReady && + !buildWorkflowContract(options).launcher.verified + ? ["resolved-cli-launcher-unverified"] + : []) + ]; + return { status, - recommendedRoute: resolveCodexIntegrationRoute({ - mcpOperationalReady, - hookRecallOperationalReady - }), + recommendedRoute: "mcp", + currentlyOperationalRoute, + routeKind, + routeEvidence, + shellDependencyLevel: "required", + hostMutationRequired: !mcpReady, + preferredRouteBlockers, + currentOperationalBlockers, preset: formatRecommendedRetrievalPreset(), assetVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, mcpReady, @@ -890,11 +1135,14 @@ export async function inspectMcpDoctor(options: { const projectRoot = resolveMcpProjectRoot(cwd); const agentsGuidancePath = path.join(projectRoot, "AGENTS.md"); const hostSelection: McpDoctorHostSelection = normalizeMcpDoctorHostSelection(options.host); + const codexSelected = selectionIncludesCodex(hostSelection); const hosts = await Promise.all( listMcpHosts(hostSelection).map((host) => inspectHost(host, projectRoot)) ); + const camCommandAvailable = isCommandAvailableInPath("cam"); const fallbackAssets = await inspectFallbackAssets(projectRoot, { - explicitCwd: options.explicitCwd ?? false + explicitCwd: options.explicitCwd ?? false, + camCommandAvailable }); const runtime = await buildRuntimeContext(cwd, {}, { ensureMemoryLayout: false }); const retrievalSidecar = buildRetrievalSidecarReport( @@ -903,13 +1151,30 @@ export async function inspectMcpDoctor(options: { cwd: options.explicitCwd ? projectRoot : undefined } ); - const camCommandAvailable = await isCommandAvailableInPath("cam"); - const codexHost = hosts.find((host) => host.host === "codex") ?? (await inspectHost("codex", projectRoot)); - const agentsGuidance = inspectCodexAgentsGuidance( - agentsGuidancePath, - (await fileExists(agentsGuidancePath)) ? await readTextFile(agentsGuidancePath) : null + const topicDiagnostics = buildTopicDiagnosticsReport( + await runtime.syncService.memoryStore.inspectTopicFiles({ + scope: "all", + state: "all" + }) + ); + const layoutDiagnostics = buildLayoutDiagnosticsReport( + await runtime.syncService.memoryStore.inspectLayoutDiagnostics({ + scope: "all", + state: "all" + }) ); - const applySafety = await inspectCodexAgentsGuidanceApplySafety(projectRoot); + const codexHost = codexSelected + ? hosts.find((host) => host.host === "codex") ?? (await inspectHost("codex", projectRoot)) + : null; + const agentsGuidance = codexSelected + ? inspectCodexAgentsGuidance( + agentsGuidancePath, + (await fileExists(agentsGuidancePath)) ? await readTextFile(agentsGuidancePath) : null + ) + : null; + const applySafety = codexSelected + ? await inspectCodexAgentsGuidanceApplySafety(projectRoot) + : null; const workflowContract = buildWorkflowContract({ cwd: options.explicitCwd ? projectRoot : undefined }); @@ -920,33 +1185,39 @@ export async function inspectMcpDoctor(options: { cwdWithinProjectRoot: isPathWithin(projectRoot, cwd), serverName: MEMORY_RETRIEVAL_MCP_SERVER_NAME, readOnlyRetrieval: true, - commandSurface: { - install: true, - serve: true, - printConfig: true, - applyGuidance: true, - doctor: true - }, + commandSurface: buildCommandSurface(hostSelection), agentsGuidance, applySafety, fallbackAssets, retrievalSidecar, + topicDiagnostics, + layoutDiagnostics, workflowContract, - experimentalHooks: buildExperimentalCodexHooksGuidance(), + experimentalHooks: codexSelected ? buildExperimentalCodexHooksGuidance() : null, hosts, - codexStack: buildCodexStackReport( - codexHost, - fallbackAssets, - camCommandAvailable, - agentsGuidance, - { - cwd: options.explicitCwd ? projectRoot : undefined - } - ) + codexStack: + codexSelected && codexHost && agentsGuidance + ? buildCodexStackReport( + codexHost, + fallbackAssets, + camCommandAvailable, + agentsGuidance, + { + cwd: options.explicitCwd ? projectRoot : undefined + } + ) + : null }; } export function formatMcpDoctorReport(report: McpDoctorReport): string { + const commandSurface = [ + report.commandSurface.install ? "cam mcp install" : null, + "cam mcp serve", + "cam mcp print-config", + report.commandSurface.applyGuidance ? "cam mcp apply-guidance" : null, + "cam mcp doctor" + ].filter((command): command is string => Boolean(command)); const lines = [ "Codex Auto Memory MCP Doctor", `Working directory: ${report.cwd}`, @@ -954,7 +1225,7 @@ export function formatMcpDoctorReport(report: McpDoctorReport): string { `Inside project root: ${report.cwdWithinProjectRoot ? "yes" : "no"}`, `Server name: ${report.serverName}`, "Retrieval plane: read-only", - "Command surface: cam mcp install, cam mcp serve, cam mcp print-config, cam mcp apply-guidance, cam mcp doctor", + `Command surface: ${commandSurface.join(", ")}`, "", "Host checks:" ]; @@ -1009,16 +1280,6 @@ export function formatMcpDoctorReport(report: McpDoctorReport): string { } lines.push( - "", - "Apply safety:", - `- AGENTS managed-block apply safety: ${report.applySafety.status}${report.applySafety.blockedReason ? ` (${report.applySafety.blockedReason})` : ""}`, - "", - "Experimental Codex hooks:", - `- Status: ${report.experimentalHooks.status}`, - `- Feature flag: ${report.experimentalHooks.featureFlag}`, - `- Target file hint: ${report.experimentalHooks.targetFileHint}`, - `- Snippet: ${report.experimentalHooks.snippet.replace(/\n/g, " | ")}`, - ...report.experimentalHooks.notes.map((note) => `- ${note}`), "", "Retrieval sidecar:", `- Status: ${report.retrievalSidecar.status}`, @@ -1029,6 +1290,22 @@ export function formatMcpDoctorReport(report: McpDoctorReport): string { `- ${check.scope}/${check.state}: ${check.status}${check.fallbackReason ? ` (${check.fallbackReason})` : ""} | index: ${check.indexPath} | generatedAt: ${check.generatedAt ?? "none"} | topicFiles: ${check.topicFileCount ?? "none"}` ), "", + "Topic diagnostics:", + `- Status: ${report.topicDiagnostics.status}`, + `- Summary: ${report.topicDiagnostics.summary}`, + ...report.topicDiagnostics.diagnostics.map( + (diagnostic) => + `- ${diagnostic.scope}/${diagnostic.state}/${diagnostic.topic}: unsafe (${diagnostic.unsafeReason ?? "unknown reason"}) | entries=${diagnostic.entryCount} | malformed=${diagnostic.invalidEntryBlockCount} | manualContent=${diagnostic.manualContentDetected ? "yes" : "no"}` + ), + "", + "Layout diagnostics:", + `- Status: ${report.layoutDiagnostics.status}`, + `- Summary: ${report.layoutDiagnostics.summary}`, + ...report.layoutDiagnostics.diagnostics.map( + (diagnostic) => + `- ${diagnostic.scope}/${diagnostic.state}/${diagnostic.fileName}: ${diagnostic.kind} | ${diagnostic.message}` + ), + "", "Fallback assets:" ); for (const asset of report.fallbackAssets.assets) { @@ -1041,7 +1318,10 @@ export function formatMcpDoctorReport(report: McpDoctorReport): string { const executableInfo = asset.executableExpected ? ` | executable: ${asset.executableOk ? "yes" : "no"}` : ""; - lines.push(`- ${asset.name}: ${asset.status} (${versionInfo})${executableInfo} (${asset.path})`); + const launcherInfo = asset.launcher + ? ` | launcher: ${asset.launcher.resolution} (${asset.launcher.operational ? "operational" : "blocked"})${asset.launcher.missingPaths.length > 0 ? ` missing: ${asset.launcher.missingPaths.join(", ")}` : ""}` + : ""; + lines.push(`- ${asset.name}: ${asset.status} (${versionInfo})${executableInfo}${launcherInfo} (${asset.path})`); } lines.push( `- Post-session sync helper installed: ${report.fallbackAssets.postSessionSyncInstalled ? "yes" : "no"}`, @@ -1051,10 +1331,11 @@ export function formatMcpDoctorReport(report: McpDoctorReport): string { `- Startup doctor installed: ${report.fallbackAssets.startupDoctorInstalled ? "yes" : "no"}`, `- Any skill surface installed: ${report.fallbackAssets.anySkillSurfaceInstalled ? "yes" : "no"}`, `- Any skill surface ready: ${report.fallbackAssets.anySkillSurfaceReady ? "yes" : "no"}`, + `- Preferred skill surface ready: ${report.fallbackAssets.preferredSkillSurfaceReady ? "yes" : "no"}`, `- Legacy skillInstalled compatibility flag: ${report.fallbackAssets.skillInstalled ? "yes" : "no"}`, `- Shell fallback available: ${report.fallbackAssets.shellFallbackAvailable ? "yes" : "no"}`, `- Guidance available: ${report.fallbackAssets.guidanceAvailable ? "yes" : "no"}`, - `- Retrieval fallback available: ${report.fallbackAssets.fallbackAvailable ? "yes" : "no"}`, + `- Executable fallback available: ${report.fallbackAssets.fallbackAvailable ? "yes" : "no"}`, `- Runtime skill dir: ${report.fallbackAssets.runtimeSkillDir || "n/a"}`, `- Runtime asset dir: ${report.fallbackAssets.runtimeAssetDir || "n/a"}`, `- Runtime source: ${report.fallbackAssets.runtimeSource}`, @@ -1078,42 +1359,79 @@ export function formatMcpDoctorReport(report: McpDoctorReport): string { `- Ready skill surfaces: ${report.fallbackAssets.readySkillSurfaces.length > 0 ? report.fallbackAssets.readySkillSurfaces.join(", ") : "none"}`, `- Skill path drift: ${report.fallbackAssets.skillPathDrift ? "yes" : "no"}`, "", - "AGENTS guidance:", - `- Path: ${report.agentsGuidance.path}`, - `- Exists: ${report.agentsGuidance.exists ? "yes" : "no"}`, - `- Status: ${report.agentsGuidance.status}`, - `- Expected version: ${report.agentsGuidance.expectedVersion}`, - `- Detected version: ${report.agentsGuidance.detectedVersion ?? "none"}`, - `- Missing signatures: ${ - report.agentsGuidance.missingSignatures.length > 0 - ? report.agentsGuidance.missingSignatures.join(", ") - : "none" - }`, - "", - "Codex stack readiness:", - `- Status: ${report.codexStack.status}`, - `- Recommended route: ${report.codexStack.recommendedRoute}`, - `- Recommended preset: ${report.codexStack.preset}`, - `- Asset version: ${report.codexStack.assetVersion}`, - `- MCP ready: ${report.codexStack.mcpReady ? "yes" : "no"}`, - `- MCP operational ready: ${report.codexStack.mcpOperationalReady ? "yes" : "no"}`, - `- cam command available: ${report.codexStack.camCommandAvailable ? "yes" : "no"}`, - `- Hook capture ready: ${report.codexStack.hookCaptureReady ? "yes" : "no"}`, - `- Hook capture operational ready: ${report.codexStack.hookCaptureOperationalReady ? "yes" : "no"}`, - `- Hook recall ready: ${report.codexStack.hookRecallReady ? "yes" : "no"}`, - `- Hook recall operational ready: ${report.codexStack.hookRecallOperationalReady ? "yes" : "no"}`, - `- Skill ready: ${report.codexStack.skillReady ? "yes" : "no"}`, - `- Workflow consistent: ${report.codexStack.workflowConsistent ? "yes" : "no"}`, - "", "Notes:", - "- cam mcp install writes the recommended project-scoped host config for codex, claude, or gemini only.", + "- cam mcp install writes the recommended project-scoped host config for codex only.", "- cam mcp doctor only inspects the recommended project-scoped wiring and never writes host config files.", "- Run the retrieval sidecar repair command above if retrieval indexes are missing, invalid, or stale.", - "- Re-run cam hooks install or cam skills install if a fallback asset is reported as stale.", + `- Re-run ${buildResolvedCliCommand("hooks install", { cwd: report.projectRoot })} or ${buildResolvedCliCommand("skills install", { cwd: report.projectRoot })} if a fallback asset is reported as stale.`, "- cam memory is the inspect/audit surface for durable memory.", - "- cam session is the temporary continuity surface and is not the same as durable memory retrieval.", - ...report.codexStack.notes.map((note) => `- ${note}`) + "- cam session is the temporary continuity surface and is not the same as durable memory retrieval." ); + if (report.applySafety) { + lines.push( + "", + "Apply safety:", + `- AGENTS managed-block apply safety: ${report.applySafety.status}${report.applySafety.blockedReason ? ` (${report.applySafety.blockedReason})` : ""}` + ); + } + + if (report.experimentalHooks) { + lines.push( + "", + "Experimental Codex hooks:", + `- Status: ${report.experimentalHooks.status}`, + `- Feature flag: ${report.experimentalHooks.featureFlag}`, + `- Target file hint: ${report.experimentalHooks.targetFileHint}`, + `- Snippet: ${report.experimentalHooks.snippet.replace(/\n/g, " | ")}`, + ...report.experimentalHooks.notes.map((note) => `- ${note}`) + ); + } + + if (report.agentsGuidance) { + lines.push( + "", + "AGENTS guidance:", + `- Path: ${report.agentsGuidance.path}`, + `- Exists: ${report.agentsGuidance.exists ? "yes" : "no"}`, + `- Status: ${report.agentsGuidance.status}`, + `- Expected version: ${report.agentsGuidance.expectedVersion}`, + `- Detected version: ${report.agentsGuidance.detectedVersion ?? "none"}`, + `- Missing signatures: ${ + report.agentsGuidance.missingSignatures.length > 0 + ? report.agentsGuidance.missingSignatures.join(", ") + : "none" + }` + ); + } + + if (report.codexStack) { + lines.push( + "", + "Codex stack readiness:", + `- Status: ${report.codexStack.status}`, + `- Recommended route: ${report.codexStack.recommendedRoute}`, + `- Current operational route: ${report.codexStack.currentlyOperationalRoute}`, + `- Route kind: ${report.codexStack.routeKind}`, + `- Route evidence: ${report.codexStack.routeEvidence.length > 0 ? report.codexStack.routeEvidence.join(", ") : "none"}`, + `- Shell dependency level: ${report.codexStack.shellDependencyLevel}`, + `- Host mutation required: ${report.codexStack.hostMutationRequired ? "yes" : "no"}`, + `- Preferred route blockers: ${report.codexStack.preferredRouteBlockers.length > 0 ? report.codexStack.preferredRouteBlockers.join(", ") : "none"}`, + `- Current operational blockers: ${report.codexStack.currentOperationalBlockers.length > 0 ? report.codexStack.currentOperationalBlockers.join(", ") : "none"}`, + `- Recommended preset: ${report.codexStack.preset}`, + `- Asset version: ${report.codexStack.assetVersion}`, + `- MCP ready: ${report.codexStack.mcpReady ? "yes" : "no"}`, + `- MCP operational ready: ${report.codexStack.mcpOperationalReady ? "yes" : "no"}`, + `- cam command available: ${report.codexStack.camCommandAvailable ? "yes" : "no"}`, + `- Hook capture ready: ${report.codexStack.hookCaptureReady ? "yes" : "no"}`, + `- Hook capture operational ready: ${report.codexStack.hookCaptureOperationalReady ? "yes" : "no"}`, + `- Hook recall ready: ${report.codexStack.hookRecallReady ? "yes" : "no"}`, + `- Hook recall operational ready: ${report.codexStack.hookRecallOperationalReady ? "yes" : "no"}`, + `- Skill ready: ${report.codexStack.skillReady ? "yes" : "no"}`, + `- Workflow consistent: ${report.codexStack.workflowConsistent ? "yes" : "no"}`, + ...report.codexStack.notes.map((note) => `- ${note}`) + ); + } + return lines.join("\n"); } diff --git a/src/lib/integration/mcp-hosts.ts b/src/lib/integration/mcp-hosts.ts index 6045f4b..f0da59c 100644 --- a/src/lib/integration/mcp-hosts.ts +++ b/src/lib/integration/mcp-hosts.ts @@ -33,11 +33,7 @@ export interface McpCanonicalConfigInspection { export const MEMORY_RETRIEVAL_MCP_SERVER_NAME = "codex_auto_memory"; -export const SUPPORTED_MCP_INSTALL_HOSTS: readonly Exclude[] = [ - "codex", - "claude", - "gemini" -] as const; +export const SUPPORTED_MCP_INSTALL_HOSTS: readonly Extract[] = ["codex"] as const; export const SUPPORTED_MCP_HOSTS: readonly McpHost[] = [ "codex", "claude", @@ -69,7 +65,7 @@ const HOST_DEFINITIONS: Record = { pinning: "cwd-arg", projectConfigRelativePath: ".mcp.json", notes: [ - "Paste this into a project-scoped .mcp.json file. Claude Code asks for approval before using project-scoped MCP servers.", + "Print-config for Claude remains manual-only and snippet-first; this repository does not auto-write Claude host config.", "The explicit --cwd argument keeps retrieval pinned to this repository root even when the host starts the server elsewhere." ] }, @@ -81,7 +77,7 @@ const HOST_DEFINITIONS: Record = { projectConfigRelativePath: path.join(".gemini", "settings.json"), userConfigHomeRelativePath: path.join(".gemini", "settings.json"), notes: [ - "Paste this into .gemini/settings.json or ~/.gemini/settings.json.", + "Print-config for Gemini remains manual-only and snippet-first; this repository does not auto-write Gemini host config.", "The snippet leaves trust set to false so tool confirmations stay host-controlled." ] }, diff --git a/src/lib/integration/mcp-install.ts b/src/lib/integration/mcp-install.ts index 64173f8..c34de6d 100644 --- a/src/lib/integration/mcp-install.ts +++ b/src/lib/integration/mcp-install.ts @@ -1,6 +1,6 @@ import path from "node:path"; import * as toml from "smol-toml"; -import { ensureDir, fileExists, readTextFile, writeTextFile } from "../util/fs.js"; +import { ensureDir, fileExists, readTextFile, writeTextFileAtomic } from "../util/fs.js"; import { READ_ONLY_RETRIEVAL_NOTE } from "./codex-stack.js"; import { buildCanonicalMcpServerConfig, @@ -135,7 +135,10 @@ async function writeConfigIfChanged( } await ensureDir(path.dirname(targetPath)); - await writeTextFile(targetPath, nextContents.endsWith("\n") ? nextContents : `${nextContents}\n`); + await writeTextFileAtomic( + targetPath, + nextContents.endsWith("\n") ? nextContents : `${nextContents}\n` + ); } async function installCodexProjectConfig(projectRoot: string): Promise { @@ -181,52 +184,6 @@ async function installCodexProjectConfig(projectRoot: string): Promise, - projectRoot: string -): Promise { - const targetPath = resolveMcpHostProjectConfigPath(host, projectRoot); - if (!targetPath) { - throw new Error(`Missing project-scoped config path for ${host} MCP install.`); - } - - const targetExists = await fileExists(targetPath); - const rawConfig = targetExists ? await readTextFile(targetPath) : ""; - const parsed = targetExists ? (JSON.parse(rawConfig) as unknown) : {}; - if (!isRecordLike(parsed)) { - throw new Error(`The existing ${targetPath} must contain a top-level JSON object.`); - } - - const mcpServers = ensureRecordProperty(parsed, "mcpServers", `${targetPath}#mcpServers`); - const canonicalServer = buildCanonicalMcpServerConfig(host, projectRoot); - const existingServer = mcpServers[MEMORY_RETRIEVAL_MCP_SERVER_NAME]; - const { nextServer, preservedCustomFields } = buildInstalledServerRecord(existingServer, canonicalServer); - const hadServer = Object.hasOwn(mcpServers, MEMORY_RETRIEVAL_MCP_SERVER_NAME); - const action: McpInstallResult["action"] = !hadServer - ? "created" - : isRecordLike(existingServer) && deepEqual(existingServer, nextServer) - ? "unchanged" - : "updated"; - - if (action !== "unchanged") { - mcpServers[MEMORY_RETRIEVAL_MCP_SERVER_NAME] = nextServer; - } - - await writeConfigIfChanged(targetPath, JSON.stringify(parsed, null, 2), action); - - return { - host, - serverName: MEMORY_RETRIEVAL_MCP_SERVER_NAME, - projectRoot, - targetPath, - action, - projectPinned: true, - readOnlyRetrieval: true, - preservedCustomFields, - notes: buildInstallNotes(preservedCustomFields) - }; -} - export async function installMcpProjectConfig( host: McpHost, projectRoot: string @@ -236,10 +193,12 @@ export async function installMcpProjectConfig( return installCodexProjectConfig(projectRoot); case "claude": case "gemini": - return installJsonProjectConfig(host, projectRoot); + throw new Error( + `MCP install does not support host "${host}". ${host} wiring remains manual-only and snippet-first; use cam mcp print-config instead. Codex-only install keeps mutable host wiring scoped to the primary product line.` + ); case "generic": throw new Error( - 'MCP install does not support host "generic". generic wiring remains manual-only; use cam mcp print-config instead.' + 'MCP install does not support host "generic". generic wiring remains manual-only and snippet-first; use cam mcp print-config instead. Codex-only install keeps mutable host wiring scoped to the primary product line.' ); } } diff --git a/src/lib/integration/retrieval-contract.ts b/src/lib/integration/retrieval-contract.ts index 05409cc..ecc5e45 100644 --- a/src/lib/integration/retrieval-contract.ts +++ b/src/lib/integration/retrieval-contract.ts @@ -1,6 +1,8 @@ import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { isCommandAvailableInPath } from "./command-path.js"; import { DEFAULT_MEMORY_RETRIEVAL_LIMIT, DEFAULT_MEMORY_RETRIEVAL_STATE @@ -29,24 +31,42 @@ export const PROGRESSIVE_DISCLOSURE_GUIDANCE = "Use progressive disclosure: search -> timeline -> details."; export const MCP_FIRST_RECALL_WORKFLOW = `Prefer retrieval MCP when it is already wired in: ${RETRIEVAL_MCP_SEARCH_TOOL} -> ${RETRIEVAL_MCP_TIMELINE_TOOL} -> ${RETRIEVAL_MCP_DETAILS_TOOL}.`; -export const CLI_FALLBACK_RECALL_WORKFLOW = - "Otherwise fall back to the local recall bridge bundle through memory-recall.sh search|timeline|details."; +export const LOCAL_BRIDGE_RECALL_WORKFLOW = + "If the retrieval MCP server is unavailable, fall back to the local recall bridge bundle through memory-recall.sh search|timeline|details."; +export const RESOLVED_CLI_RECALL_WORKFLOW = + "If the local bridge bundle is unavailable, fall back to the resolved CLI recall commands."; +export const CLI_FALLBACK_RECALL_WORKFLOW = `${LOCAL_BRIDGE_RECALL_WORKFLOW} ${RESOLVED_CLI_RECALL_WORKFLOW}`; export const MCP_SERVE_GUIDANCE = "cam mcp serve exposes the same retrieval contract over stdio MCP when a host can consume it."; -export const MCP_DOCTOR_GUIDANCE = - "Run cam mcp doctor if you are unsure whether the recommended project-scoped retrieval MCP wiring is already in place."; +export function buildMcpDoctorGuidance( + options: { + cwd?: string; + } = {} +): string { + const fallbackCommand = buildResolvedCliCommand("mcp doctor --host codex", options); + return `Run ${fallbackCommand} if you are unsure whether the recommended project-scoped retrieval MCP wiring is already in place.`; +} export const MEMORY_AUDIT_BOUNDARY = "Use cam memory for inspect/audit surfaces and startup payload review."; export const SESSION_CONTINUITY_BOUNDARY = "Use cam session only for temporary continuity, not durable memory retrieval."; export const ARCHIVE_BOUNDARY = "Treat archived memory as historical context that does not participate in default startup recall."; -export const DURABLE_MEMORY_SYNC_GUIDANCE = - `After finishing work that should affect durable memory, run ${DURABLE_MEMORY_SYNC_COMMAND} or review ${DURABLE_MEMORY_RECENT_REVIEW_COMMAND} instead of assuming temporary continuity already updated Markdown memory.`; +export function buildDurableMemorySyncGuidance( + options: { + cwd?: string; + } = {} +): string { + const syncCommand = buildResolvedPostWorkSyncCommand(options); + const reviewCommand = buildResolvedPostWorkRecentReviewCommand(options); + return `After finishing work that should affect durable memory, run ${syncCommand} or review ${reviewCommand} instead of assuming temporary continuity already updated Markdown memory.`; +} export interface WorkflowRoutePreference { preferredRoute: "mcp-first"; mcpFirst: string; + localBridge: string; + resolvedCli: string; cliFallback: string; doctor: string; serve: string; @@ -57,10 +77,37 @@ export interface WorkflowRecallWorkflow { progressiveDisclosure: string; } +export interface WorkflowExecutionContract { + preferredRoute: "mcp-first"; + recommendedPreset: string; + fallbackOrder: ["mcp", "local-bridge", "resolved-cli"]; + mcpTools: WorkflowContract["mcpTools"]; + hookFallback: WorkflowContract["hookFallback"]; + cliFallback: WorkflowContract["cliFallback"]; + resolvedCliFallback: WorkflowContract["resolvedCliFallback"]; + postWorkSyncReview: WorkflowContract["postWorkSyncReview"]; + resolvedPostWorkSyncReview: WorkflowContract["resolvedPostWorkSyncReview"]; + boundaries: WorkflowContract["boundaries"]; +} + +export interface WorkflowModelGuidanceContract { + recallFirst: string; + progressiveDisclosure: string; + routePreference: WorkflowRoutePreference; + recallWorkflow: WorkflowRecallWorkflow; +} + +export interface WorkflowHostWiringContract { + launcher: WorkflowContract["launcher"]; + doctorCommand: string; + serveGuidance: string; +} + export interface WorkflowContract { version: string; preferredRoute: "mcp-first"; recommendedPreset: string; + fallbackOrder: ["mcp", "local-bridge", "resolved-cli"]; recallFirst: string; progressiveDisclosure: string; launcher: { @@ -70,6 +117,8 @@ export interface WorkflowContract { resolution: "cam-path" | "node-dist" | "cam-unverified"; verified: boolean; resolvedCommand: string; + appliesTo: "direct-cli-and-installed-helper-assets"; + canonicalMcpServerCommand: "cam mcp serve"; }; routePreference: WorkflowRoutePreference; recallWorkflow: WorkflowRecallWorkflow; @@ -78,6 +127,14 @@ export interface WorkflowContract { timeline: string; details: string; }; + hookFallback: { + helperScript: "memory-recall.sh"; + helperPath: string; + searchCommand: string; + timelineCommand: string; + detailsCommand: string; + shellOnly: true; + }; cliFallback: { searchCommand: string; timelineCommand: string; @@ -106,10 +163,17 @@ export interface WorkflowContract { sessionContinuity: string; archive: string; }; + executionContract: WorkflowExecutionContract; + modelGuidanceContract: WorkflowModelGuidanceContract; + hostWiringContract: WorkflowHostWiringContract; } -export function buildSharedWorkflowDisciplineLines(): string[] { - const workflowContract = buildWorkflowContract(); +export function buildSharedWorkflowDisciplineLines( + options: { + cwd?: string; + } = {} +): string[] { + const workflowContract = buildWorkflowContract(options); return [ workflowContract.recallWorkflow.recallFirst, workflowContract.recallWorkflow.progressiveDisclosure, @@ -124,60 +188,48 @@ export function formatRecommendedRetrievalPreset(): string { return `state=${RECOMMENDED_RETRIEVAL_STATE}, limit=${RECOMMENDED_RETRIEVAL_LIMIT}`; } -function isExecutableOnPath(commandName: string): boolean { - const pathValue = process.env.PATH; - if (!pathValue) { - return false; +function getPackagedDistCliPath(): string { + const overriddenPath = process.env.CODEX_AUTO_MEMORY_DIST_CLI_PATH?.trim(); + if (overriddenPath) { + return path.resolve(overriddenPath); } - const executableNames = - process.platform === "win32" - ? [commandName, `${commandName}.cmd`, `${commandName}.exe`, `${commandName}.bat`] - : [commandName]; - - return pathValue.split(path.delimiter).some((directory) => - executableNames.some((candidate) => { - const candidatePath = path.join(directory, candidate); - try { - const stat = fs.statSync(candidatePath); - if (!stat.isFile()) { - return false; - } - fs.accessSync(candidatePath, fs.constants.X_OK); - return true; - } catch { - return false; - } - }) - ); -} - -function getPackagedDistCliPath(): string { const thisFilePath = fileURLToPath(import.meta.url); return path.resolve(path.dirname(thisFilePath), "../../../dist/cli.js"); } -export function resolveCliLauncher(): WorkflowContract["launcher"] { - if (isExecutableOnPath("cam")) { +export function resolveCliLauncher( + options: { + pathValue?: string; + distCliPath?: string; + distCliPathExists?: boolean; + } = {} +): WorkflowContract["launcher"] { + if (isCommandAvailableInPath("cam", options.pathValue)) { return { commandName: "cam", requiresPathResolution: true, hookHelpersShellOnly: true, resolution: "cam-path", verified: true, - resolvedCommand: "cam" + resolvedCommand: "cam", + appliesTo: "direct-cli-and-installed-helper-assets", + canonicalMcpServerCommand: "cam mcp serve" }; } - const distCliPath = getPackagedDistCliPath(); - if (fs.existsSync(distCliPath)) { + const distCliPath = options.distCliPath ?? getPackagedDistCliPath(); + const distCliPathExists = options.distCliPathExists ?? fs.existsSync(distCliPath); + if (distCliPathExists) { return { commandName: "cam", requiresPathResolution: true, hookHelpersShellOnly: true, resolution: "node-dist", verified: true, - resolvedCommand: `node ${JSON.stringify(distCliPath)}` + resolvedCommand: `node ${JSON.stringify(distCliPath)}`, + appliesTo: "direct-cli-and-installed-helper-assets", + canonicalMcpServerCommand: "cam mcp serve" }; } @@ -187,7 +239,9 @@ export function resolveCliLauncher(): WorkflowContract["launcher"] { hookHelpersShellOnly: true, resolution: "cam-unverified", verified: false, - resolvedCommand: "cam" + resolvedCommand: "cam", + appliesTo: "direct-cli-and-installed-helper-assets", + canonicalMcpServerCommand: "cam mcp serve" }; } @@ -195,27 +249,39 @@ export function hasCliCwdFlag(command: string): boolean { return /(?:^|\s)--cwd(?:\s|=)/u.test(command); } -function shellQuote(value: string): string { - return `'${value.replace(/'/g, `'\"'\"'`)}'`; -} - export function appendCliCwdFlag(command: string, cwd?: string): string { if (!cwd || hasCliCwdFlag(command)) { return command; } - return `${command} --cwd ${shellQuote(cwd)}`; + return `${command} --cwd ${JSON.stringify(cwd)}`; } export function buildResolvedCliCommand( command: string, options: { cwd?: string; - launcher?: WorkflowContract["launcher"]; } = {} ): string { - const launcher = options.launcher ?? resolveCliLauncher(); - return appendCliCwdFlag(`${launcher.resolvedCommand} ${command}`, options.cwd); + return appendCliCwdFlag(`${resolveCliLauncher().resolvedCommand} ${command}`, options.cwd); +} + +function getInstalledHookHelperPath(helperScript: string): string { + return path.join(os.homedir(), ".codex-auto-memory", "hooks", helperScript); +} + +function buildHookFallbackCommand( + action: "search" | "timeline" | "details", + argumentPlaceholder: string, + options: { + cwd?: string; + } = {} +): string { + const helperPath = JSON.stringify(getInstalledHookHelperPath("memory-recall.sh")); + const invocation = `${helperPath} ${action} ${argumentPlaceholder}`; + return options.cwd + ? `CAM_PROJECT_ROOT=${JSON.stringify(options.cwd)} ${invocation}` + : invocation; } export function buildRecommendedCliSearchCommand( @@ -283,7 +349,6 @@ export function buildResolvedCliSearchCommand( state?: MemoryRetrievalStateFilter; limit?: number; cwd?: string; - launcher?: WorkflowContract["launcher"]; } = {} ): string { const state = options.state ?? RECOMMENDED_RETRIEVAL_STATE; @@ -298,7 +363,6 @@ export function buildResolvedCliTimelineCommand( ref = "\"\"", options: { cwd?: string; - launcher?: WorkflowContract["launcher"]; } = {} ): string { return buildResolvedCliCommand(`recall timeline ${ref}`, options); @@ -308,7 +372,6 @@ export function buildResolvedCliDetailsCommand( ref = "\"\"", options: { cwd?: string; - launcher?: WorkflowContract["launcher"]; } = {} ): string { return buildResolvedCliCommand(`recall details ${ref}`, options); @@ -317,7 +380,6 @@ export function buildResolvedCliDetailsCommand( export function buildResolvedPostWorkSyncCommand( options: { cwd?: string; - launcher?: WorkflowContract["launcher"]; } = {} ): string { return buildResolvedCliCommand("sync", options); @@ -326,7 +388,6 @@ export function buildResolvedPostWorkSyncCommand( export function buildResolvedPostWorkRecentReviewCommand( options: { cwd?: string; - launcher?: WorkflowContract["launcher"]; } = {} ): string { return buildResolvedCliCommand("memory --recent", options); @@ -335,62 +396,106 @@ export function buildResolvedPostWorkRecentReviewCommand( export function buildWorkflowContract( options: { cwd?: string; + launcherOverride?: WorkflowContract["launcher"]; } = {} ): WorkflowContract { - const launcher = resolveCliLauncher(); + const launcher = options.launcherOverride ?? resolveCliLauncher(); const routePreference: WorkflowRoutePreference = { preferredRoute: "mcp-first", mcpFirst: MCP_FIRST_RECALL_WORKFLOW, + localBridge: LOCAL_BRIDGE_RECALL_WORKFLOW, + resolvedCli: RESOLVED_CLI_RECALL_WORKFLOW, cliFallback: CLI_FALLBACK_RECALL_WORKFLOW, - doctor: MCP_DOCTOR_GUIDANCE, + doctor: buildMcpDoctorGuidance(options), serve: MCP_SERVE_GUIDANCE }; const recallWorkflow: WorkflowRecallWorkflow = { recallFirst: RECALL_FIRST_GUIDANCE, progressiveDisclosure: PROGRESSIVE_DISCLOSURE_GUIDANCE }; + const recommendedPreset = formatRecommendedRetrievalPreset(); + const fallbackOrder: WorkflowContract["fallbackOrder"] = ["mcp", "local-bridge", "resolved-cli"]; + const mcpTools: WorkflowContract["mcpTools"] = { + search: RETRIEVAL_MCP_SEARCH_TOOL, + timeline: RETRIEVAL_MCP_TIMELINE_TOOL, + details: RETRIEVAL_MCP_DETAILS_TOOL + }; + const hookFallback: WorkflowContract["hookFallback"] = { + helperScript: "memory-recall.sh", + helperPath: getInstalledHookHelperPath("memory-recall.sh"), + searchCommand: buildHookFallbackCommand("search", "\"\"", options), + timelineCommand: buildHookFallbackCommand("timeline", "\"\"", options), + detailsCommand: buildHookFallbackCommand("details", "\"\"", options), + shellOnly: true + }; + const cliFallback: WorkflowContract["cliFallback"] = { + searchCommand: buildRecommendedCliSearchCommand("\"\"", options), + timelineCommand: buildCliTimelineCommand("\"\"", options), + detailsCommand: buildCliDetailsCommand("\"\"", options), + requiresCamOnPath: true + }; + const resolvedCliFallback: WorkflowContract["resolvedCliFallback"] = { + searchCommand: buildResolvedCliSearchCommand("\"\"", options), + timelineCommand: buildResolvedCliTimelineCommand("\"\"", options), + detailsCommand: buildResolvedCliDetailsCommand("\"\"", options) + }; + const postWorkSyncReview: WorkflowContract["postWorkSyncReview"] = { + helperScript: POST_WORK_SYNC_REVIEW_HELPER, + syncCommand: buildPostWorkSyncCommand(options), + reviewCommand: buildPostWorkRecentReviewCommand(options), + guidance: buildDurableMemorySyncGuidance(options), + shellOnly: true, + requiresCamOnPath: true + }; + const resolvedPostWorkSyncReview: WorkflowContract["resolvedPostWorkSyncReview"] = { + syncCommand: buildResolvedPostWorkSyncCommand(options), + reviewCommand: buildResolvedPostWorkRecentReviewCommand(options) + }; + const boundaries: WorkflowContract["boundaries"] = { + memoryAudit: MEMORY_AUDIT_BOUNDARY, + sessionContinuity: SESSION_CONTINUITY_BOUNDARY, + archive: ARCHIVE_BOUNDARY + }; return { version: RETRIEVAL_INTEGRATION_ASSET_VERSION, preferredRoute: routePreference.preferredRoute, - recommendedPreset: formatRecommendedRetrievalPreset(), + recommendedPreset, + fallbackOrder, recallFirst: recallWorkflow.recallFirst, progressiveDisclosure: recallWorkflow.progressiveDisclosure, launcher, routePreference, recallWorkflow, - mcpTools: { - search: RETRIEVAL_MCP_SEARCH_TOOL, - timeline: RETRIEVAL_MCP_TIMELINE_TOOL, - details: RETRIEVAL_MCP_DETAILS_TOOL - }, - cliFallback: { - searchCommand: buildRecommendedCliSearchCommand("\"\"", options), - timelineCommand: buildCliTimelineCommand("\"\"", options), - detailsCommand: buildCliDetailsCommand("\"\"", options), - requiresCamOnPath: true - }, - resolvedCliFallback: { - searchCommand: buildResolvedCliSearchCommand("\"\"", { ...options, launcher }), - timelineCommand: buildResolvedCliTimelineCommand("\"\"", { ...options, launcher }), - detailsCommand: buildResolvedCliDetailsCommand("\"\"", { ...options, launcher }) - }, - postWorkSyncReview: { - helperScript: POST_WORK_SYNC_REVIEW_HELPER, - syncCommand: buildPostWorkSyncCommand(options), - reviewCommand: buildPostWorkRecentReviewCommand(options), - guidance: DURABLE_MEMORY_SYNC_GUIDANCE, - shellOnly: true, - requiresCamOnPath: true + mcpTools, + hookFallback, + cliFallback, + resolvedCliFallback, + postWorkSyncReview, + resolvedPostWorkSyncReview, + boundaries, + executionContract: { + preferredRoute: routePreference.preferredRoute, + recommendedPreset, + fallbackOrder, + mcpTools, + hookFallback, + cliFallback, + resolvedCliFallback, + postWorkSyncReview, + resolvedPostWorkSyncReview, + boundaries }, - resolvedPostWorkSyncReview: { - syncCommand: buildResolvedPostWorkSyncCommand({ ...options, launcher }), - reviewCommand: buildResolvedPostWorkRecentReviewCommand({ ...options, launcher }) + modelGuidanceContract: { + recallFirst: recallWorkflow.recallFirst, + progressiveDisclosure: recallWorkflow.progressiveDisclosure, + routePreference, + recallWorkflow }, - boundaries: { - memoryAudit: MEMORY_AUDIT_BOUNDARY, - sessionContinuity: SESSION_CONTINUITY_BOUNDARY, - archive: ARCHIVE_BOUNDARY + hostWiringContract: { + launcher, + doctorCommand: routePreference.doctor, + serveGuidance: routePreference.serve } }; } @@ -416,10 +521,11 @@ export function buildRecommendedRetrievalSummaryLines( workflowContract.routePreference.mcpFirst, buildRecommendedMcpSearchInstruction(), workflowContract.routePreference.serve, - workflowContract.routePreference.cliFallback, + workflowContract.routePreference.localBridge, + workflowContract.routePreference.resolvedCli, buildRecommendedSearchPresetGuidance(), workflowContract.routePreference.doctor, - ...buildSharedWorkflowDisciplineLines().slice(2) + ...buildSharedWorkflowDisciplineLines(options).slice(2) ]; } diff --git a/test/docs-contract.test.ts b/test/docs-contract.test.ts index 4f424f1..c5abd9e 100644 --- a/test/docs-contract.test.ts +++ b/test/docs-contract.test.ts @@ -53,15 +53,42 @@ describe("docs contract", () => { expect(readme).toContain("--surface runtime|official-user|official-project"); expect(readme).toContain("cam mcp serve"); expect(readme).toContain("cam mcp print-config --host codex"); - expect(readme).toContain("cam mcp doctor --host codex"); expect(readme).toContain("AGENTS.md"); expect(readme).toContain("cam mcp doctor"); expect(readme).toContain("alternate global wiring"); expect(readme).toContain("非 canonical 自定义字段"); + expect(readme).toContain("再退到 resolved CLI"); + expect(readme).toContain("retrieval sidecar 健康度"); + expect(readme).toContain("safe references"); + expect(readme).toContain("postInstallReadinessCommand"); + expect(readme).toContain("recommendedAction"); + expect(readme).toContain("recommendedActionCommand"); + expect(readme).toContain("recommendedDoctorCommand"); + expect(readme).toContain("postApplyReadinessCommand"); + expect(readme).toContain("entryCount"); + expect(readme).toContain("uniqueAuditCount"); + expect(readme).toContain("auditCountsDeduplicated"); + expect(readme).toContain("warningsByEntryRef"); + expect(readme).toContain("leadEntryRef"); + expect(readme).toContain("detailsAvailable"); + expect(readme).toContain("subagent-rollout"); expect(readme).toContain("manual-only"); - expect(readme).toContain("rollbackSucceeded"); - expect(readme).toContain("effectiveAction"); expect(readme).toContain("cam forget \"old debug note\" --archive"); + expect(readme).toContain("`--json`"); + expect(readme).toContain("searchOrder"); + expect(readme).toContain("finalRetrievalMode"); + expect(readme).toContain("totalMatchedCount"); + expect(readme).toContain("returnedCount"); + expect(readme).toContain("globalLimitApplied"); + expect(readme).toContain("truncatedCount"); + expect(readme).toContain("resultWindow"); + expect(readme).toContain("globalRank"); + expect(readme).toContain("droppedCount"); + expect(readme).toContain("summary"); + expect(readme).toContain("entries[]"); + expect(readme).toContain("content highlights"); + expect(readme).toContain("semantic-overwrite"); + expect(readme).toContain("metadata-only"); expect(readme).toContain("README.zh-TW.md"); expect(readme).toContain("README.ja.md"); expect(readme).toContain("集成演进策略"); @@ -76,13 +103,35 @@ describe("docs contract", () => { expect(readmeTw).toContain("cam mcp install --host codex"); expect(readmeTw).toContain("cam mcp print-config --host codex"); expect(readmeTw).toContain("cam mcp apply-guidance --host codex"); - expect(readmeTw).toContain("cam mcp doctor --host codex"); expect(readmeTw).toContain("cam mcp doctor"); expect(readmeTw).toContain("alternate global wiring"); expect(readmeTw).toContain("非 canonical 自訂欄位"); + expect(readmeTw).toContain("再退到 resolved CLI"); + expect(readmeTw).toContain("retrieval sidecar 健康度"); + expect(readmeTw).toContain("safe references"); + expect(readmeTw).toContain("postInstallReadinessCommand"); + expect(readmeTw).toContain("recommendedAction"); + expect(readmeTw).toContain("recommendedActionCommand"); + expect(readmeTw).toContain("recommendedDoctorCommand"); + expect(readmeTw).toContain("postApplyReadinessCommand"); + expect(readmeTw).toContain("entryCount"); + expect(readmeTw).toContain("uniqueAuditCount"); + expect(readmeTw).toContain("auditCountsDeduplicated"); + expect(readmeTw).toContain("warningsByEntryRef"); expect(readmeTw).toContain("applyReadiness"); - expect(readmeTw).toContain("rollbackSucceeded"); - expect(readmeTw).toContain("effectiveAction"); + expect(readmeTw).toContain("stateResolution"); + expect(readmeTw).toContain("executionSummary"); + expect(readmeTw).toContain("returnedCount"); + expect(readmeTw).toContain("searchOrder"); + expect(readmeTw).toContain("totalMatchedCount"); + expect(readmeTw).toContain("globalLimitApplied"); + expect(readmeTw).toContain("truncatedCount"); + expect(readmeTw).toContain("resultWindow"); + expect(readmeTw).toContain("globalRank"); + expect(readmeTw).toContain("finalRetrievalMode"); + expect(readmeTw).toContain("droppedCount"); + expect(readmeTw).toContain("entries[]"); + expect(readmeTw).toContain("experimentalHooks"); expect(readmeTw).toContain("--state auto"); expect(readmeTw).toContain("`cam memory` / `cam session` / `cam recall` / `cam audit`"); expect(readmeTw).toContain("local bridge"); @@ -101,13 +150,40 @@ describe("docs contract", () => { expect(readmeJa).toContain("cam mcp install --host codex"); expect(readmeJa).toContain("cam mcp print-config --host codex"); expect(readmeJa).toContain("cam mcp apply-guidance --host codex"); - expect(readmeJa).toContain("cam mcp doctor --host codex"); expect(readmeJa).toContain("cam mcp doctor"); expect(readmeJa).toContain("alternate global wiring"); expect(readmeJa).toContain("non-canonical なカスタム項目"); + expect(readmeJa).toContain("resolved CLI recall"); + expect(readmeJa).toContain("retrieval sidecar"); + expect(readmeJa).toContain("safe references"); + expect(readmeJa).toContain("postInstallReadinessCommand"); + expect(readmeJa).toContain("recommendedAction"); + expect(readmeJa).toContain("recommendedActionCommand"); + expect(readmeJa).toContain("recommendedDoctorCommand"); + expect(readmeJa).toContain("postApplyReadinessCommand"); + expect(readmeJa).toContain("entryCount"); + expect(readmeJa).toContain("uniqueAuditCount"); + expect(readmeJa).toContain("auditCountsDeduplicated"); + expect(readmeJa).toContain("warningsByEntryRef"); expect(readmeJa).toContain("applyReadiness"); - expect(readmeJa).toContain("rollbackSucceeded"); - expect(readmeJa).toContain("effectiveAction"); + expect(readmeJa).toContain("stateResolution"); + expect(readmeJa).toContain("executionSummary"); + expect(readmeJa).toContain("returnedCount"); + expect(readmeJa).toContain("searchOrder"); + expect(readmeJa).toContain("totalMatchedCount"); + expect(readmeJa).toContain("globalLimitApplied"); + expect(readmeJa).toContain("truncatedCount"); + expect(readmeJa).toContain("resultWindow"); + expect(readmeJa).toContain("globalRank"); + expect(readmeJa).toContain("finalRetrievalMode"); + expect(readmeJa).toContain("droppedCount"); + expect(readmeJa).toContain("retrievalFallbackReason"); + expect(readmeJa).toContain("entries[]"); + expect(readmeJa).toContain("latestLifecycleAction"); + expect(readmeJa).toContain("latestAudit"); + expect(readmeJa).toContain("highlightCount"); + expect(readmeJa).toContain("startupSectionsRendered"); + expect(readmeJa).toContain("experimentalHooks"); expect(readmeJa).toContain("--state auto"); expect(readmeJa).toContain("`cam memory` / `cam session` / `cam recall` / `cam audit`"); expect(readmeJa).toContain("local bridge"); @@ -139,14 +215,45 @@ describe("docs contract", () => { expect(readmeEn).toContain("cam mcp apply-guidance --host codex"); expect(readmeEn).toContain("cam mcp serve"); expect(readmeEn).toContain("cam mcp print-config --host codex"); - expect(readmeEn).toContain("cam mcp doctor --host codex"); expect(readmeEn).toContain("AGENTS.md"); expect(readmeEn).toContain("cam mcp doctor"); expect(readmeEn).toContain("alternate global wiring"); expect(readmeEn).toContain("non-canonical custom fields"); + expect(readmeEn).toContain("before the resolved CLI recall commands"); + expect(readmeEn).toContain("retrieval-sidecar health"); + expect(readmeEn).toContain("safe references"); + expect(readmeEn).toContain("postInstallReadinessCommand"); + expect(readmeEn).toContain("recommendedAction"); + expect(readmeEn).toContain("recommendedActionCommand"); + expect(readmeEn).toContain("recommendedDoctorCommand"); + expect(readmeEn).toContain("postApplyReadinessCommand"); + expect(readmeEn).toContain("entryCount"); + expect(readmeEn).toContain("uniqueAuditCount"); + expect(readmeEn).toContain("auditCountsDeduplicated"); + expect(readmeEn).toContain("warningsByEntryRef"); + expect(readmeEn).toContain("leadEntryRef"); + expect(readmeEn).toContain("detailsAvailable"); + expect(readmeEn).toContain("subagent-rollout"); expect(readmeEn).toContain("manual-only"); - expect(readmeEn).toContain("rollbackSucceeded"); - expect(readmeEn).toContain("effectiveAction"); + expect(readmeEn).toContain("searchOrder"); + expect(readmeEn).toContain("finalRetrievalMode"); + expect(readmeEn).toContain("totalMatchedCount"); + expect(readmeEn).toContain("returnedCount"); + expect(readmeEn).toContain("globalLimitApplied"); + expect(readmeEn).toContain("truncatedCount"); + expect(readmeEn).toContain("resultWindow"); + expect(readmeEn).toContain("globalRank"); + expect(readmeEn).toContain("droppedCount"); + expect(readmeEn).toContain("retrievalFallbackReason"); + expect(readmeEn).toContain("summary"); + expect(readmeEn).toContain("entries[]"); + expect(readmeEn).toContain("latestLifecycleAction"); + expect(readmeEn).toContain("latestAudit"); + expect(readmeEn).toContain("highlightCount"); + expect(readmeEn).toContain("startupSectionsRendered"); + expect(readmeEn).toContain("content highlights"); + expect(readmeEn).toContain("semantic-overwrite"); + expect(readmeEn).toContain("metadata-only"); expect(readmeEn).toContain("| `cam hooks install` |"); expect(readmeEn).toContain("`cam memory`, `cam session`, and `cam recall` reviewer UX"); expect(readmeEn).toContain("--surface runtime|official-user|official-project"); @@ -158,6 +265,21 @@ describe("docs contract", () => { expect(docsReadme).toContain("manual-only"); expect(docsReadme).toContain("`--help` 文案"); expect(docsReadme).toContain("state=auto"); + expect(docsReadme).toContain("experimentalHooks"); + expect(docsReadme).toContain("stateResolution"); + expect(docsReadme).toContain("commandName=cam"); + expect(docsReadme).toContain("searchOrder"); + expect(docsReadme).toContain("totalMatchedCount"); + expect(docsReadme).toContain("globalLimitApplied"); + expect(docsReadme).toContain("resultWindow"); + expect(docsReadme).toContain("droppedCount"); + expect(docsReadme).toContain("retrievalFallbackReason"); + expect(docsReadme).toContain("entries[]"); + expect(docsReadme).toContain("latestLifecycleAction"); + expect(docsReadme).toContain("latestAudit"); + expect(docsReadme).toContain("highlightCount"); + expect(docsReadme).toContain("startupSectionsRendered"); + expect(docsReadme).toContain("content highlights"); expect(docsReadmeEn).toContain("Codex-first Hybrid"); expect(docsReadmeEn).toContain("cam mcp apply-guidance --host codex"); expect(docsReadmeEn).toContain("cam integrations apply --host codex"); @@ -166,12 +288,26 @@ describe("docs contract", () => { expect(docsReadmeEn).toContain("manual-only"); expect(docsReadmeEn).toContain("`--help` text is part of the release-facing public contract"); expect(docsReadmeEn).toContain("state=auto, limit=8"); + expect(docsReadmeEn).toContain("experimentalHooks"); + expect(docsReadmeEn).toContain("stateResolution"); + expect(docsReadmeEn).toContain("commandName=cam"); + expect(docsReadmeEn).toContain("searchOrder"); + expect(docsReadmeEn).toContain("totalMatchedCount"); + expect(docsReadmeEn).toContain("globalLimitApplied"); + expect(docsReadmeEn).toContain("resultWindow"); + expect(docsReadmeEn).toContain("droppedCount"); + expect(docsReadmeEn).toContain("retrievalFallbackReason"); + expect(docsReadmeEn).toContain("entries[]"); + expect(docsReadmeEn).toContain("latestLifecycleAction"); + expect(docsReadmeEn).toContain("latestAudit"); + expect(docsReadmeEn).toContain("highlightCount"); + expect(docsReadmeEn).toContain("startupSectionsRendered"); + expect(docsReadmeEn).toContain("content highlights"); expect(claudeReference).toContain("autoMemoryDirectory"); expect(claudeReference).toContain("共享项目劫持用户 memory 路径"); expect(claudeReferenceEn).toContain("autoMemoryDirectory"); expect(claudeReferenceEn).toContain("shared project config"); expect(claudeReferenceEn).toContain("user-level memory path"); - expect(claudeReferenceEn).toContain("### 6. `autoMemoryDirectory` has a configuration safety boundary"); expect(nativeMigrationEn).toContain("native Codex memory and hooks are still not ready"); expect(nativeMigrationEn).toContain("allow non-native integration expansion"); expect(releaseChecklist).toContain("pnpm test:dist-cli-smoke"); @@ -189,7 +325,29 @@ describe("docs contract", () => { expect(releaseChecklist).toContain("node dist/cli.js recall search pnpm --json"); expect(releaseChecklist).toContain("state=auto, limit=8"); expect(releaseChecklist).toContain("checkedPaths"); + expect(releaseChecklist).toContain('stateResolution.outcome: "explicit-state"'); + expect(releaseChecklist).toContain("searchOrder"); + expect(releaseChecklist).toContain("totalMatchedCount"); + expect(releaseChecklist).toContain("returnedCount"); + expect(releaseChecklist).toContain("globalLimitApplied"); + expect(releaseChecklist).toContain("truncatedCount"); + expect(releaseChecklist).toContain("resultWindow"); + expect(releaseChecklist).toContain("globalRank"); + expect(releaseChecklist).toContain("droppedCount"); + expect(releaseChecklist).toContain("node dist/cli.js remember \"...\" --json"); + expect(releaseChecklist).toContain("node dist/cli.js forget \"...\" --json"); + expect(releaseChecklist).toContain("entries[]"); + expect(releaseChecklist).toContain("summary"); + expect(releaseChecklist).toContain("noop attempt"); + expect(releaseChecklist).toContain("highlightCount"); + expect(releaseChecklist).toContain("omittedHighlightCount"); + expect(releaseChecklist).toContain("omittedTopicFileCount"); + expect(releaseChecklist).toContain("topicFileOmissionCounts"); + expect(releaseChecklist).toContain("topicRefCountsByScope"); + expect(releaseChecklist).toContain("highlightsByScope"); + expect(releaseChecklist).toContain("startupSectionsRendered"); expect(releaseChecklist).toContain("node dist/cli.js memory reindex --scope all --state all --json"); + expect(releaseChecklist).toContain("### Highlights"); expect(releaseChecklist).toContain("node dist/cli.js recall details --json"); expect(releaseChecklist).toContain("latestLifecycleAction"); expect(releaseChecklist).toContain("latestSessionId"); @@ -201,7 +359,7 @@ describe("docs contract", () => { expect(releaseChecklist).toContain("lineageSummary"); expect(releaseChecklist).toContain("warnings"); expect(releaseChecklist).toContain( - "node dist/cli.js mcp install --host --json" + "node dist/cli.js mcp install --host codex --json" ); expect(releaseChecklist).toContain('action: "unchanged"'); expect(releaseChecklist).toContain("node dist/cli.js mcp install --host generic"); @@ -215,7 +373,11 @@ describe("docs contract", () => { expect(releaseChecklist).toContain("node dist/cli.js mcp doctor --host codex --json"); expect(releaseChecklist).toContain("structured `workflowContract`"); expect(releaseChecklist).toContain("retrievalSidecar"); + expect(releaseChecklist).toContain("MCP -> local bridge -> resolved CLI"); expect(releaseChecklist).toContain("repair command"); + expect(releaseChecklist).toContain("hookCaptureOperationalReady"); + expect(releaseChecklist).toContain("commandName=cam"); + expect(releaseChecklist).toContain("node /dist/cli.js"); expect(releaseChecklist).toContain("alternate global wiring"); expect(releaseChecklist).toContain("non-canonical custom fields"); expect(releaseChecklist).toContain("preservedCustomFields"); @@ -226,6 +388,7 @@ describe("docs contract", () => { expect(releaseChecklist).toContain("anySkillSurfaceInstalled"); expect(releaseChecklist).toContain("post-work-memory-review.sh"); expect(releaseChecklist).toContain("node dist/cli.js hooks install --json"); + expect(releaseChecklist).toContain("postInstallReadinessCommand"); expect(releaseChecklist).toContain("node dist/cli.js skills install --json"); expect(releaseChecklist).toContain("node dist/cli.js integrations install --host codex --json"); expect(releaseChecklist).toContain("node dist/cli.js integrations apply --host codex --json"); @@ -233,6 +396,15 @@ describe("docs contract", () => { expect(releaseChecklist).toContain("preflightBlocked"); expect(releaseChecklist).toContain("skipped subactions"); expect(releaseChecklist).toContain("applyReadiness"); + expect(releaseChecklist).toContain("recommendedRoute"); + expect(releaseChecklist).toContain("recommendedAction"); + expect(releaseChecklist).toContain("recommendedActionCommand"); + expect(releaseChecklist).toContain("recommendedDoctorCommand"); + expect(releaseChecklist).toContain("finalRetrievalMode"); + expect(releaseChecklist).toContain("entryCount"); + expect(releaseChecklist).toContain("warningCount"); + expect(releaseChecklist).toContain("detailsUsableEntryCount"); + expect(releaseChecklist).toContain("timelineOnlyEntryCount"); expect(releaseChecklist).toContain("node dist/cli.js skills install --surface official-user"); expect(releaseChecklist).toContain("node dist/cli.js integrations install --host codex --skill-surface official-user --json"); expect(releaseChecklist).toContain("node dist/cli.js integrations apply --host codex --skill-surface official-user --json"); @@ -251,8 +423,8 @@ describe("docs contract", () => { expect(releaseChecklist).toContain("node dist/cli.js skills install --surface official-project --cwd "); expect(releaseChecklist).toContain("node dist/cli.js mcp apply-guidance --host codex --cwd --json"); expect(releaseChecklist).toContain("node dist/cli.js integrations apply --host codex --cwd --json"); - expect(releaseChecklist).toContain("codex, claude, gemini, or generic"); - expect(releaseChecklist).toContain("leaving `generic` out of the install branch"); + expect(releaseChecklist).toContain("codex only"); + expect(releaseChecklist).toContain("manual-only and snippet-first"); expect(releaseChecklist).toContain("README.zh-TW.md"); expect(releaseChecklist).toContain("README.ja.md"); expect(releaseChecklist).toContain("search_memories"); @@ -264,11 +436,13 @@ describe("docs contract", () => { expect(contributing).toContain("cam integrations apply"); expect(contributing).toContain("skill surface selection"); expect(packageJson.scripts["test:cli-smoke"]).toContain("test/recall-command.test.ts"); + expect(packageJson.scripts["test:cli-smoke"]).toContain("test/doctor-command.test.ts"); expect(packageJson.scripts["test:cli-smoke"]).toContain("test/hooks-command.test.ts"); expect(packageJson.scripts["test:cli-smoke"]).toContain("test/integrations-command.test.ts"); expect(packageJson.scripts["test:cli-smoke"]).toContain("test/mcp-command.test.ts"); expect(packageJson.scripts["test:cli-smoke"]).toContain("test/skills-command.test.ts"); expect(packageJson.scripts["test:reviewer-smoke"]).toContain("test/recall-command.test.ts"); + expect(packageJson.scripts["test:reviewer-smoke"]).toContain("test/doctor-command.test.ts"); expect(packageJson.scripts["test:reviewer-smoke"]).toContain("test/hooks-command.test.ts"); expect(packageJson.scripts["test:reviewer-smoke"]).toContain("test/integrations-command.test.ts"); expect(packageJson.scripts["test:reviewer-smoke"]).toContain("test/mcp-command.test.ts"); @@ -406,14 +580,15 @@ describe("docs contract", () => { expect(readmeEn).toContain("applyReadiness"); expect( claudeReferenceEn.match( - /### 6\. `autoMemoryDirectory` has a configuration safety boundary/g + /### 6\. Host integration surfaces matter, but should not replace the core contract/g )?.length ?? 0 ).toBe(1); expect( claudeReferenceEn.match( - /### 7\. Host-native breadth expands the host, not the memory model/g + /### 7\. Host integration surfaces matter, but should not replace the core contract/g )?.length ?? 0 - ).toBe(1); + ).toBe(0); + expect(claudeReferenceEn).toContain("### 7. Host-native breadth expands the host, not the memory model"); expect(registerCommands).toContain( "Manage the local bridge / fallback helper bundle for current and upcoming integrations" ); diff --git a/test/doctor-command.test.ts b/test/doctor-command.test.ts new file mode 100644 index 0000000..ad097a6 --- /dev/null +++ b/test/doctor-command.test.ts @@ -0,0 +1,239 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { MemoryStore } from "../src/lib/domain/memory-store.js"; +import { detectProjectContext } from "../src/lib/domain/project-context.js"; +import { makeAppConfig, writeCamConfig } from "./helpers/cam-test-fixtures.js"; +import { runCli } from "./helpers/cli-runner.js"; + +const tempDirs: string[] = []; +const originalHome = process.env.HOME; + +async function tempDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +async function pathExists(targetPath: string): Promise { + try { + await fs.access(targetPath); + return true; + } catch { + return false; + } +} + +afterEach(async () => { + process.env.HOME = originalHome; + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("doctor command", () => { + it("surfaces retrieval sidecar and topic diagnostics without creating an uninitialized memory layout", async () => { + const homeDir = await tempDir("cam-doctor-readonly-home-"); + const projectDir = await tempDir("cam-doctor-readonly-project-"); + const memoryRootParent = await tempDir("cam-doctor-readonly-memory-parent-"); + const memoryRoot = path.join(memoryRootParent, "memory-root"); + process.env.HOME = homeDir; + + await writeCamConfig(projectDir, makeAppConfig(), { + autoMemoryDirectory: memoryRoot + }); + + const result = runCli(projectDir, ["doctor", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + memoryRoot: string; + recommendedAction?: string; + recommendedRoute?: string; + recommendedActionCommand?: string; + recommendedDoctorCommand?: string; + readiness: { + appServer?: { + name: string; + stage: string; + enabled: boolean; + } | null; + }; + retrievalSidecar?: { + status: string; + checks: Array<{ scope: string; state: string; status: string }>; + }; + topicDiagnostics?: { + status: string; + diagnostics: unknown[]; + }; + layoutDiagnostics: unknown[]; + }; + + expect(payload.memoryRoot).toBe(memoryRoot); + expect(payload.recommendedRoute).toBe("companion"); + expect(payload.recommendedAction).toContain("mcp doctor --host codex"); + expect(payload.recommendedActionCommand).toContain("mcp doctor --host codex"); + expect(payload.recommendedDoctorCommand).toContain("doctor --json"); + expect(payload.readiness.appServer?.enabled).toBe(true); + expect(["tui", "tui_app_server"]).toContain(payload.readiness.appServer?.name); + expect(payload.retrievalSidecar).toMatchObject({ + status: "warning", + checks: expect.arrayContaining([ + expect.objectContaining({ + scope: "project", + state: "active", + status: "missing" + }) + ]) + }); + expect(payload.topicDiagnostics).toMatchObject({ + status: "ok", + diagnostics: [] + }); + expect(payload.layoutDiagnostics).toEqual([]); + expect(await pathExists(memoryRoot)).toBe(false); + }); + + it("surfaces unsafe topic and layout diagnostics through cam doctor", async () => { + const homeDir = await tempDir("cam-doctor-diagnostics-home-"); + const projectDir = await tempDir("cam-doctor-diagnostics-project-"); + const memoryRoot = await tempDir("cam-doctor-diagnostics-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await fs.writeFile( + store.getTopicFile("project", "workflow"), + [ + "# Workflow", + "", + "", + "", + "This file is maintained by Codex Auto Memory. You may edit summaries or details directly.", + "", + "Manual notes outside managed entries" + ].join("\n"), + "utf8" + ); + await fs.writeFile( + path.join(path.dirname(store.getMemoryFile("project")), "Bad Topic.md"), + "# stray\n", + "utf8" + ); + await fs.writeFile(store.getRetrievalIndexFile("project", "active"), "{not-json", "utf8"); + + const result = runCli(projectDir, ["doctor", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + retrievalSidecar?: { + status: string; + }; + topicDiagnostics?: { + status: string; + diagnostics: Array<{ topic: string; safeToRewrite: boolean }>; + }; + layoutDiagnostics: Array<{ kind: string; fileName: string }>; + }; + + expect(payload.retrievalSidecar).toMatchObject({ + status: "warning" + }); + expect(payload.topicDiagnostics).toMatchObject({ + status: "warning", + diagnostics: expect.arrayContaining([ + expect.objectContaining({ + topic: "workflow", + safeToRewrite: false + }) + ]) + }); + expect(payload.layoutDiagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "malformed-topic-filename", + fileName: "Bad Topic.md" + }) + ]) + ); + }); + + it("keeps the retrieval sidecar repair command scoped to the smallest degraded target", async () => { + const homeDir = await tempDir("cam-doctor-min-repair-home-"); + const projectDir = await tempDir("cam-doctor-min-repair-project-"); + const memoryRoot = await tempDir("cam-doctor-min-repair-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await fs.writeFile(store.getRetrievalIndexFile("project", "active"), "{not-json", "utf8"); + + const result = runCli(projectDir, ["doctor", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + retrievalSidecar?: { + status: string; + repairCommand: string; + }; + }; + + expect(payload.retrievalSidecar).toMatchObject({ + status: "warning", + repairCommand: expect.stringContaining("memory reindex --scope project --state active") + }); + }); + + it("separates native readiness from host UI signals in text output", async () => { + const homeDir = await tempDir("cam-doctor-text-home-"); + const projectDir = await tempDir("cam-doctor-text-project-"); + process.env.HOME = homeDir; + + await writeCamConfig(projectDir, makeAppConfig(), {}); + + const result = runCli(projectDir, ["doctor"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Native memory/hooks readiness:"); + expect(result.stdout).toContain("Host/UI signals:"); + }); +}); diff --git a/test/hooks-command.test.ts b/test/hooks-command.test.ts index d766e37..4b42db9 100644 --- a/test/hooks-command.test.ts +++ b/test/hooks-command.test.ts @@ -5,9 +5,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { detectProjectContext } from "../src/lib/domain/project-context.js"; import { MemoryStore } from "../src/lib/domain/memory-store.js"; import { - buildResolvedCliCommand, - buildResolvedPostWorkRecentReviewCommand, - buildResolvedPostWorkSyncCommand + buildResolvedCliCommand } from "../src/lib/integration/retrieval-contract.js"; import { runCommandCapture } from "../src/lib/util/process.js"; import { makeAppConfig, writeCamConfig } from "./helpers/cam-test-fixtures.js"; @@ -15,6 +13,7 @@ import { resolveCliInvocation, runCli } from "./helpers/cli-runner.js"; const tempDirs: string[] = []; const originalHome = process.env.HOME; +const originalCodexHome = process.env.CODEX_HOME; const shellOnlyIt = process.platform === "win32" ? it.skip : it; async function tempDir(prefix: string): Promise { @@ -38,17 +37,18 @@ async function writeCamShim(binDir: string): Promise { return shimPath; } -function shellQuoteArg(value: string): string { - return `'${value.replace(/'/g, `'\"'\"'`)}'`; -} - afterEach(async () => { process.env.HOME = originalHome; + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); describe("hooks command", () => { - shellOnlyIt("supports --cwd and pins generated hook helpers to the targeted project root", async () => { + it("supports --cwd while keeping generated hook helpers reusable across projects", async () => { const homeDir = await tempDir("cam-hooks-cwd-home-"); const projectParentDir = await tempDir("cam-hooks-cwd-parent-"); const projectDir = path.join(projectParentDir, "project with spaces"); @@ -76,20 +76,26 @@ describe("hooks command", () => { "Manual note." ); + await writeCamShim(binDir); const installResult = runCli( shellDir, ["hooks", "install", "--cwd", projectDir], - { env: { HOME: homeDir } } + { + env: { + HOME: homeDir, + PATH: `${binDir}:${process.env.PATH ?? ""}` + } + } ); expect(installResult.exitCode, installResult.stderr).toBe(0); - await writeCamShim(binDir); const hooksDir = path.join(homeDir, ".codex-auto-memory", "hooks"); const recallScriptPath = path.join(hooksDir, "memory-recall.sh"); const postWorkReviewScriptPath = path.join(hooksDir, "post-work-memory-review.sh"); const env = { ...process.env, HOME: homeDir, + CAM_PROJECT_ROOT: await fs.realpath(projectDir), PATH: `${binDir}:${process.env.PATH ?? ""}` }; @@ -110,13 +116,10 @@ describe("hooks command", () => { const recallScript = await fs.readFile(recallScriptPath, "utf8"); const postWorkReviewScript = await fs.readFile(postWorkReviewScriptPath, "utf8"); - expect(recallScript).toContain(`PROJECT_ROOT=${shellQuoteArg(await fs.realpath(projectDir))}`); - expect(postWorkReviewScript).toContain( - `${buildResolvedPostWorkSyncCommand({ cwd: await fs.realpath(projectDir) })}` - ); - expect(postWorkReviewScript).toContain( - `exec ${buildResolvedPostWorkRecentReviewCommand({ cwd: await fs.realpath(projectDir) })}` - ); + expect(recallScript).not.toContain(JSON.stringify(await fs.realpath(projectDir))); + expect(recallScript).toContain('PROJECT_ROOT="${CAM_PROJECT_ROOT:-$PWD}"'); + expect(postWorkReviewScript).toContain('sync --cwd "$PROJECT_ROOT"'); + expect(postWorkReviewScript).toContain('memory --recent --cwd "$PROJECT_ROOT"'); }); it("generates recall helper assets for hook and skill bridge flows", async () => { @@ -139,7 +142,7 @@ describe("hooks command", () => { expect(result.stdout).toContain("--limit 8"); expect(result.stdout).toContain("search_memories"); expect(result.stdout).toContain("cam mcp serve"); - expect(result.stdout).toContain("cam mcp doctor"); + expect(result.stdout).toContain(buildResolvedCliCommand("mcp doctor")); expect(result.stdout).toContain("cam memory"); expect(result.stdout).toContain("cam session"); expect(result.stdout).toContain("local bridge"); @@ -157,8 +160,9 @@ describe("hooks command", () => { const recallGuide = await fs.readFile(path.join(hooksDir, "recall-bridge.md"), "utf8"); const realProjectDir = await fs.realpath(projectDir); - expect(recallScript).toContain(`exec ${buildResolvedCliCommand("recall search")} "$@"`); - expect(recallScript).toContain(`PROJECT_ROOT=${shellQuoteArg(realProjectDir)}`); + expect(recallScript).toContain("recall search"); + expect(recallScript).not.toContain(JSON.stringify(realProjectDir)); + expect(recallScript).toContain('PROJECT_ROOT="${CAM_PROJECT_ROOT:-$PWD}"'); expect(recallScript).toContain("--state"); expect(recallScript).toContain("auto"); expect(recallScript).toContain("--limit"); @@ -166,17 +170,15 @@ describe("hooks command", () => { expect(searchScript).toContain('exec "$SCRIPT_DIR/memory-recall.sh" search "$@"'); expect(timelineScript).toContain('exec "$SCRIPT_DIR/memory-recall.sh" timeline "$@"'); expect(detailsScript).toContain('exec "$SCRIPT_DIR/memory-recall.sh" details "$@"'); - expect(postWorkReviewScript).toContain( - `${buildResolvedPostWorkSyncCommand({ cwd: realProjectDir })} "$@"` + expect(postWorkReviewScript).toMatch( + /(?:cam|node ".+dist\/cli\.js") sync --cwd "\$PROJECT_ROOT" "\$@"/u ); - expect(postWorkReviewScript).toContain( - `exec ${buildResolvedPostWorkRecentReviewCommand({ cwd: realProjectDir })}` + expect(postWorkReviewScript).toMatch( + /(?:cam|exec node ".+dist\/cli\.js") memory --recent --cwd "\$PROJECT_ROOT"/u ); expect(recallGuide).toContain("search_memories"); expect(recallGuide).toContain("memory-recall.sh search"); - expect(recallGuide).toContain( - `cam recall search "pnpm" --state auto --limit 8 --cwd ${shellQuoteArg(realProjectDir)}` - ); + expect(recallGuide).toContain('recall search "pnpm"'); expect(recallGuide).toContain("cam memory"); expect(recallGuide).toContain("cam session"); expect(recallGuide).toContain("local bridge"); @@ -194,10 +196,13 @@ describe("hooks command", () => { action: "created", targetDir: path.join(homeDir, ".codex-auto-memory", "hooks"), readOnlyRetrieval: true, + postInstallReadinessCommand: buildResolvedCliCommand("mcp doctor --host codex", { + cwd: await fs.realpath(projectDir) + }), workflowContract: { recommendedPreset: "state=auto, limit=8", cliFallback: { - searchCommand: 'cam recall search "" --state auto --limit 8' + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(await fs.realpath(projectDir))}` }, postWorkSyncReview: { helperScript: "post-work-memory-review.sh" @@ -211,6 +216,23 @@ describe("hooks command", () => { }); }); + it("keeps hooks install working when CODEX_HOME is relative because no skill path is needed", async () => { + const homeDir = await tempDir("cam-hooks-relative-codex-home-"); + const projectDir = await tempDir("cam-hooks-relative-codex-project-"); + process.env.HOME = homeDir; + process.env.CODEX_HOME = "relative-codex-home"; + + const result = runCli(projectDir, ["hooks", "install", "--json"], { + env: { HOME: homeDir, CODEX_HOME: "relative-codex-home" } + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + action: "created", + targetDir: path.join(homeDir, ".codex-auto-memory", "hooks"), + readOnlyRetrieval: true + }); + }); + shellOnlyIt("executes the recall bridge bundle without overriding explicit state or limit flags", async () => { const homeDir = await tempDir("cam-hooks-exec-home-"); const projectDir = await tempDir("cam-hooks-exec-project-"); @@ -245,20 +267,20 @@ describe("hooks command", () => { ); await store.forget("project", "historical", { archive: true }); + await writeCamShim(binDir); + const env = { + ...process.env, + HOME: homeDir, + PATH: `${binDir}:${process.env.PATH ?? ""}` + }; const installResult = runCli(projectDir, ["hooks", "install"], { - env: { HOME: homeDir } + env }); expect(installResult.exitCode, installResult.stderr).toBe(0); - await writeCamShim(binDir); const hooksDir = path.join(homeDir, ".codex-auto-memory", "hooks"); const recallScriptPath = path.join(hooksDir, "memory-recall.sh"); const searchScriptPath = path.join(hooksDir, "memory-search.sh"); - const env = { - ...process.env, - HOME: homeDir, - PATH: `${binDir}:${process.env.PATH ?? ""}` - }; const defaultResult = runCommandCapture( recallScriptPath, @@ -306,4 +328,23 @@ describe("hooks command", () => { }); expect(explicitPayload.results).toHaveLength(1); }); + + it("does not overwrite user-level hook helper content with a second project's absolute path", async () => { + const homeDir = await tempDir("cam-hooks-scope-home-"); + const firstProjectDir = await tempDir("cam-hooks-scope-first-project-"); + const secondProjectDir = await tempDir("cam-hooks-scope-second-project-"); + process.env.HOME = homeDir; + + expect(runCli(firstProjectDir, ["hooks", "install"], { env: { HOME: homeDir } }).exitCode).toBe(0); + expect(runCli(secondProjectDir, ["hooks", "install"], { env: { HOME: homeDir } }).exitCode).toBe(0); + + const recallScript = await fs.readFile( + path.join(homeDir, ".codex-auto-memory", "hooks", "memory-recall.sh"), + "utf8" + ); + + expect(recallScript).not.toContain(JSON.stringify(await fs.realpath(firstProjectDir))); + expect(recallScript).not.toContain(JSON.stringify(await fs.realpath(secondProjectDir))); + expect(recallScript).toContain('PROJECT_ROOT="${CAM_PROJECT_ROOT:-$PWD}"'); + }); }); diff --git a/test/integrations-command.test.ts b/test/integrations-command.test.ts index 0f6b42d..08692d3 100644 --- a/test/integrations-command.test.ts +++ b/test/integrations-command.test.ts @@ -2,10 +2,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { restoreOptionalEnv } from "./helpers/env.js"; import { buildResolvedCliCommand, - buildResolvedCliSearchCommand + buildResolvedCliSearchCommand, + buildWorkflowContract } from "../src/lib/integration/retrieval-contract.js"; import { makeAppConfig, writeCamConfig } from "./helpers/cam-test-fixtures.js"; import { runCli } from "./helpers/cli-runner.js"; @@ -13,6 +13,7 @@ import { runCli } from "./helpers/cli-runner.js"; const tempDirs: string[] = []; const originalHome = process.env.HOME; const originalCodexHome = process.env.CODEX_HOME; +const originalPath = process.env.PATH; async function tempDir(prefix: string): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); @@ -20,6 +21,28 @@ async function tempDir(prefix: string): Promise { return dir; } +async function withFakePackagedDistCli(callback: () => Promise): Promise { + const fakeDistDir = await tempDir("cam-fake-dist-cli-"); + const fakeDistCliPath = path.join(fakeDistDir, "cli.js"); + const originalOverride = process.env.CODEX_AUTO_MEMORY_DIST_CLI_PATH; + await fs.writeFile( + fakeDistCliPath, + "#!/usr/bin/env node\nconsole.log('fake dist cli');\n", + "utf8" + ); + process.env.CODEX_AUTO_MEMORY_DIST_CLI_PATH = fakeDistCliPath; + + try { + return await callback(); + } finally { + if (originalOverride === undefined) { + delete process.env.CODEX_AUTO_MEMORY_DIST_CLI_PATH; + } else { + process.env.CODEX_AUTO_MEMORY_DIST_CLI_PATH = originalOverride; + } + } +} + async function pathExists(targetPath: string): Promise { try { await fs.access(targetPath); @@ -68,15 +91,32 @@ async function buildPathWithoutCam(extraDir: string): Promise { return [extraDir, ...filteredEntries].join(path.delimiter); } -function shellQuoteArg(value: string): string { - return `'${value.replace(/'/g, `'\"'\"'`)}'`; +function buildStableCliEnv( + homeDir: string, + overrides: NodeJS.ProcessEnv = {} +): NodeJS.ProcessEnv { + return { + HOME: homeDir, + PATH: originalPath ?? process.env.PATH ?? "", + CODEX_HOME: originalCodexHome ?? "", + ...overrides + }; } afterEach(async () => { - restoreOptionalEnv("HOME", originalHome); - restoreOptionalEnv("CODEX_HOME", originalCodexHome); vi.restoreAllMocks(); vi.resetModules(); + process.env.HOME = originalHome; + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); @@ -96,7 +136,7 @@ describe("integrations command", () => { const first = runCli( projectDir, ["integrations", "install", "--host", "codex", "--json"], - { env: { HOME: homeDir } } + { env: buildStableCliEnv(homeDir) } ); expect(first.exitCode, first.stderr).toBe(0); expect(JSON.parse(first.stdout)).toMatchObject({ @@ -108,7 +148,7 @@ describe("integrations command", () => { workflowContract: { recommendedPreset: "state=auto, limit=8", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realProjectDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` } }, subactions: { @@ -152,7 +192,7 @@ describe("integrations command", () => { const second = runCli( projectDir, ["integrations", "install", "--host", "codex", "--json"], - { env: { HOME: homeDir } } + { env: buildStableCliEnv(homeDir) } ); expect(second.exitCode, second.stderr).toBe(0); expect(JSON.parse(second.stdout)).toMatchObject({ @@ -172,7 +212,7 @@ describe("integrations command", () => { process.env.HOME = homeDir; const result = runCli(projectDir, ["integrations", "install", "--host", "gemini"], { - env: { HOME: homeDir } + env: buildStableCliEnv(homeDir) }); expect(result.exitCode).toBe(1); expect(result.stderr).toContain("Codex-only"); @@ -208,11 +248,11 @@ describe("integrations command", () => { projectRoot: realProjectDir, readOnlyRetrieval: true, status: "missing", - recommendedRoute: "cli-direct", + recommendedRoute: "mcp", recommendedPreset: "state=auto, limit=8", retrievalSidecar: { status: "warning", - repairCommand: "cam memory reindex --scope all --state all", + repairCommand: buildResolvedCliCommand("memory reindex --scope all --state all"), checks: expect.arrayContaining([ expect.objectContaining({ scope: "project", @@ -245,12 +285,20 @@ describe("integrations command", () => { }); expect(JSON.parse(result.stdout).nextSteps).toEqual( expect.arrayContaining([ - expect.stringContaining("cam memory reindex --scope all --state all"), - expect.stringContaining("cam integrations apply --host codex"), + expect.stringContaining(buildResolvedCliCommand("memory reindex --scope all --state all")), + expect.stringContaining(buildResolvedCliCommand("integrations apply --host codex --skill-surface runtime")), expect.stringContaining(buildResolvedCliCommand("integrations install --host codex")), + expect.stringContaining(buildResolvedCliSearchCommand("\"\"")), expect.stringContaining(buildResolvedCliCommand("mcp print-config --host codex")) ]) ); + expect(JSON.parse(result.stdout).nextSteps).not.toEqual( + expect.arrayContaining([ + expect.stringContaining( + `Until the stack is installed, use \`cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}\` directly.` + ) + ]) + ); expect(await pathExists(memoryRoot)).toBe(false); }); @@ -287,15 +335,19 @@ describe("integrations command", () => { }; expect(payload.projectRoot).toBe(await fs.realpath(projectDir)); expect(payload.recommendedSkillInstallCommand).toBe( - `cam skills install --surface runtime --cwd ${shellQuoteArg(payload.projectRoot)}` + buildResolvedCliCommand("skills install --surface runtime", { + cwd: payload.projectRoot + }) ); expect(payload.workflowContract.cliFallback.searchCommand).toBe( - `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(payload.projectRoot)}` + `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(payload.projectRoot)}` ); expect(payload.nextSteps).toEqual( expect.arrayContaining([ expect.stringContaining( - `cam integrations apply --host codex --skill-surface runtime --cwd ${shellQuoteArg(payload.projectRoot)}` + `${buildResolvedCliCommand("integrations apply --host codex --skill-surface runtime", { + cwd: payload.projectRoot + })}` ), expect.stringContaining( buildResolvedCliCommand("integrations install --host codex", { @@ -322,7 +374,7 @@ describe("integrations command", () => { process.env.HOME = homeDir; const result = runCli(projectDir, ["integrations", "doctor", "--host", "codex", "--json"], { - env: { HOME: homeDir } + env: buildStableCliEnv(homeDir) }); expect(result.exitCode, result.stderr).toBe(0); expect(JSON.parse(result.stdout)).toMatchObject({ @@ -337,48 +389,47 @@ describe("integrations command", () => { }); it("does not recommend integrations apply when only AGENTS guidance is missing but cam is unavailable on PATH", async () => { - const homeDir = await tempDir("cam-integrations-doctor-path-home-"); - const projectDir = await tempDir("cam-integrations-doctor-path-project-"); - const emptyPathDir = await tempDir("cam-integrations-doctor-path-empty-"); - process.env.HOME = homeDir; - - const env = { - HOME: homeDir, - PATH: await buildPathWithoutCam(emptyPathDir) - }; - - expect(runCli(projectDir, ["mcp", "install", "--host", "codex", "--json"], { env }).exitCode).toBe(0); - expect(runCli(projectDir, ["hooks", "install", "--json"], { env }).exitCode).toBe(0); - expect(runCli(projectDir, ["skills", "install", "--json"], { env }).exitCode).toBe(0); - - const result = runCli(projectDir, ["integrations", "doctor", "--host", "codex", "--json"], { - env - }); - expect(result.exitCode, result.stderr).toBe(0); + await withFakePackagedDistCli(async () => { + const homeDir = await tempDir("cam-integrations-doctor-path-home-"); + const projectDir = await tempDir("cam-integrations-doctor-path-project-"); + const emptyPathDir = await tempDir("cam-integrations-doctor-path-empty-"); + process.env.HOME = homeDir; + + const env = { + HOME: homeDir, + PATH: await buildPathWithoutCam(emptyPathDir) + }; - const payload = JSON.parse(result.stdout) as { - nextSteps: string[]; - subchecks: { - hookCapture: { status: string }; - hookRecall: { status: string }; - agents: { status: string }; + expect(runCli(projectDir, ["mcp", "install", "--host", "codex", "--json"], { env }).exitCode).toBe(0); + expect(runCli(projectDir, ["hooks", "install", "--json"], { env }).exitCode).toBe(0); + expect(runCli(projectDir, ["skills", "install", "--json"], { env }).exitCode).toBe(0); + + const result = runCli(projectDir, ["integrations", "doctor", "--host", "codex", "--json"], { + env + }); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + nextSteps: string[]; + subchecks: { + hookCapture: { status: string }; + hookRecall: { status: string }; + agents: { status: string }; + }; }; - }; - expect(payload.subchecks).toMatchObject({ - hookCapture: { status: "warning" }, - hookRecall: { status: "warning" }, - agents: { status: "missing" } + expect(payload.subchecks).toMatchObject({ + hookCapture: { status: "ok" }, + hookRecall: { status: "ok" }, + agents: { status: "missing" } + }); + expect(payload.nextSteps[0]).not.toContain("cam integrations apply --host codex"); + expect(payload.nextSteps).toEqual( + expect.arrayContaining([ + expect.stringContaining(buildResolvedCliCommand("mcp apply-guidance --host codex")) + ]) + ); }); - expect(payload.nextSteps).not.toEqual( - expect.arrayContaining([expect.stringContaining("cam integrations apply --host codex")]) - ); - expect(payload.nextSteps).toEqual( - expect.arrayContaining([ - expect.stringContaining("cam mcp apply-guidance --host codex"), - expect.stringContaining("resolve `cam` on PATH") - ]) - ); }); it("keeps the AGENTS-only repair step pinned to the inspected project when --cwd targets another directory", async () => { @@ -414,13 +465,16 @@ describe("integrations command", () => { expect(payload.nextSteps).toEqual( expect.arrayContaining([ expect.stringContaining( - `cam mcp apply-guidance --host codex --cwd ${shellQuoteArg(payload.projectRoot)}` + `cam mcp apply-guidance --host codex --cwd ${JSON.stringify(payload.projectRoot)}` ), expect.stringContaining( - `cam mcp print-config --host codex --cwd ${shellQuoteArg(payload.projectRoot)}` + `cam mcp print-config --host codex --cwd ${JSON.stringify(payload.projectRoot)}` ) ]) ); + expect(payload.nextSteps[0]).toContain( + `cam mcp apply-guidance --host codex --cwd ${JSON.stringify(payload.projectRoot)}` + ); expect(payload.nextSteps).not.toEqual( expect.arrayContaining([expect.stringContaining("cam hooks install")]) ); @@ -467,12 +521,52 @@ describe("integrations command", () => { expect(payload.nextSteps).toEqual( expect.arrayContaining([ expect.stringContaining( - `cam hooks install --cwd ${shellQuoteArg(payload.projectRoot)}` + `cam hooks install --cwd ${JSON.stringify(payload.projectRoot)}` ) ]) ); }); + it("pins hook fallback next steps to the inspected project when integrations doctor uses --cwd", async () => { + await withFakePackagedDistCli(async () => { + const homeDir = await tempDir("cam-integrations-doctor-hook-fallback-home-"); + const projectDir = await tempDir("cam-integrations-doctor-hook-fallback-project-"); + const shellDir = await tempDir("cam-integrations-doctor-hook-fallback-shell-"); + const emptyPathDir = await tempDir("cam-integrations-doctor-hook-fallback-empty-path-"); + process.env.HOME = homeDir; + + const env = { + HOME: homeDir, + PATH: await buildPathWithoutCam(emptyPathDir) + }; + + expect(runCli(projectDir, ["hooks", "install", "--json"], { env }).exitCode).toBe(0); + expect(runCli(projectDir, ["skills", "install", "--json"], { env }).exitCode).toBe(0); + + const result = runCli( + shellDir, + ["integrations", "doctor", "--host", "codex", "--cwd", projectDir, "--json"], + { env } + ); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + projectRoot: string; + recommendedRoute: string; + nextSteps: string[]; + }; + expect(payload.recommendedRoute).toBe("mcp"); + expect(payload.nextSteps).toEqual( + expect.arrayContaining([ + expect.stringContaining( + `CAM_PROJECT_ROOT=${JSON.stringify(payload.projectRoot)}` + ), + expect.stringContaining("memory-recall.sh") + ]) + ); + }); + }); + it("surfaces a ready Codex integration stack through integrations doctor", async () => { const homeDir = await tempDir("cam-integrations-doctor-ready-home-"); const projectDir = await tempDir("cam-integrations-doctor-ready-project-"); @@ -526,6 +620,17 @@ describe("integrations command", () => { readOnlyRetrieval: true, status: "ok", recommendedRoute: "mcp", + currentlyOperationalRoute: "mcp", + routeKind: "preferred-mcp", + routeEvidence: expect.arrayContaining([ + "mcp-config-present", + "cam-command-available", + "hook-recall-operational", + "resolved-cli-launcher-verified" + ]), + shellDependencyLevel: "required", + hostMutationRequired: false, + currentOperationalBlockers: [], recommendedPreset: "state=auto, limit=8", workflowContract: { version: expect.any(String), @@ -549,13 +654,18 @@ describe("integrations command", () => { status: "ok" }, skill: { - status: "ok" + status: "ok", + summary: expect.stringContaining("guidance") }, workflowConsistency: { status: "ok" } } }); + expect(JSON.parse(doctorResult.stdout).routeEvidence).not.toContain("skill-guidance-ready"); + expect(JSON.parse(doctorResult.stdout).subchecks.skill.summary).not.toContain( + "before direct CLI recall" + ); expect(JSON.parse(doctorResult.stdout).nextSteps).toEqual( expect.arrayContaining([ expect.stringContaining("cam mcp print-config --host codex"), @@ -626,16 +736,257 @@ describe("integrations command", () => { process.env.HOME = homeDir; const created = runCli(projectDir, ["integrations", "install", "--host", "codex"], { - env: { HOME: homeDir } + env: buildStableCliEnv(homeDir) }); expect(created.exitCode, created.stderr).toBe(0); expect(created.stdout).toContain("Installed Codex integration stack."); + expect(created.stdout).toContain("Run"); + expect(created.stdout).toContain("integrations doctor --host codex"); + expect(created.stdout).toContain("confirm which retrieval route is operational"); + expect(created.stdout).not.toContain("The recommended MCP route is ready"); const unchanged = runCli(projectDir, ["integrations", "install", "--host", "codex"], { - env: { HOME: homeDir } + env: buildStableCliEnv(homeDir) }); expect(unchanged.exitCode, unchanged.stderr).toBe(0); expect(unchanged.stdout).toContain("Codex integration stack is already up to date."); + expect(unchanged.stdout).toContain("integrations doctor --host codex"); + }); + + it("rolls back staged writes when integrations install fails after partial writes", async () => { + const homeDir = await tempDir("cam-integrations-install-rollback-home-"); + const projectDir = await tempDir("cam-integrations-install-rollback-project-"); + const realProjectDir = await fs.realpath(projectDir); + const configPath = path.join(realProjectDir, ".codex", "config.toml"); + const recallScriptPath = path.join( + homeDir, + ".codex-auto-memory", + "hooks", + "memory-recall.sh" + ); + const skillFilePath = path.join( + homeDir, + ".codex", + "skills", + "codex-auto-memory-recall", + "SKILL.md" + ); + process.env.HOME = homeDir; + + vi.resetModules(); + const mcpInstallModule = await import("../src/lib/integration/mcp-install.js"); + const installAssetsModule = await import("../src/lib/integration/install-assets.js"); + const mcpConfigModule = await import("../src/lib/integration/mcp-config.js"); + + vi.spyOn(mcpConfigModule, "resolveMcpProjectRoot").mockReturnValue(realProjectDir); + vi.spyOn(mcpInstallModule, "installMcpProjectConfig").mockImplementation(async () => { + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, "[mcp_servers.codex_auto_memory]\n", "utf8"); + return { + host: "codex", + serverName: "codex_auto_memory", + projectRoot: realProjectDir, + targetPath: configPath, + action: "created", + projectPinned: true, + readOnlyRetrieval: true, + preservedCustomFields: [], + notes: ["mcp wrote"] + }; + }); + vi.spyOn(installAssetsModule, "installIntegrationAssets").mockImplementation( + async (installSurface, options = {}) => { + if (installSurface === "hooks") { + await fs.mkdir(path.dirname(recallScriptPath), { recursive: true }); + await fs.writeFile(recallScriptPath, "#!/bin/sh\n", "utf8"); + return { + installSurface, + targetDir: path.dirname(recallScriptPath), + action: "created", + readOnlyRetrieval: true, + assetVersion: "retrieval-contract-v1", + recommendedPreset: "state=auto, limit=8", + workflowContract: buildWorkflowContract({ + cwd: realProjectDir + }), + skillSurface: undefined, + preferredSkillSurface: undefined, + notes: ["hooks wrote"], + assets: [] + }; + } + + const targetSkillDir = + options.skillSurface === "official-project" + ? path.join(realProjectDir, ".agents", "skills", "codex-auto-memory-recall") + : options.skillSurface === "official-user" + ? path.join(homeDir, ".agents", "skills", "codex-auto-memory-recall") + : path.dirname(skillFilePath); + const targetSkillFile = + options.skillSurface === "runtime" || options.skillSurface === undefined + ? skillFilePath + : path.join(targetSkillDir, "SKILL.md"); + await fs.mkdir(path.dirname(targetSkillFile), { recursive: true }); + await fs.writeFile(targetSkillFile, "# partial skill\n", "utf8"); + throw new Error("simulated skill install failure"); + } + ); + + const { runIntegrationsInstall } = await import("../src/lib/commands/integrations.js"); + + const payload = JSON.parse( + await runIntegrationsInstall({ + cwd: realProjectDir, + host: "codex", + json: true + }) + ) as { + stackAction: string; + rollbackApplied: boolean; + rollbackSucceeded: boolean; + rollbackErrors: string[]; + rollbackReport: Array<{ path: string; action: string }>; + subactions: { + mcp: { attempted: boolean; rolledBack?: boolean; effectiveAction?: string }; + hooks: { attempted: boolean; rolledBack?: boolean; effectiveAction?: string }; + skills: { attempted: boolean; surface: string }; + }; + notes: string[]; + }; + + expect(payload).toMatchObject({ + stackAction: "failed", + rollbackApplied: true, + rollbackSucceeded: true, + rollbackErrors: [], + subactions: { + mcp: { + attempted: true, + rolledBack: true, + effectiveAction: "unchanged" + }, + hooks: { + attempted: true, + rolledBack: true, + effectiveAction: "unchanged" + }, + skills: { + attempted: false, + surface: "runtime" + } + } + }); + expect(payload.notes).toEqual( + expect.arrayContaining([expect.stringContaining("simulated skill install failure")]) + ); + expect(payload.rollbackReport).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: configPath, action: "deleted-new" }), + expect.objectContaining({ path: recallScriptPath, action: "deleted-new" }) + ]) + ); + + expect(await pathExists(configPath)).toBe(false); + expect(await pathExists(recallScriptPath)).toBe(false); + expect(await pathExists(skillFilePath)).toBe(false); + }); + + it("restores dangling symlink rollback targets during integrations install failure recovery", async () => { + const homeDir = await tempDir("cam-integrations-install-dangling-symlink-home-"); + const projectDir = await tempDir("cam-integrations-install-dangling-symlink-project-"); + const realProjectDir = await fs.realpath(projectDir); + const configPath = path.join(realProjectDir, ".codex", "config.toml"); + const recallScriptPath = path.join( + homeDir, + ".codex-auto-memory", + "hooks", + "memory-recall.sh" + ); + const danglingHookPath = path.join( + homeDir, + ".codex-auto-memory", + "hooks", + "post-work-memory-review.sh" + ); + const danglingTarget = path.join(homeDir, ".tmp", "missing-post-work-memory-review.sh"); + process.env.HOME = homeDir; + + await fs.mkdir(path.dirname(danglingHookPath), { recursive: true }); + await fs.symlink(danglingTarget, danglingHookPath); + + vi.resetModules(); + const mcpInstallModule = await import("../src/lib/integration/mcp-install.js"); + const installAssetsModule = await import("../src/lib/integration/install-assets.js"); + const mcpConfigModule = await import("../src/lib/integration/mcp-config.js"); + vi.spyOn(mcpConfigModule, "resolveMcpProjectRoot").mockReturnValue(realProjectDir); + vi.spyOn(mcpInstallModule, "installMcpProjectConfig").mockImplementation(async () => { + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, "[mcp_servers.codex_auto_memory]\n", "utf8"); + return { + host: "codex", + serverName: "codex_auto_memory", + projectRoot: realProjectDir, + targetPath: configPath, + action: "created", + projectPinned: true, + readOnlyRetrieval: true, + preservedCustomFields: [], + notes: ["mcp wrote"] + }; + }); + vi.spyOn(installAssetsModule, "installIntegrationAssets").mockImplementation( + async (installSurface, options = {}) => { + if (installSurface === "hooks") { + await fs.mkdir(path.dirname(recallScriptPath), { recursive: true }); + await fs.writeFile(recallScriptPath, "#!/bin/sh\n", "utf8"); + return { + installSurface, + targetDir: path.dirname(recallScriptPath), + action: "created", + readOnlyRetrieval: true, + assetVersion: "retrieval-contract-v1", + recommendedPreset: "state=auto, limit=8", + workflowContract: buildWorkflowContract({ + cwd: realProjectDir + }), + skillSurface: undefined, + preferredSkillSurface: undefined, + notes: ["hooks wrote"], + assets: [] + }; + } + + const targetSkillDir = + options.skillSurface === "official-project" + ? path.join(realProjectDir, ".agents", "skills", "codex-auto-memory-recall") + : options.skillSurface === "official-user" + ? path.join(homeDir, ".agents", "skills", "codex-auto-memory-recall") + : path.join(homeDir, ".codex", "skills", "codex-auto-memory-recall"); + await fs.mkdir(targetSkillDir, { recursive: true }); + throw new Error("simulated skill install failure"); + } + ); + + const { runIntegrationsInstall } = await import("../src/lib/commands/integrations.js"); + const payload = JSON.parse( + await runIntegrationsInstall({ + cwd: realProjectDir, + host: "codex", + json: true + }) + ) as { + rollbackReport: Array<{ path: string; action: string }>; + }; + + expect(payload.rollbackReport).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: danglingHookPath, + action: "restored-existing" + }) + ]) + ); + expect(await fs.readlink(danglingHookPath)).toBe(danglingTarget); }); it("applies the full Codex integration stack including AGENTS guidance", async () => { @@ -647,7 +998,7 @@ describe("integrations command", () => { const result = runCli( projectDir, ["integrations", "apply", "--host", "codex", "--json"], - { env: { HOME: homeDir } } + { env: buildStableCliEnv(homeDir) } ); expect(result.exitCode, result.stderr).toBe(0); expect(JSON.parse(result.stdout)).toMatchObject({ @@ -707,7 +1058,7 @@ describe("integrations command", () => { const result = runCli( projectDir, ["integrations", "apply", "--host", "codex", "--json"], - { env: { HOME: homeDir } } + { env: buildStableCliEnv(homeDir) } ); expect(result.exitCode, result.stderr).toBe(0); expect(JSON.parse(result.stdout)).toMatchObject({ @@ -808,62 +1159,16 @@ describe("integrations command", () => { assetVersion: "retrieval-contract-v1", recommendedPreset: "state=auto, limit=8", workflowContract: { - version: "retrieval-contract-v1", - preferredRoute: "mcp-first", - recommendedPreset: "state=auto, limit=8", - recallFirst: "Before repeating prior work or repo-specific decisions, recall durable memory first.", - progressiveDisclosure: "Use progressive disclosure: search -> timeline -> details.", - routePreference: { - preferredRoute: "mcp-first", - mcpFirst: "Prefer retrieval MCP when it is already wired in: search_memories -> timeline_memories -> get_memory_details.", - cliFallback: "Otherwise fall back to the local recall bridge bundle through memory-recall.sh search|timeline|details.", - doctor: "Run cam mcp doctor if you are unsure whether the recommended project-scoped retrieval MCP wiring is already in place.", - serve: "cam mcp serve exposes the same retrieval contract over stdio MCP when a host can consume it." - }, - recallWorkflow: { - recallFirst: "Before repeating prior work or repo-specific decisions, recall durable memory first.", - progressiveDisclosure: "Use progressive disclosure: search -> timeline -> details." - }, + ...buildWorkflowContract({ + cwd: realProjectDir + }), launcher: { - commandName: "cam", - requiresPathResolution: true, - hookHelpersShellOnly: true, + ...buildWorkflowContract({ + cwd: realProjectDir + }).launcher, resolution: "cam-path", verified: true, resolvedCommand: "cam" - }, - mcpTools: { - search: "search_memories", - timeline: "timeline_memories", - details: "get_memory_details" - }, - cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}`, - timelineCommand: `cam recall timeline "" --cwd ${JSON.stringify(realProjectDir)}`, - detailsCommand: `cam recall details "" --cwd ${JSON.stringify(realProjectDir)}`, - requiresCamOnPath: true - }, - resolvedCliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}`, - timelineCommand: `cam recall timeline "" --cwd ${JSON.stringify(realProjectDir)}`, - detailsCommand: `cam recall details "" --cwd ${JSON.stringify(realProjectDir)}` - }, - postWorkSyncReview: { - helperScript: "post-work-memory-review.sh", - syncCommand: `cam sync --cwd ${JSON.stringify(realProjectDir)}`, - reviewCommand: `cam memory --recent --cwd ${JSON.stringify(realProjectDir)}`, - guidance: "After finishing work that should affect durable memory, run cam sync or review cam memory --recent instead of assuming temporary continuity already updated Markdown memory.", - shellOnly: true, - requiresCamOnPath: true - }, - resolvedPostWorkSyncReview: { - syncCommand: `cam sync --cwd ${JSON.stringify(realProjectDir)}`, - reviewCommand: `cam memory --recent --cwd ${JSON.stringify(realProjectDir)}` - }, - boundaries: { - memoryAudit: "Use cam memory for inspect/audit surfaces and startup payload review.", - sessionContinuity: "Use cam session only for temporary continuity, not durable memory retrieval.", - archive: "Treat archived memory as historical context that does not participate in default startup recall." } }, notes: ["asset wrote"], @@ -899,15 +1204,23 @@ describe("integrations command", () => { surface: string; }; }; + rollbackReport?: Array<{ + path: string; + action: string; + }>; notes: string[]; }; expect(payload).toMatchObject({ stackAction: "blocked", + rollbackApplied: true, + rollbackSucceeded: true, + rollbackErrors: [], subactions: { mcp: { - attempted: false, - skipped: true + attempted: true, + rolledBack: true, + effectiveAction: "unchanged" }, agents: { status: "blocked", @@ -915,23 +1228,361 @@ describe("integrations command", () => { attempted: true }, hooks: { - attempted: false, - skipped: true + attempted: true, + rolledBack: true, + effectiveAction: "unchanged" }, skills: { - attempted: false, - skipped: true, - surface: "runtime" + attempted: true, + surface: "runtime", + rolledBack: true, + effectiveAction: "unchanged" } } }); expect(payload.notes).toEqual( expect.arrayContaining([ - expect.stringContaining("no project-scoped MCP wiring, hook assets, or skill assets were written") + expect.stringContaining( + "Rollback processed" + ) + ]) + ); + expect(payload.rollbackReport).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "deleted-new" + }) + ]) + ); + expect(installMcpProjectConfigSpy).toHaveBeenCalledTimes(1); + expect(installIntegrationAssetsSpy).toHaveBeenCalledTimes(2); + }); + + it("rolls back MCP, hooks, and skills when AGENTS apply blocks after staged writes", async () => { + const homeDir = await tempDir("cam-integrations-apply-rollback-home-"); + const projectDir = await tempDir("cam-integrations-apply-rollback-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + vi.resetModules(); + const agentsGuidanceModule = await import("../src/lib/integration/agents-guidance.js"); + const { runIntegrationsApply } = await import("../src/lib/commands/integrations.js"); + + vi.spyOn(agentsGuidanceModule, "inspectCodexAgentsGuidanceApplySafety").mockResolvedValue({ + host: "codex", + projectRoot: realProjectDir, + targetPath: path.join(realProjectDir, "AGENTS.md"), + status: "safe", + recommendedAction: "append", + notes: ["preflight safe"] + }); + vi.spyOn(agentsGuidanceModule, "applyCodexAgentsGuidance").mockResolvedValue({ + host: "codex", + projectRoot: realProjectDir, + targetPath: path.join(realProjectDir, "AGENTS.md"), + action: "blocked", + managedBlockVersion: "codex-agents-guidance-v1", + createdFile: false, + blockedReason: "managed guidance block changed after preflight", + notes: ["late block"] + }); + + const payload = JSON.parse( + await runIntegrationsApply({ + cwd: realProjectDir, + host: "codex", + json: true + }) + ) as { + stackAction: string; + rollbackApplied?: boolean; + rollbackPathCount?: number; + rollbackReport?: Array<{ + path: string; + action: string; + }>; + }; + + expect(payload).toMatchObject({ + stackAction: "blocked", + rollbackApplied: true, + rollbackSucceeded: true, + rollbackErrors: [] + }); + expect((payload.rollbackPathCount ?? 0) > 0).toBe(true); + expect(payload.rollbackReport).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "deleted-new" + }) ]) ); - expect(installMcpProjectConfigSpy).not.toHaveBeenCalled(); - expect(installIntegrationAssetsSpy).not.toHaveBeenCalled(); + + expect(await pathExists(path.join(realProjectDir, ".codex", "config.toml"))).toBe(false); + expect(await pathExists(path.join(realProjectDir, "AGENTS.md"))).toBe(false); + expect( + await pathExists( + path.join(homeDir, ".codex-auto-memory", "hooks", "memory-recall.sh") + ) + ).toBe(false); + expect( + await pathExists( + path.join(homeDir, ".codex-auto-memory", "hooks", "post-work-memory-review.sh") + ) + ).toBe(false); + expect( + await pathExists( + path.join(homeDir, ".codex", "skills", "codex-auto-memory-recall", "SKILL.md") + ) + ).toBe(false); + }); + + it("does not claim staged subactions were rolled back when rollback itself fails", async () => { + const homeDir = await tempDir("cam-integrations-apply-rollback-failed-home-"); + const projectDir = await tempDir("cam-integrations-apply-rollback-failed-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + vi.resetModules(); + await fs.writeFile(path.join(realProjectDir, "AGENTS.md"), "# Existing guidance\n", "utf8"); + vi.doMock("../src/lib/util/fs.js", async () => { + const actual = await vi.importActual( + "../src/lib/util/fs.js" + ); + return { + ...actual, + writeTextFileAtomic: vi.fn(async (filePath: string, contents: string) => { + if (filePath === path.join(realProjectDir, "AGENTS.md")) { + throw new Error("simulated rollback restore failure"); + } + return actual.writeTextFileAtomic(filePath, contents); + }) + }; + }); + const agentsGuidanceModule = await import("../src/lib/integration/agents-guidance.js"); + const { runIntegrationsApply } = await import("../src/lib/commands/integrations.js"); + + vi.spyOn(agentsGuidanceModule, "inspectCodexAgentsGuidanceApplySafety").mockResolvedValue({ + host: "codex", + projectRoot: realProjectDir, + targetPath: path.join(realProjectDir, "AGENTS.md"), + status: "safe", + recommendedAction: "append", + notes: ["preflight safe"] + }); + vi.spyOn(agentsGuidanceModule, "applyCodexAgentsGuidance").mockResolvedValue({ + host: "codex", + projectRoot: realProjectDir, + targetPath: path.join(realProjectDir, "AGENTS.md"), + action: "blocked", + managedBlockVersion: "codex-agents-guidance-v1", + createdFile: false, + blockedReason: "managed guidance block changed after preflight", + notes: ["late block"] + }); + const payload = JSON.parse( + await runIntegrationsApply({ + cwd: realProjectDir, + host: "codex", + json: true + }) + ) as { + rollbackSucceeded: boolean; + rollbackErrors: string[]; + subactions: { + mcp: { + action: string; + attempted: boolean; + rolledBack?: boolean; + effectiveAction?: string; + }; + hooks: { + action: string; + attempted: boolean; + rolledBack?: boolean; + effectiveAction?: string; + }; + skills: { + action: string; + attempted: boolean; + rolledBack?: boolean; + effectiveAction?: string; + }; + }; + }; + + expect(payload.rollbackSucceeded).toBe(false); + expect(payload.rollbackErrors).toEqual( + expect.arrayContaining([expect.stringContaining("simulated rollback restore failure")]) + ); + expect(payload.subactions.mcp).toMatchObject({ + action: "created", + attempted: true + }); + expect(payload.subactions.mcp.rolledBack).toBe(false); + expect(payload.subactions.mcp.effectiveAction).toBeUndefined(); + expect(payload.subactions.hooks.rolledBack).toBe(false); + expect(payload.subactions.skills.rolledBack).toBe(false); + }); + + it("does not apply AGENTS guidance before MCP wiring succeeds", async () => { + const projectDir = await tempDir("cam-integrations-apply-mcp-fail-project-"); + const realProjectDir = await fs.realpath(projectDir); + + vi.resetModules(); + const agentsGuidanceModule = await import("../src/lib/integration/agents-guidance.js"); + const mcpInstallModule = await import("../src/lib/integration/mcp-install.js"); + const installAssetsModule = await import("../src/lib/integration/install-assets.js"); + const mcpConfigModule = await import("../src/lib/integration/mcp-config.js"); + + vi.spyOn(mcpConfigModule, "resolveMcpProjectRoot").mockReturnValue(realProjectDir); + vi.spyOn(agentsGuidanceModule, "inspectCodexAgentsGuidanceApplySafety").mockResolvedValue({ + host: "codex", + projectRoot: realProjectDir, + targetPath: path.join(realProjectDir, "AGENTS.md"), + status: "safe", + recommendedAction: "append", + notes: ["preflight safe"] + }); + + const applyGuidanceSpy = vi.spyOn( + agentsGuidanceModule, + "applyCodexAgentsGuidance" + ).mockResolvedValue({ + host: "codex", + projectRoot: realProjectDir, + targetPath: path.join(realProjectDir, "AGENTS.md"), + action: "created", + managedBlockVersion: "codex-agents-guidance-v1", + createdFile: true, + notes: ["agents wrote"] + }); + + vi.spyOn(mcpInstallModule, "installMcpProjectConfig").mockRejectedValue( + new Error("broken codex config") + ); + const installAssetsSpy = vi.spyOn( + installAssetsModule, + "installIntegrationAssets" + ).mockResolvedValue({ + installSurface: "hooks", + action: "created", + targetDir: path.join(realProjectDir, ".tmp"), + readOnlyRetrieval: true, + assetVersion: "retrieval-contract-v1", + recommendedPreset: "state=auto, limit=8", + workflowContract: { + ...buildWorkflowContract({ + cwd: realProjectDir + }), + launcher: { + ...buildWorkflowContract({ + cwd: realProjectDir + }).launcher, + resolution: "cam-path", + verified: true, + resolvedCommand: "cam" + } + }, + notes: ["asset wrote"], + assets: [] + }); + + const { runIntegrationsApply } = await import("../src/lib/commands/integrations.js"); + + await expect( + runIntegrationsApply({ + cwd: realProjectDir, + host: "codex", + json: true + }) + ).rejects.toThrow("broken codex config"); + + expect(applyGuidanceSpy).not.toHaveBeenCalled(); + expect(installAssetsSpy).not.toHaveBeenCalled(); + }); + + it("passes an explicit homeDir through integrations apply asset installation paths", async () => { + const homeDir = await tempDir("cam-integrations-apply-home-dir-home-"); + const projectDir = await tempDir("cam-integrations-apply-home-dir-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + const mcpInstallModule = await import("../src/lib/integration/mcp-install.js"); + const installAssetsModule = await import("../src/lib/integration/install-assets.js"); + const agentsGuidanceModule = await import("../src/lib/integration/agents-guidance.js"); + + vi.spyOn(agentsGuidanceModule, "inspectCodexAgentsGuidanceApplySafety").mockResolvedValue({ + host: "codex", + projectRoot: realProjectDir, + targetPath: path.join(realProjectDir, "AGENTS.md"), + status: "safe", + recommendedAction: "append", + notes: ["preflight safe"] + }); + vi.spyOn(mcpInstallModule, "installMcpProjectConfig").mockResolvedValue({ + host: "codex", + serverName: "codex_auto_memory", + projectRoot: realProjectDir, + targetPath: path.join(realProjectDir, ".codex", "config.toml"), + action: "created", + projectPinned: true, + readOnlyRetrieval: true, + preservedCustomFields: [], + notes: ["mcp wrote"] + }); + const installAssetsSpy = vi + .spyOn(installAssetsModule, "installIntegrationAssets") + .mockResolvedValue({ + installSurface: "hooks", + action: "created", + targetDir: path.join(homeDir, ".codex-auto-memory", "hooks"), + readOnlyRetrieval: true, + assetVersion: "retrieval-contract-v1", + recommendedPreset: "state=auto, limit=8", + workflowContract: buildWorkflowContract({ + cwd: realProjectDir + }), + notes: ["asset wrote"], + assets: [] + }); + vi.spyOn(agentsGuidanceModule, "applyCodexAgentsGuidance").mockResolvedValue({ + host: "codex", + projectRoot: realProjectDir, + targetPath: path.join(realProjectDir, "AGENTS.md"), + action: "created", + managedBlockVersion: "codex-agents-guidance-v1", + createdFile: true, + notes: ["agents wrote"] + }); + + const { runIntegrationsApply } = await import("../src/lib/commands/integrations.js"); + + await expect( + runIntegrationsApply({ + cwd: realProjectDir, + host: "codex", + json: true, + homeDir + } as any) + ).resolves.toContain('"stackAction": "created"'); + + expect(installAssetsSpy).toHaveBeenNthCalledWith( + 1, + "hooks", + expect.objectContaining({ + projectRoot: realProjectDir, + homeDir + }) + ); + expect(installAssetsSpy).toHaveBeenNthCalledWith( + 2, + "skills", + expect.objectContaining({ + projectRoot: realProjectDir, + homeDir + }) + ); }); it("withholds integrations apply from doctor next steps when AGENTS guidance is unsafe", async () => { @@ -956,7 +1607,7 @@ describe("integrations command", () => { const result = runCli( shellDir, ["integrations", "doctor", "--host", "codex", "--cwd", projectDir, "--json"], - { env: { HOME: homeDir } } + { env: buildStableCliEnv(homeDir) } ); expect(result.exitCode, result.stderr).toBe(0); @@ -974,7 +1625,9 @@ describe("integrations command", () => { recommendedFix: expect.stringContaining("Repair") }); expect(payload.applyReadiness.recommendedFix).toContain( - `cam mcp apply-guidance --host codex --cwd ${shellQuoteArg(realProjectDir)}` + buildResolvedCliCommand("mcp apply-guidance --host codex", { + cwd: realProjectDir + }) ); expect(payload.nextSteps).not.toEqual( expect.arrayContaining([expect.stringContaining("cam integrations apply --host codex")]) @@ -982,7 +1635,9 @@ describe("integrations command", () => { expect(payload.nextSteps).toEqual( expect.arrayContaining([ expect.stringContaining( - `cam mcp apply-guidance --host codex --cwd ${shellQuoteArg(realProjectDir)}` + buildResolvedCliCommand("mcp apply-guidance --host codex", { + cwd: realProjectDir + }) ) ]) ); @@ -997,7 +1652,7 @@ describe("integrations command", () => { const installResult = runCli( projectDir, ["integrations", "install", "--host", "codex", "--json"], - { env: { HOME: homeDir } } + { env: buildStableCliEnv(homeDir) } ); expect(installResult.exitCode, installResult.stderr).toBe(0); await expect(fs.access(path.join(realProjectDir, "AGENTS.md"))).rejects.toMatchObject({ @@ -1007,7 +1662,7 @@ describe("integrations command", () => { const applyResult = runCli( projectDir, ["integrations", "apply", "--host", "codex", "--json"], - { env: { HOME: homeDir } } + { env: buildStableCliEnv(homeDir) } ); expect(applyResult.exitCode, applyResult.stderr).toBe(0); expect(JSON.parse(applyResult.stdout)).toMatchObject({ @@ -1015,7 +1670,7 @@ describe("integrations command", () => { workflowContract: { recommendedPreset: "state=auto, limit=8", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realProjectDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` } }, subactions: { @@ -1089,7 +1744,9 @@ describe("integrations command", () => { } }, preferredSkillSurface: "runtime", - recommendedSkillInstallCommand: "cam skills install --surface runtime", + recommendedSkillInstallCommand: expect.stringMatching( + /(?:cam|node .*dist\/cli\.js) skills install --surface runtime/ + ), installedSkillSurfaces: ["runtime"], readySkillSurfaces: ["runtime"] }); @@ -1104,7 +1761,7 @@ describe("integrations command", () => { const installResult = runCli( projectDir, ["integrations", "install", "--host", "codex", "--skill-surface", "official-user", "--json"], - { env: { HOME: homeDir } } + { env: buildStableCliEnv(homeDir) } ); expect(installResult.exitCode, installResult.stderr).toBe(0); expect(JSON.parse(installResult.stdout)).toMatchObject({ @@ -1123,7 +1780,7 @@ describe("integrations command", () => { const applyResult = runCli( projectDir, ["integrations", "apply", "--host", "codex", "--skill-surface", "official-user", "--json"], - { env: { HOME: homeDir } } + { env: buildStableCliEnv(homeDir) } ); expect(applyResult.exitCode, applyResult.stderr).toBe(0); expect(JSON.parse(applyResult.stdout)).toMatchObject({ @@ -1162,7 +1819,7 @@ describe("integrations command", () => { "official-project", "--json" ], - { env: { HOME: homeDir } } + { env: buildStableCliEnv(homeDir) } ); expect(installResult.exitCode, installResult.stderr).toBe(0); const installPayload = JSON.parse(installResult.stdout) as { @@ -1205,7 +1862,7 @@ describe("integrations command", () => { "official-project", "--json" ], - { env: { HOME: homeDir } } + { env: buildStableCliEnv(homeDir) } ); expect(applyResult.exitCode, applyResult.stderr).toBe(0); const applyPayload = JSON.parse(applyResult.stdout) as { diff --git a/test/mcp-command.test.ts b/test/mcp-command.test.ts index a2b359c..ab0aff8 100644 --- a/test/mcp-command.test.ts +++ b/test/mcp-command.test.ts @@ -1,12 +1,17 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import * as toml from "smol-toml"; import { detectProjectContext } from "../src/lib/domain/project-context.js"; import { MemoryStore } from "../src/lib/domain/memory-store.js"; import { SyncService } from "../src/lib/domain/sync-service.js"; -import { RETRIEVAL_INTEGRATION_ASSET_VERSION } from "../src/lib/integration/retrieval-contract.js"; +import { + resolveCliLauncher, + buildResolvedCliCommand, + RETRIEVAL_INTEGRATION_ASSET_VERSION +} from "../src/lib/integration/retrieval-contract.js"; +import { buildCodexAgentsGuidance } from "../src/lib/integration/codex-stack.js"; import { makeAppConfig, makeRolloutFixture, @@ -18,6 +23,7 @@ import { connectCliMcpClient } from "./helpers/mcp-client.js"; const tempDirs: string[] = []; const originalHome = process.env.HOME; const originalCodexHome = process.env.CODEX_HOME; +const originalPath = process.env.PATH; interface SearchMemoriesResponse { query: string; @@ -25,11 +31,19 @@ interface SearchMemoriesResponse { state: string; resolvedState: string; searchOrder?: string[]; + totalMatchedCount?: number; + returnedCount?: number; globalLimitApplied?: boolean; truncatedCount?: number; + resultWindow?: { + start: number; + end: number; + limit: number; + }; fallbackUsed: boolean; stateFallbackUsed?: boolean; markdownFallbackUsed?: boolean; + finalRetrievalMode?: string; retrievalMode: string; retrievalFallbackReason?: string; stateResolution?: { @@ -46,6 +60,12 @@ interface SearchMemoriesResponse { anyMarkdownFallback?: boolean; fallbackReasons?: string[]; executionModes?: string[]; + topicDiagnostics?: Array<{ + scope: string; + state: string; + topic: string; + safeToRewrite: boolean; + }>; checkedPaths: Array<{ scope: string; state: string; @@ -61,6 +81,7 @@ interface SearchMemoriesResponse { results: Array<{ ref: string; state: string; + globalRank?: number; summary: string; matchedFields: string[]; approxReadCost: number; @@ -136,6 +157,28 @@ async function tempDir(prefix: string): Promise { return dir; } +async function withFakePackagedDistCli(callback: () => Promise): Promise { + const fakeDistDir = await tempDir("cam-fake-dist-cli-"); + const fakeDistCliPath = path.join(fakeDistDir, "cli.js"); + const originalOverride = process.env.CODEX_AUTO_MEMORY_DIST_CLI_PATH; + await fs.writeFile( + fakeDistCliPath, + "#!/usr/bin/env node\nconsole.log('fake dist cli');\n", + "utf8" + ); + process.env.CODEX_AUTO_MEMORY_DIST_CLI_PATH = fakeDistCliPath; + + try { + return await callback(); + } finally { + if (originalOverride === undefined) { + delete process.env.CODEX_AUTO_MEMORY_DIST_CLI_PATH; + } else { + process.env.CODEX_AUTO_MEMORY_DIST_CLI_PATH = originalOverride; + } + } +} + async function pathExists(pathname: string): Promise { try { await fs.access(pathname); @@ -154,10 +197,6 @@ async function readJsonFile(pathname: string): Promise> return JSON.parse(await fs.readFile(pathname, "utf8")) as Record; } -function shellQuoteArg(value: string): string { - return `'${value.replace(/'/g, `'\"'\"'`)}'`; -} - async function writeCamShim(binDir: string): Promise { if (process.platform === "win32") { await fs.writeFile(path.join(binDir, "cam.cmd"), "@echo off\r\nexit /b 0\r\n", "utf8"); @@ -216,28 +255,31 @@ function readStructuredContent(result: ToolCallResultLike): T { } afterEach(async () => { + vi.restoreAllMocks(); process.env.HOME = originalHome; if (originalCodexHome === undefined) { delete process.env.CODEX_HOME; } else { process.env.CODEX_HOME = originalCodexHome; } + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); describe("mcp command", () => { - it("installs project-scoped MCP wiring for codex, claude, and gemini without replacing unrelated config", async () => { + it("installs project-scoped MCP wiring for codex without replacing unrelated config", async () => { const homeDir = await tempDir("cam-mcp-install-home-"); const projectDir = await tempDir("cam-mcp-install-project-"); const realProjectDir = await fs.realpath(projectDir); process.env.HOME = homeDir; const codexConfigPath = path.join(realProjectDir, ".codex", "config.toml"); - const claudeConfigPath = path.join(realProjectDir, ".mcp.json"); - const geminiConfigPath = path.join(realProjectDir, ".gemini", "settings.json"); await fs.mkdir(path.dirname(codexConfigPath), { recursive: true }); - await fs.mkdir(path.dirname(geminiConfigPath), { recursive: true }); await fs.writeFile( codexConfigPath, [ @@ -249,55 +291,11 @@ describe("mcp command", () => { ].join("\n"), "utf8" ); - await fs.writeFile( - claudeConfigPath, - JSON.stringify( - { - approvalMode: "project", - mcpServers: { - other_server: { - command: "other", - args: ["serve"] - } - } - }, - null, - 2 - ), - "utf8" - ); - await fs.writeFile( - geminiConfigPath, - JSON.stringify( - { - theme: "ocean", - mcpServers: { - other_server: { - command: "other", - args: ["serve"], - trust: true - } - } - }, - null, - 2 - ), - "utf8" - ); - const codexInstall = runCli(projectDir, ["mcp", "install", "--host", "codex", "--json"], { env: { HOME: homeDir } }); - const claudeInstall = runCli(projectDir, ["mcp", "install", "--host", "claude", "--json"], { - env: { HOME: homeDir } - }); - const geminiInstall = runCli(projectDir, ["mcp", "install", "--host", "gemini", "--json"], { - env: { HOME: homeDir } - }); expect(codexInstall.exitCode, codexInstall.stderr).toBe(0); - expect(claudeInstall.exitCode, claudeInstall.stderr).toBe(0); - expect(geminiInstall.exitCode, geminiInstall.stderr).toBe(0); expect(JSON.parse(codexInstall.stdout)).toMatchObject({ host: "codex", @@ -307,22 +305,6 @@ describe("mcp command", () => { readOnlyRetrieval: true, targetPath: codexConfigPath }); - expect(JSON.parse(claudeInstall.stdout)).toMatchObject({ - host: "claude", - serverName: "codex_auto_memory", - action: "created", - projectPinned: true, - readOnlyRetrieval: true, - targetPath: claudeConfigPath - }); - expect(JSON.parse(geminiInstall.stdout)).toMatchObject({ - host: "gemini", - serverName: "codex_auto_memory", - action: "created", - projectPinned: true, - readOnlyRetrieval: true, - targetPath: geminiConfigPath - }); const codexConfig = await readTomlFile(codexConfigPath); expect(codexConfig).toMatchObject({ @@ -339,40 +321,6 @@ describe("mcp command", () => { } } }); - - const claudeConfig = await readJsonFile(claudeConfigPath); - expect(claudeConfig).toMatchObject({ - approvalMode: "project", - mcpServers: { - other_server: { - command: "other", - args: ["serve"] - }, - codex_auto_memory: { - command: "cam", - args: ["mcp", "serve", "--cwd", realProjectDir], - env: {} - } - } - }); - - const geminiConfig = await readJsonFile(geminiConfigPath); - expect(geminiConfig).toMatchObject({ - theme: "ocean", - mcpServers: { - other_server: { - command: "other", - args: ["serve"], - trust: true - }, - codex_auto_memory: { - command: "cam", - args: ["mcp", "serve"], - cwd: realProjectDir, - trust: false - } - } - }); }); it("reports updated then unchanged on repeated install and makes doctor report ok for installed hosts", async () => { @@ -430,7 +378,7 @@ describe("mcp command", () => { "utf8" ); - for (const host of ["codex", "claude", "gemini"] as const) { + for (const host of ["codex"] as const) { const first = runCli(projectDir, ["mcp", "install", "--host", host, "--json"], { env: { HOME: homeDir } }); @@ -476,22 +424,6 @@ describe("mcp command", () => { projectPinned: true }) }), - expect.objectContaining({ - host: "claude", - status: "ok", - configCheck: expect.objectContaining({ - exists: true, - projectPinned: true - }) - }), - expect.objectContaining({ - host: "gemini", - status: "ok", - configCheck: expect.objectContaining({ - exists: true, - projectPinned: true - }) - }), expect.objectContaining({ host: "generic", status: "manual" @@ -557,7 +489,7 @@ describe("mcp command", () => { "utf8" ); - for (const host of ["codex", "claude", "gemini"] as const) { + for (const host of ["codex"] as const) { const result = runCli(projectDir, ["mcp", "install", "--host", host, "--json"], { env: { HOME: homeDir } }); @@ -580,34 +512,9 @@ describe("mcp command", () => { } } }); - - const claudeConfig = await readJsonFile(path.join(projectDir, ".mcp.json")); - expect(claudeConfig).toMatchObject({ - mcpServers: { - codex_auto_memory: { - command: "cam", - args: ["mcp", "serve", "--cwd", realProjectDir], - env: {}, - label: "keep-me" - } - } - }); - - const geminiConfig = await readJsonFile(path.join(projectDir, ".gemini", "settings.json")); - expect(geminiConfig).toMatchObject({ - mcpServers: { - codex_auto_memory: { - command: "cam", - args: ["mcp", "serve"], - cwd: realProjectDir, - trust: false, - label: "keep-me" - } - } - }); }); - it("supports install --cwd for writing another project's host config", async () => { + it("supports install --cwd for writing another project's codex config", async () => { const homeDir = await tempDir("cam-mcp-install-cwd-home-"); const projectDir = await tempDir("cam-mcp-install-cwd-project-"); const callerDir = await tempDir("cam-mcp-install-cwd-caller-"); @@ -616,25 +523,25 @@ describe("mcp command", () => { const result = runCli( callerDir, - ["mcp", "install", "--host", "claude", "--cwd", projectDir, "--json"], + ["mcp", "install", "--host", "codex", "--cwd", projectDir, "--json"], { env: { HOME: homeDir } } ); expect(result.exitCode, result.stderr).toBe(0); expect(JSON.parse(result.stdout)).toMatchObject({ - host: "claude", + host: "codex", action: "created", projectRoot: realProjectDir, - targetPath: path.join(realProjectDir, ".mcp.json") + targetPath: path.join(realProjectDir, ".codex", "config.toml") }); - const claudeConfig = await readJsonFile(path.join(realProjectDir, ".mcp.json")); - expect(claudeConfig).toMatchObject({ - mcpServers: { + const codexConfig = await readTomlFile(path.join(realProjectDir, ".codex", "config.toml")); + expect(codexConfig).toMatchObject({ + mcp_servers: { codex_auto_memory: { command: "cam", - args: ["mcp", "serve", "--cwd", realProjectDir], - env: {} + args: ["mcp", "serve"], + cwd: realProjectDir } } }); @@ -679,7 +586,7 @@ describe("mcp command", () => { "[mcp_servers.codex_auto_memory]", "AGENTS.md", "search_memories", - "cam recall search" + "memory-recall.sh search" ] }, { @@ -770,14 +677,16 @@ describe("mcp command", () => { expect(payload.workflowContract).toMatchObject({ recommendedPreset: "state=auto, limit=8", routePreference: { - preferredRoute: "mcp-first" + preferredRoute: "mcp-first", + localBridge: expect.stringContaining("memory-recall.sh"), + resolvedCli: expect.stringContaining("resolved CLI recall commands") }, recallWorkflow: { recallFirst: expect.stringContaining("recall durable memory first"), progressiveDisclosure: "Use progressive disclosure: search -> timeline -> details." }, cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realProjectDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` } }); expect(payload.agentsGuidance).toMatchObject({ @@ -785,7 +694,7 @@ describe("mcp command", () => { snippetFormat: "markdown" }); expect(payload.agentsGuidance?.snippet).toContain("search_memories"); - expect(payload.agentsGuidance?.snippet).toContain("cam recall search"); + expect(payload.agentsGuidance?.snippet).toContain("memory-recall.sh search"); expect(payload.agentsGuidance?.notes).toEqual( expect.arrayContaining([expect.stringContaining("local bridge")]) ); @@ -958,6 +867,49 @@ describe("mcp command", () => { expect(after).toBe(before); }); + it("does not append a second managed block when AGENTS.md already contains the current unmanaged snippet", async () => { + const homeDir = await tempDir("cam-mcp-apply-guidance-unmanaged-home-"); + const projectDir = await tempDir("cam-mcp-apply-guidance-unmanaged-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + const printConfigResult = runCli( + projectDir, + ["mcp", "print-config", "--host", "codex", "--json"], + { env: { HOME: homeDir } } + ); + expect(printConfigResult.exitCode, printConfigResult.stderr).toBe(0); + const printConfigPayload = JSON.parse(printConfigResult.stdout) as { + agentsGuidance: { snippet: string }; + }; + await fs.writeFile( + path.join(realProjectDir, "AGENTS.md"), + ["# Project Notes", "", printConfigPayload.agentsGuidance.snippet, "", "- Tail note."].join("\n"), + "utf8" + ); + + const before = await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8"); + const result = runCli( + projectDir, + ["mcp", "apply-guidance", "--host", "codex", "--json"], + { + env: { HOME: homeDir } + } + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + action: "unchanged", + createdFile: false, + managedBlockVersion: "codex-agents-guidance-v1" + }); + + const after = await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8"); + expect(after).toBe(before); + expect(after.match(/cam:codex-agents-guidance:start/gmu)).toBeNull(); + }); + it("preserves bytes outside the managed block when updating guidance", async () => { const homeDir = await tempDir("cam-mcp-apply-guidance-verbatim-home-"); const projectDir = await tempDir("cam-mcp-apply-guidance-verbatim-project-"); @@ -1114,6 +1066,44 @@ describe("mcp command", () => { }); }); + it("keeps managed AGENTS guidance unchanged across HOME and PATH differences", async () => { + const homeDirOne = await tempDir("cam-mcp-doctor-agents-stable-home-one-"); + const homeDirTwo = await tempDir("cam-mcp-doctor-agents-stable-home-two-"); + const projectDir = await tempDir("cam-mcp-doctor-agents-stable-project-"); + const realProjectDir = await fs.realpath(projectDir); + const binDir = await tempDir("cam-mcp-doctor-agents-stable-bin-"); + process.env.HOME = homeDirOne; + + await fs.writeFile(path.join(binDir, "cam"), "#!/bin/sh\nexit 0\n", "utf8"); + await fs.chmod(path.join(binDir, "cam"), 0o644); + + const createResult = runCli(projectDir, ["mcp", "apply-guidance", "--host", "codex", "--json"], { + env: { HOME: homeDirOne } + }); + expect(createResult.exitCode, createResult.stderr).toBe(0); + const before = await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8"); + + const doctorResult = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { + env: { HOME: homeDirTwo, PATH: await buildPathWithoutCam(binDir) } + }); + expect(doctorResult.exitCode, doctorResult.stderr).toBe(0); + expect(JSON.parse(doctorResult.stdout)).toMatchObject({ + agentsGuidance: { + status: "ok" + } + }); + + const applyResult = runCli(projectDir, ["mcp", "apply-guidance", "--host", "codex", "--json"], { + env: { HOME: homeDirTwo, PATH: await buildPathWithoutCam(binDir) } + }); + expect(applyResult.exitCode, applyResult.stderr).toBe(0); + expect(JSON.parse(applyResult.stdout)).toMatchObject({ + action: "unchanged" + }); + const after = await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8"); + expect(after).toBe(before); + }); + it("does not treat a fenced guidance example as installed AGENTS guidance", async () => { const homeDir = await tempDir("cam-mcp-doctor-agents-fenced-home-"); const projectDir = await tempDir("cam-mcp-doctor-agents-fenced-project-"); @@ -1245,13 +1235,7 @@ describe("mcp command", () => { projectRoot: string; readOnlyRetrieval: boolean; snippet: string; - workflowContract: { - cliFallback: { - searchCommand: string; - timelineCommand: string; - detailsCommand: string; - }; - }; + workflowContract?: unknown; }; expect(payload).toMatchObject({ host: "generic", @@ -1259,13 +1243,72 @@ describe("mcp command", () => { readOnlyRetrieval: true }); expect(payload.snippet).toContain(realProjectDir); - expect(payload.workflowContract).toMatchObject({ - cliFallback: { - searchCommand: expect.stringContaining(realProjectDir), - timelineCommand: expect.stringContaining(realProjectDir), - detailsCommand: expect.stringContaining(realProjectDir) - } + expect(payload.workflowContract).toBeUndefined(); + }); + + it("keeps workflowContract absent for claude and gemini print-config JSON payloads", async () => { + const homeDir = await tempDir("cam-mcp-print-non-codex-json-home-"); + const projectDir = await tempDir("cam-mcp-print-non-codex-json-project-"); + process.env.HOME = homeDir; + + for (const host of ["claude", "gemini"] as const) { + const result = runCli(projectDir, ["mcp", "print-config", "--host", host, "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout).workflowContract).toBeUndefined(); + } + }); + + it("keeps generic mcp doctor host-aware instead of surfacing codex-only mutable capabilities", async () => { + const homeDir = await tempDir("cam-mcp-doctor-generic-home-"); + const projectDir = await tempDir("cam-mcp-doctor-generic-project-"); + process.env.HOME = homeDir; + + const result = runCli(projectDir, ["mcp", "doctor", "--host", "generic", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + commandSurface: { + install: boolean; + serve: boolean; + printConfig: boolean; + applyGuidance: boolean; + doctor: boolean; + installHosts: string[]; + applyGuidanceHosts: string[]; + }; + agentsGuidance: null; + applySafety: null; + experimentalHooks: null; + codexStack: null; + hosts: Array<{ + host: string; + status: string; + }>; + }; + + expect(payload.commandSurface).toMatchObject({ + install: false, + serve: true, + printConfig: true, + applyGuidance: false, + doctor: true, + installHosts: ["codex"], + applyGuidanceHosts: ["codex"] }); + expect(payload.agentsGuidance).toBeNull(); + expect(payload.applySafety).toBeNull(); + expect(payload.experimentalHooks).toBeNull(); + expect(payload.codexStack).toBeNull(); + expect(payload.hosts).toEqual([ + expect.objectContaining({ + host: "generic", + status: "manual" + }) + ]); }); it("pins Codex AGENTS guidance fallback commands when print-config uses --cwd", async () => { @@ -1289,14 +1332,12 @@ describe("mcp command", () => { snippet: string; }; }; + expect(payload.agentsGuidance.snippet).toContain("memory-recall.sh search"); expect(payload.agentsGuidance.snippet).toContain( - `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realProjectDir)}` + `timeline ""` ); expect(payload.agentsGuidance.snippet).toContain( - `cam recall timeline "" --cwd ${shellQuoteArg(realProjectDir)}` - ); - expect(payload.agentsGuidance.snippet).toContain( - `cam recall details "" --cwd ${shellQuoteArg(realProjectDir)}` + `details ""` ); expect(payload.agentsGuidance.snippet).toContain( `post-work-memory-review.sh` @@ -1329,7 +1370,9 @@ describe("mcp command", () => { }; expect(payload.projectRoot).toBe(await fs.realpath(projectDir)); expect(payload.fallbackAssets.recommendedSkillInstallCommand).toBe( - `cam skills install --surface runtime --cwd ${shellQuoteArg(payload.projectRoot)}` + buildResolvedCliCommand("skills install --surface runtime", { + cwd: payload.projectRoot + }) ); }); @@ -1337,13 +1380,19 @@ describe("mcp command", () => { const homeDir = await tempDir("cam-mcp-doctor-home-"); const projectDir = await tempDir("cam-mcp-doctor-project-"); const callerDir = await tempDir("cam-mcp-doctor-caller-"); + const binDir = await tempDir("cam-mcp-doctor-bin-"); const realProjectDir = await fs.realpath(projectDir); process.env.HOME = homeDir; + await writeCamShim(binDir); + const env = { + HOME: homeDir, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}` + }; const codexSnippetResult = runCli( projectDir, ["mcp", "print-config", "--host", "codex", "--json"], - { env: { HOME: homeDir } } + { env } ); expect(codexSnippetResult.exitCode, codexSnippetResult.stderr).toBe(0); const codexSnippetPayload = JSON.parse(codexSnippetResult.stdout) as { snippet: string }; @@ -1351,7 +1400,7 @@ describe("mcp command", () => { const claudeSnippetResult = runCli( projectDir, ["mcp", "print-config", "--host", "claude", "--json"], - { env: { HOME: homeDir } } + { env } ); expect(claudeSnippetResult.exitCode, claudeSnippetResult.stderr).toBe(0); const claudeSnippetPayload = JSON.parse(claudeSnippetResult.stdout) as { snippet: string }; @@ -1364,13 +1413,13 @@ describe("mcp command", () => { ); await fs.writeFile(path.join(projectDir, ".mcp.json"), `${claudeSnippetPayload.snippet}\n`, "utf8"); - expect(runCli(projectDir, ["hooks", "install"], { env: { HOME: homeDir } }).exitCode).toBe(0); - expect(runCli(projectDir, ["skills", "install"], { env: { HOME: homeDir } }).exitCode).toBe(0); + expect(runCli(projectDir, ["hooks", "install"], { env }).exitCode).toBe(0); + expect(runCli(projectDir, ["skills", "install"], { env }).exitCode).toBe(0); const result = runCli( callerDir, ["mcp", "doctor", "--cwd", projectDir, "--json"], - { env: { HOME: homeDir } } + { env } ); expect(result.exitCode, result.stderr).toBe(0); @@ -1389,6 +1438,8 @@ describe("mcp command", () => { printConfig: boolean; applyGuidance: boolean; doctor: boolean; + installHosts: string[]; + applyGuidanceHosts: string[]; }; fallbackAssets: { hookHelpersInstalled: boolean; @@ -1461,12 +1512,18 @@ describe("mcp command", () => { expect(payload.projectRoot).toBe(realProjectDir); expect(payload.serverName).toBe("codex_auto_memory"); expect(payload.readOnlyRetrieval).toBe(true); + expect(payload.agentsGuidance).toMatchObject({ + exists: false, + status: "missing" + }); expect(payload.commandSurface).toMatchObject({ install: true, serve: true, printConfig: true, applyGuidance: true, - doctor: true + doctor: true, + installHosts: ["codex"], + applyGuidanceHosts: ["codex"] }); expect(payload.fallbackAssets).toMatchObject({ hookHelpersInstalled: true, @@ -1560,32 +1617,53 @@ describe("mcp command", () => { recallFirst: expect.stringContaining("recall durable memory first"), progressiveDisclosure: "Use progressive disclosure: search -> timeline -> details.", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realProjectDir)}`, - timelineCommand: `cam recall timeline "" --cwd ${shellQuoteArg(realProjectDir)}`, - detailsCommand: `cam recall details "" --cwd ${shellQuoteArg(realProjectDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}`, + timelineCommand: `cam recall timeline "" --cwd ${JSON.stringify(realProjectDir)}`, + detailsCommand: `cam recall details "" --cwd ${JSON.stringify(realProjectDir)}` }, postWorkSyncReview: { helperScript: "post-work-memory-review.sh", - syncCommand: `cam sync --cwd ${shellQuoteArg(realProjectDir)}`, - reviewCommand: `cam memory --recent --cwd ${shellQuoteArg(realProjectDir)}` + syncCommand: `cam sync --cwd ${JSON.stringify(realProjectDir)}`, + reviewCommand: `cam memory --recent --cwd ${JSON.stringify(realProjectDir)}` } }); expect(payload.codexStack).toMatchObject({ status: "warning", - recommendedRoute: "cli-direct", + recommendedRoute: "mcp", preset: "state=auto, limit=8", assetVersion: RETRIEVAL_INTEGRATION_ASSET_VERSION, mcpReady: false, hookCaptureReady: true, + hookCaptureOperationalReady: true, hookRecallReady: true, - hookRecallOperationalReady: false, + hookRecallOperationalReady: true, skillReady: true, workflowConsistent: false }); + expect(payload.fallbackAssets.assets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "memory-recall", + launcher: expect.objectContaining({ + resolution: "cam-path", + operational: true + }) + }), + expect.objectContaining({ + id: "post-work-memory-review", + launcher: expect.objectContaining({ + resolution: "cam-path", + operational: true + }) + }) + ]) + ); expect(payload.retrievalSidecar).toMatchObject({ status: "warning", summary: expect.stringContaining("Markdown"), - repairCommand: `cam memory reindex --scope all --state all --cwd ${shellQuoteArg(realProjectDir)}`, + repairCommand: expect.stringContaining( + `memory reindex --scope all --state all --cwd ${JSON.stringify(realProjectDir)}` + ), checks: expect.arrayContaining([ expect.objectContaining({ scope: "project", @@ -1641,7 +1719,7 @@ describe("mcp command", () => { }), expect.objectContaining({ host: "claude", - status: "ok", + status: "manual", configCheck: expect.objectContaining({ exists: true, projectPinned: true @@ -1874,10 +1952,100 @@ describe("mcp command", () => { expect(await pathExists(memoryRoot)).toBe(false); }); - it("reports CODEX_HOME runtime skills path separately from the official skills path", async () => { - const homeDir = await tempDir("cam-mcp-doctor-codex-home-home-"); - const codexHome = await tempDir("cam-mcp-doctor-codex-home-codex-home-"); - const projectDir = await tempDir("cam-mcp-doctor-codex-home-project-"); + it("surfaces canonical layout diagnostics through mcp doctor and integrations doctor", async () => { + const homeDir = await tempDir("cam-mcp-layout-diagnostics-home-"); + const projectDir = await tempDir("cam-mcp-layout-diagnostics-project-"); + const memoryRoot = await tempDir("cam-mcp-layout-diagnostics-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + await fs.writeFile( + path.join(path.dirname(store.getMemoryFile("project")), "Bad Topic.md"), + "# stray\n", + "utf8" + ); + await fs.writeFile( + path.join(path.dirname(store.getMemoryFile("project")), "retrieval-index.backup.json"), + "{}\n", + "utf8" + ); + await fs.writeFile(store.getMemoryFile("project"), "# Project Memory\n\nDrifted index.\n", "utf8"); + + const mcpDoctor = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { + env: { HOME: homeDir } + }); + expect(mcpDoctor.exitCode, mcpDoctor.stderr).toBe(0); + expect(JSON.parse(mcpDoctor.stdout)).toMatchObject({ + layoutDiagnostics: { + status: "warning", + diagnostics: expect.arrayContaining([ + expect.objectContaining({ + kind: "malformed-topic-filename", + fileName: "Bad Topic.md" + }), + expect.objectContaining({ + kind: "unexpected-sidecar", + fileName: "retrieval-index.backup.json" + }), + expect.objectContaining({ + kind: "index-drift", + fileName: "MEMORY.md" + }) + ]) + } + }); + + const integrationsDoctor = runCli( + projectDir, + ["integrations", "doctor", "--host", "codex", "--json"], + { + env: { HOME: homeDir } + } + ); + expect(integrationsDoctor.exitCode, integrationsDoctor.stderr).toBe(0); + expect(JSON.parse(integrationsDoctor.stdout)).toMatchObject({ + layoutDiagnostics: { + status: "warning", + diagnostics: expect.arrayContaining([ + expect.objectContaining({ + kind: "malformed-topic-filename", + fileName: "Bad Topic.md" + }), + expect.objectContaining({ + kind: "unexpected-sidecar", + fileName: "retrieval-index.backup.json" + }), + expect.objectContaining({ + kind: "index-drift", + fileName: "MEMORY.md" + }) + ]) + } + }); + }); + + it("reports CODEX_HOME runtime skills path separately from the official skills path", async () => { + const homeDir = await tempDir("cam-mcp-doctor-codex-home-home-"); + const codexHome = await tempDir("cam-mcp-doctor-codex-home-codex-home-"); + const projectDir = await tempDir("cam-mcp-doctor-codex-home-project-"); const realProjectDir = await fs.realpath(projectDir); process.env.HOME = homeDir; process.env.CODEX_HOME = codexHome; @@ -1900,7 +2068,7 @@ describe("mcp command", () => { runtimeAssetDir: path.join(codexHome, "skills", "codex-auto-memory-recall"), runtimeSource: "CODEX_HOME", preferredInstallSurface: "runtime", - recommendedSkillInstallCommand: "cam skills install --surface runtime", + recommendedSkillInstallCommand: buildResolvedCliCommand("skills install --surface runtime"), runtimeSkillPresent: true, runtimeSkillInstalled: true, runtimeSkillMatchesCanonical: true, @@ -1927,8 +2095,19 @@ describe("mcp command", () => { officialProjectSkillReady: false, anySkillSurfaceInstalled: true, anySkillSurfaceReady: true, + preferredSkillSurfaceReady: true, installedSkillSurfaces: ["runtime"], readySkillSurfaces: ["runtime"], + skillSurfaces: { + runtime: { + installed: true, + discoverable: true, + listed: true, + executable: true, + matchesCanonical: true, + preferred: true + } + }, skillPathDrift: true, skillInstalled: true } @@ -1956,7 +2135,7 @@ describe("mcp command", () => { fallbackAssets: { runtimeSkillDir: path.join(homeDir, ".codex", "skills", "codex-auto-memory-recall"), preferredInstallSurface: "runtime", - recommendedSkillInstallCommand: "cam skills install --surface runtime", + recommendedSkillInstallCommand: buildResolvedCliCommand("skills install --surface runtime"), runtimeSkillPresent: false, runtimeSkillInstalled: false, runtimeSkillReady: false, @@ -1981,12 +2160,25 @@ describe("mcp command", () => { officialProjectSkillReady: false, anySkillSurfaceInstalled: true, anySkillSurfaceReady: true, + preferredSkillSurfaceReady: false, installedSkillSurfaces: ["official-user"], readySkillSurfaces: ["official-user"], + skillSurfaces: { + "official-user": { + installed: true, + discoverable: true, + listed: true, + executable: false, + matchesCanonical: true, + preferred: false + } + }, skillInstalled: true }, codexStack: { - skillReady: true + skillReady: false, + workflowAssetsConsistent: false, + workflowConsistent: false } }); }); @@ -2029,12 +2221,25 @@ describe("mcp command", () => { officialProjectSkillReady: true, anySkillSurfaceInstalled: true, anySkillSurfaceReady: true, + preferredSkillSurfaceReady: false, installedSkillSurfaces: ["official-project"], readySkillSurfaces: ["official-project"], + skillSurfaces: { + "official-project": { + installed: true, + discoverable: true, + listed: true, + executable: false, + matchesCanonical: true, + preferred: false + } + }, skillInstalled: true }, codexStack: { - skillReady: true + skillReady: false, + workflowAssetsConsistent: false, + workflowConsistent: false } }); }); @@ -2091,6 +2296,7 @@ describe("mcp command", () => { startupDoctorInstalled: boolean; anySkillSurfaceInstalled: boolean; anySkillSurfaceReady: boolean; + preferredSkillSurfaceReady: boolean; skillInstalled: boolean; fallbackAvailable: boolean; assets: Array<{ @@ -2108,6 +2314,7 @@ describe("mcp command", () => { startupDoctorInstalled: false, anySkillSurfaceInstalled: true, anySkillSurfaceReady: false, + preferredSkillSurfaceReady: false, skillInstalled: false, fallbackAvailable: false }); @@ -2131,6 +2338,54 @@ describe("mcp command", () => { ); }); + it("keeps manual-only Claude wiring out of the same readiness tier as Codex", async () => { + const homeDir = await tempDir("cam-mcp-doctor-claude-manual-home-"); + const projectDir = await tempDir("cam-mcp-doctor-claude-manual-project-"); + process.env.HOME = homeDir; + + await fs.writeFile( + path.join(projectDir, ".mcp.json"), + JSON.stringify( + { + mcpServers: { + codex_auto_memory: { + command: "cam", + args: ["mcp", "serve", "--cwd", projectDir] + } + } + }, + null, + 2 + ), + "utf8" + ); + + const result = runCli(projectDir, ["mcp", "doctor", "--host", "claude", "--json"], { + env: { HOME: homeDir } + }); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + hosts: Array<{ + host: string; + status: string; + summary: string; + configScopeSummary?: string; + }>; + }; + + expect(payload.hosts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + host: "claude", + status: "manual", + configScopeSummary: "project-ready", + summary: expect.stringContaining("manual") + }) + ]) + ); + }); + it("flags stale fallback assets when version markers remain but key content signatures are missing", async () => { const homeDir = await tempDir("cam-mcp-doctor-marker-only-home-"); const projectDir = await tempDir("cam-mcp-doctor-marker-only-project-"); @@ -2192,6 +2447,7 @@ describe("mcp command", () => { startupDoctorInstalled: boolean; anySkillSurfaceInstalled: boolean; anySkillSurfaceReady: boolean; + preferredSkillSurfaceReady: boolean; skillInstalled: boolean; fallbackAvailable: boolean; assets: Array<{ @@ -2209,6 +2465,7 @@ describe("mcp command", () => { startupDoctorInstalled: false, anySkillSurfaceInstalled: true, anySkillSurfaceReady: false, + preferredSkillSurfaceReady: false, skillInstalled: false, fallbackAvailable: false }); @@ -2293,100 +2550,270 @@ describe("mcp command", () => { ); expect(payload.codexStack).toMatchObject({ status: "warning", - recommendedRoute: "cli-direct", + recommendedRoute: "mcp", hookRecallReady: false }); }); it("distinguishes configured MCP wiring from operational readiness when cam is unavailable on PATH", async () => { - const homeDir = await tempDir("cam-mcp-doctor-command-home-"); - const projectDir = await tempDir("cam-mcp-doctor-command-project-"); - const emptyPathDir = await tempDir("cam-mcp-doctor-command-empty-path-"); - process.env.HOME = homeDir; + await withFakePackagedDistCli(async () => { + const homeDir = await tempDir("cam-mcp-doctor-command-home-"); + const projectDir = await tempDir("cam-mcp-doctor-command-project-"); + const emptyPathDir = await tempDir("cam-mcp-doctor-command-empty-path-"); + process.env.HOME = homeDir; + + const installResult = runCli(projectDir, ["mcp", "install", "--host", "codex", "--json"], { + env: { + HOME: homeDir, + PATH: await buildPathWithoutCam(emptyPathDir) + } + }); + expect(installResult.exitCode, installResult.stderr).toBe(0); - const installResult = runCli(projectDir, ["mcp", "install", "--host", "codex", "--json"], { - env: { - HOME: homeDir, - PATH: await buildPathWithoutCam(emptyPathDir) - } + const doctorResult = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { + env: { + HOME: homeDir, + PATH: await buildPathWithoutCam(emptyPathDir) + } + }); + expect(doctorResult.exitCode, doctorResult.stderr).toBe(0); + + const payload = JSON.parse(doctorResult.stdout) as { + codexStack: { + recommendedRoute: string; + currentlyOperationalRoute: string; + routeKind: string; + routeEvidence: string[]; + mcpReady: boolean; + mcpOperationalReady: boolean; + camCommandAvailable: boolean; + shellDependencyLevel: string; + hostMutationRequired: boolean; + preferredRouteBlockers: string[]; + currentOperationalBlockers: string[]; + hookCaptureOperationalReady: boolean; + hookRecallOperationalReady: boolean; + }; + hosts: Array<{ + host: string; + status: string; + }>; + }; + + expect(payload.hosts).toEqual([ + expect.objectContaining({ + host: "codex", + status: "ok" + }) + ]); + expect(payload.codexStack).toMatchObject({ + recommendedRoute: "mcp", + currentlyOperationalRoute: "cli-direct", + routeKind: "fallback-cli", + mcpReady: true, + mcpOperationalReady: false, + camCommandAvailable: false, + shellDependencyLevel: "required", + hostMutationRequired: false, + preferredRouteBlockers: expect.arrayContaining(["cam-command-unavailable-for-mcp"]), + currentOperationalBlockers: [], + hookCaptureOperationalReady: false, + hookRecallOperationalReady: false + }); + expect(payload.codexStack.routeEvidence).toEqual( + expect.arrayContaining(["mcp-config-present", "resolved-cli-launcher-verified"]) + ); }); - expect(installResult.exitCode, installResult.stderr).toBe(0); + }); - const doctorResult = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { - env: { - HOME: homeDir, - PATH: await buildPathWithoutCam(emptyPathDir) - } + it("treats hook recall assets as operational when their embedded node launcher stays valid", async () => { + await withFakePackagedDistCli(async () => { + const homeDir = await tempDir("cam-mcp-doctor-hook-op-home-"); + const projectDir = await tempDir("cam-mcp-doctor-hook-op-project-"); + const emptyPathDir = await tempDir("cam-mcp-doctor-hook-op-empty-path-"); + process.env.HOME = homeDir; + + const hooksInstall = runCli(projectDir, ["hooks", "install", "--json"], { + env: { + HOME: homeDir, + PATH: await buildPathWithoutCam(emptyPathDir) + } + }); + expect(hooksInstall.exitCode, hooksInstall.stderr).toBe(0); + + const fakeCliPath = path.join(emptyPathDir, "fake-cam-dist-cli.js"); + await fs.writeFile(fakeCliPath, 'console.log("fake cli");\n', "utf8"); + const recallScriptPath = path.join(homeDir, ".codex-auto-memory", "hooks", "memory-recall.sh"); + const originalRecallScript = await fs.readFile(recallScriptPath, "utf8"); + const patchedRecallScript = originalRecallScript.replace( + /node\s+"[^"]+cli\.js"/u, + `node ${JSON.stringify(fakeCliPath)}` + ); + expect(patchedRecallScript).not.toBe(originalRecallScript); + await fs.writeFile(recallScriptPath, patchedRecallScript, "utf8"); + + const doctorResult = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { + env: { + HOME: homeDir, + PATH: await buildPathWithoutCam(emptyPathDir) + } + }); + expect(doctorResult.exitCode, doctorResult.stderr).toBe(0); + + const payload = JSON.parse(doctorResult.stdout) as { + fallbackAssets: { + assets: Array<{ + id: string; + launcher?: { + resolution: string; + operational: boolean; + missingPaths: string[]; + }; + }>; + }; + codexStack: { + recommendedRoute: string; + currentlyOperationalRoute: string; + routeKind: string; + camCommandAvailable: boolean; + hookRecallReady: boolean; + hookRecallOperationalReady: boolean; + routeEvidence: string[]; + currentOperationalBlockers: string[]; + }; + }; + + expect(payload.codexStack).toMatchObject({ + recommendedRoute: "mcp", + currentlyOperationalRoute: "hooks-fallback", + routeKind: "fallback-hooks", + camCommandAvailable: false, + hookRecallReady: true, + hookRecallOperationalReady: true, + currentOperationalBlockers: [] + }); + expect(payload.codexStack.routeEvidence).toEqual( + expect.arrayContaining(["hook-recall-operational", "resolved-cli-launcher-verified"]) + ); + expect(payload.codexStack.routeEvidence).not.toContain("skill-guidance-ready"); + expect(payload.fallbackAssets.assets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "memory-recall", + launcher: expect.objectContaining({ + resolution: "node-dist", + operational: true, + missingPaths: [] + }) + }) + ]) + ); }); - expect(doctorResult.exitCode, doctorResult.stderr).toBe(0); + }); - const payload = JSON.parse(doctorResult.stdout) as { - codexStack: { - recommendedRoute: string; - mcpReady: boolean; - mcpOperationalReady: boolean; - camCommandAvailable: boolean; - hookCaptureOperationalReady: boolean; - hookRecallOperationalReady: boolean; + it("flags hook recall assets as stale when their embedded node launcher path is broken", async () => { + await withFakePackagedDistCli(async () => { + const homeDir = await tempDir("cam-mcp-doctor-hook-launcher-home-"); + const projectDir = await tempDir("cam-mcp-doctor-hook-launcher-project-"); + const emptyPathDir = await tempDir("cam-mcp-doctor-hook-launcher-empty-path-"); + process.env.HOME = homeDir; + + const hooksInstall = runCli(projectDir, ["hooks", "install", "--json"], { + env: { + HOME: homeDir, + PATH: await buildPathWithoutCam(emptyPathDir) + } + }); + expect(hooksInstall.exitCode, hooksInstall.stderr).toBe(0); + + const recallScriptPath = path.join(homeDir, ".codex-auto-memory", "hooks", "memory-recall.sh"); + const originalRecallScript = await fs.readFile(recallScriptPath, "utf8"); + const brokenRecallScript = originalRecallScript.replace( + /node\s+"[^"]+cli\.js"/u, + 'node "/tmp/missing-cam-dist-cli.js"' + ); + expect(brokenRecallScript).not.toBe(originalRecallScript); + await fs.writeFile(recallScriptPath, brokenRecallScript, "utf8"); + + const doctorResult = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { + env: { + HOME: homeDir, + PATH: await buildPathWithoutCam(emptyPathDir) + } + }); + expect(doctorResult.exitCode, doctorResult.stderr).toBe(0); + + const payload = JSON.parse(doctorResult.stdout) as { + fallbackAssets: { + hookHelpersInstalled: boolean; + assets: Array<{ + id: string; + status: string; + launcher?: { + resolution: string; + operational: boolean; + missingPaths: string[]; + }; + }>; + }; + codexStack: { + recommendedRoute: string; + hookRecallReady: boolean; + hookRecallOperationalReady: boolean; + }; }; - hosts: Array<{ - host: string; - status: string; - }>; - }; - expect(payload.hosts).toEqual([ - expect.objectContaining({ - host: "codex", - status: "ok" - }) - ]); - expect(payload.codexStack).toMatchObject({ - recommendedRoute: "cli-direct", - mcpReady: true, - mcpOperationalReady: false, - camCommandAvailable: false, - hookCaptureOperationalReady: false, - hookRecallOperationalReady: false + expect(payload.fallbackAssets.hookHelpersInstalled).toBe(false); + expect(payload.fallbackAssets.assets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "memory-recall", + status: "stale", + launcher: expect.objectContaining({ + resolution: "node-dist", + operational: false, + missingPaths: ["/tmp/missing-cam-dist-cli.js"] + }) + }) + ]) + ); + expect(payload.codexStack).toMatchObject({ + recommendedRoute: "mcp", + hookRecallReady: false, + hookRecallOperationalReady: false + }); }); }); - it("does not treat hook recall assets as operational when cam is unavailable on PATH", async () => { - const homeDir = await tempDir("cam-mcp-doctor-hook-op-home-"); - const projectDir = await tempDir("cam-mcp-doctor-hook-op-project-"); - const emptyPathDir = await tempDir("cam-mcp-doctor-hook-op-empty-path-"); + it("does not report executable fallback when only skill guidance is installed", async () => { + const homeDir = await tempDir("cam-mcp-doctor-skill-only-home-"); + const projectDir = await tempDir("cam-mcp-doctor-skill-only-project-"); process.env.HOME = homeDir; - const hooksInstall = runCli(projectDir, ["hooks", "install", "--json"], { - env: { - HOME: homeDir, - PATH: await buildPathWithoutCam(emptyPathDir) - } + const skillsInstall = runCli(projectDir, ["skills", "install", "--json"], { + env: { HOME: homeDir } }); - expect(hooksInstall.exitCode, hooksInstall.stderr).toBe(0); + expect(skillsInstall.exitCode, skillsInstall.stderr).toBe(0); - const doctorResult = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { - env: { - HOME: homeDir, - PATH: await buildPathWithoutCam(emptyPathDir) - } + const doctorResult = runCli(projectDir, ["mcp", "doctor", "--json"], { + env: { HOME: homeDir } }); expect(doctorResult.exitCode, doctorResult.stderr).toBe(0); const payload = JSON.parse(doctorResult.stdout) as { - codexStack: { - recommendedRoute: string; - camCommandAvailable: boolean; - hookRecallReady: boolean; - hookRecallOperationalReady: boolean; + fallbackAssets: { + skillInstalled: boolean; + guidanceAvailable: boolean; + shellFallbackAvailable: boolean; + fallbackAvailable: boolean; }; }; - expect(payload.codexStack).toMatchObject({ - recommendedRoute: "cli-direct", - camCommandAvailable: false, - hookRecallReady: true, - hookRecallOperationalReady: false + expect(payload.fallbackAssets).toMatchObject({ + skillInstalled: true, + guidanceAvailable: true, + shellFallbackAvailable: false, + fallbackAvailable: false }); }); @@ -2427,7 +2854,7 @@ describe("mcp command", () => { expect(JSON.parse(result.stdout)).toMatchObject({ retrievalSidecar: { status: "warning", - repairCommand: "cam memory reindex --scope project --state active", + repairCommand: buildResolvedCliCommand("memory reindex --scope project --state active"), checks: expect.arrayContaining([ expect.objectContaining({ scope: "project", @@ -2524,27 +2951,45 @@ describe("mcp command", () => { const expectedCore = { recommendedPreset: "state=auto, limit=8", preferredRoute: "mcp-first", + fallbackOrder: ["mcp", "local-bridge", "resolved-cli"], launcher: { commandName: "cam", requiresPathResolution: true, hookHelpersShellOnly: true }, routePreference: { - preferredRoute: "mcp-first" + preferredRoute: "mcp-first", + localBridge: "If the retrieval MCP server is unavailable, fall back to the local recall bridge bundle through memory-recall.sh search|timeline|details.", + resolvedCli: "If the local bridge bundle is unavailable, fall back to the resolved CLI recall commands." }, recallWorkflow: { progressiveDisclosure: "Use progressive disclosure: search -> timeline -> details." }, + executionContract: { + preferredRoute: "mcp-first", + recommendedPreset: "state=auto, limit=8", + fallbackOrder: ["mcp", "local-bridge", "resolved-cli"] + }, + modelGuidanceContract: { + progressiveDisclosure: "Use progressive disclosure: search -> timeline -> details." + }, + hostWiringContract: { + launcher: { + commandName: "cam", + requiresPathResolution: true, + hookHelpersShellOnly: true + } + }, cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realProjectDir)}`, - timelineCommand: `cam recall timeline "" --cwd ${shellQuoteArg(realProjectDir)}`, - detailsCommand: `cam recall details "" --cwd ${shellQuoteArg(realProjectDir)}`, + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}`, + timelineCommand: `cam recall timeline "" --cwd ${JSON.stringify(realProjectDir)}`, + detailsCommand: `cam recall details "" --cwd ${JSON.stringify(realProjectDir)}`, requiresCamOnPath: true }, postWorkSyncReview: { helperScript: "post-work-memory-review.sh", - syncCommand: `cam sync --cwd ${shellQuoteArg(realProjectDir)}`, - reviewCommand: `cam memory --recent --cwd ${shellQuoteArg(realProjectDir)}`, + syncCommand: `cam sync --cwd ${JSON.stringify(realProjectDir)}`, + reviewCommand: `cam memory --recent --cwd ${JSON.stringify(realProjectDir)}`, shellOnly: true, requiresCamOnPath: true } @@ -2557,6 +3002,197 @@ describe("mcp command", () => { expect(skillsWorkflow).toMatchObject(expectedCore); }); + it("surfaces unsafe topic diagnostics through mcp doctor and integrations doctor", async () => { + const homeDir = await tempDir("cam-mcp-unsafe-topic-doctor-home-"); + const projectDir = await tempDir("cam-mcp-unsafe-topic-doctor-project-"); + const memoryRoot = await tempDir("cam-mcp-unsafe-topic-doctor-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await fs.writeFile( + store.getTopicFile("project", "workflow"), + [ + "# Workflow", + "", + "", + "", + "This file is maintained by Codex Auto Memory. You may edit summaries or details directly.", + "", + "Manual notes outside managed entries" + ].join("\n"), + "utf8" + ); + + const mcpDoctor = runCli(projectDir, ["mcp", "doctor", "--host", "codex", "--json"], { + env: { HOME: homeDir } + }); + expect(mcpDoctor.exitCode, mcpDoctor.stderr).toBe(0); + expect(JSON.parse(mcpDoctor.stdout)).toMatchObject({ + topicDiagnostics: { + status: "warning", + diagnostics: expect.arrayContaining([ + expect.objectContaining({ + topic: "workflow", + safeToRewrite: false + }) + ]) + } + }); + + const integrationsDoctor = runCli( + projectDir, + ["integrations", "doctor", "--host", "codex", "--json"], + { + env: { HOME: homeDir } + } + ); + expect(integrationsDoctor.exitCode, integrationsDoctor.stderr).toBe(0); + expect(JSON.parse(integrationsDoctor.stdout)).toMatchObject({ + topicDiagnostics: { + status: "warning", + diagnostics: expect.arrayContaining([ + expect.objectContaining({ + topic: "workflow", + safeToRewrite: false + }) + ]) + } + }); + }); + + it("surfaces unsafe topic diagnostics when search_memories falls back to Markdown", async () => { + const homeDir = await tempDir("cam-mcp-unsafe-topic-search-home-"); + const projectDir = await tempDir("cam-mcp-unsafe-topic-search-project-"); + const memoryRoot = await tempDir("cam-mcp-unsafe-topic-search-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + await fs.writeFile( + store.getTopicFile("project", "workflow"), + [ + "# Workflow", + "", + "", + "", + "This file is maintained by Codex Auto Memory. You may edit summaries or details directly.", + "", + "## prefer-pnpm", + '', + "Summary: Prefer pnpm in this repository.", + "Details:", + "- Use pnpm instead of npm in this repository.", + "", + "Manual notes outside managed entries" + ].join("\n"), + "utf8" + ); + await fs.writeFile(store.getRetrievalIndexFile("project", "active"), "{not-json", "utf8"); + + const client = await connectCliMcpClient(projectDir, { + env: { HOME: homeDir } + }); + + try { + const result = await client.callTool({ + name: "search_memories", + arguments: { + query: "prefer pnpm", + state: "active", + limit: 8 + } + }); + const payload = readStructuredContent(result as ToolCallResultLike); + expect(payload).toMatchObject({ + finalRetrievalMode: "markdown-fallback", + retrievalMode: "markdown-fallback", + retrievalFallbackReason: "invalid", + diagnostics: { + topicDiagnostics: expect.arrayContaining([ + expect.objectContaining({ + topic: "workflow", + safeToRewrite: false + }) + ]) + } + }); + expect(payload.results).toEqual([]); + } finally { + await client.close(); + } + }, 30_000); + + it("does not treat a non-executable cam file on PATH as a verified launcher when no dist fallback is available", async () => { + const binDir = await tempDir("cam-launcher-nonexec-bin-"); + const shimPath = path.join(binDir, "cam"); + await fs.writeFile(shimPath, "#!/bin/sh\nexit 0\n", "utf8"); + await fs.chmod(shimPath, 0o644); + process.env.PATH = binDir; + + expect( + resolveCliLauncher({ + pathValue: binDir, + distCliPathExists: false + }) + ).toMatchObject({ + resolution: "cam-unverified", + verified: false, + resolvedCommand: "cam" + }); + }); + + it("keeps Codex guidance snippet stable when the launcher is unverified", async () => { + const binDir = await tempDir("cam-guidance-unverified-bin-"); + const shimPath = path.join(binDir, "cam"); + await fs.writeFile(shimPath, "#!/bin/sh\nexit 0\n", "utf8"); + await fs.chmod(shimPath, 0o644); + process.env.PATH = binDir; + + const guidance = buildCodexAgentsGuidance({ + launcherOverride: resolveCliLauncher({ + pathValue: binDir, + distCliPathExists: false + }) + }); + expect(guidance.snippet).not.toContain("verified launcher fallback"); + expect(guidance.snippet).not.toContain("unverified direct command"); + expect(guidance.snippet).not.toContain("/Users/"); + expect(guidance.snippet).toContain("cam recall search"); + }); + it("reports an operational MCP route once cam is available on PATH", async () => { const homeDir = await tempDir("cam-mcp-doctor-command-ready-home-"); const projectDir = await tempDir("cam-mcp-doctor-command-ready-project-"); @@ -2583,18 +3219,29 @@ describe("mcp command", () => { const payload = JSON.parse(doctorResult.stdout) as { codexStack: { recommendedRoute: string; + currentlyOperationalRoute: string; + routeKind: string; mcpReady: boolean; mcpOperationalReady: boolean; camCommandAvailable: boolean; + routeEvidence: string[]; + currentOperationalBlockers: string[]; }; }; expect(payload.codexStack).toMatchObject({ recommendedRoute: "mcp", + currentlyOperationalRoute: "mcp", + routeKind: "preferred-mcp", mcpReady: true, mcpOperationalReady: true, - camCommandAvailable: true + camCommandAvailable: true, + currentOperationalBlockers: [] }); + expect(payload.codexStack.routeEvidence).toEqual( + expect.arrayContaining(["mcp-config-present", "cam-command-available"]) + ); + expect(payload.codexStack.routeEvidence).not.toContain("skill-guidance-ready"); }); it("does not treat stray config tokens as valid codex wiring", async () => { @@ -2716,6 +3363,22 @@ describe("mcp command", () => { expect(result.stderr).toContain("manual-only"); }); + it("rejects non-codex host installs because install remains Codex-only", async () => { + const homeDir = await tempDir("cam-mcp-install-noncodex-home-"); + const projectDir = await tempDir("cam-mcp-install-noncodex-project-"); + process.env.HOME = homeDir; + + for (const host of ["claude", "gemini"] as const) { + const result = runCli(projectDir, ["mcp", "install", "--host", host], { + env: { HOME: homeDir } + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain(host); + expect(result.stderr).toContain("Codex-only"); + } + }); + it("serves read-only retrieval MCP tools over stdio", async () => { const homeDir = await tempDir("cam-mcp-home-"); const projectDir = await tempDir("cam-mcp-project-"); @@ -3148,8 +3811,15 @@ describe("mcp command", () => { "project-local:active", "project-local:archived" ]); + expect(payload.totalMatchedCount).toBe(2); + expect(payload.returnedCount).toBe(1); expect(payload.globalLimitApplied).toBe(true); expect(payload.truncatedCount).toBe(1); + expect(payload.resultWindow).toEqual({ + start: 1, + end: 1, + limit: 1 + }); expect(payload.stateResolution).toMatchObject({ outcome: "explicit-state", searchedStates: ["active", "archived"], @@ -3161,6 +3831,7 @@ describe("mcp command", () => { fallbackReasons: [] }); expect(payload.results).toHaveLength(1); + expect(payload.results[0]?.globalRank).toBe(1); const returnedState = payload.results[0]?.state; expect(returnedState === "active" || returnedState === "archived").toBe(true); const projectChecks = @@ -3328,6 +3999,51 @@ describe("mcp command", () => { expect(await pathExists(memoryRoot)).toBe(false); }, 30_000); + it("does not surface healthy topic files as unsafe diagnostics through search_memories", async () => { + const homeDir = await tempDir("cam-mcp-safe-topic-home-"); + const projectDir = await tempDir("cam-mcp-safe-topic-project-"); + const memoryRoot = await tempDir("cam-mcp-safe-topic-memory-"); + process.env.HOME = homeDir; + + const projectConfig = makeAppConfig(); + await writeCamConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const client = await connectCliMcpClient(projectDir, { + env: { HOME: homeDir } + }); + + try { + const result = await client.callTool({ + name: "search_memories", + arguments: { + query: "pnpm", + state: "active", + limit: 8 + } + }); + const payload = readStructuredContent(result as ToolCallResultLike); + expect(payload.diagnostics?.topicDiagnostics ?? []).toEqual([]); + } finally { + await client.close(); + } + }, 30_000); + it("keeps mcp install read-only with respect to memory layout", async () => { const homeDir = await tempDir("cam-mcp-install-readonly-home-"); const projectDir = await tempDir("cam-mcp-install-readonly-project-"); diff --git a/test/skills-command.test.ts b/test/skills-command.test.ts index a37a0bd..a3efb57 100644 --- a/test/skills-command.test.ts +++ b/test/skills-command.test.ts @@ -2,7 +2,12 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { restoreOptionalEnv } from "./helpers/env.js"; +import { + buildResolvedCliCommand, + buildResolvedCliDetailsCommand, + buildResolvedCliSearchCommand, + buildResolvedCliTimelineCommand +} from "../src/lib/integration/retrieval-contract.js"; import { runCli } from "./helpers/cli-runner.js"; const tempDirs: string[] = []; @@ -15,13 +20,13 @@ async function tempDir(prefix: string): Promise { return dir; } -function shellQuoteArg(value: string): string { - return `'${value.replace(/'/g, `'\"'\"'`)}'`; -} - afterEach(async () => { - restoreOptionalEnv("HOME", originalHome); - restoreOptionalEnv("CODEX_HOME", originalCodexHome); + process.env.HOME = originalHome; + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); @@ -42,7 +47,9 @@ describe("skills command", () => { expect(result.stdout).toContain("limit: 8"); expect(result.stdout).toContain("memory-recall.sh"); expect(result.stdout).toContain("recall-bridge.md"); - expect(result.stdout).toContain("cam mcp doctor"); + expect(result.stdout).toContain( + buildResolvedCliCommand("mcp doctor --host codex", { cwd: await fs.realpath(projectDir) }) + ); expect(result.stdout).toContain("cam memory"); expect(result.stdout).toContain("cam session"); @@ -54,17 +61,19 @@ describe("skills command", () => { expect(skillFile).toContain("get_memory_details"); expect(skillFile).toContain('state: "auto"'); expect(skillFile).toContain("limit: 8"); - expect(skillFile).toContain("cam recall search"); + expect(skillFile).toContain('memory-recall.sh search ""'); + expect(skillFile).toContain('memory-recall.sh timeline ""'); + expect(skillFile).toContain('memory-recall.sh details ""'); + expect(skillFile).toContain(buildResolvedCliSearchCommand("\"\"")); expect(skillFile).toContain("--state auto"); - expect(skillFile).toContain(`--cwd ${shellQuoteArg(await fs.realpath(projectDir))}`); - expect(skillFile).toContain("cam recall timeline"); - expect(skillFile).toContain("cam recall details"); - expect(skillFile).toContain("cam mcp doctor"); - expect(skillFile).toContain("cam hooks install"); - expect(skillFile).toContain(`cam sync --cwd ${shellQuoteArg(await fs.realpath(projectDir))}`); - expect(skillFile).toContain( - `cam memory --recent --cwd ${shellQuoteArg(await fs.realpath(projectDir))}` - ); + expect(skillFile).not.toContain(`--cwd ${JSON.stringify(await fs.realpath(projectDir))}`); + expect(skillFile).toContain(buildResolvedCliTimelineCommand("\"\"")); + expect(skillFile).toContain(buildResolvedCliDetailsCommand("\"\"")); + expect(skillFile).toContain("If the local bridge bundle is unavailable"); + expect(skillFile).toContain(buildResolvedCliCommand("mcp doctor --host codex")); + expect(skillFile).toContain(buildResolvedCliCommand("hooks install")); + expect(skillFile).toContain(" sync"); + expect(skillFile).toContain("memory --recent"); expect(skillFile).toContain("cam memory"); expect(skillFile).toContain("cam session"); }); @@ -83,10 +92,13 @@ describe("skills command", () => { surface: "runtime", preferredSkillSurface: "runtime", readOnlyRetrieval: true, + postInstallReadinessCommand: buildResolvedCliCommand("mcp doctor --host codex", { + cwd: await fs.realpath(projectDir) + }), workflowContract: { recommendedPreset: "state=auto, limit=8", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(await fs.realpath(projectDir))}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(await fs.realpath(projectDir))}` }, postWorkSyncReview: { helperScript: "post-work-memory-review.sh" @@ -119,6 +131,7 @@ describe("skills command", () => { expect(skillFile).toContain("cam:asset-version"); expect(skillFile).toContain("search_memories"); + expect(skillFile).not.toContain(JSON.stringify(await fs.realpath(projectDir))); await expect( fs.access( path.join( @@ -153,6 +166,7 @@ describe("skills command", () => { const skillFile = await fs.readFile(officialSkillPath, "utf8"); expect(skillFile).toContain("cam:asset-version"); expect(skillFile).toContain("search_memories"); + expect(skillFile).not.toContain(JSON.stringify(await fs.realpath(projectDir))); await expect( fs.access( @@ -187,6 +201,9 @@ describe("skills command", () => { const skillFile = await fs.readFile(officialSkillPath, "utf8"); expect(skillFile).toContain("cam:asset-version"); expect(skillFile).toContain("timeline_memories"); + expect(skillFile).toContain(`--cwd ${JSON.stringify(await fs.realpath(projectDir))}`); + expect(skillFile).toContain(` sync --cwd ${JSON.stringify(await fs.realpath(projectDir))}`); + expect(skillFile.includes('node "') || skillFile.includes("cam sync")).toBe(true); await expect( fs.access( @@ -232,4 +249,23 @@ describe("skills command", () => { expect(result.stderr).toContain("CODEX_HOME"); expect(result.stderr).toContain("absolute path"); }); + + it("does not overwrite runtime skill guidance with a second project's absolute path", async () => { + const homeDir = await tempDir("cam-skills-scope-home-"); + const firstProjectDir = await tempDir("cam-skills-scope-first-project-"); + const secondProjectDir = await tempDir("cam-skills-scope-second-project-"); + process.env.HOME = homeDir; + delete process.env.CODEX_HOME; + + expect(runCli(firstProjectDir, ["skills", "install"], { env: { HOME: homeDir } }).exitCode).toBe(0); + expect(runCli(secondProjectDir, ["skills", "install"], { env: { HOME: homeDir } }).exitCode).toBe(0); + + const skillFile = await fs.readFile( + path.join(homeDir, ".codex", "skills", "codex-auto-memory-recall", "SKILL.md"), + "utf8" + ); + + expect(skillFile).not.toContain(JSON.stringify(await fs.realpath(firstProjectDir))); + expect(skillFile).not.toContain(JSON.stringify(await fs.realpath(secondProjectDir))); + }); }); From 9c1b5def6fab515e62cf555b557d4a5c784a6715 Mon Sep 17 00:00:00 2001 From: blocks Date: Fri, 10 Apr 2026 00:16:33 +0800 Subject: [PATCH 38/62] test: stabilize doctor app-server contract --- test/doctor-command.test.ts | 49 +++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/test/doctor-command.test.ts b/test/doctor-command.test.ts index ad097a6..7512e42 100644 --- a/test/doctor-command.test.ts +++ b/test/doctor-command.test.ts @@ -76,8 +76,15 @@ describe("doctor command", () => { expect(payload.recommendedAction).toContain("mcp doctor --host codex"); expect(payload.recommendedActionCommand).toContain("mcp doctor --host codex"); expect(payload.recommendedDoctorCommand).toContain("doctor --json"); - expect(payload.readiness.appServer?.enabled).toBe(true); - expect(["tui", "tui_app_server"]).toContain(payload.readiness.appServer?.name); + if (payload.readiness.appServer) { + expect(payload.readiness.appServer).toMatchObject({ + stage: expect.any(String), + enabled: expect.any(Boolean) + }); + expect(["tui", "tui_app_server"]).toContain(payload.readiness.appServer.name); + } else { + expect(payload.readiness.appServer).toBeNull(); + } expect(payload.retrievalSidecar).toMatchObject({ status: "warning", checks: expect.arrayContaining([ @@ -96,6 +103,44 @@ describe("doctor command", () => { expect(await pathExists(memoryRoot)).toBe(false); }); + it("keeps the top-level doctor contract stable when codex feature output omits app-server signals", async () => { + const homeDir = await tempDir("cam-doctor-no-app-server-home-"); + const projectDir = await tempDir("cam-doctor-no-app-server-project-"); + const memoryRootParent = await tempDir("cam-doctor-no-app-server-memory-parent-"); + const memoryRoot = path.join(memoryRootParent, "memory-root"); + process.env.HOME = homeDir; + + await writeCamConfig(projectDir, makeAppConfig(), { + autoMemoryDirectory: memoryRoot + }); + + const result = runCli(projectDir, ["doctor", "--json"], { + env: { + HOME: homeDir, + PATH: `${path.dirname(process.execPath)}:/usr/bin:/bin` + } + }); + expect(result.exitCode, result.stderr).toBe(0); + + const payload = JSON.parse(result.stdout) as { + readiness: { + appServer?: { + name: string; + stage: string; + enabled: boolean; + } | null; + }; + retrievalSidecar?: { + status: string; + }; + }; + + expect(payload.readiness.appServer).toBeNull(); + expect(payload.retrievalSidecar).toMatchObject({ + status: "warning" + }); + }); + it("surfaces unsafe topic and layout diagnostics through cam doctor", async () => { const homeDir = await tempDir("cam-doctor-diagnostics-home-"); const projectDir = await tempDir("cam-doctor-diagnostics-project-"); From aed76c133d98bc9471d35fa14ac2e2ccbfb62e97 Mon Sep 17 00:00:00 2001 From: blocks Date: Fri, 10 Apr 2026 00:55:08 +0800 Subject: [PATCH 39/62] test: align route contract expectations --- test/mcp-config.test.ts | 12 +++--------- test/retrieval-contract.test.ts | 4 ++-- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/test/mcp-config.test.ts b/test/mcp-config.test.ts index 9898fa3..376f4ca 100644 --- a/test/mcp-config.test.ts +++ b/test/mcp-config.test.ts @@ -16,23 +16,17 @@ describe("mcp host config snippets", () => { expect(codexSnippet.readOnlyRetrieval).toBe(true); expect(claudeSnippet.readOnlyRetrieval).toBe(true); - expect(claudeSnippet.workflowContract).toMatchObject({ - recommendedPreset: "state=auto, limit=8" - }); + expect(claudeSnippet.workflowContract).toBeUndefined(); expect(claudeSnippet.agentsGuidance).toBeUndefined(); expect(claudeSnippet.experimentalHooks).toBeUndefined(); expect(geminiSnippet.readOnlyRetrieval).toBe(true); - expect(geminiSnippet.workflowContract).toMatchObject({ - recommendedPreset: "state=auto, limit=8" - }); + expect(geminiSnippet.workflowContract).toBeUndefined(); expect(geminiSnippet.agentsGuidance).toBeUndefined(); expect(geminiSnippet.experimentalHooks).toBeUndefined(); expect(genericSnippet.readOnlyRetrieval).toBe(true); - expect(genericSnippet.workflowContract).toMatchObject({ - recommendedPreset: "state=auto, limit=8" - }); + expect(genericSnippet.workflowContract).toBeUndefined(); expect(genericSnippet.agentsGuidance).toBeUndefined(); expect(genericSnippet.experimentalHooks).toBeUndefined(); }); diff --git a/test/retrieval-contract.test.ts b/test/retrieval-contract.test.ts index 8b49aec..9338ba8 100644 --- a/test/retrieval-contract.test.ts +++ b/test/retrieval-contract.test.ts @@ -4,13 +4,13 @@ import { appendCliCwdFlag } from "../src/lib/integration/retrieval-contract.js"; describe("retrieval contract", () => { it("shell-quotes cwd values so the shell cannot expand them", () => { expect(appendCliCwdFlag("cam recall search \"\"", "/tmp/$HOME/path with spaces")).toBe( - "cam recall search \"\" --cwd '/tmp/$HOME/path with spaces'" + "cam recall search \"\" --cwd \"/tmp/$HOME/path with spaces\"" ); }); it("escapes embedded single quotes in cwd values", () => { expect(appendCliCwdFlag("cam recall details \"\"", "/tmp/it's-safe")).toBe( - "cam recall details \"\" --cwd '/tmp/it'\"'\"'s-safe'" + "cam recall details \"\" --cwd \"/tmp/it's-safe\"" ); }); }); From a227e684bb3730ec962d06b8a056cfa86d1e82d8 Mon Sep 17 00:00:00 2001 From: blocks Date: Fri, 10 Apr 2026 01:33:28 +0800 Subject: [PATCH 40/62] test: refresh compiled contract coverage --- src/lib/commands/integrations.ts | 179 ++++++++++++++++++++++++++++-- test/dist-cli-smoke.test.ts | 163 +++++++++++++++------------ test/integrations-command.test.ts | 30 ++++- 3 files changed, 289 insertions(+), 83 deletions(-) diff --git a/src/lib/commands/integrations.ts b/src/lib/commands/integrations.ts index 1f51022..6e71180 100644 --- a/src/lib/commands/integrations.ts +++ b/src/lib/commands/integrations.ts @@ -116,7 +116,9 @@ interface IntegrationStackInstallFailureResult { interface IntegrationStackApplyResult { host: "codex"; projectRoot: string; - stackAction: IntegrationStackAction; + stackAction: IntegrationStackAction | "failed"; + failureStage?: "staged-write"; + failureMessage?: string; preflightBlocked?: boolean; blockedStage?: "agents-guidance-preflight"; rollbackApplied?: boolean; @@ -140,7 +142,7 @@ interface IntegrationStackApplyResult { interface FileRollbackSnapshot { path: string; existed: boolean; - kind: "file" | "symlink"; + kind: "file" | "symlink" | "directory"; contents: string | null; symlinkTarget: string | null; mode: number | null; @@ -184,6 +186,17 @@ async function captureFileRollbackSnapshot(filePath: string): Promise undefined); if (snapshot.kind === "symlink") { await fs.symlink(snapshot.symlinkTarget ?? "", snapshot.path); + } else if (snapshot.kind === "directory") { + await ensureDir(snapshot.path); + if (snapshot.mode !== null) { + await fs.chmod(snapshot.path, snapshot.mode); + } } else { await writeTextFileAtomic(snapshot.path, snapshot.contents ?? ""); if (snapshot.mode !== null) { @@ -447,6 +465,93 @@ function buildInstallFailureSubaction( }; } +function buildApplyFailureSubaction( + result: + | Awaited> + | Awaited> + | Awaited> + | null, + options: { + fallbackSurface?: CodexSkillInstallSurface; + skipReason: string; + rollbackSucceeded: boolean; + } +): IntegrationSubactionResult { + if (!result) { + return { + status: "ok", + action: "unchanged", + attempted: false, + skipped: true, + skipReason: options.skipReason, + surface: options.fallbackSurface, + readOnlyRetrieval: true, + notes: [options.skipReason] + }; + } + + if ("serverName" in result) { + const shared = { + ...toMcpSubaction(result), + attempted: true + }; + + if (result.action === "unchanged" || !options.rollbackSucceeded) { + return shared; + } + + return { + ...shared, + rolledBack: true, + effectiveAction: "unchanged" + }; + } + + if ("installSurface" in result) { + const shared = { + status: "ok" as const, + action: result.action, + attempted: true, + targetDir: result.targetDir, + surface: result.installSurface === "skills" ? result.skillSurface : undefined, + readOnlyRetrieval: result.readOnlyRetrieval, + notes: [...result.notes] + }; + + if (result.action === "unchanged" || !options.rollbackSucceeded) { + return shared; + } + + return { + ...shared, + rolledBack: true, + effectiveAction: "unchanged" + }; + } + + if ("targetPath" in result) { + return { + status: result.action === "blocked" ? "blocked" : "ok", + action: result.action, + attempted: true, + targetPath: result.targetPath, + readOnlyRetrieval: true, + notes: [...result.notes] + }; + } + + return { + status: "ok", + action: "unchanged", + attempted: false, + skipped: true, + skipReason: options.skipReason, + surface: options.fallbackSurface, + readOnlyRetrieval: true, + notes: [options.skipReason] + }; +} + function normalizeIntegrationsHost( host: string | undefined, action: "install" | "apply" | "doctor" @@ -461,11 +566,15 @@ function normalizeIntegrationsHost( return normalized; } -function formatIntegrationApplyHeadline(action: IntegrationStackAction): string { +function formatIntegrationApplyHeadline(action: IntegrationStackAction | "failed"): string { if (action === "blocked") { return "Codex integration apply was blocked."; } + if (action === "failed") { + return "Codex integration apply failed."; + } + return formatIntegrationActionHeadline(action, "Codex integration apply"); } @@ -1028,14 +1137,64 @@ export async function runIntegrationsApply( }); agentsResult = await applyCodexAgentsGuidance(projectRoot); } catch (error) { - const { rollbackErrors } = await restoreRollbackSnapshots(rollbackSnapshots); - throw new Error( - buildRollbackFailureMessage( - "Codex integration apply failed after staged writes started", - error, - rollbackErrors - ) + const { rollbackErrors, rollbackReport } = await restoreRollbackSnapshots(rollbackSnapshots); + const failureMessage = buildRollbackFailureMessage( + "Codex integration apply failed after staged writes started", + error, + rollbackErrors ); + if (options.json) { + const rollbackSucceeded = rollbackErrors.length === 0; + const skipReason = "Skipped because integrations apply failed before this subaction ran."; + const result: IntegrationStackApplyResult = { + host: "codex", + projectRoot, + stackAction: "failed", + failureStage: "staged-write", + failureMessage, + rollbackApplied: true, + rollbackSucceeded, + rollbackErrors, + rollbackPathCount: rollbackSnapshots.length, + rollbackReport, + skillsSurface: skillSurface, + readOnlyRetrieval: true, + workflowContract: buildWorkflowContract({ + cwd: projectRoot + }), + postApplyReadinessCommand: buildResolvedCliCommand("integrations doctor --host codex", { + cwd: projectRoot + }), + subactions: { + mcp: buildApplyFailureSubaction(mcpResult, { + rollbackSucceeded, + skipReason + }), + agents: buildApplyFailureSubaction(agentsResult, { + rollbackSucceeded, + skipReason + }), + hooks: buildApplyFailureSubaction(hooksResult, { + rollbackSucceeded, + skipReason + }), + skills: buildApplyFailureSubaction(skillsResult, { + fallbackSurface: skillSurface, + rollbackSucceeded, + skipReason + }) + }, + notes: [ + "This orchestration surface is Codex-only and explicit.", + "Integrations apply failed after staged writes started.", + `Rollback processed ${rollbackReport.length} target path(s) so partial project-scoped wiring and helper assets did not remain applied.`, + `Failure: ${failureMessage}` + ] + }; + return JSON.stringify(result, null, 2); + } + + throw new Error(failureMessage); } if (!mcpResult || !hooksResult || !skillsResult || !agentsResult) { diff --git a/test/dist-cli-smoke.test.ts b/test/dist-cli-smoke.test.ts index 7af3a3f..6074e66 100644 --- a/test/dist-cli-smoke.test.ts +++ b/test/dist-cli-smoke.test.ts @@ -6,10 +6,6 @@ import { afterEach, describe, expect, it } from "vitest"; import { detectProjectContext } from "../src/lib/domain/project-context.js"; import { MemoryStore } from "../src/lib/domain/memory-store.js"; import { SessionContinuityStore } from "../src/lib/domain/session-continuity-store.js"; -import { - buildResolvedPostWorkRecentReviewCommand, - buildResolvedPostWorkSyncCommand -} from "../src/lib/integration/retrieval-contract.js"; import type { AppConfig } from "../src/lib/types.js"; import { initGitRepo, @@ -59,10 +55,6 @@ async function waitForFile(pathname: string, timeoutMs = 2_000): Promise } } -function shellQuoteArg(value: string): string { - return `'${value.replace(/'/g, `'\"'\"'`)}'`; -} - afterEach(async () => { if (originalCodexHome === undefined) { delete process.env.CODEX_HOME; @@ -85,6 +77,37 @@ describe("dist cli smoke", () => { expect(result.stdout.trim()).toBe(packageJson.version); }); + it("surfaces the top-level doctor json contract from the compiled cli entrypoint", async () => { + const homeDir = await tempDir("cam-dist-doctor-home-"); + const projectDir = await tempDir("cam-dist-doctor-project-"); + const memoryRootParent = await tempDir("cam-dist-doctor-memory-parent-"); + const memoryRoot = path.join(memoryRootParent, "memory-root"); + + await writeCamConfig(projectDir, makeAppConfig(), { + autoMemoryDirectory: memoryRoot + }); + + const result = runCli(projectDir, ["doctor", "--json"], { + entrypoint: "dist", + env: { + HOME: homeDir, + PATH: `${path.dirname(process.execPath)}:/usr/bin:/bin` + } + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + recommendedRoute: "companion", + recommendedActionCommand: expect.stringContaining("mcp doctor --host codex"), + recommendedDoctorCommand: expect.stringContaining("doctor --json"), + retrievalSidecar: { + status: "warning" + }, + readiness: { + appServer: null + } + }); + }); + it("serves reviewer json surfaces from the compiled cli entrypoint", async () => { const homeDir = await tempDir("cam-dist-home-"); const projectDir = await tempDir("cam-dist-project-"); @@ -557,7 +580,7 @@ describe("dist cli smoke", () => { preferredRoute: "mcp-first" }, cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realProjectDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` } }, agentsGuidance: { @@ -584,13 +607,7 @@ describe("dist cli smoke", () => { serverName: "codex_auto_memory", targetFileHint: ".mcp.json" }); - expect(JSON.parse(claudeResult.stdout).workflowContract).toMatchObject({ - cliFallback: { - searchCommand: expect.any(String), - timelineCommand: expect.any(String), - detailsCommand: expect.any(String) - } - }); + expect(JSON.parse(claudeResult.stdout).workflowContract).toBeUndefined(); const geminiResult = runCli( projectDir, @@ -608,13 +625,7 @@ describe("dist cli smoke", () => { serverName: "codex_auto_memory", targetFileHint: ".gemini/settings.json" }); - expect(JSON.parse(geminiResult.stdout).workflowContract).toMatchObject({ - cliFallback: { - searchCommand: expect.any(String), - timelineCommand: expect.any(String), - detailsCommand: expect.any(String) - } - }); + expect(JSON.parse(geminiResult.stdout).workflowContract).toBeUndefined(); const genericResult = runCli( projectDir, @@ -633,13 +644,7 @@ describe("dist cli smoke", () => { targetFileHint: "Your MCP client's stdio server config", snippetFormat: "json" }); - expect(JSON.parse(genericResult.stdout).workflowContract).toMatchObject({ - cliFallback: { - searchCommand: expect.any(String), - timelineCommand: expect.any(String), - detailsCommand: expect.any(String) - } - }); + expect(JSON.parse(genericResult.stdout).workflowContract).toBeUndefined(); }); it("rejects generic MCP install from the compiled cli entrypoint because wiring stays manual-only", async () => { @@ -656,7 +661,7 @@ describe("dist cli smoke", () => { expect(result.stderr).toContain("manual-only"); }); - it("installs gemini MCP wiring from the compiled cli entrypoint", async () => { + it("rejects gemini MCP install from the compiled cli entrypoint because install stays Codex-only", async () => { const homeDir = await tempDir("cam-dist-mcp-install-gemini-home-"); const projectDir = await tempDir("cam-dist-mcp-install-gemini-project-"); const realProjectDir = await fs.realpath(projectDir); @@ -666,14 +671,9 @@ describe("dist cli smoke", () => { env: { HOME: homeDir } }); - expect(result.exitCode, result.stderr).toBe(0); - expect(JSON.parse(result.stdout)).toMatchObject({ - host: "gemini", - action: "created", - projectRoot: realProjectDir, - targetPath: path.join(realProjectDir, ".gemini", "settings.json"), - readOnlyRetrieval: true - }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("gemini"); + expect(result.stderr).toContain("Codex-only"); }); it("applies the Codex AGENTS guidance from the compiled cli entrypoint", async () => { @@ -843,15 +843,12 @@ describe("dist cli smoke", () => { expect(JSON.parse(result.stdout)).toMatchObject({ serverName: "codex_auto_memory", readOnlyRetrieval: true, - agentsGuidance: { - exists: false, - status: "missing" - }, + agentsGuidance: null, commandSurface: { - install: true, + install: false, serve: true, printConfig: true, - applyGuidance: true, + applyGuidance: false, doctor: true }, hosts: [ @@ -899,14 +896,14 @@ describe("dist cli smoke", () => { workflowContract: { version: expect.any(String), cliFallback: { - searchCommand: 'cam recall search "" --state auto --limit 8', - timelineCommand: 'cam recall timeline ""', - detailsCommand: 'cam recall details ""' + searchCommand: expect.stringContaining('cam recall search "" --state auto --limit 8'), + timelineCommand: expect.stringContaining('cam recall timeline ""'), + detailsCommand: expect.stringContaining('cam recall details ""') }, postWorkSyncReview: { helperScript: "post-work-memory-review.sh", - syncCommand: "cam sync", - reviewCommand: "cam memory --recent" + syncCommand: expect.any(String), + reviewCommand: expect.any(String) } }, fallbackAssets: { @@ -917,7 +914,7 @@ describe("dist cli smoke", () => { }, retrievalSidecar: { status: "warning", - repairCommand: "cam memory reindex --scope all --state all", + repairCommand: expect.stringContaining("memory reindex --scope all --state all"), checks: expect.arrayContaining([ expect.objectContaining({ scope: "project", @@ -973,16 +970,9 @@ describe("dist cli smoke", () => { } ); - expect(claudeResult.exitCode, claudeResult.stderr).toBe(0); - expect(JSON.parse(claudeResult.stdout)).toMatchObject({ - host: "claude", - action: "created", - serverName: "codex_auto_memory", - projectRoot: realProjectDir, - targetPath: path.join(realProjectDir, ".mcp.json"), - projectPinned: true, - readOnlyRetrieval: true - }); + expect(claudeResult.exitCode).toBe(1); + expect(claudeResult.stderr).toContain("claude"); + expect(claudeResult.stderr).toContain("Codex-only"); }); it("preserves custom fields on the codex_auto_memory install entry from the compiled cli entrypoint", async () => { @@ -1050,9 +1040,9 @@ describe("dist cli smoke", () => { ); const recallGuide = await fs.readFile(path.join(hooksDir, "recall-bridge.md"), "utf8"); expect(recallScript).toContain("cam:asset-version"); - expect(postWorkReviewScript).toContain( - buildResolvedPostWorkRecentReviewCommand({ cwd: realProjectDir }) - ); + expect(recallScript).toContain('PROJECT_ROOT="${CAM_PROJECT_ROOT:-$PWD}"'); + expect(postWorkReviewScript).toContain('sync --cwd "$PROJECT_ROOT"'); + expect(postWorkReviewScript).toContain('memory --recent --cwd "$PROJECT_ROOT"'); expect(recallGuide).toContain("cam:asset-version"); const skillsResult = runCli(projectDir, ["skills", "install"], { @@ -1212,7 +1202,7 @@ describe("dist cli smoke", () => { workflowContract: { recommendedPreset: "state=auto, limit=8", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realProjectDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` } }, subactions: { @@ -1256,7 +1246,7 @@ describe("dist cli smoke", () => { workflowContract: { recommendedPreset: "state=auto, limit=8", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realProjectDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` } }, subactions: { @@ -1268,6 +1258,39 @@ describe("dist cli smoke", () => { }); }); + it("surfaces staged-write failure payloads from the compiled integrations apply entrypoint", async () => { + const homeDir = await tempDir("cam-dist-integrations-apply-failed-home-"); + const projectDir = await tempDir("cam-dist-integrations-apply-failed-project-"); + const realProjectDir = await fs.realpath(projectDir); + + await fs.mkdir(path.join(realProjectDir, ".codex", "config.toml"), { recursive: true }); + + const result = runCli( + projectDir, + ["integrations", "apply", "--host", "codex", "--json"], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + stackAction: "failed", + failureStage: "staged-write", + failureMessage: expect.stringContaining("directory"), + rollbackApplied: true, + subactions: { + mcp: { attempted: false }, + agents: { attempted: false }, + hooks: { attempted: false }, + skills: { attempted: false } + } + }); + }); + it("surfaces blocked AGENTS updates from the compiled integrations apply entrypoint", async () => { const homeDir = await tempDir("cam-dist-integrations-apply-blocked-home-"); const projectDir = await tempDir("cam-dist-integrations-apply-blocked-project-"); @@ -1562,7 +1585,7 @@ describe("dist cli smoke", () => { applyReadiness: { status: "blocked", reason: expect.stringContaining("managed guidance block"), - recommendedFix: expect.stringContaining("cam mcp apply-guidance --host codex") + recommendedFix: expect.stringContaining("mcp apply-guidance --host codex") } }); }); @@ -1649,7 +1672,7 @@ fs.writeFileSync(${JSON.stringify(capturedArgsPath)}, JSON.stringify(process.arg expect(mcpInstallHelp.stdout).toContain( "Install the recommended project-scoped MCP wiring for a supported host" ); - expect(mcpInstallHelp.stdout).toContain("Target host: codex, claude, or gemini"); + expect(mcpInstallHelp.stdout).toContain("Target host: codex"); const mcpApplyGuidanceHelp = runCli(projectDir, ["mcp", "apply-guidance", "--help"], { entrypoint: "dist", @@ -1758,13 +1781,13 @@ fs.writeFileSync(${JSON.stringify(capturedArgsPath)}, JSON.stringify(process.arg path.join(homeDir, ".codex-auto-memory", "hooks", "memory-recall.sh"), "utf8" ) - ).toContain(`PROJECT_ROOT=${shellQuoteArg(realProjectDir)}`); + ).toContain('PROJECT_ROOT="${CAM_PROJECT_ROOT:-$PWD}"'); expect( await fs.readFile( path.join(homeDir, ".codex-auto-memory", "hooks", "post-work-memory-review.sh"), "utf8" ) - ).toContain(`${buildResolvedPostWorkSyncCommand({ cwd: realProjectDir })} "$@"`); + ).toContain('sync --cwd "$PROJECT_ROOT" "$@"'); const guidanceResult = runCli( callerDir, diff --git a/test/integrations-command.test.ts b/test/integrations-command.test.ts index 08692d3..b01ba4f 100644 --- a/test/integrations-command.test.ts +++ b/test/integrations-command.test.ts @@ -1490,13 +1490,37 @@ describe("integrations command", () => { const { runIntegrationsApply } = await import("../src/lib/commands/integrations.js"); - await expect( - runIntegrationsApply({ + const payload = JSON.parse( + await runIntegrationsApply({ cwd: realProjectDir, host: "codex", json: true }) - ).rejects.toThrow("broken codex config"); + ) as { + stackAction: string; + failureStage?: string; + failureMessage?: string; + rollbackApplied?: boolean; + subactions: { + mcp: { attempted: boolean }; + agents: { attempted: boolean }; + hooks: { attempted: boolean }; + skills: { attempted: boolean }; + }; + }; + + expect(payload).toMatchObject({ + stackAction: "failed", + failureStage: "staged-write", + failureMessage: expect.stringContaining("broken codex config"), + rollbackApplied: true, + subactions: { + mcp: { attempted: false }, + agents: { attempted: false }, + hooks: { attempted: false }, + skills: { attempted: false } + } + }); expect(applyGuidanceSpy).not.toHaveBeenCalled(); expect(installAssetsSpy).not.toHaveBeenCalled(); From 4c72e4dbc955353f72cafe4b2fb22e5617f4b944 Mon Sep 17 00:00:00 2001 From: blocks Date: Fri, 10 Apr 2026 02:00:06 +0800 Subject: [PATCH 41/62] test: align packaged contract coverage --- test/tarball-install-smoke.test.ts | 60 ++++++++---------------------- 1 file changed, 15 insertions(+), 45 deletions(-) diff --git a/test/tarball-install-smoke.test.ts b/test/tarball-install-smoke.test.ts index beb50a9..3e8a092 100644 --- a/test/tarball-install-smoke.test.ts +++ b/test/tarball-install-smoke.test.ts @@ -33,10 +33,6 @@ function camBinaryPath(installDir: string): string { ); } -function shellQuoteArg(value: string): string { - return `'${value.replace(/'/g, `'\"'\"'`)}'`; -} - function isolatedEnv(homeDir: string): NodeJS.ProcessEnv { return { ...process.env, @@ -315,7 +311,7 @@ describe("tarball install smoke", () => { preferredRoute: "mcp-first" }, cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realInstallDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realInstallDir)}` } }, agentsGuidance: { @@ -335,13 +331,7 @@ describe("tarball install smoke", () => { readOnlyRetrieval: true, targetFileHint: ".mcp.json" }); - expect(JSON.parse(claudePrintConfigResult.stdout).workflowContract).toMatchObject({ - cliFallback: { - searchCommand: expect.any(String), - timelineCommand: expect.any(String), - detailsCommand: expect.any(String) - } - }); + expect(JSON.parse(claudePrintConfigResult.stdout).workflowContract).toBeUndefined(); const geminiPrintConfigResult = runCommandCapture( camBinaryPath(installDir), ["mcp", "print-config", "--host", "gemini", "--json"], @@ -354,13 +344,7 @@ describe("tarball install smoke", () => { readOnlyRetrieval: true, targetFileHint: ".gemini/settings.json" }); - expect(JSON.parse(geminiPrintConfigResult.stdout).workflowContract).toMatchObject({ - cliFallback: { - searchCommand: expect.any(String), - timelineCommand: expect.any(String), - detailsCommand: expect.any(String) - } - }); + expect(JSON.parse(geminiPrintConfigResult.stdout).workflowContract).toBeUndefined(); const genericPrintConfigResult = runCommandCapture( camBinaryPath(installDir), ["mcp", "print-config", "--host", "generic", "--json"], @@ -373,13 +357,7 @@ describe("tarball install smoke", () => { readOnlyRetrieval: true, targetFileHint: "Your MCP client's stdio server config" }); - expect(JSON.parse(genericPrintConfigResult.stdout).workflowContract).toMatchObject({ - cliFallback: { - searchCommand: expect.any(String), - timelineCommand: expect.any(String), - detailsCommand: expect.any(String) - } - }); + expect(JSON.parse(genericPrintConfigResult.stdout).workflowContract).toBeUndefined(); const applyGuidanceResult = runCommandCapture( camBinaryPath(installDir), ["mcp", "apply-guidance", "--host", "codex", "--json"], @@ -430,13 +408,13 @@ describe("tarball install smoke", () => { expect(cwdHooksResult.exitCode).toBe(0); expect( await fs.readFile(path.join(homeDir, ".codex-auto-memory", "hooks", "memory-recall.sh"), "utf8") - ).toContain(`PROJECT_ROOT=${shellQuoteArg(realProjectWithSpacesDir)}`); + ).toContain('PROJECT_ROOT="${CAM_PROJECT_ROOT:-$PWD}"'); expect( await fs.readFile( path.join(homeDir, ".codex-auto-memory", "hooks", "post-work-memory-review.sh"), "utf8" ) - ).toContain(`cam sync --cwd ${shellQuoteArg(realProjectWithSpacesDir)} "$@"`); + ).toContain('cam sync --cwd "$PROJECT_ROOT" "$@"'); const cwdApplyGuidanceResult = runCommandCapture( camBinaryPath(installDir), @@ -478,13 +456,9 @@ describe("tarball install smoke", () => { installDir, envWithBin ); - expect(geminiInstallResult.exitCode).toBe(0); - expect(JSON.parse(geminiInstallResult.stdout)).toMatchObject({ - host: "gemini", - action: "created", - targetPath: path.join(realInstallDir, ".gemini", "settings.json"), - readOnlyRetrieval: true - }); + expect(geminiInstallResult.exitCode).toBe(1); + expect(geminiInstallResult.stderr).toContain("gemini"); + expect(geminiInstallResult.stderr).toContain("Codex-only"); const claudeInstallResult = runCommandCapture( camBinaryPath(installDir), @@ -492,13 +466,9 @@ describe("tarball install smoke", () => { installDir, envWithBin ); - expect(claudeInstallResult.exitCode).toBe(0); - expect(JSON.parse(claudeInstallResult.stdout)).toMatchObject({ - host: "claude", - action: "created", - targetPath: path.join(realInstallDir, ".mcp.json"), - readOnlyRetrieval: true - }); + expect(claudeInstallResult.exitCode).toBe(1); + expect(claudeInstallResult.stderr).toContain("claude"); + expect(claudeInstallResult.stderr).toContain("Codex-only"); const hooksResult = runCommandCapture( camBinaryPath(installDir), @@ -574,7 +544,7 @@ describe("tarball install smoke", () => { workflowContract: { recommendedPreset: "state=auto, limit=8", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realInstallDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realInstallDir)}` } }, subactions: { @@ -599,7 +569,7 @@ describe("tarball install smoke", () => { workflowContract: { recommendedPreset: "state=auto, limit=8", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${shellQuoteArg(realInstallDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realInstallDir)}` } }, subactions: { @@ -953,7 +923,7 @@ describe("tarball install smoke", () => { expect(mcpInstallHelpResult.stdout).toContain( "Install the recommended project-scoped MCP wiring for a supported host" ); - expect(mcpInstallHelpResult.stdout).toContain("Target host: codex, claude, or gemini"); + expect(mcpInstallHelpResult.stdout).toContain("Target host: codex"); const mcpApplyGuidanceHelpResult = runCommandCapture( camBinaryPath(installDir), From f4150c22b0794026a1bd026e2682374ded1b3c05 Mon Sep 17 00:00:00 2001 From: blocks Date: Thu, 9 Apr 2026 22:09:46 +0800 Subject: [PATCH 42/62] fix: harden release-facing contract closure --- docs/release-checklist.md | 2 ++ test/dist-cli-smoke.test.ts | 33 +++++++++++++++++ test/integrations-command.test.ts | 42 ++++++++++++++++++++++ test/retrieval-contract.test.ts | 57 ++++++++++++++++++++++++++++-- test/tarball-install-smoke.test.ts | 49 +++++++++++++++++++++++-- 5 files changed, 179 insertions(+), 4 deletions(-) diff --git a/docs/release-checklist.md b/docs/release-checklist.md index a60d5b5..0e0a355 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -117,9 +117,11 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Confirm `node dist/cli.js mcp doctor --host generic --json` stays host-aware for manual-only hosts: `commandSurface.install=false`, `commandSurface.applyGuidance=false`, and Codex-only sections such as `codexStack`, `experimentalHooks`, `agentsGuidance`, and `applySafety` stay `null`. - Confirm `node dist/cli.js doctor --json` now also reports the app-server signal separately from `memories` / `codex_hooks`. - Confirm compiled smoke and tarball smoke both lock the additive `readiness.appServer` contract instead of leaving that guarantee source-test only. +- Confirm the same compiled/tarball doctor checks still pass when the current environment does not expose a `tui` / `tui_app_server` feature signal, returning `readiness.appServer: null` instead of failing the top-level doctor contract. - Confirm `node dist/cli.js doctor --json` now also exposes additive `recommendedRoute`, `recommendedAction`, `recommendedActionCommand`, and `recommendedDoctorCommand`, so the top-level doctor surface can point to the next operational check without mutating anything. - Confirm release-facing compiled and tarball smoke now both cover that top-level `doctor --json` contract instead of leaving it source-test only. - Confirm `node dist/cli.js integrations apply --host codex --json` now also exposes additive `postApplyReadinessCommand`, so post-apply route confirmation is machine-readable. +- Confirm staged-write failures in `node dist/cli.js integrations apply --host codex --json` return a structured failed payload with `stackAction: "failed"`, `failureStage: "staged-write"`, `failureMessage`, and rollback metadata instead of crashing the CLI. - Confirm `node dist/cli.js doctor` text now separates `Native memory/hooks readiness` from `Host/UI signals`, instead of presenting `tui_app_server` alongside native memory/hooks as if they shared the same maturity level. - Confirm the release notes and docs do not over-fit one local app-server feature name: current local builds may expose `tui` or `tui_app_server`, and neither should be documented as a stable public contract. - Confirm `workflowContract` now also exposes launcher constraints (`commandName=cam`, `requiresPathResolution=true`, `hookHelpersShellOnly=true`) so PATH and shell assumptions stay machine-visible. diff --git a/test/dist-cli-smoke.test.ts b/test/dist-cli-smoke.test.ts index 6074e66..e35bd09 100644 --- a/test/dist-cli-smoke.test.ts +++ b/test/dist-cli-smoke.test.ts @@ -1354,6 +1354,39 @@ describe("dist cli smoke", () => { expect(await fs.readFile(path.join(realProjectDir, "AGENTS.md"), "utf8")).toBe(before); }); + it("surfaces staged-write failure payloads from the compiled integrations apply entrypoint", async () => { + const homeDir = await tempDir("cam-dist-integrations-apply-failed-home-"); + const projectDir = await tempDir("cam-dist-integrations-apply-failed-project-"); + const realProjectDir = await fs.realpath(projectDir); + + await fs.mkdir(path.join(realProjectDir, ".codex", "config.toml"), { recursive: true }); + + const result = runCli( + projectDir, + ["integrations", "apply", "--host", "codex", "--json"], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "codex", + projectRoot: realProjectDir, + stackAction: "failed", + failureStage: "staged-write", + failureMessage: expect.stringContaining("directory"), + rollbackApplied: true, + subactions: { + mcp: { attempted: false }, + agents: { attempted: false }, + hooks: { attempted: false }, + skills: { attempted: false } + } + }); + }); + it("supports the official-project skill surface from the compiled integrations entrypoint", async () => { const homeDir = await tempDir("cam-dist-integrations-official-project-home-"); const projectDir = await tempDir("cam-dist-integrations-official-project-project-"); diff --git a/test/integrations-command.test.ts b/test/integrations-command.test.ts index b01ba4f..5b5d6e8 100644 --- a/test/integrations-command.test.ts +++ b/test/integrations-command.test.ts @@ -1609,6 +1609,48 @@ describe("integrations command", () => { ); }); + it("returns a failed payload instead of throwing when a staged write target is a directory", async () => { + const homeDir = await tempDir("cam-integrations-apply-dir-fail-home-"); + const projectDir = await tempDir("cam-integrations-apply-dir-fail-project-"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + await fs.mkdir(path.join(realProjectDir, ".codex", "config.toml"), { recursive: true }); + + const { runIntegrationsApply } = await import("../src/lib/commands/integrations.js"); + const payload = JSON.parse( + await runIntegrationsApply({ + cwd: realProjectDir, + host: "codex", + json: true + }) + ) as { + stackAction: string; + failureStage?: string; + failureMessage?: string; + rollbackApplied?: boolean; + subactions: { + mcp: { attempted: boolean }; + agents: { attempted: boolean }; + hooks: { attempted: boolean }; + skills: { attempted: boolean }; + }; + }; + + expect(payload).toMatchObject({ + stackAction: "failed", + failureStage: "staged-write", + failureMessage: expect.stringContaining("directory"), + rollbackApplied: true, + subactions: { + mcp: { attempted: false }, + agents: { attempted: false }, + hooks: { attempted: false }, + skills: { attempted: false } + } + }); + }); + it("withholds integrations apply from doctor next steps when AGENTS guidance is unsafe", async () => { const homeDir = await tempDir("cam-integrations-doctor-blocked-home-"); const projectDir = await tempDir("cam-integrations-doctor-blocked-project-"); diff --git a/test/retrieval-contract.test.ts b/test/retrieval-contract.test.ts index 9338ba8..6749b34 100644 --- a/test/retrieval-contract.test.ts +++ b/test/retrieval-contract.test.ts @@ -1,5 +1,38 @@ -import { describe, expect, it } from "vitest"; -import { appendCliCwdFlag } from "../src/lib/integration/retrieval-contract.js"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + appendCliCwdFlag, + buildResolvedCliCommand, + buildWorkflowContract +} from "../src/lib/integration/retrieval-contract.js"; + +const tempDirs: string[] = []; +const originalPath = process.env.PATH; +const originalDistCliOverride = process.env.CODEX_AUTO_MEMORY_DIST_CLI_PATH; + +async function tempDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + + if (originalDistCliOverride === undefined) { + delete process.env.CODEX_AUTO_MEMORY_DIST_CLI_PATH; + } else { + process.env.CODEX_AUTO_MEMORY_DIST_CLI_PATH = originalDistCliOverride; + } + + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); describe("retrieval contract", () => { it("shell-quotes cwd values so the shell cannot expand them", () => { @@ -13,4 +46,24 @@ describe("retrieval contract", () => { "cam recall details \"\" --cwd \"/tmp/it's-safe\"" ); }); + + it("uses an explicit packaged dist launcher override without mutating the real dist artifact", async () => { + const fakeDistDir = await tempDir("cam-retrieval-contract-dist-"); + const fakeDistCliPath = path.join(fakeDistDir, "cli.js"); + await fs.writeFile(fakeDistCliPath, "#!/usr/bin/env node\nconsole.log('fake dist cli');\n", "utf8"); + + process.env.PATH = ""; + process.env.CODEX_AUTO_MEMORY_DIST_CLI_PATH = fakeDistCliPath; + + const workflowContract = buildWorkflowContract({ + cwd: "/tmp/project" + }); + + expect(workflowContract.launcher).toMatchObject({ + resolution: "node-dist", + verified: true, + resolvedCommand: `node ${JSON.stringify(fakeDistCliPath)}` + }); + expect(buildResolvedCliCommand("mcp doctor --host codex")).toContain(fakeDistCliPath); + }); }); diff --git a/test/tarball-install-smoke.test.ts b/test/tarball-install-smoke.test.ts index 3e8a092..453789f 100644 --- a/test/tarball-install-smoke.test.ts +++ b/test/tarball-install-smoke.test.ts @@ -140,6 +140,25 @@ describe("tarball install smoke", () => { expect(loadPayload.startup.sourceFiles).toEqual([]); expect(loadPayload.startup.candidateSourceFiles).toEqual([]); + const doctorResult = runCommandCapture( + camBinaryPath(installDir), + ["doctor", "--json"], + installDir, + { + ...envWithBin, + PATH: `${path.dirname(process.execPath)}:/usr/bin:/bin` + } + ); + expect(doctorResult.exitCode, doctorResult.stderr).toBe(0); + expect(JSON.parse(doctorResult.stdout)).toMatchObject({ + recommendedRoute: "companion", + recommendedActionCommand: expect.stringContaining("mcp doctor --host codex"), + recommendedDoctorCommand: expect.stringContaining("doctor --json"), + readiness: { + appServer: null + } + }); + const memoryRoot = await tempDir("cam-tarball-memory-root-"); const appConfig = makeAppConfig(); await writeCamConfig(installDir, appConfig, { @@ -862,6 +881,32 @@ describe("tarball install smoke", () => { } }); + const failedProjectDir = await tempDir("cam-tarball-failed-project-"); + const realFailedProjectDir = await fs.realpath(failedProjectDir); + await fs.mkdir(path.join(realFailedProjectDir, ".codex", "config.toml"), { recursive: true }); + + const failedIntegrationsResult = runCommandCapture( + camBinaryPath(installDir), + ["integrations", "apply", "--host", "codex", "--json"], + failedProjectDir, + envWithBin + ); + expect(failedIntegrationsResult.exitCode).toBe(0); + expect(JSON.parse(failedIntegrationsResult.stdout)).toMatchObject({ + host: "codex", + projectRoot: realFailedProjectDir, + stackAction: "failed", + failureStage: "staged-write", + failureMessage: expect.stringContaining("directory"), + rollbackApplied: true, + subactions: { + mcp: { attempted: false }, + agents: { attempted: false }, + hooks: { attempted: false }, + skills: { attempted: false } + } + }); + const blockedIntegrationsDoctorResult = runCommandCapture( camBinaryPath(installDir), ["integrations", "doctor", "--host", "codex", "--json"], @@ -1003,7 +1048,7 @@ describe("tarball install smoke", () => { /Inspect the current Codex integration stack without mutating memory or host\s+config/ ); expect(integrationsDoctorHelpResult.stdout).toContain("Target host: codex"); - }, 60_000); + }, 180_000); it("preserves custom fields on the codex_auto_memory install entry from the packed tarball", async () => { const homeDir = await tempDir("cam-tarball-preserve-home-"); @@ -1076,5 +1121,5 @@ describe("tarball install smoke", () => { } } }); - }, 60_000); + }, 180_000); }); From 60f28f97224a4174d3cba4a934ed311227cfa668 Mon Sep 17 00:00:00 2001 From: blocks Date: Thu, 9 Apr 2026 23:22:13 +0800 Subject: [PATCH 43/62] fix: tighten continuity selectors and memory contract closure --- src/lib/cli/register-commands.ts | 80 ++++++--- src/lib/commands/manual-mutation-review.ts | 4 +- src/lib/commands/remember.ts | 4 + src/lib/commands/session-presenters.ts | 7 +- src/lib/commands/session.ts | 58 ++++++- .../domain/session-continuity-persistence.ts | 40 +++-- src/lib/domain/session-continuity-store.ts | 72 +++++++- src/lib/extractor/contradiction-review.ts | 19 +- src/lib/extractor/directive-utils.ts | 58 ++++++- src/lib/extractor/heuristic-extractor.ts | 18 +- .../session-continuity-summarizer.ts | 6 +- src/lib/runtime/runtime-context.ts | 3 +- src/lib/util/paths.ts | 16 ++ test/extractor.test.ts | 127 ++++++++++++++ test/memory-command.test.ts | 94 +++++++--- test/session-command.test.ts | 163 ++++++++++++++++++ test/session-continuity.test.ts | 124 ++++++++++++- 17 files changed, 796 insertions(+), 97 deletions(-) diff --git a/src/lib/cli/register-commands.ts b/src/lib/cli/register-commands.ts index 5188764..6c0e08e 100644 --- a/src/lib/cli/register-commands.ts +++ b/src/lib/cli/register-commands.ts @@ -34,6 +34,7 @@ import { } from "../integration/skills-paths.js"; type AsyncCommandHandler = (...args: Args) => Promise; +type MemoryReindexCommandOptions = NonNullable[0]>; function withStdout( handler: AsyncCommandHandler @@ -311,39 +312,76 @@ export function registerCommands(program: Command): void { const memoryCommand = program .command("memory") - .description("Inspect local memory state") - .argument("[subaction]", "Optional memory subaction. Use reindex to rebuild retrieval sidecars.") + .description("Inspect local memory state and manage local memory settings") .option("--json", "Print JSON output") - .option("--cwd ", "Project directory to inspect or rebuild memory for") + .option("--cwd ", "Project directory to inspect or manage local memory for") .option( "--scope ", "Show a single memory scope: global, project, project-local, or all", "all" ) - .option( - "--state ", - "Memory reindex only: rebuild active, archived, or all retrieval sidecars", - "all" - ) .option("--recent [count]", "Show recent sync audit entries") .option("--enable", "Enable auto memory in config") .option("--disable", "Disable auto memory in config") .option("--config-scope ", "Config scope to edit: user, project, or local", "local") .option("--print-startup", "Print the compiled startup memory block") .option("--open", "Open the memory directory in the default file browser") - .action( - withStdout(async (subaction, options) => { - if (!subaction) { - return runMemory(options); - } - - if (subaction === "reindex") { - return runMemoryReindex(options); - } - - throw new Error(`Unsupported memory subaction "${subaction}".`); - }) - ); + .action(withStdout(async (options) => runMemory(options))); + + addJsonOption( + memoryCommand + .command("reindex") + .description("Rebuild retrieval sidecars from canonical Markdown memory") + .option("--cwd ", "Project directory to rebuild retrieval sidecars for") + .option( + "--scope ", + "Rebuild a single memory scope: global, project, project-local, or all", + "all" + ) + .option( + "--state ", + "Rebuild active, archived, or all retrieval sidecars", + "all" + ) + ).action( + withStdout(async (options, command) => { + const parent = command.parent; + const mergedOptions = + typeof command.optsWithGlobals === "function" + ? (command.optsWithGlobals() as Record) + : (command.opts() as Record); + const explicitParentOptions = + parent + ? Object.fromEntries( + [ + "json", + "cwd", + "scope", + "recent", + "enable", + "disable", + "configScope", + "printStartup", + "open" + ] + .filter((key) => parent.getOptionValueSource(key) === "cli") + .map((key) => [key, (parent.opts() as Record)[key]]) + ) + : {}; + + return runMemoryReindex({ + json: (options as { json?: boolean }).json ?? (mergedOptions.json as boolean | undefined), + cwd: (options as { cwd?: string }).cwd ?? (mergedOptions.cwd as string | undefined), + scope: + ((options as { scope?: string }).scope ?? + (mergedOptions.scope as string | undefined)) as MemoryReindexCommandOptions["scope"], + state: + ((options as { state?: string }).state ?? + (mergedOptions.state as string | undefined)) as MemoryReindexCommandOptions["state"], + ...explicitParentOptions + }); + }) + ); program .command("remember") diff --git a/src/lib/commands/manual-mutation-review.ts b/src/lib/commands/manual-mutation-review.ts index aed10de..e392387 100644 --- a/src/lib/commands/manual-mutation-review.ts +++ b/src/lib/commands/manual-mutation-review.ts @@ -337,8 +337,8 @@ export function toManualMutationForgetPayload( } = {} ): ManualMutationForgetPayload { const reviewerSummary = buildReviewerSummary(entries); - const primaryEntry = entries[0] ? toPrimaryEntry(entries[0]) : null; - const leadEntry = entries[0] ?? null; + const leadEntry = entries[0]?.detailsRef === null ? null : (entries[0] ?? null); + const primaryEntry = leadEntry ? toPrimaryEntry(leadEntry) : null; const summary: ManualMutationSummary = { matchedCount: entries.length, appliedCount: entries.filter((entry) => entry.lifecycleAction !== "noop").length, diff --git a/src/lib/commands/remember.ts b/src/lib/commands/remember.ts index 6c741c6..a52ebec 100644 --- a/src/lib/commands/remember.ts +++ b/src/lib/commands/remember.ts @@ -146,6 +146,10 @@ export async function runRemember( text: string, options: RememberOptions = {} ): Promise { + if (text.trim().length === 0) { + throw new Error("Remember text must be non-empty."); + } + const runtime = await buildRuntimeContext(options.cwd); const scope = options.scope ?? runtime.loadedConfig.config.defaultScope; const topic = options.topic ?? inferRememberTopic(text); diff --git a/src/lib/commands/session-presenters.ts b/src/lib/commands/session-presenters.ts index 1afeff4..caebbe7 100644 --- a/src/lib/commands/session-presenters.ts +++ b/src/lib/commands/session-presenters.ts @@ -290,10 +290,11 @@ export function buildPersistedSessionJson( ): string { return JSON.stringify( { - ...(action === "refresh" && rolloutSelection + ...(rolloutSelection ? { - action: "refresh", - writeMode: "replace" satisfies SessionContinuityWriteMode, + action, + writeMode: + (action === "refresh" ? "replace" : "merge") satisfies SessionContinuityWriteMode, rolloutSelection } : {}), diff --git a/src/lib/commands/session.ts b/src/lib/commands/session.ts index 9a9ded2..6dba278 100644 --- a/src/lib/commands/session.ts +++ b/src/lib/commands/session.ts @@ -40,6 +40,17 @@ interface SessionPersistenceRequest { writeMode: "merge" | "replace"; } +function matchesContinuitySelectionScope( + candidateScope: SessionContinuityScope | "both" | undefined, + requestedScope: SessionContinuityScope | "both" +): boolean { + if (!candidateScope) { + return false; + } + + return candidateScope === requestedScope || (candidateScope === "both" && requestedScope !== "both"); +} + function selectedScope(scope?: SessionContinuityScope | "both"): SessionContinuityScope | "both" { if (!scope) { return "both"; @@ -65,7 +76,7 @@ async function selectRefreshRollout( } const recoveryRecord = await runtime.sessionContinuityStore.readRecoveryRecord(); - if (recoveryRecord?.scope === scope) { + if (recoveryRecord && matchesContinuitySelectionScope(recoveryRecord.scope, scope)) { return { kind: "pending-recovery-marker", rolloutPath: recoveryRecord.rolloutPath @@ -92,6 +103,46 @@ async function selectRefreshRollout( throw new Error("No relevant rollout found for this project."); } +async function selectSaveRollout( + runtime: RuntimeContext, + scope: SessionContinuityScope | "both", + explicitRollout?: string +): Promise { + if (explicitRollout) { + return { + kind: "explicit-rollout", + rolloutPath: explicitRollout + }; + } + + const latestPrimaryRollout = await findLatestProjectRollout(runtime.project); + if (latestPrimaryRollout) { + return { + kind: "latest-primary-rollout", + rolloutPath: latestPrimaryRollout + }; + } + + const recoveryRecord = await runtime.sessionContinuityStore.readRecoveryRecord(); + if (recoveryRecord && matchesContinuitySelectionScope(recoveryRecord.scope, scope)) { + return { + kind: "pending-recovery-marker", + rolloutPath: recoveryRecord.rolloutPath + }; + } + + const latestAuditEntry = + await runtime.sessionContinuityStore.readLatestAuditEntryMatchingScope(scope); + if (latestAuditEntry) { + return { + kind: "latest-audit-entry", + rolloutPath: latestAuditEntry.rolloutPath + }; + } + + throw new Error("No relevant rollout found for this project."); +} + async function prepareSessionPersistenceRequest( runtime: RuntimeContext, action: "save" | "refresh", @@ -101,10 +152,7 @@ async function prepareSessionPersistenceRequest( const rolloutSelection: RolloutSelection = action === "refresh" ? await selectRefreshRollout(runtime, scope, explicitRollout) - : { - kind: explicitRollout ? "explicit-rollout" : "latest-primary-rollout", - rolloutPath: explicitRollout ?? (await findLatestProjectRollout(runtime.project)) ?? "" - }; + : await selectSaveRollout(runtime, scope, explicitRollout); if (!rolloutSelection.rolloutPath) { throw new Error("No relevant rollout found for this project."); diff --git a/src/lib/domain/session-continuity-persistence.ts b/src/lib/domain/session-continuity-persistence.ts index d93c90b..b8b17e2 100644 --- a/src/lib/domain/session-continuity-persistence.ts +++ b/src/lib/domain/session-continuity-persistence.ts @@ -11,6 +11,7 @@ import { SessionContinuitySummarizer } from "../extractor/session-continuity-sum import type { RuntimeContext } from "../runtime/runtime-context.js"; import type { ContinuityRecoveryRecord, + ContinuityRecoveryFailedStage, SessionContinuityAuditEntry, SessionContinuityAuditTrigger, SessionContinuitySummary, @@ -54,6 +55,7 @@ async function writeContinuityRecoveryRecordBestEffort( diagnostics: SessionContinuityDiagnostics, scope: SessionContinuityScope | "both", writtenPaths: string[], + failedStage: ContinuityRecoveryFailedStage, failureMessage: string, trigger: SessionContinuityAuditTrigger, writeMode: SessionContinuityWriteMode @@ -68,7 +70,7 @@ async function writeContinuityRecoveryRecordBestEffort( writeMode, scope, writtenPaths, - failedStage: "audit-write", + failedStage, failureMessage }) ); @@ -124,16 +126,31 @@ export async function persistSessionContinuity( const summarizer = new SessionContinuitySummarizer(options.runtime.loadedConfig.config); const generation = await summarizer.summarizeWithDiagnostics(parsedEvidence, existing); - const written = - options.writeMode === "replace" - ? await options.runtime.sessionContinuityStore.replaceSummary( - generation.summary, - options.scope - ) - : await options.runtime.sessionContinuityStore.saveSummary( - generation.summary, - options.scope - ); + let written: string[]; + try { + written = + options.writeMode === "replace" + ? await options.runtime.sessionContinuityStore.replaceSummary( + generation.summary, + options.scope + ) + : await options.runtime.sessionContinuityStore.saveSummary( + generation.summary, + options.scope + ); + } catch (error) { + await writeContinuityRecoveryRecordBestEffort( + options.runtime, + generation.diagnostics, + options.scope, + [], + "summary-write", + errorMessage(error), + options.trigger, + options.writeMode + ); + throw error; + } const auditEntry = buildSessionContinuityAuditEntry( options.runtime.project, options.runtime.loadedConfig.config, @@ -154,6 +171,7 @@ export async function persistSessionContinuity( generation.diagnostics, options.scope, written, + "audit-write", errorMessage(error), options.trigger, options.writeMode diff --git a/src/lib/domain/session-continuity-store.ts b/src/lib/domain/session-continuity-store.ts index 3f99ec9..baf6c84 100644 --- a/src/lib/domain/session-continuity-store.ts +++ b/src/lib/domain/session-continuity-store.ts @@ -13,7 +13,14 @@ import type { SessionContinuitySummary, SessionContinuityWriteMode } from "../types.js"; -import { appendJsonl, fileExists, readJsonFile, readTextFile, writeJsonFile, writeTextFile } from "../util/fs.js"; +import { + appendJsonl, + fileExists, + readJsonFile, + readTextFile, + writeJsonFile, + writeTextFileAtomic +} from "../util/fs.js"; import { getDefaultMemoryDirectory } from "./project-context.js"; import { isSessionContinuityAuditEntry } from "./session-continuity-diagnostics.js"; import { isContinuityRecoveryRecord } from "./recovery-records.js"; @@ -30,6 +37,17 @@ function todayStamp(): string { return new Date().toISOString().slice(0, 10); } +interface SessionContinuityWriteSnapshot { + path: string; + existed: boolean; + contents: string | null; +} + +interface SessionContinuityPendingWrite { + path: string; + contents: string; +} + export class SessionContinuityStore { public readonly paths: SessionContinuityPaths; @@ -234,7 +252,10 @@ export class SessionContinuityStore { const entries = await this.readAuditEntries(); for (let index = entries.length - 1; index >= 0; index -= 1) { const entry = entries[index]; - if (entry?.scope === scope) { + if ( + entry && + (entry.scope === scope || (entry.scope === "both" && scope !== "both")) + ) { return entry; } } @@ -247,9 +268,9 @@ export class SessionContinuityStore { scope: SessionContinuityScope | "both", writeMode: SessionContinuityWriteMode ): Promise { - const written: string[] = []; const targets = scope === "both" ? (["project", "project-local"] satisfies SessionContinuityScope[]) : [scope]; + const pendingWrites: SessionContinuityPendingWrite[] = []; for (const target of targets) { if (target === "project") { @@ -272,13 +293,54 @@ export class SessionContinuityStore { target === "project" ? this.paths.sharedFile : await this.resolveLocalWritePath(writeMode); - await writeTextFile(filePath, renderSessionContinuity(nextState)); - written.push(filePath); + pendingWrites.push({ + path: filePath, + contents: renderSessionContinuity(nextState) + }); + } + + const snapshots = await this.captureWriteSnapshots(pendingWrites.map((write) => write.path)); + const written: string[] = []; + + try { + for (const pendingWrite of pendingWrites) { + await writeTextFileAtomic(pendingWrite.path, pendingWrite.contents); + written.push(pendingWrite.path); + } + } catch (error) { + await this.restoreWriteSnapshots(snapshots); + throw error; } return written; } + private async captureWriteSnapshots(paths: string[]): Promise { + return Promise.all( + [...new Set(paths)].map(async (filePath) => { + const existed = await fileExists(filePath); + return { + path: filePath, + existed, + contents: existed ? await readTextFile(filePath) : null + }; + }) + ); + } + + private async restoreWriteSnapshots( + snapshots: SessionContinuityWriteSnapshot[] + ): Promise { + for (const snapshot of snapshots) { + if (!snapshot.existed) { + await fs.rm(snapshot.path, { force: true }).catch(() => undefined); + continue; + } + + await writeTextFileAtomic(snapshot.path, snapshot.contents ?? ""); + } + } + private async resolveLocalReadPath(): Promise { if (this.config.sessionContinuityLocalPathStyle !== "claude") { return (await fileExists(this.paths.localFile)) ? this.paths.localFile : null; diff --git a/src/lib/extractor/contradiction-review.ts b/src/lib/extractor/contradiction-review.ts index b28d961..985afd6 100644 --- a/src/lib/extractor/contradiction-review.ts +++ b/src/lib/extractor/contradiction-review.ts @@ -5,7 +5,11 @@ import type { MemoryScope } from "../types.js"; import { canonicalCommandSignature } from "./command-signatures.js"; -import { extractReferenceResourceKey, splitDirectiveClauses } from "./directive-utils.js"; +import { + extractReferenceResourceKey, + inferReferenceCategory, + splitDirectiveClauses +} from "./directive-utils.js"; interface DirectiveChoice { key: string; @@ -166,19 +170,10 @@ function normalizeReferenceUrl(url: string): string { } function extractReferenceChoices(text: string): DirectiveChoice[] { - const normalized = text.toLowerCase(); const urlMatch = text.match(/https?:\/\/[^\s)]+/iu)?.[0]; const url = urlMatch ? normalizeReferenceUrl(urlMatch) : null; - const category = - /\bdashboard\b|仪表盘/u.test(normalized) - ? "dashboard" - : /\brunbook\b|操作手册|run book/u.test(normalized) - ? "runbook" - : /\bdoc(?:s|umentation)?\b|文档/u.test(normalized) - ? "docs" - : /\b(?:linear|jira|issue tracker|issues?)\b|缺陷追踪|问题追踪/u.test(normalized) - ? "issue-tracker" - : "pointer"; + const category = inferReferenceCategory(text); + const normalized = text.toLowerCase(); if (url) { const resourceKey = extractReferenceResourceKey(text, category, url) ?? category; diff --git a/src/lib/extractor/directive-utils.ts b/src/lib/extractor/directive-utils.ts index 6edd2c9..e247265 100644 --- a/src/lib/extractor/directive-utils.ts +++ b/src/lib/extractor/directive-utils.ts @@ -9,9 +9,12 @@ const genericReferenceTokens = new Set([ "pointer", "issue-tracker", "issues", - "issue" + "issue", + "browse" ]); +const genericHostTokens = new Set(["www", "com", "org", "net", "io", "dev", "app"]); + function trimReferencePrefix(value: string): string { return value .trim() @@ -34,6 +37,25 @@ function resourceTokenFromUrl(url: string, category: string): string | null { return tailToken; } + if (category === "issue-tracker") { + const nonGenericPathTokens = pathTokens.filter((token) => { + if (genericReferenceTokens.has(token)) { + return false; + } + + return !/^[a-z]+-\d+$/iu.test(token) && !/^\d+$/u.test(token); + }); + const hostTokens = parsed.hostname + .split(".") + .map((segment) => slugify(segment)) + .filter(Boolean); + const hostContextToken = + hostTokens.find((token) => !genericHostTokens.has(token)) ?? slugify(parsed.hostname); + const contextToken = nonGenericPathTokens.slice(-2).join("-"); + + return [hostContextToken, contextToken].filter(Boolean).join("-") || category; + } + if (tailToken) { return tailToken; } @@ -44,6 +66,20 @@ function resourceTokenFromUrl(url: string, category: string): string | null { return category; } +export function inferReferenceCategory(text: string): string { + const normalized = text.toLowerCase(); + + return /\bdashboard\b|仪表盘/u.test(normalized) + ? "dashboard" + : /\brunbook\b|操作手册|run book/u.test(normalized) + ? "runbook" + : /\bdoc(?:s|umentation)?\b|文档/u.test(normalized) + ? "docs" + : /\b(?:linear|jira|issue tracker|issues?)\b|缺陷追踪|问题追踪/u.test(normalized) + ? "issue-tracker" + : "pointer"; +} + export function splitDirectiveClauses(text: string): string[] { return text .split(/\s*(?:,|;|,|;|\bbut\b|\bhowever\b|但是|但|不过)\s*/iu) @@ -57,6 +93,10 @@ export function extractReferenceResourceKey( url?: string | null ): string | null { const normalizedText = text.toLowerCase(); + if (category === "issue-tracker" && url) { + return resourceTokenFromUrl(url, category); + } + const nounPattern = category === "runbook" ? /([a-z0-9][a-z0-9 -]{0,80})\s+runbook\b/iu @@ -70,7 +110,11 @@ export function extractReferenceResourceKey( const nounMatch = nounPattern?.exec(normalizedText)?.[1]; if (nounMatch) { const normalized = slugify(trimReferencePrefix(nounMatch)); - if (normalized && !genericReferenceTokens.has(normalized)) { + if ( + normalized && + normalized !== "memory-entry" && + !genericReferenceTokens.has(normalized) + ) { return normalized; } } @@ -79,5 +123,15 @@ export function extractReferenceResourceKey( return resourceTokenFromUrl(url, category); } + if (category === "issue-tracker") { + const trackerMatch = normalizedText.match(/\b(linear|jira|github issues?)\b/iu)?.[1]; + if (trackerMatch) { + const normalized = slugify(trackerMatch); + if (normalized && !genericReferenceTokens.has(normalized)) { + return normalized; + } + } + } + return category; } diff --git a/src/lib/extractor/heuristic-extractor.ts b/src/lib/extractor/heuristic-extractor.ts index b6e0188..e54dbba 100644 --- a/src/lib/extractor/heuristic-extractor.ts +++ b/src/lib/extractor/heuristic-extractor.ts @@ -5,7 +5,7 @@ import type { MemoryExtractorAdapter } from "../runtime/contracts.js"; import { slugify } from "../util/text.js"; import { commandSucceeded, extractCommand, isCommandToolCall } from "./command-utils.js"; import { canonicalCommandSignature } from "./command-signatures.js"; -import { extractReferenceResourceKey } from "./directive-utils.js"; +import { extractReferenceResourceKey, inferReferenceCategory } from "./directive-utils.js"; interface ExplicitCorrection { scope: MemoryOperation["scope"]; @@ -190,7 +190,7 @@ function isStableDirectiveSummary(topic: string, summary: string): boolean { summary ); case "architecture": - return /\b(markdown-first|db-first|database-first|source of truth|canonical)\b|主真相|规范存储/u.test( + return /(markdown-first|db-first|database-first|source of truth|canonical)|主真相|规范存储/iu.test( summary ); case "debugging": @@ -251,7 +251,8 @@ function stableDirectiveReplacementKey(topic: string, summary: string): string | if (topic === "reference") { const urlMatch = summary.match(/https?:\/\/[^\s)]+/iu)?.[0]; const url = urlMatch?.replace(/[),.;]+$/u, "").trim().toLowerCase(); - return `reference:${extractReferenceResourceKey(summary, "runbook", url) ?? "pointer"}`; + const category = inferReferenceCategory(summary); + return `reference:${category}:${extractReferenceResourceKey(summary, category, url) ?? category}`; } if ( @@ -513,9 +514,18 @@ function extractStableAssistantSummary(message: string): { return null; } + if (!isHighConfidenceExplicitCorrection(summary)) { + return null; + } + + const topic = inferTopic(summary); + if (!stableDirectiveTopics.has(topic) || !isStableDirectiveSummary(topic, summary)) { + return null; + } + return { scope: inferScope(summary), - topic: inferTopic(summary), + topic, summary, details: [summary], reason: "Stable assistant summary extracted from the session." diff --git a/src/lib/extractor/session-continuity-summarizer.ts b/src/lib/extractor/session-continuity-summarizer.ts index 153bc10..a980d52 100644 --- a/src/lib/extractor/session-continuity-summarizer.ts +++ b/src/lib/extractor/session-continuity-summarizer.ts @@ -100,7 +100,7 @@ const GENERIC_GOAL_PATTERNS = [ /^(?:check|verify)\s+(?:it|this|that|again)\s*[.!?]*$/iu, /^(?:can|could|would)\s+you\b.+\b(?:it|this|that)\b[?!.\s]*$/iu, /^(?:look|take a look)\s+(?:into|at)\s+(?:it|this|that)\s*[.!?]*$/iu, - /^(?:what about|why)\b.+$/iu, + /^(?:what about)\b.+$/iu, /^(?:继续|接着)\s*[。!?!?.]*$/u, /^(?:跑|重跑)\s*(?:检查|测试|校验)\s*[。!?!?.]*$/u, /^(?:看看|看一下|检查一下)\s*(?:这个|这个问题|它)?\s*[。!?!?.]*$/u, @@ -264,10 +264,10 @@ function heuristicSummary( const goalLooksLocal = latestGoalCandidate.length > 0 && looksLocalSpecific(latestGoalCandidate); const sharedGoal = latestGoalCandidate.length === 0 - ? existingProject?.goal || existingLocal?.goal || "" + ? existingProject?.goal || "" : goalLooksLocal ? existingProject?.goal ?? "" - : latestGoalCandidate || existingProject?.goal || existingLocal?.goal || ""; + : latestGoalCandidate || existingProject?.goal || ""; const localGoal = latestGoalCandidate.length === 0 ? existingLocal?.goal ?? "" diff --git a/src/lib/runtime/runtime-context.ts b/src/lib/runtime/runtime-context.ts index 6809b42..238ed42 100644 --- a/src/lib/runtime/runtime-context.ts +++ b/src/lib/runtime/runtime-context.ts @@ -4,6 +4,7 @@ import { MemoryRetrievalService } from "../domain/memory-retrieval.js"; import { detectProjectContext } from "../domain/project-context.js"; import { SessionContinuityStore } from "../domain/session-continuity-store.js"; import { SyncService } from "../domain/sync-service.js"; +import { ensureExistingDirectory } from "../util/paths.js"; import type { AppConfig, ConfigScope, @@ -32,7 +33,7 @@ export async function buildRuntimeContext( overrides: Partial = {}, options: RuntimeContextOptions = {} ): Promise { - const project = detectProjectContext(cwd); + const project = detectProjectContext(await ensureExistingDirectory(cwd)); const loadedConfig = await loadConfig(project, overrides); const syncService = new SyncService(project, loadedConfig.config); const sessionContinuityStore = new SessionContinuityStore(project, loadedConfig.config); diff --git a/src/lib/util/paths.ts b/src/lib/util/paths.ts index 6d8d1ca..99e53b7 100644 --- a/src/lib/util/paths.ts +++ b/src/lib/util/paths.ts @@ -1,3 +1,4 @@ +import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -17,3 +18,18 @@ export function resolveAppPath(input: string): string { return path.resolve(expandHome(input)); } +export async function ensureExistingDirectory(input: string): Promise { + const resolved = resolveAppPath(input); + let stat; + try { + stat = await fs.stat(resolved); + } catch { + throw new Error(`Path must be an existing directory: ${resolved}`); + } + + if (!stat.isDirectory()) { + throw new Error(`Path must be an existing directory: ${resolved}`); + } + + return resolved; +} diff --git a/test/extractor.test.ts b/test/extractor.test.ts index 5d4271b..dc31100 100644 --- a/test/extractor.test.ts +++ b/test/extractor.test.ts @@ -372,6 +372,20 @@ describe("HeuristicExtractor", () => { ).toBe(false); }); + it("does not extract hedged stable assistant summaries into durable memory", async () => { + const extractor = new HeuristicExtractor(); + const operations = await extractor.extract( + baseEvidence({ + agentMessages: [ + "Confirmed maybe use bun instead of pnpm in this repository for now." + ] + }), + [] + ); + + expect(operations).toEqual([]); + }); + it("adds a newer command memory from a real rollout fixture without deleting a different toolchain command", async () => { const extractor = new HeuristicExtractor(); const evidence = await parseRolloutEvidence( @@ -1185,6 +1199,119 @@ describe("HeuristicExtractor", () => { ); }); + it("does not replace an issue tracker reference with a different pointer when both lack URLs", async () => { + const extractor = new HeuristicExtractor(); + const existingEntries: MemoryEntry[] = [ + { + id: "jira-incidents", + scope: "project", + topic: "reference", + summary: "Incidents are tracked in Jira.", + details: ["Old issue tracker pointer."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ]; + + const operations = await extractor.extract( + baseEvidence({ + userMessages: ["Work is tracked in Linear."] + }), + existingEntries + ); + const reviewed = reviewExtractedMemoryOperations(operations, existingEntries); + + expect(reviewed.operations).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "delete", + topic: "reference", + id: "jira-incidents" + }) + ]) + ); + expect(reviewed.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "upsert", + topic: "reference" + }) + ]) + ); + }); + + it("does not replace an existing issue tracker URL when a different tracker URL shares a generic tail token", async () => { + const extractor = new HeuristicExtractor(); + const existingEntries: MemoryEntry[] = [ + { + id: "jira-issues", + scope: "project", + topic: "reference", + summary: "Issues are tracked at https://acme.atlassian.net/issues", + details: ["Primary issue tracker pointer."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ]; + + const operations = await extractor.extract( + baseEvidence({ + userMessages: ["Issues are tracked at https://github.com/acme/widgets/issues."] + }), + existingEntries + ); + const reviewed = reviewExtractedMemoryOperations(operations, existingEntries); + + expect(reviewed.operations).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "delete", + topic: "reference", + id: "jira-issues" + }) + ]) + ); + expect(reviewed.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "upsert", + topic: "reference", + summary: "Issues are tracked at https://github.com/acme/widgets/issues" + }) + ]) + ); + }); + + it("does not suppress different issue tracker URLs when both use a generic browse tail", () => { + const reviewed = reviewExtractedMemoryOperations( + [ + { + action: "upsert", + scope: "project", + topic: "reference", + id: "jira-browse", + summary: "The issue tracker lives at https://jira.example.com/browse", + details: ["Jira browse pointer."], + reason: "Stable directive extracted from the session." + }, + { + action: "upsert", + scope: "project", + topic: "reference", + id: "github-browse", + summary: "The issue tracker lives at https://github.com/acme/widgets/browse", + details: ["GitHub browse pointer."], + reason: "Stable directive extracted from the session." + } + ], + [] + ); + + expect(reviewed.suppressedOperationCount).toBe(0); + expect(reviewed.conflicts).toEqual([]); + expect(reviewed.operations).toHaveLength(2); + }); + it("treats remember-style reference corrections as explicit replacements end-to-end", async () => { const extractor = new HeuristicExtractor(); const existingEntries: MemoryEntry[] = [ diff --git a/test/memory-command.test.ts b/test/memory-command.test.ts index 7f1c167..6d9e4c1 100644 --- a/test/memory-command.test.ts +++ b/test/memory-command.test.ts @@ -1509,6 +1509,30 @@ describe("runMemory", () => { expect(await store.listEntries("project")).toHaveLength(1); }); + it("fails closed at the CLI surface when remember text is empty or whitespace-only", async () => { + const homeDir = await tempDir("cam-remember-empty-cli-home-"); + const projectDir = await tempDir("cam-remember-empty-cli-project-"); + const memoryRoot = await tempDir("cam-remember-empty-cli-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const emptyResult = runCli(projectDir, ["remember", ""], { + env: { HOME: homeDir } + }); + expect(emptyResult.exitCode).toBe(1); + expect(emptyResult.stderr).toContain("non-empty"); + + const blankResult = runCli(projectDir, ["remember", " "], { + env: { HOME: homeDir } + }); + expect(blankResult.exitCode).toBe(1); + expect(blankResult.stderr).toContain("non-empty"); + }); + it("surfaces a structured reviewer payload for remember --json", async () => { const homeDir = await tempDir("cam-remember-json-home-"); const projectDir = await tempDir("cam-remember-json-project-"); @@ -2287,43 +2311,29 @@ describe("runMemory", () => { uniqueAuditCount: 0, auditCountsDeduplicated: true, warningsByEntryRef: {}, - leadEntryRef: "project:active:workflow:prefer-pnpm", - leadEntryIndex: 0, + leadEntryRef: null, + leadEntryIndex: null, detailsAvailable: false, - reviewRefState: "active", + reviewRefState: null, detailsUsableEntryCount: 0, timelineOnlyEntryCount: 1, matchedCount: 1, appliedCount: 1, noopCount: 0, - ref: "project:active:workflow:prefer-pnpm", - timelineRef: "project:active:workflow:prefer-pnpm", + ref: null, + timelineRef: null, detailsRef: null, - lifecycleAction: "delete", - latestLifecycleAction: "delete", - latestAppliedLifecycle: { - action: "delete" - }, - latestLifecycleAttempt: { - action: "delete", - outcome: "applied", - updateKind: null - }, - latestState: "deleted", + lifecycleAction: null, + latestLifecycleAction: null, + latestAppliedLifecycle: null, + latestLifecycleAttempt: null, + latestState: null, latestSessionId: null, latestRolloutPath: null, timelineWarningCount: 0, - lineageSummary: { - latestAction: "delete", - latestUpdateKind: null - }, + lineageSummary: null, warnings: [], - entry: { - id: "prefer-pnpm", - scope: "project", - topic: "workflow", - summary: "Prefer pnpm in this repository." - }, + entry: null, affectedRefs: ["project:active:workflow:prefer-pnpm"], followUp: { timelineRefs: ["project:active:workflow:prefer-pnpm"], @@ -2907,10 +2917,30 @@ describe("runMemory", () => { expect(JSON.parse(forgetResult.stdout)).toMatchObject({ mutationKind: "forget", matchedCount: 1, - ref: "project:active:preferences:prefer-pnpm-in-this-repository" + ref: null, + timelineRef: null, + detailsRef: null }); }); + it("fails closed when --cwd does not point to an existing directory", async () => { + const homeDir = await tempDir("cam-memory-invalid-cwd-home-"); + const callerDir = await tempDir("cam-memory-invalid-cwd-caller-"); + const missingDir = path.join(callerDir, "missing-project-dir"); + process.env.HOME = homeDir; + + const result = runCli( + callerDir, + ["remember", "Prefer pnpm in this repository.", "--cwd", missingDir], + { + env: { HOME: homeDir } + } + ); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("existing directory"); + }); + it("surfaces startup omission reasons for low-signal, duplicate, unsafe, and budget-trimmed highlights", async () => { const homeDir = await tempDir("cam-memory-startup-omissions-home-"); const projectDir = await tempDir("cam-memory-startup-omissions-project-"); @@ -4113,4 +4143,14 @@ describe("runMemory", () => { "\"prefer-pnpm\"" ); }); + + it("exposes memory reindex as a dedicated CLI subcommand", async () => { + const result = runCli(process.cwd(), ["memory", "reindex", "--help"]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Usage: "); + expect(result.stdout).toContain("memory reindex"); + expect(result.stdout).toContain("Rebuild retrieval sidecars from canonical Markdown memory"); + expect(result.stdout).not.toContain("--enable"); + }); }); diff --git a/test/session-command.test.ts b/test/session-command.test.ts index 64eb54e..f283736 100644 --- a/test/session-command.test.ts +++ b/test/session-command.test.ts @@ -967,6 +967,126 @@ describe("runSession", () => { }); }, 30_000); + it("refresh can reuse scope=both audit provenance when refreshing only the shared project layer", async () => { + const repoDir = await tempDir("cam-session-refresh-project-scope-both-audit-repo-"); + const memoryRoot = await tempDir("cam-session-refresh-project-scope-both-audit-memory-"); + const sessionsDir = await tempDir("cam-session-refresh-project-scope-both-audit-sessions-"); + const dayDir = path.join(sessionsDir, "2026", "03", "15"); + process.env.CAM_CODEX_SESSIONS_DIR = sessionsDir; + await fs.mkdir(dayDir, { recursive: true }); + await initRepo(repoDir); + + await writeProjectConfig(repoDir, configJson(), { + autoMemoryDirectory: memoryRoot + }); + + const auditRolloutPath = path.join(repoDir, "matching-both-audit-rollout.jsonl"); + await fs.writeFile( + auditRolloutPath, + rolloutFixture(repoDir, "Use the shared project audit provenance.", { + sessionId: "session-both-audit" + }), + "utf8" + ); + const primaryRolloutPath = path.join(dayDir, "rollout-primary.jsonl"); + await fs.writeFile( + primaryRolloutPath, + rolloutFixture(repoDir, "Fallback primary rollout should stay unused here.", { + sessionId: "session-primary-fallback" + }), + "utf8" + ); + + const project = detectProjectContext(repoDir); + const store = new SessionContinuityStore(project, { + ...configJson(), + autoMemoryDirectory: memoryRoot + }); + await store.appendAuditLog({ + generatedAt: "2026-03-18T00:01:00.000Z", + projectId: project.projectId, + worktreeId: project.worktreeId, + configuredExtractorMode: "heuristic", + trigger: "manual-save", + writeMode: "merge", + scope: "both", + rolloutPath: auditRolloutPath, + sourceSessionId: "session-both-audit", + preferredPath: "heuristic", + actualPath: "heuristic", + fallbackReason: "configured-heuristic", + evidenceCounts: makeEvidenceCounts(), + writtenPaths: ["/tmp/continuity-audit.md"] + }); + + const payload = JSON.parse( + await runSession("refresh", { cwd: repoDir, scope: "project", json: true }) + ) as { + rolloutSelection: { kind: string; rolloutPath: string }; + }; + expect(payload.rolloutSelection).toEqual({ + kind: "latest-audit-entry", + rolloutPath: auditRolloutPath + }); + + const merged = await store.readMergedState(); + expect(merged?.goal).toContain("Use the shared project audit provenance."); + }, 30_000); + + it("save can reuse scope=both audit provenance when saving only the local project layer", async () => { + const repoDir = await tempDir("cam-session-save-local-scope-both-audit-repo-"); + const memoryRoot = await tempDir("cam-session-save-local-scope-both-audit-memory-"); + await initRepo(repoDir); + + await writeProjectConfig(repoDir, configJson(), { + autoMemoryDirectory: memoryRoot + }); + + const auditRolloutPath = path.join(repoDir, "matching-both-save-audit-rollout.jsonl"); + await fs.writeFile( + auditRolloutPath, + rolloutFixture(repoDir, "Use the local project audit provenance.", { + sessionId: "session-both-save-audit" + }), + "utf8" + ); + + const project = detectProjectContext(repoDir); + const store = new SessionContinuityStore(project, { + ...configJson(), + autoMemoryDirectory: memoryRoot + }); + await store.appendAuditLog({ + generatedAt: "2026-03-18T00:01:00.000Z", + projectId: project.projectId, + worktreeId: project.worktreeId, + configuredExtractorMode: "heuristic", + trigger: "manual-save", + writeMode: "merge", + scope: "both", + rolloutPath: auditRolloutPath, + sourceSessionId: "session-both-save-audit", + preferredPath: "heuristic", + actualPath: "heuristic", + fallbackReason: "configured-heuristic", + evidenceCounts: makeEvidenceCounts(), + writtenPaths: ["/tmp/continuity-audit.md"] + }); + + const payload = JSON.parse( + await runSession("save", { cwd: repoDir, scope: "project-local", json: true }) + ) as { + rolloutSelection: { kind: string; rolloutPath: string }; + latestContinuityAuditEntry: { trigger?: string; writeMode?: string } | null; + }; + expect(payload.rolloutSelection).toEqual({ + kind: "latest-audit-entry", + rolloutPath: auditRolloutPath + }); + expect(payload.latestContinuityAuditEntry?.trigger).toBe("manual-save"); + expect(payload.latestContinuityAuditEntry?.writeMode).toBe("merge"); + }, 30_000); + it("does not fall back to a lower-priority source when the selected refresh provenance cannot be read", async () => { const repoDir = await tempDir("cam-session-refresh-missing-provenance-repo-"); const memoryRoot = await tempDir("cam-session-refresh-missing-provenance-memory-"); @@ -2277,6 +2397,49 @@ describe("runSession", () => { expect(await store.readRecoveryRecord()).toBeNull(); }, 30_000); + it("writes a summary-write recovery marker when continuity summary persistence fails", async () => { + const repoDir = await tempDir("cam-session-summary-write-recovery-repo-"); + const memoryRoot = await tempDir("cam-session-summary-write-recovery-memory-"); + await initRepo(repoDir); + + await writeProjectConfig(repoDir, configJson(), { + autoMemoryDirectory: memoryRoot + }); + + const rolloutPath = path.join(repoDir, "rollout.jsonl"); + await fs.writeFile( + rolloutPath, + rolloutFixture(repoDir, "Fail continuity summary writes before audit append."), + "utf8" + ); + + const saveSummarySpy = vi + .spyOn(SessionContinuityStore.prototype, "saveSummary") + .mockRejectedValueOnce(new Error("continuity summary write failed")); + + await expect( + runSession("save", { + cwd: repoDir, + rollout: rolloutPath, + scope: "both" + }) + ).rejects.toThrow("continuity summary write failed"); + saveSummarySpy.mockRestore(); + + const store = new SessionContinuityStore(detectProjectContext(repoDir), { + ...configJson(), + autoMemoryDirectory: memoryRoot + }); + expect(await store.readRecoveryRecord()).toMatchObject({ + rolloutPath, + failedStage: "summary-write", + failureMessage: "continuity summary write failed", + scope: "both", + writtenPaths: [] + }); + expect(await store.readLatestAuditEntry()).toBeNull(); + }, 30_000); + it("does not clear an unrelated continuity recovery marker after a successful save", async () => { const repoDir = await tempDir("cam-session-stale-recovery-repo-"); const memoryRoot = await tempDir("cam-session-stale-recovery-memory-"); diff --git a/test/session-continuity.test.ts b/test/session-continuity.test.ts index 2fe5dd9..1789fa1 100644 --- a/test/session-continuity.test.ts +++ b/test/session-continuity.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { applySessionContinuityLayerSummary, compileSessionContinuity, @@ -365,6 +365,31 @@ describe("session continuity domain", () => { expect(summary.projectLocal.goal).toBe(existing.projectLocal.goal); }); + it("heuristic summarizer does not backfill a shared goal from an existing local-only goal", async () => { + const existing = { + project: createEmptySessionContinuityState("project", "p1", "w1"), + projectLocal: { + ...createEmptySessionContinuityState("project-local", "p1", "w1"), + goal: "Finish the current worktree patch for login cookie handling." + } + }; + const evidence: RolloutEvidence = { + sessionId: "session-generic-local-goal", + createdAt: "2026-03-15T00:00:00.000Z", + cwd: "/tmp/project", + userMessages: ["Continue", "Run checks"], + agentMessages: [], + toolCalls: [], + rolloutPath: "/tmp/rollout.jsonl" + }; + + const summarizer = new SessionContinuitySummarizer(baseConfig("/tmp/memory-root")); + const summary = await summarizer.summarize(evidence, existing); + + expect(summary.project.goal).toBe(""); + expect(summary.projectLocal.goal).toBe(existing.projectLocal.goal); + }); + it("heuristic summarizer drops historical in-progress pseudo-failures from existing state", async () => { const existing = { project: { @@ -512,6 +537,33 @@ describe("session continuity domain", () => { ); }); + it("treats concrete question-style latest requests as meaningful goals and fallback next steps", async () => { + const evidence: RolloutEvidence = { + sessionId: "session-question-goal", + createdAt: "2026-03-15T00:00:00.000Z", + cwd: "/tmp/project", + userMessages: ["Why is the login cookie missing after the middleware redirect?"], + agentMessages: [], + toolCalls: [], + rolloutPath: "/tmp/rollout.jsonl" + }; + + const summarizer = new SessionContinuitySummarizer(baseConfig("/tmp/memory-root")); + const result = await summarizer.summarizeWithDiagnostics(evidence); + + expect(result.summary.project.goal).toBe( + "Why is the login cookie missing after the middleware redirect?" + ); + expect(result.summary.projectLocal.incompleteNext).toEqual([ + "Continue with the latest request: Why is the login cookie missing after the middleware redirect?" + ]); + expect(result.diagnostics.warnings).toEqual( + expect.arrayContaining([ + expect.stringContaining("Next steps were inferred from the latest request") + ]) + ); + }); + it("heuristic summarizer clears stale local goals so the merged goal can fall back to the shared layer", async () => { const evidence: RolloutEvidence = { sessionId: "session-clear-stale-local-goal", @@ -1858,4 +1910,74 @@ describe("SessionContinuityStore", () => { expect((await fs.readdir(store.paths.localDir)).filter((name) => name.endsWith("-session.tmp"))).toHaveLength(2); }); + it("rolls back shared and local continuity files if one summary write fails", async () => { + const repoDir = await tempDir("cam-continuity-atomic-repo-"); + const memoryRoot = await tempDir("cam-continuity-atomic-memory-"); + await initRepo(repoDir); + + const store = new SessionContinuityStore(detectProjectContext(repoDir), baseConfig(memoryRoot)); + await store.saveSummary( + { + project: { + goal: "Initial shared goal.", + confirmedWorking: ["Initial shared success."], + triedAndFailed: [], + notYetTried: [], + incompleteNext: [], + filesDecisionsEnvironment: [] + }, + projectLocal: { + goal: "Initial local goal.", + confirmedWorking: [], + triedAndFailed: [], + notYetTried: [], + incompleteNext: ["Initial local next step."], + filesDecisionsEnvironment: [] + }, + sourceSessionId: "session-initial" + }, + "both" + ); + + const sharedBefore = await fs.readFile(store.paths.sharedFile, "utf8"); + const localBefore = await fs.readFile(store.paths.localFile, "utf8"); + const originalRename = fs.rename; + const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async (from, to) => { + if (String(to) === store.paths.localFile) { + throw new Error("local continuity rename failed"); + } + + return await originalRename(from, to); + }); + + await expect( + store.saveSummary( + { + project: { + goal: "Updated shared goal.", + confirmedWorking: ["Updated shared success."], + triedAndFailed: [], + notYetTried: [], + incompleteNext: [], + filesDecisionsEnvironment: [] + }, + projectLocal: { + goal: "Updated local goal.", + confirmedWorking: [], + triedAndFailed: [], + notYetTried: [], + incompleteNext: ["Updated local next step."], + filesDecisionsEnvironment: [] + }, + sourceSessionId: "session-updated" + }, + "both" + ) + ).rejects.toThrow("local continuity rename failed"); + renameSpy.mockRestore(); + + expect(await fs.readFile(store.paths.sharedFile, "utf8")).toBe(sharedBefore); + expect(await fs.readFile(store.paths.localFile, "utf8")).toBe(localBefore); + }); + }); From e293df674bfab674f6358678ebb087d58766f004 Mon Sep 17 00:00:00 2001 From: blocks Date: Thu, 9 Apr 2026 23:45:03 +0800 Subject: [PATCH 44/62] fix: retry continuity recovery before newer rollouts --- src/lib/commands/session.ts | 16 +-- src/lib/domain/session-continuity-store.ts | 21 +++- test/session-command.test.ts | 116 +++++++++++++++++++++ test/session-continuity.test.ts | 48 +++++++++ 4 files changed, 192 insertions(+), 9 deletions(-) diff --git a/src/lib/commands/session.ts b/src/lib/commands/session.ts index 6dba278..38c585b 100644 --- a/src/lib/commands/session.ts +++ b/src/lib/commands/session.ts @@ -115,14 +115,6 @@ async function selectSaveRollout( }; } - const latestPrimaryRollout = await findLatestProjectRollout(runtime.project); - if (latestPrimaryRollout) { - return { - kind: "latest-primary-rollout", - rolloutPath: latestPrimaryRollout - }; - } - const recoveryRecord = await runtime.sessionContinuityStore.readRecoveryRecord(); if (recoveryRecord && matchesContinuitySelectionScope(recoveryRecord.scope, scope)) { return { @@ -140,6 +132,14 @@ async function selectSaveRollout( }; } + const latestPrimaryRollout = await findLatestProjectRollout(runtime.project); + if (latestPrimaryRollout) { + return { + kind: "latest-primary-rollout", + rolloutPath: latestPrimaryRollout + }; + } + throw new Error("No relevant rollout found for this project."); } diff --git a/src/lib/domain/session-continuity-store.ts b/src/lib/domain/session-continuity-store.ts index baf6c84..82c9ef7 100644 --- a/src/lib/domain/session-continuity-store.ts +++ b/src/lib/domain/session-continuity-store.ts @@ -271,11 +271,29 @@ export class SessionContinuityStore { const targets = scope === "both" ? (["project", "project-local"] satisfies SessionContinuityScope[]) : [scope]; const pendingWrites: SessionContinuityPendingWrite[] = []; + const snapshotsByPath = new Map(); + + const captureSnapshot = async (filePath: string): Promise => { + if (snapshotsByPath.has(filePath)) { + return; + } + + const existed = await fileExists(filePath); + snapshotsByPath.set(filePath, { + path: filePath, + existed, + contents: existed ? await readTextFile(filePath) : null + }); + }; for (const target of targets) { if (target === "project") { await this.ensureSharedLayout(); } else { + const localIgnorePath = this.getLocalIgnorePath(); + if (localIgnorePath) { + await captureSnapshot(localIgnorePath); + } await this.ensureLocalLayout(); await this.ensureLocalIgnore(); } @@ -293,13 +311,14 @@ export class SessionContinuityStore { target === "project" ? this.paths.sharedFile : await this.resolveLocalWritePath(writeMode); + await captureSnapshot(filePath); pendingWrites.push({ path: filePath, contents: renderSessionContinuity(nextState) }); } - const snapshots = await this.captureWriteSnapshots(pendingWrites.map((write) => write.path)); + const snapshots = [...snapshotsByPath.values()]; const written: string[] = []; try { diff --git a/test/session-command.test.ts b/test/session-command.test.ts index f283736..7552afb 100644 --- a/test/session-command.test.ts +++ b/test/session-command.test.ts @@ -1087,6 +1087,79 @@ describe("runSession", () => { expect(payload.latestContinuityAuditEntry?.writeMode).toBe("merge"); }, 30_000); + it("save prefers a matching recovery marker over a newer primary rollout", async () => { + const repoDir = await tempDir("cam-session-save-recovery-priority-repo-"); + const memoryRoot = await tempDir("cam-session-save-recovery-priority-memory-"); + const sessionsDir = await tempDir("cam-session-save-recovery-priority-sessions-"); + const dayDir = path.join(sessionsDir, "2026", "03", "15"); + process.env.CAM_CODEX_SESSIONS_DIR = sessionsDir; + await fs.mkdir(dayDir, { recursive: true }); + await initRepo(repoDir); + + await writeProjectConfig(repoDir, configJson(), { + autoMemoryDirectory: memoryRoot + }); + + const recoveryRolloutPath = path.join(repoDir, "recovery-rollout.jsonl"); + await fs.writeFile( + recoveryRolloutPath, + rolloutFixture(repoDir, "Retry the recovery provenance for save.", { + sessionId: "session-save-recovery" + }), + "utf8" + ); + const primaryRolloutPath = path.join(dayDir, "rollout-primary.jsonl"); + await fs.writeFile( + primaryRolloutPath, + rolloutFixture(repoDir, "This newer primary rollout should not win.", { + sessionId: "session-save-primary" + }), + "utf8" + ); + + const project = detectProjectContext(repoDir); + const store = new SessionContinuityStore(project, { + ...configJson(), + autoMemoryDirectory: memoryRoot + }); + await store.writeRecoveryRecord({ + recordedAt: "2026-03-18T00:00:00.000Z", + projectId: project.projectId, + worktreeId: project.worktreeId, + rolloutPath: recoveryRolloutPath, + sourceSessionId: "session-save-recovery", + trigger: "manual-save", + writeMode: "merge", + scope: "both", + writtenPaths: [store.paths.sharedFile, store.paths.localFile], + preferredPath: "heuristic", + actualPath: "heuristic", + fallbackReason: "configured-heuristic", + evidenceCounts: makeEvidenceCounts(), + failedStage: "audit-write", + failureMessage: "retry the same save provenance" + }); + + const payload = JSON.parse( + await runSession("save", { cwd: repoDir, scope: "both", json: true }) + ) as { + rolloutPath: string; + rolloutSelection: { kind: string; rolloutPath: string }; + latestContinuityAuditEntry: { trigger?: string; writeMode?: string } | null; + }; + expect(payload.rolloutSelection).toEqual({ + kind: "pending-recovery-marker", + rolloutPath: recoveryRolloutPath + }); + expect(payload.rolloutPath).toBe(recoveryRolloutPath); + expect(payload.latestContinuityAuditEntry?.trigger).toBe("manual-save"); + expect(payload.latestContinuityAuditEntry?.writeMode).toBe("merge"); + + const merged = await store.readMergedState(); + expect(merged?.goal).toContain("Retry the recovery provenance for save."); + expect(await store.readRecoveryRecord()).toBeNull(); + }, 30_000); + it("does not fall back to a lower-priority source when the selected refresh provenance cannot be read", async () => { const repoDir = await tempDir("cam-session-refresh-missing-provenance-repo-"); const memoryRoot = await tempDir("cam-session-refresh-missing-provenance-memory-"); @@ -2440,6 +2513,49 @@ describe("runSession", () => { expect(await store.readLatestAuditEntry()).toBeNull(); }, 30_000); + it("writes a summary-write recovery marker when refresh replacement fails", async () => { + const repoDir = await tempDir("cam-session-refresh-summary-write-recovery-repo-"); + const memoryRoot = await tempDir("cam-session-refresh-summary-write-recovery-memory-"); + await initRepo(repoDir); + + await writeProjectConfig(repoDir, configJson(), { + autoMemoryDirectory: memoryRoot + }); + + const rolloutPath = path.join(repoDir, "rollout.jsonl"); + await fs.writeFile( + rolloutPath, + rolloutFixture(repoDir, "Fail continuity replacement writes before audit append."), + "utf8" + ); + + const replaceSummarySpy = vi + .spyOn(SessionContinuityStore.prototype, "replaceSummary") + .mockRejectedValueOnce(new Error("continuity replace write failed")); + + await expect( + runSession("refresh", { + cwd: repoDir, + rollout: rolloutPath, + scope: "both" + }) + ).rejects.toThrow("continuity replace write failed"); + replaceSummarySpy.mockRestore(); + + const store = new SessionContinuityStore(detectProjectContext(repoDir), { + ...configJson(), + autoMemoryDirectory: memoryRoot + }); + expect(await store.readRecoveryRecord()).toMatchObject({ + rolloutPath, + failedStage: "summary-write", + failureMessage: "continuity replace write failed", + scope: "both", + writtenPaths: [] + }); + expect(await store.readLatestAuditEntry()).toBeNull(); + }, 30_000); + it("does not clear an unrelated continuity recovery marker after a successful save", async () => { const repoDir = await tempDir("cam-session-stale-recovery-repo-"); const memoryRoot = await tempDir("cam-session-stale-recovery-memory-"); diff --git a/test/session-continuity.test.ts b/test/session-continuity.test.ts index 1789fa1..7ba7d9a 100644 --- a/test/session-continuity.test.ts +++ b/test/session-continuity.test.ts @@ -1980,4 +1980,52 @@ describe("SessionContinuityStore", () => { expect(await fs.readFile(store.paths.localFile, "utf8")).toBe(localBefore); }); + it("rolls back git exclude updates if a local continuity write fails", async () => { + const repoDir = await tempDir("cam-continuity-ignore-rollback-repo-"); + const memoryRoot = await tempDir("cam-continuity-ignore-rollback-memory-"); + await initRepo(repoDir); + + const store = new SessionContinuityStore(detectProjectContext(repoDir), baseConfig(memoryRoot)); + const excludePath = store.getLocalIgnorePath(); + expect(excludePath).not.toBeNull(); + const excludeBefore = await fs.readFile(excludePath!, "utf8"); + + const originalRename = fs.rename; + const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async (from, to) => { + if (String(to) === store.paths.localFile) { + throw new Error("local continuity rename failed"); + } + + return await originalRename(from, to); + }); + + await expect( + store.saveSummary( + { + project: { + goal: "", + confirmedWorking: [], + triedAndFailed: [], + notYetTried: [], + incompleteNext: [], + filesDecisionsEnvironment: [] + }, + projectLocal: { + goal: "Initial local goal.", + confirmedWorking: [], + triedAndFailed: [], + notYetTried: [], + incompleteNext: ["Initial local next step."], + filesDecisionsEnvironment: [] + }, + sourceSessionId: "session-initial" + }, + "project-local" + ) + ).rejects.toThrow("local continuity rename failed"); + renameSpy.mockRestore(); + + expect(await fs.readFile(excludePath!, "utf8")).toBe(excludeBefore); + }); + }); From 00addbd5841ffcf128b77fbf571f1d7f5c85b15e Mon Sep 17 00:00:00 2001 From: blocks Date: Fri, 10 Apr 2026 00:26:15 +0800 Subject: [PATCH 45/62] docs: clarify integrations install AGENTS boundary --- README.en.md | 2 +- README.ja.md | 2 +- README.md | 4 ++-- README.zh-TW.md | 2 +- src/lib/cli/register-commands.ts | 2 +- test/dist-cli-smoke.test.ts | 1 + test/tarball-install-smoke.test.ts | 1 + 7 files changed, 8 insertions(+), 6 deletions(-) diff --git a/README.en.md b/README.en.md index 394e495..2f31481 100644 --- a/README.en.md +++ b/README.en.md @@ -206,7 +206,7 @@ cam audit | `cam remember` / `cam forget` | explicitly add or remove durable memory; both commands now also support `--cwd ` so manual corrections can target another project root directly; when `cam remember` omits `--topic`, it now performs lightweight durable-topic inference and prefers updating an existing memory instead of appending a second active entry when there is one clearly identifiable old value; `cam forget --archive` moves matching entries into the archive layer; `forget` now also shares the same multi-term query normalization as `recall search`, so queries like `pnpm npm` can match one memory across `summary/details` instead of requiring the original substring to appear contiguously; both commands now also support `--json`, returning a structured manual-mutation reviewer payload with `mutationKind`, `matchedCount`, `appliedCount`, `noopCount`, `summary`, `primaryEntry`, `entries[]`, `followUp`, `nextRecommendedActions`, and top-level lifecycle/detail fields (`latestAppliedLifecycle`, `latestLifecycleAttempt`, `latestLifecycleAction`, `latestState`, `latestSessionId`, `latestRolloutPath`, `latestAudit`, `timelineWarningCount`, `warnings`, `entry`, `lineageSummary`, `ref/path/historyPath`) whenever at least one matched ref exists; they now also add `leadEntryRef`, `leadEntryIndex`, `detailsAvailable`, `reviewRefState`, `uniqueAuditCount`, `auditCountsDeduplicated`, and `warningsByEntryRef` so delete/archive/multi-entry reviewer payloads are less ambiguous without breaking older consumers; empty `forget --json` results stay additive and now leave `nextRecommendedActions` empty instead of emitting placeholder refs; delete flows also distinguish timeline-only review refs from details-usable refs; text mode now also prints the same project-pinned `timeline/details -> recent -> reindex` follow-up route so manual corrections drop back into the reviewer loop naturally | | `cam recall search` / `timeline` / `details` | progressively retrieve durable memory through a search -> timeline -> details workflow; `search` now defaults to `state=auto, limit=8`, so active memory is checked before archived fallback while staying read-only, and multi-term queries now match across `id/topic/summary/details` instead of requiring every term to live in one field; the JSON surface now also exposes additive `retrievalMode`, `finalRetrievalMode`, `retrievalFallbackReason`, `stateResolution`, `executionSummary`, `searchOrder`, `totalMatchedCount`, `returnedCount`, `globalLimitApplied`, `truncatedCount`, `resultWindow`, `globalRank`, and `diagnostics.checkedPaths[].returnedCount` / `droppedCount` fields so fallback behavior, global sorting, and post-limit drops stay reviewer-visible; `finalRetrievalMode` is an explicit alias for the final result mode while `retrievalMode` keeps its compatibility semantics | | `cam mcp serve` | start a read-only retrieval MCP server that exposes the same workflow through `search_memories`, `timeline_memories`, and `get_memory_details` | -| `cam integrations install --host codex` | install the recommended Codex integration stack in one explicit step by writing project-scoped MCP wiring and refreshing the hook bridge bundle plus Codex skill assets; it defaults to the runtime skill target, but also accepts `--skill-surface runtime|official-user|official-project`; stays idempotent, Codex-only, does not touch the Markdown memory store, and now rolls back staged MCP / hook / skill writes if installation fails mid-flight; `--json` now also returns a structured rollback failure payload; after installation it explicitly points you back to `cam integrations doctor --host codex` to confirm which retrieval route is operational in the current environment | +| `cam integrations install --host codex` | install the recommended Codex integration stack in one explicit step by writing project-scoped MCP wiring and refreshing the hook bridge bundle plus Codex skill assets; it defaults to the runtime skill target, but also accepts `--skill-surface runtime|official-user|official-project`; stays idempotent, Codex-only, does not touch `AGENTS.md` or the Markdown memory store, and now rolls back staged MCP / hook / skill writes if installation fails mid-flight; `--json` now also returns a structured rollback failure payload; after installation it explicitly points you back to `cam integrations doctor --host codex` to confirm which retrieval route is operational in the current environment | | `cam integrations apply --host codex` | explicitly apply the full Codex integration state: it keeps `integrations install` unchanged, but also orchestrates `cam mcp apply-guidance --host codex`; it defaults to the runtime skill target, but also accepts `--skill-surface runtime|official-user|official-project`; if the `AGENTS.md` managed block is unsafe, the command returns a preflight `blocked` result before any stack writes happen, and if a later block or staged write fails the JSON payload now reports rollback outcome plus the final effective action; after apply you should still use doctor to confirm whether MCP, the local bridge bundle, or the resolved CLI route is the operational path | | `cam integrations doctor --host codex` | inspect the current Codex integration stack through a thin read-only aggregation surface that reports the recommended route and current route truth (`recommendedRoute`, `currentlyOperationalRoute`, `routeKind`, `routeEvidence`, `shellDependencyLevel`, `hostMutationRequired`, `preferredRouteBlockers`, `currentOperationalBlockers`), recommended preset, structured `workflowContract`, `applyReadiness`, additive `experimentalHooks` guidance, `layoutDiagnostics`, subchecks, and minimum next steps; `recommendedRoute` stays MCP-first, while the blocker fields separately explain why the preferred route is unavailable and whether the current fallback still has its own operational issues; it also surfaces skill-surface steering (`preferredSkillSurface`, `recommendedSkillInstallCommand`, `installedSkillSurfaces`, `readySkillSurfaces`) without describing skills as an executable fallback route; when doctor is anchored to another repository with `--cwd`, hook-fallback next steps now also project-pin the local bridge route via `CAM_PROJECT_ROOT=...`; when `cam` is unavailable on PATH, the direct CLI next step now prefers the resolved `node dist/cli.js recall ...` fallback instead of a broken bare `cam recall ...`; when the managed `AGENTS.md` block is unsafe, it now tells you to repair that block first instead of recommending `cam integrations apply --host codex` immediately | | `cam mcp install --host codex` | explicitly write the recommended Codex project-scoped host config for `codex_auto_memory`; only that server entry is updated, hooks/skills stay opt-in, and non-canonical custom fields on that entry are preserved when safe; lower-priority non-Codex host wiring stays in `docs/host-surfaces.md` instead of the default product path, and some of those routes remain `manual-only` | diff --git a/README.ja.md b/README.ja.md index cacf2db..7671894 100644 --- a/README.ja.md +++ b/README.ja.md @@ -200,7 +200,7 @@ cam audit | `cam remember` / `cam forget` | durable memory の明示的な追加・削除。両方とも `--cwd ` をサポートし、別の project root を明示的に対象化できる。`cam forget --archive` は一致した項目をアーカイブ層へ移動する。`forget` は `recall search` と同じ多語 query 正規化も共有するようになり、`pnpm npm` のような query でも元の substring が連続していなくても `summary/details` をまたいで 1 件の memory に命中できる。両方とも `--json` をサポートし、`mutationKind`、`matchedCount`、`appliedCount`、`noopCount`、`summary`、`primaryEntry`、`entries[]`、`followUp`、`nextRecommendedActions`、そして少なくとも 1 件ヒットしたときにだけ出るトップレベルの lifecycle/detail フィールド(`latestAppliedLifecycle`、`latestLifecycleAttempt`、`latestLifecycleAction`、`latestState`、`latestSessionId`、`latestRolloutPath`、`latestAudit`、`timelineWarningCount`、`warnings`、`entry`、`lineageSummary`、`ref/path/historyPath`)を含む manual mutation reviewer payload を返す。さらに `leadEntryRef`、`leadEntryIndex`、`detailsAvailable`、`reviewRefState`、`uniqueAuditCount`、`auditCountsDeduplicated`、`warningsByEntryRef` も返す。空の `forget --json` は additive な空 payload のままで、`nextRecommendedActions` も空配列を返し、占位 `""` は出さない。delete フローでは timeline-only と details-usable の review route も分けて返す。テキスト出力でも project-pinned な `timeline/details -> recent -> reindex` の follow-up を直接案内するようになった | | `cam recall search` / `timeline` / `details` | `search -> timeline -> details` の progressive disclosure workflow で durable memory を段階的に取得する。`search` は `state=auto, limit=8` を既定値として使い、active を先に調べてヒットしなければ archived にフォールバックしつつ read-only を保つ。複数語の query は `id/topic/summary/details` をまたいで集約マッチするようになり、すべての term が同一 field にある必要はない。JSON ではさらに `retrievalMode`、`finalRetrievalMode`、`retrievalFallbackReason`、`stateResolution`、`executionSummary`、`searchOrder`、`totalMatchedCount`、`returnedCount`、`globalLimitApplied`、`truncatedCount`、`resultWindow`、`globalRank`、`diagnostics.checkedPaths[].returnedCount` / `droppedCount` を返し、fallback、global sorting、post-limit の挙動を reviewer-visible にする | | `cam mcp serve` | `search_memories` / `timeline_memories` / `get_memory_details` を通じて同じ retrieval contract を公開する read-only MCP server を起動する | -| `cam integrations install --host codex` | 推奨される Codex integration stack を一度に導入し、project-scoped MCP wiring を書き込みつつ、hook bridge bundle と Codex skill assets を更新する。skills は runtime target が既定だが、`--skill-surface runtime|official-user|official-project` も指定できる。明示的・冪等・Codex-only を保ち、Markdown memory store には触れない。さらに staged install の途中で失敗した場合は、MCP / hooks / skills の書き込みを rollback し、`--json` では構造化された rollback failure payload も返す。導入後は `cam integrations doctor --host codex` に戻り、現在の環境で本当に operational な retrieval route を確認する | +| `cam integrations install --host codex` | 推奨される Codex integration stack を一度に導入し、project-scoped MCP wiring を書き込みつつ、hook bridge bundle と Codex skill assets を更新する。skills は runtime target が既定だが、`--skill-surface runtime|official-user|official-project` も指定できる。明示的・冪等・Codex-only を保ち、`AGENTS.md` と Markdown memory store には触れない。さらに staged install の途中で失敗した場合は、MCP / hooks / skills の書き込みを rollback し、`--json` では構造化された rollback failure payload も返す。導入後は `cam integrations doctor --host codex` に戻り、現在の環境で本当に operational な retrieval route を確認する | | `cam integrations apply --host codex` | 明示的・冪等・Codex-only のまま完全な integration state を適用する。`integrations install` の既存境界は変えず、その上で `cam mcp apply-guidance --host codex` も編成する。skills は runtime target が既定だが、`--skill-surface runtime|official-user|official-project` も指定できる。`AGENTS.md` managed block が unsafe な場合は、stack への書き込み前に preflight `blocked` を返す。apply 後も `doctor` に戻り、実際に有効なのが MCP、local bridge、resolved CLI のどれかを確認する必要がある | | `cam integrations doctor --host codex` | 現在の Codex integration stack を薄い read-only 集約面として点検し、推奨ルートと現在の route truth(`recommendedRoute`、`currentlyOperationalRoute`、`routeKind`、`routeEvidence`、`shellDependencyLevel`、`hostMutationRequired`、`preferredRouteBlockers`、`currentOperationalBlockers`)、推奨 preset、構造化された `workflowContract`、`applyReadiness`、`experimentalHooks`、`layoutDiagnostics`、サブチェック結果、次の最小アクションを返す。`recommendedRoute` は MCP-first のまま維持され、blocker フィールドが「なぜ preferred route が使えないのか」と「現在の fallback 自体に operational blocker があるか」を分けて示す。さらに skill-surface steering(`preferredSkillSurface`、`recommendedSkillInstallCommand`、`installedSkillSurfaces`、`readySkillSurfaces`)も返し、guidance surface の導入先を示すが、skills 自体を executable fallback route とは扱わない。hook helper についても「installed だが今の shell では operational でない」を区別して返す。`--cwd` で別リポジトリを検査した場合、hooks fallback の next step も `CAM_PROJECT_ROOT=...` を付けて local bridge route を対象 project に pin する。`cam` が PATH で解決できない場合、direct CLI next step は壊れた bare `cam recall ...` ではなく resolved `node dist/cli.js recall ...` fallback を優先する。`AGENTS.md` managed block が unsafe な場合は、まずその修復を案内し、すぐに `cam integrations apply --host codex` を勧めない | | `cam mcp install --host codex` | 推奨される Codex project-scoped 宿主設定を明示的に書き込み、`codex_auto_memory` の項目だけを更新する。hooks/skills は自動導入せず、その entry に non-canonical なカスタム項目がある場合は安全な範囲で保持する。より低優先度の非 Codex host wiring は `docs/host-surfaces.md` に収め、既定の製品導線にはしない。その一部は引き続き `manual-only` のまま扱う | diff --git a/README.md b/README.md index c6799a0..190f833 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

简体中文 | 繁體中文 | - English + English | 日本語

@@ -211,7 +211,7 @@ cam audit | `cam remember` / `cam forget` | 显式新增、删除或修正 memory;两者现在也支持 `--cwd ` 用于跨目录锚定目标项目;`cam remember` 在省略 `--topic` 时会做轻量 durable topic 推断,并在“唯一旧值可识别”时优先更新现有 memory,而不是无脑追加第二条 active entry;`cam forget --archive` 会把匹配条目移入归档层;`forget` 现在还会和 `recall search` 共用多词 query 归一化语义,允许像 `pnpm npm` 这样的 query 跨 `summary/details` 命中同一条 memory,而不是要求整段原始 substring 连续出现;两者现在都支持 `--json`,返回 manual mutation 的 reviewer payload,包括 `mutationKind`、`matchedCount`、`appliedCount`、`noopCount`、`summary`、`primaryEntry`、`entries[]`、`followUp`、`nextRecommendedActions`,以及在至少命中一个 ref 时额外暴露的顶层 lifecycle/detail 字段(`latestAppliedLifecycle`、`latestLifecycleAttempt`、`latestLifecycleAction`、`latestState`、`latestSessionId`、`latestRolloutPath`、`latestAudit`、`timelineWarningCount`、`warnings`、`entry`、`lineageSummary`、`ref/path/historyPath`);现在还会额外暴露 `leadEntryRef`、`leadEntryIndex`、`detailsAvailable`、`reviewRefState`、`uniqueAuditCount`、`auditCountsDeduplicated` 与 `warningsByEntryRef`,让 delete / archive / multi-entry forget 的 lead-entry 与聚合 reviewer 语义更显式;空的 `forget --json` 结果现在保持 additive,并会返回空的 `nextRecommendedActions`,不再给出占位式 `""` 提示;delete 分支还会显式区分 timeline-only 与 details-usable review routes;文本模式现在也会直接给出 project-pinned 的 `timeline/details -> recent -> reindex` follow-up,帮助手工修正后自然回到 reviewer 闭环 | | `cam recall search` / `timeline` / `details` | 以 `search -> timeline -> details` 的 progressive disclosure 检索 durable memory;`search` 默认采用 `state=auto`、`limit=8`,先查 active,未命中再回退 archived,且保持只读 retrieval;多词查询现在会跨 `id/topic/summary/details` 聚合命中,而不是要求所有 term 落在同一个字段;JSON 输出现在还会额外暴露 `retrievalMode`、`finalRetrievalMode`、`retrievalFallbackReason`、`stateResolution`、`executionSummary`、`searchOrder`、`totalMatchedCount`、`returnedCount`、`globalLimitApplied`、`truncatedCount`、`resultWindow`、`globalRank`,以及 `diagnostics.checkedPaths[].returnedCount` / `droppedCount`,把 auto-state、global sort、fallback 与 post-limit 行为说清楚;其中 `finalRetrievalMode` 只是对最终结果面的显式别名,`retrievalMode` 继续保留兼容语义 | | `cam mcp serve` | 启动只读 retrieval MCP server,通过 `search_memories` / `timeline_memories` / `get_memory_details` 暴露同一套渐进式检索契约 | -| `cam integrations install --host codex` | 一次性安装推荐的 Codex integration stack:写入 project-scoped MCP wiring,并刷新 hook bridge bundle 与 Codex skill 资产;默认使用 runtime skills target,也支持显式 `--skill-surface runtime|official-user|official-project`;保持显式、幂等、Codex-only,不触碰 Markdown memory store;如果 staged install 中途失败,现在也会回滚已写入的 MCP / hooks / skills 文件,避免留下半成功状态;`--json` 还会返回结构化 rollback payload;安装完成后会明确提醒再跑 `cam integrations doctor --host codex` 确认当前环境里真正 operational 的 retrieval route | +| `cam integrations install --host codex` | 一次性安装推荐的 Codex integration stack:写入 project-scoped MCP wiring,并刷新 hook bridge bundle 与 Codex skill 资产;默认使用 runtime skills target,也支持显式 `--skill-surface runtime|official-user|official-project`;保持显式、幂等、Codex-only,不触碰 `AGENTS.md` 或 Markdown memory store;如果 staged install 中途失败,现在也会回滚已写入的 MCP / hooks / skills 文件,避免留下半成功状态;`--json` 还会返回结构化 rollback payload;安装完成后会明确提醒再跑 `cam integrations doctor --host codex` 确认当前环境里真正 operational 的 retrieval route | | `cam integrations apply --host codex` | 以显式、幂等、Codex-only 的方式应用完整 integration state:在保留 `integrations install` 旧语义不变的前提下,额外编排 `cam mcp apply-guidance --host codex`;默认使用 runtime skills target,也支持显式 `--skill-surface runtime|official-user|official-project`;若 `AGENTS.md` managed block 不安全,会在任何 stack 写入之前 preflight `blocked`,保持 additive / fail-closed;若 late-block 或 staged write 失败,JSON payload 现在也会显式暴露 rollback outcome 与最终 effective action,避免把“尝试写过”误读成“最终已安装”;apply 完成后同样需要再用 doctor 判断当前环境里是 MCP、local bridge 还是 resolved CLI 在实际生效 | | `cam integrations doctor --host codex` | 以 Codex-only、只读、薄聚合的方式汇总当前 integration stack readiness,直接给出推荐路由、当前 operational route truth(`recommendedRoute`、`currentlyOperationalRoute`、`routeKind`、`routeEvidence`、`shellDependencyLevel`、`hostMutationRequired`、`preferredRouteBlockers`、`currentOperationalBlockers`)、推荐 preset、结构化 `workflowContract`、`applyReadiness`、`experimentalHooks`、`layoutDiagnostics`、子检查结果与下一步最小动作;其中 `recommendedRoute` 继续表示 MCP-first 的首选路径,而 blocker 字段会分开说明“为什么首选路由没跑起来”和“当前 fallback 自己是否还有问题”;还会额外暴露 skill-surface steering(`preferredSkillSurface`、`recommendedSkillInstallCommand`、`installedSkillSurfaces`、`readySkillSurfaces`),帮助后续安装 guidance surface,但不把 skills 误写成 executable fallback route;当通过 `--cwd` 检查另一个项目时,hooks fallback 的 next steps 现在也会通过 `CAM_PROJECT_ROOT=...` 把 local bridge route project-pin 到目标仓库;当 `cam` 当前不可解析时,direct CLI next step 也会优先给出 resolved `node dist/cli.js recall ...` fallback,而不是先给出会失败的裸 `cam recall ...`;当 AGENTS guidance 处于 unsafe managed-block 状态时,会先提示修复 `AGENTS.md`,而不是直接推荐 `cam integrations apply --host codex` | | `cam mcp install --host codex` | 显式写入推荐的 Codex project-scoped 宿主 MCP 配置;只更新 `codex_auto_memory` 这一项,不会自动安装 hooks/skills;若该 entry 已带有非 canonical 自定义字段,会在安全前提下保留它们;更低优先级的非 Codex host wiring 细节继续收口到 `docs/host-surfaces.md`,不作为默认产品路径,其中一部分仍保持 `manual-only` | diff --git a/README.zh-TW.md b/README.zh-TW.md index 02a30ae..cac71bf 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -202,7 +202,7 @@ cam audit | `cam remember` / `cam forget` | 顯式新增或刪除 durable memory;兩者現在也支援 `--cwd `,可跨目錄鎖定另一個 project root;`cam forget --archive` 會把匹配條目移入歸檔層;`forget` 現在也和 `recall search` 共用同一套多詞 query 歸一化語義,像 `pnpm npm` 這樣的 query 可以跨 `summary/details` 命中同一條 memory,而不需要原始 substring 連續出現;兩者現在也支援 `--json`,回傳手工 mutation 的 reviewer payload,包括 `mutationKind`、`matchedCount`、`appliedCount`、`noopCount`、`summary`、`primaryEntry`、`entries[]`、`followUp`、`nextRecommendedActions`,以及在至少命中一個 ref 時才額外暴露的頂層 lifecycle/detail 欄位(`latestAppliedLifecycle`、`latestLifecycleAttempt`、`latestLifecycleAction`、`latestState`、`latestSessionId`、`latestRolloutPath`、`latestAudit`、`timelineWarningCount`、`warnings`、`entry`、`lineageSummary`、`ref/path/historyPath`);現在也會額外暴露 `leadEntryRef`、`leadEntryIndex`、`detailsAvailable`、`reviewRefState`、`uniqueAuditCount`、`auditCountsDeduplicated` 與 `warningsByEntryRef`;空的 `forget --json` 結果現在會保留 additive 空 payload,並回傳空的 `nextRecommendedActions`,不再輸出占位式 `""` 提示;delete 分支也會明確區分 timeline-only 與 details-usable review route;文字模式現在也會直接給出 project-pinned 的 `timeline/details -> recent -> reindex` follow-up,讓手工修正後更自然回到 reviewer 閉環 | | `cam recall search` / `timeline` / `details` | 以 `search -> timeline -> details` 的 progressive disclosure 工作流檢索 durable memory;`search` 現在預設採用 `state=auto`、`limit=8`,會先查 active,未命中再回退 archived,且保持只讀 retrieval;多詞查詢現在會跨 `id/topic/summary/details` 聚合命中,而不是要求所有 term 都落在同一個欄位;JSON 現在還會額外暴露 `retrievalMode`、`finalRetrievalMode`、`retrievalFallbackReason`、`stateResolution`、`executionSummary`、`searchOrder`、`totalMatchedCount`、`returnedCount`、`globalLimitApplied`、`truncatedCount`、`resultWindow`、`globalRank`,以及 `diagnostics.checkedPaths[].returnedCount` / `droppedCount`,把 explicit-state、global sorting、fallback 與 post-limit 行為說清楚;其中 `finalRetrievalMode` 只是最終結果面的顯式別名 | | `cam mcp serve` | 啟動只讀 retrieval MCP server,以 `search_memories` / `timeline_memories` / `get_memory_details` 暴露同一套漸進式檢索契約 | -| `cam integrations install --host codex` | 一次性安裝推薦的 Codex integration stack:寫入 project-scoped MCP wiring,並刷新 hook bridge bundle 與 Codex skill 資產;預設使用 runtime skills target,也支援顯式 `--skill-surface runtime|official-user|official-project`;保持顯式、幂等、Codex-only,且不碰 Markdown memory store;若 staged install 中途失敗,現在也會回滾已寫入的 MCP / hooks / skills 檔案;`--json` 也會回傳結構化 rollback failure payload;安裝完成後會明確提示再跑 `cam integrations doctor --host codex`,確認當前環境裡真正 operational 的 retrieval route | +| `cam integrations install --host codex` | 一次性安裝推薦的 Codex integration stack:寫入 project-scoped MCP wiring,並刷新 hook bridge bundle 與 Codex skill 資產;預設使用 runtime skills target,也支援顯式 `--skill-surface runtime|official-user|official-project`;保持顯式、幂等、Codex-only,且不碰 `AGENTS.md` 或 Markdown memory store;若 staged install 中途失敗,現在也會回滾已寫入的 MCP / hooks / skills 檔案;`--json` 也會回傳結構化 rollback failure payload;安裝完成後會明確提示再跑 `cam integrations doctor --host codex`,確認當前環境裡真正 operational 的 retrieval route | | `cam integrations apply --host codex` | 以顯式、幂等、Codex-only 的方式套用完整 integration state:在保留 `integrations install` 舊語義不變的前提下,額外編排 `cam mcp apply-guidance --host codex`;預設使用 runtime skills target,也支援顯式 `--skill-surface runtime|official-user|official-project`;若 `AGENTS.md` managed block 不安全,現在會在任何 stack 寫入前 preflight `blocked`;apply 完成後同樣需要回到 doctor 判斷實際生效的是 MCP、local bridge 還是 resolved CLI | | `cam integrations doctor --host codex` | 以 Codex-only、只讀、薄聚合的方式彙總目前 integration stack readiness,直接給出推薦路由、當前 operational route truth(`recommendedRoute`、`currentlyOperationalRoute`、`routeKind`、`routeEvidence`、`shellDependencyLevel`、`hostMutationRequired`、`preferredRouteBlockers`、`currentOperationalBlockers`)、推薦 preset、結構化 `workflowContract`、`applyReadiness`、`experimentalHooks`、`layoutDiagnostics`、子檢查結果與下一步最小動作;其中 `recommendedRoute` 會維持 MCP-first 的首選路徑,而 blocker 欄位會分開說明「為何首選路由沒跑起來」以及「目前 fallback 自己是否還有 operational 問題」;現在還會顯式暴露 skill-surface steering(`preferredSkillSurface`、`recommendedSkillInstallCommand`、`installedSkillSurfaces`、`readySkillSurfaces`),幫助後續安裝 guidance surface,但不把 skills 說成 executable fallback route;也會區分 hook helper 是只是 installed,還是在目前 shell 中真正 operational;當用 `--cwd` 檢查另一個 repo 時,hooks fallback 的 next steps 也會透過 `CAM_PROJECT_ROOT=...` 把 local bridge route 明確 pin 到目標專案;若 `cam` 在 PATH 中不可解析,direct CLI next step 也會優先給出 resolved `node dist/cli.js recall ...` fallback,而不是先給出會失敗的裸 `cam recall ...`;若 `AGENTS.md` managed block 處於 unsafe 狀態,會先提示修復它,而不是直接推薦 `cam integrations apply --host codex` | | `cam mcp install --host codex` | 顯式寫入推薦的 Codex project-scoped 宿主 MCP 配置;只更新 `codex_auto_memory` 這一項,不會自動安裝 hooks/skills;若該 entry 已帶有非 canonical 自訂欄位,會在安全前提下保留它們;更低優先級的非 Codex host wiring 繼續收口到 `docs/host-surfaces.md`,不作為預設產品路徑,其中一部分仍保持 `manual-only` | diff --git a/src/lib/cli/register-commands.ts b/src/lib/cli/register-commands.ts index 6c0e08e..ea676f0 100644 --- a/src/lib/cli/register-commands.ts +++ b/src/lib/cli/register-commands.ts @@ -284,7 +284,7 @@ function registerIntegrationCommands(program: Command): void { integrationsCommand .command("install") .description( - "Install the recommended project-scoped Codex integration stack. The runtime default stays in place unless you opt into an official copy." + "Install the recommended project-scoped Codex integration stack without updating AGENTS.md. The runtime default stays in place unless you opt into an official copy." ) .requiredOption("--host ", `Target host: ${formatMcpHostChoices(["codex"])}`) .option( diff --git a/test/dist-cli-smoke.test.ts b/test/dist-cli-smoke.test.ts index e35bd09..5daca47 100644 --- a/test/dist-cli-smoke.test.ts +++ b/test/dist-cli-smoke.test.ts @@ -1745,6 +1745,7 @@ fs.writeFileSync(${JSON.stringify(capturedArgsPath)}, JSON.stringify(process.arg }); expect(integrationsHelp.exitCode, integrationsHelp.stderr).toBe(0); expect(integrationsHelp.stdout).toContain("Install the recommended project-scoped Codex integration stack"); + expect(integrationsHelp.stdout).toMatch(/without updating\s+AGENTS\.md/); expect(integrationsHelp.stdout).toContain("Target host: codex"); expect(integrationsHelp.stdout).toMatch( /Skill install surface: runtime, official-user, or\s+official-project/ diff --git a/test/tarball-install-smoke.test.ts b/test/tarball-install-smoke.test.ts index 453789f..fbc31c3 100644 --- a/test/tarball-install-smoke.test.ts +++ b/test/tarball-install-smoke.test.ts @@ -1020,6 +1020,7 @@ describe("tarball install smoke", () => { expect(integrationsInstallHelpResult.stdout).toContain( "Install the recommended project-scoped Codex integration stack" ); + expect(integrationsInstallHelpResult.stdout).toMatch(/without updating\s+AGENTS\.md/); expect(integrationsInstallHelpResult.stdout).toContain("Target host: codex"); expect(integrationsInstallHelpResult.stdout).toMatch( /Skill install surface: runtime, official-user, or\s+official-project/ From 8b93a4361bb37aa45711be55b99a105e9aa7c768 Mon Sep 17 00:00:00 2001 From: blocks Date: Fri, 10 Apr 2026 00:26:56 +0800 Subject: [PATCH 46/62] docs: add repository AGENTS guide --- AGENTS.md | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3dd81b9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,107 @@ +# Codex Auto Memory Agent Notes + +## 功能描述 + +`codex-auto-memory` 是一个面向 Codex 的 `Markdown-first` 本地记忆运行层。 + +当前产品边界: + +- durable memory 与 session continuity 分层维护 +- `cam memory` 提供 inspect / audit surface +- `cam session` 提供 temporary continuity surface +- canonical source of truth 仍然是 Markdown,而不是数据库 +- 当前最稳入口仍然是 wrapper + CLI,同时继续向 hooks、skills、MCP-aware retrieval 演进 + +## 使用方法 + +常用开发命令: + +```bash +pnpm install +pnpm lint +pnpm test +pnpm build +pnpm test:docs-contract +pnpm test:dist-cli-smoke +pnpm test:tarball-install-smoke +``` + +常用产品命令: + +```bash +cam run +cam sync +cam memory +cam memory reindex +cam recall search "" +cam mcp serve +cam mcp install --host codex +cam mcp print-config --host codex +cam mcp apply-guidance --host codex +cam mcp doctor --host codex +cam integrations install --host codex +cam integrations apply --host codex +cam integrations doctor --host codex +cam session save +cam session refresh +cam session load +cam session status +``` + +## 参数说明 + +关键配置文件: + +- `codex-auto-memory.json` +- `.codex-auto-memory.local.json` + +关键配置字段: + +- `autoMemoryEnabled`: 是否开启 durable memory sync +- `extractorMode`: `codex` 或 `heuristic` +- `defaultScope`: 默认 memory scope +- `maxStartupLines`: startup durable memory 行预算 +- `sessionContinuityAutoLoad`: wrapper 是否自动注入 continuity +- `sessionContinuityAutoSave`: wrapper 是否自动保存 continuity +- `maxSessionContinuityLines`: continuity startup 行预算 +- `codexBinary`: 调用的 Codex 可执行文件 + +集成相关公开参数: + +- `cam skills install --surface runtime|official-user|official-project` +- `cam integrations install/apply --skill-surface runtime|official-user|official-project` +- `cam ... --cwd ` 用于跨目录锚定目标项目 + +## 返回值说明 + +关键 JSON reviewer / integration contract: + +- `cam memory --json`: 返回 startup files、topic refs、recent sync audit、`topicDiagnostics`、`layoutDiagnostics` 等 inspect 信息 +- `cam memory reindex --json`: 返回 rebuilt sidecar 摘要与对应 `indexPath` / `generatedAt` +- `cam recall search --json`: 返回 compact refs、`retrievalMode`、`stateResolution`、`executionSummary`、`diagnostics.checkedPaths` +- `cam recall timeline --json`: 返回 lifecycle history、`warnings`、`lineageSummary` +- `cam recall details --json`: 返回 detail、`latestState`、`latestAudit`、`warnings` +- `cam mcp print-config --json`: 对 Codex 暴露 project-scoped MCP snippet、`workflowContract`、推荐 `AGENTS.md` guidance +- `cam mcp doctor --json`: 暴露 retrieval MCP wiring、fallback assets、`codexStack`、`retrievalSidecar` +- `cam integrations install/apply --json`: 暴露 staged subactions、rollback payload、`postInstallReadinessCommand` / `postApplyReadinessCommand` +- `cam integrations doctor --json`: 暴露 `recommendedRoute`、`currentlyOperationalRoute`、`workflowContract`、`applyReadiness` + +## 项目规划 + +当前优先事项: + +1. 继续保持 issue5 stack 的 reviewer contract、help surface、release-facing smoke 一致 +2. 保持 `Markdown-first` canonical store 与 sidecar retrieval plane 的边界稳定 +3. 保持 `cam integrations install` 与 `cam integrations apply` 的 AGENTS mutation boundary 清晰 +4. 继续扩大 deterministic release gate:`lint`、`test`、`docs-contract`、`dist-cli-smoke`、`tarball-install-smoke` + +下一阶段建议: + +1. 继续做小步 stack closure,而不是重新摊大 remediation +2. 优先把 help / docs / smoke contract 固定成同一套公开语义 +3. 在不扩张宿主边界的前提下,继续维持 Codex-first、manual-only 非 Codex host 的产品表述 + +## 变更记录 + +- 2026-04-10: 新增根级 `AGENTS.md`,补齐仓库级功能说明、命令面、关键 JSON 契约与项目规划。 +- 2026-04-10: 明确 `cam integrations install --help` 与四语 README 命令表的公开边界:install 编排 stack,但不更新 `AGENTS.md`。 From 72fcd853fdc335b03cc53fb41a8bcfc49477a1e0 Mon Sep 17 00:00:00 2001 From: blocks Date: Fri, 10 Apr 2026 20:47:16 +0800 Subject: [PATCH 47/62] docs: clarify Claude and Gemini host boundaries --- AGENTS.md | 8 +- README.en.md | 1 + README.md | 1 + docs/README.en.md | 4 + docs/README.md | 4 + docs/host-integration-claude-gemini.md | 190 +++++++++++++++++++++++++ docs/host-surfaces.md | 28 ++++ docs/integration-strategy.md | 2 + 8 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 docs/host-integration-claude-gemini.md diff --git a/AGENTS.md b/AGENTS.md index 3dd81b9..8b49aef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,9 +91,10 @@ cam session status 当前优先事项: 1. 继续保持 issue5 stack 的 reviewer contract、help surface、release-facing smoke 一致 -2. 保持 `Markdown-first` canonical store 与 sidecar retrieval plane 的边界稳定 -3. 保持 `cam integrations install` 与 `cam integrations apply` 的 AGENTS mutation boundary 清晰 -4. 继续扩大 deterministic release gate:`lint`、`test`、`docs-contract`、`dist-cli-smoke`、`tarball-install-smoke` +2. 把 Claude Code / Gemini CLI 的官方公开宿主能力面,与本仓当前真实支持的 manual-only host 边界继续写清楚 +3. 保持 `Markdown-first` canonical store 与 sidecar retrieval plane 的边界稳定 +4. 保持 `cam integrations install` 与 `cam integrations apply` 的 AGENTS mutation boundary 清晰 +5. 继续扩大 deterministic release gate:`lint`、`test`、`docs-contract`、`dist-cli-smoke`、`tarball-install-smoke` 下一阶段建议: @@ -105,3 +106,4 @@ cam session status - 2026-04-10: 新增根级 `AGENTS.md`,补齐仓库级功能说明、命令面、关键 JSON 契约与项目规划。 - 2026-04-10: 明确 `cam integrations install --help` 与四语 README 命令表的公开边界:install 编排 stack,但不更新 `AGENTS.md`。 +- 2026-04-10: 新增 Claude Code / Gemini CLI 宿主接入边界文档,并同步收紧宿主策略文档与 README 入口,明确非 Codex 宿主当前仍是 manual-only / snippet-first。 diff --git a/README.en.md b/README.en.md index 2f31481..a84423d 100644 --- a/README.en.md +++ b/README.en.md @@ -323,6 +323,7 @@ See the architecture docs for the full boundary breakdown. ### Core design docs - [Claude reference contract (中文)](docs/claude-reference.md) | [English](docs/claude-reference.en.md) +- [Claude Code / Gemini CLI host integration boundaries (中文)](docs/host-integration-claude-gemini.md) - [Architecture (中文)](docs/architecture.md) | [English](docs/architecture.en.md) - [Integration strategy (中文)](docs/integration-strategy.md) - [Host surfaces (中文)](docs/host-surfaces.md) diff --git a/README.md b/README.md index 190f833..cc3b8bd 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,7 @@ Session continuity: ### 核心设计文档 - [Claude Code 参考契约(中文)](docs/claude-reference.md) | [English](docs/claude-reference.en.md) +- [Claude Code / Gemini CLI 宿主接入边界(中文)](docs/host-integration-claude-gemini.md) - [架构设计(中文)](docs/architecture.md) | [English](docs/architecture.en.md) - [集成演进策略(中文)](docs/integration-strategy.md) - [宿主能力面(中文)](docs/host-surfaces.md) diff --git a/docs/README.en.md b/docs/README.en.md index 796f617..8fe021c 100644 --- a/docs/README.en.md +++ b/docs/README.en.md @@ -14,6 +14,7 @@ 3. [Architecture](./architecture.en.md) 4. [Integration strategy](./integration-strategy.md) (Chinese) 5. [Native migration strategy](./native-migration.en.md) +6. [Claude Code / Gemini CLI host integration boundaries](./host-integration-claude-gemini.md) (Chinese) ### Maintainers @@ -23,6 +24,7 @@ 4. [Session continuity design](./session-continuity.md) 5. [Release checklist](./release-checklist.md) 6. [ClaudeCode patch audit](./claudecode-patch-audit.md) +7. [Claude Code / Gemini CLI host integration boundaries](./host-integration-claude-gemini.md) (Chinese) ### Reviewers and follow-up agents @@ -32,12 +34,14 @@ 4. [Host surfaces](./host-surfaces.md) (Chinese) 5. [Native migration strategy](./native-migration.en.md) 6. [Session continuity design](./session-continuity.md) +7. [Claude Code / Gemini CLI host integration boundaries](./host-integration-claude-gemini.md) (Chinese) ## Core design docs | Document | Purpose | Language | | :-- | :-- | :-- | | [Claude reference contract](./claude-reference.en.md) | defines which public Claude Code memory behaviors this project intentionally mirrors, and where it now intentionally diverges | English / [中文](./claude-reference.md) | +| [Claude Code / Gemini CLI host integration boundaries](./host-integration-claude-gemini.md) | explains the difference between publicly documented Claude/Gemini host surfaces and the manual-only integration boundary this repository currently supports | 中文 | | [Architecture](./architecture.en.md) | explains the current Codex-first Hybrid architecture: wrapper path today, broader integration surfaces tomorrow | English / [中文](./architecture.md) | | [Integration strategy](./integration-strategy.md) | explains how the current repository expands from a Codex companion into a Codex-first Hybrid memory system | 中文 | | [Host surfaces](./host-surfaces.md) | records host capability boundaries and future integration posture across Codex and adjacent ecosystems | 中文 | diff --git a/docs/README.md b/docs/README.md index 6287d28..78ee294 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ 2. [Claude Code 参考契约](./claude-reference.md) 3. [架构设计](./architecture.md) 4. [集成演进策略](./integration-strategy.md) +5. [Claude Code / Gemini CLI 宿主接入边界](./host-integration-claude-gemini.md) ### 维护者 @@ -22,6 +23,7 @@ 4. [Session continuity 设计](./session-continuity.md) 5. [Native migration 策略](./native-migration.md) 6. [Release checklist](./release-checklist.md) +7. [Claude Code / Gemini CLI 宿主接入边界](./host-integration-claude-gemini.md) ### Reviewer / 外部审查工具 @@ -30,12 +32,14 @@ 3. [集成演进策略](./integration-strategy.md) 4. [宿主能力面](./host-surfaces.md) 5. [Session continuity 设计](./session-continuity.md) +6. [Claude Code / Gemini CLI 宿主接入边界](./host-integration-claude-gemini.md) ## 核心设计文档 | 文档 | 作用 | 语言 | | :-- | :-- | :-- | | [Claude Code 参考契约](./claude-reference.md) | 说明本项目主动对齐的 Claude Code memory 契约边界 | 中文 / [English](./claude-reference.en.md) | +| [Claude Code / Gemini CLI 宿主接入边界](./host-integration-claude-gemini.md) | 收口 Claude / Gemini 当前公开宿主面与本仓真实支持边界之间的关系 | 中文 | | [架构设计](./architecture.md) | 解释当前主实现:startup injection、sync、continuity 与 Markdown store | 中文 / [English](./architecture.en.md) | | [集成演进策略](./integration-strategy.md) | 解释当前仓库如何从 Codex companion 演进为 Codex-first Hybrid memory system | 中文 | | [宿主能力面](./host-surfaces.md) | 固化当前仓库对 Codex 及其他宿主的能力判断与边界 | 中文 | diff --git a/docs/host-integration-claude-gemini.md b/docs/host-integration-claude-gemini.md new file mode 100644 index 0000000..5d884ed --- /dev/null +++ b/docs/host-integration-claude-gemini.md @@ -0,0 +1,190 @@ +# Claude Code / Gemini CLI 宿主接入边界 + +> 本文回答两个问题: +> 1. Claude Code 与 Gemini CLI 当前公开了哪些值得对齐的宿主能力面。 +> 2. 在这些公开能力已经存在的前提下,`codex-auto-memory` 现在真正应该支持到哪一层。 + +## 一页结论 + +当前最稳的结论应固定为: + +- `codex-auto-memory` 仍然是 **Codex-first Hybrid memory system** +- Claude Code 与 Gemini CLI 都是 **重要参考宿主** +- 当前仓库对 Claude / Gemini 的真实接入面仍是: + - `cam mcp print-config --host ` + - `cam mcp doctor --host ` + - manual-only / snippet-first 的宿主接线指导 +- 当前仓库 **不** 应在这一轮新增: + - `claude` / `gemini` 的自动写配置 install/apply + - Claude / Gemini host-native hooks/skills/extensions 的自动安装 + - 多宿主统一 runtime 抽象 + +## 证据分层 + +### A. 官方公开资料 + +应优先信任以下公开面: + +- Claude Code 官方文档: + - memory + - settings + - hooks + - sub-agents + - MCP +- Claude Code 官方仓库公开资料: + - `anthropics/claude-code` + - plugins 公开说明 +- Gemini CLI 官方仓库公开资料: + - `google-gemini/gemini-cli` + - `settings.json` / project-scoped `.gemini/settings.json` + - `GEMINI.md` + - memory / `save_memory` + - hooks + - skills + - extensions + - MCP + +### B. 当前仓库事实 + +当前仓库代码与测试已经明确表达: + +- `cam mcp install` 只有 `codex` 可写 +- `claude` / `gemini` / `generic` 继续保持 manual-only / snippet-first +- `cam mcp print-config --host ` 可以给出宿主接线片段 +- `cam mcp doctor --host ` 只能检查 snippet/config truth,不冒充 Codex 级 operational readiness + +### C. 非官方研究资料 + +例如: + +- `Boulea7/ClaudeCode-Source-DeepDive` + +这类资料可以帮助理解内部结构与未来可能的适配点,但不能单独升级为本仓的公开产品承诺。 + +## Claude Code:当前应如何看待 + +Claude Code 当前公开的能力面足够强,至少已经把以下内容放进了公开宿主表面: + +- memory +- settings +- hooks +- sub-agents +- MCP +- plugins / commands / agents / skills / hooks / MCP 组合扩展 + +对当前仓库的含义: + +- Claude 是 **产品契约参考宿主** +- Claude memory contract 继续是本仓 memory semantics 的高价值对齐对象 +- Claude hooks / sub-agents / plugins 继续是未来 host adapter 设计的重要参照 +- 但当前仓库不应把这些公开能力误写成“本仓已经支持 Claude host-native integration” + +当前真实可做的 Claude 接入: + +- 用 `cam mcp print-config --host claude` 生成 `.mcp.json` 片段 +- 由用户手动粘贴到 Claude host config +- 用 `cam mcp doctor --host claude --cwd ` 检查当前 snippet/config 是否存在且 project-pinned + +当前不应做的 Claude 接入: + +- `cam mcp install --host claude` +- `cam integrations install/apply --host claude` +- 自动写 Claude hooks / plugins / skills / subagent 资产 + +## Gemini CLI:当前应如何看待 + +Gemini CLI 的公开宿主面已经明显超过“只有一个 MCP config file”的水平。当前公开能力至少包括: + +- `~/.gemini/settings.json` 与 `.gemini/settings.json` +- `GEMINI.md` 分层上下文 +- memory / `save_memory` +- hooks +- skills +- extensions +- MCP + +这意味着 Gemini 不是“只够拿来对照 MCP 片段”的宿主,而是: + +- 一个公开 surface 相当丰富的参考宿主 +- 一个未来可能值得单独做 adapter 的宿主 +- 但在当前仓库里,仍然不应直接升级成可写主宿主 + +当前真实可做的 Gemini 接入: + +- 用 `cam mcp print-config --host gemini` 生成 `.gemini/settings.json` 里的 `mcpServers` 片段 +- 保持 `trust=false` 这类 host-controlled 安全边界 +- 用 `cam mcp doctor --host gemini --cwd ` 检查 project-scoped 或 user-scoped wiring truth +- 在文档里明确:Gemini 自己还有 `GEMINI.md`、hooks、skills、extensions、memory 这些 host-native surface,但本仓当前不接管它们的自动安装 + +当前不应做的 Gemini 接入: + +- `cam mcp install --host gemini` +- `cam integrations install/apply --host gemini` +- 自动写 `.gemini/settings.json` +- 自动安装 Gemini hooks / skills / extensions + +## 当前仓库的正式边界 + +当前仓库对外应保持以下一致表述: + +- Codex 是唯一的 mutable host +- Claude / Gemini / generic 的当前策略都是 manual-only / snippet-first +- `mcp print-config` 与 `mcp doctor` 是非 Codex 宿主当前真实支持的边界 +- 这条边界是有意收口,不是“忘了做” + +更具体地说: + +- Codex: + - 可写 install / apply / integrations stack + - 可管理 repo-level `AGENTS.md` guidance + - 可汇总 Codex-only route truth +- Claude: + - 当前只提供 manual-only MCP wiring guidance + - 当前不提供自动写 config / hooks / plugins / skills +- Gemini: + - 当前只提供 manual-only MCP wiring guidance + - 当前不提供自动写 config / hooks / skills / extensions + +## 这一轮可以直接做什么 + +可以直接做: + +- 收紧文档与 README 里的宿主边界表述 +- 明确区分: + - 官方公开宿主能力 + - 本仓当前真正支持的接入面 + - deferred 的 host-native integration 面 +- 保持 `mcp print-config` / `mcp doctor` 对 Claude/Gemini 的 manual-only 叙事稳定 + +应 deferred: + +- Claude/Gemini 可写 install/apply +- host-native hooks / skills / extensions 自动安装 +- 多宿主统一 memory runtime 抽象 + +需要额外验证后再动: + +- 是否值得单独为 Gemini 再开一条更细的 manual-host contract PR +- Claude plugins / marketplace / skills 的哪些表面属于“稳定公开能力”,哪些仍应只作为参考面引用 + +## 推荐的后续动作 + +如果未来要继续推进 Claude / Gemini 适配,建议按以下顺序: + +1. 先把文档、README、help、tests 对齐到当前真实边界 +2. 再单独评估 `manual-only host snippet/doctor parity` 是否需要代码级 closure +3. 如果未来真的要做 host-native adapter,再在独立 PR 中分别处理 Claude 与 Gemini,而不是一次性摊平成多宿主统一层 + +## 参考来源 + +- Claude Code 官方文档: + - + - + - + - +- Claude Code 官方仓库: + - +- Gemini CLI 官方仓库: + - +- 非官方研究参考: + - diff --git a/docs/host-surfaces.md b/docs/host-surfaces.md index 525d217..03d0781 100644 --- a/docs/host-surfaces.md +++ b/docs/host-surfaces.md @@ -42,6 +42,7 @@ 价值: - 提供最完整的官方 auto memory、hooks、plugins、skills、subagents 参考契约 +- 官方公开 surface 足够丰富,适合作为本仓 memory / host boundary 的高价值对照对象 在当前仓库里的角色: @@ -49,11 +50,24 @@ - 用来定义产品体验与宿主能力边界 - 不作为当前仓库直接承诺支持的主宿主 +当前真实可行的接入面: + +- `cam mcp print-config --host claude` +- `cam mcp doctor --host claude` +- 用户手动维护 Claude host config + +当前明确不做: + +- `cam mcp install --host claude` +- `cam integrations install/apply --host claude` +- 自动写 Claude hooks / plugins / skills / subagent 资产 + ### Gemini CLI 价值: - hooks、extensions、MCP、sub-agents 能力都很强 +- 官方公开 surface 已不只是 MCP config file,还包括 `settings.json`、`GEMINI.md`、memory、skills 与 extensions - 适合作为未来独立 memory runtime 的优先宿主之一 在当前仓库里的角色: @@ -62,6 +76,18 @@ - 帮助当前仓库设计未来 skill / hook / MCP surfaces - 但不把当前仓库直接改写成 Gemini 主仓 +当前真实可行的接入面: + +- `cam mcp print-config --host gemini` +- `cam mcp doctor --host gemini` +- 用户手动维护 `.gemini/settings.json` + +当前明确不做: + +- `cam mcp install --host gemini` +- `cam integrations install/apply --host gemini` +- 自动写 Gemini hooks / skills / extensions / memory 配置 + ### OpenCode 价值: @@ -99,6 +125,7 @@ - `cam mcp doctor --host codex` 与 `cam integrations doctor --host codex` 现在还会把“偏好 route”和“当前可运行 route”分开表达:`recommendedRoute` 继续表示首选的 MCP-first 路径;`currentlyOperationalRoute`、`routeKind`、`routeEvidence`、`shellDependencyLevel`、`hostMutationRequired`、`preferredRouteBlockers`、`currentOperationalBlockers` 则表达当前环境里哪条 route 真正可跑、首选 route 为什么没跑起来、以及当前 fallback 自己是否还有 blocker。skills 继续被视为 guidance surface,而不是 executable fallback route - `cam integrations doctor --host codex` 还会显式暴露 skill-surface steering:`preferredSkillSurface`、`recommendedSkillInstallCommand`、`installedSkillSurfaces`、`readySkillSurfaces`。这些字段表达的是“当前建议把 guidance 安装到哪里”,而不是技能已经成为 executable fallback route。 - release-facing `--help` 文案也视为宿主能力面的稳定公开接口,必须和上述 install / apply / doctor / manual-only 边界保持一致 +- 对 Claude / Gemini 这类非 Codex 宿主,当前仓库只承接 manual-only / snippet-first 接入,不把官方更强的 host-native surface 误写成本仓已经自动接管的能力 不应该吸收: @@ -114,6 +141,7 @@ - 它当前服务于 Codex - 它会正式吸收 hooks、skills、MCP-aware integration 方向 - 它不会在当前阶段直接承担多宿主统一平台职责 +- Claude / Gemini 当前都属于“公开能力值得研究,但本仓只支持 manual-only wiring guidance”的范围 ## 与独立新仓的接口边界 diff --git a/docs/integration-strategy.md b/docs/integration-strategy.md index 8d62af4..c7a35b3 100644 --- a/docs/integration-strategy.md +++ b/docs/integration-strategy.md @@ -90,6 +90,7 @@ - 当前推荐的渐进式检索 preset 统一为:`state=auto`、`limit=8` - `cam mcp install --host codex` 会显式写入推荐的 project-scoped Codex 宿主配置,继续降低接线摩擦,但不改变 retrieval 的只读语义;若已有 `codex_auto_memory` entry 带有非 canonical 自定义字段,会在安全前提下保留它们 - `claude`、`gemini` 与 `generic` host 都保持 manual-only / snippet-first:不提供自动写入的 install 分支,只通过 `cam mcp print-config --host ` 暴露 ready-to-paste snippet +- 这里的 manual-only 是有意的产品边界,不是“尚未补完的小缺口”:Claude Code 与 Gemini CLI 虽然都公开了比当前本仓接线更丰富的 host-native surface,但当前仓库仍然只承接 MCP wiring guidance,而不自动写它们的 host config、hooks、skills、extensions 或 memory-specific assets - `cam mcp print-config --host ...` 会打印 ready-to-paste 宿主接入片段;其中 `--host codex` 现在还会额外打印推荐的 `AGENTS.md` snippet,并在 JSON 输出里附带共享 `workflowContract`,把 durable memory workflow 正式接到 Codex 当前公开稳定 surface 上 - `cam mcp apply-guidance --host codex` 会以 additive、可审计、fail-closed 的方式创建或更新 repo 根 `AGENTS.md` 中由本仓维护的 guidance block,继续降低手工粘贴成本 - `cam integrations install --host codex` 现在提供显式的一次性 stack install 入口:统一编排 project-scoped MCP wiring、hooks 与 skills,但不触碰 `AGENTS.md` @@ -132,6 +133,7 @@ - 不为了宿主兼容而把当前仓库直接升格成统一多宿主主仓 - 不围绕 plugin format 做统一抽象 - 不把 hooks / skills / MCP 的引入理解成“放弃 CLI 主线” +- 不把 Claude / Gemini 已公开的 host-native surface 直接翻译成本仓的自动安装承诺 ## 当前仓库应该优先完成的产品面 From 459e203d77e0c14cee223ede0368ddeb31d026f1 Mon Sep 17 00:00:00 2001 From: blocks Date: Fri, 10 Apr 2026 22:14:27 +0800 Subject: [PATCH 48/62] fix: harden runtime contract remediation --- src/lib/cli/register-commands.ts | 37 ++++--- src/lib/commands/integrations.ts | 77 +++++++++++++- src/lib/commands/session.ts | 16 +-- src/lib/domain/recovery-records.ts | 2 +- src/lib/integration/assets.ts | 3 +- src/lib/integration/retrieval-contract.ts | 10 +- test/dist-cli-smoke.test.ts | 8 +- test/hooks-command.test.ts | 2 +- test/integrations-command.test.ts | 32 ++++-- test/mcp-command.test.ts | 24 ++--- test/memory-command.test.ts | 53 ++++++++++ test/retrieval-contract.test.ts | 4 +- test/session-command.test.ts | 118 ++++++++++++++++++++++ test/skills-command.test.ts | 6 +- test/tarball-install-smoke.test.ts | 8 +- 15 files changed, 337 insertions(+), 63 deletions(-) diff --git a/src/lib/cli/register-commands.ts b/src/lib/cli/register-commands.ts index ea676f0..f2e6a51 100644 --- a/src/lib/cli/register-commands.ts +++ b/src/lib/cli/register-commands.ts @@ -327,6 +327,7 @@ export function registerCommands(program: Command): void { .option("--print-startup", "Print the compiled startup memory block") .option("--open", "Open the memory directory in the default file browser") .action(withStdout(async (options) => runMemory(options))); + memoryCommand.enablePositionalOptions(); addJsonOption( memoryCommand @@ -346,10 +347,23 @@ export function registerCommands(program: Command): void { ).action( withStdout(async (options, command) => { const parent = command.parent; - const mergedOptions = - typeof command.optsWithGlobals === "function" - ? (command.optsWithGlobals() as Record) - : (command.opts() as Record); + const subcommandOptions = command.opts() as Record; + const subcommandJson = + command.getOptionValueSource("json") === "cli" + ? (subcommandOptions.json as boolean | undefined) + : undefined; + const subcommandCwd = + command.getOptionValueSource("cwd") === "cli" + ? (subcommandOptions.cwd as string | undefined) + : undefined; + const subcommandScope = + command.getOptionValueSource("scope") === "cli" + ? (subcommandOptions.scope as string | undefined) + : undefined; + const subcommandState = + command.getOptionValueSource("state") === "cli" + ? (subcommandOptions.state as string | undefined) + : undefined; const explicitParentOptions = parent ? Object.fromEntries( @@ -370,15 +384,14 @@ export function registerCommands(program: Command): void { : {}; return runMemoryReindex({ - json: (options as { json?: boolean }).json ?? (mergedOptions.json as boolean | undefined), - cwd: (options as { cwd?: string }).cwd ?? (mergedOptions.cwd as string | undefined), + ...explicitParentOptions, + json: subcommandJson ?? (explicitParentOptions.json as boolean | undefined), + cwd: subcommandCwd ?? (explicitParentOptions.cwd as string | undefined), scope: - ((options as { scope?: string }).scope ?? - (mergedOptions.scope as string | undefined)) as MemoryReindexCommandOptions["scope"], - state: - ((options as { state?: string }).state ?? - (mergedOptions.state as string | undefined)) as MemoryReindexCommandOptions["state"], - ...explicitParentOptions + (subcommandScope ?? + (explicitParentOptions.scope as string | undefined) ?? + "all") as MemoryReindexCommandOptions["scope"], + state: (subcommandState ?? "all") as MemoryReindexCommandOptions["state"] }); }) ); diff --git a/src/lib/commands/integrations.ts b/src/lib/commands/integrations.ts index 6e71180..9a11840 100644 --- a/src/lib/commands/integrations.ts +++ b/src/lib/commands/integrations.ts @@ -242,11 +242,21 @@ async function restoreRollbackSnapshots(snapshots: FileRollbackSnapshot[]): Prom try { await ensureDir(path.dirname(snapshot.path)); - await fs.rm(snapshot.path, { force: true, recursive: true }).catch(() => undefined); + if (snapshot.kind === "directory") { + const currentStat = await fs.lstat(snapshot.path).catch(() => null); + if (!currentStat?.isDirectory()) { + await fs.rm(snapshot.path, { force: true, recursive: true }).catch(() => undefined); + await ensureDir(snapshot.path); + } + if (snapshot.mode !== null) { + await fs.chmod(snapshot.path, snapshot.mode); + } + } else { + await fs.rm(snapshot.path, { force: true, recursive: true }).catch(() => undefined); + } if (snapshot.kind === "symlink") { await fs.symlink(snapshot.symlinkTarget ?? "", snapshot.path); } else if (snapshot.kind === "directory") { - await ensureDir(snapshot.path); if (snapshot.mode !== null) { await fs.chmod(snapshot.path, snapshot.mode); } @@ -465,6 +475,33 @@ function buildInstallFailureSubaction( }; } +type ApplyFailureSubactionName = "mcp" | "agents" | "hooks" | "skills"; + +function inferFailedApplySubaction( + mcpResult: Awaited> | null, + hooksResult: Awaited> | null, + skillsResult: Awaited> | null, + agentsResult: Awaited> | null +): ApplyFailureSubactionName | null { + if (!mcpResult) { + return "mcp"; + } + + if (!hooksResult) { + return "hooks"; + } + + if (!skillsResult) { + return "skills"; + } + + if (!agentsResult) { + return "agents"; + } + + return null; +} + function buildApplyFailureSubaction( result: | Awaited> @@ -472,12 +509,26 @@ function buildApplyFailureSubaction( | Awaited> | null, options: { + currentSubaction: ApplyFailureSubactionName; + failedSubaction: ApplyFailureSubactionName | null; fallbackSurface?: CodexSkillInstallSurface; + failureMessage: string; skipReason: string; rollbackSucceeded: boolean; } ): IntegrationSubactionResult { if (!result) { + if (options.currentSubaction === options.failedSubaction) { + return { + status: "blocked", + action: "blocked", + attempted: true, + surface: options.fallbackSurface, + readOnlyRetrieval: true, + notes: [options.failureMessage] + }; + } + return { status: "ok", action: "unchanged", @@ -1166,23 +1217,45 @@ export async function runIntegrationsApply( cwd: projectRoot }), subactions: { + ...(function () { + const failedSubaction = inferFailedApplySubaction( + mcpResult, + hooksResult, + skillsResult, + agentsResult + ); + return { mcp: buildApplyFailureSubaction(mcpResult, { + currentSubaction: "mcp", + failedSubaction, rollbackSucceeded, + failureMessage, skipReason }), agents: buildApplyFailureSubaction(agentsResult, { + currentSubaction: "agents", + failedSubaction, rollbackSucceeded, + failureMessage, skipReason }), hooks: buildApplyFailureSubaction(hooksResult, { + currentSubaction: "hooks", + failedSubaction, rollbackSucceeded, + failureMessage, skipReason }), skills: buildApplyFailureSubaction(skillsResult, { + currentSubaction: "skills", + failedSubaction, fallbackSurface: skillSurface, rollbackSucceeded, + failureMessage, skipReason }) + }; + })() }, notes: [ "This orchestration surface is Codex-only and explicit.", diff --git a/src/lib/commands/session.ts b/src/lib/commands/session.ts index 38c585b..5d8810e 100644 --- a/src/lib/commands/session.ts +++ b/src/lib/commands/session.ts @@ -123,6 +123,14 @@ async function selectSaveRollout( }; } + const latestPrimaryRollout = await findLatestProjectRollout(runtime.project); + if (latestPrimaryRollout) { + return { + kind: "latest-primary-rollout", + rolloutPath: latestPrimaryRollout + }; + } + const latestAuditEntry = await runtime.sessionContinuityStore.readLatestAuditEntryMatchingScope(scope); if (latestAuditEntry) { @@ -132,14 +140,6 @@ async function selectSaveRollout( }; } - const latestPrimaryRollout = await findLatestProjectRollout(runtime.project); - if (latestPrimaryRollout) { - return { - kind: "latest-primary-rollout", - rolloutPath: latestPrimaryRollout - }; - } - throw new Error("No relevant rollout found for this project."); } diff --git a/src/lib/domain/recovery-records.ts b/src/lib/domain/recovery-records.ts index ee1412f..89b0745 100644 --- a/src/lib/domain/recovery-records.ts +++ b/src/lib/domain/recovery-records.ts @@ -394,6 +394,6 @@ export function matchesContinuityRecoveryRecord( record.worktreeId === identity.worktreeId && record.rolloutPath === identity.rolloutPath && record.sourceSessionId === identity.sourceSessionId && - record.scope === identity.scope + (record.scope === identity.scope || (record.scope === "both" && identity.scope !== "both")) ); } diff --git a/src/lib/integration/assets.ts b/src/lib/integration/assets.ts index 340d625..2d45dd3 100644 --- a/src/lib/integration/assets.ts +++ b/src/lib/integration/assets.ts @@ -123,7 +123,8 @@ function resolveInstallDir( function buildProjectRootResolutionBlock(projectRoot?: string): string { if (projectRoot) { - return `PROJECT_ROOT=${JSON.stringify(projectRoot)} + const escapedProjectRoot = projectRoot.replace(/'/g, "'\"'\"'"); + return `PROJECT_ROOT='${escapedProjectRoot}' `; } diff --git a/src/lib/integration/retrieval-contract.ts b/src/lib/integration/retrieval-contract.ts index ecc5e45..87ad341 100644 --- a/src/lib/integration/retrieval-contract.ts +++ b/src/lib/integration/retrieval-contract.ts @@ -249,12 +249,16 @@ export function hasCliCwdFlag(command: string): boolean { return /(?:^|\s)--cwd(?:\s|=)/u.test(command); } +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\"'\"'")}'`; +} + export function appendCliCwdFlag(command: string, cwd?: string): string { if (!cwd || hasCliCwdFlag(command)) { return command; } - return `${command} --cwd ${JSON.stringify(cwd)}`; + return `${command} --cwd ${shellQuote(cwd)}`; } export function buildResolvedCliCommand( @@ -277,10 +281,10 @@ function buildHookFallbackCommand( cwd?: string; } = {} ): string { - const helperPath = JSON.stringify(getInstalledHookHelperPath("memory-recall.sh")); + const helperPath = shellQuote(getInstalledHookHelperPath("memory-recall.sh")); const invocation = `${helperPath} ${action} ${argumentPlaceholder}`; return options.cwd - ? `CAM_PROJECT_ROOT=${JSON.stringify(options.cwd)} ${invocation}` + ? `CAM_PROJECT_ROOT=${shellQuote(options.cwd)} ${invocation}` : invocation; } diff --git a/test/dist-cli-smoke.test.ts b/test/dist-cli-smoke.test.ts index 5daca47..b134795 100644 --- a/test/dist-cli-smoke.test.ts +++ b/test/dist-cli-smoke.test.ts @@ -580,7 +580,7 @@ describe("dist cli smoke", () => { preferredRoute: "mcp-first" }, cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd '${realProjectDir}'` } }, agentsGuidance: { @@ -1202,7 +1202,7 @@ describe("dist cli smoke", () => { workflowContract: { recommendedPreset: "state=auto, limit=8", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd '${realProjectDir}'` } }, subactions: { @@ -1246,7 +1246,7 @@ describe("dist cli smoke", () => { workflowContract: { recommendedPreset: "state=auto, limit=8", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd '${realProjectDir}'` } }, subactions: { @@ -1379,7 +1379,7 @@ describe("dist cli smoke", () => { failureMessage: expect.stringContaining("directory"), rollbackApplied: true, subactions: { - mcp: { attempted: false }, + mcp: { attempted: true, status: "blocked", action: "blocked" }, agents: { attempted: false }, hooks: { attempted: false }, skills: { attempted: false } diff --git a/test/hooks-command.test.ts b/test/hooks-command.test.ts index 4b42db9..170bb95 100644 --- a/test/hooks-command.test.ts +++ b/test/hooks-command.test.ts @@ -202,7 +202,7 @@ describe("hooks command", () => { workflowContract: { recommendedPreset: "state=auto, limit=8", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(await fs.realpath(projectDir))}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd '${await fs.realpath(projectDir)}'` }, postWorkSyncReview: { helperScript: "post-work-memory-review.sh" diff --git a/test/integrations-command.test.ts b/test/integrations-command.test.ts index 5b5d6e8..246cbe1 100644 --- a/test/integrations-command.test.ts +++ b/test/integrations-command.test.ts @@ -15,6 +15,10 @@ const originalHome = process.env.HOME; const originalCodexHome = process.env.CODEX_HOME; const originalPath = process.env.PATH; +function shellQuoteArg(value: string): string { + return `'${value.replace(/'/g, "'\"'\"'")}'`; +} + async function tempDir(prefix: string): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); tempDirs.push(dir); @@ -148,7 +152,7 @@ describe("integrations command", () => { workflowContract: { recommendedPreset: "state=auto, limit=8", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd '${realProjectDir}'` } }, subactions: { @@ -340,7 +344,7 @@ describe("integrations command", () => { }) ); expect(payload.workflowContract.cliFallback.searchCommand).toBe( - `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(payload.projectRoot)}` + `cam recall search "" --state auto --limit 8 --cwd '${payload.projectRoot}'` ); expect(payload.nextSteps).toEqual( expect.arrayContaining([ @@ -465,15 +469,15 @@ describe("integrations command", () => { expect(payload.nextSteps).toEqual( expect.arrayContaining([ expect.stringContaining( - `cam mcp apply-guidance --host codex --cwd ${JSON.stringify(payload.projectRoot)}` + `cam mcp apply-guidance --host codex --cwd ${shellQuoteArg(payload.projectRoot)}` ), expect.stringContaining( - `cam mcp print-config --host codex --cwd ${JSON.stringify(payload.projectRoot)}` + `cam mcp print-config --host codex --cwd ${shellQuoteArg(payload.projectRoot)}` ) ]) ); expect(payload.nextSteps[0]).toContain( - `cam mcp apply-guidance --host codex --cwd ${JSON.stringify(payload.projectRoot)}` + `cam mcp apply-guidance --host codex --cwd ${shellQuoteArg(payload.projectRoot)}` ); expect(payload.nextSteps).not.toEqual( expect.arrayContaining([expect.stringContaining("cam hooks install")]) @@ -521,7 +525,7 @@ describe("integrations command", () => { expect(payload.nextSteps).toEqual( expect.arrayContaining([ expect.stringContaining( - `cam hooks install --cwd ${JSON.stringify(payload.projectRoot)}` + `cam hooks install --cwd ${shellQuoteArg(payload.projectRoot)}` ) ]) ); @@ -559,7 +563,7 @@ describe("integrations command", () => { expect(payload.nextSteps).toEqual( expect.arrayContaining([ expect.stringContaining( - `CAM_PROJECT_ROOT=${JSON.stringify(payload.projectRoot)}` + `CAM_PROJECT_ROOT=${shellQuoteArg(payload.projectRoot)}` ), expect.stringContaining("memory-recall.sh") ]) @@ -1515,7 +1519,7 @@ describe("integrations command", () => { failureMessage: expect.stringContaining("broken codex config"), rollbackApplied: true, subactions: { - mcp: { attempted: false }, + mcp: { attempted: true, status: "blocked", action: "blocked" }, agents: { attempted: false }, hooks: { attempted: false }, skills: { attempted: false } @@ -1616,6 +1620,11 @@ describe("integrations command", () => { process.env.HOME = homeDir; await fs.mkdir(path.join(realProjectDir, ".codex", "config.toml"), { recursive: true }); + await fs.writeFile( + path.join(realProjectDir, ".codex", "config.toml", "keep.txt"), + "do-not-delete", + "utf8" + ); const { runIntegrationsApply } = await import("../src/lib/commands/integrations.js"); const payload = JSON.parse( @@ -1643,12 +1652,15 @@ describe("integrations command", () => { failureMessage: expect.stringContaining("directory"), rollbackApplied: true, subactions: { - mcp: { attempted: false }, + mcp: { attempted: true, status: "blocked", action: "blocked" }, agents: { attempted: false }, hooks: { attempted: false }, skills: { attempted: false } } }); + expect( + await fs.readFile(path.join(realProjectDir, ".codex", "config.toml", "keep.txt"), "utf8") + ).toBe("do-not-delete"); }); it("withholds integrations apply from doctor next steps when AGENTS guidance is unsafe", async () => { @@ -1736,7 +1748,7 @@ describe("integrations command", () => { workflowContract: { recommendedPreset: "state=auto, limit=8", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd '${realProjectDir}'` } }, subactions: { diff --git a/test/mcp-command.test.ts b/test/mcp-command.test.ts index ab0aff8..9c76c3b 100644 --- a/test/mcp-command.test.ts +++ b/test/mcp-command.test.ts @@ -686,7 +686,7 @@ describe("mcp command", () => { progressiveDisclosure: "Use progressive disclosure: search -> timeline -> details." }, cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd '${realProjectDir}'` } }); expect(payload.agentsGuidance).toMatchObject({ @@ -1617,14 +1617,14 @@ describe("mcp command", () => { recallFirst: expect.stringContaining("recall durable memory first"), progressiveDisclosure: "Use progressive disclosure: search -> timeline -> details.", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}`, - timelineCommand: `cam recall timeline "" --cwd ${JSON.stringify(realProjectDir)}`, - detailsCommand: `cam recall details "" --cwd ${JSON.stringify(realProjectDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd '${realProjectDir}'`, + timelineCommand: `cam recall timeline "" --cwd '${realProjectDir}'`, + detailsCommand: `cam recall details "" --cwd '${realProjectDir}'` }, postWorkSyncReview: { helperScript: "post-work-memory-review.sh", - syncCommand: `cam sync --cwd ${JSON.stringify(realProjectDir)}`, - reviewCommand: `cam memory --recent --cwd ${JSON.stringify(realProjectDir)}` + syncCommand: `cam sync --cwd '${realProjectDir}'`, + reviewCommand: `cam memory --recent --cwd '${realProjectDir}'` } }); expect(payload.codexStack).toMatchObject({ @@ -1662,7 +1662,7 @@ describe("mcp command", () => { status: "warning", summary: expect.stringContaining("Markdown"), repairCommand: expect.stringContaining( - `memory reindex --scope all --state all --cwd ${JSON.stringify(realProjectDir)}` + `memory reindex --scope all --state all --cwd '${realProjectDir}'` ), checks: expect.arrayContaining([ expect.objectContaining({ @@ -2981,15 +2981,15 @@ describe("mcp command", () => { } }, cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realProjectDir)}`, - timelineCommand: `cam recall timeline "" --cwd ${JSON.stringify(realProjectDir)}`, - detailsCommand: `cam recall details "" --cwd ${JSON.stringify(realProjectDir)}`, + searchCommand: `cam recall search "" --state auto --limit 8 --cwd '${realProjectDir}'`, + timelineCommand: `cam recall timeline "" --cwd '${realProjectDir}'`, + detailsCommand: `cam recall details "" --cwd '${realProjectDir}'`, requiresCamOnPath: true }, postWorkSyncReview: { helperScript: "post-work-memory-review.sh", - syncCommand: `cam sync --cwd ${JSON.stringify(realProjectDir)}`, - reviewCommand: `cam memory --recent --cwd ${JSON.stringify(realProjectDir)}`, + syncCommand: `cam sync --cwd '${realProjectDir}'`, + reviewCommand: `cam memory --recent --cwd '${realProjectDir}'`, shellOnly: true, requiresCamOnPath: true } diff --git a/test/memory-command.test.ts b/test/memory-command.test.ts index 6d9e4c1..3b0767f 100644 --- a/test/memory-command.test.ts +++ b/test/memory-command.test.ts @@ -4153,4 +4153,57 @@ describe("runMemory", () => { expect(result.stdout).toContain("Rebuild retrieval sidecars from canonical Markdown memory"); expect(result.stdout).not.toContain("--enable"); }); + + it("lets memory reindex subcommand options override parent memory options", async () => { + const homeDir = await tempDir("cam-memory-reindex-parent-child-home-"); + const projectDir = await tempDir("cam-memory-reindex-parent-child-project-"); + const memoryRoot = await tempDir("cam-memory-reindex-parent-child-root-"); + process.env.HOME = homeDir; + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const store = new MemoryStore(detectProjectContext(projectDir), { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const result = runCli( + projectDir, + [ + "memory", + "--scope", + "project-local", + "reindex", + "--scope", + "project", + "--state", + "active", + "--json" + ], + { env: { HOME: homeDir } } + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + requestedScope: "project", + requestedState: "active", + rebuilt: [ + expect.objectContaining({ + scope: "project", + state: "active" + }) + ] + }); + }); }); diff --git a/test/retrieval-contract.test.ts b/test/retrieval-contract.test.ts index 6749b34..d7e7af7 100644 --- a/test/retrieval-contract.test.ts +++ b/test/retrieval-contract.test.ts @@ -37,13 +37,13 @@ afterEach(async () => { describe("retrieval contract", () => { it("shell-quotes cwd values so the shell cannot expand them", () => { expect(appendCliCwdFlag("cam recall search \"\"", "/tmp/$HOME/path with spaces")).toBe( - "cam recall search \"\" --cwd \"/tmp/$HOME/path with spaces\"" + "cam recall search \"\" --cwd '/tmp/$HOME/path with spaces'" ); }); it("escapes embedded single quotes in cwd values", () => { expect(appendCliCwdFlag("cam recall details \"\"", "/tmp/it's-safe")).toBe( - "cam recall details \"\" --cwd \"/tmp/it's-safe\"" + "cam recall details \"\" --cwd '/tmp/it'\"'\"'s-safe'" ); }); diff --git a/test/session-command.test.ts b/test/session-command.test.ts index 7552afb..f0cdd18 100644 --- a/test/session-command.test.ts +++ b/test/session-command.test.ts @@ -1087,6 +1087,71 @@ describe("runSession", () => { expect(payload.latestContinuityAuditEntry?.writeMode).toBe("merge"); }, 30_000); + it("save still prefers the latest primary rollout over older matching audit provenance", async () => { + const repoDir = await tempDir("cam-session-save-primary-preferred-repo-"); + const memoryRoot = await tempDir("cam-session-save-primary-preferred-memory-"); + const sessionsDir = await tempDir("cam-session-save-primary-preferred-sessions-"); + const dayDir = path.join(sessionsDir, "2026", "03", "15"); + process.env.CAM_CODEX_SESSIONS_DIR = sessionsDir; + await fs.mkdir(dayDir, { recursive: true }); + await initRepo(repoDir); + + await writeProjectConfig(repoDir, configJson(), { + autoMemoryDirectory: memoryRoot + }); + + const auditRolloutPath = path.join(repoDir, "older-matching-audit-rollout.jsonl"); + await fs.writeFile( + auditRolloutPath, + rolloutFixture(repoDir, "Old audit provenance should not win normal save.", { + sessionId: "session-older-audit" + }), + "utf8" + ); + const primaryRolloutPath = path.join(dayDir, "rollout-primary.jsonl"); + await fs.writeFile( + primaryRolloutPath, + rolloutFixture(repoDir, "Newest primary rollout should drive normal save.", { + sessionId: "session-new-primary" + }), + "utf8" + ); + + const project = detectProjectContext(repoDir); + const store = new SessionContinuityStore(project, { + ...configJson(), + autoMemoryDirectory: memoryRoot + }); + await store.appendAuditLog({ + generatedAt: "2026-03-18T00:01:00.000Z", + projectId: project.projectId, + worktreeId: project.worktreeId, + configuredExtractorMode: "heuristic", + trigger: "manual-save", + writeMode: "merge", + scope: "both", + rolloutPath: auditRolloutPath, + sourceSessionId: "session-older-audit", + preferredPath: "heuristic", + actualPath: "heuristic", + fallbackReason: "configured-heuristic", + evidenceCounts: makeEvidenceCounts(), + writtenPaths: ["/tmp/continuity-audit.md"] + }); + + const payload = JSON.parse( + await runSession("save", { cwd: repoDir, scope: "project-local", json: true }) + ) as { + rolloutPath: string; + rolloutSelection: { kind: string; rolloutPath: string }; + }; + expect(payload.rolloutSelection).toEqual({ + kind: "latest-primary-rollout", + rolloutPath: primaryRolloutPath + }); + expect(payload.rolloutPath).toBe(primaryRolloutPath); + }, 30_000); + it("save prefers a matching recovery marker over a newer primary rollout", async () => { const repoDir = await tempDir("cam-session-save-recovery-priority-repo-"); const memoryRoot = await tempDir("cam-session-save-recovery-priority-memory-"); @@ -1160,6 +1225,59 @@ describe("runSession", () => { expect(await store.readRecoveryRecord()).toBeNull(); }, 30_000); + it("clears a scope=both recovery marker after a single-scope save reuses it", async () => { + const repoDir = await tempDir("cam-session-save-clear-shared-recovery-repo-"); + const memoryRoot = await tempDir("cam-session-save-clear-shared-recovery-memory-"); + await initRepo(repoDir); + + await writeProjectConfig(repoDir, configJson(), { + autoMemoryDirectory: memoryRoot + }); + + const recoveryRolloutPath = path.join(repoDir, "shared-recovery-rollout.jsonl"); + await fs.writeFile( + recoveryRolloutPath, + rolloutFixture(repoDir, "Single-scope save should clear the shared recovery marker.", { + sessionId: "session-shared-recovery" + }), + "utf8" + ); + + const project = detectProjectContext(repoDir); + const store = new SessionContinuityStore(project, { + ...configJson(), + autoMemoryDirectory: memoryRoot + }); + await store.writeRecoveryRecord({ + recordedAt: "2026-03-18T00:00:00.000Z", + projectId: project.projectId, + worktreeId: project.worktreeId, + rolloutPath: recoveryRolloutPath, + sourceSessionId: "session-shared-recovery", + trigger: "manual-save", + writeMode: "merge", + scope: "both", + writtenPaths: [store.paths.sharedFile, store.paths.localFile], + preferredPath: "heuristic", + actualPath: "heuristic", + fallbackReason: "configured-heuristic", + evidenceCounts: makeEvidenceCounts(), + failedStage: "audit-write", + failureMessage: "retry the same save provenance" + }); + + const payload = JSON.parse( + await runSession("save", { cwd: repoDir, scope: "project-local", json: true }) + ) as { + rolloutSelection: { kind: string; rolloutPath: string }; + }; + expect(payload.rolloutSelection).toEqual({ + kind: "pending-recovery-marker", + rolloutPath: recoveryRolloutPath + }); + expect(await store.readRecoveryRecord()).toBeNull(); + }, 30_000); + it("does not fall back to a lower-priority source when the selected refresh provenance cannot be read", async () => { const repoDir = await tempDir("cam-session-refresh-missing-provenance-repo-"); const memoryRoot = await tempDir("cam-session-refresh-missing-provenance-memory-"); diff --git a/test/skills-command.test.ts b/test/skills-command.test.ts index a3efb57..894035c 100644 --- a/test/skills-command.test.ts +++ b/test/skills-command.test.ts @@ -98,7 +98,7 @@ describe("skills command", () => { workflowContract: { recommendedPreset: "state=auto, limit=8", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(await fs.realpath(projectDir))}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd '${await fs.realpath(projectDir)}'` }, postWorkSyncReview: { helperScript: "post-work-memory-review.sh" @@ -201,8 +201,8 @@ describe("skills command", () => { const skillFile = await fs.readFile(officialSkillPath, "utf8"); expect(skillFile).toContain("cam:asset-version"); expect(skillFile).toContain("timeline_memories"); - expect(skillFile).toContain(`--cwd ${JSON.stringify(await fs.realpath(projectDir))}`); - expect(skillFile).toContain(` sync --cwd ${JSON.stringify(await fs.realpath(projectDir))}`); + expect(skillFile).toContain(`--cwd '${await fs.realpath(projectDir)}'`); + expect(skillFile).toContain(` sync --cwd '${await fs.realpath(projectDir)}'`); expect(skillFile.includes('node "') || skillFile.includes("cam sync")).toBe(true); await expect( diff --git a/test/tarball-install-smoke.test.ts b/test/tarball-install-smoke.test.ts index fbc31c3..f1c49be 100644 --- a/test/tarball-install-smoke.test.ts +++ b/test/tarball-install-smoke.test.ts @@ -330,7 +330,7 @@ describe("tarball install smoke", () => { preferredRoute: "mcp-first" }, cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realInstallDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd '${realInstallDir}'` } }, agentsGuidance: { @@ -563,7 +563,7 @@ describe("tarball install smoke", () => { workflowContract: { recommendedPreset: "state=auto, limit=8", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realInstallDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd '${realInstallDir}'` } }, subactions: { @@ -588,7 +588,7 @@ describe("tarball install smoke", () => { workflowContract: { recommendedPreset: "state=auto, limit=8", cliFallback: { - searchCommand: `cam recall search "" --state auto --limit 8 --cwd ${JSON.stringify(realInstallDir)}` + searchCommand: `cam recall search "" --state auto --limit 8 --cwd '${realInstallDir}'` } }, subactions: { @@ -900,7 +900,7 @@ describe("tarball install smoke", () => { failureMessage: expect.stringContaining("directory"), rollbackApplied: true, subactions: { - mcp: { attempted: false }, + mcp: { attempted: true, status: "blocked", action: "blocked" }, agents: { attempted: false }, hooks: { attempted: false }, skills: { attempted: false } From 32e686076f05800d3df51a9c2e8c30142f99faac Mon Sep 17 00:00:00 2001 From: blocks Date: Sat, 11 Apr 2026 20:41:15 +0800 Subject: [PATCH 49/62] test: refresh recovery record scope expectation --- test/recovery-records.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/recovery-records.test.ts b/test/recovery-records.test.ts index f846b10..a9919f7 100644 --- a/test/recovery-records.test.ts +++ b/test/recovery-records.test.ts @@ -209,6 +209,6 @@ describe("recovery-records", () => { sourceSessionId: "session-1", scope: "project" }) - ).toBe(false); + ).toBe(true); }); }); From e179cae435417baf79d5b2ed63f5fbaa3bb4b1fa Mon Sep 17 00:00:00 2001 From: blocks Date: Sat, 11 Apr 2026 20:55:39 +0800 Subject: [PATCH 50/62] test: align compiled staged-write failure contract --- test/dist-cli-smoke.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/dist-cli-smoke.test.ts b/test/dist-cli-smoke.test.ts index b134795..47f1a86 100644 --- a/test/dist-cli-smoke.test.ts +++ b/test/dist-cli-smoke.test.ts @@ -1283,7 +1283,7 @@ describe("dist cli smoke", () => { failureMessage: expect.stringContaining("directory"), rollbackApplied: true, subactions: { - mcp: { attempted: false }, + mcp: { attempted: true, status: "blocked", action: "blocked" }, agents: { attempted: false }, hooks: { attempted: false }, skills: { attempted: false } From fad8b57b63f07bb2d7716db1be30e7bd8247c6b7 Mon Sep 17 00:00:00 2001 From: blocks Date: Fri, 10 Apr 2026 23:10:07 +0800 Subject: [PATCH 51/62] fix: close runtime safety seams --- AGENTS.md | 4 + src/lib/commands/manual-mutation-review.ts | 7 +- src/lib/commands/mcp.ts | 23 ++++-- src/lib/integration/retrieval-contract.ts | 37 +++++++-- test/mcp-command.test.ts | 17 ++++ test/memory-command.test.ts | 93 ++++++++++++++++++++++ test/retrieval-contract.test.ts | 24 +++++- 7 files changed, 188 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8b49aef..96930da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,9 +101,13 @@ cam session status 1. 继续做小步 stack closure,而不是重新摊大 remediation 2. 优先把 help / docs / smoke contract 固定成同一套公开语义 3. 在不扩张宿主边界的前提下,继续维持 Codex-first、manual-only 非 Codex host 的产品表述 +4. 将 issue5 剩余 closeout seams 继续拆成小 PR:`cam init` 幂等/`--force`、`cam session status/load` 只读化、Vitest `.worktrees/**` 边界、docs/help parity +5. 保持发布面验证串行执行:`dist-cli-smoke` 与 `tarball-install-smoke` 不并行跑,避免 `prepack -> rimraf dist` 造成假阴性 ## 变更记录 +- 2026-04-10: issue5 PR14 收口了三类 runtime contract seam:`mcp` 命令的空 `--cwd` 现在 fail-closed;`workflowContract` 的 resolved launcher 与显式 `launcherOverride` 保持一致;delete-only 的 forget follow-up 文案不再硬编码裸 `cam recall timeline`。 +- 2026-04-10: `test/recovery-records.test.ts` 已对齐当前 continuity 语义:`scope=both` continuity recovery marker 可以被后续 single-scope save/refresh 复用,并继续由 `session-command` 行为测试锁定。 - 2026-04-10: 新增根级 `AGENTS.md`,补齐仓库级功能说明、命令面、关键 JSON 契约与项目规划。 - 2026-04-10: 明确 `cam integrations install --help` 与四语 README 命令表的公开边界:install 编排 stack,但不更新 `AGENTS.md`。 - 2026-04-10: 新增 Claude Code / Gemini CLI 宿主接入边界文档,并同步收紧宿主策略文档与 README 入口,明确非 Codex 宿主当前仍是 manual-only / snippet-first。 diff --git a/src/lib/commands/manual-mutation-review.ts b/src/lib/commands/manual-mutation-review.ts index e392387..5783962 100644 --- a/src/lib/commands/manual-mutation-review.ts +++ b/src/lib/commands/manual-mutation-review.ts @@ -187,7 +187,12 @@ function buildNextRecommendedActions( ) ); } else { - steps.push("Details are unavailable for deleted refs; use cam recall timeline to review the deletion trail."); + steps.push( + `Details are unavailable for deleted refs; review the deletion trail with ${buildResolvedCliTimelineCommand( + JSON.stringify(timelineRefs[0]), + options + )}.` + ); } steps.push( diff --git a/src/lib/commands/mcp.ts b/src/lib/commands/mcp.ts index 8111d12..dab99d9 100644 --- a/src/lib/commands/mcp.ts +++ b/src/lib/commands/mcp.ts @@ -10,6 +10,7 @@ import { import { installMcpProjectConfig } from "../integration/mcp-install.js"; import { formatMcpDoctorReport, inspectMcpDoctor } from "../integration/mcp-doctor.js"; import { startRetrievalMcpServer } from "../mcp/retrieval-server.js"; +import { ensureExistingDirectory } from "../util/paths.js"; interface McpServeOptions { cwd?: string; @@ -39,17 +40,25 @@ interface McpApplyGuidanceOptions { json?: boolean; } -function resolveCommandCwd(cwd: string | undefined): string { - return cwd ? path.resolve(cwd) : process.cwd(); +async function resolveCommandCwd(cwd: string | undefined): Promise { + if (cwd === undefined) { + return process.cwd(); + } + + if (!cwd.trim()) { + throw new Error("--cwd must be a non-empty path to an existing directory."); + } + + return ensureExistingDirectory(path.resolve(cwd)); } export async function runMcpServe(options: McpServeOptions = {}): Promise { - await startRetrievalMcpServer(resolveCommandCwd(options.cwd)); + await startRetrievalMcpServer(await resolveCommandCwd(options.cwd)); } export async function runMcpPrintConfig(options: McpPrintConfigOptions = {}): Promise { const host = normalizeMcpHost(options.host); - const projectRoot = resolveMcpProjectRoot(resolveCommandCwd(options.cwd)); + const projectRoot = resolveMcpProjectRoot(await resolveCommandCwd(options.cwd)); const snippet = buildMcpHostConfigSnippet(host, projectRoot); if (options.json) { @@ -61,7 +70,7 @@ export async function runMcpPrintConfig(options: McpPrintConfigOptions = {}): Pr export async function runMcpDoctor(options: McpDoctorOptions = {}): Promise { const report = await inspectMcpDoctor({ - cwd: resolveCommandCwd(options.cwd), + cwd: await resolveCommandCwd(options.cwd), host: options.host, explicitCwd: Boolean(options.cwd) }); @@ -75,7 +84,7 @@ export async function runMcpDoctor(options: McpDoctorOptions = {}): Promise { const host = normalizeMcpHost(options.host); - const projectRoot = resolveMcpProjectRoot(resolveCommandCwd(options.cwd)); + const projectRoot = resolveMcpProjectRoot(await resolveCommandCwd(options.cwd)); const result = await installMcpProjectConfig(host, projectRoot); if (options.json) { @@ -103,7 +112,7 @@ export async function runMcpApplyGuidance( ); } - const projectRoot = resolveMcpProjectRoot(resolveCommandCwd(options.cwd)); + const projectRoot = resolveMcpProjectRoot(await resolveCommandCwd(options.cwd)); const result = await applyCodexAgentsGuidance(projectRoot); if (options.json) { diff --git a/src/lib/integration/retrieval-contract.ts b/src/lib/integration/retrieval-contract.ts index 87ad341..a26dbd0 100644 --- a/src/lib/integration/retrieval-contract.ts +++ b/src/lib/integration/retrieval-contract.ts @@ -41,6 +41,7 @@ export const MCP_SERVE_GUIDANCE = export function buildMcpDoctorGuidance( options: { cwd?: string; + launcherOverride?: WorkflowContract["launcher"]; } = {} ): string { const fallbackCommand = buildResolvedCliCommand("mcp doctor --host codex", options); @@ -55,6 +56,7 @@ export const ARCHIVE_BOUNDARY = export function buildDurableMemorySyncGuidance( options: { cwd?: string; + launcherOverride?: WorkflowContract["launcher"]; } = {} ): string { const syncCommand = buildResolvedPostWorkSyncCommand(options); @@ -265,9 +267,11 @@ export function buildResolvedCliCommand( command: string, options: { cwd?: string; + launcherOverride?: WorkflowContract["launcher"]; } = {} ): string { - return appendCliCwdFlag(`${resolveCliLauncher().resolvedCommand} ${command}`, options.cwd); + const launcher = options.launcherOverride ?? resolveCliLauncher(); + return appendCliCwdFlag(`${launcher.resolvedCommand} ${command}`, options.cwd); } function getInstalledHookHelperPath(helperScript: string): string { @@ -353,6 +357,7 @@ export function buildResolvedCliSearchCommand( state?: MemoryRetrievalStateFilter; limit?: number; cwd?: string; + launcherOverride?: WorkflowContract["launcher"]; } = {} ): string { const state = options.state ?? RECOMMENDED_RETRIEVAL_STATE; @@ -367,6 +372,7 @@ export function buildResolvedCliTimelineCommand( ref = "\"\"", options: { cwd?: string; + launcherOverride?: WorkflowContract["launcher"]; } = {} ): string { return buildResolvedCliCommand(`recall timeline ${ref}`, options); @@ -376,6 +382,7 @@ export function buildResolvedCliDetailsCommand( ref = "\"\"", options: { cwd?: string; + launcherOverride?: WorkflowContract["launcher"]; } = {} ): string { return buildResolvedCliCommand(`recall details ${ref}`, options); @@ -384,6 +391,7 @@ export function buildResolvedCliDetailsCommand( export function buildResolvedPostWorkSyncCommand( options: { cwd?: string; + launcherOverride?: WorkflowContract["launcher"]; } = {} ): string { return buildResolvedCliCommand("sync", options); @@ -392,6 +400,7 @@ export function buildResolvedPostWorkSyncCommand( export function buildResolvedPostWorkRecentReviewCommand( options: { cwd?: string; + launcherOverride?: WorkflowContract["launcher"]; } = {} ): string { return buildResolvedCliCommand("memory --recent", options); @@ -410,7 +419,7 @@ export function buildWorkflowContract( localBridge: LOCAL_BRIDGE_RECALL_WORKFLOW, resolvedCli: RESOLVED_CLI_RECALL_WORKFLOW, cliFallback: CLI_FALLBACK_RECALL_WORKFLOW, - doctor: buildMcpDoctorGuidance(options), + doctor: buildMcpDoctorGuidance({ ...options, launcherOverride: launcher }), serve: MCP_SERVE_GUIDANCE }; const recallWorkflow: WorkflowRecallWorkflow = { @@ -439,21 +448,33 @@ export function buildWorkflowContract( requiresCamOnPath: true }; const resolvedCliFallback: WorkflowContract["resolvedCliFallback"] = { - searchCommand: buildResolvedCliSearchCommand("\"\"", options), - timelineCommand: buildResolvedCliTimelineCommand("\"\"", options), - detailsCommand: buildResolvedCliDetailsCommand("\"\"", options) + searchCommand: buildResolvedCliSearchCommand("\"\"", { + ...options, + launcherOverride: launcher + }), + timelineCommand: buildResolvedCliTimelineCommand("\"\"", { + ...options, + launcherOverride: launcher + }), + detailsCommand: buildResolvedCliDetailsCommand("\"\"", { + ...options, + launcherOverride: launcher + }) }; const postWorkSyncReview: WorkflowContract["postWorkSyncReview"] = { helperScript: POST_WORK_SYNC_REVIEW_HELPER, syncCommand: buildPostWorkSyncCommand(options), reviewCommand: buildPostWorkRecentReviewCommand(options), - guidance: buildDurableMemorySyncGuidance(options), + guidance: buildDurableMemorySyncGuidance({ ...options, launcherOverride: launcher }), shellOnly: true, requiresCamOnPath: true }; const resolvedPostWorkSyncReview: WorkflowContract["resolvedPostWorkSyncReview"] = { - syncCommand: buildResolvedPostWorkSyncCommand(options), - reviewCommand: buildResolvedPostWorkRecentReviewCommand(options) + syncCommand: buildResolvedPostWorkSyncCommand({ ...options, launcherOverride: launcher }), + reviewCommand: buildResolvedPostWorkRecentReviewCommand({ + ...options, + launcherOverride: launcher + }) }; const boundaries: WorkflowContract["boundaries"] = { memoryAudit: MEMORY_AUDIT_BOUNDARY, diff --git a/test/mcp-command.test.ts b/test/mcp-command.test.ts index 9c76c3b..904b25c 100644 --- a/test/mcp-command.test.ts +++ b/test/mcp-command.test.ts @@ -700,6 +700,23 @@ describe("mcp command", () => { ); }); + it("fails closed when print-config --cwd is an empty string", async () => { + const homeDir = await tempDir("cam-mcp-empty-cwd-home-"); + const projectDir = await tempDir("cam-mcp-empty-cwd-project-"); + process.env.HOME = homeDir; + + const result = runCli( + projectDir, + ["mcp", "print-config", "--host", "codex", "--cwd", "", "--json"], + { + env: { HOME: homeDir } + } + ); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("--cwd must be a non-empty path to an existing directory"); + }); + it("applies the recommended AGENTS guidance by creating a managed block when AGENTS.md is missing", async () => { const homeDir = await tempDir("cam-mcp-apply-guidance-create-home-"); const projectDir = await tempDir("cam-mcp-apply-guidance-create-project-"); diff --git a/test/memory-command.test.ts b/test/memory-command.test.ts index 3b0767f..88251eb 100644 --- a/test/memory-command.test.ts +++ b/test/memory-command.test.ts @@ -34,6 +34,43 @@ async function readFileIfExists(filePath: string): Promise { } } +async function pathExists(pathname: string): Promise { + try { + await fs.access(pathname); + return true; + } catch { + return false; + } +} + +async function pathContainsCam(dir: string): Promise { + const candidates = + process.platform === "win32" + ? [path.join(dir, "cam.cmd"), path.join(dir, "cam.exe")] + : [path.join(dir, "cam")]; + + for (const candidate of candidates) { + if (await pathExists(candidate)) { + return true; + } + } + + return false; +} + +async function buildPathWithoutCam(extraDir: string): Promise { + const baseEntries = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean); + const filteredEntries: string[] = []; + + for (const entry of baseEntries) { + if (!(await pathContainsCam(entry))) { + filteredEntries.push(entry); + } + } + + return [extraDir, ...filteredEntries].join(path.delimiter); +} + async function snapshotFiles(filePaths: string[]): Promise> { return Object.fromEntries( await Promise.all( @@ -2805,6 +2842,62 @@ describe("runMemory", () => { expect(result.stdout).toContain("memory reindex"); }); + it("keeps delete-only forget follow-up commands aligned with the resolved launcher and pinned cwd", async () => { + const homeDir = await tempDir("cam-forget-delete-only-home-"); + const projectDir = await tempDir("cam-forget-delete-only-project-"); + const callerDir = await tempDir("cam-forget-delete-only-caller-"); + const memoryRoot = await tempDir("cam-forget-delete-only-root-"); + const emptyPathDir = await tempDir("cam-forget-delete-only-empty-path-"); + const fakeDistDir = await tempDir("cam-forget-delete-only-dist-"); + const fakeDistCliPath = path.join(fakeDistDir, "cli.js"); + const realProjectDir = await fs.realpath(projectDir); + process.env.HOME = homeDir; + + await fs.writeFile( + fakeDistCliPath, + "#!/usr/bin/env node\nconsole.log('fake dist cli');\n", + "utf8" + ); + + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); + + const project = detectProjectContext(projectDir); + const store = new MemoryStore(project, { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.remember( + "project", + "workflow", + "prefer-pnpm", + "Prefer pnpm in this repository.", + ["Use pnpm instead of npm in this repository."], + "Manual note." + ); + + const result = runCli( + callerDir, + ["forget", "pnpm", "--scope", "project", "--cwd", projectDir], + { + env: { + HOME: homeDir, + PATH: await buildPathWithoutCam(emptyPathDir), + CODEX_AUTO_MEMORY_DIST_CLI_PATH: fakeDistCliPath + } + } + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain( + `node ${JSON.stringify(fakeDistCliPath)} recall timeline "project:active:workflow:prefer-pnpm" --cwd '${realProjectDir}'` + ); + expect(result.stdout).toContain("node "); + expect(result.stdout).not.toContain("use cam recall timeline to review the deletion trail"); + }); + it("surfaces an additive empty reviewer payload for forget --json when nothing matches", async () => { const homeDir = await tempDir("cam-forget-empty-json-home-"); const projectDir = await tempDir("cam-forget-empty-json-project-"); diff --git a/test/retrieval-contract.test.ts b/test/retrieval-contract.test.ts index d7e7af7..20e12e0 100644 --- a/test/retrieval-contract.test.ts +++ b/test/retrieval-contract.test.ts @@ -5,7 +5,8 @@ import { afterEach, describe, expect, it } from "vitest"; import { appendCliCwdFlag, buildResolvedCliCommand, - buildWorkflowContract + buildWorkflowContract, + resolveCliLauncher } from "../src/lib/integration/retrieval-contract.js"; const tempDirs: string[] = []; @@ -66,4 +67,25 @@ describe("retrieval contract", () => { }); expect(buildResolvedCliCommand("mcp doctor --host codex")).toContain(fakeDistCliPath); }); + + it("keeps resolved CLI fallback commands aligned with an explicit launcher override", () => { + const launcher = resolveCliLauncher({ + pathValue: "", + distCliPath: "/tmp/custom-dist/cli.js", + distCliPathExists: true + }); + + const workflowContract = buildWorkflowContract({ + cwd: "/tmp/project", + launcherOverride: launcher + }); + + expect(workflowContract.launcher).toMatchObject({ + resolution: "node-dist", + resolvedCommand: 'node "/tmp/custom-dist/cli.js"' + }); + expect(workflowContract.resolvedCliFallback.searchCommand).toContain("/tmp/custom-dist/cli.js"); + expect(workflowContract.resolvedCliFallback.timelineCommand).toContain("/tmp/custom-dist/cli.js"); + expect(workflowContract.resolvedCliFallback.detailsCommand).toContain("/tmp/custom-dist/cli.js"); + }); }); From 94ba1c9a0d373931e60e9e75c92277ec16503622 Mon Sep 17 00:00:00 2001 From: blocks Date: Fri, 10 Apr 2026 23:53:53 +0800 Subject: [PATCH 52/62] fix: make init explicit and idempotent --- src/lib/cli/register-commands.ts | 3 +- src/lib/commands/init.ts | 59 +++++++++-- test/init-command.test.ts | 163 +++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 11 deletions(-) create mode 100644 test/init-command.test.ts diff --git a/src/lib/cli/register-commands.ts b/src/lib/cli/register-commands.ts index f2e6a51..ab63bee 100644 --- a/src/lib/cli/register-commands.ts +++ b/src/lib/cli/register-commands.ts @@ -308,7 +308,8 @@ export function registerCommands(program: Command): void { program .command("init") .description("Initialize Codex Auto Memory in the current project") - .action(withStdout(async () => runInit())); + .option("--force", "Overwrite existing init config files with canonical defaults") + .action(withStdout(async (options) => runInit(options))); const memoryCommand = program .command("memory") diff --git a/src/lib/commands/init.ts b/src/lib/commands/init.ts index 10b7d7b..5a57459 100644 --- a/src/lib/commands/init.ts +++ b/src/lib/commands/init.ts @@ -1,8 +1,10 @@ import { configPaths } from "../config/load-config.js"; +import { rawProjectConfigSchema } from "../config/schema.js"; import { MemoryStore } from "../domain/memory-store.js"; import { detectProjectContext, getDefaultMemoryDirectory } from "../domain/project-context.js"; import { SessionContinuityStore } from "../domain/session-continuity-store.js"; -import { updateGitignoreLine, writeJsonFile } from "../util/fs.js"; +import { buildRuntimeContext } from "../runtime/runtime-context.js"; +import { fileExists, readJsonFile, updateGitignoreLine, writeJsonFile } from "../util/fs.js"; import type { AppConfig } from "../types.js"; interface InitOptions { @@ -10,6 +12,32 @@ interface InitOptions { force?: boolean; } +async function ensureInitConfigShape( + filePath: string, + label: "project" | "local" +): Promise { + if (!(await fileExists(filePath))) { + return; + } + + let raw: unknown; + try { + raw = await readJsonFile(filePath); + } catch { + throw new Error( + `Existing ${label} config at ${filePath} is invalid. Re-run with --force to overwrite it.` + ); + } + + try { + rawProjectConfigSchema.parse(raw); + } catch { + throw new Error( + `Existing ${label} config at ${filePath} is invalid. Re-run with --force to overwrite it.` + ); + } +} + export async function runInit(options: InitOptions = {}): Promise { const project = detectProjectContext(options.cwd); const projectConfigPath = configPaths.getProjectConfigPath(project.projectRoot); @@ -26,18 +54,29 @@ export async function runInit(options: InitOptions = {}): Promise { codexBinary: "codex" }; - await writeJsonFile(projectConfigPath, projectConfig); - await writeJsonFile(localConfigPath, { - autoMemoryEnabled: true - }); + if (!options.force) { + await ensureInitConfigShape(projectConfigPath, "project"); + await ensureInitConfigShape(localConfigPath, "local"); + } + + if (options.force || !(await fileExists(projectConfigPath))) { + await writeJsonFile(projectConfigPath, projectConfig); + } + if (options.force || !(await fileExists(localConfigPath))) { + await writeJsonFile(localConfigPath, { + autoMemoryEnabled: true + }); + } await updateGitignoreLine(project.projectRoot, ".codex-auto-memory.local.json"); - const config: AppConfig = { - ...projectConfig, - autoMemoryDirectory: getDefaultMemoryDirectory() - }; + const runtime = await buildRuntimeContext(project.projectRoot); + const config: AppConfig = runtime.loadedConfig.config.autoMemoryDirectory + ? runtime.loadedConfig.config + : { + ...runtime.loadedConfig.config, + autoMemoryDirectory: getDefaultMemoryDirectory() + }; const store = new MemoryStore(project, config); - await store.ensureLayout(); const continuityStore = new SessionContinuityStore(project, config); const excludePath = await continuityStore.ensureLocalIgnore(); diff --git a/test/init-command.test.ts b/test/init-command.test.ts new file mode 100644 index 0000000..f477915 --- /dev/null +++ b/test/init-command.test.ts @@ -0,0 +1,163 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + initGitRepo, + writeCamConfig +} from "./helpers/cam-test-fixtures.js"; +import { runCli } from "./helpers/cli-runner.js"; + +const tempDirs: string[] = []; + +async function tempDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(process.env.TMPDIR ?? "/tmp", prefix)); + tempDirs.push(dir); + return dir; +} + +async function pathExists(targetPath: string): Promise { + try { + await fs.access(targetPath); + return true; + } catch { + return false; + } +} + +async function readJson(filePath: string): Promise { + return JSON.parse(await fs.readFile(filePath, "utf8")) as unknown; +} + +const expectedProjectInitConfig = { + autoMemoryEnabled: true, + extractorMode: "codex", + defaultScope: "project", + maxStartupLines: 200, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false, + sessionContinuityLocalPathStyle: "codex", + maxSessionContinuityLines: 60, + codexBinary: "codex" +}; + +const expectedLocalInitConfig = { + autoMemoryEnabled: true +}; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("cam init", () => { + it("keeps existing valid init config files by default", async () => { + const homeDir = await tempDir("cam-init-home-"); + const repoDir = await tempDir("cam-init-repo-"); + const customMemoryRoot = await tempDir("cam-init-custom-memory-"); + await initGitRepo(repoDir); + + const existingProjectConfig = { + ...expectedProjectInitConfig, + defaultScope: "project-local", + sessionContinuityAutoLoad: true, + codexBinary: "codex-dev" + }; + const existingLocalConfig = { + autoMemoryEnabled: false, + autoMemoryDirectory: customMemoryRoot, + sessionContinuityAutoSave: true + }; + await writeCamConfig(repoDir, existingProjectConfig, existingLocalConfig); + + const result = runCli(repoDir, ["init"], { + env: { HOME: homeDir } + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(await readJson(path.join(repoDir, "codex-auto-memory.json"))).toEqual(existingProjectConfig); + expect(await readJson(path.join(repoDir, ".codex-auto-memory.local.json"))).toEqual(existingLocalConfig); + expect(await pathExists(customMemoryRoot)).toBe(true); + expect(await fs.readFile(path.join(repoDir, ".gitignore"), "utf8")).toContain( + ".codex-auto-memory.local.json" + ); + expect(await fs.readFile(path.join(repoDir, ".git", "info", "exclude"), "utf8")).toContain( + ".codex-auto-memory/" + ); + }); + + it("overwrites existing init config files when --force is passed", async () => { + const homeDir = await tempDir("cam-init-force-home-"); + const repoDir = await tempDir("cam-init-force-repo-"); + const customMemoryRoot = await tempDir("cam-init-force-custom-memory-"); + await initGitRepo(repoDir); + + await writeCamConfig( + repoDir, + { + ...expectedProjectInitConfig, + defaultScope: "project-local", + codexBinary: "codex-dev" + }, + { + autoMemoryEnabled: false, + autoMemoryDirectory: customMemoryRoot + } + ); + + const result = runCli(repoDir, ["init", "--force"], { + env: { HOME: homeDir } + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(await readJson(path.join(repoDir, "codex-auto-memory.json"))).toEqual( + expectedProjectInitConfig + ); + expect(await readJson(path.join(repoDir, ".codex-auto-memory.local.json"))).toEqual( + expectedLocalInitConfig + ); + }); + + it("fails closed on invalid existing init config without --force", async () => { + const homeDir = await tempDir("cam-init-invalid-home-"); + const repoDir = await tempDir("cam-init-invalid-repo-"); + const memoryRootParent = await tempDir("cam-init-invalid-memory-parent-"); + const memoryRoot = path.join(memoryRootParent, "memory-root"); + await initGitRepo(repoDir); + + await fs.writeFile(path.join(repoDir, "codex-auto-memory.json"), "{\n", "utf8"); + await fs.writeFile( + path.join(repoDir, ".codex-auto-memory.local.json"), + JSON.stringify({ autoMemoryDirectory: memoryRoot }, null, 2), + "utf8" + ); + + const result = runCli(repoDir, ["init"], { + env: { HOME: homeDir } + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Re-run with --force"); + expect(await fs.readFile(path.join(repoDir, "codex-auto-memory.json"), "utf8")).toBe("{\n"); + expect(await pathExists(memoryRoot)).toBe(false); + }); + + it("rebuilds invalid existing init config when --force is passed", async () => { + const homeDir = await tempDir("cam-init-rebuild-home-"); + const repoDir = await tempDir("cam-init-rebuild-repo-"); + await initGitRepo(repoDir); + + await fs.writeFile(path.join(repoDir, "codex-auto-memory.json"), "{\n", "utf8"); + await fs.writeFile(path.join(repoDir, ".codex-auto-memory.local.json"), "{\n", "utf8"); + + const result = runCli(repoDir, ["init", "--force"], { + env: { HOME: homeDir } + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(await readJson(path.join(repoDir, "codex-auto-memory.json"))).toEqual( + expectedProjectInitConfig + ); + expect(await readJson(path.join(repoDir, ".codex-auto-memory.local.json"))).toEqual( + expectedLocalInitConfig + ); + }); +}); From cc2ea2f72ea3014e2eee6db9ee4d627e017803fd Mon Sep 17 00:00:00 2001 From: blocks Date: Fri, 10 Apr 2026 23:48:52 +0800 Subject: [PATCH 53/62] fix: keep session inspection read-only --- src/lib/commands/session.ts | 7 +++- test/dist-cli-smoke.test.ts | 71 ++++++++++++++++++++++++++++++++++++ test/session-command.test.ts | 68 ++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) diff --git a/src/lib/commands/session.ts b/src/lib/commands/session.ts index 5d8810e..b88c799 100644 --- a/src/lib/commands/session.ts +++ b/src/lib/commands/session.ts @@ -170,10 +170,10 @@ export async function runSession( options: SessionOptions = {} ): Promise { const cwd = options.cwd ?? process.cwd(); - const runtime = await buildRuntimeContext(cwd); const scope = selectedScope(options.scope); if (action === "save" || action === "refresh") { + const runtime = await buildRuntimeContext(cwd); const persistenceRequest = await prepareSessionPersistenceRequest( runtime, action, @@ -197,6 +197,7 @@ export async function runSession( } if (action === "clear") { + const runtime = await buildRuntimeContext(cwd); const cleared = await runtime.sessionContinuityStore.clear(scope); if (options.json) { return JSON.stringify({ cleared }, null, 2); @@ -210,6 +211,7 @@ export async function runSession( } if (action === "open") { + const runtime = await buildRuntimeContext(cwd); await runtime.sessionContinuityStore.ensureLocalLayout(); openPath(runtime.sessionContinuityStore.paths.localDir); return [ @@ -218,6 +220,9 @@ export async function runSession( ].join("\n"); } + const runtime = await buildRuntimeContext(cwd, {}, { + ensureMemoryLayout: false + }); const view = await loadSessionInspectionView(runtime); if (action === "load") { diff --git a/test/dist-cli-smoke.test.ts b/test/dist-cli-smoke.test.ts index 47f1a86..6a80ebe 100644 --- a/test/dist-cli-smoke.test.ts +++ b/test/dist-cli-smoke.test.ts @@ -55,6 +55,15 @@ async function waitForFile(pathname: string, timeoutMs = 2_000): Promise } } +async function pathExists(targetPath: string): Promise { + try { + await fs.access(targetPath); + return true; + } catch { + return false; + } +} + afterEach(async () => { if (originalCodexHome === undefined) { delete process.env.CODEX_HOME; @@ -298,6 +307,68 @@ describe("dist cli smoke", () => { expect(forgetPayload.followUp.timelineRefs.length).toBeGreaterThan(0); }, 30_000); + it("keeps session inspection read-only from the compiled cli entrypoint", async () => { + const homeDir = await tempDir("cam-dist-session-readonly-home-"); + const projectDir = await tempDir("cam-dist-session-readonly-project-"); + const memoryRootParent = await tempDir("cam-dist-session-readonly-memory-parent-"); + const memoryRoot = path.join(memoryRootParent, "memory-root"); + + await writeCamConfig(projectDir, makeAppConfig(), { + autoMemoryDirectory: memoryRoot + }); + + const sessionStatusResult = runCli(projectDir, ["session", "status", "--json"], { + entrypoint: "dist", + env: { HOME: homeDir } + }); + const sessionLoadResult = runCli( + projectDir, + ["session", "load", "--json", "--print-startup"], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + + expect(sessionStatusResult.exitCode, sessionStatusResult.stderr).toBe(0); + expect(sessionLoadResult.exitCode, sessionLoadResult.stderr).toBe(0); + expect(JSON.parse(sessionStatusResult.stdout)).toMatchObject({ + projectLocation: { + exists: false + }, + localLocation: { + exists: false + }, + latestContinuityAuditEntry: null, + latestContinuityDiagnostics: null, + pendingContinuityRecovery: null, + startup: { + sourceFiles: [], + candidateSourceFiles: [], + continuityMode: "startup", + continuityProvenanceKind: "temporary-continuity" + } + }); + expect(JSON.parse(sessionLoadResult.stdout)).toMatchObject({ + projectLocation: { + exists: false + }, + localLocation: { + exists: false + }, + latestContinuityAuditEntry: null, + latestContinuityDiagnostics: null, + pendingContinuityRecovery: null, + startup: { + sourceFiles: [], + candidateSourceFiles: [], + continuityMode: "startup", + continuityProvenanceKind: "temporary-continuity" + } + }); + expect(await pathExists(memoryRoot)).toBe(false); + }); + it("uses the recommended recall search preset from the compiled cli entrypoint without creating memory layout on first lookup", async () => { const homeDir = await tempDir("cam-dist-recall-home-"); const projectDir = await tempDir("cam-dist-recall-project-"); diff --git a/test/session-command.test.ts b/test/session-command.test.ts index f0cdd18..c953474 100644 --- a/test/session-command.test.ts +++ b/test/session-command.test.ts @@ -57,6 +57,15 @@ process.exit(0); return mockCodexPath; } +async function pathExists(targetPath: string): Promise { + try { + await fs.access(targetPath); + return true; + } catch { + return false; + } +} + describe("runSession", () => { it("shows an empty compact prior preview when no continuity audit history exists", async () => { const repoDir = await tempDir("cam-session-empty-history-repo-"); @@ -576,6 +585,65 @@ describe("runSession", () => { expect(statusPayload.localLocation.exists).toBe(false); }, 30_000); + it("keeps session load and status read-only on an uninitialized project", async () => { + const homeDir = await tempDir("cam-session-readonly-home-"); + const projectDir = await tempDir("cam-session-readonly-project-"); + const memoryRootParent = await tempDir("cam-session-readonly-memory-parent-"); + const memoryRoot = path.join(memoryRootParent, "memory-root"); + await initRepo(projectDir); + + await writeProjectConfig( + projectDir, + configJson(), + { autoMemoryDirectory: memoryRoot } + ); + + const statusResult = runCli(projectDir, ["session", "status", "--json"], { + env: { HOME: homeDir } + }); + const loadResult = runCli(projectDir, ["session", "load", "--json", "--print-startup"], { + env: { HOME: homeDir } + }); + + expect(statusResult.exitCode, statusResult.stderr).toBe(0); + expect(loadResult.exitCode, loadResult.stderr).toBe(0); + expect(JSON.parse(statusResult.stdout)).toMatchObject({ + projectLocation: { + exists: false + }, + localLocation: { + exists: false + }, + latestContinuityAuditEntry: null, + latestContinuityDiagnostics: null, + pendingContinuityRecovery: null, + startup: { + sourceFiles: [], + candidateSourceFiles: [], + continuityMode: "startup", + continuityProvenanceKind: "temporary-continuity" + } + }); + expect(JSON.parse(loadResult.stdout)).toMatchObject({ + projectLocation: { + exists: false + }, + localLocation: { + exists: false + }, + latestContinuityAuditEntry: null, + latestContinuityDiagnostics: null, + pendingContinuityRecovery: null, + startup: { + sourceFiles: [], + candidateSourceFiles: [], + continuityMode: "startup", + continuityProvenanceKind: "temporary-continuity" + } + }); + expect(await pathExists(memoryRoot)).toBe(false); + }, 30_000); + it("refresh replaces only the selected scope", async () => { const repoDir = await tempDir("cam-session-refresh-scope-repo-"); const memoryRoot = await tempDir("cam-session-refresh-scope-memory-"); From 5ecc00b80ac0a69036f2deaa327e052b188f1c0f Mon Sep 17 00:00:00 2001 From: blocks Date: Sat, 11 Apr 2026 22:33:52 +0800 Subject: [PATCH 54/62] fix: keep init independent from global config state --- src/lib/commands/init.ts | 23 +++++++++++++------- test/init-command.test.ts | 44 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/lib/commands/init.ts b/src/lib/commands/init.ts index 5a57459..d5cde93 100644 --- a/src/lib/commands/init.ts +++ b/src/lib/commands/init.ts @@ -3,8 +3,8 @@ import { rawProjectConfigSchema } from "../config/schema.js"; import { MemoryStore } from "../domain/memory-store.js"; import { detectProjectContext, getDefaultMemoryDirectory } from "../domain/project-context.js"; import { SessionContinuityStore } from "../domain/session-continuity-store.js"; -import { buildRuntimeContext } from "../runtime/runtime-context.js"; import { fileExists, readJsonFile, updateGitignoreLine, writeJsonFile } from "../util/fs.js"; +import { resolveAppPath } from "../util/paths.js"; import type { AppConfig } from "../types.js"; interface InitOptions { @@ -69,13 +69,20 @@ export async function runInit(options: InitOptions = {}): Promise { } await updateGitignoreLine(project.projectRoot, ".codex-auto-memory.local.json"); - const runtime = await buildRuntimeContext(project.projectRoot); - const config: AppConfig = runtime.loadedConfig.config.autoMemoryDirectory - ? runtime.loadedConfig.config - : { - ...runtime.loadedConfig.config, - autoMemoryDirectory: getDefaultMemoryDirectory() - }; + const persistedProjectConfig = rawProjectConfigSchema.parse( + (await readJsonFile(projectConfigPath)) ?? projectConfig + ); + const persistedLocalConfig = rawProjectConfigSchema.parse( + (await readJsonFile(localConfigPath)) ?? { autoMemoryEnabled: true } + ); + const config: AppConfig = { + ...projectConfig, + ...persistedProjectConfig, + ...persistedLocalConfig, + autoMemoryDirectory: persistedLocalConfig.autoMemoryDirectory + ? resolveAppPath(persistedLocalConfig.autoMemoryDirectory) + : getDefaultMemoryDirectory() + }; const store = new MemoryStore(project, config); const continuityStore = new SessionContinuityStore(project, config); const excludePath = await continuityStore.ensureLocalIgnore(); diff --git a/test/init-command.test.ts b/test/init-command.test.ts index f477915..d549192 100644 --- a/test/init-command.test.ts +++ b/test/init-command.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { configPaths } from "../src/lib/config/load-config.js"; import { initGitRepo, writeCamConfig @@ -160,4 +161,47 @@ describe("cam init", () => { expectedLocalInitConfig ); }); + + it("does not let an invalid user config outside the target project block init", async () => { + const homeDir = await tempDir("cam-init-invalid-user-home-"); + const repoDir = await tempDir("cam-init-invalid-user-repo-"); + await initGitRepo(repoDir); + + const userConfigPath = configPaths.getUserConfigPath(); + await fs.mkdir(path.dirname(userConfigPath), { recursive: true }); + await fs.writeFile(userConfigPath, "{\n", "utf8"); + + const result = runCli(repoDir, ["init"], { + env: { HOME: homeDir } + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(await readJson(path.join(repoDir, "codex-auto-memory.json"))).toEqual( + expectedProjectInitConfig + ); + }); + + it("does not let an invalid managed config block init", async () => { + const homeDir = await tempDir("cam-init-invalid-managed-home-"); + const repoDir = await tempDir("cam-init-invalid-managed-repo-"); + const managedConfigPath = path.join( + await tempDir("cam-init-invalid-managed-config-"), + "config.json" + ); + await initGitRepo(repoDir); + + await fs.writeFile(managedConfigPath, "{\n", "utf8"); + + const result = runCli(repoDir, ["init"], { + env: { + HOME: homeDir, + CAM_MANAGED_CONFIG: managedConfigPath + } + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(await readJson(path.join(repoDir, "codex-auto-memory.json"))).toEqual( + expectedProjectInitConfig + ); + }); }); From 820c355e30de0c1d8b61d9b545a8c91292e56510 Mon Sep 17 00:00:00 2001 From: blocks Date: Sat, 11 Apr 2026 22:50:56 +0800 Subject: [PATCH 55/62] test: isolate init env overrides --- test/init-command.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/init-command.test.ts b/test/init-command.test.ts index d549192..14acc10 100644 --- a/test/init-command.test.ts +++ b/test/init-command.test.ts @@ -9,6 +9,8 @@ import { import { runCli } from "./helpers/cli-runner.js"; const tempDirs: string[] = []; +const originalHome = process.env.HOME; +const originalManagedConfig = process.env.CAM_MANAGED_CONFIG; async function tempDir(prefix: string): Promise { const dir = await fs.mkdtemp(path.join(process.env.TMPDIR ?? "/tmp", prefix)); @@ -46,6 +48,12 @@ const expectedLocalInitConfig = { }; afterEach(async () => { + process.env.HOME = originalHome; + if (originalManagedConfig === undefined) { + delete process.env.CAM_MANAGED_CONFIG; + } else { + process.env.CAM_MANAGED_CONFIG = originalManagedConfig; + } await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); @@ -166,6 +174,7 @@ describe("cam init", () => { const homeDir = await tempDir("cam-init-invalid-user-home-"); const repoDir = await tempDir("cam-init-invalid-user-repo-"); await initGitRepo(repoDir); + process.env.HOME = homeDir; const userConfigPath = configPaths.getUserConfigPath(); await fs.mkdir(path.dirname(userConfigPath), { recursive: true }); @@ -189,6 +198,7 @@ describe("cam init", () => { "config.json" ); await initGitRepo(repoDir); + process.env.HOME = homeDir; await fs.writeFile(managedConfigPath, "{\n", "utf8"); From 119d0c352f3a30f712ac9534162e2fff4d833909 Mon Sep 17 00:00:00 2001 From: blocks Date: Sat, 11 Apr 2026 00:17:13 +0800 Subject: [PATCH 56/62] test: keep vitest config coverage lint-safe --- test/vitest-config.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 test/vitest-config.test.ts diff --git a/test/vitest-config.test.ts b/test/vitest-config.test.ts new file mode 100644 index 0000000..3650fdd --- /dev/null +++ b/test/vitest-config.test.ts @@ -0,0 +1,12 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("vitest config", () => { + it("excludes project-local worktree directories from test discovery", async () => { + const configSource = await fs.readFile(path.resolve("vitest.config.ts"), "utf8"); + + expect(configSource).toContain("**/.worktrees/**"); + expect(configSource).toContain("**/worktrees/**"); + }); +}); From 4e33abd3868bc37dc6138309158182cefb67f7b5 Mon Sep 17 00:00:00 2001 From: blocks Date: Sat, 11 Apr 2026 22:55:47 +0800 Subject: [PATCH 57/62] fix: keep worktree exclusions in vitest config --- vitest.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vitest.config.ts b/vitest.config.ts index e7d1043..0931ea2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { + exclude: ["**/.worktrees/**", "**/worktrees/**"], fileParallelism: false, hookTimeout: 30_000, testTimeout: 30_000 From f337d906a64bf2c5129768969d6d40957f70a643 Mon Sep 17 00:00:00 2001 From: blocks Date: Sat, 11 Apr 2026 22:59:25 +0800 Subject: [PATCH 58/62] fix: preserve vitest default excludes --- vitest.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vitest.config.ts b/vitest.config.ts index 0931ea2..8eb2ef0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,8 +1,8 @@ -import { defineConfig } from "vitest/config"; +import { configDefaults, defineConfig } from "vitest/config"; export default defineConfig({ test: { - exclude: ["**/.worktrees/**", "**/worktrees/**"], + exclude: [...configDefaults.exclude, "**/.worktrees/**", "**/worktrees/**"], fileParallelism: false, hookTimeout: 30_000, testTimeout: 30_000 From 678694fcdbea2333210dcb2fbd041a18d149d89f Mon Sep 17 00:00:00 2001 From: blocks Date: Sat, 11 Apr 2026 22:33:52 +0800 Subject: [PATCH 59/62] fix: preserve same-host issue tracker refs --- src/lib/extractor/directive-utils.ts | 7 ++- test/extractor.test.ts | 84 ++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/lib/extractor/directive-utils.ts b/src/lib/extractor/directive-utils.ts index e247265..351b374 100644 --- a/src/lib/extractor/directive-utils.ts +++ b/src/lib/extractor/directive-utils.ts @@ -30,6 +30,8 @@ function resourceTokenFromUrl(url: string, category: string): string | null { const parsed = new URL(url); const pathTokens = parsed.pathname .split("/") + .map((segment) => segment.trim()) + .filter(Boolean) .map((segment) => slugify(segment)) .filter(Boolean); const tailToken = [...pathTokens].reverse().find(Boolean); @@ -38,6 +40,9 @@ function resourceTokenFromUrl(url: string, category: string): string | null { } if (category === "issue-tracker") { + const ticketToken = + [...pathTokens].reverse().find((token) => /^[a-z]+-\d+$/iu.test(token) || /^\d+$/u.test(token)) ?? + null; const nonGenericPathTokens = pathTokens.filter((token) => { if (genericReferenceTokens.has(token)) { return false; @@ -51,7 +56,7 @@ function resourceTokenFromUrl(url: string, category: string): string | null { .filter(Boolean); const hostContextToken = hostTokens.find((token) => !genericHostTokens.has(token)) ?? slugify(parsed.hostname); - const contextToken = nonGenericPathTokens.slice(-2).join("-"); + const contextToken = nonGenericPathTokens.slice(-2).join("-") || ticketToken; return [hostContextToken, contextToken].filter(Boolean).join("-") || category; } diff --git a/test/extractor.test.ts b/test/extractor.test.ts index dc31100..39d87df 100644 --- a/test/extractor.test.ts +++ b/test/extractor.test.ts @@ -1282,6 +1282,90 @@ describe("HeuristicExtractor", () => { ); }); + it("does not collapse repo-specific issue URLs that share the same numeric issue id", async () => { + const extractor = new HeuristicExtractor(); + const existingEntries: MemoryEntry[] = [ + { + id: "widgets-issues", + scope: "project", + topic: "reference", + summary: "Issues are tracked at https://github.com/acme/widgets/issues/123", + details: ["Primary widgets issue tracker pointer."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ]; + + const operations = await extractor.extract( + baseEvidence({ + userMessages: ["Issues are tracked at https://github.com/acme/api/issues/123."] + }), + existingEntries + ); + const reviewed = reviewExtractedMemoryOperations(operations, existingEntries); + + expect(reviewed.operations).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "delete", + topic: "reference", + id: "widgets-issues" + }) + ]) + ); + expect(reviewed.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "upsert", + topic: "reference", + summary: "Issues are tracked at https://github.com/acme/api/issues/123" + }) + ]) + ); + }); + + it("does not collapse same-host issue tracker URLs that use distinct ticket ids", async () => { + const extractor = new HeuristicExtractor(); + const existingEntries: MemoryEntry[] = [ + { + id: "jira-auth-123", + scope: "project", + topic: "reference", + summary: "Issues are tracked at https://jira.example.com/browse/AUTH-123", + details: ["Primary auth issue tracker pointer."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ]; + + const operations = await extractor.extract( + baseEvidence({ + userMessages: ["Issues are tracked at https://jira.example.com/browse/PLAT-456."] + }), + existingEntries + ); + const reviewed = reviewExtractedMemoryOperations(operations, existingEntries); + + expect(reviewed.operations).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "delete", + topic: "reference", + id: "jira-auth-123" + }) + ]) + ); + expect(reviewed.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "upsert", + topic: "reference", + summary: "Issues are tracked at https://jira.example.com/browse/PLAT-456" + }) + ]) + ); + }); + it("does not suppress different issue tracker URLs when both use a generic browse tail", () => { const reviewed = reviewExtractedMemoryOperations( [ From fbef294c7ba7f21dc1c427e4e9af2a8bdc7bda88 Mon Sep 17 00:00:00 2001 From: blocks Date: Sat, 11 Apr 2026 22:33:51 +0800 Subject: [PATCH 60/62] fix: fail closed for invalid integration doctor cwd --- src/lib/integration/mcp-config.ts | 23 ++++++++++++++++++++++- src/lib/integration/mcp-doctor.ts | 6 ++++-- test/dist-cli-smoke.test.ts | 18 ++++++++++++++++++ test/hooks-command.test.ts | 16 +++++++++++++++- test/integrations-command.test.ts | 19 +++++++++++++++++++ test/skills-command.test.ts | 15 +++++++++++++++ test/tarball-install-smoke.test.ts | 13 +++++++++++++ 7 files changed, 106 insertions(+), 4 deletions(-) diff --git a/src/lib/integration/mcp-config.ts b/src/lib/integration/mcp-config.ts index 836167f..54c187c 100644 --- a/src/lib/integration/mcp-config.ts +++ b/src/lib/integration/mcp-config.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import path from "node:path"; import { detectProjectContext } from "../domain/project-context.js"; import { @@ -36,8 +37,28 @@ export interface McpHostConfigSnippet { export { normalizeMcpHost }; +export function resolveMcpProjectCwd(cwd = process.cwd()): string { + if (!cwd.trim()) { + throw new Error("--cwd must be a non-empty path to an existing directory."); + } + + const resolved = path.resolve(cwd); + let stat; + try { + stat = fs.statSync(resolved); + } catch { + throw new Error("--cwd must be a non-empty path to an existing directory."); + } + + if (!stat.isDirectory()) { + throw new Error("--cwd must be a non-empty path to an existing directory."); + } + + return resolved; +} + export function resolveMcpProjectRoot(cwd = process.cwd()): string { - return detectProjectContext(path.resolve(cwd)).projectRoot; + return detectProjectContext(resolveMcpProjectCwd(cwd)).projectRoot; } export function buildMcpHostConfigSnippet(host: McpHost, projectRoot: string): McpHostConfigSnippet { diff --git a/src/lib/integration/mcp-doctor.ts b/src/lib/integration/mcp-doctor.ts index e13ae75..0d38edc 100644 --- a/src/lib/integration/mcp-doctor.ts +++ b/src/lib/integration/mcp-doctor.ts @@ -51,7 +51,7 @@ import { } from "./skills-paths.js"; import { fileExists, readTextFile } from "../util/fs.js"; import { buildRuntimeContext } from "../runtime/runtime-context.js"; -import { resolveMcpProjectRoot } from "./mcp-config.js"; +import { resolveMcpProjectCwd, resolveMcpProjectRoot } from "./mcp-config.js"; import type { RetrievalSidecarCheck } from "../domain/memory-store.js"; import type { MemoryLayoutDiagnostic, TopicFileDiagnostic } from "../types.js"; @@ -1131,7 +1131,9 @@ export async function inspectMcpDoctor(options: { host?: string; explicitCwd?: boolean; } = {}): Promise { - const cwd = await normalizeComparablePath(options.cwd ?? process.cwd()); + const cwd = await normalizeComparablePath( + options.cwd === undefined ? process.cwd() : resolveMcpProjectCwd(options.cwd) + ); const projectRoot = resolveMcpProjectRoot(cwd); const agentsGuidancePath = path.join(projectRoot, "AGENTS.md"); const hostSelection: McpDoctorHostSelection = normalizeMcpDoctorHostSelection(options.host); diff --git a/test/dist-cli-smoke.test.ts b/test/dist-cli-smoke.test.ts index 6a80ebe..bc82547 100644 --- a/test/dist-cli-smoke.test.ts +++ b/test/dist-cli-smoke.test.ts @@ -1694,6 +1694,24 @@ describe("dist cli smoke", () => { }); }); + it("fails closed for invalid --cwd on the compiled integrations doctor entrypoint", async () => { + const homeDir = await tempDir("cam-dist-integrations-doctor-invalid-cwd-home-"); + const projectDir = await tempDir("cam-dist-integrations-doctor-invalid-cwd-project-"); + + for (const cwd of ["", " ", path.join(projectDir, "missing-project")]) { + const result = runCli( + projectDir, + ["integrations", "doctor", "--host", "codex", "--cwd", cwd], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("--cwd must be a non-empty path to an existing directory."); + } + }); + it("routes exec through the compiled wrapper entrypoint", async () => { const repoDir = await tempDir("cam-dist-wrapper-repo-"); const homeDir = await tempDir("cam-dist-wrapper-home-"); diff --git a/test/hooks-command.test.ts b/test/hooks-command.test.ts index 170bb95..976abd9 100644 --- a/test/hooks-command.test.ts +++ b/test/hooks-command.test.ts @@ -48,7 +48,21 @@ afterEach(async () => { }); describe("hooks command", () => { - it("supports --cwd while keeping generated hook helpers reusable across projects", async () => { + it("fails closed when --cwd is empty, whitespace-only, or missing", async () => { + const homeDir = await tempDir("cam-hooks-empty-cwd-home-"); + const projectDir = await tempDir("cam-hooks-empty-cwd-project-"); + process.env.HOME = homeDir; + const missingDir = path.join(projectDir, "missing-project"); + + for (const cwd of ["", " ", missingDir]) { + const result = runCli(projectDir, ["hooks", "install", "--cwd", cwd], { + env: { HOME: homeDir } + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("--cwd must be a non-empty path to an existing directory."); + } + }); + shellOnlyIt("supports --cwd while keeping generated hook helpers reusable across projects", async () => { const homeDir = await tempDir("cam-hooks-cwd-home-"); const projectParentDir = await tempDir("cam-hooks-cwd-parent-"); const projectDir = path.join(projectParentDir, "project with spaces"); diff --git a/test/integrations-command.test.ts b/test/integrations-command.test.ts index 246cbe1..7030a56 100644 --- a/test/integrations-command.test.ts +++ b/test/integrations-command.test.ts @@ -125,6 +125,25 @@ afterEach(async () => { }); describe("integrations command", () => { + it("fails closed when integrations install and doctor use empty, whitespace-only, or missing --cwd", async () => { + const homeDir = await tempDir("cam-integrations-empty-cwd-home-"); + const projectDir = await tempDir("cam-integrations-empty-cwd-project-"); + process.env.HOME = homeDir; + const missingDir = path.join(projectDir, "missing-project"); + + for (const command of [ + ["integrations", "install", "--host", "codex"] as const, + ["integrations", "doctor", "--host", "codex"] as const + ]) { + for (const cwd of ["", " ", missingDir]) { + const result = runCli(projectDir, [...command, "--cwd", cwd], { + env: buildStableCliEnv(homeDir) + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("--cwd must be a non-empty path to an existing directory."); + } + } + }); it("installs the recommended Codex integration stack without creating memory layout", async () => { const homeDir = await tempDir("cam-integrations-home-"); const projectDir = await tempDir("cam-integrations-project-"); diff --git a/test/skills-command.test.ts b/test/skills-command.test.ts index 894035c..158ef36 100644 --- a/test/skills-command.test.ts +++ b/test/skills-command.test.ts @@ -31,6 +31,21 @@ afterEach(async () => { }); describe("skills command", () => { + it("fails closed when --cwd is empty, whitespace-only, or missing", async () => { + const homeDir = await tempDir("cam-skills-empty-cwd-home-"); + const projectDir = await tempDir("cam-skills-empty-cwd-project-"); + process.env.HOME = homeDir; + delete process.env.CODEX_HOME; + const missingDir = path.join(projectDir, "missing-project"); + + for (const cwd of ["", " ", missingDir]) { + const result = runCli(projectDir, ["skills", "install", "--cwd", cwd], { + env: { HOME: homeDir } + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("--cwd must be a non-empty path to an existing directory."); + } + }); it("installs a Codex skill for progressive durable memory retrieval", async () => { const homeDir = await tempDir("cam-skills-home-"); const projectDir = await tempDir("cam-skills-project-"); diff --git a/test/tarball-install-smoke.test.ts b/test/tarball-install-smoke.test.ts index f1c49be..888bd2e 100644 --- a/test/tarball-install-smoke.test.ts +++ b/test/tarball-install-smoke.test.ts @@ -924,6 +924,19 @@ describe("tarball install smoke", () => { } }); + for (const cwd of ["", " ", path.join(realBlockedProjectDir, "missing-project")]) { + const invalidDoctorResult = runCommandCapture( + camBinaryPath(installDir), + ["integrations", "doctor", "--host", "codex", "--cwd", cwd], + blockedProjectDir, + envWithBin + ); + expect(invalidDoctorResult.exitCode).toBe(1); + expect(invalidDoctorResult.stderr).toContain( + "--cwd must be a non-empty path to an existing directory." + ); + } + const recallHelpResult = runCommandCapture( camBinaryPath(installDir), ["recall", "search", "--help"], From 62b7301d3b5cefa00528ee148070dab395e6c83e Mon Sep 17 00:00:00 2001 From: blocks Date: Sat, 11 Apr 2026 22:55:47 +0800 Subject: [PATCH 61/62] fix: keep issue tracker keys repo-aware --- src/lib/extractor/directive-utils.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/extractor/directive-utils.ts b/src/lib/extractor/directive-utils.ts index 351b374..4e5a0fb 100644 --- a/src/lib/extractor/directive-utils.ts +++ b/src/lib/extractor/directive-utils.ts @@ -35,10 +35,6 @@ function resourceTokenFromUrl(url: string, category: string): string | null { .map((segment) => slugify(segment)) .filter(Boolean); const tailToken = [...pathTokens].reverse().find(Boolean); - if (tailToken && !genericReferenceTokens.has(tailToken)) { - return tailToken; - } - if (category === "issue-tracker") { const ticketToken = [...pathTokens].reverse().find((token) => /^[a-z]+-\d+$/iu.test(token) || /^\d+$/u.test(token)) ?? @@ -61,6 +57,10 @@ function resourceTokenFromUrl(url: string, category: string): string | null { return [hostContextToken, contextToken].filter(Boolean).join("-") || category; } + if (tailToken && !genericReferenceTokens.has(tailToken)) { + return tailToken; + } + if (tailToken) { return tailToken; } From 805f319a97725be01aee5676def733abd6bf452b Mon Sep 17 00:00:00 2001 From: blocks Date: Sat, 11 Apr 2026 23:48:18 +0800 Subject: [PATCH 62/62] fix: close issue5 tail closeout blockers --- AGENTS.md | 10 ++-- src/lib/extractor/directive-utils.ts | 3 +- test/dist-cli-smoke.test.ts | 29 ++++++---- test/extractor.test.ts | 87 ++++++++++++++++++++++++++++ test/integrations-command.test.ts | 3 +- test/tarball-install-smoke.test.ts | 27 +++++---- test/vitest-config.test.ts | 1 + 7 files changed, 131 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 96930da..0873a90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,10 +91,11 @@ cam session status 当前优先事项: 1. 继续保持 issue5 stack 的 reviewer contract、help surface、release-facing smoke 一致 -2. 把 Claude Code / Gemini CLI 的官方公开宿主能力面,与本仓当前真实支持的 manual-only host 边界继续写清楚 -3. 保持 `Markdown-first` canonical store 与 sidecar retrieval plane 的边界稳定 -4. 保持 `cam integrations install` 与 `cam integrations apply` 的 AGENTS mutation boundary 清晰 -5. 继续扩大 deterministic release gate:`lint`、`test`、`docs-contract`、`dist-cli-smoke`、`tarball-install-smoke` +2. 收紧 issue-tracker durable memory replacement key,避免不同 host 的 tracker URL 因相同 repo/path 或 ticket id 被误判为同一条 memory +3. 把 Claude Code / Gemini CLI 的官方公开宿主能力面,与本仓当前真实支持的 manual-only host 边界继续写清楚 +4. 保持 `Markdown-first` canonical store 与 sidecar retrieval plane 的边界稳定 +5. 保持 `cam integrations install` 与 `cam integrations apply` 的 AGENTS mutation boundary 清晰 +6. 继续扩大 deterministic release gate:`lint`、`test`、`docs-contract`、`dist-cli-smoke`、`tarball-install-smoke` 下一阶段建议: @@ -106,6 +107,7 @@ cam session status ## 变更记录 +- 2026-04-11: issue5 tail closeout 新增 cross-host issue-tracker 回归保护:`directive-utils` 现在用完整的 non-generic hostname 片段构造 issue-tracker resource key,避免不同 host 但相同 repo/path 或 ticket id 的 URL 互相覆盖;同时补强 `vitest.config.ts` 的默认 exclude contract 测试,并把 `integrations apply --cwd` 的 invalid-path fail-closed 行为纳入 source / dist / tarball 回归覆盖。 - 2026-04-10: issue5 PR14 收口了三类 runtime contract seam:`mcp` 命令的空 `--cwd` 现在 fail-closed;`workflowContract` 的 resolved launcher 与显式 `launcherOverride` 保持一致;delete-only 的 forget follow-up 文案不再硬编码裸 `cam recall timeline`。 - 2026-04-10: `test/recovery-records.test.ts` 已对齐当前 continuity 语义:`scope=both` continuity recovery marker 可以被后续 single-scope save/refresh 复用,并继续由 `session-command` 行为测试锁定。 - 2026-04-10: 新增根级 `AGENTS.md`,补齐仓库级功能说明、命令面、关键 JSON 契约与项目规划。 diff --git a/src/lib/extractor/directive-utils.ts b/src/lib/extractor/directive-utils.ts index 4e5a0fb..142903e 100644 --- a/src/lib/extractor/directive-utils.ts +++ b/src/lib/extractor/directive-utils.ts @@ -51,7 +51,8 @@ function resourceTokenFromUrl(url: string, category: string): string | null { .map((segment) => slugify(segment)) .filter(Boolean); const hostContextToken = - hostTokens.find((token) => !genericHostTokens.has(token)) ?? slugify(parsed.hostname); + hostTokens.filter((token) => !genericHostTokens.has(token)).join("-") || + slugify(parsed.hostname); const contextToken = nonGenericPathTokens.slice(-2).join("-") || ticketToken; return [hostContextToken, contextToken].filter(Boolean).join("-") || category; diff --git a/test/dist-cli-smoke.test.ts b/test/dist-cli-smoke.test.ts index bc82547..faac4ed 100644 --- a/test/dist-cli-smoke.test.ts +++ b/test/dist-cli-smoke.test.ts @@ -1694,21 +1694,26 @@ describe("dist cli smoke", () => { }); }); - it("fails closed for invalid --cwd on the compiled integrations doctor entrypoint", async () => { + it("fails closed for invalid --cwd on compiled integrations entrypoints", async () => { const homeDir = await tempDir("cam-dist-integrations-doctor-invalid-cwd-home-"); const projectDir = await tempDir("cam-dist-integrations-doctor-invalid-cwd-project-"); - for (const cwd of ["", " ", path.join(projectDir, "missing-project")]) { - const result = runCli( - projectDir, - ["integrations", "doctor", "--host", "codex", "--cwd", cwd], - { - entrypoint: "dist", - env: { HOME: homeDir } - } - ); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("--cwd must be a non-empty path to an existing directory."); + for (const command of [ + ["integrations", "apply", "--host", "codex"] as const, + ["integrations", "doctor", "--host", "codex"] as const + ]) { + for (const cwd of ["", " ", path.join(projectDir, "missing-project")]) { + const result = runCli( + projectDir, + [...command, "--cwd", cwd], + { + entrypoint: "dist", + env: { HOME: homeDir } + } + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("--cwd must be a non-empty path to an existing directory."); + } } }); diff --git a/test/extractor.test.ts b/test/extractor.test.ts index 39d87df..dc330c2 100644 --- a/test/extractor.test.ts +++ b/test/extractor.test.ts @@ -1366,6 +1366,93 @@ describe("HeuristicExtractor", () => { ); }); + it("does not collapse cross-host issue tracker URLs that share the same repo path and ticket id", async () => { + const extractor = new HeuristicExtractor(); + const existingEntries: MemoryEntry[] = [ + { + id: "github-foo-widgets-123", + scope: "project", + topic: "reference", + summary: "Issues are tracked at https://github.foo.example.com/acme/widgets/issues/123", + details: ["Primary GitHub Enterprise tracker pointer."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ]; + + const operations = await extractor.extract( + baseEvidence({ + userMessages: [ + "Issues are tracked at https://github.bar.example.com/acme/widgets/issues/123." + ] + }), + existingEntries + ); + const reviewed = reviewExtractedMemoryOperations(operations, existingEntries); + + expect(reviewed.operations).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "delete", + topic: "reference", + id: "github-foo-widgets-123" + }) + ]) + ); + expect(reviewed.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "upsert", + topic: "reference", + summary: + "Issues are tracked at https://github.bar.example.com/acme/widgets/issues/123" + }) + ]) + ); + }); + + it("does not collapse cross-host issue tracker URLs that share the same ticket id behind a generic browse path", async () => { + const extractor = new HeuristicExtractor(); + const existingEntries: MemoryEntry[] = [ + { + id: "jira-foo-auth-123", + scope: "project", + topic: "reference", + summary: "Issues are tracked at https://jira.foo.example.com/browse/AUTH-123", + details: ["Primary Jira issue tracker pointer."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ]; + + const operations = await extractor.extract( + baseEvidence({ + userMessages: ["Issues are tracked at https://jira.bar.example.com/browse/AUTH-123."] + }), + existingEntries + ); + const reviewed = reviewExtractedMemoryOperations(operations, existingEntries); + + expect(reviewed.operations).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "delete", + topic: "reference", + id: "jira-foo-auth-123" + }) + ]) + ); + expect(reviewed.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "upsert", + topic: "reference", + summary: "Issues are tracked at https://jira.bar.example.com/browse/AUTH-123" + }) + ]) + ); + }); + it("does not suppress different issue tracker URLs when both use a generic browse tail", () => { const reviewed = reviewExtractedMemoryOperations( [ diff --git a/test/integrations-command.test.ts b/test/integrations-command.test.ts index 7030a56..7f03e29 100644 --- a/test/integrations-command.test.ts +++ b/test/integrations-command.test.ts @@ -125,7 +125,7 @@ afterEach(async () => { }); describe("integrations command", () => { - it("fails closed when integrations install and doctor use empty, whitespace-only, or missing --cwd", async () => { + it("fails closed when integrations install, apply, and doctor use empty, whitespace-only, or missing --cwd", async () => { const homeDir = await tempDir("cam-integrations-empty-cwd-home-"); const projectDir = await tempDir("cam-integrations-empty-cwd-project-"); process.env.HOME = homeDir; @@ -133,6 +133,7 @@ describe("integrations command", () => { for (const command of [ ["integrations", "install", "--host", "codex"] as const, + ["integrations", "apply", "--host", "codex"] as const, ["integrations", "doctor", "--host", "codex"] as const ]) { for (const cwd of ["", " ", missingDir]) { diff --git a/test/tarball-install-smoke.test.ts b/test/tarball-install-smoke.test.ts index 888bd2e..2484a80 100644 --- a/test/tarball-install-smoke.test.ts +++ b/test/tarball-install-smoke.test.ts @@ -924,17 +924,22 @@ describe("tarball install smoke", () => { } }); - for (const cwd of ["", " ", path.join(realBlockedProjectDir, "missing-project")]) { - const invalidDoctorResult = runCommandCapture( - camBinaryPath(installDir), - ["integrations", "doctor", "--host", "codex", "--cwd", cwd], - blockedProjectDir, - envWithBin - ); - expect(invalidDoctorResult.exitCode).toBe(1); - expect(invalidDoctorResult.stderr).toContain( - "--cwd must be a non-empty path to an existing directory." - ); + for (const command of [ + ["integrations", "apply", "--host", "codex"] as const, + ["integrations", "doctor", "--host", "codex"] as const + ]) { + for (const cwd of ["", " ", path.join(realBlockedProjectDir, "missing-project")]) { + const invalidResult = runCommandCapture( + camBinaryPath(installDir), + [...command, "--cwd", cwd], + blockedProjectDir, + envWithBin + ); + expect(invalidResult.exitCode).toBe(1); + expect(invalidResult.stderr).toContain( + "--cwd must be a non-empty path to an existing directory." + ); + } } const recallHelpResult = runCommandCapture( diff --git a/test/vitest-config.test.ts b/test/vitest-config.test.ts index 3650fdd..9a09d05 100644 --- a/test/vitest-config.test.ts +++ b/test/vitest-config.test.ts @@ -6,6 +6,7 @@ describe("vitest config", () => { it("excludes project-local worktree directories from test discovery", async () => { const configSource = await fs.readFile(path.resolve("vitest.config.ts"), "utf8"); + expect(configSource).toContain("configDefaults.exclude"); expect(configSource).toContain("**/.worktrees/**"); expect(configSource).toContain("**/worktrees/**"); });