@vodmal/vdx-cli 0.13.1 → 0.15.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
@@ -26,7 +26,7 @@ ephemeral environment.
26
26
 
27
27
  **First command after install: `vdx doctor`** — it inspects your environment
28
28
  (Node version, `vdx` on PATH, git, mise, npm auth, container runtime, Claude
29
- Code plugin) and points to remedies for everything that's missing or
29
+ Code plugin; in a project — git hooks and the commit author) and points to remedies for everything that's missing or
30
30
  sub-optimal.
31
31
 
32
32
  ## Use
@@ -48,7 +48,7 @@ vdx publish <patch|minor|major> [--dry-run] [--force]
48
48
  vdx doctor [--format=ansi|markdown|json]
49
49
 
50
50
  # Start your agent in a project per your profile (~/.vdx-environment.yaml or $VDX_ENVIRONMENT)
51
- vdx ai [project-path] [--new] [--restart] [--detach] [--dry-run] # default: cwd
51
+ vdx ai[@host] [project-path] [--new] [--restart] [--detach] [--dry-run] # default: cwd; @host: over ssh, in tmux there
52
52
 
53
53
  # Print the installed version
54
54
  vdx --version
@@ -73,11 +73,20 @@ vdx publish minor # bump → npm publish (OTP prompt) → git commit + tag (no
73
73
  # 4. Start the agent here, continuing its last conversation (--new: a new one);
74
74
  # re-run to attach, --restart to apply a changed profile
75
75
  vdx ai
