@vodmal/vdx-cli 0.21.1 → 0.22.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,8 +26,9 @@ 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; in a project — git hooks and the commit author) and points to remedies for everything that's missing or
30
- sub-optimal.
29
+ Code plugin; in a project — whether its git hooks are on in this clone, and the
30
+ commit author; with a profile — whether your personal hooks run on this machine)
31
+ and points to remedies for everything that's missing or sub-optimal.
31
32
 
32
33
  ## Use
33
34
 
@@ -44,8 +45,10 @@ vdx init [project-path] [--baseline github.com/org/repo@vX.Y] [--stack <id>] [-
44
45
  # Publish a library (Node MVP; PHP/Python coming in Y.3)
45
46
  vdx publish <patch|minor|major> [--dry-run] [--force] [--no-push]
46
47
 
47
- # Environment self-check (Node / git / mise / npm auth / docker / Claude Code plugin)
48
- vdx doctor [--format=ansi|markdown|json]
48
+ # Environment self-check (Node / git / mise / npm auth / docker / Claude Code plugin; git hooks of the project)
49
+ vdx doctor [project-path] [--format=ansi|markdown|json] # default: cwd
50
+ vdx doctor --check [project-path] # one line per hook declared but not running here — the plugin's SessionStart hook tells the agent
51
+ vdx doctor --fix # write the profile's personal hooks (git.hooks) into ~/.gitconfig
49
52
 
50
53
  # Start your agent in a project per your profile (~/.vdx-environment.yaml or $VDX_ENVIRONMENT)
51
54
  vdx ai[@host] [project-path] [--new | --conversation <id>] [--restart] [--detach] [--dry-run] # default: cwd; @host: over ssh, in tmux there
@@ -123,9 +126,14 @@ npm run typecheck
123
126
  - `src/rubric.ts` — types + YAML loader
124
127
  - `src/manifest.ts` — parser for `[vdx]` block in `mise.toml` + `.vdx-overrides.yml`
125
128
  - `src/facts.ts` — fact source loaders (tasks, packages, configs, sub-package detection)
126
- - `src/predicates.ts` — predicate registry (11 functions)
129
+ - `src/predicates.ts` — predicate registry; true / false / unknown with a reason (schema 0.3)
127
130
  - `src/evaluator.ts` — recursive evaluator + sugar notation
128
- - `src/scoring.ts` — delta-style levels, flags for orthogonal axes
131
+ - `src/scoring.ts` — delta-style levels, `not_required` levels, flags for orthogonal axes
132
+ - `src/gha.ts` — GitHub Actions workflows as the `ci` axis reads them
133
+ - `src/vocabulary.ts` — which project tasks a command calls through the project's runner
134
+ - `src/hooks.ts` — one recognizer of hook frameworks for audit and doctor
135
+ - `src/clone-checks.ts` — clone checks printed by doctor and next to an axis in audit
136
+ - `src/personal-hooks.ts` — the profile's personal hooks in ~/.gitconfig: check and `--fix`
129
137
  - `src/audit.ts` — orchestrator: overrides + applies_to filter + subpackage-ctx
130
138
  - `src/init.ts` — `vdx init` planner (`selectVerbTask` + mise.toml renderer)
131
139
  - `src/run.ts` — `resolveLifecycleVerb` + error renderer (pure logic for `vdx <verb>`)
@@ -139,7 +147,9 @@ npm run typecheck
139
147
 
140
148
  ## Not implemented yet
141
149
 
142
- - `command_succeeds` predicate is a stub (warning at evaluation).
150
+ - Above L1 the `ci` axis reads GitHub Actions only; other CI systems answer
151
+ "unknown". `branch-protection` is "unknown" everywhere until a hosting
152
+ extension exists.
143
153
  - Baseline loading from a git ref is documented but evaluator still reads file
144
154
  paths only — `baseline:` in `mise.toml` is recorded but does not auto-fetch.
145
155
  - 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.21.1",
3
+ "version": "0.22.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",
@@ -7,11 +7,11 @@
7
7
  # дельту относительно L(N-1). Уровень оси = max L, для которого истинны
8
8
  # requires(L1), ..., requires(L) подряд (без разрывов).
9
9
 
10
- schema_version: "0.2"
10
+ schema_version: "0.3"
11
11
 
12
12
  metadata:
13
13
  name: vdx-rubric-vodmal
14
- version: "0.7.0"
14
+ version: "1.0.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"
@@ -24,8 +24,8 @@ levels:
24
24
  L0: { name: chaos, description: "Не поднимается, знание в голове" }
25
25
  L1: { name: reproducible, description: "Поднимается одной задокументированной командой" }
26
26
  L2: { name: testable, description: "Тесты есть; единый словарь up/test/build" }
27
- L3: { name: gated, description: "Статанализ + стиль + CI-гейты на PR (реальные!)" }
28
- L4: { name: exemplar, description: "Полная глубина: хуки, mock-инфра, observability, версионированная общая инфра" }
27
+ L3: { name: gated, description: "Статанализ + стиль; CI зовёт словарь проекта; main на хостинге принимает только проверенное" }
28
+ L4: { name: exemplar, description: "Полная глубина: хуки повторяют CI, mock-инфра, observability, версионированная общая инфра" }
29
29
 
30
30
  axes:
31
31
 
@@ -183,13 +183,15 @@ axes:
183
183
 
184
184
  - id: ci
185
185
  class: critical
186
- description: "Реальное гейтование PR (no `|| true`!)"
186
+ description: "Сигнал: тесты на каждый push в основную ветку, падение не замаскировано. Гейт до слияния — ось branch-protection"
187
187
  storage: level
188
188
  default_target: L4
189
189
  fact_sources:
190
190
  - .github/workflows/
191
191
  - .gitlab-ci.yml
192
192
  - .circleci/config.yml
193
+ # Выше L1 vdx читает только GitHub Actions; у прочих CI-систем L2+ —
194
+ # «неизвестно», в общем уровне это «не выполнено».
193
195
  levels:
194
196
  L1:
195
197
  requires:
@@ -197,28 +199,45 @@ axes:
197
199
  - has_file: .github/workflows
198
200
  - has_file: .gitlab-ci.yml
199
201
  - has_file: .circleci/config.yml
202
+ # Тесты на push в main/master (фильтр веток или без него). Шаг с `|| true`
203
+ # или continue-on-error не засчитывается. Только PR-триггер — тоже: в репо
204
+ # с пушами в main такой CI не запускается никогда.
200
205
  L2:
201
206
  requires:
202
- any_of:
203
- - file_contains:
204
- paths: [.github/workflows/*.yml, .gitlab-ci.yml]
205
- pattern: "(phpstan|phpunit|composer test|npm test|jest|pytest)"
207
+ gha_tests_on_push:
208
+ pattern: "\\b(phpunit|pest|composer test|npm test|npm run test|yarn test|pnpm test|jest|vitest|pytest|go test|cargo test|mocha|mix test|rspec|vdx test|mise run test)\\b"
209
+ # Паритет: CI вызывает задачи проекта check и test (или check:* / test:*)
210
+ # через его раннер — то же, что человек и агент запускают локально.
206
211
  L3:
207
212
  requires:
208
- all_of:
209
- - any_of:
210
- - gh_workflow_blocks_pr: {}
211
- - file_contains: { path: .gitlab-ci.yml, pattern: "rules:.+merge_request" }
212
- - not:
213
- file_contains:
214
- paths: [.github/workflows/*.yml, .gitlab-ci.yml]
215
- pattern: "\\|\\| true"
213
+ gha_runs_tasks: { tasks: [check, test] }
214
+ # Ширина или конвейер: проверяющая задача идёт по matrix, либо всё, что CI
215
+ # выкладывает или выпускает (deploy/release/publish), ждёт её через needs:.
216
216
  L4:
217
217
  requires:
218
218
  any_of:
219
- - file_contains: { path: .github/workflows/*.yml, pattern: "matrix:" }
220
- - file_contains: { path: .github/workflows/*.yml, pattern: "sentry-release|action-release" }
221
- - gh_workflow_blocks_pr: { check_name: "deploy" }
219
+ - gha_test_matrix:
220
+ pattern: "\\b(phpunit|pest|composer test|npm test|npm run test|yarn test|pnpm test|jest|vitest|pytest|go test|cargo test|mocha|mix test|rspec|vdx test|mise run test)\\b"
221
+ - gha_ship_needs_checks:
222
+ pattern: "\\b(phpunit|pest|composer test|npm test|npm run test|yarn test|pnpm test|jest|vitest|pytest|go test|cargo test|mocha|mix test|rspec|vdx test|mise run test)\\b"
223
+
224
+ - id: branch-protection
225
+ class: critical
226
+ description: "Основная ветка на хостинге принимает изменения только после обязательной проверки"
227
+ storage: level
228
+ default_target: L4
229
+ fact_sources: [hosting API]
230
+ # Гейт — свойство L3 общей лестницы; ниже ось ничего не требует и уровень
231
+ # не ограничивает. Знает о защите только хостинг: пока расширения для него
232
+ # нет, L3 — «неизвестно» у всех. L4 — позже: force-push, удаление ветки,
233
+ # ревью (как Branch-Protection у Scorecard).
234
+ levels:
235
+ L1: { not_required: true }
236
+ L2: { not_required: true }
237
+ L3:
238
+ requires:
239
+ branch_requires_checks: {}
240
+ L4: { not_required: true }
222
241
 
223
242
  # =========================================================================
224
243
  # SUPPORTING
@@ -364,32 +383,36 @@ axes:
364
383
 
365
384
  - id: git-hygiene
366
385
  class: supporting
386
+ description: "Хуки репозитория: ставятся сами, зовут словарь проекта, повторяют проверки CI"
367
387
  storage: level
368
388
  default_target: L4
369
- fact_sources: [.husky/, .lefthook.yml, lefthook.yml, composer.json, package.json]
389
+ fact_sources: [.gitignore, .husky/, lefthook.yml, .lefthook.yml, .githooks/, package.json, composer.json, mise.toml]
390
+ # Включены ли хуки в этом клоне — пометка рядом с осью в `vdx audit` и строка
391
+ # `vdx doctor`; уровень не меняет: тот же репо и тот же тег дают тот же отчёт
392
+ # в любом клоне.
393
+ clone_check: git-hooks
370
394
  levels:
371
395
  L1:
372
396
  requires:
373
397
  has_file: .gitignore
398
+ # Репо ставит свои хуки сам на обычной установке: husky в prepare/postinstall
399
+ # (под Yarn 2+ — только postinstall), пакеты lefthook / simple-git-hooks,
400
+ # `cghooks add` в post-install-cmd, `git config core.hooksPath` в шаге
401
+ # установки или в задаче up. Объявленный, но не ставящийся сам — не L2.
374
402
  L2:
375
403
  requires:
376
- any_of:
377
- - has_file: .husky
378
- - has_file: .lefthook.yml
379
- - has_file: lefthook.yml
380
- - package_present: { name: brainmaestro/composer-git-hooks, ecosystem: composer }
381
- - package_present: { name: husky, ecosystem: npm }
404
+ git_hooks_arranged: {}
405
+ # pre-commit или pre-push зовёт задачу словаря (test, check, check:*, test:*)
406
+ # через раннер проекта.
382
407
  L3:
383
408
  requires:
384
- any_of:
385
- - file_contains: { path: composer.json, pattern: "\"pre-push\"" }
386
- - file_contains: { paths: [.lefthook.yml, lefthook.yml], pattern: "pre-push:" }
387
- - has_file: .husky/pre-push
409
+ git_hook_runs_task: { tasks: [test, check] }
410
+ # pre-push вызывает все задачи словаря, которые вызывает CI на push в main
411
+ # (@-ссылки composer и npm run раскрываются): пуш, прошедший хук, не падает
412
+ # в CI на проверках.
388
413
  L4:
389
414
  requires:
390
- any_of:
391
- - file_contains: { path: composer.json, pattern: "\"(post-commit|commit-msg|post-merge)\"" }
392
- - file_contains: { paths: [.lefthook.yml, lefthook.yml], pattern: "(post-commit|commit-msg|post-merge):" }
415
+ git_hook_covers_ci: { tasks: [test, check] }
393
416
 
394
417
  - id: observability
395
418
  class: supporting
package/src/ai.ts CHANGED
@@ -4,6 +4,7 @@ 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 { GIT_HOOK_EVENTS } from './hooks.ts';
7
8
  import { autoDetectStack, readStructured, resolveConfigPath, type Ctx } from './facts.ts';
8
9
  import { loadManifest } from './manifest.ts';
9
10
  import { type Author, effectiveAuthor, isGitRepo, rankAuthors, scanPool, writeAuthor } from './author.ts';
@@ -83,9 +84,26 @@ export interface SessionProfile {
83
84
  machines?: string[];
84
85
  }
85
86
 
87
+ /**
88
+ * A personal hook: the owner's own gate, kept in git's user config
89
+ * (`hook.<name>.event` / `.command`, Git 2.54) instead of in every repository.
90
+ * `vdx doctor` checks it is there, `vdx doctor --fix` writes it.
91
+ */
92
+ export interface PersonalHook {
93
+ name: string;
94
+ /** Git hook event(s): `pre-commit`, … */
95
+ event: string | string[];
96
+ /** Run by git through the shell; `$HOME` and quotes work as in a hook script. */
97
+ command: string;
98
+ /** The hook applies on a machine where this path exists (the tool is installed); `~` is home. */
99
+ when_exists?: string;
100
+ description?: string;
101
+ }
102
+
86
103
  export interface GitProfile {
87
104
  /** Directories whose repos are the neighbours an author is proposed from; default — the project's parent. */
88
105
  author_pool?: string[];
106
+ hooks?: PersonalHook[];
89
107
  }
90
108
 
91
109
  export interface Environment {
@@ -202,6 +220,27 @@ export function parseEnvironment(text: string, source: string): Environment {
202
220
  if (gitDoc.author_pool !== undefined && !isStringList(gitDoc.author_pool)) {
203
221
  fail('git.author_pool', 'must be a list of directories');
204
222
  }
223
+ if (gitDoc.hooks !== undefined) {
224
+ if (!Array.isArray(gitDoc.hooks)) fail('git.hooks', 'must be a list');
225
+ const names = new Set<string>();
226
+ gitDoc.hooks.forEach((h, i) => {
227
+ const at = `git.hooks[${i}]`;
228
+ if (typeof h !== 'object' || h === null) fail(at, 'must be a mapping');
229
+ if (typeof h.name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(h.name)) {
230
+ fail(`${at}.name`, 'must be a name of letters, digits, . _ - (the git config subsection)');
231
+ }
232
+ if (names.has(h.name)) fail(`${at}.name`, `"${h.name}" is declared twice`);
233
+ names.add(h.name);
234
+ const events = Array.isArray(h.event) ? h.event : [h.event];
235
+ if (events.length === 0 || !events.every((e) => typeof e === 'string' && GIT_HOOK_EVENTS.has(e))) {
236
+ fail(`${at}.event`, 'must be a git hook event (pre-commit, pre-push, …) or a list of them');
237
+ }
238
+ if (typeof h.command !== 'string' || h.command.trim() === '') fail(`${at}.command`, 'must be a non-empty string');
239
+ if (h.when_exists !== undefined && (typeof h.when_exists !== 'string' || h.when_exists === '')) {
240
+ fail(`${at}.when_exists`, 'must be a path');
241
+ }
242
+ });
243
+ }
205
244
  }
206
245
  return envDoc;
207
246
  }
package/src/audit.ts CHANGED
@@ -4,7 +4,8 @@ import type { Rubric, LevelName } from './rubric.ts';
4
4
  import { findSubPackages, stackForDir, type Ctx } from './facts.ts';
5
5
  import { evalPredicate } from './evaluator.ts';
6
6
  import type { Override, VdxManifest } from './manifest.ts';
7
- import { evalAxis, projectLevel, type AxisResult } from './scoring.ts';
7
+ import { evalAxisDetailed, projectLevel, type AxisResult } from './scoring.ts';
8
+ import { cloneCheck } from './clone-checks.ts';
8
9
 
9
10
  function levelToInt(L: LevelName): number {
10
11
  return parseInt(L.slice(1), 10);
@@ -126,7 +127,7 @@ export function audit(
126
127
  });
127
128
  continue;
128
129
  }
129
- const achieved = evalAxis(axis, evalCtx);
130
+ const { achieved, not_required, unknown } = evalAxisDetailed(axis, evalCtx);
130
131
  const ov = overrideByAxis.get(axis.id);
131
132
  const target = (ov?.target as LevelName) ?? axis.default_target;
132
133
  const drift: 'aligned' | 'gap' | 'over' =
@@ -143,6 +144,12 @@ export function audit(
143
144
  drift_kind: drift,
144
145
  };
145
146
  if (ov?.target) entry.override_target = ov.target as LevelName;
147
+ if (not_required) entry.not_required = not_required;
148
+ if (unknown) entry.unknown = unknown;
149
+ if (axis.clone_check) {
150
+ const c = cloneCheck(axis.clone_check, ctx.projectRoot);
151
+ if (c && c.status !== 'ok') entry.note = c.remedy ? `${c.message} → ${c.remedy}` : c.message;
152
+ }
146
153
  perAxis.push(entry);
147
154
  }
