@ngockhoale/ukit 3.0.8 → 3.0.10

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 (105) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/manifests/documentation.yaml +11 -0
  3. package/package.json +1 -1
  4. package/scripts/audit/decision-coverage.mjs +29 -2
  5. package/scripts/bench/data-foundation.mjs +52 -3
  6. package/scripts/bench/decision-runtime-baseline.mjs +427 -0
  7. package/scripts/bench/decision-runtime-metrics.mjs +67 -0
  8. package/scripts/bench/decision-runtime-variant.mjs +626 -0
  9. package/scripts/bench/memory-ablation.mjs +495 -0
  10. package/scripts/bench/memory-baseline.mjs +596 -0
  11. package/scripts/bench/memory-bench.mjs +661 -0
  12. package/scripts/bench/memory-canary.mjs +321 -0
  13. package/scripts/bench/memory-corpus.mjs +354 -0
  14. package/scripts/bench/memory-gate.mjs +389 -0
  15. package/scripts/bench/memory-metrics.mjs +179 -0
  16. package/scripts/bench/parallel-agents.mjs +33 -11
  17. package/scripts/bench/recorder-overhead.mjs +204 -0
  18. package/scripts/bench/sqlite-spike.mjs +451 -0
  19. package/scripts/measure-decision-gateway.mjs +306 -0
  20. package/scripts/perf/audit-perf.mjs +35 -17
  21. package/src/bug/triageBug.js +4 -3
  22. package/src/cli/commands/memory.js +357 -63
  23. package/src/context/detectProjectContext.js +11 -1
  24. package/src/core/agentRuntime/adapters.js +254 -0
  25. package/src/core/agentRuntime/artifacts.js +192 -0
  26. package/src/core/agentRuntime/completionGate.js +176 -0
  27. package/src/core/agentRuntime/context.js +149 -0
  28. package/src/core/agentRuntime/contract.js +247 -0
  29. package/src/core/agentRuntime/diagnostics.js +244 -0
  30. package/src/core/agentRuntime/evaluation.js +163 -0
  31. package/src/core/agentRuntime/eventStore.js +404 -0
  32. package/src/core/agentRuntime/liveness.js +60 -0
  33. package/src/core/agentRuntime/planCompiler.js +322 -0
  34. package/src/core/agentRuntime/promotion.js +53 -0
  35. package/src/core/agentRuntime/qualityComparison.js +112 -0
  36. package/src/core/agentRuntime/recovery.js +266 -0
  37. package/src/core/agentRuntime/resourcePolicy.js +78 -0
  38. package/src/core/agentRuntime/runtimeSupport.js +237 -0
  39. package/src/core/agentRuntime/supervisor.js +565 -0
  40. package/src/core/agentRuntime/vmEngine.js +621 -0
  41. package/src/core/codeintel/analogy.js +3 -2
  42. package/src/core/experiments/dynamicWorkflow.js +17 -2
  43. package/src/core/fileOps.js +21 -3
  44. package/src/core/memory/deltaOverlays.js +75 -30
  45. package/src/core/memory/learningCandidates.js +93 -48
  46. package/src/core/memory/memoryFlags.js +83 -0
  47. package/src/core/memory/memoryFreshness.js +190 -0
  48. package/src/core/memory/memoryHit.js +144 -0
  49. package/src/core/memory/migrate.js +69 -189
  50. package/src/core/memory/migrateMapping.js +232 -0
  51. package/src/core/memory/mutateMemory.js +323 -0
  52. package/src/core/memory/policy.js +96 -0
  53. package/src/core/memory/projectIdentity.js +266 -0
  54. package/src/core/memory/recordIndex.js +178 -0
  55. package/src/core/memory/recordStore.js +133 -20
  56. package/src/core/memory/records.js +144 -6
  57. package/src/core/memory/retrieval.js +259 -125
  58. package/src/core/memory/store.js +16 -5
  59. package/src/core/memory/storeBackup.js +226 -0
  60. package/src/core/memory/storeV2.js +63 -26
  61. package/src/core/memory/storeV2Loader.js +30 -12
  62. package/src/core/memory/userMemory.js +38 -20
  63. package/src/core/memory/writeClassification.js +161 -0
  64. package/src/core/memory/writeGuard.js +129 -0
  65. package/src/core/observability/adapters/hookTelemetryAdapter.js +90 -0
  66. package/src/core/observability/analytics/cohorts.js +148 -0
  67. package/src/core/observability/analytics/storeDigest.js +163 -0
  68. package/src/core/observability/evaluation/experimentPlan.js +95 -0
  69. package/src/core/observability/evaluation/findings.js +99 -0
  70. package/src/core/observability/evaluation/optimizationKnowledge.js +10 -1
  71. package/src/core/observability/evaluation/perturbation.js +273 -0
  72. package/src/core/observability/evaluation/replay.js +7 -1
  73. package/src/core/observability/evaluation/scorecard.js +23 -3
  74. package/src/core/observability/rollout.js +11 -7
  75. package/src/core/observability/schema/compatibility.js +135 -0
  76. package/src/core/observability/schema/registry.js +99 -0
  77. package/src/core/observability/schema/validate.js +7 -0
  78. package/src/core/observability/support/import.js +53 -9
  79. package/src/core/observability/support/paths.js +13 -3
  80. package/src/core/observability/support/projector.js +148 -12
  81. package/src/core/output/index.js +12 -2
  82. package/src/core/runtimeConfig.js +83 -0
  83. package/src/core/runtimePaths.js +3 -0
  84. package/src/core/sensitiveValueScanner.js +40 -0
  85. package/src/core/token/index.js +40 -3
  86. package/src/decision/client.js +37 -13
  87. package/src/decision/protocol.js +1 -1
  88. package/src/decision/registry.js +5 -3
  89. package/src/decision/runtimeDecide.js +242 -0
  90. package/src/decision/runtimeFilter.js +150 -0
  91. package/src/decision/runtimeScheduler.js +239 -0
  92. package/src/index/buildIndex.js +13 -12
  93. package/src/index/queryIndex.js +35 -14
  94. package/src/index/relatedTests.js +50 -8
  95. package/src/index/resolveContext.js +9 -4
  96. package/src/manifest/selectItems.js +7 -3
  97. package/src/render/instructionRenderer.js +17 -5
  98. package/template_project/.claude/ukit/index/lib/index-core.mjs +94 -39
  99. package/template_project/.claude/ukit/index/route-task.mjs +121 -19
  100. package/template_project/.claude/ukit/index/unic-decision.mjs +28 -13
  101. package/template_project/.claude/ukit/runtime/memory-flags.mjs +51 -0
  102. package/template_project/.claude/ukit/runtime/memory-freshness.mjs +155 -0
  103. package/template_project/.claude/ukit/runtime/memory-policy.mjs +286 -0
  104. package/template_project/.claude/ukit/runtime/output-compression.mjs +3 -0
  105. package/template_project/.claude/ukit/runtime/reinject-context.mjs +145 -14
