@vodmal/vdx-cli 0.14.0 → 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
@@ -85,7 +85,8 @@ The session's `{project}` is `[vdx] name` from the project's `mise.toml`, else t
85
85
  first file in the profile's `session.project_names` that has one, else the repo
86
86
  name. `vdx ai@<host>` runs the same command on `<host>` over ssh — that machine's vdx,
87
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.
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`).
89
90
 
90
91
  By default the bundled `rubric/vdx-rubric.yaml` is used (a mirror of canonical
91
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.14.0",
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.5.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
@@ -6,6 +6,7 @@ import YAML from 'js-yaml';
6
6
  import { evalPredicate } from './evaluator.ts';
7
7
  import { autoDetectStack, readStructured, resolveConfigPath, type Ctx } from './facts.ts';
8
8
  import { loadManifest } from './manifest.ts';
9
+ import { type Author, effectiveAuthor, isGitRepo, rankAuthors, scanPool, writeAuthor } from './author.ts';
9
10
  import type { Predicate } from './rubric.ts';
10
11
 
11
12
  /**
@@ -61,11 +62,17 @@ export interface SessionProfile {
61
62
  project_names?: NameSource[];
62
63
  }
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[];
68
+ }
69
+
64
70
  export interface Environment {
65
71
  schema_version?: string;
66
72
  metadata?: Record<string, unknown>;
67
73
  agent?: AgentProfile;
68
74
  session?: SessionProfile;
75
+ git?: GitProfile;
69
76
  }
70
77
 
71
78
  export const ENVIRONMENT_ENV_VAR = 'VDX_ENVIRONMENT';
@@ -161,6 +168,14 @@ export function parseEnvironment(text: string, source: string): Environment {
161
168
  });
162
169
  }
163
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
+ }
178
+ }
164
179
  return envDoc;
165
180
  }
166
181
 
@@ -583,12 +598,37 @@ export interface AiDeps {
583
598
  processes: () => ProcInfo[];
584
599
  sleep: (ms: number) => void;
585
600
  confirmTimeoutMs: number;
601
+ /** One line from the person at the terminal, or null when there is none. */
602
+ ask: (prompt: string) => string | null;
586
603
  }
587
604
 
588
605
  export function sleepSync(ms: number): void {
589
606
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
590
607
  }
591
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
+
592
632
  export function defaultDeps(): AiDeps {
593
633
  return {
594
634
  env: process.env,
@@ -602,6 +642,7 @@ export function defaultDeps(): AiDeps {
602
642
  processes: listProcesses,
603
643
  sleep: sleepSync,
604
644
  confirmTimeoutMs: 30_000,
645
+ ask: askLine,
605
646
  };
606
647
  }
607
648
 
@@ -794,6 +835,61 @@ export function remoteAiArgs(input: {
794
835
  return [input.tty ? '-t' : '-T', host, script];
795
836
  }
796
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
+
797
893
  export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, version: string): number {
798
894
  const projectPath = resolveProjectRoot(opts.path) ?? path.resolve(opts.path);
799
895
  const tty = deps.interactive && !opts.detach && !opts.dryRun;
@@ -839,12 +935,13 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
839
935
  return EXIT_USAGE;
840
936
  }
841
937
 
938
+ const profileDir = profilePath ? path.dirname(profilePath) : deps.home;
842
939
  const named = resolveProjectName({
843
940
  projectRoot,
844
941
  repo: projectIdentity(projectRoot),
845
942
  sources: environment.session?.project_names ?? [],
846
943
  home: deps.home,
847
- profileDir: profilePath ? path.dirname(profilePath) : deps.home,
944
+ profileDir,
848
945
  });
849
946
 
850
947
  let plan: LaunchPlan;
@@ -876,10 +973,13 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
876
973
  ? findAgentPanes(tmux.panes(), deps.processes(), projectRoot, plan.command)
877
974
  : [];
878
975
 
976
+ const poolDirs = authorPoolDirs(environment, projectRoot, deps.home, profileDir);
879
977
  if (opts.dryRun) {
880
978
  deps.out(renderPlan(plan, multiplexer, running));
979
+ ensureAuthor(projectRoot, poolDirs, deps, true);
881
980
  return EXIT_OK;
882
981
  }
982
+ ensureAuthor(projectRoot, poolDirs, deps, false);
883
983
 
884
984
  if (multiplexer === 'none') {
885
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/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.5.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'],