@bli-cockpit/cli 0.2.119 → 0.2.121

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.
Files changed (38) hide show
  1. package/dist/commands/analyze.js +74 -54
  2. package/dist/commands/brief-rewrite.js +164 -101
  3. package/dist/commands/brief.js +38 -13
  4. package/dist/commands/correct.js +38 -21
  5. package/dist/commands/docs.js +13 -10
  6. package/dist/commands/editor.js +59 -30
  7. package/dist/commands/install-receipts.js +106 -91
  8. package/dist/commands/local-args-tower-admin.js +4 -0
  9. package/dist/commands/local-args-tower-cal.js +28 -3
  10. package/dist/commands/local-args-tower-chat.js +55 -28
  11. package/dist/commands/local-args-tower-docs-msg.js +39 -8
  12. package/dist/commands/local-args-tower-mail.js +27 -1
  13. package/dist/commands/local-args-tower-models.js +14 -17
  14. package/dist/commands/local-args-tower-work.js +37 -6
  15. package/dist/commands/local-help-commands.js +2 -1
  16. package/dist/commands/mcp-stdio-probe.js +92 -73
  17. package/dist/commands/memory-hook-performance.js +135 -101
  18. package/dist/commands/memory-install-claude.js +15 -14
  19. package/dist/commands/memory-install-codex.js +10 -6
  20. package/dist/commands/memory-install-config.js +5 -4
  21. package/dist/commands/memory-install-contract.js +56 -10
  22. package/dist/commands/memory-install-report.js +16 -11
  23. package/dist/commands/memory-install-skills.js +11 -11
  24. package/dist/commands/memory-log.js +22 -5
  25. package/dist/commands/msg.js +11 -5
  26. package/dist/commands/onboard-setup.js +16 -1
  27. package/dist/commands/ops-sections.js +89 -0
  28. package/dist/commands/ops.js +117 -120
  29. package/dist/commands/public-root.js +1 -1
  30. package/dist/commands/scout.js +90 -68
  31. package/dist/commands/session-sync-failures.js +19 -13
  32. package/dist/commands/session-sync-record.js +53 -52
  33. package/dist/commands/session-sync-upload.js +15 -11
  34. package/dist/commands/sessions.js +61 -51
  35. package/dist/commands/slack.js +90 -61
  36. package/dist/commands/status.js +53 -41
  37. package/dist/commands/workbook.js +23 -20
  38. package/package.json +2 -2
