@ngockhoale/ukit 3.0.12 → 3.1.1

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 (66) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +1 -0
  3. package/manifests/documentation.yaml +12 -0
  4. package/manifests/platform.full.yaml +24 -0
  5. package/package.json +1 -1
  6. package/scripts/bench/data-foundation.mjs +368 -50
  7. package/src/cli/commands/doctor.js +232 -3
  8. package/src/cli/commands/feedback.js +64 -1
  9. package/src/cli/commands/install.js +18 -0
  10. package/src/cli/commands/memory.js +42 -37
  11. package/src/cli/commands/telemetry.js +460 -0
  12. package/src/cli/index.js +7 -0
  13. package/src/core/agentRuntime/adapters.js +83 -2
  14. package/src/core/agentRuntime/diagnostics.js +104 -0
  15. package/src/core/agentRuntime/supervisor.js +137 -0
  16. package/src/core/agentRuntime/telemetry.js +204 -0
  17. package/src/core/memory/memoryEmit.js +131 -0
  18. package/src/core/memory/memoryHit.js +1 -1
  19. package/src/core/memory/migrate.js +18 -11
  20. package/src/core/memory/migrateMapping.js +15 -7
  21. package/src/core/memory/mutateMemory.js +22 -4
  22. package/src/core/memory/recordIndex.js +10 -3
  23. package/src/core/memory/recordStore.js +28 -3
  24. package/src/core/memory/retrieval.js +79 -38
  25. package/src/core/memory/store.js +37 -37
  26. package/src/core/memory/storeV2.js +28 -25
  27. package/src/core/memory/storeV2Loader.js +2 -2
  28. package/src/core/observability/adapters/ingest.js +576 -0
  29. package/src/core/observability/analytics/anomalies.js +415 -0
  30. package/src/core/observability/analytics/summary.js +16 -1
  31. package/src/core/observability/emit/config.js +69 -1
  32. package/src/core/observability/emit/crash.js +434 -0
  33. package/src/core/observability/emit/lifecycle.js +349 -0
  34. package/src/core/observability/emit/recorder.js +135 -9
  35. package/src/core/observability/evaluation/aiPacket.js +52 -10
  36. package/src/core/observability/evaluation/outcomes.js +95 -0
  37. package/src/core/observability/evaluation/runner.js +225 -0
  38. package/src/core/observability/privacy/allowlist.js +23 -3
  39. package/src/core/observability/schema/compatibility.js +48 -3
  40. package/src/core/observability/schema/constants.js +5 -0
  41. package/src/core/observability/schema/registry.js +57 -0
  42. package/src/core/observability/schema/validate.js +68 -6
  43. package/src/core/observability/segments/internal.js +42 -8
  44. package/src/core/observability/segments/readSegments.js +35 -1
  45. package/src/core/observability/segments/recovery.js +3 -2
  46. package/src/core/observability/segments/retention.js +137 -33
  47. package/src/core/observability/support/projector.js +88 -18
  48. package/src/core/observability/support/provision.js +160 -0
  49. package/src/core/observability/support/renderer.js +2 -2
  50. package/src/core/observability/support/schedule.js +174 -0
  51. package/src/decision/registry.js +144 -0
  52. package/src/decision/reviewVerdict.js +309 -0
  53. package/template_project/.claude/agents/code-reviewer.md +25 -1
  54. package/template_project/.claude/agents/ukit-small-task-maintainer.md +16 -0
  55. package/template_project/.claude/commands/ukit/handoff-fullstack.md +2 -0
  56. package/template_project/.claude/commands/ukit/handoff-review.md +12 -0
  57. package/template_project/.claude/hooks/auto-allow-bash.sh +7 -1
  58. package/template_project/.claude/hooks/auto-prune-bash.sh +16 -7
  59. package/template_project/.claude/hooks/verification-guard.sh +13 -4
  60. package/template_project/.claude/ukit/index/review-verdict.mjs +592 -0
  61. package/template_project/.claude/ukit/index/sidecar-decision.mjs +595 -0
  62. package/template_project/.claude/ukit/index/unic-decision.mjs +10 -1
  63. package/template_project/.claude/ukit/runtime/async-lock.mjs +26 -0
  64. package/template_project/.codex/settings.json +3 -0
  65. package/template_project/.omp/agents/code-reviewer.md +25 -1
  66. package/template_project/.omp/agents/ukit-small-task-maintainer.md +16 -0
