@ngockhoale/ukit 2.6.10 → 2.7.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/manifests/documentation.yaml +24 -2
  3. package/manifests/instructionRules.yaml +62 -0
  4. package/manifests/platform.full.yaml +11 -0
  5. package/package.json +1 -1
  6. package/scripts/perf/audit-perf.mjs +920 -0
  7. package/src/cli/commands/doctor.js +23 -4
  8. package/src/cli/commands/feedback.js +97 -0
  9. package/src/cli/commands/memory.js +250 -1
  10. package/src/cli/commands/metrics.js +109 -1
  11. package/src/cli/index.js +7 -0
  12. package/src/core/codeintel/retriever.js +65 -0
  13. package/src/core/diffPlan.js +8 -0
  14. package/src/core/memory/store.js +7 -2
  15. package/src/core/ompConfigMerge.js +222 -0
  16. package/src/core/runInstallPipeline.js +11 -0
  17. package/src/core/runtimeConfig.js +64 -0
  18. package/src/core/unattendedDoctor.js +227 -0
  19. package/src/diagnostics/failurePatterns.js +1 -34
  20. package/src/diagnostics/feedbackEvents.js +196 -0
  21. package/src/diagnostics/laneStats.js +111 -0
  22. package/src/diagnostics/ledgerFiles.js +47 -0
  23. package/src/diagnostics/skillAccuracy.js +158 -0
  24. package/src/learning/patternProposals.js +151 -0
  25. package/src/learning/tuning.js +213 -0
  26. package/templates/.claude/hooks/block-dangerous.sh +76 -9
  27. package/templates/.claude/hooks/context-hardcap-gate.sh +26 -8
  28. package/templates/.claude/hooks/project-important.sh +70 -9
  29. package/templates/.claude/hooks/protect-files.sh +24 -7
  30. package/templates/.claude/hooks/sensitive-data-guard.sh +57 -5
  31. package/templates/.claude/hooks/session-episode.sh +84 -0
  32. package/templates/.claude/settings.json +29 -113
  33. package/templates/.claude/ukit/index/route-task.mjs +6 -0
  34. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +217 -10
  35. package/templates/.claude/ukit/runtime/hook-input.sh +119 -0
  36. package/templates/.omp/config.yml +32 -4
  37. package/templates/.omp/hooks/pre/ukit-bridge.js +12 -1
  38. package/templates/AGENTS.md +22 -10
  39. package/templates/CLAUDE.md +22 -10
  40. package/templates/adapter-presets/opencode/opencode.template.json +1 -1
  41. package/templates/docs/UKIT_INTERNALS.md +17 -0
  42. package/templates/instructions/core.md +22 -10
  43. package/templates/instructions/layout.yaml +12 -12
  44. package/templates/ukit/storage/config.json +20 -0
@@ -1,5 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
+ import fsSync from 'node:fs';
2
3
  import path from 'node:path';
4
+ import { createHash } from 'node:crypto';
3
5
 
4
6
  import { getArtifactPath, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION, normalizeRelative } from '../../index/paths.js';
5
7
  import { loadRuntimeConfig } from '../runtimeConfig.js';
@@ -260,6 +262,55 @@ function concatMerge(lanes) {
260
262
  return merged;
261
263
  }
262
264
 
