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",
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/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/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/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.
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-');
});