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",