@vodmal/vdx-cli 0.25.0 → 0.27.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
@@ -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.25.0",
3
+ "version": "0.27.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",
@@ -34,16 +34,21 @@
34
34
  "LICENSE"
35
35
  ],
36
36
  "scripts": {
37
+ "prepare": "git config core.hooksPath .githooks || true",
37
38
  "vdx": "tsx src/index.ts",
38
39
  "vdx-mcp": "tsx src/mcp-server.ts",
39
40
  "audit": "tsx src/index.ts audit",
40
41
  "smoke": "bash smoke.sh",
41
42
  "typecheck": "tsc --noEmit",
42
43
  "format:check": "prettier --check .",
43
- "check": "npm run typecheck && npm run format:check",
44
- "fix": "prettier --write .",
44
+ "lint": "eslint .",
45
+ "knip": "knip",
46
+ "check": "npm run typecheck && npm run format:check && npm run lint && npm run knip",
47
+ "check:code:security": "npm audit --omit=dev",
48
+ "fix": "eslint --fix . && prettier --write .",
45
49
  "test": "vitest run",
46
50
  "test:unit": "vitest run tests/unit",
51
+ "test:e2e": "vitest run --config vitest.e2e.config.ts",
47
52
  "coverage": "vitest run --coverage"
48
53
  },
49
54
  "dependencies": {
@@ -57,18 +62,23 @@
57
62
  "zod": "^3.23.0"
58
63
  },
59
64
  "devDependencies": {
65
+ "@eslint/js": "^10.0.1",
60
66
  "@types/js-yaml": "^4.0.9",
61
67
  "@types/marked-terminal": "^6.1.1",
62
68
  "@types/node": "^22.0.0",
63
- "@vitest/coverage-v8": "^4.1.7",
69
+ "@vitest/coverage-v8": "^4.1.11",
70
+ "eslint": "^10.12.0",
71
+ "eslint-config-prettier": "^10.1.8",
72
+ "knip": "^6.41.0",
64
73
  "prettier": "^3.9.10",
65
74
  "typescript": "^5.6.0",
66
- "vitest": "^4.1.7"
75
+ "typescript-eslint": "^8.71.2",
76
+ "vitest": "^4.1.11"
67
77
  },
68
78
  "publishConfig": {
69
79
  "access": "public"
70
80
  },
71
81
  "engines": {
72
- "node": ">=20.11"
82
+ "node": ">=22"
73
83
  }
74
84
  }
@@ -11,7 +11,7 @@ schema_version: "0.3"
11
11
 
12
12
  metadata:
13
13
  name: vdx-rubric-vodmal
