diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index 018f0384..b6be6fb4 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "commitlore",
"displayName": "CommitLore",
- "version": "1.2.3",
+ "version": "1.2.4",
"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 15cc39b1..da184b0d 100644
--- a/.codex-plugin/plugin.json
+++ b/.codex-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "commitlore",
- "version": "1.2.3",
+ "version": "1.2.4",
"description": "Decision memory from Git history, with verified capture for coding sessions.",
"author": {
"name": "MongLong0214",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8150c2b6..2bf2c752 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,39 @@ 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.4
+
+`doctor` blamed the hook that worked, and prescribed reinstalling it.
+
+**`hook-runtime` attributed a preserved hook's failure to commitlore's hook
+(#876).** The installed stub runs the hook it preserved at install time first and
+exits with that hook's code, verbatim, before commitlore is resolved. Probed as
+one process, a preserved hook that called `node` by name died with 127 under
+git's PATH and the row read "the hook cannot find a node interpreter", with
+`commitlore hooks install` as the fix. That command rewrites only commitlore's
+own file; it reported the file unchanged, and the next `doctor` failed
+identically. The reporter spent several minutes repairing the wrong component,
+because the diagnostic named it.
+
+**The preserved hook now runs on its own first**, under the same PATH-less
+environment and through `sh` the way the stub invokes it. If it exits non-zero,
+the row says commitlore's hook is not what failed, names the preserved hook by
+path, classifies its first stderr line the same way the stub's was (node
+missing, node threw, unclear), and prescribes a fix aimed at that file. Only once
+it has passed does the stub run, so every remaining failure is commitlore's own
+resolution and `hooks install` is once again a remedy that can move it.
+
+**`commit-msg-hook` inherits the fix along with the outcome.** It was the row the
+reporter read: it already carried the runtime row's outcome when blocked on it,
+and kept saying `hooks install` under an outcome that had just explained why
+that could not help.
+
+Not changed: a broken preserved hook still blocks every commit. That is the
+chaining contract — a hook that was rejecting commits before commitlore arrived
+must keep rejecting them — and whether a hook that is broken rather than
+rejecting deserves different treatment is a separate decision from a diagnostic
+one.
+
## 1.2.3
The capture prompt was the session, so on a long session there was no prompt.
diff --git a/README.ja.md b/README.ja.md
index 1ccf4a88..2fb227b1 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -47,18 +47,18 @@
```bash
-curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4
```
先にインストーラーを読みたいですか?
```bash
-curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh
-sh install.sh v1.2.3
+curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh
+sh install.sh v1.2.4
# あるいはスクリプトを使わずに。スクリプトが作るチェックアウトは自分でも作れます。
-git clone --depth 1 --branch v1.2.3 https://github.com/MongLong0214/commitlore
+git clone --depth 1 --branch v1.2.4 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.3/install.sh | sh -s v1.2.3
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4
```
Windows:
```powershell
-& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.ps1))) v1.2.3
+& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4
```
Node.js 22.23.2+ と Git が必要です。スクリプトは何かを書き込む前に両方を確認します。
diff --git a/README.ko.md b/README.ko.md
index c5bb0b9f..a5883f2c 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -47,18 +47,18 @@
```bash
-curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4
```
먼저 설치기를 읽어 보고 싶나요?
```bash
-curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh
-sh install.sh v1.2.3
+curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh
+sh install.sh v1.2.4
# 또는 스크립트를 건너뜁니다. 스크립트가 만드는 체크아웃은 직접 만들 수 있습니다.
-git clone --depth 1 --branch v1.2.3 https://github.com/MongLong0214/commitlore
+git clone --depth 1 --branch v1.2.4 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.3/install.sh | sh -s v1.2.3
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4
```
Windows:
```powershell
-& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.ps1))) v1.2.3
+& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4
```
Node.js 22.23.2+와 Git이 필요합니다. 스크립트는 무엇이든 쓰기 전에 둘을 확인합니다.
diff --git a/README.md b/README.md
index 9c0d0634..365acbd0 100644
--- a/README.md
+++ b/README.md
@@ -48,18 +48,18 @@
```bash
-curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4
```
Prefer to read the installer first?
```bash
-curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh
-sh install.sh v1.2.3
+curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh
+sh install.sh v1.2.4
# Or skip the script: the checkout it makes is one you can make yourself.
-git clone --depth 1 --branch v1.2.3 https://github.com/MongLong0214/commitlore
+git clone --depth 1 --branch v1.2.4 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.3/install.sh | sh -s v1.2.3
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4
```
Windows:
```powershell
-& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.ps1))) v1.2.3
+& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4
```
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 337ab275..8790036f 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.3/install.sh | sh -s v1.2.3
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4
```
想先阅读安装器吗?
```bash
-curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh
-sh install.sh v1.2.3
+curl -fsSLO https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh
+sh install.sh v1.2.4
# 或者跳过脚本:它创建的检出,你自己也能创建。
-git clone --depth 1 --branch v1.2.3 https://github.com/MongLong0214/commitlore
+git clone --depth 1 --branch v1.2.4 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.3/install.sh | sh -s v1.2.3
+curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.sh | sh -s v1.2.4
```
Windows:
```powershell
-& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.ps1))) v1.2.3
+& ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.4/install.ps1))) v1.2.4
```
需要 Node.js 22.23.2+ 和 Git。脚本会在写入任何内容前检查两者。
diff --git a/dist/commands/doctor/checks/capture-commit-msg-hook.js b/dist/commands/doctor/checks/capture-commit-msg-hook.js
index a71a5fd0..63e07625 100644
--- a/dist/commands/doctor/checks/capture-commit-msg-hook.js
+++ b/dist/commands/doctor/checks/capture-commit-msg-hook.js
@@ -67,6 +67,11 @@ export const checkHook = (ctx, runtime) => {
];
if (runtime !== undefined && runtime.status !== 'ok') {
const inherited = `installed at ${path}; ${targetDetail}; outcome: ${runtime.detail}`;
+ // This row is blocked on the runtime's finding, so the fix that moves that
+ // finding is the only fix that moves this one. Prescribing `hooks install`
+ // here regardless is how a preserved hook's failure came to carry a remedy
+ // that reinstalls the hook which worked (#876).
+ const inheritedFix = runtime.fix ?? install;
// A skipped runtime would make this row a skip too, and a skip has to name
// a reason. Inheriting the runtime's is the only answer that stays true —
// this row did not look for the same reason that one did not. The branch is
@@ -74,12 +79,12 @@ export const checkHook = (ctx, runtime) => {
// written out rather than cast away so that adding one cannot silently
// produce a reasonless skip here.
if (runtime.status === 'skipped') {
- return blocked(runtime, check(id, category, title, 'skipped', inherited, install, false, false, {
+ return blocked(runtime, check(id, category, title, 'skipped', inherited, inheritedFix, false, false, {
evidence: { ...hookEvidence, runtime_status: runtime.status },
skipReason: runtime.skipReason ?? 'nothing_applicable',
}));
}
- return blocked(runtime, check(id, category, title, runtime.status, inherited, install, false, undefined, { evidence: { ...hookEvidence, runtime_status: runtime.status } }));
+ return blocked(runtime, check(id, category, title, runtime.status, inherited, inheritedFix, false, undefined, { evidence: { ...hookEvidence, runtime_status: runtime.status } }));
}
return problems.length === 0
? check(id, category, title, 'ok', `installed at ${path}; ${targetDetail}`, null, false, undefined, { evidence: hookEvidence })
diff --git a/dist/commands/doctor/checks/capture-commit-msg-hook.js.map b/dist/commands/doctor/checks/capture-commit-msg-hook.js.map
index 161f9d91..50f3a7f2 100644
--- a/dist/commands/doctor/checks/capture-commit-msg-hook.js.map
+++ b/dist/commands/doctor/checks/capture-commit-msg-hook.js.map
@@ -1 +1 @@
-{"version":3,"file":"capture-commit-msg-hook.js","sourceRoot":"","sources":["../../../../src/commands/doctor/checks/capture-commit-msg-hook.ts"],"names":[],"mappings":"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"}
\ No newline at end of file
+{"version":3,"file":"capture-commit-msg-hook.js","sourceRoot":"","sources":["../../../../src/commands/doctor/checks/capture-commit-msg-hook.ts"],"names":[],"mappings":"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"}
\ No newline at end of file
diff --git a/dist/commands/doctor/checks/capture-hook-runtime.d.ts b/dist/commands/doctor/checks/capture-hook-runtime.d.ts
index 3ac266f9..8e935a1f 100644
--- a/dist/commands/doctor/checks/capture-hook-runtime.d.ts
+++ b/dist/commands/doctor/checks/capture-hook-runtime.d.ts
@@ -23,5 +23,11 @@ import { type DoctorCheck, type DoctorContext } from '../model.js';
* The probe message is valid, so a healthy hook exits 0. A hook that cannot find
* a runtime exits non-zero having parsed nothing, which is indistinguishable
* from "your message was fine" to everyone except this check.
+ *
+ * Two hooks run here, not one. The stub hands the message to the hook it
+ * preserved at install time before it resolves commitlore, and exits with that
+ * hook's code if it fails -- so the preserved hook is probed on its own first,
+ * and its failure is reported as its own, with a fix aimed at it. `hooks
+ * install` cannot move a finding about a file it does not write (#876).
*/
export declare const checkHookRuntime: (ctx: DoctorContext) => DoctorCheck;
diff --git a/dist/commands/doctor/checks/capture-hook-runtime.js b/dist/commands/doctor/checks/capture-hook-runtime.js
index dd1c96f0..512df761 100644
--- a/dist/commands/doctor/checks/capture-hook-runtime.js
+++ b/dist/commands/doctor/checks/capture-hook-runtime.js
@@ -4,10 +4,38 @@
* It owns execution of the installed hook under Git's environment; consumers
* receive its completed row through the registry rather than importing it.
*/
-import { existsSync, rmSync, writeFileSync } from 'node:fs';
+import { accessSync, constants as fsConstants, existsSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir as tmpdirPath } from 'node:os';
-import { join, resolve } from 'node:path';
+import { dirname, join, resolve } from 'node:path';
+import { CHAINED_HOOK_NAME } from '../../../hooks/commit-msg.js';
import { check, gitOptions, PROBE_MESSAGE, streamEvidence } from '../model.js';
+/**
+ * The stub runs `"$chained" "$@"` only when `[ -x "$chained" ]` holds, so a
+ * preserved hook without its execute bit is inert to git and to the stub alike.
+ * The same test here, so this check does not probe a file the hook will skip.
+ */
+const isExecutable = (path) => {
+ try {
+ accessSync(path, fsConstants.X_OK);
+ return true;
+ }
+ catch {
+ return false;
+ }
+};
+/**
+ * How the stub's failure reads on its first stderr line: node was never found,
+ * node ran and threw, or neither. Shared between the two hooks this check runs,
+ * because the preserved hook fails in the same three shapes and the
+ * classification is about the line, not about who wrote it.
+ */
+const classifyFailure = (status, said) => {
+ if (status === 127 || /\bnode\b.*not found|ENOENT|command not found.*\bnode\b/i.test(said))
+ return 'node-missing';
+ if (/^\s*at\s|\.js:\d+/.test(said))
+ return 'node-threw';
+ return 'unclear';
+};
/**
* Whether the installed hook actually runs, in the environment git gives it.
*
@@ -26,6 +54,12 @@ import { check, gitOptions, PROBE_MESSAGE, streamEvidence } from '../model.js';
* The probe message is valid, so a healthy hook exits 0. A hook that cannot find
* a runtime exits non-zero having parsed nothing, which is indistinguishable
* from "your message was fine" to everyone except this check.
+ *
+ * Two hooks run here, not one. The stub hands the message to the hook it
+ * preserved at install time before it resolves commitlore, and exits with that
+ * hook's code if it fails -- so the preserved hook is probed on its own first,
+ * and its failure is reported as its own, with a fix aimed at it. `hooks
+ * install` cannot move a finding about a file it does not write (#876).
*/
export const checkHookRuntime = (ctx) => {
const { opts, git, spawn, env } = ctx;
@@ -51,15 +85,59 @@ export const checkHookRuntime = (ctx) => {
return check(id, category, title, 'ok', 'no hook installed — nothing to run', null, false, undefined, { evidence: { hook_path: hook } });
}
const probe = join(tmpdirPath(), `commitlore-doctor-${String(process.pid)}.txt`);
+ // No node, and no PATH entry that could supply one. `git` must stay
+ // reachable: the hook reads its own config through it.
+ const hookEnv = { PATH: '/usr/bin:/bin', HOME: env['HOME'] ?? '' };
try {
+ // The stub runs the hook it preserved at install time first, and that
+ // hook's non-zero exit is the stub's exit, verbatim, before commitlore is
+ // reached. Probed through the stub alone, the two are one process with one
+ // stderr, and the row attributed a preserved hook's `node: command not
+ // found` to the installed hook and prescribed `hooks install` -- which
+ // reports the file unchanged, because the file it writes was never the one
+ // failing (#876). So the preserved hook runs on its own first, the way the
+ // stub runs it: through sh, so a script without a shebang behaves the same
+ // here as it does there.
+ const chained = join(dirname(hook), CHAINED_HOOK_NAME);
+ if (isExecutable(chained)) {
+ writeFileSync(probe, PROBE_MESSAGE);
+ const preserved = spawn('/bin/sh', ['-c', '"$0" "$1"', chained, probe], {
+ shell: false,
+ encoding: 'utf8',
+ cwd,
+ env: hookEnv,
+ });
+ const exit = preserved.error === undefined ? preserved.status : null;
+ if (preserved.error !== undefined || exit !== 0) {
+ const spoke = `${preserved.stderr ?? ''}`.trim();
+ const said = preserved.error?.message ?? (spoke.split('\n')[0] ?? '');
+ const shape = preserved.error === undefined ? classifyFailure(exit, said) : 'unclear';
+ const because = shape === 'node-missing'
+ ? `it calls node by name and git's PATH has none: ${said}`
+ : shape === 'node-threw'
+ ? `its node process ran but threw (exit ${String(exit)}): ${said}`
+ : `it exited ${String(exit ?? 'unavailable')} under the restricted PATH: ${said || 'no output'}`;
+ return check(id, category, title, 'fail', `commitlore's hook is not what failed. It runs the hook it preserved first, and that hook -- ${chained} -- stops the commit before commitlore is reached: ${because}. That file was this repository's commit-msg hook before commitlore was installed; \`hooks install\` rewrites only commitlore's own and leaves it as it is`, shape === 'node-missing'
+ ? `edit ${chained} to call node by absolute path (or remove it if it is no longer wanted)`
+ : `fix or remove ${chained}`, false, undefined, {
+ evidence: {
+ hook_path: hook,
+ chained_hook_path: chained,
+ exit_code: String(exit ?? 'unavailable'),
+ ...(preserved.error === undefined ? {} : { error: preserved.error.message }),
+ ...streamEvidence('stderr', preserved.stderr ?? ''),
+ },
+ });
+ }
+ }
+ // A commit-msg hook may rewrite the message it is given; the probe is
+ // written again so the stub reads the same bytes the preserved hook did.
writeFileSync(probe, PROBE_MESSAGE);
const run = spawn('/bin/sh', [hook, probe], {
shell: false,
encoding: 'utf8',
cwd,
- // No node, and no PATH entry that could supply one. `git` must stay
- // reachable: the hook reads its own config through it.
- env: { PATH: '/usr/bin:/bin', HOME: env['HOME'] ?? '' },
+ env: hookEnv,
});
if (run.error !== undefined) {
return check(id, category, title, 'fail', `could not run the hook: ${run.error.message}`, fix, false, undefined, {
@@ -74,9 +152,11 @@ export const checkHookRuntime = (ctx) => {
if (run.status !== 0) {
const spoke = `${run.stderr ?? ''}`.trim();
const said = spoke.split('\n')[0] ?? '';
- const nodeMissing = run.status === 127 ||
- /\bnode\b.*not found|ENOENT|command not found.*\bnode\b/i.test(said);
- const nodeThrew = /^\s*at\s|\.js:\d+/.test(said);
+ // The preserved hook, if any, has already exited 0 on its own above, so
+ // whatever follows is commitlore's resolution failing, not a hand-off.
+ const shape = classifyFailure(run.status, said);
+ const nodeMissing = shape === 'node-missing';
+ const nodeThrew = shape === 'node-threw';
// The stub says this when the recorded pair resolved and the containment
// check refused it: present, executable, and under a tree this install
// did not record. An upgrade produces it, because `commitlore.bin` follows
diff --git a/dist/commands/doctor/checks/capture-hook-runtime.js.map b/dist/commands/doctor/checks/capture-hook-runtime.js.map
index f888fe44..d4d4d40d 100644
--- a/dist/commands/doctor/checks/capture-hook-runtime.js.map
+++ b/dist/commands/doctor/checks/capture-hook-runtime.js.map
@@ -1 +1 @@
-{"version":3,"file":"capture-hook-runtime.js","sourceRoot":"","sources":["../../../../src/commands/doctor/checks/capture-hook-runtime.ts"],"names":[],"mappings":"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"}
\ No newline at end of file
+{"version":3,"file":"capture-hook-runtime.js","sourceRoot":"","sources":["../../../../src/commands/doctor/checks/capture-hook-runtime.ts"],"names":[],"mappings":"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"}
\ No newline at end of file
diff --git a/dist/commitlore.mjs b/dist/commitlore.mjs
index 805633f5..a0cfcb6b 100755
--- a/dist/commitlore.mjs
+++ b/dist/commitlore.mjs
@@ -18365,7 +18365,7 @@ var register3 = (program3) => {
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync as rmSync6, writeFileSync as writeFileSync14, mkdirSync as mkdirSync10 } from "node:fs";
import { tmpdir as tmpdir2 } from "node:os";
-import { dirname as dirname11, join as join17, resolve as resolve17 } from "node:path";
+import { dirname as dirname12, join as join17, resolve as resolve17 } from "node:path";
// src/demo/fixture.ts
var targetPath = "src/pricing.ts";
@@ -19782,6 +19782,7 @@ var checkHook = (ctx, runtime) => {
];
if (runtime !== void 0 && runtime.status !== "ok") {
const inherited = `installed at ${path2}; ${targetDetail}; outcome: ${runtime.detail}`;
+ const inheritedFix = runtime.fix ?? install;
if (runtime.status === "skipped") {
return blocked(
runtime,
@@ -19791,7 +19792,7 @@ var checkHook = (ctx, runtime) => {
title2,
"skipped",
inherited,
- install,
+ inheritedFix,
false,
false,
{
@@ -19809,7 +19810,7 @@ var checkHook = (ctx, runtime) => {
title2,
runtime.status,
inherited,
- install,
+ inheritedFix,
false,
void 0,
{ evidence: { ...hookEvidence, runtime_status: runtime.status } }
@@ -19840,9 +19841,22 @@ var checkHook = (ctx, runtime) => {
};
// src/commands/doctor/checks/capture-hook-runtime.ts
-import { existsSync as existsSync11, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "node:fs";
+import { accessSync as accessSync2, constants as fsConstants, existsSync as existsSync11, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "node:fs";
import { tmpdir as tmpdirPath } from "node:os";
-import { join as join8, resolve as resolve9 } from "node:path";
+import { dirname as dirname7, join as join8, resolve as resolve9 } from "node:path";
+var isExecutable = (path2) => {
+ try {
+ accessSync2(path2, fsConstants.X_OK);
+ return true;
+ } catch {
+ return false;
+ }
+};
+var classifyFailure = (status, said) => {
+ if (status === 127 || /\bnode\b.*not found|ENOENT|command not found.*\bnode\b/i.test(said)) return "node-missing";
+ if (/^\s*at\s|\.js:\d+/.test(said)) return "node-threw";
+ return "unclear";
+};
var checkHookRuntime = (ctx) => {
const { opts, git: git2, spawn: spawn3, env } = ctx;
const title2 = "hook runtime";
@@ -19885,15 +19899,50 @@ var checkHookRuntime = (ctx) => {
);
}
const probe = join8(tmpdirPath(), `commitlore-doctor-${String(process.pid)}.txt`);
+ const hookEnv = { PATH: "/usr/bin:/bin", HOME: env["HOME"] ?? "" };
try {
+ const chained = join8(dirname7(hook), CHAINED_HOOK_NAME);
+ if (isExecutable(chained)) {
+ writeFileSync5(probe, PROBE_MESSAGE);
+ const preserved = spawn3("/bin/sh", ["-c", '"$0" "$1"', chained, probe], {
+ shell: false,
+ encoding: "utf8",
+ cwd,
+ env: hookEnv
+ });
+ const exit = preserved.error === void 0 ? preserved.status : null;
+ if (preserved.error !== void 0 || exit !== 0) {
+ const spoke = `${preserved.stderr ?? ""}`.trim();
+ const said = preserved.error?.message ?? (spoke.split("\n")[0] ?? "");
+ const shape = preserved.error === void 0 ? classifyFailure(exit, said) : "unclear";
+ const because = shape === "node-missing" ? `it calls node by name and git's PATH has none: ${said}` : shape === "node-threw" ? `its node process ran but threw (exit ${String(exit)}): ${said}` : `it exited ${String(exit ?? "unavailable")} under the restricted PATH: ${said || "no output"}`;
+ return check(
+ id2,
+ category2,
+ title2,
+ "fail",
+ `commitlore's hook is not what failed. It runs the hook it preserved first, and that hook -- ${chained} -- stops the commit before commitlore is reached: ${because}. That file was this repository's commit-msg hook before commitlore was installed; \`hooks install\` rewrites only commitlore's own and leaves it as it is`,
+ shape === "node-missing" ? `edit ${chained} to call node by absolute path (or remove it if it is no longer wanted)` : `fix or remove ${chained}`,
+ false,
+ void 0,
+ {
+ evidence: {
+ hook_path: hook,
+ chained_hook_path: chained,
+ exit_code: String(exit ?? "unavailable"),
+ ...preserved.error === void 0 ? {} : { error: preserved.error.message },
+ ...streamEvidence("stderr", preserved.stderr ?? "")
+ }
+ }
+ );
+ }
+ }
writeFileSync5(probe, PROBE_MESSAGE);
const run = spawn3("/bin/sh", [hook, probe], {
shell: false,
encoding: "utf8",
cwd,
- // No node, and no PATH entry that could supply one. `git` must stay
- // reachable: the hook reads its own config through it.
- env: { PATH: "/usr/bin:/bin", HOME: env["HOME"] ?? "" }
+ env: hookEnv
});
if (run.error !== void 0) {
return check(
@@ -19918,8 +19967,9 @@ var checkHookRuntime = (ctx) => {
if (run.status !== 0) {
const spoke = `${run.stderr ?? ""}`.trim();
const said = spoke.split("\n")[0] ?? "";
- const nodeMissing = run.status === 127 || /\bnode\b.*not found|ENOENT|command not found.*\bnode\b/i.test(said);
- const nodeThrew = /^\s*at\s|\.js:\d+/.test(said);
+ const shape = classifyFailure(run.status, said);
+ const nodeMissing = shape === "node-missing";
+ const nodeThrew = shape === "node-threw";
const containmentRefused2 = /outside the install this hook trusts/.test(spoke);
let detail;
if (containmentRefused2) {
@@ -20998,7 +21048,7 @@ var checkDirectiveTrustMode = (ctx) => {
// src/mcp/lifecycle.ts
import { appendFileSync, mkdirSync as mkdirSync4, readFileSync as readFileSync14, statSync as statSync6, writeFileSync as writeFileSync7, writeSync } from "node:fs";
-import { dirname as dirname7, join as join10, resolve as resolve10 } from "node:path";
+import { dirname as dirname8, join as join10, resolve as resolve10 } from "node:path";
var MAX_BYTES = 64 * 1024;
var LIFECYCLE_FILE = "mcp-lifecycle.log";
var lifecyclePath = (cwd = process.cwd()) => {
@@ -21019,7 +21069,7 @@ var write = (cwd, line2) => {
try {
const path2 = lifecyclePath(cwd);
if (path2 === null) return;
- mkdirSync4(dirname7(path2), { recursive: true });
+ mkdirSync4(dirname8(path2), { recursive: true });
appendFileSync(path2, `${line2}
`);
trim(path2);
@@ -22035,7 +22085,7 @@ var checkInstallationIntegrity = (_ctx) => {
import { spawn as spawn2, spawnSync as spawnSync6 } from "node:child_process";
import { mkdirSync as mkdirSync5, readFileSync as readFileSync15, renameSync as renameSync4, rmSync as rmSync3, writeFileSync as writeFileSync8 } from "node:fs";
import { homedir as homedir2, tmpdir } from "node:os";
-import { dirname as dirname8, join as join12 } from "node:path";
+import { dirname as dirname9, join as join12 } from "node:path";
// src/core/release-version.ts
var RELEASE_TAG = /^v?(\d+)\.(\d+)\.(\d+)$/;
@@ -22106,7 +22156,7 @@ var readCache = (path2) => {
};
var writeCache = (path2, entry) => {
try {
- mkdirSync5(dirname8(path2), { recursive: true });
+ mkdirSync5(dirname9(path2), { recursive: true });
const scratch = `${path2}.${process.pid}.tmp`;
writeFileSync8(scratch, `${JSON.stringify(entry)}
`, "utf8");
@@ -22914,7 +22964,7 @@ import {
unlinkSync as unlinkSync5,
writeFileSync as writeFileSync12
} from "node:fs";
-import { basename as basename2, dirname as dirname9, join as join14, resolve as resolve15 } from "node:path";
+import { basename as basename2, dirname as dirname10, join as join14, resolve as resolve15 } from "node:path";
// src/hooks/post-commit.ts
import { createHash as createHash7, randomBytes as randomBytes5 } from "node:crypto";
@@ -23536,7 +23586,7 @@ var resolveHooksDir = (cwd) => {
}
return resolve15(cwd, result.stdout.trim());
};
-var isExecutable = (path2) => {
+var isExecutable2 = (path2) => {
try {
return (statSync7(path2).mode & 73) !== 0;
} catch {
@@ -23564,7 +23614,7 @@ var readHookStatus = (cwd = process.cwd()) => {
state: readHookState(hookPath),
chainedPath,
chained: existsSync19(chainedPath),
- chainedExecutable: isExecutable(chainedPath),
+ chainedExecutable: isExecutable2(chainedPath),
recordedTarget: readRecordedHookTarget(cwd)
};
};
@@ -23592,9 +23642,9 @@ var resolveEntryForRecord = (entry, cwd) => {
return null;
};
var versionFreeEntryFor = (bundle) => {
- const versionDir = dirname9(dirname9(bundle));
+ const versionDir = dirname10(dirname10(bundle));
if (!/^v\d/.test(basename2(versionDir))) return null;
- const candidate = join14(dirname9(versionDir), "current", "dist", "commitlore.mjs");
+ const candidate = join14(dirname10(versionDir), "current", "dist", "commitlore.mjs");
try {
return realpathSync4(candidate) === realpathSync4(bundle) ? candidate : null;
} catch {
@@ -23942,14 +23992,14 @@ var performUpgrade = (tag, deps) => {
// src/core/agents-guidance.ts
import { existsSync as existsSync20, readFileSync as readFileSync22, renameSync as renameSync9, rmSync as rmSync5, statSync as statSync8, writeFileSync as writeFileSync13 } from "node:fs";
-import { basename as basename3, dirname as dirname10, join as join16, resolve as resolve16 } from "node:path";
+import { basename as basename3, dirname as dirname11, join as join16, resolve as resolve16 } from "node:path";
import { fileURLToPath as fileURLToPath2 } from "node:url";
var AGENTS_SECTION_BEGIN = "";
var AGENTS_SECTION_END = "";
var messageOf6 = (error2) => error2 instanceof Error ? error2.message : String(error2);
var shippedAgentsPath = () => {
const source = fileURLToPath2(import.meta.url);
- const here = dirname10(source);
+ const here = dirname11(source);
return basename3(here) === "dist" ? resolve16(here, "..", "AGENTS.md") : resolve16(here, "..", "..", "AGENTS.md");
};
var readCommitloreAgentsSection = () => {
@@ -24567,12 +24617,12 @@ var runDemo = async (opts = {}) => {
if (tmpResolved === userCwd || tmpResolved.startsWith(userCwd + "/") || userCwd.startsWith(tmpResolved + "/")) {
throw new Error("demo: temporary directory overlaps with user repository \u2014 aborting");
}
- git(["init", "--quiet", "--template=", "--initial-branch=main", tmpDir], dirname11(tmpDir));
+ git(["init", "--quiet", "--template=", "--initial-branch=main", tmpDir], dirname12(tmpDir));
git(["config", "user.name", "CommitLore Demo"], tmpDir);
git(["config", "user.email", "demo@commitlore.example"], tmpDir);
git(["config", "commit.gpgsign", "false"], tmpDir);
const targetFullPath = join17(tmpDir, targetPath);
- mkdirSync10(dirname11(targetFullPath), { recursive: true });
+ mkdirSync10(dirname12(targetFullPath), { recursive: true });
writeFileSync14(targetFullPath, "export const calculatePrice = () => {};\n");
git(["add", "."], tmpDir);
git(["commit", "-m", predecessorCommitMessage], tmpDir);
@@ -25040,7 +25090,7 @@ var register15 = (program3) => {
import { spawnSync as spawnSync9 } from "node:child_process";
import { copyFileSync, existsSync as existsSync22, mkdirSync as mkdirSync11, readFileSync as readFileSync26, renameSync as renameSync10, statSync as statSync9, writeFileSync as writeFileSync17 } from "node:fs";
import { homedir as homedir4 } from "node:os";
-import { basename as basename4, dirname as dirname12, join as join18, resolve as resolve19 } from "node:path";
+import { basename as basename4, dirname as dirname13, join as join18, resolve as resolve19 } from "node:path";
// src/core/hermes-config.ts
import { relative as relative2, resolve as resolve18, sep as sep3 } from "node:path";
@@ -25321,7 +25371,7 @@ var backupPathFor = (configPath) => {
}
};
var atomicallyWrite = (path2, contents, mode) => {
- const temporary = join18(dirname12(path2), `.${basename4(path2)}.commitlore-${process.pid}.tmp`);
+ const temporary = join18(dirname13(path2), `.${basename4(path2)}.commitlore-${process.pid}.tmp`);
try {
if (mode === void 0) writeFileSync17(temporary, contents, "utf8");
else writeFileSync17(temporary, contents, { encoding: "utf8", mode });
@@ -25367,7 +25417,7 @@ var runHermesInstall = (options = {}) => {
const dataRoot2 = options.dataRoot ?? join18(dataHome, "commitlore");
const versionedSkills = join18(dataRoot2, `v${runtimeIdentity().version}`, "hermes", "skills");
const skillsDir = options.skillsDir ?? (existsSync22(versionedSkills) ? versionedSkills : installedPath("hermes", "skills"));
- const detected = options.detected ?? (existsSync22(dirname12(configPath)) || commandExists("hermes"));
+ const detected = options.detected ?? (existsSync22(dirname13(configPath)) || commandExists("hermes"));
const report = [];
const verified = [];
if (!detected) {
@@ -25399,7 +25449,7 @@ var runHermesInstall = (options = {}) => {
}
if (edit.added.length > 0) {
try {
- mkdirSync11(dirname12(configPath), { recursive: true });
+ mkdirSync11(dirname13(configPath), { recursive: true });
if (existsSync22(configPath)) {
const backup = backupPathFor(configPath);
copyFileSync(configPath, backup);
@@ -25539,7 +25589,7 @@ var register17 = (program3) => {
// src/commands/inject.ts
import { readFileSync as readFileSync27, realpathSync as realpathSync6 } from "node:fs";
-import { basename as basename5, dirname as dirname13, isAbsolute as isAbsolute3, join as join19, relative as relative3, resolve as resolve20, sep as sep4 } from "node:path";
+import { basename as basename5, dirname as dirname14, isAbsolute as isAbsolute3, join as join19, relative as relative3, resolve as resolve20, sep as sep4 } from "node:path";
// src/core/inject.ts
import { createHash as createHash9 } from "node:crypto";
@@ -25950,7 +26000,7 @@ var canonical = (target) => {
const real = realpathSync6(current);
return tail.length === 0 ? real : join19(real, ...tail);
} catch {
- const parent = dirname13(current);
+ const parent = dirname14(current);
if (parent === current) return absolute;
tail.unshift(basename5(current));
current = parent;
@@ -26111,8 +26161,8 @@ var register18 = (program3) => {
};
// src/commands/installer-hosts.ts
-import { accessSync as accessSync2, constants as constants2, existsSync as existsSync23, mkdirSync as mkdirSync12, renameSync as renameSync11, statSync as statSync10, unlinkSync as unlinkSync6, writeFileSync as writeFileSync18, readFileSync as readFileSync28 } from "node:fs";
-import { delimiter as delimiter2, dirname as dirname14, extname, isAbsolute as isAbsolute4, join as join20 } from "node:path";
+import { accessSync as accessSync3, constants as constants2, existsSync as existsSync23, mkdirSync as mkdirSync12, renameSync as renameSync11, statSync as statSync10, unlinkSync as unlinkSync6, writeFileSync as writeFileSync18, readFileSync as readFileSync28 } from "node:fs";
+import { delimiter as delimiter2, dirname as dirname15, extname, isAbsolute as isAbsolute4, join as join20 } from "node:path";
import { randomUUID } from "node:crypto";
import { spawnSync as spawnSync10 } from "node:child_process";
var INSTALLER_HOSTS_SCHEMA = "commitlore_installer_hosts.v1";
@@ -26137,8 +26187,8 @@ var commandOf = (format, entry) => {
var entryFor = (format, wrapper) => format === "json-mcp" ? { type: "local", command: [wrapper, "mcp"], enabled: true } : { command: wrapper, args: ["mcp"] };
var atomicTemporaryName = (target, unique) => `.${target.split(/[/\\]/).pop() ?? target}.commitlore-${unique}.tmp`;
var atomicJsonWrite = (path2, value) => {
- mkdirSync12(dirname14(path2), { recursive: true });
- const temporary = join20(dirname14(path2), atomicTemporaryName(path2, `${process.pid}-${randomUUID()}`));
+ mkdirSync12(dirname15(path2), { recursive: true });
+ const temporary = join20(dirname15(path2), atomicTemporaryName(path2, `${process.pid}-${randomUUID()}`));
try {
writeFileSync18(temporary, `${JSON.stringify(value, null, 2)}
`, { encoding: "utf8", mode: 384 });
@@ -26227,8 +26277,8 @@ command = ${escaped}
args = ["mcp"]
`;
try {
- mkdirSync12(dirname14(path2), { recursive: true });
- const temporary = join20(dirname14(path2), atomicTemporaryName(path2, `${process.pid}-${randomUUID()}`));
+ mkdirSync12(dirname15(path2), { recursive: true });
+ const temporary = join20(dirname15(path2), atomicTemporaryName(path2, `${process.pid}-${randomUUID()}`));
try {
writeFileSync18(temporary, next, { encoding: "utf8", mode: 384 });
if (process.env.COMMITLORE_INSTALLER_TEST_INTERRUPT_WRITE === "1") throw new Error("interrupted before atomic rename");
@@ -26259,7 +26309,7 @@ var executableExtensions = (command) => {
var isExecutableFile2 = (path2) => {
try {
if (statSync10(path2, { throwIfNoEntry: false })?.isFile() !== true) return false;
- accessSync2(path2, constants2.X_OK);
+ accessSync3(path2, constants2.X_OK);
return true;
} catch {
return false;
diff --git a/install.ps1 b/install.ps1
index 055f8561..24558bcb 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.3/install.ps1 | iex
- & ([scriptblock]::Create((irm https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.ps1))) v1.2.3
+ 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
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 f77ed7cb..c69df874 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.3/install.sh | sh
-# curl -fsSL https://raw.githubusercontent.com/MongLong0214/commitlore/v1.2.3/install.sh | sh -s v1.2.3
+# 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
#
# **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/installer/canonical-artifact.json b/installer/canonical-artifact.json
index f0c599be..06375734 100644
--- a/installer/canonical-artifact.json
+++ b/installer/canonical-artifact.json
@@ -15,10 +15,10 @@
"tsconfig.json",
"src"
],
- "sha256": "7a52859972c6d27bceb02a6385ada26d043a3074040dd543e1b8aa363a57adb8"
+ "sha256": "647fdb182d701fe945822747cdf93142fa1834ba9f663e438e71b8a58117d979"
},
"artifact": {
- "sha256": "840230caf73d42cfd29a2be799d204f15f9aa544448fcb4e6d735bedc1ab0847",
+ "sha256": "9254b0587193cda00301cc8347daf77f8ec93339af6ae79d1cfb4ccfa7d67286",
"files": [
{
"path": "dist/cli.d.ts",
@@ -98,23 +98,23 @@
},
{
"path": "dist/commands/doctor/checks/capture-commit-msg-hook.js",
- "sha256": "bd84069551cd556778ae5c1bb63603b316a54ee5ec275a6fab48ec660a038ef6"
+ "sha256": "a73e61a8306fdc5662d2277db6ba910686795e890f4d74f80288ebfdb6d8c1cc"
},
{
"path": "dist/commands/doctor/checks/capture-commit-msg-hook.js.map",
- "sha256": "b74dae3a78f029358b9373a80d04bfdab499a5d7085b9c6cf24ffb7e7992c593"
+ "sha256": "43aa3b25291294f104ecec701783bfa2c66dc222d9e7bf767b5fd932b57c8a02"
},
{
"path": "dist/commands/doctor/checks/capture-hook-runtime.d.ts",
- "sha256": "9e62c9cbb4e61fa29c665e736a3f2089255b35872fc462412b95a326c2a3effd"
+ "sha256": "23e63a052183772de052a4ad17acf055e3bd729470f61d286a137225ef54502e"
},
{
"path": "dist/commands/doctor/checks/capture-hook-runtime.js",
- "sha256": "9c667bd16d77fa003d5506c555a9cd69b00c3e5a312283ccc5f00d6bc6178b50"
+ "sha256": "94cad831b61540a1091f3ac8b59ca9c7385bc1928e0c36b89c29eb2fa2e892ee"
},
{
"path": "dist/commands/doctor/checks/capture-hook-runtime.js.map",
- "sha256": "5a45d54178c0defbccc3d69b57e0d1d3d5dbfc3b05873dd7d7547ae4228a5f75"
+ "sha256": "e71f3739c57dfcaad23f75e26d7e1e929ad7265f410928d9aa24f6def2dffbb0"
},
{
"path": "dist/commands/doctor/checks/capture-pending-backlog.d.ts",
@@ -622,7 +622,7 @@
},
{
"path": "dist/commitlore.mjs",
- "sha256": "f9fbb5b0753abd257b66d28a4f5056e3e76a905ff4df3f979730ee216a84a7fb"
+ "sha256": "d800ef0f2ddcd255eaf56a8d42d37d6647a83bef890ab2cda5dc4d3bd6e325fe"
},
{
"path": "dist/core/agent-configs.d.ts",
diff --git a/package-lock.json b/package-lock.json
index 282256ab..5730f28a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "commitlore",
- "version": "1.2.3",
+ "version": "1.2.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "commitlore",
- "version": "1.2.3",
+ "version": "1.2.4",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
diff --git a/package.json b/package.json
index e3d0c2a1..b1b7b646 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "commitlore",
- "version": "1.2.3",
+ "version": "1.2.4",
"description": "Git-native, lifecycle-aware decision memory for coding agents",
"license": "MIT",
"private": true,
diff --git a/server.json b/server.json
index 503946f5..6e4f9cb6 100644
--- a/server.json
+++ b/server.json
@@ -8,7 +8,7 @@
"source": "github"
},
"websiteUrl": "https://github.com/MongLong0214/commitlore#readme",
- "version": "1.2.3",
+ "version": "1.2.4",
"_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.3/install.sh | sh -s v1.2.3",
- "release": "https://github.com/MongLong0214/commitlore/releases/tag/v1.2.3"
+ "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"
},
"runtime": {
"transport": "stdio",
diff --git a/src/commands/doctor/checks/capture-commit-msg-hook.ts b/src/commands/doctor/checks/capture-commit-msg-hook.ts
index dc7471f9..73f3cbf1 100644
--- a/src/commands/doctor/checks/capture-commit-msg-hook.ts
+++ b/src/commands/doctor/checks/capture-commit-msg-hook.ts
@@ -115,6 +115,11 @@ export const checkHook = (ctx: DoctorContext, runtime?: DoctorCheck): DoctorChec
];
if (runtime !== undefined && runtime.status !== 'ok') {
const inherited = `installed at ${path}; ${targetDetail}; outcome: ${runtime.detail}`;
+ // This row is blocked on the runtime's finding, so the fix that moves that
+ // finding is the only fix that moves this one. Prescribing `hooks install`
+ // here regardless is how a preserved hook's failure came to carry a remedy
+ // that reinstalls the hook which worked (#876).
+ const inheritedFix = runtime.fix ?? install;
// A skipped runtime would make this row a skip too, and a skip has to name
// a reason. Inheriting the runtime's is the only answer that stays true —
// this row did not look for the same reason that one did not. The branch is
@@ -130,7 +135,7 @@ export const checkHook = (ctx: DoctorContext, runtime?: DoctorCheck): DoctorChec
title,
'skipped',
inherited,
- install,
+ inheritedFix,
false,
false,
{
@@ -148,7 +153,7 @@ export const checkHook = (ctx: DoctorContext, runtime?: DoctorCheck): DoctorChec
title,
runtime.status,
inherited,
- install,
+ inheritedFix,
false,
undefined,
{ evidence: { ...hookEvidence, runtime_status: runtime.status } },
diff --git a/src/commands/doctor/checks/capture-hook-runtime.ts b/src/commands/doctor/checks/capture-hook-runtime.ts
index 9d2b018b..b7f554a2 100644
--- a/src/commands/doctor/checks/capture-hook-runtime.ts
+++ b/src/commands/doctor/checks/capture-hook-runtime.ts
@@ -5,12 +5,39 @@
* receive its completed row through the registry rather than importing it.
*/
-import { existsSync, rmSync, writeFileSync } from 'node:fs';
+import { accessSync, constants as fsConstants, existsSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir as tmpdirPath } from 'node:os';
-import { join, resolve } from 'node:path';
+import { dirname, join, resolve } from 'node:path';
+import { CHAINED_HOOK_NAME } from '../../../hooks/commit-msg.js';
import { check, gitOptions, PROBE_MESSAGE, streamEvidence, type Category, type DoctorCheck, type DoctorContext } from '../model.js';
+/**
+ * The stub runs `"$chained" "$@"` only when `[ -x "$chained" ]` holds, so a
+ * preserved hook without its execute bit is inert to git and to the stub alike.
+ * The same test here, so this check does not probe a file the hook will skip.
+ */
+const isExecutable = (path: string): boolean => {
+ try {
+ accessSync(path, fsConstants.X_OK);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+/**
+ * How the stub's failure reads on its first stderr line: node was never found,
+ * node ran and threw, or neither. Shared between the two hooks this check runs,
+ * because the preserved hook fails in the same three shapes and the
+ * classification is about the line, not about who wrote it.
+ */
+const classifyFailure = (status: number | null, said: string): 'node-missing' | 'node-threw' | 'unclear' => {
+ if (status === 127 || /\bnode\b.*not found|ENOENT|command not found.*\bnode\b/i.test(said)) return 'node-missing';
+ if (/^\s*at\s|\.js:\d+/.test(said)) return 'node-threw';
+ return 'unclear';
+};
+
/**
* Whether the installed hook actually runs, in the environment git gives it.
*
@@ -29,6 +56,12 @@ import { check, gitOptions, PROBE_MESSAGE, streamEvidence, type Category, type D
* The probe message is valid, so a healthy hook exits 0. A hook that cannot find
* a runtime exits non-zero having parsed nothing, which is indistinguishable
* from "your message was fine" to everyone except this check.
+ *
+ * Two hooks run here, not one. The stub hands the message to the hook it
+ * preserved at install time before it resolves commitlore, and exits with that
+ * hook's code if it fails -- so the preserved hook is probed on its own first,
+ * and its failure is reported as its own, with a fix aimed at it. `hooks
+ * install` cannot move a finding about a file it does not write (#876).
*/
export const checkHookRuntime = (ctx: DoctorContext): DoctorCheck => {
const { opts, git, spawn, env } = ctx;
@@ -77,15 +110,71 @@ export const checkHookRuntime = (ctx: DoctorContext): DoctorCheck => {
}
const probe = join(tmpdirPath(), `commitlore-doctor-${String(process.pid)}.txt`);
+ // No node, and no PATH entry that could supply one. `git` must stay
+ // reachable: the hook reads its own config through it.
+ const hookEnv = { PATH: '/usr/bin:/bin', HOME: env['HOME'] ?? '' };
try {
+ // The stub runs the hook it preserved at install time first, and that
+ // hook's non-zero exit is the stub's exit, verbatim, before commitlore is
+ // reached. Probed through the stub alone, the two are one process with one
+ // stderr, and the row attributed a preserved hook's `node: command not
+ // found` to the installed hook and prescribed `hooks install` -- which
+ // reports the file unchanged, because the file it writes was never the one
+ // failing (#876). So the preserved hook runs on its own first, the way the
+ // stub runs it: through sh, so a script without a shebang behaves the same
+ // here as it does there.
+ const chained = join(dirname(hook), CHAINED_HOOK_NAME);
+ if (isExecutable(chained)) {
+ writeFileSync(probe, PROBE_MESSAGE);
+ const preserved = spawn('/bin/sh', ['-c', '"$0" "$1"', chained, probe], {
+ shell: false,
+ encoding: 'utf8',
+ cwd,
+ env: hookEnv,
+ });
+ const exit = preserved.error === undefined ? preserved.status : null;
+ if (preserved.error !== undefined || exit !== 0) {
+ const spoke = `${preserved.stderr ?? ''}`.trim();
+ const said = preserved.error?.message ?? (spoke.split('\n')[0] ?? '');
+ const shape = preserved.error === undefined ? classifyFailure(exit, said) : 'unclear';
+ const because =
+ shape === 'node-missing'
+ ? `it calls node by name and git's PATH has none: ${said}`
+ : shape === 'node-threw'
+ ? `its node process ran but threw (exit ${String(exit)}): ${said}`
+ : `it exited ${String(exit ?? 'unavailable')} under the restricted PATH: ${said || 'no output'}`;
+ return check(
+ id,
+ category,
+ title,
+ 'fail',
+ `commitlore's hook is not what failed. It runs the hook it preserved first, and that hook -- ${chained} -- stops the commit before commitlore is reached: ${because}. That file was this repository's commit-msg hook before commitlore was installed; \`hooks install\` rewrites only commitlore's own and leaves it as it is`,
+ shape === 'node-missing'
+ ? `edit ${chained} to call node by absolute path (or remove it if it is no longer wanted)`
+ : `fix or remove ${chained}`,
+ false,
+ undefined,
+ {
+ evidence: {
+ hook_path: hook,
+ chained_hook_path: chained,
+ exit_code: String(exit ?? 'unavailable'),
+ ...(preserved.error === undefined ? {} : { error: preserved.error.message }),
+ ...streamEvidence('stderr', preserved.stderr ?? ''),
+ },
+ },
+ );
+ }
+ }
+
+ // A commit-msg hook may rewrite the message it is given; the probe is
+ // written again so the stub reads the same bytes the preserved hook did.
writeFileSync(probe, PROBE_MESSAGE);
const run = spawn('/bin/sh', [hook, probe], {
shell: false,
encoding: 'utf8',
cwd,
- // No node, and no PATH entry that could supply one. `git` must stay
- // reachable: the hook reads its own config through it.
- env: { PATH: '/usr/bin:/bin', HOME: env['HOME'] ?? '' },
+ env: hookEnv,
});
if (run.error !== undefined) {
@@ -111,10 +200,11 @@ export const checkHookRuntime = (ctx: DoctorContext): DoctorCheck => {
if (run.status !== 0) {
const spoke = `${run.stderr ?? ''}`.trim();
const said = spoke.split('\n')[0] ?? '';
- const nodeMissing =
- run.status === 127 ||
- /\bnode\b.*not found|ENOENT|command not found.*\bnode\b/i.test(said);
- const nodeThrew = /^\s*at\s|\.js:\d+/.test(said);
+ // The preserved hook, if any, has already exited 0 on its own above, so
+ // whatever follows is commitlore's resolution failing, not a hand-off.
+ const shape = classifyFailure(run.status, said);
+ const nodeMissing = shape === 'node-missing';
+ const nodeThrew = shape === 'node-threw';
// The stub says this when the recorded pair resolved and the containment
// check refused it: present, executable, and under a tree this install
// did not record. An upgrade produces it, because `commitlore.bin` follows
diff --git a/test/doctor.test.ts b/test/doctor.test.ts
index ad80e0e2..c66f8488 100644
--- a/test/doctor.test.ts
+++ b/test/doctor.test.ts
@@ -42,7 +42,7 @@ import { POLICY_FILE_NAME } from '../src/core/capture-policy.js';
import { REQUIRE_SIGNED_DIRECTIVE_KEY } from '../src/core/trusted-authors.js';
// The real stub T-202 installs — doctor must recognize that exact file, so the
// fixture is the installer's own output rather than a lookalike.
-import { HOOK_MARKER, commitMsgStub } from '../src/hooks/commit-msg.js';
+import { CHAINED_HOOK_NAME, HOOK_MARKER, commitMsgStub } from '../src/hooks/commit-msg.js';
import {
CLAUDE_HOOK_MARKER,
claudeSettingsPath,
@@ -655,6 +655,96 @@ describe('doctor: hook runtime', () => {
expect(runtime?.detail).toMatch(/unclear|cannot determine/i);
expect(runtime?.detail).not.toContain('carries no node');
});
+
+ /**
+ * #876. The stub runs the hook it preserved at install time first and exits
+ * with that hook's code, so a preserved hook that calls `node` by name dies
+ * with 127 before commitlore is reached. Probed as one process, the row read
+ * that as commitlore's hook failing and prescribed `hooks install`, which
+ * reported the file unchanged and left the failure exactly where it was. The
+ * row has to name the file that produced the exit and offer a fix that can
+ * move it.
+ */
+ const chainedPath = (repo: string): string => join(dirname(hookPath(repo)), CHAINED_HOOK_NAME);
+
+ it('names the preserved hook, not the installed one, when the preserved hook cannot find node', () => {
+ const repo = initRepo('doctor-runtime-chained-no-node');
+ installedHook(repo);
+ // The shape of the reporter's hook: a repository's own commit-msg calling
+ // node by name, which is fine on an interactive PATH and 127 on git's.
+ writeScript(chainedPath(repo), '#!/bin/sh\nexec node /nonexistent/lint-commit.js "$@"\n');
+ chmodSync(chainedPath(repo), 0o755);
+
+ const report = runDoctor({ cwd: repo });
+ const runtime = report.checks.find((entry) => entry.id === 'hook-runtime');
+ expect(runtime?.status).toBe('fail');
+ expect(runtime?.detail).toContain(chainedPath(repo));
+ expect(runtime?.detail).toMatch(/commitlore's hook is not what failed/);
+ expect(runtime?.detail).toContain('node');
+ expect(runtime?.fix).toContain(chainedPath(repo));
+ expect(runtime?.fix).not.toContain('hooks install');
+ // The row the reporter read was `commit-msg-hook`, which inherits the
+ // runtime's outcome. It has to inherit the remedy too, or it keeps saying
+ // `hooks install` under an outcome that just explained why that cannot help.
+ const installation = report.checks.find((entry) => entry.id === 'commit-msg-hook');
+ expect(installation?.status).toBe('fail');
+ expect(installation?.blockedBy).toBe('hook-runtime');
+ expect(installation?.fix).toContain(chainedPath(repo));
+ expect(installation?.fix).not.toContain('hooks install');
+ // Evidence paths are normalised (a home prefix becomes `~`), so the name
+ // is what is pinned, not the absolute string.
+ expect(runtime?.evidence['chained_hook_path']?.endsWith(`/${CHAINED_HOOK_NAME}`)).toBe(true);
+ expect(runtime?.evidence['exit_code']).toBe('127');
+ });
+
+ it('names the preserved hook when it exits non-zero for a reason unrelated to node', () => {
+ const repo = initRepo('doctor-runtime-chained-broken');
+ installedHook(repo);
+ writeScript(chainedPath(repo), '#!/bin/sh\necho "lint config missing" >&2\nexit 3\n');
+ chmodSync(chainedPath(repo), 0o755);
+
+ const runtime = runtimeCheck(repo);
+ expect(runtime?.status).toBe('fail');
+ expect(runtime?.detail).toContain(chainedPath(repo));
+ expect(runtime?.detail).toContain('lint config missing');
+ expect(runtime?.detail).not.toMatch(/the hook cannot find a node interpreter/);
+ expect(runtime?.fix).toContain(chainedPath(repo));
+ expect(runtime?.fix).not.toContain('hooks install');
+ expect(runtime?.evidence['exit_code']).toBe('3');
+ });
+
+ it('still runs the installed hook, and blames it, once the preserved hook has passed', () => {
+ const repo = initRepo('doctor-runtime-chained-ok-then-node-gone');
+ installedHook(repo);
+ writeScript(chainedPath(repo), '#!/bin/sh\nexit 0\n');
+ chmodSync(chainedPath(repo), 0o755);
+ git(repo, ['config', '--local', 'commitlore.node', '/nonexistent/node']);
+
+ const runtime = runtimeCheck(repo);
+ expect(runtime?.status).toBe('fail');
+ expect(runtime?.detail).not.toMatch(/commitlore's hook is not what failed/);
+ expect(runtime?.fix).toContain('hooks install');
+ expect(runtime?.evidence['chained_hook_path']).toBeUndefined();
+ });
+
+ it('reports ok when the preserved hook passes and so does the installed one', () => {
+ const repo = initRepo('doctor-runtime-chained-ok');
+ installedHook(repo);
+ writeScript(chainedPath(repo), '#!/bin/sh\nexit 0\n');
+ chmodSync(chainedPath(repo), 0o755);
+
+ expect(runtimeCheck(repo)?.status).toBe('ok');
+ });
+
+ it('ignores a preserved hook without its execute bit, as the stub does', () => {
+ // `[ -x "$chained" ]` in the stub: git would not have run this file either.
+ const repo = initRepo('doctor-runtime-chained-inert');
+ installedHook(repo);
+ writeScript(chainedPath(repo), '#!/bin/sh\nexit 1\n');
+ chmodSync(chainedPath(repo), 0o644);
+
+ expect(runtimeCheck(repo)?.status).toBe('ok');
+ });
});
describe('doctor: PreToolUse hook runtime', () => {