diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 53c5ca90..0a28a4c1 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "commitlore", "displayName": "CommitLore", - "version": "1.2.5", + "version": "1.2.6", "description": "Recorded decisions from git history, delivered to the agent before it edits. Constraints, alternatives already ruled out, and warnings left by whoever was here last.", "author": { "name": "MongLong0214", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 268d8063..4f72e395 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "commitlore", - "version": "1.2.5", + "version": "1.2.6", "description": "Decision memory from Git history, with verified capture for coding sessions.", "author": { "name": "MongLong0214", diff --git a/CHANGELOG.md b/CHANGELOG.md index e668d61e..46294571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,85 @@ Release notes for 1.0.0, 1.0.1 and 1.0.2 are on the [GitHub releases page](https://github.com/MongLong0214/commitlore/releases); they were not written here. +## 1.2.6 + +A long session killed the process outright, and an upgrade nobody could see had +already happened. + +**`capture` died with a fatal V8 error on a large transcript (#884).** On a +74,173,844-byte session it exited 133 having written no JSON at all: + +``` +# Fatal error in , line 0 +# Fatal JavaScript invalid size error 134217728 +``` + +Not a graceful refusal — an engine abort. `capture` reports outcomes +structurally so a caller can branch on `staged` / `empty` / `rejected` rather +than on the exit code, and this escaped that contract entirely: a wrapper that +had been careful to read the outcome had nothing to read. Reproduced on 1.2.3 as +well, so it was a standing limit rather than a regression the last release +introduced. + +1.2.3 bounded the *prompt* (#873). It did not bound the guard beside it. +`prepareCaptureContext` handed the whole transcript to the guard advisory on one +line and took a 256 KiB window of it on the next. The guard normalises its +proposal through the anti-injection normaliser, whose `\p{Script=Latin}\p{M}*` +global replace collects one match per letter into a single array; past roughly +69 MB that array crosses V8's 2^27 `FixedArray` ceiling and the process dies +inside `Runtime_RegExpExecMultiple`. + +**The guard now reads the same window the prompt carries.** That is a narrowing, +and it is also the more honest alignment: the advisory is shown beside the +prompt, so an advisory computed over a whole session could warn about a decision +the prompt does not contain. Measured on a 74,450,108-byte transcript: exit 0, +`outcome: staged`, 0.7 s end to end, where before the process aborted. + +**It says when it did so.** A truncated scan must never read as a complete one, +so the advisory carries a `proposal-windowed` gap whenever the window dropped +part of the session — an empty `matches` array is then silence about the window, +not a clean scan of the transcript. + +A note on what is *not* fixed: verification still reads the whole transcript on +purpose, so a quote from outside the window still verifies, and its scan builds +per-character structures over the whole file. That survives 74 MB and would fail +somewhere past ~134 M characters. Bounding it would break the guarantee that a +locator names a line of the file it is checked against. + +**`doctor`'s runtime-mismatch row named versions and stopped (#885).** It +reported three distinct live CommitLore runtimes answering MCP while every +registration on the machine was correct — the old runtimes were live processes +that outlived an upgrade, because a host resolves the launcher once at session +start and holds that runtime for the life of the session. Agent sessions there +ran for days. + +Those runtimes write. Records captured by a session started two days earlier +come from the build that session started on, and nothing on the commit says +which. The row gave an operator no way to tell whether that was cosmetic, and +the scan already knew the process ids and discarded them, so the reporter had to +run `ps` themselves to find the five processes behind the three names. + +The row now names every pid, says plainly that each runtime keeps writing +records with the build it started on, and offers an action: restart the host +sessions that own those pids, because an upgrade cannot reach a process that is +already running. It deliberately does not label any runtime the stale one — a +copied or stale install can report the same version as a current one. + +**`upgrade` reported a release older than the binary printing it (#885, not +separately filed).** On a machine running 1.2.5 it said `latest v1.2.3` and +"this is the newest release". The lookup was fine; the answer is cached for a +day in `~/.cache/commitlore/latest-release.json` and only `upgrade` acting +clears it, so a release installed any other way — `install.sh`, the plugin +marketplace, a manual checkout — leaves yesterday's answer standing. A `latest` +older than the version already running cannot be the latest, and that case now +re-asks. Equality still serves from the cache, or the cache would never serve +the case it exists for. + +Not addressed, and left for its own decision: #885 also asks that the producing +runtime be recorded on the record itself. `CommitLore-Version:` is not that +field — SPEC §8 defines it as the protocol version a record targets — so this +needs a new trailer and a wider decision than a patch release should make. + ## 1.2.5 Two flags on `capture` did nothing and said nothing; a third refusal knew the diff --git a/README.ja.md b/README.ja.md index 1977f407..bc6d10d1 100644 --- a/README.ja.md +++ b/README.ja.md @@ -47,18 +47,18 @@

```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.6/install.sh | sh -s v1.2.6 ```
先にインストーラーを読みたいですか? ```bash -curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh -sh install.sh v1.2.5 +curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.6/install.sh +sh install.sh v1.2.6 # あるいはスクリプトを使わずに。スクリプトが作るチェックアウトは自分でも作れます。 -git clone --depth 1 --branch v1.2.5 https://github.com/MongLong0214/commitlore +git clone --depth 1 --branch v1.2.6 https://github.com/MongLong0214/commitlore node commitlore/dist/commitlore.mjs --version ``` @@ -107,13 +107,13 @@ CommitLore はその判断をコードのそばに残します。 macOS と Linux: ```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.6/install.sh | sh -s v1.2.6 ``` Windows: ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.ps1))) v1.2.5 +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.6/install.ps1))) v1.2.6 ``` Node.js 22.23.2+ と Git が必要です。スクリプトは何かを書き込む前に両方を確認します。 diff --git a/README.ko.md b/README.ko.md index 6b39adb0..95a6dcc5 100644 --- a/README.ko.md +++ b/README.ko.md @@ -47,18 +47,18 @@

```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.6/install.sh | sh -s v1.2.6 ```
먼저 설치기를 읽어 보고 싶나요? ```bash -curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh -sh install.sh v1.2.5 +curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.6/install.sh +sh install.sh v1.2.6 # 또는 스크립트를 건너뜁니다. 스크립트가 만드는 체크아웃은 직접 만들 수 있습니다. -git clone --depth 1 --branch v1.2.5 https://github.com/MongLong0214/commitlore +git clone --depth 1 --branch v1.2.6 https://github.com/MongLong0214/commitlore node commitlore/dist/commitlore.mjs --version ``` @@ -107,13 +107,13 @@ CommitLore는 그 판단을 코드 곁에 보관합니다. macOS와 Linux: ```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.6/install.sh | sh -s v1.2.6 ``` Windows: ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.ps1))) v1.2.5 +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.6/install.ps1))) v1.2.6 ``` Node.js 22.23.2+와 Git이 필요합니다. 스크립트는 무엇이든 쓰기 전에 둘을 확인합니다. diff --git a/README.md b/README.md index 3a242739..326c7468 100644 --- a/README.md +++ b/README.md @@ -48,18 +48,18 @@

