From 895542cb995a47cba313ba857c90fe3d5300cad9 Mon Sep 17 00:00:00 2001
From: operator
Date: Tue, 8 Sep 2026 18:24:40 +0900
Subject: [PATCH 1/3] fix(capture): report the nonce --out promised, and refuse
--diff at the flag
Two flags on `capture`, one reporter, one failure seen from both ends.
`--out` is documented as "write the pending nonce to a file". The write was
wired correctly and guarded on `result.nonce`, but the prompt-only branch
returned `nonce: null` while prepare had already persisted a real transaction
under a real nonce. Prompt-only is the step `--out` exists for -- get the nonce,
hand the prompt to a model, come back with `--draft` -- so the guard was false on
exactly the run that needed it. Three runs, three exit 0, three missing files,
with and without `--diff`. The nonce is now reported, so `--out` writes it and
`--json` carries it (#878).
`--diff` was accepted, ignored by prepare, and honoured by verify. Prepare hashes
`git diff --cached` itself into `source_hashes.diff`; verify hashed the file the
caller named. Passing the same `--diff` to both steps therefore failed whenever
that file was not byte-identical to the staged diff, and failed as
`source-mismatch: diff hash does not match the prepared transaction` -- a
statement about the draft's sources, for a fault belonging entirely to the flag.
The reporter re-checked quotes and locators that were never wrong (#877).
The refusal is correct and stays. A capture transaction binds to the staged diff,
which prepare, verify and stage each recompute independently, because every
binding is computed server-side and never from the caller (capture-stage.ts).
Binding `--diff` at prepare would only move the same rejection to stage, and
would let a record's diff evidence be checked against a diff that is not the one
being committed. So `--diff` can assert what is staged; it cannot select
something else, and the help text now says that instead of implying an override
it never had.
What changed is where the refusal happens. It is raised before prepare, as a
usage error, naming the flag and naming the way out -- `git reset --soft`, which
is the flow the reporter had to discover on their own after the message sent them
elsewhere. Refusing before prepare also stops a doomed run leaving a transaction
behind; the old path wrote two pending files per attempt for a capture that could
not succeed.
The `#359` help-text guard read the option out of a single source line, which
r-diffdefault had already recorded as its known limit. The description outgrew
one line here and all three of its assertions went from checking a string to
checking `undefined` -- green either way. It now reads the whole `.option(...)`
call, and asserts the description does not promise a diff other than the staged
one, which is the #359 defect pointed the other way.
Both issue repros were run against the built CLI. `--out` writes a 32-hex nonce
naming a real file under .git/commitlore/pending (3/3, was 0/3), and a `--diff`
that differs exits 2 naming the flag with zero pending files left (was
`source-mismatch`, exit 0, two files). Negative controls: with `nonce: null`
restored the three #878 tests fail; with the refusal disabled the two #877
refusal tests fail while the identical-`--diff` control still passes, which is
what keeps the fix from degenerating into rejecting every `--diff`. Full suite
3192 passed, 0 failed; tsc clean.
Closes #878
Closes #877
Record-Id: r-capflags877
Provenance: authored
Ruled-out: make --diff bind the transaction at prepare | capture-stage.ts recomputes the staged diff and rejects a mismatch before writing, so the same failure would surface one step later, and the binding contract states every binding is computed server-side and never from the caller
Ruled-out: drop --diff from capture entirely | every existing caller passes one, including this suite's own fixtures, and an assertion about what is staged is worth keeping
Ruled-out: leave the refusal at verify and only reword its message | verify cannot name the flag without knowing a flag was passed, and the pending file is already written by then
Limit: --out is still silently skipped when the run fails before a nonce exists; those paths already exit non-zero, so the silence is not the only signal, but it is not an error either
Limit: the refusal compares whole file contents, so a --diff differing only in trailing whitespace is refused with no hint that the difference is invisible
Warn: the #359 guard's teeth depend on the four-space indent of chained .option calls; reformatting that chain makes it match nothing again
Blast: module
Undo: easy
Certainty: firm
Verified: 23/23 in test/capture.test.ts, 7/7 in test/help-text-honesty.test.ts, full suite 3192 passed 4 skipped 0 failed, tsc exit 0
Verified: each fix seen failing with the defect restored and the source rebuilt, not only passing with it applied
Unverified: whether the MCP capture tools reach the same prompt-only path and were losing the nonce the same way -- only the CLI was measured
Co-Authored-By: Claude
---
src/commands/capture.ts | 54 +++++++++++++++----
test/capture.test.ts | 98 ++++++++++++++++++++++++++++++++++
test/help-text-honesty.test.ts | 27 ++++++++--
3 files changed, 166 insertions(+), 13 deletions(-)
diff --git a/src/commands/capture.ts b/src/commands/capture.ts
index 90e70de9..bbeaa178 100644
--- a/src/commands/capture.ts
+++ b/src/commands/capture.ts
@@ -210,14 +210,37 @@ const runCapturePipeline = (opts: {
const { transcriptPath, diffPath, draftPath, cwd } = opts;
const transcript = readCallerFile(transcriptPath);
- // Prepare hashes `git diff --cached` itself, so verification has to be given
- // the same bytes. This used to default to the empty string, whose hash never
- // matches -- every record was refused with `source-mismatch` and the command
- // printed `no record staged`, so `capture --draft` could not succeed at all
- // unless the caller happened to pass a --diff file byte-identical to the
- // staged diff. A caller-supplied --diff that differs is still a real mismatch
- // and is still refused.
- const diff = diffPath ? readCallerFile(diffPath) : execGitOrThrow(['diff', '--cached'], { cwd });
+ // The transaction binds to the staged diff and only to that: prepare hashes
+ // `git diff --cached` itself, and stage recomputes it a third time before
+ // writing, because every binding is computed server-side and never from the
+ // caller (capture-stage.ts). So `--diff` can assert what is staged; it cannot
+ // override it.
+ //
+ // It used to default to the empty string, whose hash never matches -- every
+ // record was refused with `source-mismatch` and the command printed `no record
+ // staged`, so `capture --draft` could not succeed at all unless the caller
+ // happened to pass a --diff file byte-identical to the staged diff.
+ //
+ // A --diff that differs is still refused. What #877 is about is where that
+ // refusal surfaced: prepare wrote a pending file for a run that could not
+ // succeed, and verify then blamed the draft -- `source-mismatch: diff hash
+ // does not match the prepared transaction` -- for a fault belonging entirely
+ // to the flag. The reporter re-checked quotes and locators that were never
+ // wrong. It is refused here instead, before prepare, naming the flag.
+ const callerDiff = diffPath === undefined ? undefined : readCallerFile(diffPath);
+ const diff = execGitOrThrow(['diff', '--cached'], { cwd });
+ if (callerDiff !== undefined && callerDiff !== diff) {
+ throw markCaptureError(
+ new Error(
+ `--diff ${JSON.stringify(diffPath)} is not the staged diff. A capture transaction ` +
+ 'binds to the staged diff -- prepare, verify and stage each recompute it, so --diff ' +
+ 'can assert what is staged but cannot override it. Stage the change you are ' +
+ 'recording; to record against a commit that already exists, soft-reset it first ' +
+ '(git reset --soft HEAD~1).',
+ ),
+ 'usage',
+ );
+ }
// 1. Prepare: compute bindings, generate prompt, persist prepared transaction
const prepareResult = prepareCaptureContext({
@@ -239,10 +262,17 @@ const runCapturePipeline = (opts: {
}
// 2. If no draft provided, print the prompt contract and exit (prompt-only mode)
+ //
+ // The nonce is reported, not dropped (#878). Prepare has already persisted the
+ // transaction under it, and prompt-only is the step `--out` exists for: get the
+ // nonce, hand the prompt to a model, come back with --draft. Returning null
+ // here made `if (options.out && result.nonce)` false on exactly that run, so
+ // --out wrote nothing and said nothing, and a scripted caller had no handle on
+ // which transaction it was completing until verify failed several steps later.
if (!draftPath) {
return {
outcome: 'empty',
- nonce: null,
+ nonce: prepareResult.nonce,
staged: false,
prompt: prepareResult.prompt,
transcript_window: prepareResult.transcript_window,
@@ -396,7 +426,11 @@ export const register = (program: Command): void => {
'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(
+ '--diff ',
+ 'assert the staged diff equals this file; the transaction always binds to the staged diff, ' +
+ 'so this cannot select a different one',
+ )
.option('--draft ', 'path to the draft JSON file (omit for prompt-only mode)')
.option('--out ', 'write the pending nonce to a file')
.option('--shadow', 'measure historical capture candidates without writing anything')
diff --git a/test/capture.test.ts b/test/capture.test.ts
index 2bf50641..8aed8c51 100644
--- a/test/capture.test.ts
+++ b/test/capture.test.ts
@@ -496,6 +496,104 @@ describe('commitlore capture', () => {
});
});
+describe('commitlore capture --out (#878)', () => {
+ // Prompt-only is the step --out exists for -- get the nonce, hand the prompt
+ // to a model, come back with --draft -- and it was the one run where the
+ // pipeline returned `nonce: null`, so the write was guarded out. Exit 0, no
+ // file, nothing said. Three runs because the report measured three.
+ it('writes the pending nonce in prompt-only mode', () => {
+ const cwd = makeRepo();
+ const { transcriptPath } = makeFixtures(cwd);
+
+ for (const run of [1, 2, 3]) {
+ const outPath = join(cwd, `nonce-${run}.txt`);
+ const result = runCapture(['--transcript', transcriptPath, '--out', outPath], { cwd });
+
+ expect(result.exitCode).toBe(0);
+ expect(existsSync(outPath), `run ${run} wrote no file`).toBe(true);
+ expect(readFileSync(outPath, 'utf8').trim()).toMatch(/^[0-9a-f]{32}$/);
+ }
+ });
+
+ // The file is worthless unless it names the transaction prepare actually
+ // persisted -- a well-formed nonce nothing can be staged under would be the
+ // same silence in a different shape.
+ it('writes a nonce that names a real pending transaction', () => {
+ const cwd = makeRepo();
+ const { transcriptPath } = makeFixtures(cwd);
+ const outPath = join(cwd, 'nonce.txt');
+
+ runCapture(['--transcript', transcriptPath, '--out', outPath], { cwd });
+
+ const nonce = readFileSync(outPath, 'utf8').trim();
+ expect(listPending(cwd)).toContain(`${nonce}.json`);
+ });
+
+ it('reports the nonce in the JSON envelope too', () => {
+ const cwd = makeRepo();
+ const { transcriptPath } = makeFixtures(cwd);
+
+ const result = runCapture(['--transcript', transcriptPath, '--json'], { cwd });
+
+ const parsed = JSON.parse(result.stdout) as { outcome: string; nonce: string | null };
+ expect(parsed.outcome).toBe('empty');
+ expect(parsed.nonce).toMatch(/^[0-9a-f]{32}$/);
+ });
+});
+
+describe('commitlore capture --diff (#877)', () => {
+ /** A real diff that is not the staged one: the previous commit's. */
+ const writeForeignDiff = (cwd: string): string => {
+ const path = join(cwd, 'other.patch');
+ writeFileSync(path, execSync('git show HEAD', { cwd, encoding: 'utf8' }));
+ return path;
+ };
+
+ // The transaction binds to the staged diff and nothing else, so a --diff that
+ // differs cannot be honoured. It was refused before this too -- but at verify,
+ // as `source-mismatch` against the draft's sources, which sent the reporter to
+ // re-check quotes and locators that were never wrong.
+ it('refuses a --diff that is not the staged diff, naming the flag', () => {
+ const cwd = makeRepo();
+ const { transcriptPath } = makeFixtures(cwd);
+ const foreign = writeForeignDiff(cwd);
+
+ const result = runCapture(['--transcript', transcriptPath, '--diff', foreign], { cwd });
+
+ expect(result.exitCode).toBe(2);
+ expect(result.stderr).toContain('--diff');
+ expect(result.stderr, 'the flag is named, not the draft').not.toContain('source-mismatch');
+ expect(result.stderr, 'and the way out is named').toContain('git reset --soft');
+ });
+
+ // Refused before prepare, so a run that cannot succeed leaves no transaction
+ // for `capture gc` to collect. Two were left behind per attempt before.
+ it('writes no pending transaction for a --diff it refuses', () => {
+ const cwd = makeRepo();
+ const { transcriptPath, draftPath } = makeFixtures(cwd);
+ const foreign = writeForeignDiff(cwd);
+
+ runCapture(['--transcript', transcriptPath, '--diff', foreign, '--draft', draftPath], { cwd });
+
+ expect(listPending(cwd)).toEqual([]);
+ });
+
+ // The control. Without it the fix degenerates into "reject every --diff",
+ // which passes the two assertions above and breaks every caller that has one.
+ it('still stages when --diff is byte-identical to the staged diff', () => {
+ const cwd = makeRepo();
+ const { transcriptPath, diffPath, draftPath } = makeFixtures(cwd);
+
+ const result = runCapture(
+ ['--transcript', transcriptPath, '--diff', diffPath, '--draft', draftPath],
+ { cwd },
+ );
+
+ expect(result.exitCode).toBe(0);
+ expect(listStagedPending(cwd).length).toBe(1);
+ });
+});
+
describe('commitlore capture --unattended (#511)', () => {
it('is refused where the repository did not opt in, and stages nothing', () => {
const cwd = makeRepo();
diff --git a/test/help-text-honesty.test.ts b/test/help-text-honesty.test.ts
index 62f696c0..962f24b0 100644
--- a/test/help-text-honesty.test.ts
+++ b/test/help-text-honesty.test.ts
@@ -91,12 +91,24 @@ describe('#303 user-facing text names only packages the manifest carries', () =>
*/
describe('#359 capture --diff documents the default it actually has', () => {
const captureSource = readFileSync(join(REPO_ROOT, 'src/commands/capture.ts'), 'utf8');
- const diffOption = captureSource
- .split('\n')
- .find((line) => line.includes(".option('--diff '"));
+
+ // The whole `.option(...)` call, not one line of it. r-diffdefault recorded
+ // reading a single line as this check's known limit, and #877 collected on it:
+ // the description outgrew one line, the call became multi-line, and all three
+ // assertions below stopped seeing any string at all. A guard that silently
+ // matches nothing reports the same green as one that passed.
+ const optionCall = (flag: string): string | undefined => {
+ const chunk = captureSource.split('.option(').find((part) => part.trimStart().startsWith(flag));
+ if (chunk === undefined) return undefined;
+ // Chained calls are indented four spaces; stop at the next one.
+ const end = chunk.indexOf('\n .');
+ return end === -1 ? chunk : chunk.slice(0, end);
+ };
+ const diffOption = optionCall("'--diff '");
it('the option line exists to be checked', () => {
expect(diffOption).toBeDefined();
+ expect(diffOption).toContain('--diff ');
});
it('does not tell the caller the default is empty', () => {
@@ -107,6 +119,15 @@ describe('#359 capture --diff documents the default it actually has', () => {
expect(diffOption).toMatch(/staged/i);
});
+ // #877 narrowed what the flag means without changing what it reads. The
+ // transaction binds to the staged diff at prepare, verify and stage, so a
+ // description promising that `--diff` selects a different diff would be the
+ // #359 defect again in the other direction: the caller passes one, every
+ // record comes back `source-mismatch`, and the help text told them to.
+ it('does not promise that --diff selects a diff other than the staged one', () => {
+ expect(diffOption).toMatch(/assert|cannot (select|override)/i);
+ });
+
// Teeth: the sentence above is only worth asserting while the code still
// behaves that way. If the default ever moves off the staged diff, this fails
// and the help text has to be re-decided rather than quietly drifting again.
From 06cd4ab7c670c7a2ed53489b85237d58026e9133 Mon Sep 17 00:00:00 2001
From: operator
Date: Tue, 8 Sep 2026 18:25:14 +0900
Subject: [PATCH 2/3] fix(validate): name the X- form an unknown key is one
prefix away from
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`Claude-Session:` is neither a SPEC §3 key nor `X-`, so refusing it is
correct and it stays refused. What it cost is the point: Claude Code instructs
every session to end its commit message with that trailer, so the key arrives by
default, the hook refuses it, the commit is lost, and an agent that cannot see
the repository's earlier commits emits it again. Reported twice in one day in two
repositories by the same author, the second time after already knowing about the
first, because `X-` is not the kind of thing that stays in mind between
repositories. This repository's own history already carries the accepted form, so
the convention exists and nothing surfaced it at the point of refusal.
`X-` was in the answer and was not enough. The answer now names the key the
author could have written:
want "a key from SPEC §3, or X-Claude-Session if this is your own metadata"
SPEC §3 still leads the sentence, because for most unknown keys the protocol
vocabulary is the better answer. Nothing is accepted that was not accepted
before, and nothing is rewritten -- SPEC §6 says the message is not modified, and
silently renaming someone's trailer would be worse than refusing it.
Two keys are deliberately not offered the prefix. One whose value reads as a
sentence keeps the #647 answer: that author's problem is that their prose became
metadata, and `X-` is the wrong door for them -- pointing at it is what sent an
author to invent `X-Live:` the last time. One that differs from a SPEC §3 key
only by case keeps the plain answer too, because `limit:` is `Limit:` miswritten
and `X-limit` would be a valid record carrying the wrong key. That second guard
exists only because this change created the hazard; without it the fix would make
that case worse than it was.
There is no predicate separating `Claude-Session` from `Constraint` -- `X-` makes
almost any alphabetic key valid -- so the hint fires for both, and
spec/fixtures/invalid/03-unknown-key.expected.json moves with it. The violation
that fixture pins is unchanged and `Constraint:` is still refused; only the
advisory `want` differs, which test/validate.test.ts already records as prose
owned by src/core/schema.ts rather than part of the conformance contract, since
SPEC §9 pins the violation class. r-prosetrailer647 ruled the other way on a
conformance fixture once -- there the fixture caught a misclassification and the
heuristic changed. Nothing is misclassified here.
Verification ran the reporter's own case: the commit-msg refusal of
`Claude-Session:` now names `X-Claude-Session`, and still exits 1 with the
message unmodified. Negative control: with the constant restored, three of the
schema tests fail, including the conformance fixture comparison.
Closes #881
Record-Id: r-xprefixhint881
Provenance: authored
Ruled-out: hint only for hyphenated keys, to leave the fixture untouched | the predicate is a guess dressed as a rule -- a single-word vendor key would silently get no hint, and hyphenation describes protocol keys too
Ruled-out: carry the hint in a new Violation field instead of want | the conformance fixture is compared with toEqual, so an added field moves it exactly as changed text does, for a larger surface
Ruled-out: accept Claude-Session as a well-known foreign key | r-coauthoredcasing holds that exemption to a fixed pair of standardised trailers, and the reporter explicitly did not ask for the key to be accepted
Limit: `Constraint:` is now told about `X-Constraint` when `Limit:` is probably what it meant; the message names SPEC §3 first, but nothing points at the specific key it resembles
Limit: the case-insensitive guard covers SPEC §3 keys only, so `signed-off-by:` still gets the prefix offered even though the foreign-key exemption would have taken it
Warn: this is the second time #647's reasoning has been narrowed; the prose branch is what protects it, and folding the two branches together would undo both changes at once
Blast: module
Undo: easy
Certainty: firm
Verified: 57/57 in test/schema.test.ts, full suite 3192 passed 4 skipped 0 failed, tsc exit 0
Verified: seen failing with the old constant restored before it was seen passing
Unverified: whether any other advisory want in schema.ts describes a shape where it could name the value, as this one now does
Co-Authored-By: Claude
---
.../invalid/03-unknown-key.expected.json | 2 +-
src/core/schema.ts | 39 +++++++++++++++-
test/schema.test.ts | 44 +++++++++++++++++--
3 files changed, 80 insertions(+), 5 deletions(-)
diff --git a/spec/fixtures/invalid/03-unknown-key.expected.json b/spec/fixtures/invalid/03-unknown-key.expected.json
index 8d3a13f2..d6ccc0c0 100644
--- a/spec/fixtures/invalid/03-unknown-key.expected.json
+++ b/spec/fixtures/invalid/03-unknown-key.expected.json
@@ -4,6 +4,6 @@
{ "key": "Constraint", "value": "must ship by friday per the compliance deadline" }
],
"violations": [
- { "key": "Constraint", "value": "must ship by friday per the compliance deadline", "rule": "unknown-key", "got": "Constraint", "want": "a key from SPEC §3 or X-" }
+ { "key": "Constraint", "value": "must ship by friday per the compliance deadline", "rule": "unknown-key", "got": "Constraint", "want": "a key from SPEC §3, or X-Constraint if this is your own metadata" }
]
}
diff --git a/src/core/schema.ts b/src/core/schema.ts
index 4f72f8ef..9580a1ed 100644
--- a/src/core/schema.ts
+++ b/src/core/schema.ts
@@ -75,6 +75,43 @@ const FORMAT_WANT: Readonly> = {
const UNKNOWN_KEY_WANT = 'a key from SPEC §3 or X-';
+/**
+ * Name the `X-` form the author could have written, rather than its shape (#881).
+ *
+ * `X-` is already in the answer, and it was not enough: Claude Code tells
+ * every session to end its commit message with `Claude-Session:`, so the key
+ * arrives by default, the hook refuses it, the commit is lost, and the agent —
+ * which cannot see the repository's earlier commits — writes it again. Reported
+ * twice in one day in two repositories by the same author, the second time after
+ * already knowing about the first, because `X-` is not the kind of thing that
+ * stays in mind between repositories. Naming `X-Claude-Session` turns a refused
+ * commit into a corrected one.
+ *
+ * Not a rewrite: SPEC §6 says the message is not modified, and silently renaming
+ * someone's trailer would be worse than refusing it. This only says the name.
+ *
+ * Two keys are deliberately not offered the prefix:
+ *
+ * - one whose value reads as a sentence, which keeps the #647 answer instead —
+ * that author's problem is that their prose became metadata, and `X-` is the
+ * wrong door for them. That branch is chosen before this function is called.
+ * - one that differs from a SPEC §3 key only by case. `limit:` is `Limit:`
+ * miswritten, not an extension, and `X-limit` would be a valid record holding
+ * the wrong key. SPEC §3 matches case-sensitively and `KNOWN_KEYS` still does,
+ * so the plain answer — which names §3 first — is the useful one here.
+ *
+ * Anything the prefix cannot rescue (a leading digit, an underscore) falls back
+ * too: `EXTENSION_KEY_RE` is asked rather than assumed.
+ */
+const unknownKeyWant = (key: string): string => {
+ if ((KNOWN_KEYS as readonly string[]).some((known) => known.toLowerCase() === key.toLowerCase())) {
+ return UNKNOWN_KEY_WANT;
+ }
+ const prefixed = `X-${key}`;
+ if (!EXTENSION_KEY_RE.test(prefixed)) return UNKNOWN_KEY_WANT;
+ return `a key from SPEC §3, or ${prefixed} if this is your own metadata`;
+};
+
/**
* The same violation, told to someone who did not write a trailer (#647).
*
@@ -204,7 +241,7 @@ const violationFor = (trailer: Trailer, field: string): Violation | null => {
value: trailer.value,
rule: 'unknown-key',
got: trailer.key,
- want: looksLikeProse(trailer.value) ? PROSE_KEY_WANT : UNKNOWN_KEY_WANT,
+ want: looksLikeProse(trailer.value) ? PROSE_KEY_WANT : unknownKeyWant(trailer.key),
};
}
diff --git a/test/schema.test.ts b/test/schema.test.ts
index e02a05b4..746a1fbf 100644
--- a/test/schema.test.ts
+++ b/test/schema.test.ts
@@ -193,13 +193,51 @@ describe('validateRecord', () => {
});
// The other half, and the reason the value decides rather than the key: a
- // key someone meant as a key gets the short answer. spec/fixtures/invalid/
- // 03-unknown-key.txt pins this same reading through the conformance suite.
- it('keeps the plain answer for a key that was meant as a key', () => {
+ // key someone meant as a key is not told their sentence became metadata.
+ // What it is told changed in #881 — the answer now names the X- form the
+ // author could have written instead of describing its shape. spec/fixtures/
+ // invalid/03-unknown-key.json pins this same reading through the conformance
+ // suite; the violation it pins is unchanged, and `Constraint` is still
+ // refused. Only the advice moved, which test/validate.test.ts already
+ // records as advisory prose rather than the conformance contract.
+ it('names the extension form for a key that was meant as a key', () => {
const violations = validateRecord([
{ key: 'Constraint', value: 'must ship by friday per the compliance deadline' },
]);
+ expect(violations.map((v) => v.rule)).toEqual(['unknown-key']);
+ expect(violations[0]?.want).toBe(
+ 'a key from SPEC §3, or X-Constraint if this is your own metadata',
+ );
+ });
+
+ // #881: the reported case. Claude Code tells every session to end its commit
+ // message with this key, so it arrives by default and the refusal has to
+ // carry the repair or the same commit is written, refused and written again.
+ it('names X-Claude-Session when refusing Claude-Session', () => {
+ const violations = validateRecord([
+ { key: 'Claude-Session', value: 'https://claude.ai/code/session_abc123' },
+ ]);
+
+ expect(violations.map((v) => v.rule)).toEqual(['unknown-key']);
+ expect(violations[0]?.want).toContain('X-Claude-Session');
+ expect(violations[0]?.want, 'SPEC §3 still comes first').toContain('a key from SPEC §3');
+ });
+
+ // The guard the hint made necessary. `limit:` is `Limit:` miswritten, not an
+ // extension, and `X-limit` would be a valid record carrying the wrong key.
+ it('does not offer the X- form for a SPEC §3 key that differs only by case', () => {
+ const violations = validateRecord([{ key: 'limit', value: 'the cache times out at 30s' }]);
+
+ expect(violations.map((v) => v.rule)).toEqual(['unknown-key']);
+ expect(violations[0]?.want).toBe('a key from SPEC §3 or X-');
+ });
+
+ // A key the prefix cannot rescue: EXTENSION_KEY_RE requires a letter after
+ // `X-`, so `X-2fa-mode` is not valid either and must not be suggested.
+ it('keeps the plain answer for a key that X- cannot make valid', () => {
+ const violations = validateRecord([{ key: '2fa-mode', value: 'totp' }]);
+
expect(violations.map((v) => v.rule)).toEqual(['unknown-key']);
expect(violations[0]?.want).toBe('a key from SPEC §3 or X-');
});
From 9d03977aa46f09afee73b5450d4f4cb4d397f80a Mon Sep 17 00:00:00 2001
From: operator
Date: Tue, 8 Sep 2026 18:25:38 +0900
Subject: [PATCH 3/3] release: 1.2.5
Two capture flags that did nothing and said nothing, and a refusal that knew the
fix and did not say it: #877, #878, #881.
Thirty-six version pins across eleven files. The JSON manifests were bumped by
parsing the document rather than replacing text, because `"version": "1.2.4"`
also matches `"rolldown": "~1.2.4"` in the lockfile -- a dependency genuinely at
that version, which no test reads, so corrupting it would be silent. The READMEs
and installers were anchored to the three install shapes rather than to the bare
version string, because README.md also carries prose about the v1.0.2 release
boundary that is a historical fact and must not move.
server.json's installer one-liner and release URL are under the registry's
publisher-provided `_meta` block, not under `packages`, which is where a path
written from memory put them; the completeness check caught it before anything
was written. The bump script re-reads every file it touched afterwards and
reports every remaining occurrence of the old version, so the one that is
supposed to remain is seen rather than assumed.
Record-Id: r-release125
Provenance: authored
Ruled-out: text-replacing the version across all manifests | it also matches four dependencies genuinely at that version, and no test reads them
Ruled-out: bumping every v1.2.4 found in the READMEs | README.md's release-boundary prose is a historical statement, and moving it makes the document say something false that no test checks
Limit: dist/ and installer/canonical-artifact.json are not in this commit, so artifact:verify fails on this tree by design -- canonical-merge.yml rebuilds them on linux/amd64, where a macOS esbuild output would not match
Blast: system
Undo: easy
Certainty: firm
Verified: 156/156 across manifest, readme, release-version, check-release-version and install-script tests
Verified: the only remaining 1.2.4 in the eleven touched files is package-lock.json:2744 "rolldown": "~1.2.4"
Unverified: the install one-liners cannot be exercised until the tag exists; install.sh clones a pinned tag, so any check before that reads the previous release
Co-Authored-By: Claude
---
.claude-plugin/plugin.json | 2 +-
.codex-plugin/plugin.json | 2 +-
CHANGELOG.md | 59 ++++++++++++++++++++++++++++++++++++++
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, 95 insertions(+), 36 deletions(-)
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index b6be6fb4..53c5ca90 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "commitlore",
"displayName": "CommitLore",
- "version": "1.2.4",
+ "version": "1.2.5",
"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 da184b0d..268d8063 100644
--- a/.codex-plugin/plugin.json
+++ b/.codex-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "commitlore",
- "version": "1.2.4",
+ "version": "1.2.5",
"description": "Decision memory from Git history, with verified capture for coding sessions.",
"author": {
"name": "MongLong0214",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2bf2c752..e668d61e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,65 @@ 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.5
+
+Two flags on `capture` did nothing and said nothing; a third refusal knew the
+fix and did not say it.
+
+**`capture --out` exited 0 and never wrote the file (#878).** The flag is
+documented as "write the pending nonce to a file", and prompt-only mode — get the
+nonce, hand the prompt to a model, come back with `--draft` — is the step it
+exists for. That was the one run where the pipeline reported `nonce: null` while
+prepare had already persisted a real transaction under a real nonce, so the write
+was guarded out. Measured by the reporter as 3 runs, 3 exit 0, 3 missing files,
+with and without `--diff`. The nonce is now reported, so `--out` writes it and
+`--json` carries it.
+
+**`capture --diff` was accepted, ignored by prepare, and honoured by verify
+(#877).** Passing the *same* `--diff` file to both steps failed whenever that
+file was not byte-identical to the staged diff:
+`discarded record 0 (source-mismatch): diff hash does not match the prepared
+transaction`. That message is about the draft's sources, and the fault was
+entirely the flag's — the reporter spent several attempts re-checking quotes and
+locators that were never wrong.
+
+The refusal itself was right and stays. A capture transaction binds to the staged
+diff, which prepare, verify and stage each recompute independently, because every
+binding is computed server-side and never from the caller. `--diff` cannot select
+a different diff; it can only assert what is staged. So it is now refused where
+that is decidable — up front, exit 2, naming the flag and naming the way out
+(`git reset --soft`, which is what the reporter had to find on their own) — rather
+than several steps later against the record. A run refused this way also leaves no
+pending transaction behind; the old path wrote two per attempt.
+
+`--diff` byte-identical to the staged diff keeps working, and the help text now
+says what the flag does rather than implying an override it never had.
+
+**`unknown-key` now names the `X-` form the author could have written (#881).**
+Claude Code instructs every session to end its commit message with
+`Claude-Session:`, so the key arrives by default, the hook refuses it, the commit
+is lost, and an agent that cannot see the repository's earlier commits writes it
+again. Reported twice in one day in two repositories by the same author, the
+second time after already knowing about the first, because `X-` is not the kind of
+thing that stays in mind between repositories.
+
+```
+31: unknown-key Claude-Session — got "Claude-Session",
+ want "a key from SPEC §3, or X-Claude-Session if this is your own metadata"
+```
+
+Nothing is accepted that was not accepted before, and nothing is rewritten: SPEC
+§6 says the message is not modified, and silently renaming someone's trailer would
+be worse than refusing it. This only says the name. Two keys are deliberately not
+offered the prefix — one whose value reads as a sentence, which keeps the #647
+answer, because that author's problem is that their prose became metadata; and one
+that differs from a SPEC §3 key only by case, because `X-limit` would be a valid
+record carrying the wrong key.
+
+`spec/fixtures/invalid/03-unknown-key.expected.json` moves with it. The violation
+it pins is unchanged and `Constraint:` is still refused; only the advisory `want`
+text differs.
+
## 1.2.4
`doctor` blamed the hook that worked, and prescribed reinstalling it.
diff --git a/README.ja.md b/README.ja.md
index 2fb227b1..1977f407 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -47,18 +47,18 @@
```bash
-curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5
```
先にインストーラーを読みたいですか?
```bash
-curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh
-sh install.sh v1.2.4
+curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh
+sh install.sh v1.2.5
# あるいはスクリプトを使わずに。スクリプトが作るチェックアウトは自分でも作れます。
-git clone --depth 1 --branch v1.2.4 https://github.com/MongLong0214/commitlore
+git clone --depth 1 --branch v1.2.5 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.4/install.sh | sh -s v1.2.4
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5
```
Windows:
```powershell
-& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4
+& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.ps1))) v1.2.5
```
Node.js 22.23.2+ と Git が必要です。スクリプトは何かを書き込む前に両方を確認します。
diff --git a/README.ko.md b/README.ko.md
index a5883f2c..6b39adb0 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -47,18 +47,18 @@
```bash
-curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5
```
먼저 설치기를 읽어 보고 싶나요?
```bash
-curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh
-sh install.sh v1.2.4
+curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh
+sh install.sh v1.2.5
# 또는 스크립트를 건너뜁니다. 스크립트가 만드는 체크아웃은 직접 만들 수 있습니다.
-git clone --depth 1 --branch v1.2.4 https://github.com/MongLong0214/commitlore
+git clone --depth 1 --branch v1.2.5 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.4/install.sh | sh -s v1.2.4
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5
```
Windows:
```powershell
-& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4
+& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.ps1))) v1.2.5
```
Node.js 22.23.2+와 Git이 필요합니다. 스크립트는 무엇이든 쓰기 전에 둘을 확인합니다.
diff --git a/README.md b/README.md
index 365acbd0..3a242739 100644
--- a/README.md
+++ b/README.md
@@ -48,18 +48,18 @@
```bash
-curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5
```
Prefer to read the installer first?
```bash
-curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh
-sh install.sh v1.2.4
+curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh
+sh install.sh v1.2.5
# Or skip the script: the checkout it makes is one you can make yourself.
-git clone --depth 1 --branch v1.2.4 https://github.com/MongLong0214/commitlore
+git clone --depth 1 --branch v1.2.5 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.4/install.sh | sh -s v1.2.4
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5
```
Windows:
```powershell
-& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4
+& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.ps1))) v1.2.5
```
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 8790036f..df475c1a 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.4/install.sh | sh -s v1.2.4
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5
```
想先阅读安装器吗?
```bash
-curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh
-sh install.sh v1.2.4
+curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh
+sh install.sh v1.2.5
# 或者跳过脚本:它创建的检出,你自己也能创建。
-git clone --depth 1 --branch v1.2.4 https://github.com/MongLong0214/commitlore
+git clone --depth 1 --branch v1.2.5 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.4/install.sh | sh -s v1.2.4
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.sh | sh -s v1.2.5
```
Windows:
```powershell
-& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4
+& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.5/install.ps1))) v1.2.5
```
需要 Node.js 22.23.2+ 和 Git。脚本会在写入任何内容前检查两者。
diff --git a/install.ps1 b/install.ps1
index 24558bcb..7d38ee6f 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.4/install.ps1 | iex
- & ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4
+ 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
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 c69df874..c1d78dce 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.4/install.sh | sh
-# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4
+# 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
#
# **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 5730f28a..afc2d547 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "commitlore",
- "version": "1.2.4",
+ "version": "1.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "commitlore",
- "version": "1.2.4",
+ "version": "1.2.5",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
diff --git a/package.json b/package.json
index b1b7b646..aaccc9ff 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "commitlore",
- "version": "1.2.4",
+ "version": "1.2.5",
"description": "Git-native, lifecycle-aware decision memory for coding agents",
"license": "MIT",
"private": true,
diff --git a/server.json b/server.json
index 6e4f9cb6..19048891 100644
--- a/server.json
+++ b/server.json
@@ -8,7 +8,7 @@
"source": "github"
},
"websiteUrl": "https://github.com/MongLong0214/commitlore#readme",
- "version": "1.2.4",
+ "version": "1.2.5",
"_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.4/install.sh | sh -s v1.2.4",
- "release": "https://github.com/MongLong0214/commitlore/releases/tag/v1.2.4"
+ "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"
},
"runtime": {
"transport": "stdio",