14
- version: "1.4.0"
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
- predicate:
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
- pattern: "recommended-type-checked"
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
- - has_file: .prettierrc
320
- - has_file: biome.json
321
- - has_file: eslint.config.js
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
- - any_of: [{ has_file: .prettierrc }, { has_file: biome.json }]
337
- - has_file: eslint.config.js
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 { loadManifest } from './manifest.ts';
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
@@ -48,12 +49,12 @@ import {
48
49
  */
49
50
 
50
51
  /** A prompt a conditional flag is known to cause, and the tmux keys that answer it. */
51
- export interface ConfirmRule {
52
+ interface ConfirmRule {
52
53
  screen: string;
53
54
  keys: string[];
54
55
  }
55
56
 
56
- export interface WhenRule {
57
+ interface WhenRule {
57
58
  id: string;
58
59
  description?: string;
59
60
  if: Predicate;
@@ -70,7 +71,7 @@ export interface AgentProfile {
70
71
  when?: WhenRule[];
71
72
  }
72
73
 
73
- export type Multiplexer = 'tmux' | 'none';
74
+ type Multiplexer = 'tmux' | 'none';
74
75
 
75
76
  /** A place a project's short name may come from: a structured file and a dot path into it. */
76
77
  export interface NameSource {
@@ -79,10 +80,10 @@ export interface NameSource {
79
80
  jsonpath: string;
80
81
  }
81
82
 
82
- export interface SessionProfile {
83
+ 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[];
@@ -106,7 +107,7 @@ export interface PersonalHook {
106
107
  description?: string;
107
108
  }
108
109
 
109
- export interface GitProfile {
110
+ interface GitProfile {
110
111
  /** Directories whose repos are the neighbours an author is proposed from; default — the project's parent. */
111
112
  author_pool?: string[];
112
113
  hooks?: PersonalHook[];
@@ -120,19 +121,19 @@ export interface Environment {
120
121
  git?: GitProfile;
121
122
  }
122
123
 
123
- export const ENVIRONMENT_ENV_VAR = 'VDX_ENVIRONMENT';
124
- export const HOST_ENV_VAR = 'VDX_HOST';
124
+ const ENVIRONMENT_ENV_VAR = 'VDX_ENVIRONMENT';
125
+ const HOST_ENV_VAR = 'VDX_HOST';
125
126
  /** Set by `vdx ai@<host>` for the vdx it starts there: the machine the person sits at. */
126
- export const SEAT_ENV_VAR = 'VDX_AI_FROM';
127
+ const SEAT_ENV_VAR = 'VDX_AI_FROM';
127
128
  /**
128
129
  * `1` in the environment of an agent started with `vdx ai --focused`, and so of
129
130
  * its hooks and MCP servers: nothing wakes this session or reads its inbox for it.
130
131
  */
131
- export const FOCUSED_ENV_VAR = 'VDX_FOCUSED';
132
- export const USER_ENVIRONMENT_FILE = '.vdx-environment.yaml';
132
+ const FOCUSED_ENV_VAR = 'VDX_FOCUSED';
133
+ const USER_ENVIRONMENT_FILE = '.vdx-environment.yaml';
133
134
  /** Tried in order when no profile names an agent. Started plain, with no flags. */
134
- export const FALLBACK_AGENTS = ['claude', 'codex'] as const;
135
- export const DEFAULT_SESSION_NAME = '{project}';
135
+ const FALLBACK_AGENTS = ['claude', 'codex'] as const;
136
+ const DEFAULT_SESSION_NAME = '{project}';
136
137
 
137
138
  export function resolveEnvironmentPath(
138
139
  env: NodeJS.ProcessEnv = process.env,
@@ -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) as unknown;
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: any) {
275
- throw new Error(`cannot read profile ${file}: ${e?.code ?? e?.message ?? 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_\/.:@%+=,-]+$/.test(s)) return s;
372
+ if (s !== '' && /^[A-Za-z0-9_/.:@%+=,-]+$/.test(s)) return s;
376
373
  return `'${s.replace(/'/g, `'\\''`)}'`;
377
374
  }
378
375
 
@@ -404,7 +401,7 @@ export interface ProcInfo {
404
401
  args: string;
405
402
  }
406
403
 
407
- export const PANE_FORMAT = '#{session_name}\t#{pane_id}\t#{pane_pid}\t#{pane_current_path}';
404
+ const PANE_FORMAT = '#{session_name}\t#{pane_id}\t#{pane_pid}\t#{pane_current_path}';
408
405
 
409
406
  export function parsePanes(out: string): PaneInfo[] {
410
407
  const panes: PaneInfo[] = [];
@@ -465,7 +462,7 @@ function realpathOr(p: string): string {
465
462
  }
466
463
  }
467
464
 
468
- export function isInside(child: string, parent: string): boolean {
465
+ function isInside(child: string, parent: string): boolean {
469
466
  return child === parent || child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
470
467
  }
471
468
 
@@ -500,7 +497,7 @@ export function missingArgs(procArgs: string, required: string[]): string[] {
500
497
  return required.filter((a) => (/\s/.test(a) ? !procArgs.includes(a) : !tokens.has(a)));
501
498
  }
502
499
 
503
- export function resolveProjectRoot(p: string): string | null {
500
+ function resolveProjectRoot(p: string): string | null {
504
501
  const abs = path.resolve(p);
505
502
  try {
506
503
  if (!fs.statSync(abs).isDirectory()) return null;
@@ -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 own = loadManifest(input.projectRoot)?.name;
559
- if (typeof own === 'string' && own.trim()) return { name: own.trim(), source: 'mise.toml [vdx] name' };
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,
@@ -582,12 +580,12 @@ export function hostLabel(env: NodeJS.ProcessEnv = process.env): string {
582
580
  }
583
581
 
584
582
  /** The machine the person sits at: the one whose `vdx ai@<host>` started this vdx, else this one. */
585
- export function seatLabel(env: NodeJS.ProcessEnv = process.env): string {
583
+ function seatLabel(env: NodeJS.ProcessEnv = process.env): string {
586
584
  return env[SEAT_ENV_VAR]?.trim() || hostLabel(env);
587
585
  }
588
586
 
589
587
  /** Machine labels compare without case: `M3` in the profile is `m3` in a transcript. */
590
- export function sameMachine(a: string | null, b: string | null): boolean {
588
+ function sameMachine(a: string | null, b: string | null): boolean {
591
589
  return (a ?? '').toLowerCase() === (b ?? '').toLowerCase();
592
590
  }
593
591
 
@@ -739,9 +737,9 @@ export interface AiDeps {
739
737
  liveHere?: () => LiveSession[];
740
738
  }
741
739
 
742
- export function runSsh(args: string[], capture: boolean): { status: number; stdout: string } | null {
740
+ 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 as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') return null;
742
+ if (at(res.error, 'code') === 'ENOENT') return null;
745
743
  return { status: res.status ?? 1, stdout: res.stdout ?? '' };
746
744
  }
747
745
 
@@ -750,7 +748,7 @@ export function sleepSync(ms: number): void {
750
748
  }
751
749
 
752
750
  /** Read one line from the terminal, synchronously: the prompt runs before tmux takes it. */
753
- export function askLine(prompt: string): string | null {
751
+ function askLine(prompt: string): string | null {
754
752
  process.stderr.write(prompt);
755
753
  const buf = Buffer.alloc(256);
756
754
  let text = '';
@@ -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: any) {
762
- if (e?.code === 'EAGAIN') {
759
+ } catch (e) {
760
+ if (at(e, 'code') === 'EAGAIN') {
763
761
  sleepSync(50);
764
762
  continue;
765
763
  }
@@ -800,9 +798,9 @@ export const EXIT_LAUNCH_FAILED = 4;
800
798
  * a start with the resume args had nothing to resume (`claude --continue` exits
801
799
  * 1 at once); the window covers answering the channel prompt that comes first.
802
800
  */
803
- export const NOTHING_TO_RESUME_MS = 30_000;
801
+ const NOTHING_TO_RESUME_MS = 30_000;
804
802
 
805
- export function renderPlan(
803
+ function renderPlan(
806
804
  plan: LaunchPlan,
807
805
  multiplexer: Multiplexer,
808
806
  running: AgentPane[],
@@ -1085,7 +1083,7 @@ export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, from: s
1085
1083
  * The Claude Code sessions alive on `host` right now; null when it does not
1086
1084
  * answer over ssh (BatchMode: a password prompt counts as no answer).
1087
1085
  */
1088
- export function askMachine(host: string, deps: AiDeps): MachineReport | null {
1086
+ function askMachine(host: string, deps: AiDeps): MachineReport | null {
1089
1087
  const res = (deps.ssh ?? runSsh)(
1090
1088
  ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', host, `sh -c ${shellQuote(LIVE_SESSIONS_SCRIPT)}`],
1091
1089
  true,
@@ -1094,7 +1092,7 @@ export function askMachine(host: string, deps: AiDeps): MachineReport | null {
1094
1092
  }
1095
1093
 
1096
1094
  /** What a start continues: resume args here, or a conversation on another machine. */
1097
- export interface ConversationChoice {
1095
+ interface ConversationChoice {
1098
1096
  args: string[];
1099
1097
  /** Continue it on that machine (`vdx ai@<host>`); `reachable` — it answered over ssh just now. */
1100
1098
  remote?: { host: string; id: string; reachable: boolean };
@@ -1108,7 +1106,7 @@ export interface ConversationChoice {
1108
1106
  lines: string[];
1109
1107
  }
1110
1108
 
1111
- export function isClaude(command: string): boolean {
1109
+ function isClaude(command: string): boolean {
1112
1110
  return path.basename(command) === 'claude';
1113
1111
  }
1114
1112
 
@@ -1138,7 +1136,7 @@ const resumeId = (id: string) => ['--resume', id];
1138
1136
  * takes the newest overall; without a terminal it is this machine's newest
1139
1137
  * idle one, never another's.
1140
1138
  */
1141
- export function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boolean): ConversationChoice {
1139
+ function chooseConversation(plan: LaunchPlan, deps: AiDeps, canAsk: boolean): ConversationChoice {
1142
1140
  if (!isClaude(plan.command)) return { args: plan.resumeArgs, lines: [] };
1143
1141
  const host = plan.host;
1144
1142
  const same = sameMachine;
@@ -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: any) {
1355
- log(`vdx ai: ${e?.message ?? 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: any) {
1394
- log(`vdx ai: ${profilePath ?? 'profile'}: ${e?.message ?? 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 };
@@ -1414,7 +1412,7 @@ export function processFocused(pid: number): boolean {
1414
1412
  }
1415
1413
 
1416
1414
  /** macOS guards these folders on their own; a tmux server can hold a denial for every session under it. */
1417
- export const GUARDED_FOLDERS = ['Downloads', 'Desktop', 'Documents'];
1415
+ const GUARDED_FOLDERS = ['Downloads', 'Desktop', 'Documents'];
1418
1416
 
1419
1417
  /**
1420
1418
  * The guarded folders an agent started here could not read. Through the tmux server
@@ -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: any) {
1449
- return e?.code === 'EPERM' || e?.code === 'EACCES';
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 as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
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/author.ts CHANGED
@@ -81,7 +81,7 @@ const GENERIC = new Set([
81
81
  'dmitry',
82
82
  ]);
83
83
 
84
- export function nameTokens(dir: string, remotePath: string): Set<string> {
84
+ function nameTokens(dir: string, remotePath: string): Set<string> {
85
85
  return new Set(
86
86
  `${path.basename(dir)} ${remotePath}`
87
87
  .toLowerCase()
@@ -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 {
@@ -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
@@ -28,7 +29,7 @@ import * as path from 'node:path';
28
29
  */
29
30
 
30
31
  /** The opening of `vdx ai --check`'s text; the label is an ssh destination from the other machines. */
31
- export const MACHINE_LINE_PREFIX = 'vdx ai: this machine is';
32
+ const MACHINE_LINE_PREFIX = 'vdx ai: this machine is';
32
33
  const MACHINE_LINE_RE = /^vdx ai: this machine is `([^`]+)`/m;
33
34
  /** A label read from a synced file goes to ssh: it must not be able to read as an option. */
34
35
  const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
@@ -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: any;
90
+ let entry: unknown;
90
91
  try {
91
92
  entry = JSON.parse(line);
92
93
  } catch {
93
94
  continue;
94
95
  }
95
- if (entry?.type === 'ai-title' && typeof entry.aiTitle === 'string') aiTitle = entry.aiTitle;
96
- else if (entry?.type === 'last-prompt' && typeof entry.lastPrompt === 'string') lastPrompt = entry.lastPrompt;
97
- else if (entry?.type === 'attachment' && entry.attachment?.type === 'hook_additional_context') {
98
- const content = entry.attachment.content;
99
- for (const part of Array.isArray(content) ? content : [content]) {
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
  }
@@ -108,7 +112,7 @@ export function readConversation(file: string, updated: Date): Conversation | nu
108
112
  }
109
113
 
110
114
  /** Which of `ids` had a prompt typed in them on this machine. */
111
- export function typedHere(env: NodeJS.ProcessEnv, home: string, ids: Set<string>): Set<string> {
115
+ function typedHere(env: NodeJS.ProcessEnv, home: string, ids: Set<string>): Set<string> {
112
116
  const found = new Set<string>();
113
117
  let text: string;
114
118
  try {
@@ -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(fs.readFileSync(path.join(claudeConfigDir(env, home), 'sessions', `${pid}.json`), 'utf8'));
201
- const id = data?.sessionId;
202
- return typeof id === 'string' && isConversationId(id) ? id : null;
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 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;
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
  }
@@ -230,24 +235,26 @@ export interface LiveSession {
230
235
  }
231
236
 
232
237
  /** Session files, one JSON object per line — a file read here, or the output of LIVE_SESSIONS_SCRIPT. */
233
- export function parseLiveSessions(text: string): LiveSession[] {
238
+ 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: any;
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
- if (!Number.isInteger(data?.pid) || typeof data?.sessionId !== 'string' || !isConversationId(data.sessionId))
244
- continue;
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: data.pid,
247
- conversation: data.sessionId,
248
- tmux: typeof data.tmux === 'string' ? data.tmux : null,
249
- status: typeof data.status === 'string' ? data.status : null,
250
- cwd: typeof data.cwd === 'string' ? data.cwd : null,
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: any) {
281
- return e?.code === 'EPERM';
287
+ } catch (e) {
288
+ return at(e, 'code') === 'EPERM';
282
289
  }
283
290
  }
284
291