@@ -32,6 +32,22 @@ import { buildUserPaths } from '../../core/userPaths.js';
32
32
  import { loadPlaybooks } from '../../core/userPlaybooks.js';
33
33
  import { userMemoryStats } from '../../core/memory/userMemory.js';
34
34
 
35
+ import { resolveSeamStages } from '../../core/observability/rollout.js';
36
+ import {
37
+ observabilityRoot,
38
+ segmentsRoot,
39
+ } from '../../core/observability/emit/lifecycle.js';
40
+ import { readCrashReports } from '../../core/observability/emit/crash.js';
41
+ import {
42
+ listSegments,
43
+ totalSegmentBytes,
44
+ ioReason,
45
+ } from '../../core/observability/segments/internal.js';
46
+ import {
47
+ lastProjectionStampPath,
48
+ MIN_PROJECTION_INTERVAL_MS,
49
+ } from '../../core/observability/support/schedule.js';
50
+
35
51
  export const DOCTOR_HELP_FLAGS = new Set(['--help', '-h']);
36
52
  const KNOWN_FLAGS = new Set([...DOCTOR_HELP_FLAGS, '--skills', '--gateway', '--docs', '--permissions', '--conformance', '--json']);
37
53
  const SUPPORTED_FLAGS_LIST = '--help, -h, --skills, --gateway, --docs, --permissions, --conformance, --json';
@@ -73,6 +89,193 @@ function printPermissionSection(permissionReport, { verbose }) {
73
89
  }
74
90
  }
75
91
 
