@vodmal/vdx-cli 0.21.1 → 0.22.1
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 +17 -7
- package/package.json +1 -1
- package/rubric/vdx-rubric.yaml +57 -34
- package/src/ai.ts +95 -15
- package/src/audit.ts +9 -2
- package/src/clone-checks.ts +166 -0
- package/src/conversations.ts +6 -3
- package/src/doctor.ts +35 -129
- package/src/evaluator.ts +60 -10
- package/src/gha.ts +211 -0
- package/src/hooks.ts +424 -0
- package/src/index.ts +34 -7
- package/src/init.ts +1 -1
- package/src/personal-hooks.ts +181 -0
- package/src/predicates.ts +123 -1
- package/src/report.ts +32 -1
- package/src/rubric.ts +80 -4
- package/src/scoring.ts +46 -6
- package/src/vocabulary.ts +150 -0
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
|
|
30
|
-
|
|
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 (
|
|
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
|
-
- `
|
|
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.
|
|
3
|
+
"version": "0.22.1",
|
|
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",
|
package/rubric/vdx-rubric.yaml
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
# дельту относительно L(N-1). Уровень оси = max L, для которого истинны
|
|
8
8
|
# requires(L1), ..., requires(L) подряд (без разрывов).
|
|
9
9
|
|
|
10
|
-
schema_version: "0.
|
|
10
|
+
schema_version: "0.3"
|
|
11
11
|
|
|
12
12
|
metadata:
|
|
13
13
|
name: vdx-rubric-vodmal
|
|
14
|
-
version: "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: "Статанализ +
|
|
28
|
-
L4: { name: exemplar, description: "Полная глубина:
|
|
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: "
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
-
|
|
220
|
-
|
|
221
|
-
-
|
|
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/,
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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 {
|
|
@@ -98,6 +116,8 @@ export interface Environment {
|
|
|
98
116
|
|
|
99
117
|
export const ENVIRONMENT_ENV_VAR = 'VDX_ENVIRONMENT';
|
|
100
118
|
export const HOST_ENV_VAR = 'VDX_HOST';
|
|
119
|
+
/** Set by `vdx ai@<host>` for the vdx it starts there: the machine the person sits at. */
|
|
120
|
+
export const SEAT_ENV_VAR = 'VDX_AI_FROM';
|
|
101
121
|
export const USER_ENVIRONMENT_FILE = '.vdx-environment.yaml';
|
|
102
122
|
/** Tried in order when no profile names an agent. Started plain, with no flags. */
|
|
103
123
|
export const FALLBACK_AGENTS = ['claude', 'codex'] as const;
|
|
@@ -202,6 +222,27 @@ export function parseEnvironment(text: string, source: string): Environment {
|
|
|
202
222
|
if (gitDoc.author_pool !== undefined && !isStringList(gitDoc.author_pool)) {
|
|
203
223
|
fail('git.author_pool', 'must be a list of directories');
|
|
204
224
|
}
|
|
225
|
+
if (gitDoc.hooks !== undefined) {
|
|
226
|
+
if (!Array.isArray(gitDoc.hooks)) fail('git.hooks', 'must be a list');
|
|
227
|
+
const names = new Set<string>();
|
|
228
|
+
gitDoc.hooks.forEach((h, i) => {
|
|
229
|
+
const at = `git.hooks[${i}]`;
|
|
230
|
+
if (typeof h !== 'object' || h === null) fail(at, 'must be a mapping');
|
|
231
|
+
if (typeof h.name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(h.name)) {
|
|
232
|
+
fail(`${at}.name`, 'must be a name of letters, digits, . _ - (the git config subsection)');
|
|
233
|
+
}
|
|
234
|
+
if (names.has(h.name)) fail(`${at}.name`, `"${h.name}" is declared twice`);
|
|
235
|
+
names.add(h.name);
|
|
236
|
+
const events = Array.isArray(h.event) ? h.event : [h.event];
|
|
237
|
+
if (events.length === 0 || !events.every((e) => typeof e === 'string' && GIT_HOOK_EVENTS.has(e))) {
|
|
238
|
+
fail(`${at}.event`, 'must be a git hook event (pre-commit, pre-push, …) or a list of them');
|
|
239
|
+
}
|
|
240
|
+
if (typeof h.command !== 'string' || h.command.trim() === '') fail(`${at}.command`, 'must be a non-empty string');
|
|
241
|
+
if (h.when_exists !== undefined && (typeof h.when_exists !== 'string' || h.when_exists === '')) {
|
|
242
|
+
fail(`${at}.when_exists`, 'must be a path');
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}
|
|
205
246
|
}
|
|
206
247
|
return envDoc;
|
|
207
248
|
}
|
|
@@ -240,6 +281,8 @@ export interface LaunchPlan {
|
|
|
240
281
|
/** Where `project` came from: mise.toml, a profile source, or the repository name. */
|
|
241
282
|
projectSource: string;
|
|
242
283
|
host: string;
|
|
284
|
+
/** The machine the person sits at: `host`, or the one that ran `vdx ai@<host>`. */
|
|
285
|
+
seat: string;
|
|
243
286
|
profilePath: string | null;
|
|
244
287
|
command: string;
|
|
245
288
|
/** agent.args plus the args of every matched `when` rule — what a running agent must carry. */
|
|
@@ -261,6 +304,7 @@ export function planLaunch(input: {
|
|
|
261
304
|
project: string;
|
|
262
305
|
projectSource?: string;
|
|
263
306
|
host: string;
|
|
307
|
+
seat?: string;
|
|
264
308
|
}): LaunchPlan {
|
|
265
309
|
const { environment, agent, projectRoot } = input;
|
|
266
310
|
const ctx: Ctx = { projectRoot, stack: autoDetectStack(projectRoot), cache: new Map() };
|
|
@@ -278,6 +322,7 @@ export function planLaunch(input: {
|
|
|
278
322
|
project: input.project,
|
|
279
323
|
projectSource: input.projectSource ?? 'repository name',
|
|
280
324
|
host: input.host,
|
|
325
|
+
seat: input.seat ?? input.host,
|
|
281
326
|
profilePath: input.profilePath,
|
|
282
327
|
command: agent.command,
|
|
283
328
|
args,
|
|
@@ -496,6 +541,16 @@ export function hostLabel(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
496
541
|
return label || os.hostname().split('.')[0]!;
|
|
497
542
|
}
|
|
498
543
|
|
|
544
|
+
/** The machine the person sits at: the one whose `vdx ai@<host>` started this vdx, else this one. */
|
|
545
|
+
export function seatLabel(env: NodeJS.ProcessEnv = process.env): string {
|
|
546
|
+
return env[SEAT_ENV_VAR]?.trim() || hostLabel(env);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Machine labels compare without case: `M3` in the profile is `m3` in a transcript. */
|
|
550
|
+
export function sameMachine(a: string | null, b: string | null): boolean {
|
|
551
|
+
return (a ?? '').toLowerCase() === (b ?? '').toLowerCase();
|
|
552
|
+
}
|
|
553
|
+
|
|
499
554
|
export function onPath(bin: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
500
555
|
for (const dir of (env['PATH'] ?? '').split(path.delimiter)) {
|
|
501
556
|
if (!dir) continue;
|
|
@@ -856,9 +911,12 @@ export function remotePathWord(abs: string, home: string): string {
|
|
|
856
911
|
* The ssh argv for `vdx ai@<host>`: the same `vdx ai` on the host, in its own
|
|
857
912
|
* tmux there. The path goes first: an older vdx hands the token after a flag it
|
|
858
913
|
* does not know to that flag. A different vdx version there is named, not fatal.
|
|
914
|
+
* `from` — the machine the person sits at, so the vdx there speaks from it; an
|
|
915
|
+
* older vdx leaves the variable alone.
|
|
859
916
|
*/
|
|
860
917
|
export function remoteAiArgs(input: {
|
|
861
918
|
host: string;
|
|
919
|
+
from: string;
|
|
862
920
|
projectPath: string;
|
|
863
921
|
home: string;
|
|
864
922
|
version: string;
|
|
@@ -877,6 +935,7 @@ export function remoteAiArgs(input: {
|
|
|
877
935
|
const script =
|
|
878
936
|
`v=$(vdx --version 2>/dev/null) || v='older than 0.13.1'; ` +
|
|
879
937
|
`[ "$v" = ${v} ] || echo "note: vdx on ${host} is $v, here "${v} >&2; ` +
|
|
938
|
+
`export ${SEAT_ENV_VAR}=${shellQuote(input.from)}; ` +
|
|
880
939
|
`exec vdx ai ${[remotePathWord(input.projectPath, input.home), ...flags].join(' ')}`;
|
|
881
940
|
return [input.tty ? '-t' : '-T', host, script];
|
|
882
941
|
}
|
|
@@ -936,10 +995,11 @@ export function ensureAuthor(projectRoot: string, poolDirs: string[], deps: AiDe
|
|
|
936
995
|
}
|
|
937
996
|
}
|
|
938
997
|
|
|
939
|
-
|
|
998
|
+
/** `from` — where the person sits; a vdx that `vdx ai@<host>` started passes its own on. */
|
|
999
|
+
export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, from: string = seatLabel(deps.env)): number {
|
|
940
1000
|
const projectPath = resolveProjectRoot(opts.path) ?? path.resolve(opts.path);
|
|
941
1001
|
const tty = deps.interactive && !opts.detach && !opts.dryRun;
|
|
942
|
-
const args = remoteAiArgs({ host, projectPath, home: deps.home, version, opts, tty });
|
|
1002
|
+
const args = remoteAiArgs({ host, from, projectPath, home: deps.home, version: deps.version ?? '', opts, tty });
|
|
943
1003
|
deps.log(`→ ${host}: vdx ai ${shellQuote(projectPath)}${opts.conversation ? ` --conversation ${opts.conversation}` : ''}`);
|
|
944
1004
|
const res = (deps.ssh ?? runSsh)(args, false);
|
|
945
1005
|
if (res === null) {
|
|
@@ -1012,9 +1072,12 @@ const resumeId = (id: string) => ['--resume', id];
|
|
|
1012
1072
|
export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boolean): ConversationChoice {
|
|
1013
1073
|
if (!isClaude(plan.command)) return { args: plan.resumeArgs, lines: [] };
|
|
1014
1074
|
const host = plan.host;
|
|
1015
|
-
const same =
|
|
1075
|
+
const same = sameMachine;
|
|
1016
1076
|
const all = plan.resumeArgs.length === 0 ? [] : listConversations({ env: deps.env, home: deps.home, projectRoot: plan.projectRoot });
|
|
1017
1077
|
const root = shellQuote(plan.projectRoot);
|
|
1078
|
+
// Said from where the person sits: under `vdx ai@<host>` that is not this machine.
|
|
1079
|
+
const atSeat = same(plan.seat, host);
|
|
1080
|
+
const on = (m: string) => (same(m, plan.seat) ? (atSeat ? 'here' : `on ${m}, where you are,`) : `on ${m}`);
|
|
1018
1081
|
|
|
1019
1082
|
// Who runs what right now: this machine from its session files, the others over ssh.
|
|
1020
1083
|
const reach = new Map<string, MachineReport | null>();
|
|
@@ -1025,9 +1088,18 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1025
1088
|
const elsewhere = [...reach].flatMap(([m, r]) =>
|
|
1026
1089
|
(r?.sessions ?? []).filter((l) => runsInProject(l, r!.home, plan.projectRoot, deps.home)).map((l) => ({ m, l })),
|
|
1027
1090
|
);
|
|
1091
|
+
// The project's path on the machine `m`, by its home: the person types it there.
|
|
1092
|
+
const rootOn = (m: string) => {
|
|
1093
|
+
const theirs = [...reach].find(([k]) => same(k, m))?.[1]?.home;
|
|
1094
|
+
const rel = path.relative(deps.home, plan.projectRoot);
|
|
1095
|
+
return theirs && !rel.startsWith('..') && !path.isAbsolute(rel) ? shellQuote(path.join(theirs, rel)) : root;
|
|
1096
|
+
};
|
|
1097
|
+
// `vdx ai` attaches to an agent in tmux; one in a terminal of its own has no command to reach it.
|
|
1098
|
+
const reachAgent = (m: string, l: LiveSession) =>
|
|
1099
|
+
!l.tmux ? '' : same(m, plan.seat) ? ` — attach: vdx ai ${rootOn(m)}` : ` — there: vdx ai@${m} ${root}`;
|
|
1028
1100
|
const fresh = (lines: string[]): ConversationChoice => {
|
|
1029
1101
|
if (elsewhere.length === 0) return { args: [], lines };
|
|
1030
|
-
const agents = elsewhere.map(({ m, l }) => `an agent of this project is ${describeLive(m, l
|
|
1102
|
+
const agents = elsewhere.map(({ m, l }) => `an agent of this project is ${describeLive(on(m), l)}${reachAgent(m, l)}`);
|
|
1031
1103
|
return { args: [], elsewhere: [...new Set(elsewhere.map(({ m }) => m))], lines: [...lines, ...agents] };
|
|
1032
1104
|
};
|
|
1033
1105
|
if (plan.resumeArgs.length === 0) return fresh(silent);
|
|
@@ -1047,7 +1119,7 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1047
1119
|
(a, b) => b.updated.getTime() - a.updated.getTime(),
|
|
1048
1120
|
);
|
|
1049
1121
|
const show = (c: Conversation) => describeConversation(c, machine(c));
|
|
1050
|
-
const running = (c: Conversation) => (live.get(c.id) ?? []).map((l) => describeLive(l.machine, l.session
|
|
1122
|
+
const running = (c: Conversation) => (live.get(c.id) ?? []).map((l) => describeLive(on(l.machine), l.session));
|
|
1051
1123
|
|
|
1052
1124
|
const take = (c: Conversation): ConversationChoice => {
|
|
1053
1125
|
const m = machine(c);
|
|
@@ -1055,7 +1127,7 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1055
1127
|
if (here) {
|
|
1056
1128
|
return {
|
|
1057
1129
|
args: [],
|
|
1058
|
-
askHere: { id: c.id, why: `It runs
|
|
1130
|
+
askHere: { id: c.id, why: `It runs ${on(host)} right now (${here.session.tmux ?? `pid ${here.session.pid}`}); two sessions would write to it` },
|
|
1059
1131
|
lines: [`chosen: ${show(c)}`],
|
|
1060
1132
|
};
|
|
1061
1133
|
}
|
|
@@ -1063,7 +1135,7 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1063
1135
|
if (m) return { args: [], remote: { host: m, id: c.id, reachable: reach.get(m) != null }, lines: [`continuing on ${m}: ${show(c)}`] };
|
|
1064
1136
|
return {
|
|
1065
1137
|
args: [],
|
|
1066
|
-
askHere: { id: c.id, why:
|
|
1138
|
+
askHere: { id: c.id, why: `Its machine is unknown (never typed in ${on(host)}); if it runs there, both machines will write to it` },
|
|
1067
1139
|
lines: [`chosen: ${show(c)}`],
|
|
1068
1140
|
};
|
|
1069
1141
|
};
|
|
@@ -1076,7 +1148,7 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1076
1148
|
if (canAsk && deps.interactive) {
|
|
1077
1149
|
deps.log('conversations of this project:');
|
|
1078
1150
|
found.forEach((c, i) => {
|
|
1079
|
-
const marks = [i === 0 && 'newest', c === firstIdle && 'this machine', !machine(c) && 'machine unknown', ...running(c)];
|
|
1151
|
+
const marks = [i === 0 && 'newest', c === firstIdle && (atSeat ? 'this machine' : `idle on ${host}`), !machine(c) && 'machine unknown', ...running(c)];
|
|
1080
1152
|
const m = marks.filter(Boolean);
|
|
1081
1153
|
deps.log(` ${i + 1}) ${show(c)}${m.length ? ` ← ${m.join(', ')}` : ''}`);
|
|
1082
1154
|
});
|
|
@@ -1103,16 +1175,17 @@ export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boole
|
|
|
1103
1175
|
`${c.updated > (firstIdle?.updated ?? new Date(0)) ? 'a newer' : 'another'} conversation ${c.id.slice(0, 8)}` +
|
|
1104
1176
|
(now.length ? `, ${now.join(', ')}` : '');
|
|
1105
1177
|
if (runsHere(c)) return `note: ${which} — not taken: it would have two sessions`;
|
|
1178
|
+
if (m && same(m, plan.seat)) return `note: ${m} has ${which} — continue it where you are: vdx ai ${rootOn(m)} --conversation ${c.id}`;
|
|
1106
1179
|
return m
|
|
1107
1180
|
? `note: ${m} has ${which} — continue it there: vdx ai@${m} ${root} --conversation ${c.id}`
|
|
1108
|
-
: `note: ${which} is of an unknown machine (never typed in
|
|
1181
|
+
: `note: ${which} is of an unknown machine (never typed in ${on(host)}) — on its machine: vdx ai@<machine> ${root} --conversation ${c.id}`;
|
|
1109
1182
|
});
|
|
1110
1183
|
notes.push(...silent);
|
|
1111
1184
|
if (firstIdle) {
|
|
1112
1185
|
const chosen = take(firstIdle);
|
|
1113
1186
|
return { ...chosen, lines: [...chosen.lines, ...notes] };
|
|
1114
1187
|
}
|
|
1115
|
-
return fresh([
|
|
1188
|
+
return fresh([`no idle conversation ${atSeat ? 'of this machine' : `on ${host}`} — starting a new one`, ...notes]);
|
|
1116
1189
|
}
|
|
1117
1190
|
|
|
1118
1191
|
interface Prepared {
|
|
@@ -1172,6 +1245,7 @@ function prepare(pathArg: string, deps: AiDeps): Prepared | number {
|
|
|
1172
1245
|
project: named.name,
|
|
1173
1246
|
projectSource: named.source,
|
|
1174
1247
|
host: hostLabel(deps.env),
|
|
1248
|
+
seat: seatLabel(deps.env),
|
|
1175
1249
|
});
|
|
1176
1250
|
} catch (e: any) {
|
|
1177
1251
|
log(`vdx ai: ${profilePath ?? 'profile'}: ${e?.message ?? e}`);
|
|
@@ -1242,6 +1316,12 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1242
1316
|
if (typeof prepared === 'number') return prepared;
|
|
1243
1317
|
const { projectRoot, environment, profileDir } = prepared;
|
|
1244
1318
|
let { plan } = prepared;
|
|
1319
|
+
// The agent started here runs on this machine: it does not inherit where the person sat.
|
|
1320
|
+
delete deps.env[SEAT_ENV_VAR];
|
|
1321
|
+
// Under `vdx ai@<host>` the person is not here: this machine goes by its name.
|
|
1322
|
+
const atSeat = sameMachine(plan.seat, plan.host);
|
|
1323
|
+
const here = atSeat ? `here, on ${plan.host}` : `on ${plan.host}`;
|
|
1324
|
+
const vdxAiHere = atSeat ? 'vdx ai' : `vdx ai@${plan.host}`;
|
|
1245
1325
|
if (opts.conversation && !isClaude(plan.command)) {
|
|
1246
1326
|
log(`vdx ai: --conversation names a Claude Code conversation; the agent here is ${plan.command}`);
|
|
1247
1327
|
return EXIT_USAGE;
|
|
@@ -1286,7 +1366,7 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1286
1366
|
for (const l of choice?.lines ?? []) log(l);
|
|
1287
1367
|
if (choice?.abort) return EXIT_LAUNCH_FAILED;
|
|
1288
1368
|
if (choice?.elsewhere && !current) {
|
|
1289
|
-
const answer = deps.interactive ? deps.ask(`Start a new conversation here,
|
|
1369
|
+
const answer = deps.interactive ? deps.ask(`Start a new conversation ${here}${atSeat ? ',' : ''} anyway? [y/N]: `) : null;
|
|
1290
1370
|
if (!/^y(es)?$/i.test(answer?.trim() ?? '')) {
|
|
1291
1371
|
log('nothing started');
|
|
1292
1372
|
return EXIT_LAUNCH_FAILED;
|
|
@@ -1296,17 +1376,17 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1296
1376
|
let askHere = choice?.askHere;
|
|
1297
1377
|
if (choice?.remote) {
|
|
1298
1378
|
const { host, id, reachable } = choice.remote;
|
|
1299
|
-
if (reachable) return runAiRemote({ ...opts, path: projectRoot, conversation: id }, host, deps);
|
|
1379
|
+
if (reachable) return runAiRemote({ ...opts, path: projectRoot, conversation: id }, host, deps, plan.seat);
|
|
1300
1380
|
log(`✗ ${host} does not answer over ssh — its conversation ${id.slice(0, 8)} cannot be continued there`);
|
|
1301
1381
|
askHere = { id, why: `If it still runs on ${host}, both machines will write to it` };
|
|
1302
1382
|
}
|
|
1303
1383
|
if (askHere) {
|
|
1304
1384
|
const { id, why } = askHere;
|
|
1305
|
-
const answer = deps.interactive ? deps.ask(`Continue ${id.slice(0, 8)}
|
|
1385
|
+
const answer = deps.interactive ? deps.ask(`Continue ${id.slice(0, 8)} ${here}? ${why}. [y/N]: `) : null;
|
|
1306
1386
|
if (!/^y(es)?$/i.test(answer?.trim() ?? '')) {
|
|
1307
1387
|
log(
|
|
1308
|
-
`nothing started. Here:
|
|
1309
|
-
` · a new conversation:
|
|
1388
|
+
`nothing started. ${atSeat ? 'Here' : `On ${plan.host}`}: ${vdxAiHere} ${shellQuote(projectRoot)} --conversation ${id}` +
|
|
1389
|
+
` · a new conversation: ${vdxAiHere} --new ${shellQuote(projectRoot)}`,
|
|
1310
1390
|
);
|
|
1311
1391
|
return EXIT_LAUNCH_FAILED;
|
|
1312
1392
|
}
|
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 {
|
|
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 =
|
|
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
|
|