@ngockhoale/ukit 3.4.69 → 3.4.70

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.
@@ -7,6 +7,7 @@
7
7
 
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
+ import crypto from 'node:crypto';
10
11
  import { fileURLToPath } from 'node:url';
11
12
  import { spawnSync } from 'node:child_process';
12
13
  import {
@@ -1412,9 +1413,8 @@ export async function runSessionStop(
1412
1413
  };
1413
1414
  const payload = buildHookPayload('Stop', metadata);
1414
1415
 
1415
- // TASK-007 (OMP-2b): the coordinator's once-per-stop dedupe. omp can deliver
1416
- // two session_stop events for one logical stop; the second inside the window
1417
- // is skipped entirely — it must not burn a second continuation.
1416
+ // Duplicate callbacks suppress evaluator mutations, not unresolved decisions.
1417
+ // Successful releases always re-evaluate; episode side effects dedupe separately.
1418
1418
  let coordinator = null;
1419
1419
  try {
1420
1420
  coordinator = await loadStopCoordinatorModule();
@@ -1423,19 +1423,45 @@ export async function runSessionStop(
1423
1423
  // session — the completion gate below still owns the stop.
1424
1424
  pi.logger?.warn?.(`[UKit] stop-coordinator module unavailable (dedupe + handoff lanes skipped): ${error?.message || error}`);
1425
1425
  }
1426
- if (coordinator) {
1427
- const sessionKey = typeof payload.session_id === 'string' && payload.session_id
1428
- ? payload.session_id
1429
- : (typeof payload.transcript_path === 'string' && payload.transcript_path ? payload.transcript_path : 'no-session');
1430
- const duplicate = await coordinator.alreadyCoordinatedThisStop({
1431
- projectRoot,
1432
- sessionKey,
1433
- now,
1434
- dedupeWindowMs: dedupeWindowMs ?? coordinator.DEDUPE_WINDOW_MS,
1435
- lockBudgetMs,
1436
- });
1437
- if (duplicate) return undefined;
1426
+ const state = suppliedState ?? await readRouteState(projectRoot, payload);
1427
+ const ledger = suppliedLedger ?? await readExecutionLedger(projectRoot, payload) ?? {};
1428
+ const authority = () => ({ state, ledger: suppliedLedger ?? null });
1429
+ const claimToken = crypto.randomUUID();
1430
+ const initialInput = coordinator
1431
+ ? await coordinator.stopCoordinationInput({ projectRoot, payload, authority: authority() }) : null;
1432
+ const key = initialInput ? `omp:${initialInput.key}` : null;
1433
+ if (coordinator && await coordinator.alreadyCoordinatedThisStop({
1434
+ projectRoot, sessionKey: key, now,
1435
+ dedupeWindowMs: dedupeWindowMs ?? coordinator.DEDUPE_WINDOW_MS,
1436
+ lockBudgetMs, replayOnlyBlocked: true, claimToken,
1437
+ })) {
1438
+ try {
1439
+ const saved = JSON.parse(await fs.promises.readFile(path.join(projectRoot, '.ukit/storage/cache/stop-coordinator/state.json'), 'utf8'));
1440
+ const decision = saved.stopClaims?.[key]?.decision;
1441
+ if (decision?.decision === 'block') return decision.output;
1442
+ } catch { /* pending/unknown does not authorize release */ }
1443
+ return { continue: true, additionalContext: 'UKit completion gate: Stop evaluation ownership is pending or unavailable (duplicate, claim capacity, or lock contention). No evaluator was admitted and no completion has been verified; a subsequent Stop can recheck ownership.' };
1438
1444
  }
1445
+ const finish = async (output) => {
1446
+ if (coordinator) {
1447
+ const evaluatedInput = await coordinator.stopCoordinationInput({ projectRoot, payload, authority: authority() });
1448
+ await coordinator.publishStopDecision({
1449
+ projectRoot, key,
1450
+ evaluatedKey: initialInput.currency === evaluatedInput.currency ? `omp:${evaluatedInput.key}` : key,
1451
+ claimToken, now, lockBudgetMs,
1452
+ decision: output ? { decision: 'block', output } : { decision: null },
1453
+ });
1454
+ }
1455
+ return output;
1456
+ };
1457
+ const recordEpisode = async () => {
1458
+ const sessionKey = payload.session_id || payload.transcript_path || 'no-session';
1459
+ const duplicate = coordinator && await coordinator.alreadyCoordinatedThisStop({
1460
+ projectRoot, sessionKey: `omp-episode:${sessionKey}`, now,
1461
+ dedupeWindowMs: dedupeWindowMs ?? coordinator.DEDUPE_WINDOW_MS, lockBudgetMs,
1462
+ });
1463
+ if (!duplicate) await runSessionEpisodeChain(pi, payload, projectRoot);
1464
+ };
1439
1465
 
1440
1466
  // TASK-007 (OMP-2c): the handoff-cursor lane. While docs/AI_HANDOFF/RUN.md
1441
1467
  // reports a phase outside {done, blocked} the run owns this stop — the
@@ -1462,8 +1488,6 @@ export async function runSessionStop(
1462
1488
  sendContext(pi, [handoff.systemMessage], 'nextTurn', { display: true });
1463
1489
  }
1464
1490
 
1465
- const state = suppliedState ?? await readRouteState(projectRoot, payload);
1466
- const ledger = suppliedLedger ?? await readExecutionLedger(projectRoot, payload) ?? {};
1467
1491
  let evaluation;
1468
1492
  try {
1469
1493
  // Command authority (RC-A): same shared phase signal the Claude Stop path uses, so a
@@ -1483,17 +1507,17 @@ export async function runSessionStop(
1483
1507
  + 'The completion gate is DOWN, not your work: run `ukit install` to repair, '
1484
1508
  + 'then re-send the task in a new message if the run must continue.',
1485
1509
  ], 'nextTurn', { display: true });
1486
- await runSessionEpisodeChain(pi, payload, projectRoot);
1487
- return undefined;
1510
+ await recordEpisode();
1511
+ return finish(undefined);
1488
1512
  }
1489
1513
  pi.logger?.warn?.(`[UKit] completion evaluator crashed (streak ${streak}/${maxStreak}): ${error?.message || error}`);
1490
- return {
1514
+ return finish({
1491
1515
  continue: true,
1492
1516
  additionalContext:
1493
1517
  `UKit stop coordinator: infrastructure failure while evaluating this stop (crash streak ${streak}/${maxStreak}) — `
1494
1518
  + 'the stop is blocked fail-closed (details withheld). Run: ukit install, then re-send the task in a new message.'
1495
1519
  + (handoffBlock ? `\n${HANDOFF_PROVENANCE_PREFIX}${handoffBlock}` : ''),
1496
- };
1520
+ });
1497
1521
  }
1498
1522
  // A clean evaluation resets the consecutive-crash streak (shared with the shell hook).
1499
1523
  await resetCompletionCrashStreak(projectRoot);
@@ -1520,10 +1544,10 @@ export async function runSessionStop(
1520
1544
  // completion). Its own stall streak — not the ledger continuation counter —
1521
1545
  // bounds this lane, so no continuation bookkeeping runs here.
1522
1546
  if (handoffBlock) {
1523
- return { continue: true, additionalContext: handoffBlock };
1547
+ return finish({ continue: true, additionalContext: handoffBlock });
1524
1548
  }
1525
- await runSessionEpisodeChain(pi, payload, projectRoot);
1526
- return undefined;
1549
+ await recordEpisode();
1550
+ return finish(undefined);
1527
1551
  }
1528
1552
 
1529
1553
  if (suppliedLedger === undefined) {
@@ -1536,14 +1560,14 @@ export async function runSessionStop(
1536
1560
  pi.logger?.warn?.(`[UKit] continuation bookkeeping failed open: ${error?.message || error}`);
1537
1561
  }
1538
1562
  }
1539
- return {
1563
+ return finish({
1540
1564
  continue: true,
1541
1565
  // Coordinator merge order: the completion gate owns the continuation, and a
1542
1566
  // losing handoff-cursor block travels inside the winning reason.
1543
1567
  additionalContext: handoffBlock
1544
1568
  ? `${evaluation.reason}\n${HANDOFF_PROVENANCE_PREFIX}${handoffBlock}`
1545
1569
  : evaluation.reason,
1546
- };
1570
+ });
1547
1571
  }
