vexp-cli 3.3.9 → 3.4.0
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/agent-config.js +3454 -602
- package/dist/agent-consent.js +111 -1
- package/dist/cli.js +99 -62
- package/dist/devmind.js +269 -0
- package/dist/doctor.js +1068 -89
- package/dist/git-worktree.js +136 -0
- package/dist/hook-scripts.js +271 -0
- package/dist/license-activate.js +33 -0
- package/dist/serve.js +7 -2
- package/mcp/mcp-server.cjs +52 -51
- package/package.json +7 -7
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { isStateHome } from "./state-home.js";
|
|
4
|
+
// Linked git worktrees, as the CLI sees them.
|
|
5
|
+
//
|
|
6
|
+
// Agent answers (agent-consent.ts) and the setup writers need to know when a
|
|
7
|
+
// folder is a linked worktree of another checkout: the answers given in the
|
|
8
|
+
// main checkout apply to its worktrees, and the files committed there are
|
|
9
|
+
// checked out in every worktree, where a setup must not repin them (field
|
|
10
|
+
// report, 2026-11-07). The rule is the one vexp-core (utils.rs
|
|
11
|
+
// git_project_root / worktree_consent_source), vexp-mcp (daemon-client.ts)
|
|
12
|
+
// and the extension (vexp-vscode src/git-worktree.ts) apply:
|
|
13
|
+
// test/git-worktree.lockstep.test.ts runs the same fixtures through all
|
|
14
|
+
// three ports.
|
|
15
|
+
//
|
|
16
|
+
// Detection is by the content of the worktree's .git FILE, never by a path
|
|
17
|
+
// convention: harnesses put worktrees anywhere (.claude/worktrees/,
|
|
18
|
+
// .worktrees/, a sibling folder).
|
|
19
|
+
/**
|
|
20
|
+
* The main checkout of a linked git worktree, or null when `dir` is not
|
|
21
|
+
* one. A linked worktree has a `.git` FILE whose `gitdir:` line points into
|
|
22
|
+
* `<main>/.git/worktrees/<name>`; git 2.48's `worktree.useRelativePaths`
|
|
23
|
+
* writes that path relative to the worktree. A submodule's gitdir points at
|
|
24
|
+
* `<parent>/.git/modules/<name>`, or, inside a linked worktree, at
|
|
25
|
+
* `<main>/.git/worktrees/<wt>/modules/<name>`, and is its own project: null. Lockstep
|
|
26
|
+
* with vexp-core utils::git_project_root / is_linked_worktree and the
|
|
27
|
+
* extension's linkedWorktreeMain.
|
|
28
|
+
*/
|
|
29
|
+
export function linkedWorktreeMain(dir) {
|
|
30
|
+
let content;
|
|
31
|
+
try {
|
|
32
|
+
const dotGit = path.join(dir, ".git");
|
|
33
|
+
if (!fs.statSync(dotGit).isFile())
|
|
34
|
+
return null;
|
|
35
|
+
content = fs.readFileSync(dotGit, "utf-8");
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const gitdir = content
|
|
41
|
+
.split(/\r?\n/)
|
|
42
|
+
.map((l) => l.trim())
|
|
43
|
+
.find((l) => l.startsWith("gitdir:"))
|
|
44
|
+
?.slice("gitdir:".length)
|
|
45
|
+
.trim()
|
|
46
|
+
.replace(/\\/g, "/");
|
|
47
|
+
const at = gitdir ? gitdir.indexOf("/.git/worktrees/") : -1;
|
|
48
|
+
if (!gitdir || at <= 0)
|
|
49
|
+
return null;
|
|
50
|
+
// A worktree's admin dir is one folder, <common>/.git/worktrees/<name>. A
|
|
51
|
+
// submodule checked out inside a linked worktree has its git dir under
|
|
52
|
+
// that admin dir (<main>/.git/worktrees/<wt>/modules/<path>): it is its
|
|
53
|
+
// own project, as a submodule of the main checkout is, and reading it as
|
|
54
|
+
// a worktree of <main> lent it <main>'s consent and a daemon. Lockstep
|
|
55
|
+
// with vexp-core utils::git_project_root.
|
|
56
|
+
const name = gitdir.slice(at + "/.git/worktrees/".length).replace(/\/+$/, "");
|
|
57
|
+
if (!name || name.includes("/"))
|
|
58
|
+
return null;
|
|
59
|
+
const main = path.resolve(dir, gitdir.slice(0, at));
|
|
60
|
+
// A gitdir naming the folder itself is not a link to another checkout.
|
|
61
|
+
return path.resolve(main) === path.resolve(dir) ? null : main;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Is `other` a checkout of the same repository as `root`, on this machine?
|
|
65
|
+
* A folder that is there, whose main checkout (the one a linked worktree's
|
|
66
|
+
* .git file names, else the folder itself) is `root`'s: a worktree's main
|
|
67
|
+
* checkout, a sibling worktree, the main checkout's live worktree. Not a
|
|
68
|
+
* folder that is missing here (a teammate's clone, a renamed or moved
|
|
69
|
+
* project, a removed worktree) and not an independent clone with its own
|
|
70
|
+
* .git. A committed file's pin or hook path is kept (W3) only for such a
|
|
71
|
+
* folder: kept for any other, a fresh clone or a moved repository got a
|
|
72
|
+
* vexp server pinned to a folder that does not exist, and a Codex hook that
|
|
73
|
+
* failed at every prompt, where 3.3.9 had pointed the file at the checkout
|
|
74
|
+
* (review, 2026-11-07). Filesystem only, no git. Lockstep with the
|
|
75
|
+
* extension's.
|
|
76
|
+
*/
|
|
77
|
+
export function sameRepositoryCheckout(other, root) {
|
|
78
|
+
try {
|
|
79
|
+
if (!fs.statSync(other).isDirectory())
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
const mainOf = (d) => linkedWorktreeMain(d) ?? d;
|
|
86
|
+
const key = (p) => {
|
|
87
|
+
const r = path.resolve(p).replace(/[\\/]+$/, "");
|
|
88
|
+
const k = r.length > 0 ? r : path.resolve(p);
|
|
89
|
+
return process.platform === "win32" ? k.toLowerCase() : k;
|
|
90
|
+
};
|
|
91
|
+
const a = mainOf(other);
|
|
92
|
+
const b = mainOf(root);
|
|
93
|
+
if (key(a) === key(b))
|
|
94
|
+
return true;
|
|
95
|
+
try {
|
|
96
|
+
return key(fs.realpathSync.native(a)) === key(fs.realpathSync.native(b));
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The main checkout a linked worktree takes its consent from: set up with
|
|
104
|
+
* vexp (manifest.json or index.db in its .vexp/) and not a folder the engine
|
|
105
|
+
* refuses whatever it holds (the home directory, a filesystem root). null
|
|
106
|
+
* for anything that is not a linked worktree. Lockstep with vexp-core
|
|
107
|
+
* utils::worktree_consent_source, vexp-mcp worktreeConsentSource and the
|
|
108
|
+
* extension's worktreeConsentSource.
|
|
109
|
+
*/
|
|
110
|
+
export function worktreeConsentSource(dir) {
|
|
111
|
+
const main = linkedWorktreeMain(dir);
|
|
112
|
+
if (!main)
|
|
113
|
+
return null;
|
|
114
|
+
const resolved = path.resolve(main);
|
|
115
|
+
// VEXP_ALLOW_HOME_WORKSPACE=1 lifts both refusals, as in the extension's
|
|
116
|
+
// refusedWhateverItHolds (isStateHome already answers false then).
|
|
117
|
+
const fsRoot = process.env.VEXP_ALLOW_HOME_WORKSPACE !== "1" && path.dirname(resolved) === resolved;
|
|
118
|
+
if (isStateHome(resolved) || fsRoot)
|
|
119
|
+
return null;
|
|
120
|
+
const vexp = path.join(main, ".vexp");
|
|
121
|
+
return fs.existsSync(path.join(vexp, "manifest.json")) || fs.existsSync(path.join(vexp, "index.db")) ? main : null;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Does the vexp extension start this folder's daemon when a window opens on
|
|
125
|
+
* it? Lockstep with the extension's startsAtActivation (non-null): the
|
|
126
|
+
* folder has an index of its own (.vexp/index.db), or, not on Windows, it
|
|
127
|
+
* is a linked worktree of a checkout set up for vexp (worktreeConsentSource),
|
|
128
|
+
* whose first start builds its index. Not on Windows: a live daemon holds
|
|
129
|
+
* the worktree's index open, and Windows then refuses `git worktree remove`.
|
|
130
|
+
* What `vexp doctor` says about a daemon that is down: a fresh worktree of
|
|
131
|
+
* a set-up repository was told to press "Start Indexing", while opening its
|
|
132
|
+
* window is enough (review, 2026-11-07).
|
|
133
|
+
*/
|
|
134
|
+
export function extensionStartsOnOpen(root, platform = process.platform) {
|
|
135
|
+
return fs.existsSync(path.join(root, ".vexp", "index.db")) || (platform !== "win32" && worktreeConsentSource(root) !== null);
|
|
136
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
// Committed hook scripts (K1), as `vexp setup` sees them. Twin:
|
|
3
|
+
// packages/vexp-vscode/src/providers/hook-scripts.ts, the same code and the
|
|
4
|
+
// same line word for word (vexp-vscode test/hook-scripts-lockstep.test.ts).
|
|
5
|
+
//
|
|
6
|
+
// Why: every hook script vexp writes into a project bakes the absolute path
|
|
7
|
+
// of the engine that wrote it (VEXP_BIN), because Claude Code and Codex run
|
|
8
|
+
// the script with no vexp on PATH. A team that commits .claude/hooks/ and
|
|
9
|
+
// .codex/ then saw every checkout rewrite them whenever that path changed:
|
|
10
|
+
// an extension update (a new versioned folder), a teammate's machine, a
|
|
11
|
+
// container with another HOME, and `vexp setup` against the extension's
|
|
12
|
+
// copy (the extension adds an upgrade fallback the CLI does not). git status
|
|
13
|
+
// showed them modified in every clone and worktree, and an agent's `git add
|
|
14
|
+
// -A` committed the churn (field report, 2026-11-07). A committed script
|
|
15
|
+
// whose text is exactly what this vexp writes, for the engine path it
|
|
16
|
+
// bakes, and that still finds a working engine, is left as it is.
|
|
17
|
+
/**
|
|
18
|
+
* The extension's upgrade fallback, inserted after the VEXP_BIN line of
|
|
19
|
+
* every sh hook script it writes (vexp-vscode agent-auto-config.ts
|
|
20
|
+
* bakeVexpBin, where the reasoning lives). The CLI does not write it: its
|
|
21
|
+
* own paths are version-stable. It recognizes it, so a committed script
|
|
22
|
+
* the extension wrote is not rewritten into the CLI's form. Byte for byte
|
|
23
|
+
* the extension's.
|
|
24
|
+
*/
|
|
25
|
+
export const VEXP_BIN_FALLBACK = `
|
|
26
|
+
# An extension upgrade removes the versioned folder above; fall back to the
|
|
27
|
+
# newest installed vexp extension binary. Managed by vexp.
|
|
28
|
+
if [ ! -x "$VEXP_BIN" ]; then
|
|
29
|
+
case "$VEXP_BIN" in
|
|
30
|
+
*/extensions/vexp.vexp-vscode-*)
|
|
31
|
+
_vexp_ext_root="\${VEXP_BIN%%/vexp.vexp-vscode-*}"
|
|
32
|
+
_vexp_latest="$(ls -d "$_vexp_ext_root"/vexp.vexp-vscode-*/binaries/*/vexp-core* 2>/dev/null | sort -V 2>/dev/null | tail -n 1)"
|
|
33
|
+
[ -n "$_vexp_latest" ] && [ -x "$_vexp_latest" ] && VEXP_BIN="$_vexp_latest"
|
|
34
|
+
;;
|
|
35
|
+
esac
|
|
36
|
+
fi`;
|
|
37
|
+
/**
|
|
38
|
+
* `plain`, a sh hook script as the CLI bakes it, in the extension's form:
|
|
39
|
+
* VEXP_BIN_FALLBACK right after its `VEXP_BIN="..."` line, which is where
|
|
40
|
+
* bakeVexpBin puts it. undefined for a script with no such line (the
|
|
41
|
+
* Windows .cmd twin, the opencode / Kilo plugins): neither twin adds a
|
|
42
|
+
* fallback there.
|
|
43
|
+
*/
|
|
44
|
+
export function withExtensionFallback(plain) {
|
|
45
|
+
const m = /^VEXP_BIN="[^"\r\n]*"$/m.exec(plain);
|
|
46
|
+
if (!m)
|
|
47
|
+
return undefined;
|
|
48
|
+
const at = m.index + m[0].length;
|
|
49
|
+
return plain.slice(0, at) + VEXP_BIN_FALLBACK + plain.slice(at);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The engine path a hook script bakes: the sh scripts' `VEXP_BIN="..."`,
|
|
53
|
+
* the .cmd twin's `set "VEXP_BIN=..."`, the opencode / Kilo plugins'
|
|
54
|
+
* `const VEXP_BIN = "...";` and `const BIN = "...";`. The first such line;
|
|
55
|
+
* undefined when the text has none.
|
|
56
|
+
*/
|
|
57
|
+
export function bakedEnginePath(text) {
|
|
58
|
+
const patterns = [
|
|
59
|
+
/^VEXP_BIN="([^"\r\n]*)"\r?$/m,
|
|
60
|
+
/^set "VEXP_BIN=([^"\r\n]*)"\r?$/m,
|
|
61
|
+
/^const (?:VEXP_)?BIN = "([^"\r\n]*)";\r?$/m,
|
|
62
|
+
];
|
|
63
|
+
for (const re of patterns) {
|
|
64
|
+
const m = re.exec(text);
|
|
65
|
+
if (m && m[1] !== "")
|
|
66
|
+
return m[1];
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
function executable(p) {
|
|
71
|
+
try {
|
|
72
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function isFile(p) {
|
|
80
|
+
try {
|
|
81
|
+
return fs.statSync(p).isFile();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function dirNames(dir) {
|
|
88
|
+
try {
|
|
89
|
+
return fs.readdirSync(dir);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const isDigit = (c) => c >= "0" && c <= "9";
|
|
96
|
+
const isAlpha = (c) => (c >= "A" && c <= "Z") || (c >= "a" && c <= "z");
|
|
97
|
+
/** gnulib filevercmp's file_prefixlen: the length of `s` without a suffix like `.tar.gz` or `.exe`. */
|
|
98
|
+
function filePrefixLen(s) {
|
|
99
|
+
const n = s.length;
|
|
100
|
+
let prefixlen = 0;
|
|
101
|
+
for (let i = 0;;) {
|
|
102
|
+
if (i === n)
|
|
103
|
+
return prefixlen;
|
|
104
|
+
i++;
|
|
105
|
+
prefixlen = i;
|
|
106
|
+
while (i + 1 < n && s[i] === "." && (isAlpha(s[i + 1]) || s[i + 1] === "~")) {
|
|
107
|
+
for (i += 2; i < n && (isAlpha(s[i]) || isDigit(s[i]) || s[i] === "~"); i++) {
|
|
108
|
+
/* the suffix goes on */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** gnulib filevercmp's order(): where the byte at `pos` sorts. */
|
|
114
|
+
function order(s, pos, len) {
|
|
115
|
+
if (pos === len)
|
|
116
|
+
return -1;
|
|
117
|
+
const c = s[pos];
|
|
118
|
+
if (isDigit(c))
|
|
119
|
+
return 0;
|
|
120
|
+
if (isAlpha(c))
|
|
121
|
+
return c.charCodeAt(0);
|
|
122
|
+
if (c === "~")
|
|
123
|
+
return -2;
|
|
124
|
+
return c.charCodeAt(0) + 256;
|
|
125
|
+
}
|
|
126
|
+
/** gnulib filevercmp's verrevcmp, over the first `alen` and `blen` characters. */
|
|
127
|
+
function verrevcmp(a, alen, b, blen) {
|
|
128
|
+
let i = 0;
|
|
129
|
+
let j = 0;
|
|
130
|
+
while (i < alen || j < blen) {
|
|
131
|
+
let firstDiff = 0;
|
|
132
|
+
while ((i < alen && !isDigit(a[i])) || (j < blen && !isDigit(b[j]))) {
|
|
133
|
+
const ac = order(a, i, alen);
|
|
134
|
+
const bc = order(b, j, blen);
|
|
135
|
+
if (ac !== bc)
|
|
136
|
+
return ac - bc;
|
|
137
|
+
i++;
|
|
138
|
+
j++;
|
|
139
|
+
}
|
|
140
|
+
while (i < alen && a[i] === "0")
|
|
141
|
+
i++;
|
|
142
|
+
while (j < blen && b[j] === "0")
|
|
143
|
+
j++;
|
|
144
|
+
while (i < alen && j < blen && isDigit(a[i]) && isDigit(b[j])) {
|
|
145
|
+
if (!firstDiff)
|
|
146
|
+
firstDiff = a.charCodeAt(i) - b.charCodeAt(j);
|
|
147
|
+
i++;
|
|
148
|
+
j++;
|
|
149
|
+
}
|
|
150
|
+
if (i < alen && isDigit(a[i]))
|
|
151
|
+
return 1;
|
|
152
|
+
if (j < blen && isDigit(b[j]))
|
|
153
|
+
return -1;
|
|
154
|
+
if (firstDiff)
|
|
155
|
+
return firstDiff;
|
|
156
|
+
}
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* `sort -V` as GNU coreutils orders two lines (gnulib filevercmp, then the
|
|
161
|
+
* whole line byte by byte when they tie), which is what the fallback's
|
|
162
|
+
* `sort -V | tail -n 1` uses to pick the newest build: 3.3.10 after 3.3.9.
|
|
163
|
+
* Negative, zero or positive, like a comparator.
|
|
164
|
+
*/
|
|
165
|
+
export function versionSortCompare(a, b) {
|
|
166
|
+
const byVersion = (() => {
|
|
167
|
+
if (a === "" || b === "")
|
|
168
|
+
return (b === "" ? 1 : 0) - (a === "" ? 1 : 0);
|
|
169
|
+
if (a[0] === ".") {
|
|
170
|
+
if (b[0] !== ".")
|
|
171
|
+
return -1;
|
|
172
|
+
const adot = a.length === 1;
|
|
173
|
+
const bdot = b.length === 1;
|
|
174
|
+
if (adot || bdot)
|
|
175
|
+
return (bdot ? 1 : 0) - (adot ? 1 : 0);
|
|
176
|
+
const adotdot = a[1] === "." && a.length === 2;
|
|
177
|
+
const bdotdot = b[1] === "." && b.length === 2;
|
|
178
|
+
if (adotdot || bdotdot)
|
|
179
|
+
return (bdotdot ? 1 : 0) - (adotdot ? 1 : 0);
|
|
180
|
+
}
|
|
181
|
+
else if (b[0] === ".") {
|
|
182
|
+
return 1;
|
|
183
|
+
}
|
|
184
|
+
const ap = filePrefixLen(a);
|
|
185
|
+
const bp = filePrefixLen(b);
|
|
186
|
+
const onePass = ap === a.length && bp === b.length;
|
|
187
|
+
const r = verrevcmp(a, ap, b, bp);
|
|
188
|
+
return r || onePass ? r : verrevcmp(a, a.length, b, b.length);
|
|
189
|
+
})();
|
|
190
|
+
if (byVersion !== 0)
|
|
191
|
+
return byVersion;
|
|
192
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The build VEXP_BIN_FALLBACK runs for `bin`, by the rules of its sh code:
|
|
196
|
+
* only for a path inside a VS Code extensions folder
|
|
197
|
+
* (`*\/extensions/vexp.vexp-vscode-*`), the newest (`sort -V`) of
|
|
198
|
+
* `<that folder>/vexp.vexp-vscode-*\/binaries/*\/vexp-core*`, and only when
|
|
199
|
+
* it is executable. undefined when the fallback would pick none. The sh
|
|
200
|
+
* code does not run at all while `bin` itself is executable.
|
|
201
|
+
*/
|
|
202
|
+
export function extensionFallbackEngine(bin) {
|
|
203
|
+
if (!bin.includes("/extensions/vexp.vexp-vscode-"))
|
|
204
|
+
return undefined;
|
|
205
|
+
// ${VEXP_BIN%%/vexp.vexp-vscode-*}: everything before the first match.
|
|
206
|
+
const root = bin.slice(0, bin.indexOf("/vexp.vexp-vscode-"));
|
|
207
|
+
// The glob, as sh expands it: no name that starts with a dot under a `*`.
|
|
208
|
+
const found = [];
|
|
209
|
+
for (const build of dirNames(root || "/")) {
|
|
210
|
+
if (!build.startsWith("vexp.vexp-vscode-"))
|
|
211
|
+
continue;
|
|
212
|
+
const binaries = `${root}/${build}/binaries`;
|
|
213
|
+
for (const platform of dirNames(binaries)) {
|
|
214
|
+
if (platform.startsWith("."))
|
|
215
|
+
continue;
|
|
216
|
+
for (const name of dirNames(`${binaries}/${platform}`)) {
|
|
217
|
+
if (name.startsWith("vexp-core"))
|
|
218
|
+
found.push(`${binaries}/${platform}/${name}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (found.length === 0)
|
|
223
|
+
return undefined;
|
|
224
|
+
const newest = found.reduce((a, b) => (versionSortCompare(a, b) >= 0 ? a : b));
|
|
225
|
+
return executable(newest) && isFile(newest) ? newest : undefined;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Would a hook script that bakes `bin` run an engine right now? The sh
|
|
229
|
+
* scripts run `$VEXP_BIN` when it is executable (and an executable that is
|
|
230
|
+
* no file runs nothing); a script with the extension's fallback
|
|
231
|
+
* (`withFallback`) otherwise runs the build extensionFallbackEngine names.
|
|
232
|
+
*/
|
|
233
|
+
export function hookEngineWorks(bin, withFallback) {
|
|
234
|
+
if (executable(bin))
|
|
235
|
+
return isFile(bin);
|
|
236
|
+
return withFallback && extensionFallbackEngine(bin) !== undefined;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* K1: may the hook script `text` (what is on disk) stay as it is? Yes when
|
|
240
|
+
* it is exactly what this vexp writes for the engine path it bakes
|
|
241
|
+
* (`plainFor(bin)`, the CLI's form, or that with the extension's fallback),
|
|
242
|
+
* that script would find a working engine now, and `tracked()` (git, asked
|
|
243
|
+
* last) says the file is committed. A script whose template changed, whose
|
|
244
|
+
* engine is gone with no fallback that finds one, or that git does not
|
|
245
|
+
* track is written as before.
|
|
246
|
+
*/
|
|
247
|
+
export function committedHookScriptStays(text, plainFor, tracked) {
|
|
248
|
+
const bin = bakedEnginePath(text);
|
|
249
|
+
if (bin === undefined)
|
|
250
|
+
return false;
|
|
251
|
+
const plain = plainFor(bin);
|
|
252
|
+
const extension = withExtensionFallback(plain);
|
|
253
|
+
if (text !== plain && text !== extension)
|
|
254
|
+
return false;
|
|
255
|
+
if (!hookEngineWorks(bin, text === extension))
|
|
256
|
+
return false;
|
|
257
|
+
return tracked();
|
|
258
|
+
}
|
|
259
|
+
/** Why a committed hook script was left as it is (K1). ASCII, word for word the extension's. */
|
|
260
|
+
export function committedHookScriptLine(file) {
|
|
261
|
+
return `${file} is committed to this repository and its vexp engine path still works here; vexp left it as it is`;
|
|
262
|
+
}
|
|
263
|
+
/** The script on disk at `file`, or undefined when there is none to read. */
|
|
264
|
+
export function readHookScript(file) {
|
|
265
|
+
try {
|
|
266
|
+
return fs.readFileSync(file, "utf-8");
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
}
|
package/dist/license-activate.js
CHANGED
|
@@ -11,6 +11,39 @@ export function planLabel(plan) {
|
|
|
11
11
|
export function isLifetimePlan(plan) {
|
|
12
12
|
return /^tier[1-4]$/.test(plan);
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* The tool counts `vexp license` states, as the servers define them. The
|
|
16
|
+
* line said "no (7/10)" from v1.2.16 on, long after the surface grew, and
|
|
17
|
+
* "shows 4 by default" for the native server alone. Held to the sources by
|
|
18
|
+
* test/license.tool-counts.test.ts: vexp-core's build_all_tool_defs,
|
|
19
|
+
* FREE_TOOLS and CORE_LISTED_TOOLS (mcp_stdio.rs), and the Node server's
|
|
20
|
+
* MINIMAL_TOOL_DEFINITIONS (vexp-mcp/src/index.ts).
|
|
21
|
+
*/
|
|
22
|
+
export const TOOL_COUNTS = {
|
|
23
|
+
/** Every tool vexp-core's MCP server defines. */
|
|
24
|
+
native: 15,
|
|
25
|
+
/** Those its tools/call serves on a plan without all_tools. */
|
|
26
|
+
nativeFree: 9,
|
|
27
|
+
/** What vexp-core lists by default. */
|
|
28
|
+
nativeListed: 4,
|
|
29
|
+
/** What the Node MCP server (mcp-server.cjs) lists by default: the same
|
|
30
|
+
* four since it lists expand_vexp_ref too. */
|
|
31
|
+
nodeListed: 4,
|
|
32
|
+
};
|
|
33
|
+
/** The "All tools" lines of `vexp license` and the menu's status, unindented. */
|
|
34
|
+
export function allToolsLines(allTools) {
|
|
35
|
+
const c = TOOL_COUNTS;
|
|
36
|
+
if (!allTools)
|
|
37
|
+
return [`All tools: no (the Free set: ${c.nativeFree} of vexp-core's ${c.native} tools)`];
|
|
38
|
+
// One number while both servers list as many; both, should they part again.
|
|
39
|
+
const shown = c.nodeListed === c.nativeListed
|
|
40
|
+
? `${c.nativeListed} by default`
|
|
41
|
+
: `${c.nativeListed} by default, ${c.nodeListed} through the Node MCP server`;
|
|
42
|
+
return [
|
|
43
|
+
"All tools: yes",
|
|
44
|
+
`(the MCP tool list shows ${shown}, to keep the catalog small; every tool stays callable - VEXP_ALL_TOOLS=1 lists them all)`,
|
|
45
|
+
];
|
|
46
|
+
}
|
|
14
47
|
/** "Last check-in: <date>" / "never", for `vexp license` and the menu. */
|
|
15
48
|
export function lastCheckInText() {
|
|
16
49
|
const at = lastCheckIn();
|
package/dist/serve.js
CHANGED
|
@@ -184,7 +184,8 @@ export function isOwnerEntry(ws, sock) {
|
|
|
184
184
|
* and took a 16-core host down: ~1,585% CPU, 2 GB/s of reads, hard reset
|
|
185
185
|
* (field incident, 2026-09-02, tier 3). Worktree daemons run on demand
|
|
186
186
|
* only; resurrection is for canonical workspaces. A submodule's gitdir
|
|
187
|
-
* points at `/.git/modules/`
|
|
187
|
+
* points at `/.git/modules/` (or, inside a linked worktree,
|
|
188
|
+
* `/.git/worktrees/<wt>/modules/`) and is left alone. Mirrors vexp-core
|
|
188
189
|
* utils::git_project_root. */
|
|
189
190
|
export function isLinkedWorktree(ws) {
|
|
190
191
|
try {
|
|
@@ -197,7 +198,11 @@ export function isLinkedWorktree(ws) {
|
|
|
197
198
|
.find((l) => l.trim().startsWith("gitdir:"));
|
|
198
199
|
if (!gitdir)
|
|
199
200
|
return false;
|
|
200
|
-
|
|
201
|
+
// Only `<common>/.git/worktrees/<name>`, one folder deep: a submodule
|
|
202
|
+
// checked out inside a linked worktree (`.git/worktrees/<wt>/modules/
|
|
203
|
+
// <path>`) is its own project. Lockstep with linkedWorktreeMain
|
|
204
|
+
// (git-worktree.ts) and vexp-core utils::git_project_root.
|
|
205
|
+
return /\/\.git\/worktrees\/[^/]+\/?$/.test(gitdir.slice(gitdir.indexOf("gitdir:") + "gitdir:".length).replace(/\\/g, "/").trim());
|
|
201
206
|
}
|
|
202
207
|
catch {
|
|
203
208
|
return false;
|