@lumenflow/cli 3.5.0 → 3.6.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.
@@ -3,17 +3,21 @@
3
3
  // SPDX-License-Identifier: AGPL-3.0-only
4
4
  /**
5
5
  * @file config-set.ts
6
- * WU-1902 / WU-1973: Safe config:set CLI command for workspace.yaml modification
6
+ * WU-2185: Workspace-aware config:set CLI command
7
7
  *
8
- * Accepts dotpath keys and writes them to workspace.yaml under software_delivery.
9
- * Values are validated against the LumenFlow config schema before writing.
8
+ * Routes keys by prefix:
9
+ * - WRITABLE_ROOT_KEYS -> write at workspace root
10
+ * - Pack config_key -> write under pack config block (validated against pack schema)
11
+ * - MANAGED_ROOT_KEYS -> error with "use <command>" guidance
12
+ * - Unknown -> hard error with did-you-mean
10
13
  *
11
- * Follows the lane:edit pattern (WU-1854).
14
+ * All keys must be fully qualified from workspace root.
15
+ * No implicit software_delivery prefixing.
12
16
  *
13
17
  * Usage:
14
18
  * pnpm config:set --key software_delivery.methodology.testing --value test-after
15
19
  * pnpm config:set --key software_delivery.gates.minCoverage --value 85
16
- * pnpm config:set --key software_delivery.agents.methodology.principles --value Library-First,KISS
20
+ * pnpm config:set --key control_plane.sync_interval --value 60
17
21
  */
18
22
  import path from 'node:path';
19
23
  import { createError, ErrorCodes } from '@lumenflow/core';
@@ -23,8 +27,11 @@ import { findProjectRoot, WORKSPACE_CONFIG_FILE_NAME, clearConfigCache, } from '
23
27
  import { die } from '@lumenflow/core/error-handler';
24
28
  import { FILE_SYSTEM } from '@lumenflow/core/wu-constants';
25
29
  import { withMicroWorktree } from '@lumenflow/core/micro-worktree';
26
- import { LumenFlowConfigSchema, WORKSPACE_V2_KEYS } from '@lumenflow/core/config-schema';
30
+ import { LumenFlowConfigSchema } from '@lumenflow/core/config-schema';
31
+ import { WorkspaceControlPlaneConfigSchema } from '@lumenflow/kernel/schemas';
27
32
  import { normalizeConfigKeys } from '@lumenflow/core/normalize-config-keys';
33
+ import { WRITABLE_ROOT_KEYS, MANAGED_ROOT_KEYS, WORKSPACE_ROOT_KEYS } from '@lumenflow/core/config';
34
+ import { resolvePackManifestPaths, resolvePackSchemaMetadata } from '@lumenflow/kernel/pack';
28
35
  import { runCLI } from './cli-entry-point.js';
29
36
  // ---------------------------------------------------------------------------
30
37
  // Constants
@@ -37,35 +44,65 @@ const ARG_HELP = '--help';
37
44
  const COMMIT_PREFIX = 'chore: config:set';
38
45
  const WORKSPACE_INIT_COMMAND = 'pnpm workspace-init --yes';
39
46
  export const WORKSPACE_FILE_NAME = WORKSPACE_CONFIG_FILE_NAME;
40
- export const WORKSPACE_CONFIG_ROOT_KEY = WORKSPACE_V2_KEYS.SOFTWARE_DELIVERY;
41
- export const WORKSPACE_CONFIG_PREFIX = `${WORKSPACE_CONFIG_ROOT_KEY}.`;
47
+ /**
48
+ * Known sub-keys of LumenFlowConfigSchema (software_delivery pack config).
49
+ * Used for did-you-mean suggestions when a user provides an unqualified key.
50
+ */
51
+ const KNOWN_SD_SUBKEYS = [
52
+ 'version',
53
+ 'methodology',
54
+ 'gates',
55
+ 'directories',
56
+ 'state',
57
+ 'git',
58
+ 'wu',
59
+ 'memory',
60
+ 'ui',
61
+ 'yaml',
62
+ 'agents',
63
+ 'experimental',
64
+ 'cleanup',
65
+ 'telemetry',
66
+ 'cloud',
67
+ 'lanes',
68
+ 'escalation',
69
+ 'package_manager',
70
+ 'test_runner',
71
+ 'build_command',
72
+ ];
73
+ /** Well-known pack ID for the software-delivery pack */
74
+ const SD_PACK_ID = 'software-delivery';
42
75
  // ---------------------------------------------------------------------------
43
76
  // Help text
44
77
  // ---------------------------------------------------------------------------
