@vodmal/vdx-cli 0.10.0 → 0.12.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
@@ -36,10 +36,10 @@ sub-optimal.
36
36
  vdx up | down | build | test | check | fix
37
37
 
38
38
  # Maturity audit against the owner baseline rubric
39
- vdx audit <project-path> [--format=ansi|markdown|json] [--rubric <path>] [--stack <id>]
39
+ vdx audit [project-path] [--format=ansi|markdown|json] [--rubric <path>] [--stack <id>] # default: cwd
40
40
 
41
- # Generate mise.toml + AGENTS.md for a project
42
- vdx init <project-path> [--baseline github.com/org/repo@vX.Y] [--stack <id>] [--dry-run] [--force]
41
+ # Generate mise.toml for a project (no AGENTS.md — vdx-discover skill covers Claude Code; pick your own format for other agents)
42
+ vdx init [project-path] [--baseline github.com/org/repo@vX.Y] [--stack <id>] [--dry-run] [--force] # default: cwd
43
43
 
44
44
  # Publish a library (Node MVP; PHP/Python coming in Y.3)
45
45
  vdx publish <patch|minor|major> [--dry-run] [--force]
@@ -47,6 +47,9 @@ vdx publish <patch|minor|major> [--dry-run] [--force]
47
47
  # Environment self-check (Node / git / mise / npm auth / docker / Claude Code plugin)
48
48
  vdx doctor [--format=ansi|markdown|json]
49
49
 
50
+ # Start your agent in a project per your profile (~/.vdx-environment.yaml or $VDX_ENVIRONMENT)
51
+ vdx ai [project-path] [--restart] [--resume] [--detach] [--dry-run] # default: cwd
52
+
50
53
  # MCP stdio server consumed by the Claude Code plugin
51
54
  vdx-mcp --project <path>
52
55
  ```
@@ -54,17 +57,24 @@ vdx-mcp --project <path>
54
57
  **Three quick examples**
55
58
 
56
59
  ```bash
57
- # 1. Score the project I'm standing in
58
- vdx audit .
60
+ # 1. Score the project I'm standing in (no arg → cwd)
61
+ vdx audit
59
62
 
60
63
  # 2. Wire native scripts into the 6 lifecycle verbs, then run one
61
- vdx init . --stack node
64
+ vdx init --stack node
62
65
  vdx test # → mise run test (which calls `vitest run` or whatever was detected)
63
66
 
64
67
  # 3. Ship a new minor release of a Node lib
65
68
  vdx publish minor # bump → npm publish (OTP prompt) → git commit + tag (no push)
