@vodmal/vdx-cli 0.23.0 → 0.25.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/src/author.ts CHANGED
@@ -61,8 +61,24 @@ export function remoteParts(url: string): { host: string; group: string; path: s
61
61
 
62
62
  /** Words that say nothing about whose repo it is. */
63
63
  const GENERIC = new Set([
64
- 'git', 'com', 'org', 'name', 'www', 'ssh', 'http', 'https', 'the', 'app', 'mcp',
65
- 'github', 'gitlab', 'dev', 'net', 'vorobyev', 'vodmal', 'dmitry',
64
+ 'git',
65
+ 'com',
66
+ 'org',
67
+ 'name',
68
+ 'www',
69
+ 'ssh',
70
+ 'http',
71
+ 'https',
72
+ 'the',
73
+ 'app',
74
+ 'mcp',
75
+ 'github',
76
+ 'gitlab',
77
+ 'dev',
78
+ 'net',
79
+ 'vorobyev',
80
+ 'vodmal',
81
+ 'dmitry',
66
82
  ]);
67
83
 
68
84
  export function nameTokens(dir: string, remotePath: string): Set<string> {
@@ -77,7 +93,11 @@ export function nameTokens(dir: string, remotePath: string): Set<string> {
77
93
  function describe(dir: string): PoolRepo {
78
94
  const cfg = git(dir, ['config', '--local', '--get-regexp', '^(user\\.(name|email)|remote\\.origin\\.url)$']) ?? '';
79
95
  const val = (key: string) =>
80
- cfg.split('\n').find((l) => l.toLowerCase().startsWith(`${key} `))?.slice(key.length + 1).trim() ?? '';
96
+ cfg
97
+ .split('\n')
98
+ .find((l) => l.toLowerCase().startsWith(`${key} `))
99
+ ?.slice(key.length + 1)
100
+ .trim() ?? '';
81
101
  const email = val('user.email');
82
102
  const remote = remoteParts(val('remote.origin.url'));
83
103
  return {
@@ -177,11 +197,10 @@ export function rankAuthors(dir: string, pool: PoolRepo[]): AuthorCandidate[] {
177
197
  }
178
198
 
179
199
  const fallbackName = [...names.values()].flatMap((m) => [...m.entries()]).sort((a, b) => b[1] - a[1])[0]?.[0] ?? '';
180
- return ranked
181
- .map(([email, s]) => {
182
- const byName = [...(names.get(email)?.entries() ?? [])].sort((a, b) => b[1] - a[1]);
183
- return { email, name: byName[0]?.[0] ?? fallbackName, score: s, why: why.get(email) ?? [] };
184
- });
200
+ return ranked.map(([email, s]) => {
201
+ const byName = [...(names.get(email)?.entries() ?? [])].sort((a, b) => b[1] - a[1]);
202
+ return { email, name: byName[0]?.[0] ?? fallbackName, score: s, why: why.get(email) ?? [] };
203
+ });
185
204
  }
186
205
 
187
206
  export function writeAuthor(dir: string, author: Author): boolean {
@@ -95,12 +95,14 @@ export function checkRepoHooks(root: string | null): CheckResult | null {
95
95
  remedy: `chmod +x ${hooksPath}/*`,
96
96
  };
97
97
  }
98
- return bodyProblems(id, label, [...hooks.values()]) ?? {
99
- id,
100
- label,
101
- status: 'ok',
102
- message: `${hooks.size} hook(s) active via ${hooksPath}`,
103
- };
98
+ return (
99
+ bodyProblems(id, label, [...hooks.values()]) ?? {
100
+ id,
101
+ label,
102
+ status: 'ok',
103
+ message: `${hooks.size} hook(s) active via ${hooksPath}`,
104
+ }
105
+ );
104
106
  }
105
107
 
106
108
  const states = cloneHookStates(root, ctx, frameworks);
@@ -110,7 +112,9 @@ export function checkRepoHooks(root: string | null): CheckResult | null {
110
112
  id,
111
113
  label,
112
114
  status: 'warning',
113
- message: off.map((s) => `${s.framework.id} (${s.framework.declaredIn}): ${s.problem} — those hooks never run`).join('; '),
115
+ message: off
116
+ .map((s) => `${s.framework.id} (${s.framework.declaredIn}): ${s.problem} — those hooks never run`)
117
+ .join('; '),
114
118
  ...(off[0]!.remedy ? { remedy: off[0]!.remedy } : {}),
115
119
  };
116
120
  }
@@ -149,7 +153,8 @@ function bodyProblems(id: string, label: string, bodies: string[]): CheckResult
149
153
  label,
150
154
  status: 'warning',
151
155
  message: `hooks call ${missing.join(', ')} — not on this machine, the commit fails or the gate is skipped`,
152
- remedy: 'install what the hook calls here, or move the hook out of the repository (a personal hook in git config)',
156
+ remedy:
157
+ 'install what the hook calls here, or move the hook out of the repository (a personal hook in git config)',
153
158
  };
154
159
  }