```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.6/install.sh | sh -s v1.2.6 ```
Prefer to read the installer first? ```bash -curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh -sh install.sh v1.2.5 +curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.6/install.sh +sh install.sh v1.2.6 # Or skip the script: the checkout it makes is one you can make yourself. -git clone --depth 1 --branch v1.2.5 https://github.com/MongLong0214/commitlore +git clone --depth 1 --branch v1.2.6 https://github.com/MongLong0214/commitlore node commitlore/dist/commitlore.mjs --version ``` @@ -109,13 +109,13 @@ preserve, not for narrating every change. macOS and Linux: ```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.6/install.sh | sh -s v1.2.6 ``` Windows: ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.ps1))) v1.2.5 +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.6/install.ps1))) v1.2.6 ``` Requires Node.js 22.23.2+ and Git. The script checks both before it writes anything. diff --git a/README.zh-CN.md b/README.zh-CN.md index df475c1a..052bc311 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -47,18 +47,18 @@

```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.6/install.sh | sh -s v1.2.6 ```
想先阅读安装器吗? ```bash -curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh -sh install.sh v1.2.5 +curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.6/install.sh +sh install.sh v1.2.6 # 或者跳过脚本:它创建的检出,你自己也能创建。 -git clone --depth 1 --branch v1.2.5 https://github.com/MongLong0214/commitlore +git clone --depth 1 --branch v1.2.6 https://github.com/MongLong0214/commitlore node commitlore/dist/commitlore.mjs --version ``` @@ -105,13 +105,13 @@ CommitLore 把那份判断留在代码旁边。 macOS 和 Linux: ```bash -curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5 +curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.6/install.sh | sh -s v1.2.6 ``` Windows: ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.ps1))) v1.2.5 +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.6/install.ps1))) v1.2.6 ``` 需要 Node.js 22.23.2+ 和 Git。脚本会在写入任何内容前检查两者。 diff --git a/install.ps1 b/install.ps1 index 7d38ee6f..6adf1d27 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,8 +1,8 @@ <# Installs commitlore from source on Windows, for any agent that is not Claude Code. - irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.ps1 | iex - & ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.ps1))) v1.2.5 + irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.6/install.ps1 | iex + & ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.6/install.ps1))) v1.2.6 Claude Code users do not need this script. The repository is itself a plugin marketplace (ADR-0011), so two /plugin commands register the MCP server, the diff --git a/install.sh b/install.sh index c1d78dce..b2949f8a 100755 --- a/install.sh +++ b/install.sh @@ -1,8 +1,8 @@ #!/bin/sh # Installs commitlore from source, for any agent that is not Claude Code. # -# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5 +# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.6/install.sh | sh +# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.6/install.sh | sh -s v1.2.6 # # **Claude Code users do not need this script.** The repository is itself a # plugin marketplace (ADR-0011), so two `/plugin` commands register the MCP diff --git a/package-lock.json b/package-lock.json index afc2d547..8ad5d222 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "commitlore", - "version": "1.2.5", + "version": "1.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "commitlore", - "version": "1.2.5", + "version": "1.2.6", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/package.json b/package.json index aaccc9ff..4725b8b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "commitlore", - "version": "1.2.5", + "version": "1.2.6", "description": "Git-native, lifecycle-aware decision memory for coding agents", "license": "MIT", "private": true, diff --git a/server.json b/server.json index 19048891..8582477b 100644 --- a/server.json +++ b/server.json @@ -8,7 +8,7 @@ "source": "github" }, "websiteUrl": "https://github.com/MongLong0214/commitlore#readme", - "version": "1.2.5", + "version": "1.2.6", "_meta": { "io.modelcontextprotocol.registry/publisher-provided": { "registryFit": "Distribution is a tagged git checkout plus a Claude Code plugin marketplace (ADR-0011 registry-free git distribution, ADR-0026 no compiled executables and no uploaded release asset), so no official package type applies and this record relies on websiteUrl plus publisher metadata.", @@ -18,8 +18,8 @@ "/plugin marketplace add MongLong0214/commitlore", "/plugin install commitlore@commitlore" ], - "installer": "curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5", - "release": "https://github.com/MongLong0214/commitlore/releases/tag/v1.2.5" + "installer": "curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.6/install.sh | sh -s v1.2.6", + "release": "https://github.com/MongLong0214/commitlore/releases/tag/v1.2.6" }, "runtime": { "transport": "stdio", diff --git a/src/commands/doctor/checks/delivery-mcp-runtime-identity.ts b/src/commands/doctor/checks/delivery-mcp-runtime-identity.ts index 81800c3c..ed43c4b2 100644 --- a/src/commands/doctor/checks/delivery-mcp-runtime-identity.ts +++ b/src/commands/doctor/checks/delivery-mcp-runtime-identity.ts @@ -22,6 +22,28 @@ import { check, type Category, type DoctorCheck, type DoctorContext } from '../m const identityOf = (runtime: LiveMcpRuntime): string => `${runtime.entrypointRealpath} (root ${runtime.packageRoot})`; +/** + * Group the scan by identity, keeping every pid rather than the first (#885). + * + * The scan already knows each process id and this row used to drop all of them + * on the way to a deduplicated identity string. That left an operator told three + * runtimes were answering and given nothing to act on — the reporter had to run + * `ps` themselves to find the five processes behind those three names. + */ +const pidsByIdentity = (runtimes: readonly LiveMcpRuntime[]): Map => { + const grouped = new Map(); + for (const runtime of runtimes) { + const key = identityOf(runtime); + const pids = grouped.get(key); + if (pids === undefined) grouped.set(key, [runtime.pid]); + else pids.push(runtime.pid); + } + return grouped; +}; + +const withPids = (identity: string, pids: readonly number[]): string => + `${identity} pid ${pids.join(', ')}`; + const missingAssets = (runtime: LiveMcpRuntime): string[] => [ ...(runtime.bundlePresent ? [] : ['dist/commitlore.mjs']), ...(runtime.specPresent ? [] : ['spec/SPEC.md']), @@ -86,14 +108,29 @@ export const checkMcpRuntimeIdentity = (ctx: DoctorContext): DoctorCheck => { const identities = [...new Map(scan.runtimes.map((runtime) => [identityOf(runtime), runtime])).values()]; if (identities.length > 1) { + const grouped = pidsByIdentity(scan.runtimes); + const allPids = scan.runtimes.map((runtime) => runtime.pid); return check( id, category, title, 'warn', `${identities.length} distinct live CommitLore runtimes are answering MCP — runtime mismatch: ` + - identities.map(identityOf).join('; '), - null, + identities + .map((runtime) => withPids(identityOf(runtime), grouped.get(identityOf(runtime)) ?? [])) + .join('; ') + + // #885: the row named versions and stopped, so an operator could not tell + // whether it was cosmetic. These runtimes write. Each answers with the + // build it started on, so records committed in one repository on one day + // can come from more than one of them, and nothing on the commit says + // which. Deliberately does not name one of them as the stale one: + // r-liveruntime660 ruled that out, because a copied or stale install can + // report the same version as a current one. + '. Each keeps writing records with the build it started on, so this' + + ' repository can receive records from more than one of them', + 'restart the host sessions that own these pids so every session answers from one install' + + ` (${allPids.join(', ')}) — a host resolves the launcher once at session start and holds` + + ' that runtime until the session ends, so an upgrade does not reach a session already running', false, // Machine state, not this repository's -- see the note above. false, @@ -103,6 +140,7 @@ export const checkMcpRuntimeIdentity = (ctx: DoctorContext): DoctorCheck => { runtime_count: String(scan.runtimes.length), distinct_identities: String(identities.length), package_roots: identities.map((runtime) => runtime.packageRoot).join(', '), + pids: allPids.join(', '), }, }, ); diff --git a/src/commands/update.ts b/src/commands/update.ts index f3fee887..a914addb 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -32,7 +32,12 @@ import { join } from 'node:path'; import type { Command } from 'commander'; -import { latestRelease, sourceUrl, type CheckOutcome } from '../core/latest-release.js'; +import { + forgetCachedRelease, + latestRelease, + sourceUrl, + type CheckOutcome, +} from '../core/latest-release.js'; import { packageVersion, readInstalledFile } from '../core/paths.js'; import { isNewerRelease } from '../core/release-version.js'; @@ -87,7 +92,25 @@ export const buildReport = async ( env: NodeJS.ProcessEnv = process.env, ): Promise => { const current = packageVersion(); - const { outcome, checkedAt } = await latestRelease({ env }); + let result = await latestRelease({ env }); + // A "latest" older than the version already running cannot be the latest + // (#885). The answer is cached for a day and only `upgrade` acting clears it, + // so a release installed any other way -- install.sh, the plugin marketplace, + // a manual checkout -- leaves the previous answer standing, and `upgrade` + // then reports an older tag as `latest` and says "this is the newest + // release". Reported against 1.2.5 while the cache still held v1.2.3. + // + // Only strictly-older re-asks. Equal is the ordinary up-to-date answer and + // must stay cached, or the cache would never serve the case it exists for. + if ( + result.cached && + result.outcome.kind === 'resolved' && + isNewerRelease(`v${current}`, result.outcome.tag) + ) { + forgetCachedRelease(env['HOME']); + result = await latestRelease({ env }); + } + const { outcome, checkedAt } = result; const latest = outcome.kind === 'resolved' ? outcome.tag : null; const unknown = describe(outcome); return { diff --git a/src/core/capture-prepare.ts b/src/core/capture-prepare.ts index 6045de63..32e4f9ef 100644 --- a/src/core/capture-prepare.ts +++ b/src/core/capture-prepare.ts @@ -10,7 +10,7 @@ import { createHash, randomBytes } from 'node:crypto'; import { markCaptureError } from './capture-outcome.js'; import { execGitOrThrow } from './git.js'; import { guard, renderGuardMatch, type GuardResult } from './guard.js'; -import { buildHarvestPromptWithWindow, type TranscriptWindow } from './harvest.js'; +import { windowTranscript, buildHarvestPromptWithWindow, type TranscriptWindow } from './harvest.js'; import { policySourceLabel, resolvePolicy } from './capture-policy.js'; import { createPending, @@ -59,9 +59,24 @@ const deriveGuardGaps = (result: GuardResult): GuardGap[] => { /** * Compute the guard advisory for a capture. Never throws — any error becomes * a recorded gap. The capture must always succeed regardless of guard outcome. + * + * "Never throws" was not enough (#884). This was handed the *whole* transcript + * while the prompt beside it was already windowed, and `guard` normalises its + * proposal through `normalizeForMatch` — whose `\p{Script=Latin}\p{M}*` global + * replace collects one match per letter. On a 74 MB session that array passed + * V8's 2^27 FixedArray ceiling and the process died inside + * `Runtime_RegExpExecMultiple` with `invalid size error 134217728`, exit 133, + * before a byte of JSON was written. A fatal engine abort is not catchable, so + * the try/catch below could not honour its own contract; the input had to stop + * being unbounded instead. + * + * `proposalTruncated` records that the advisory saw the window rather than the + * session, so an empty `matches` array is never mistaken for a clean scan of + * the whole transcript. */ const computeGuardAdvisory = (opts: { proposal: string; + proposalTruncated: boolean; paths: readonly string[]; cwd: string; readOnly?: boolean; @@ -81,16 +96,20 @@ const computeGuardAdvisory = (opts: { ? {} : { trustedSignerFingerprints: opts.trustedSignerFingerprints }), }); + const gaps = deriveGuardGaps(result); + if (opts.proposalTruncated) gaps.push('proposal-windowed'); return { matches: result.matches.map(renderGuardMatch), - gaps: deriveGuardGaps(result), + gaps, disclosure: GUARD_DISCLOSURE, }; } catch { // Guard failure degrades to a recorded gap — never a capture failure return { matches: [], - gaps: ['history-unavailable'], + gaps: opts.proposalTruncated + ? ['history-unavailable', 'proposal-windowed'] + : ['history-unavailable'], disclosure: GUARD_DISCLOSURE, }; } @@ -242,10 +261,16 @@ const prepareValues = (opts: { } const diffPaths = extractPathsFromDiff(diff); + // Windowed once, then used for both the advisory and the prompt. The guard + // reads the same bytes the model is shown: an advisory computed over the + // whole session could warn about a decision that is not in the prompt at all, + // and reading the whole session is what killed the process in #884. + const windowed = windowTranscript(transcript); const advisory = opts.skipGuard === true ? null : computeGuardAdvisory({ - proposal: transcript, + proposal: windowed.text, + proposalTruncated: windowed.window.truncated, paths: diffPaths, cwd, ...(opts.readOnly ? { readOnly: true } : {}), @@ -256,7 +281,7 @@ const prepareValues = (opts: { : { trustedSignerFingerprints: opts.trustedSignerFingerprints }), }); - const harvest = buildHarvestPromptWithWindow({ transcript, diff }); + const harvest = buildHarvestPromptWithWindow({ transcript, diff }, windowed); return { base_head: baseHead, diff --git a/src/core/harvest.ts b/src/core/harvest.ts index b07552d6..d23b1a06 100644 --- a/src/core/harvest.ts +++ b/src/core/harvest.ts @@ -565,10 +565,17 @@ export const buildHarvestContract = (): string => { */ export const buildHarvestPromptWithWindow = ( input: HarvestInput, + /** + * A window the caller already computed. `windowTranscript` splits the whole + * transcript to find its tail, so a caller that needs the window for its own + * reasons — capture hands the same bytes to the guard (#884) — passes it back + * rather than paying for a second split of a session that can be tens of MB. + */ + precomputed?: { text: string; window: TranscriptWindow }, ): { prompt: string; window: TranscriptWindow } => { const entries = loadVocabulary().filter((entry) => entry.key !== 'Verified'); const diff = input.diff.trim() === '' ? '(no diff)' : input.diff.replace(/\n+$/, ''); - const { text, window } = windowTranscript(input.transcript); + const { text, window } = precomputed ?? windowTranscript(input.transcript); const prompt = [ '# CommitLore harvest', diff --git a/src/core/pending.ts b/src/core/pending.ts index 0d6b0be0..bb5a0d57 100644 --- a/src/core/pending.ts +++ b/src/core/pending.ts @@ -20,8 +20,18 @@ import { isFullObjectId } from './types.js'; // Types // --------------------------------------------------------------------------- -/** The three verification gaps, in canonical order (T-1024's closed vocabulary). */ -export type GuardGap = 'history-unavailable' | 'shallow-history' | 'notes-unfetched'; +/** + * The verification gaps, in canonical order (T-1024's closed vocabulary). + * + * `proposal-windowed` (#884) says the advisory read the same bounded window the + * prompt carries rather than the whole session, so an empty `matches` array is + * silence about the window and not about the transcript. + */ +export type GuardGap = + | 'history-unavailable' + | 'shallow-history' + | 'notes-unfetched' + | 'proposal-windowed'; export interface GuardAdvisory { matches: RenderedGuardMatch[]; diff --git a/test/capture-prompt-budget.test.ts b/test/capture-prompt-budget.test.ts index 87c69d50..167bd042 100644 --- a/test/capture-prompt-budget.test.ts +++ b/test/capture-prompt-budget.test.ts @@ -228,3 +228,110 @@ describe('#873 capture returns a prompt a model can read, and says what it is', expect(verified.accepted).toHaveLength(1); }); }); + +/** + * #884: the prompt was bounded and the guard beside it was not. + * + * `prepareValues` handed `computeGuardAdvisory` the *whole* transcript while + * `buildHarvestPromptWithWindow` on the next line took a 256 KiB window of it. + * `guard` normalises its proposal through `normalizeForMatch`, whose + * `\p{Script=Latin}\p{M}*` global replace collects one match per letter into a + * single array; on a 74,173,844-byte session that array crossed V8's 2^27 + * FixedArray ceiling and the process aborted with + * `Fatal JavaScript invalid size error 134217728`, exit 133, before a byte of + * JSON was written. Measured on both 1.2.5 and 1.2.3, so it was a standing + * limit rather than a regression, and the native stack named + * `Runtime_RegExpExecMultiple` as the frame that died. + * + * `computeGuardAdvisory` documents that it never throws and degrades to a gap. + * A fatal engine abort is not catchable, so that contract could not hold while + * the input was unbounded — the input had to stop being unbounded. + * + * The two properties below are what hold it: + * + * 1. The guard reads the window, not the session. Asserted by where a reviving + * phrase sits rather than by any size, because a test that only counted + * bytes would pass against the old code too. + * 2. It says so. Silence about a window must not read as a clean scan of the + * whole transcript. + */ +describe('#884 the guard advisory reads the same window the prompt carries', () => { + const RULED_OUT = 'Use shared Redis cache for sessions'; + const REVIVES = 'We should use a shared Redis cache for sessions to share state across replicas.'; + + /** A repo whose history rules out an alternative the transcript can revive. */ + const makeRepoWithRuledOut = (): string => { + const dir = mkdtempSync(join(tmpdir(), 'capture-guard-window-')); + scratch.push(dir); + execSync('git init --quiet --initial-branch=main', { cwd: dir }); + execSync('git config user.name "Test"', { cwd: dir }); + execSync('git config user.email "test@test.com"', { cwd: dir }); + execSync('git config commit.gpgsign false', { cwd: dir }); + writeFileSync(join(dir, 'a.txt'), 'hello\n'); + execSync('git add a.txt', { cwd: dir }); + execSync( + `git commit -m "feat: sessions\n\nRuled-out: ${RULED_OUT} | race condition under failover\nRecord-Id: r-window884" --no-verify --quiet`, + { cwd: dir }, + ); + writeFileSync(join(dir, 'a.txt'), 'hello\nworld\n'); + execSync('git add a.txt', { cwd: dir }); + return dir; + }; + + const withBudget = (bytes: string, body: () => T): T => { + const previous = process.env['COMMITLORE_TRANSCRIPT_BUDGET_BYTES']; + process.env['COMMITLORE_TRANSCRIPT_BUDGET_BYTES'] = bytes; + try { + return body(); + } finally { + if (previous === undefined) delete process.env['COMMITLORE_TRANSCRIPT_BUDGET_BYTES']; + else process.env['COMMITLORE_TRANSCRIPT_BUDGET_BYTES'] = previous; + } + }; + + it('does not match a ruled-out alternative that only appears outside the window', () => { + const cwd = makeRepoWithRuledOut(); + // The reviving sentence is line 1, and the window keeps only the tail. + const transcript = `${REVIVES}\n${transcriptOf(4000)}`; + + const prepared = withBudget('2048', () => prepareCaptureContext({ cwd, transcript })); + + expect(prepared.transcript_window.truncated).toBe(true); + expect(prepared.transcript_window.first_line).toBeGreaterThan(1); + // Reading the whole transcript is what this used to do, and it is what + // killed the process. Matching here means the guard saw line 1. + expect(prepared.guard_advisory!.matches).toHaveLength(0); + }); + + it('still matches the same alternative when it appears inside the window', () => { + const cwd = makeRepoWithRuledOut(); + // Same repo, same phrase, same budget — only its position changes. Without + // this the fix could be "the guard never matches anything" and pass above. + const transcript = `${transcriptOf(4000)}\n${REVIVES}`; + + const prepared = withBudget('2048', () => prepareCaptureContext({ cwd, transcript })); + + expect(prepared.transcript_window.truncated).toBe(true); + expect(prepared.guard_advisory!.matches.length).toBeGreaterThanOrEqual(1); + }); + + it('records proposal-windowed so an empty match list is not read as a clean scan', () => { + const cwd = makeRepoWithRuledOut(); + const transcript = `${REVIVES}\n${transcriptOf(4000)}`; + + const prepared = withBudget('2048', () => prepareCaptureContext({ cwd, transcript })); + + expect(prepared.guard_advisory!.gaps).toContain('proposal-windowed'); + }); + + it('does not claim a gap when the whole session fitted', () => { + const cwd = makeRepoWithRuledOut(); + + const prepared = prepareCaptureContext({ cwd, transcript: REVIVES }); + + expect(prepared.transcript_window.truncated).toBe(false); + expect(prepared.guard_advisory!.gaps).not.toContain('proposal-windowed'); + // And the advisory still works on the unwindowed path. + expect(prepared.guard_advisory!.matches.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/test/runtime-identity-action.test.ts b/test/runtime-identity-action.test.ts new file mode 100644 index 00000000..73e8b099 --- /dev/null +++ b/test/runtime-identity-action.test.ts @@ -0,0 +1,118 @@ +/** + * #885: the row named versions and stopped there. + * + * `doctor` reported three distinct live runtimes answering MCP. It was right, + * and every registration on the machine was correct — the old runtimes were + * live processes that outlived the upgrade, because a host resolves the + * launcher once at session start and holds that runtime for as long as the + * session lives. Agent sessions there ran for days. + * + * What made it costly is that these runtimes *write*. Records captured by a + * session started two days earlier are produced by the build that session + * started on, while the operator believes the repository is on the release they + * installed, and nothing on the commit says which runtime produced it. The row + * named three versions, offered no action, and gave an operator no way to tell + * whether that was cosmetic or whether half their records came from old code. + * + * The scan already knew the pids and dropped them on the way to a deduplicated + * identity string, so the reporter had to run `ps` themselves to find the five + * processes behind the three names. + * + * What this must NOT do: name one runtime as the stale one. r-liveruntime660 + * ruled that out — a copied or stale install can report the same version as a + * current one, so a version comparison here proves nothing about identity. + */ + +import { describe, expect, it } from 'vitest'; + +import { checkMcpRuntimeIdentity } from '../src/commands/doctor/checks/delivery-mcp-runtime-identity.js'; +import type { DoctorContext } from '../src/commands/doctor/model.js'; +import type { LiveMcpRuntime, LiveMcpScan } from '../src/core/mcp-probe.js'; + +const runtime = (root: string, pid: number): LiveMcpRuntime => ({ + pid, + entrypointRealpath: `${root}/dist/commitlore.mjs`, + packageRoot: root, + bundlePresent: true, + specPresent: true, +}); + +const contextWith = (scan: LiveMcpScan): DoctorContext => + ({ liveMcpRuntimes: () => scan }) as unknown as DoctorContext; + +/** The reporter's machine: five processes, three distinct runtimes. */ +const reported = (): LiveMcpScan => ({ + available: true, + detail: 'process list', + runtimes: [ + runtime('/data/v1.2.5', 13359), + runtime('/data/v1.2.3', 19075), + runtime('/data/v1.2.3', 23251), + runtime('/data/v1.2.0', 47396), + runtime('/data/v1.2.0', 4869), + ], +}); + +describe('#885 the runtime-mismatch row carries the pids and an action', () => { + it('names every pid, including two processes sharing one identity', () => { + const row = checkMcpRuntimeIdentity(contextWith(reported())); + + expect(row.status).toBe('warn'); + for (const pid of [13359, 19075, 23251, 47396, 4869]) { + expect(row.detail, `pid ${pid} is not in the row`).toContain(String(pid)); + } + // Three identities behind five processes -- the count must still describe + // distinct runtimes, not the process total, or "3 distinct" becomes a lie. + expect(row.detail).toMatch(/^3 distinct live CommitLore runtimes/); + }); + + it('says plainly that the older runtimes are still writing records', () => { + const row = checkMcpRuntimeIdentity(contextWith(reported())); + + // The operator's actual question was whether this is cosmetic. + expect(row.detail).toMatch(/writing records/); + }); + + it('offers an action naming the pids to restart', () => { + const row = checkMcpRuntimeIdentity(contextWith(reported())); + + expect(row.fix, 'the row offered no action at all').not.toBeNull(); + expect(row.fix).toContain('13359'); + // Why restarting is the action and reinstalling is not: the upgrade already + // happened, and it cannot reach a process that is already running. + expect(row.fix).toMatch(/session/); + }); + + it('puts the pids in evidence, where a machine reader can use them', () => { + const row = checkMcpRuntimeIdentity(contextWith(reported())); + + expect(row.evidence['pids']).toBe('13359, 19075, 23251, 47396, 4869'); + expect(row.evidence['distinct_identities']).toBe('3'); + expect(row.evidence['runtime_count']).toBe('5'); + }); + + it('does not declare which runtime is the stale one', () => { + const row = checkMcpRuntimeIdentity(contextWith(reported())); + + // r-liveruntime660: a copied or stale install can report the same version + // as a current one. The row reports what is running; it does not rank them. + expect(`${row.detail} ${row.fix ?? ''}`).not.toMatch(/\b(stale|outdated|obsolete)\b/i); + }); + + it('still does not claim attention — this is the machine, not the checkout', () => { + const row = checkMcpRuntimeIdentity(contextWith(reported())); + + // #750: `init` treats a check needing attention as a step that did not + // complete, and a leftover server on a developer's machine is not that. + expect(row.needsAttention).toBe(false); + }); + + it('says nothing about pids or restarting when one runtime answers', () => { + const row = checkMcpRuntimeIdentity( + contextWith({ available: true, detail: 'process list', runtimes: [runtime('/data/v1.2.5', 13359)] }), + ); + + expect(row.status).toBe('ok'); + expect(row.fix).toBeNull(); + }); +}); diff --git a/test/update-command.test.ts b/test/update-command.test.ts index 2b75e2bf..70240808 100644 --- a/test/update-command.test.ts +++ b/test/update-command.test.ts @@ -16,6 +16,7 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { buildReport, installCommand } from '../src/commands/update.js'; +import { packageVersion } from '../src/core/paths.js'; const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const scratch = (label: string): string => mkdtempSync(join(tmpdir(), `cl-upgrade-${label}-`)); @@ -147,3 +148,61 @@ describe('T-1603 ADR-0037 is enforced, not described', () => { for (const call of calls) expect(call.startsWith('ls-remote')).toBe(true); }); }); + +/** + * #885 (incidental): `upgrade` reported `latest v1.2.3` on a machine running + * 1.2.5, and added "this is the newest release". + * + * The answer is cached in `~/.cache/commitlore/latest-release.json` for a day, + * and only `upgrade` acting calls `forgetCachedRelease`. A release installed any + * other way — `install.sh`, the plugin marketplace, a manual checkout — leaves + * yesterday's answer standing, and the command then reports a tag older than the + * binary printing it. Nothing was wrong with the lookup; the cache had simply + * outlived the fact. + * + * A `latest` older than the version already running cannot be the latest, and + * that is the whole rule. Equality still serves from the cache, or the cache + * would never serve the case it exists for. + */ +describe('#885 a cached latest older than the running version is re-asked', () => { + it('re-asks rather than reporting a tag older than the binary printing it', async () => { + const home = scratch('home-stale'); + + // Yesterday: the newest tag really was older than what is installed now. + const primed = await buildReport({ + COMMITLORE_INSTALL_SOURCE: remoteWithTags(['v0.0.1']), + HOME: home, + }); + expect(primed.latest, 'the cache was not primed').toBe('v0.0.1'); + + // Today: a newer release exists, and the day-long cache still holds v0.0.1. + const report = await buildReport({ + COMMITLORE_INSTALL_SOURCE: remoteWithTags(['v0.0.1', 'v99.0.0']), + HOME: home, + }); + + expect(report.latest).toBe('v99.0.0'); + expect(report.updateAvailable).toBe(true); + }); + + it('still serves the cache when it agrees with the running version', async () => { + const home = scratch('home-current'); + const current = `v${packageVersion()}`; + + const primed = await buildReport({ + COMMITLORE_INSTALL_SOURCE: remoteWithTags([current]), + HOME: home, + }); + expect(primed.latest).toBe(current); + + // The remote has moved, but "up to date" is exactly the answer the cache + // exists to hold. Re-asking here would make the cache decorative. + const report = await buildReport({ + COMMITLORE_INSTALL_SOURCE: remoteWithTags([current, 'v99.0.0']), + HOME: home, + }); + + expect(report.latest).toBe(current); + expect(report.updateAvailable).toBe(false); + }); +});