@bli-cockpit/cli 0.2.119 → 0.2.122

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 (42) 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/careers.js +81 -9
  5. package/dist/commands/correct.js +38 -21
  6. package/dist/commands/docs.js +13 -10
  7. package/dist/commands/editor.js +59 -30
  8. package/dist/commands/install-receipts.js +106 -91
  9. package/dist/commands/local-args-tower-admin.js +4 -0
  10. package/dist/commands/local-args-tower-cal.js +28 -3
  11. package/dist/commands/local-args-tower-careers.js +56 -9
  12. package/dist/commands/local-args-tower-chat.js +55 -28
  13. package/dist/commands/local-args-tower-docs-msg.js +39 -8
  14. package/dist/commands/local-args-tower-mail.js +27 -1
  15. package/dist/commands/local-args-tower-models.js +14 -17
  16. package/dist/commands/local-args-tower-work.js +37 -6
  17. package/dist/commands/local-help-commands-tower.js +15 -1
  18. package/dist/commands/local-help-commands.js +2 -1
  19. package/dist/commands/local-help.js +1 -1
  20. package/dist/commands/mcp-stdio-probe.js +92 -73
  21. package/dist/commands/memory-hook-performance.js +135 -101
  22. package/dist/commands/memory-install-claude.js +15 -14
  23. package/dist/commands/memory-install-codex.js +10 -6
  24. package/dist/commands/memory-install-config.js +5 -4
  25. package/dist/commands/memory-install-contract.js +56 -10
  26. package/dist/commands/memory-install-report.js +16 -11
  27. package/dist/commands/memory-install-skills.js +11 -11
  28. package/dist/commands/memory-log.js +22 -5
  29. package/dist/commands/msg.js +11 -5
  30. package/dist/commands/onboard-setup.js +16 -1
  31. package/dist/commands/ops-sections.js +89 -0
  32. package/dist/commands/ops.js +117 -120
  33. package/dist/commands/public-root.js +1 -1
  34. package/dist/commands/scout.js +90 -68
  35. package/dist/commands/session-sync-failures.js +19 -13
  36. package/dist/commands/session-sync-record.js +53 -52
  37. package/dist/commands/session-sync-upload.js +15 -11
  38. package/dist/commands/sessions.js +61 -51
  39. package/dist/commands/slack.js +90 -61
  40. package/dist/commands/status.js +53 -41
  41. package/dist/commands/workbook.js +23 -20
  42. package/package.json +2 -2
