@mnemahq/cli 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/binding-stats.mjs +93 -0
- package/src/cli.mjs +28 -0
- package/src/git-hooks.mjs +174 -0
- package/src/hook-install.mjs +5 -1
package/package.json
CHANGED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev Mode Phase 5b — the miss-rate report.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ ASSERT 5's FIRST BOX: "warn mode logs a real miss-rate over 7 days — paste
|
|
5
|
+
* the number." The PreToolUse hook accumulates counts into
|
|
6
|
+
* `~/.claude/hooks/state/<sessionId>.binding.json` — a local file append, never a
|
|
7
|
+
* request, because it runs before EVERY tool call. This reads them back.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ IT REPORTS ZERO AS ZERO, WITH A REASON. "0% miss rate" and "the hook never
|
|
10
|
+
* ran" produce the same number and mean opposite things, so a run with no files
|
|
11
|
+
* says so instead of printing a flattering percentage.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
17
|
+
|
|
18
|
+
export function stateDir() { return join(homedir(), '.claude', 'hooks', 'state'); }
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Aggregate the per-session counters.
|
|
22
|
+
*
|
|
23
|
+
* @param sinceDays only files modified within this window (0 = all)
|
|
24
|
+
*/
|
|
25
|
+
export function collectBindingStats(sinceDays = 7, dir = stateDir()) {
|
|
26
|
+
const out = {
|
|
27
|
+
sessions: 0, hits: 0, misses: 0, branches: [],
|
|
28
|
+
missRate: null, windowDays: sinceDays, reason: null,
|
|
29
|
+
};
|
|
30
|
+
let names = [];
|
|
31
|
+
try { names = readdirSync(dir).filter((f) => f.endsWith('.binding.json')); } catch {
|
|
32
|
+
out.reason = 'no hook state directory — the PreToolUse hook has never run on this machine';
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
if (names.length === 0) {
|
|
36
|
+
out.reason = 'no binding counters — PreToolUse is not installed, or no tool call has run since it was';
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const cutoff = sinceDays > 0 ? Date.now() - sinceDays * 86400000 : 0;
|
|
41
|
+
const branches = new Set();
|
|
42
|
+
for (const n of names) {
|
|
43
|
+
const p = join(dir, n);
|
|
44
|
+
try {
|
|
45
|
+
if (cutoff && statSync(p).mtimeMs < cutoff) continue;
|
|
46
|
+
const s = JSON.parse(readFileSync(p, 'utf8'));
|
|
47
|
+
out.sessions += 1;
|
|
48
|
+
out.hits += Number(s.hits) || 0;
|
|
49
|
+
out.misses += Number(s.misses) || 0;
|
|
50
|
+
for (const b of s.branches || []) branches.add(b);
|
|
51
|
+
} catch { /* a partially-written counter is skipped, not fatal */ }
|
|
52
|
+
}
|
|
53
|
+
out.branches = [...branches].sort();
|
|
54
|
+
|
|
55
|
+
const total = out.hits + out.misses;
|
|
56
|
+
if (total === 0) {
|
|
57
|
+
out.reason = `${out.sessions} counter file(s) in the window, but no tool calls recorded in them`;
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
out.missRate = out.misses / total;
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function formatBindingStats(s) {
|
|
65
|
+
const lines = [];
|
|
66
|
+
lines.push('');
|
|
67
|
+
lines.push(` Task-binding miss rate — last ${s.windowDays || 'all'} day(s)`);
|
|
68
|
+
lines.push('');
|
|
69
|
+
if (s.missRate === null) {
|
|
70
|
+
// ⚠️ Never a percentage here. There is no number, and inventing 0% would
|
|
71
|
+
// read as "everything is bound" when it means "nothing was measured".
|
|
72
|
+
lines.push(` NO DATA — ${s.reason}`);
|
|
73
|
+
lines.push('');
|
|
74
|
+
return lines.join('\n');
|
|
75
|
+
}
|
|
76
|
+
const pct = (s.missRate * 100).toFixed(1);
|
|
77
|
+
lines.push(` sessions measured ${s.sessions}`);
|
|
78
|
+
lines.push(` tool calls on a task ${s.hits}`);
|
|
79
|
+
lines.push(` tool calls with none ${s.misses}`);
|
|
80
|
+
lines.push(` MISS RATE ${pct}%`);
|
|
81
|
+
if (s.branches.length) {
|
|
82
|
+
lines.push('');
|
|
83
|
+
lines.push(' branches with no task:');
|
|
84
|
+
for (const b of s.branches.slice(0, 15)) lines.push(` ${b}`);
|
|
85
|
+
if (s.branches.length > 15) lines.push(` … ${s.branches.length - 15} more`);
|
|
86
|
+
}
|
|
87
|
+
lines.push('');
|
|
88
|
+
lines.push(s.missRate > 0.5
|
|
89
|
+
? ' ⚠️ Above 50%. Do NOT flip enforce=block yet — it would stop most sessions.'
|
|
90
|
+
: ' Below 50%. Flipping enforce=block is defensible; do it on one install first.');
|
|
91
|
+
lines.push('');
|
|
92
|
+
return lines.join('\n');
|
|
93
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -30,6 +30,8 @@ import { getSecret, setSecret, deleteSecrets, backendName, usingFallback } from
|
|
|
30
30
|
import {
|
|
31
31
|
installHook, uninstallHook, hookInstalled, hookConfigPath, defaultDeveloperId, sweepScriptPath,
|
|
32
32
|
} from './hook-install.mjs';
|
|
33
|
+
import { installGitHooks, uninstallGitHooks } from './git-hooks.mjs';
|
|
34
|
+
import { collectBindingStats, formatBindingStats } from './binding-stats.mjs';
|
|
33
35
|
import { applyContext, scaffold } from './artifacts.mjs';
|
|
34
36
|
import { execFileSync } from 'node:child_process';
|
|
35
37
|
|
|
@@ -196,6 +198,17 @@ async function cmdInit(flags) {
|
|
|
196
198
|
try {
|
|
197
199
|
await installHook({ origin, workspaceId, hookToken, developerId: defaultDeveloperId() });
|
|
198
200
|
console.log(c.green('done'));
|
|
201
|
+
|
|
202
|
+
// Phase 5f — the portable layer. These fire for Cursor, Codex, a human, a
|
|
203
|
+
// script; the Claude hook only fires inside an agent that supports hooks.
|
|
204
|
+
// ⚠️ Repo-local and non-blocking. An existing foreign hook is never
|
|
205
|
+
// overwritten — it is reported and left alone.
|
|
206
|
+
const g = installGitHooks();
|
|
207
|
+
if (g.ok && g.installed.length) console.log(` git hooks: ${g.installed.join(', ')} (warn only)`);
|
|
208
|
+
if (g.ok && g.skipped.length) {
|
|
209
|
+
for (const s2 of g.skipped) console.log(c.yellow(` git hook ${s2.name} SKIPPED — ${s2.reason}`));
|
|
210
|
+
}
|
|
211
|
+
if (!g.ok) console.log(c.dim(` git hooks: skipped — ${g.reason}`));
|
|
199
212
|
} catch (e) {
|
|
200
213
|
console.log(c.red('failed'));
|
|
201
214
|
console.error(` ${e.message}`);
|
|
@@ -479,6 +492,9 @@ async function cmdDoctor(flags) {
|
|
|
479
492
|
async function cmdUninstall(flags) {
|
|
480
493
|
const { root, workspaceId } = resolveContext(flags);
|
|
481
494
|
uninstallHook();
|
|
495
|
+
// Only removes hooks carrying our marker — a foreign pre-push is left alone.
|
|
496
|
+
const gone = uninstallGitHooks();
|
|
497
|
+
if (gone.removed.length) console.log(` git hooks removed: ${gone.removed.join(', ')}`);
|
|
482
498
|
if (workspaceId) deleteSecrets(workspaceId);
|
|
483
499
|
let purge = flags.purge === true;
|
|
484
500
|
if (!purge && process.stdin.isTTY) {
|
|
@@ -647,6 +663,17 @@ export async function run(argv) {
|
|
|
647
663
|
// That is this repo's characteristic bug aimed at its own test suite.
|
|
648
664
|
if (flags.help || flags.h) { help(); return; }
|
|
649
665
|
const cmd = rest.shift();
|
|
666
|
+
/**
|
|
667
|
+
* Phase 5b — print the measured task-binding miss rate.
|
|
668
|
+
*
|
|
669
|
+
* ⭐ This is ASSERT 5's first box. It exists so the decision to flip
|
|
670
|
+
* enforce=block is made against a number rather than a hunch.
|
|
671
|
+
*/
|
|
672
|
+
function cmdBinding(flags) {
|
|
673
|
+
const days = flags.limit ? Number(flags.limit) : 7;
|
|
674
|
+
console.log(formatBindingStats(collectBindingStats(Number.isFinite(days) ? days : 7)));
|
|
675
|
+
}
|
|
676
|
+
|
|
650
677
|
switch (cmd) {
|
|
651
678
|
case 'login': return cmdLogin(flags);
|
|
652
679
|
case 'logout': return cmdLogout();
|
|
@@ -657,6 +684,7 @@ export async function run(argv) {
|
|
|
657
684
|
case 'pull': return cmdPull(flags);
|
|
658
685
|
case 'search': return cmdSearch(flags, rest);
|
|
659
686
|
case 'doctor': return cmdDoctor(flags);
|
|
687
|
+
case 'binding': return cmdBinding(flags);
|
|
660
688
|
case 'uninstall': return cmdUninstall(flags);
|
|
661
689
|
|
|
662
690
|
// Reads. Each resolves context once and hands the SDK client to a wrapper;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev Mode Phase 5f — the portable git-hook layer.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ WHY GIT HOOKS AS WELL AS CLAUDE HOOKS. The PreToolUse warning only fires
|
|
5
|
+
* inside an agent that supports hooks. These two fire for ANYONE committing in
|
|
6
|
+
* the repo — Cursor, Codex, a human, a script — which is what makes task binding
|
|
7
|
+
* a property of the repository rather than of one tool. Audit C5: no husky, no
|
|
8
|
+
* lefthook, no pre-commit, so this is a clean install with nothing to merge.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ REPO-LOCAL, NEVER GLOBAL. `core.hooksPath` is not touched: setting it would
|
|
11
|
+
* silently redirect hooks for every repo on the machine, and someone would spend
|
|
12
|
+
* a day finding out why. These are written into THIS repo's .git/hooks.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ NEITHER HOOK BLOCKS ON DAY ONE. `prepare-commit-msg` only ADDS a trailer;
|
|
15
|
+
* `pre-push` warns to stderr and exits 0. 80% of branches carry no task (audit
|
|
16
|
+
* B3), so blocking would stop nearly every push. `pre-push` reads
|
|
17
|
+
* MNEMA_ENFORCE=block to become blocking, the same switch 5c uses.
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ `--no-verify` BYPASSES BOTH, AND THAT IS FINE — it is git's own escape
|
|
20
|
+
* hatch and pretending otherwise invites someone to disable the hooks entirely.
|
|
21
|
+
* The bypass is documented in the hook body so it is discoverable rather than
|
|
22
|
+
* folklore.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { writeFileSync, existsSync, readFileSync, chmodSync, rmSync } from 'node:fs';
|
|
27
|
+
import { execFileSync } from 'node:child_process';
|
|
28
|
+
|
|
29
|
+
const MARKER = '# mnema-task-binding';
|
|
30
|
+
|
|
31
|
+
/** The repo root, or null when not inside a git work tree. */
|
|
32
|
+
export function repoRoot(cwd = process.cwd()) {
|
|
33
|
+
try {
|
|
34
|
+
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
35
|
+
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
36
|
+
}).trim() || null;
|
|
37
|
+
} catch { return null; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function gitHooksDir(cwd = process.cwd()) {
|
|
41
|
+
const root = repoRoot(cwd);
|
|
42
|
+
return root ? join(root, '.git', 'hooks') : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Injects `Task: t-<n>` as a trailer when the branch names a task and the message
|
|
47
|
+
* does not already carry one.
|
|
48
|
+
*
|
|
49
|
+
* ⚠️ Same token-boundary rule as everywhere else: `revert-t-5-fix` names nothing.
|
|
50
|
+
* A trailer is additive — it never rewrites what the author wrote.
|
|
51
|
+
*/
|
|
52
|
+
export const PREPARE_COMMIT_MSG = `#!/bin/sh
|
|
53
|
+
${MARKER}
|
|
54
|
+
# Adds a "Task: t-<n>" trailer when the branch names a task. Never blocks.
|
|
55
|
+
# Bypass: git commit --no-verify
|
|
56
|
+
msg_file="$1"
|
|
57
|
+
src="$2"
|
|
58
|
+
[ "$src" = "merge" ] && exit 0
|
|
59
|
+
[ "$src" = "squash" ] && exit 0
|
|
60
|
+
|
|
61
|
+
# ⚠️ symbolic-ref, NOT rev-parse. Before the first commit, rev-parse
|
|
62
|
+
# --abbrev-ref HEAD FAILS and prints the literal "HEAD", which the exempt
|
|
63
|
+
# list below then swallows — so the hook silently did nothing on the very
|
|
64
|
+
# first commit in a repo. symbolic-ref answers correctly with no commits, and
|
|
65
|
+
# fails cleanly (empty) in detached HEAD, which is what we want to skip.
|
|
66
|
+
branch=$(git symbolic-ref --short -q HEAD 2>/dev/null || true)
|
|
67
|
+
case "$branch" in
|
|
68
|
+
main|master|HEAD|"") exit 0 ;;
|
|
69
|
+
esac
|
|
70
|
+
|
|
71
|
+
# t-<n> must sit on a token boundary: start, or after / _ .
|
|
72
|
+
# ⚠️ grep -Eo, NOT sed. The first version used \`sed -n 's/...\\|.../'\` and returned
|
|
73
|
+
# EMPTY FOR EVERY BRANCH: \`\\|\` alternation is a GNU extension BSD sed does not have,
|
|
74
|
+
# and \`^\` inside a group is not an anchor. It was found by RUNNING it, not reading it.
|
|
75
|
+
task=$(printf '%s' "$branch" | grep -Eo '(^|[/_.])t-[0-9]+([-/_.]|$)' | head -1 | grep -Eo 't-[0-9]+' | head -1)
|
|
76
|
+
[ -z "$task" ] && exit 0
|
|
77
|
+
|
|
78
|
+
grep -qi "^Task: " "$msg_file" && exit 0
|
|
79
|
+
printf '\\nTask: %s\\n' "$task" >> "$msg_file"
|
|
80
|
+
exit 0
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Warns (or blocks, under MNEMA_ENFORCE=block) when pushing a branch that names
|
|
85
|
+
* no task.
|
|
86
|
+
*
|
|
87
|
+
* ⚠️ Never fires on main/master — pushing the default branch is not task-bound
|
|
88
|
+
* work, and warning there teaches people to ignore the warning.
|
|
89
|
+
*/
|
|
90
|
+
export const PRE_PUSH = `#!/bin/sh
|
|
91
|
+
${MARKER}
|
|
92
|
+
# Warns when the branch names no task. Blocks only when MNEMA_ENFORCE=block.
|
|
93
|
+
# Bypass: git push --no-verify
|
|
94
|
+
# ⚠️ symbolic-ref, NOT rev-parse. Before the first commit, rev-parse
|
|
95
|
+
# --abbrev-ref HEAD FAILS and prints the literal "HEAD", which the exempt
|
|
96
|
+
# list below then swallows — so the hook silently did nothing on the very
|
|
97
|
+
# first commit in a repo. symbolic-ref answers correctly with no commits, and
|
|
98
|
+
# fails cleanly (empty) in detached HEAD, which is what we want to skip.
|
|
99
|
+
branch=$(git symbolic-ref --short -q HEAD 2>/dev/null || true)
|
|
100
|
+
case "$branch" in
|
|
101
|
+
main|master|HEAD|"") exit 0 ;;
|
|
102
|
+
esac
|
|
103
|
+
|
|
104
|
+
# ⚠️ grep -Eo, NOT sed. The first version used \`sed -n 's/...\\|.../'\` and returned
|
|
105
|
+
# EMPTY FOR EVERY BRANCH: \`\\|\` alternation is a GNU extension BSD sed does not have,
|
|
106
|
+
# and \`^\` inside a group is not an anchor. It was found by RUNNING it, not reading it.
|
|
107
|
+
task=$(printf '%s' "$branch" | grep -Eo '(^|[/_.])t-[0-9]+([-/_.]|$)' | head -1 | grep -Eo 't-[0-9]+' | head -1)
|
|
108
|
+
[ -n "$task" ] && exit 0
|
|
109
|
+
|
|
110
|
+
printf '\\n [mnema] Pushing a branch that names no task: %s\\n' "$branch" >&2
|
|
111
|
+
printf ' Nothing on the board will link to this work.\\n' >&2
|
|
112
|
+
if [ "$MNEMA_ENFORCE" = "block" ]; then
|
|
113
|
+
printf ' Enforcement is set to block. Rename the branch, or push with --no-verify.\\n\\n' >&2
|
|
114
|
+
exit 1
|
|
115
|
+
fi
|
|
116
|
+
printf ' Not blocked. Rename it t-<n>-<slug> to link it.\\n\\n' >&2
|
|
117
|
+
exit 0
|
|
118
|
+
`;
|
|
119
|
+
|
|
120
|
+
const HOOKS = [
|
|
121
|
+
['prepare-commit-msg', PREPARE_COMMIT_MSG],
|
|
122
|
+
['pre-push', PRE_PUSH],
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Install both hooks into this repo.
|
|
127
|
+
*
|
|
128
|
+
* ⚠️ AN EXISTING HOOK THAT IS NOT OURS IS NEVER OVERWRITTEN. It is reported and
|
|
129
|
+
* skipped — silently clobbering someone's pre-push is the kind of "helpful"
|
|
130
|
+
* install that loses trust permanently. Ours carries a marker line, so re-running
|
|
131
|
+
* upgrades our own and only our own.
|
|
132
|
+
*/
|
|
133
|
+
export function installGitHooks(cwd = process.cwd()) {
|
|
134
|
+
const dir = gitHooksDir(cwd);
|
|
135
|
+
if (!dir) return { ok: false, reason: 'not a git repository', installed: [], skipped: [] };
|
|
136
|
+
|
|
137
|
+
const installed = [];
|
|
138
|
+
const skipped = [];
|
|
139
|
+
for (const [name, body] of HOOKS) {
|
|
140
|
+
const p = join(dir, name);
|
|
141
|
+
if (existsSync(p)) {
|
|
142
|
+
let existing = '';
|
|
143
|
+
try { existing = readFileSync(p, 'utf8'); } catch { /* unreadable */ }
|
|
144
|
+
if (!existing.includes(MARKER)) { skipped.push({ name, reason: 'a different hook is already installed' }); continue; }
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
writeFileSync(p, body, { mode: 0o755 });
|
|
148
|
+
chmodSync(p, 0o755);
|
|
149
|
+
installed.push(name);
|
|
150
|
+
} catch (e) {
|
|
151
|
+
skipped.push({ name, reason: String(e && e.message ? e.message : e) });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { ok: true, reason: null, installed, skipped };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Remove only the hooks we wrote — identified by the marker, never by name. */
|
|
158
|
+
export function uninstallGitHooks(cwd = process.cwd()) {
|
|
159
|
+
const dir = gitHooksDir(cwd);
|
|
160
|
+
if (!dir) return { removed: [], kept: [] };
|
|
161
|
+
const removed = [];
|
|
162
|
+
const kept = [];
|
|
163
|
+
for (const [name] of HOOKS) {
|
|
164
|
+
const p = join(dir, name);
|
|
165
|
+
if (!existsSync(p)) continue;
|
|
166
|
+
let body = '';
|
|
167
|
+
try { body = readFileSync(p, 'utf8'); } catch { /* ignore */ }
|
|
168
|
+
if (!body.includes(MARKER)) { kept.push(name); continue; }
|
|
169
|
+
try { rmSync(p); removed.push(name); } catch { kept.push(name); }
|
|
170
|
+
}
|
|
171
|
+
return { removed, kept };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export { MARKER as GIT_HOOK_MARKER };
|
package/src/hook-install.mjs
CHANGED
|
@@ -8,7 +8,11 @@ import { homedir, userInfo, hostname } from 'node:os';
|
|
|
8
8
|
import { join, dirname } from 'node:path';
|
|
9
9
|
import { mkdirSync, writeFileSync, existsSync, readFileSync, rmSync, chmodSync } from 'node:fs';
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
// ⚠️ PreToolUse (Phase 5a) is the ONLY event that runs before a tool, so it is the
|
|
12
|
+
// only one whose cost is felt. It is local-only — a git call and a file append,
|
|
13
|
+
// no API round-trip — and it exits 0 always. The installer merges idempotently,
|
|
14
|
+
// so adding it here upgrades existing installs on the next `mnema hook install`.
|
|
15
|
+
const HOOK_EVENTS = ['SessionStart', 'SessionEnd', 'Stop', 'PostToolUse', 'PostToolUseFailure', 'PreToolUse'];
|
|
12
16
|
|
|
13
17
|
export function claudeDir() { return join(homedir(), '.claude'); }
|
|
14
18
|
export function hooksDir() { return join(claudeDir(), 'hooks'); }
|