92
+ // TASK-016 / SPEC §5 DF2-FR16 — flight-recorder health, read COLD from disk.
93
+ // Doctor must never spawn a live recorder (no getRecorder/recorderHealth here —
94
+ // lifecycle.js is imported for its path helpers only), never write, rotate, or
95
+ // queue. Every disk-derived value is `unknown` when unavailable — never a
96
+ // fabricated zero. Queue/drop counters live only in the live recorder, so a
97
+ // cold read honestly reports them as unknown. Severity: FAIL-class faults are
98
+ // reserved for hard faults (segment root unwritable while the recorder seam is
99
+ // active); staleness/degraded reads are WARN-class advisories.
100
+
101
+ const FLIGHT_FAIL_REMEDY =
102
+ 'Fix permissions on .ukit/storage/observability/segments (or an ancestor) so the recorder can write, or set observability.stage to "off".';
103
+
104
+ // Writability probe that never creates anything: when the segment root itself
105
+ // is absent the recorder would create it lazily, so walk up to the nearest
106
+ // existing ancestor and probe that for W_OK instead.
107
+ async function probeWritableSegmentsRoot(segRoot) {
108
+ let probe = segRoot;
109
+ for (;;) {
110
+ try {
111
+ await fs.access(probe, fs.constants.W_OK);
112
+ return { writable: true };
113
+ } catch (error) {
114
+ if (error?.code === 'ENOENT') {
115
+ const parent = path.dirname(probe);
116
+ if (parent === probe) return { writable: false, reason: 'io_enoent' };
117
+ probe = parent;
118
+ continue;
119
+ }
120
+ return { writable: false, reason: ioReason(error) };
121
+ }
122
+ }
123
+ }
124
+
125
+ // Scheduler stamp (`ukit-last-projection/1`) read defensively — a missing or
126
+ // corrupt stamp degrades to null, matching schedule.js's own readStamp.
127
+ async function readProjectionStampAt(stampPath) {
128
+ try {
129
+ const parsed = JSON.parse(await fs.readFile(stampPath, 'utf8'));
130
+ const at = parsed && typeof parsed === 'object' ? parsed.projected_at : undefined;
131
+ return Number.isFinite(at) ? at : null;
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Read-only snapshot of the flight recorder's durable state.
139
+ * @returns {{stage:string, seams:object, segments:object, queue:object,
140
+ * lastProjection:{lagMs:number|null}, crashes:object, faults:object[]}}
141
+ */
142
+ async function inspectFlightRecorderDisk({ projectRoot, config }) {
143
+ const seams = resolveSeamStages(config);
144
+ const fr = {
145
+ stage: seams?.recorder ?? 'off',
146
+ seams,
147
+ segments: { active: null, sealed: null, bytes: null },
148
+ queue: { depth: null, dropped: null },
149
+ lastProjection: { lagMs: null },
150
+ crashes: { count: null },
151
+ faults: [],
152
+ };
153
+
154
+ const obsRoot = observabilityRoot(projectRoot);
155
+ if (!(await pathExists(obsRoot))) {
156
+ return fr; // never recorded — all disk fields stay unknown
157
+ }
158
+
159
+ const segRoot = segmentsRoot(projectRoot);
160
+ const listing = await listSegments(segRoot);
161
+ if (listing.ok) {
162
+ fr.segments = {
163
+ active: listing.active ? 1 : 0,
164
+ sealed: listing.sealed.length,
165
+ bytes: totalSegmentBytes(listing),
166
+ };
167
+ if (listing.skipped.length > 0) {
168
+ const skippedNames = listing.skipped.map((entry) => entry.name).join(', ');
169
+ fr.faults.push({
170
+ severity: 'warn',
171
+ reason: 'segments_degraded',
172
+ detail: `${listing.skipped.length} segment file(s) skipped: ${skippedNames}`,
173
+ });
174
+ }
175
+ } else {
176
+ fr.faults.push({
177
+ severity: 'warn',
178
+ reason: 'segments_unreadable',
179
+ detail: listing.reason,
180
+ });
181
+ }
182
+
183
+ if (fr.stage !== 'off') {
184
+ const probe = await probeWritableSegmentsRoot(segRoot);
185
+ if (!probe.writable) {
186
+ fr.faults.push({
187
+ severity: 'fail',
188
+ reason: 'segments_root_unwritable',
189
+ detail: `${segRoot} not writable (${probe.reason})`,
190
+ });
191
+ }
192
+ }
193
+
194
+ const stampAt = await readProjectionStampAt(lastProjectionStampPath(projectRoot));
195
+ if (stampAt !== null) {
196
+ const lagMs = Math.max(0, Date.now() - stampAt);
197
+ fr.lastProjection = { projectedAt: stampAt, lagMs };
198
+ if ((seams?.projector ?? 'off') !== 'off' && lagMs > MIN_PROJECTION_INTERVAL_MS) {
199
+ fr.faults.push({
200
+ severity: 'warn',
201
+ reason: 'projection_stale',
202
+ detail: `last projection ${lagMs}ms ago exceeds ${MIN_PROJECTION_INTERVAL_MS}ms refresh interval`,
203
+ });
204
+ }
205
+ }
206
+
207
+ const crashes = readCrashReports(obsRoot);
208
+ if (crashes.ok) {
209
+ fr.crashes = {
210
+ count: crashes.coverage.files,
211
+ parsed: crashes.coverage.parsed,
212
+ corrupt: crashes.coverage.corrupt,
213
+ expired: crashes.coverage.expired,
214
+ };
215
+ } else {
216
+ fr.crashes = { count: null, error: crashes.reason };
217
+ }
218
+
219
+ return fr;
220
+ }
221
+
222
+ const fmtUnknown = (value) => (value === null || value === undefined ? 'unknown' : String(value));
223
+
224
+ function printFlightRecorderSection(flightRecorder) {
225
+ const { segments, queue, lastProjection, crashes } = flightRecorder;
226
+ const seamText = ['recorder', 'analytics', 'projector', 'evaluation']
227
+ .map((name) => `${name}=${flightRecorder.seams?.[name] ?? 'unknown'}`)
228
+ .join(' ');
229
+ const segmentText = segments.sealed === null
230
+ ? 'unknown'
231
+ : `active=${fmtUnknown(segments.active)} sealed=${fmtUnknown(segments.sealed)} bytes=${fmtUnknown(segments.bytes)}`;
232
+ const lagText = lastProjection.lagMs === null || lastProjection.lagMs === undefined
233
+ ? 'unknown'
234
+ : `lag=${lastProjection.lagMs}ms`;
235
+ const crashText = crashes.count === null || crashes.count === undefined
236
+ ? `unknown${crashes.error ? ` (${crashes.error})` : ''}`
237
+ : String(crashes.count);
238
+ console.log(`[UKit] Flight recorder: stage: ${flightRecorder.stage} | seams: ${seamText}`);
239
+ console.log(`[UKit] segments: ${segmentText}`);
240
+ console.log(`[UKit] queue: depth=${fmtUnknown(queue.depth)} dropped=${fmtUnknown(queue.dropped)}`);
241
+ console.log(`[UKit] last_projection: ${lagText}`);
242
+ console.log(`[UKit] crashes: ${crashText}`);
243
+ for (const fault of flightRecorder.faults) {
244
+ console.log(`[UKit] ${fault.severity === 'fail' ? 'FAIL' : 'WARN'} ${fault.reason}${fault.detail ? ` — ${fault.detail}` : ''}`);
245
+ if (fault.severity === 'fail') {
246
+ console.log(`[UKit] remedy: ${FLIGHT_FAIL_REMEDY}`);
247
+ }
248
+ }
249
+ }
250
+
251
+ // --json machine shape: null = unknown (mirrors `ukit telemetry status`).
252
+ function flightRecorderJson(flightRecorder) {
253
+ const { segments, queue, lastProjection, crashes } = flightRecorder;
254
+ return {
255
+ stage: flightRecorder.stage,
256
+ seams: { ...flightRecorder.seams },
257
+ segments: {
258
+ active: segments.active,
259
+ sealed: segments.sealed,
260
+ bytes: segments.bytes,
261
+ },
262
+ queue: { depth: queue.depth, dropped: queue.dropped },
263
+ last_projection: {
264
+ projected_at: lastProjection.projectedAt ?? null,
265
+ lag_ms: lastProjection.lagMs,
266
+ },
267
+ crashes: {
268
+ count: crashes.count ?? null,
269
+ ...(crashes.error ? { error: crashes.error } : {}),
270
+ },
271
+ faults: flightRecorder.faults.map((fault) => ({
272
+ severity: fault.severity,
273
+ reason: fault.reason,
274
+ ...(fault.detail ? { detail: fault.detail } : {}),
275
+ })),
276
+ };
277
+ }
278
+
76
279
  export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir = os.homedir(), ompPath = 'omp' }) {
77
280
  const unknownFlags = argv.filter((flag) => !KNOWN_FLAGS.has(flag));
78
281
  if (unknownFlags.length > 0) {
@@ -85,11 +288,18 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir =
85
288
  }
86
289
 
87
290
  // TASK-006 / FR-006 — `--json` is a machine surface: emit ONLY the permission
88
- // report object, exit 1 when any FAIL class fires.
291
+ // report object (plus TASK-016's flight-recorder snapshot), exit 1 when any
292
+ // FAIL class fires.
89
293
  if (argv.includes('--json')) {
90
294
  const permissionReport = await inspectPermissions({ projectRoot, ompPath });
91
- console.log(JSON.stringify({ permissions: permissionReport }, null, 2));
92
- if (permissionReport.failures.length > 0) process.exitCode = 1;
295
+ const { config: jsonRuntimeConfig } = await inspectRuntimeConfig(projectRoot, { homeDir });
296
+ const flightRecorder = await inspectFlightRecorderDisk({ projectRoot, config: jsonRuntimeConfig });
297
+ console.log(JSON.stringify({
298
+ permissions: permissionReport,
299
+ flight_recorder: flightRecorderJson(flightRecorder),
300
+ }, null, 2));
301
+ const flightFailed = flightRecorder.faults.some((fault) => fault.severity === 'fail');
302
+ if (permissionReport.failures.length > 0 || flightFailed) process.exitCode = 1;
93
303
  return;
94
304
  }
95
305
 
@@ -345,6 +555,25 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir =
345
555
  );
