@vodmal/vdx-cli 0.2.1 → 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/package.json +7 -3
- package/rubric/vdx-rubric.yaml +82 -1
- package/src/audit.ts +11 -0
- package/src/index.ts +11 -2
- package/src/init.ts +119 -17
- package/src/rubric.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vodmal/vdx-cli",
|
|
3
|
-
"version": "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",
|
|
@@ -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": "
|
|
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
|
-
"
|
|
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"
|
package/rubric/vdx-rubric.yaml
CHANGED
|
@@ -11,7 +11,7 @@ schema_version: "0.2"
|
|
|
11
11
|
|
|
12
12
|
metadata:
|
|
13
13
|
name: vdx-rubric-vodmal
|
|
14
|
-
version: "0.
|
|
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
|
@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import type { Rubric, LevelName } from './rubric.ts';
|
|
4
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
|
|
|
@@ -115,6 +116,16 @@ export function audit(
|
|
|
115
116
|
});
|
|
116
117
|
continue;
|
|
117
118
|
}
|
|
119
|
+
if (axis.applies_when && !evalPredicate(axis.applies_when, evalCtx)) {
|
|
120
|
+
perAxis.push({
|
|
121
|
+
axis_id: axis.id,
|
|
122
|
+
class: axis.class,
|
|
123
|
+
achieved: 'L0',
|
|
124
|
+
target: axis.default_target,
|
|
125
|
+
drift_kind: 'excluded',
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
118
129
|
const achieved = evalAxis(axis, evalCtx);
|
|
119
130
|
const ov = overrideByAxis.get(axis.id);
|
|
120
131
|
const target = (ov?.target as LevelName) ?? axis.default_target;
|
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
|
|
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.
|
|
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(
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
245
|
-
|
|
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(
|
|
276
|
-
|
|
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