@vodmal/vdx-cli 0.25.0 → 0.26.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 +2 -2
- package/package.json +8 -3
- package/rubric/vdx-rubric.yaml +45 -9
- package/src/ai.ts +22 -23
- package/src/clone-checks.ts +1 -1
- package/src/conversations.ts +30 -23
- package/src/doctor.ts +5 -4
- package/src/evaluator.ts +21 -20
- package/src/facts.ts +24 -49
- package/src/gha.ts +34 -26
- package/src/hooks.ts +31 -31
- package/src/index.ts +22 -13
- package/src/init.ts +9 -12
- package/src/manifest.ts +25 -4
- package/src/mcp-server.ts +39 -35
- package/src/narrow.ts +56 -0
- package/src/predicates.ts +10 -11
- package/src/publish.ts +27 -25
- package/src/rubric.ts +2 -2
- package/src/run.ts +3 -2
- package/src/vocabulary.ts +8 -12
package/README.md
CHANGED
|
@@ -91,7 +91,7 @@ vdx ai@m3
|
|
|
91
91
|
`vdx ai` reads a personal profile, never a bundled one: `$VDX_ENVIRONMENT`, else
|
|
92
92
|
`~/.vdx-environment.yaml`, else a plain `claude`/`codex` with no flags. Format:
|
|
93
93
|
[docs/specs/environment-format.md](../docs/specs/environment-format.md).
|
|
94
|
-
The session's `{project}` is `[vdx] name` from the project's `mise.toml`, else the
|
|
94
|
+
The session's `{project}` is `[_.vdx] name` (or the older `[vdx]`) from the project's `mise.toml`, else the
|
|
95
95
|
first file in the profile's `session.project_names` that has one, else the repo
|
|
96
96
|
name. `vdx ai@<host>` runs the same command on `<host>` over ssh — that machine's vdx,
|
|
97
97
|
profile and tmux, the path taken under its home directory; `--detach` only
|
|
@@ -126,7 +126,7 @@ npm run typecheck
|
|
|
126
126
|
## Structure
|
|
127
127
|
|
|
128
128
|
- `src/rubric.ts` — types + YAML loader
|
|
129
|
-
- `src/manifest.ts` — parser for `[vdx]` block in `mise.toml` + `.vdx-overrides.yml`
|
|
129
|
+
- `src/manifest.ts` — parser for the `[_.vdx]` block in `mise.toml` (the older `[vdx]` too) + `.vdx-overrides.yml`
|
|
130
130
|
- `src/facts.ts` — fact source loaders (tasks, packages, configs, sub-package detection)
|
|
131
131
|
- `src/predicates.ts` — predicate registry; true / false / unknown with a reason (schema 0.3)
|
|
132
132
|
- `src/evaluator.ts` — recursive evaluator + sugar notation
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vodmal/vdx-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.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",
|
|
@@ -40,8 +40,9 @@
|
|
|
40
40
|
"smoke": "bash smoke.sh",
|
|
41
41
|
"typecheck": "tsc --noEmit",
|
|
42
42
|
"format:check": "prettier --check .",
|
|
43
|
-
"
|
|
44
|
-
"
|
|
43
|
+
"lint": "eslint .",
|
|
44
|
+
"check": "npm run typecheck && npm run format:check && npm run lint",
|
|
45
|
+
"fix": "eslint --fix . && prettier --write .",
|
|
45
46
|
"test": "vitest run",
|
|
46
47
|
"test:unit": "vitest run tests/unit",
|
|
47
48
|
"coverage": "vitest run --coverage"
|
|
@@ -57,12 +58,16 @@
|
|
|
57
58
|
"zod": "^3.23.0"
|
|
58
59
|
},
|
|
59
60
|
"devDependencies": {
|
|
61
|
+
"@eslint/js": "^10.0.1",
|
|
60
62
|
"@types/js-yaml": "^4.0.9",
|
|
61
63
|
"@types/marked-terminal": "^6.1.1",
|
|
62
64
|
"@types/node": "^22.0.0",
|
|
63
65
|
"@vitest/coverage-v8": "^4.1.7",
|
|
66
|
+
"eslint": "^10.12.0",
|
|
67
|
+
"eslint-config-prettier": "^10.1.8",
|
|
64
68
|
"prettier": "^3.9.10",
|
|
65
69
|
"typescript": "^5.6.0",
|
|
70
|
+
"typescript-eslint": "^8.71.2",
|
|
66
71
|
"vitest": "^4.1.7"
|
|
67
72
|
},
|
|
68
73
|
"publishConfig": {
|
package/rubric/vdx-rubric.yaml
CHANGED
|
@@ -11,7 +11,7 @@ schema_version: "0.3"
|
|
|
11
11
|
|
|
12
12
|
metadata:
|
|
13
13
|
name: vdx-rubric-vodmal
|
|
14
|
-
version: "1.4.
|
|
14
|
+
version: "1.4.2"
|
|
15
15
|
description: "Personal maturity rubric for multi-stack projects (vodmal)"
|
|
16
16
|
owner: vodmal
|
|
17
17
|
homepage: "https://github.com/vodmal/vdx-rubric"
|
|
@@ -193,17 +193,24 @@ axes:
|
|
|
193
193
|
predicate: { tsc_flag: { name: noImplicitOverride, equals: true } }
|
|
194
194
|
- id: eslint_flat
|
|
195
195
|
weight: 1.0
|
|
196
|
-
|
|
196
|
+
# Имена flat-конфига — те, что ищет ESLint 10 (lib/config/config-loader.js).
|
|
197
|
+
# Одно определение на сет: code-style ссылается сюда (v1.4.1).
|
|
198
|
+
predicate: &eslint_flat_config
|
|
197
199
|
any_of:
|
|
198
200
|
- has_file: eslint.config.js
|
|
199
201
|
- has_file: eslint.config.mjs
|
|
202
|
+
- has_file: eslint.config.cjs
|
|
200
203
|
- has_file: eslint.config.ts
|
|
204
|
+
- has_file: eslint.config.mts
|
|
205
|
+
- has_file: eslint.config.cts
|
|
201
206
|
- id: eslint_type_checked
|
|
202
207
|
weight: 1.0
|
|
203
208
|
predicate:
|
|
204
209
|
file_contains:
|
|
205
|
-
paths: [eslint.config.js, eslint.config.mjs, eslint.config.ts]
|
|
206
|
-
|
|
210
|
+
paths: [eslint.config.js, eslint.config.mjs, eslint.config.cjs, eslint.config.ts, eslint.config.mts, eslint.config.cts]
|
|
211
|
+
# typescript-eslint во flat-конфиге — recommendedTypeChecked / strictTypeChecked,
|
|
212
|
+
# в .eslintrc было recommended-type-checked (v1.4.1).
|
|
213
|
+
pattern: "(recommended|strict)-?[Tt]ype-?[Cc]hecked"
|
|
207
214
|
level_thresholds: { L4: 0.85, L3: 0.65, L2: 0.35, L1: 0.10 }
|
|
208
215
|
|
|
209
216
|
- id: ci
|
|
@@ -316,9 +323,31 @@ axes:
|
|
|
316
323
|
- has_file: ecs.php
|
|
317
324
|
- has_file: .php-cs-fixer.php
|
|
318
325
|
- has_file: phpcs.xml.dist
|
|
319
|
-
|
|
320
|
-
-
|
|
321
|
-
|
|
326
|
+
# Форматтер: имена конфига — те, что ищет Prettier 3.9, и Biome (v1.4.1).
|
|
327
|
+
- &formatter_config
|
|
328
|
+
any_of:
|
|
329
|
+
- has_file: .prettierrc
|
|
330
|
+
- has_file: .prettierrc.json
|
|
331
|
+
- has_file: .prettierrc.yaml
|
|
332
|
+
- has_file: .prettierrc.yml
|
|
333
|
+
- has_file: .prettierrc.json5
|
|
334
|
+
- has_file: .prettierrc.toml
|
|
335
|
+
- has_file: .prettierrc.js
|
|
336
|
+
- has_file: .prettierrc.mjs
|
|
337
|
+
- has_file: .prettierrc.cjs
|
|
338
|
+
- has_file: .prettierrc.ts
|
|
339
|
+
- has_file: .prettierrc.mts
|
|
340
|
+
- has_file: .prettierrc.cts
|
|
341
|
+
- has_file: prettier.config.js
|
|
342
|
+
- has_file: prettier.config.mjs
|
|
343
|
+
- has_file: prettier.config.cjs
|
|
344
|
+
- has_file: prettier.config.ts
|
|
345
|
+
- has_file: prettier.config.mts
|
|
346
|
+
- has_file: prettier.config.cts
|
|
347
|
+
- config_value: { path: package.json, jsonpath: "prettier", op: present }
|
|
348
|
+
- has_file: biome.json
|
|
349
|
+
- has_file: biome.jsonc
|
|
350
|
+
- *eslint_flat_config
|
|
322
351
|
L3:
|
|
323
352
|
requires:
|
|
324
353
|
any_of:
|
|
@@ -333,8 +362,8 @@ axes:
|
|
|
333
362
|
- has_file: ecs.php
|
|
334
363
|
- has_file: phpcs.xml.dist
|
|
335
364
|
- all_of:
|
|
336
|
-
-
|
|
337
|
-
-
|
|
365
|
+
- *formatter_config
|
|
366
|
+
- *eslint_flat_config
|
|
338
367
|
|
|
339
368
|
- id: dependency-hygiene
|
|
340
369
|
class: supporting
|
|
@@ -544,10 +573,17 @@ axes:
|
|
|
544
573
|
requires:
|
|
545
574
|
any_of:
|
|
546
575
|
- file_contains: { path: scripts/docker-up.sh, pattern: "(traefik|nginx-proxy)" }
|
|
576
|
+
# Манифест vdx — в [_.vdx] (vdx 0.26+) или в старом [vdx]; оба места — с v1.4.2.
|
|
577
|
+
- config_value: { path: mise.toml, jsonpath: "_.vdx.shared_infra.precheck", equals: true }
|
|
547
578
|
- config_value: { path: mise.toml, jsonpath: "vdx.shared_infra.precheck", equals: true }
|
|
548
579
|
L4:
|
|
549
580
|
requires:
|
|
550
581
|
any_of:
|
|
582
|
+
- config_value:
|
|
583
|
+
path: mise.toml
|
|
584
|
+
jsonpath: "_.vdx.shared_infra.provider"
|
|
585
|
+
op: matches
|
|
586
|
+
pattern: "github.com/.+@v\\d+"
|
|
551
587
|
- config_value:
|
|
552
588
|
path: mise.toml
|
|
553
589
|
jsonpath: "vdx.shared_infra.provider"
|
package/src/ai.ts
CHANGED
|
@@ -6,7 +6,7 @@ import YAML from 'js-yaml';
|
|
|
6
6
|
import { evalPredicate } from './evaluator.ts';
|
|
7
7
|
import { GIT_HOOK_EVENTS } from './hooks.ts';
|
|
8
8
|
import { autoDetectStack, readStructured, resolveConfigPath, type Ctx } from './facts.ts';
|
|
9
|
-
import {
|
|
9
|
+
import { loadManifestAt } from './manifest.ts';
|
|
10
10
|
import { type Author, effectiveAuthor, isGitRepo, rankAuthors, scanPool, writeAuthor } from './author.ts';
|
|
11
11
|
import type { Predicate } from './rubric.ts';
|
|
12
12
|
import {
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
parseMachineReport,
|
|
29
29
|
runsInProject,
|
|
30
30
|
} from './conversations.ts';
|
|
31
|
+
import { at, errorMessage, isStringList, str } from './narrow.ts';
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* `vdx ai` — start the person's agent in a project, with the flags their
|
|
@@ -82,7 +83,7 @@ export interface NameSource {
|
|
|
82
83
|
export interface SessionProfile {
|
|
83
84
|
multiplexer?: Multiplexer;
|
|
84
85
|
name?: string;
|
|
85
|
-
/** Tried after `[vdx] name` in the project's mise.toml; the first non-empty string wins. */
|
|
86
|
+
/** Tried after `[_.vdx] name` (or the older `[vdx]`) in the project's mise.toml; the first non-empty string wins. */
|
|
86
87
|
project_names?: NameSource[];
|
|
87
88
|
/** The machines a project's agent may run on, as ssh destinations; vdx asks the others what runs there. */
|
|
88
89
|
machines?: string[];
|
|
@@ -144,13 +145,9 @@ export function resolveEnvironmentPath(
|
|
|
144
145
|
return fs.existsSync(user) ? user : null;
|
|
145
146
|
}
|
|
146
147
|
|
|
147
|
-
function isStringList(v: unknown): v is string[] {
|
|
148
|
-
return Array.isArray(v) && v.every((x) => typeof x === 'string');
|
|
149
|
-
}
|
|
150
|
-
|
|
151
148
|
/** Parse and validate a profile. Throws with the offending key named. */
|
|
152
149
|
export function parseEnvironment(text: string, source: string): Environment {
|
|
153
|
-
const data = YAML.load(text)
|
|
150
|
+
const data: unknown = YAML.load(text);
|
|
154
151
|
if (data === null || data === undefined) return {};
|
|
155
152
|
if (typeof data !== 'object' || Array.isArray(data)) {
|
|
156
153
|
throw new Error(`${source}: expected a YAML mapping at the top level`);
|
|
@@ -271,8 +268,8 @@ export function loadEnvironment(file: string): Environment {
|
|
|
271
268
|
let text: string;
|
|
272
269
|
try {
|
|
273
270
|
text = fs.readFileSync(file, 'utf8');
|
|
274
|
-
} catch (e
|
|
275
|
-
throw new Error(`cannot read profile ${file}: ${e
|
|
271
|
+
} catch (e) {
|
|
272
|
+
throw new Error(`cannot read profile ${file}: ${str(at(e, 'code')) ?? errorMessage(e)}`, { cause: e });
|
|
276
273
|
}
|
|
277
274
|
return parseEnvironment(text, file);
|
|
278
275
|
}
|
|
@@ -372,7 +369,7 @@ export function planLaunch(input: {
|
|
|
372
369
|
}
|
|
373
370
|
|
|
374
371
|
export function shellQuote(s: string): string {
|
|
375
|
-
if (s !== '' && /^[A-Za-z0-9_
|
|
372
|
+
if (s !== '' && /^[A-Za-z0-9_/.:@%+=,-]+$/.test(s)) return s;
|
|
376
373
|
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
377
374
|
}
|
|
378
375
|
|
|
@@ -544,7 +541,7 @@ export interface ProjectName {
|
|
|
544
541
|
}
|
|
545
542
|
|
|
546
543
|
/**
|
|
547
|
-
* `{project}`: `[vdx] name` in the project's mise.toml, else the first profile
|
|
544
|
+
* `{project}`: `[_.vdx] name` (or the older `[vdx]`) in the project's mise.toml, else the first profile
|
|
548
545
|
* source holding a non-empty string, else the repository name. A source that is
|
|
549
546
|
* missing or unreadable is skipped — a name is a label, not a reason to fail.
|
|
550
547
|
*/
|
|
@@ -555,8 +552,9 @@ export function resolveProjectName(input: {
|
|
|
555
552
|
home: string;
|
|
556
553
|
profileDir: string;
|
|
557
554
|
}): ProjectName {
|
|
558
|
-
const
|
|
559
|
-
|
|
555
|
+
const found = loadManifestAt(input.projectRoot);
|
|
556
|
+
const own = found?.manifest.name;
|
|
557
|
+
if (typeof own === 'string' && own.trim()) return { name: own.trim(), source: `mise.toml [${found!.place}] name` };
|
|
560
558
|
for (const src of input.sources) {
|
|
561
559
|
const file = path.resolve(
|
|
562
560
|
input.profileDir,
|
|
@@ -741,7 +739,7 @@ export interface AiDeps {
|
|
|
741
739
|
|
|
742
740
|
export function runSsh(args: string[], capture: boolean): { status: number; stdout: string } | null {
|
|
743
741
|
const res = spawnSync('ssh', args, { stdio: capture ? ['ignore', 'pipe', 'ignore'] : 'inherit', encoding: 'utf8' });
|
|
744
|
-
if ((res.error
|
|
742
|
+
if (at(res.error, 'code') === 'ENOENT') return null;
|
|
745
743
|
return { status: res.status ?? 1, stdout: res.stdout ?? '' };
|
|
746
744
|
}
|
|
747
745
|
|
|
@@ -758,8 +756,8 @@ export function askLine(prompt: string): string | null {
|
|
|
758
756
|
let n: number;
|
|
759
757
|
try {
|
|
760
758
|
n = fs.readSync(0, buf, 0, buf.length, null);
|
|
761
|
-
} catch (e
|
|
762
|
-
if (e
|
|
759
|
+
} catch (e) {
|
|
760
|
+
if (at(e, 'code') === 'EAGAIN') {
|
|
763
761
|
sleepSync(50);
|
|
764
762
|
continue;
|
|
765
763
|
}
|
|
@@ -1351,8 +1349,8 @@ function prepare(pathArg: string, deps: AiDeps, focused = false): Prepared | num
|
|
|
1351
1349
|
if (profilePath) {
|
|
1352
1350
|
try {
|
|
1353
1351
|
environment = loadEnvironment(profilePath);
|
|
1354
|
-
} catch (e
|
|
1355
|
-
log(`vdx ai: ${e
|
|
1352
|
+
} catch (e) {
|
|
1353
|
+
log(`vdx ai: ${errorMessage(e)}`);
|
|
1356
1354
|
return EXIT_USAGE;
|
|
1357
1355
|
}
|
|
1358
1356
|
}
|
|
@@ -1390,8 +1388,8 @@ function prepare(pathArg: string, deps: AiDeps, focused = false): Prepared | num
|
|
|
1390
1388
|
let plan: LaunchPlan;
|
|
1391
1389
|
try {
|
|
1392
1390
|
plan = replan(focused);
|
|
1393
|
-
} catch (e
|
|
1394
|
-
log(`vdx ai: ${profilePath ?? 'profile'}: ${e
|
|
1391
|
+
} catch (e) {
|
|
1392
|
+
log(`vdx ai: ${profilePath ?? 'profile'}: ${errorMessage(e)}`);
|
|
1395
1393
|
return EXIT_USAGE;
|
|
1396
1394
|
}
|
|
1397
1395
|
return { projectRoot, environment, profilePath, profileDir, plan, replan };
|
|
@@ -1445,8 +1443,9 @@ export function unreadableFolders(tmux: Tmux | null, home: string): string[] {
|
|
|
1445
1443
|
try {
|
|
1446
1444
|
fs.readdirSync(d);
|
|
1447
1445
|
return false;
|
|
1448
|
-
} catch (e
|
|
1449
|
-
|
|
1446
|
+
} catch (e) {
|
|
1447
|
+
const code = at(e, 'code');
|
|
1448
|
+
return code === 'EPERM' || code === 'EACCES';
|
|
1450
1449
|
}
|
|
1451
1450
|
});
|
|
1452
1451
|
}
|
|
@@ -1672,7 +1671,7 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
|
|
|
1672
1671
|
const env = plan.focused ? { ...process.env, [FOCUSED_ENV_VAR]: '1' } : undefined;
|
|
1673
1672
|
const startedAt = Date.now();
|
|
1674
1673
|
let res = spawnSync(plan.command, [...plan.args, ...plan.resumeArgs], { cwd: projectRoot, stdio: 'inherit', env });
|
|
1675
|
-
if ((res.error
|
|
1674
|
+
if (at(res.error, 'code') === 'ENOENT') {
|
|
1676
1675
|
log(`vdx ai: \`${plan.command}\` not found on PATH`);
|
|
1677
1676
|
return 127;
|
|
1678
1677
|
}
|
package/src/clone-checks.ts
CHANGED
|
@@ -34,7 +34,7 @@ function isGitWorkTree(root: string): boolean {
|
|
|
34
34
|
|
|
35
35
|
function executableHooks(dir: string): Map<string, string> {
|
|
36
36
|
const out = new Map<string, string>();
|
|
37
|
-
let names: string[]
|
|
37
|
+
let names: string[];
|
|
38
38
|
try {
|
|
39
39
|
names = fs.readdirSync(dir).filter((f) => !f.startsWith('.') && !f.endsWith('.sample'));
|
|
40
40
|
} catch {
|
package/src/conversations.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
+
import { at, dict, str } from './narrow.ts';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Claude Code conversations of a project, and the machine each one was last
|
|
@@ -86,17 +87,20 @@ export function readConversation(file: string, updated: Date): Conversation | nu
|
|
|
86
87
|
const isTitle = line.includes('"ai-title"') || line.includes('"last-prompt"');
|
|
87
88
|
if (!turns && line.includes('"type":"user"')) turns = true;
|
|
88
89
|
if (!isHook && !isTitle) continue;
|
|
89
|
-
let entry:
|
|
90
|
+
let entry: unknown;
|
|
90
91
|
try {
|
|
91
92
|
entry = JSON.parse(line);
|
|
92
93
|
} catch {
|
|
93
94
|
continue;
|
|
94
95
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
const type = at(entry, 'type');
|
|
97
|
+
const title = str(at(entry, 'aiTitle'));
|
|
98
|
+
const prompt = str(at(entry, 'lastPrompt'));
|
|
99
|
+
if (type === 'ai-title' && title !== undefined) aiTitle = title;
|
|
100
|
+
else if (type === 'last-prompt' && prompt !== undefined) lastPrompt = prompt;
|
|
101
|
+
else if (type === 'attachment' && at(entry, 'attachment', 'type') === 'hook_additional_context') {
|
|
102
|
+
const content = at(entry, 'attachment', 'content');
|
|
103
|
+
for (const part of Array.isArray(content) ? (content as unknown[]) : [content]) {
|
|
100
104
|
const label = typeof part === 'string' ? part.match(MACHINE_LINE_RE)?.[1] : undefined;
|
|
101
105
|
if (label && LABEL_RE.test(label)) machine = label;
|
|
102
106
|
}
|
|
@@ -197,9 +201,11 @@ export function latestPerMachine(
|
|
|
197
201
|
*/
|
|
198
202
|
export function liveConversationId(env: NodeJS.ProcessEnv, home: string, pid: number): string | null {
|
|
199
203
|
try {
|
|
200
|
-
const data = JSON.parse(
|
|
201
|
-
|
|
202
|
-
|
|
204
|
+
const data: unknown = JSON.parse(
|
|
205
|
+
fs.readFileSync(path.join(claudeConfigDir(env, home), 'sessions', `${pid}.json`), 'utf8'),
|
|
206
|
+
);
|
|
207
|
+
const id = str(at(data, 'sessionId'));
|
|
208
|
+
return id !== undefined && isConversationId(id) ? id : null;
|
|
203
209
|
} catch {
|
|
204
210
|
return null;
|
|
205
211
|
}
|
|
@@ -209,9 +215,8 @@ export function liveConversationId(env: NodeJS.ProcessEnv, home: string, pid: nu
|
|
|
209
215
|
export function liveStartedAt(env: NodeJS.ProcessEnv, home: string, pid: number): number | null {
|
|
210
216
|
try {
|
|
211
217
|
const file = path.join(claudeConfigDir(env, home), 'sessions', `${pid}.json`);
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
return typeof at === 'number' ? at : null;
|
|
218
|
+
const started = at(JSON.parse(fs.readFileSync(file, 'utf8')), 'startedAt');
|
|
219
|
+
return typeof started === 'number' ? started : null;
|
|
215
220
|
} catch {
|
|
216
221
|
return null;
|
|
217
222
|
}
|
|
@@ -234,20 +239,22 @@ export function parseLiveSessions(text: string): LiveSession[] {
|
|
|
234
239
|
const out: LiveSession[] = [];
|
|
235
240
|
for (const line of text.split('\n')) {
|
|
236
241
|
if (!line.trim().startsWith('{')) continue;
|
|
237
|
-
let data:
|
|
242
|
+
let data: Record<string, unknown> | undefined;
|
|
238
243
|
try {
|
|
239
|
-
data = JSON.parse(line);
|
|
244
|
+
data = dict(JSON.parse(line));
|
|
240
245
|
} catch {
|
|
241
246
|
continue;
|
|
242
247
|
}
|
|
243
|
-
|
|
244
|
-
|
|
248
|
+
const pid = data?.['pid'];
|
|
249
|
+
const conversation = str(data?.['sessionId']);
|
|
250
|
+
if (typeof pid !== 'number' || !Number.isInteger(pid) || conversation === undefined) continue;
|
|
251
|
+
if (!isConversationId(conversation)) continue;
|
|
245
252
|
out.push({
|
|
246
|
-
pid
|
|
247
|
-
conversation
|
|
248
|
-
tmux:
|
|
249
|
-
status:
|
|
250
|
-
cwd:
|
|
253
|
+
pid,
|
|
254
|
+
conversation,
|
|
255
|
+
tmux: str(data?.['tmux']) ?? null,
|
|
256
|
+
status: str(data?.['status']) ?? null,
|
|
257
|
+
cwd: str(data?.['cwd']) ?? null,
|
|
251
258
|
});
|
|
252
259
|
}
|
|
253
260
|
return out;
|
|
@@ -277,8 +284,8 @@ function pidAlive(pid: number): boolean {
|
|
|
277
284
|
try {
|
|
278
285
|
process.kill(pid, 0);
|
|
279
286
|
return true;
|
|
280
|
-
} catch (e
|
|
281
|
-
return e
|
|
287
|
+
} catch (e) {
|
|
288
|
+
return at(e, 'code') === 'EPERM';
|
|
282
289
|
}
|
|
283
290
|
}
|
|
284
291
|
|
package/src/doctor.ts
CHANGED
|
@@ -6,6 +6,7 @@ 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 { at, dict } from './narrow.ts';
|
|
9
10
|
import {
|
|
10
11
|
checkPersonalHooks,
|
|
11
12
|
defaultPersonalHookDeps,
|
|
@@ -354,7 +355,7 @@ export function checkClaudeCodePlugin(
|
|
|
354
355
|
claudeDir: string = process.env['CLAUDE_CONFIG_DIR'] || path.join(os.homedir(), '.claude'),
|
|
355
356
|
): CheckResult {
|
|
356
357
|
const base = { id: 'claude-plugin', label: 'Claude Code vdx plugin' };
|
|
357
|
-
const readJson = (file: string):
|
|
358
|
+
const readJson = (file: string): unknown => {
|
|
358
359
|
try {
|
|
359
360
|
return JSON.parse(fs.readFileSync(path.join(claudeDir, file), 'utf8'));
|
|
360
361
|
} catch {
|
|
@@ -370,13 +371,13 @@ export function checkClaudeCodePlugin(
|
|
|
370
371
|
remedy: PLUGIN_INSTALL_HINT,
|
|
371
372
|
};
|
|
372
373
|
}
|
|
373
|
-
const key = Object.keys(installed
|
|
374
|
+
const key = Object.keys(dict(at(installed, 'plugins')) ?? {}).find((k) => k.startsWith('vdx@'));
|
|
374
375
|
if (!key) {
|
|
375
376
|
return { ...base, status: 'warning', message: 'not installed', remedy: PLUGIN_INSTALL_HINT };
|
|
376
377
|
}
|
|
377
|
-
const version = installed
|
|
378
|
+
const version = at(installed, 'plugins', key, '0', 'version');
|
|
378
379
|
const label = `${key}${typeof version === 'string' ? ` ${version}` : ''}`;
|
|
379
|
-
if (readJson('settings.json')
|
|
380
|
+
if (at(readJson('settings.json'), 'enabledPlugins', key) === false) {
|
|
380
381
|
return { ...base, status: 'warning', message: `${label} installed but disabled`, remedy: `/plugin enable ${key}` };
|
|
381
382
|
}
|
|
382
383
|
return { ...base, status: 'ok', message: label };
|
package/src/evaluator.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Predicate } from './rubric.ts';
|
|
2
2
|
import type { Ctx } from './facts.ts';
|
|
3
|
-
import { REGISTRY, isUnknown, type Unknown, type Verdict } from './predicates.ts';
|
|
3
|
+
import { REGISTRY, isUnknown, type Args, type Unknown, type Verdict } from './predicates.ts';
|
|
4
|
+
import { at, isDict, list } from './narrow.ts';
|
|
4
5
|
|
|
5
6
|
export { isUnknown, type Unknown, type Verdict };
|
|
6
7
|
|
|
@@ -34,21 +35,21 @@ export function evalVerdict(p: Predicate, ctx: Ctx): Verdict {
|
|
|
34
35
|
if (p === null || p === undefined) return false;
|
|
35
36
|
if (typeof p === 'boolean') return p;
|
|
36
37
|
|
|
37
|
-
if (
|
|
38
|
-
const obj = p
|
|
38
|
+
if (isDict(p)) {
|
|
39
|
+
const obj = p;
|
|
39
40
|
|
|
40
41
|
if ('all_of' in obj) {
|
|
41
|
-
const vs = (obj
|
|
42
|
+
const vs = list(obj['all_of']).map((q) => evalVerdict(q, ctx));
|
|
42
43
|
if (vs.some((v) => v === false)) return false;
|
|
43
44
|
return firstUnknown(vs) ?? true;
|
|
44
45
|
}
|
|
45
46
|
if ('any_of' in obj) {
|
|
46
|
-
const vs = (obj
|
|
47
|
+
const vs = list(obj['any_of']).map((q) => evalVerdict(q, ctx));
|
|
47
48
|
if (vs.some((v) => v === true)) return true;
|
|
48
49
|
return firstUnknown(vs) ?? false;
|
|
49
50
|
}
|
|
50
51
|
if ('at_least_n_of' in obj) {
|
|
51
|
-
const { n, predicates } = obj
|
|
52
|
+
const { n, predicates } = obj['at_least_n_of'] as { n: number; predicates: Predicate[] };
|
|
52
53
|
const vs = predicates.map((q) => evalVerdict(q, ctx));
|
|
53
54
|
const matched = vs.filter((v) => v === true).length;
|
|
54
55
|
if (matched >= n) return true;
|
|
@@ -57,16 +58,18 @@ export function evalVerdict(p: Predicate, ctx: Ctx): Verdict {
|
|
|
57
58
|
return firstUnknown(vs)!;
|
|
58
59
|
}
|
|
59
60
|
if ('not' in obj) {
|
|
60
|
-
const v = evalVerdict(obj
|
|
61
|
+
const v = evalVerdict(obj['not'], ctx);
|
|
61
62
|
return isUnknown(v) ? v : !v;
|
|
62
63
|
}
|
|
63
64
|
if ('fn' in obj) {
|
|
64
|
-
const
|
|
65
|
+
const name = String(obj['fn']);
|
|
66
|
+
const fn = REGISTRY[name];
|
|
65
67
|
if (!fn) {
|
|
66
|
-
process.stderr.write(`warning: unknown predicate "${
|
|
68
|
+
process.stderr.write(`warning: unknown predicate "${name}"\n`);
|
|
67
69
|
return false;
|
|
68
70
|
}
|
|
69
|
-
|
|
71
|
+
// `args` is the predicate's to read: package_present takes a bare string too.
|
|
72
|
+
return fn((obj['args'] ?? {}) as Args, ctx);
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
// Sugar form: single-key object where key is a registered predicate
|
|
@@ -76,9 +79,7 @@ export function evalVerdict(p: Predicate, ctx: Ctx): Verdict {
|
|
|
76
79
|
const fn = REGISTRY[key];
|
|
77
80
|
if (fn) {
|
|
78
81
|
const value = obj[key];
|
|
79
|
-
if (
|
|
80
|
-
return fn(value, ctx);
|
|
81
|
-
}
|
|
82
|
+
if (isDict(value)) return fn(value, ctx);
|
|
82
83
|
const argKey = SUGAR_ARG_KEY[key];
|
|
83
84
|
if (argKey) {
|
|
84
85
|
return fn({ [argKey]: value }, ctx);
|
|
@@ -104,14 +105,14 @@ export function evalPredicate(p: Predicate, ctx: Ctx): boolean {
|
|
|
104
105
|
export function predicateNames(p: Predicate): string[] {
|
|
105
106
|
const out: string[] = [];
|
|
106
107
|
const walk = (q: Predicate): void => {
|
|
107
|
-
if (
|
|
108
|
-
const obj = q
|
|
109
|
-
if ('all_of' in obj) return (obj
|
|
110
|
-
if ('any_of' in obj) return (obj
|
|
111
|
-
if ('at_least_n_of' in obj) return ((obj
|
|
112
|
-
if ('not' in obj) return walk(obj
|
|
108
|
+
if (!isDict(q)) return;
|
|
109
|
+
const obj = q;
|
|
110
|
+
if ('all_of' in obj) return list(obj['all_of']).forEach(walk);
|
|
111
|
+
if ('any_of' in obj) return list(obj['any_of']).forEach(walk);
|
|
112
|
+
if ('at_least_n_of' in obj) return list(at(obj, 'at_least_n_of', 'predicates')).forEach(walk);
|
|
113
|
+
if ('not' in obj) return walk(obj['not']);
|
|
113
114
|
if ('fn' in obj) {
|
|
114
|
-
out.push(String(obj
|
|
115
|
+
out.push(String(obj['fn']));
|
|
115
116
|
return;
|
|
116
117
|
}
|
|
117
118
|
const keys = Object.keys(obj);
|