@bli-cockpit/cli 0.2.49 → 0.2.50
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/dist/adapters/raw-evidence-claude-reader.js +108 -0
- package/dist/adapters/raw-evidence-codex-reader.js +147 -0
- package/dist/adapters/raw-evidence-collection-state.js +199 -0
- package/dist/adapters/raw-evidence-facts.js +338 -0
- package/dist/adapters/raw-evidence-git-diff-reader.js +187 -0
- package/dist/adapters/raw-evidence-image-reader.js +107 -0
- package/dist/adapters/raw-evidence-sanitize.js +56 -0
- package/dist/adapters/raw-evidence-transcript-file.js +182 -0
- package/dist/adapters/raw-evidence.js +63 -1183
- package/dist/commands/backfill-batches.js +34 -0
- package/dist/commands/backfill-candidates.js +54 -0
- package/dist/commands/backfill-checkpoint.js +101 -0
- package/dist/commands/backfill-command-line.js +70 -0
- package/dist/commands/backfill-evidence-outcomes.js +104 -0
- package/dist/commands/backfill-issues.js +265 -0
- package/dist/commands/backfill-output.js +75 -0
- package/dist/commands/backfill-plan.js +71 -0
- package/dist/commands/backfill-reasons.js +107 -0
- package/dist/commands/backfill-report.js +298 -0
- package/dist/commands/backfill-result.js +150 -0
- package/dist/commands/backfill-scan.js +274 -0
- package/dist/commands/backfill-scope.js +114 -0
- package/dist/commands/backfill-session-report.js +145 -0
- package/dist/commands/backfill-types.js +1 -0
- package/dist/commands/backfill-upload.js +212 -0
- package/dist/commands/backfill.js +41 -1961
- package/dist/commands/doctor.js +57 -0
- package/dist/commands/jarvis-trace.js +184 -0
- package/dist/commands/jarvis.js +144 -4
- package/dist/commands/local-args-collector.js +26 -0
- package/dist/commands/local-args-tower.js +21 -0
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-help.js +19 -2
- package/dist/commands/local.js +3 -0
- package/dist/commands/memory-install-claude.js +294 -0
- package/dist/commands/memory-install-codex.js +205 -0
- package/dist/commands/memory-install-contract.js +231 -0
- package/dist/commands/memory-install-files.js +63 -0
- package/dist/commands/memory-install-skills.js +121 -0
- package/dist/commands/memory-install-toml.js +265 -0
- package/dist/commands/memory-install.js +378 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/sync-followups.js +105 -0
- package/dist/commands/sync.js +7 -1
- package/dist/local-state-attributed-target.js +75 -0
- package/dist/local-state-config.js +147 -0
- package/dist/local-state-files.js +59 -0
- package/dist/local-state-identity.js +73 -0
- package/dist/local-state-pairing.js +263 -0
- package/dist/local-state-paths.js +61 -0
- package/dist/local-state-session.js +68 -0
- package/dist/local-state-status.js +163 -0
- package/dist/local-state-work-context.js +190 -0
- package/dist/local-state.js +34 -848
- package/dist/tower-client.js +3 -2
- package/dist/tower-stream.js +57 -3
- package/package.json +2 -1
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit memory install` / `cockpit memory status` (BLI-3580).
|
|
3
|
+
*
|
|
4
|
+
* BLI Memory replaces hosted Supermemory: our own table, our own doors, and an
|
|
5
|
+
* MCP server (`@bli-cockpit/memory-mcp`, bin `bli-memory-mcp`) that both agent
|
|
6
|
+
* hosts talk to. Nothing about that reaches an intern's machine unless
|
|
7
|
+
* something puts it there — the vendor plugin never had a fleet install path,
|
|
8
|
+
* which is why every machine was configured by hand. This is that path, and it
|
|
9
|
+
* does not ask: `do-everything` runs it, and the sync tick re-runs it at most
|
|
10
|
+
* once a day so a machine converges without anyone typing anything.
|
|
11
|
+
*
|
|
12
|
+
* What it writes, and where, is in the two halves:
|
|
13
|
+
* memory-install-claude.ts ~/.claude.json (user-scope MCP) + ~/.claude/settings.json (hooks, allow-list)
|
|
14
|
+
* memory-install-codex.ts ~/.codex/config.toml (one table) + ~/.codex/skills/bli-memory/
|
|
15
|
+
*
|
|
16
|
+
* What this module owns is the decisions around them:
|
|
17
|
+
*
|
|
18
|
+
* - **Where the server is.** `bli-memory-mcp` ships as a DEPENDENCY of
|
|
19
|
+
* `@bli-cockpit/cli`, so the canonical lookup is the `node_modules/.bin` on
|
|
20
|
+
* the way up from this CLI's own entry point; PATH is the fallback. Nobody
|
|
21
|
+
* ever installs a second global package. See `resolveMemoryMcpBin`.
|
|
22
|
+
* - **No server, no write.** If the bin does not resolve, not one file is
|
|
23
|
+
* opened and the outcome is `skipped bin_missing` — see `no_bin_no_write`
|
|
24
|
+
* below for why a written-but-inert registration is the worse option.
|
|
25
|
+
* - **Which config shape.** `bli-memory-mcp --print-config --claude` wins when
|
|
26
|
+
* the bin answers it; the built-in template of the same shape is the
|
|
27
|
+
* fallback for an older server, and it is only ever used with a bin that
|
|
28
|
+
* actually resolved.
|
|
29
|
+
* - **Never throw at the caller.** The sync tick calls this. A failure is a
|
|
30
|
+
* named receipt, never an exception that could cost a machine its
|
|
31
|
+
* collection tick.
|
|
32
|
+
* - **Never claim an install it did not read back** (BLI-2541). Both halves
|
|
33
|
+
* re-read and re-parse; this module only aggregates what they proved.
|
|
34
|
+
*/
|
|
35
|
+
import os from "node:os";
|
|
36
|
+
import path from "node:path";
|
|
37
|
+
import { writeLine } from "./cli-io.js";
|
|
38
|
+
import { builtinMemoryInstallConfig, isUnsafeBinPath, MEMORY_MCP_BIN, parsePrintedMemoryInstallConfig, } from "./memory-install-contract.js";
|
|
39
|
+
import { defaultMemoryFileIo, } from "./memory-install-files.js";
|
|
40
|
+
import { installClaudeMemoryIntegration, inspectClaudeMemoryIntegration, } from "./memory-install-claude.js";
|
|
41
|
+
import { installCodexMemoryIntegration, inspectCodexMemoryIntegration, } from "./memory-install-codex.js";
|
|
42
|
+
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
|
|
43
|
+
import { envWithNodeRuntimeOnPath } from "../scheduled-self-update.js";
|
|
44
|
+
export async function runMemoryInstall(command, io, deps = {}) {
|
|
45
|
+
const outcome = command.action === "status"
|
|
46
|
+
? await inspectMemoryIntegration(command, io, deps)
|
|
47
|
+
: await installMemoryIntegration(command, io, deps);
|
|
48
|
+
if (command.json) {
|
|
49
|
+
writeLine(io.stdout, JSON.stringify(outcome, null, 2));
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
for (const line of memoryOutcomeLines(outcome))
|
|
53
|
+
writeLine(io.stdout, line);
|
|
54
|
+
}
|
|
55
|
+
return outcome.status === "failed" ? 1 : 0;
|
|
56
|
+
}
|
|
57
|
+
export async function installMemoryIntegration(command, io, deps = {}) {
|
|
58
|
+
const homeDir = deps.homeDir ?? command.homeDir ?? os.homedir();
|
|
59
|
+
const platform = deps.platform ?? process.platform;
|
|
60
|
+
const fileIo = deps.io ?? defaultMemoryFileIo();
|
|
61
|
+
const resolved = await resolveMemoryConfig(command, io, platform, deps);
|
|
62
|
+
const targets = [resolved.binTarget];
|
|
63
|
+
// `no_bin_no_write`: with no resolved server there is nothing safe to
|
|
64
|
+
// register, so not one file is opened. The bin target carries the reason.
|
|
65
|
+
if (resolved.config) {
|
|
66
|
+
targets.push(...(await installClaudeMemoryIntegration({
|
|
67
|
+
homeDir,
|
|
68
|
+
config: resolved.config,
|
|
69
|
+
dryRun: command.dryRun,
|
|
70
|
+
io: fileIo,
|
|
71
|
+
})));
|
|
72
|
+
targets.push(...(await installCodexMemoryIntegration({
|
|
73
|
+
homeDir,
|
|
74
|
+
config: resolved.config,
|
|
75
|
+
dryRun: command.dryRun,
|
|
76
|
+
io: fileIo,
|
|
77
|
+
})));
|
|
78
|
+
}
|
|
79
|
+
const outcome = {
|
|
80
|
+
action: "install",
|
|
81
|
+
...aggregate(targets),
|
|
82
|
+
config_source: resolved.source,
|
|
83
|
+
bin_found: resolved.config !== null,
|
|
84
|
+
...(resolved.bin_source ? { bin_source: resolved.bin_source } : {}),
|
|
85
|
+
targets,
|
|
86
|
+
};
|
|
87
|
+
logMemoryOutcome(outcome, platform);
|
|
88
|
+
return outcome;
|
|
89
|
+
}
|
|
90
|
+
export async function inspectMemoryIntegration(command, io, deps = {}) {
|
|
91
|
+
const homeDir = deps.homeDir ?? command.homeDir ?? os.homedir();
|
|
92
|
+
const platform = deps.platform ?? process.platform;
|
|
93
|
+
const fileIo = deps.io ?? defaultMemoryFileIo();
|
|
94
|
+
const resolved = await resolveMemoryConfig(command, io, platform, deps);
|
|
95
|
+
const targets = [resolved.binTarget];
|
|
96
|
+
// Without a resolved server there is no shape to compare the stored config
|
|
97
|
+
// against, so `status` says the one true thing — the server is not here —
|
|
98
|
+
// rather than reporting four targets against a shape we invented.
|
|
99
|
+
if (resolved.config) {
|
|
100
|
+
targets.push(...(await inspectClaudeMemoryIntegration({
|
|
101
|
+
homeDir,
|
|
102
|
+
config: resolved.config,
|
|
103
|
+
io: fileIo,
|
|
104
|
+
})));
|
|
105
|
+
targets.push(...(await inspectCodexMemoryIntegration({
|
|
106
|
+
homeDir,
|
|
107
|
+
config: resolved.config,
|
|
108
|
+
io: fileIo,
|
|
109
|
+
})));
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
action: "status",
|
|
113
|
+
...aggregate(targets),
|
|
114
|
+
config_source: resolved.source,
|
|
115
|
+
bin_found: resolved.config !== null,
|
|
116
|
+
...(resolved.bin_source ? { bin_source: resolved.bin_source } : {}),
|
|
117
|
+
targets,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* **`no_bin_no_write`.** When `bli-memory-mcp` cannot be resolved, this command
|
|
122
|
+
* writes NOTHING — not the MCP entry, not the hooks, not the Codex table, not
|
|
123
|
+
* the skills.
|
|
124
|
+
*
|
|
125
|
+
* An earlier revision wrote the registration anyway, on the reasoning that a
|
|
126
|
+
* correct shape waiting for the package is better than nothing. It is not:
|
|
127
|
+
* Claude Code RUNS a registered hook. Three hooks pointing at a binary that
|
|
128
|
+
* does not exist would print a hook failure on every SessionStart, every
|
|
129
|
+
* prompt and every Stop, on every intern machine, until the package shipped —
|
|
130
|
+
* a self-inflicted outage in the one surface people look at all day.
|
|
131
|
+
*
|
|
132
|
+
* So an absent bin is `skipped bin_missing`: a receipt, not a write. The daily
|
|
133
|
+
* self-heal retries tomorrow, and the first tick after the package lands does
|
|
134
|
+
* the whole registration at once.
|
|
135
|
+
*/
|
|
136
|
+
async function resolveMemoryConfig(command, io, platform, deps) {
|
|
137
|
+
const dashboardUrl = await resolveDashboardUrl(command, deps);
|
|
138
|
+
const found = await resolveMemoryMcpBin({
|
|
139
|
+
env: envWithNodeRuntimeOnPath(io.env ?? process.env),
|
|
140
|
+
platform,
|
|
141
|
+
fileExists: deps.fileExists,
|
|
142
|
+
cliEntryPoint: deps.cliEntryPoint,
|
|
143
|
+
});
|
|
144
|
+
if (!found) {
|
|
145
|
+
return {
|
|
146
|
+
config: null,
|
|
147
|
+
source: "none",
|
|
148
|
+
binTarget: {
|
|
149
|
+
target: "bin",
|
|
150
|
+
status: "skipped",
|
|
151
|
+
reason: "bin_missing",
|
|
152
|
+
detail: `${MEMORY_MCP_BIN} is not installed beside this CLI or on PATH; nothing was written, and the next daily run will try again`,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (isUnsafeBinPath(found.path)) {
|
|
157
|
+
// A hook command is a shell string by the platform's design. A path that
|
|
158
|
+
// cannot be quoted safely is not escaped cleverly, and it is not swapped
|
|
159
|
+
// for a bare name that may resolve to something else either — the install
|
|
160
|
+
// refuses and says why.
|
|
161
|
+
return {
|
|
162
|
+
config: null,
|
|
163
|
+
source: "none",
|
|
164
|
+
binTarget: {
|
|
165
|
+
target: "bin",
|
|
166
|
+
status: "failed",
|
|
167
|
+
reason: "bin_path_unsafe",
|
|
168
|
+
detail: "the resolved bin path contains characters that cannot appear in a hook command; nothing was written",
|
|
169
|
+
},
|
|
170
|
+
bin_source: found.source,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const printed = await printedMemoryConfig(io, found.path);
|
|
174
|
+
if (printed) {
|
|
175
|
+
return {
|
|
176
|
+
config: printed,
|
|
177
|
+
source: "bin",
|
|
178
|
+
binTarget: { target: "bin", status: "already", reason: "bin_printed_config" },
|
|
179
|
+
bin_source: found.source,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
config: builtinMemoryInstallConfig({
|
|
184
|
+
binPath: found.path,
|
|
185
|
+
platform,
|
|
186
|
+
dashboardUrl,
|
|
187
|
+
}),
|
|
188
|
+
source: "template",
|
|
189
|
+
binTarget: {
|
|
190
|
+
target: "bin",
|
|
191
|
+
status: "already",
|
|
192
|
+
reason: "bin_present_template_used",
|
|
193
|
+
detail: "the bin did not print a usable --print-config; the built-in shape was used",
|
|
194
|
+
},
|
|
195
|
+
bin_source: found.source,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Asks the server for its own shape. Arguments go as an ARRAY — nothing is
|
|
200
|
+
* interpolated into a command line — and on Windows the captured runner routes
|
|
201
|
+
* a `.cmd` shim through ComSpec with its own escaping (process-runner.ts).
|
|
202
|
+
*/
|
|
203
|
+
async function printedMemoryConfig(io, binPath) {
|
|
204
|
+
const exec = io.exec;
|
|
205
|
+
if (!exec)
|
|
206
|
+
return null;
|
|
207
|
+
try {
|
|
208
|
+
const result = await exec(binPath, ["--print-config", "--claude"]);
|
|
209
|
+
if (result.code !== 0)
|
|
210
|
+
return null;
|
|
211
|
+
return parsePrintedMemoryInstallConfig(result.stdout);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// Deliberately silent here: the caller reports
|
|
215
|
+
// `bin_present_template_used`, which is the same information with a name
|
|
216
|
+
// on it, and this path is reached on every machine that has an older bin.
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Two steps, in this order, and the first is the canonical one.
|
|
222
|
+
*
|
|
223
|
+
* **1. Beside the CLI that is running.** `bli-memory-mcp` ships as a DEPENDENCY
|
|
224
|
+
* of `@bli-cockpit/cli`, so installing the CLI installs the server, and npm
|
|
225
|
+
* links its bin into a `node_modules/.bin` on the path from this entry point up
|
|
226
|
+
* to the install root — `…/@bli-cockpit/cli/node_modules/.bin` when it is
|
|
227
|
+
* nested, `…/lib/node_modules/.bin` when npm hoists it. Walking up from
|
|
228
|
+
* `process.argv[1]` finds it either way, and it is the ONLY lookup that cannot
|
|
229
|
+
* find somebody else's `bli-memory-mcp`. Nobody ever runs `npm i -g` for a
|
|
230
|
+
* second package.
|
|
231
|
+
*
|
|
232
|
+
* **2. PATH, as a fallback**, for a linked checkout or a hand-installed server.
|
|
233
|
+
* Done in this process rather than through `which`/`where`: it spawns nothing,
|
|
234
|
+
* behaves the same on both host families, and is testable without a fixture
|
|
235
|
+
* binary. The npm global bin sits beside the running node binary in the
|
|
236
|
+
* standard layouts, which is why the caller passes a PATH that already includes
|
|
237
|
+
* it (`envWithNodeRuntimeOnPath`) — the launchd tick's PATH is otherwise
|
|
238
|
+
* `/usr/bin:/bin:/usr/sbin:/sbin` and would find nothing.
|
|
239
|
+
*/
|
|
240
|
+
export async function resolveMemoryMcpBin(options) {
|
|
241
|
+
const exists = options.fileExists ?? defaultFileExists;
|
|
242
|
+
const beside = await resolveBesideCli(options, exists);
|
|
243
|
+
if (beside)
|
|
244
|
+
return { path: beside, source: "cli_dependency" };
|
|
245
|
+
const onPath = await resolveOnPath(options, exists);
|
|
246
|
+
return onPath ? { path: onPath, source: "path" } : null;
|
|
247
|
+
}
|
|
248
|
+
async function resolveBesideCli(options, exists) {
|
|
249
|
+
const entry = options.cliEntryPoint ?? process.argv[1];
|
|
250
|
+
if (!entry)
|
|
251
|
+
return null;
|
|
252
|
+
const platformPath = options.platform === "win32" ? path.win32 : path.posix;
|
|
253
|
+
const extensions = binExtensions(options.platform);
|
|
254
|
+
let directory = platformPath.dirname(platformPath.resolve(entry));
|
|
255
|
+
// Bounded walk: deep enough for `…/node_modules/@scope/pkg/dist/cli.js` plus
|
|
256
|
+
// a hoisted root above it, and it stops at the filesystem root anyway.
|
|
257
|
+
for (let depth = 0; depth < 12; depth += 1) {
|
|
258
|
+
for (const extension of extensions) {
|
|
259
|
+
const candidate = platformPath.join(directory, "node_modules", ".bin", `${MEMORY_MCP_BIN}${extension}`);
|
|
260
|
+
if (await exists(candidate))
|
|
261
|
+
return candidate;
|
|
262
|
+
}
|
|
263
|
+
const parent = platformPath.dirname(directory);
|
|
264
|
+
if (parent === directory)
|
|
265
|
+
break;
|
|
266
|
+
directory = parent;
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
async function resolveOnPath(options, exists) {
|
|
271
|
+
// The TARGET platform's path rules, not the running one's. On a real machine
|
|
272
|
+
// they are the same; asking for them explicitly is what lets the Windows
|
|
273
|
+
// lookup be tested from a Mac, which is the only Windows proof this repo
|
|
274
|
+
// gets before a release (AGENTS.md, supported fleet).
|
|
275
|
+
const platformPath = options.platform === "win32" ? path.win32 : path.posix;
|
|
276
|
+
const entries = (options.env["PATH"] ?? options.env["Path"] ?? "")
|
|
277
|
+
.split(platformPath.delimiter)
|
|
278
|
+
.map((entry) => entry.trim())
|
|
279
|
+
.filter(Boolean);
|
|
280
|
+
for (const entry of entries) {
|
|
281
|
+
for (const extension of binExtensions(options.platform)) {
|
|
282
|
+
const candidate = platformPath.join(entry, `${MEMORY_MCP_BIN}${extension}`);
|
|
283
|
+
if (await exists(candidate))
|
|
284
|
+
return candidate;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
/** npm writes `.cmd` (and `.ps1`) shims on Windows; POSIX gets the bare name. */
|
|
290
|
+
function binExtensions(platform) {
|
|
291
|
+
return platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""];
|
|
292
|
+
}
|
|
293
|
+
async function defaultFileExists(file) {
|
|
294
|
+
const { stat } = await import("node:fs/promises");
|
|
295
|
+
try {
|
|
296
|
+
return (await stat(file)).isFile();
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
async function resolveDashboardUrl(command, deps) {
|
|
303
|
+
if (command.dashboardUrl)
|
|
304
|
+
return command.dashboardUrl;
|
|
305
|
+
const paths = getCollectorRuntimePaths(deps.homeDir ?? command.homeDir);
|
|
306
|
+
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
307
|
+
return config?.dashboard_url ?? DEFAULT_DASHBOARD_URL;
|
|
308
|
+
}
|
|
309
|
+
function aggregate(targets) {
|
|
310
|
+
const failed = targets.filter((target) => target.status === "failed");
|
|
311
|
+
if (failed.length > 0) {
|
|
312
|
+
return {
|
|
313
|
+
status: "failed",
|
|
314
|
+
// The first named reason, not a count: an operator needs the reason, and
|
|
315
|
+
// the per-target list beside it carries the rest.
|
|
316
|
+
reason: failed[0]?.reason ?? "unknown_failure",
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
if (targets.some((target) => target.status === "would_install")) {
|
|
320
|
+
return { status: "would_install", reason: "dry_run" };
|
|
321
|
+
}
|
|
322
|
+
if (targets.some((target) => target.status === "installed")) {
|
|
323
|
+
return { status: "installed", reason: "wrote_entry" };
|
|
324
|
+
}
|
|
325
|
+
if (targets.some((target) => target.status === "mismatch" || target.status === "missing")) {
|
|
326
|
+
return { status: "missing", reason: "entry_absent" };
|
|
327
|
+
}
|
|
328
|
+
const skipped = targets.find((target) => target.status === "skipped");
|
|
329
|
+
if (skipped) {
|
|
330
|
+
// Not a failure and not a success: nothing was written, on purpose, and
|
|
331
|
+
// the next daily run will try again. `bin_missing` is the only one today.
|
|
332
|
+
return { status: "skipped", reason: skipped.reason };
|
|
333
|
+
}
|
|
334
|
+
return { status: "already", reason: "already_current" };
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Both branches log, and neither carries a path — a home directory names a
|
|
338
|
+
* person, and this line ends up in `sync.err.log` on every machine.
|
|
339
|
+
*/
|
|
340
|
+
function logMemoryOutcome(outcome, platform) {
|
|
341
|
+
const fields = {
|
|
342
|
+
status: outcome.status,
|
|
343
|
+
reason: outcome.reason,
|
|
344
|
+
config_source: outcome.config_source,
|
|
345
|
+
bin_found: outcome.bin_found,
|
|
346
|
+
platform,
|
|
347
|
+
installed_count: outcome.targets.filter((target) => target.status === "installed").length,
|
|
348
|
+
already_count: outcome.targets.filter((target) => target.status === "already").length,
|
|
349
|
+
failed: outcome.targets
|
|
350
|
+
.filter((target) => target.status === "failed")
|
|
351
|
+
.map((target) => `${target.target}:${target.reason}`),
|
|
352
|
+
};
|
|
353
|
+
// stderr on both branches: launchd captures it to sync.err.log, and stdout is
|
|
354
|
+
// reserved for `--json`.
|
|
355
|
+
console.error(outcome.status === "failed"
|
|
356
|
+
? "[memory-install] BLI Memory is not fully registered on this machine"
|
|
357
|
+
: "[memory-install] BLI Memory registration converged", JSON.stringify(fields));
|
|
358
|
+
}
|
|
359
|
+
export function memoryOutcomeLines(outcome) {
|
|
360
|
+
const headline = outcome.status === "installed"
|
|
361
|
+
? "BLI Memory registered on this machine."
|
|
362
|
+
: outcome.status === "already"
|
|
363
|
+
? "BLI Memory is already registered on this machine."
|
|
364
|
+
: outcome.status === "would_install"
|
|
365
|
+
? "BLI Memory would be registered (dry run; nothing was written)."
|
|
366
|
+
: outcome.status === "missing"
|
|
367
|
+
? "BLI Memory is not registered on this machine."
|
|
368
|
+
: outcome.status === "skipped"
|
|
369
|
+
? "BLI Memory was not registered and nothing was written: the bli-memory-mcp server is not on this machine yet."
|
|
370
|
+
: `BLI Memory is not fully registered: ${outcome.reason}.`;
|
|
371
|
+
const lines = [headline];
|
|
372
|
+
for (const target of outcome.targets) {
|
|
373
|
+
const where = target.path ? ` ${target.path}` : "";
|
|
374
|
+
const detail = target.detail ? ` — ${target.detail}` : "";
|
|
375
|
+
lines.push(` ${target.target}: ${target.status} (${target.reason})${where}${detail}`);
|
|
376
|
+
}
|
|
377
|
+
return lines;
|
|
378
|
+
}
|
|
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
18
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
18
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.50");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -1,4 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the sync tick does AFTER collection's own outcome is decided and
|
|
3
|
+
* reported: keep this machine's CLI current on npm `latest` (BLI-2601), put a
|
|
4
|
+
* broken scheduler registration back (BLI-2721), and keep BLI Memory
|
|
5
|
+
* registered with both agent hosts (BLI-3580).
|
|
6
|
+
*
|
|
7
|
+
* Split out of commands/sync.ts (BLI-3578), moved verbatim. They belong
|
|
8
|
+
* together because they share one rule, and it is the reason both are called
|
|
9
|
+
* last: a follow-up may never block, delay or fail collection. Every error path
|
|
10
|
+
* here is swallowed on purpose and reported as its own named receipt — an
|
|
11
|
+
* `update` step, or an autostart repair step — never as a `sync` failure.
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
1
15
|
import { resolveAutostartRoots } from "./autostart-command.js";
|
|
16
|
+
import { installMemoryIntegration, } from "./memory-install.js";
|
|
2
17
|
import { redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
|
|
3
18
|
import { runSelfUpdate, SelfUpdateError } from "./install-update.js";
|
|
4
19
|
import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
|
|
@@ -78,6 +93,96 @@ async function reportAutostartSelfHealOutcome(command, io, dashboardUrl, result)
|
|
|
78
93
|
io,
|
|
79
94
|
});
|
|
80
95
|
}
|
|
96
|
+
export const MEMORY_INSTALL_THROTTLE_MARKER = ".last-memory-install";
|
|
97
|
+
const MEMORY_INSTALL_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
98
|
+
/**
|
|
99
|
+
* BLI-3580: BLI Memory's registration converges on its own.
|
|
100
|
+
*
|
|
101
|
+
* Nobody is going to be asked to install a hook. `do-everything` registers it
|
|
102
|
+
* on the way through, and this puts it back if a host config is edited,
|
|
103
|
+
* replaced, or restored from a machine that never had it — at most once a day,
|
|
104
|
+
* because the steady state is "already current" and re-proving that every
|
|
105
|
+
* fifteen minutes is four file reads a tick for no new information.
|
|
106
|
+
*
|
|
107
|
+
* Same rule as the two follow-ups above: it runs only once collection's own
|
|
108
|
+
* outcome has been decided and reported, it never throws, and its outcome is
|
|
109
|
+
* its own named receipt rather than a sync failure.
|
|
110
|
+
*/
|
|
111
|
+
export async function runMemoryInstallAfterSync(command, io, dashboardUrl, options = {}) {
|
|
112
|
+
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
113
|
+
const now = options.now ?? new Date();
|
|
114
|
+
const marker = path.join(paths.state_dir, MEMORY_INSTALL_THROTTLE_MARKER);
|
|
115
|
+
const lastAttempt = await fs.stat(marker).catch(() => null);
|
|
116
|
+
if (lastAttempt &&
|
|
117
|
+
now.getTime() - lastAttempt.mtimeMs < MEMORY_INSTALL_MIN_INTERVAL_MS) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// Written for the ATTEMPT, not the outcome — the same idiom the self-update
|
|
121
|
+
// and autostart repair use, so a machine that cannot write a host config does
|
|
122
|
+
// not retry it every fifteen minutes.
|
|
123
|
+
await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
|
|
124
|
+
await fs.writeFile(marker, now.toISOString()).catch(() => undefined);
|
|
125
|
+
let event;
|
|
126
|
+
try {
|
|
127
|
+
const outcome = await installMemoryIntegration({
|
|
128
|
+
kind: "memory",
|
|
129
|
+
action: "install",
|
|
130
|
+
homeDir: command.homeDir,
|
|
131
|
+
dashboardUrl,
|
|
132
|
+
dryRun: false,
|
|
133
|
+
json: command.json,
|
|
134
|
+
}, io);
|
|
135
|
+
event = memoryInstallEvent(outcome);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
event = {
|
|
139
|
+
step: "memory_install",
|
|
140
|
+
status: "fail",
|
|
141
|
+
error_code: "memory_install_threw",
|
|
142
|
+
error_detail: redactedSyncErrorDetail(error),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
await reportInstallEventsBestEffort({
|
|
146
|
+
homeDir: command.homeDir,
|
|
147
|
+
dashboardUrl,
|
|
148
|
+
command: "sync",
|
|
149
|
+
events: [event],
|
|
150
|
+
json: command.json,
|
|
151
|
+
io,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Target names and reason labels only. A target's `path` names a person's home
|
|
156
|
+
* directory and a `write_failed` detail can carry one, so neither travels: the
|
|
157
|
+
* receipt says `claude_hooks:read_back_mismatch`, which is the part an operator
|
|
158
|
+
* can act on.
|
|
159
|
+
*/
|
|
160
|
+
function memoryInstallEvent(outcome) {
|
|
161
|
+
const detail = [
|
|
162
|
+
`source=${outcome.config_source}`,
|
|
163
|
+
...outcome.targets.map((target) => `${target.target}:${target.status}/${target.reason}`),
|
|
164
|
+
].join("; ");
|
|
165
|
+
if (outcome.status === "failed") {
|
|
166
|
+
return {
|
|
167
|
+
step: "memory_install",
|
|
168
|
+
status: "fail",
|
|
169
|
+
error_code: outcome.reason,
|
|
170
|
+
error_detail: detail,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (outcome.status === "skipped") {
|
|
174
|
+
// Nothing was written, on purpose (`no_bin_no_write`). A fleet-wide
|
|
175
|
+
// `bin_missing` is the receipt that says the server package has not
|
|
176
|
+
// reached the machines yet — a fact, not a fault.
|
|
177
|
+
return {
|
|
178
|
+
step: "memory_install",
|
|
179
|
+
status: "skipped",
|
|
180
|
+
error_code: outcome.reason,
|
|
181
|
+
error_detail: detail,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return { step: "memory_install", status: "ok", error_detail: detail };
|
|
185
|
+
}
|
|
81
186
|
/**
|
|
82
187
|
* BLI-2601: the fleet keeps itself current on npm `latest` without anyone
|
|
83
188
|
* re-running `npm i -g @bli-cockpit/cli` by hand after day 0. This always
|
package/dist/commands/sync.js
CHANGED
|
@@ -5,7 +5,7 @@ import { discoverCommandWorktrees } from "./local-discovery.js";
|
|
|
5
5
|
import { sendCollectorHeartbeatBestEffort, } from "./heartbeat.js";
|
|
6
6
|
import { classifySyncFailureRecords, classifySyncHealthError, redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
|
|
7
7
|
import { runAttributedWorktreeSync, } from "./session-sync.js";
|
|
8
|
-
import { runAutostartSelfHealAfterSync, runScheduledSelfUpdateAfterSync, } from "./sync-followups.js";
|
|
8
|
+
import { runAutostartSelfHealAfterSync, runMemoryInstallAfterSync, runScheduledSelfUpdateAfterSync, } from "./sync-followups.js";
|
|
9
9
|
import { describeError } from "../health-detail.js";
|
|
10
10
|
import { inspectBackfillLock } from "../backfill-lock.js";
|
|
11
11
|
import { rotateCollectorLogsBestEffort } from "../log-rotation.js";
|
|
@@ -49,6 +49,9 @@ export async function runSync(command, io) {
|
|
|
49
49
|
// already decided and reported, win or lose. See the function doc.
|
|
50
50
|
await runScheduledSelfUpdateAfterSync(command, io, dashboardUrl, minCliVersion ?? minCliVersionAtStart);
|
|
51
51
|
await runAutostartSelfHealAfterSync(command, io, dashboardUrl);
|
|
52
|
+
// BLI-3580: BLI Memory's registration converges the same way — after
|
|
53
|
+
// collection, at most once a day, its own receipt either way.
|
|
54
|
+
await runMemoryInstallAfterSync(command, io, dashboardUrl);
|
|
52
55
|
return result.exitCode;
|
|
53
56
|
}
|
|
54
57
|
catch (error) {
|
|
@@ -77,6 +80,9 @@ export async function runSync(command, io) {
|
|
|
77
80
|
});
|
|
78
81
|
await runScheduledSelfUpdateAfterSync(command, io, dashboardUrl, minCliVersion ?? minCliVersionAtStart);
|
|
79
82
|
await runAutostartSelfHealAfterSync(command, io, dashboardUrl);
|
|
83
|
+
// BLI-3580: BLI Memory's registration converges the same way — after
|
|
84
|
+
// collection, at most once a day, its own receipt either way.
|
|
85
|
+
await runMemoryInstallAfterSync(command, io, dashboardUrl);
|
|
80
86
|
throw error;
|
|
81
87
|
}
|
|
82
88
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { normalizeGitOrigin, repoFingerprintFromLocalRoot, repoFingerprintFromOrigin, repoLabelFromOrigin, stableWorktreeFingerprint, stableWorktreeRoot, } from "./repo-identity.js";
|
|
3
|
+
import { isSamePath } from "./root-normalization.js";
|
|
4
|
+
/**
|
|
5
|
+
* A transcript-attributed target is an identity a CALLER built — for a wrapper
|
|
6
|
+
* folder or a repo that no longer exists on disk — so every field of it is
|
|
7
|
+
* re-derived here before a single byte of context state is written. Nothing
|
|
8
|
+
* downstream can tell an invented fingerprint from a real one.
|
|
9
|
+
*
|
|
10
|
+
* The checks run in a fixed order and each throws its own sentence, so the
|
|
11
|
+
* first thing wrong is the thing the operator is told about.
|
|
12
|
+
*/
|
|
13
|
+
export async function validateAttributedTargetIdentity(requestedRepoRoot, identity) {
|
|
14
|
+
const canonicalRoot = await stableWorktreeRoot(requestedRepoRoot);
|
|
15
|
+
const identityRoot = await stableWorktreeRoot(identity.repo_root);
|
|
16
|
+
const identityRequestedPath = await stableWorktreeRoot(identity.requested_path);
|
|
17
|
+
if (!isSamePath(canonicalRoot, identityRoot) ||
|
|
18
|
+
!isSamePath(canonicalRoot, identityRequestedPath)) {
|
|
19
|
+
throw new Error("Attributed target identity paths do not match the requested repo root.");
|
|
20
|
+
}
|
|
21
|
+
const expectedWorktreeFingerprint = stableWorktreeFingerprint(canonicalRoot);
|
|
22
|
+
if (identity.worktree_fingerprint !== expectedWorktreeFingerprint) {
|
|
23
|
+
throw new Error("Attributed target worktree fingerprint does not match its repo root.");
|
|
24
|
+
}
|
|
25
|
+
const expectedWorktreeLabel = path.basename(canonicalRoot) || "workspace";
|
|
26
|
+
if (identity.worktree_label !== expectedWorktreeLabel) {
|
|
27
|
+
throw new Error("Attributed target worktree label does not match its repo root.");
|
|
28
|
+
}
|
|
29
|
+
if (identity.repo_origin_url) {
|
|
30
|
+
assertRepoFieldsMatchOrigin(identity, identity.repo_origin_url);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
assertRepoFieldsMatchLocalRoot(identity, canonicalRoot, expectedWorktreeLabel);
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
...identity,
|
|
37
|
+
requested_path: canonicalRoot,
|
|
38
|
+
repo_root: canonicalRoot,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* An origin-derived target is a repo reconstructed from transcript
|
|
43
|
+
* provenance, so it may not claim to be the live primary worktree of anything.
|
|
44
|
+
*/
|
|
45
|
+
function assertRepoFieldsMatchOrigin(identity, repoOriginUrl) {
|
|
46
|
+
const normalizedOrigin = normalizeGitOrigin(repoOriginUrl);
|
|
47
|
+
if (normalizedOrigin !== repoOriginUrl) {
|
|
48
|
+
throw new Error("Attributed target repo origin is not normalized.");
|
|
49
|
+
}
|
|
50
|
+
if (identity.repo_fingerprint !== repoFingerprintFromOrigin(normalizedOrigin)) {
|
|
51
|
+
throw new Error("Attributed target repo fingerprint does not match its origin.");
|
|
52
|
+
}
|
|
53
|
+
if (identity.repo_label !== repoLabelFromOrigin(normalizedOrigin)) {
|
|
54
|
+
throw new Error("Attributed target repo label does not match its origin.");
|
|
55
|
+
}
|
|
56
|
+
if (identity.worktree_is_primary) {
|
|
57
|
+
throw new Error("Origin-derived attributed targets cannot claim a primary live worktree.");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Without an origin the only thing an identity can honestly be derived from is
|
|
62
|
+
* the local root, so the fingerprint, the label and primacy all follow from it.
|
|
63
|
+
*/
|
|
64
|
+
function assertRepoFieldsMatchLocalRoot(identity, canonicalRoot, expectedWorktreeLabel) {
|
|
65
|
+
if (identity.repo_fingerprint !==
|
|
66
|
+
repoFingerprintFromLocalRoot(canonicalRoot)) {
|
|
67
|
+
throw new Error("Attributed target repo fingerprint does not match its local root.");
|
|
68
|
+
}
|
|
69
|
+
if (identity.repo_label !== expectedWorktreeLabel) {
|
|
70
|
+
throw new Error("Attributed target repo label does not match its local root.");
|
|
71
|
+
}
|
|
72
|
+
if (!identity.worktree_is_primary) {
|
|
73
|
+
throw new Error("Local attributed targets must use their primary local identity.");
|
|
74
|
+
}
|
|
75
|
+
}
|