@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemahq/cli",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Mnema CLI — connect a repo to your Mnema workspace: install session capture, sweep past sessions, and search from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 };
@@ -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
- const HOOK_EVENTS = ['SessionStart', 'SessionEnd', 'Stop', 'PostToolUse', 'PostToolUseFailure'];
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'); }