@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemahq/cli",
3
- "version": "0.11.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.0"
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 };
@@ -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'); }
@@ -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
+ }