@vodmal/vdx-cli 0.2.0 → 0.3.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
@@ -1,51 +1,63 @@
1
- # vdx-cli — Native predicate evaluator (v0.1)
1
+ # @vodmal/vdx-cli — vdx evaluator + MCP server
2
2
 
3
- Минимальный аудит-движок для рубрики `vdx-rubric-vodmal`. Шаг C плана
4
- реализации (см. `../docs/decisions.md` D10 — нативный оценщик, не OPA).
3
+ Native predicate evaluator for `vdx-rubric-vodmal` + MCP stdio server for the
4
+ Claude Code `vdx` plugin. Decision basis: [docs/decisions.md](../docs/decisions.md)
5
+ D10 (native evaluator, not OPA).
5
6
 
6
- ## Установка
7
+ Published on npm as **[@vodmal/vdx-cli](https://www.npmjs.com/package/@vodmal/vdx-cli)**.
8
+
9
+ ## Install
7
10
 
8
11
  ```bash
9
- cd "$(realpath .)"
10
- npm install
12
+ # global install
13
+ npm install -g @vodmal/vdx-cli
14
+
15
+ # or one-shot via npx (no install)
16
+ npx -y -p @vodmal/vdx-cli vdx audit /path/to/project
11
17
  ```
12
18
 
13
- ## Запуск
19
+ ## Use
14
20
 
15
21
  ```bash
16
- npx tsx src/index.ts audit /path/to/project
17
- # или
18
- npm run audit -- /path/to/project --json
22
+ vdx audit <project-path> [--json] [--rubric <path>] [--stack <id>]
23
+ vdx init <project-path> [--baseline github.com/org/repo@vX.Y] [--dry-run] [--force]
24
+ vdx-mcp --project <path> # MCP stdio server (used by the plugin)
19
25
  ```
20
26
 
21
- По умолчанию рубрика читается из
22
- `/Users/vdm/AI Projects/vdx-rubric-vodmal/vdx-rubric.yaml`. Override:
23
- `--rubric /alt/path.yaml`.
27
+ By default the bundled `rubric/vdx-rubric.yaml` is used (a mirror of canonical
28
+ [vdx-rubric-vodmal](https://github.com/VoDmAl/vdx-rubric-vodmal) at the time of
29
+ each CLI release). Override via `--rubric <path>` or the env var `VDX_RUBRIC`.
24
30
 
25
- ## Smoke-тест
31
+ ## Dev
26
32
 
27
33
  ```bash
28
- npm run smoke
34
+ git clone https://github.com/VoDmAl/vdx
35
+ cd vdx/cli
36
+ npm install
37
+ npm run audit -- /path/to/project --json
38
+ npm run smoke # gauge against 3 calibration references
39
+ npm run typecheck
29
40
  ```
30
41
 
31
- Прогоняет аудит на трёх калибровочных референсах
32
- (telegram / t23b / bookmap). Ожидаемо все capped at L2 на оси `ci`.
33
-
34
- ## Структура
42
+ ## Structure
35
43
 
36
- - `src/rubric.ts` — типы + YAML-загрузчик
37
- - `src/manifest.ts` — парсинг `[vdx]` блока в `mise.toml` + `.vdx-overrides.yml`
38
- - `src/facts.ts` — fact source loaders (tasks, packages, configs)
39
- - `src/predicates.ts` — registry предикатов (v0.1: 11 функций)
40
- - `src/evaluator.ts` — рекурсивный evaluator + сахарная нотация
41
- - `src/scoring.ts` — delta-style для уровней, flags для гибкой оси
42
- - `src/audit.ts` — оркестратор: применяет overrides + считает уровень (D7)
43
- - `src/report.ts` — markdown / JSON формат отчёта
44
+ - `src/rubric.ts` — types + YAML loader
45
+ - `src/manifest.ts` — parser for `[vdx]` block in `mise.toml` + `.vdx-overrides.yml`
46
+ - `src/facts.ts` — fact source loaders (tasks, packages, configs, sub-package detection)
47
+ - `src/predicates.ts` — predicate registry (11 functions)
48
+ - `src/evaluator.ts` — recursive evaluator + sugar notation
49
+ - `src/scoring.ts` — delta-style levels, flags for orthogonal axes
50
+ - `src/audit.ts` — orchestrator: overrides + applies_to filter + subpackage-ctx
51
+ - `src/report.ts` — markdown / JSON output
44
52
  - `src/index.ts` — CLI entry
53
+ - `src/mcp-server.ts` — MCP stdio server (9 tools)
54
+ - `src/defaults.ts` — bundled rubric resolution
55
+ - `bin/vdx.cjs`, `bin/vdx-mcp.cjs` — Node wrappers (tsx ESM loader in-process)
45
56
 
46
- ## Что НЕ реализовано в v0.1
57
+ ## Not implemented yet
47
58
 
48
- - `config_value`, `command_succeeds` предикаты — заглушки с warning.
49
- - Загрузка baseline из git-репо по `baseline:` ссылке — пока только local file.
50
- - Watermark drift (фаза 2 из drift-algorithm.md).
51
- - MCP-server обёртка (D6 — следующий шаг).
59
+ - `config_value`/`command_succeeds` predicates are stubs (warning at evaluation).
60
+ - Baseline loading from a git ref is documented but evaluator still reads file
61
+ paths only — `baseline:` in `mise.toml` is recorded but does not auto-fetch.
62
+ - Watermark drift (phase 2 of `drift-algorithm.md`).
63
+ - O25/O26/O27/O33/O34 — see [docs/decisions.md](../docs/decisions.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
@@ -23,8 +23,8 @@
23
23
  },
24
24
  "type": "module",
25
25
  "bin": {
26
- "vdx": "./bin/vdx.cjs",
27
- "vdx-mcp": "./bin/vdx-mcp.cjs"
26
+ "vdx": "bin/vdx.cjs",
27
+ "vdx-mcp": "bin/vdx-mcp.cjs"
28
28
  },
29
29
  "files": [
30
30
  "src",
@@ -39,7 +39,9 @@
39
39
  "audit": "tsx src/index.ts audit",
40
40
  "smoke": "bash smoke.sh",
41
41
  "typecheck": "tsc --noEmit",
42
- "test": "tsc --noEmit"
42
+ "test": "vitest run",
43
+ "test:unit": "vitest run tests/unit",
44
+ "coverage": "vitest run --coverage"
43
45
  },
44
46
  "dependencies": {
45
47
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -51,7 +53,9 @@
51
53
  "devDependencies": {
52
54
  "@types/js-yaml": "^4.0.9",
53
55
  "@types/node": "^22.0.0",
54
- "typescript": "^5.6.0"
56
+ "@vitest/coverage-v8": "^4.1.7",
57
+ "typescript": "^5.6.0",
58
+ "vitest": "^4.1.7"
55
59
  },
56
60
  "publishConfig": {
57
61
  "access": "public"
@@ -11,7 +11,7 @@ schema_version: "0.2"
11
11
 
12
12
  metadata:
13
13
  name: vdx-rubric-vodmal
14
- version: "0.2.2"
14
+ version: "0.3.1"
15
15
  description: "Personal maturity rubric for multi-stack projects (vodmal)"
16
16
  owner: vodmal
17
17
  homepage: "https://github.com/vodmal/vdx-rubric"
@@ -534,3 +534,84 @@ axes:
534
534
  file_contains:
535
535
  paths: [misc/traefik-global/README.md, misc/traefik-global/start.sh]
536
536
  pattern: "@v\\d+\\.\\d+\\.\\d+" # semver pin
537
+
538
+ - id: release-artifact
539
+ class: supporting
540
+ description: "Publish-readiness: metadata completeness + entry points + registry signals (O34, v0.3.0). Применяется только к проектам, заявляющим publish-intent (O35, v0.3.1) — иначе excluded."
541
+ applies_to: [node, php, ruby, python]
542
+ # O35 (v0.3.1): ось применяется только если проект декларирует намерение
543
+ # публиковать артефакт. Чистые app'ы (private:true в package.json или
544
+ # composer type:project) → excluded.
545
+ applies_when:
546
+ any_of:
547
+ # Node-сторона: НЕ private И есть хоть один entry-point/publish-signal.
548
+ - all_of:
549
+ - has_file: package.json
550
+ - not:
551
+ config_value: { path: package.json, jsonpath: "private", equals: true }
552
+ - any_of:
553
+ - config_value: { path: package.json, jsonpath: "publishConfig", op: present }
554
+ - config_value: { path: package.json, jsonpath: "bin", op: present }
555
+ - config_value: { path: package.json, jsonpath: "main", op: present }
556
+ - config_value: { path: package.json, jsonpath: "exports", op: present }
557
+ - config_value: { path: package.json, jsonpath: "module", op: present }
558
+ # PHP-сторона: composer.json есть, name задан, type ≠ project (project = app).
559
+ - all_of:
560
+ - has_file: composer.json
561
+ - config_value: { path: composer.json, jsonpath: "name", op: present }
562
+ - not:
563
+ config_value: { path: composer.json, jsonpath: "type", op: equals, equals: "project" }
564
+ storage: level
565
+ default_target: L4
566
+ fact_sources:
567
+ - package.json
568
+ - composer.json
569
+ - LICENSE
570
+ - LICENSE.md
571
+ - LICENSE.txt
572
+ levels:
573
+ L1:
574
+ requires:
575
+ any_of:
576
+ - all_of:
577
+ - config_value: { path: package.json, jsonpath: "name", op: present }
578
+ - config_value: { path: package.json, jsonpath: "version", op: present }
579
+ - config_value: { path: package.json, jsonpath: "license", op: present }
580
+ - all_of:
581
+ - config_value: { path: composer.json, jsonpath: "name", op: present }
582
+ - config_value: { path: composer.json, jsonpath: "license", op: present }
583
+ L2:
584
+ requires:
585
+ any_of:
586
+ - all_of:
587
+ - config_value: { path: package.json, jsonpath: "description", op: present }
588
+ - config_value: { path: package.json, jsonpath: "repository", op: present }
589
+ - any_of:
590
+ - has_file: LICENSE
591
+ - has_file: LICENSE.md
592
+ - has_file: LICENSE.txt
593
+ - all_of:
594
+ - config_value: { path: composer.json, jsonpath: "description", op: present }
595
+ - any_of:
596
+ - has_file: LICENSE
597
+ - has_file: LICENSE.md
598
+ L3:
599
+ requires:
600
+ any_of:
601
+ - all_of:
602
+ - config_value: { path: package.json, jsonpath: "files", op: present }
603
+ - any_of:
604
+ - config_value: { path: package.json, jsonpath: "bin", op: present }
605
+ - config_value: { path: package.json, jsonpath: "main", op: present }
606
+ - config_value: { path: package.json, jsonpath: "exports", op: present }
607
+ - all_of:
608
+ - config_value: { path: composer.json, jsonpath: "autoload", op: present }
609
+ - config_value: { path: composer.json, jsonpath: "type", op: present }
610
+ L4:
611
+ requires:
612
+ any_of:
613
+ - all_of:
614
+ - config_value: { path: package.json, jsonpath: "publishConfig", op: present }
615
+ - config_value: { path: package.json, jsonpath: "homepage", op: present }
616
+ - config_value: { path: package.json, jsonpath: "bugs", op: present }
617
+ - config_value: { path: composer.json, jsonpath: "extra.publish", op: present }
package/src/audit.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import type { Rubric, LevelName } from './rubric.ts';
4
- import { findSubPackages, type Ctx } from './facts.ts';
4
+ import { findSubPackages, stackForDir, type Ctx } from './facts.ts';
5
+ import { evalPredicate } from './evaluator.ts';
5
6
  import type { Override, VdxManifest } from './manifest.ts';
6
7
  import { evalAxis, projectLevel, type AxisResult } from './scoring.ts';
7
8
 
@@ -33,12 +34,14 @@ function resolveSubpackageCtx(
33
34
  manifest: VdxManifest | null,
34
35
  ): { ctx: Ctx; relPath: string | null } {
35
36
  let relPath: string | null = null;
37
+ let subpackageStack: string | null = null;
36
38
 
37
39
  if (manifest?.primary_subpackage) {
38
40
  const p = manifest.primary_subpackage;
39
41
  const abs = path.join(ctx.projectRoot, p);
40
42
  if (fs.existsSync(abs)) {
41
43
  relPath = p;
44
+ subpackageStack = stackForDir(abs);
42
45
  } else {
43
46
  process.stderr.write(
44
47
  `warning: primary_subpackage "${p}" does not exist — falling back to root\n`,
@@ -49,6 +52,12 @@ function resolveSubpackageCtx(
49
52
  const matching = subs.filter((s) => s.stack === ctx.stack);
50
53
  if (matching.length === 1) {
51
54
  relPath = matching[0]!.relPath;
55
+ subpackageStack = matching[0]!.stack;
56
+ } else if (matching.length === 0 && subs.length === 1) {
57
+ // O33: single sub-package with a stack different from ctx.stack —
58
+ // adopt it so applies_to-axes for that stack can evaluate.
59
+ relPath = subs[0]!.relPath;
60
+ subpackageStack = subs[0]!.stack;
52
61
  }
53
62
  }
54
63
 
@@ -56,7 +65,7 @@ function resolveSubpackageCtx(
56
65
  return {
57
66
  ctx: {
58
67
  projectRoot: path.join(ctx.projectRoot, relPath),
59
- stack: ctx.stack,
68
+ stack: subpackageStack ?? ctx.stack,
60
69
  cache: new Map(),
61
70
  },
62
71
  relPath,
@@ -96,7 +105,18 @@ export function audit(
96
105
  });
97
106
  continue;
98
107
  }
99
- if (axis.applies_to && !axis.applies_to.includes(ctx.stack)) {
108
+ const evalCtx = axis.applies_to ? subpackageCtx : ctx;
109
+ if (axis.applies_to && !axis.applies_to.includes(evalCtx.stack)) {
110
+ perAxis.push({
111
+ axis_id: axis.id,
112
+ class: axis.class,
113
+ achieved: 'L0',
114
+ target: axis.default_target,
115
+ drift_kind: 'excluded',
116
+ });
117
+ continue;
118
+ }
119
+ if (axis.applies_when && !evalPredicate(axis.applies_when, evalCtx)) {
100
120
  perAxis.push({
101
121
  axis_id: axis.id,
102
122
  class: axis.class,
@@ -106,7 +126,6 @@ export function audit(
106
126
  });
107
127
  continue;
108
128
  }
109
- const evalCtx = axis.applies_to ? subpackageCtx : ctx;
110
129
  const achieved = evalAxis(axis, evalCtx);
111
130
  const ov = overrideByAxis.get(axis.id);
112
131
  const target = (ov?.target as LevelName) ?? axis.default_target;
package/src/facts.ts CHANGED
@@ -216,7 +216,7 @@ const MANIFEST_TO_STACK: Array<[string, string]> = [
216
216
  ['requirements.txt', 'python'],
217
217
  ];
218
218
 
219
- function stackForDir(dir: string): string | null {
219
+ export function stackForDir(dir: string): string | null {
220
220
  for (const [manifest, stack] of MANIFEST_TO_STACK) {
221
221
  if (fs.existsSync(path.join(dir, manifest))) return stack;
222
222
  }
package/src/index.ts CHANGED
@@ -14,7 +14,7 @@ function usage(): never {
14
14
  process.stderr.write(
15
15
  `Usage:
16
16
  vdx audit <project_path> [--rubric <path>] [--stack <stack>] [--json]
17
- vdx init <project_path> [--baseline <ref>] [--dry-run] [--force]
17
+ vdx init <project_path> [--stack <id>] [--baseline <ref>] [--dry-run] [--force]
18
18
  `,
19
19
  );
20
20
  process.exit(1);
@@ -86,10 +86,19 @@ function cmdInit(opts: ParsedArgs): void {
86
86
 
87
87
  const baselineFlag = opts.flags.baseline;
88
88
  const baseline = typeof baselineFlag === 'string' ? baselineFlag : undefined;
89
- const plan = planInit(projectRoot, baseline ? { baseline } : {});
89
+ const stackFlag = opts.flags.stack;
90
+ const stackOverride = typeof stackFlag === 'string' ? stackFlag : undefined;
91
+ const plan = planInit(projectRoot, {
92
+ ...(baseline ? { baseline } : {}),
93
+ ...(stackOverride ? { stack: stackOverride } : {}),
94
+ });
90
95
 
91
96
  process.stdout.write(renderPlanSummary(plan));
92
97
 
98
+ for (const w of plan.warnings) {
99
+ process.stderr.write(`\n⚠ ${w}\n`);
100
+ }
101
+
93
102
  if (opts.flags['dry-run']) {
94
103
  process.stderr.write('\n[dry-run] mise.toml / AGENTS.md не записаны.\n');
95
104
  return;
package/src/init.ts CHANGED
@@ -7,12 +7,14 @@ import {
7
7
  readText,
8
8
  listAllTasks,
9
9
  autoDetectStack,
10
+ findSubPackages,
11
+ type SubPackage,
10
12
  } from './facts.ts';
11
13
 
12
14
  export const STANDARD_VERBS = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
13
15
  export type Verb = (typeof STANDARD_VERBS)[number];
14
16
 
15
- export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v0.2.2';
17
+ export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v0.3.1';
16
18
 
17
19
  const VERB_ALIASES: Record<Verb, string[]> = {
18
20
  up: ['docker:up', 'docker-up', 'docker:up:detached', 'start'],
@@ -41,6 +43,10 @@ export interface VerbMapping {
41
43
  export interface InitPlan {
42
44
  projectRoot: string;
43
45
  stack: string;
46
+ /** Was `stack` derived from autoDetectStack (false) or supplied via --stack (true). */
47
+ stackOverridden: boolean;
48
+ /** Non-null only when stack === 'meta' and resolved a single nested manifest. */
49
+ primarySubpackage: string | null;
44
50
  pkgManager: 'composer' | 'npm' | 'pnpm' | 'yarn' | null;
45
51
  tools: Record<string, string>;
46
52
  mappings: VerbMapping[];
@@ -49,6 +55,8 @@ export interface InitPlan {
49
55
  agentsMdPath: string;
50
56
  agentsMdContent: string;
51
57
  baseline: string;
58
+ /** Human-readable advisories for the user — printed to stderr by the CLI. */
59
+ warnings: string[];
52
60
  }
53
61
 
54
62
  function detectNodePackageManager(projectRoot: string): 'pnpm' | 'yarn' | 'npm' {
@@ -67,6 +75,15 @@ function renderRunCommand(task: string, stack: string, projectRoot: string): str
67
75
  return task;
68
76
  }
69
77
 
78
+ function renderRunCommandInSubpackage(
79
+ task: string,
80
+ subStack: string,
81
+ subAbs: string,
82
+ subRel: string,
83
+ ): string {
84
+ return `cd ${subRel} && ${renderRunCommand(task, subStack, subAbs)}`;
85
+ }
86
+
70
87
  function pickPrefixGroup(prefixes: string[], tasks: Set<string>, verb: Verb): string | null {
71
88
  if (prefixes.length === 0) return null;
72
89
  // Prefer entries that look like a canonical "prod/dist/release" first, then any.
@@ -135,12 +152,23 @@ export function renderMiseToml(
135
152
  mappings: VerbMapping[],
136
153
  stack: string,
137
154
  baseline: string,
155
+ primarySubpackage: string | null = null,
138
156
  ): string {
139
157
  const out: string[] = [];
140
158
  out.push('# Generated by vdx init. The [vdx] block is metadata for vdx audit.');
141
159
  out.push('# Lifecycle verbs follow the vdx contract: up/down/build/test/check/fix.');
142
160
  out.push('');
143
161
 
162
+ if (stack === 'unknown' || stack === 'monorepo') {
163
+ out.push(
164
+ '# TODO(vdx): stack auto-detection вернул "' +
165
+ stack +
166
+ '". Замени на php/node/go/python/meta',
167
+ );
168
+ out.push('# и заполни [tasks.*] блоки руками, либо перегенери: vdx init . --stack <id>.');
169
+ out.push('');
170
+ }
171
+
144
172
  if (Object.keys(tools).length > 0) {
145
173
  out.push('[tools]');
146
174
  for (const [k, v] of Object.entries(tools)) {
@@ -161,6 +189,9 @@ export function renderMiseToml(
161
189
  out.push('schema_version = "0.2"');
162
190
  out.push(`baseline = ${quoteToml(baseline)}`);
163
191
  out.push(`stack = ${quoteToml(stack)}`);
192
+ if (primarySubpackage) {
193
+ out.push(`primary_subpackage = ${quoteToml(primarySubpackage)}`);
194
+ }
164
195
  const verbsList = mappings
165
196
  .filter((m) => m.runCommand)
166
197
  .map((m) => quoteToml(m.verb))
@@ -217,36 +248,93 @@ export function buildAgentsMd(existing: string | null, section: string): string
217
248
  return existing + sep + section + '\n';
218
249
  }
219
250
 
220
- export function planInit(projectRoot: string, opts: { baseline?: string } = {}): InitPlan {
221
- const stack = autoDetectStack(projectRoot);
222
- const ctx: Ctx = { projectRoot, stack, cache: new Map() };
223
- const tasks = listAllTasks(ctx);
251
+ export function planInit(
252
+ projectRoot: string,
253
+ opts: { baseline?: string; stack?: string } = {},
254
+ ): InitPlan {
255
+ const detectedStack = autoDetectStack(projectRoot);
256
+ const stackOverridden = opts.stack !== undefined && opts.stack !== detectedStack;
257
+ const stack = opts.stack ?? detectedStack;
258
+ const warnings: string[] = [];
259
+
260
+ // For meta-stack — try to resolve a single nested subpackage and scan it for tasks.
261
+ let primarySubpackage: string | null = null;
262
+ let scanRoot = projectRoot;
263
+ let scanStack = stack;
264
+
265
+ if (stack === 'meta') {
266
+ const subs: SubPackage[] = findSubPackages(projectRoot);
267
+ if (subs.length === 1) {
268
+ primarySubpackage = subs[0]!.relPath;
269
+ scanRoot = path.join(projectRoot, primarySubpackage);
270
+ scanStack = subs[0]!.stack;
271
+ } else if (subs.length === 0) {
272
+ warnings.push(
273
+ 'stack="meta", но nested manifest не найден. mise.toml будет иметь пустой verbs[]; ' +
274
+ 'заполни [tasks.*] руками.',
275
+ );
276
+ } else {
277
+ const list = subs.map((s) => `${s.relPath}(${s.stack})`).join(', ');
278
+ warnings.push(
279
+ `stack="meta", найдено несколько subpackages: ${list}. ` +
280
+ 'primary_subpackage не выбран; укажи его руками в [vdx] блоке.',
281
+ );
282
+ }
283
+ } else if (!stackOverridden && (detectedStack === 'unknown' || detectedStack === 'monorepo')) {
284
+ if (detectedStack === 'unknown') {
285
+ warnings.push(
286
+ 'autoDetectStack вернул "unknown" — в корне нет manifest и нет subpackages. ' +
287
+ 'Передай --stack <id> (php|node|go|python|meta) либо отредактируй mise.toml вручную.',
288
+ );
289
+ } else {
290
+ const subs = findSubPackages(projectRoot);
291
+ const list = subs.map((s) => `${s.relPath}(${s.stack})`).join(', ');
292
+ warnings.push(
293
+ `autoDetectStack вернул "monorepo": ${list}. ` +
294
+ 'Передай --stack <id> чтобы выбрать конкретный стек, либо --stack meta + укажи primary_subpackage.',
295
+ );
296
+ }
297
+ }
298
+
299
+ const scanCtx: Ctx = { projectRoot: scanRoot, stack: scanStack, cache: new Map() };
300
+ const tasks = listAllTasks(scanCtx);
224
301
 
225
302
  const mappings: VerbMapping[] = STANDARD_VERBS.map((verb) => {
226
303
  const native = mapVerb(verb, tasks);
227
- return {
228
- verb,
229
- nativeTask: native,
230
- runCommand: native ? renderRunCommand(native, stack, projectRoot) : null,
231
- };
304
+ let runCommand: string | null = null;
305
+ if (native) {
306
+ runCommand =
307
+ primarySubpackage !== null
308
+ ? renderRunCommandInSubpackage(native, scanStack, scanRoot, primarySubpackage)
309
+ : renderRunCommand(native, stack, projectRoot);
310
+ }
311
+ return { verb, nativeTask: native, runCommand };
232
312
  });
233
313
 
234
- const tools = collectTools(ctx);
314
+ // tools читаем из root независимо: для PHP/Node корневой manifest часто
315
+ // содержит engines/require php; для meta-проектов вынос tool-версий в
316
+ // root mise.toml — общая практика.
317
+ const rootCtx: Ctx = { projectRoot, stack, cache: new Map() };
318
+ const tools = collectTools(rootCtx);
235
319
  const baseline = opts.baseline ?? DEFAULT_BASELINE;
236
- const miseTomlContent = renderMiseToml(tools, mappings, stack, baseline);
320
+ const miseTomlContent = renderMiseToml(tools, mappings, stack, baseline, primarySubpackage);
237
321
 
238
322
  const agentsMdPath = path.join(projectRoot, 'AGENTS.md');
239
- const existingAgents = fileExists(ctx, 'AGENTS.md') ? readText(ctx, 'AGENTS.md') : null;
323
+ const existingAgents = fileExists(rootCtx, 'AGENTS.md') ? readText(rootCtx, 'AGENTS.md') : null;
240
324
  const section = renderAgentsCommandsSection(mappings, baseline);
241
325
  const agentsMdContent = buildAgentsMd(existingAgents, section);
242
326
 
243
327
  let pkgManager: InitPlan['pkgManager'] = null;
244
- if (stack === 'php') pkgManager = 'composer';
245
- else if (stack === 'node') pkgManager = detectNodePackageManager(projectRoot);
328
+ const pmStack = primarySubpackage ? scanStack : stack;
329
+ const pmRoot = primarySubpackage ? scanRoot : projectRoot;
330
+ if (pmStack === 'php') pkgManager = 'composer';
331
+ else if (pmStack === 'node') pkgManager = detectNodePackageManager(pmRoot);
246
332
 
247
333
  return {
248
334
  projectRoot,
249
335
  stack,
336
+ stackOverridden,
337
+ primarySubpackage,
250
338
  pkgManager,
251
339
  tools,
252
340
  mappings,
@@ -255,6 +343,7 @@ export function planInit(projectRoot: string, opts: { baseline?: string } = {}):
255
343
  agentsMdPath,
256
344
  agentsMdContent,
257
345
  baseline,
346
+ warnings,
258
347
  };
259
348
  }
260
349
 
@@ -272,10 +361,23 @@ export function renderPlanSummary(plan: InitPlan): string {
272
361
  const lines: string[] = [];
273
362
  lines.push(`# vdx init plan`);
274
363
  lines.push('');
275
- lines.push(`- **Project**: ${plan.projectRoot}`);
276
- lines.push(`- **Stack**: ${plan.stack}`);
364
+ lines.push(
365
+ `- **Project**: ${plan.projectRoot}`,
366
+ );
367
+ lines.push(
368
+ `- **Stack**: ${plan.stack}${plan.stackOverridden ? ' (via --stack)' : ''}`,
369
+ );
370
+ if (plan.primarySubpackage) {
371
+ lines.push(`- **Primary subpackage**: ${plan.primarySubpackage}`);
372
+ }
277
373
  if (plan.pkgManager) lines.push(`- **Package manager**: ${plan.pkgManager}`);
278
374
  lines.push(`- **Baseline**: ${plan.baseline}`);
375
+ if (plan.warnings.length > 0) {
376
+ lines.push('');
377
+ lines.push('## ⚠ Warnings');
378
+ lines.push('');
379
+ for (const w of plan.warnings) lines.push(`- ${w}`);
380
+ }
279
381
  if (Object.keys(plan.tools).length > 0) {
280
382
  const toolStr = Object.entries(plan.tools)
281
383
  .map(([k, v]) => `${k}=${v}`)
package/src/rubric.ts CHANGED
@@ -30,6 +30,7 @@ export interface Axis {
30
30
  levels?: Partial<Record<LevelName, { requires: Predicate }>>;
31
31
  stack_implementations?: Record<string, StackImpl>;
32
32
  applies_to?: string[];
33
+ applies_when?: Predicate;
33
34
  }
34
35
 
35
36
  export interface Rubric {