@@ -0,0 +1,135 @@
1
+ // Registry compatibility — SPEC W1 G1-FR02, §4.
2
+ //
3
+ // checkRegistryCompatibility(prev, next, options?) → { ok, violations }
4
+ // validateEnvelopeSchemaVersion(record) → { ok } | { ok, reason }
5
+ //
6
+ // Enforces the frozen registry lifecycle rules mechanically, not by
7
+ // convention: entries are append-only. New keys and new OPTIONAL metadata
8
+ // fields are additive and tolerated; mutating an existing entry's meaning
9
+ // (definition, unit, privacy floor) in place, removing a key, resurrecting
10
+ // a deprecated entry, or re-dating an entry's introduced_version is a
11
+ // breaking change that requires a new SCHEMA_VERSION major — never an
12
+ // in-place edit.
13
+ //
14
+ // Violation codes are a static closed set:
15
+ // meaning_changed — definition/unit/privacy_class mutated in place
16
+ // removed — a key present in prev is absent in next
17
+ // deprecated_resurrected — a deprecated entry lost its deprecation marker
18
+ // major_required — introduced_version was rewritten on an
19
+ // existing entry (re-dating history) or the
20
+ // registry's own version marker regressed
21
+
22
+ import { SCHEMA_VERSION } from './constants.js';
23
+
24
+ export const COMPATIBILITY_VIOLATION_CODES = Object.freeze([
25
+ 'meaning_changed',
26
+ 'removed',
27
+ 'deprecated_resurrected',
28
+ 'major_required',
29
+ ]);
30
+
31
+ // Fields whose mutation changes what an entry MEANS. New optional fields
32
+ // not in this list (e.g. additional hints) are additive and tolerated.
33
+ const MEANING_FIELDS = ['definition', 'unit', 'privacy_class'];
34
+
35
+ function isPlainObject(value) {
36
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
37
+ }
38
+
39
+ function isDeprecated(entry) {
40
+ return entry.deprecated !== undefined && entry.deprecated !== null;
41
+ }
42
+
43
+ /**
44
+ * Compare two registry snapshots ({ key: entry }) under the additive-only
45
+ * lifecycle contract. `prev`/`next` may be SEMANTIC_REGISTRY, REASON_CODES,
46
+ * or any same-shaped registry map.
47
+ *
48
+ * @param {object} prev — earlier registry snapshot.
49
+ * @param {object} next — candidate registry snapshot.
50
+ * @param {{ schemaVersion?: number }} [options] — the schema major the next
51
+ * snapshot ships under. Breaking changes are only legitimate when this is
52
+ * a NEW major (> SCHEMA_VERSION); defaults to the current SCHEMA_VERSION,
53
+ * so callers must opt into a major bump explicitly.
54
+ * @returns {{ ok: boolean, violations: Array<{ code: string, key: string }> }}
55
+ */
56
+ export function checkRegistryCompatibility(prev, next, options = {}) {
57
+ const violations = [];
58
+ const prevRegistry = isPlainObject(prev) ? prev : {};
59
+ const nextRegistry = isPlainObject(next) ? next : {};
60
+ const schemaVersion = options.schemaVersion === undefined ? SCHEMA_VERSION : options.schemaVersion;
61
+
62
+ // A declared major bump opens a new registry lineage — breaking changes
63
+ // are legitimate there and there is nothing to enforce on this contract.
64
+ if (Number.isInteger(schemaVersion) && schemaVersion > SCHEMA_VERSION) {
65
+ return { ok: true, violations: [] };
66
+ }
67
+ // A regressed major marker is always invalid.
68
+ if (schemaVersion !== SCHEMA_VERSION) {
69
+ return { ok: false, violations: [{ code: 'major_required', key: 'schema_version' }] };
70
+ }
71
+ // Same-major snapshot pair: enforce the additive-only lifecycle rules.
72
+
73
+ for (const key of Object.keys(prevRegistry)) {
74
+ const before = prevRegistry[key];
75
+ const after = nextRegistry[key];
76
+
77
+ if (after === undefined) {
78
+ violations.push({ code: 'removed', key });
79
+ continue;
80
+ }
81
+ if (!isPlainObject(before) || !isPlainObject(after)) {
82
+ violations.push({ code: 'meaning_changed', key });
83
+ continue;
84
+ }
85
+
86
+ // Re-dating an existing entry's introduced_version rewrites registry
87
+ // history — always a major boundary, never an in-place edit.
88
+ if (before.introduced_version !== after.introduced_version) {
89
+ violations.push({ code: 'major_required', key });
90
+ }
91
+
92
+ if (isDeprecated(before) && !isDeprecated(after)) {
93
+ violations.push({ code: 'deprecated_resurrected', key });
94
+ }
95
+
96
+ for (const field of MEANING_FIELDS) {
97
+ if (before[field] === undefined && after[field] === undefined) continue;
98
+ // A missing field on either side is not a meaning change — older
99
+ // snapshots simply did not carry the metadata.
100
+ if (before[field] === undefined || after[field] === undefined) continue;
101
+ if (before[field] !== after[field]) {
102
+ violations.push({ code: 'meaning_changed', key });
103
+ break;
104
+ }
105
+ }
106
+ }
107
+
108
+ return { ok: violations.length === 0, violations };
109
+ }
110
+
111
+ /**
112
+ * Gate a semantic envelope on its declared schema major. Tolerates any
113
+ * unknown optional fields — version compatibility is judged ONLY on
114
+ * `schema_version` (DF-FR03 additive-only tolerance lives upstream in
115
+ * validateSemanticRecord).
116
+ *
117
+ * @param {object} record — candidate semantic envelope.
118
+ * @returns {{ ok: true } | { ok: false, reason: string }}
119
+ */
120
+ export function validateEnvelopeSchemaVersion(record) {
121
+ if (!isPlainObject(record)) {
122
+ return { ok: false, reason: 'record must be a plain object' };
123
+ }
124
+ const version = record.schema_version;
125
+ if (!Number.isInteger(version)) {
126
+ return { ok: false, reason: `schema_version must be an integer (got ${JSON.stringify(version)})` };
127
+ }
128
+ if (version !== SCHEMA_VERSION) {
129
+ return {
130
+ ok: false,
131
+ reason: `unsupported schema_version ${version} — this major requires schema ${version} support; supported: ${SCHEMA_VERSION}`,
132
+ };
133
+ }
134
+ return { ok: true };
135
+ }
@@ -18,111 +18,147 @@ const V1 = '1';
18
18
  export const SEMANTIC_REGISTRY = Object.freeze({
19
19
  'execution.started': {
20
20
  definition: 'An execution unit (task/episode) began. Span root for the execution.',
21
+ unit: 'event',
21
22
  privacy_class: 'internal',
22
23
  retention_hint: 'retain',
23
24
  introduced_version: V1,
25
+ deprecated: null,
24
26
  },
25
27
  'execution.completed': {
26
28
  definition: 'An execution unit finished successfully. Payload carries outcome and duration.',
29
+ unit: 'ms',
27
30
  privacy_class: 'internal',
28
31
  retention_hint: 'retain',
29
32
  introduced_version: V1,
33
+ deprecated: null,
30
34
  },
31
35
  'execution.failed': {
32
36
  definition: 'An execution unit terminated with failure. Payload carries error_code.',
37
+ unit: 'ms',
33
38
  privacy_class: 'internal',
34
39
  retention_hint: 'retain',
35
40
  introduced_version: V1,
41
+ deprecated: null,
36
42
  },
37
43
  'execution.blocked': {
38
44
  definition: 'An execution unit stopped on an unmet precondition or gate.',
45
+ unit: 'ms',
39
46
  privacy_class: 'internal',
40
47
  retention_hint: 'retain',
41
48
  introduced_version: V1,
49
+ deprecated: null,
42
50
  },
43
51
  'tool.started': {
44
52
  definition: 'A tool invocation began. Payload may name the tool; arguments stay redacted.',
53
+ unit: 'event',
45
54
  privacy_class: 'sensitive',
46
55
  retention_hint: 'retain',
47
56
  introduced_version: V1,
57
+ deprecated: null,
48
58
  },
49
59
  'tool.completed': {
50
60
  definition: 'A tool invocation finished. Payload carries duration; output stays redacted.',
61
+ unit: 'ms',
51
62
  privacy_class: 'sensitive',
52
63
  retention_hint: 'retain',
53
64
  introduced_version: V1,
65
+ deprecated: null,
54
66
  },
55
67
  'tool.failed': {
56
68
  definition: 'A tool invocation failed. Payload carries error_code; stderr stays redacted.',
69
+ unit: 'ms',
57
70
  privacy_class: 'sensitive',
58
71
  retention_hint: 'retain',
59
72
  introduced_version: V1,
73
+ deprecated: null,
60
74
  },
61
75
  'model.started': {
62
76
  definition: 'A model attempt began. Payload carries attempt_index.',
77
+ unit: 'event',
63
78
  privacy_class: 'internal',
64
79
  retention_hint: 'retain',
65
80
  introduced_version: V1,
81
+ deprecated: null,
66
82
  },
67
83
  'model.completed': {
68
84
  definition: 'A model attempt finished. Payload carries duration and typed resource usage.',
85
+ unit: 'ms',
69
86
  privacy_class: 'internal',
70
87
  retention_hint: 'retain',
71
88
  introduced_version: V1,
89
+ deprecated: null,
72
90
  },
73
91
  'model.failed': {
74
92
  definition: 'A model attempt failed. Payload carries error_code and typed resource usage.',
93
+ unit: 'ms',
75
94
  privacy_class: 'internal',
76
95
  retention_hint: 'retain',
77
96
  introduced_version: V1,
97
+ deprecated: null,
78
98
  },
79
99
  'cache.hit': {
80
100
  definition: 'A cache lookup resolved from cache. Payload identifies the cache seam, not content.',
101
+ unit: 'event',
81
102
  privacy_class: 'internal',
82
103
  retention_hint: 'aggregate',
83
104
  introduced_version: V1,
105
+ deprecated: null,
84
106
  },
85
107
  'cache.miss': {
86
108
  definition: 'A cache lookup did not resolve from cache.',
109
+ unit: 'event',
87
110
  privacy_class: 'internal',
88
111
  retention_hint: 'aggregate',
89
112
  introduced_version: V1,
113
+ deprecated: null,
90
114
  },
91
115
  'verification.completed': {
92
116
  definition: 'A verification step ran to completion. Payload carries the verdict, not raw output.',
117
+ unit: 'event',
93
118
  privacy_class: 'internal',
94
119
  retention_hint: 'retain',
95
120
  introduced_version: V1,
121
+ deprecated: null,
96
122
  },
97
123
  'context.item.injected': {
98
124
  definition: 'A context item (e.g. user correction) entered the episode. Kind only, never content.',
125
+ unit: 'event',
99
126
  privacy_class: 'sensitive',
100
127
  retention_hint: 'retain',
101
128
  introduced_version: V1,
129
+ deprecated: null,
102
130
  },
103
131
  'decision.made': {
104
132
  definition: 'An optimization or routing decision was recorded with experiment provenance.',
133
+ unit: 'event',
105
134
  privacy_class: 'internal',
106
135
  retention_hint: 'retain',
107
136
  introduced_version: V1,
137
+ deprecated: null,
108
138
  },
109
139
  'telemetry.dropped': {
110
140
  definition: 'Telemetry records were dropped or sampled. Payload carries reason_code and count.',
141
+ unit: 'records',
111
142
  privacy_class: 'internal',
112
143
  retention_hint: 'retain',
113
144
  introduced_version: V1,
145
+ deprecated: null,
114
146
  },
115
147
  'telemetry.recovered': {
116
148
  definition: 'Previously incomplete telemetry recovered (e.g. partial tail repaired).',
149
+ unit: 'records',
117
150
  privacy_class: 'internal',
118
151
  retention_hint: 'retain',
119
152
  introduced_version: V1,
153
+ deprecated: null,
120
154
  },
121
155
  'telemetry.redacted': {
122
156
  definition: 'A record or field was redacted before persistence. Payload carries the field path.',
157
+ unit: 'fields',
123
158
  privacy_class: 'internal',
124
159
  retention_hint: 'retain',
125
160
  introduced_version: V1,
161
+ deprecated: null,
126
162
  },
127
163
  });