155
160
  return null;
@@ -179,7 +179,10 @@ export function machineOf(c: Conversation, host: string): string | null {
179
179
  }
180
180
 
181
181
  /** The newest conversation of each machine (unknown counts as one), newest first. */
182
- export function latestPerMachine(conversations: Conversation[], machine: (c: Conversation) => string | null): Conversation[] {
182
+ export function latestPerMachine(
183
+ conversations: Conversation[],
184
+ machine: (c: Conversation) => string | null,
185
+ ): Conversation[] {
183
186
  const out: Conversation[] = [];
184
187
  for (const c of [...conversations].sort((a, b) => b.updated.getTime() - a.updated.getTime())) {
185
188
  if (!out.some((o) => sameMachine(machine(o), machine(c)))) out.push(c);
@@ -187,7 +190,6 @@ export function latestPerMachine(conversations: Conversation[], machine: (c: Con
187
190
  return out;
188
191
  }
189
192
 
190
-
191
193
  /**
192
194
  * The conversation a running Claude Code process is in, from its session file
193
195
  * `<config>/sessions/<pid>.json` — it follows /clear. That folder is per
@@ -203,6 +205,18 @@ export function liveConversationId(env: NodeJS.ProcessEnv, home: string, pid: nu
203
205
  }
204
206
  }
205
207
 
208
+ /** When a running Claude Code process started (ms), from the same session file; null — not known. */
209
+ export function liveStartedAt(env: NodeJS.ProcessEnv, home: string, pid: number): number | null {
210
+ try {
211
+ const file = path.join(claudeConfigDir(env, home), 'sessions', `${pid}.json`);
212
+ const data: unknown = JSON.parse(fs.readFileSync(file, 'utf8'));
213
+ const at = typeof data === 'object' && data !== null ? (data as { startedAt?: unknown }).startedAt : undefined;
214
+ return typeof at === 'number' ? at : null;
215
+ } catch {
216
+ return null;
217
+ }
218
+ }
219
+
206
220
  /** A Claude Code process running right now, and the conversation it is in. */
207
221
  export interface LiveSession {
208
222
  pid: number;
@@ -226,7 +240,8 @@ export function parseLiveSessions(text: string): LiveSession[] {
226
240
  } catch {
227
241
  continue;
228
242
  }
229
- if (!Number.isInteger(data?.pid) || typeof data?.sessionId !== 'string' || !isConversationId(data.sessionId)) continue;
243
+ if (!Number.isInteger(data?.pid) || typeof data?.sessionId !== 'string' || !isConversationId(data.sessionId))
244
+ continue;
230
245
  out.push({
231
246
  pid: data.pid,
232
247
  conversation: data.sessionId,
@@ -282,7 +297,11 @@ export const LIVE_SESSIONS_SCRIPT =
282
297
  'kill -0 "$p" 2>/dev/null && tr -d "\\n" < "$f" && echo; done; exit 0';
283
298
 
284
299
  /** Claude Code processes alive on this machine. */
285
- export function liveSessionsHere(env: NodeJS.ProcessEnv, home: string, alive: (pid: number) => boolean = pidAlive): LiveSession[] {
300
+ export function liveSessionsHere(
301
+ env: NodeJS.ProcessEnv,
302
+ home: string,
303
+ alive: (pid: number) => boolean = pidAlive,
304
+ ): LiveSession[] {
286
305
  const dir = path.join(claudeConfigDir(env, home), 'sessions');
287
306
  let names: string[];
288
307
  try {
package/src/doctor.ts CHANGED
@@ -6,7 +6,12 @@ import { execFileSync } from 'node:child_process';
6
6
  import { effectiveAuthor, isGitRepo, rankAuthors, scanPool } from './author.ts';
7
7
  import { authorPoolDirs, loadEnvironment, resolveEnvironmentPath, shellQuote, type Environment } from './ai.ts';
8
8
  import { checkRepoHooks } from './clone-checks.ts';
9
- import { checkPersonalHooks, defaultPersonalHookDeps, fixPersonalHooks, type PersonalHookDeps } from './personal-hooks.ts';
9
+ import {
10
+ checkPersonalHooks,
11
+ defaultPersonalHookDeps,
12
+ fixPersonalHooks,
13
+ type PersonalHookDeps,
14
+ } from './personal-hooks.ts';
10
15
 
11
16
  export type CheckStatus = 'ok' | 'warning' | 'missing';
12
17
 
@@ -39,11 +44,7 @@ export interface DoctorReport {
39
44
 
40
45
  export function readCliVersion(): string {
41
46
  try {
42
- const pkgPath = path.resolve(
43
- path.dirname(fileURLToPath(import.meta.url)),
44
- '..',
45
- 'package.json',
46
- );
47
+ const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
47
48
  const raw = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version?: string };
48
49
  return raw.version ?? 'unknown';
49
50
  } catch {
@@ -315,8 +316,7 @@ function checkVdx(): CheckResult {
315
316
  status: 'ok',
316
317
  level,
317
318
  message: `${tag} — works inside this project only (${found})`,
318
- remedy:
319
- 'npm i -g @vodmal/vdx-cli OR add `alias vdx="npx -y -p @vodmal/vdx-cli vdx"` to your shell rc',
319
+ remedy: 'npm i -g @vodmal/vdx-cli OR add `alias vdx="npx -y -p @vodmal/vdx-cli vdx"` to your shell rc',
320
320
  };
321
321
  }
322
322
 
@@ -458,9 +458,7 @@ export function runDoctorCheck(
458
458
  );
459
459
  return rows
460
460
  .map(
461
- (c) =>
462
- `vdx doctor: ${c.label} — ${c.message}.` +
463
- (c.remedy ? ` Fix, on the user's word: \`${c.remedy}\`.` : ''),
461
+ (c) => `vdx doctor: ${c.label} — ${c.message}.` + (c.remedy ? ` Fix, on the user's word: \`${c.remedy}\`.` : ''),
464
462
  )
465
463
  .join('\n');
466
464
  }
package/src/evaluator.ts CHANGED
@@ -108,7 +108,7 @@ export function predicateNames(p: Predicate): string[] {
108
108
  const obj = q as Record<string, any>;
109
109
  if ('all_of' in obj) return (obj.all_of as Predicate[]).forEach(walk);
110
110
  if ('any_of' in obj) return (obj.any_of as Predicate[]).forEach(walk);
111
- if ('at_least_n_of' in obj) return (obj.at_least_n_of?.predicates as Predicate[] ?? []).forEach(walk);
111
+ if ('at_least_n_of' in obj) return ((obj.at_least_n_of?.predicates as Predicate[]) ?? []).forEach(walk);
112
112
  if ('not' in obj) return walk(obj.not);
113
113
  if ('fn' in obj) {
114
114
  out.push(String(obj.fn));
package/src/facts.ts CHANGED
@@ -7,16 +7,41 @@ export interface Ctx {
7
7
  projectRoot: string;
8
8
  stack: string;
9
9
  cache: Map<string, unknown>;
10
+ /**
11
+ * Meta repo: the other half, where a file missing here is looked for — the repo root
12
+ * for an axis evaluated in the primary subpackage (`.github/` lives there), the
13
+ * subpackage for an axis evaluated in a root without a stack manifest of its own.
14
+ */
15
+ fallbackRoot?: string;
16
+ }
17
+
18
+ /** Absolute path of `rel`: here, else in `fallbackRoot` when it is there. */
19
+ function locate(ctx: Ctx, rel: string): string {
20
+ const here = path.join(ctx.projectRoot, rel);
21
+ if (ctx.fallbackRoot === undefined || fs.existsSync(here)) return here;
22
+ const there = path.join(ctx.fallbackRoot, rel);
23
+ return fs.existsSync(there) ? there : here;
24
+ }
25
+
26
+ /** The same directory without the fallback: a project's tasks are the ones its own runner sees. */
27
+ function ownCtx(ctx: Ctx): Ctx {
28
+ if (ctx.fallbackRoot === undefined) return ctx;
29
+ let own = ctx.cache.get('ctx:own') as Ctx | undefined;
30
+ if (!own) {
31
+ own = { projectRoot: ctx.projectRoot, stack: ctx.stack, cache: new Map() };
32
+ ctx.cache.set('ctx:own', own);
33
+ }
34
+ return own;
10
35
  }
11
36
 
12
37
  export function fileExists(ctx: Ctx, rel: string): boolean {
13
- return fs.existsSync(path.join(ctx.projectRoot, rel));
38
+ return fs.existsSync(locate(ctx, rel));
14
39
  }
15
40
 
16
41
  export function readText(ctx: Ctx, rel: string): string | null {
17
42
  const key = `file:${rel}`;
18
43
  if (ctx.cache.has(key)) return ctx.cache.get(key) as string | null;
19
- const abs = path.join(ctx.projectRoot, rel);
44
+ const abs = locate(ctx, rel);
20
45
  try {
21
46
  const t = fs.readFileSync(abs, 'utf8');
22
47
  ctx.cache.set(key, t);
@@ -107,34 +132,38 @@ export function resolveConfigPath(root: unknown, jsonpath: string): unknown {
107
132
  return cur;
108
133
  }
109
134
 
110
- /** Aggregated task names from mise.toml, composer scripts, npm scripts, Makefile. */
135
+ /**
136
+ * Aggregated task names from mise.toml, composer scripts, npm scripts, Makefile.
137
+ * Without the fallback: `vdx test` here runs this directory's tasks, not the other half's.
138
+ */
111
139
  export function listAllTasks(ctx: Ctx): Set<string> {
112
140
  const key = 'tasks:all';
113
141
  if (ctx.cache.has(key)) return ctx.cache.get(key) as Set<string>;
114
142
  const tasks = new Set<string>();
143
+ const own = ownCtx(ctx);
115
144
 
116
- const miseT = readToml(ctx, 'mise.toml');
145
+ const miseT = readToml(own, 'mise.toml');
117
146
  if (miseT?.tasks && typeof miseT.tasks === 'object') {
118
147
  for (const k of Object.keys(miseT.tasks as object)) tasks.add(k);
119
148
  }
120
149
 
121
- const composer = readJson(ctx, 'composer.json');
150
+ const composer = readJson(own, 'composer.json');
122
151
  if (composer?.scripts && typeof composer.scripts === 'object') {
123
152
  for (const k of Object.keys(composer.scripts)) tasks.add(k);
124
153
  }
125
154
 
126
- const pkg = readJson(ctx, 'package.json');
155
+ const pkg = readJson(own, 'package.json');
127
156
  if (pkg?.scripts && typeof pkg.scripts === 'object') {
128
157
  for (const k of Object.keys(pkg.scripts)) tasks.add(k);
129
158
  }
130
159
 
131
160
  // monorepo-ish fallback: server/package.json (см. bookmap)
132
- const serverPkg = readJson(ctx, 'server/package.json');
161
+ const serverPkg = readJson(own, 'server/package.json');
133
162
  if (serverPkg?.scripts && typeof serverPkg.scripts === 'object') {
134
163
  for (const k of Object.keys(serverPkg.scripts)) tasks.add(`server:${k}`);
135
164
  }
136
165
 
137
- const make = readText(ctx, 'Makefile');
166
+ const make = readText(own, 'Makefile');
138
167
  if (make) {
139
168
  const lines = make.split('\n');
140
169
  for (const line of lines) {
@@ -176,7 +205,7 @@ export function expandPaths(paths: string[], ctx: Ctx): string[] {
176
205
  if (m && m[1] && m[2]) {
177
206
  const dir = m[1];
178
207
  const ext = m[2];
179
- const abs = path.join(ctx.projectRoot, dir);
208
+ const abs = locate(ctx, dir);
180
209
  try {
181
210
  const files = fs.readdirSync(abs);
182
211
  for (const f of files) {
@@ -224,8 +253,19 @@ export function stackForDir(dir: string): string | null {
224
253
  }
225
254
 
226
255
  const SUBPACKAGE_SCAN_IGNORE = new Set([
227
- 'node_modules', 'vendor', 'dist', 'build', 'out', 'target',
228
- '.git', '.next', '__pycache__', '.venv', 'venv', '.cache', 'coverage',
256
+ 'node_modules',
257
+ 'vendor',
258
+ 'dist',
259
+ 'build',
260
+ 'out',
261
+ 'target',
262
+ '.git',
263
+ '.next',
264
+ '__pycache__',
265
+ '.venv',
266
+ 'venv',
267
+ '.cache',
268
+ 'coverage',
229
269
  ]);
230
270
 
231
271
  export interface SubPackage {
package/src/gha.ts CHANGED
@@ -120,7 +120,10 @@ export function readWorkflows(ctx: Ctx): GhaWorkflow[] {
120
120
  const out: GhaWorkflow[] = [];
121
121
  let files: string[] = [];
122
122
  try {
123
- files = fs.readdirSync(dir).filter((f) => /\.ya?ml$/.test(f)).sort();
123
+ files = fs
124
+ .readdirSync(dir)
125
+ .filter((f) => /\.ya?ml$/.test(f))
126
+ .sort();
124
127
  } catch {
125
128
  files = [];
126
129
  }
package/src/hooks.ts CHANGED
@@ -36,11 +36,29 @@ export interface HookFramework {
36
36
  }
37
37
 
38
38
  export const GIT_HOOK_EVENTS = new Set([
39
- 'applypatch-msg', 'pre-applypatch', 'post-applypatch', 'pre-commit', 'pre-merge-commit',
40
- 'prepare-commit-msg', 'commit-msg', 'post-commit', 'pre-rebase', 'post-checkout', 'post-merge',
41
- 'pre-push', 'pre-auto-gc', 'post-rewrite', 'reference-transaction', 'push-to-checkout',
42
- 'post-index-change', 'sendemail-validate', 'fsmonitor-watchman', 'p4-changelist',
43
- 'p4-prepare-changelist', 'p4-post-changelist', 'p4-pre-submit',
39
+ 'applypatch-msg',
40
+ 'pre-applypatch',
41
+ 'post-applypatch',
42
+ 'pre-commit',
43
+ 'pre-merge-commit',
44
+ 'prepare-commit-msg',
45
+ 'commit-msg',
46
+ 'post-commit',
47
+ 'pre-rebase',
48
+ 'post-checkout',
49
+ 'post-merge',
50
+ 'pre-push',
51
+ 'pre-auto-gc',
52
+ 'post-rewrite',
53
+ 'reference-transaction',
54
+ 'push-to-checkout',
55
+ 'post-index-change',
56
+ 'sendemail-validate',
57
+ 'fsmonitor-watchman',
58
+ 'p4-changelist',
59
+ 'p4-prepare-changelist',
60
+ 'p4-post-changelist',
61
+ 'p4-pre-submit',
44
62
  ]);
45
63
 
46
64
  /** Install steps a clone runs anyway: npm-family lifecycle scripts, composer's, the vdx `up` task. */
@@ -173,7 +191,10 @@ export function detectHookFrameworks(ctx: Ctx): HookFramework[] {
173
191
  id: 'husky',
174
192
  declaredIn: '.husky/',
175
193
  hooks: husky,
176
- installedBy: findStep(steps.filter((s) => s.where.startsWith('package.json') || s.where === 'mise task up'), /(^|[\s;&|(])husky(\s+install)?(?=$|[\s;&|)])/m),
194
+ installedBy: findStep(
195
+ steps.filter((s) => s.where.startsWith('package.json') || s.where === 'mise task up'),
196
+ /(^|[\s;&|(])husky(\s+install)?(?=$|[\s;&|)])/m,
197
+ ),
177
198
  manualInstall: 'npx husky',
178
199
  hooksPath: '.husky/_',
179
200
  copies: false,
@@ -247,7 +268,10 @@ export function detectHookFrameworks(ctx: Ctx): HookFramework[] {
247
268
  id: 'cghooks',
248
269
  declaredIn: 'composer.json extra.hooks',
249
270
  hooks,
250
- installedBy: findStep(steps.filter((s) => s.where.startsWith('composer') || s.where === 'mise task up'), /\bcghooks\s+(add|update)\b/),
271
+ installedBy: findStep(
272
+ steps.filter((s) => s.where.startsWith('composer') || s.where === 'mise task up'),
273
+ /\bcghooks\s+(add|update)\b/,
274
+ ),
251
275
  manualInstall: 'vendor/bin/cghooks update',
252
276
  hooksPath: null,
253
277
  copies: true,
@@ -353,7 +377,13 @@ export function cloneHookStates(root: string, ctx: Ctx, frameworks: HookFramewor
353
377
  }
354
378
  if (fw.id === 'lefthook') {
355
379
  const foreign = events.filter((e) => !/lefthook/.test(readFile(path.join(dir, e))));
356
- if (foreign.length > 0) return { framework: fw, enabled: false, problem: `${foreign.join(', ')} in ${path.relative(root, dir)} is not lefthook's`, remedy };
380
+ if (foreign.length > 0)
381
+ return {
382
+ framework: fw,
383
+ enabled: false,
384
+ problem: `${foreign.join(', ')} in ${path.relative(root, dir)} is not lefthook's`,
385
+ remedy,
386
+ };
357
387
  }
358
388
  if (fw.copies) {
359
389
  const stale = events.filter((e) => {
@@ -361,7 +391,12 @@ export function cloneHookStates(root: string, ctx: Ctx, frameworks: HookFramewor
361
391
  return fw.hooks.get(e)!.some((cmd) => !body.includes(cmd.trim()));
362
392
  });
363
393
  if (stale.length > 0) {
364
- return { framework: fw, enabled: false, problem: `installed ${stale.join(', ')} older than ${fw.declaredIn}`, remedy };
394
+ return {
395
+ framework: fw,
396
+ enabled: false,
397
+ problem: `installed ${stale.join(', ')} older than ${fw.declaredIn}`,
398
+ remedy,
399
+ };
365
400
  }
366
401
  }
367
402
  return { framework: fw, enabled: true };
@@ -386,8 +421,7 @@ function readFile(file: string): string {
386
421
  * anywhere in a script says nothing about whether the hook still does its job,
387
422
  * while an unset variable inside the guard means the guarded block never runs.
388
423
  */
389
- const GATING_VAR_RE =
390
- /\[\s+-[nxfse]\s+"\$\{([A-Z][A-Z0-9_]*)(?::-[^}]*)?\}"|\[\s+-[nxfse]\s+"\$([A-Z][A-Z0-9_]*)"/g;
424
+ const GATING_VAR_RE = /\[\s+-[nxfse]\s+"\$\{([A-Z][A-Z0-9_]*)(?::-[^}]*)?\}"|\[\s+-[nxfse]\s+"\$([A-Z][A-Z0-9_]*)"/g;
391
425
 
392
426
  export function unsetGatingVars(commands: string[], env: NodeJS.ProcessEnv = process.env): string[] {
393
427
  const out = new Set<string>();
@@ -406,7 +440,8 @@ export function unsetGatingVars(commands: string[], env: NodeJS.ProcessEnv = pro
406
440
  * Only what is run: a path in an argument or an `echo` says nothing about
407
441
  * whether the hook works here.
408
442
  */
409
- const MACHINE_PROGRAM_RE = /^(?:"((?:\$HOME|\$\{HOME\}|~|\/Users|\/home|\/Volumes)\/[^"]+)"|((?:\$HOME|\$\{HOME\}|~|\/Users|\/home|\/Volumes)\/[^\s"';|&)]+))/;
443
+ const MACHINE_PROGRAM_RE =
444
+ /^(?:"((?:\$HOME|\$\{HOME\}|~|\/Users|\/home|\/Volumes)\/[^"]+)"|((?:\$HOME|\$\{HOME\}|~|\/Users|\/home|\/Volumes)\/[^\s"';|&)]+))/;
410
445
 
411
446
  export function missingMachinePaths(commands: string[], home: string = os.homedir()): string[] {
412
447
  const out = new Set<string>();
package/src/index.ts CHANGED
@@ -4,12 +4,7 @@ import { execFileSync } from 'node:child_process';
4
4
  import { loadRubric } from './rubric.ts';
5
5
  import { loadManifest, loadOverrides } from './manifest.ts';
6
6
  import { autoDetectStack, type Ctx } from './facts.ts';
7
- import {
8
- LIFECYCLE_VERBS,
9
- type LifecycleVerb,
10
- resolveLifecycleVerb,
11
- renderResolveError,
12
- } from './run.ts';
7
+ import { LIFECYCLE_VERBS, type LifecycleVerb, resolveLifecycleVerb, renderResolveError } from './run.ts';
13
8
  import { audit } from './audit.ts';
14
9
  import {
15
10
  reportMarkdown,
@@ -19,7 +14,14 @@ import {
19
14
  reportDoctorJson,
20
15
  reportDoctorAnsi,
21
16
  } from './report.ts';
22
- import { runDoctor, runDoctorCheck, runDoctorFix, resolveDoctorCtx, looksLikeProject, readCliVersion } from './doctor.ts';
17
+ import {
18
+ runDoctor,
19
+ runDoctorCheck,
20
+ runDoctorFix,
21
+ resolveDoctorCtx,
22
+ looksLikeProject,
23
+ readCliVersion,
24
+ } from './doctor.ts';
23
25
  import { planInit, writeInit, renderPlanSummary } from './init.ts';
24
26
  import {
25
27
  planPublish,
@@ -66,6 +68,7 @@ interface CommandSpec {
66
68
  }
67
69
 
68
70
  const AI_HELP = `Starts your agent in the project, or attaches to the one already running there.
71
+ Several running there: a list to pick from — Enter takes the newest.
69
72
  Which agent, its flags and whether it runs in tmux come from your profile:
70
73
  $VDX_ENVIRONMENT, else ~/.vdx-environment.yaml, else a plain claude/codex.
71
74
 
@@ -75,15 +78,23 @@ the project's files). Hand a person \`vdx ai\`, not \`<agent> --<flag>\`.
75
78
 
76
79
  project_path the project (default: cwd); its git toplevel is used
77
80
  @host the same command on <host> over ssh: its vdx, profile and tmux
78
- --new a new conversation instead of continuing the last one
79
- --focused a session nothing wakes: the agent, its hooks and MCP servers get
80
- VDX_FOCUSED=1 — for the plugins that wake sessions to leave it
81
- alone — and the profile's rules marked \`wakes: true\` (a channel
82
- flag) are left out. A running agent keeps its mode; --restart
83
- applies this one
81
+ --new a new conversation instead of continuing the last one; an agent
82
+ already running here keeps running, the new one starts next to
83
+ it (a new tmux window)
84
+ --focused a new conversation, as --new, in a session nothing wakes: the
85
+ agent, its hooks and MCP servers get VDX_FOCUSED=1 — for the
86
+ plugins that wake sessions to leave it alone — and the profile's
87
+ rules marked \`wakes: true\` (a channel flag) are left out. Back
88
+ to it: --focused --conversation <id>. A running agent keeps its
89
+ mode: plain vdx ai attaches to it as it is
84
90
  --conversation <id>
85
- continue this Claude Code conversation
86
- --restart restart the running agent in its pane — applies a changed profile
91
+ continue this Claude Code conversation: attach to the agent in
92
+ it, or start one next to the agents running here
93
+ --restart restart the running agent in its pane — applies a changed profile;
94
+ with --new, --focused or --conversation, it is replaced by that.
95
+ Without it, an agent running without the profile's flags is
96
+ a question in a terminal: Enter attaches to it as is, r
97
+ restarts it, q leaves it
87
98
  --detach start without attaching; print the command that attaches
88
99
  --dry-run print the plan and the project's running agents; start nothing
89
100
  --check for the agent's own session: how the agent here is launched, and
@@ -102,8 +113,8 @@ machine's. A new conversation (--new, or none to continue) while an agent of
102
113
  the project runs on another machine starts only after a yes.
103
114
 
104
115
  Exit codes: 0 the agent runs per the profile; 2 profile or usage error;
105
- 3 the running agent lacks profile flags, or is not focused under --focused
106
- (rerun with --restart); 4 the start
116
+ 3 the running agent lacks profile flags, or is not focused under --focused,
117
+ and was left as is — no terminal, or q (rerun with --restart); 4 the start
107
118
  was not confirmed.
108
119
  Profile format: https://github.com/VoDmAl/vdx/blob/main/docs/specs/environment-format.md
109
120
  `;
@@ -134,7 +145,8 @@ mise.toml is overwritten only with --force.
134
145
  pre-flight checks — at a terminal an expired npm login is renewed (\`npm login\`)
135
146
  and the checks run again;
136
147
  the version bump in package.json and package-lock.json;
137
- npm publish (npm asks for an OTP);
148
+ npm publish (npm asks for an OTP or a confirmation in the browser; Ctrl-C
149
+ there puts the version back);
138
150
  git commit and tag, then \`git push --follow-tags\` to the branch's upstream;
139
151
  a wait until the registry shows the new version (up to 3 min).
140
152
  --dry-run runs the pre-flight and prints the plan; --force goes on past a failed
@@ -247,7 +259,9 @@ function checkArgs(name: string, opts: ParsedArgs): ParsedArgs {
247
259
  process.exit(2);
248
260
  };
249
261
  const unknown = [
250
- ...Object.keys(flags).filter((f) => !bools.includes(f) && !values.includes(f)).map((f) => `--${f}`),
262
+ ...Object.keys(flags)
263
+ .filter((f) => !bools.includes(f) && !values.includes(f))
264
+ .map((f) => `--${f}`),
251
265
  ...positionals.filter((p) => p.startsWith('-')),
252
266
  ];
253
267
  if (unknown.length > 0) fail(`unknown option ${unknown.join(', ')}`);
@@ -277,8 +291,7 @@ function cmdAudit(opts: ParsedArgs): void {
277
291
  const rubricPath = (opts.flags.rubric as string) || DEFAULT_RUBRIC;
278
292
  const rubric = loadRubric(rubricPath);
279
293
 
280
- const stack =
281
- (opts.flags.stack as string) || manifest?.stack || autoDetectStack(projectRoot);
294
+ const stack = (opts.flags.stack as string) || manifest?.stack || autoDetectStack(projectRoot);
282
295
 
283
296
  const ctx: Ctx = {
284
297
  projectRoot,
@@ -289,8 +302,7 @@ function cmdAudit(opts: ParsedArgs): void {
289
302
  const baselineRef = manifest?.baseline ?? `file://${rubricPath}`;
290
303
  const result = audit(rubric, ctx, overrides, baselineRef, manifest);
291
304
 
292
- const formatFlag =
293
- typeof opts.flags.format === 'string' ? opts.flags.format : undefined;
305
+ const formatFlag = typeof opts.flags.format === 'string' ? opts.flags.format : undefined;
294
306
  const wantJson = opts.flags.json === true || formatFlag === 'json';
295
307
  const wantMarkdown = formatFlag === 'markdown' || formatFlag === 'md';
296
308
  const wantAnsi = formatFlag === 'ansi';
@@ -343,9 +355,7 @@ function cmdInit(opts: ParsedArgs): void {
343
355
  function cmdPublish(opts: ParsedArgs): void {
344
356
  const bumpArg = opts.positionals[0];
345
357
  if (!bumpArg || !['patch', 'minor', 'major'].includes(bumpArg)) {
346
- process.stderr.write(
347
- 'Usage: vdx publish <patch|minor|major> [--dry-run] [--force] [--no-push]\n',
348
- );
358
+ process.stderr.write('Usage: vdx publish <patch|minor|major> [--dry-run] [--force] [--no-push]\n');
349
359
  process.exit(1);
350
360
  }
351
361
  const bump = bumpArg as BumpKind;
@@ -427,8 +437,7 @@ function cmdRun(verb: LifecycleVerb): void {
427
437
  } catch (e: any) {
428
438
  if (e?.code === 'ENOENT') {
429
439
  process.stderr.write(
430
- `vdx: \`mise\` binary not found on PATH\n` +
431
- `hint: install mise — https://mise.jdx.dev/getting-started.html\n`,
440
+ `vdx: \`mise\` binary not found on PATH\n` + `hint: install mise — https://mise.jdx.dev/getting-started.html\n`,
432
441
  );
433
442
  process.exit(127);
434
443
  }
@@ -450,7 +459,9 @@ function cmdDoctor(opts: ParsedArgs): void {
450
459
  if (opts.flags.fix === true) {
451
460
  try {
452
461
  const done = runDoctorFix();
453
- process.stderr.write(done.length > 0 ? done.map((d) => `✓ ${d}`).join('\n') + '\n' : 'nothing to fix in ~/.gitconfig\n');
462
+ process.stderr.write(
463
+ done.length > 0 ? done.map((d) => `✓ ${d}`).join('\n') + '\n' : 'nothing to fix in ~/.gitconfig\n',
464
+ );
454
465
  } catch (e: any) {
455
466
  process.stderr.write(`vdx doctor --fix: ${e?.message ?? String(e)}\n`);
456
467
  process.exit(2);
@@ -458,8 +469,7 @@ function cmdDoctor(opts: ParsedArgs): void {
458
469
  }
459
470
  const report = runDoctor(ctx);
460
471
 
461
- const formatFlag =
462
- typeof opts.flags.format === 'string' ? opts.flags.format : undefined;
472
+ const formatFlag = typeof opts.flags.format === 'string' ? opts.flags.format : undefined;
463
473
  const wantJson = opts.flags.json === true || formatFlag === 'json';
464
474
  const wantMarkdown = formatFlag === 'markdown' || formatFlag === 'md';
465
475
  const wantAnsi = formatFlag === 'ansi';