@@ -15,130 +15,163 @@ export async function readMemoryHookPerformance(options) {
15
15
  const directory = memoryHookSamplesDirectory(options.homeDir);
16
16
  const start = now.getTime() - 24 * HOUR_MS;
17
17
  const reasons = new Set();
18
- const result = {
19
- schema_version: "memory-hook-performance.v1",
20
- window_start: new Date(start).toISOString(),
21
- window_end: now.toISOString(),
22
- sampling_since: null,
23
- samples: 0,
24
- outcomes: { printed: 0, empty: 0, timeouts: 0, failed: 0, skipped: 0 },
25
- duration_by_cache: {
26
- hit: blankHistogram(), miss: blankHistogram(), shared: blankHistogram(), unknown: blankHistogram(),
27
- },
28
- producer_versions: [],
29
- invalid_samples: 0,
30
- incomplete_samples: 0,
31
- unreadable_samples: 0,
32
- capped: false,
33
- reasons: [],
34
- };
35
- const versions = new Map();
36
- const started = performance.now();
37
- let filesRead = 0;
18
+ const state = createSamplingState(now, start);
38
19
  try {
39
20
  const lease = await renewLease(directory, now, reasons);
40
- result.sampling_since = lease.continuous_since;
21
+ state.result.sampling_since = lease.continuous_since;
41
22
  if (Date.parse(lease.continuous_since) > start)
42
23
  reasons.add("partial_window");
43
- // Exactly the 25 hour directories touching this rolling 24-hour window.
44
- // Timestamp filtering below trims both partial boundary hours.
45
- hours: for (let offset = 0; offset <= 24; offset += 1) {
46
- const hour = new Date(now.getTime() - offset * HOUR_MS).toISOString().slice(0, 13);
47
- let entries;
48
- try {
49
- entries = await fs.opendir(path.join(directory, hour));
50
- }
51
- catch (error) {
52
- if (error.code !== "ENOENT")
53
- reasons.add("read_failed");
24
+ await collectHourlySamples(directory, now, start, options, reasons, state);
25
+ await pruneExpiredHours(directory, now, reasons);
26
+ }
27
+ catch {
28
+ reasons.add("read_failed");
29
+ }
30
+ return finishSampling(state, reasons);
31
+ }
32
+ function createSamplingState(now, start) {
33
+ return {
34
+ result: {
35
+ schema_version: "memory-hook-performance.v1",
36
+ window_start: new Date(start).toISOString(),
37
+ window_end: now.toISOString(),
38
+ sampling_since: null,
39
+ samples: 0,
40
+ outcomes: { printed: 0, empty: 0, timeouts: 0, failed: 0, skipped: 0 },
41
+ duration_by_cache: {
42
+ hit: blankHistogram(),
43
+ miss: blankHistogram(),
44
+ shared: blankHistogram(),
45
+ unknown: blankHistogram(),
46
+ },
47
+ producer_versions: [],
48
+ invalid_samples: 0,
49
+ incomplete_samples: 0,
50
+ unreadable_samples: 0,
51
+ capped: false,
52
+ reasons: [],
53
+ },
54
+ versions: new Map(),
55
+ started: performance.now(),
56
+ filesRead: 0,
57
+ };
58
+ }
59
+ async function collectHourlySamples(directory, now, start, options, reasons, state) {
60
+ // Exactly the 25 hour directories touching this rolling 24-hour window.
61
+ // Timestamp filtering below trims both partial boundary hours.
62
+ hours: for (let offset = 0; offset <= 24; offset += 1) {
63
+ const hour = new Date(now.getTime() - offset * HOUR_MS).toISOString().slice(0, 13);
64
+ const entries = await openHourDirectory(directory, hour, reasons);
65
+ if (!entries)
66
+ continue;
67
+ for await (const entry of entries) {
68
+ if (!entry.isFile() || !SAMPLE_FILE.test(entry.name))
54
69
  continue;
70
+ if (hasReachedReadLimit(options, state)) {
71
+ state.result.capped = true;
72
+ reasons.add("read_limit");
73
+ break hours;
55
74
  }
56
- for await (const entry of entries) {
57
- if (!entry.isFile() || !SAMPLE_FILE.test(entry.name))
58
- continue;
59
- if (filesRead >= (options.maxFiles ?? HOOK_SAMPLE_READ_LIMIT) ||
60
- performance.now() - started >= (options.readBudgetMs ?? READ_BUDGET_MS)) {
61
- result.capped = true;
62
- reasons.add("read_limit");
63
- break hours;
64
- }
65
- filesRead += 1;
66
- const file = path.join(directory, hour, entry.name);
67
- let raw;
68
- try {
69
- const stat = await fs.stat(file);
70
- if (stat.size > MAX_SAMPLE_BYTES) {
71
- result.invalid_samples += 1;
72
- continue;
73
- }
74
- raw = await fs.readFile(file, "utf8");
75
- }
76
- catch {
77
- result.unreadable_samples += 1;
78
- continue;
79
- }
80
- if (!raw.endsWith("\n")) {
81
- // Could be a live writer or one killed by the host. Leave it for the
82
- // next tick, and keep the incomplete observation out of every rate.
83
- result.incomplete_samples += 1;
84
- continue;
85
- }
86
- let parsed;
87
- try {
88
- parsed = JSON.parse(raw);
89
- }
90
- catch {
91
- result.invalid_samples += 1;
92
- continue;
93
- }
94
- const sample = MemoryHookSampleSchema.safeParse(parsed);
95
- if (!sample.success || sample.data.recorded_at.slice(0, 13) !== hour) {
96
- result.invalid_samples += 1;
97
- continue;
98
- }
99
- const at = Date.parse(sample.data.recorded_at);
100
- if (at < start || at > now.getTime())
101
- continue;
102
- const version = sample.data.producer_version;
103
- if (!versions.has(version) && versions.size >= 16) {
104
- result.capped = true;
105
- reasons.add("read_limit");
106
- break hours;
107
- }
108
- result.samples += 1;
109
- result.outcomes[sample.data.outcome] += 1;
110
- addDuration(result.duration_by_cache[sample.data.embed_cache], sample.data.elapsed_ms);
111
- versions.set(version, (versions.get(version) ?? 0) + 1);
75
+ state.filesRead += 1;
76
+ const shouldContinue = await readSample(path.join(directory, hour, entry.name), hour, now, start, reasons, state);
77
+ if (!shouldContinue) {
78
+ break hours;
112
79
  }
113
80
  }
114
- await pruneExpiredHours(directory, now, reasons);
81
+ }
82
+ }
83
+ async function openHourDirectory(directory, hour, reasons) {
84
+ try {
85
+ return await fs.opendir(path.join(directory, hour));
86
+ }
87
+ catch (error) {
88
+ if (error.code !== "ENOENT")
89
+ reasons.add("read_failed");
90
+ return null;
91
+ }
92
+ }
93
+ function hasReachedReadLimit(options, state) {
94
+ return state.filesRead >= (options.maxFiles ?? HOOK_SAMPLE_READ_LIMIT) ||
95
+ performance.now() - state.started >= (options.readBudgetMs ?? READ_BUDGET_MS);
96
+ }
97
+ async function readSample(file, hour, now, start, reasons, state) {
98
+ let raw;
99
+ try {
100
+ const stat = await fs.stat(file);
101
+ if (stat.size > MAX_SAMPLE_BYTES) {
102
+ state.result.invalid_samples += 1;
103
+ return true;
104
+ }
105
+ raw = await fs.readFile(file, "utf8");
115
106
  }
116
107
  catch {
117
- reasons.add("read_failed");
108
+ state.result.unreadable_samples += 1;
109
+ return true;
110
+ }
111
+ if (!raw.endsWith("\n")) {
112
+ // Could be a live writer or one killed by the host. Leave it for the
113
+ // next tick, and keep the incomplete observation out of every rate.
114
+ state.result.incomplete_samples += 1;
115
+ return true;
116
+ }
117
+ return recordSample(raw, hour, now, start, reasons, state);
118
+ }
119
+ function recordSample(raw, hour, now, start, reasons, state) {
120
+ let parsed;
121
+ try {
122
+ parsed = JSON.parse(raw);
118
123
  }
119
- if (!result.samples)
124
+ catch {
125
+ state.result.invalid_samples += 1;
126
+ return true;
127
+ }
128
+ const sample = MemoryHookSampleSchema.safeParse(parsed);
129
+ if (!sample.success || sample.data.recorded_at.slice(0, 13) !== hour) {
130
+ state.result.invalid_samples += 1;
131
+ return true;
132
+ }
133
+ const at = Date.parse(sample.data.recorded_at);
134
+ if (at < start || at > now.getTime())
135
+ return true;
136
+ const version = sample.data.producer_version;
137
+ if (!state.versions.has(version) && state.versions.size >= 16) {
138
+ state.result.capped = true;
139
+ reasons.add("read_limit");
140
+ return false;
141
+ }
142
+ state.result.samples += 1;
143
+ state.result.outcomes[sample.data.outcome] += 1;
144
+ addDuration(state.result.duration_by_cache[sample.data.embed_cache], sample.data.elapsed_ms);
145
+ state.versions.set(version, (state.versions.get(version) ?? 0) + 1);
146
+ return true;
147
+ }
148
+ function finishSampling(state, reasons) {
149
+ if (!state.result.samples)
120
150
  reasons.add("no_samples");
121
- if (result.invalid_samples)
151
+ if (state.result.invalid_samples)
122
152
  reasons.add("invalid_samples");
123
- if (result.incomplete_samples)
153
+ if (state.result.incomplete_samples)
124
154
  reasons.add("incomplete_samples");
125
- if (result.unreadable_samples)
155
+ if (state.result.unreadable_samples)
126
156
  reasons.add("unreadable_samples");
127
- result.producer_versions = [...versions].sort(([a], [b]) => a.localeCompare(b))
157
+ state.result.producer_versions = [...state.versions].sort(([a], [b]) => a.localeCompare(b))
128
158
  .map(([version, samples]) => ({ version, samples }));
129
- result.reasons = [...reasons];
130
- return result;
159
+ state.result.reasons = [...reasons];
160
+ return state.result;
131
161
  }
132
162
  async function renewLease(directory, now, reasons) {
133
163
  let continuousSince = now.toISOString();
134
164
  try {
135
165
  const previous = MemoryHookSamplingLeaseSchema.safeParse(JSON.parse(await fs.readFile(path.join(directory, "lease.json"), "utf8")));
136
- if (!previous.success || Date.parse(previous.data.continuous_since) > now.getTime())
166
+ if (!previous.success || Date.parse(previous.data.continuous_since) > now.getTime()) {
137
167
  reasons.add("lease_invalid");
138
- else if (Date.parse(previous.data.expires_at) <= now.getTime())
168
+ }
169
+ else if (Date.parse(previous.data.expires_at) <= now.getTime()) {
139
170
  reasons.add("lease_expired");
140
- else
171
+ }
172
+ else {
141
173
  continuousSince = previous.data.continuous_since;
174
+ }
142
175
  }
143
176
  catch (error) {
144
177
  reasons.add(error.code === "ENOENT" ? "lease_absent" : "lease_invalid");
@@ -160,8 +193,9 @@ async function pruneExpiredHours(directory, now, reasons) {
160
193
  const entries = await fs.opendir(directory);
161
194
  let pruned = 0;
162
195
  for await (const entry of entries) {
163
- if (!entry.isDirectory() || !HOUR_DIRECTORY.test(entry.name) || entry.name >= oldest)
196
+ if (!entry.isDirectory() || !HOUR_DIRECTORY.test(entry.name) || entry.name >= oldest) {
164
197
  continue;
198
+ }
165
199
  // Cleanup belongs to the collector and cannot delay a prompt. A bounded
166
200
  // number of expired hours is enough to catch up over successive ticks.
167
201
  await fs.rm(path.join(directory, entry.name), { recursive: true, force: true });
@@ -84,21 +84,11 @@ export async function inspectClaudeMemoryIntegration(options) {
84
84
  * `matches` pair runs differs, both already parameters here.
85
85
  */
86
86
  export async function applyJsonTarget(input) {
87
+ const prepared = await readJsonTargetRoot(input);
88
+ if ("target" in prepared)
89
+ return prepared;
87
90
  const { file, options } = input;
88
- const raw = await input.options.io.readText(file);
89
- let root;
90
- if (raw === null || !raw.trim()) {
91
- root = {};
92
- }
93
- else {
94
- const parsed = parseJsonRecord(raw);
95
- if (!parsed) {
96
- // Refuse rather than replace. A `~/.claude.json` that will not parse
97
- // still holds the person's project history and their other servers.
98
- return failure(input.target, file, "config_unreadable", "the file is not valid JSON; fix it and re-run");
99
- }
100
- root = parsed;
101
- }
91
+ const { raw, root } = prepared;
102
92
  if (input.matches(root)) {
103
93
  return { target: input.target, status: "already", reason: "already_current", path: file };
104
94
  }
@@ -126,6 +116,17 @@ export async function applyJsonTarget(input) {
126
116
  }
127
117
  return { target: input.target, status: "installed", reason: "wrote_entry", path: file };
128
118
  }
119
+ async function readJsonTargetRoot(input) {
120
+ const raw = await input.options.io.readText(input.file);
121
+ if (raw === null || !raw.trim())
122
+ return { raw, root: {} };
123
+ const root = parseJsonRecord(raw);
124
+ if (root)
125
+ return { raw, root };
126
+ // Refuse rather than replace. A `~/.claude.json` that will not parse
127
+ // still holds the person's project history and their other servers.
128
+ return failure(input.target, input.file, "config_unreadable", "the file is not valid JSON; fix it and re-run");
129
+ }
129
130
  /** Exported for `tower-mcp-claude.ts` (BLI-3706) — see `applyJsonTarget`'s note. */
130
131
  export async function inspectJsonTarget(input) {
131
132
  const raw = await input.io.readText(input.file);
@@ -123,12 +123,7 @@ async function applyCodexMcpTable(options) {
123
123
  async function applyCodexSkills(options) {
124
124
  const directory = codexSkillDirectory(options.homeDir);
125
125
  const files = memoryCodexSkillFiles();
126
- const stale = [];
127
- for (const [relative, contents] of Object.entries(files)) {
128
- const stored = await options.io.readText(path.join(directory, relative));
129
- if (stored !== contents)
130
- stale.push(relative);
131
- }
126
+ const stale = await findStaleCodexSkillFiles(directory, files, options.io);
132
127
  if (stale.length === 0) {
133
128
  return {
134
129
  target: "codex_skills",
@@ -181,6 +176,15 @@ async function applyCodexSkills(options) {
181
176
  detail: `${stale.length} skill file(s) written`,
182
177
  };
183
178
  }
179
+ async function findStaleCodexSkillFiles(directory, files, io) {
180
+ const stale = [];
181
+ for (const [relative, contents] of Object.entries(files)) {
182
+ const stored = await io.readText(path.join(directory, relative));
183
+ if (stored !== contents)
184
+ stale.push(relative);
185
+ }
186
+ return stale;
187
+ }
184
188
  export function renderMemoryTable(config) {
185
189
  const entries = [
186
190
  ["command", config.mcp_server.command],
@@ -51,9 +51,10 @@ export async function resolveMemoryConfig(command, io, platform, deps) {
51
51
  }
52
52
  if (isUnsafeBinPath(found.path)) {
53
53
  // A hook command is a shell string by the platform's design. A path that
54
- // cannot be quoted safely is not escaped cleverly, and it is not swapped
55
- // for a bare name that may resolve to something else either — the install
56
- // refuses and says why.
54
+ // cannot be expressed safely in one is not escaped cleverly, and it is not
55
+ // swapped for a bare name that may resolve to something else either — the
56
+ // install refuses and says why. See `shellSafeBinPath` for what CAN be
57
+ // expressed, including the Windows separator normalisation of BLI-4136.
57
58
  return {
58
59
  config: null,
59
60
  source: "none",
@@ -61,7 +62,7 @@ export async function resolveMemoryConfig(command, io, platform, deps) {
61
62
  target: "bin",
62
63
  status: "failed",
63
64
  reason: "bin_path_unsafe",
64
- detail: "the resolved bin path contains characters that cannot appear in a hook command; nothing was written",
65
+ detail: "the resolved bin path cannot be expressed safely in a hook command; nothing was written",
65
66
  },
66
67
  bin_source: found.source,
67
68
  };
@@ -49,6 +49,12 @@
49
49
  * machine resolved before anything is written. Skipping that step registered
50
50
  * three hooks that answered `command not found` on every turn.
51
51
  *
52
+ * **And the path it is re-pointed at is written for a SHELL, not for the
53
+ * filesystem** — `shellSafeBinPath`. A hook command is a shell string, Windows
54
+ * runs it through bash, and a native path's backslashes are eaten as escapes:
55
+ * the same three hooks, the same `command not found`, arrived at from the other
56
+ * direction (BLI-4136).
57
+ *
52
58
  * Nothing here touches the filesystem. The halves that do are
53
59
  * `memory-install-claude.ts` and `memory-install-codex.ts`.
54
60
  */
@@ -96,16 +102,56 @@ export const MEMORY_AUTO_APPROVE_TOOLS = [
96
102
  ];
97
103
  /** Characters that would let a resolved path change the meaning of a hook command string. */
98
104
  const UNSAFE_PATH_CHARACTERS = /["'`$;&|<>\r\n]/u;
105
+ /**
106
+ * Windows device paths — `\\?\C:\…` (extended length) and `\\.\…` (device
107
+ * namespace). Both prefixes are DEFINED in terms of backslashes and stop being
108
+ * that prefix the moment `shellSafeBinPath` turns them into forward slashes, so
109
+ * a hook command cannot carry one without silently naming a different file.
110
+ * Refused with a name rather than rewritten into something plausible.
111
+ */
112
+ const WINDOWS_DEVICE_PATH = /^\\\\[?.]\\/u;
99
113
  export function isUnsafeBinPath(binPath) {
100
- return UNSAFE_PATH_CHARACTERS.test(binPath);
114
+ return UNSAFE_PATH_CHARACTERS.test(binPath) || WINDOWS_DEVICE_PATH.test(binPath);
101
115
  }
102
116
  /**
103
- * A Claude Code hook `command` is a shell string by the platform's design, so
104
- * the only defence is quoting — and a path that could not be quoted safely is
105
- * refused upstream (see `resolveMemoryMcpBin`) rather than escaped cleverly.
117
+ * A path that no shell gives a meaning to, so it needs no quoting. Deliberately
118
+ * a tight allow-list rather than a list of known-bad characters: `(` and `)` in
119
+ * `C:/Program Files (x86)/…` are a bash syntax error, and the earlier
120
+ * whitespace-only trigger caught them only by the accident of the space.
121
+ */
122
+ const PLAINLY_SAFE_PATH = /^[A-Za-z0-9_@:./+,-]+$/u;
123
+ /**
124
+ * The resolved bin path, as it must appear INSIDE a hook command string.
125
+ *
126
+ * A Claude Code hook `command` is a shell string by the platform's design, and
127
+ * on Windows the host runs it through **bash** — which eats every backslash as
128
+ * an escape. So the native path this installer resolved,
129
+ * `C:\Users\…\.bin\bli-memory-mcp.cmd`, reaches the shell as
130
+ * `C:Users….binbli-memory-mcp.cmd` and answers `command not found` on every
131
+ * SessionStart, every prompt and every Stop. Non-blocking and therefore silent:
132
+ * memory recall simply degrades, and nothing says so (BLI-4136, fixed by hand
133
+ * twice on the same machine before the installer was the thing that changed).
134
+ *
135
+ * Two independent defences, because either one alone is a thin edge:
136
+ *
137
+ * 1. **Separators are normalised to `/` on Windows.** Win32 accepts forward
138
+ * slashes in every path it resolves, and after this there is no escape
139
+ * character left in the string for a shell to consume. This is also what
140
+ * survives a `\\`: double-quoting ALONE would collapse the UNC prefix of
141
+ * `\\server\share\…` back to one backslash and name the wrong path.
142
+ * 2. **Anything not plainly safe is double-quoted**, which covers spaces and
143
+ * the bracket characters in `Program Files (x86)`.
144
+ *
145
+ * A path that still could not be expressed safely is refused upstream
146
+ * (`isUnsafeBinPath`, checked in `resolveMemoryConfig`) rather than escaped
147
+ * cleverly.
148
+ *
149
+ * POSIX keeps its separators untouched — a backslash there is a legal
150
+ * character in a filename, and rewriting it would name a different file.
106
151
  */
107
- export function shellQuoteBinPath(binPath) {
108
- return /\s/u.test(binPath) ? `"${binPath}"` : binPath;
152
+ export function shellSafeBinPath(binPath, platform) {
153
+ const forShell = platform === "win32" ? binPath.replace(/\\/gu, "/") : binPath;
154
+ return PLAINLY_SAFE_PATH.test(forShell) ? forShell : `"${forShell}"`;
109
155
  }
110
156
  /**
111
157
  * Windows cannot spawn an npm `.cmd` shim directly — Node refuses it without a
@@ -125,13 +171,13 @@ export function memoryMcpServerEntry(options) {
125
171
  return { command: options.binPath, args: [], env };
126
172
  }
127
173
  export function builtinMemoryInstallConfig(options) {
128
- const quoted = shellQuoteBinPath(options.binPath);
174
+ const binPath = shellSafeBinPath(options.binPath, options.platform);
129
175
  return {
130
176
  server_id: MEMORY_MCP_SERVER_ID,
131
177
  mcp_server: memoryMcpServerEntry(options),
132
178
  hooks: MEMORY_HOOK_EVENTS.map((event) => ({
133
179
  event,
134
- command: `${quoted} ${MEMORY_HOOK_SUBCOMMAND[event]}`,
180
+ command: `${binPath} ${MEMORY_HOOK_SUBCOMMAND[event]}`,
135
181
  timeout_seconds: MEMORY_HOOK_TIMEOUT_SECONDS[event],
136
182
  })),
137
183
  permissions_allow: [...MEMORY_AUTO_APPROVE_TOOLS],
@@ -237,13 +283,13 @@ export function parsePrintedMemoryInstallConfig(stdout) {
237
283
  * hook that runs something else.
238
284
  */
239
285
  export function withResolvedBinPath(config, options) {
240
- const quoted = shellQuoteBinPath(options.binPath);
286
+ const binPath = shellSafeBinPath(options.binPath, options.platform);
241
287
  const hooks = [];
242
288
  for (const hook of config.hooks) {
243
289
  const tail = hookSubcommandTail(hook.command);
244
290
  if (!tail)
245
291
  return null;
246
- hooks.push({ ...hook, command: `${quoted} ${tail}` });
292
+ hooks.push({ ...hook, command: `${binPath} ${tail}` });
247
293
  }
248
294
  const entry = memoryMcpServerEntry(options);
249
295
  return {
@@ -83,17 +83,7 @@ export function logMemoryOutcome(outcome, platform) {
83
83
  : "[memory-install] BLI Memory registration converged", JSON.stringify(fields));
84
84
  }
85
85
  export function memoryOutcomeLines(outcome) {
86
- const headline = outcome.status === "installed"
87
- ? "BLI Memory registered on this machine."
88
- : outcome.status === "already"
89
- ? "BLI Memory is already registered on this machine."
90
- : outcome.status === "would_install"
91
- ? "BLI Memory would be registered (dry run; nothing was written)."
92
- : outcome.status === "missing"
93
- ? "BLI Memory is not registered on this machine."
94
- : outcome.status === "skipped"
95
- ? "BLI Memory was not registered and nothing was written: the bli-memory-mcp server is not on this machine yet."
96
- : `BLI Memory is not fully registered: ${outcome.reason}.`;
86
+ const headline = memoryOutcomeHeadline(outcome);
97
87
  const lines = [headline, ` ${memoryReceiptLine(outcome.receipt)}`];
98
88
  // BLI-3884. Only `status` asks; an absent reading is not printed as "no
99
89
  // daemon", because nothing looked.
@@ -115,4 +105,19 @@ export function memoryOutcomeLines(outcome) {
115
105
  lines.push(` ${target.target}: ${target.status} (${target.reason})${where}${detail}`);
116
106
  }
117
107
  return lines;
108
+ }
109
+ function memoryOutcomeHeadline(outcome) {
110
+ if (outcome.status === "installed")
111
+ return "BLI Memory registered on this machine.";
112
+ if (outcome.status === "already")
113
+ return "BLI Memory is already registered on this machine.";
114
+ if (outcome.status === "would_install") {
115
+ return "BLI Memory would be registered (dry run; nothing was written).";
116
+ }
117
+ if (outcome.status === "missing")
118
+ return "BLI Memory is not registered on this machine.";
119
+ if (outcome.status === "skipped") {
120
+ return "BLI Memory was not registered and nothing was written: the bli-memory-mcp server is not on this machine yet.";
121
+ }
122
+ return `BLI Memory is not fully registered: ${outcome.reason}.`;
118
123
  }
@@ -13,16 +13,6 @@
13
13
  * install is idempotent to the byte and a drifted copy is replaced rather than
14
14
  * merged — the same deal `cockpit agent-rules` offers for its managed block.
15
15
  */
16
- /** Relative path → exact file contents. The map IS the install. */
17
- export function memoryCodexSkillFiles() {
18
- return {
19
- "SKILL.md": SKILL_MD,
20
- "references/search.md": SEARCH_MD,
21
- "references/save.md": SAVE_MD,
22
- "references/update.md": UPDATE_MD,
23
- "references/forget.md": FORGET_MD,
24
- };
25
- }
26
16
  const SKILL_MD = `---
27
17
  name: bli-memory
28
18
  description: BLI Memory — durable memory for this machine. Use when you need to recall what was decided before, or when a session produced a durable decision, preference or correction worth keeping.
@@ -118,4 +108,14 @@ again. Matching by content is exact after whitespace normalisation — never
118
108
  fuzzy, and never widened to every container.
119
109
 
120
110
  It returns what it removed. A bare "done" is not an answer.
121
- `;
111
+ `;
112
+ /** Relative path → exact file contents. The map IS the install. */
113
+ export function memoryCodexSkillFiles() {
114
+ return {
115
+ "SKILL.md": SKILL_MD,
116
+ "references/search.md": SEARCH_MD,
117
+ "references/save.md": SAVE_MD,
118
+ "references/update.md": UPDATE_MD,
119
+ "references/forget.md": FORGET_MD,
120
+ };
121
+ }
@@ -5,20 +5,37 @@ import { callTower, openTower } from "./tower-command.js";
5
5
  function sender(command, io) {
6
6
  return async (entry) => {
7
7
  const tower = await openTower("memory log", command, io);
8
- const result = await callTower(tower, { path: "/api/memory/experience", method: "POST", body: entry, label: "memory experience", timeoutMs: 5000 });
9
- return result.ok ? { ok: result.body?.ok === true, reason: "experience_not_acknowledged" } : result;
8
+ const result = await callTower(tower, {
9
+ path: "/api/memory/experience",
10
+ method: "POST",
11
+ body: entry,
12
+ label: "memory experience",
13
+ timeoutMs: 5000,
14
+ });
15
+ return result.ok
16
+ ? {
17
+ ok: result.body?.ok === true,
18
+ reason: "experience_not_acknowledged",
19
+ }
20
+ : result;
10
21
  };
11
22
  }
12
23
  export async function runMemoryLog(command, io) {
13
- const reason = command.reasonStdin ? (await readPipedText(io.stdin, { maxChars: 1002 })).trim() : command.reason;
24
+ const reason = command.reasonStdin
25
+ ? (await readPipedText(io.stdin, { maxChars: 1002 })).trim()
26
+ : command.reason;
14
27
  validateExperience(command.store, command.verdict, reason);
15
28
  const entry = await appendExperience({ store: command.store, verdict: command.verdict, reason }, {
16
- homeDir: command.homeDir, agent: "cockpit", project: path.basename(process.cwd()),
29
+ homeDir: command.homeDir,
30
+ agent: "cockpit",
31
+ project: path.basename(process.cwd()),
17
32
  });
18
33
  const delivery = await shipExperience(entry, sender(command, io), command.homeDir);
19
34
  const receipt = { ok: true, id: entry.id, local: true, ...delivery };
20
35
  writeLine(io.stderr, `[memory experience] recorded ${JSON.stringify(receipt)}`);
21
- writeLine(io.stdout, command.json ? JSON.stringify(receipt) : `Experience appended; shipped: ${delivery.shipped} (${delivery.reason}).`);
36
+ writeLine(io.stdout, command.json
37
+ ? JSON.stringify(receipt)
38
+ : `Experience appended; shipped: ${delivery.shipped} (${delivery.reason}).`);
22
39
  return 0;
23
40
  }
24
41
  export async function runMemoryExperienceAfterSync(command, io) {
@@ -220,16 +220,23 @@ async function createChannel(command, door) {
220
220
  const membersAdded = body.members_added ?? [];
221
221
  const membersFailed = body.members_failed ?? [];
222
222
  const requestedMembers = command.memberEmails?.length ?? 0;
223
+ logChannelCreation(door, channel, command.isPrivate, requestedMembers, membersAdded, membersFailed);
224
+ if (door.json) {
225
+ return emitAgentDoor(door, { ok: true, channel, membersAdded, membersFailed });
226
+ }
227
+ reportChannelCreationToHuman(door, channel, name, requestedMembers, membersAdded, membersFailed);
228
+ return 0;
229
+ }
230
+ function logChannelCreation(door, channel, isPrivate, requestedMembers, membersAdded, membersFailed) {
223
231
  writeLine(door.io.stderr, `${TAG} created ${JSON.stringify({
224
232
  channel_id: channel?.id ?? null,
225
- is_private: command.isPrivate,
233
+ is_private: isPrivate,
226
234
  members_requested: requestedMembers,
227
235
  members_added: membersAdded.length,
228
236
  members_failed: membersFailed.length,
229
237
  })}`);
230
- if (door.json) {
231
- return emitAgentDoor(door, { ok: true, channel, membersAdded, membersFailed });
232
- }
238
+ }
239
+ function reportChannelCreationToHuman(door, channel, name, requestedMembers, membersAdded, membersFailed) {
233
240
  writeLine(door.io.stdout, `Created #${channel?.name ?? name} (${channel?.id ?? "?"}).`);
234
241
  if (membersAdded.length > 0) {
235
242
  writeLine(door.io.stdout, `${membersAdded.length} member(s) added.`);
@@ -246,7 +253,6 @@ async function createChannel(command, door) {
246
253
  for (const failure of membersFailed) {
247
254
  writeLine(door.io.stdout, `Not added (${failure.reason}): ${failure.userId}`);
248
255
  }
249
- return 0;
250
256
  }
251
257
  async function openDm(command, door) {
252
258
  const email = command.dmEmail ?? "";