45
78
  const SET_HELP_TEXT = `Usage: pnpm config:set --key <dotpath> --value <value>
46
79
 
47
80
  Safely update ${WORKSPACE_FILE_NAME} via micro-worktree commit.
48
- Validates against Zod schema before writing.
81
+ Keys must be fully qualified from the workspace root.
82
+ Validates against schema before writing.
49
83
 
50
84
  Required:
51
- ${ARG_KEY} <dotpath> Config key in dot notation (e.g., software_delivery.methodology.testing)
85
+ ${ARG_KEY} <dotpath> Config key in dot notation (fully qualified)
52
86
  ${ARG_VALUE} <value> Value to set (comma-separated for arrays)
53
87
 
54
88
  Examples:
55
89
  pnpm config:set --key software_delivery.methodology.testing --value test-after
56
90
  pnpm config:set --key software_delivery.gates.minCoverage --value 85
57
- pnpm config:set --key software_delivery.agents.methodology.principles --value Library-First,KISS
91
+ pnpm config:set --key control_plane.sync_interval --value 60
92
+ pnpm config:set --key memory_namespace --value my-project
58
93
  `;
59
94
  const GET_HELP_TEXT = `Usage: pnpm config:get --key <dotpath>
60
95
 
61
96
  Read and display a value from ${WORKSPACE_FILE_NAME}.
97
+ Keys must be fully qualified from the workspace root.
62
98
 
63
99
  Required:
64
- ${ARG_KEY} <dotpath> Config key in dot notation (e.g., software_delivery.methodology.testing)
100
+ ${ARG_KEY} <dotpath> Config key in dot notation (fully qualified)
65
101
 
66
102
  Examples:
67
103
  pnpm config:get --key software_delivery.methodology.testing
68
104
  pnpm config:get --key software_delivery.gates.minCoverage
105
+ pnpm config:get --key control_plane.sync_interval
69
106
  `;
70
107
  // ---------------------------------------------------------------------------
71
108
  // Argument parsing
@@ -120,24 +157,64 @@ export function parseConfigGetArgs(argv) {
120
157
  }
121
158
  return { key };
122
159
  }
160
+ // ---------------------------------------------------------------------------
161
+ // Key routing (pure, no side effects)
162
+ // ---------------------------------------------------------------------------
123
163
  /**
124
- * Normalize a user-provided key to software_delivery-relative dotpath.
164
+ * Route a fully-qualified config key to the correct write target.
125
165
  *
126
- * Supported key forms:
127
- * - `software_delivery.methodology.testing` (canonical)
128
- * - `methodology.testing` (shorthand; automatically scoped)
166
+ * Routing rules (checked in order):
167
+ * 1. First segment in WRITABLE_ROOT_KEYS -> workspace-root
168
+ * 2. First segment matches a pack config_key -> pack-config
169
+ * 3. First segment in MANAGED_ROOT_KEYS -> managed-error
170
+ * 4. Otherwise -> unknown-error (with optional did-you-mean suggestion)
129
171
  *
130
- * @param key - User-provided config key
131
- * @returns Dotpath relative to software_delivery
172
+ * @param key - Fully qualified dotpath key (e.g., "software_delivery.gates.minCoverage")
173
+ * @param packConfigKeys - Map of config_key -> pack_id from loaded pack manifests
174
+ * @returns Route describing where/how to write the key
132
175
  */
