@vodmal/vdx-cli 0.33.0 → 0.35.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/README.md CHANGED
@@ -55,7 +55,7 @@ vdx doctor --fix # write the profile's personal hooks (git.ho
55
55
 
56
56
  # Start your agent in a project per your profile (~/.vdx-environment.yaml or $VDX_ENVIRONMENT)
57
57
  vdx ai[@host] [project-path] [--new | --conversation <id>] [--focused] [--restart] [--detach] [--dry-run] # default: cwd; @host: over ssh, in tmux there
58
- vdx ai --check [project-path] # this session against the profile — what the plugin's SessionStart hook tells the agent
58
+ vdx ai --check [project-path] # this session against the profile and the installed Claude Code and plugins — what the plugin's SessionStart hook tells the agent
59
59
 
60
60
  # What a command does and takes; nothing runs (ai: also where launch flags live)
61
61
  vdx <command> --help
@@ -134,7 +134,16 @@ nobody bypasses; a public repo needs no token, a private one takes it from
134
134
  its name, or one glab has a token for): nobody may push to the branch directly,
135
135
  and a merge request needs a passing pipeline; the token is glab's for that
136
136
  host, or `GITLAB_TOKEN` when the host is `GITLAB_HOST` (`gitlab.com` unless
137
- set) — vdx never sends a token to a host it does not belong to. Not met comes with the reason; another hosting, no network or no token
137
+ set) — vdx never sends a token to a host it does not belong to. Bitbucket
138
+ Server (a host `BITBUCKET_HOST` names, one with `bitbucket` in its name, or one
139
+ git keeps an https login for — and that says it is Bitbucket): a restriction
140
+ lets nothing into the branch but a pull request and exempts nobody, and a pull
141
+ request needs a successful build ("Minimum successful builds"); the login is
142
+ the one git keeps for that host (`git credential fill`, never a prompt — log in
143
+ once with `git ls-remote https://<host>/scm/<project>/<repo>.git`, a personal
144
+ access token as the password), or `BITBUCKET_TOKEN` when the host is
145
+ `BITBUCKET_HOST`. Branch restrictions are visible to a repository admin only.
146
+ Not met comes with the reason; another hosting, no network or no token
138
147
  where one is needed leave the axis unknown, with the reason too;
139
148
  `VDX_OFFLINE=1` does not ask at all.
140
149
 
@@ -177,8 +186,9 @@ npm run typecheck
177
186
  ## Not implemented yet
178
187
 
179
188
  - Above L1 the `ci` axis reads GitHub Actions only; other CI systems answer
180
- "unknown". `branch-protection` reads GitHub and GitLab; other hostings
181
- (gitolite, Bitbucket) answer "unknown".
189
+ "unknown". `branch-protection` reads GitHub, GitLab and Bitbucket Server;
190
+ other hostings (gitolite, Bitbucket Cloud) answer "unknown", and so do
191
+ Bitbucket restrictions by pattern or branching model.
182
192
  - Baseline loading from a git ref is documented but evaluator still reads file
183
193
  paths only — `baseline:` in `mise.toml` is recorded but does not auto-fetch.
184
194
  - Watermark drift (phase 2 of `drift-algorithm.md`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.33.0",
3
+ "version": "0.35.0",
4
4
  "description": "vdx — unified lifecycle interface (up/down/build/test/check/fix) + versioned maturity audit with drift detection. Pairs with the vdx Claude Code plugin.",
5
5
  "keywords": [
6
6
  "vdx",
package/src/ai.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  runsInProject,
30
30
  } from './conversations.ts';
31
31
  import { at, errorMessage, isStringList, str } from './narrow.ts';
32
+ import { type Lag, sessionLag } from './session-versions.ts';
32
33
 
33
34
  /**
34
35
  * `vdx ai` — start the person's agent in a project, with the flags their
@@ -749,6 +750,28 @@ export interface AiDeps {
749
750
  ssh?: (args: string[], capture: boolean) => { status: number; stdout: string } | null;
750
751
  /** Claude Code processes alive on this machine; read from its session files when not given. */
751
752
  liveHere?: () => LiveSession[];
753
+ /** What the agent process `pid` lags behind; read from Claude Code's files when not given. */
754
+ lag?: (pid: number, projectRoot: string, command: string) => Lag | null;
755
+ }
756
+
757
+ function machineLag(deps: AiDeps, pid: number, projectRoot: string, command: string): Lag | null {
758
+ return sessionLag({
759
+ env: deps.env,
760
+ home: deps.home,
761
+ pid,
762
+ projectRoot,
763
+ installedClaude: () => {
764
+ try {
765
+ return execFileSync(command, ['--version'], {
766
+ encoding: 'utf8',
767
+ stdio: ['ignore', 'pipe', 'ignore'],
768
+ timeout: 5_000,
769
+ });
770
+ } catch {
771
+ return null;
772
+ }
773
+ },
774
+ });
752
775
  }
