@bli-cockpit/cli 0.2.49 → 0.2.51

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 (57) hide show
  1. package/dist/adapters/raw-evidence-claude-reader.js +108 -0
  2. package/dist/adapters/raw-evidence-codex-reader.js +147 -0
  3. package/dist/adapters/raw-evidence-collection-state.js +199 -0
  4. package/dist/adapters/raw-evidence-facts.js +338 -0
  5. package/dist/adapters/raw-evidence-git-diff-reader.js +187 -0
  6. package/dist/adapters/raw-evidence-image-reader.js +107 -0
  7. package/dist/adapters/raw-evidence-sanitize.js +56 -0
  8. package/dist/adapters/raw-evidence-transcript-file.js +182 -0
  9. package/dist/adapters/raw-evidence.js +63 -1183
  10. package/dist/commands/backfill-batches.js +34 -0
  11. package/dist/commands/backfill-candidates.js +54 -0
  12. package/dist/commands/backfill-checkpoint.js +101 -0
  13. package/dist/commands/backfill-command-line.js +70 -0
  14. package/dist/commands/backfill-evidence-outcomes.js +104 -0
  15. package/dist/commands/backfill-issues.js +265 -0
  16. package/dist/commands/backfill-output.js +75 -0
  17. package/dist/commands/backfill-plan.js +71 -0
  18. package/dist/commands/backfill-reasons.js +107 -0
  19. package/dist/commands/backfill-report.js +298 -0
  20. package/dist/commands/backfill-result.js +150 -0
  21. package/dist/commands/backfill-scan.js +274 -0
  22. package/dist/commands/backfill-scope.js +114 -0
  23. package/dist/commands/backfill-session-report.js +145 -0
  24. package/dist/commands/backfill-types.js +1 -0
  25. package/dist/commands/backfill-upload.js +212 -0
  26. package/dist/commands/backfill.js +41 -1961
  27. package/dist/commands/doctor.js +57 -0
  28. package/dist/commands/jarvis-trace.js +184 -0
  29. package/dist/commands/jarvis.js +144 -4
  30. package/dist/commands/local-args-collector.js +26 -0
  31. package/dist/commands/local-args-tower.js +21 -0
  32. package/dist/commands/local-args.js +3 -1
  33. package/dist/commands/local-help.js +19 -2
  34. package/dist/commands/local.js +3 -0
  35. package/dist/commands/memory-install-claude.js +294 -0
  36. package/dist/commands/memory-install-codex.js +205 -0
  37. package/dist/commands/memory-install-contract.js +286 -0
  38. package/dist/commands/memory-install-files.js +63 -0
  39. package/dist/commands/memory-install-skills.js +121 -0
  40. package/dist/commands/memory-install-toml.js +265 -0
  41. package/dist/commands/memory-install.js +465 -0
  42. package/dist/commands/public-root.js +1 -1
  43. package/dist/commands/sync-followups.js +105 -0
  44. package/dist/commands/sync.js +7 -1
  45. package/dist/local-state-attributed-target.js +75 -0
  46. package/dist/local-state-config.js +147 -0
  47. package/dist/local-state-files.js +59 -0
  48. package/dist/local-state-identity.js +73 -0
  49. package/dist/local-state-pairing.js +263 -0
  50. package/dist/local-state-paths.js +61 -0
  51. package/dist/local-state-session.js +68 -0
  52. package/dist/local-state-status.js +163 -0
  53. package/dist/local-state-work-context.js +190 -0
  54. package/dist/local-state.js +34 -848
  55. package/dist/tower-client.js +3 -2
  56. package/dist/tower-stream.js +57 -3
  57. package/package.json +2 -1
