@ngockhoale/ukit 3.3.3 → 3.4.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 (89) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/manifests/engineConformance.yaml +17 -1
  3. package/manifests/hostCapabilities.yaml +68 -1
  4. package/manifests/platform.full.yaml +138 -0
  5. package/manifests/platform.user.yaml +255 -3
  6. package/package.json +1 -1
  7. package/scripts/bench/subagent-orchestrator-corpus.mjs +275 -0
  8. package/scripts/bench/subagent-orchestrator-eval.mjs +565 -0
  9. package/scripts/probe/codex-capability-probe.mjs +169 -0
  10. package/src/cli/commands/doctor.js +168 -0
  11. package/src/cli/commands/indexTools.js +7 -0
  12. package/src/cli/commands/metrics.js +66 -2
  13. package/src/cli/commands/playbook.js +4 -4
  14. package/src/cli/commands/vm.js +49 -8
  15. package/src/core/agentRuntime/adapters.js +328 -27
  16. package/src/core/agentRuntime/artifacts.js +89 -0
  17. package/src/core/agentRuntime/context.js +345 -1
  18. package/src/core/agentRuntime/contract.js +296 -0
  19. package/src/core/agentRuntime/eventStore.js +176 -0
  20. package/src/core/agentRuntime/shadowRun.js +481 -5
  21. package/src/core/agentRuntime/telemetry.js +121 -0
  22. package/src/core/observability/emit/lifecycle.js +68 -1
  23. package/src/core/observability/emit/sessionBoot.js +393 -0
  24. package/src/core/observability/privacy/allowlist.js +10 -1
  25. package/src/core/observability/schema/registry.js +10 -0
  26. package/src/core/runtimeConfig.js +133 -0
  27. package/src/core/userPlaybooks.js +18 -3
  28. package/src/decision/registry.js +19 -0
  29. package/src/diagnostics/feedbackEvents.js +7 -4
  30. package/src/diagnostics/routeOutcomes.js +51 -6
  31. package/src/diagnostics/skillAccuracy.js +43 -3
  32. package/src/index/crossCheckMatrix.js +412 -0
  33. package/src/index/fixLoopEscalation.js +453 -0
  34. package/src/index/playbookRegistry.js +691 -0
  35. package/src/index/reviewPolicy.js +368 -0
  36. package/src/index/routeResolver.js +915 -0
  37. package/src/index/sessionHistoryExtractor.js +359 -0
  38. package/src/index/taskRouting.js +764 -581
  39. package/src/index/tierSelection.js +308 -0
  40. package/src/index/verificationMap.js +404 -0
  41. package/template_project/.claude/hooks/observability-emit.mjs +14 -0
  42. package/template_project/.claude/hooks/record-execution.mjs +19 -1
  43. package/template_project/.claude/hooks/skill-router.sh +691 -25
  44. package/template_project/.claude/hooks/verification-guard.sh +230 -1
  45. package/template_project/.claude/settings.json +2 -2
  46. package/template_project/.claude/ukit/index/cross-check-matrix.mjs +415 -0
  47. package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +456 -0
  48. package/template_project/.claude/ukit/index/playbook-registry.mjs +690 -0
  49. package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +20 -2
  50. package/template_project/.claude/ukit/index/review-policy.mjs +376 -0
  51. package/template_project/.claude/ukit/index/route-resolver.mjs +1059 -0
  52. package/template_project/.claude/ukit/index/route-task.mjs +1253 -846
  53. package/template_project/.claude/ukit/index/session-history-extractor.mjs +362 -0
  54. package/template_project/.claude/ukit/index/tier-selection.mjs +309 -0
  55. package/template_project/.claude/ukit/index/verification-map.mjs +403 -0
  56. package/template_project/.claude/ukit/index/worktree-sweep.mjs +195 -0
  57. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +789 -11
  58. package/template_project/.claude/ukit/runtime/observability-emit.mjs +1102 -0
  59. package/template_project/.claude/ukit/runtime/reinject-context.mjs +9 -1
  60. package/template_project/.claude/ukit/runtime/resumable-run.mjs +149 -5
  61. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +323 -6
  62. package/template_project/.codex/README.md +8 -0
  63. package/template_project/.omp/hooks/pre/ukit-bridge.js +8 -1
  64. package/template_project/ukit/README.md +1 -1
  65. package/template_project/ukit/storage/config.json +20 -0
  66. package/template_user/playbooks/architecture-decision.md +28 -0
  67. package/template_user/playbooks/autonomous-run.md +43 -0
  68. package/template_user/playbooks/autopilot-full.md +59 -0
  69. package/template_user/playbooks/autopilot-stack.md +54 -0
  70. package/template_user/playbooks/babysit.md +39 -0
  71. package/template_user/playbooks/bug-fix.md +3 -1
  72. package/template_user/playbooks/{issue-implementation.md → feature-implementation.md} +4 -2
  73. package/template_user/playbooks/hillclimb.md +44 -0
  74. package/template_user/playbooks/investigation.md +21 -0
  75. package/template_user/playbooks/migration.md +21 -0
  76. package/template_user/playbooks/open-pr.md +48 -0
  77. package/template_user/playbooks/orchestrate.md +45 -0
  78. package/template_user/playbooks/performance.md +33 -0
  79. package/template_user/playbooks/prototype.md +28 -0
  80. package/template_user/playbooks/refactor.md +19 -0
  81. package/template_user/playbooks/release.md +28 -0
  82. package/template_user/playbooks/runtime-forensics.md +23 -0
  83. package/template_user/playbooks/session-pickup.md +31 -0
  84. package/template_user/playbooks/shipping.md +53 -0
  85. package/template_user/playbooks/skill-evaluation.md +48 -0
  86. package/template_user/playbooks/small-feature.md +20 -0
  87. package/template_user/playbooks/verification-map.json +153 -0
  88. package/template_user/playbooks/verification.md +22 -0
  89. package/template_user/playbooks/worktree-cleanup.md +37 -0