753
776
 
754
777
  function runSsh(args: string[], capture: boolean): { status: number; stdout: string } | null {
@@ -1534,24 +1557,53 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
1534
1557
  }
1535
1558
  const missing = missingArgs(own.args, plan.args);
1536
1559
  const why = plan.matched.length ? ` (profile conditions met here: ${plan.matched.join(', ')})` : '';
1537
- if (missing.length === 0) {
1560
+ // A live session keeps the Claude Code and plugins it started with (vdx-ai, Sidetrack #24).
1561
+ const lag = (deps.lag ?? ((pid, root, command) => machineLag(deps, pid, root, command)))(
1562
+ own.pid,
1563
+ projectRoot,
1564
+ plan.command,
1565
+ );
1566
+ if (missing.length === 0 && !lag) {
1538
1567
  lines.push(`✓ this session carries the profile's flags${why}.`);
1539
1568
  deps.out(lines.join('\n') + '\n');
1540
1569
  return EXIT_OK;
1541
1570
  }
1571
+ const problems: string[] = [];
1572
+ if (missing.length > 0) problems.push(`✗ this session runs without ${missing.join(' ')}${why}.`);
1573
+ else lines.push(`✓ this session carries the profile's flags${why}.`);
1574
+ if (lag) problems.push(`✗ ${describeLag(lag)}.`);
1575
+
1542
1576
  const root = shellQuote(projectRoot);
1543
- lines.push(
1544
- `✗ this session runs without ${missing.join(' ')}${why}. Hand the user the fix — ` +
1577
+ const again = [
1578
+ missing.length > 0 ? " with the profile's flags" : '',
1579
+ lag ? `${missing.length > 0 ? ',' : ''} on the installed Claude Code and plugins` : '',
1580
+ ].join('');
1581
+ // Plugins alone: /reload-plugins loads them and the conversation goes on in the same process.
1582
+ const reload =
1583
+ missing.length === 0 && lag && !lag.claude
1584
+ ? '`/reload-plugins`: it loads the installed plugins and keeps the conversation (Claude Code stays as it is); or '
1585
+ : '';
1586
+ problems.push(
1587
+ `Hand the user the fix — ${reload}` +
1545
1588
  (deps.env['TMUX']
1546
- ? `\`${vdxAi} --restart ${root}\`: it stops this session and starts it again with the profile's flags` +
1589
+ ? `\`${vdxAi} --restart ${root}\`: it stops this session and starts it again${again}` +
1547
1590
  (plan.resumeArgs.length ? `, resuming the conversation (${plan.resumeArgs.join(' ')})` : '') +
1548
1591
  `. Do not run it yourself: it ends this session.`
1549
1592
  : `this session runs outside tmux, so vdx cannot restart it: the user exits it and runs \`${vdxAi} ${root}\`.`),
1550
1593
  );
1594
+ lines.push(problems.join(' '));
1551
1595
  deps.out(lines.join('\n') + '\n');
1552
1596
  return EXIT_DRIFT;
1553
1597
  }
1554
1598
 
1599
+ /** `this session runs Claude Code 2.1.294 — 2.1.304 is installed; plugins updated since it loaded them: vdm 2.54.0`. */
1600
+ function describeLag(lag: Lag): string {
1601
+ const names = lag.plugins.map((p) => `${p.name} ${p.version}`).join(', ');
1602
+ if (!lag.claude) return `plugins updated since this session loaded them: ${names} — it still runs the ones it loaded`;
1603
+ const claude = `this session runs Claude Code ${lag.claude.runs} — ${lag.claude.installed} is installed`;
1604
+ return lag.plugins.length > 0 ? `${claude}; plugins updated since it loaded them: ${names}` : claude;
1605
+ }
1606
+
1555
1607
  export function runAi(opts: AiOptions, deps: AiDeps): number {
1556
1608
  const { log, tmux } = deps;
1557
1609
 
package/src/hosting.ts CHANGED
@@ -19,16 +19,26 @@ export interface HostingIo {
19
19
  ghToken: () => string | null;
20
20
  /** The token `glab` keeps for a GitLab host; null without glab or a login there. */
21
21
  glabToken: (host: string) => string | null;
22
- /** GET of an api.github.com path, or of a whole https:// URL. Throws when no answer came at all. */
23
- get: (apiPath: string, token: string | null) => Answer;
22
+ /** The login git keeps for https://<host> (`git credential fill`), never by a prompt; null without one. */
23
+ gitCredential: (host: string) => { username: string; password: string } | null;
24
+ /**
25
+ * GET of an api.github.com path, or of a whole https:// URL, with the token as `Bearer`
26
+ * or `Basic`. Throws when no answer came at all.
27
+ */
28
+ get: (apiPath: string, token: string | null, scheme?: 'Bearer' | 'Basic') => Answer;
24
29
  }
25
30
 
26
31
  // A child node runs the request: the audit is synchronous and fetch is not
27
32
  // (DL #6 of docs/tasks/hosting-extension). The token travels in its
28
- // environment, not its arguments — those are visible in ps.
33
+ // environment, not its arguments — those are visible in ps. GitHub's media
34
+ // type only to GitHub: Bitbucket answers it with 406 (DL #17).
29
35
  const GET = `
30
- const headers = { accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28', 'user-agent': 'vdx' };
31
- if (process.env.VDX_HOSTING_TOKEN) headers.authorization = 'Bearer ' + process.env.VDX_HOSTING_TOKEN;
36
+ const headers = new URL(process.argv[1]).host === 'api.github.com'
37
+ ? { accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28', 'user-agent': 'vdx' }
38
+ : { accept: 'application/json', 'user-agent': 'vdx' };
39
+ if (process.env.VDX_HOSTING_TOKEN) {
40
+ headers.authorization = (process.env.VDX_HOSTING_SCHEME || 'Bearer') + ' ' + process.env.VDX_HOSTING_TOKEN;
41
+ }
32
42
  try {
33
43
  const r = await fetch(process.argv[1], { headers, signal: AbortSignal.timeout(15000) });
34
44
  process.stdout.write(JSON.stringify({ status: r.status, text: await r.text() }));
@@ -37,10 +47,11 @@ try {
37
47
  }
38
48
  `;
39
49
 
40
- function get(apiPath: string, token: string | null): Answer {
50
+ function get(apiPath: string, token: string | null, scheme: 'Bearer' | 'Basic' = 'Bearer'): Answer {
41
51
  const env = { ...process.env };
42
52
  delete env.VDX_HOSTING_TOKEN;
43
- if (token) env.VDX_HOSTING_TOKEN = token;
53
+ delete env.VDX_HOSTING_SCHEME;
54
+ if (token) Object.assign(env, { VDX_HOSTING_TOKEN: token, VDX_HOSTING_SCHEME: scheme });
44
55
  const url = apiPath.startsWith('https://') ? apiPath : `https://api.github.com${apiPath}`;
45
56
  const out: unknown = JSON.parse(
46
57
  execFileSync(process.execPath, ['--input-type=module', '-e', GET, url], {
@@ -80,6 +91,25 @@ export function machineIo(root: string): HostingIo {
80
91
  git: (args) => run('git', ['--no-optional-locks', '-C', root, ...args]),
81
92
  ghToken: () => run('gh', ['auth', 'token']),
82
93
  glabToken: (host) => run('glab', ['config', 'get', 'token', '--host', host]),
94
+ gitCredential: (host) => {
95
+ if (!/^[a-z0-9.-]+$/i.test(host)) return null;
96
+ try {
97
+ const out = execFileSync('git', ['--no-optional-locks', '-C', root, 'credential', 'fill'], {
98
+ encoding: 'utf8',
99
+ input: `protocol=https\nhost=${host}\n\n`,
100
+ // Without a login git would ask for one: no terminal, no askpass program, no window.
101
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', GCM_INTERACTIVE: 'never' },
102
+ stdio: ['pipe', 'pipe', 'ignore'],
103
+ timeout: 5_000,
104
+ });
105
+ const field = (key: string) => out.match(new RegExp(`^${key}=(.*)$`, 'm'))?.[1];
106
+ const username = field('username');
107
+ const password = field('password');
108
+ return username && password ? { username, password } : null;
109
+ } catch {
110
+ return null;
111
+ }
112
+ },
83
113
  get,
84
114
  };
85
115
  }
@@ -164,7 +194,8 @@ function judge(io: HostingIo, repo: string, token: string | null): Verdict {
164
194
 
165
195
  /**
166
196
  * Whether the default branch on the hosting of `origin` takes changes only after a check
167
- * required of everyone. GitHub and GitLab; what vdx cannot ask is unknown, with the reason.
197
+ * required of everyone. GitHub, GitLab and Bitbucket Server; what vdx cannot ask is unknown,
198
+ * with the reason.
168
199
  */
169
200
  export function branchRequiresChecks(ctx: Ctx, io: HostingIo = machineIo(ctx.projectRoot)): Verdict {
170
201
  const key = 'hosting:branch-requires-checks';
@@ -191,10 +222,7 @@ function ask(io: HostingIo): Verdict {
191
222
  // or one with gitlab in its name. A token goes only where it belongs: GITLAB_TOKEN to
192
223
  // GITLAB_HOST, as glab sends it; glab's to its own host. Any other host is asked without
193
224
  // one — an origin is the repository's to name, and may point anywhere.
194
- const tokenHost = (io.env.GITLAB_HOST || 'gitlab.com')
195
- .replace(/^https?:\/\//, '')
196
- .replace(/\/.*$/, '')
197
- .toLowerCase();
225
+ const tokenHost = hostOf(io.env.GITLAB_HOST || 'gitlab.com');
198
226
  const glab = io.glabToken(host);
199
227
  const token = (host.toLowerCase() === tokenHost ? io.env.GITLAB_TOKEN : undefined) || glab;
200
228
  if (host.toLowerCase() === tokenHost || glab || host.includes('gitlab')) {
@@ -204,7 +232,45 @@ function ask(io: HostingIo): Verdict {
204
232
  return { unknown: `${host} did not answer: ${errorMessage(e)}` };
205
233
  }
206
234
  }
207
- return { unknown: `origin is on ${host || 'a host vdx cannot tell'} — vdx reads GitHub and GitLab` };
235
+ // Bitbucket Server: the host BITBUCKET_HOST names, one with bitbucket in its name, or one
236
+ // git keeps a login for (git.finam.ru, DL #16) — once the host, asked without a login,
237
+ // says it is Bitbucket. BITBUCKET_TOKEN goes to BITBUCKET_HOST only, git's login to the
238
+ // host git keeps it for.
239
+ const bitbucketHost = io.env.BITBUCKET_HOST ? hostOf(io.env.BITBUCKET_HOST) : null;
240
+ const login = io.gitCredential(host);
241
+ if (host.toLowerCase() === bitbucketHost || host.toLowerCase().includes('bitbucket') || login) {
242
+ const auth: BitbucketAuth | null =
243
+ host.toLowerCase() === bitbucketHost && io.env.BITBUCKET_TOKEN
244
+ ? { token: io.env.BITBUCKET_TOKEN, scheme: 'Bearer', source: 'BITBUCKET_TOKEN' }
245
+ : login
246
+ ? {
247
+ token: Buffer.from(`${login.username}:${login.password}`).toString('base64'),
248
+ scheme: 'Basic',
249
+ source: 'the login git keeps',
250
+ }
251
+ : null;
252
+ try {
253
+ const about = io.get(`https://${host}/rest/api/1.0/application-properties`, null);
254
+ if (about.status === 200 && at(about.body, 'displayName') === 'Bitbucket') {
255
+ return judgeBitbucket(io, host, repo, auth);
256
+ }
257
+ } catch (e) {
258
+ return { unknown: `${host} did not answer: ${errorMessage(e)}` };
259
+ }
260
+ }
261
+ // Not every host is asked whether it is Bitbucket: gitolite has no https, and the audit
262
+ // would wait out the timeout. The reason says how vdx tells Bitbucket.
263
+ return {
264
+ unknown: `origin is on ${host || 'a host vdx cannot tell'} — vdx reads GitHub, GitLab and Bitbucket Server, the last once git keeps a login for it or BITBUCKET_HOST names it`,
265
+ };
266
+ }
267
+
268
+ /** The host of a `*_HOST` variable, which may come as a URL. */
269
+ function hostOf(value: string): string {
270
+ return value
271
+ .replace(/^https?:\/\//, '')
272
+ .replace(/\/.*$/, '')
273
+ .toLowerCase();
208
274
  }
209
275
 
210
276
  /** A refusal of a GitLab API as the reason the answer is unknown; null for a 200. */
@@ -262,6 +328,112 @@ function judgeGitlab(io: HostingIo, host: string, repo: string, token: string |
262
328
  return { no: why.join('; ') };
263
329
  }
264
330
 
331
+ /** What vdx asks Bitbucket with, and where it comes from — for the reason when it is refused. */
332
+ interface BitbucketAuth {
333
+ token: string;
334
+ scheme: 'Bearer' | 'Basic';
335
+ source: string;
336
+ }
337
+
338
+ const MIN_BUILDS = 'com.atlassian.bitbucket.server.bitbucket-build:requiredBuildsMergeCheck';
339
+
340
+ /** A refusal of Bitbucket as the reason the answer is unknown; null for a 200. */
341
+ function bitbucketRefusal(a: Answer, host: string, repo: string, auth: BitbucketAuth | null): Unknown | null {
342
+ if (a.status === 200) return null;
343
+ const message = str(at(list(at(a.body, 'errors'))[0], 'message')) ?? '';
344
+ if (a.status === 401 && !auth) {
345
+ return {
346
+ unknown: `${host} shows ${repo} only after a login — once: git ls-remote https://${host}/scm/${repo}.git, a token as the password; or BITBUCKET_TOKEN with BITBUCKET_HOST`,
347
+ };
348
+ }
349
+ if (a.status === 401) {
350
+ return { unknown: `${host} refused ${repo} to ${auth?.source} — no access, a changed password, a revoked token?` };
351
+ }
352
+ return { unknown: `${host} answered ${a.status} for ${repo}${message ? `: ${message}` : ''}` };
353
+ }
354
+
355
+ /**
356
+ * Bitbucket Server: the default branch takes changes only after a build when nothing
357
+ * reaches it but a pull request — a `pull-request-only` or `read-only` restriction on it
358
+ * exempts nobody; an exempt user pushes past the build — and a pull request needs a
359
+ * successful build (the merge check «Minimum successful builds»). Shapes of git.finam.ru,
360
+ * 5.16.1 (DL #17). The first refusal ends the questions: every login refused brings the
361
+ * owner's account nearer a CAPTCHA.
362
+ */
363
+ function judgeBitbucket(io: HostingIo, host: string, path: string, auth: BitbucketAuth | null): Verdict {
364
+ const [project, slug, ...rest] = path.replace(/^scm\//, '').split('/');
365
+ if (!project || !slug || rest.length > 0) return { unknown: `${host}: no project and repository in ${path}` };
366
+ const repo = `${project}/${slug}`;
367
+ const repoPath = `projects/${encodeURIComponent(project)}/repos/${encodeURIComponent(slug)}`;
368
+ const api = `https://${host}/rest/api/1.0/${repoPath}`;
369
+ const ask = (url: string) => (auth ? io.get(url, auth.token, auth.scheme) : io.get(url, null));
370
+
371
+ let branch = io.git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])?.replace(/^origin\//, '');
372
+ if (!branch) {
373
+ const d = ask(`${api}/branches/default`);
374
+ const refused = bitbucketRefusal(d, host, repo, auth);
375
+ if (refused) return refused;
376
+ branch = str(at(d.body, 'displayId'));
377
+ if (!branch) return { unknown: `${host} names no default branch for ${repo}` };
378
+ }
379
+
380
+ // Whoever reads the repository sees its merge checks; only an admin sees its restrictions.
381
+ const hooks = ask(`${api}/settings/hooks?type=PRE_PULL_REQUEST_MERGE&limit=100`);
382
+ const refusedHooks = bitbucketRefusal(hooks, host, repo, auth);
383
+ if (refusedHooks) return refusedHooks;
384
+ const builds = list(at(hooks.body, 'values')).some(
385
+ (h) => at(h, 'details', 'key') === MIN_BUILDS && at(h, 'enabled') === true,
386
+ );
387
+ const noBuilds = `pull requests into ${branch} need no successful build`;
388
+
389
+ const r = ask(`https://${host}/rest/branch-permissions/2.0/${repoPath}/restrictions?limit=100`);
390
+ if (r.status !== 200) {
391
+ // Without the build check the branch takes unbuilt changes, whoever may push to it.
392
+ if (!builds) return { no: noBuilds };
393
+ if (r.status === 401 && auth) {
394
+ return { unknown: `${host} shows who may change ${branch} of ${repo} only to a repository admin` };
395
+ }
396
+ return bitbucketRefusal(r, host, repo, auth) ?? { unknown: `${host} answered ${r.status} for ${repo}` };
397
+ }
398
+ if (at(r.body, 'isLastPage') === false) return { unknown: `${repo} has more than 100 branch restrictions` };
399
+
400
+ let guarded = false;
401
+ let byPattern = false;
402
+ const exempt = new Set<string>();
403
+ for (const x of list(at(r.body, 'values'))) {
404
+ if (!['read-only', 'pull-request-only'].includes(str(at(x, 'type')) ?? '')) continue;
405
+ if (at(x, 'matcher', 'type', 'id') !== 'BRANCH') {
406
+ byPattern = true;
407
+ continue;
408
+ }
409
+ if (at(x, 'matcher', 'id') !== `refs/heads/${branch}`) continue;
410
+ const who = [
411
+ ...list(at(x, 'users')).map((u) => str(at(u, 'name')) ?? 'a user'),
412
+ ...list(at(x, 'groups')).map((g) => `group ${str(g) ?? '?'}`),
413
+ ...list(at(x, 'accessKeys')).map((k) => `access key ${str(at(k, 'key', 'label')) ?? '?'}`),
414
+ ];
415
+ if (who.length === 0) guarded = true;
416
+ for (const w of who) exempt.add(w);
417
+ }
418
+
419
+ // A pattern or the branching model may cover the branch: vdx does not read them (DL #17).
420
+ if (!guarded && byPattern && builds) {
421
+ return {
422
+ unknown: `a branch restriction by pattern or branching model on ${repo} may cover ${branch} — vdx reads those that name the branch`,
423
+ };
424
+ }
425
+ const why: string[] = [];
426
+ if (!guarded && !byPattern) {
427
+ why.push(
428
+ exempt.size > 0
429
+ ? `${branch} takes changes without a pull request from ${[...exempt].join(', ')} — they skip the build`
430
+ : `${branch} is not restricted — whoever may write pushes to it`,
431
+ );
432
+ }
433
+ if (!builds) why.push(noBuilds);
434
+ return why.length > 0 ? { no: why.join('; ') } : true;
435
+ }
436
+
265
437
  /** A remote's repository on GitHub and the token to ask with; unknown, with the reason, when vdx cannot ask. */
266
438
  export function githubRepo(io: HostingIo, remote: string): { repo: string; token: string | null } | Unknown {
267
439
  if (io.env.VDX_OFFLINE) return { unknown: 'VDX_OFFLINE is set — the hosting was not asked' };
package/src/index.ts CHANGED
@@ -100,8 +100,9 @@ the project's files). Hand a person \`vdx ai\`, not \`<agent> --<flag>\`.
100
100
  restarts it, q leaves it
101
101
  --detach start without attaching; print the command that attaches
102
102
  --dry-run print the plan and the project's running agents; start nothing
103
- --check for the agent's own session: how the agent here is launched, and
104
- whether this session carries the profile's flags (the vdx plugin's
103
+ --check for the agent's own session: how the agent here is launched,
104
+ whether this session carries the profile's flags, and whether it
105
+ runs the installed Claude Code and plugins (the vdx plugin's
105
106
  SessionStart hook); prints nothing without a profile; exit 3 on drift
106
107
  -h, --help this help
107
108
 
@@ -131,7 +132,9 @@ For branch-protection it asks the hosting of origin whether its default branch
131
132
  takes changes only after a check required of everyone: GitHub, with a token from
132
133
  $GH_TOKEN, $GITHUB_TOKEN or gh when there is one; GitLab, with glab's token for that host,
133
134
  or $GITLAB_TOKEN when the host is $GITLAB_HOST (gitlab.com unless set) — no
134
- other host gets it. Another hosting is unknown; VDX_OFFLINE=1 does not ask, and the axis says
135
+ other host gets it; Bitbucket Server, with the login git keeps for that host
136
+ (git credential), or $BITBUCKET_TOKEN when the host is $BITBUCKET_HOST.
137
+ Another hosting is unknown; VDX_OFFLINE=1 does not ask, and the axis says
135
138
  unknown.
136
139
  `,
137
140
  bools: ['json'],
@@ -0,0 +1,149 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { claudeConfigDir, projectDirName } from './conversations.ts';
4
+ import { at, dict, list, str } from './narrow.ts';
5
+
6
+ /**
7
+ * What a running Claude Code session lags behind: a live session keeps the
8
+ * Claude Code and the plugins it started with, and takes new ones only when it
9
+ * starts again or by `/reload-plugins` (docs/tasks/vdx-ai, Sidetrack #24).
10
+ * Read from Claude Code's own files — its session file, `installed_plugins.json`,
11
+ * the settings that enable a plugin, the transcript — which are its internals,
12
+ * not an API.
13
+ */
14
+ export interface Lag {
15
+ /** Claude Code: the version the session runs and the one installed now; null when they agree. */
16
+ claude: { runs: string; installed: string } | null;
17
+ /** Enabled plugins updated after the session loaded its plugins: their name and the installed version. */
18
+ plugins: Array<{ name: string; version: string }>;
19
+ }
20
+
21
+ export interface LagInput {
22
+ env: NodeJS.ProcessEnv;
23
+ home: string;
24
+ /** The session's Claude Code process. */
25
+ pid: number;
26
+ projectRoot: string;
27
+ /** `claude --version` of the Claude Code a new start would run; null when it does not answer. */
28
+ installedClaude: () => string | null;
29
+ /** Default `Date.now()`. */
30
+ now?: number;
31
+ }
32
+
33
+ /**
34
+ * How long what a session loaded may stand before newer counts as a lag. On
35
+ * 2026-10-21 all 14 live sessions on lft were behind, an echelon release minutes
36
+ * old among them; the harm is a session that runs old code for days (twelve, in
37
+ * ai-dev-plugins' count). A day lets an update arrive without every session's
38
+ * next compaction calling for a reload.
39
+ */
40
+ const LAG_AFTER_MS = 24 * 60 * 60 * 1000;
41
+
42
+ /** What the session lags behind; null when nothing — or when its session file is not here to say. */
43
+ export function sessionLag(input: LagInput): Lag | null {
44
+ const now = input.now ?? Date.now();
45
+ const config = claudeConfigDir(input.env, input.home);
46
+ const session = readJson(path.join(config, 'sessions', `${input.pid}.json`));
47
+ const startedAt = at(session, 'startedAt');
48
+ if (typeof startedAt !== 'number' || now - startedAt < LAG_AFTER_MS) return null;
49
+
50
+ const runs = str(at(session, 'version'));
51
+ const installed = versionOf(input.installedClaude());
52
+ const claude = runs && installed && newer(installed, runs) ? { runs, installed } : null;
53
+
54
+ let plugins = updatedPlugins(config, input.projectRoot).filter((p) => p.at > startedAt);
55
+ if (plugins.length > 0) {
56
+ // A /reload-plugins loaded what was installed then: the transcript keeps when (its "Reloaded: …" line).
57
+ const sessionId = str(at(session, 'sessionId'));
58
+ const reloaded = sessionId ? lastReload(config, input.projectRoot, sessionId) : null;
59
+ if (reloaded !== null) plugins = now - reloaded < LAG_AFTER_MS ? [] : plugins.filter((p) => p.at > reloaded);
60
+ }
61
+ if (!claude && plugins.length === 0) return null;
62
+ return { claude, plugins: plugins.map(({ name, version }) => ({ name, version })) };
63
+ }
64
+
65
+ /** Enabled plugins and when each was last updated (ms) — the user's and this project's. */
66
+ function updatedPlugins(config: string, projectRoot: string): Array<{ name: string; version: string; at: number }> {
67
+ const records = dict(at(readJson(path.join(config, 'plugins', 'installed_plugins.json')), 'plugins')) ?? {};
68
+ // The nearest settings that name a plugin decide: the project's local, the project's, the user's.
69
+ const layers = [
70
+ path.join(projectRoot, '.claude', 'settings.local.json'),
71
+ path.join(projectRoot, '.claude', 'settings.json'),
72
+ path.join(config, 'settings.json'),
73
+ ].map((f) => dict(at(readJson(f), 'enabledPlugins')) ?? {});
74
+ const out: Array<{ name: string; version: string; at: number }> = [];
75
+ for (const [key, entries] of Object.entries(records)) {
76
+ if (layers.map((l) => l[key]).find((v) => typeof v === 'boolean') !== true) continue;
77
+ const entry = list(entries).find((e) => at(e, 'scope') === 'user' || at(e, 'projectPath') === projectRoot);
78
+ const when = Date.parse(str(at(entry, 'lastUpdated')) ?? '');
79
+ const version = str(at(entry, 'version'));
80
+ if (Number.isNaN(when) || !version) continue;
81
+ out.push({ name: key.split('@')[0] ?? key, version, at: when });
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /** When the session last ran /reload-plugins (ms), from its transcript; null — never, or no transcript here. */
87
+ function lastReload(config: string, projectRoot: string, sessionId: string): number | null {
88
+ const projects = path.join(config, 'projects');
89
+ let file = path.join(projects, projectDirName(projectRoot), `${sessionId}.jsonl`);
90
+ if (!fs.existsSync(file)) {
91
+ // The transcript sits under the directory the session started in, which may be another.
92
+ const dir = safeReaddir(projects).find((d) => fs.existsSync(path.join(projects, d, `${sessionId}.jsonl`)));
93
+ if (dir === undefined) return null;
94
+ file = path.join(projects, dir, `${sessionId}.jsonl`);
95
+ }
96
+ let text: string;
97
+ try {
98
+ text = fs.readFileSync(file, 'utf8');
99
+ } catch {
100
+ return null;
101
+ }
102
+ let last: number | null = null;
103
+ for (const line of text.split('\n')) {
104
+ if (!line.includes('"local_command"') || !line.includes('Reloaded:')) continue;
105
+ let entry: unknown;
106
+ try {
107
+ entry = JSON.parse(line);
108
+ } catch {
109
+ continue;
110
+ }
111
+ if (at(entry, 'type') !== 'system' || at(entry, 'subtype') !== 'local_command') continue;
112
+ if (!/<local-command-stdout>Reloaded:/.test(str(at(entry, 'content')) ?? '')) continue;
113
+ const when = Date.parse(str(at(entry, 'timestamp')) ?? '');
114
+ if (!Number.isNaN(when) && (last === null || when > last)) last = when;
115
+ }
116
+ return last;
117
+ }
118
+
119
+ /** `2.1.304 (Claude Code)` → `2.1.304`. */
120
+ function versionOf(text: string | null): string | null {
121
+ return text?.match(/\d+\.\d+\.\d+/)?.[0] ?? null;
122
+ }
123
+
124
+ /** Whether version `a` is newer than `b`, part by part. */
125
+ function newer(a: string, b: string): boolean {
126
+ const pa = a.split('.').map(Number);
127
+ const pb = b.split('.').map(Number);
128
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
129
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
130
+ if (d !== 0) return d > 0;
131
+ }
132
+ return false;
133
+ }
134
+
135
+ function readJson(file: string): unknown {
136
+ try {
137
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ function safeReaddir(dir: string): string[] {
144
+ try {
145
+ return fs.readdirSync(dir);
146
+ } catch {
147
+ return [];
148
+ }
149
+ }