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