@bli-cockpit/cli 0.2.56 → 0.2.58
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/commands/agent-door.js +85 -0
- package/dist/commands/docs.js +227 -0
- package/dist/commands/issue-contracts.js +99 -0
- package/dist/commands/issue-write.js +129 -0
- package/dist/commands/issue.js +189 -0
- package/dist/commands/local-args-tower-docs-msg.js +126 -0
- package/dist/commands/local-args-tower-work.js +178 -0
- package/dist/commands/local-args-tower.js +7 -1
- package/dist/commands/local-args.js +10 -2
- package/dist/commands/local-help.js +70 -0
- package/dist/commands/local.js +12 -0
- package/dist/commands/mcp-bin-resolve.js +102 -0
- package/dist/commands/memory-install-claude.js +13 -5
- package/dist/commands/memory-install-config.js +140 -0
- package/dist/commands/memory-install-report.js +89 -0
- package/dist/commands/memory-install.js +51 -362
- package/dist/commands/msg.js +188 -0
- package/dist/commands/notes-door.js +120 -0
- package/dist/commands/notes-reads.js +134 -0
- package/dist/commands/notes-writes.js +208 -0
- package/dist/commands/notes.js +16 -442
- package/dist/commands/ops-render.js +18 -2
- package/dist/commands/ops.js +9 -2
- package/dist/commands/project.js +38 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/tower-mcp-claude.js +30 -0
- package/dist/commands/tower-mcp-codex.js +100 -0
- package/dist/commands/tower-mcp-contract.js +39 -0
- package/dist/commands/tower-mcp-install.js +75 -0
- package/dist/repo-identity-fingerprint.js +88 -0
- package/dist/repo-identity-git.js +76 -0
- package/dist/repo-identity-linked-worktrees.js +81 -0
- package/dist/repo-identity.js +5 -222
- package/dist/upload-envelope-build.js +240 -0
- package/dist/upload-envelope-event.js +198 -0
- package/dist/upload-envelope.js +16 -427
- package/dist/upload-ingest-receipt.js +121 -0
- package/dist/upload-session-reports-queue.js +156 -0
- package/dist/upload-session-reports-wire.js +275 -0
- package/dist/upload-session-reports.js +14 -425
- package/dist/upload-sync.js +291 -0
- package/dist/upload.js +24 -396
- package/package.json +6 -5
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Codex half of `bli-tower`'s registration (BLI-3706) — one table,
|
|
3
|
+
* `[mcp_servers.bli-tower]`, in `~/.codex/config.toml`.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately narrower than `memory-install-codex.ts`: `bli-tower` has no
|
|
6
|
+
* Codex skill to teach (its tools are self-describing over MCP, same as any
|
|
7
|
+
* other server Codex discovers), so there is no `codex_skills` target here —
|
|
8
|
+
* only the one table, written the same byte-preserving way BLI Memory's own
|
|
9
|
+
* table is (`memory-install-toml.ts`'s line-span swap, never a
|
|
10
|
+
* parse-and-reserialise, so a person's model/approval/sandbox settings and
|
|
11
|
+
* every other MCP server come out untouched).
|
|
12
|
+
*/
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { TOWER_MCP_SERVER_ID } from "./tower-mcp-contract.js";
|
|
15
|
+
import { findTomlTableSpan, readTomlTable, renderTomlTable, upsertTomlTable, } from "./memory-install-toml.js";
|
|
16
|
+
const TOWER_TOML_PATH = ["mcp_servers", TOWER_MCP_SERVER_ID];
|
|
17
|
+
export function codexConfigFile(homeDir) {
|
|
18
|
+
return path.join(homeDir, ".codex", "config.toml");
|
|
19
|
+
}
|
|
20
|
+
export async function installCodexTowerIntegration(options) {
|
|
21
|
+
const file = codexConfigFile(options.homeDir);
|
|
22
|
+
const raw = (await options.io.readText(file)) ?? "";
|
|
23
|
+
if (tableMatches(raw, options.config)) {
|
|
24
|
+
return { target: "tower_codex_mcp", status: "already", reason: "already_current", path: file };
|
|
25
|
+
}
|
|
26
|
+
const next = upsertTomlTable(raw, TOWER_TOML_PATH, renderTowerTable(options.config));
|
|
27
|
+
if (options.dryRun) {
|
|
28
|
+
return { target: "tower_codex_mcp", status: "would_install", reason: "dry_run", path: file };
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
await options.io.writeText(file, next);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
return {
|
|
35
|
+
target: "tower_codex_mcp",
|
|
36
|
+
status: "failed",
|
|
37
|
+
reason: "write_failed",
|
|
38
|
+
path: file,
|
|
39
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
// BLI-2541: parse the table back out of the stored bytes, same discipline
|
|
43
|
+
// as every other target this installer writes.
|
|
44
|
+
const stored = await options.io.readText(file);
|
|
45
|
+
if (stored === null || !tableMatches(stored, options.config)) {
|
|
46
|
+
return {
|
|
47
|
+
target: "tower_codex_mcp",
|
|
48
|
+
status: "failed",
|
|
49
|
+
reason: "read_back_mismatch",
|
|
50
|
+
path: file,
|
|
51
|
+
detail: "the table on disk does not parse back to the entry that was written",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return { target: "tower_codex_mcp", status: "installed", reason: "wrote_entry", path: file };
|
|
55
|
+
}
|
|
56
|
+
export async function inspectCodexTowerIntegration(options) {
|
|
57
|
+
const file = codexConfigFile(options.homeDir);
|
|
58
|
+
const raw = await options.io.readText(file);
|
|
59
|
+
if (raw === null) {
|
|
60
|
+
return { target: "tower_codex_mcp", status: "missing", reason: "file_absent", path: file };
|
|
61
|
+
}
|
|
62
|
+
if (tableMatches(raw, options.config)) {
|
|
63
|
+
return { target: "tower_codex_mcp", status: "installed", reason: "already_current", path: file };
|
|
64
|
+
}
|
|
65
|
+
return findTomlTableSpan(raw, TOWER_TOML_PATH)
|
|
66
|
+
? { target: "tower_codex_mcp", status: "mismatch", reason: "entry_differs", path: file }
|
|
67
|
+
: { target: "tower_codex_mcp", status: "missing", reason: "entry_absent", path: file };
|
|
68
|
+
}
|
|
69
|
+
function renderTowerTable(config) {
|
|
70
|
+
const entries = [
|
|
71
|
+
["command", config.mcp_server.command],
|
|
72
|
+
["args", config.mcp_server.args],
|
|
73
|
+
];
|
|
74
|
+
if (Object.keys(config.mcp_server.env).length > 0) {
|
|
75
|
+
entries.push(["env", config.mcp_server.env]);
|
|
76
|
+
}
|
|
77
|
+
return renderTomlTable(TOWER_TOML_PATH, entries);
|
|
78
|
+
}
|
|
79
|
+
function tableMatches(raw, config) {
|
|
80
|
+
const table = readTomlTable(raw, TOWER_TOML_PATH);
|
|
81
|
+
if (!table)
|
|
82
|
+
return false;
|
|
83
|
+
if (table["command"] !== config.mcp_server.command)
|
|
84
|
+
return false;
|
|
85
|
+
const args = table["args"];
|
|
86
|
+
if (!Array.isArray(args))
|
|
87
|
+
return config.mcp_server.args.length === 0;
|
|
88
|
+
if (args.length !== config.mcp_server.args.length)
|
|
89
|
+
return false;
|
|
90
|
+
if (!args.every((entry, index) => entry === config.mcp_server.args[index]))
|
|
91
|
+
return false;
|
|
92
|
+
const env = table["env"];
|
|
93
|
+
for (const [key, value] of Object.entries(config.mcp_server.env)) {
|
|
94
|
+
if (!env || Array.isArray(env) || typeof env === "string")
|
|
95
|
+
return false;
|
|
96
|
+
if (env[key] !== value)
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `bli-tower` MCP server's registration shape (BLI-3706).
|
|
3
|
+
*
|
|
4
|
+
* `bli-tower` is the server id `@bli-cockpit/mcp` (bin `bli-cockpit-mcp`)
|
|
5
|
+
* registers under — the `docs_*`/`msg_*` tools over the collector device
|
|
6
|
+
* token, beside `bli-memory` (BLI-3580). Unlike BLI Memory, this server has
|
|
7
|
+
* NO Claude Code hooks: it is tools only, so `hooks: []` and
|
|
8
|
+
* `permissions_allow: []` on every config this module builds — the shared
|
|
9
|
+
* `memory-install-claude.ts`/`memory-install-codex.ts` writers already treat
|
|
10
|
+
* an empty hooks array as "nothing to do" for that half (see their own
|
|
11
|
+
* `config.hooks` loops), so no server-specific hook-writing code exists here.
|
|
12
|
+
*
|
|
13
|
+
* `@bli-cockpit/mcp` does not (yet) print its own `--print-config`, unlike
|
|
14
|
+
* `bli-memory-mcp` — so `builtinTowerInstallConfig` is the ONLY source of the
|
|
15
|
+
* config today; there is no `parsePrintedTowerInstallConfig` counterpart to
|
|
16
|
+
* `memory-install-contract.ts`'s equivalent. Adding one there is a natural
|
|
17
|
+
* follow-up once the server ships its own printer, matching BLI Memory's own
|
|
18
|
+
* shape (see that file's header for why the bin's own answer should win when
|
|
19
|
+
* it can be asked).
|
|
20
|
+
*/
|
|
21
|
+
import { memoryMcpServerEntry } from "./memory-install-contract.js";
|
|
22
|
+
/** The published bin name. Both hosts spawn this — `@bli-cockpit/mcp`'s package.json `bin`. */
|
|
23
|
+
export const TOWER_MCP_BIN = "bli-cockpit-mcp";
|
|
24
|
+
/** The MCP server id, as it appears in `mcp__<server>__<tool>`. */
|
|
25
|
+
export const TOWER_MCP_SERVER_ID = "bli-tower";
|
|
26
|
+
/** The one env var the server is handed. A URL, never a token — same contract as BLI Memory's. */
|
|
27
|
+
export const TOWER_DASHBOARD_URL_ENV = "COCKPIT_DASHBOARD_URL";
|
|
28
|
+
export function builtinTowerInstallConfig(options) {
|
|
29
|
+
return {
|
|
30
|
+
server_id: TOWER_MCP_SERVER_ID,
|
|
31
|
+
mcp_server: memoryMcpServerEntry(options),
|
|
32
|
+
// No hooks: bli-tower is tools-only. Left as an empty array rather than
|
|
33
|
+
// omitted so this is still a complete MemoryInstallConfig — the shared
|
|
34
|
+
// Claude/Codex writers are already generic over "a server with zero
|
|
35
|
+
// hooks" (see this file's own header).
|
|
36
|
+
hooks: [],
|
|
37
|
+
permissions_allow: [],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bli-tower`'s registration — the docs_* and msg_* MCP server, beside
|
|
3
|
+
* `bli-memory` (BLI-3706, "Agent-friendly is the definition of done").
|
|
4
|
+
*
|
|
5
|
+
* Rides `cockpit memory install`/`cockpit memory status` rather than a new
|
|
6
|
+
* verb: that command already runs unasked from `do-everything` and the daily
|
|
7
|
+
* sync tick (`memory-install.ts`'s own header), which is the ONLY way a
|
|
8
|
+
* registration reaches every intern machine without anyone typing anything.
|
|
9
|
+
* A second, un-invoked `cockpit mcp install` command would ship the feature
|
|
10
|
+
* and still leave every existing machine unregistered until someone learned
|
|
11
|
+
* to type the new verb.
|
|
12
|
+
*
|
|
13
|
+
* `no_bin_no_write` applies here too, for the identical reason
|
|
14
|
+
* `memory-install.ts` states it for `bli-memory-mcp`: a Claude Code entry
|
|
15
|
+
* pointing at a server that is not on this machine is a dead MCP connection
|
|
16
|
+
* on every startup, not a graceful absence.
|
|
17
|
+
*/
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import { resolveMcpBin } from "./mcp-bin-resolve.js";
|
|
20
|
+
import { installClaudeTowerIntegration, inspectClaudeTowerIntegration } from "./tower-mcp-claude.js";
|
|
21
|
+
import { installCodexTowerIntegration, inspectCodexTowerIntegration } from "./tower-mcp-codex.js";
|
|
22
|
+
import { builtinTowerInstallConfig, TOWER_MCP_BIN } from "./tower-mcp-contract.js";
|
|
23
|
+
import { envWithNodeRuntimeOnPath } from "../scheduled-self-update.js";
|
|
24
|
+
async function resolveTowerConfig(deps) {
|
|
25
|
+
const found = await resolveMcpBin({
|
|
26
|
+
binName: TOWER_MCP_BIN,
|
|
27
|
+
env: envWithNodeRuntimeOnPath(deps.env),
|
|
28
|
+
platform: deps.platform,
|
|
29
|
+
fileExists: deps.fileExists,
|
|
30
|
+
cliEntryPoint: deps.cliEntryPoint,
|
|
31
|
+
realpath: deps.realpath,
|
|
32
|
+
});
|
|
33
|
+
if (!found) {
|
|
34
|
+
return {
|
|
35
|
+
config: null,
|
|
36
|
+
binTarget: {
|
|
37
|
+
target: "tower_bin",
|
|
38
|
+
status: "skipped",
|
|
39
|
+
reason: "bin_missing",
|
|
40
|
+
detail: `${TOWER_MCP_BIN} is not installed beside this CLI or on PATH; nothing was written, and the next daily run will try again`,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
config: builtinTowerInstallConfig({ binPath: found.path, platform: deps.platform, dashboardUrl: deps.dashboardUrl }),
|
|
46
|
+
binTarget: { target: "tower_bin", status: "already", reason: "bin_present_template_used" },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export async function installTowerIntegration(deps, dryRun) {
|
|
50
|
+
const resolved = await resolveTowerConfig(deps);
|
|
51
|
+
const targets = [resolved.binTarget];
|
|
52
|
+
if (!resolved.config)
|
|
53
|
+
return targets;
|
|
54
|
+
targets.push(await installClaudeTowerIntegration({ homeDir: deps.homeDir, config: resolved.config, dryRun, io: deps.io }));
|
|
55
|
+
targets.push(await installCodexTowerIntegration({ homeDir: deps.homeDir, config: resolved.config, dryRun, io: deps.io }));
|
|
56
|
+
return targets;
|
|
57
|
+
}
|
|
58
|
+
export async function inspectTowerIntegration(deps) {
|
|
59
|
+
const resolved = await resolveTowerConfig(deps);
|
|
60
|
+
const targets = [resolved.binTarget];
|
|
61
|
+
if (!resolved.config)
|
|
62
|
+
return targets;
|
|
63
|
+
targets.push(await inspectClaudeTowerIntegration({ homeDir: deps.homeDir, config: resolved.config, io: deps.io }));
|
|
64
|
+
targets.push(await inspectCodexTowerIntegration({ homeDir: deps.homeDir, config: resolved.config, io: deps.io }));
|
|
65
|
+
return targets;
|
|
66
|
+
}
|
|
67
|
+
export function defaultTowerDeps(overrides) {
|
|
68
|
+
return {
|
|
69
|
+
platform: process.platform,
|
|
70
|
+
homeDir: os.homedir(),
|
|
71
|
+
dashboardUrl: overrides.dashboardUrl ?? "",
|
|
72
|
+
env: process.env,
|
|
73
|
+
...overrides,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a filesystem path or a git origin URL into the stable ids this
|
|
3
|
+
* package hands out (`repo_fingerprint`, `worktree_fingerprint`, the label),
|
|
4
|
+
* plus the collection-root canonicalization built on the same realpath call.
|
|
5
|
+
* Sibling of `repo-identity.ts`, named in its header.
|
|
6
|
+
*/
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import fs from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
export async function stableWorktreeRoot(repoRoot) {
|
|
11
|
+
const resolvedRoot = path.resolve(repoRoot);
|
|
12
|
+
return fs.realpath(resolvedRoot).catch(() => resolvedRoot);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Canonicalizes existing collection roots through the filesystem so transcript
|
|
16
|
+
* paths and consent roots use the same spelling (for example `/private/var`
|
|
17
|
+
* versus the `/var` symlink on macOS). Missing roots remain resolved as typed.
|
|
18
|
+
*/
|
|
19
|
+
export async function canonicalizeCollectionRootPaths(roots) {
|
|
20
|
+
const canonical = await Promise.all(roots.map(stableWorktreeRoot));
|
|
21
|
+
return [...new Set(canonical)];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Returns the operator-entered resolved paths plus filesystem-canonical aliases.
|
|
25
|
+
* Both are needed for absent child repos because the child itself cannot be
|
|
26
|
+
* realpathed after deletion, while its transcript may retain either spelling.
|
|
27
|
+
*/
|
|
28
|
+
export async function collectionRootPathAliases(roots) {
|
|
29
|
+
const resolved = roots.map((root) => path.resolve(root));
|
|
30
|
+
const canonical = await canonicalizeCollectionRootPaths(resolved);
|
|
31
|
+
return [...new Set([...resolved, ...canonical])];
|
|
32
|
+
}
|
|
33
|
+
function normalizeFingerprintPath(repoRoot, pathApi = path) {
|
|
34
|
+
const resolved = pathApi.resolve(repoRoot);
|
|
35
|
+
return pathApi.sep === "\\" ? resolved.toLowerCase() : resolved;
|
|
36
|
+
}
|
|
37
|
+
export function stableWorktreeFingerprint(repoRoot, pathApi = path) {
|
|
38
|
+
return `wt-${sha256(`worktree:${normalizeFingerprintPath(repoRoot, pathApi)}`).slice(0, 24)}`;
|
|
39
|
+
}
|
|
40
|
+
export function repoFingerprintFromLocalRoot(root, pathApi = path) {
|
|
41
|
+
return `repo-${sha256(`local:${normalizeFingerprintPath(root, pathApi)}`).slice(0, 24)}`;
|
|
42
|
+
}
|
|
43
|
+
export function repoFingerprintFromOrigin(origin) {
|
|
44
|
+
const normalizedOrigin = normalizeGitOrigin(origin);
|
|
45
|
+
return `repo-${sha256(`origin:${normalizedOrigin}`).slice(0, 24)}`;
|
|
46
|
+
}
|
|
47
|
+
export function normalizeGitOrigin(rawOrigin) {
|
|
48
|
+
const trimmed = rawOrigin.trim();
|
|
49
|
+
if (!trimmed)
|
|
50
|
+
return "";
|
|
51
|
+
const scpLike = trimmed.match(/^(?:[^@]+@)?([^:]+):(.+)$/);
|
|
52
|
+
if (scpLike && !trimmed.includes("://")) {
|
|
53
|
+
return normalizeOriginParts(scpLike[1] ?? "", scpLike[2] ?? "");
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const url = new URL(trimmed);
|
|
57
|
+
return normalizeOriginParts(url.hostname, url.pathname);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Deliberately silent (BLI-3238). `new URL` is being used as the test for
|
|
61
|
+
// "is this origin URL-shaped?", and a plain path or an unusual remote form
|
|
62
|
+
// failing to parse IS the answer — the fallback below is the intended
|
|
63
|
+
// normalization for exactly that case, not a degradation.
|
|
64
|
+
return trimmed
|
|
65
|
+
.replace(/\.git$/i, "")
|
|
66
|
+
.replace(/^\/+|\/+$/g, "")
|
|
67
|
+
.toLowerCase();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function repoLabelFromOrigin(origin) {
|
|
71
|
+
const segments = origin.split("/").filter(Boolean);
|
|
72
|
+
return segments.at(-1) ?? origin;
|
|
73
|
+
}
|
|
74
|
+
function normalizeOriginParts(host, repoPath) {
|
|
75
|
+
return [
|
|
76
|
+
host.trim().toLowerCase(),
|
|
77
|
+
repoPath
|
|
78
|
+
.trim()
|
|
79
|
+
.replace(/\.git$/i, "")
|
|
80
|
+
.replace(/^\/+|\/+$/g, "")
|
|
81
|
+
.toLowerCase(),
|
|
82
|
+
]
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.join("/");
|
|
85
|
+
}
|
|
86
|
+
export function sha256(value) {
|
|
87
|
+
return crypto.createHash("sha256").update(value, "utf8").digest("hex");
|
|
88
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every place this package shells out to `git`, plus the two ways a branch
|
|
3
|
+
* is read when git itself cannot be asked (a `.git` file's HEAD, for a
|
|
4
|
+
* linked worktree whose git process is unavailable). Sibling of
|
|
5
|
+
* `repo-identity.ts`, named in its header.
|
|
6
|
+
*/
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import fs from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
import { describeError, isMissingFileFailure } from "./health-detail.js";
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
export async function runGit(args, cwd) {
|
|
14
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
15
|
+
cwd,
|
|
16
|
+
timeout: 2_000,
|
|
17
|
+
maxBuffer: 1024 * 1024,
|
|
18
|
+
});
|
|
19
|
+
return stdout;
|
|
20
|
+
}
|
|
21
|
+
export async function hasGitMarker(dir) {
|
|
22
|
+
return fs.stat(path.join(dir, ".git")).then((stat) => stat.isDirectory() || stat.isFile(), () => false);
|
|
23
|
+
}
|
|
24
|
+
export async function resolveGitBranchWithGit(repoRoot) {
|
|
25
|
+
const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot).then((value) => value.trim(), () => "");
|
|
26
|
+
if (branch && branch !== "HEAD")
|
|
27
|
+
return branch;
|
|
28
|
+
const head = await runGit(["rev-parse", "--short=12", "HEAD"], repoRoot).then((value) => value.trim(), () => "");
|
|
29
|
+
return head ? `detached:${head}` : "unknown";
|
|
30
|
+
}
|
|
31
|
+
export async function resolveBranchFromHead(repoRoot) {
|
|
32
|
+
try {
|
|
33
|
+
const gitPath = path.join(repoRoot, ".git");
|
|
34
|
+
const stat = await fs.stat(gitPath);
|
|
35
|
+
const headPath = stat.isFile()
|
|
36
|
+
? path.join(await resolveLinkedGitDir(gitPath), "HEAD")
|
|
37
|
+
: path.join(gitPath, "HEAD");
|
|
38
|
+
const head = (await fs.readFile(headPath, "utf8")).trim();
|
|
39
|
+
if (head.startsWith("ref: refs/heads/")) {
|
|
40
|
+
return head.slice("ref: refs/heads/".length);
|
|
41
|
+
}
|
|
42
|
+
return head ? `detached:${head.slice(0, 12)}` : "unknown";
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
// Not a repo → quiet, that is an ordinary approved folder. A `.git` that
|
|
46
|
+
// exists and will not read → every session from this worktree is labelled
|
|
47
|
+
// branch `unknown` and, until BLI-3238, nothing said why.
|
|
48
|
+
if (!isMissingFileFailure(error)) {
|
|
49
|
+
console.error("[repo-identity] could not read HEAD, branch recorded as unknown", JSON.stringify({
|
|
50
|
+
reason: "git_head_unreadable",
|
|
51
|
+
...describeError(error),
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
return "unknown";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function resolveLinkedGitDir(gitFile) {
|
|
58
|
+
const raw = await fs.readFile(gitFile, "utf8");
|
|
59
|
+
const match = raw.match(/^gitdir:\s*(.+)$/m);
|
|
60
|
+
if (!match)
|
|
61
|
+
return path.dirname(gitFile);
|
|
62
|
+
const gitDir = match[1].trim();
|
|
63
|
+
return path.isAbsolute(gitDir) ? gitDir : path.resolve(path.dirname(gitFile), gitDir);
|
|
64
|
+
}
|
|
65
|
+
export async function listLinkedWorktreePaths(repoRoot) {
|
|
66
|
+
const porcelain = await runGit(["worktree", "list", "--porcelain"], repoRoot).catch(() => "");
|
|
67
|
+
const paths = [];
|
|
68
|
+
for (const line of porcelain.split("\n")) {
|
|
69
|
+
if (line.startsWith("worktree ")) {
|
|
70
|
+
const value = line.slice("worktree ".length).trim();
|
|
71
|
+
if (value)
|
|
72
|
+
paths.push(value);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return paths;
|
|
76
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adding a discovered repo's linked worktrees (Claude Code isolation
|
|
3
|
+
* worktrees, `git worktree add` checkouts) as first-class candidates.
|
|
4
|
+
* Sibling of `repo-identity.ts`, named in its header.
|
|
5
|
+
*/
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { containsPath, isCodexWorktreePath } from "./root-normalization.js";
|
|
8
|
+
import { listLinkedWorktreePaths } from "./repo-identity-git.js";
|
|
9
|
+
import { compareIdentity, resolveRepoWorktreeIdentity } from "./repo-identity.js";
|
|
10
|
+
/**
|
|
11
|
+
* Directory discovery skips dot-dirs, so Claude Code's isolation worktrees
|
|
12
|
+
* (`<repo>/.claude/worktrees/<name>`) and any other linked worktree are never
|
|
13
|
+
* found by walking the filesystem. A session whose cwd sits inside one would
|
|
14
|
+
* otherwise satisfy `isPathWithin(cwd, parentRoot)` and attribute confidently
|
|
15
|
+
* to the PARENT with the wrong branch and worktree fingerprint. Enumerating
|
|
16
|
+
* `git worktree list --porcelain` for each discovered repo adds the linked
|
|
17
|
+
* worktrees as first-class candidates with their own branch/fingerprint; the
|
|
18
|
+
* deepest-root tie-break (attribution-core D6) then attributes nested-worktree
|
|
19
|
+
* sessions to the correct linked worktree. Benefits Codex sessions identically.
|
|
20
|
+
*/
|
|
21
|
+
export async function expandLinkedWorktrees(identities, maxWorktrees, allowedRoots) {
|
|
22
|
+
const byFingerprint = new Map(identities.map((identity) => [identity.worktree_fingerprint, identity]));
|
|
23
|
+
const seenRoots = new Set(identities.map((identity) => localPathKey(identity.repo_root)));
|
|
24
|
+
let maxWorktreesReached = identities.length > maxWorktrees;
|
|
25
|
+
for (const identity of identities.slice(0, maxWorktrees)) {
|
|
26
|
+
// One `git worktree list` from any worktree returns every worktree of that
|
|
27
|
+
// repo, so a single call per already-discovered repo covers its linked set.
|
|
28
|
+
for (const worktreePath of await listLinkedWorktreePaths(identity.repo_root)) {
|
|
29
|
+
const resolved = path.resolve(worktreePath);
|
|
30
|
+
const rootKey = localPathKey(resolved);
|
|
31
|
+
if (seenRoots.has(rootKey))
|
|
32
|
+
continue;
|
|
33
|
+
seenRoots.add(rootKey);
|
|
34
|
+
const linked = await resolveRepoWorktreeIdentity(resolved).catch(() => null);
|
|
35
|
+
if (!linked ||
|
|
36
|
+
byFingerprint.has(linked.worktree_fingerprint) ||
|
|
37
|
+
!isLinkedWorktreeWithinCollectionScope(linked, identities, allowedRoots)) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (byFingerprint.size >= maxWorktrees) {
|
|
41
|
+
maxWorktreesReached = true;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
byFingerprint.set(linked.worktree_fingerprint, linked);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const worktrees = [...byFingerprint.values()]
|
|
48
|
+
.sort(compareIdentity)
|
|
49
|
+
.slice(0, maxWorktrees);
|
|
50
|
+
const incompleteReasons = maxWorktreesReached
|
|
51
|
+
? ["max_worktrees_reached"]
|
|
52
|
+
: [];
|
|
53
|
+
return {
|
|
54
|
+
worktrees,
|
|
55
|
+
complete: incompleteReasons.length === 0,
|
|
56
|
+
incomplete_reasons: incompleteReasons,
|
|
57
|
+
// Expansion asks git for its own worktree list; it never walks folders, so
|
|
58
|
+
// it has no unreadable directories of its own to report.
|
|
59
|
+
unreadable_dirs: [],
|
|
60
|
+
// Linked-worktree expansion is not scoped to one root; callers merge this
|
|
61
|
+
// into a result that already knows which roots were involved.
|
|
62
|
+
incomplete_roots: [],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function isLinkedWorktreeWithinCollectionScope(linked, discoveredFromApprovedRoots, allowedRoots) {
|
|
66
|
+
if (allowedRoots.some((root) => containsPath(root, linked.repo_root))) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
// Codex isolation worktrees live under ~/.codex/worktrees, outside the
|
|
70
|
+
// approved workspace parent. They remain in scope only when Git proves they
|
|
71
|
+
// belong to a clone discovered inside an approved root. Arbitrary sibling or
|
|
72
|
+
// personal linked worktrees do not inherit that consent.
|
|
73
|
+
if (!isCodexWorktreePath(linked.repo_root))
|
|
74
|
+
return false;
|
|
75
|
+
return discoveredFromApprovedRoots.some((identity) => identity.repo_fingerprint === linked.repo_fingerprint &&
|
|
76
|
+
allowedRoots.some((root) => containsPath(root, identity.repo_root)));
|
|
77
|
+
}
|
|
78
|
+
function localPathKey(value) {
|
|
79
|
+
const resolved = path.resolve(value);
|
|
80
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
81
|
+
}
|