@@ -217,6 +217,17 @@ export function resolveDecisionRuntimeStage(config = null, key = 'vm') {
217
217
  return resolveConfigStage(config, `decisionRuntime.${key}.stage`);
218
218
  }
219
219
 
220
+ // C89 TASK-002 (SPEC §11): subagentOrchestrator family stage resolver —
221
+ // resolves `subagentOrchestrator.<key>.stage` for one pinned family flag
222
+ // ('telemetry', 'referenceHandoff', 'roleAdvice', 'contextSelection').
223
+ // Same grammar and semantics as resolveDecisionRuntimeStage: absent config,
224
+ // absent family, non-string key, or malformed stage all resolve 'off' — a
225
+ // bad config can never promote a subagentOrchestrator slice.
226
+ export function resolveSubagentOrchestratorStage(config = null, key = 'telemetry') {
227
+ if (typeof key !== 'string' || key.trim() === '') return 'off';
228
+ return resolveConfigStage(config, `subagentOrchestrator.${key}.stage`);
229
+ }
230
+
220
231
  // Pushes a stage-enum error when `node.stage` is present but not reserved.
221
232
  // Absent stage is valid (absence = 'off'); a non-object node is reported by
222
233
  // the caller's own "must be an object" check.
@@ -226,6 +237,73 @@ function pushStageError(errors, node, label) {
226
237
  }
227
238
  }
228
239
 