@@ -65,6 +65,15 @@ export async function probeMcpTool(request) {
65
65
  catch (error) {
66
66
  return { status: "no_answer", ms: elapsed(), reason: "spawn_failed", said: firstLine(errorText(error)) };
67
67
  }
68
+ const replies = listenForMcpReplies(child, timeoutMs);
69
+ try {
70
+ return await callMcpTool(request, replies, elapsed);
71
+ }
72
+ finally {
73
+ closeMcpProbe(child);
74
+ }
75
+ }
76
+ function listenForMcpReplies(child, timeoutMs) {
68
77
  const pending = new Map();
69
78
  /**
70
79
  * A reply that arrived before anyone asked for it. Nothing in the protocol
@@ -84,6 +93,17 @@ export async function probeMcpTool(request) {
84
93
  };
85
94
  child.on("error", ((error) => end("spawn_failed", firstLine(errorText(error)))));
86
95
  child.on("exit", ((code) => end("server_exited", `the server exited with code ${code ?? "null"} before answering`)));
96
+ readMcpStdout(child, pending, early, end);
97
+ const send = (message) => {
98
+ child.stdin?.write(`${JSON.stringify(message)}\n`);
99
+ };
100
+ return {
101
+ send,
102
+ awaitReply: (id) => awaitMcpReply(id, pending, early, () => ended, endWaiters, end, timeoutMs),
103
+ endedDetail: () => endedDetail(ended),
104
+ };
105
+ }
106
+ function readMcpStdout(child, pending, early, end) {
87
107
  let buffer = "";
88
108
  child.stdout?.on("data", (chunk) => {
89
109
  buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
@@ -104,33 +124,32 @@ export async function probeMcpTool(request) {
104
124
  end("protocol_error", "the server wrote something to stdout that is not JSON-RPC");
105
125
  return;
106
126
  }
107
- const message = parsed;
108
- const id = typeof message["id"] === "number" ? message["id"] : null;
109
- if (id === null)
110
- continue;
111
- const waiter = pending.get(id);
112
- if (!waiter) {
113
- early.set(id, message);
114
- continue;
115
- }
116
- pending.delete(id);
117
- waiter(message);
127
+ deliverMcpReply(parsed, pending, early);
118
128
  }
119
129
  });
120
- const send = (message) => {
121
- child.stdin?.write(`${JSON.stringify(message)}\n`);
122
- };
123
- const awaitReply = (id) => new Promise((resolve) => {
130
+ }
131
+ function deliverMcpReply(message, pending, early) {
132
+ const id = typeof message["id"] === "number" ? message["id"] : null;
133
+ if (id === null)
134
+ return;
135
+ const waiter = pending.get(id);
136
+ if (!waiter) {
137
+ early.set(id, message);
138
+ return;
139
+ }
140
+ pending.delete(id);
141
+ waiter(message);
142
+ }
143
+ function awaitMcpReply(id, pending, early, getEnded, endWaiters, end, timeoutMs) {
144
+ return new Promise((resolve) => {
124
145
  const alreadyHere = early.get(id);
125
146
  if (alreadyHere) {
126
147
  early.delete(id);
127
148
  resolve(alreadyHere);
128
149
  return;
129
150
  }
130
- if (ended) {
131
- resolve(null);
132
- return;
133
- }
151
+ if (getEnded())
152
+ return resolve(null);
134
153
  const timer = setTimeout(() => {
135
154
  pending.delete(id);
136
155
  end("timed_out", `the server did not answer within ${timeoutMs} ms`);
@@ -147,62 +166,62 @@ export async function probeMcpTool(request) {
147
166
  resolve(message);
148
167
  });
149
168
  });
169
+ }
170
+ async function callMcpTool(request, replies, elapsed) {
171
+ replies.send({
172
+ jsonrpc: "2.0",
173
+ id: 1,
174
+ method: "initialize",
175
+ params: {
176
+ protocolVersion: "2025-06-18",
177
+ capabilities: {},
178
+ clientInfo: { name: "cockpit-doctor", version: "1" },
179
+ },
180
+ });
181
+ const initialized = await replies.awaitReply(1);
182
+ if (!initialized)
183
+ return { status: "no_answer", ms: elapsed(), ...replies.endedDetail() };
184
+ if (initialized["error"]) {
185
+ return {
186
+ status: "no_answer",
187
+ ms: elapsed(),
188
+ reason: "protocol_error",
189
+ said: firstLine(rpcErrorMessage(initialized)),
190
+ };
191
+ }
192
+ replies.send({ jsonrpc: "2.0", method: "notifications/initialized" });
193
+ replies.send({
194
+ jsonrpc: "2.0",
195
+ id: 2,
196
+ method: "tools/call",
197
+ params: { name: request.toolName, arguments: request.toolArguments },
198
+ });
199
+ const called = await replies.awaitReply(2);
200
+ if (!called)
201
+ return { status: "no_answer", ms: elapsed(), ...replies.endedDetail() };
202
+ if (called["error"]) {
203
+ return {
204
+ status: "no_answer",
205
+ ms: elapsed(),
206
+ reason: "protocol_error",
207
+ said: firstLine(rpcErrorMessage(called)),
208
+ };
209
+ }
210
+ const result = (called["result"] ?? {});
211
+ const said = firstLine((result.content ?? []).find((part) => part.type === "text")?.text ?? "");
212
+ if (result.isError !== true)
213
+ return { status: "answered", ms: elapsed(), said };
214
+ return { status: "refused", ms: elapsed(), reason: refusalReason(said), said };
215
+ }
216
+ function closeMcpProbe(child) {
150
217
  try {
151
- send({
152
- jsonrpc: "2.0",
153
- id: 1,
154
- method: "initialize",
155
- params: {
156
- protocolVersion: "2025-06-18",
157
- capabilities: {},
158
- clientInfo: { name: "cockpit-doctor", version: "1" },
159
- },
160
- });
161
- const initialized = await awaitReply(1);
162
- if (!initialized)
163
- return { status: "no_answer", ms: elapsed(), ...endedDetail(ended) };
164
- if (initialized["error"]) {
165
- return {
166
- status: "no_answer",
167
- ms: elapsed(),
168
- reason: "protocol_error",
169
- said: firstLine(rpcErrorMessage(initialized)),
170
- };
171
- }
172
- send({ jsonrpc: "2.0", method: "notifications/initialized" });
173
- send({
174
- jsonrpc: "2.0",
175
- id: 2,
176
- method: "tools/call",
177
- params: { name: request.toolName, arguments: request.toolArguments },
178
- });
179
- const called = await awaitReply(2);
180
- if (!called)
181
- return { status: "no_answer", ms: elapsed(), ...endedDetail(ended) };
182
- if (called["error"]) {
183
- return {
184
- status: "no_answer",
185
- ms: elapsed(),
186
- reason: "protocol_error",
187
- said: firstLine(rpcErrorMessage(called)),
188
- };
189
- }
190
- const result = (called["result"] ?? {});
191
- const said = firstLine((result.content ?? []).find((part) => part.type === "text")?.text ?? "");
192
- if (result.isError !== true)
193
- return { status: "answered", ms: elapsed(), said };
194
- return { status: "refused", ms: elapsed(), reason: refusalReason(said), said };
218
+ child.stdin?.end();
219
+ child.kill();
195
220
  }
196
- finally {
197
- try {
198
- child.stdin?.end();
199
- child.kill();
200
- }
201
- catch (error) {
202
- // The verdict is already decided; a child that will not close changes
203
- // none of it. Not silent: the reason travels in the caller's log.
204
- void error;
205
- }
221
+ catch (error) {
222
+ // The verdict is already decided; a child that will not close changes
223
+ // none of it. Not silent: the reason travels in the caller's log.
224
+ void error;
206
225
  }
207
226
  }
208
227
  /**
@@ -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
  };