69
+
70
+ # 4. Start the agent here; re-run to attach, --restart to apply a changed profile
71
+ vdx ai
66
72
  ```
67
73
 
74
+ `vdx ai` reads a personal profile, never a bundled one: `$VDX_ENVIRONMENT`, else
75
+ `~/.vdx-environment.yaml`, else a plain `claude`/`codex` with no flags. Format:
76
+ [docs/specs/environment-format.md](../docs/specs/environment-format.md).
77
+
68
78
  By default the bundled `rubric/vdx-rubric.yaml` is used (a mirror of canonical
69
79
  [vdx-rubric-vodmal](https://github.com/VoDmAl/vdx-rubric-vodmal) at the time of
70
80
  each CLI release). Override via `--rubric <path>` or the env var `VDX_RUBRIC`.
@@ -89,9 +99,10 @@ npm run typecheck
89
99
  - `src/evaluator.ts` — recursive evaluator + sugar notation
90
100
  - `src/scoring.ts` — delta-style levels, flags for orthogonal axes
91
101
  - `src/audit.ts` — orchestrator: overrides + applies_to filter + subpackage-ctx
92
- - `src/init.ts` — `vdx init` planner (`selectVerbTask` + mise.toml/AGENTS.md renderers)
102
+ - `src/init.ts` — `vdx init` planner (`selectVerbTask` + mise.toml renderer)
93
103
  - `src/run.ts` — `resolveLifecycleVerb` + error renderer (pure logic for `vdx <verb>`)
94
104
  - `src/publish.ts` — `planPublish` (pre-flight) + `executePublish` (bump → npm → git)
105
+ - `src/ai.ts` — `vdx ai`: profile loader, launch plan, tmux session handling
95
106
  - `src/report.ts` — markdown / JSON output
96
107
  - `src/index.ts` — CLI entry
97
108
  - `src/mcp-server.ts` — MCP stdio server (9 tools)
@@ -100,7 +111,7 @@ npm run typecheck
100
111
 
101
112
  ## Not implemented yet
102
113
 
103
- - `config_value`/`command_succeeds` predicates are stubs (warning at evaluation).
114
+ - `command_succeeds` predicate is a stub (warning at evaluation).
104
115
  - Baseline loading from a git ref is documented but evaluator still reads file
105
116
  paths only — `baseline:` in `mise.toml` is recorded but does not auto-fetch.
106
117
  - 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.10.0",
3
+ "version": "0.12.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",
@@ -11,7 +11,7 @@ schema_version: "0.2"
11
11
 
12
12
  metadata:
13
13
  name: vdx-rubric-vodmal
14
- version: "0.3.1"
14
+ version: "0.4.0"
15
15
  description: "Personal maturity rubric for multi-stack projects (vodmal)"
16
16
  owner: vodmal
17
17
  homepage: "https://github.com/vodmal/vdx-rubric"
package/src/ai.ts ADDED
@@ -0,0 +1,799 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { execFileSync, spawnSync } from 'node:child_process';
5
+ import YAML from 'js-yaml';
6
+ import { evalPredicate } from './evaluator.ts';
7
+ import { autoDetectStack, type Ctx } from './facts.ts';
8
+ import type { Predicate } from './rubric.ts';
9
+
10
+ /**
11
+ * `vdx ai` — start the person's agent in a project, with the flags their
12
+ * profile asks for. vdx runs nothing itself: it builds the agent's command line
13
+ * and hands it to tmux, or runs it in this terminal when tmux is not wanted or
14
+ * not installed.
15
+ *
16
+ * The profile is personal (which agent, which permissions), so it is never
17
+ * bundled into the CLI: it is read from `$VDX_ENVIRONMENT` or
18
+ * `~/.vdx-environment.yaml`. Without one, vdx starts a plain `claude` or
19
+ * `codex` with no flags. @see docs/specs/environment-format.md
20
+ */
21
+
22
+ /** A prompt a conditional flag is known to cause, and the tmux keys that answer it. */
23
+ export interface ConfirmRule {
24
+ screen: string;
25
+ keys: string[];
26
+ }
27
+
28
+ export interface WhenRule {
29
+ id: string;
30
+ description?: string;
31
+ if: Predicate;
32
+ args?: string[];
33
+ confirm?: ConfirmRule[];
34
+ }
35
+
36
+ export interface AgentProfile {
37
+ command: string;
38
+ args?: string[];
39
+ resume_args?: string[];
40
+ when?: WhenRule[];
41
+ }
42
+
43
+ export type Multiplexer = 'tmux' | 'none';
44
+
45
+ export interface SessionProfile {
46
+ multiplexer?: Multiplexer;
47
+ name?: string;
48
+ }
49
+
50
+ export interface Environment {
51
+ schema_version?: string;
52
+ metadata?: Record<string, unknown>;
53
+ agent?: AgentProfile;
54
+ session?: SessionProfile;
55
+ }
56
+
57
+ export const ENVIRONMENT_ENV_VAR = 'VDX_ENVIRONMENT';
58
+ export const HOST_ENV_VAR = 'VDX_HOST';
59
+ export const USER_ENVIRONMENT_FILE = '.vdx-environment.yaml';
60
+ /** Tried in order when no profile names an agent. Started plain, with no flags. */
61
+ export const FALLBACK_AGENTS = ['claude', 'codex'] as const;
62
+ export const DEFAULT_SESSION_NAME = '{project}';
63
+
64
+ export function resolveEnvironmentPath(
65
+ env: NodeJS.ProcessEnv = process.env,
66
+ home: string = os.homedir(),
67
+ ): string | null {
68
+ const explicit = env[ENVIRONMENT_ENV_VAR];
69
+ if (explicit) return path.resolve(explicit.replace(/^~(?=$|\/)/, home));
70
+ const user = path.join(home, USER_ENVIRONMENT_FILE);
71
+ return fs.existsSync(user) ? user : null;
72
+ }
73
+
74
+ function isStringList(v: unknown): v is string[] {
75
+ return Array.isArray(v) && v.every((x) => typeof x === 'string');
76
+ }
77
+
78
+ /** Parse and validate a profile. Throws with the offending key named. */
79
+ export function parseEnvironment(text: string, source: string): Environment {
80
+ const data = YAML.load(text) as unknown;
81
+ if (data === null || data === undefined) return {};
82
+ if (typeof data !== 'object' || Array.isArray(data)) {
83
+ throw new Error(`${source}: expected a YAML mapping at the top level`);
84
+ }
85
+ const envDoc = data as Environment;
86
+ const fail = (key: string, what: string): never => {
87
+ throw new Error(`${source}: ${key} ${what}`);
88
+ };
89
+
90
+ const agent = envDoc.agent;
91
+ if (agent !== undefined) {
92
+ if (typeof agent !== 'object' || agent === null) fail('agent', 'must be a mapping');
93
+ if (typeof agent.command !== 'string' || agent.command.trim() === '') {
94
+ fail('agent.command', 'must be a non-empty string');
95
+ }
96
+ for (const key of ['args', 'resume_args'] as const) {
97
+ if (agent[key] !== undefined && !isStringList(agent[key])) {
98
+ fail(`agent.${key}`, 'must be a list of strings');
99
+ }
100
+ }
101
+ if (agent.when !== undefined) {
102
+ if (!Array.isArray(agent.when)) fail('agent.when', 'must be a list');
103
+ agent.when.forEach((rule, i) => {
104
+ const at = `agent.when[${i}]`;
105
+ if (typeof rule !== 'object' || rule === null) fail(at, 'must be a mapping');
106
+ if (typeof rule.id !== 'string' || rule.id === '') fail(`${at}.id`, 'must be a non-empty string');
107
+ if (rule.if === undefined) fail(`${at}.if`, 'is required (a rubric predicate)');
108
+ if (rule.args !== undefined && !isStringList(rule.args)) {
109
+ fail(`${at}.args`, 'must be a list of strings');
110
+ }
111
+ if (rule.confirm !== undefined) {
112
+ if (!Array.isArray(rule.confirm)) fail(`${at}.confirm`, 'must be a list');
113
+ rule.confirm.forEach((c, j) => {
114
+ const cat = `${at}.confirm[${j}]`;
115
+ if (typeof c?.screen !== 'string' || c.screen === '') {
116
+ fail(`${cat}.screen`, 'must be a non-empty string');
117
+ }
118
+ if (!isStringList(c.keys) || c.keys.length === 0) {
119
+ fail(`${cat}.keys`, 'must be a non-empty list of tmux key names');
120
+ }
121
+ });
122
+ }
123
+ });
124
+ }
125
+ }
126
+
127
+ const session = envDoc.session;
128
+ if (session !== undefined) {
129
+ if (typeof session !== 'object' || session === null) fail('session', 'must be a mapping');
130
+ if (session.multiplexer !== undefined && !['tmux', 'none'].includes(session.multiplexer)) {
131
+ fail('session.multiplexer', 'must be "tmux" or "none"');
132
+ }
133
+ if (session.name !== undefined && typeof session.name !== 'string') {
134
+ fail('session.name', 'must be a string');
135
+ }
136
+ }
137
+ return envDoc;
138
+ }
139
+
140
+ export function loadEnvironment(file: string): Environment {
141
+ let text: string;
142
+ try {
143
+ text = fs.readFileSync(file, 'utf8');
144
+ } catch (e: any) {
145
+ throw new Error(`cannot read profile ${file}: ${e?.code ?? e?.message ?? e}`);
146
+ }
147
+ return parseEnvironment(text, file);
148
+ }
149
+
150
+ export function fallbackAgent(onPath: (bin: string) => boolean): AgentProfile | null {
151
+ const found = FALLBACK_AGENTS.find((bin) => onPath(bin));
152
+ return found ? { command: found } : null;
153
+ }
154
+
155
+ /**
156
+ * tmux turns `.` and `:` in a session name into `_`; doing it here keeps the
157
+ * name vdx looks up equal to the name tmux stores.
158
+ */
159
+ export function renderSessionName(template: string, vars: Record<string, string>): string {
160
+ const name = template.replace(/\{(\w+)\}/g, (_, key: string) => {
161
+ const v = vars[key];
162
+ if (v === undefined) throw new Error(`session.name: unknown placeholder {${key}}`);
163
+ return v;
164
+ });
165
+ return name.replace(/[.:]/g, '_');
166
+ }
167
+
168
+ export interface LaunchPlan {
169
+ projectRoot: string;
170
+ project: string;
171
+ host: string;
172
+ profilePath: string | null;
173
+ command: string;
174
+ /** agent.args plus the args of every matched `when` rule — what a running agent must carry. */
175
+ args: string[];
176
+ resumeArgs: string[];
177
+ matched: string[];
178
+ confirm: ConfirmRule[];
179
+ multiplexer: Multiplexer;
180
+ sessionName: string;
181
+ }
182
+
183
+ export function planLaunch(input: {
184
+ environment: Environment;
185
+ profilePath: string | null;
186
+ agent: AgentProfile;
187
+ projectRoot: string;
188
+ project: string;
189
+ host: string;
190
+ }): LaunchPlan {
191
+ const { environment, agent, projectRoot } = input;
192
+ const ctx: Ctx = { projectRoot, stack: autoDetectStack(projectRoot), cache: new Map() };
193
+ const args = [...(agent.args ?? [])];
194
+ const matched: string[] = [];
195
+ const confirm: ConfirmRule[] = [];
196
+ for (const rule of agent.when ?? []) {
197
+ if (!evalPredicate(rule.if, ctx)) continue;
198
+ matched.push(rule.id);
199
+ args.push(...(rule.args ?? []));
200
+ confirm.push(...(rule.confirm ?? []));
201
+ }
202
+ return {
203
+ projectRoot,
204
+ project: input.project,
205
+ host: input.host,
206
+ profilePath: input.profilePath,
207
+ command: agent.command,
208
+ args,
209
+ resumeArgs: [...(agent.resume_args ?? [])],
210
+ matched,
211
+ confirm,
212
+ multiplexer: environment.session?.multiplexer ?? 'none',
213
+ sessionName: renderSessionName(environment.session?.name ?? DEFAULT_SESSION_NAME, {
214
+ project: input.project,
215
+ host: input.host,
216
+ }),
217
+ };
218
+ }
219
+
220
+ export function shellQuote(s: string): string {
221
+ if (s !== '' && /^[A-Za-z0-9_\/.:@%+=,-]+$/.test(s)) return s;
222
+ return `'${s.replace(/'/g, `'\\''`)}'`;
223
+ }
224
+
225
+ export function commandLine(command: string, args: string[]): string {
226
+ return [command, ...args].map(shellQuote).join(' ');
227
+ }
228
+
229
+ /**
230
+ * The pane runs the agent inside the user's login shell, so the agent sees what
231
+ * `.zshrc` sets up exactly as when it is typed by hand; the tmux server's own
232
+ * environment does not have it. When the agent exits, the pane keeps a shell.
233
+ */
234
+ export function tmuxShellCommand(line: string, shell: string | null): string {
235
+ if (!shell) return line;
236
+ const sh = shellQuote(shell);
237
+ return `exec ${sh} -lic ${shellQuote(`${line}; exec ${sh} -l`)}`;
238
+ }
239
+
240
+ export interface PaneInfo {
241
+ session: string;
242
+ paneId: string;
243
+ panePid: number;
244
+ cwd: string;
245
+ }
246
+
247
+ export interface ProcInfo {
248
+ pid: number;
249
+ ppid: number;
250
+ args: string;
251
+ }
252
+
253
+ export const PANE_FORMAT = '#{session_name}\t#{pane_id}\t#{pane_pid}\t#{pane_current_path}';
254
+
255
+ export function parsePanes(out: string): PaneInfo[] {
256
+ const panes: PaneInfo[] = [];
257
+ for (const line of out.split('\n')) {
258
+ const [session, paneId, pid, cwd] = line.split('\t');
259
+ if (!session || !paneId || !pid || cwd === undefined) continue;
260
+ panes.push({ session, paneId, panePid: Number(pid), cwd });
261
+ }
262
+ return panes;
263
+ }
264
+
265
+ export function parsePs(out: string): ProcInfo[] {
266
+ const procs: ProcInfo[] = [];
267
+ for (const line of out.split('\n')) {
268
+ const m = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
269
+ if (m) procs.push({ pid: Number(m[1]), ppid: Number(m[2]), args: m[3]!.trim() });
270
+ }
271
+ return procs;
272
+ }
273
+
274
+ /** `pid` itself and everything below it. */
275
+ export function processTree(procs: ProcInfo[], pid: number): ProcInfo[] {
276
+ const byParent = new Map<number, ProcInfo[]>();
277
+ for (const p of procs) {
278
+ const list = byParent.get(p.ppid) ?? [];
279
+ list.push(p);
280
+ byParent.set(p.ppid, list);
281
+ }
282
+ const tree: ProcInfo[] = [];
283
+ const self = procs.find((p) => p.pid === pid);
284
+ if (self) tree.push(self);
285
+ const queue = [pid];
286
+ while (queue.length > 0) {
287
+ for (const child of byParent.get(queue.shift()!) ?? []) {
288
+ tree.push(child);
289
+ queue.push(child.pid);
290
+ }
291
+ }
292
+ return tree;
293
+ }
294
+
295
+ const INTERPRETERS = new Set(['node', 'bun', 'deno', 'python', 'python3', 'ruby', 'sh', 'bash', 'zsh', 'dash']);
296
+
297
+ /** Is this process the agent — run directly, or as a script under an interpreter? */
298
+ export function isAgentProcess(proc: ProcInfo, command: string): boolean {
299
+ const want = path.basename(command);
300
+ const [first, second] = proc.args.split(/\s+/);
301
+ if (!first) return false;
302
+ if (path.basename(first) === want) return true;
303
+ return INTERPRETERS.has(path.basename(first)) && !!second && path.basename(second) === want;
304
+ }
305
+
306
+ function realpathOr(p: string): string {
307
+ try {
308
+ return fs.realpathSync(p);
309
+ } catch {
310
+ return path.resolve(p);
311
+ }
312
+ }
313
+
314
+ export function isInside(child: string, parent: string): boolean {
315
+ return child === parent || child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
316
+ }
317
+
318
+ export interface AgentPane {
319
+ pane: PaneInfo;
320
+ proc: ProcInfo;
321
+ }
322
+
323
+ /**
324
+ * The project's running agent is found by what runs, not by session name: a
325
+ * pane whose working directory lies in the project, with the agent below it.
326
+ */
327
+ export function findAgentPanes(
328
+ panes: PaneInfo[],
329
+ procs: ProcInfo[],
330
+ projectRoot: string,
331
+ command: string,
332
+ ): AgentPane[] {
333
+ const root = realpathOr(projectRoot);
334
+ const found: AgentPane[] = [];
335
+ for (const pane of panes) {
336
+ if (!isInside(realpathOr(pane.cwd), root)) continue;
337
+ const proc = processTree(procs, pane.panePid).find((p) => isAgentProcess(p, command));
338
+ if (proc) found.push({ pane, proc });
339
+ }
340
+ return found;
341
+ }
342
+
343
+ /** Required args absent from a process's command line (`ps` joins argv with spaces). */
344
+ export function missingArgs(procArgs: string, required: string[]): string[] {
345
+ const tokens = new Set(procArgs.split(/\s+/));
346
+ return required.filter((a) => (/\s/.test(a) ? !procArgs.includes(a) : !tokens.has(a)));
347
+ }
348
+
349
+ export function resolveProjectRoot(p: string): string | null {
350
+ const abs = path.resolve(p);
351
+ try {
352
+ if (!fs.statSync(abs).isDirectory()) return null;
353
+ } catch {
354
+ return null;
355
+ }
356
+ try {
357
+ const top = execFileSync('git', ['-C', abs, 'rev-parse', '--show-toplevel'], {
358
+ encoding: 'utf8',
359
+ stdio: ['ignore', 'pipe', 'ignore'],
360
+ }).trim();
361
+ if (top) return realpathOr(top);
362
+ } catch {
363
+ /* not a git checkout — the directory itself is the project */
364
+ }
365
+ return realpathOr(abs);
366
+ }
367
+
368
+ /** `{project}`: the origin remote's repository name, else the directory name. */
369
+ export function projectIdentity(projectRoot: string): string {
370
+ try {
371
+ const url = execFileSync('git', ['-C', projectRoot, 'remote', 'get-url', 'origin'], {
372
+ encoding: 'utf8',
373
+ stdio: ['ignore', 'pipe', 'ignore'],
374
+ }).trim();
375
+ const slug = url.replace(/\/+$/, '').replace(/\.git$/, '').split(/[/:]/).pop();
376
+ if (slug) return slug.toLowerCase();
377
+ } catch {
378
+ /* no git or no origin */
379
+ }
380
+ return path.basename(projectRoot);
381
+ }
382
+
383
+ /** `{host}`: `$VDX_HOST` when set (a machine label such as `lft`), else the short hostname. */
384
+ export function hostLabel(env: NodeJS.ProcessEnv = process.env): string {
385
+ const label = env[HOST_ENV_VAR]?.trim();
386
+ return label || os.hostname().split('.')[0]!;
387
+ }
388
+
389
+ export function onPath(bin: string, env: NodeJS.ProcessEnv = process.env): boolean {
390
+ for (const dir of (env['PATH'] ?? '').split(path.delimiter)) {
391
+ if (!dir) continue;
392
+ try {
393
+ fs.accessSync(path.join(dir, bin), fs.constants.X_OK);
394
+ return true;
395
+ } catch {
396
+ /* keep looking */
397
+ }
398
+ }
399
+ return false;
400
+ }
401
+
402
+ export function listProcesses(): ProcInfo[] {
403
+ // -A (all processes) means the same on macOS and Linux; -e does not.
404
+ return parsePs(execFileSync('ps', ['-A', '-o', 'pid=,ppid=,args='], { encoding: 'utf8' }));
405
+ }
406
+
407
+ export class Tmux {
408
+ constructor(private readonly socket: string | null = null) {}
409
+
410
+ private argv(args: string[]): string[] {
411
+ return this.socket ? ['-L', this.socket, ...args] : args;
412
+ }
413
+
414
+ available(): boolean {
415
+ try {
416
+ execFileSync('tmux', ['-V'], { stdio: 'ignore' });
417
+ return true;
418
+ } catch {
419
+ return false;
420
+ }
421
+ }
422
+
423
+ run(args: string[]): string {
424
+ return execFileSync('tmux', this.argv(args), {
425
+ encoding: 'utf8',
426
+ stdio: ['ignore', 'pipe', 'pipe'],
427
+ });
428
+ }
429
+
430
+ tryRun(args: string[]): string | null {
431
+ try {
432
+ return this.run(args);
433
+ } catch {
434
+ return null;
435
+ }
436
+ }
437
+
438
+ /** Every pane on the server; none when no server is running. */
439
+ panes(): PaneInfo[] {
440
+ const out = this.tryRun(['list-panes', '-a', '-F', PANE_FORMAT]);
441
+ return out ? parsePanes(out) : [];
442
+ }
443
+
444
+ hasSession(name: string): boolean {
445
+ return this.tryRun(['has-session', '-t', `=${name}`]) !== null;
446
+ }
447
+
448
+ newSession(name: string, cwd: string, cmd: string): string {
449
+ return this.run(['new-session', '-d', '-s', name, '-c', cwd, '-P', '-F', '#{pane_id}', cmd]).trim();
450
+ }
451
+
452
+ newWindow(session: string, cwd: string, cmd: string): string {
453
+ return this.run(['new-window', '-d', '-t', `=${session}:`, '-c', cwd, '-P', '-F', '#{pane_id}', cmd]).trim();
454
+ }
455
+
456
+ respawn(paneId: string, cwd: string, cmd: string): void {
457
+ this.run(['respawn-pane', '-k', '-t', paneId, '-c', cwd, cmd]);
458
+ }
459
+
460
+ /** Visible screen with wrapped lines joined, so a matched phrase is not split by the pane width. */
461
+ capture(paneId: string): string | null {
462
+ return this.tryRun(['capture-pane', '-p', '-J', '-t', paneId]);
463
+ }
464
+
465
+ sendKey(paneId: string, key: string): void {
466
+ this.run(['send-keys', '-t', paneId, key]);
467
+ }
468
+
469
+ panePid(paneId: string): number | null {
470
+ const out = this.tryRun(['display-message', '-p', '-t', paneId, '#{pane_pid}']);
471
+ const pid = out ? Number(out.trim()) : NaN;
472
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
473
+ }
474
+
475
+ /** Make the pane its session's current window and pane, so attaching lands on it. */
476
+ focus(paneId: string): void {
477
+ this.tryRun(['select-window', '-t', paneId]);
478
+ this.tryRun(['select-pane', '-t', paneId]);
479
+ }
480
+
481
+ attach(session: string, insideTmux: boolean): number {
482
+ const args = insideTmux
483
+ ? ['switch-client', '-t', `=${session}`]
484
+ : ['attach-session', '-t', `=${session}`];
485
+ return spawnSync('tmux', this.argv(args), { stdio: 'inherit' }).status ?? 1;
486
+ }
487
+
488
+ /** How a person reaches the session from another terminal. */
489
+ attachHint(session: string): string {
490
+ return `tmux ${this.socket ? `-L ${this.socket} ` : ''}attach -t ${shellQuote(`=${session}`)}`;
491
+ }
492
+ }
493
+
494
+ export interface AiOptions {
495
+ path: string;
496
+ restart: boolean;
497
+ resume: boolean;
498
+ detach: boolean;
499
+ dryRun: boolean;
500
+ }
501
+
502
+ export interface AiDeps {
503
+ env: NodeJS.ProcessEnv;
504
+ home: string;
505
+ tmux: Tmux;
506
+ /** stdin and stdout are a terminal: an interactive agent can run here and tmux can attach. */
507
+ interactive: boolean;
508
+ shell: string | null;
509
+ log: (line: string) => void;
510
+ out: (text: string) => void;
511
+ onPath: (bin: string) => boolean;
512
+ processes: () => ProcInfo[];
513
+ sleep: (ms: number) => void;
514
+ confirmTimeoutMs: number;
515
+ }
516
+
517
+ export function sleepSync(ms: number): void {
518
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
519
+ }
520
+
521
+ export function defaultDeps(): AiDeps {
522
+ return {
523
+ env: process.env,
524
+ home: os.homedir(),
525
+ tmux: new Tmux(),
526
+ interactive: process.stdin.isTTY === true && process.stdout.isTTY === true,
527
+ shell: process.env['SHELL'] || null,
528
+ log: (line) => process.stderr.write(line + '\n'),
529
+ out: (text) => process.stdout.write(text),
530
+ onPath: (bin) => onPath(bin),
531
+ processes: listProcesses,
532
+ sleep: sleepSync,
533
+ confirmTimeoutMs: 30_000,
534
+ };
535
+ }
536
+
537
+ export const EXIT_OK = 0;
538
+ export const EXIT_USAGE = 2;
539
+ export const EXIT_DRIFT = 3;
540
+ export const EXIT_LAUNCH_FAILED = 4;
541
+
542
+ export function renderPlan(plan: LaunchPlan, multiplexer: Multiplexer, running: AgentPane[]): string {
543
+ const lines = [
544
+ `vdx ai — ${plan.projectRoot}`,
545
+ ` profile: ${plan.profilePath ?? `none — built-in default (${FALLBACK_AGENTS.join(' or ')}, no flags, no tmux)`}`,
546
+ ` agent: ${commandLine(plan.command, plan.args)}`,
547
+ ` resume: ${plan.resumeArgs.length ? plan.resumeArgs.join(' ') : '—'}`,
548
+ ` matched: ${plan.matched.length ? plan.matched.join(', ') : '—'}`,
549
+ ];
550
+ for (const c of plan.confirm) lines.push(` confirm: "${c.screen}" → ${c.keys.join(' ')}`);
551
+ lines.push(
552
+ multiplexer === 'tmux'
553
+ ? ` session: tmux; a new session is named "${plan.sessionName}"`
554
+ : ' session: this terminal (no tmux)',
555
+ );
556
+ if (multiplexer === 'tmux') {
557
+ if (running.length === 0) lines.push(' running: —');
558
+ for (const r of running) {
559
+ const missing = missingArgs(r.proc.args, plan.args);
560
+ lines.push(
561
+ ` running: ${r.pane.session} ${r.pane.paneId} — ${
562
+ missing.length ? `missing ${missing.join(' ')}` : 'matches the profile'
563
+ }`,
564
+ );
565
+ }
566
+ }
567
+ return lines.join('\n') + '\n';
568
+ }
569
+
570
+ function lastLines(screen: string | null, n: number): string {
571
+ const lines = (screen ?? '').split('\n').filter((l) => l.trim() !== '');
572
+ return lines.slice(-n).map((l) => ` | ${l}`).join('\n');
573
+ }
574
+
575
+ function findAgentInPane(plan: LaunchPlan, paneId: string, deps: AiDeps): ProcInfo | null {
576
+ const pid = deps.tmux.panePid(paneId);
577
+ if (pid === null) return null;
578
+ return processTree(deps.processes(), pid).find((p) => isAgentProcess(p, plan.command)) ?? null;
579
+ }
580
+
581
+ type SettleResult = 'ok' | 'not-running' | 'failed';
582
+
583
+ /**
584
+ * After a start or restart: answer the prompts the profile says will appear,
585
+ * then check the agent is alive and carries every required arg.
586
+ */
587
+ function settle(plan: LaunchPlan, paneId: string, deps: AiDeps): SettleResult {
588
+ const { tmux, log } = deps;
589
+ const pending = [...plan.confirm];
590
+ const deadline = Date.now() + deps.confirmTimeoutMs;
591
+ const agentGrace = Date.now() + Math.min(5_000, deps.confirmTimeoutMs);
592
+ let screen: string | null = null;
593
+
594
+ while (pending.length > 0 && Date.now() < deadline) {
595
+ screen = tmux.capture(paneId);
596
+ const i = pending.findIndex((c) => screen?.includes(c.screen));
597
+ if (i >= 0) {
598
+ const rule = pending.splice(i, 1)[0]!;
599
+ for (const key of rule.keys) {
600
+ tmux.sendKey(paneId, key);
601
+ deps.sleep(250);
602
+ }
603
+ const goneBy = Date.now() + 5_000;
604
+ while (Date.now() < goneBy && tmux.capture(paneId)?.includes(rule.screen)) deps.sleep(200);
605
+ if (tmux.capture(paneId)?.includes(rule.screen)) {
606
+ log(`✗ "${rule.screen}" is still on screen after ${rule.keys.join(' ')}`);
607
+ return 'failed';
608
+ }
609
+ log(`✓ answered "${rule.screen}" with ${rule.keys.join(' ')}`);
610
+ continue;
611
+ }
612
+ if (Date.now() > agentGrace && !findAgentInPane(plan, paneId, deps)) break;
613
+ deps.sleep(300);
614
+ }
615
+
616
+ // Seen twice a second apart: an agent can quit right after its first prompt
617
+ // is answered (claude --continue with no conversation does).
618
+ let proc: ProcInfo | null = null;
619
+ const procBy = Date.now() + 10_000;
620
+ for (;;) {
621
+ const first = findAgentInPane(plan, paneId, deps);
622
+ if (first) {
623
+ deps.sleep(1_000);
624
+ const again = findAgentInPane(plan, paneId, deps);
625
+ if (again?.pid === first.pid) {
626
+ proc = again;
627
+ break;
628
+ }
629
+ }
630
+ if (Date.now() >= procBy) break;
631
+ deps.sleep(300);
632
+ }
633
+ if (!proc) {
634
+ log(`✗ ${plan.command} is not running in the pane. The pane shows:`);
635
+ log(lastLines(tmux.capture(paneId), 8));
636
+ return 'not-running';
637
+ }
638
+ if (pending.length > 0) {
639
+ log(
640
+ `✗ expected prompt not seen: ${pending.map((c) => `"${c.screen}"`).join(', ')} — ` +
641
+ 'the agent may be waiting on another question. The pane shows:',
642
+ );
643
+ log(lastLines(screen ?? tmux.capture(paneId), 8));
644
+ return 'failed';
645
+ }
646
+ const missing = missingArgs(proc.args, plan.args);
647
+ if (missing.length > 0) {
648
+ log(`✗ the agent is running without: ${missing.join(' ')}`);
649
+ return 'failed';
650
+ }
651
+ log(`✓ running: ${proc.args}`);
652
+ return 'ok';
653
+ }
654
+
655
+ /**
656
+ * Settle a pane that was started with the resume args. An agent with nothing to
657
+ * resume exits at once; start it once more without them rather than leave the
658
+ * project without an agent.
659
+ */
660
+ function settleResumed(plan: LaunchPlan, paneId: string, deps: AiDeps): boolean {
661
+ const result = settle(plan, paneId, deps);
662
+ if (result !== 'not-running' || plan.resumeArgs.length === 0) return result === 'ok';
663
+ const line = commandLine(plan.command, plan.args);
664
+ deps.log(`↻ nothing to resume — starting without ${plan.resumeArgs.join(' ')}: ${line}`);
665
+ deps.tmux.respawn(paneId, plan.projectRoot, tmuxShellCommand(line, deps.shell));
666
+ return settle(plan, paneId, deps) === 'ok';
667
+ }
668
+
669
+ function finish(ok: boolean, session: string, paneId: string, opts: AiOptions, deps: AiDeps): number {
670
+ if (deps.interactive && !opts.detach) {
671
+ if (!ok) deps.log('attaching so you can see the pane');
672
+ deps.tmux.focus(paneId);
673
+ return deps.tmux.attach(session, Boolean(deps.env['TMUX']));
674
+ }
675
+ deps.log(`attach: ${deps.tmux.attachHint(session)}`);
676
+ return ok ? EXIT_OK : EXIT_LAUNCH_FAILED;
677
+ }
678
+
679
+ export function runAi(opts: AiOptions, deps: AiDeps): number {
680
+ const { log, tmux } = deps;
681
+
682
+ const projectRoot = resolveProjectRoot(opts.path);
683
+ if (!projectRoot) {
684
+ log(`vdx ai: ${path.resolve(opts.path)} is not a directory`);
685
+ return EXIT_USAGE;
686
+ }
687
+
688
+ let environment: Environment = {};
689
+ const profilePath = resolveEnvironmentPath(deps.env, deps.home);
690
+ if (profilePath) {
691
+ try {
692
+ environment = loadEnvironment(profilePath);
693
+ } catch (e: any) {
694
+ log(`vdx ai: ${e?.message ?? e}`);
695
+ return EXIT_USAGE;
696
+ }
697
+ }
698
+
699
+ const agent = environment.agent ?? fallbackAgent(deps.onPath);
700
+ if (!agent) {
701
+ log(
702
+ `vdx ai: no agent to start — ${FALLBACK_AGENTS.join(' / ')} not found on PATH and no profile names one.\n` +
703
+ `hint: put an \`agent:\` section into ~/${USER_ENVIRONMENT_FILE} (or point $${ENVIRONMENT_ENV_VAR} at your profile).`,
704
+ );
705
+ return EXIT_USAGE;
706
+ }
707
+
708
+ let plan: LaunchPlan;
709
+ try {
710
+ plan = planLaunch({
711
+ environment,
712
+ profilePath,
713
+ agent,
714
+ projectRoot,
715
+ project: projectIdentity(projectRoot),
716
+ host: hostLabel(deps.env),
717
+ });
718
+ } catch (e: any) {
719
+ log(`vdx ai: ${profilePath ?? 'profile'}: ${e?.message ?? e}`);
720
+ return EXIT_USAGE;
721
+ }
722
+
723
+ let multiplexer = plan.multiplexer;
724
+ if (multiplexer === 'tmux' && !tmux.available()) {
725
+ log('vdx ai: tmux is not installed — starting the agent in this terminal');
726
+ multiplexer = 'none';
727
+ }
728
+
729
+ const running =
730
+ multiplexer === 'tmux'
731
+ ? findAgentPanes(tmux.panes(), deps.processes(), projectRoot, plan.command)
732
+ : [];
733
+
734
+ if (opts.dryRun) {
735
+ deps.out(renderPlan(plan, multiplexer, running));
736
+ return EXIT_OK;
737
+ }
738
+
739
+ if (multiplexer === 'none') {
740
+ if (opts.restart) {
741
+ log('vdx ai: --restart works on tmux sessions; without tmux there is nothing to restart');
742
+ return EXIT_USAGE;
743
+ }
744
+ if (!deps.interactive) {
745
+ log('vdx ai: no terminal here and no tmux — an interactive agent needs one of them');
746
+ return EXIT_USAGE;
747
+ }
748
+ for (const c of plan.confirm) log(`note: the agent will ask "${c.screen}" — answer it with ${c.keys.join(' ')}`);
749
+ const args = [...plan.args, ...(opts.resume ? plan.resumeArgs : [])];
750
+ const res = spawnSync(plan.command, args, { cwd: projectRoot, stdio: 'inherit' });
751
+ if ((res.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
752
+ log(`vdx ai: \`${plan.command}\` not found on PATH`);
753
+ return 127;
754
+ }
755
+ return res.status ?? 1;
756
+ }
757
+
758
+ const current = running[0];
759
+ if (running.length > 1) {
760
+ log(
761
+ `note: ${running.length} panes run ${plan.command} in this project: ` +
762
+ `${running.map((r) => `${r.pane.session} ${r.pane.paneId}`).join(', ')} — using the first`,
763
+ );
764
+ }
765
+
766
+ if (current && !opts.restart) {
767
+ const missing = missingArgs(current.proc.args, plan.args);
768
+ if (missing.length > 0) {
769
+ log(`✗ ${current.pane.session}: ${plan.command} is running without ${missing.join(' ')}`);
770
+ log(
771
+ ` to apply the profile: vdx ai --restart ${shellQuote(projectRoot)}` +
772
+ (plan.resumeArgs.length
773
+ ? ` (stops the running agent, then resumes the conversation with ${plan.resumeArgs.join(' ')})`
774
+ : ' (stops the running agent; the conversation is not resumed — the profile has no resume_args)'),
775
+ );
776
+ return EXIT_DRIFT;
777
+ }
778
+ log(`✓ ${current.pane.session}: already running with the profile — ${current.proc.args}`);
779
+ return finish(true, current.pane.session, current.pane.paneId, opts, deps);
780
+ }
781
+
782
+ if (current) {
783
+ const line = commandLine(plan.command, [...plan.args, ...plan.resumeArgs]);
784
+ log(`↻ ${current.pane.session} ${current.pane.paneId}: restarting — ${line}`);
785
+ tmux.respawn(current.pane.paneId, projectRoot, tmuxShellCommand(line, deps.shell));
786
+ return finish(settleResumed(plan, current.pane.paneId, deps), current.pane.session, current.pane.paneId, opts, deps);
787
+ }
788
+
789
+ if (opts.restart) log(`note: no running ${plan.command} in this project — starting a new one`);
790
+ const line = commandLine(plan.command, [...plan.args, ...(opts.resume ? plan.resumeArgs : [])]);
791
+ const cmd = tmuxShellCommand(line, deps.shell);
792
+ const reuse = tmux.hasSession(plan.sessionName);
793
+ const paneId = reuse
794
+ ? tmux.newWindow(plan.sessionName, projectRoot, cmd)
795
+ : tmux.newSession(plan.sessionName, projectRoot, cmd);
796
+ log(`▶ ${plan.sessionName}${reuse ? ' (new window)' : ''}: ${line}`);
797
+ const ok = opts.resume ? settleResumed(plan, paneId, deps) : settle(plan, paneId, deps) === 'ok';
798
+ return finish(ok, plan.sessionName, paneId, opts, deps);
799
+ }
package/src/doctor.ts CHANGED
@@ -15,6 +15,16 @@ export interface CheckResult {
15
15
  remedy?: string;
16
16
  }
17
17
 
18
+ /**
19
+ * Context a check may consult. Doctor is primarily a machine-level report, but
20
+ * some checks are about the repository it is invoked in (git config of this
21
+ * clone, tracked hook files). `projectRoot` is null when doctor runs outside a
22
+ * project — `vdx doctor` is explicitly usable from anywhere.
23
+ */
24
+ export interface DoctorCtx {
25
+ projectRoot: string | null;
26
+ }
27
+
18
28
  export interface DoctorReport {
19
29
  cliVersion: string;
20
30
  checks: CheckResult[];
@@ -365,7 +375,135 @@ function checkClaudeCodePlugin(): CheckResult {
365
375
  }
366
376
  }
367
377
 
368
- const CHECKS: Array<() => CheckResult> = [
378
+ /**
379
+ * Variables a hook body *gates on*: `[ -n "${VAR:-}" ]`, `[ -x "$VAR" ]` and
380
+ * friends. Restricted to test-expression positions on purpose — a bare `$VAR`
381
+ * anywhere in a script says nothing about whether the hook still does its job,
382
+ * while an unset variable inside the guard means the guarded block never runs.
383
+ */
384
+ const GATING_VAR_RE =
385
+ /\[\s+-[nxfse]\s+"\$\{([A-Z][A-Z0-9_]*)(?::-[^}]*)?\}"|\[\s+-[nxfse]\s+"\$([A-Z][A-Z0-9_]*)"/g;
386
+
387
+ /**
388
+ * Git hooks: declared vs actually wired.
389
+ *
390
+ * A hook framework leaves two separable traces: files in the repository
391
+ * (tracked, same for every clone) and activation in `.git/config` (per-clone,
392
+ * never committed). Presence of the first says nothing about the second, and a
393
+ * hook that is present but inert fails exactly like success — nothing is
394
+ * printed, the commit goes through. Same for a hook whose body is gated on an
395
+ * environment variable nobody set: the guard short-circuits and the gate is a
396
+ * no-op.
397
+ *
398
+ * Returns null (row omitted) when the repo declares no hooks at all — there is
399
+ * nothing to be wrong about.
400
+ */
401
+ function checkGitHooks(ctx: DoctorCtx): CheckResult | null {
402
+ const root = ctx.projectRoot;
403
+ if (root === null) return null;
404
+ if (!fs.existsSync(path.join(root, '.git'))) return null;
405
+
406
+ const id = 'git-hooks';
407
+ const label = 'git hooks';
408
+
409
+ let hooksPath: string | null = null;
410
+ try {
411
+ const out = execFileSync('git', ['-C', root, 'config', '--get', 'core.hooksPath'], {
412
+ encoding: 'utf8',
413
+ stdio: ['ignore', 'pipe', 'ignore'],
414
+ });
415
+ hooksPath = out.trim() || null;
416
+ } catch {
417
+ hooksPath = null;
418
+ }
419
+
420
+ // Directories a framework would have written into the repo.
421
+ const declaredDirs = ['.githooks', '.husky'].filter((d) =>
422
+ fs.existsSync(path.join(root, d)),
423
+ );
424
+
425
+ if (hooksPath === null) {
426
+ if (declaredDirs.length === 0) return null; // nothing declared — not applicable
427
+ return {
428
+ id,
429
+ label,
430
+ status: 'warning',
431
+ message: `${declaredDirs.join(', ')} present but core.hooksPath is unset — hooks never run`,
432
+ remedy: `git -C ${root} config core.hooksPath ${declaredDirs[0]}`,
433
+ };
434
+ }
435
+
436
+ const dir = path.resolve(root, hooksPath);
437
+ if (!fs.existsSync(dir)) {
438
+ return {
439
+ id,
440
+ label,
441
+ status: 'missing',
442
+ message: `core.hooksPath=${hooksPath} but that directory does not exist`,
443
+ remedy: `git -C ${root} config --unset core.hooksPath, or create ${hooksPath}/`,
444
+ };
445
+ }
446
+
447
+ let entries: string[] = [];
448
+ try {
449
+ entries = fs.readdirSync(dir).filter((f) => !f.startsWith('.') && !f.endsWith('.sample'));
450
+ } catch {
451
+ entries = [];
452
+ }
453
+ const executable = entries.filter((f) => {
454
+ try {
455
+ fs.accessSync(path.join(dir, f), fs.constants.X_OK);
456
+ return true;
457
+ } catch {
458
+ return false;
459
+ }
460
+ });
461
+
462
+ if (executable.length === 0) {
463
+ return {
464
+ id,
465
+ label,
466
+ status: 'warning',
467
+ message: `core.hooksPath=${hooksPath} but it holds no executable hook`,
468
+ remedy: `chmod +x ${hooksPath}/*`,
469
+ };
470
+ }
471
+
472
+ // A hook gated on an unset variable is inert while looking installed.
473
+ const unresolved = new Set<string>();
474
+ for (const f of executable) {
475
+ let body = '';
476
+ try {
477
+ body = fs.readFileSync(path.join(dir, f), 'utf8');
478
+ } catch {
479
+ continue;
480
+ }
481
+ for (const m of body.matchAll(GATING_VAR_RE)) {
482
+ const name = m[1] ?? m[2];
483
+ if (name && !process.env[name]) unresolved.add(name);
484
+ }
485
+ }
486
+
487
+ if (unresolved.size > 0) {
488
+ const names = [...unresolved].sort();
489
+ return {
490
+ id,
491
+ label,
492
+ status: 'warning',
493
+ message: `${executable.length} hook(s) via ${hooksPath}, but gated on unset ${names.join(', ')} — those gates are no-ops`,
494
+ remedy: `export ${names[0]}=... in your shell profile (see the tool that ships the hook)`,
495
+ };
496
+ }
497
+
498
+ return {
499
+ id,
500
+ label,
501
+ status: 'ok',
502
+ message: `${executable.length} hook(s) active via ${hooksPath}`,
503
+ };
504
+ }
505
+
506
+ const CHECKS: Array<(ctx: DoctorCtx) => CheckResult | null> = [
369
507
  checkVdxVersion,
370
508
  checkVdx,
371
509
  checkClaudeCode,
@@ -375,10 +513,15 @@ const CHECKS: Array<() => CheckResult> = [
375
513
  checkMise,
376
514
  checkNpmAuth,
377
515
  checkContainerRuntime,
516
+ checkGitHooks,
378
517
  ];
379
518
 
380
- export function runDoctor(): DoctorReport {
381
- const checks = CHECKS.map((fn) => fn());
519
+ export function resolveDoctorCtx(cwd: string = process.cwd()): DoctorCtx {
520
+ return { projectRoot: looksLikeProject(cwd) ? cwd : null };
521
+ }
522
+
523
+ export function runDoctor(ctx: DoctorCtx = resolveDoctorCtx()): DoctorReport {
524
+ const checks = CHECKS.map((fn) => fn(ctx)).filter((c): c is CheckResult => c !== null);
382
525
  const ok = checks.filter((c) => c.status === 'ok').length;
383
526
  const warning = checks.filter((c) => c.status === 'warning').length;
384
527
  const missing = checks.filter((c) => c.status === 'missing').length;
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  type PublishOptions,
30
30
  } from './publish.ts';
31
31
  import { resolveDefaultRubric } from './defaults.ts';
32
+ import { runAi, defaultDeps } from './ai.ts';
32
33
 
33
34
  const DEFAULT_RUBRIC = resolveDefaultRubric();
34
35
 
@@ -40,6 +41,7 @@ function usage(): never {
40
41
  vdx init [project_path] [--stack <id>] [--baseline <ref>] [--dry-run] [--force] (default: cwd)
41
42
  vdx publish <patch|minor|major> [--dry-run] [--force]
42
43
  vdx doctor [--format=ansi|markdown|json] [--json]
44
+ vdx ai [project_path] [--restart] [--resume] [--detach] [--dry-run] (default: cwd)
43
45
  `,
44
46
  );
45
47
  process.exit(1);
@@ -140,21 +142,20 @@ function cmdInit(opts: ParsedArgs): void {
140
142
  ...(stackOverride ? { stack: stackOverride } : {}),
141
143
  });
142
144
 
143
- process.stdout.write(renderPlanSummary(plan));
145
+ const dryRun = Boolean(opts.flags['dry-run']);
146
+ process.stdout.write(renderPlanSummary(plan, { verbose: dryRun }));
144
147
 
145
148
  for (const w of plan.warnings) {
146
149
  process.stderr.write(`\n⚠ ${w}\n`);
147
150
  }
148
151
 
149
- if (opts.flags['dry-run']) {
150
- process.stderr.write('\n[dry-run] mise.toml / AGENTS.md не записаны.\n');
152
+ if (dryRun) {
153
+ process.stderr.write('\n[dry-run] mise.toml не записан.\n');
151
154
  return;
152
155
  }
153
156
  try {
154
157
  writeInit(plan, { force: Boolean(opts.flags.force) });
155
- process.stderr.write(
156
- `\n✓ Записаны:\n ${plan.miseTomlPath}\n ${plan.agentsMdPath}\n`,
157
- );
158
+ process.stderr.write(`\n✓ wrote ${plan.miseTomlPath}\n`);
158
159
  } catch (e: any) {
159
160
  process.stderr.write(`\nerror: ${e?.message ?? String(e)}\n`);
160
161
  process.exit(2);
@@ -260,11 +261,30 @@ function cmdDoctor(opts: ParsedArgs): void {
260
261
  if (report.missing > 0) process.exit(2);
261
262
  }
262
263
 
264
+ function cmdAi(opts: ParsedArgs): void {
265
+ // parseArgs hands the token after a bare flag to that flag as its value;
266
+ // these flags take none, so such a token is the project path.
267
+ const positionals = [...opts.positionals];
268
+ const bool = (name: string): boolean => {
269
+ const v = opts.flags[name];
270
+ if (typeof v === 'string') positionals.push(v);
271
+ return v !== undefined;
272
+ };
273
+ const restart = bool('restart');
274
+ const resume = bool('resume');
275
+ const detach = bool('detach');
276
+ const dryRun = bool('dry-run');
277
+ process.exit(
278
+ runAi({ path: positionals[0] ?? '.', restart, resume, detach, dryRun }, defaultDeps()),
279
+ );
280
+ }
281
+
263
282
  const parsed = parseArgs(process.argv);
264
283
  if (parsed.cmd === 'audit') cmdAudit(parsed);
265
284
  else if (parsed.cmd === 'init') cmdInit(parsed);
266
285
  else if (parsed.cmd === 'publish') cmdPublish(parsed);
267
286
  else if (parsed.cmd === 'doctor') cmdDoctor(parsed);
287
+ else if (parsed.cmd === 'ai') cmdAi(parsed);
268
288
  else if ((LIFECYCLE_VERBS as readonly string[]).includes(parsed.cmd))
269
289
  cmdRun(parsed.cmd as LifecycleVerb);
270
290
  else usage();
package/src/init.ts CHANGED
@@ -2,9 +2,7 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import {
4
4
  Ctx,
5
- fileExists,
6
5
  readJson,
7
- readText,
8
6
  listAllTasks,
9
7
  autoDetectStack,
10
8
  findSubPackages,
@@ -14,7 +12,7 @@ import {
14
12
  export const STANDARD_VERBS = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
15
13
  export type Verb = (typeof STANDARD_VERBS)[number];
16
14
 
17
- export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v0.3.1';
15
+ export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v0.4.0';
18
16
 
19
17
  const VERB_ALIASES: Record<Verb, string[]> = {
20
18
  up: ['docker:up', 'docker-up', 'docker:up:detached', 'start', 'dev', 'serve'],
@@ -124,8 +122,6 @@ export interface InitPlan {
124
122
  mappings: VerbMapping[];
125
123
  miseTomlPath: string;
126
124
  miseTomlContent: string;
127
- agentsMdPath: string;
128
- agentsMdContent: string;
129
125
  baseline: string;
130
126
  /** Human-readable advisories for the user — printed to stderr by the CLI. */
131
127
  warnings: string[];
@@ -299,7 +295,7 @@ export function renderMiseToml(
299
295
  stack +
300
296
  '". Замени на php/node/go/python/meta',
301
297
  );
302
- out.push('# и заполни [tasks.*] блоки руками, либо перегенери: vdx init . --stack <id>.');
298
+ out.push('# и заполни [tasks.*] блоки руками, либо перегенери: vdx init --stack <id>.');
303
299
  out.push('');
304
300
  }
305
301
 
@@ -340,52 +336,6 @@ export function renderMiseToml(
340
336
  return out.join('\n');
341
337
  }
342
338
 
343
- const AGENTS_MARKER_OPEN = '<!-- vdx:commands -->';
344
- const AGENTS_MARKER_CLOSE = '<!-- /vdx:commands -->';
345
-
346
- export function renderAgentsCommandsSection(
347
- mappings: VerbMapping[],
348
- baseline: string,
349
- ): string {
350
- const lines: string[] = [];
351
- lines.push(AGENTS_MARKER_OPEN);
352
- lines.push('## Commands');
353
- lines.push('');
354
- lines.push(
355
- `This project follows the vdx lifecycle interface (baseline: ${baseline}).`,
356
- );
357
- lines.push('');
358
- lines.push('| Verb | What it does | Native command |');
359
- lines.push('|------|--------------|----------------|');
360
- for (const m of mappings) {
361
- if (!m.runCommand) {
362
- lines.push(`| \`mise run ${m.verb}\` | ${VERB_DESCRIPTIONS[m.verb]} | _(not mapped)_ |`);
363
- } else {
364
- lines.push(
365
- `| \`mise run ${m.verb}\` | ${VERB_DESCRIPTIONS[m.verb]} | \`${m.runCommand}\` |`,
366
- );
367
- }
368
- }
369
- lines.push(AGENTS_MARKER_CLOSE);
370
- return lines.join('\n');
371
- }
372
-
373
- export function buildAgentsMd(existing: string | null, section: string): string {
374
- if (existing === null) {
375
- return `# AGENTS\n\nProject agent guide.\n\n${section}\n`;
376
- }
377
- const openIdx = existing.indexOf(AGENTS_MARKER_OPEN);
378
- const closeIdx = existing.indexOf(AGENTS_MARKER_CLOSE);
379
- if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) {
380
- const before = existing.slice(0, openIdx);
381
- const after = existing.slice(closeIdx + AGENTS_MARKER_CLOSE.length);
382
- return before + section + after;
383
- }
384
- // No markers — append.
385
- const sep = existing.endsWith('\n') ? '\n' : '\n\n';
386
- return existing + sep + section + '\n';
387
- }
388
-
389
339
  export function planInit(
390
340
  projectRoot: string,
391
341
  opts: { baseline?: string; stack?: string } = {},
@@ -463,11 +413,6 @@ export function planInit(
463
413
  const baseline = opts.baseline ?? DEFAULT_BASELINE;
464
414
  const miseTomlContent = renderMiseToml(tools, mappings, stack, baseline, primarySubpackage);
465
415
 
466
- const agentsMdPath = path.join(projectRoot, 'AGENTS.md');
467
- const existingAgents = fileExists(rootCtx, 'AGENTS.md') ? readText(rootCtx, 'AGENTS.md') : null;
468
- const section = renderAgentsCommandsSection(mappings, baseline);
469
- const agentsMdContent = buildAgentsMd(existingAgents, section);
470
-
471
416
  let pkgManager: InitPlan['pkgManager'] = null;
472
417
  const pmStack = primarySubpackage ? scanStack : stack;
473
418
  const pmRoot = primarySubpackage ? scanRoot : projectRoot;
@@ -484,8 +429,6 @@ export function planInit(
484
429
  mappings,
485
430
  miseTomlPath: path.join(projectRoot, 'mise.toml'),
486
431
  miseTomlContent,
487
- agentsMdPath,
488
- agentsMdContent,
489
432
  baseline,
490
433
  warnings,
491
434
  };
@@ -498,10 +441,24 @@ export function writeInit(plan: InitPlan, opts: { force: boolean }): void {
498
441
  );
499
442
  }
500
443
  fs.writeFileSync(plan.miseTomlPath, plan.miseTomlContent, 'utf8');
501
- fs.writeFileSync(plan.agentsMdPath, plan.agentsMdContent, 'utf8');
502
444
  }
503
445
 
504
- export function renderPlanSummary(plan: InitPlan): string {
446
+ export function renderPlanSummary(plan: InitPlan, opts: { verbose?: boolean } = {}): string {
447
+ const verbose = opts.verbose === true;
448
+ const mapped = plan.mappings.filter((m) => m.runCommand).length;
449
+ const total = plan.mappings.length;
450
+ const unmatched = plan.mappings.filter((m) => !m.runCommand).map((m) => m.verb);
451
+
452
+ if (!verbose) {
453
+ const lines: string[] = [];
454
+ lines.push(`vdx init: stack=${plan.stack}${plan.stackOverridden ? ' (via --stack)' : ''}, ${mapped}/${total} verbs mapped`);
455
+ if (plan.primarySubpackage) lines.push(` primary subpackage: ${plan.primarySubpackage}`);
456
+ if (unmatched.length > 0) {
457
+ lines.push(` not mapped: ${unmatched.join(', ')} (add [tasks.X] in mise.toml manually)`);
458
+ }
459
+ return lines.join('\n') + '\n';
460
+ }
461
+
505
462
  const lines: string[] = [];
506
463
  lines.push(`# vdx init plan`);
507
464
  lines.push('');
@@ -555,7 +512,6 @@ export function renderPlanSummary(plan: InitPlan): string {
555
512
  }
556
513
  lines.push('');
557
514
  }
558
- const unmatched = plan.mappings.filter((m) => !m.runCommand).map((m) => m.verb);
559
515
  if (unmatched.length > 0) {
560
516
  lines.push(`> Not mapped: ${unmatched.join(', ')}. Добавь тиски в \`mise.toml\` вручную.`);
561
517
  lines.push('');
package/src/mcp-server.ts CHANGED
@@ -12,7 +12,7 @@ import { loadManifest, loadOverrides } from './manifest.ts';
12
12
  import { autoDetectStack, readToml, type Ctx } from './facts.ts';
13
13
  import { audit } from './audit.ts';
14
14
  import { reportJson, reportMarkdown } from './report.ts';
15
- import { STANDARD_VERBS, type Verb } from './init.ts';
15
+ import { DEFAULT_BASELINE, STANDARD_VERBS, type Verb } from './init.ts';
16
16
  import { resolveDefaultRubric } from './defaults.ts';
17
17
 
18
18
  const DEFAULT_RUBRIC = resolveDefaultRubric();
@@ -156,7 +156,7 @@ function recordSuccessPath(input: {
156
156
 
157
157
  doc.vdx = doc.vdx ?? {
158
158
  schema_version: '0.2',
159
- baseline: 'github.com/VoDmAl/vdx-rubric-vodmal@v0.3.1',
159
+ baseline: DEFAULT_BASELINE,
160
160
  stack: autoDetectStack(PROJECT_ROOT),
161
161
  };
162
162
  doc.vdx.verbs = input.verbs.map((v) => v.name);
package/src/run.ts CHANGED
@@ -43,14 +43,14 @@ export function renderResolveError(
43
43
  case 'no-mise-toml':
44
44
  return (
45
45
  `vdx: no mise.toml in ${projectRoot}\n` +
46
- `hint: run \`vdx init .\` to generate one, or define it manually.\n`
46
+ `hint: run \`vdx init\` to generate one, or define it manually.\n`
47
47
  );
48
48
  case 'parse-error':
49
49
  return `vdx: failed to parse mise.toml: ${res.detail ?? 'unknown error'}\n`;
50
50
  case 'no-task':
51
51
  return (
52
52
  `vdx: no \`[tasks.${verb}]\` in mise.toml\n` +
53
- `hint: run \`vdx init . --force\` to re-detect, or add the task manually.\n`
53
+ `hint: run \`vdx init --force\` to re-detect, or add the task manually.\n`
54
54
  );
55
55
  }
56
56
  }