@ngockhoale/ukit 3.3.3 → 3.4.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.
- package/CHANGELOG.md +44 -0
- package/manifests/engineConformance.yaml +17 -1
- package/manifests/hostCapabilities.yaml +68 -1
- package/manifests/platform.full.yaml +138 -0
- package/manifests/platform.user.yaml +255 -3
- package/package.json +1 -1
- package/scripts/bench/subagent-orchestrator-corpus.mjs +275 -0
- package/scripts/bench/subagent-orchestrator-eval.mjs +565 -0
- package/scripts/probe/codex-capability-probe.mjs +169 -0
- package/src/cli/commands/doctor.js +168 -0
- package/src/cli/commands/indexTools.js +7 -0
- package/src/cli/commands/metrics.js +66 -2
- package/src/cli/commands/playbook.js +4 -4
- package/src/cli/commands/vm.js +49 -8
- package/src/core/agentRuntime/adapters.js +328 -27
- package/src/core/agentRuntime/artifacts.js +89 -0
- package/src/core/agentRuntime/context.js +345 -1
- package/src/core/agentRuntime/contract.js +296 -0
- package/src/core/agentRuntime/eventStore.js +176 -0
- package/src/core/agentRuntime/shadowRun.js +481 -5
- package/src/core/agentRuntime/telemetry.js +121 -0
- package/src/core/observability/emit/lifecycle.js +68 -1
- package/src/core/observability/emit/sessionBoot.js +393 -0
- package/src/core/observability/privacy/allowlist.js +10 -1
- package/src/core/observability/schema/registry.js +10 -0
- package/src/core/runtimeConfig.js +133 -0
- package/src/core/userPlaybooks.js +18 -3
- package/src/decision/registry.js +19 -0
- package/src/diagnostics/feedbackEvents.js +7 -4
- package/src/diagnostics/routeOutcomes.js +51 -6
- package/src/diagnostics/skillAccuracy.js +43 -3
- package/src/index/crossCheckMatrix.js +412 -0
- package/src/index/fixLoopEscalation.js +453 -0
- package/src/index/playbookRegistry.js +691 -0
- package/src/index/reviewPolicy.js +368 -0
- package/src/index/routeResolver.js +915 -0
- package/src/index/sessionHistoryExtractor.js +359 -0
- package/src/index/taskRouting.js +764 -581
- package/src/index/tierSelection.js +308 -0
- package/src/index/verificationMap.js +404 -0
- package/template_project/.claude/hooks/observability-emit.mjs +14 -0
- package/template_project/.claude/hooks/record-execution.mjs +19 -1
- package/template_project/.claude/hooks/skill-router.sh +691 -25
- package/template_project/.claude/hooks/verification-guard.sh +230 -1
- package/template_project/.claude/settings.json +2 -2
- package/template_project/.claude/ukit/index/cross-check-matrix.mjs +415 -0
- package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +456 -0
- package/template_project/.claude/ukit/index/playbook-registry.mjs +690 -0
- package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +20 -2
- package/template_project/.claude/ukit/index/review-policy.mjs +376 -0
- package/template_project/.claude/ukit/index/route-resolver.mjs +1059 -0
- package/template_project/.claude/ukit/index/route-task.mjs +1253 -846
- package/template_project/.claude/ukit/index/session-history-extractor.mjs +362 -0
- package/template_project/.claude/ukit/index/tier-selection.mjs +309 -0
- package/template_project/.claude/ukit/index/verification-map.mjs +403 -0
- package/template_project/.claude/ukit/index/worktree-sweep.mjs +195 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +789 -11
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +1102 -0
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +9 -1
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +149 -5
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +323 -6
- package/template_project/.codex/README.md +8 -0
- package/template_project/.omp/hooks/pre/ukit-bridge.js +8 -1
- package/template_project/ukit/README.md +1 -1
- package/template_project/ukit/storage/config.json +20 -0
- package/template_user/playbooks/architecture-decision.md +28 -0
- package/template_user/playbooks/autonomous-run.md +43 -0
- package/template_user/playbooks/autopilot-full.md +59 -0
- package/template_user/playbooks/autopilot-stack.md +54 -0
- package/template_user/playbooks/babysit.md +39 -0
- package/template_user/playbooks/bug-fix.md +3 -1
- package/template_user/playbooks/{issue-implementation.md → feature-implementation.md} +4 -2
- package/template_user/playbooks/hillclimb.md +44 -0
- package/template_user/playbooks/investigation.md +21 -0
- package/template_user/playbooks/migration.md +21 -0
- package/template_user/playbooks/open-pr.md +48 -0
- package/template_user/playbooks/orchestrate.md +45 -0
- package/template_user/playbooks/performance.md +33 -0
- package/template_user/playbooks/prototype.md +28 -0
- package/template_user/playbooks/refactor.md +19 -0
- package/template_user/playbooks/release.md +28 -0
- package/template_user/playbooks/runtime-forensics.md +23 -0
- package/template_user/playbooks/session-pickup.md +31 -0
- package/template_user/playbooks/shipping.md +53 -0
- package/template_user/playbooks/skill-evaluation.md +48 -0
- package/template_user/playbooks/small-feature.md +20 -0
- package/template_user/playbooks/verification-map.json +153 -0
- package/template_user/playbooks/verification.md +22 -0
- package/template_user/playbooks/worktree-cleanup.md +37 -0
|
@@ -0,0 +1,1102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* observability-emit.mjs (TASK-003, SPEC §5 FR-003, §6, §14) — the installed
|
|
4
|
+
* live session-boot emit surface. Parity-locked mirror of
|
|
5
|
+
* src/core/observability/emit/sessionBoot.js: installed runtime code cannot
|
|
6
|
+
* import package source, so this file replicates the same pipeline —
|
|
7
|
+
* envelope fill → validate → sanitize → sampling decider → bounded append —
|
|
8
|
+
* against the same semantic contract (SCHEMA_VERSION 1 envelope, registry
|
|
9
|
+
* names/privacy floors, allowlist/deny-list, redaction rules). Keep this
|
|
10
|
+
* file byte-equivalent in behavior to the twin;
|
|
11
|
+
* tests/consistency/sessionBootEmitParity.test.js asserts identical
|
|
12
|
+
* persisted shape.
|
|
13
|
+
*
|
|
14
|
+
* emitSessionBoot({ projectRoot, engine, sessionId, config?, deps? })
|
|
15
|
+
* emitOutcomeObserved({ projectRoot, record, config?, deps? })
|
|
16
|
+
* resolveEmitStage(config) → 'off' | 'shadow' | 'canary' | 'default'
|
|
17
|
+
* loadEmitConfig(projectRoot) → merged emit config (never throws)
|
|
18
|
+
* runHook(ctx) — in-proc chain step: SessionStart → emitSessionBoot
|
|
19
|
+
*
|
|
20
|
+
* A hook child lives for milliseconds — startLifecycle's interval timers
|
|
21
|
+
* cannot survive it, so the emit is a bounded one-shot write per event
|
|
22
|
+
* (SPEC §14). Fire-and-forget, bounded (< SESSION_BOOT_EMIT_DEADLINE_MS),
|
|
23
|
+
* never throws, never delays the session.
|
|
24
|
+
*
|
|
25
|
+
* Segment layout parity with segments/retention.js: appends land in
|
|
26
|
+
* active.jsonl (O_APPEND, O_NOFOLLOW) with _state.json bookkeeping. The
|
|
27
|
+
* mirror does not seal/rotate segments itself — an active file that would
|
|
28
|
+
* exceed maxSegmentBytes yields a typed 'segment_full' drop rather than
|
|
29
|
+
* an unsafe half-rotation; total-segment growth past maxTotalBytes flips
|
|
30
|
+
* the _state.json disabled flag (the recorder's retention owns eviction).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import crypto from 'node:crypto';
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
|
|
37
|
+
import { scanText } from './sensitive-value-scanner.mjs';
|
|
38
|
+
|
|
39
|
+
// --- frozen schema contract (mirror of schema/constants.js + registry) ---
|
|
40
|
+
|
|
41
|
+
const SCHEMA_VERSION = 1;
|
|
42
|
+
const RECORD_TYPES = new Set(['fact', 'derived', 'evaluation', 'decision']);
|
|
43
|
+
const PRIVACY_CLASSES = Object.freeze(['public', 'internal', 'sensitive', 'private']);
|
|
44
|
+
const IMPORTANCE_LEVELS = Object.freeze(['low', 'normal', 'high', 'critical']);
|
|
45
|
+
const RESOURCE_SOURCES = new Set(['PROVIDER', 'LOCAL_TOKENIZER', 'ESTIMATED', 'UNKNOWN']);
|
|
46
|
+
const CONFIDENCE_TYPES = Object.freeze([
|
|
47
|
+
'HEURISTIC',
|
|
48
|
+
'STATISTICAL',
|
|
49
|
+
'EVALUATOR',
|
|
50
|
+
'CALIBRATED_MODEL',
|
|
51
|
+
'MODEL_SELF_REPORT',
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const ENVELOPE_FIELDS = new Set([
|
|
55
|
+
'record_type',
|
|
56
|
+
'semantic_name',
|
|
57
|
+
'schema_version',
|
|
58
|
+
'record_id',
|
|
59
|
+
'trace_id',
|
|
60
|
+
'span_id',
|
|
61
|
+
'parent_span_id',
|
|
62
|
+
'execution_id',
|
|
63
|
+
'session_id',
|
|
64
|
+
'project_ref',
|
|
65
|
+
'boot_id',
|
|
66
|
+
'writer_id',
|
|
67
|
+
'sequence',
|
|
68
|
+
'wall_time_utc',
|
|
69
|
+
'monotonic_ns',
|
|
70
|
+
'importance',
|
|
71
|
+
'privacy_class',
|
|
72
|
+
'origin',
|
|
73
|
+
'payload',
|
|
74
|
+
'agent_id',
|
|
75
|
+
'project_instance_id',
|
|
76
|
+
'sampling',
|
|
77
|
+
]);
|
|
78
|
+
const KNOWN_ENVELOPE_FIELDS = new Set([...ENVELOPE_FIELDS, 'redaction_version']);
|
|
79
|
+
const REQUIRED_ENVELOPE_FIELDS = [
|
|
80
|
+
'record_type',
|
|
81
|
+
'semantic_name',
|
|
82
|
+
'schema_version',
|
|
83
|
+
'record_id',
|
|
84
|
+
'boot_id',
|
|
85
|
+
'writer_id',
|
|
86
|
+
'sequence',
|
|
87
|
+
'wall_time_utc',
|
|
88
|
+
'importance',
|
|
89
|
+
'privacy_class',
|
|
90
|
+
'payload',
|
|
91
|
+
];
|
|
92
|
+
const OPTIONAL_ID_FIELDS = [
|
|
93
|
+
'trace_id',
|
|
94
|
+
'span_id',
|
|
95
|
+
'parent_span_id',
|
|
96
|
+
'execution_id',
|
|
97
|
+
'session_id',
|
|
98
|
+
'project_ref',
|
|
99
|
+
'agent_id',
|
|
100
|
+
];
|
|
101
|
+
const REASON_CODE_FIELDS = ['error_code', 'reason_code', 'wait_code'];
|
|
102
|
+
const MAX_ID_FIELD_CHARS = 128;
|
|
103
|
+
const ISO_8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
104
|
+
|
|
105
|
+
// Registry privacy floors (name → privacy_class) — the validator's semantic
|
|
106
|
+
// vocabulary and the sanitizer's floor stamp both read this.
|
|
107
|
+
const SEMANTIC_PRIVACY = Object.freeze({
|
|
108
|
+
'execution.started': 'internal',
|
|
109
|
+
'execution.completed': 'internal',
|
|
110
|
+
'execution.failed': 'internal',
|
|
111
|
+
'execution.blocked': 'internal',
|
|
112
|
+
'tool.started': 'sensitive',
|
|
113
|
+
'tool.completed': 'sensitive',
|
|
114
|
+
'tool.failed': 'sensitive',
|
|
115
|
+
'model.started': 'internal',
|
|
116
|
+
'model.completed': 'internal',
|
|
117
|
+
'model.failed': 'internal',
|
|
118
|
+
'cache.hit': 'internal',
|
|
119
|
+
'cache.miss': 'internal',
|
|
120
|
+
'verification.completed': 'internal',
|
|
121
|
+
'context.item.injected': 'internal',
|
|
122
|
+
'decision.made': 'internal',
|
|
123
|
+
'telemetry.dropped': 'internal',
|
|
124
|
+
'telemetry.recovered': 'internal',
|
|
125
|
+
'telemetry.redacted': 'internal',
|
|
126
|
+
'memory.write': 'internal',
|
|
127
|
+
'memory.retrieved': 'internal',
|
|
128
|
+
'retrieval.query': 'internal',
|
|
129
|
+
'outcome.observed': 'internal',
|
|
130
|
+
'anomaly.detected': 'internal',
|
|
131
|
+
'telemetry.crashed': 'internal',
|
|
132
|
+
'subagent.usage': 'internal',
|
|
133
|
+
});
|
|
134
|
+
const SEMANTIC_NAMES = new Set(Object.keys(SEMANTIC_PRIVACY));
|
|
135
|
+
|
|
136
|
+
const REASON_CODES = new Set([
|
|
137
|
+
'TOOL_TIMEOUT', 'AUTH_FAILED', 'RATE_LIMITED', 'MODEL_ERROR', 'NETWORK_ERROR',
|
|
138
|
+
'QUEUE_OVERFLOW', 'SAMPLED_OUT', 'HOST_BLIND', 'FLUSH_FAILED', 'DISK_FULL',
|
|
139
|
+
'PERMISSION_DENIED', 'SCHEMA_MISMATCH', 'CHECKSUM_MISMATCH', 'PARTIAL_TAIL',
|
|
140
|
+
'ROTATED', 'REDACTED_FIELD', 'USER_CANCELLED', 'GATE_BLOCKED',
|
|
141
|
+
'WAITING_ON_TOOL', 'WAITING_ON_MODEL', 'WAITING_ON_USER', 'CONFIG_INVALID',
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
const RESOURCE_SEMANTICS = new Set(['model.completed', 'model.failed']);
|
|
145
|
+
|
|
146
|
+
// --- frozen privacy contract (mirror of privacy/allowlist.js) ------------
|
|
147
|
+
|
|
148
|
+
const REDACTION_VERSION = 'df-redact-6';
|
|
149
|
+
const ALLOWED_FIELDS = ENVELOPE_FIELDS;
|
|
150
|
+
const DENIED_PAYLOAD_FIELDS = new Set([
|
|
151
|
+
'error_detail', 'feedback', 'comment', 'note_text', 'prompt', 'prompt_text',
|
|
152
|
+
'system_prompt', 'messages', 'command', 'cmd', 'argv', 'stdin', 'stdout',
|
|
153
|
+
'stderr', 'output', 'raw_output', 'body', 'request_body', 'response_body',
|
|
154
|
+
'content', 'text', 'diff', 'patch', 'snippet', 'env', 'dotenv', 'secret',
|
|
155
|
+
'token', 'api_key', 'apikey', 'password', 'passwd', 'credential',
|
|
156
|
+
'credentials', 'auth', 'authorization', 'cookie', 'cookies', 'session_token',
|
|
157
|
+
'private_key', 'email', 'username', 'hostname', 'host', 'ip', 'ip_address',
|
|
158
|
+
'url', 'uri', 'path', 'file_path', 'filepath', 'abs_path', 'cwd', 'home',
|
|
159
|
+
'home_dir', 'stack', 'stack_trace', 'traceback',
|
|
160
|
+
]);
|
|
161
|
+
const MAX_RECORD_BYTES = 64 * 1024;
|
|
162
|
+
const MAX_STRING_CHARS = 8 * 1024;
|
|
163
|
+
const MAX_OBJECT_KEYS = 64;
|
|
164
|
+
const MAX_ARRAY_ITEMS = 128;
|
|
165
|
+
const MAX_DEPTH = 8;
|
|
166
|
+
|
|
167
|
+
// --- redaction (mirror of privacy/redaction.js) ----------------------------
|
|
168
|
+
|
|
169
|
+
const REDACTION_RULES = [
|
|
170
|
+
{ tag: 'secret', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/g },
|
|
171
|
+
{ tag: 'secret', re: /\bsk-(?:proj-|ant-|svc-|acct-|admin-)?[A-Za-z0-9_-]{20,}/g },
|
|
172
|
+
{ tag: 'secret', re: /(?<![A-Za-z0-9+/_-])(?:AKIA|ASIA)[0-9A-Z]{16}(?![A-Za-z0-9+/_-])/g },
|
|
173
|
+
{ tag: 'secret', re: /\bgithub_pat_[A-Za-z0-9_]{20,}/g },
|
|
174
|
+
{ tag: 'secret', re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/g },
|
|
175
|
+
{ tag: 'secret', re: /\bglpat-[A-Za-z0-9_-]{20,}/g },
|
|
176
|
+
{ tag: 'secret', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g },
|
|
177
|
+
{ tag: 'secret', re: /(?<![A-Za-z0-9+/_-])AIza[0-9A-Za-z_-]{20,}(?![A-Za-z0-9+/_-])/g },
|
|
178
|
+
{ tag: 'secret', re: /\b[srp]k_live_[A-Za-z0-9]{20,}/g },
|
|
179
|
+
{ tag: 'secret', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
|
|
180
|
+
{ tag: 'secret', re: /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi },
|
|
181
|
+
{ tag: 'secret', re: /\bssh-(?:rsa|ed25519|dss)\s+[A-Za-z0-9+/=]{20,}/g },
|
|
182
|
+
{ tag: 'secret', re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]+@[^\s]+/gi },
|
|
183
|
+
{ tag: 'secret', re: /\b(?:SECRET|TOKEN|PASSWORD|PASSWD|API[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIALS?)[A-Z0-9_-]*\s*=\s*[^\s"']+/gi },
|
|
184
|
+
{ tag: 'path', re: /\/(?:[\w.@+-]+)(?:\/[\w.@+-]+)+/g },
|
|
185
|
+
{ tag: 'path', re: /\b[A-Za-z]:\\(?:[^\s"']+\\)*[^\s"']*/g },
|
|
186
|
+
{ tag: 'pii', re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g },
|
|
187
|
+
];
|
|
188
|
+
const FORBIDDEN_CHARS_RE =
|
|
189
|
+
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u00AD\u2028-\u202E\u2066-\u2069\u200B-\u200D\u2060\uFEFF\uFFF0-\uFFFF]/g;
|
|
190
|
+
|
|
191
|
+
function hasLineBreak(value) {
|
|
192
|
+
return value.includes('\n') || value.includes('\r');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function redactString(value, { scanner, maxChars }) {
|
|
196
|
+
let out = value;
|
|
197
|
+
if (hasLineBreak(out)) return '[REDACTED:multiline]';
|
|
198
|
+
for (const { tag, re } of REDACTION_RULES) {
|
|
199
|
+
re.lastIndex = 0;
|
|
200
|
+
out = out.replace(re, `[REDACTED:${tag}]`);
|
|
201
|
+
}
|
|
202
|
+
FORBIDDEN_CHARS_RE.lastIndex = 0;
|
|
203
|
+
out = out.replace(FORBIDDEN_CHARS_RE, '');
|
|
204
|
+
if (typeof scanner === 'function') {
|
|
205
|
+
let hit = false;
|
|
206
|
+
try {
|
|
207
|
+
const res = scanner(out);
|
|
208
|
+
hit = Boolean(res && res.hasSecret);
|
|
209
|
+
} catch {
|
|
210
|
+
hit = true;
|
|
211
|
+
}
|
|
212
|
+
if (hit) return '[REDACTED:secret]';
|
|
213
|
+
}
|
|
214
|
+
if (out.length > maxChars) {
|
|
215
|
+
const marker = '…[TRUNCATED]';
|
|
216
|
+
out = `${out.slice(0, Math.max(0, maxChars - marker.length))}${marker}`;
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// --- sanitize (mirror of privacy/sanitizeObserved.js) ----------------------
|
|
222
|
+
|
|
223
|
+
function sanitizeKey(key, ctx) {
|
|
224
|
+
if (typeof key !== 'string' || key.length === 0 || key.length > 128) return null;
|
|
225
|
+
const cleaned = redactString(key, { scanner: ctx.scanner, maxChars: 128 });
|
|
226
|
+
if (cleaned !== key) return null;
|
|
227
|
+
return key;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function sanitizeValue(value, ctx, depth) {
|
|
231
|
+
if (value === null) return null;
|
|
232
|
+
const t = typeof value;
|
|
233
|
+
if (t === 'boolean') return value;
|
|
234
|
+
if (t === 'number') return Number.isFinite(value) ? value : null;
|
|
235
|
+
if (t === 'string') {
|
|
236
|
+
return redactString(value, { scanner: ctx.scanner, maxChars: MAX_STRING_CHARS });
|
|
237
|
+
}
|
|
238
|
+
if (depth >= MAX_DEPTH) return undefined;
|
|
239
|
+
if (Array.isArray(value)) {
|
|
240
|
+
const out = [];
|
|
241
|
+
for (const item of value.slice(0, MAX_ARRAY_ITEMS)) {
|
|
242
|
+
const v = sanitizeValue(item, ctx, depth + 1);
|
|
243
|
+
out.push(v === undefined ? null : v);
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
if (isPlainObject(value)) {
|
|
248
|
+
const out = {};
|
|
249
|
+
let kept = 0;
|
|
250
|
+
for (const [k, v] of Object.entries(value)) {
|
|
251
|
+
if (kept >= MAX_OBJECT_KEYS) break;
|
|
252
|
+
if (DENIED_PAYLOAD_FIELDS.has(k)) continue;
|
|
253
|
+
const key = sanitizeKey(k, ctx);
|
|
254
|
+
if (key === null) continue;
|
|
255
|
+
const sv = sanitizeValue(v, ctx, depth + 1);
|
|
256
|
+
if (sv === undefined) continue;
|
|
257
|
+
out[key] = sv;
|
|
258
|
+
kept += 1;
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function sanitizeObserved(record, options = {}) {
|
|
266
|
+
try {
|
|
267
|
+
if (!isPlainObject(record)) return { ok: false, reason: 'record_not_object' };
|
|
268
|
+
const ctx = {
|
|
269
|
+
scanner: typeof options.scanner === 'function' ? options.scanner : scanText,
|
|
270
|
+
};
|
|
271
|
+
const out = {};
|
|
272
|
+
for (const [key, value] of Object.entries(record)) {
|
|
273
|
+
if (!ALLOWED_FIELDS.has(key)) continue;
|
|
274
|
+
if (key === 'payload') continue;
|
|
275
|
+
const sv = sanitizeValue(value, ctx, 0);
|
|
276
|
+
if (sv !== undefined) out[key] = sv;
|
|
277
|
+
}
|
|
278
|
+
if (!isPlainObject(record.payload)) return { ok: false, reason: 'payload_not_object' };
|
|
279
|
+
out.payload = sanitizeValue(record.payload, ctx, 0);
|
|
280
|
+
if (!PRIVACY_CLASSES.includes(out.privacy_class)) {
|
|
281
|
+
const floor = SEMANTIC_PRIVACY[out.semantic_name];
|
|
282
|
+
out.privacy_class = floor || 'internal';
|
|
283
|
+
}
|
|
284
|
+
out.redaction_version = REDACTION_VERSION;
|
|
285
|
+
let serialized;
|
|
286
|
+
try {
|
|
287
|
+
serialized = JSON.stringify(out);
|
|
288
|
+
} catch {
|
|
289
|
+
return { ok: false, reason: 'serialization_failed' };
|
|
290
|
+
}
|
|
291
|
+
if (serialized.length > MAX_RECORD_BYTES) return { ok: false, reason: 'record_too_large' };
|
|
292
|
+
return { ok: true, record: out };
|
|
293
|
+
} catch {
|
|
294
|
+
return { ok: false, reason: 'sanitizer_error' };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// --- validate (mirror of schema/validate.js, sans cross-record context) ----
|
|
299
|
+
|
|
300
|
+
function validateSemanticRecord(record) {
|
|
301
|
+
const errors = [];
|
|
302
|
+
if (!isPlainObject(record)) return { ok: false, errors: ['record must be a plain object'] };
|
|
303
|
+
|
|
304
|
+
for (const key of Object.keys(record)) {
|
|
305
|
+
if (!KNOWN_ENVELOPE_FIELDS.has(key)) errors.push(`unknown envelope field: ${key}`);
|
|
306
|
+
}
|
|
307
|
+
for (const field of REQUIRED_ENVELOPE_FIELDS) {
|
|
308
|
+
if (record[field] === undefined || record[field] === null) {
|
|
309
|
+
errors.push(`missing required field: ${field}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
313
|
+
|
|
314
|
+
if (!Number.isInteger(record.schema_version)) {
|
|
315
|
+
errors.push('schema_version must be an integer');
|
|
316
|
+
} else if (record.schema_version !== SCHEMA_VERSION) {
|
|
317
|
+
errors.push(`unsupported schema version: ${record.schema_version} (supported: ${SCHEMA_VERSION})`);
|
|
318
|
+
}
|
|
319
|
+
if (!RECORD_TYPES.has(record.record_type)) {
|
|
320
|
+
errors.push(`record_type must be one of ${[...RECORD_TYPES].join('|')}`);
|
|
321
|
+
}
|
|
322
|
+
if (!isNonEmptyString(record.semantic_name)) {
|
|
323
|
+
errors.push('semantic_name must be a non-empty string');
|
|
324
|
+
} else if (!SEMANTIC_NAMES.has(record.semantic_name)) {
|
|
325
|
+
errors.push(`unknown semantic name: ${record.semantic_name}`);
|
|
326
|
+
}
|
|
327
|
+
if (!isNonEmptyString(record.record_id)) errors.push('record_id must be a non-empty string');
|
|
328
|
+
if (!isNonEmptyString(record.boot_id)) errors.push('boot_id must be a non-empty string');
|
|
329
|
+
if (!isNonEmptyString(record.writer_id)) errors.push('writer_id must be a non-empty string');
|
|
330
|
+
if (!Number.isInteger(record.sequence) || record.sequence < 0) {
|
|
331
|
+
errors.push('sequence must be a non-negative integer');
|
|
332
|
+
}
|
|
333
|
+
if (typeof record.wall_time_utc !== 'string'
|
|
334
|
+
|| !ISO_8601_RE.test(record.wall_time_utc)
|
|
335
|
+
|| Number.isNaN(Date.parse(record.wall_time_utc))) {
|
|
336
|
+
errors.push('wall_time_utc must be an ISO-8601 timestamp string');
|
|
337
|
+
}
|
|
338
|
+
if (record.monotonic_ns !== undefined && record.monotonic_ns !== null) {
|
|
339
|
+
if (typeof record.monotonic_ns !== 'number' || !Number.isFinite(record.monotonic_ns) || record.monotonic_ns < 0) {
|
|
340
|
+
errors.push('monotonic_ns must be a non-negative number when present');
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (!IMPORTANCE_LEVELS.includes(record.importance)) {
|
|
344
|
+
errors.push('importance must be a valid importance level');
|
|
345
|
+
}
|
|
346
|
+
if (!PRIVACY_CLASSES.includes(record.privacy_class)) {
|
|
347
|
+
errors.push('privacy_class must be a valid privacy class');
|
|
348
|
+
} else {
|
|
349
|
+
const floor = SEMANTIC_PRIVACY[record.semantic_name];
|
|
350
|
+
if (floor && PRIVACY_CLASSES.indexOf(record.privacy_class) < PRIVACY_CLASSES.indexOf(floor)) {
|
|
351
|
+
errors.push(`privacy_class below registry floor ${floor} for ${record.semantic_name}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
for (const field of OPTIONAL_ID_FIELDS) {
|
|
355
|
+
const value = record[field];
|
|
356
|
+
if (value !== undefined && value !== null && typeof value !== 'string') {
|
|
357
|
+
errors.push(`${field} must be a string or null`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
for (const field of ['agent_id', 'project_instance_id']) {
|
|
361
|
+
const value = record[field];
|
|
362
|
+
if (value !== undefined && value !== null) {
|
|
363
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_ID_FIELD_CHARS) {
|
|
364
|
+
errors.push(`${field} must be a non-empty string of at most ${MAX_ID_FIELD_CHARS} characters`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (record.sampling !== undefined && record.sampling !== null) {
|
|
369
|
+
const sampling = record.sampling;
|
|
370
|
+
if (!isPlainObject(sampling)) {
|
|
371
|
+
errors.push('sampling must be an object {policy_version, rate}');
|
|
372
|
+
} else {
|
|
373
|
+
if (!isNonEmptyString(sampling.policy_version) || sampling.policy_version.length > MAX_ID_FIELD_CHARS) {
|
|
374
|
+
errors.push('sampling.policy_version invalid');
|
|
375
|
+
}
|
|
376
|
+
if (typeof sampling.rate !== 'number' || !Number.isFinite(sampling.rate) || sampling.rate < 0 || sampling.rate > 1) {
|
|
377
|
+
errors.push('sampling.rate must be a number in [0, 1]');
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (!isPlainObject(record.payload)) {
|
|
383
|
+
errors.push('payload must be an object');
|
|
384
|
+
} else {
|
|
385
|
+
const payload = record.payload;
|
|
386
|
+
if (payload.resource !== undefined && payload.resource !== null) {
|
|
387
|
+
const resource = payload.resource;
|
|
388
|
+
if (!isPlainObject(resource)) {
|
|
389
|
+
errors.push('payload.resource must be an object {value, source}');
|
|
390
|
+
} else {
|
|
391
|
+
if (!RESOURCE_SOURCES.includes(resource.source)) {
|
|
392
|
+
errors.push('payload.resource.source invalid');
|
|
393
|
+
}
|
|
394
|
+
if (resource.value !== null && resource.value !== undefined && typeof resource.value !== 'number') {
|
|
395
|
+
errors.push('payload.resource.value must be a number or null');
|
|
396
|
+
}
|
|
397
|
+
if (typeof resource.value === 'number' && resource.source === 'UNKNOWN') {
|
|
398
|
+
errors.push('payload.resource.value must be null when source is UNKNOWN');
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
for (const field of REASON_CODE_FIELDS) {
|
|
403
|
+
const code = payload[field];
|
|
404
|
+
if (code !== undefined && code !== null && !REASON_CODES.has(code)) {
|
|
405
|
+
errors.push(`payload.${field} is not in the reason-code registry`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (payload.confidence !== undefined && payload.confidence !== null) {
|
|
409
|
+
if (isPlainObject(payload.confidence)) {
|
|
410
|
+
if (!CONFIDENCE_TYPES.includes(payload.confidence.type)) {
|
|
411
|
+
errors.push('payload.confidence.type invalid');
|
|
412
|
+
}
|
|
413
|
+
if (typeof payload.confidence.value !== 'number' || !Number.isFinite(payload.confidence.value)) {
|
|
414
|
+
errors.push('payload.confidence.value must be a finite number');
|
|
415
|
+
}
|
|
416
|
+
} else if (!CONFIDENCE_TYPES.includes(payload.confidence)) {
|
|
417
|
+
errors.push('payload.confidence invalid');
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (payload.telemetry_complete !== undefined && typeof payload.telemetry_complete !== 'boolean') {
|
|
421
|
+
errors.push('payload.telemetry_complete must be a boolean');
|
|
422
|
+
}
|
|
423
|
+
if (payload.duration_ms !== undefined && payload.duration_ms !== null) {
|
|
424
|
+
if (typeof payload.duration_ms !== 'number' || !Number.isFinite(payload.duration_ms) || payload.duration_ms < 0) {
|
|
425
|
+
errors.push('payload.duration_ms must be a non-negative finite number');
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const origin = record.origin;
|
|
431
|
+
switch (record.record_type) {
|
|
432
|
+
case 'fact':
|
|
433
|
+
if (origin !== undefined && origin !== null && !isPlainObject(origin)) {
|
|
434
|
+
errors.push('origin must be an object when present');
|
|
435
|
+
}
|
|
436
|
+
break;
|
|
437
|
+
case 'derived':
|
|
438
|
+
if (!isPlainObject(origin)) errors.push('record_type "derived" requires origin with derived_from');
|
|
439
|
+
else if (!Array.isArray(origin.derived_from) || origin.derived_from.length === 0) {
|
|
440
|
+
errors.push('origin.derived_from must be a non-empty array of source record refs');
|
|
441
|
+
}
|
|
442
|
+
break;
|
|
443
|
+
case 'evaluation':
|
|
444
|
+
if (!isPlainObject(origin)) errors.push('record_type "evaluation" requires origin with evaluator and confidence');
|
|
445
|
+
else {
|
|
446
|
+
if (!isNonEmptyString(origin.evaluator)) errors.push('origin.evaluator must name the evaluator');
|
|
447
|
+
if (!CONFIDENCE_TYPES.includes(origin.confidence)) errors.push('origin.confidence invalid');
|
|
448
|
+
}
|
|
449
|
+
break;
|
|
450
|
+
case 'decision':
|
|
451
|
+
if (!isPlainObject(origin)) errors.push('record_type "decision" requires origin with experiment provenance');
|
|
452
|
+
else if (!isNonEmptyString(origin.experiment)) errors.push('origin.experiment invalid');
|
|
453
|
+
break;
|
|
454
|
+
default:
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return { ok: errors.length === 0, errors };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// --- emit config + sampling (mirror of emit/config.js + recorder decider) --
|
|
462
|
+
|
|
463
|
+
const VALID_STAGES = new Set(['off', 'shadow', 'canary', 'default']);
|
|
464
|
+
|
|
465
|
+
function resolveStage(config = null) {
|
|
466
|
+
const node = isPlainObject(config) ? config.observability : undefined;
|
|
467
|
+
const stage = isPlainObject(node) ? node.stage : undefined;
|
|
468
|
+
return VALID_STAGES.has(stage) ? stage : 'off';
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const SAMPLING_RATE_KEYS = new Set(['low', 'normal', 'high', 'critical']);
|
|
472
|
+
const MAX_POLICY_VERSION_CHARS = 128;
|
|
473
|
+
const SAMPLING_BUCKETS = 10000;
|
|
474
|
+
const SAMPLING_SEED = 0;
|
|
475
|
+
let samplingConfigInvalidSeen = false;
|
|
476
|
+
|
|
477
|
+
function resolveSampling(config = null) {
|
|
478
|
+
const node = isPlainObject(config) ? config.observability : undefined;
|
|
479
|
+
if (!isPlainObject(node)) return null;
|
|
480
|
+
const sampling = node.sampling;
|
|
481
|
+
if (sampling === undefined || sampling === null) return null;
|
|
482
|
+
const invalid = () => {
|
|
483
|
+
samplingConfigInvalidSeen = true;
|
|
484
|
+
return null;
|
|
485
|
+
};
|
|
486
|
+
if (!isPlainObject(sampling)) return invalid();
|
|
487
|
+
const { policy_version, rates } = sampling;
|
|
488
|
+
if (
|
|
489
|
+
typeof policy_version !== 'string'
|
|
490
|
+
|| policy_version.length === 0
|
|
491
|
+
|| policy_version.length > MAX_POLICY_VERSION_CHARS
|
|
492
|
+
) {
|
|
493
|
+
return invalid();
|
|
494
|
+
}
|
|
495
|
+
if (!isPlainObject(rates)) return invalid();
|
|
496
|
+
const out = {};
|
|
497
|
+
for (const [key, value] of Object.entries(rates)) {
|
|
498
|
+
if (!SAMPLING_RATE_KEYS.has(key)) return invalid();
|
|
499
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) {
|
|
500
|
+
return invalid();
|
|
501
|
+
}
|
|
502
|
+
out[key] = value;
|
|
503
|
+
}
|
|
504
|
+
for (const level of SAMPLING_RATE_KEYS) {
|
|
505
|
+
if (out[level] === undefined) out[level] = 1;
|
|
506
|
+
}
|
|
507
|
+
return { policy_version, rates: out };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function samplingBucket(recordId, seed) {
|
|
511
|
+
let h = (seed >>> 0) ^ 0x9747b28c;
|
|
512
|
+
for (let i = 0; i < recordId.length; i += 1) {
|
|
513
|
+
h = Math.imul(h ^ recordId.charCodeAt(i), 0x5bd1e995);
|
|
514
|
+
h = (h << 15) | (h >>> 17);
|
|
515
|
+
}
|
|
516
|
+
h ^= h >>> 15;
|
|
517
|
+
h = Math.imul(h, 0x85ebca6b);
|
|
518
|
+
h ^= h >>> 13;
|
|
519
|
+
return (h >>> 0) % SAMPLING_BUCKETS;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function effectiveSamplingRate(policy, importance) {
|
|
523
|
+
if (importance === 'critical') return 1;
|
|
524
|
+
const rate = policy.rates[importance];
|
|
525
|
+
return typeof rate === 'number' ? rate : 1;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function keptBySampling(recordId, rate, seed) {
|
|
529
|
+
if (rate >= 1) return true;
|
|
530
|
+
if (rate <= 0) return false;
|
|
531
|
+
if (typeof recordId !== 'string' || recordId.length === 0) return true;
|
|
532
|
+
return samplingBucket(recordId, seed) < Math.round(rate * SAMPLING_BUCKETS);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function stampSampling(candidate, policy) {
|
|
536
|
+
if (policy !== null && isPlainObject(candidate)) {
|
|
537
|
+
candidate.sampling = {
|
|
538
|
+
policy_version: policy.policy_version,
|
|
539
|
+
rate: effectiveSamplingRate(policy, candidate.importance),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// --- emit module -----------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
/** Hard upper bound for the whole one-shot emit (ms). */
|
|
547
|
+
export const SESSION_BOOT_EMIT_DEADLINE_MS = 250;
|
|
548
|
+
|
|
549
|
+
const ACTIVE_FILE = 'active.jsonl';
|
|
550
|
+
const STATE_FILE = '_state.json';
|
|
551
|
+
const MAX_SEGMENT_BYTES = 8 * 1024 * 1024;
|
|
552
|
+
const MAX_TOTAL_BYTES = 256 * 1024 * 1024;
|
|
553
|
+
const IO_TIMEOUT_MS = 5000;
|
|
554
|
+
|
|
555
|
+
const STATE_DEFAULTS = Object.freeze({
|
|
556
|
+
next_segment_seq: 1,
|
|
557
|
+
expired_segments: 0,
|
|
558
|
+
quarantined_segments: 0,
|
|
559
|
+
disabled: false,
|
|
560
|
+
active_since_ms: null,
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
const SEMANTIC_EXECUTION_STARTED = 'execution.started';
|
|
564
|
+
const SEMANTIC_OUTCOME_OBSERVED = 'outcome.observed';
|
|
565
|
+
|
|
566
|
+
function isPlainObject(value) {
|
|
567
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function isNonEmptyString(value) {
|
|
571
|
+
return typeof value === 'string' && value.length > 0;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function hasTraversal(rawPath) {
|
|
575
|
+
return String(rawPath).split(/[\\/]+/).includes('..');
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function observabilityRoot(projectRoot) {
|
|
579
|
+
return path.join(projectRoot, '.ukit', 'storage', 'observability');
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function segmentsRoot(projectRoot) {
|
|
583
|
+
return path.join(observabilityRoot(projectRoot), 'segments');
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Deterministic session-boot join key — same as the canonical twin. */
|
|
587
|
+
export function sessionBootRequestKey({ projectRoot, sessionId } = {}) {
|
|
588
|
+
const digest = crypto
|
|
589
|
+
.createHash('sha256')
|
|
590
|
+
.update(`${String(projectRoot ?? '')}\n${String(sessionId ?? '')}`)
|
|
591
|
+
.digest('hex')
|
|
592
|
+
.slice(0, 24);
|
|
593
|
+
return `session-boot-${digest}`;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function buildSessionBootRecord({
|
|
597
|
+
projectRoot,
|
|
598
|
+
engine,
|
|
599
|
+
sessionId,
|
|
600
|
+
nowMs = Date.now(),
|
|
601
|
+
} = {}) {
|
|
602
|
+
if (!isNonEmptyString(projectRoot)) return null;
|
|
603
|
+
const trace_id = `trace-${crypto.randomUUID()}`;
|
|
604
|
+
return {
|
|
605
|
+
semantic_name: SEMANTIC_EXECUTION_STARTED,
|
|
606
|
+
trace_id,
|
|
607
|
+
span_id: `span-${crypto.randomUUID()}`,
|
|
608
|
+
parent_span_id: null,
|
|
609
|
+
execution_id: `exec-${crypto.randomUUID()}`,
|
|
610
|
+
session_id: isNonEmptyString(sessionId) ? sessionId : undefined,
|
|
611
|
+
project_ref: `proj-${crypto
|
|
612
|
+
.createHash('sha256')
|
|
613
|
+
.update(String(path.resolve(projectRoot)))
|
|
614
|
+
.digest('hex')
|
|
615
|
+
.slice(0, 12)}`,
|
|
616
|
+
payload: {
|
|
617
|
+
operation: 'session-boot',
|
|
618
|
+
duration_ms: null,
|
|
619
|
+
engine: isNonEmptyString(engine) ? engine : 'unknown',
|
|
620
|
+
sessionId: isNonEmptyString(sessionId) ? sessionId : 'unknown',
|
|
621
|
+
ts: new Date(nowMs).toISOString(),
|
|
622
|
+
request_key: sessionBootRequestKey({ projectRoot, sessionId }),
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export function resolveEmitStage(config = null) {
|
|
628
|
+
const node = isPlainObject(config) ? config.observability : undefined;
|
|
629
|
+
if (isPlainObject(node) && node.stage === 'off') return 'off';
|
|
630
|
+
const stage = resolveStage(config);
|
|
631
|
+
return stage === 'off' ? 'default' : stage;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
export async function loadEmitConfig(projectRoot) {
|
|
635
|
+
if (!isNonEmptyString(projectRoot)) return null;
|
|
636
|
+
try {
|
|
637
|
+
const raw = await fs.promises.readFile(
|
|
638
|
+
path.join(projectRoot, '.ukit', 'storage', 'config.json'),
|
|
639
|
+
'utf8',
|
|
640
|
+
);
|
|
641
|
+
const parsed = JSON.parse(raw);
|
|
642
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
643
|
+
} catch {
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function withTimeout(promise, ms) {
|
|
649
|
+
if (ms <= 0) {
|
|
650
|
+
return Promise.reject(Object.assign(new Error('io timeout'), { code: 'IO_TIMEOUT' }));
|
|
651
|
+
}
|
|
652
|
+
let timer;
|
|
653
|
+
const timeout = new Promise((_, reject) => {
|
|
654
|
+
timer = setTimeout(() => {
|
|
655
|
+
reject(Object.assign(new Error('io timeout'), { code: 'IO_TIMEOUT' }));
|
|
656
|
+
}, ms);
|
|
657
|
+
if (timer.unref) timer.unref();
|
|
658
|
+
});
|
|
659
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async function readState(root, timeoutMs) {
|
|
663
|
+
try {
|
|
664
|
+
const raw = await withTimeout(fs.promises.readFile(path.join(root, STATE_FILE), 'utf8'), timeoutMs);
|
|
665
|
+
const parsed = JSON.parse(raw);
|
|
666
|
+
return { ...STATE_DEFAULTS, ...(parsed && typeof parsed === 'object' ? parsed : {}) };
|
|
667
|
+
} catch {
|
|
668
|
+
return { ...STATE_DEFAULTS };
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async function writeState(root, state, timeoutMs) {
|
|
673
|
+
try {
|
|
674
|
+
await withTimeout(
|
|
675
|
+
fs.promises.writeFile(path.join(root, STATE_FILE), JSON.stringify(state, null, 2)),
|
|
676
|
+
timeoutMs,
|
|
677
|
+
);
|
|
678
|
+
return true;
|
|
679
|
+
} catch {
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Bounded append: replicate appendRecord's durable invariants (mkdir, state
|
|
685
|
+
// gate, symlink rejection, O_APPEND, active_since stamp) without the seal
|
|
686
|
+
// lane — an oversized active file drops typed, the total cap flips disabled.
|
|
687
|
+
async function appendBounded(root, record, { nowMs } = {}) {
|
|
688
|
+
const timeoutMs = IO_TIMEOUT_MS;
|
|
689
|
+
if (typeof root !== 'string' || root.length === 0 || hasTraversal(root)) {
|
|
690
|
+
return { ok: false, reason: 'unsafe_root' };
|
|
691
|
+
}
|
|
692
|
+
const line = JSON.stringify(record) + '\n';
|
|
693
|
+
const lineBytes = Buffer.byteLength(line, 'utf8');
|
|
694
|
+
try {
|
|
695
|
+
const st = await withTimeout(fs.promises.lstat(root), timeoutMs);
|
|
696
|
+
if (!st.isDirectory()) return { ok: false, reason: 'root_not_directory' };
|
|
697
|
+
} catch (err) {
|
|
698
|
+
if (!err || err.code !== 'ENOENT') return { ok: false, reason: `io_${err?.code || 'error'}` };
|
|
699
|
+
}
|
|
700
|
+
try {
|
|
701
|
+
await withTimeout(fs.promises.mkdir(root, { recursive: true }), timeoutMs);
|
|
702
|
+
} catch (err) {
|
|
703
|
+
return { ok: false, reason: `io_${err?.code || 'error'}` };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const state = await readState(root, timeoutMs);
|
|
707
|
+
if (state.disabled) return { ok: false, reason: 'disabled' };
|
|
708
|
+
|
|
709
|
+
const activePath = path.join(root, ACTIVE_FILE);
|
|
710
|
+
try {
|
|
711
|
+
const st = await withTimeout(fs.promises.lstat(activePath), timeoutMs);
|
|
712
|
+
if (st.isSymbolicLink()) return { ok: false, reason: 'unsafe_path' };
|
|
713
|
+
if (st.isFile() && st.size > 0 && st.size + lineBytes > MAX_SEGMENT_BYTES) {
|
|
714
|
+
return { ok: false, reason: 'segment_full' };
|
|
715
|
+
}
|
|
716
|
+
} catch (err) {
|
|
717
|
+
if (!err || err.code !== 'ENOENT') return { ok: false, reason: `io_${err?.code || 'error'}` };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const appendFlags =
|
|
721
|
+
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND |
|
|
722
|
+
(fs.constants.O_NOFOLLOW || 0);
|
|
723
|
+
try {
|
|
724
|
+
await withTimeout(fs.promises.appendFile(activePath, line, { flag: appendFlags }), timeoutMs);
|
|
725
|
+
} catch (err) {
|
|
726
|
+
return { ok: false, reason: `io_${err?.code || 'error'}` };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (state.active_since_ms === null || state.active_since_ms === undefined) {
|
|
730
|
+
state.active_since_ms = Number.isFinite(nowMs) ? nowMs : Date.now();
|
|
731
|
+
await writeState(root, state, timeoutMs);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Total-size bound (retention-lite): past the hard cap the store marks
|
|
735
|
+
// itself disabled — future emits drop typed rather than grow unbounded.
|
|
736
|
+
try {
|
|
737
|
+
let total = 0;
|
|
738
|
+
for (const name of await withTimeout(fs.promises.readdir(root), timeoutMs)) {
|
|
739
|
+
if (name === ACTIVE_FILE || name.endsWith('.jsonl') || name.endsWith('.jsonl.zst')) {
|
|
740
|
+
try {
|
|
741
|
+
total += (await withTimeout(fs.promises.stat(path.join(root, name)), timeoutMs)).size;
|
|
742
|
+
} catch { /* stat race — skip */ }
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
if (total > MAX_TOTAL_BYTES) {
|
|
746
|
+
await writeState(root, { ...state, disabled: true }, timeoutMs);
|
|
747
|
+
}
|
|
748
|
+
} catch { /* accounting failure never fails the write */ }
|
|
749
|
+
|
|
750
|
+
return { ok: true, written: true };
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
async function bounded(promise, deadlineMs) {
|
|
754
|
+
if (!Number.isFinite(deadlineMs) || deadlineMs <= 0) {
|
|
755
|
+
return { timedOut: true };
|
|
756
|
+
}
|
|
757
|
+
let timer;
|
|
758
|
+
try {
|
|
759
|
+
const result = await Promise.race([
|
|
760
|
+
Promise.resolve(promise).then((value) => ({ value })),
|
|
761
|
+
new Promise((resolve) => {
|
|
762
|
+
timer = setTimeout(() => resolve({ timedOut: true }), deadlineMs);
|
|
763
|
+
if (timer.unref) timer.unref();
|
|
764
|
+
}),
|
|
765
|
+
]);
|
|
766
|
+
return result;
|
|
767
|
+
} finally {
|
|
768
|
+
clearTimeout(timer);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function makeClock(deps) {
|
|
773
|
+
return {
|
|
774
|
+
nowMs:
|
|
775
|
+
typeof deps.nowMs === 'function'
|
|
776
|
+
? deps.nowMs
|
|
777
|
+
: () => (Number.isFinite(deps.nowMs) ? deps.nowMs : Date.now()),
|
|
778
|
+
nowNs:
|
|
779
|
+
typeof deps.nowNs === 'function'
|
|
780
|
+
? deps.nowNs
|
|
781
|
+
: () => (Number.isFinite(deps.nowNs) ? deps.nowNs : Number(process.hrtime.bigint())),
|
|
782
|
+
iso:
|
|
783
|
+
typeof deps.iso === 'function'
|
|
784
|
+
? deps.iso
|
|
785
|
+
: () => (typeof deps.iso === 'string' ? deps.iso : new Date().toISOString()),
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Envelope fill — the recorder's fillEnvelope verbatim: caller ids win,
|
|
790
|
+
// missing fields are stamped by this boot.
|
|
791
|
+
function fillEnvelope(record, state, time) {
|
|
792
|
+
const out = { ...record };
|
|
793
|
+
if (out.record_type === undefined || out.record_type === null) out.record_type = 'fact';
|
|
794
|
+
if (out.schema_version === undefined || out.schema_version === null) {
|
|
795
|
+
out.schema_version = SCHEMA_VERSION;
|
|
796
|
+
}
|
|
797
|
+
if (typeof out.record_id !== 'string' || out.record_id.length === 0) {
|
|
798
|
+
out.record_id = `rec-${crypto.randomUUID()}`;
|
|
799
|
+
}
|
|
800
|
+
if (typeof out.boot_id !== 'string' || out.boot_id.length === 0) out.boot_id = state.boot_id;
|
|
801
|
+
if (typeof out.writer_id !== 'string' || out.writer_id.length === 0) {
|
|
802
|
+
out.writer_id = state.writer_id;
|
|
803
|
+
}
|
|
804
|
+
if (!Number.isInteger(out.sequence)) out.sequence = ++state.sequence;
|
|
805
|
+
if (typeof out.wall_time_utc !== 'string') out.wall_time_utc = time.iso();
|
|
806
|
+
if (out.monotonic_ns === undefined || out.monotonic_ns === null) {
|
|
807
|
+
out.monotonic_ns = Number(time.nowNs());
|
|
808
|
+
}
|
|
809
|
+
if (typeof out.importance !== 'string') out.importance = 'normal';
|
|
810
|
+
if (typeof out.privacy_class !== 'string') {
|
|
811
|
+
out.privacy_class = SEMANTIC_PRIVACY[out.semantic_name] || 'internal';
|
|
812
|
+
}
|
|
813
|
+
if (isPlainObject(out.payload)) {
|
|
814
|
+
out.payload = { ...out.payload };
|
|
815
|
+
if (RESOURCE_SEMANTICS.has(out.semantic_name) && out.payload.resource === undefined) {
|
|
816
|
+
out.payload.resource = { value: null, source: 'UNKNOWN' };
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return out;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async function emitOne({ projectRoot, config, record, deps, deadlineMs }) {
|
|
823
|
+
const started = Date.now();
|
|
824
|
+
const fail = (reason) => ({
|
|
825
|
+
status: 'dropped',
|
|
826
|
+
reason,
|
|
827
|
+
elapsed_ms: Math.max(0, Date.now() - started),
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
if (!isNonEmptyString(projectRoot)) return fail('missing-project-root');
|
|
831
|
+
if (!isPlainObject(record)) return fail('invalid-record');
|
|
832
|
+
if (resolveEmitStage(config) === 'off') return fail('stage-off');
|
|
833
|
+
|
|
834
|
+
const injected = isPlainObject(deps) ? deps : {};
|
|
835
|
+
const time = makeClock(injected);
|
|
836
|
+
const seed = Number.isFinite(injected.samplingSeed) ? injected.samplingSeed >>> 0 : SAMPLING_SEED;
|
|
837
|
+
|
|
838
|
+
const effectiveConfig = isPlainObject(config)
|
|
839
|
+
? {
|
|
840
|
+
...config,
|
|
841
|
+
observability: {
|
|
842
|
+
...(isPlainObject(config.observability) ? config.observability : {}),
|
|
843
|
+
stage: resolveEmitStage(config),
|
|
844
|
+
},
|
|
845
|
+
}
|
|
846
|
+
: { observability: { stage: 'default' } };
|
|
847
|
+
|
|
848
|
+
const budget = Number.isFinite(deadlineMs) && deadlineMs > 0
|
|
849
|
+
? Math.min(deadlineMs, SESSION_BOOT_EMIT_DEADLINE_MS)
|
|
850
|
+
: SESSION_BOOT_EMIT_DEADLINE_MS;
|
|
851
|
+
|
|
852
|
+
try {
|
|
853
|
+
const policy = resolveSampling(effectiveConfig);
|
|
854
|
+
const state = {
|
|
855
|
+
boot_id: `boot-${crypto.randomUUID()}`,
|
|
856
|
+
writer_id: `recorder-${crypto.randomUUID()}`,
|
|
857
|
+
sequence: 0,
|
|
858
|
+
};
|
|
859
|
+
const candidate = fillEnvelope(record, state, time);
|
|
860
|
+
stampSampling(candidate, policy);
|
|
861
|
+
const validation = validateSemanticRecord(candidate);
|
|
862
|
+
if (!validation.ok) return fail('invalid');
|
|
863
|
+
const clean = sanitizeObserved(candidate);
|
|
864
|
+
if (!clean.ok) return fail('sanitize-rejected');
|
|
865
|
+
if (policy !== null && !keptBySampling(candidate.record_id, candidate.sampling.rate, seed)) {
|
|
866
|
+
// Recorder parity: a sampled drop emits one aggregate self-record —
|
|
867
|
+
// the one-shot seam writes it directly (no queue) when it lands.
|
|
868
|
+
const aggregate = fillEnvelope({
|
|
869
|
+
semantic_name: 'telemetry.dropped',
|
|
870
|
+
payload: { reason_code: 'SAMPLED_OUT', dropped_count: 1 },
|
|
871
|
+
}, state, time);
|
|
872
|
+
stampSampling(aggregate, policy);
|
|
873
|
+
const aggregateClean = sanitizeObserved(aggregate);
|
|
874
|
+
if (aggregateClean.ok) {
|
|
875
|
+
await bounded(appendBounded(segmentsRoot(path.resolve(projectRoot)), aggregateClean.record, { nowMs: time.nowMs() }), budget);
|
|
876
|
+
}
|
|
877
|
+
return fail('sampled-out');
|
|
878
|
+
}
|
|
879
|
+
if (samplingConfigInvalidSeen && !state.configInvalidReported) {
|
|
880
|
+
state.configInvalidReported = true;
|
|
881
|
+
const aggregate = fillEnvelope({
|
|
882
|
+
semantic_name: 'telemetry.dropped',
|
|
883
|
+
payload: { reason_code: 'CONFIG_INVALID', dropped_count: 1 },
|
|
884
|
+
}, state, time);
|
|
885
|
+
const aggregateClean = sanitizeObserved(aggregate);
|
|
886
|
+
if (aggregateClean.ok) {
|
|
887
|
+
await bounded(appendBounded(segmentsRoot(path.resolve(projectRoot)), aggregateClean.record, { nowMs: time.nowMs() }), budget);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
const writer =
|
|
891
|
+
typeof injected.writer === 'function'
|
|
892
|
+
? (rec) => injected.writer(rec)
|
|
893
|
+
: (rec) => appendBounded(segmentsRoot(path.resolve(projectRoot)), rec, { nowMs: time.nowMs() });
|
|
894
|
+
const written = await bounded(Promise.resolve().then(() => writer(clean.record)), budget);
|
|
895
|
+
if (written.timedOut) {
|
|
896
|
+
return { status: 'partial', reason: 'deadline', elapsed_ms: Math.max(0, Date.now() - started) };
|
|
897
|
+
}
|
|
898
|
+
const result = written.value;
|
|
899
|
+
if (result && result.ok) {
|
|
900
|
+
return { status: 'written', written: 1, elapsed_ms: Math.max(0, Date.now() - started) };
|
|
901
|
+
}
|
|
902
|
+
return {
|
|
903
|
+
status: 'dropped',
|
|
904
|
+
reason: (result && result.reason) || 'write-failed',
|
|
905
|
+
elapsed_ms: Math.max(0, Date.now() - started),
|
|
906
|
+
};
|
|
907
|
+
} catch {
|
|
908
|
+
return fail('emit-error');
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
export async function emitSessionBoot({
|
|
913
|
+
projectRoot,
|
|
914
|
+
engine,
|
|
915
|
+
sessionId,
|
|
916
|
+
config,
|
|
917
|
+
deps,
|
|
918
|
+
deadlineMs,
|
|
919
|
+
} = {}) {
|
|
920
|
+
const injected = isPlainObject(deps) ? deps : {};
|
|
921
|
+
const nowMs = typeof injected.nowMs === 'function' ? injected.nowMs() : injected.nowMs;
|
|
922
|
+
const record = buildSessionBootRecord({
|
|
923
|
+
projectRoot,
|
|
924
|
+
engine: isNonEmptyString(engine) ? engine : 'unknown',
|
|
925
|
+
sessionId,
|
|
926
|
+
nowMs: Number.isFinite(nowMs) ? nowMs : Date.now(),
|
|
927
|
+
});
|
|
928
|
+
return emitOne({ projectRoot, config, record, deps: injected, deadlineMs });
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
export async function emitOutcomeObserved({
|
|
932
|
+
projectRoot,
|
|
933
|
+
record,
|
|
934
|
+
config,
|
|
935
|
+
deps,
|
|
936
|
+
deadlineMs,
|
|
937
|
+
} = {}) {
|
|
938
|
+
const candidate = isPlainObject(record) ? { ...record } : record;
|
|
939
|
+
if (isPlainObject(candidate) && candidate.semantic_name === undefined) {
|
|
940
|
+
candidate.semantic_name = SEMANTIC_OUTCOME_OBSERVED;
|
|
941
|
+
}
|
|
942
|
+
return emitOne({
|
|
943
|
+
projectRoot,
|
|
944
|
+
config,
|
|
945
|
+
record: candidate,
|
|
946
|
+
deps: isPlainObject(deps) ? deps : {},
|
|
947
|
+
deadlineMs,
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
// --- TASK-005 (FR-005, BL-007): terminal OutcomeRecord construction ---------
|
|
953
|
+
// The three terminal emitters (record-execution.mjs receipts,
|
|
954
|
+
// stop-coordinator.mjs verdicts, execution-ledger.mjs finalizers) share ONE
|
|
955
|
+
// record builder so every `outcome.observed` record carries the ARCH
|
|
956
|
+
// §Data Contracts OutcomeRecord fields plus the TASK-003 session-boot
|
|
957
|
+
// `request_key` join id — the missing 1-of-3934 emitter surface (TM §2).
|
|
958
|
+
|
|
959
|
+
export const OUTCOME_VERDICTS = Object.freeze([
|
|
960
|
+
'VERIFIED', 'FAILED', 'BLOCKED', 'INCONCLUSIVE', 'INTERRUPTED',
|
|
961
|
+
]);
|
|
962
|
+
|
|
963
|
+
const OUTCOME_ENFORCEMENTS = new Set(['enforced', 'advisory']);
|
|
964
|
+
|
|
965
|
+
// FR-005/FR-006: `advisory` is the Codex marker — the engine stamp may arrive
|
|
966
|
+
// as an explicit `engine` argument or as `env.UKIT_HARNESS` (the stamp the
|
|
967
|
+
// .sh wrappers and the omp bridge export). Anything else is `enforced`.
|
|
968
|
+
export function deriveEnforcement({ engine = null, env = null } = {}) {
|
|
969
|
+
const fromArg = isNonEmptyString(engine) ? engine : null;
|
|
970
|
+
const fromEnv = isPlainObject(env) && isNonEmptyString(env.UKIT_HARNESS) ? env.UKIT_HARNESS : null;
|
|
971
|
+
return (fromArg === 'codex' || fromEnv === 'codex') ? 'advisory' : 'enforced';
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// The run id is the ledger's session identity: `run-<safe session id>` when a
|
|
975
|
+
// session is known, `run-transcript-<sha256[:12]>` for transcript-only
|
|
976
|
+
// payloads (never the raw path — it would be path-redacted anyway), else null.
|
|
977
|
+
export function outcomeRunId(payload = {}) {
|
|
978
|
+
const sessionId = isNonEmptyString(payload.session_id)
|
|
979
|
+
? payload.session_id
|
|
980
|
+
: (isNonEmptyString(payload.sessionId) ? payload.sessionId : null);
|
|
981
|
+
if (sessionId) {
|
|
982
|
+
const safe = sessionId.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96);
|
|
983
|
+
return `run-${safe}`;
|
|
984
|
+
}
|
|
985
|
+
const transcript = isNonEmptyString(payload.transcript_path)
|
|
986
|
+
? payload.transcript_path
|
|
987
|
+
: (isNonEmptyString(payload.transcriptPath) ? payload.transcriptPath : null);
|
|
988
|
+
if (transcript) {
|
|
989
|
+
const digest = crypto.createHash('sha256').update(String(transcript)).digest('hex').slice(0, 12);
|
|
990
|
+
return `run-transcript-${digest}`;
|
|
991
|
+
}
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function outcomeStringList(value, cap) {
|
|
996
|
+
if (!Array.isArray(value)) return [];
|
|
997
|
+
return value
|
|
998
|
+
.filter((item) => typeof item === 'string' && item.length > 0)
|
|
999
|
+
.map((item) => item.slice(0, 160))
|
|
1000
|
+
.slice(0, cap);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function outcomeCost(value) {
|
|
1004
|
+
if (!isPlainObject(value)) return { latencyMs: null, rounds: null };
|
|
1005
|
+
const cost = value;
|
|
1006
|
+
return {
|
|
1007
|
+
latencyMs: Number.isFinite(cost.latencyMs) && cost.latencyMs >= 0 ? cost.latencyMs : null,
|
|
1008
|
+
rounds: Number.isFinite(cost.rounds) && cost.rounds >= 0 ? cost.rounds : null,
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* buildOutcomeObservedRecord({ outcome, projectRoot, payload, sessionId,
|
|
1014
|
+
* engine, env }) → semantic-record candidate for `outcome.observed`.
|
|
1015
|
+
*
|
|
1016
|
+
* `outcome` is the ARCH §Data Contracts OutcomeRecord subset the emitter knows:
|
|
1017
|
+
* { outcomeId?, runId?, routeId?, routeFingerprint?, ledgerKey?, verdict,
|
|
1018
|
+
* evidenceIds?, cost?, reviewActions?, enforcement?, engine?, source?, ts? }
|
|
1019
|
+
* Every field defaults sanely — a terminal event without runId/route data
|
|
1020
|
+
* still emits with nulls (task case 4) and never blocks the caller's path.
|
|
1021
|
+
* `payload` is the hook payload: it supplies session identity for runId
|
|
1022
|
+
* derivation, the session-boot request_key join, and the envelope session_id.
|
|
1023
|
+
* `enforcement` derives per FR-006 unless the emitter overrode it.
|
|
1024
|
+
*/
|
|
1025
|
+
export function buildOutcomeObservedRecord({
|
|
1026
|
+
outcome = null,
|
|
1027
|
+
projectRoot = null,
|
|
1028
|
+
payload = {},
|
|
1029
|
+
sessionId = null,
|
|
1030
|
+
engine = null,
|
|
1031
|
+
env = null,
|
|
1032
|
+
} = {}) {
|
|
1033
|
+
const safe = isPlainObject(outcome) ? outcome : {};
|
|
1034
|
+
const resolvedSessionId = isNonEmptyString(sessionId)
|
|
1035
|
+
? sessionId
|
|
1036
|
+
: (isNonEmptyString(payload?.session_id)
|
|
1037
|
+
? payload.session_id
|
|
1038
|
+
: (isNonEmptyString(payload?.sessionId) ? payload.sessionId : null));
|
|
1039
|
+
const engineName = isNonEmptyString(engine)
|
|
1040
|
+
? engine
|
|
1041
|
+
: (isNonEmptyString(safe.engine) ? safe.engine : null);
|
|
1042
|
+
const verdict = OUTCOME_VERDICTS.includes(safe.verdict) ? safe.verdict : 'INCONCLUSIVE';
|
|
1043
|
+
const recordPayload = {
|
|
1044
|
+
outcomeId: isNonEmptyString(safe.outcomeId) ? safe.outcomeId : `oc-${crypto.randomUUID()}`,
|
|
1045
|
+
runId: safe.runId !== undefined ? safe.runId : outcomeRunId(payload),
|
|
1046
|
+
routeId: safe.routeId !== undefined ? safe.routeId : null,
|
|
1047
|
+
routeFingerprint: safe.routeFingerprint !== undefined ? safe.routeFingerprint : null,
|
|
1048
|
+
ledgerKey: safe.ledgerKey !== undefined ? safe.ledgerKey : null,
|
|
1049
|
+
verdict,
|
|
1050
|
+
evidenceIds: outcomeStringList(safe.evidenceIds, 32),
|
|
1051
|
+
cost: outcomeCost(safe.cost),
|
|
1052
|
+
reviewActions: outcomeStringList(safe.reviewActions, 32),
|
|
1053
|
+
// TASK-C85-016 (BL-016): per-pair tracking — nullable, additive. The
|
|
1054
|
+
// join key for implementer↔reviewer outcome-rate measurement
|
|
1055
|
+
// (ARCH §Adaptive Cross-Check calibration).
|
|
1056
|
+
implementer: isNonEmptyString(safe.implementer) ? safe.implementer.slice(0, 96) : null,
|
|
1057
|
+
reviewer: isNonEmptyString(safe.reviewer) ? safe.reviewer.slice(0, 96) : null,
|
|
1058
|
+
reviewVerdict: isNonEmptyString(safe.reviewVerdict) ? safe.reviewVerdict.slice(0, 96) : null,
|
|
1059
|
+
enforcement: OUTCOME_ENFORCEMENTS.has(safe.enforcement)
|
|
1060
|
+
? safe.enforcement
|
|
1061
|
+
: deriveEnforcement({ engine: engineName, env }),
|
|
1062
|
+
engine: engineName,
|
|
1063
|
+
source: isNonEmptyString(safe.source) ? safe.source.slice(0, 96) : null,
|
|
1064
|
+
request_key: sessionBootRequestKey({ projectRoot, sessionId: resolvedSessionId }),
|
|
1065
|
+
ts: isNonEmptyString(safe.ts) ? safe.ts : new Date().toISOString(),
|
|
1066
|
+
};
|
|
1067
|
+
return {
|
|
1068
|
+
semantic_name: SEMANTIC_OUTCOME_OBSERVED,
|
|
1069
|
+
session_id: resolvedSessionId ?? undefined,
|
|
1070
|
+
payload: recordPayload,
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// --- SPEC §8 chain-step entry ------------------------------------------------
|
|
1075
|
+
// runHook(ctx) — in-proc SessionStart chain step. Advisory: always exit 0;
|
|
1076
|
+
// a telemetry fault can never block session start. The record is written
|
|
1077
|
+
// BEFORE slow .sh chain members can exhaust the chain budget, which is why
|
|
1078
|
+
// this step sits first in the SessionStart list.
|
|
1079
|
+
export async function runHook(ctx = {}) {
|
|
1080
|
+
try {
|
|
1081
|
+
const payload = isPlainObject(ctx.payload) ? ctx.payload : {};
|
|
1082
|
+
if (payload.hook_event_name !== 'SessionStart') {
|
|
1083
|
+
return { code: 0, stdout: '', stderr: '' };
|
|
1084
|
+
}
|
|
1085
|
+
const projectRoot = isNonEmptyString(ctx.projectRoot)
|
|
1086
|
+
? ctx.projectRoot
|
|
1087
|
+
: (isNonEmptyString(payload.cwd) ? payload.cwd : null);
|
|
1088
|
+
if (!projectRoot) return { code: 0, stdout: '', stderr: '' };
|
|
1089
|
+
const env = isPlainObject(ctx.env) ? ctx.env : process.env;
|
|
1090
|
+
const config = await loadEmitConfig(projectRoot);
|
|
1091
|
+
await emitSessionBoot({
|
|
1092
|
+
projectRoot,
|
|
1093
|
+
engine: isNonEmptyString(env.UKIT_HARNESS) ? env.UKIT_HARNESS : 'claude-code',
|
|
1094
|
+
sessionId: payload.session_id,
|
|
1095
|
+
config,
|
|
1096
|
+
deadlineMs: Number.isFinite(ctx.deadlineMs) ? ctx.deadlineMs : undefined,
|
|
1097
|
+
});
|
|
1098
|
+
return { code: 0, stdout: '', stderr: '' };
|
|
1099
|
+
} catch {
|
|
1100
|
+
return { code: 0, stdout: '', stderr: '' };
|
|
1101
|
+
}
|
|
1102
|
+
}
|