1548
1572
 
1549
1573
  function installedProjectRoot() {
@@ -1,246 +0,0 @@
1
- #!/usr/bin/env node
2
- // One-shot development capture. Every subprocess is bounded; OMP is never signalled or modified.
3
- import fs from 'node:fs/promises';
4
- import path from 'node:path';
5
- import os from 'node:os';
6
- import { spawn } from 'node:child_process';
7
- import { fileURLToPath } from 'node:url';
8
- import { readBoundedTail, summarizeSessionTail, buildStallSnapshot, associateSessionEvidence, classifySessionActivity, parseProcessInventory, processInventoryCommand, parseOwnerPids, runBoundedProcess } from '../../src/diagnostics/ompStallSnapshot.js';
9
- const ownPath = fileURLToPath(import.meta.url);
10
- const args = process.argv.slice(2);
11
- const option = (key) => { const at = args.indexOf(key); return at < 0 ? null : args[at + 1]; };
12
- const worker = process.env.UKIT_STALL_CAPTURE_WORKER === '1';
13
-
14
- if (!worker) {
15
- const child = spawn(process.execPath, [ownPath, '--worker', ...args], { stdio: ['ignore', 'pipe', 'ignore'], env: { ...process.env, UKIT_STALL_CAPTURE_WORKER: '1' } });
16
- let output = '';
17
- let failed = false;
18
- const timer = setTimeout(() => { failed = true; child.kill('SIGKILL'); }, 5000);
19
- child.stdout.on('data', (chunk) => {
20
- output += chunk;
21
- if (Buffer.byteLength(output) > 131072) { failed = true; child.kill('SIGKILL'); }
22
- });
23
- child.on('error', () => { clearTimeout(timer); console.error('Capture unavailable'); process.exitCode = 1; });
24
- child.on('close', async (code) => {
25
- clearTimeout(timer);
26
- if (code !== 0 || failed) { console.error('Capture incomplete: collector deadline or bounded I/O failure; OMP untouched.'); process.exitCode = 1; return; }
27
- try {
28
- const report = JSON.parse(output);
29
- const dir = path.resolve(option('--output-dir') || path.join(os.tmpdir(), 'ukit-stall-captures'));
30
- await fs.mkdir(dir, { recursive: true, mode: 0o700 });
31
- const dirStat = await fs.lstat(dir);
32
- if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) throw new Error('unsafe output directory');
33
- await fs.chmod(dir, 0o700);
34
- let file = null;
35
- let handle = null;
36
- const startSlot = Date.now() % 8;
37
- for (let attempt = 0; attempt < 8 && !file; attempt += 1) {
38
- const slot = (startSlot + attempt) % 8;
39
- const candidate = path.join(dir, `snapshot-${slot}.json`);
40
- try {
41
- const existing = await fs.lstat(candidate);
42
- if (!existing.isFile() || existing.isSymbolicLink()) continue;
43
- await fs.unlink(candidate);
44
- } catch (error) { if (error.code !== 'ENOENT') throw error; }
45
- file = candidate;
46
- const temporary = path.join(dir, `snapshot-${slot}-${process.pid}-${Math.random().toString(16).slice(2)}.tmp`);
47
- try {
48
- handle = await fs.open(temporary, 'wx', 0o600);
49
- await handle.chmod(0o600);
50
- await handle.writeFile(`${JSON.stringify(report, null, 2)}\n`);
51
- await handle.close();
52
- handle = null;
53
- await fs.rename(temporary, file);
54
- } catch (error) {
55
- if (handle) await handle.close();
56
- await fs.unlink(temporary).catch(() => {});
57
- file = null;
58
- if (attempt === 7) throw error;
59
- }
60
- }
61
- if (!file) throw new Error('no safe snapshot slot available');
62
- console.log(`${report.captureStatus} — snapshot saved: ${file}`);
63
- } catch {
64
- console.error('Capture incomplete: private snapshot could not be saved; OMP untouched.');
65
- process.exitCode = 1;
66
- }
67
- });
68
- } else {
69
- const maxSessions = 32;
70
- const maxLogs = 8;
71
- const home = option('--omp-home') || path.join(os.homedir(), '.omp');
72
- const sessionsRoot = option('--sessions-root') || path.join(home, 'agent', 'sessions');
73
- const ownersRoot = option('--owners-root') || path.join(home, 'run', 'session-owners');
74
- const logsRoot = option('--logs-root') || path.join(home, 'logs');
75
- const explicitSession = option('--session');
76
- const packageInfo = JSON.parse(await fs.readFile(new URL('../../package.json', import.meta.url), 'utf8'));
77
-
78
- async function discoverTranscripts(root) {
79
- const candidates = [];
80
- let truncated = false;
81
- try {
82
- const rootDir = await fs.opendir(root);
83
- let visited = 0;
84
- for await (const entry of rootDir) {
85
- if (++visited > 128) { truncated = true; break; }
86
- if (entry.isFile() && entry.name.endsWith('.jsonl')) candidates.push(path.join(root, entry.name));
87
- if (!entry.isDirectory()) continue;
88
- let childCount = 0;
89
- try {
90
- const childDir = await fs.opendir(path.join(root, entry.name));
91
- for await (const child of childDir) {
92
- if (++childCount > 128 || candidates.length >= maxSessions) { truncated = true; break; }
93
- if (child.isFile() && child.name.endsWith('.jsonl')) candidates.push(path.join(root, entry.name, child.name));
94
- }
95
- } catch { truncated = true; }
96
- if (candidates.length >= maxSessions) { truncated = true; break; }
97
- }
98
- return { state: 'OBSERVED', paths: candidates.slice(0, maxSessions), truncated };
99
- } catch { return { state: 'NOT_OBSERVABLE', paths: [], truncated: true }; }
100
- }
101
-
102
- async function discoverOwnerIds(root) {
103
- try {
104
- const dir = await fs.opendir(root);
105
- const ids = [];
106
- let visited = 0;
107
- let truncated = false;
108
- for await (const entry of dir) {
109
- if (++visited > 128) { truncated = true; break; }
110
- if (!entry.isFile() || !entry.name.endsWith('.lock')) continue;
111
- const sessionId = entry.name.slice(0, -5);
112
- if (/^[\w.-]{1,120}$/.test(sessionId)) ids.push(sessionId);
113
- if (ids.length >= maxSessions) { truncated = true; break; }
114
- }
115
- return { state: 'OBSERVED', ids, truncated };
116
- } catch { return { state: 'NOT_OBSERVABLE', ids: [], truncated: true }; }
117
- }
118
-
119
- async function runBounded(command, commandArgs, timeout = 650) {
120
- return runBoundedProcess(command, commandArgs, { timeoutMs: timeout, maxBuffer: 65536 });
121
- }
122
-
123
-
124
- async function probeOwnerLock(sessionId, rootExists) {
125
- if (!rootExists || !/^[\w.-]{1,120}$/.test(sessionId)) return { state: 'NOT_OBSERVABLE', pids: [] };
126
- const file = path.join(ownersRoot, `${sessionId}.lock`);
127
- try {
128
- const stat = await fs.lstat(file);
129
- if (!stat.isFile()) return { state: 'NOT_OBSERVABLE', pids: [] };
130
- } catch (error) {
131
- return error.code === 'ENOENT' ? { state: 'UNHELD', pids: [] } : { state: 'NOT_OBSERVABLE', pids: [] };
132
- }
133
- if (process.platform === 'win32') {
134
- const handles = await runBounded('handle.exe', ['-nobanner', file], 400);
135
- if (!handles.ok) return { state: 'NOT_OBSERVABLE', pids: [] };
136
- const expected = path.resolve(file).replaceAll('/', '\\').toLowerCase();
137
- const matches = handles.stdout.split(/\r?\n/).flatMap((line) => {
138
- const match = line.match(/\bpid:\s*(\d+)\s+type:\s*File\b.*?:\s*(.+)$/i);
139
- if (!match || match[2].trim().replaceAll('/', '\\').toLowerCase() !== expected) return [];
140
- return [Number(match[1])];
141
- });
142
- const pids = [...new Set(matches)];
143
- return pids.length === 1 ? { state: 'OBSERVED', pids } : { state: 'NOT_OBSERVABLE', pids: [] };
144
- }
145
- const lsof = await runBounded('lsof', ['-t', '--', file], 250);
146
- if (lsof.ok) return { state: 'OBSERVED', pids: parseOwnerPids('lsof', lsof.stdout) };
147
- if (lsof.exitCode === 1) return /permission|denied|error/i.test(lsof.stderr) ? { state: 'NOT_OBSERVABLE', pids: [] } : { state: 'OBSERVED', pids: [] };
148
- if (lsof.errorCode !== 'ENOENT') return { state: 'NOT_OBSERVABLE', pids: [] };
149
- const fuser = await runBounded('fuser', ['-f', file], 250);
150
- if (fuser.ok) return { state: 'OBSERVED', pids: parseOwnerPids('fuser', `${fuser.stdout}\n${fuser.stderr}`) };
151
- if (fuser.exitCode === 1) return /permission|denied|error/i.test(fuser.stderr) ? { state: 'NOT_OBSERVABLE', pids: [] } : { state: 'OBSERVED', pids: [] };
152
- return { state: 'NOT_OBSERVABLE', pids: [] };
153
- }
154
-
155
-
156
- async function readProcessInventory() {
157
- const [command, commandArgs] = processInventoryCommand(process.platform);
158
- const result = await runBounded(command, commandArgs);
159
- if (!result.ok) return { state: 'NOT_OBSERVABLE', processes: [], ompProcessCount: null };
160
- const processes = parseProcessInventory(process.platform, result.stdout);
161
- return { state: 'OBSERVED', processes, ompProcessCount: processes.filter((entry) => /^omp(?:\.exe)?$/i.test(entry.name)).length };
162
- }
163
-
164
- async function readOmpLog(pid) {
165
- try {
166
- const names = [];
167
- const directory = await fs.opendir(logsRoot);
168
- let visited = 0;
169
- for await (const entry of directory) {
170
- if (++visited > 256) break;
171
- if (entry.isFile() && new RegExp(`^omp\\.\\d{4}-\\d{2}-\\d{2}\\.${pid}\\.log$`).test(entry.name)) names.push(entry.name);
172
- }
173
- names.sort();
174
- const name = names.at(-1);
175
- if (!name) return null;
176
- const tail = await readBoundedTail(path.join(logsRoot, name), { maxBytes: 32768 });
177
- const rows = [];
178
- for (const line of tail.text.split('\n')) {
179
- try {
180
- const row = JSON.parse(line);
181
- // Never copy message text, URLs, account fields, headers or response bodies.
182
- const timestamp = typeof row.timestamp === 'string' && Number.isFinite(Date.parse(row.timestamp)) ? new Date(row.timestamp).toISOString() : null;
183
- rows.push({ timestamp,
184
- level: ['debug', 'info', 'warn', 'error'].includes(row.level) ? row.level : null,
185
- status: Number.isInteger(row.status) ? row.status : null });
186
- } catch { /* Partial log row. */ }
187
- }
188
- return { pid, bytesRead: tail.bytesRead, rows: rows.slice(-16) };
189
- } catch { return null; }
190
- }
191
-
192
- let discovery;
193
- if (explicitSession) discovery = { state: 'OBSERVED', paths: [explicitSession] };
194
- else discovery = await discoverTranscripts(sessionsRoot);
195
- const transcriptSessions = [];
196
- for (const transcriptPath of discovery.paths.slice(0, maxSessions)) {
197
- try {
198
- const tail = await readBoundedTail(transcriptPath);
199
- const summary = summarizeSessionTail(tail.text);
200
- const filenameId = path.basename(transcriptPath).replace(/\.jsonl$/i, '');
201
- const uuidFromFilename = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(filenameId) ? filenameId : null;
202
- const sessionId = summary.sessionId || uuidFromFilename;
203
- const summarySessionId = summary.sessionId;
204
- summary.sessionId = sessionId;
205
- transcriptSessions.push({ sessionId, transcriptPath, summary, sourceCoverage: { sessionTailBytes: tail.bytesRead, truncated: tail.truncated, sessionIdentityObserved: summarySessionId !== null || uuidFromFilename !== null } });
206
- } catch { transcriptSessions.push({ sessionId: null, transcriptPath, summary: null, sourceCoverage: { state: 'NOT_OBSERVABLE' } }); }
207
- }
208
- const ownerDiscovery = await discoverOwnerIds(ownersRoot);
209
- const sessions = [...transcriptSessions];
210
- for (const sessionId of ownerDiscovery.ids) if (!sessions.some((entry) => entry.sessionId === sessionId)) sessions.push({ sessionId, transcriptPath: null, summary: null, sourceCoverage: { state: 'NOT_OBSERVABLE' } });
211
- const boundedSessions = sessions.slice(0, maxSessions);
212
- const ownerRootExists = ownerDiscovery.state === 'OBSERVED';
213
- const [ownerResults, processInventory] = await Promise.all([
214
- Promise.all(boundedSessions.filter((entry) => entry.sessionId).map(async (entry) => [entry.sessionId, await probeOwnerLock(entry.sessionId, ownerRootExists)])),
215
- readProcessInventory(),
216
- ]);
217
- const ownerLocks = Object.fromEntries(ownerResults);
218
- const logs = (await Promise.all([...new Set(ownerResults.flatMap(([, lock]) => lock.state === 'OBSERVED' ? lock.pids : []))].slice(0, maxLogs).map(readOmpLog))).filter(Boolean);
219
- const associations = associateSessionEvidence({ sessions: boundedSessions, ownerLocks, processInventory, logs });
220
- const snapshots = associations.map((association, index) => {
221
- const source = boundedSessions[index];
222
- const summary = source.summary;
223
- const sessionActivity = classifySessionActivity({ association: association.sessionAssociation, summary });
224
- return { ...association, sessionActivity, ...(summary || {}), sourceCoverage: source.sourceCoverage };
225
- });
226
- const activeOwners = snapshots.filter((entry) => entry.sessionAssociation === 'ACTIVE_OWNER');
227
- const allLocksUnheld = ownerResults.length === boundedSessions.filter((entry) => entry.sessionId).length
228
- && ownerResults.every(([, lock]) => lock.state === 'UNHELD');
229
- const coverageComplete = !discovery.truncated && !ownerDiscovery.truncated && sessions.length <= maxSessions;
230
- const transcriptsFullyIdentified = transcriptSessions.every((entry) => entry.sessionId !== null);
231
- const executionState = activeOwners.length ? 'EXECUTING'
232
- : coverageComplete && transcriptsFullyIdentified && ownerDiscovery.state === 'OBSERVED' && processInventory.state === 'OBSERVED'
233
- && processInventory.ompProcessCount === 0 && allLocksUnheld ? 'NO_EXECUTION' : 'NOT_OBSERVABLE';
234
- const activity = activeOwners.length === 1 ? activeOwners[0].sessionActivity
235
- : activeOwners.length > 1 ? 'MULTIPLE_ACTIVE_SESSIONS' : executionState === 'NO_EXECUTION' ? 'NO_EXECUTION' : 'NOT_OBSERVABLE';
236
- const captureStatus = activeOwners.length > 0 && activeOwners.every((entry) => entry.sourceCoverage && entry.summary)
237
- && discovery.state === 'OBSERVED' && !discovery.truncated && ownerDiscovery.state === 'OBSERVED' && !ownerDiscovery.truncated
238
- && processInventory.state === 'OBSERVED' && ownerResults.every(([, lock]) => lock.state === 'OBSERVED' || lock.state === 'UNHELD')
239
- && snapshots.every((entry) => entry.sessionId !== null && entry.identityVerified) ? 'CAPTURE_READY' : 'CAPTURE_PARTIAL';
240
- const snapshot = buildStallSnapshot({ executionState, activity, captureStatus,
241
- sessionDiscovery: discovery.state, ownerLockDiscovery: ownerDiscovery.state,
242
- processInventory: { state: processInventory.state, ompProcessCount: processInventory.ompProcessCount },
243
- sessions: snapshots, ompVersion: 'NOT_OBSERVABLE', ukitVersion: packageInfo.version,
244
- limitations: ['Persisted transcript events do not prove live stream or gateway state.', 'Unobservable ownership and operation states remain NOT_OBSERVABLE.', 'No OMP freeze root cause is inferred.'] });
245
- process.stdout.write(JSON.stringify(snapshot) + '\n');
246
- }