@ngockhoale/ukit 3.0.3 → 3.0.4

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 (109) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/bin/ukit +12 -4
  3. package/manifests/engineConformance.yaml +29 -0
  4. package/manifests/platform.full.yaml +13 -0
  5. package/package.json +2 -1
  6. package/scripts/audit/decision-coverage.mjs +295 -0
  7. package/scripts/bench/outline-savings.mjs +19 -4
  8. package/scripts/bench/parallel-agents.mjs +15 -4
  9. package/scripts/bench/runGold.mjs +22 -4
  10. package/scripts/bench/v3-ceremony.mjs +7 -1
  11. package/scripts/bug/triage.mjs +56 -17
  12. package/scripts/index/build-index.mjs +94 -28
  13. package/scripts/index/query-index.mjs +48 -14
  14. package/scripts/index/refresh-index.mjs +142 -62
  15. package/scripts/perf/audit-perf.mjs +8 -2
  16. package/scripts/skill/audit-skill.mjs +54 -25
  17. package/src/bug/triageBug.js +9 -6
  18. package/src/cli/adapters.js +6 -0
  19. package/src/cli/commands/code.js +7 -1
  20. package/src/cli/commands/indexArgs.js +4 -2
  21. package/src/cli/commands/indexTools.js +8 -1
  22. package/src/cli/commands/install.js +13 -0
  23. package/src/cli/commands/memory.js +10 -3
  24. package/src/cli/commands/status.js +17 -1
  25. package/src/cli/commands/update.js +7 -0
  26. package/src/context/detectProjectContext.js +3 -1
  27. package/src/core/codeintel/analogy.js +1 -1
  28. package/src/core/codeintel/diagnostics.js +9 -6
  29. package/src/core/codeintel/graph.js +14 -8
  30. package/src/core/codeintel/impact.js +0 -1
  31. package/src/core/codeintel/invalidation.js +5 -3
  32. package/src/core/codeintel/packet.js +11 -0
  33. package/src/core/codeintel/router.js +17 -3
  34. package/src/core/codeintel/semanticProvider.js +1 -1
  35. package/src/core/codeintel/summaries.js +11 -7
  36. package/src/core/compact/contextBudget.js +26 -12
  37. package/src/core/compact/index.js +15 -8
  38. package/src/core/docContracts.js +10 -2
  39. package/src/core/experiments/deliberation.js +321 -0
  40. package/src/core/experiments/dynamicWorkflow.js +492 -0
  41. package/src/core/fileOps.js +8 -1
  42. package/src/core/gatewayProbe.js +29 -1
  43. package/src/core/gatewayResilienceEnv.js +44 -3
  44. package/src/core/handoffDocValidator.js +3 -1
  45. package/src/core/hookChainDoctor.js +16 -1
  46. package/src/core/memory/deltaOverlays.js +448 -0
  47. package/src/core/memory/learningCandidates.js +302 -0
  48. package/src/core/memory/migrate.js +59 -32
  49. package/src/core/memory/recordStore.js +24 -1
  50. package/src/core/memory/store.js +44 -8
  51. package/src/core/memory/storeV2.js +48 -33
  52. package/src/core/memory/userMemory.js +11 -10
  53. package/src/core/output/index.js +15 -10
  54. package/src/core/permissionPolicy.js +8 -0
  55. package/src/core/runtimeConfig.js +224 -4
  56. package/src/core/sensitiveValueScanner.js +10 -2
  57. package/src/core/taskBudgetValidator.js +12 -17
  58. package/src/core/taskProgressGuard.js +59 -9
  59. package/src/core/unattendedDoctor.js +5 -2
  60. package/src/core/uninstall.js +37 -8
  61. package/src/decision/client.js +371 -0
  62. package/src/decision/lease.js +198 -0
  63. package/src/decision/preflight.js +492 -0
  64. package/src/decision/protocol.js +308 -0
  65. package/src/decision/registry.js +384 -0
  66. package/src/decision/shadow.js +281 -0
  67. package/src/decision/statePacket.js +165 -0
  68. package/src/diagnostics/failurePatterns.js +2 -1
  69. package/src/diagnostics/ledgerFiles.js +3 -1
  70. package/src/diagnostics/routeOutcomes.js +1 -29
  71. package/src/index/buildIndex.js +23 -11
  72. package/src/index/impactContext.js +21 -2
  73. package/src/index/importResolution.js +13 -7
  74. package/src/index/queryIndex.js +11 -5
  75. package/src/index/resolveContext.js +16 -6
  76. package/src/index/taskRouting.js +63 -2
  77. package/src/index/verificationPlan.js +12 -1
  78. package/src/learning/patternProposals.js +6 -0
  79. package/src/render/renderTemplate.js +1 -1
  80. package/src/skill/auditSkill.js +3 -1
  81. package/src/stack/detectStack.js +3 -1
  82. package/template_project/.claude/agents/handoff-planner.md +2 -5
  83. package/template_project/.claude/hooks/auto-allow-bash.sh +5 -0
  84. package/template_project/.claude/hooks/block-dangerous.mjs +11 -4
  85. package/template_project/.claude/hooks/context-hardcap-gate.sh +10 -1
  86. package/template_project/.claude/hooks/handoff-model-guard.sh +14 -4
  87. package/template_project/.claude/hooks/handoff-resume.sh +10 -1
  88. package/template_project/.claude/hooks/protect-files.sh +0 -1
  89. package/template_project/.claude/hooks/record-execution.mjs +13 -1
  90. package/template_project/.claude/hooks/sensitive-data-guard.mjs +71 -5
  91. package/template_project/.claude/hooks/session-episode.sh +9 -2
  92. package/template_project/.claude/skills/pdf-processing-pro/SKILL.md +1 -1
  93. package/template_project/.claude/ukit/index/handoff-doc-validator.mjs +3 -1
  94. package/template_project/.claude/ukit/index/lib/index-core.mjs +123 -39
  95. package/template_project/.claude/ukit/index/route-task.mjs +444 -0
  96. package/template_project/.claude/ukit/index/task-budget-validator.mjs +12 -16
  97. package/template_project/.claude/ukit/index/unic-decision.mjs +786 -0
  98. package/template_project/.claude/ukit/index/verify-context.mjs +11 -0
  99. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +116 -9
  100. package/template_project/.claude/ukit/runtime/project-important.mjs +9 -7
  101. package/template_project/.claude/ukit/runtime/reinject-context.mjs +48 -0
  102. package/template_project/.claude/ukit/runtime/resumable-run.mjs +596 -0
  103. package/template_project/.claude/ukit/runtime/sensitive-value-scanner.mjs +4 -7
  104. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +1 -1
  105. package/template_project/docs/AI_HANDOFF/PLAN.md +7 -7
  106. package/template_project/docs/AI_HANDOFF/RULES.md +1 -1
  107. package/template_project/ukit/storage/config.json +34 -1
  108. package/template_project/.claude/ukit/skill-router-state.json +0 -1
  109. package/template_project/.ukit/storage/cache/hook-latency/unknown.jsonl +0 -4