240
+ // C85 BL-016 (SPEC FR-004): the closed verification-depth enum the crossCheck
241
+ // overrides may tighten/loosen within — kept here (not imported from
242
+ // src/index/crossCheckMatrix.js) so core never depends on the index layer.
243
+ // The parity test pins this set against the matrix module's CROSS_CHECK_DEPTHS.
244
+ export const VALID_CROSS_CHECK_DEPTHS = new Set(['none', 'runnable', 'review-round', 'escalate']);
245
+
246
+ // The only keys a bound leaf may carry; every other key inside crossCheck is
247
+ // rejected so a later milestone cannot smuggle an unbounded knob through the
248
+ // maintainer config.
249
+ const CROSS_CHECK_BOUND_KEYS = new Set(['depth', 'minDepth', 'maxDepth']);
250
+ const CROSS_CHECK_SECTION_KEYS = new Set(['enabled', 'byRole', 'byModel']);
251
+
252
+ // Validates one crossCheck bound leaf or project map. A node carrying any of
253
+ // depth/minDepth/maxDepth IS a bound leaf — its remaining keys must be bound
254
+ // keys too, and every depth value must be in the enum. Otherwise the node is
255
+ // treated as a {project|'*' → bound} map and each value is re-validated.
256
+ function validateCrossCheckBound(errors, node, label) {
257
+ if (!isPlainObject(node)) {
258
+ errors.push(`${label} must be an object.`);
259
+ return;
260
+ }
261
+ const carriesBoundKey = Object.keys(node).some((key) => CROSS_CHECK_BOUND_KEYS.has(key));
262
+ if (carriesBoundKey) {
263
+ for (const [key, value] of Object.entries(node)) {
264
+ if (!CROSS_CHECK_BOUND_KEYS.has(key)) {
265
+ errors.push(`crossCheck.${label}.${key} is not a supported override key (expected depth, minDepth, maxDepth).`);
266
+ } else if (!VALID_CROSS_CHECK_DEPTHS.has(value)) {
267
+ errors.push(`crossCheck.${label}.${key} must be one of: ${[...VALID_CROSS_CHECK_DEPTHS].join(', ')}.`);
268
+ }
269
+ }
270
+ return;
271
+ }
272
+ for (const [project, leaf] of Object.entries(node)) {
273
+ validateCrossCheckBound(errors, leaf, `${label}.${project}`);
274
+ }
275
+ }
276
+
277
+ // crossCheck section: maintainer-level bounded overrides, shipped ON.
278
+ // Optional-present like routing.* — absent → valid (pre-C85 configs); present
279
+ // → only the reserved keys, enum-constrained depth bounds, no freeform knobs.
280
+ function validateCrossCheck(errors, crossCheck) {
281
+ if (crossCheck === undefined) return;
282
+ if (!isPlainObject(crossCheck)) {
283
+ errors.push('crossCheck must be an object.');
284
+ return;
285
+ }
286
+ for (const key of Object.keys(crossCheck)) {
287
+ if (!CROSS_CHECK_SECTION_KEYS.has(key)) {
288
+ errors.push(`crossCheck.${key} is not a supported key (expected enabled, byRole, byModel).`);
289
+ }
290
+ }
291
+ if (crossCheck.enabled !== undefined) {
292
+ pushBooleanError(errors, crossCheck.enabled, 'crossCheck.enabled');
293
+ }
294
+ for (const mapKey of ['byRole', 'byModel']) {
295
+ const map = crossCheck[mapKey];
296
+ if (map === undefined) continue;
297
+ if (!isPlainObject(map)) {
298
+ errors.push(`crossCheck.${mapKey} must be an object.`);
299
+ continue;
300
+ }
301
+ for (const [name, entry] of Object.entries(map)) {
302
+ validateCrossCheckBound(errors, entry, `${mapKey}.${name}`);
303
+ }
304
+ }
305
+ }
306
+
229
307
  export function buildDefaultRuntimeConfig(overrides = {}) {
230
308
  const safeOverrides = isPlainObject(overrides) ? overrides : {};
231
309
 
@@ -309,6 +387,17 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
309
387
  fastPath: { stage: 'default' },
310
388
  escalation: { stage: 'default' },
311
389
  },
390
+ // C85 BL-016 (SPEC FR-004): adaptive cross-check depth overrides.
391
+ // Maintainer-level config only — no user-facing settings. Bounded
392
+ // {role|model}×{project} overrides may tighten/loosen the resolved
393
+ // verification depth within the closed enum (none | runnable |
394
+ // review-round | escalate); the depth matrix stays the resolver.
395
+ // Absent/empty maps mean "matrix defaults apply" — the feature ships ON.
396
+ crossCheck: {
397
+ enabled: true,
398
+ byRole: {},
399
+ byModel: {},
400
+ },
312
401
  // C52 M07: `unic-decision` is the owner's local non-LLM Lava/JEV model in
313
402
  // UNIC Provider. Its OpenAI-compatible API is transport only, not proof of
314
403
  // remote hosting or generative-LLM billing. Preferred for every bounded
@@ -376,7 +465,14 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
376
465
  },
377
466
  escalation: {
378
467
  enabled: true,
468
+ // C85 BL-018: fix-loop repeat counting joins on routeFingerprint/
469
+ // requestKey (historySignals.fixLoopCount) — never session-wide
470
+ // counters (GAP M12). At debugLoopThreshold the lane deepens through
471
+ // the laneDeepening/verificationDepth decision; laneDeepening:false
472
+ // keeps the tier-bump ladder only. debugLoopThreshold is the shared
473
+ // threshold for tier-bump AND lane escalation.
379
474
  debugLoopThreshold: 2,
475
+ laneDeepening: true,
380
476
  tierOrder: ['lite', 'code', 'smart'],
381
477
  cap: 'smart',
382
478
  },