@@ -0,0 +1,294 @@
1
+ /**
2
+ * The Claude Code half of `cockpit memory install` (BLI-3580).
3
+ *
4
+ * TWO files, because Claude Code reads two:
5
+ *
6
+ * ~/.claude.json user-scope MCP servers, under top-level
7
+ * `mcpServers`. This is where `claude mcp add
8
+ * --scope user` puts a server, and — per the
9
+ * BLI-3580 touchpoint survey — the ONLY place a
10
+ * non-plugin server can be registered for every
11
+ * project on the machine. The Supermemory plugin
12
+ * reached Claude Code through its own marketplace
13
+ * `.mcp.json`; we are not a plugin, so we write a
14
+ * direct user-scope entry instead.
15
+ * ~/.claude/settings.json `hooks` (the three recall/save hooks) and
16
+ * `permissions.allow` (auto-approve for the
17
+ * read-only tool, the documented equivalent of the
18
+ * vendor's PreToolUse regex trick).
19
+ *
20
+ * Three rules govern every write here:
21
+ *
22
+ * 1. **Merge, never clobber.** Both files belong to the person. Every other
23
+ * key, every other MCP server and every other hook comes out untouched, and
24
+ * a file that will not parse is a REFUSAL (`config_unreadable`), never an
25
+ * overwrite — the opposite of what `agent-rules.ts` may do to a markdown
26
+ * file, because losing a person's whole Claude configuration is a different
27
+ * order of damage from losing a rules block.
28
+ * 2. **Idempotent by identity.** Our hook entries are recognised by the bin
29
+ * name (`isMemoryHookCommand`), so a re-run replaces exactly our three and
30
+ * appends nothing.
31
+ * 3. **Read back what the platform stored** (BLI-2541). A write that returned
32
+ * without throwing is not proof. The file is re-read, re-parsed and compared
33
+ * field by field; a difference is `read_back_mismatch` and the target is
34
+ * reported failed.
35
+ */
36
+ import path from "node:path";
37
+ import { isMemoryHookCommand, } from "./memory-install-contract.js";
38
+ export function claudeMcpConfigFile(homeDir) {
39
+ return path.join(homeDir, ".claude.json");
40
+ }
41
+ export function claudeSettingsFile(homeDir) {
42
+ return path.join(homeDir, ".claude", "settings.json");
43
+ }
44
+ export async function installClaudeMemoryIntegration(options) {
45
+ return [
46
+ await applyJsonTarget({
47
+ target: "claude_mcp",
48
+ file: claudeMcpConfigFile(options.homeDir),
49
+ options,
50
+ apply: (root) => applyMcpServer(root, options.config),
51
+ matches: (root) => mcpServerMatches(root, options.config),
52
+ }),
53
+ await applyJsonTarget({
54
+ target: "claude_hooks",
55
+ file: claudeSettingsFile(options.homeDir),
56
+ options,
57
+ apply: (root) => applyHooksAndPermissions(root, options.config),
58
+ matches: (root) => hooksAndPermissionsMatch(root, options.config),
59
+ }),
60
+ ];
61
+ }
62
+ export async function inspectClaudeMemoryIntegration(options) {
63
+ return [
64
+ await inspectJsonTarget({
65
+ target: "claude_mcp",
66
+ file: claudeMcpConfigFile(options.homeDir),
67
+ io: options.io,
68
+ present: (root) => readMcpServer(root, options.config.server_id) !== null,
69
+ matches: (root) => mcpServerMatches(root, options.config),
70
+ }),
71
+ await inspectJsonTarget({
72
+ target: "claude_hooks",
73
+ file: claudeSettingsFile(options.homeDir),
74
+ io: options.io,
75
+ present: (root) => ourHookCount(root) > 0,
76
+ matches: (root) => hooksAndPermissionsMatch(root, options.config),
77
+ }),
78
+ ];
79
+ }
80
+ async function applyJsonTarget(input) {
81
+ const { file, options } = input;
82
+ const raw = await input.options.io.readText(file);
83
+ let root;
84
+ if (raw === null || !raw.trim()) {
85
+ root = {};
86
+ }
87
+ else {
88
+ const parsed = parseJsonRecord(raw);
89
+ if (!parsed) {
90
+ // Refuse rather than replace. A `~/.claude.json` that will not parse
91
+ // still holds the person's project history and their other servers.
92
+ return failure(input.target, file, "config_unreadable", "the file is not valid JSON; fix it and re-run");
93
+ }
94
+ root = parsed;
95
+ }
96
+ if (input.matches(root)) {
97
+ return { target: input.target, status: "already", reason: "already_current", path: file };
98
+ }
99
+ const next = input.apply(structuredClone(root));
100
+ const serialized = serializeJson(next, raw ?? "");
101
+ if (options.dryRun) {
102
+ return {
103
+ target: input.target,
104
+ status: "would_install",
105
+ reason: "dry_run",
106
+ path: file,
107
+ };
108
+ }
109
+ try {
110
+ await options.io.writeText(file, serialized);
111
+ }
112
+ catch (error) {
113
+ return failure(input.target, file, "write_failed", error instanceof Error ? error.message : String(error));
114
+ }
115
+ // BLI-2541: what the platform stored, not what we sent it.
116
+ const storedRaw = await options.io.readText(file);
117
+ const stored = storedRaw === null ? null : parseJsonRecord(storedRaw);
118
+ if (!stored || !input.matches(stored)) {
119
+ return failure(input.target, file, "read_back_mismatch", "the file on disk does not contain the entry that was just written");
120
+ }
121
+ return { target: input.target, status: "installed", reason: "wrote_entry", path: file };
122
+ }
123
+ async function inspectJsonTarget(input) {
124
+ const raw = await input.io.readText(input.file);
125
+ if (raw === null || !raw.trim()) {
126
+ return { target: input.target, status: "missing", reason: "file_absent", path: input.file };
127
+ }
128
+ const root = parseJsonRecord(raw);
129
+ if (!root) {
130
+ return failure(input.target, input.file, "config_unreadable", "the file is not valid JSON");
131
+ }
132
+ if (input.matches(root)) {
133
+ return { target: input.target, status: "installed", reason: "already_current", path: input.file };
134
+ }
135
+ const status = input.present(root) ? "mismatch" : "missing";
136
+ return {
137
+ target: input.target,
138
+ status,
139
+ reason: status === "mismatch" ? "entry_differs" : "entry_absent",
140
+ path: input.file,
141
+ };
142
+ }
143
+ function applyMcpServer(root, config) {
144
+ const servers = asRecord(root["mcpServers"]) ?? {};
145
+ servers[config.server_id] = {
146
+ command: config.mcp_server.command,
147
+ args: config.mcp_server.args,
148
+ ...(Object.keys(config.mcp_server.env).length > 0
149
+ ? { env: config.mcp_server.env }
150
+ : {}),
151
+ };
152
+ root["mcpServers"] = servers;
153
+ return root;
154
+ }
155
+ function readMcpServer(root, serverId) {
156
+ const servers = asRecord(root["mcpServers"]);
157
+ if (!servers)
158
+ return null;
159
+ return asRecord(servers[serverId]);
160
+ }
161
+ function mcpServerMatches(root, config) {
162
+ const entry = readMcpServer(root, config.server_id);
163
+ if (!entry)
164
+ return false;
165
+ if (entry["command"] !== config.mcp_server.command)
166
+ return false;
167
+ if (!sameStringArray(entry["args"], config.mcp_server.args))
168
+ return false;
169
+ const env = asRecord(entry["env"]) ?? {};
170
+ for (const [key, value] of Object.entries(config.mcp_server.env)) {
171
+ if (env[key] !== value)
172
+ return false;
173
+ }
174
+ return true;
175
+ }
176
+ /**
177
+ * Claude Code's hook shape, verbatim: `hooks.<Event>` is an ARRAY of matcher
178
+ * groups, each `{ matcher?, hooks: [{ type: "command", command, timeout? }] }`.
179
+ * The matcher is omitted on purpose — all three events want every source.
180
+ */
181
+ function applyHooksAndPermissions(root, config) {
182
+ const hooks = asRecord(root["hooks"]) ?? {};
183
+ for (const spec of config.hooks) {
184
+ const groups = Array.isArray(hooks[spec.event])
185
+ ? hooks[spec.event].slice()
186
+ : [];
187
+ const survivors = groups.filter((group) => !groupIsOurs(group));
188
+ survivors.push({
189
+ hooks: [
190
+ {
191
+ type: "command",
192
+ command: spec.command,
193
+ timeout: spec.timeout_seconds,
194
+ },
195
+ ],
196
+ });
197
+ hooks[spec.event] = survivors;
198
+ }
199
+ root["hooks"] = hooks;
200
+ if (config.permissions_allow.length > 0) {
201
+ const permissions = asRecord(root["permissions"]) ?? {};
202
+ const allow = Array.isArray(permissions["allow"])
203
+ ? permissions["allow"].filter((entry) => typeof entry === "string")
204
+ : [];
205
+ for (const rule of config.permissions_allow) {
206
+ if (!allow.includes(rule))
207
+ allow.push(rule);
208
+ }
209
+ permissions["allow"] = allow;
210
+ root["permissions"] = permissions;
211
+ }
212
+ return root;
213
+ }
214
+ function hooksAndPermissionsMatch(root, config) {
215
+ const hooks = asRecord(root["hooks"]);
216
+ if (!hooks)
217
+ return false;
218
+ for (const spec of config.hooks) {
219
+ const groups = Array.isArray(hooks[spec.event]) ? hooks[spec.event] : [];
220
+ const ours = groups.filter(groupIsOurs);
221
+ if (ours.length !== 1)
222
+ return false;
223
+ const commands = hookCommands(ours[0]);
224
+ if (commands.length !== 1 || commands[0] !== spec.command)
225
+ return false;
226
+ }
227
+ const allow = asRecord(root["permissions"])?.["allow"];
228
+ const allowed = Array.isArray(allow) ? allow : [];
229
+ return config.permissions_allow.every((rule) => allowed.includes(rule));
230
+ }
231
+ function ourHookCount(root) {
232
+ const hooks = asRecord(root["hooks"]);
233
+ if (!hooks)
234
+ return 0;
235
+ let count = 0;
236
+ for (const value of Object.values(hooks)) {
237
+ if (!Array.isArray(value))
238
+ continue;
239
+ count += value.filter(groupIsOurs).length;
240
+ }
241
+ return count;
242
+ }
243
+ function groupIsOurs(group) {
244
+ return hookCommands(group).some(isMemoryHookCommand);
245
+ }
246
+ function hookCommands(group) {
247
+ const record = asRecord(group);
248
+ if (!record || !Array.isArray(record["hooks"]))
249
+ return [];
250
+ return record["hooks"]
251
+ .map((entry) => asRecord(entry)?.["command"])
252
+ .filter((command) => typeof command === "string");
253
+ }
254
+ function failure(target, file, reason, detail) {
255
+ return { target, status: "failed", reason, path: file, detail };
256
+ }
257
+ function parseJsonRecord(raw) {
258
+ try {
259
+ const parsed = JSON.parse(raw);
260
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
261
+ return null;
262
+ return parsed;
263
+ }
264
+ catch {
265
+ return null;
266
+ }
267
+ }
268
+ function asRecord(value) {
269
+ if (!value || typeof value !== "object" || Array.isArray(value))
270
+ return null;
271
+ return value;
272
+ }
273
+ function sameStringArray(value, expected) {
274
+ if (!Array.isArray(value))
275
+ return expected.length === 0;
276
+ return (value.length === expected.length &&
277
+ value.every((entry, index) => entry === expected[index]));
278
+ }
279
+ /**
280
+ * Keeps the file's own indentation. Claude Code rewrites `~/.claude.json`
281
+ * itself, so this is cosmetic — but a diff that reindents a person's whole
282
+ * configuration reads like damage even when it is not.
283
+ */
284
+ function serializeJson(root, previous) {
285
+ const indent = detectIndent(previous);
286
+ return `${JSON.stringify(root, null, indent)}\n`;
287
+ }
288
+ function detectIndent(previous) {
289
+ const match = previous.match(/\n([ \t]+)"/u);
290
+ if (!match)
291
+ return 2;
292
+ const whitespace = match[1] ?? " ";
293
+ return whitespace.startsWith("\t") ? "\t" : whitespace.length;
294
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * The Codex half of `cockpit memory install` (BLI-3580).
3
+ *
4
+ * TWO targets:
5
+ *
6
+ * ~/.codex/config.toml one table, `[mcp_servers.bli-memory]`.
7
+ * ~/.codex/skills/bli-memory/ the skill that teaches Codex when to
8
+ * recall, save, update and forget — taking
9
+ * over the job the four `supermemory-*`
10
+ * skills did on Edward's machine.
11
+ *
12
+ * **`config.toml` is somebody's own configuration and is treated that way.**
13
+ * The standing rule for that file is "never change HIS settings"; this respects
14
+ * it literally by touching one table and nothing else. The write is a line-span
15
+ * swap (see `memory-install-toml.ts`), not a parse-and-reserialise, so model,
16
+ * approval policy, sandbox settings, comments and every other MCP server come
17
+ * out byte-identical — which a test asserts by comparing the bytes outside our
18
+ * span before and after.
19
+ *
20
+ * The skill files are ours end to end, so they are written whole and compared
21
+ * whole: a person who edited one gets it replaced on the next install, which is
22
+ * the same deal `cockpit agent-rules` offers for its managed block.
23
+ */
24
+ import path from "node:path";
25
+ import { MEMORY_MCP_SERVER_ID, } from "./memory-install-contract.js";
26
+ import { findTomlTableSpan, readTomlTable, renderTomlTable, upsertTomlTable, } from "./memory-install-toml.js";
27
+ import { memoryCodexSkillFiles } from "./memory-install-skills.js";
28
+ const MEMORY_TOML_PATH = ["mcp_servers", MEMORY_MCP_SERVER_ID];
29
+ export function codexConfigFile(homeDir) {
30
+ return path.join(homeDir, ".codex", "config.toml");
31
+ }
32
+ export function codexSkillDirectory(homeDir) {
33
+ return path.join(homeDir, ".codex", "skills", MEMORY_MCP_SERVER_ID);
34
+ }
35
+ export async function installCodexMemoryIntegration(options) {
36
+ return [
37
+ await applyCodexMcpTable(options),
38
+ await applyCodexSkills(options),
39
+ ];
40
+ }
41
+ export async function inspectCodexMemoryIntegration(options) {
42
+ const file = codexConfigFile(options.homeDir);
43
+ const raw = await options.io.readText(file);
44
+ const mcp = raw === null
45
+ ? { target: "codex_mcp", status: "missing", reason: "file_absent", path: file }
46
+ : tableMatches(raw, options.config)
47
+ ? { target: "codex_mcp", status: "installed", reason: "already_current", path: file }
48
+ : findTomlTableSpan(raw, MEMORY_TOML_PATH)
49
+ ? { target: "codex_mcp", status: "mismatch", reason: "entry_differs", path: file }
50
+ : { target: "codex_mcp", status: "missing", reason: "entry_absent", path: file };
51
+ const directory = codexSkillDirectory(options.homeDir);
52
+ const files = memoryCodexSkillFiles();
53
+ let present = 0;
54
+ let current = 0;
55
+ for (const [relative, contents] of Object.entries(files)) {
56
+ const stored = await options.io.readText(path.join(directory, relative));
57
+ if (stored === null)
58
+ continue;
59
+ present += 1;
60
+ if (stored === contents)
61
+ current += 1;
62
+ }
63
+ const total = Object.keys(files).length;
64
+ const skills = current === total
65
+ ? { target: "codex_skills", status: "installed", reason: "already_current", path: directory }
66
+ : present === 0
67
+ ? { target: "codex_skills", status: "missing", reason: "entry_absent", path: directory }
68
+ : {
69
+ target: "codex_skills",
70
+ status: "mismatch",
71
+ reason: "entry_differs",
72
+ path: directory,
73
+ detail: `${current} of ${total} skill files current`,
74
+ };
75
+ return [mcp, skills];
76
+ }
77
+ async function applyCodexMcpTable(options) {
78
+ const file = codexConfigFile(options.homeDir);
79
+ const raw = (await options.io.readText(file)) ?? "";
80
+ if (tableMatches(raw, options.config)) {
81
+ return { target: "codex_mcp", status: "already", reason: "already_current", path: file };
82
+ }
83
+ const next = upsertTomlTable(raw, MEMORY_TOML_PATH, renderMemoryTable(options.config));
84
+ if (options.dryRun) {
85
+ return { target: "codex_mcp", status: "would_install", reason: "dry_run", path: file };
86
+ }
87
+ try {
88
+ await options.io.writeText(file, next);
89
+ }
90
+ catch (error) {
91
+ return {
92
+ target: "codex_mcp",
93
+ status: "failed",
94
+ reason: "write_failed",
95
+ path: file,
96
+ detail: error instanceof Error ? error.message : String(error),
97
+ };
98
+ }
99
+ // BLI-2541: parse the table back out of the stored bytes the way Codex will
100
+ // read it. A successful write is not a correct registration.
101
+ const stored = await options.io.readText(file);
102
+ if (stored === null || !tableMatches(stored, options.config)) {
103
+ return {
104
+ target: "codex_mcp",
105
+ status: "failed",
106
+ reason: "read_back_mismatch",
107
+ path: file,
108
+ detail: "the table on disk does not parse back to the entry that was written",
109
+ };
110
+ }
111
+ return { target: "codex_mcp", status: "installed", reason: "wrote_entry", path: file };
112
+ }
113
+ async function applyCodexSkills(options) {
114
+ const directory = codexSkillDirectory(options.homeDir);
115
+ const files = memoryCodexSkillFiles();
116
+ const stale = [];
117
+ for (const [relative, contents] of Object.entries(files)) {
118
+ const stored = await options.io.readText(path.join(directory, relative));
119
+ if (stored !== contents)
120
+ stale.push(relative);
121
+ }
122
+ if (stale.length === 0) {
123
+ return {
124
+ target: "codex_skills",
125
+ status: "already",
126
+ reason: "already_current",
127
+ path: directory,
128
+ };
129
+ }
130
+ if (options.dryRun) {
131
+ return {
132
+ target: "codex_skills",
133
+ status: "would_install",
134
+ reason: "dry_run",
135
+ path: directory,
136
+ detail: `${stale.length} of ${Object.keys(files).length} skill files would be written`,
137
+ };
138
+ }
139
+ for (const relative of stale) {
140
+ const target = path.join(directory, relative);
141
+ try {
142
+ await options.io.writeText(target, files[relative]);
143
+ }
144
+ catch (error) {
145
+ return {
146
+ target: "codex_skills",
147
+ status: "failed",
148
+ reason: "write_failed",
149
+ path: directory,
150
+ detail: error instanceof Error ? error.message : String(error),
151
+ };
152
+ }
153
+ }
154
+ for (const relative of stale) {
155
+ const stored = await options.io.readText(path.join(directory, relative));
156
+ if (stored !== files[relative]) {
157
+ return {
158
+ target: "codex_skills",
159
+ status: "failed",
160
+ reason: "read_back_mismatch",
161
+ path: directory,
162
+ detail: `${relative} does not read back as written`,
163
+ };
164
+ }
165
+ }
166
+ return {
167
+ target: "codex_skills",
168
+ status: "installed",
169
+ reason: "wrote_entry",
170
+ path: directory,
171
+ detail: `${stale.length} skill file(s) written`,
172
+ };
173
+ }
174
+ export function renderMemoryTable(config) {
175
+ const entries = [
176
+ ["command", config.mcp_server.command],
177
+ ["args", config.mcp_server.args],
178
+ ];
179
+ if (Object.keys(config.mcp_server.env).length > 0) {
180
+ entries.push(["env", config.mcp_server.env]);
181
+ }
182
+ return renderTomlTable(MEMORY_TOML_PATH, entries);
183
+ }
184
+ function tableMatches(raw, config) {
185
+ const table = readTomlTable(raw, MEMORY_TOML_PATH);
186
+ if (!table)
187
+ return false;
188
+ if (table["command"] !== config.mcp_server.command)
189
+ return false;
190
+ const args = table["args"];
191
+ if (!Array.isArray(args))
192
+ return config.mcp_server.args.length === 0;
193
+ if (args.length !== config.mcp_server.args.length)
194
+ return false;
195
+ if (!args.every((entry, index) => entry === config.mcp_server.args[index]))
196
+ return false;
197
+ const env = table["env"];
198
+ for (const [key, value] of Object.entries(config.mcp_server.env)) {
199
+ if (!env || Array.isArray(env) || typeof env === "string")
200
+ return false;
201
+ if (env[key] !== value)
202
+ return false;
203
+ }
204
+ return true;
205
+ }