76
+
77
+ # 5. The same on another machine (an ssh host with vdx), attaching from here
78
+ vdx ai@m3
76
79
  ```
77
80
 
78
81
  `vdx ai` reads a personal profile, never a bundled one: `$VDX_ENVIRONMENT`, else
79
82
  `~/.vdx-environment.yaml`, else a plain `claude`/`codex` with no flags. Format:
80
83
  [docs/specs/environment-format.md](../docs/specs/environment-format.md).
84
+ The session's `{project}` is `[vdx] name` from the project's `mise.toml`, else the
85
+ first file in the profile's `session.project_names` that has one, else the repo
86
+ name. `vdx ai@<host>` runs the same command on `<host>` over ssh — that machine's vdx,
87
+ profile and tmux, the path taken under its home directory; `--detach` only
88
+ starts it. The machine's own label (`$VDX_HOST`) runs here. Where git knows no
89
+ commit author, `vdx ai` proposes one (Enter writes it into `.git/config`).
81
90
 
82
91
  By default the bundled `rubric/vdx-rubric.yaml` is used (a mirror of canonical
83
92
  [vdx-rubric-vodmal](https://github.com/VoDmAl/vdx-rubric-vodmal) at the time of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.13.1",
3
+ "version": "0.15.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.4.0"
14
+ version: "0.6.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 CHANGED
@@ -4,7 +4,9 @@ import * as path from 'node:path';
4
4
  import { execFileSync, spawnSync } from 'node:child_process';
5
5
  import YAML from 'js-yaml';
6
6
  import { evalPredicate } from './evaluator.ts';
7
- import { autoDetectStack, type Ctx } from './facts.ts';
7
+ import { autoDetectStack, readStructured, resolveConfigPath, type Ctx } from './facts.ts';
8
+ import { loadManifest } from './manifest.ts';
9
+ import { type Author, effectiveAuthor, isGitRepo, rankAuthors, scanPool, writeAuthor } from './author.ts';
8
10
  import type { Predicate } from './rubric.ts';
9
11
 
10
12
  /**
@@ -46,9 +48,23 @@ export interface AgentProfile {
46
48
 
47
49
  export type Multiplexer = 'tmux' | 'none';
48
50
 
51
+ /** A place a project's short name may come from: a structured file and a dot path into it. */
52
+ export interface NameSource {
53
+ /** `{repo}` is the repository name; `~` the home directory; relative to the profile's directory. */
54
+ file: string;
55
+ jsonpath: string;
56
+ }
57
+
49
58
  export interface SessionProfile {
50
59
  multiplexer?: Multiplexer;
51
60
  name?: string;
61
+ /** Tried after `[vdx] name` in the project's mise.toml; the first non-empty string wins. */
62
+ project_names?: NameSource[];
63
+ }
64
+
65
+ export interface GitProfile {
66
+ /** Directories whose repos are the neighbours an author is proposed from; default — the project's parent. */
67
+ author_pool?: string[];
52
68
  }
53
69
 
54
70
  export interface Environment {
@@ -56,6 +72,7 @@ export interface Environment {
56
72
  metadata?: Record<string, unknown>;
57
73
  agent?: AgentProfile;
58
74
  session?: SessionProfile;
75
+ git?: GitProfile;
59
76
  }
60
77
 
61
78
  export const ENVIRONMENT_ENV_VAR = 'VDX_ENVIRONMENT';
@@ -137,6 +154,27 @@ export function parseEnvironment(text: string, source: string): Environment {
137
154
  if (session.name !== undefined && typeof session.name !== 'string') {
138
155
  fail('session.name', 'must be a string');
139
156
  }
157
+ if (session.project_names !== undefined) {
158
+ if (!Array.isArray(session.project_names)) fail('session.project_names', 'must be a list');
159
+ session.project_names.forEach((src, i) => {
160
+ const at = `session.project_names[${i}]`;
161
+ if (typeof src !== 'object' || src === null) fail(at, 'must be a mapping');
162
+ if (typeof src.file !== 'string' || src.file === '') fail(`${at}.file`, 'must be a non-empty string');
163
+ const unknown = src.file.match(/\{(?!repo\})\w+\}/);
164
+ if (unknown) fail(`${at}.file`, `has an unknown placeholder ${unknown[0]} (only {repo})`);
165
+ if (typeof src.jsonpath !== 'string' || src.jsonpath === '') {
166
+ fail(`${at}.jsonpath`, 'must be a non-empty string (a dot path, e.g. names.0)');
167
+ }
168
+ });
169
+ }
170
+ }
171
+
172
+ const gitDoc = envDoc.git;
173
+ if (gitDoc !== undefined) {
174
+ if (typeof gitDoc !== 'object' || gitDoc === null) fail('git', 'must be a mapping');
175
+ if (gitDoc.author_pool !== undefined && !isStringList(gitDoc.author_pool)) {
176
+ fail('git.author_pool', 'must be a list of directories');
177
+ }
140
178
  }
141
179
  return envDoc;
142
180
  }
@@ -172,6 +210,8 @@ export function renderSessionName(template: string, vars: Record<string, string>
172
210
  export interface LaunchPlan {
173
211
  projectRoot: string;
174
212
  project: string;
213
+ /** Where `project` came from: mise.toml, a profile source, or the repository name. */
214
+ projectSource: string;
175
215
  host: string;
176
216
  profilePath: string | null;
177
217
  command: string;
@@ -190,6 +230,7 @@ export function planLaunch(input: {
190
230
  agent: AgentProfile;
191
231
  projectRoot: string;
192
232
  project: string;
233
+ projectSource?: string;
193
234
  host: string;
194
235
  }): LaunchPlan {
195
236
  const { environment, agent, projectRoot } = input;
@@ -206,6 +247,7 @@ export function planLaunch(input: {
206
247
  return {
207
248
  projectRoot,
208
249
  project: input.project,
250
+ projectSource: input.projectSource ?? 'repository name',
209
251
  host: input.host,
210
252
  profilePath: input.profilePath,
211
253
  command: agent.command,
@@ -384,6 +426,40 @@ export function projectIdentity(projectRoot: string): string {
384
426
  return path.basename(projectRoot);
385
427
  }
386
428
 
429
+ export interface ProjectName {
430
+ name: string;
431
+ source: string;
432
+ }
433
+
434
+ /**
435
+ * `{project}`: `[vdx] name` in the project's mise.toml, else the first profile
436
+ * source holding a non-empty string, else the repository name. A source that is
437
+ * missing or unreadable is skipped — a name is a label, not a reason to fail.
438
+ */
439
+ export function resolveProjectName(input: {
440
+ projectRoot: string;
441
+ repo: string;
442
+ sources: NameSource[];
443
+ home: string;
444
+ profileDir: string;
445
+ }): ProjectName {
446
+ const own = loadManifest(input.projectRoot)?.name;
447
+ if (typeof own === 'string' && own.trim()) return { name: own.trim(), source: 'mise.toml [vdx] name' };
448
+ for (const src of input.sources) {
449
+ const file = path.resolve(
450
+ input.profileDir,
451
+ src.file.replace(/^~(?=$|\/)/, input.home).replace(/\{repo\}/g, input.repo),
452
+ );
453
+ // Readers take paths relative to a project root; from "/" an absolute path stays itself.
454
+ const value = resolveConfigPath(readStructured({ projectRoot: '/', stack: '', cache: new Map() }, file), src.jsonpath);
455
+ if (typeof value === 'string' && value.trim()) {
456
+ const shown = file.startsWith(input.home + path.sep) ? `~${file.slice(input.home.length)}` : file;
457
+ return { name: value.trim(), source: `${shown} ${src.jsonpath}` };
458
+ }
459
+ }
460
+ return { name: input.repo, source: 'repository name' };
461
+ }
462
+
387
463
  /** `{host}`: `$VDX_HOST` when set (a machine label such as `lft`), else the short hostname. */
388
464
  export function hostLabel(env: NodeJS.ProcessEnv = process.env): string {
389
465
  const label = env[HOST_ENV_VAR]?.trim();
@@ -522,12 +598,37 @@ export interface AiDeps {
522
598
  processes: () => ProcInfo[];
523
599
  sleep: (ms: number) => void;
524
600
  confirmTimeoutMs: number;
601
+ /** One line from the person at the terminal, or null when there is none. */
602
+ ask: (prompt: string) => string | null;
525
603
  }
526
604
 
527
605
  export function sleepSync(ms: number): void {
528
606
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
529
607
  }
530
608
 
609
+ /** Read one line from the terminal, synchronously: the prompt runs before tmux takes it. */
610
+ export function askLine(prompt: string): string | null {
611
+ process.stderr.write(prompt);
612
+ const buf = Buffer.alloc(256);
613
+ let text = '';
614
+ for (;;) {
615
+ let n: number;
616
+ try {
617
+ n = fs.readSync(0, buf, 0, buf.length, null);
618
+ } catch (e: any) {
619
+ if (e?.code === 'EAGAIN') {
620
+ sleepSync(50);
621
+ continue;
622
+ }
623
+ return null;
624
+ }
625
+ if (n === 0) return text === '' ? null : text.trim();
626
+ text += buf.toString('utf8', 0, n);
627
+ const nl = text.indexOf('\n');
628
+ if (nl >= 0) return text.slice(0, nl).trim();
629
+ }
630
+ }
631
+
531
632
  export function defaultDeps(): AiDeps {
532
633
  return {
533
634
  env: process.env,
@@ -541,6 +642,7 @@ export function defaultDeps(): AiDeps {
541
642
  processes: listProcesses,
542
643
  sleep: sleepSync,
543
644
  confirmTimeoutMs: 30_000,
645
+ ask: askLine,
544
646
  };
545
647
  }
546
648
 
@@ -559,6 +661,7 @@ export const NOTHING_TO_RESUME_MS = 30_000;
559
661
  export function renderPlan(plan: LaunchPlan, multiplexer: Multiplexer, running: AgentPane[]): string {
560
662
  const lines = [
561
663
  `vdx ai — ${plan.projectRoot}`,
664
+ ` name: ${plan.project} (${plan.projectSource})`,
562
665
  ` profile: ${plan.profilePath ?? `none — built-in default (${FALLBACK_AGENTS.join(' or ')}, no flags, no tmux)`}`,
563
666
  ` agent: ${commandLine(plan.command, plan.args)}`,
564
667
  ` resume: ${plan.resumeArgs.length ? plan.resumeArgs.join(' ') : '—'}`,
@@ -693,6 +796,116 @@ function finish(ok: boolean, session: string, paneId: string, opts: AiOptions, d
693
796
  return ok ? EXIT_OK : EXIT_LAUNCH_FAILED;
694
797
  }
695
798
 
799
+ /** `vdx ai@<host>`: an ssh destination such as `m3` — never an option to ssh. */
800
+ export const REMOTE_HOST_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
801
+
802
+ /** A path under the local home as a shell word under the remote home; any other path as is. */
803
+ export function remotePathWord(abs: string, home: string): string {
804
+ const rel = path.relative(home, abs);
805
+ if (rel === '') return '"$HOME"';
806
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return shellQuote(abs);
807
+ return `"$HOME"/${shellQuote(rel)}`;
808
+ }
809
+
810
+ /**
811
+ * The ssh argv for `vdx ai@<host>`: the same `vdx ai` on the host, in its own
812
+ * tmux there. The path goes first: an older vdx hands the token after a flag it
813
+ * does not know to that flag. A different vdx version there is named, not fatal.
814
+ */
815
+ export function remoteAiArgs(input: {
816
+ host: string;
817
+ projectPath: string;
818
+ home: string;
819
+ version: string;
820
+ opts: AiOptions;
821
+ tty: boolean;
822
+ }): string[] {
823
+ const { host, opts, version } = input;
824
+ const flags = [
825
+ opts.fresh && '--new',
826
+ opts.restart && '--restart',
827
+ opts.detach && '--detach',
828
+ opts.dryRun && '--dry-run',
829
+ ].filter((f): f is string => Boolean(f));
830
+ const v = shellQuote(version);
831
+ const script =
832
+ `v=$(vdx --version 2>/dev/null) || v='older than 0.13.1'; ` +
833
+ `[ "$v" = ${v} ] || echo "note: vdx on ${host} is $v, here "${v} >&2; ` +
834
+ `exec vdx ai ${[remotePathWord(input.projectPath, input.home), ...flags].join(' ')}`;
835
+ return [input.tty ? '-t' : '-T', host, script];
836
+ }
837
+
838
+ /** Where neighbour repos live: the profile's `git.author_pool`, else the project's parent directory. */
839
+ export function authorPoolDirs(environment: Environment, projectRoot: string, home: string, profileDir: string): string[] {
840
+ const listed = environment.git?.author_pool;
841
+ if (!listed?.length) return [path.dirname(projectRoot)];
842
+ return listed.map((d) => path.resolve(profileDir, d.replace(/^~(?=$|\/)/, home)));
843
+ }
844
+
845
+ /**
846
+ * A repo without its own author makes git refuse to commit (`user.useConfigOnly`).
847
+ * At a terminal vdx proposes the likely author and Enter takes the first;
848
+ * without one it says so and goes on — a scripted start must not stop here.
849
+ * A running agent needs no restart: git reads the author at each commit.
850
+ */
851
+ export function ensureAuthor(projectRoot: string, poolDirs: string[], deps: AiDeps, dryRun: boolean): void {
852
+ if (!isGitRepo(projectRoot)) return;
853
+ const current = effectiveAuthor(projectRoot);
854
+ if (current) {
855
+ if (dryRun) deps.out(` author: ${current.email} (${current.scope})\n`);
856
+ return;
857
+ }
858
+ const top = rankAuthors(projectRoot, scanPool(poolDirs)).slice(0, 3);
859
+ const q = shellQuote(projectRoot);
860
+ const command = (a: Author) =>
861
+ `git -C ${q} config --local user.name ${shellQuote(a.name)} && git -C ${q} config --local user.email ${shellQuote(a.email)}`;
862
+ if (dryRun) {
863
+ const proposal = top.length ? `; would propose ${top.map((c) => c.email).join(', ')}` : '';
864
+ deps.out(` author: none — git refuses to commit here${proposal}\n`);
865
+ return;
866
+ }
867
+ deps.log(`⚠ ${path.basename(projectRoot)}: no commit author of its own — git will refuse to commit here`);
868
+ if (!deps.interactive || top.length === 0) {
869
+ deps.log(` set it: ${top[0] ? command(top[0]) : `git -C ${q} config --local user.email <address>`}`);
870
+ return;
871
+ }
872
+ top.forEach((c, i) => deps.log(` ${i + 1}) ${c.name} <${c.email}>${c.why.length ? ` ${c.why.join('; ')}` : ''}`));
873
+ deps.log(' s) skip');
874
+ for (let tries = 0; tries < 2; tries++) {
875
+ const answer = deps.ask('Author [1]: ');
876
+ if (answer === null) return;
877
+ const a = answer.trim();
878
+ if (/^[sn]$/i.test(a)) {
879
+ deps.log(' skipped');
880
+ return;
881
+ }
882
+ const pick: Author | undefined =
883
+ a === '' ? top[0] : /^\d+$/.test(a) ? top[Number(a) - 1] : a.includes('@') ? { name: top[0]!.name, email: a } : undefined;
884
+ if (pick) {
885
+ if (writeAuthor(projectRoot, pick)) deps.log(`✓ ${pick.name} <${pick.email}> → .git/config`);
886
+ else deps.log(`✗ could not write .git/config — ${command(pick)}`);
887
+ return;
888
+ }
889
+ deps.log(` 1–${top.length}, an address, or s`);
890
+ }
891
+ }
892
+
893
+ export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, version: string): number {
894
+ const projectPath = resolveProjectRoot(opts.path) ?? path.resolve(opts.path);
895
+ const tty = deps.interactive && !opts.detach && !opts.dryRun;
896
+ const args = remoteAiArgs({ host, projectPath, home: deps.home, version, opts, tty });
897
+ deps.log(`→ ${host}: vdx ai ${shellQuote(projectPath)}`);
898
+ const res = spawnSync('ssh', args, { stdio: 'inherit' });
899
+ if ((res.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
900
+ deps.log('vdx ai: `ssh` not found on PATH');
901
+ return 127;
902
+ }
903
+ if (opts.detach && res.status === EXIT_OK) {
904
+ deps.log(`attach from here: vdx ai@${host} ${shellQuote(projectPath)}`);
905
+ }
906
+ return res.status ?? 1;
907
+ }
908
+
696
909
  export function runAi(opts: AiOptions, deps: AiDeps): number {
697
910
  const { log, tmux } = deps;
698
911
 
@@ -722,6 +935,15 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
722
935
  return EXIT_USAGE;
723
936
  }
724
937
 
938
+ const profileDir = profilePath ? path.dirname(profilePath) : deps.home;
939
+ const named = resolveProjectName({
940
+ projectRoot,
941
+ repo: projectIdentity(projectRoot),
942
+ sources: environment.session?.project_names ?? [],
943
+ home: deps.home,
944
+ profileDir,
945
+ });
946
+
725
947
  let plan: LaunchPlan;
726
948
  try {
727
949
  plan = planLaunch({
@@ -729,7 +951,8 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
729
951
  profilePath,
730
952
  agent,
731
953
  projectRoot,
732
- project: projectIdentity(projectRoot),
954
+ project: named.name,
955
+ projectSource: named.source,
733
956
  host: hostLabel(deps.env),
734
957
  });
735
958
  } catch (e: any) {
@@ -750,10 +973,13 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
750
973
  ? findAgentPanes(tmux.panes(), deps.processes(), projectRoot, plan.command)
751
974
  : [];
752
975
 
976
+ const poolDirs = authorPoolDirs(environment, projectRoot, deps.home, profileDir);
753
977
  if (opts.dryRun) {
754
978
  deps.out(renderPlan(plan, multiplexer, running));
979
+ ensureAuthor(projectRoot, poolDirs, deps, true);
755
980
  return EXIT_OK;
756
981
  }
982
+ ensureAuthor(projectRoot, poolDirs, deps, false);
757
983
 
758
984
  if (multiplexer === 'none') {
759
985
  if (opts.restart) {
package/src/author.ts ADDED
@@ -0,0 +1,192 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+
5
+ /**
6
+ * Who commits in a repo. The rule this serves: every repo carries its own
7
+ * author in `.git/config`, and git refuses where there is none
8
+ * (`user.useConfigOnly`). When a repo has none, vdx proposes one — the
9
+ * addresses some repo in the pool already carries, ranked by this repo's
10
+ * history, repos in the same remote group, and repos with a similar name.
11
+ */
12
+
13
+ export interface Author {
14
+ name: string;
15
+ email: string;
16
+ }
17
+
18
+ export interface PoolRepo {
19
+ dir: string;
20
+ author: Author | null;
21
+ host: string;
22
+ group: string;
23
+ tokens: Set<string>;
24
+ }
25
+
26
+ export interface AuthorCandidate extends Author {
27
+ score: number;
28
+ why: string[];
29
+ }
30
+
31
+ function git(dir: string, args: string[]): string | null {
32
+ try {
33
+ return execFileSync('git', ['--no-optional-locks', '-C', dir, ...args], {
34
+ encoding: 'utf8',
35
+ stdio: ['ignore', 'pipe', 'ignore'],
36
+ }).trim();
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ export function isGitRepo(dir: string): boolean {
43
+ return git(dir, ['rev-parse', '--git-dir']) !== null;
44
+ }
45
+
46
+ /** The address git would commit with here, and the scope it comes from; null when git knows none. */
47
+ export function effectiveAuthor(dir: string): { email: string; scope: string } | null {
48
+ const out = git(dir, ['config', '--show-scope', '--get', 'user.email']);
49
+ if (!out) return null;
50
+ const [scope, email] = out.split('\t');
51
+ return email ? { email, scope: scope ?? '' } : null;
52
+ }
53
+
54
+ /** `git@host:group/repo.git`, `ssh://git@host:7999/group/repo.git` → host and group ("" when none). */
55
+ export function remoteParts(url: string): { host: string; group: string; path: string } {
56
+ const m = url.match(/^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:]+)(?::\d+)?[:/](.*?)(?:\.git)?\/?$/);
57
+ if (!m) return { host: '', group: '', path: '' };
58
+ const p = m[2]!;
59
+ return { host: m[1]!, group: p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '', path: p };
60
+ }
61
+
62
+ /** Words that say nothing about whose repo it is. */
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',
66
+ ]);
67
+
68
+ export function nameTokens(dir: string, remotePath: string): Set<string> {
69
+ return new Set(
70
+ `${path.basename(dir)} ${remotePath}`
71
+ .toLowerCase()
72
+ .split(/[^a-z0-9]+/)
73
+ .filter((t) => t.length > 2 && !GENERIC.has(t)),
74
+ );
75
+ }
76
+
77
+ function describe(dir: string): PoolRepo {
78
+ const cfg = git(dir, ['config', '--local', '--get-regexp', '^(user\\.(name|email)|remote\\.origin\\.url)$']) ?? '';
79
+ const val = (key: string) =>
80
+ cfg.split('\n').find((l) => l.toLowerCase().startsWith(`${key} `))?.slice(key.length + 1).trim() ?? '';
81
+ const email = val('user.email');
82
+ const remote = remoteParts(val('remote.origin.url'));
83
+ return {
84
+ dir,
85
+ author: email ? { name: val('user.name'), email: email.toLowerCase() } : null,
86
+ host: remote.host,
87
+ group: remote.group,
88
+ tokens: nameTokens(dir, remote.path),
89
+ };
90
+ }
91
+
92
+ /** Git repos one level under each directory; the directories themselves are not repos. */
93
+ export function scanPool(dirs: string[]): PoolRepo[] {
94
+ const seen = new Set<string>();
95
+ const out: PoolRepo[] = [];
96
+ for (const d of dirs) {
97
+ let entries: fs.Dirent[];
98
+ try {
99
+ entries = fs.readdirSync(d, { withFileTypes: true });
100
+ } catch {
101
+ continue;
102
+ }
103
+ for (const e of entries) {
104
+ const dir = path.join(d, e.name);
105
+ if (!e.isDirectory() || seen.has(dir) || !fs.existsSync(path.join(dir, '.git'))) continue;
106
+ seen.add(dir);
107
+ out.push(describe(dir));
108
+ }
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /**
114
+ * Weights measured 2026-09-30 against the owner's 38 repos with their own
115
+ * author, each guessed from the others: the right address came first in 31,
116
+ * among the first three in 37.
117
+ */
118
+ const W_HISTORY = 1.0;
119
+ const W_GROUP = 0.3;
120
+ const W_HOST = 0.1;
121
+ const W_TOKEN = 0.4;
122
+
123
+ /** The pool's addresses, best first. Only addresses some pool repo carries are proposed. */
124
+ export function rankAuthors(dir: string, pool: PoolRepo[]): AuthorCandidate[] {
125
+ const self = describe(dir);
126
+ const others = pool.filter((p) => p.author && path.resolve(p.dir) !== path.resolve(dir));
127
+ const names = new Map<string, Map<string, number>>();
128
+ for (const p of others) {
129
+ const byName = names.get(p.author!.email) ?? new Map<string, number>();
130
+ if (p.author!.name) byName.set(p.author!.name, (byName.get(p.author!.name) ?? 0) + 1);
131
+ names.set(p.author!.email, byName);
132
+ }
133
+ if (names.size === 0) return [];
134
+
135
+ const score = new Map<string, number>();
136
+ const why = new Map<string, string[]>();
137
+ const add = (email: string, s: number, reason?: string) => {
138
+ score.set(email, (score.get(email) ?? 0) + s);
139
+ if (reason) why.set(email, [...(why.get(email) ?? []), reason]);
140
+ };
141
+
142
+ // Counted by git: a per-commit log of a large repo overflows the output buffer.
143
+ const history = new Map<string, number>();
144
+ for (const line of (git(dir, ['shortlog', '-se', '--all']) ?? '').split('\n')) {
145
+ const m = line.match(/^\s*(\d+)\s+.*<([^>]*)>\s*$/);
146
+ const e = m?.[2]?.toLowerCase();
147
+ if (e && names.has(e)) history.set(e, (history.get(e) ?? 0) + Number(m![1]));
148
+ }
149
+ const total = [...history.values()].reduce((a, b) => a + b, 0);
150
+ for (const [e, n] of history) add(e, (W_HISTORY * n) / total, `history ${n}`);
151
+
152
+ const group = new Map<string, number>();
153
+ const similar = new Map<string, string[]>();
154
+ for (const p of others) {
155
+ const e = p.author!.email;
156
+ if (self.host && p.host === self.host) {
157
+ const same = Boolean(self.group) && p.group === self.group;
158
+ add(e, same ? W_GROUP : W_HOST);
159
+ if (same) group.set(e, (group.get(e) ?? 0) + 1);
160
+ }
161
+ const overlap = [...self.tokens].filter((t) => p.tokens.has(t)).length;
162
+ if (overlap > 0) {
163
+ add(e, W_TOKEN * overlap);
164
+ similar.set(e, [...(similar.get(e) ?? []), path.basename(p.dir)]);
165
+ }
166
+ }
167
+ for (const [e, n] of group) add(e, 0, `same group ${self.host}/${self.group}: ${n}`);
168
+ for (const [e, list] of similar) add(e, 0, `similar: ${list.slice(0, 3).join(', ')}`);
169
+
170
+ const ranked = [...score.entries()].sort((a, b) => b[1] - a[1]);
171
+ // Addresses with no signal still belong in the menu, after the rest, by how many repos carry them.
172
+ const repos = (e: string) => [...(names.get(e)?.values() ?? [])].reduce((a, b) => a + b, 0);
173
+ const unsignalled = [...names.keys()].filter((e) => !score.has(e)).sort((a, b) => repos(b) - repos(a));
174
+ for (const e of unsignalled) {
175
+ ranked.push([e, 0]);
176
+ why.set(e, [`in ${repos(e)} repos`]);
177
+ }
178
+
179
+ 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
+ });
185
+ }
186
+
187
+ export function writeAuthor(dir: string, author: Author): boolean {
188
+ return (
189
+ git(dir, ['config', '--local', 'user.name', author.name]) !== null &&
190
+ git(dir, ['config', '--local', 'user.email', author.email]) !== null
191
+ );
192
+ }
package/src/doctor.ts CHANGED
@@ -3,6 +3,8 @@ import * as os from 'node:os';
3
3
  import * as path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { execFileSync } from 'node:child_process';