346
556
  }
347
557
 
558
+ // TASK-016 / SPEC §5 DF2-FR16 — flight-recorder health read cold from disk.
559
+ // Read-only: no recorder spawn, no writes. FAIL-class faults join
560
+ // projectChecks so they print remedies and block the exit code; WARNs stay
561
+ // advisory and never do.
562
+ const flightRecorder = await inspectFlightRecorderDisk({
563
+ projectRoot,
564
+ config: runtimeConfigInspection.config,
565
+ });
566
+ printFlightRecorderSection(flightRecorder);
567
+ for (const fault of flightRecorder.faults) {
568
+ if (fault.severity !== 'fail') continue;
569
+ projectChecks.push({
570
+ label: `Flight recorder: ${fault.reason}`,
571
+ passed: false,
572
+ remediationClass: 'owner-action',
573
+ remedy: FLIGHT_FAIL_REMEDY,
574
+ });
575
+ }
576
+
348
577
  console.log('[UKit] Project rules checks:');
349
578
  for (const check of projectChecks) {
350
579
  if (check.applicable === false) continue;
@@ -6,10 +6,21 @@
6
6
  // `sessionId` = UKIT_SESSION_ID env only (SessionEnd hook exports it) — omitted
7
7
  // when unset. `--list` prints a byKind roll-up via collectFeedbackEvents and
8
8
  // never writes. Missing/empty text or unknown flag → usage on stderr, exit 1.
9
+ //
10
+ // DF2-FR13 (TASK-013): a successful write also emits one `outcome.observed`
11
+ // record (kind 'user-correction', verdict 'rejected') through the recorder —
12
+ // the write-time emit seam: the ingest lane does NOT read the manual file
13
+ // (SPEC §14). Best-effort only: observability stage off → silent skip, and
14
+ // any emit failure is swallowed — telemetry never changes the feedback UX.
9
15
 
10
16
  import fs from 'node:fs/promises';
17
+ import os from 'node:os';
11
18
  import path from 'node:path';
12
19
  import { collectFeedbackEvents } from '../../diagnostics/feedbackEvents.js';
20
+ import { inspectRuntimeConfig } from '../../core/runtimeConfig.js';
21
+ import { resolveStage } from '../../core/observability/emit/config.js';
22
+ import { getRecorder, segmentsRoot } from '../../core/observability/emit/lifecycle.js';
23
+ import { recordOutcome } from '../../core/observability/evaluation/outcomes.js';
13
24
 
14
25
  const HELP_FLAGS = new Set(['--help', '-h', 'help']);
15
26
 
@@ -27,7 +38,55 @@ async function appendManualLine(projectRoot, record) {
27
38
  await fs.appendFile(filePath, JSON.stringify(record) + '\n');
28
39
  }
29
40
 
30
- export async function runFeedback({ projectRoot, argv = [] }) {
41
+ // Outcome-emit latency bound: a one-shot CLI has no lifecycle loop, so the
42
+ // record must be flushed inline to reach the segment file — bounded so the
43
+ // telemetry path can never stall the command.
44
+ const OUTCOME_FLUSH_DEADLINE_MS = 1_000;
45
+
46
+ async function loadOutcomeConfig(projectRoot) {
47
+ try {
48
+ const inspection = await inspectRuntimeConfig(projectRoot, { homeDir: os.homedir() });
49
+ return inspection && inspection.config && typeof inspection.config === 'object'
50
+ ? inspection.config
51
+ : {};
52
+ } catch {
53
+ return {};
54
+ }
55
+ }
56
+
57
+ // One logical event → one record: emit at write-time only. `manualRecord` is
58
+ // the line just appended; only its sessionId leaves the process as an
59
+ // envelope join key + opaque evidence ref — the feedback text stays in the
60
+ // manual file, never in a payload.
61
+ async function emitFeedbackOutcome(projectRoot, config, manualRecord) {
62
+ try {
63
+ const resolved = config ?? (await loadOutcomeConfig(projectRoot));
64
+ if (resolveStage(resolved) === 'off') return;
65
+ const recorder = getRecorder({ projectRoot, config: resolved });
66
+ const sessionId = typeof manualRecord?.sessionId === 'string' && manualRecord.sessionId
67
+ ? manualRecord.sessionId
68
+ : undefined;
69
+ recordOutcome({
70
+ recorder,
71
+ verdict: 'rejected', // a manual wrong-route label rejects the routed pick
72
+ kind: 'user-correction',
73
+ source: 'ukit-feedback',
74
+ sessionId,
75
+ evidenceRefs: sessionId ? [`session:${sessionId}`] : [],
76
+ });
77
+ // The segment store's appendRecord resolves the parent dir by realpath,
78
+ // which ENOENTs when no lifecycle has provisioned it yet (fresh project,
79
+ // one-shot CLI). Provision the recorder root inline — production does the
80
+ // same via startLifecycle's crash capture.
81
+ await fs.mkdir(segmentsRoot(projectRoot), { recursive: true });
82
+ await recorder.flush({ deadlineMs: OUTCOME_FLUSH_DEADLINE_MS });
83
+ } catch (err) {
84
+ if (process.env.UKIT_DEBUG_OUTCOME) console.error('outcome emit failed:', err);
85
+ // best-effort by contract — outcome telemetry never breaks feedback
86
+ }
87
+ }
88
+
89
+ export async function runFeedback({ projectRoot, argv = [], config } = {}) {
31
90
  const args = argv ?? [];
32
91
 
33
92
  if (args.some((a) => HELP_FLAGS.has(a))) {
@@ -94,4 +153,8 @@ export async function runFeedback({ projectRoot, argv = [] }) {
94
153
  return;
95
154
  }
96
155
  console.log('feedback recorded');
156
+
157
+ // DF2-FR13: land the outcome signal after the UX line is printed so the
158
+ // emit path can never stand between the user and the confirmation.
159
+ await emitFeedbackOutcome(projectRoot, config, record);
97
160
  }
@@ -7,6 +7,7 @@ import { installIndexRefreshHooks } from '../../index/gitHooks.js';
7
7
  import fs from 'node:fs/promises';
8
8
  import { pathExists, readJsonIfExists, removeFileOrLinkOnly } from '../../core/fileOps.js';
9
9
  import { removeTrackedPathsFromMetadata } from '../../core/metadata.js';
10
+ import { provisionSupportDir } from '../../core/observability/support/provision.js';
10
11
  import {
11
12
  ADAPTER_BY_KEY,
12
13
  DEFAULT_OPTIONAL_TOOL_KEYS,
@@ -282,6 +283,18 @@ export async function runInstall({ packageRoot, projectRoot, packageVersion, arg
282
283
  withCodegraph,
283
284
  });
284
285
 
286
+ // TASK-010 (DF2-FR10): provision Documents/UKit Support after mirror
287
+ // sync + config write succeed, before the summary block. The result joins
288
+ // the summary below; any degrade is a WARNING — never an install failure.
289
+ let supportProvision = null;
290
+ try {
291
+ supportProvision = await provisionSupportDir({});
292
+ } catch (error) {
293
+ supportProvision = {
294
+ status: 'degraded',
295
+ reason: error?.message ?? 'provision_failed',
296
+ };
297
+ }
285
298
  const { create, update, unchanged, skip } = result.summary;
286
299
 
287
300
  console.log(`[UKit] Project: ${result.projectContext.project.name}`);
@@ -313,6 +326,11 @@ export async function runInstall({ packageRoot, projectRoot, packageVersion, arg
313
326
  .join(', ');
314
327
  console.log(`[UKit] Providers: ${providerStatus}, all=${result.providerContext.allSupported}`);
315
328
 
329
+ if (supportProvision && supportProvision.status !== 'degraded') {
330
+ console.log(`[UKit] Support folder: ${supportProvision.dir}`);
331
+ } else {
332
+ console.warn(`[UKit] Support folder: deferred (${supportProvision?.reason ?? 'unknown'})`);
333
+ }
316
334
  for (const line of formatRepairReport(result.hookRepair ?? { removals: [], files: [] })) {
317
335
  console.log(`[UKit] ${line}`);
318
336
  }