@@ -3,11 +3,11 @@
3
3
  // ({ schemaVersion: 2, records: [...] }); writes via fileOps.writeJson (tmp+rename).
4
4
  // Read ops call ensureMigrated() first so every consumer triggers the lazy
5
5
  // v1→v2 migration (SPEC §4); migrate.js is lazy-imported to break the
6
- // storeV2↔migrate import cycle (runMigration writes via saveRecords).
7
- // The record-document read/write core lives in recordStore.js (FR-007) so the
8
- // user-level store (userMemory.js) shares it without the migration flow.
6
+ // storeV2↔migrate import cycle. The record-document read/write core lives in
7
+ // recordStore.js (FR-007) so the user-level store (userMemory.js) shares it
8
+ // without the migration flow; mutations go through mutateRecordStore, which
9
+ // serializes read-modify-write under the shared file lock.
9
10
 
10
- import fs from 'node:fs/promises';
11
11
  import { buildRuntimePaths } from '../runtimePaths.js';
12
12
  import { loadRuntimeConfig } from '../runtimeConfig.js';
13
13
  import { createRecord, normalizeRecord } from './records.js';
@@ -16,35 +16,44 @@ import {
16
16
  writeRecordStore,
17
17
  applyRecordPatch,
18
18
  isRawRecord,
19
+ mutateRecordStore,
19
20
  } from './recordStore.js';
20
21
 
21
- // Per-process memoization for ensureMigrated — one-shot check per projectRoot.
22
- const migratedRoots = new Set();
22
+ // Per-process memoization for ensureMigrated — one in-flight migration per
23
+ // projectRoot. The PROMISE is cached (not a done-flag): concurrent callers
24
+ // during a migration must await the same run — a done-flag set up front let
25
+ // a second reader see an empty store mid-migration, and a writer could create
26
+ // records.json which the in-flight migration then clobbered.
27
+ const migrationPromises = new Map();
28
+
29
+
23
30
 
