From 2ef59c2d6a61d3366b981bd81ccae558f8a362bd Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 8 Sep 2026 03:38:43 +0000
Subject: [PATCH 1/2] fix(capture): bound the transcript the prompt carries
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The prompt embedded the transcript whole, so the prompt was the session. On the
reporter's machine a 67,981,436-byte transcript produced a 67,468,122-byte
prompt — 99.3% of the file, and larger than any model can read, so the capture
pipeline could not be completed and no record was ever written. The pipeline
itself was fine: `tail -n 400` on the same transcript gave 537,250 bytes and the
same command worked.
That made it worse than a size. Prompt-only mode reports `outcome: "empty"`,
`staged: false`, exit 0, so an operator who tried capture once on a real session
got a prompt they could not use and no statement that anything was wrong. In the
repository where it was measured, `stale` reported 1 record in 1000 commits with
unattended capture already enabled — permission was never the obstacle.
The prompt now carries the end of the transcript within a byte budget, 256 KiB
by default and `COMMITLORE_TRANSCRIPT_BUDGET_BYTES` to change it. The end rather
than the beginning: a decision is taken near the end of the session that
implements it, and the diff being captured is that end.
Three things the bound does not do. It does not renumber: the window's lines
keep the numbers they have in the whole transcript, because verification reads
the whole transcript and a locator renumbered from 1 would name a different line
of the file it is checked against. It does not reach the hash: `source_hashes`
and every quote check are still over the whole transcript, so a quote from
outside the window still verifies and a caller passing the session it actually
had is never told the transcript was substituted. And it does not stay quiet —
the prompt says which lines it is showing and how many were left out, and
`transcript_window` says the same to `capture --json` and to
`commitlore_prepare_capture`. A bounded prompt that did not say so would be the
old silence in a smaller package.
One line of a JSONL transcript can hold an entire tool result and outrun the
budget by itself. That line is shown from its end rather than dropped, and the
window says so, because a window of no lines is worse than a window of one
partial line. The byte slice never leaves a split codepoint at the front: a
replacement character inside a quotable line is a character nobody can copy back.
Where the boundary should be is not claimed. 256 KiB is comfortably readable by
current models and carries far more than the ~537 KB slice measured to work; the
reporter said they had not measured where the useful boundary is, and neither
has this.
Closes #873
Claude-Session: https://claude.ai/code/session_01USc9G3aJ1s8pnhWy5K5hLr
Co-Authored-By: Claude Opus 5
---
src/commands/capture.ts | 16 +-
src/core/capture-prepare.ts | 17 ++-
src/core/harvest.ts | 175 ++++++++++++++++++++--
src/mcp/server.ts | 10 +-
test/capture-prompt-budget.test.ts | 230 +++++++++++++++++++++++++++++
5 files changed, 433 insertions(+), 15 deletions(-)
create mode 100644 test/capture-prompt-budget.test.ts
diff --git a/src/commands/capture.ts b/src/commands/capture.ts
index f7e8de75..90e70de9 100644
--- a/src/commands/capture.ts
+++ b/src/commands/capture.ts
@@ -39,7 +39,7 @@ import {
configuredTrustedSignerFingerprints,
configuredTrustedAuthors,
} from '../core/trusted-authors.js';
-import { parseDraft } from '../core/harvest.js';
+import { parseDraft, type TranscriptWindow } from '../core/harvest.js';
import { gcPending } from '../core/pending-gc.js';
import type { GuardAdvisory } from '../core/pending.js';
@@ -80,6 +80,13 @@ export interface CaptureResult {
nonce: string | null;
staged: boolean;
prompt?: string;
+ /**
+ * What of the transcript `prompt` carries, present whenever `prompt` is
+ * (#873). The prompt is bounded; verification is not — it reads the whole
+ * transcript. Without this a caller could not tell a slice from the session,
+ * and the prompt was previously the session, at whatever size that was.
+ */
+ transcript_window?: TranscriptWindow;
guard_advisory?: GuardAdvisory | null;
/**
* Every reason a record was refused (#309). Both sources are included: the
@@ -238,6 +245,7 @@ const runCapturePipeline = (opts: {
nonce: null,
staged: false,
prompt: prepareResult.prompt,
+ transcript_window: prepareResult.transcript_window,
guard_advisory: prepareResult.guard_advisory,
};
}
@@ -383,7 +391,11 @@ export const register = (program: Command): void => {
// when a subcommand was invoked, so `capture gc` — which needs no transcript
// — would fail before its own action ran. The requirement is enforced in the
// action below instead, where it applies only to the capture flow itself.
- .option('--transcript ', 'path to the session transcript file')
+ .option(
+ '--transcript ',
+ 'path to the session transcript file (the prompt carries its last 256 KiB; ' +
+ 'COMMITLORE_TRANSCRIPT_BUDGET_BYTES changes that, and verification always reads all of it)',
+ )
.option('--diff ', 'path to the diff file (defaults to the staged diff)')
.option('--draft ', 'path to the draft JSON file (omit for prompt-only mode)')
.option('--out ', 'write the pending nonce to a file')
diff --git a/src/core/capture-prepare.ts b/src/core/capture-prepare.ts
index da1f1f82..6045de63 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 { buildHarvestPrompt } from './harvest.js';
+import { buildHarvestPromptWithWindow, type TranscriptWindow } from './harvest.js';
import { policySourceLabel, resolvePolicy } from './capture-policy.js';
import {
createPending,
@@ -136,6 +136,13 @@ export interface PrepareResult {
policy_identity_hash: string;
source_hashes: { transcript: string; diff: string };
prompt: string;
+ /**
+ * What of the transcript the prompt carries (#873). The prompt is bounded;
+ * `source_hashes.transcript` and every verification are not — they are over
+ * the whole transcript. A caller that assumed the prompt was the session had
+ * no way to tell, so this says it.
+ */
+ transcript_window: TranscriptWindow;
guard_advisory: GuardAdvisory | null;
/**
* A named reason when a policy file exists but could not be used (T-1110).
@@ -158,6 +165,7 @@ interface PreparedValues {
policy_identity_hash: string;
source_hashes: { transcript: string; diff: string };
prompt: string;
+ transcript_window: TranscriptWindow;
guard_advisory: GuardAdvisory | null;
policy_error: string | null;
}
@@ -248,13 +256,16 @@ const prepareValues = (opts: {
: { trustedSignerFingerprints: opts.trustedSignerFingerprints }),
});
+ const harvest = buildHarvestPromptWithWindow({ transcript, diff });
+
return {
base_head: baseHead,
staged_diff_hash: stagedDiffHash,
staged_tree_oid: stagedTreeOid,
policy_identity_hash: policy.identityHash,
source_hashes: sourceHashes,
- prompt: buildHarvestPrompt({ transcript, diff }),
+ prompt: harvest.prompt,
+ transcript_window: harvest.window,
guard_advisory: advisory,
policy_error: policy.error,
};
@@ -297,6 +308,7 @@ export const prepareCaptureContext = (opts: PrepareCaptureOptions): PrepareResul
policy_identity_hash: prepared.policy_identity_hash,
source_hashes: prepared.source_hashes,
prompt: prepared.prompt,
+ transcript_window: prepared.transcript_window,
policy_error: prepared.policy_error,
guard_advisory: prepared.guard_advisory,
};
@@ -335,6 +347,7 @@ export const prepareCaptureContextReadOnly = (opts: PrepareCaptureOptions & {
policy_identity_hash: prepared.policy_identity_hash,
source_hashes: prepared.source_hashes,
prompt: prepared.prompt,
+ transcript_window: prepared.transcript_window,
policy_error: prepared.policy_error,
guard_advisory: prepared.guard_advisory,
pending,
diff --git a/src/core/harvest.ts b/src/core/harvest.ts
index dbc51c1a..b07552d6 100644
--- a/src/core/harvest.ts
+++ b/src/core/harvest.ts
@@ -332,12 +332,156 @@ const vocabularyBlock = (entries: VocabularyEntry[]): string[] => {
return lines;
};
-/** Line-numbered so a citation can name a range the verifier can find again. */
-const numberLines = (text: string): string => {
+/**
+ * Line-numbered so a citation can name a range the verifier can find again.
+ *
+ * `firstLine` is the number the first line of `text` carries in the whole
+ * transcript, which is 1 unless the prompt is showing a window of it. The
+ * numbers have to be the transcript's own: verification reads the whole
+ * transcript, and a locator renumbered to a window would name a different line
+ * of the file it is checked against (#873).
+ */
+const numberLines = (text: string, firstLine = 1): string => {
const lines = text.split('\n');
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
- const width = String(lines.length).length;
- return lines.map((line, index) => `${String(index + 1).padStart(width)} | ${line}`).join('\n');
+ const width = String(firstLine + lines.length - 1).length;
+ return lines
+ .map((line, index) => `${String(firstLine + index).padStart(width)} | ${line}`)
+ .join('\n');
+};
+
+/**
+ * How much transcript the prompt may carry.
+ *
+ * #873: the prompt embedded the transcript whole, so a 67,981,436-byte session
+ * produced a 67,468,122-byte prompt — larger than any model can read, which
+ * makes the capture pipeline unusable exactly on the long sessions that have
+ * the most to record. The reporter measured the pipeline working again at
+ * roughly half a megabyte and said, correctly, that they had not measured where
+ * the useful boundary is. Neither has this: 256 KiB is a bound chosen to be
+ * comfortably readable by current models while still carrying far more than the
+ * ~537,250-byte slice that was shown to work, not a claim about how much
+ * context a good record needs.
+ *
+ * It is the last bytes rather than the first: a decision is taken near the end
+ * of the session that implements it, and the diff being captured is that end.
+ */
+const DEFAULT_TRANSCRIPT_BUDGET_BYTES = 256 * 1024;
+
+/** An override, for an operator whose model reads more, or less, than this. */
+const transcriptBudgetBytes = (): number => {
+ const raw = Number(process.env['COMMITLORE_TRANSCRIPT_BUDGET_BYTES']);
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_TRANSCRIPT_BUDGET_BYTES;
+};
+
+/**
+ * What the prompt actually showed, so a caller is never left to assume it was
+ * the session. Nothing here changes what verification reads — the whole
+ * transcript is still hashed and still searched for every quote.
+ */
+export interface TranscriptWindow {
+ /** 1-based number, in the whole transcript, of the window's first line. */
+ first_line: number;
+ last_line: number;
+ total_lines: number;
+ /** Bytes of the whole transcript, so the caller can see the size it did not get. */
+ total_bytes: number;
+ /** Bytes of the window as the prompt carries it. */
+ window_bytes: number;
+ /** False when the whole transcript fitted and the window is the session. */
+ truncated: boolean;
+ /**
+ * True when `first_line` is shown from its middle. One JSONL line can hold a
+ * whole tool result and outrun the budget by itself, and a window of no lines
+ * at all would be worse than a window of one partial one.
+ */
+ first_line_partial: boolean;
+}
+
+/** Byte length as the prompt will carry it, newline included. */
+const lineBytes = (line: string): number => Buffer.byteLength(line, 'utf8') + 1;
+
+/**
+ * The last `budget` bytes of one line, without a split codepoint at the front.
+ * Slicing a UTF-8 buffer mid-character yields U+FFFD, and a replacement
+ * character inside a quotable line is a character nobody can copy back.
+ */
+const tailBytes = (line: string, budget: number): string =>
+ Buffer.from(line, 'utf8').subarray(-budget).toString('utf8').replace(/^\uFFFD+/, '');
+
+/**
+ * The tail of `transcript` that fits in `budget` bytes, in whole lines where
+ * whole lines fit.
+ */
+export const windowTranscript = (
+ transcript: string,
+ budget: number = transcriptBudgetBytes(),
+): { text: string; window: TranscriptWindow } => {
+ const lines = transcript.split('\n');
+ if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
+ const totalLines = lines.length;
+ const totalBytes = Buffer.byteLength(transcript, 'utf8');
+
+ let kept = 0;
+ let bytes = 0;
+ for (let index = totalLines - 1; index >= 0; index -= 1) {
+ const next = bytes + lineBytes(lines[index]!);
+ if (next > budget && kept > 0) break;
+ if (next > budget) break; // not even the last line fits whole
+ bytes = next;
+ kept += 1;
+ }
+
+ if (kept === 0) {
+ // One line longer than the whole budget. Showing its tail keeps the window
+ // anchored where the decision is, and the line number stays the file's.
+ const last = lines[totalLines - 1] ?? '';
+ const text = tailBytes(last, budget);
+ return {
+ text,
+ window: {
+ first_line: totalLines,
+ last_line: totalLines,
+ total_lines: totalLines,
+ total_bytes: totalBytes,
+ window_bytes: Buffer.byteLength(text, 'utf8'),
+ truncated: true,
+ first_line_partial: true,
+ },
+ };
+ }
+
+ const firstLine = totalLines - kept + 1;
+ const text = lines.slice(firstLine - 1).join('\n');
+ return {
+ text,
+ window: {
+ first_line: firstLine,
+ last_line: totalLines,
+ total_lines: totalLines,
+ total_bytes: totalBytes,
+ window_bytes: Buffer.byteLength(text, 'utf8'),
+ truncated: firstLine > 1,
+ first_line_partial: false,
+ },
+ };
+};
+
+/**
+ * The line the prompt carries when it is showing a window, so the reader is
+ * told rather than left to infer it from a transcript that starts mid-sentence.
+ */
+const windowNotice = (window: TranscriptWindow): string[] => {
+ if (!window.truncated) return [];
+ const omitted = window.first_line - 1;
+ return [
+ `(This is the end of the transcript: lines ${window.first_line}-${window.last_line} of ` +
+ `${window.total_lines}, ${omitted} earlier line(s) omitted to bound this prompt` +
+ `${window.first_line_partial ? `, and line ${window.first_line} is shown from its middle` : ''}. ` +
+ 'The numbers below are the transcript\'s own, so a locator you write still names ' +
+ 'the line in the whole file. Cite only what you can see here.)',
+ '',
+ ];
};
const outputBlock = (entries: VocabularyEntry[]): string[] => {
@@ -414,15 +558,19 @@ export const buildHarvestContract = (): string => {
};
/**
- * Builds the prompt contract handed to the user's agent session. Deterministic
- * by construction — no clock, no randomness, no model — so the same transcript
- * and diff always produce the same bytes.
+ * Builds the prompt contract handed to the user's agent session, and says what
+ * of the transcript it carries. Deterministic by construction — no clock, no
+ * randomness, no model — so the same transcript, diff and budget always produce
+ * the same bytes.
*/
-export const buildHarvestPrompt = (input: HarvestInput): string => {
+export const buildHarvestPromptWithWindow = (
+ input: HarvestInput,
+): { 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);
- return [
+ const prompt = [
'# CommitLore harvest',
'',
'You are recording the decision context for a change that is about to be',
@@ -443,15 +591,22 @@ export const buildHarvestPrompt = (input: HarvestInput): string => {
'',
'## TRANSCRIPT',
'',
- numberLines(input.transcript),
+ ...windowNotice(window),
+ numberLines(text, window.first_line),
'',
'## DIFF',
'',
diff,
'',
].join('\n');
+
+ return { prompt, window };
};
+/** The prompt alone, for the callers that only emit it. */
+export const buildHarvestPrompt = (input: HarvestInput): string =>
+ buildHarvestPromptWithWindow(input).prompt;
+
const RECORD_FIELDS = ['trailers', 'evidence'];
const EVIDENCE_FIELDS = ['key', 'source', 'quote', 'locator'];
const EVIDENCE_SOURCES: readonly EvidenceSource[] = ['transcript', 'diff'];
diff --git a/src/mcp/server.ts b/src/mcp/server.ts
index 5f2767f9..92292b34 100644
--- a/src/mcp/server.ts
+++ b/src/mcp/server.ts
@@ -379,7 +379,10 @@ const TOOLS: readonly Tool[] = [
description:
'Prepare a capture transaction: computes binding conditions (HEAD, staged diff, tree, ' +
'policy hash), generates the prompt contract for the agent to use, and persists a ' +
- 'phase:"prepared" pending transaction. Returns the nonce needed for verify and stage.',
+ 'phase:"prepared" pending transaction. Returns the nonce needed for verify and stage. ' +
+ 'The prompt carries the end of the transcript rather than all of it; transcript_window ' +
+ 'says which lines, numbered as the whole transcript numbers them. Verification still ' +
+ 'reads the whole transcript, so quote only what the prompt shows you.',
inputSchema: {
type: 'object',
properties: {
@@ -638,6 +641,11 @@ export const createServer = (opts: McpServerOptions = {}): Server => {
policy_identity_hash: result.policy_identity_hash,
source_hashes: result.source_hashes,
prompt: result.prompt,
+ // What of the transcript that prompt carries (#873). It travels here
+ // for the same reason the two fields below do: MCP is the first-class
+ // surface for every agent but the plugin, and an agent handed a slice
+ // of its own session with no way to tell would cite the whole of it.
+ transcript_window: result.transcript_window,
// MCP is the first-class surface for every agent other than the Claude
// Code plugin, so both of these must travel here and not only to the
// pending file and the CLI. `guard_advisory` is always present, never
diff --git a/test/capture-prompt-budget.test.ts b/test/capture-prompt-budget.test.ts
new file mode 100644
index 00000000..87c69d50
--- /dev/null
+++ b/test/capture-prompt-budget.test.ts
@@ -0,0 +1,230 @@
+/**
+ * #873: `capture` embedded the transcript in the prompt whole, so the prompt
+ * was the session. Measured by the reporter: a 67,981,436-byte transcript
+ * produced a 67,468,122-byte prompt — 99.3% of the file, and larger than any
+ * model can read. The pipeline itself was fine; the reporter confirmed that by
+ * slicing the transcript to `tail -n 400` (537,250 bytes) and watching the same
+ * command work.
+ *
+ * That made the failure worse than a size: it is silent. Prompt-only mode
+ * returns `outcome: "empty"`, `staged: false`, **exit 0**, so an operator who
+ * tried capture once on a real session got a prompt they could not use and no
+ * statement that anything was wrong. In the repository where it was measured,
+ * `stale` reported 1 record in 1000 commits with unattended capture already
+ * enabled — permission was never the obstacle.
+ *
+ * Three properties are load-bearing here, and each has a test below that fails
+ * when its half of the fix is reverted:
+ *
+ * 1. The prompt is bounded, whatever the transcript weighs.
+ * 2. The line numbers in that window are the *whole transcript's* numbers.
+ * Verification reads the whole transcript, so a window renumbered from 1
+ * would have every locator name a different line of the file it is checked
+ * against.
+ * 3. The caller is told it received a window. The old prompt was the session,
+ * so nothing downstream had any reason to ask; a bounded prompt that does
+ * not say so is the same silence in a smaller package.
+ */
+
+import { execSync } from 'node:child_process';
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+import { afterAll, afterEach, describe, expect, it } from 'vitest';
+
+import { prepareCaptureContext } from '../src/core/capture-prepare.js';
+import { verifyCaptureRecords } from '../src/core/capture-verify.js';
+import { buildHarvestPromptWithWindow, windowTranscript, type DraftRecord } from '../src/core/harvest.js';
+
+const scratch: string[] = [];
+afterAll(() => {
+ for (const dir of scratch) rmSync(dir, { recursive: true, force: true });
+});
+
+const makeRepo = (): string => {
+ const dir = mkdtempSync(join(tmpdir(), 'capture-prompt-budget-'));
+ 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 "init" --quiet', { cwd: dir });
+ writeFileSync(join(dir, 'a.txt'), 'hello\nworld\n');
+ execSync('git add a.txt', { cwd: dir });
+ return dir;
+};
+
+/** A transcript of `lines` numbered lines, each recognisable by its own number. */
+const transcriptOf = (lines: number, padTo = 0): string =>
+ Array.from({ length: lines }, (_, index) => {
+ const body = `line ${index + 1} of the session`;
+ return padTo > body.length ? body + ' '.repeat(padTo - body.length) : body;
+ }).join('\n');
+
+const DIFF = 'diff --git a/x b/x\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new\n';
+
+describe('#873 windowTranscript bounds what the prompt can carry', () => {
+ it('is the whole transcript, unmarked, when the whole transcript fits', () => {
+ const transcript = transcriptOf(20);
+ const { text, window } = windowTranscript(transcript, 64 * 1024);
+
+ expect(text).toBe(transcript);
+ expect(window.truncated).toBe(false);
+ expect(window.first_line).toBe(1);
+ expect(window.last_line).toBe(20);
+ expect(window.total_lines).toBe(20);
+ expect(window.first_line_partial).toBe(false);
+ });
+
+ it('keeps the end of a transcript that does not fit, within the budget', () => {
+ const transcript = transcriptOf(5000);
+ const { text, window } = windowTranscript(transcript, 4096);
+
+ expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(4096);
+ expect(window.truncated).toBe(true);
+ expect(window.last_line).toBe(5000);
+ expect(window.total_lines).toBe(5000);
+ expect(window.first_line).toBeGreaterThan(1);
+ // The end, not the beginning: the decision is taken where the session ends.
+ expect(text.endsWith('line 5000 of the session')).toBe(true);
+ expect(text.startsWith(`line ${window.first_line} of the session`)).toBe(true);
+ expect(window.total_bytes).toBe(Buffer.byteLength(transcript, 'utf8'));
+ expect(window.window_bytes).toBe(Buffer.byteLength(text, 'utf8'));
+ });
+
+ it('shows the tail of one line that outruns the budget by itself', () => {
+ // A single JSONL line can hold an entire tool result. A window of no lines
+ // at all would be worse than a window of one partial line.
+ const transcript = `first\n${'x'.repeat(20_000)}`;
+ const { text, window } = windowTranscript(transcript, 1024);
+
+ expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(1024);
+ expect(window.first_line).toBe(2);
+ expect(window.last_line).toBe(2);
+ expect(window.first_line_partial).toBe(true);
+ expect(window.truncated).toBe(true);
+ });
+
+ it('never leaves a split codepoint at the front of a partial line', () => {
+ // Slicing a UTF-8 buffer mid-character yields U+FFFD, and a replacement
+ // character inside a quotable line is a character nobody can copy back.
+ const transcript = '한'.repeat(4000);
+ const { text } = windowTranscript(transcript, 1000);
+
+ expect(text.startsWith('�')).toBe(false);
+ expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(1000);
+ });
+
+ it('honours COMMITLORE_TRANSCRIPT_BUDGET_BYTES', () => {
+ const previous = process.env['COMMITLORE_TRANSCRIPT_BUDGET_BYTES'];
+ process.env['COMMITLORE_TRANSCRIPT_BUDGET_BYTES'] = '2048';
+ try {
+ const { window } = windowTranscript(transcriptOf(5000));
+ expect(window.window_bytes).toBeLessThanOrEqual(2048);
+ expect(window.truncated).toBe(true);
+ } finally {
+ if (previous === undefined) delete process.env['COMMITLORE_TRANSCRIPT_BUDGET_BYTES'];
+ else process.env['COMMITLORE_TRANSCRIPT_BUDGET_BYTES'] = previous;
+ }
+ });
+});
+
+describe('#873 the prompt numbers the window as the transcript numbers it', () => {
+ const budgeted = (lines: number): ReturnType => {
+ const previous = process.env['COMMITLORE_TRANSCRIPT_BUDGET_BYTES'];
+ process.env['COMMITLORE_TRANSCRIPT_BUDGET_BYTES'] = '4096';
+ try {
+ return buildHarvestPromptWithWindow({ transcript: transcriptOf(lines), diff: DIFF });
+ } finally {
+ if (previous === undefined) delete process.env['COMMITLORE_TRANSCRIPT_BUDGET_BYTES'];
+ else process.env['COMMITLORE_TRANSCRIPT_BUDGET_BYTES'] = previous;
+ }
+ };
+
+ it('gives the window\'s first line its number in the whole transcript', () => {
+ const { prompt, window } = budgeted(5000);
+
+ // The exact property a renumbered window would break: the line the prompt
+ // labels N really is line N of the transcript verification will read.
+ expect(prompt).toContain(`${window.first_line} | line ${window.first_line} of the session`);
+ expect(prompt).toContain(`${window.last_line} | line ${window.last_line} of the session`);
+ expect(prompt).not.toContain('1 | line 1 of the session');
+ });
+
+ it('says it is a window, and how much was left out', () => {
+ const { prompt, window } = budgeted(5000);
+
+ expect(prompt).toContain(`lines ${window.first_line}-${window.last_line} of ${window.total_lines}`);
+ expect(prompt).toContain('earlier line(s) omitted to bound this prompt');
+ });
+
+ it('says nothing about a window when there is no window', () => {
+ const { prompt } = buildHarvestPromptWithWindow({ transcript: transcriptOf(5), diff: DIFF });
+
+ expect(prompt).not.toContain('omitted to bound this prompt');
+ expect(prompt).toContain('1 | line 1 of the session');
+ });
+});
+
+describe('#873 capture returns a prompt a model can read, and says what it is', () => {
+ const previous = process.env['COMMITLORE_TRANSCRIPT_BUDGET_BYTES'];
+ afterEach(() => {
+ if (previous === undefined) delete process.env['COMMITLORE_TRANSCRIPT_BUDGET_BYTES'];
+ else process.env['COMMITLORE_TRANSCRIPT_BUDGET_BYTES'] = previous;
+ });
+
+ it('does not return a prompt the size of the transcript', () => {
+ // The reporter's ratio, at a size a test can afford: the prompt used to be
+ // ~99.3% of the transcript, so it grew without bound with the session.
+ const cwd = makeRepo();
+ process.env['COMMITLORE_TRANSCRIPT_BUDGET_BYTES'] = '8192';
+ const transcript = transcriptOf(40_000, 200);
+
+ const result = prepareCaptureContext({ cwd, transcript });
+
+ expect(Buffer.byteLength(transcript, 'utf8')).toBeGreaterThan(4_000_000);
+ expect(Buffer.byteLength(result.prompt, 'utf8')).toBeLessThan(64 * 1024);
+ expect(result.transcript_window.truncated).toBe(true);
+ expect(result.transcript_window.total_lines).toBe(40_000);
+ expect(result.transcript_window.last_line).toBe(40_000);
+ });
+
+ it('hashes and verifies the whole transcript, not the window', () => {
+ // The bound is on the prompt alone. If it ever reached the hash, `verify`
+ // would be checking quotes against a slice — and a caller passing the
+ // session it actually had would be told the transcript was substituted.
+ const cwd = makeRepo();
+ process.env['COMMITLORE_TRANSCRIPT_BUDGET_BYTES'] = '4096';
+ const quote = 'we ruled out the queue worker because it loses ordering';
+ const transcript = `${quote}\n${transcriptOf(5000)}`;
+
+ const prepared = prepareCaptureContext({ cwd, transcript });
+ expect(prepared.transcript_window.first_line).toBeGreaterThan(1);
+
+ const draft: DraftRecord[] = [
+ {
+ trailers: [
+ { key: 'Ruled-out', value: 'queue worker | loses ordering' },
+ { key: 'Record-Id', value: 'r-window1' },
+ ],
+ evidence: [{ key: 'Ruled-out', source: 'transcript', quote, locator: 'L1-L1' }],
+ },
+ ];
+
+ const verified = verifyCaptureRecords({
+ nonce: prepared.nonce,
+ draft,
+ transcript,
+ diff: execSync('git diff --cached', { cwd, encoding: 'utf8' }),
+ cwd,
+ });
+
+ // The quote is on line 1, which the window does not show — and it verifies,
+ // because verification never looked at the window.
+ expect(verified.rejected).toHaveLength(0);
+ expect(verified.accepted).toHaveLength(1);
+ });
+});
From 9e35741f140167e6e566186a4c1b8e7d9348c399 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 8 Sep 2026 04:27:48 +0000
Subject: [PATCH 2/2] release: 1.2.3
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Version fields, install pins and the changelog entry for 1.2.3.
The release carries one fix: `capture` embedded the whole transcript in its
prompt, so on a long session the prompt was larger than any model can read and
no record was ever written (#873). The failure was silent — prompt-only mode
reports outcome "empty", staged false, exit 0.
The install pins move; the field-report paragraph in each README keeps saying
v1.2.1, because that is the version the run it describes was made on.
`dist/` is deliberately not in this branch. `canonical-merge.yml` rebuilds the
bundle from the merged tree and refuses a pull request that touches it, so the
committed bundle matches the source it lands with.
Claude-Session: https://claude.ai/code/session_01USc9G3aJ1s8pnhWy5K5hLr
Co-Authored-By: Claude Opus 5
---
.claude-plugin/plugin.json | 2 +-
.codex-plugin/plugin.json | 2 +-
CHANGELOG.md | 53 ++++++++++++++++++++++++++++++++++++++
README.ja.md | 12 ++++-----
README.ko.md | 12 ++++-----
README.md | 12 ++++-----
README.zh-CN.md | 12 ++++-----
install.ps1 | 4 +--
install.sh | 4 +--
package-lock.json | 4 +--
package.json | 2 +-
server.json | 6 ++---
12 files changed, 89 insertions(+), 36 deletions(-)
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index 1a1701b0..018f0384 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "commitlore",
"displayName": "CommitLore",
- "version": "1.2.2",
+ "version": "1.2.3",
"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 1a79f028..15cc39b1 100644
--- a/.codex-plugin/plugin.json
+++ b/.codex-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "commitlore",
- "version": "1.2.2",
+ "version": "1.2.3",
"description": "Decision memory from Git history, with verified capture for coding sessions.",
"author": {
"name": "MongLong0214",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 51d244d1..8150c2b6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,59 @@ 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.3
+
+The capture prompt was the session, so on a long session there was no prompt.
+
+**`capture` embedded the whole transcript in its prompt (#873).** Measured by the
+reporter: a 67,981,436-byte transcript produced a 67,468,122-byte prompt — 99.3%
+of the file, and larger than any model can consume, so the pipeline could not be
+completed and no record was ever written. The pipeline itself was fine, which is
+how they proved it: `tail -n 400` on the same transcript gave 537,250 bytes and
+the same command in the same repository worked.
+
+**The failure was silent, which is why it reads as the reason capture is not
+used.** Prompt-only mode reports `outcome: "empty"`, `staged: false`, exit 0. An
+operator who tried capture once on a real session got a prompt they could not
+use and no statement that anything was wrong, and did not try again. In the
+repository where this was measured, `stale` reported 1 record in 1000 commits
+while roughly twenty commits in one recent session carried real decision context
+and produced none — with `{"mode":"auto","unattended":true}` already set, so
+permission was never the obstacle.
+
+**The prompt now carries the end of the transcript within a byte budget**, 256
+KiB by default and `COMMITLORE_TRANSCRIPT_BUDGET_BYTES` to change it. The end
+rather than the beginning: a decision is taken near the end of the session that
+implements it, and the diff being captured is that end.
+
+**Three things the bound deliberately does not do.** It does not renumber — the
+window keeps the line numbers it has in the whole transcript, because
+verification reads the whole transcript and a window renumbered from 1 would
+have every `L-L` locator name a different line of the file it is
+checked against. It does not reach the hash — `source_hashes` and every quote
+check are still over the whole transcript, so a quote from outside the window
+still verifies and a caller passing the session it actually had is never told
+the transcript was substituted. And it does not stay quiet — the prompt says
+which lines it shows and how many were left out, and `transcript_window` says
+the same to `capture --json` and to `commitlore_prepare_capture`. A bounded
+prompt that did not say so would be the old silence in a smaller package.
+
+**One line can outrun the budget by itself.** A JSONL transcript line can hold an
+entire tool result. That line is shown from its end rather than dropped, and the
+window marks it partial, because a window of no lines is worse than a window of
+one partial line. The byte slice never leaves a split codepoint at the front: a
+replacement character inside a quotable line is a character nobody can copy back.
+
+**Not claimed.** Where the useful boundary is. 256 KiB is comfortably readable by
+current models and carries far more than the ~537 KB slice measured to work; the
+reporter said they had not measured where a good record stops needing context,
+and neither has this. Also unchanged, and worth stating because automation is
+built on it: `outcome` is `staged`, `empty` or `rejected`, and all three exit 0
+— anything driving capture must read `--json`, because an exit-code check reads
+a rejected record as a success.
+
+**Not reviewed.** No cross-provider review was run on this change.
+
## 1.2.2
One line of configuration, and the plugin's MCP server had never started for
diff --git a/README.ja.md b/README.ja.md
index b443698a..1ccf4a88 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -47,18 +47,18 @@
```bash
-curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.sh | sh -s v1.2.2
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3
```
先にインストーラーを読みたいですか?
```bash
-curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.sh
-sh install.sh v1.2.2
+curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh
+sh install.sh v1.2.3
# あるいはスクリプトを使わずに。スクリプトが作るチェックアウトは自分でも作れます。
-git clone --depth 1 --branch v1.2.2 https://github.com/MongLong0214/commitlore
+git clone --depth 1 --branch v1.2.3 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.2/install.sh | sh -s v1.2.2
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3
```
Windows:
```powershell
-& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.ps1))) v1.2.2
+& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.ps1))) v1.2.3
```
Node.js 22.23.2+ と Git が必要です。スクリプトは何かを書き込む前に両方を確認します。
diff --git a/README.ko.md b/README.ko.md
index 396becd9..c5bb0b9f 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -47,18 +47,18 @@
```bash
-curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.sh | sh -s v1.2.2
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3
```
먼저 설치기를 읽어 보고 싶나요?
```bash
-curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.sh
-sh install.sh v1.2.2
+curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh
+sh install.sh v1.2.3
# 또는 스크립트를 건너뜁니다. 스크립트가 만드는 체크아웃은 직접 만들 수 있습니다.
-git clone --depth 1 --branch v1.2.2 https://github.com/MongLong0214/commitlore
+git clone --depth 1 --branch v1.2.3 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.2/install.sh | sh -s v1.2.2
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3
```
Windows:
```powershell
-& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.ps1))) v1.2.2
+& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.ps1))) v1.2.3
```
Node.js 22.23.2+와 Git이 필요합니다. 스크립트는 무엇이든 쓰기 전에 둘을 확인합니다.
diff --git a/README.md b/README.md
index ce9331e3..9c0d0634 100644
--- a/README.md
+++ b/README.md
@@ -48,18 +48,18 @@
```bash
-curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.sh | sh -s v1.2.2
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3
```
Prefer to read the installer first?
```bash
-curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.sh
-sh install.sh v1.2.2
+curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh
+sh install.sh v1.2.3
# Or skip the script: the checkout it makes is one you can make yourself.
-git clone --depth 1 --branch v1.2.2 https://github.com/MongLong0214/commitlore
+git clone --depth 1 --branch v1.2.3 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.2/install.sh | sh -s v1.2.2
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3
```
Windows:
```powershell
-& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.ps1))) v1.2.2
+& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.ps1))) v1.2.3
```
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 8448a86b..337ab275 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.2/install.sh | sh -s v1.2.2
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3
```
想先阅读安装器吗?
```bash
-curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.sh
-sh install.sh v1.2.2
+curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh
+sh install.sh v1.2.3
# 或者跳过脚本:它创建的检出,你自己也能创建。
-git clone --depth 1 --branch v1.2.2 https://github.com/MongLong0214/commitlore
+git clone --depth 1 --branch v1.2.3 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.2/install.sh | sh -s v1.2.2
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3
```
Windows:
```powershell
-& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.ps1))) v1.2.2
+& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.ps1))) v1.2.3
```
需要 Node.js 22.23.2+ 和 Git。脚本会在写入任何内容前检查两者。
diff --git a/install.ps1 b/install.ps1
index 0e7bc0fd..055f8561 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.2/install.ps1 | iex
- & ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.ps1))) v1.2.2
+ irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.ps1 | iex
+ & ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.ps1))) v1.2.3
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 c05ff382..f77ed7cb 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.2/install.sh | sh
-# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.2/install.sh | sh -s v1.2.2
+# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh
+# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3
#
# **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 24b1d386..282256ab 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "commitlore",
- "version": "1.2.2",
+ "version": "1.2.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "commitlore",
- "version": "1.2.2",
+ "version": "1.2.3",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
diff --git a/package.json b/package.json
index b2e96388..e3d0c2a1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "commitlore",
- "version": "1.2.2",
+ "version": "1.2.3",
"description": "Git-native, lifecycle-aware decision memory for coding agents",
"license": "MIT",
"private": true,
diff --git a/server.json b/server.json
index f597fef4..503946f5 100644
--- a/server.json
+++ b/server.json
@@ -8,7 +8,7 @@
"source": "github"
},
"websiteUrl": "https://github.com/MongLong0214/commitlore#readme",
- "version": "1.2.2",
+ "version": "1.2.3",
"_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.2/install.sh | sh -s v1.2.2",
- "release": "https://github.com/MongLong0214/commitlore/releases/tag/v1.2.2"
+ "installer": "curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3",
+ "release": "https://github.com/MongLong0214/commitlore/releases/tag/v1.2.3"
},
"runtime": {
"transport": "stdio",