@mnemahq/cli 0.11.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 +2 -2
- package/src/binding-stats.mjs +93 -0
- package/src/cli.mjs +36 -1
- package/src/git-hooks.mjs +174 -0
- package/src/hook-install.mjs +5 -1
- package/src/read-commands.mjs +62 -1
- package/src/tui/app.mjs +10 -3
- package/src/tui/screens/flows.mjs +77 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mnemahq/cli",
|
|
3
|
-
"version": "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": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"htm": "^3.1.1",
|
|
35
35
|
"ink": "^6.8.0",
|
|
36
36
|
"react": "^19.2.7",
|
|
37
|
-
"@mnemahq/sdk": "0.4.
|
|
37
|
+
"@mnemahq/sdk": "0.4.1"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
40
|
"build": "node -e \"process.exit(0)\"",
|
|
@@ -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
|
@@ -15,6 +15,7 @@ import { cmdLogin, cmdLogout, accessToken } from './login.mjs';
|
|
|
15
15
|
import { makeClient, call, hasApiKey, canAuthenticate, renderError, mintHookToken } from './client.mjs';
|
|
16
16
|
import {
|
|
17
17
|
cmdTasks, cmdNext, cmdDocs, cmdDoc, cmdProjects, cmdAsk, cmdGraph, cmdBriefing,
|
|
18
|
+
cmdFlows, cmdFlow,
|
|
18
19
|
} from './read-commands.mjs';
|
|
19
20
|
import {
|
|
20
21
|
DEFAULT_ORIGIN, DEFAULT_APP_URL, c, truncate, width, mark, gitInfo, canonicalRepo, readConfig, writeConfig,
|
|
@@ -29,6 +30,8 @@ import { getSecret, setSecret, deleteSecrets, backendName, usingFallback } from
|
|
|
29
30
|
import {
|
|
30
31
|
installHook, uninstallHook, hookInstalled, hookConfigPath, defaultDeveloperId, sweepScriptPath,
|
|
31
32
|
} from './hook-install.mjs';
|
|
33
|
+
import { installGitHooks, uninstallGitHooks } from './git-hooks.mjs';
|
|
34
|
+
import { collectBindingStats, formatBindingStats } from './binding-stats.mjs';
|
|
32
35
|
import { applyContext, scaffold } from './artifacts.mjs';
|
|
33
36
|
import { execFileSync } from 'node:child_process';
|
|
34
37
|
|
|
@@ -195,6 +198,17 @@ async function cmdInit(flags) {
|
|
|
195
198
|
try {
|
|
196
199
|
await installHook({ origin, workspaceId, hookToken, developerId: defaultDeveloperId() });
|
|
197
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}`));
|
|
198
212
|
} catch (e) {
|
|
199
213
|
console.log(c.red('failed'));
|
|
200
214
|
console.error(` ${e.message}`);
|
|
@@ -478,6 +492,9 @@ async function cmdDoctor(flags) {
|
|
|
478
492
|
async function cmdUninstall(flags) {
|
|
479
493
|
const { root, workspaceId } = resolveContext(flags);
|
|
480
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(', ')}`);
|
|
481
498
|
if (workspaceId) deleteSecrets(workspaceId);
|
|
482
499
|
let purge = flags.purge === true;
|
|
483
500
|
if (!purge && process.stdin.isTTY) {
|
|
@@ -519,6 +536,8 @@ Read your workspace:
|
|
|
519
536
|
docs List documents [--limit]
|
|
520
537
|
doc [id] Print one document as markdown; no id opens a picker
|
|
521
538
|
projects List projects
|
|
539
|
+
flows List flows
|
|
540
|
+
flow [slug] One flow; no slug opens a picker
|
|
522
541
|
briefing What deserves attention — pulse, deltas, findings
|
|
523
542
|
|
|
524
543
|
Ask the knowledge graph (paid feature):
|
|
@@ -597,7 +616,7 @@ export function screenFor(cmd, flags, rest = []) {
|
|
|
597
616
|
if (rest.length > 1) return null;
|
|
598
617
|
return rest[0] ? { name: 'node', target: { label: rest[0] } } : { name: 'graph' };
|
|
599
618
|
}
|
|
600
|
-
if (cmd === 'tasks' || cmd === 'docs' || cmd === 'briefing') return { name: cmd };
|
|
619
|
+
if (cmd === 'tasks' || cmd === 'docs' || cmd === 'briefing' || cmd === 'flows') return { name: cmd };
|
|
601
620
|
return null;
|
|
602
621
|
}
|
|
603
622
|
|
|
@@ -644,6 +663,17 @@ export async function run(argv) {
|
|
|
644
663
|
// That is this repo's characteristic bug aimed at its own test suite.
|
|
645
664
|
if (flags.help || flags.h) { help(); return; }
|
|
646
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
|
+
|
|
647
677
|
switch (cmd) {
|
|
648
678
|
case 'login': return cmdLogin(flags);
|
|
649
679
|
case 'logout': return cmdLogout();
|
|
@@ -654,6 +684,7 @@ export async function run(argv) {
|
|
|
654
684
|
case 'pull': return cmdPull(flags);
|
|
655
685
|
case 'search': return cmdSearch(flags, rest);
|
|
656
686
|
case 'doctor': return cmdDoctor(flags);
|
|
687
|
+
case 'binding': return cmdBinding(flags);
|
|
657
688
|
case 'uninstall': return cmdUninstall(flags);
|
|
658
689
|
|
|
659
690
|
// Reads. Each resolves context once and hands the SDK client to a wrapper;
|
|
@@ -667,6 +698,10 @@ export async function run(argv) {
|
|
|
667
698
|
return cmdDocs(flags, resolveContext(flags));
|
|
668
699
|
case 'doc': return cmdDoc(flags, resolveContext(flags), rest);
|
|
669
700
|
case 'projects': return cmdProjects(flags, resolveContext(flags));
|
|
701
|
+
case 'flows':
|
|
702
|
+
if (await maybeInteractive(flags, screenFor('flows', flags))) return 0;
|
|
703
|
+
return cmdFlows(flags, resolveContext(flags));
|
|
704
|
+
case 'flow': return cmdFlow(flags, resolveContext(flags), rest);
|
|
670
705
|
case 'ask': return cmdAsk(flags, resolveContext(flags), rest);
|
|
671
706
|
case 'graph':
|
|
672
707
|
// With a node named, open the walker AT it. With no argument, open the hub
|
|
@@ -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'); }
|
package/src/read-commands.mjs
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
import { call, hasApiKey, canAuthenticate, renderError } from './client.mjs';
|
|
28
28
|
import { take } from './paging.mjs';
|
|
29
29
|
import { c, truncate, link, mark } from './util.mjs';
|
|
30
|
-
import { heading, empty, rows, more, copyable } from './render/layout.mjs';
|
|
30
|
+
import { heading, entries, empty, rows, more, copyable } from './render/layout.mjs';
|
|
31
31
|
import { pick } from './render/pick.mjs';
|
|
32
32
|
|
|
33
33
|
/** Machine output is the whole object; humans get the formatted view. */
|
|
@@ -274,3 +274,64 @@ export async function cmdBriefing(flags, ctx) {
|
|
|
274
274
|
});
|
|
275
275
|
} catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'briefing' }); }
|
|
276
276
|
}
|
|
277
|
+
|
|
278
|
+
// ── flows ─────────────────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* ⚠️ THE PUBLIC FLOW ENDPOINT RETURNS METADATA ONLY — no nodes, no steps, no run
|
|
282
|
+
* history. Rendering a "Steps" heading with nothing under it would be the exact
|
|
283
|
+
* silent-empty failure this codebase is named for: it would read as "this flow
|
|
284
|
+
* has no steps" when it means "this endpoint does not serve them". So the detail
|
|
285
|
+
* shows what is genuinely there and says where the rest lives.
|
|
286
|
+
*/
|
|
287
|
+
export async function cmdFlows(flags, ctx) {
|
|
288
|
+
await guard(ctx.workspaceId);
|
|
289
|
+
const limit = limitOf(flags, 50);
|
|
290
|
+
try {
|
|
291
|
+
const page = await call(ctx, (m) => take(m.flows.list(), limit));
|
|
292
|
+
emit(flags, page.rows, () => {
|
|
293
|
+
heading('Flows', page.rows.length);
|
|
294
|
+
if (!page.rows.length) return empty('No flows yet.', 'Build one in the app and it shows up here.');
|
|
295
|
+
rows(page.rows.map((f) => ({
|
|
296
|
+
cells: [f.slug ?? '', f.name ?? ''],
|
|
297
|
+
style: [c.cyan, undefined],
|
|
298
|
+
})));
|
|
299
|
+
more(page.more, `mnema flows --limit ${limit * 2}`);
|
|
300
|
+
});
|
|
301
|
+
} catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'flows' }); }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export async function cmdFlow(flags, ctx, rest) {
|
|
305
|
+
await guard(ctx.workspaceId);
|
|
306
|
+
let slug = rest[0];
|
|
307
|
+
const interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
308
|
+
if (!slug && !flags.json && interactive) {
|
|
309
|
+
const page = await call(ctx, (m) => take(m.flows.list(), 50)).catch(() => null);
|
|
310
|
+
if (page?.rows.length) {
|
|
311
|
+
const chosen = await pick(page.rows.map((f) => ({ label: `${f.name ?? f.slug}` })), { title: 'Which flow?' });
|
|
312
|
+
if (chosen === null) return;
|
|
313
|
+
slug = page.rows[chosen].slug;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (!slug) {
|
|
317
|
+
console.error(c.red('Usage: mnema flow <slug>'));
|
|
318
|
+
console.error(c.dim(' Run it without a slug in a terminal to pick from a list.'));
|
|
319
|
+
process.exit(1);
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
const f = await call(ctx, (m) => m.flows.get(slug));
|
|
323
|
+
emit(flags, f, () => {
|
|
324
|
+
heading(f.name ?? slug);
|
|
325
|
+
entries([
|
|
326
|
+
{ label: 'slug', value: f.slug ?? slug },
|
|
327
|
+
{ label: 'published', value: f.publishedVersionId ? 'yes' : 'draft', state: Boolean(f.publishedVersionId) },
|
|
328
|
+
...(f.projectId ? [{ label: 'project', value: f.projectId }] : []),
|
|
329
|
+
{ label: 'updated', value: String(f.updatedAt ?? '').slice(0, 10), dim: true },
|
|
330
|
+
]);
|
|
331
|
+
if (f.description) console.log(`\n ${c.dim(f.description)}`);
|
|
332
|
+
// Saying this is the point: the absence of steps here is the endpoint's
|
|
333
|
+
// shape, not the flow's emptiness.
|
|
334
|
+
console.log(c.dim('\n Steps and runs are not on the public API — open the flow in the app to walk it.'));
|
|
335
|
+
});
|
|
336
|
+
} catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'flow' }); }
|
|
337
|
+
}
|
package/src/tui/app.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { Header } from './components/header.mjs';
|
|
|
23
23
|
import { GraphHome } from './screens/graph-home.mjs';
|
|
24
24
|
import { Tasks, Task } from './screens/tasks.mjs';
|
|
25
25
|
import { Docs, Doc } from './screens/docs.mjs';
|
|
26
|
+
import { Flows, Flow } from './screens/flows.mjs';
|
|
26
27
|
|
|
27
28
|
const HOME = { name: 'briefing' };
|
|
28
29
|
|
|
@@ -71,6 +72,7 @@ export function App({ ctx, initial }) {
|
|
|
71
72
|
if (input === 'g') { setStack([{ name: 'graph' }]); return; }
|
|
72
73
|
if (input === 't') { setStack([{ name: 'tasks' }]); return; }
|
|
73
74
|
if (input === 'd') { setStack([{ name: 'docs' }]); return; }
|
|
75
|
+
if (input === 'f') { setStack([{ name: 'flows' }]); return; }
|
|
74
76
|
});
|
|
75
77
|
|
|
76
78
|
if (fatal?.kind === 'auth') {
|
|
@@ -100,6 +102,11 @@ export function App({ ctx, initial }) {
|
|
|
100
102
|
onOpen=${(d) => push({ name: 'doc', doc: d })} />`;
|
|
101
103
|
} else if (top.name === 'doc') {
|
|
102
104
|
body = html`<${Doc} ctx=${wrapped} doc=${top.doc} focused=${true} />`;
|
|
105
|
+
} else if (top.name === 'flows') {
|
|
106
|
+
body = html`<${Flows} ctx=${wrapped} focused=${true}
|
|
107
|
+
onOpen=${(f) => push({ name: 'flow', flow: f })} />`;
|
|
108
|
+
} else if (top.name === 'flow') {
|
|
109
|
+
body = html`<${Flow} ctx=${wrapped} flow=${top.flow} />`;
|
|
103
110
|
} else if (top.name === 'briefing') {
|
|
104
111
|
body = html`<${Briefing} ctx=${wrapped} focused=${true} onOpen=${(f) => push({ name: 'finding', finding: f })} />`;
|
|
105
112
|
} else if (top.name === 'node') {
|
|
@@ -144,13 +151,13 @@ export function App({ ctx, initial }) {
|
|
|
144
151
|
* which is worse than not mentioning it. The global jumps are appended once, in
|
|
145
152
|
* one place, so they cannot drift per screen.
|
|
146
153
|
*/
|
|
147
|
-
const JUMPS = 'b briefing · g graph · t tasks · d docs · q quit';
|
|
154
|
+
const JUMPS = 'b briefing · g graph · t tasks · d docs · f flows · q quit';
|
|
148
155
|
|
|
149
156
|
function footerFor(top) {
|
|
150
157
|
if (top.name === 'briefing') return `↑↓ move · enter open · r refresh · ${JUMPS}`;
|
|
151
158
|
if (top.name === 'graph') return `↑↓ move · enter walk in · ${JUMPS}`;
|
|
152
|
-
if (top.name === 'tasks' || top.name === 'docs') return `↑↓ move · enter open · ${JUMPS}`;
|
|
153
|
-
if (top.name === 'task') return `esc back · ${JUMPS}`;
|
|
159
|
+
if (top.name === 'tasks' || top.name === 'docs' || top.name === 'flows') return `↑↓ move · enter open · ${JUMPS}`;
|
|
160
|
+
if (top.name === 'task' || top.name === 'flow') return `esc back · ${JUMPS}`;
|
|
154
161
|
if (top.name === 'doc') return `j/k scroll · esc back · ${JUMPS}`;
|
|
155
162
|
if (top.name === 'node') return `↑↓ move · enter walk in · esc back · r refresh · ${JUMPS}`;
|
|
156
163
|
if (top.name === 'finding') {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flows (t-640).
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ THE PUBLIC ENDPOINT SERVES METADATA ONLY — no nodes, no steps, no runs. A
|
|
5
|
+
* "Steps" heading with nothing under it would read as "this flow is empty" when
|
|
6
|
+
* it means "this endpoint does not serve them", which is exactly the silent-empty
|
|
7
|
+
* failure this codebase is named for. So the detail shows what is genuinely there
|
|
8
|
+
* and names where the rest lives.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Box, Text } from 'ink';
|
|
12
|
+
import { html } from '../h.mjs';
|
|
13
|
+
import { useResource, ago } from '../store.mjs';
|
|
14
|
+
import { List } from '../components/list.mjs';
|
|
15
|
+
import { take } from '../../paging.mjs';
|
|
16
|
+
|
|
17
|
+
export function Flows({ ctx, focused, onOpen }) {
|
|
18
|
+
const { status, data, error, elapsed, at } = useResource('flows', () =>
|
|
19
|
+
ctx.call((m) => take(m.flows.list(), 60)));
|
|
20
|
+
|
|
21
|
+
if (status === 'loading') {
|
|
22
|
+
return html`<${Box} paddingX=${1}><${Text} dimColor>Loading flows… ${elapsed > 1 ? `${elapsed}s` : ''}<//><//>`;
|
|
23
|
+
}
|
|
24
|
+
if (error && !data) {
|
|
25
|
+
return html`<${Box} paddingX=${1}><${Text} color="red">${error.message ?? String(error)}<//><//>`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const rows = data?.rows ?? [];
|
|
29
|
+
return html`
|
|
30
|
+
<${Box} flexDirection="column" paddingX=${1}>
|
|
31
|
+
<${Box}>
|
|
32
|
+
<${Text} bold>Flows<//>
|
|
33
|
+
<${Text} dimColor> ${rows.length}${data?.more ? '+' : ''} · ${ago(at)}<//>
|
|
34
|
+
<//>
|
|
35
|
+
${rows.length === 0
|
|
36
|
+
? html`<${Text} dimColor>No flows yet.<//>`
|
|
37
|
+
: html`<${List}
|
|
38
|
+
items=${rows.map((f) => ({ key: f.id ?? f.slug, left: f.slug ?? '', label: f.name ?? '' }))}
|
|
39
|
+
focused=${focused}
|
|
40
|
+
height=${12}
|
|
41
|
+
onSelect=${(i) => onOpen(rows[i])}
|
|
42
|
+
/>`}
|
|
43
|
+
<//>`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function Flow({ ctx, flow }) {
|
|
47
|
+
const { status, data, error, elapsed } = useResource(`flow:${flow?.slug}`, () =>
|
|
48
|
+
ctx.call((m) => m.flows.get(flow.slug)));
|
|
49
|
+
|
|
50
|
+
if (status === 'loading') {
|
|
51
|
+
return html`<${Box} paddingX=${1}><${Text} dimColor>Opening ${flow?.name ?? ''}… ${elapsed > 1 ? `${elapsed}s` : ''}<//><//>`;
|
|
52
|
+
}
|
|
53
|
+
if (error) {
|
|
54
|
+
return html`<${Box} flexDirection="column" paddingX=${1}>
|
|
55
|
+
<${Text} color="red">${error.message ?? String(error)}<//>
|
|
56
|
+
<${Text} dimColor>esc to go back<//>
|
|
57
|
+
<//>`;
|
|
58
|
+
}
|
|
59
|
+
const f = data ?? flow ?? {};
|
|
60
|
+
|
|
61
|
+
return html`
|
|
62
|
+
<${Box} flexDirection="column" paddingX=${1}>
|
|
63
|
+
<${Text} bold>${f.name ?? f.slug ?? '(unnamed)'}<//>
|
|
64
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
65
|
+
<${Box}><${Text} dimColor>slug <//><${Text} color="cyan">${f.slug ?? ''}<//><//>
|
|
66
|
+
<${Box}><${Text} dimColor>published <//>
|
|
67
|
+
<${Text} color=${f.publishedVersionId ? 'green' : undefined}>${f.publishedVersionId ? 'yes' : 'draft'}<//><//>
|
|
68
|
+
<${Box}><${Text} dimColor>updated <//><${Text}>${String(f.updatedAt ?? '').slice(0, 10)}<//><//>
|
|
69
|
+
<//>
|
|
70
|
+
${f.description
|
|
71
|
+
? html`<${Box} marginTop=${1}><${Text} dimColor>${f.description}<//><//>`
|
|
72
|
+
: null}
|
|
73
|
+
<${Box} marginTop=${1}>
|
|
74
|
+
<${Text} dimColor>Steps and runs are not on the public API — open the flow in the app to walk it.<//>
|
|
75
|
+
<//>
|
|
76
|
+
<//>`;
|
|
77
|
+
}
|