24
31
  async function ensureMigrated(projectRoot) {
25
32
  const key = String(projectRoot);
26
- if (migratedRoots.has(key)) return;
27
- migratedRoots.add(key); // mark first: a failed/again migration must not loop
28
- try {
29
- const paths = buildRuntimePaths(projectRoot);
30
- let recordsExists = false;
31
- try {
32
- await fs.access(paths.memoryV2RecordsPath);
33
- recordsExists = true;
34
- } catch {
35
- recordsExists = false;
36
- }
37
- if (recordsExists) return;
33
+ const existing = migrationPromises.get(key);
34
+ if (existing) return existing;
38
35
 
39
- const config = await loadRuntimeConfig(projectRoot);
40
- if (config?.memoryV2?.autoMigrate === false) return;
36
+ const promise = (async () => {
37
+ try {
38
+ const config = await loadRuntimeConfig(projectRoot);
39
+ if (config?.memoryV2?.autoMigrate === false) return;
41
40
 
42
- const migrate = await import('./migrate.js');
43
- if (await migrate.needsMigration(projectRoot)) {
44
- await migrate.runMigration(projectRoot);
41
+ const migrate = await import('./migrate.js');
42
+ // needsMigration checks the marker — NOT records.json. Gating on
43
+ // records.json stranded v1 data forever whenever the file existed
44
+ // without the marker (a pre-migration write, a crashed first run).
45
+ if (await migrate.needsMigration(projectRoot)) {
46
+ await migrate.runMigration(projectRoot);
47
+ }
48
+ } catch {
49
+ // Lazy migration is best-effort: a failed auto-run must not break reads.
45
50
  }
46
- } catch {
47
- // Lazy migration is best-effort: a failed auto-run must not break reads.
51
+ })();
52
+ migrationPromises.set(key, promise);
53
+ try {
54
+ await promise;
55
+ } finally {
56
+ migrationPromises.delete(key);
48
57
  }
49
58
  }
50
59
 
@@ -73,6 +82,8 @@ export async function saveRecords(projectRoot, records) {
73
82
  /**
74
83
  * addRecord(projectRoot, recordInput) → record
75
84
  * Accepts either createRecord-style input or a full raw record (normalized).
85
+ * Runs ensureMigrated first — a first v2 write must not create records.json
86
+ * ahead of the v1→v2 migration and strand legacy data.
76
87
  */
77
88
  export async function addRecord(projectRoot, recordInput) {
78
89
  const record = isRawRecord(recordInput)
@@ -81,9 +92,13 @@ export async function addRecord(projectRoot, recordInput) {
81
92
  if (!record) {
82
93
  throw new Error('addRecord: input could not be normalized into a valid record');
83
94
  }
84
- const { records } = await readStore(projectRoot);
85
- await saveRecords(projectRoot, [...records, record]);
86
- return record;
95
+ await ensureMigrated(projectRoot);
96
+ const paths = buildRuntimePaths(projectRoot);
97
+ const stored = await mutateRecordStore(paths.memoryV2RecordsPath, (records) => ({
98
+ result: record,
99
+ records: [...records, record],
100
+ }));
101
+ return stored ?? record;
87
102
  }
88
103
 
89
104
  /**
@@ -92,11 +107,11 @@ export async function addRecord(projectRoot, recordInput) {
92
107
  export async function updateRecord(projectRoot, id, patch = {}) {
93
108
  await ensureMigrated(projectRoot);
94
109
  const paths = buildRuntimePaths(projectRoot);
95
- const { records } = await readRecordStore(paths.memoryV2RecordsPath);
96
- const result = applyRecordPatch(records, id, patch);
97
- if (!result) return null;
98
- await writeRecordStore(paths.memoryV2RecordsPath, result.records);
99
- return result.record;
110
+ return mutateRecordStore(paths.memoryV2RecordsPath, (records) => {
111
+ const result = applyRecordPatch(records, id, patch);
112
+ if (!result) return null;
113
+ return { result: result.record, records: result.records };
114
+ });
100
115
  }
101
116
 
102
117
  /**
@@ -11,6 +11,7 @@ import {
11
11
  writeRecordStore,
12
12
  applyRecordPatch,
13
13
  isRawRecord,
14
+ mutateRecordStore,
14
15
  } from './recordStore.js';
15
16
  import { loadRecords } from './storeV2.js';
16
17
 
@@ -44,22 +45,22 @@ export async function addUserRecord(recordInput, { homeDir } = {}) {
44
45
  if (!record) {
45
46
  throw new Error('addUserRecord: input could not be normalized into a valid record');
46
47
  }
47
- const recordsPath = userRecordsPath(homeDir);
48
- const { records } = await readRecordStore(recordsPath);
49
- await writeRecordStore(recordsPath, [...records, record]);
50
- return record;
48
+ const stored = await mutateRecordStore(userRecordsPath(homeDir), (records) => ({
49
+ result: record,
50
+ records: [...records, record],
51
+ }));
52
+ return stored ?? record;
51
53
  }
52
54
 
53
55
  /**
54
56
  * updateUserRecord(id, patch, {homeDir}={}) → record | null
55
57
  */
56
58
  export async function updateUserRecord(id, patch = {}, { homeDir } = {}) {
57
- const recordsPath = userRecordsPath(homeDir);
58
- const { records } = await readRecordStore(recordsPath);
59
- const result = applyRecordPatch(records, id, patch);
60
- if (!result) return null;
61
- await writeRecordStore(recordsPath, result.records);
62
- return result.record;
59
+ return mutateRecordStore(userRecordsPath(homeDir), (records) => {
60
+ const result = applyRecordPatch(records, id, patch);
61
+ if (!result) return null;
62
+ return { result: result.record, records: result.records };
63
+ });
63
64
  }
64
65
 
65
66
  /**
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { readJsonIfExists, writeJson, withFileLock } from '../fileOps.js';
3
+ import { readJsonIfExists, writeJson, withFileLock, escapeRegExp } from '../fileOps.js';
4
4
  import { recordCompaction } from '../compact/index.js';
5
5
  import { buildRuntimePaths } from '../runtimePaths.js';
6
6
  import {
@@ -202,9 +202,6 @@ function detectOutputProfile(command) {
202
202
  return OUTPUT_PROFILES.find((profile) => matchesAnyPattern(commandText, profile.commandPatterns)) ?? null;
203
203
  }
204
204
 
205
- function escapeRegExp(value) {
206
- return String(value ?? '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
207
- }
208
205
 
209
206
  function buildProjectRootVariants(projectRoot) {
210
207
  const trimmed = String(projectRoot ?? '').trim();
@@ -381,16 +378,24 @@ export async function sweepTeeCache(dir, {
381
378
  maxEntries = TEE_SWEEP_MAX_ENTRIES,
382
379
  maxRemovals = TEE_SWEEP_MAX_REMOVALS,
383
380
  } = {}) {
384
- let names;
381
+ let dirents;
385
382
  try {
386
- names = await fs.readdir(dir);
383
+ dirents = await fs.readdir(dir, { withFileTypes: true });
387
384
  } catch {
388
385
  return { sampled: true, scanned: 0, removed: 0 };
389
386
  }
387
+ // Eligible-file count comes from the dirents (one bounded readdir, no per-name
388
+ // stat) so the count cap is enforceable even when maxFiles > maxEntries —
389
+ // mirror of BUG-C22-10 in the runtime twin: capping `scanned` at maxEntries
390
+ // made `entries.length` an under-count and `overflow` was always 0.
391
+ const eligibleNames = [];
392
+ for (const dirent of dirents) {
393
+ if (dirent.isFile()) eligibleNames.push(dirent.name);
394
+ }
390
395
  const cutoff = now() - maxAgeMs;
391
396
  const entries = [];
392
397
  let scanned = 0;
393
- for (const name of names) {
398
+ for (const name of eligibleNames) {
394
399
  if (scanned >= maxEntries) break;
395
400
  scanned += 1;
396
401
  try {
@@ -400,9 +405,9 @@ export async function sweepTeeCache(dir, {
400
405
  }
401
406
  entries.sort((a, b) => a.mtimeMs - b.mtimeMs);
402
407
  // Overflow counts eligible FILES only — foreign entries (non-matching names,
403
- // dirs) inflate `names.length` and would evict real entries while the dir is
408
+ // dirs) inflate the dir listing and would evict real entries while the dir is
404
409
  // actually under the cap.
405
- const overflow = Math.max(0, entries.length - maxFiles);
410
+ const overflow = Math.max(0, eligibleNames.length - maxFiles);
406
411
  let removed = 0;
407
412
  for (const entry of entries) {
408
413
  if (removed >= maxRemovals) break;
@@ -1148,7 +1153,7 @@ export async function readOutputHistory(projectRoot, options = {}) {
1148
1153
 
1149
1154
  export async function appendOutputHistory(projectRoot, entry, options = {}) {
1150
1155
  const normalizedEntry = normalizeOutputHistoryEntry(entry);
1151
- if (!normalizedEntry || !normalizedEntry.command || !normalizedEntry.summary) {
1156
+ if (!normalizedEntry || !normalizedEntry.summary) {
1152
1157
  return readOutputHistory(projectRoot, options);
1153
1158
  }
1154
1159
 
@@ -102,6 +102,14 @@ export function compilePermissionPolicy(input = {}) {
102
102
  if (surfaces === null || typeof surfaces !== 'object' || Array.isArray(surfaces)) {
103
103
  throw policyError('surfaces must be an object when provided');
104
104
  }
105
+ // Fail closed on malformed surface lists: a string iterates into per-char
106
+ // rules and a number throws a bare TypeError — both bypass the policy
107
+ // contract. Only arrays (or absent keys) are legal.
108
+ for (const key of ['protect', 'deny', 'allow', 'ask']) {
109
+ if (surfaces[key] !== undefined && !Array.isArray(surfaces[key])) {
110
+ throw policyError(`surfaces.${key} must be an array when provided, got: ${JSON.stringify(surfaces[key])}`);
111
+ }
112
+ }
105
113
 
106
114
  const mode = permissionMode;
107
115
  const effectiveMode = mode;
@@ -183,6 +183,36 @@ function pushNonEmptyStringError(errors, value, label) {
183
183
  }
184
184
  }
185
185
 
186
+ // Reserved rollout stages for every feature-stage key (routing.*, decisionPlane.*,
187
+ // continuity.*, learning.*). Stages promote off → shadow → canary → default;
188
+ // absence always means 'off'. Single source of truth — validators and stage
189
+ // resolvers share this set.
190
+ export const VALID_ROUTE_STAGES = new Set(['off', 'shadow', 'canary', 'default']);
191
+
192
+ // Generic stage resolver for dotted paths like 'decisionPlane.stage' or
193
+ // 'continuity.resumableRun.stage'. Absent config, absent key, non-object
194
+ // intermediate, or malformed stage value all resolve to 'off' — a bad config
195
+ // can never promote a feature. The validator reports the same malformed value
196
+ // as an error, so 'off' here is the safe fallback, not silent acceptance.
197
+ export function resolveConfigStage(config = null, path) {
198
+ const keys = String(path ?? '').split('.');
199
+ let node = config;
200
+ for (const key of keys) {
201
+ if (!isPlainObject(node)) return 'off';
202
+ node = node[key];
203
+ }
204
+ return VALID_ROUTE_STAGES.has(node) ? node : 'off';
205
+ }
206
+
207
+ // Pushes a stage-enum error when `node.stage` is present but not reserved.
208
+ // Absent stage is valid (absence = 'off'); a non-object node is reported by
209
+ // the caller's own "must be an object" check.
210
+ function pushStageError(errors, node, label) {
211
+ if (node.stage !== undefined && !VALID_ROUTE_STAGES.has(node.stage)) {
212
+ errors.push(`${label}.stage must be one of: ${[...VALID_ROUTE_STAGES].join(', ')}.`);
213
+ }
214
+ }
215
+
186
216
  export function buildDefaultRuntimeConfig(overrides = {}) {
187
217
  const safeOverrides = isPlainObject(overrides) ? overrides : {};
188
218
 
@@ -263,6 +293,36 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
263
293
  fastPath: { stage: 'off' },
264
294
  escalation: { stage: 'off' },
265
295
  },
296
+ // C52 M07 UNIC Decision Agent (docs/pstack/UNIC_DECISION_SPEC §17). All
297
+ // stages ship 'off' — zero outbound calls, deterministic policy stays
298
+ // authoritative. `enabled:false` is the global emergency disable.
299
+ decisionPlane: {
300
+ enabled: true,
301
+ stage: 'off',
302
+ logicalModel: 'unic-decision',
303
+ checkpoints: {
304
+ default: 'unic-decision/laya-multilingual',
305
+ english: 'unic-decision/laya',
306
+ unknownLanguage: 'unic-decision/auto',
307
+ },
308
+ timeoutMs: 5000,
309
+ maxStateTokens: { multilingual: 1024, english: 512 },
310
+ maxEnumLabels: 11,
311
+ maxBatchQuestions: 8,
312
+ preflightRequired: true,
313
+ portableReasoningEfforts: ['low', 'medium', 'high', 'max'],
314
+ leaseChoices: [1, 2, 5, 10],
315
+ cachePolicy: {
316
+ decideBeforeLane: true,
317
+ freezeCacheSensitiveFields: true,
318
+ requireVerifiedNativeUpdate: true,
319
+ },
320
+ circuitBreaker: { failureThreshold: 3, cooldownMs: 30_000 },
321
+ // Per-family stage overrides (preflight, route, capability, verify,
322
+ // resume, learn, …) — absence = 'off'; names are declared by the
323
+ // decision registry, not here.
324
+ families: {},
325
+ },
266
326
  // SPEC-model-roles: role→tier map injected into delegating-lane route output.
267
327
  // Values are tier names or 'inherit-parent' — never raw provider model IDs.
268
328
  modelRoles: {
@@ -360,6 +420,25 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
360
420
  proposals: { minCount: 3, minSessions: 2 },
361
421
  episodes: { autoWrite: false },
362
422
  tuning: { enabled: true, applyMode: 'manual' },
423
+ // C52 M04.2 stage keys (SPEC §5 FR-016–FR-018). candidates: repeated
424
+ // corrections/suppressions/escalations promote to a LearningCandidate
425
+ // only after minOccurrences + cross-session evidence; report-only at
426
+ // 'shadow'. overlays: delta overlays on policy fields; base-version
427
+ // mismatch → conflict, never silent apply. Promotion stays manual.
428
+ candidates: { stage: 'off', minOccurrences: 3 },
429
+ overlays: { stage: 'off' },
430
+ },
431
+ // C52 M04.1 compact resumable state (SPEC §5 FR-012–FR-015). Stage 'off'
432
+ // → no resumable-run records written or consumed; resume falls back to
433
+ // the existing reinject path.
434
+ continuity: {
435
+ resumableRun: { stage: 'off' },
436
+ },
437
+ // C52 M06 experiments (SPEC §5 FR-021–FR-023). Disabled by default —
438
+ // zero calls and zero prompt content when off; no auto-promotion.
439
+ experiments: {
440
+ deliberation: { enabled: false },
441
+ dynamicWorkflow: { enabled: false, maxAttemptsPerFailure: 2, noProgressCap: 3 },
363
442
  },
364
443
  safePatch: {
365
444
  enabled: true,
@@ -539,7 +618,6 @@ export function validateRuntimeConfig(config) {
539
618
  if (!isPlainObject(config.routing)) {
540
619
  errors.push('routing must be an object.');
541
620
  } else {
542
- const VALID_ROUTE_STAGES = new Set(['off', 'shadow', 'canary', 'default']);
543
621
  for (const key of ['routeSchema', 'rigor', 'fastPath', 'escalation']) {
544
622
  const stageKey = config.routing[key];
545
623
  if (stageKey === undefined) {
@@ -547,9 +625,133 @@ export function validateRuntimeConfig(config) {
547
625
  }
548
626
  if (!isPlainObject(stageKey)) {
549
627
  errors.push(`routing.${key} must be an object.`);
550
- } else if (stageKey.stage !== undefined
551
- && !VALID_ROUTE_STAGES.has(stageKey.stage)) {
552
- errors.push(`routing.${key}.stage must be one of: ${[...VALID_ROUTE_STAGES].join(', ')}.`);
628
+ } else {
629
+ pushStageError(errors, stageKey, `routing.${key}`);
630
+ }
631
+ }
632
+ }
633
+ }
634
+
635
+ // C52 M07 decisionPlane — optional-present, additive-namespaced. Absent →
636
+ // valid (pre-C52 configs); present → stage keys must hold reserved stages,
637
+ // present fields must be well-typed. families.* accepts arbitrary family
638
+ // names (declared by the decision registry) but every family stage is
639
+ // enum-checked.
640
+ if (config.decisionPlane !== undefined) {
641
+ if (!isPlainObject(config.decisionPlane)) {
642
+ errors.push('decisionPlane must be an object.');
643
+ } else {
644
+ const decisionPlane = config.decisionPlane;
645
+ if (decisionPlane.enabled !== undefined) {
646
+ pushBooleanError(errors, decisionPlane.enabled, 'decisionPlane.enabled');
647
+ }
648
+ pushStageError(errors, decisionPlane, 'decisionPlane');
649
+ if (decisionPlane.timeoutMs !== undefined) {
650
+ pushPositiveNumberError(errors, decisionPlane.timeoutMs, 'decisionPlane.timeoutMs');
651
+ }
652
+ if (decisionPlane.maxEnumLabels !== undefined) {
653
+ pushPositiveNumberError(errors, decisionPlane.maxEnumLabels, 'decisionPlane.maxEnumLabels');
654
+ }
655
+ if (decisionPlane.maxBatchQuestions !== undefined) {
656
+ pushPositiveNumberError(errors, decisionPlane.maxBatchQuestions, 'decisionPlane.maxBatchQuestions');
657
+ }
658
+ if (decisionPlane.preflightRequired !== undefined) {
659
+ pushBooleanError(errors, decisionPlane.preflightRequired, 'decisionPlane.preflightRequired');
660
+ }
661
+ if (decisionPlane.maxStateTokens !== undefined) {
662
+ if (!isPlainObject(decisionPlane.maxStateTokens)) {
663
+ errors.push('decisionPlane.maxStateTokens must be an object.');
664
+ } else {
665
+ pushPositiveNumberError(errors, decisionPlane.maxStateTokens.multilingual, 'decisionPlane.maxStateTokens.multilingual');
666
+ pushPositiveNumberError(errors, decisionPlane.maxStateTokens.english, 'decisionPlane.maxStateTokens.english');
667
+ }
668
+ }
669
+ if (decisionPlane.checkpoints !== undefined) {
670
+ if (!isPlainObject(decisionPlane.checkpoints)) {
671
+ errors.push('decisionPlane.checkpoints must be an object.');
672
+ } else {
673
+ for (const key of ['default', 'english', 'unknownLanguage']) {
674
+ if (decisionPlane.checkpoints[key] !== undefined) {
675
+ pushNonEmptyStringError(errors, decisionPlane.checkpoints[key], `decisionPlane.checkpoints.${key}`);
676
+ }
677
+ }
678
+ }
679
+ }
680
+ if (decisionPlane.leaseChoices !== undefined && !Array.isArray(decisionPlane.leaseChoices)) {
681
+ errors.push('decisionPlane.leaseChoices must be an array.');
682
+ }
683
+ if (decisionPlane.portableReasoningEfforts !== undefined
684
+ && !Array.isArray(decisionPlane.portableReasoningEfforts)) {
685
+ errors.push('decisionPlane.portableReasoningEfforts must be an array.');
686
+ }
687
+ if (decisionPlane.cachePolicy !== undefined && !isPlainObject(decisionPlane.cachePolicy)) {
688
+ errors.push('decisionPlane.cachePolicy must be an object.');
689
+ }
690
+ if (decisionPlane.circuitBreaker !== undefined) {
691
+ if (!isPlainObject(decisionPlane.circuitBreaker)) {
692
+ errors.push('decisionPlane.circuitBreaker must be an object.');
693
+ } else {
694
+ if (decisionPlane.circuitBreaker.failureThreshold !== undefined) {
695
+ pushPositiveNumberError(errors, decisionPlane.circuitBreaker.failureThreshold, 'decisionPlane.circuitBreaker.failureThreshold');
696
+ }
697
+ if (decisionPlane.circuitBreaker.cooldownMs !== undefined) {
698
+ pushPositiveNumberError(errors, decisionPlane.circuitBreaker.cooldownMs, 'decisionPlane.circuitBreaker.cooldownMs');
699
+ }
700
+ }
701
+ }
702
+ if (decisionPlane.families !== undefined) {
703
+ if (!isPlainObject(decisionPlane.families)) {
704
+ errors.push('decisionPlane.families must be an object.');
705
+ } else {
706
+ for (const [family, familyConfig] of Object.entries(decisionPlane.families)) {
707
+ if (!isPlainObject(familyConfig)) {
708
+ errors.push(`decisionPlane.families.${family} must be an object.`);
709
+ } else {
710
+ pushStageError(errors, familyConfig, `decisionPlane.families.${family}`);
711
+ }
712
+ }
713
+ }
714
+ }
715
+ }
716
+ }
717
+
718
+ // C52 M04.1 continuity — optional-present; resumableRun carries one stage key.
719
+ if (config.continuity !== undefined) {
720
+ if (!isPlainObject(config.continuity)) {
721
+ errors.push('continuity must be an object.');
722
+ } else if (config.continuity.resumableRun !== undefined) {
723
+ if (!isPlainObject(config.continuity.resumableRun)) {
724
+ errors.push('continuity.resumableRun must be an object.');
725
+ } else {
726
+ pushStageError(errors, config.continuity.resumableRun, 'continuity.resumableRun');
727
+ }
728
+ }
729
+ }
730
+
731
+ // C52 M06 experiments — optional-present; both experiments are
732
+ // disabled-by-default booleans plus bounded breakers.
733
+ if (config.experiments !== undefined) {
734
+ if (!isPlainObject(config.experiments)) {
735
+ errors.push('experiments must be an object.');
736
+ } else {
737
+ if (config.experiments.deliberation !== undefined) {
738
+ if (!isPlainObject(config.experiments.deliberation)) {
739
+ errors.push('experiments.deliberation must be an object.');
740
+ } else {
741
+ pushBooleanError(errors, config.experiments.deliberation.enabled, 'experiments.deliberation.enabled');
742
+ }
743
+ }
744
+ if (config.experiments.dynamicWorkflow !== undefined) {
745
+ if (!isPlainObject(config.experiments.dynamicWorkflow)) {
746
+ errors.push('experiments.dynamicWorkflow must be an object.');
747
+ } else {
748
+ pushBooleanError(errors, config.experiments.dynamicWorkflow.enabled, 'experiments.dynamicWorkflow.enabled');
749
+ if (config.experiments.dynamicWorkflow.maxAttemptsPerFailure !== undefined) {
750
+ pushPositiveNumberError(errors, config.experiments.dynamicWorkflow.maxAttemptsPerFailure, 'experiments.dynamicWorkflow.maxAttemptsPerFailure');
751
+ }
752
+ if (config.experiments.dynamicWorkflow.noProgressCap !== undefined) {
753
+ pushPositiveNumberError(errors, config.experiments.dynamicWorkflow.noProgressCap, 'experiments.dynamicWorkflow.noProgressCap');
754
+ }
553
755
  }
554
756
  }
555
757
  }
@@ -797,6 +999,24 @@ export function validateRuntimeConfig(config) {
797
999
  }
798
1000
  }
799
1001
  }
1002
+ // C52 M04.2 stage keys — same optional-present contract as routing.*.
1003
+ if (learning.candidates !== undefined) {
1004
+ if (!isPlainObject(learning.candidates)) {
1005
+ errors.push('learning.candidates must be an object.');
1006
+ } else {
1007
+ pushStageError(errors, learning.candidates, 'learning.candidates');
1008
+ if (learning.candidates.minOccurrences !== undefined) {
1009
+ pushPositiveNumberError(errors, learning.candidates.minOccurrences, 'learning.candidates.minOccurrences');
1010
+ }
1011
+ }
1012
+ }
1013
+ if (learning.overlays !== undefined) {
1014
+ if (!isPlainObject(learning.overlays)) {
1015
+ errors.push('learning.overlays must be an object.');
1016
+ } else {
1017
+ pushStageError(errors, learning.overlays, 'learning.overlays');
1018
+ }
1019
+ }
800
1020
  }
801
1021
  }
802
1022
 
@@ -58,8 +58,12 @@ export function scanText(text, options = {}) {
58
58
  if (gateEnabled === false) return clean;
59
59
  if (typeof text !== 'string' || text.length === 0) return clean;
60
60
 
61
+ // sha256() emits lowercase hex; normalize caller/config digests so an
62
+ // uppercase-but-valid allowlist entry cannot silently fail to match.
61
63
  const allowed = new Set(
62
- Array.isArray(allowlistHashes) ? allowlistHashes.filter((h) => typeof h === 'string') : [],
64
+ Array.isArray(allowlistHashes)
65
+ ? allowlistHashes.filter((h) => typeof h === 'string').map((h) => h.toLowerCase())
66
+ : [],
63
67
  );
64
68
 
65
69
  const labels = new Set();
@@ -114,5 +118,9 @@ export function loadSensitiveAllowlist(config) {
114
118
  if (Array.isArray(security.sensitiveDataAllowlist)) {
115
119
  candidates.push(...security.sensitiveDataAllowlist);
116
120
  }
117
- return candidates.filter((v) => typeof v === 'string' && SHA256_HEX_RE.test(v));
121
+ // Normalize to lowercase: sha256() emits lowercase hex, and an
122
+ // uppercase-but-valid config digest must still match (audit D1).
123
+ return candidates
124
+ .filter((v) => typeof v === 'string' && SHA256_HEX_RE.test(v))
125
+ .map((v) => v.toLowerCase());
118
126
  }
@@ -116,24 +116,19 @@ function countTargetFiles(body) {
116
116
  // separator row `|---|-----|...` are both skipped.
117
117
  function countTestCases(body) {
118
118
  if (body == null) return 0;
119
- const lines = body.split('\n');
120
- let count = 0;
121
- let sawHeader = false;
122
- let sawSeparator = false;
123
- for (const line of lines) {
124
- const trimmed = line.trim();
125
- if (!trimmed.startsWith('|')) continue;
126
- if (!sawHeader) {
127
- sawHeader = true;
128
- continue;
129
- }
130
- if (!sawSeparator && /^\|[-\s|]+\|\s*$/.test(trimmed)) {
131
- sawSeparator = true;
132
- continue;
133
- }
134
- count += 1;
119
+ const rows = body
120
+ .split('\n')
121
+ .map((line) => line.trim())
122
+ .filter((line) => line.startsWith('|'));
123
+ const separatorIdx = rows.findIndex((r) => /^\|[-\s|]+\|\s*$/.test(r));
124
+ if (separatorIdx >= 0) {
125
+ // Header row + separator row precede the data rows.
126
+ return rows.length - (separatorIdx + 1);
135
127
  }
136
- return count;
128
+ // No separator row: a leading canonical header (first cell '#') is still a
129
+ // header; every other pipe row is a test case — a headerless table counts
130
+ // every row, never silently dropping the first.
131
+ return rows[0] && /^\|\s*#\s*\|/.test(rows[0]) ? rows.length - 1 : rows.length;
137
132
  }
138
133
 
139
134
  // Extract the first ```bash ... ``` fence under `## Verification Commands` and split
@@ -66,24 +66,74 @@ function sliceSection(markdown, headingRe) {
66
66
  return markdown.slice(start, end);
67
67
  }
68
68
 
69
+ // Target-file entries may carry brace alternation (`src/core/{a,b}.js`) or glob
70
+ // stars (`src/core/compact/*.js`) — planners write both. Expand them so a
71
+ // progress entry naming a covered file is not a false undeclared-drift.
72
+ function escapeRegExp(text) {
73
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
74
+ }
75
+
76
+ function targetPatternToRegExp(pattern) {
77
+ // `{a,b}` → alternation; `**` → any path; `*` → any non-separator run.
78
+ let source = '';
79
+ for (let i = 0; i < pattern.length; i += 1) {
80
+ const ch = pattern[i];
81
+ if (ch === '*') {
82
+ if (pattern[i + 1] === '*') {
83
+ source += '.*';
84
+ i += 1;
85
+ } else {
86
+ source += '[^/]*';
87
+ }
88
+ } else if (ch === '{') {
89
+ const close = pattern.indexOf('}', i + 1);
90
+ if (close === -1) {
91
+ source += '\\{';
92
+ } else {
93
+ source += `(?:${pattern.slice(i + 1, close).split(',').map(escapeRegExp).join('|')})`;
94
+ i = close;
95
+ }
96
+ } else {
97
+ source += escapeRegExp(ch);
98
+ }
99
+ }
100
+ return new RegExp(`^${source}$`);
101
+ }
102
+
69
103
  function collectTargetFiles(markdown) {
70
104
  const section = sliceSection(markdown, TARGET_HEADING);
71
- if (!section) return new Set();
72
105
  const files = new Set();
73
- for (const line of section.split('\n')) {
74
- // Lines look like "- `path/to/file.js` — what changes"
75
- const m = /^\s*-\s+`?([^`\s]+(?:\.[^`\s]+)?)`?/.exec(line);
76
- if (m) files.add(m[1]);
106
+ const patterns = [];
107
+ if (section) {
108
+ for (const line of section.split('\n')) {
109
+ // Lines look like "- `path/to/file.js` — what changes"
110
+ const m = /^\s*-\s+`?([^`\s]+(?:\.[^`\s]+)?)`?/.exec(line);
111
+ if (!m) continue;
112
+ const target = m[1];
113
+ if (/[{*]/.test(target)) patterns.push(targetPatternToRegExp(target));
114
+ else files.add(target);
115
+ }
77
116
  }
78
- return files;
117
+ return {
118
+ has(file) {
119
+ return files.has(file) || patterns.some((re) => re.test(file));
120
+ },
121
+ };
79
122
  }
80
123
 
81
124
  function collectProgressEntries(progressSection) {
82
125
  const entries = [];
83
126
  for (const line of progressSection.split('\n')) {
84
- if (!line.trim().startsWith('- ')) continue;
85
- const parsed = parseEntry(line.trim());
86
- if (parsed) entries.push(parsed);
127
+ const trimmed = line.trim();
128
+ if (!trimmed.startsWith('- ')) continue;
129
+ const parsed = parseEntry(trimmed);
130
+ if (parsed) {
131
+ entries.push(parsed);
132
+ } else if (trimmed.includes('milestone:')) {
133
+ // A bullet that looks like a progress entry but fails the entry grammar is
134
+ // malformed — report it rather than silently dropping it.
135
+ entries.push({ ok: false, raw: trimmed });
136
+ }
87
137
  }
88
138
  return entries;
89
139
  }