@@ -499,6 +595,19 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
499
595
  diagnostics: { stage: 'default' },
500
596
  },
501
597
 
598
+ // C89 TASK-002 (SPEC §11): subagentOrchestrator family — pinned keys
599
+ // `telemetry` (child-run usage provenance records), `referenceHandoff`
600
+ // (contract/result-envelope handoff), `roleAdvice` (typed delegation
601
+ // shadow) and `contextSelection` (context manifest). All ship 'off':
602
+ // telemetry is opt-in shadow observation and the other three gates are
603
+ // behavior changes — a bad or absent value can never promote a slice.
604
+ subagentOrchestrator: {
605
+ telemetry: { stage: 'off' },
606
+ referenceHandoff: { stage: 'off' },
607
+ roleAdvice: { stage: 'off' },
608
+ contextSelection: { stage: 'off' },
609
+ },
610
+
502
611
  // C52 M06 experiments (SPEC §5 FR-021–FR-023). 3.3.0 zero-config: enabled;
503
612
  // each experiment still runs only where a caller invokes it and never
504
613
  // auto-promotes its own result.
@@ -688,6 +797,10 @@ export function validateRuntimeConfig(config) {
688
797
  }
689
798
  }
690
799
 
800
+ // C85 BL-016: crossCheck bounded overrides — optional-present, schema
801
+ // bounded to the closed depth enum (VALID_CROSS_CHECK_DEPTHS).
802
+ validateCrossCheck(errors, config.crossCheck);
803
+
691
804
  // routing.* stage keys are optional-present and additive-namespaced: absent → valid
692
805
  // (pre-M01.1 configs); present → each known stage key must hold a reserved stage.
693
806
  // Unknown siblings under routing.* stay valid so later milestones can add stages
@@ -829,6 +942,26 @@ export function validateRuntimeConfig(config) {
829
942
  }
830
943
  }
831
944
  }
945
+
946
+ // C89 TASK-002 (SPEC §11) subagentOrchestrator — optional-present; one
947
+ // stage key per pinned slice (subagentOrchestrator.telemetry,
948
+ // .referenceHandoff, .roleAdvice, .contextSelection). Absent → valid;
949
+ // malformed stage → error + resolver falls back to 'off'.
950
+ if (config.subagentOrchestrator !== undefined) {
951
+ if (!isPlainObject(config.subagentOrchestrator)) {
952
+ errors.push('subagentOrchestrator must be an object.');
953
+ } else {
954
+ for (const key of ['telemetry', 'referenceHandoff', 'roleAdvice', 'contextSelection']) {
955
+ const stageKey = config.subagentOrchestrator[key];
956
+ if (stageKey === undefined) continue;
957
+ if (!isPlainObject(stageKey)) {
958
+ errors.push(`subagentOrchestrator.${key} must be an object.`);
959
+ } else {
960
+ pushStageError(errors, stageKey, `subagentOrchestrator.${key}`);
961
+ }
962
+ }
963
+ }
964
+ }
832
965
  // C52 M06 experiments — optional-present; both experiments are
833
966
  // disabled-by-default booleans plus bounded breakers.