128
164
 
@@ -131,87 +167,150 @@ export const SEMANTIC_REGISTRY = Object.freeze({
131
167
  export const REASON_CODES = Object.freeze({
132
168
  TOOL_TIMEOUT: {
133
169
  definition: 'A tool or model call exceeded its deadline.',
170
+ unit: null,
134
171
  introduced_version: V1,
172
+ deprecated: null,
173
+ privacy_class: 'internal',
135
174
  },
136
175
  AUTH_FAILED: {
137
176
  definition: 'Authentication or authorization was rejected by the provider.',
177
+ unit: null,
138
178
  introduced_version: V1,
179
+ deprecated: null,
180
+ privacy_class: 'internal',
139
181
  },
140
182
  RATE_LIMITED: {
141
183
  definition: 'The provider or host throttled the request.',
184
+ unit: null,
142
185
  introduced_version: V1,
186
+ deprecated: null,
187
+ privacy_class: 'internal',
143
188
  },
144
189
  MODEL_ERROR: {
145
190
  definition: 'The model endpoint returned an error response.',
191
+ unit: null,
146
192
  introduced_version: V1,
193
+ deprecated: null,
194
+ privacy_class: 'internal',
147
195
  },
148
196
  NETWORK_ERROR: {
149
197
  definition: 'A transport-level failure prevented the call.',
198
+ unit: null,
150
199
  introduced_version: V1,
200
+ deprecated: null,
201
+ privacy_class: 'internal',
151
202
  },
152
203
  QUEUE_OVERFLOW: {
153
204
  definition: 'The emit queue exceeded its bound; records were dropped.',
205
+ unit: null,
154
206
  introduced_version: V1,
207
+ deprecated: null,
208
+ privacy_class: 'internal',
155
209
  },
156
210
  SAMPLED_OUT: {
157
211
  definition: 'The record was excluded by the configured sampling policy.',
212
+ unit: null,
158
213
  introduced_version: V1,
214
+ deprecated: null,
215
+ privacy_class: 'internal',
159
216
  },
160
217
  HOST_BLIND: {
161
218
  definition: 'The host does not expose the field; value stays explicitly unknown.',
219
+ unit: null,
162
220
  introduced_version: V1,
221
+ deprecated: null,
222
+ privacy_class: 'internal',
163
223
  },
164
224
  FLUSH_FAILED: {
165
225
  definition: 'A recorder flush could not persist its batch; user task unaffected.',
226
+ unit: null,
166
227
  introduced_version: V1,
228
+ deprecated: null,
229
+ privacy_class: 'internal',
167
230
  },
168
231
  DISK_FULL: {
169
232
  definition: 'The storage volume rejected a write for lack of space.',
233
+ unit: null,
170
234
  introduced_version: V1,
235
+ deprecated: null,
236
+ privacy_class: 'internal',
171
237
  },
172
238
  PERMISSION_DENIED: {
173
239
  definition: 'The filesystem or OS denied the operation.',
240
+ unit: null,
174
241
  introduced_version: V1,
242
+ deprecated: null,
243
+ privacy_class: 'internal',
175
244
  },
176
245
  SCHEMA_MISMATCH: {
177
246
  definition: 'A record or bundle failed schema validation.',
247
+ unit: null,
178
248
  introduced_version: V1,
249
+ deprecated: null,
250
+ privacy_class: 'internal',
179
251
  },
180
252
  CHECKSUM_MISMATCH: {
181
253
  definition: 'A segment or bundle digest did not match its manifest.',
254
+ unit: null,
182
255
  introduced_version: V1,
256
+ deprecated: null,
257
+ privacy_class: 'internal',
183
258
  },
184
259
  PARTIAL_TAIL: {
185
260
  definition: 'A segment ended mid-record; the tail was truncated on recovery.',
261
+ unit: null,
186
262
  introduced_version: V1,
263
+ deprecated: null,
264
+ privacy_class: 'internal',
187
265
  },
188
266
  ROTATED: {
189
267
  definition: 'A segment was rotated out by retention or cap policy.',
268
+ unit: null,
190
269
  introduced_version: V1,
270
+ deprecated: null,
271
+ privacy_class: 'internal',
191
272
  },
192
273
  REDACTED_FIELD: {
193
274
  definition: 'A payload field was removed by the privacy gate before persistence.',
275
+ unit: null,
194
276
  introduced_version: V1,
277
+ deprecated: null,
278
+ privacy_class: 'internal',
195
279
  },
196
280
  USER_CANCELLED: {
197
281
  definition: 'The user aborted the operation.',
282
+ unit: null,
198
283
  introduced_version: V1,
284
+ deprecated: null,
285
+ privacy_class: 'internal',
199
286
  },
200
287
  GATE_BLOCKED: {
201
288
  definition: 'A completion or security gate stopped the execution.',
289
+ unit: null,
202
290
  introduced_version: V1,
291
+ deprecated: null,
292
+ privacy_class: 'internal',
203
293
  },
204
294
  WAITING_ON_TOOL: {
205
295
  definition: 'The span is waiting on a tool result.',
296
+ unit: null,
206
297
  introduced_version: V1,
298
+ deprecated: null,
299
+ privacy_class: 'internal',
207
300
  },
208
301
  WAITING_ON_MODEL: {
209
302
  definition: 'The span is waiting on a model response.',
303
+ unit: null,
210
304
  introduced_version: V1,
305
+ deprecated: null,
306
+ privacy_class: 'internal',
211
307
  },
212
308
  WAITING_ON_USER: {
213
309
  definition: 'The span is waiting on user input.',
310
+ unit: null,
214
311
  introduced_version: V1,
312
+ deprecated: null,
313
+ privacy_class: 'internal',
215
314
  },
216
315
  });
217
316
 
@@ -112,6 +112,13 @@ function validatePayload(payload, errors) {
112
112
  if (payload.telemetry_complete !== undefined && typeof payload.telemetry_complete !== 'boolean') {
113
113
  errors.push('payload.telemetry_complete must be a boolean');
114
114
  }
115
+ // Duration is always milliseconds — a unit-suffixed string or non-finite
116
+ // number is malformed, never a unit conversion request.
117
+ if (payload.duration_ms !== undefined && payload.duration_ms !== null) {
118
+ if (typeof payload.duration_ms !== 'number' || !Number.isFinite(payload.duration_ms) || payload.duration_ms < 0) {
119
+ errors.push('payload.duration_ms must be a non-negative finite number (unit is always milliseconds)');
120
+ }
121
+ }
115
122
  }
116
123
 
117
124
  export function validateSemanticRecord(record, context) {
@@ -11,12 +11,16 @@
11
11
  *
12
12
  * Validation order (fail-fast, first reason wins):
13
13
  * input shape → container parse (zip EOCD/CD/local headers, or dir
14
- * listing) → per-entry name safety (flat namespace, no traversal,
15
- * no absolute/drive/backslash) → compression method allowlist →
16
- * entry/total size caps → zip-bomb ratio → CRC32 + declared-size
17
- * integrity → manifest presence → manifest format/checksum/member
18
- * verification → file-type allowlist (.md/.json/.jsonl) →
19
- * records.jsonl line parsing.
14
+ * listing) → one-folder unwrap (zip only: if every member shares
15
+ * exactly one `Prefix/…` root — an OS "Compress folder" bundle — the
16
+ * prefix is stripped; prefix-only dir entries are ignored; mixed-root
17
+ * archives keep raw names and fail below) → symlink check (unix mode
18
+ * in CD external attrs, `symlink_entry`) → per-entry name safety
19
+ * (flat namespace, no traversal, no absolute/drive/backslash) →
20
+ * compression method allowlist → entry/total size caps → zip-bomb
21
+ * ratio → CRC32 + declared-size integrity → manifest presence →
22
+ * manifest format/checksum/member verification → file-type allowlist
23
+ * (.md/.json/.jsonl) → records.jsonl line parsing.
20
24
  *
21
25
  * Defense invariants:
22
26
  * - Nothing is ever executed, sourced, or written outside the input
@@ -134,15 +138,55 @@ function unzip(buf) {
134
138
  const nameLen = buf.readUInt16LE(p + 28);
135
139
  const extraLen = buf.readUInt16LE(p + 30);
136
140
  const commentLen = buf.readUInt16LE(p + 32);
141
+ // upper 16 bits of external attrs carry the unix mode (zip spec §4.4.15)
142
+ const externalAttrs = buf.readUInt32LE(p + 38);
137
143
  const localOffset = buf.readUInt32LE(p + 42);
138
144
  const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
139
- central.push({ name, method, crc, compressedSize, uncompressedSize, localOffset });
145
+ central.push({ name, method, crc, compressedSize, uncompressedSize, externalAttrs, localOffset });
140
146
  p += 46 + nameLen + extraLen + commentLen;
141
147
  }
142
148
 
149
+ // One-folder unwrap (G4-FR09): a real OS "Compress folder → .zip" prefixes
150
+ // every member with `FolderName/`. When every member shares exactly one
151
+ // top-level directory prefix, strip it so the flat-name safety rules and
152
+ // the manifest map apply to the unwrapped names. Prefix-only dir entries
153
+ // (`FolderName/`) carry no payload and are ignored. Mixed-root archives
154
+ // (no shared single prefix) keep their raw names and fail the flat-name
155
+ // checks below — we never guess which subtree is the bundle.
156
+ const prefixes = new Set();
157
+ let sharedPrefix = true;
158
+ for (const e of central) {
159
+ const i = e.name.indexOf('/');
160
+ if (i <= 0) {
161
+ sharedPrefix = false; // root-level or leading-slash member → no unwrap
162
+ break;
163
+ }
164
+ prefixes.add(e.name.slice(0, i + 1));
165
+ }
166
+ const members = [];
167
+ if (sharedPrefix && prefixes.size === 1) {
168
+ const prefix = [...prefixes][0];
169
+ for (const e of central) {
170
+ if (e.name !== prefix) {
171
+ e.name = e.name.slice(prefix.length);
172
+ members.push(e);
173
+ }
174
+ // e.name === prefix → prefix-only dir entry, ignored
175
+ }
176
+ } else {
177
+ members.push(...central);
178
+ }
179
+
143
180
  // Metadata-level bounds before touching payloads.
181
+ const UNIX_S_IFMT = 0o170000;
182
+ const UNIX_S_IFLNK = 0o120000;
144
183
  let totalUncompressed = 0;
145
- for (const e of central) {
184
+ for (const e of members) {
185
+ // symlink members are rejected on central-directory metadata alone —
186
+ // the payload is never touched (G4-FR10).
187
+ if (((e.externalAttrs >>> 16) & UNIX_S_IFMT) === UNIX_S_IFLNK) {
188
+ return fail('symlink_entry', { name: e.name });
189
+ }
146
190
  if (!isSafeEntryName(e.name)) {
147
191
  return fail('unsafe_entry_name', { name: e.name });
148
192
  }
@@ -170,7 +214,7 @@ function unzip(buf) {
170
214
  }
171
215
 
172
216
  // Extraction: local header → data slice → inflate → CRC + size check.
173
- for (const e of central) {
217
+ for (const e of members) {
174
218
  const off = e.localOffset;
175
219
  if (off + 30 > buf.length || buf.readUInt32LE(off) !== LOCAL_SIG) {
176
220
  return fail('corrupt_local_header', { name: e.name });
@@ -30,12 +30,22 @@ import { ioReason } from '../segments/internal.js';
30
30
 
31
31
  export const SUPPORT_DIR_NAME = 'UKit Support';
32
32
 
33
+ /**
34
+ * Path operations follow the INJECTED platform, never the host: an
35
+ * observability record replayed on a different OS must still produce the
36
+ * target platform's separators and join semantics.
37
+ */
38
+ function pathFor(platform) {
39
+ return platform === 'win32' ? path.win32 : path.posix;
40
+ }
41
+
33
42
  function candidates({ homeDir, env, platform }) {
34
43
  const list = [];
44
+ const p = pathFor(platform);
35
45
  if (platform === 'win32') {
36
46
  const profile = env.USERPROFILE || homeDir;
37
47
  if (typeof profile === 'string' && profile.length > 0) {
38
- list.push(path.join(profile, 'Documents'));
48
+ list.push(p.join(profile, 'Documents'));
39
49
  }
40
50
  return list;
41
51
  }
@@ -44,7 +54,7 @@ function candidates({ homeDir, env, platform }) {
44
54
  if (typeof xdg === 'string' && xdg.length > 0) list.push(xdg);
45
55
  }
46
56
  if (typeof homeDir === 'string' && homeDir.length > 0) {
47
- list.push(path.join(homeDir, 'Documents'));
57
+ list.push(p.join(homeDir, 'Documents'));
48
58
  }
49
59
  return list;
50
60
  }
@@ -88,7 +98,7 @@ export async function resolveSupportDir(opts = {}) {
88
98
  reason = err && err.code === 'EACCES' ? 'documents_not_writable' : ioReason(err);
89
99
  continue;
90
100
  }
91
- return { ok: true, dir: path.join(documents, SUPPORT_DIR_NAME) };
101
+ return { ok: true, dir: pathFor(platform).join(documents, SUPPORT_DIR_NAME) };
92
102
  }
93
103
  return { ok: false, reason };
94
104
  }