265
+ // FR-202b (TASK-228): fire-and-forget lane telemetry. One JSONL line per
266
+ // retrieval at .ukit/storage/cache/retriever-lanes.jsonl; query persisted as
267
+ // sha256 only (no raw prompts on disk). Failures are swallowed — telemetry
268
+ // must never break retrieve(). Sync writes keep the fire-and-forget contract
269
+ // deterministic for callers/tests.
270
+ const RETRIEVER_LANES_MAX_BYTES = 256 * 1024;
271
+ const RETRIEVER_LANES_KEEP_LINES = 2000;
272
+
273
+ function emitRetrieverLaneTelemetry(rootDir, { query, mode, lanes, omitted, mergedPaths, merge, weights }) {
274
+ try {
275
+ const laneStats = {};
276
+ for (const laneName of LANE_ORDER) {
277
+ const lane = lanes.get(laneName);
278
+ const stat = {
279
+ hits: lane ? lane.length : 0,
280
+ weightUsed: weights?.[laneName] ?? 0,
281
+ };
282
+ const why = omitted?.find((o) => o?.what === `${laneName}-lane`)?.why;
283
+ if (!lane && why) stat.omitted = why;
284
+ if (lane) stat.paths = lane.map((entry) => entry.path).slice(0, 100);
285
+ laneStats[laneName] = stat;
286
+ }
287
+ const event = {
288
+ ts: Date.now(),
289
+ query: createHash('sha256').update(String(query ?? '')).digest('hex'),
290
+ mode,
291
+ lanes: laneStats,
292
+ mergedPaths,
293
+ merge,
294
+ };
295
+ const filePath = path.join(rootDir, '.ukit', 'storage', 'cache', 'retriever-lanes.jsonl');
296
+ fsSync.mkdirSync(path.dirname(filePath), { recursive: true });
297
+ // Best-effort cap: past 256 KiB keep only the newest ~2000 lines.
298
+ try {
299
+ const st = fsSync.statSync(filePath);
300
+ if (st.size > RETRIEVER_LANES_MAX_BYTES) {
301
+ const kept = fsSync.readFileSync(filePath, 'utf8').split('\n').filter(Boolean)
302
+ .slice(-RETRIEVER_LANES_KEEP_LINES);
303
+ fsSync.writeFileSync(filePath, kept.length ? `${kept.join('\n')}\n` : '');
304
+ }
305
+ } catch {
306
+ // Missing/unreadable file is fine — append below recreates it.
307
+ }
308
+ fsSync.appendFileSync(filePath, `${JSON.stringify(event)}\n`);
309
+ } catch {
310
+ // Telemetry is advisory; never propagate.
311
+ }
312
+ }
313
+
263
314
  /**
264
315
  * retrieve(projectRoot, query, { mode, limit, snapshot, merge, weights }) →
265
316
  * { anchors, evidence, relations, omitted }
@@ -384,6 +435,20 @@ export async function retrieve(projectRoot, query, { mode = 'search', limit, sna
384
435
  omitted.push({ what: 'anchors', why: 'no-match' });
385
436
  }
386
437
 
438
+ // FR-202b: emit lane telemetry only when a merge result exists — skipped
439
+ // entirely on the early no-index return and on empty merges.
440
+ if (merged.length > 0) {
441
+ emitRetrieverLaneTelemetry(rootDir, {
442
+ query: normalizedQuery,
443
+ mode,
444
+ lanes,
445
+ omitted,
446
+ mergedPaths: anchors.map((a) => a.path),
447
+ merge: effectiveMerge,
448
+ weights: effectiveWeights,
449
+ });
450
+ }
451
+
387
452
  const relations = [];
388
453
 
389
454
  // Impact mode — 1-hop reverse imports: who imports each anchor file.
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import { isSymlinkTo } from './fileOps.js';
3
3
  import { GATEWAY_RESILIENCE_ENV_DEFAULTS } from './gatewayResilienceEnv.js';
4
+ import { isOmpConfigTarget, mergeOmpConfig } from './ompConfigMerge.js';
4
5
 
5
6
  const GATEWAY_RESILIENCE_KEYS = new Set(Object.keys(GATEWAY_RESILIENCE_ENV_DEFAULTS));
6
7
 
@@ -116,6 +117,13 @@ function resolveFileAction(entry, existingContent) {
116
117
  : entry.mergeStrategy === 'skip'
117
118
  ? 'skip'
118
119
  : 'update';
120
+ } else if (isOmpConfigTarget(entry.targetPath) && typeof existingContent === 'string' && typeof entry.renderedContent === 'string') {
121
+ // SPEC §7 step 9: `.omp/config.yml` gets a semantic, comment-safe migration
122
+ // merge — the action compares the MERGED text against the existing file, so
123
+ // preserved user regions do not count as drift.
124
+ const merged = mergeOmpConfig(existingContent, entry.renderedContent);
125
+ action = merged.text === existingContent ? 'unchanged' : 'update';
126
+ return { ...entry, renderedContent: merged.text, migrationReport: merged.report, exists, action, existingContent };
119
127
  } else if (entry.mergeStrategy === 'merge_env_overwrite_with_backup' && typeof existingContent === 'string') {
120
128
  // Settings.json: ignore UKit-managed gateway resilience env keys (post-apply written
121
129
  // and may carry user overrides). All other top-level keys — and the rest of the env
@@ -540,7 +540,8 @@ async function proposePatternCandidateV2(projectRoot, projectId, candidate) {
540
540
  .filter((record) => record.provenance === 'pattern-candidate'
541
541
  || String(record.provenance ?? '').includes('pattern-candidate'))
542
542
  .find((record) => record.meta?.legacyStatus === 'pending'
543
- && normalizePatternText(record.text) === normalizedText);
543
+ && (normalizePatternText(record.text) === normalizedText
544
+ || (candidate?.signature && record.meta?.signature === candidate.signature)));
544
545
  if (existing) {
545
546
  return patternCandidateFromRecord(existing);
546
547
  }
@@ -553,6 +554,7 @@ async function proposePatternCandidateV2(projectRoot, projectId, candidate) {
553
554
  detectedAt: Date.now(),
554
555
  status: 'pending',
555
556
  };
557
+ if (candidate?.signature) entry.signature = candidate.signature;
556
558
 
557
559
  await v2.addRecord(projectRoot, {
558
560
  type: 'derived_fact',
@@ -626,7 +628,9 @@ export async function proposePatternCandidate(projectRoot, projectId, candidate)
626
628
 
627
629
  const patternCandidates = Array.isArray(memory.patternCandidates) ? memory.patternCandidates : [];
628
630
  const existingPending = patternCandidates.find(
629
- (entry) => entry.status === 'pending' && normalizePatternText(entry.text) === normalizedText,
631
+ (entry) => entry.status === 'pending'
632
+ && (normalizePatternText(entry.text) === normalizedText
633
+ || (candidate?.signature && entry.signature === candidate.signature)),
630
634
  );
631
635
  if (existingPending) {
632
636
  return existingPending;
@@ -640,6 +644,7 @@ export async function proposePatternCandidate(projectRoot, projectId, candidate)
640
644
  detectedAt: Date.now(),
641
645
  status: 'pending',
642
646
  };
647
+ if (candidate?.signature) entry.signature = candidate.signature;
643
648
 
644
649
  memory.patternCandidates = [...patternCandidates, entry];
645
650
  await persistProjectMemoryWithHygiene(projectRoot, runtimePaths, projectId, filePath, memory);
@@ -0,0 +1,222 @@
1
+ import YAML from 'yaml';
2
+
3
+ // TASK-010 / SPEC §5.2 + §7 (FR-006): semantic, comment-safe migration merge for
4
+ // `.omp/config.yml`. Operates on the rendered template TEXT with surgical splices
5
+ // so the template's load-bearing comments survive — a full YAML round-trip
6
+ // (parse + stringify) would drop every comment.
7
+
8
+ export const PRESERVED_MARKER = '# Preserved by UKit unattended migration (C35):';
9
+
10
+ const OMP_CONFIG_SUFFIX = '.omp/config.yml';
11
+
12
+ export function isOmpConfigTarget(targetPath) {
13
+ return typeof targetPath === 'string' && targetPath.replace(/\\/g, '/').endsWith(OMP_CONFIG_SUFFIX);
14
+ }
15
+
16
+ function emptyReport() {
17
+ return {
18
+ migrated: false,
19
+ legacyApprovalMode: null,
20
+ strippedPromptApprovals: [],
21
+ coercedPromptPatterns: [],
22
+ preservedModelRoles: false,
23
+ preservedTopLevelKeys: [],
24
+ preservedPatterns: [],
25
+ parseError: null,
26
+ };
27
+ }
28
+
29
+ // Extract a top-level scalar map's value span: the `key:` line plus following
30
+ // lines that are indented or blank. Stops before the next non-indented,
31
+ // non-blank line — comments preceding the next top-level key stay with it.
32
+ function extractScalarMapSpan(lines, key) {
33
+ const start = lines.findIndex((line) => new RegExp(`^${key}:\\s*$`).test(line) || new RegExp(`^${key}:\\s`).test(line));
34
+ if (start === -1) return null;
35
+ let end = start + 1;
36
+ while (end < lines.length) {
37
+ const line = lines[end];
38
+ if (line.trim() === '' || /^\s/.test(line)) {
39
+ end += 1;
40
+ } else {
41
+ break;
42
+ }
43
+ }
44
+ // Trim trailing blank lines out of the span — they belong to the gap before
45
+ // the next key, not to this block.
46
+ let blockEnd = end;
47
+ while (blockEnd > start + 1 && lines[blockEnd - 1].trim() === '') {
48
+ blockEnd -= 1;
49
+ }
50
+ return { start, end: blockEnd, lines: lines.slice(start, blockEnd) };
51
+ }
52
+
53
+ function isPlainObject(value) {
54
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
55
+ }
56
+
57
+ // Indent every line of a YAML fragment by `indent` spaces.
58
+ function indentFragment(fragment, indent) {
59
+ const pad = ' '.repeat(indent);
60
+ return fragment
61
+ .split('\n')
62
+ .filter((line) => line !== '')
63
+ .map((line) => pad + line);
64
+ }
65
+
66
+ export function mergeOmpConfig(existingText, renderedText) {
67
+ const report = emptyReport();
68
+
69
+ let existing;
70
+ try {
71
+ existing = YAML.parse(existingText);
72
+ } catch (error) {
73
+ report.parseError = error?.message ?? String(error);
74
+ return { text: renderedText, report };
75
+ }
76
+ if (!isPlainObject(existing)) {
77
+ report.parseError = 'existing .omp/config.yml did not parse to a mapping';
78
+ return { text: renderedText, report };
79
+ }
80
+
81
+ let rendered;
82
+ try {
83
+ rendered = YAML.parse(renderedText);
84
+ } catch (error) {
85
+ // A broken template is not a migration problem — surface it as parseError so
86
+ // the caller falls back to plain overwrite rather than crashing install.
87
+ report.parseError = `rendered template failed to parse: ${error?.message ?? error}`;
88
+ return { text: renderedText, report };
89
+ }
90
+
91
+ const existingTools = isPlainObject(existing.tools) ? existing.tools : {};
92
+ const existingApproval = isPlainObject(existingTools.approval) ? existingTools.approval : {};
93
+ const renderedApproval = isPlainObject(rendered?.tools?.approval) ? rendered.tools.approval : {};
94
+
95
+ report.legacyApprovalMode = typeof existingTools.approvalMode === 'string'
96
+ ? existingTools.approvalMode
97
+ : null;
98
+
99
+ // §7.2 — user-defined tools.approval.<k> keys absent from the canonical map:
100
+ // preserved only when allow|deny; every `prompt` value (canonical or custom)
101
+ // is recorded in strippedPromptApprovals.
102
+ const extraApprovalEntries = [];
103
+ for (const [key, value] of Object.entries(existingApproval)) {
104
+ if (value === 'prompt') {
105
+ report.strippedPromptApprovals.push(key);
106
+ continue;
107
+ }
108
+ if (!(key in renderedApproval) && (value === 'allow' || value === 'deny')) {
109
+ extraApprovalEntries.push([key, value]);
110
+ }
111
+ }
112
+
113
+ // §7.4 — bash.patterns union: existing-only entries (dedupe by match) appended
114
+ // before the trailing `- match: "*"` allow; `prompt` re-emitted as `deny`.
115
+ const existingPatterns = Array.isArray(existing?.bash?.patterns) ? existing.bash.patterns : [];
116
+ const renderedPatterns = Array.isArray(rendered?.bash?.patterns) ? rendered.bash.patterns : [];
117
+ const renderedMatches = new Set(renderedPatterns.map((p) => p?.match));
118
+ const extraPatterns = [];
119
+ for (const pattern of existingPatterns) {
120
+ if (!isPlainObject(pattern) || typeof pattern.match !== 'string') continue;
121
+ if (renderedMatches.has(pattern.match)) continue;
122
+ let approval = pattern.approval;
123
+ if (approval === 'prompt') {
124
+ approval = 'deny';
125
+ report.coercedPromptPatterns.push(pattern.match);
126
+ }
127
+ extraPatterns.push({ ...pattern, approval });
128
+ report.preservedPatterns.push(pattern.match);
129
+ }
130
+
131
+ // §7.6 — extra top-level keys absent from the rendered template.
132
+ const extraTopLevel = {};
133
+ if (isPlainObject(rendered)) {
134
+ for (const [key, value] of Object.entries(existing)) {
135
+ if (!(key in rendered)) {
136
+ extraTopLevel[key] = value;
137
+ report.preservedTopLevelKeys.push(key);
138
+ }
139
+ }
140
+ }
141
+
142
+ const lines = renderedText.split('\n');
143
+
144
+ // §7.3 — modelRoles block splice: replace the rendered `modelRoles:` scalar
145
+ // map span with the existing span (maintainer edits for non-UNIC providers).
146
+ const renderedRoles = extractScalarMapSpan(lines, 'modelRoles');
147
+ const existingLines = existingText.split('\n');
148
+ const existingRoles = extractScalarMapSpan(existingLines, 'modelRoles');
149
+ if (renderedRoles && existingRoles && isPlainObject(existing.modelRoles)) {
150
+ lines.splice(renderedRoles.start, renderedRoles.end - renderedRoles.start, ...existingRoles.lines);
151
+ report.preservedModelRoles = true;
152
+ }
153
+
154
+ // §7.2 splice — append preserved allow/deny keys to the rendered
155
+ // tools.approval map (before the first line that leaves the map's indent).
156
+ if (extraApprovalEntries.length > 0) {
157
+ const approvalStart = lines.findIndex((line) => /^\s+approval:\s*$/.test(line));
158
+ if (approvalStart !== -1) {
159
+ const baseIndent = lines[approvalStart].match(/^\s*/)[0].length;
160
+ let insertAt = approvalStart + 1;
161
+ while (insertAt < lines.length) {
162
+ const line = lines[insertAt];
163
+ if (line.trim() === '') {
164
+ insertAt += 1;
165
+ continue;
166
+ }
167
+ const indent = line.match(/^\s*/)[0].length;
168
+ if (indent <= baseIndent) break;
169
+ insertAt += 1;
170
+ }
171
+ const fragment = extraApprovalEntries.map(([k, v]) => `${' '.repeat(baseIndent + 2)}${k}: ${v}`);
172
+ lines.splice(insertAt, 0, ...fragment);
173
+ }
174
+ }
175
+
176
+ // §7.4 splice — insert preserved patterns before the trailing `- match: "*"`
177
+ // allow (or at the end of the patterns list if no wildcard exists).
178
+ if (extraPatterns.length > 0) {
179
+ // YAML.stringify emits sequence items at column 0; preserve each line's
180
+ // relative indentation and shift the whole fragment to the list's indent.
181
+ const rawFragment = YAML.stringify(extraPatterns).trimEnd().split('\n');
182
+ const wildcardIdx = lines.findIndex((line) => /- match: ["']?\*["']?\s*$/.test(line));
183
+ if (wildcardIdx !== -1) {
184
+ const itemIndent = lines[wildcardIdx].match(/^\s*/)[0].length;
185
+ lines.splice(wildcardIdx, 0, ...rawFragment.map((line) => ' '.repeat(itemIndent) + line));
186
+ } else {
187
+ const patternsStart = lines.findIndex((line) => /^\s+patterns:\s*$/.test(line));
188
+ if (patternsStart !== -1) {
189
+ const baseIndent = lines[patternsStart].match(/^\s*/)[0].length;
190
+ let insertAt = patternsStart + 1;
191
+ while (insertAt < lines.length) {
192
+ const line = lines[insertAt];
193
+ if (line.trim() === '') {
194
+ insertAt += 1;
195
+ continue;
196
+ }
197
+ const indent = line.match(/^\s*/)[0].length;
198
+ if (indent <= baseIndent) break;
199
+ insertAt += 1;
200
+ }
201
+ const itemIndent = baseIndent + 2;
202
+ lines.splice(insertAt, 0, ...rawFragment.map((line) => ' '.repeat(itemIndent) + line));
203
+ }
204
+ }
205
+ }
206
+
207
+ // §7.6 splice — append extra top-level keys under the preserved marker.
208
+ if (report.preservedTopLevelKeys.length > 0) {
209
+ const fragment = YAML.stringify(extraTopLevel).trimEnd().split('\n');
210
+ let end = lines.length;
211
+ while (end > 0 && lines[end - 1].trim() === '') end -= 1;
212
+ lines.splice(end, lines.length - end, '', PRESERVED_MARKER, ...fragment, '');
213
+ }
214
+
215
+ const text = lines.join('\n');
216
+ // "migrated" = the merge changed anything relative to what is on disk —
217
+ // includes the approval-surface rewrite itself (legacy approvalMode, prompt
218
+ // values) even when no user region needed splicing.
219
+ report.migrated = text !== existingText;
220
+
221
+ return { text, report };
222
+ }
@@ -310,6 +310,17 @@ export async function runInstallPipeline({
310
310
  projectRoot: pathConfig.projectRoot,
311
311
  });
312
312
 
313
+ // SPEC §7 step 9: surface the .omp/config.yml unattended migration result.
314
+ for (const entry of diffResults) {
315
+ const report = entry?.migrationReport;
316
+ if (!report?.migrated) continue;
317
+ const removed = (report.strippedPromptApprovals?.length ?? 0) + (report.coercedPromptPatterns?.length ?? 0);
318
+ const preserved = (report.preservedModelRoles ? 1 : 0)
319
+ + (report.preservedTopLevelKeys?.length ?? 0)
320
+ + (report.preservedPatterns?.length ?? 0);
321
+ console.log(`[UKit] .omp/config.yml: migrated approval surface (prompt→removed: ${removed}, preserved: ${preserved})`);
322
+ }
323
+
313
324
  // BUG-C22-19: bound the .bak accumulation — every overwrite_with_backup write
314
325
  // adds one and nothing removed them. Advisory: a prune failure must never
315
326
  // fail the install.
@@ -12,6 +12,10 @@ const { version: PACKAGE_VERSION } = require('../../package.json');
12
12
  // config.json naming it must not become invalid.
13
13
  const VALID_AGENTS = new Set(['claude-code', 'codex', 'antigravity', 'opencode', 'omp']);
14
14
 
15
+ // Reserved permission modes for orchestration.permissionMode (single source of truth).
16
+ // 'unattended' is the only mode implemented this cycle; interactive/safe-auto are reserved.
17
+ export const VALID_PERMISSION_MODES = new Set(['interactive', 'safe-auto', 'unattended']);
18
+
15
19
  const BLOCKED_MERGE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
16
20
 
17
21
  function isPlainObject(value) {
@@ -137,6 +141,7 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
137
141
  enabled: true,
138
142
  orchestratorModel: 'claude-sonnet-5',
139
143
  advisorEnabled: true,
144
+ permissionMode: 'unattended',
140
145
  contracts: buildConfigContracts(),
141
146
  modelTiers: {
142
147
  lite: { claudeModel: 'claude-haiku-4-5', genericModel: 'unic-lite' },
@@ -209,6 +214,14 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
209
214
  maxRetries: 1,
210
215
  confidenceThreshold: 50,
211
216
  },
217
+ // Phase-4 learning loop (SPEC §8a). Advisory only — applyMode 'manual'
218
+ // means suggestions are computed + persisted, never auto-applied.
219
+ learning: {
220
+ feedback: { enabled: true, maxEvents: 200 },
221
+ proposals: { minCount: 3, minSessions: 2 },
222
+ episodes: { autoWrite: false },
223
+ tuning: { enabled: true, applyMode: 'manual' },
224
+ },
212
225
  safePatch: {
213
226
  enabled: true,
214
227
  strictSharedRisk: true,
@@ -385,6 +398,12 @@ export function validateRuntimeConfig(config) {
385
398
  pushBooleanError(errors, config.orchestration.enabled, 'orchestration.enabled');
386
399
  pushNonEmptyStringError(errors, config.orchestration.orchestratorModel, 'orchestration.orchestratorModel');
387
400
  pushBooleanError(errors, config.orchestration.advisorEnabled, 'orchestration.advisorEnabled');
401
+ // permissionMode is optional-present: absent → tolerated (pre-C35 configs);
402
+ // present → must be a reserved mode.
403
+ if (config.orchestration.permissionMode !== undefined
404
+ && !VALID_PERMISSION_MODES.has(config.orchestration.permissionMode)) {
405
+ errors.push(`orchestration.permissionMode must be one of: ${[...VALID_PERMISSION_MODES].join(', ')}.`);
406
+ }
388
407
  if (!isPlainObject(config.orchestration.contracts)) {
389
408
  errors.push('orchestration.contracts must be an object.');
390
409
  }
@@ -564,6 +583,51 @@ export function validateRuntimeConfig(config) {
564
583
  pushPositiveNumberError(errors, config.safePatch.deltaMaxDiffCells, 'safePatch.deltaMaxDiffCells');
565
584
  }
566
585
 
586
+ // learning is optional-present: absent → valid (old configs); present but
587
+ // not an object → error. Per-key checks apply only when the sub-object
588
+ // exists, so partial learning blocks stay valid via defaults merge.
589
+ if (config.learning !== undefined) {
590
+ if (!isPlainObject(config.learning)) {
591
+ errors.push('learning must be an object.');
592
+ } else {
593
+ const learning = config.learning;
594
+ if (learning.feedback !== undefined) {
595
+ if (!isPlainObject(learning.feedback)) {
596
+ errors.push('learning.feedback must be an object.');
597
+ } else {
598
+ pushBooleanError(errors, learning.feedback.enabled, 'learning.feedback.enabled');
599
+ pushPositiveNumberError(errors, learning.feedback.maxEvents, 'learning.feedback.maxEvents');
600
+ }
601
+ }
602
+ if (learning.proposals !== undefined) {
603
+ if (!isPlainObject(learning.proposals)) {
604
+ errors.push('learning.proposals must be an object.');
605
+ } else {
606
+ pushPositiveNumberError(errors, learning.proposals.minCount, 'learning.proposals.minCount');
607
+ pushPositiveNumberError(errors, learning.proposals.minSessions, 'learning.proposals.minSessions');
608
+ }
609
+ }
610
+ if (learning.episodes !== undefined) {
611
+ if (!isPlainObject(learning.episodes)) {
612
+ errors.push('learning.episodes must be an object.');
613
+ } else {
614
+ pushBooleanError(errors, learning.episodes.autoWrite, 'learning.episodes.autoWrite');
615
+ }
616
+ }
617
+ if (learning.tuning !== undefined) {
618
+ if (!isPlainObject(learning.tuning)) {
619
+ errors.push('learning.tuning must be an object.');
620
+ } else {
621
+ pushBooleanError(errors, learning.tuning.enabled, 'learning.tuning.enabled');
622
+ const VALID_APPLY_MODES = new Set(['manual', 'off']);
623
+ if (!VALID_APPLY_MODES.has(learning.tuning.applyMode)) {
624
+ errors.push(`learning.tuning.applyMode must be one of: ${[...VALID_APPLY_MODES].join(', ')}.`);
625
+ }
626
+ }
627
+ }
628
+ }
629
+ }
630
+
567
631
  if (!isPlainObject(config.subagents)) {
568
632
  errors.push('subagents must be an object.');
569
633
  } else {