148
155
 
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Checks of this clone that both `vdx doctor` (a row) and `vdx audit` (a note
3
+ * next to the axis that names it in `clone_check`) print — one check, two
4
+ * surfaces, so they cannot disagree.
5
+ */
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
+ import { execFileSync } from 'node:child_process';
9
+ import type { CheckResult } from './doctor.ts';
10
+ import {
11
+ cloneHookStates,
12
+ detectHookFrameworks,
13
+ effectiveHooksDir,
14
+ missingMachinePaths,
15
+ unsetGatingVars,
16
+ } from './hooks.ts';
17
+ import { autoDetectStack, type Ctx } from './facts.ts';
18
+
19
+ function gitConfig(root: string, key: string): string | null {
20
+ try {
21
+ const out = execFileSync('git', ['-C', root, 'config', '--get', key], {
22
+ encoding: 'utf8',
23
+ stdio: ['ignore', 'pipe', 'ignore'],
24
+ }).trim();
25
+ return out || null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ function isGitWorkTree(root: string): boolean {
32
+ return fs.existsSync(path.join(root, '.git'));
33
+ }
34
+
35
+ function executableHooks(dir: string): Map<string, string> {
36
+ const out = new Map<string, string>();
37
+ let names: string[] = [];
38
+ try {
39
+ names = fs.readdirSync(dir).filter((f) => !f.startsWith('.') && !f.endsWith('.sample'));
40
+ } catch {
41
+ return out;
42
+ }
43
+ for (const f of names) {
44
+ const file = path.join(dir, f);
45
+ try {
46
+ fs.accessSync(file, fs.constants.X_OK);
47
+ if (fs.statSync(file).isFile()) out.set(f, fs.readFileSync(file, 'utf8'));
48
+ } catch {
49
+ /* not executable — git skips it silently */
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * Git hooks: declared vs actually running in this clone.
57
+ *
58
+ * A framework leaves two separable traces: files in the repository (tracked,
59
+ * the same for every clone) and activation in the clone (`core.hooksPath`, or
60
+ * hooks written into the git hooks directory). The first says nothing about
61
+ * the second, and a hook that is declared but off fails exactly like success —
62
+ * nothing is printed, the commit goes through. So does a hook gated on a
63
+ * variable nobody set, or one that calls a path missing on this machine.
64
+ *
65
+ * Null (no row) when the repo declares no hooks — there is nothing to be wrong about.
66
+ */
67
+ export function checkRepoHooks(root: string | null): CheckResult | null {
68
+ if (root === null || !isGitWorkTree(root)) return null;
69
+ const id = 'git-hooks';
70
+ const label = 'git hooks';
71
+ const ctx: Ctx = { projectRoot: root, stack: autoDetectStack(root), cache: new Map() };
72
+ const frameworks = detectHookFrameworks(ctx);
73
+ const hooksPath = gitConfig(root, 'core.hooksPath');
74
+
75
+ if (frameworks.length === 0) {
76
+ if (hooksPath === null) return null;
77
+ // Hooks in a directory no framework owns: read what is there.
78
+ const dir = path.resolve(root, hooksPath);
79
+ if (!fs.existsSync(dir)) {
80
+ return {
81
+ id,
82
+ label,
83
+ status: 'missing',
84
+ message: `core.hooksPath=${hooksPath} but that directory does not exist`,
85
+ remedy: `git -C ${root} config --unset core.hooksPath, or create ${hooksPath}/`,
86
+ };
87
+ }
88
+ const hooks = executableHooks(dir);
89
+ if (hooks.size === 0) {
90
+ return {
91
+ id,
92
+ label,
93
+ status: 'warning',
94
+ message: `core.hooksPath=${hooksPath} but it holds no executable hook`,
95
+ remedy: `chmod +x ${hooksPath}/*`,
96
+ };
97
+ }
98
+ return bodyProblems(id, label, [...hooks.values()]) ?? {
99
+ id,
100
+ label,
101
+ status: 'ok',
102
+ message: `${hooks.size} hook(s) active via ${hooksPath}`,
103
+ };
104
+ }
105
+
106
+ const states = cloneHookStates(root, ctx, frameworks);
107
+ const off = states.filter((s) => !s.enabled);
108
+ if (off.length > 0) {
109
+ return {
110
+ id,
111
+ label,
112
+ status: 'warning',
113
+ message: off.map((s) => `${s.framework.id} (${s.framework.declaredIn}): ${s.problem} — those hooks never run`).join('; '),
114
+ ...(off[0]!.remedy ? { remedy: off[0]!.remedy } : {}),
115
+ };
116
+ }
117
+
118
+ const commands = frameworks.flatMap((f) => [...f.hooks.values()].flat());
119
+ const problem = bodyProblems(id, label, commands);
120
+ if (problem) return problem;
121
+
122
+ const dir = effectiveHooksDir(root);
123
+ const where = dir ? path.relative(root, dir) || dir : 'the hooks directory';
124
+ const count = frameworks.reduce((n, f) => n + f.hooks.size, 0);
125
+ return {
126
+ id,
127
+ label,
128
+ status: 'ok',
129
+ message: `${frameworks.map((f) => f.id).join(', ')}: ${count} hook(s) active in ${where}`,
130
+ };
131
+ }
132
+
133
+ /** A body that looks installed but does nothing here: gated on an unset variable, or calling a missing path. */
134
+ function bodyProblems(id: string, label: string, bodies: string[]): CheckResult | null {
135
+ const unset = unsetGatingVars(bodies);
136
+ if (unset.length > 0) {
137
+ return {
138
+ id,
139
+ label,
140
+ status: 'warning',
141
+ message: `hooks gated on unset ${unset.join(', ')} — those gates are no-ops`,
142
+ remedy: `export ${unset[0]}=... in your shell profile (see the tool that ships the hook)`,
143
+ };
144
+ }
145
+ const missing = missingMachinePaths(bodies);
146
+ if (missing.length > 0) {
147
+ return {
148
+ id,
149
+ label,
150
+ status: 'warning',
151
+ message: `hooks call ${missing.join(', ')} — not on this machine, the commit fails or the gate is skipped`,
152
+ remedy: 'install what the hook calls here, or move the hook out of the repository (a personal hook in git config)',
153
+ };
154
+ }
155
+ return null;
156
+ }
157
+
158
+ /** The checks an axis may name in `clone_check`. */
159
+ export function cloneCheck(name: 'git-hooks', root: string): CheckResult | null {
160
+ switch (name) {
161
+ case 'git-hooks':
162
+ return checkRepoHooks(root);
163
+ default:
164
+ return null;
165
+ }
166
+ }
package/src/doctor.ts CHANGED
@@ -5,6 +5,8 @@ import { fileURLToPath } from 'node:url';
5
5
  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
+ import { checkRepoHooks } from './clone-checks.ts';
9
+ import { checkPersonalHooks, defaultPersonalHookDeps, fixPersonalHooks, type PersonalHookDeps } from './personal-hooks.ts';
8
10
 
9
11
  export type CheckStatus = 'ok' | 'warning' | 'missing';
10
12
 
@@ -380,134 +382,6 @@ export function checkClaudeCodePlugin(
380
382
  return { ...base, status: 'ok', message: label };
381
383
  }
382
384
 
383
- /**
384
- * Variables a hook body *gates on*: `[ -n "${VAR:-}" ]`, `[ -x "$VAR" ]` and
385
- * friends. Restricted to test-expression positions on purpose — a bare `$VAR`
386
- * anywhere in a script says nothing about whether the hook still does its job,
387
- * while an unset variable inside the guard means the guarded block never runs.
388
- */
389
- const GATING_VAR_RE =
390
- /\[\s+-[nxfse]\s+"\$\{([A-Z][A-Z0-9_]*)(?::-[^}]*)?\}"|\[\s+-[nxfse]\s+"\$([A-Z][A-Z0-9_]*)"/g;
391
-
392
- /**
393
- * Git hooks: declared vs actually wired.
394
- *
395
- * A hook framework leaves two separable traces: files in the repository
396
- * (tracked, same for every clone) and activation in `.git/config` (per-clone,
397
- * never committed). Presence of the first says nothing about the second, and a
398
- * hook that is present but inert fails exactly like success — nothing is
399
- * printed, the commit goes through. Same for a hook whose body is gated on an
400
- * environment variable nobody set: the guard short-circuits and the gate is a
401
- * no-op.
402
- *
403
- * Returns null (row omitted) when the repo declares no hooks at all — there is
404
- * nothing to be wrong about.
405
- */
406
- function checkGitHooks(ctx: DoctorCtx): CheckResult | null {
407
- const root = ctx.projectRoot;
408
- if (root === null) return null;
409
- if (!fs.existsSync(path.join(root, '.git'))) return null;
410
-
411
- const id = 'git-hooks';
412
- const label = 'git hooks';
413
-
414
- let hooksPath: string | null = null;
415
- try {
416
- const out = execFileSync('git', ['-C', root, 'config', '--get', 'core.hooksPath'], {
417
- encoding: 'utf8',
418
- stdio: ['ignore', 'pipe', 'ignore'],
419
- });
420
- hooksPath = out.trim() || null;
421
- } catch {
422
- hooksPath = null;
423
- }
424
-
425
- // Directories a framework would have written into the repo.
426
- const declaredDirs = ['.githooks', '.husky'].filter((d) =>
427
- fs.existsSync(path.join(root, d)),
428
- );
429
-
430
- if (hooksPath === null) {
431
- if (declaredDirs.length === 0) return null; // nothing declared — not applicable
432
- return {
433
- id,
434
- label,
435
- status: 'warning',
436
- message: `${declaredDirs.join(', ')} present but core.hooksPath is unset — hooks never run`,
437
- remedy: `git -C ${root} config core.hooksPath ${declaredDirs[0]}`,
438
- };
439
- }
440
-
441
- const dir = path.resolve(root, hooksPath);
442
- if (!fs.existsSync(dir)) {
443
- return {
444
- id,
445
- label,
446
- status: 'missing',
447
- message: `core.hooksPath=${hooksPath} but that directory does not exist`,
448
- remedy: `git -C ${root} config --unset core.hooksPath, or create ${hooksPath}/`,
449
- };
450
- }
451
-
452
- let entries: string[] = [];
453
- try {
454
- entries = fs.readdirSync(dir).filter((f) => !f.startsWith('.') && !f.endsWith('.sample'));
455
- } catch {
456
- entries = [];
457
- }
458
- const executable = entries.filter((f) => {
459
- try {
460
- fs.accessSync(path.join(dir, f), fs.constants.X_OK);
461
- return true;
462
- } catch {
463
- return false;
464
- }
465
- });
466
-
467
- if (executable.length === 0) {
468
- return {
469
- id,
470
- label,
471
- status: 'warning',
472
- message: `core.hooksPath=${hooksPath} but it holds no executable hook`,
473
- remedy: `chmod +x ${hooksPath}/*`,
474
- };
475
- }
476
-
477
- // A hook gated on an unset variable is inert while looking installed.
478
- const unresolved = new Set<string>();
479
- for (const f of executable) {
480
- let body = '';
481
- try {
482
- body = fs.readFileSync(path.join(dir, f), 'utf8');
483
- } catch {
484
- continue;
485
- }
486
- for (const m of body.matchAll(GATING_VAR_RE)) {
487
- const name = m[1] ?? m[2];
488
- if (name && !process.env[name]) unresolved.add(name);
489
- }
490
- }
491
-
492
- if (unresolved.size > 0) {
493
- const names = [...unresolved].sort();
494
- return {
495
- id,
496
- label,
497
- status: 'warning',
498
- message: `${executable.length} hook(s) via ${hooksPath}, but gated on unset ${names.join(', ')} — those gates are no-ops`,
499
- remedy: `export ${names[0]}=... in your shell profile (see the tool that ships the hook)`,
500
- };
501
- }
502
-
503
- return {
504
- id,
505
- label,
506
- status: 'ok',
507
- message: `${executable.length} hook(s) active via ${hooksPath}`,
508
- };
509
- }
510
-
511
385
  /**
512
386
  * The project's commit author. With `user.useConfigOnly` git refuses to commit
513
387
  * in a repo that has none; the remedy names the likely one, as `vdx ai` does.
@@ -552,7 +426,8 @@ const CHECKS: Array<(ctx: DoctorCtx) => CheckResult | null> = [
552
426
  checkMise,
553
427
  checkNpmAuth,
554
428
  checkContainerRuntime,
555
- checkGitHooks,
429
+ (ctx) => checkRepoHooks(ctx.projectRoot),
430
+ (ctx) => checkPersonalHooks(ctx.projectRoot),
556
431
  checkGitAuthor,
557
432
  ];
558
433
 
@@ -568,6 +443,37 @@ export function runDoctor(ctx: DoctorCtx = resolveDoctorCtx()): DoctorReport {
568
443
  return { cliVersion: readCliVersion(), checks, ok, warning, missing };
569
444
  }
570
445
 
446
+ /**
447
+ * For the agent's own session (the vdx plugin's SessionStart hook): one line
448
+ * per hook that is declared but does not run — in this clone, or a personal
449
+ * hook on this machine. Nothing when they are in order; the fix is the user's
450
+ * call, so the line names it rather than applying it.
451
+ */
452
+ export function runDoctorCheck(
453
+ ctx: DoctorCtx = resolveDoctorCtx(),
454
+ deps: PersonalHookDeps = defaultPersonalHookDeps(),
455
+ ): string {
456
+ const rows = [checkRepoHooks(ctx.projectRoot), checkPersonalHooks(ctx.projectRoot, deps)].filter(
457
+ (c): c is CheckResult => c !== null && c.status !== 'ok',
458
+ );
459
+ return rows
460
+ .map(
461
+ (c) =>
462
+ `vdx doctor: ${c.label} — ${c.message}.` +
463
+ (c.remedy ? ` Fix, on the user's word: \`${c.remedy}\`.` : ''),
464
+ )
465
+ .join('\n');
466
+ }
467
+
468
+ /**
469
+ * `vdx doctor --fix`: what doctor can put right without installing anything —
470
+ * the profile's personal hooks in ~/.gitconfig. Repository hooks are switched
471
+ * on by the project's own install step; doctor names it and leaves it to you.
472
+ */
473
+ export function runDoctorFix(deps: PersonalHookDeps = defaultPersonalHookDeps()): string[] {
474
+ return fixPersonalHooks(deps);
475
+ }
476
+
571
477
  const PROJECT_MARKER_FILES = [
572
478
  'package.json',
573
479
  'composer.json',