834
967
  if (config.experiments !== undefined) {
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
3
4
 
4
5
  import { buildUserPaths } from './userPaths.js';
5
6
  // Cycle note: taskRouting.js (TASK-007) imports resolvePlaybook from this module,
@@ -8,6 +9,14 @@ import { buildUserPaths } from './userPaths.js';
8
9
  // time — never at module top-level — so the live binding is always initialized.
9
10
  import { WORKFLOW_POLICIES, WORKFLOW_POLICY_BY_MODE } from '../index/taskRouting.js';
10
11
 
12
+ // TASK-007 (BL-009): playbooks also ship as files under the package's
13
+ // template_user/playbooks — the install pipeline seeds them into ~/.ukit, but
14
+ // a dev checkout or a pre-seed install should resolve them as builtin too.
15
+ const PACKAGE_BUILTIN_PLAYBOOK_DIR = path.resolve(
16
+ path.dirname(fileURLToPath(import.meta.url)),
17
+ '..', '..', 'template_user', 'playbooks',
18
+ );
19
+
11
20
  // Playbook file format (SPEC FR-009): `playbooks/<id>.md` with optional
12
21
  // frontmatter `---\nid: <id>\nlanes: [a, b]\n---` followed by the markdown body.
13
22
  // Missing `id` → filename stem; missing `lanes` → []. A file whose body is empty
@@ -123,7 +132,7 @@ function isSafePolicyName(policyName) {
123
132
 
124
133
  export async function resolvePlaybook(
125
134
  policyName,
126
- { projectRoot, homeDir, builtins = WORKFLOW_POLICIES } = {},
135
+ { projectRoot, homeDir, builtins = WORKFLOW_POLICIES, builtinDir = PACKAGE_BUILTIN_PLAYBOOK_DIR } = {},
127
136
  ) {
128
137
  if (!isSafePolicyName(policyName)) {
129
138
  return null;
@@ -139,6 +148,11 @@ export async function resolvePlaybook(
139
148
  dir: buildUserPaths({ homeDir }).playbooksDir,
140
149
  source: 'user',
141
150
  });
151
+ // Packaged template_user files sit between the user tier and the inline
152
+ // builtin table — a dev checkout resolves them the same as a seeded install.
153
+ if (builtinDir) {
154
+ candidates.push({ dir: builtinDir, source: 'builtin' });
155
+ }
142
156
  for (const { dir, source } of candidates) {
143
157
  const playbook = await readPlaybookFile(path.join(dir, `${policyName}.md`));
144
158
  if (playbook !== null) {
@@ -153,7 +167,7 @@ export async function resolvePlaybook(
153
167
  }
154
168
 
155
169
  export async function listPlaybooks(
156
- { projectRoot, homeDir, builtins = WORKFLOW_POLICIES } = {},
170
+ { projectRoot, homeDir, builtins = WORKFLOW_POLICIES, builtinDir = PACKAGE_BUILTIN_PLAYBOOK_DIR } = {},
157
171
  ) {
158
172
  // Builtin lanes are derived from the shipped lane→policy map (inverse of
159
173
  // WORKFLOW_POLICY_BY_MODE); injected builtins not in that map list no lanes.
@@ -167,6 +181,7 @@ export async function listPlaybooks(
167
181
  }
168
182
  const userDir = buildUserPaths({ homeDir }).playbooksDir;
169
183
  for (const [dir, source] of [
184
+ [builtinDir || null, 'builtin'],
170
185
  [userDir, 'user'],
171
186
  [projectRoot ? path.join(projectRoot, '.ukit', 'playbooks') : null, 'project'],
172
187
  ]) {
@@ -177,7 +192,7 @@ export async function listPlaybooks(
177
192
  merged.set(playbook.id, {
178
193
  id: playbook.id,
179
194
  source,
180
- lanes: playbook.lanes,
195
+ lanes: playbook.lanes.length > 0 ? playbook.lanes : (builtinLanes[playbook.id] ?? playbook.lanes),
181
196
  });
182
197
  }
183
198
  }
@@ -189,6 +189,25 @@ export const DECISION_REGISTRY = Object.freeze([
189
189
  cacheSensitivity: 'none',
190
190
  leasePolicy: 'none',
191
191
  },
192
+ {
193
+ decisionKey: 'route.delegation-role.v1',
194
+ schemaVersion: 1,
195
+ family: 'route',
196
+ owner: 'taskRouting',
197
+ kind: 'choice',
198
+ // C89 TASK-005 (SPEC §6 FR-04): owner-bounded role pick inside the typed
199
+ // delegation advice shadow — the model may only answer from the
200
+ // deterministic shortlist the route already derived.
201
+ description: 'Delegation role for a delegated lane: retriever | reasoner | reviewer.',
202
+ candidatePolicy: 'owner-shortlist',
203
+ hardConstraints: ['lane-eligibility', 'risk-floor'],
204
+ probabilityPolicy: 'raw-label',
205
+ fallbackPolicy: 'deterministic-route',
206
+ telemetryClass: 'decision',
207
+ rolloutStage: 'off',
208
+ cacheSensitivity: 'none',
209
+ leasePolicy: 'none',
210
+ },
192
211
  {
193
212
  decisionKey: 'route.rigor.v1',
194
213
  schemaVersion: 1,
@@ -141,6 +141,7 @@ export async function collectFeedbackEvents(projectRoot, { limitLedgers = 500, m
141
141
  const base = {
142
142
  ts: typeof entry.ts === 'number' ? entry.ts : 0,
143
143
  requestKey: typeof entry.requestKey === 'string' ? entry.requestKey : undefined,
144
+ routeFingerprint: typeof entry.routeFingerprint === 'string' ? entry.routeFingerprint : undefined,
144
145
  targetFile: typeof entry.targetFile === 'string' ? entry.targetFile : undefined,
145
146
  taskType: typeof entry.taskType === 'string' ? entry.taskType : undefined,
146
147
  executionMode: typeof entry.executionMode === 'string' ? entry.executionMode : undefined,
@@ -159,13 +160,14 @@ export async function collectFeedbackEvents(projectRoot, { limitLedgers = 500, m
159
160
  }
160
161
 
161
162
  // re-route: audit is newest-first — entry i is the later entry, i+1 the
162
- // previous one for the same promptFingerprint + targetFile.
163
+ // previous one for the same routeFingerprint (BL-018: the fingerprint
164
+ // join, not promptFingerprint + targetFile — same-symptom detection keys
165
+ // on routeFingerprint/requestKey, never session-wide counters).
163
166
  for (let i = 0; i < auditEntries.length - 1; i += 1) {
164
167
  const later = auditEntries[i];
165
168
  const prev = auditEntries[i + 1];
166
- if (typeof later.promptFingerprint !== 'string' || !later.promptFingerprint) continue;
167
- if (later.promptFingerprint !== prev.promptFingerprint) continue;
168
- if (later.targetFile !== prev.targetFile) continue;
169
+ if (typeof later.routeFingerprint !== 'string' || !later.routeFingerprint) continue;
170
+ if (later.routeFingerprint !== prev.routeFingerprint) continue;
169
171
  if (later.wideningBlocked === true) continue;
170
172
  const prevMode = prev.executionMode;
171
173
  const newMode = later.executionMode;
@@ -174,6 +176,7 @@ export async function collectFeedbackEvents(projectRoot, { limitLedgers = 500, m
174
176
  ts: typeof later.ts === 'number' ? later.ts : 0,
175
177
  kind: 're-route',
176
178
  requestKey: typeof later.requestKey === 'string' ? later.requestKey : undefined,
179
+ routeFingerprint: later.routeFingerprint,
177
180
  targetFile: typeof later.targetFile === 'string' ? later.targetFile : undefined,
178
181
  taskType: typeof later.taskType === 'string' ? later.taskType : undefined,
179
182
  executionMode: typeof later.executionMode === 'string' ? later.executionMode : undefined,
@@ -1,5 +1,6 @@
1
- // Route-outcome join (SPEC §4, SI-102): joins route-audit.json entries to
2
- // per-request exec-ledger ledgers on requestKey and aggregates outcome stats
1
+ // Route-outcome join (SPEC §4, SI-102): joins route-audit.json entries —
2
+ // plus rows spilled past the ring cap to route-audit.segments.jsonl (BL-004) —
3
+ // to per-request exec-ledger ledgers on requestKey and aggregates outcome stats
3
4
  // per executionMode / taskType. Read-only; never throws.
4
5
 
5
6
  import fs from 'node:fs/promises';
@@ -14,10 +15,13 @@ function zeroedResult(extra = {}) {
14
15
  ledgersScanned: 0,
15
16
  auditRowsScanned: 0,
16
17
  joined: 0,
17
- unmatchedAudit: 0,
18
- unmatchedLedger: 0,
19
18
  byMode: {},
20
19
  byTaskType: {},
20
+ // BL-019 (SPEC FR-001): additive per-(mode,tier) success-rate buckets —
21
+ // "<mode>|<tier>" keys beside byMode/byTaskType — plus the row-level join
22
+ // for deriveTierStats (the measured tier map consumes it).
23
+ byModeTier: {},
24
+ joinedRows: [],
21
25
  joinCoverage: 0,
22
26
  ...extra,
23
27
  };
@@ -45,6 +49,29 @@ async function readJson(filePath) {
45
49
  }
46
50
  }
47
51
 
52
+ // BL-004: evicted ring rows spill to this append-only JSONL sidecar — same row
53
+ // shape as ring entries; malformed lines are skipped like parseSidecarLines.
54
+ const SEGMENTS_REL = 'route-audit.segments.jsonl';
55
+
56
+ async function readJsonLines(filePath) {
57
+ const rows = [];
58
+ try {
59
+ const raw = await fs.readFile(filePath, 'utf8');
60
+ for (const line of raw.split('\n')) {
61
+ if (!line.trim()) continue;
62
+ try {
63
+ const item = JSON.parse(line);
64
+ if (item && typeof item === 'object' && !Array.isArray(item)) rows.push(item);
65
+ } catch {
66
+ // skip malformed segment lines
67
+ }
68
+ }
69
+ } catch {
70
+ // missing/unreadable sidecar → no spilled rows
71
+ }
72
+ return rows;
73
+ }
74
+
48
75
  function bump(bucket, ledger, auditEntry) {
49
76
  bucket.joined += 1;
50
77
  if (ledger.writeSucceeded === true) bucket.writeOk += 1;
@@ -61,6 +88,9 @@ function bump(bucket, ledger, auditEntry) {
61
88
  export async function collectRouteOutcomes(projectRoot, { limitLedgers = 500 } = {}) {
62
89
  try {
63
90
  const cacheDir = path.join(projectRoot, '.ukit', 'storage', 'cache');
91
+ // BL-004: rows evicted past the 40-entry ring cap spill to the segments
92
+ // sidecar — they must still reach the join or coverage silently decays.
93
+ const segmentEntries = await readJsonLines(path.join(cacheDir, SEGMENTS_REL));
64
94
  const auditDoc = await readJson(path.join(cacheDir, 'route-audit.json'));
65
95
  const auditEntries = Array.isArray(auditDoc?.entries) ? auditDoc.entries : [];
66
96
 
@@ -74,10 +104,20 @@ export async function collectRouteOutcomes(projectRoot, { limitLedgers = 500 } =
74
104
 
75
105
  const result = zeroedResult();
76
106
  result.ledgersScanned = ledgers.length;
77
- result.auditRowsScanned = auditEntries.length;
107
+ result.auditRowsScanned = auditEntries.length + segmentEntries.length;
78
108
 
79
109
  const auditByKey = new Map();
80
- for (const entry of auditEntries) {
110
+ // Dedupe on requestKey, newest wins — last `set` wins, so scan oldest→
111
+ // newest: segments are appended oldest→newest (forward), the ring is
112
+ // stored newest-first (reverse it), and ring rows always post-date
113
+ // their spilled twins so the ring is scanned last.
114
+ for (const entry of segmentEntries) {
115
+ if (entry && typeof entry.requestKey === 'string' && entry.requestKey) {
116
+ auditByKey.set(entry.requestKey, entry);
117
+ }
118
+ }
119
+ for (let i = auditEntries.length - 1; i >= 0; i -= 1) {
120
+ const entry = auditEntries[i];
81
121
  if (entry && typeof entry.requestKey === 'string' && entry.requestKey) {
82
122
  auditByKey.set(entry.requestKey, entry);
83
123
  }
@@ -94,13 +134,18 @@ export async function collectRouteOutcomes(projectRoot, { limitLedgers = 500 } =
94
134
  const taskKey = entry.taskType ?? UNCLASSIFIED;
95
135
  const modeBucket = result.byMode[modeKey] ??= emptyBucket();
96
136
  const taskBucket = result.byTaskType[taskKey] ??= emptyBucket();
137
+ const modeTierBucket = result.byModeTier[`${modeKey}|${entry.modelTier ?? UNCLASSIFIED}`]
138
+ ??= emptyBucket();
97
139
  modeBucket.routes += 1;
98
140
  taskBucket.routes += 1;
141
+ modeTierBucket.routes += 1;
99
142
  const ledger = ledgers.find((candidate) => candidate.requestKey === entry.requestKey);
100
143
  if (!ledger) continue;
101
144
  result.joined += 1;
145
+ result.joinedRows.push({ audit: entry, ledger });
102
146
  bump(modeBucket, ledger, entry);
103
147
  bump(taskBucket, ledger, entry);
148
+ bump(modeTierBucket, ledger, entry);
104
149
  }
105
150
 
106
151
  result.unmatchedAudit = auditByKey.size - result.joined;
@@ -21,6 +21,9 @@ import { listLedgerFiles, LEDGER_DIR_REL } from './ledgerFiles.js';
21
21
 
22
22
  const CACHE_DIR_REL = path.join('.ukit', 'storage', 'cache');
23
23
  const AUDIT_REL = path.join(CACHE_DIR_REL, 'route-audit.json');
24
+ // BL-004: rows evicted past the 40-entry ring cap spill to this append-only
25
+ // JSONL sidecar — same row shape; malformed lines skipped like parseSidecarLines.
26
+ const SEGMENTS_REL = path.join(CACHE_DIR_REL, 'route-audit.segments.jsonl');
24
27
  const ARTIFACT_REL = path.join('.ukit', 'storage', 'learning', 'skill-accuracy.json');
25
28
  const CONFIG_REL = path.join('.ukit', 'storage', 'config.json');
26
29
  const MAX_SKILL_IDS = 8;
@@ -62,6 +65,25 @@ async function readJson(filePath) {
62
65
  }
63
66
  }
64
67
 
68
+ async function readJsonLines(filePath) {
69
+ const rows = [];
70
+ try {
71
+ const raw = await fs.readFile(filePath, 'utf8');
72
+ for (const line of raw.split('\n')) {
73
+ if (!line.trim()) continue;
74
+ try {
75
+ const item = JSON.parse(line);
76
+ if (item && typeof item === 'object') rows.push(item);
77
+ } catch {
78
+ // skip malformed segment lines
79
+ }
80
+ }
81
+ } catch {
82
+ // missing/unreadable sidecar → no spilled rows
83
+ }
84
+ return rows;
85
+ }
86
+
65
87
  // Default true when `learning`/`feedback` is absent (namespace lands in TASK-232).
66
88
  async function feedbackEnabled(projectRoot) {
67
89
  const config = await readJson(path.join(projectRoot, CONFIG_REL));
@@ -106,8 +128,26 @@ export async function collectSkillAccuracy(projectRoot, { limitLedgers = 500 } =
106
128
  const auditEntries = Array.isArray(auditDoc?.entries)
107
129
  ? auditDoc.entries.filter(isObject)
108
130
  : [];
109
- result.auditRowsScanned = auditEntries.length;
110
-
131
+ // BL-004: rows evicted past the ring cap live in the segments sidecar —
132
+ // they still carry skillIds and must reach the join.
133
+ const segmentEntries = (await readJsonLines(path.join(projectRoot, SEGMENTS_REL)))
134
+ .filter(isObject);
135
+ result.auditRowsScanned = auditEntries.length + segmentEntries.length;
136
+ // Dedupe on requestKey (newest wins, per collectRouteOutcomes): last write
137
+ // wins, so scan oldest→newest — segments append oldest→newest (forward),
138
+ // the ring stores newest-first (reversed), and ring rows post-date their
139
+ // spilled twins so the ring is scanned last. Unkeyed rows keep today's
140
+ // pass-through semantics.
141
+ const auditByKey = new Map();
142
+ const unkeyedEntries = [];
143
+ for (const entry of [...segmentEntries, ...auditEntries.slice().reverse()]) {
144
+ if (typeof entry.requestKey === 'string' && entry.requestKey) {
145
+ auditByKey.set(entry.requestKey, entry);
146
+ } else {
147
+ unkeyedEntries.push(entry);
148
+ }
149
+ }
150
+ const dedupedEntries = [...auditByKey.values(), ...unkeyedEntries];
111
151
  const ledgerDir = path.join(projectRoot, LEDGER_DIR_REL);
112
152
  const ledgerNames = await listLedgerFiles(ledgerDir, limitLedgers);
113
153
  const ledgerByKey = new Map();
@@ -120,7 +160,7 @@ export async function collectSkillAccuracy(projectRoot, { limitLedgers = 500 } =
120
160
  }
121
161
  }
122
162
 
123
- for (const entry of auditEntries) {
163
+ for (const entry of dedupedEntries) {
124
164
  const ids = skillIdsOf(entry);
125
165
  if (ids.length === 0) continue;
126
166
  const ledger = typeof entry.requestKey === 'string'