6
+ import { effectiveAuthor, isGitRepo, rankAuthors, scanPool } from './author.ts';
7
+ import { authorPoolDirs, loadEnvironment, resolveEnvironmentPath, shellQuote, type Environment } from './ai.ts';
6
8
 
7
9
  export type CheckStatus = 'ok' | 'warning' | 'missing';
8
10
 
@@ -503,6 +505,40 @@ function checkGitHooks(ctx: DoctorCtx): CheckResult | null {
503
505
  };
504
506
  }
505
507
 
508
+ /**
509
+ * The project's commit author. With `user.useConfigOnly` git refuses to commit
510
+ * in a repo that has none; the remedy names the likely one, as `vdx ai` does.
511
+ */
512
+ export function checkGitAuthor(ctx: DoctorCtx): CheckResult | null {
513
+ const root = ctx.projectRoot;
514
+ if (root === null || !isGitRepo(root)) return null;
515
+ const id = 'git-author';
516
+ const label = 'git author';
517
+ const current = effectiveAuthor(root);
518
+ if (current) return { id, label, status: 'ok', message: `${current.email} (${current.scope})` };
519
+
520
+ let environment: Environment = {};
521
+ const profilePath = resolveEnvironmentPath();
522
+ try {
523
+ if (profilePath) environment = loadEnvironment(profilePath);
524
+ } catch {
525
+ /* a broken profile is vdx ai's report; the parent directory still gives neighbours */
526
+ }
527
+ const home = os.homedir();
528
+ const dirs = authorPoolDirs(environment, root, home, profilePath ? path.dirname(profilePath) : home);
529
+ const top = rankAuthors(root, scanPool(dirs))[0];
530
+ const q = shellQuote(root);
531
+ return {
532
+ id,
533
+ label,
534
+ status: 'warning',
535
+ message: `no author of its own — git refuses to commit here${top ? `; likely ${top.email}` : ''}`,
536
+ remedy: top
537
+ ? `git -C ${q} config --local user.name ${shellQuote(top.name)} && git -C ${q} config --local user.email ${shellQuote(top.email)}`
538
+ : `git -C ${q} config --local user.email <address>`,
539
+ };
540
+ }
541
+
506
542
  const CHECKS: Array<(ctx: DoctorCtx) => CheckResult | null> = [
507
543
  checkVdxVersion,
508
544
  checkVdx,
@@ -514,6 +550,7 @@ const CHECKS: Array<(ctx: DoctorCtx) => CheckResult | null> = [
514
550
  checkNpmAuth,
515
551
  checkContainerRuntime,
516
552
  checkGitHooks,
553
+ checkGitAuthor,
517
554
  ];
518
555
 
519
556
  export function resolveDoctorCtx(cwd: string = process.cwd()): DoctorCtx {
package/src/index.ts CHANGED
@@ -29,7 +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
+ import { runAi, runAiRemote, defaultDeps, hostLabel, REMOTE_HOST_RE } from './ai.ts';
33
33
 
34
34
  const DEFAULT_RUBRIC = resolveDefaultRubric();
35
35
 
@@ -41,7 +41,7 @@ function usage(): never {
41
41
  vdx init [project_path] [--stack <id>] [--baseline <ref>] [--dry-run] [--force] (default: cwd)
42
42
  vdx publish <patch|minor|major> [--dry-run] [--force]
43
43
  vdx doctor [--format=ansi|markdown|json] [--json]
44
- vdx ai [project_path] [--new] [--restart] [--detach] [--dry-run] (default: cwd)
44
+ vdx ai[@host] [project_path] [--new] [--restart] [--detach] [--dry-run] (default: cwd; @host: over ssh, in tmux there)
45
45
  vdx --version
46
46
  `,
47
47
  );
@@ -278,9 +278,19 @@ function cmdAi(opts: ParsedArgs): void {
278
278
  bool('resume');
279
279
  const detach = bool('detach');
280
280
  const dryRun = bool('dry-run');
281
- process.exit(
282
- runAi({ path: positionals[0] ?? '.', restart, fresh, detach, dryRun }, defaultDeps()),
283
- );
281
+ const options = { path: positionals[0] ?? '.', restart, fresh, detach, dryRun };
282
+ // `ai@m3`: the same command on another machine. Its own label runs here.
283
+ const host = opts.cmd.startsWith('ai@') ? opts.cmd.slice(3) : null;
284
+ if (host !== null) {
285
+ if (!REMOTE_HOST_RE.test(host)) {
286
+ process.stderr.write(`vdx ai@host: "${host}" is not a host name (an ssh destination such as m3)\n`);
287
+ process.exit(2);
288
+ }
289
+ if (host.toLowerCase() !== hostLabel().toLowerCase()) {
290
+ process.exit(runAiRemote(options, host, defaultDeps(), readCliVersion()));
291
+ }
292
+ }
293
+ process.exit(runAi(options, defaultDeps()));
284
294
  }
285
295
 
286
296
  const parsed = parseArgs(process.argv);
@@ -289,7 +299,7 @@ else if (parsed.cmd === 'audit') cmdAudit(parsed);
289
299
  else if (parsed.cmd === 'init') cmdInit(parsed);
290
300
  else if (parsed.cmd === 'publish') cmdPublish(parsed);
291
301
  else if (parsed.cmd === 'doctor') cmdDoctor(parsed);
292
- else if (parsed.cmd === 'ai') cmdAi(parsed);
302
+ else if (parsed.cmd === 'ai' || parsed.cmd.startsWith('ai@')) cmdAi(parsed);
293
303
  else if ((LIFECYCLE_VERBS as readonly string[]).includes(parsed.cmd))
294
304
  cmdRun(parsed.cmd as LifecycleVerb);
295
305
  else usage();
package/src/init.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  export const STANDARD_VERBS = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
13
13
  export type Verb = (typeof STANDARD_VERBS)[number];
14
14
 
15
- export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v0.4.0';
15
+ export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v0.6.0';
16
16
 
17
17
  const VERB_ALIASES: Record<Verb, string[]> = {
18
18
  up: ['docker:up', 'docker-up', 'docker:up:detached', 'start', 'dev', 'serve'],
package/src/manifest.ts CHANGED
@@ -5,6 +5,8 @@ import YAML from 'js-yaml';
5
5
 
6
6
  export interface VdxManifest {
7
7
  schema_version?: string;
8
+ /** Short name people use for the project (a session name, a sidebar); defaults to the repository name. */
9
+ name?: string;
8
10
  baseline?: string;
9
11
  stack?: string;
10
12
  primary_language?: string;