133
- export function normalizeWorkspaceConfigKey(key) {
134
- if (key === WORKSPACE_CONFIG_ROOT_KEY) {
135
- return '';
176
+ export function routeConfigKey(key, packConfigKeys) {
177
+ const segments = key.split('.');
178
+ const firstSegment = segments[0];
179
+ const subPath = segments.slice(1).join('.');
180
+ // 1. Writable root keys (e.g., control_plane, memory_namespace, event_namespace)
181
+ if (WRITABLE_ROOT_KEYS.has(firstSegment)) {
182
+ return { type: 'workspace-root', rootKey: firstSegment, subPath };
136
183
  }
137
- if (key.startsWith(WORKSPACE_CONFIG_PREFIX)) {
138
- return key.slice(WORKSPACE_CONFIG_PREFIX.length);
184
+ // 2. Pack config_key (e.g., software_delivery -> software-delivery pack)
185
+ const packId = packConfigKeys.get(firstSegment);
186
+ if (packId !== undefined) {
187
+ return { type: 'pack-config', rootKey: firstSegment, subPath, packId };
139
188
  }
140
- return key;
189
+ // 3. Managed root keys (e.g., packs, lanes, security, id, name, policies)
190
+ if (firstSegment in MANAGED_ROOT_KEYS) {
191
+ const managedCommand = MANAGED_ROOT_KEYS[firstSegment];
192
+ return { type: 'managed-error', rootKey: firstSegment, command: managedCommand };
193
+ }
194
+ // 4. Unknown key - check for did-you-mean suggestions
195
+ const suggestion = buildDidYouMeanSuggestion(key, firstSegment, packConfigKeys);
196
+ return { type: 'unknown-error', rootKey: firstSegment, suggestion };
197
+ }
198
+ /**
199
+ * Build a did-you-mean suggestion for an unknown key.
200
+ *
201
+ * If the first segment matches a known sub-key of a pack config schema,
202
+ * suggest the fully-qualified version.
203
+ *
204
+ * @param fullKey - The full user-provided key
205
+ * @param firstSegment - The first segment of the key
206
+ * @param packConfigKeys - Map of config_key -> pack_id
207
+ * @returns A suggestion string, or undefined if no match
208
+ */
209
+ function buildDidYouMeanSuggestion(fullKey, firstSegment, packConfigKeys) {
210
+ // Check if first segment is a known SD sub-key
211
+ if (KNOWN_SD_SUBKEYS.includes(firstSegment)) {
212
+ // Find the pack config_key that owns SD config
213
+ for (const [configKey] of packConfigKeys) {
214
+ return `Did you mean "${configKey}.${fullKey}"?`;
215
+ }
216
+ }
217
+ return undefined;
141
218
  }
142
219
  // ---------------------------------------------------------------------------
143
220
  // Dotpath helpers (pure, no side effects)
@@ -186,6 +263,16 @@ function setNestedValue(obj, dotpath, value) {
186
263
  current[lastSegment] = value;
187
264
  return result;
188
265
  }
266
+ /**
267
+ * Check if a string represents a numeric value (integer or decimal).
268
+ * Uses Number() instead of regex to avoid unsafe regex patterns.
269
+ */
270
+ function isNumericString(value) {
271
+ if (value === '' || value.trim() !== value)
272
+ return false;
273
+ const num = Number(value);
274
+ return !isNaN(num) && isFinite(num);
275
+ }
189
276
  /**
190
277
  * Coerce a string value to the appropriate type based on context.
191
278
  *
@@ -200,7 +287,7 @@ function coerceValue(value, existingValue) {
200
287
  if (value === 'false')
201
288
  return false;
202
289
  // Number coercion (if existing value is a number or value looks numeric)
203
- if (typeof existingValue === 'number' || /^\d+(\.\d+)?$/.test(value)) {
290
+ if (typeof existingValue === 'number' || isNumericString(value)) {
204
291
  const numValue = Number(value);
205
292
  if (!isNaN(numValue))
206
293
  return numValue;
@@ -215,31 +302,236 @@ function coerceValue(value, existingValue) {
215
302
  return value;
216
303
  }
217
304
  // ---------------------------------------------------------------------------
218
- // Core logic: applyConfigSet (pure, validates via Zod)
305
+ // Unknown key detection (WU-2190)
306
+ // ---------------------------------------------------------------------------
307
+ /**
308
+ * Recursively compare an input object against a Zod-parsed output to find
309
+ * keys that were silently stripped during parsing (i.e., unknown keys).
310
+ *
311
+ * @param input - The pre-parse object (normalized config)
312
+ * @param parsed - The post-parse object (Zod output with defaults applied)
313
+ * @param prefix - Dot-notation prefix for nested paths
314
+ * @returns Array of dotpath strings for keys present in input but missing in parsed output
315
+ */
316
+ function findStrippedKeys(input, parsed, prefix = '') {
317
+ const stripped = [];
318
+ for (const key of Object.keys(input)) {
319
+ const fullPath = prefix ? `${prefix}.${key}` : key;
320
+ if (!(key in parsed)) {
321
+ stripped.push(fullPath);
322
+ continue;
323
+ }
324
+ // Recurse into nested objects (skip arrays and non-objects)
325
+ const inputVal = input[key];
326
+ const parsedVal = parsed[key];
327
+ if (inputVal !== null &&
328
+ inputVal !== undefined &&
329
+ typeof inputVal === 'object' &&
330
+ !Array.isArray(inputVal) &&
331
+ parsedVal !== null &&
332
+ parsedVal !== undefined &&
333
+ typeof parsedVal === 'object' &&
334
+ !Array.isArray(parsedVal)) {
335
+ stripped.push(...findStrippedKeys(inputVal, parsedVal, fullPath));
336
+ }
337
+ }
338
+ return stripped;
339
+ }
340
+ // ---------------------------------------------------------------------------
341
+ // Core logic: applyConfigSet (workspace-aware routing)
219
342
  // ---------------------------------------------------------------------------
220
343
  /**
221
- * Apply a config:set operation to a config object.
344
+ * Apply a config:set operation to a full workspace object.
222
345
  *
223
- * 1. Coerces the string value to the appropriate type
224
- * 2. Sets the value at the dotpath
225
- * 3. Normalizes keys for Zod compatibility
226
- * 4. Validates the resulting config against the Zod schema
346
+ * Routes the key by prefix, validates against the appropriate schema,
347
+ * and returns the updated workspace or an error.
227
348
  *
228
- * @param config - Current config object (raw YAML parse, not yet Zod-parsed)
229
- * @param dotpath - Dot-separated key path
349
+ * @param workspace - Full workspace object (raw YAML parse)
350
+ * @param key - Fully qualified dotpath key (e.g., "software_delivery.gates.minCoverage")
230
351
  * @param rawValue - String value from CLI
231
- * @returns Result with updated config or error
352
+ * @param packConfigKeys - Map of config_key -> pack_id from loaded pack manifests
353
+ * @returns Result with updated workspace or error
232
354
  */
233
- export function applyConfigSet(config, dotpath, rawValue) {
355
+ export function applyConfigSet(workspace, key, rawValue, packConfigKeys, packSchemaOpts) {
356
+ const route = routeConfigKey(key, packConfigKeys);
357
+ switch (route.type) {
358
+ case 'managed-error':
359
+ return {
360
+ ok: false,
361
+ error: `Key "${route.rootKey}" is managed by a dedicated command. Use \`pnpm ${route.command}\` instead.`,
362
+ };
363
+ case 'unknown-error': {
364
+ const hint = route.suggestion ? ` ${route.suggestion}` : '';
365
+ return {
366
+ ok: false,
367
+ error: `Unknown root key "${route.rootKey}".${hint} Valid root keys: ${[...WRITABLE_ROOT_KEYS].join(', ')}, or a pack config_key (${[...packConfigKeys.keys()].join(', ')}).`,
368
+ };
369
+ }
370
+ case 'workspace-root':
371
+ return applyWorkspaceRootSet(workspace, key, rawValue);
372
+ case 'pack-config':
373
+ return applyPackConfigSet(workspace, route, rawValue, packSchemaOpts);
374
+ }
375
+ }
376
+ /**
377
+ * Scalar-only root keys that must remain simple string values.
378
+ * Sub-path writes (e.g., memory_namespace.foo) are invalid for these keys.
379
+ */
380
+ const SCALAR_ROOT_KEYS = new Set(['memory_namespace', 'event_namespace']);
381
+ /**
382
+ * Apply a set operation at the workspace root level.
383
+ * Used for WRITABLE_ROOT_KEYS like control_plane, memory_namespace, event_namespace.
384
+ *
385
+ * WU-2190: Validates root key writes against schemas/type constraints:
386
+ * - control_plane: validated against WorkspaceControlPlaneConfigSchema (.strict())
387
+ * - memory_namespace / event_namespace: must be strings, no sub-path writes
388
+ */
389
+ function applyWorkspaceRootSet(workspace, key, rawValue) {
390
+ const segments = key.split('.');
391
+ const rootKey = segments[0];
392
+ const subPath = segments.slice(1).join('.');
393
+ // --- Scalar root keys (memory_namespace, event_namespace) ---
394
+ if (SCALAR_ROOT_KEYS.has(rootKey)) {
395
+ if (subPath) {
396
+ return {
397
+ ok: false,
398
+ error: `"${rootKey}" is a scalar value and does not support sub-path writes. Use: config:set --key ${rootKey} --value <string>`,
399
+ };
400
+ }
401
+ // Scalar keys must remain strings
402
+ return { ok: true, config: setNestedValue(workspace, key, rawValue) };
403
+ }
404
+ // --- control_plane validation ---
405
+ if (rootKey === 'control_plane') {
406
+ // Reject overwriting the entire control_plane object with a scalar
407
+ if (!subPath) {
408
+ return {
409
+ ok: false,
410
+ error: `Cannot overwrite "control_plane" with a scalar value. Set individual sub-keys instead (e.g., control_plane.sync_interval).`,
411
+ };
412
+ }
413
+ // Build the candidate control_plane object with the new value applied
414
+ const existingCP = workspace.control_plane;
415
+ const cpConfig = existingCP && typeof existingCP === 'object' && !Array.isArray(existingCP)
416
+ ? JSON.parse(JSON.stringify(existingCP))
417
+ : {};
418
+ const existingValue = getConfigValue(cpConfig, subPath);
419
+ const coercedValue = coerceValue(rawValue, existingValue);
420
+ const updatedCP = setNestedValue(cpConfig, subPath, coercedValue);
421
+ // Validate the updated control_plane against a partial-but-strict schema.
422
+ // partial() makes all fields optional (real configs may have only a subset),
423
+ // but .strict() still rejects unknown keys.
424
+ const parseResult = WorkspaceControlPlaneConfigSchema.partial().strict().safeParse(updatedCP);
425
+ if (!parseResult.success) {
426
+ const issues = parseResult.error.issues
427
+ .map((issue) => `${issue.path.join('.')}: ${issue.message}`)
428
+ .join('; ');
429
+ return {
430
+ ok: false,
431
+ error: `Validation failed for ${key}=${rawValue}: ${issues}`,
432
+ };
433
+ }
434
+ const updatedWorkspace = { ...workspace, control_plane: updatedCP };
435
+ return { ok: true, config: updatedWorkspace };
436
+ }
437
+ // Fallback for any future writable root keys: apply without extra validation
438
+ const existingValue = getConfigValue(workspace, key);
439
+ const coercedValue = coerceValue(rawValue, existingValue);
440
+ const updatedWorkspace = setNestedValue(workspace, key, coercedValue);
441
+ return { ok: true, config: updatedWorkspace };
442
+ }
443
+ // ---------------------------------------------------------------------------
444
+ // JSON Schema validation (WU-2192)
445
+ // ---------------------------------------------------------------------------
446
+ /**
447
+ * Walk a JSON Schema to validate that a dotpath corresponds to a known property.
448
+ *
449
+ * For each segment in the dotpath, checks if the current schema level has
450
+ * a `properties` entry for that segment. Returns the list of unrecognized
451
+ * segments as dotpath strings.
452
+ *
453
+ * This is a lightweight structural validation -- it checks key existence,
454
+ * not value types. Full JSON Schema validation (via ajv) is deferred to
455
+ * a future WU if needed.
456
+ *
457
+ * @param schema - Parsed JSON Schema object
458
+ * @param dotpath - Dot-separated path to validate (e.g., "metrics.interval")
459
+ * @returns Array of unrecognized dotpath segments, or empty array if valid
460
+ */
461
+ function validateJsonSchemaPath(schema, dotpath) {
462
+ const segments = dotpath.split('.');
463
+ let currentSchema = schema;
464
+ const unrecognized = [];
465
+ for (let i = 0; i < segments.length; i++) {
466
+ const segment = segments[i];
467
+ const properties = currentSchema.properties;
468
+ if (!properties || !(segment in properties)) {
469
+ // This segment is not recognized at the current level
470
+ unrecognized.push(segments.slice(0, i + 1).join('.'));
471
+ break;
472
+ }
473
+ // Move deeper into the schema
474
+ currentSchema = properties[segment];
475
+ }
476
+ return unrecognized;
477
+ }
478
+ /**
479
+ * Apply a set operation within a pack's config block.
480
+ * Extracts the pack section, applies the change, validates via Zod schema,
481
+ * then writes back into the workspace.
482
+ */
483
+ function applyPackConfigSet(workspace, route, rawValue, packSchemaOpts) {
484
+ // Extract the pack's config section
485
+ const packSection = workspace[route.rootKey];
486
+ const packConfig = packSection && typeof packSection === 'object' && !Array.isArray(packSection)
487
+ ? packSection
488
+ : {};
489
+ if (!route.subPath) {
490
+ return {
491
+ ok: false,
492
+ error: `Key must target a nested field under ${route.rootKey} (e.g., ${route.rootKey}.methodology.testing).`,
493
+ };
494
+ }
234
495
  // Get existing value for type inference
235
- const existingValue = getConfigValue(config, dotpath);
496
+ const existingValue = getConfigValue(packConfig, route.subPath);
236
497
  // Coerce value
237
498
  const coercedValue = coerceValue(rawValue, existingValue);
238
- // Set value in config
239
- const updatedConfig = setNestedValue(config, dotpath, coercedValue);
499
+ // Set value in pack config
500
+ const updatedPackConfig = setNestedValue(packConfig, route.subPath, coercedValue);
501
+ // WU-2192: Route validation by pack identity.
502
+ // SD pack uses the built-in LumenFlowConfigSchema (Zod).
503
+ // Non-SD packs with config_schema use JSON Schema validation.
504
+ // Non-SD packs without config_schema reject all writes.
505
+ if (route.packId === SD_PACK_ID || !packSchemaOpts) {
506
+ // SD pack path (or legacy callers without schema opts): use LumenFlowConfigSchema
507
+ return applyPackConfigSetWithZod(workspace, route, rawValue, updatedPackConfig);
508
+ }
509
+ // Non-SD pack path: check if the pack declares a config_schema
510
+ const hasSchema = packSchemaOpts.packSchemaMap.get(route.packId);
511
+ if (!hasSchema) {
512
+ return {
513
+ ok: false,
514
+ error: `Pack "${route.packId}" declares no config_schema. Config writes to "${route.rootKey}" are rejected because the pack has not declared a schema for validation.`,
515
+ };
516
+ }
517
+ // Non-SD pack with config_schema: validate using JSON Schema
518
+ const jsonSchema = packSchemaOpts.jsonSchemas.get(route.packId);
519
+ if (!jsonSchema) {
520
+ return {
521
+ ok: false,
522
+ error: `Pack "${route.packId}" declares a config_schema but the schema could not be loaded. Config writes to "${route.rootKey}" are rejected.`,
523
+ };
524
+ }
525
+ return applyPackConfigSetWithJsonSchema(workspace, route, updatedPackConfig, jsonSchema);
526
+ }
527
+ /**
528
+ * Validate a pack config write using LumenFlowConfigSchema (Zod).
529
+ * Used for the SD pack and legacy callers.
530
+ */
531
+ function applyPackConfigSetWithZod(workspace, route, rawValue, updatedPackConfig) {
240
532
  // Normalize keys for Zod compatibility (snake_case -> camelCase)
241
- const normalized = normalizeConfigKeys(updatedConfig);
242
- // Validate against Zod schema
533
+ const normalized = normalizeConfigKeys(updatedPackConfig);
534
+ // Validate against LumenFlowConfigSchema (SD pack schema)
243
535
  const parseResult = LumenFlowConfigSchema.safeParse(normalized);
244
536
  if (!parseResult.success) {
245
537
  const issues = parseResult.error.issues
@@ -247,11 +539,68 @@ export function applyConfigSet(config, dotpath, rawValue) {
247
539
  .join('; ');
248
540
  return {
249
541
  ok: false,
250
- error: `Validation failed for ${dotpath}=${rawValue}: ${issues}`,
542
+ error: `Validation failed for ${route.rootKey}.${route.subPath}=${rawValue}: ${issues}`,
543
+ };
544
+ }
545
+ // WU-2190: Detect unknown keys that Zod silently stripped during parsing.
546
+ // WU-2197: Scope the check to the sub-tree being written, not the full config.
547
+ // This prevents pre-existing unknown keys in unrelated paths from blocking writes.
548
+ const parsedData = parseResult.data;
549
+ const writeTargetKey = route.subPath.split('.')[0];
550
+ let strippedKeys;
551
+ if (writeTargetKey &&
552
+ typeof normalized[writeTargetKey] === 'object' &&
553
+ normalized[writeTargetKey] !== null &&
554
+ !Array.isArray(normalized[writeTargetKey]) &&
555
+ typeof parsedData[writeTargetKey] === 'object' &&
556
+ parsedData[writeTargetKey] !== null &&
557
+ !Array.isArray(parsedData[writeTargetKey])) {
558
+ // Nested write (e.g., methodology.testing): scope check to the sub-tree
559
+ strippedKeys = findStrippedKeys(normalized[writeTargetKey], parsedData[writeTargetKey], writeTargetKey);
560
+ }
561
+ else {
562
+ // Top-level or scalar write: check only whether the target key itself was stripped
563
+ strippedKeys =
564
+ writeTargetKey && writeTargetKey in normalized && !(writeTargetKey in parsedData)
565
+ ? [writeTargetKey]
566
+ : [];
567
+ }
568
+ if (strippedKeys.length > 0) {
569
+ return {
570
+ ok: false,
571
+ error: `Unknown config key(s) rejected: ${strippedKeys.join(', ')}. These keys are not recognized by the ${route.rootKey} schema.`,
572
+ };
573
+ }
574
+ // Write updated pack config back into workspace
575
+ const updatedWorkspace = {
576
+ ...workspace,
577
+ [route.rootKey]: updatedPackConfig,
578
+ };
579
+ return { ok: true, config: updatedWorkspace };
580
+ }
581
+ /**
582
+ * Validate a pack config write using a JSON Schema object.
583
+ * Used for non-SD packs that declare config_schema in their manifest.
584
+ *
585
+ * This performs structural validation: checks that the dotpath corresponds
586
+ * to a known property in the JSON Schema. Full type validation is deferred
587
+ * to a future WU if needed (would require ajv or similar).
588
+ */
589
+ function applyPackConfigSetWithJsonSchema(workspace, route, updatedPackConfig, jsonSchema) {
590
+ // Validate the subPath against the JSON Schema structure
591
+ const unrecognized = validateJsonSchemaPath(jsonSchema, route.subPath);
592
+ if (unrecognized.length > 0) {
593
+ return {
594
+ ok: false,
595
+ error: `Unknown config key(s) rejected for pack "${route.packId}": ${unrecognized.join(', ')}. These keys are not recognized by the ${route.rootKey} schema.`,
251
596
  };
252
597
  }
253
- // Return the updated raw config (not the Zod-parsed one, to preserve YAML structure)
254
- return { ok: true, config: updatedConfig };
598
+ // Write updated pack config back into workspace
599
+ const updatedWorkspace = {
600
+ ...workspace,
601
+ [route.rootKey]: updatedPackConfig,
602
+ };
603
+ return { ok: true, config: updatedWorkspace };
255
604
  }
256
605
  // ---------------------------------------------------------------------------
257
606
  // Config I/O helpers
@@ -278,30 +627,71 @@ function writeRawWorkspace(workspacePath, workspace) {
278
627
  writeFileSync(workspacePath, nextContent, FILE_SYSTEM.UTF8);
279
628
  }
280
629
  /**
281
- * Extract software_delivery config section from workspace object.
630
+ * Load pack config_keys from workspace packs field.
631
+ * Reads pinned pack manifests to discover their declared config_key.
282
632
  *
633
+ * Delegates to the shared pack manifest resolver in @lumenflow/kernel
634
+ * which handles all pack source types (local, registry, git) instead
635
+ * of hardcoding a monorepo-only path.
636
+ *
637
+ * @param projectRoot - Absolute path to project root
283
638
  * @param workspace - Parsed workspace object
284
- * @returns software_delivery config object (empty when unset/invalid)
639
+ * @returns Map of config_key -> pack_id
285
640
  */
286
- export function getSoftwareDeliveryConfigFromWorkspace(workspace) {
287
- const section = workspace[WORKSPACE_CONFIG_ROOT_KEY];
288
- if (!section || typeof section !== 'object' || Array.isArray(section)) {
289
- return {};
641
+ export function loadPackConfigKeys(projectRoot, workspace) {
642
+ const packs = workspace.packs;
643
+ if (!Array.isArray(packs)) {
644
+ return new Map();
290
645
  }
291
- return section;
646
+ return resolvePackManifestPaths({ projectRoot, packs });
292
647
  }
293
648
  /**
294
- * Return a new workspace object with updated software_delivery section.
649
+ * Load pack schema options for pack-aware validation (WU-2192).
650
+ *
651
+ * Reads pack manifests to discover config_schema declarations, then loads
652
+ * the referenced JSON Schema files from disk. The SD pack is always marked
653
+ * as having a schema (its built-in Zod schema is used instead).
295
654
  *
296
- * @param workspace - Existing workspace object
297
- * @param config - Updated software_delivery config object
298
- * @returns New workspace object with replaced software_delivery section
655
+ * @param projectRoot - Absolute path to project root
656
+ * @param workspace - Parsed workspace object
657
+ * @returns PackSchemaOpts for use with applyConfigSet
299
658
  */
300
- export function setSoftwareDeliveryConfigInWorkspace(workspace, config) {
301
- return {
302
- ...workspace,
303
- [WORKSPACE_CONFIG_ROOT_KEY]: config,
304
- };
659
+ export function loadPackSchemaOpts(projectRoot, workspace) {
660
+ const packs = workspace.packs;
661
+ const packSchemaMap = new Map();
662
+ const jsonSchemas = new Map();
663
+ if (!Array.isArray(packs)) {
664
+ return { packSchemaMap, jsonSchemas };
665
+ }
666
+ // The SD pack always has a schema (built-in Zod)
667
+ packSchemaMap.set(SD_PACK_ID, true);
668
+ const schemaMetadata = resolvePackSchemaMetadata({ projectRoot, packs });
669
+ for (const [packId, meta] of schemaMetadata) {
670
+ if (packId === SD_PACK_ID) {
671
+ // SD pack schema is built-in, already marked above
672
+ continue;
673
+ }
674
+ if (meta.hasSchema && meta.schemaPath) {
675
+ packSchemaMap.set(packId, true);
676
+ // Try to load the JSON Schema file
677
+ if (existsSync(meta.schemaPath)) {
678
+ try {
679
+ const schemaContent = readFileSync(meta.schemaPath, 'utf8');
680
+ const parsed = JSON.parse(schemaContent);
681
+ jsonSchemas.set(packId, parsed);
682
+ }
683
+ catch {
684
+ // Schema file exists but is unreadable/invalid
685
+ // packSchemaMap still says true, but jsonSchemas won't have it.
686
+ // applyPackConfigSet handles this case with a clear error.
687
+ }
688
+ }
689
+ }
690
+ else {
691
+ packSchemaMap.set(packId, false);
692
+ }
693
+ }
694
+ return { packSchemaMap, jsonSchemas };
305
695
  }
306
696
  // ---------------------------------------------------------------------------
307
697
  // Main: config:set
@@ -309,15 +699,23 @@ export function setSoftwareDeliveryConfigInWorkspace(workspace, config) {
309
699
  export async function main() {
310
700
  const userArgs = process.argv.slice(2);
311
701
  const options = parseConfigSetArgs(userArgs);
312
- const configKey = normalizeWorkspaceConfigKey(options.key);
313
- if (!configKey) {
314
- die(`${LOG_PREFIX} Key must target a nested field under ${WORKSPACE_CONFIG_ROOT_KEY} (e.g., ${WORKSPACE_CONFIG_PREFIX}methodology.testing).`);
315
- }
316
702
  const projectRoot = findProjectRoot();
317
703
  const workspacePath = path.join(projectRoot, WORKSPACE_FILE_NAME);
318
704
  if (!existsSync(workspacePath)) {
319
705
  die(`${LOG_PREFIX} Missing ${WORKSPACE_FILE_NAME}. Run \`${WORKSPACE_INIT_COMMAND}\` first.`);
320
706
  }
707
+ // Load pack config_keys from workspace for routing
708
+ const rawWorkspace = readRawWorkspace(workspacePath);
709
+ const packConfigKeys = loadPackConfigKeys(projectRoot, rawWorkspace);
710
+ // Validate routing before starting micro-worktree
711
+ const route = routeConfigKey(options.key, packConfigKeys);
712
+ if (route.type === 'managed-error') {
713
+ die(`${LOG_PREFIX} Key "${route.rootKey}" is managed by a dedicated command. Use \`pnpm ${route.command}\` instead.`);
714
+ }
715
+ if (route.type === 'unknown-error') {
716
+ const hint = route.suggestion ? ` ${route.suggestion}` : '';
717
+ die(`${LOG_PREFIX} Unknown root key "${route.rootKey}".${hint} Valid root keys: ${[...WRITABLE_ROOT_KEYS].join(', ')}, or a pack config_key (${[...packConfigKeys.keys()].join(', ')}).`);
718
+ }
321
719
  console.log(`${LOG_PREFIX} Setting ${options.key}=${options.value} in ${WORKSPACE_FILE_NAME} via micro-worktree isolation`);
322
720
  // Use micro-worktree to make atomic changes
323
721
  await withMicroWorktree({
@@ -331,17 +729,18 @@ export async function main() {
331
729
  if (!existsSync(mwWorkspacePath)) {
332
730
  die(`${LOG_PREFIX} Config file not found in micro-worktree: ${workspaceRelPath}`);
333
731
  }
334
- // Read workspace and extract software_delivery config section
732
+ // Read full workspace
335
733
  const workspace = readRawWorkspace(mwWorkspacePath);
336
- const softwareDeliveryConfig = getSoftwareDeliveryConfigFromWorkspace(workspace);
337
- // Apply set
338
- const result = applyConfigSet(softwareDeliveryConfig, configKey, options.value);
734
+ // Re-load pack config keys and schema opts from micro-worktree workspace
735
+ const mwPackConfigKeys = loadPackConfigKeys(worktreePath, workspace);
736
+ const mwPackSchemaOpts = loadPackSchemaOpts(worktreePath, workspace);
737
+ // Apply set with workspace-aware routing and pack-specific validation
738
+ const result = applyConfigSet(workspace, options.key, options.value, mwPackConfigKeys, mwPackSchemaOpts);
339
739
  if (!result.ok) {
340
740
  die(`${LOG_PREFIX} ${result.error}`);
341
741
  }
342
- // Write updated workspace with replaced software_delivery section
343
- const updatedWorkspace = setSoftwareDeliveryConfigInWorkspace(workspace, result.config);
344
- writeRawWorkspace(mwWorkspacePath, updatedWorkspace);
742
+ // Write updated workspace
743
+ writeRawWorkspace(mwWorkspacePath, result.config);
345
744
  console.log(`${LOG_PREFIX} Config validated and written successfully.`);
346
745
  return {
347
746
  commitMessage: `${COMMIT_PREFIX} ${options.key}=${options.value}`,