engineering-memory 1.11.18 → 1.11.20

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.
@@ -0,0 +1,401 @@
1
+ import * as z from 'zod/v4';
2
+ import { languageTag } from '../runtime/questionnaire-store.js';
3
+ import { sha256, stableStringify } from '../utilities/hash.js';
4
+ import { answerChoice, askQuestionnaire } from './questionnaire-tools.js';
5
+ import { assertSafeToPersist } from '../runtime/offline-outbox.js';
6
+ const role = z.enum([
7
+ 'work_started',
8
+ 'pr_opened',
9
+ 'all_prs_merged',
10
+ 'test_failed',
11
+ 'blocked_declared',
12
+ ]);
13
+ const mode = z.enum(['automatic', 'confirm', 'ignore']);
14
+ const navigation = z.tuple([z.string().trim().min(1).max(120), z.string().trim().min(1).max(400)]);
15
+ const wording = z
16
+ .object({
17
+ roles: z.record(role, z
18
+ .object({
19
+ title: z.string().trim().min(1).max(60),
20
+ message: z.string().trim().min(1).max(300),
21
+ context: z.string().trim().min(1).max(600),
22
+ example: z.string().trim().min(1).max(400),
23
+ })
24
+ .strict()),
25
+ modes: z.record(mode, z.string().trim().min(1).max(80)),
26
+ page: z.string().trim().min(1).max(80),
27
+ next: navigation,
28
+ previous: navigation,
29
+ defer: navigation,
30
+ approve: navigation,
31
+ review: z
32
+ .object({
33
+ message: z.string().trim().min(1).max(300),
34
+ context: z.string().trim().min(1).max(400),
35
+ example: z.string().trim().min(1).max(400),
36
+ })
37
+ .strict(),
38
+ })
39
+ .strict();
40
+ const setupInput = z
41
+ .object({
42
+ repoRoot: z.string().min(1),
43
+ projectId: z.string().uuid(),
44
+ externalTaskId: z.string().trim().min(2).max(160),
45
+ requestKey: z.string().regex(/^[A-Za-z0-9_-]{1,80}$/),
46
+ expectedSnapshot: z.string().regex(/^[a-f0-9]{64}$/),
47
+ recommendations: z
48
+ .array(z
49
+ .object({
50
+ event: role,
51
+ statusId: z.string().uuid(),
52
+ mode,
53
+ reason: z.string().trim().min(1).max(200),
54
+ })
55
+ .strict())
56
+ .length(5)
57
+ .refine((values) => new Set(values.map((value) => value.event)).size === 5),
58
+ language: languageTag,
59
+ copy: wording.optional(),
60
+ decisionAttempt: z.number().int().min(1).max(10000).optional(),
61
+ presentation: z.literal('host_native').optional(),
62
+ })
63
+ .strict();
64
+ const stage = z.object({
65
+ id: z.string().uuid(),
66
+ name: z.string().min(1).max(120),
67
+ slug: z.string().min(1).max(64),
68
+ meaning: z.string().max(4000),
69
+ archivedAt: z.string().nullable(),
70
+ });
71
+ const workflowPage = z.object({
72
+ snapshot: z.string().regex(/^[a-f0-9]{64}$/),
73
+ items: z.array(stage).max(100),
74
+ total: z.number().int().min(0),
75
+ offset: z.number().int().min(0),
76
+ limit: z.number().int().min(1).max(100),
77
+ });
78
+ const copy = {
79
+ tr: {
80
+ roles: {
81
+ work_started: {
82
+ title: 'Geliştirme',
83
+ message: 'Geliştirmeye başlayınca hangi kolon kullanılsın?',
84
+ context: 'Yeni işin açıldığı kolondan ayrı olarak, geliştirmeye başlama adımını ayarlıyoruz.',
85
+ example: 'Ödeme hatasını düzeltmeye başladığında kullanılacak kolonu seçiyorsun.',
86
+ },
87
+ pr_opened: {
88
+ title: 'İncelemeye hazır PR',
89
+ message: 'PR incelemeye hazır olunca hangi kolon kullanılsın?',
90
+ context: 'Taslak PR davranışı aynı kalır; burada incelemeye hazır PR adımını ayarlıyoruz.',
91
+ example: 'Değişikliğini bitirip ekip arkadaşının incelemesine açtığında kullanılacak kolon.',
92
+ },
93
+ all_prs_merged: {
94
+ title: 'Bütün PR’lar birleşti',
95
+ message: 'İşin bütün PR’ları birleşince hangi kolon kullanılsın?',
96
+ context: 'Bu işe bağlı bütün PR’ların birleştiği adımı ayarlıyoruz.',
97
+ example: 'Hem web hem sunucu değişiklikleri birleştirildiğinde kullanılacak kolon.',
98
+ },
99
+ test_failed: {
100
+ title: 'Başarısız test',
101
+ message: 'Test başarısız olunca hangi kolon kullanılsın?',
102
+ context: 'Başarısız testin ardından yapılacak düzeltmeler için kolon seçiyoruz.',
103
+ example: 'Ödeme testinde hata bulundu; düzeltme bekleyen iş için kullanılacak kolon.',
104
+ },
105
+ blocked_declared: {
106
+ title: 'Park edilen işler',
107
+ message: 'Beklemeye alınan işler hangi kolonda park edilsin?',
108
+ context: 'Projenin aktif park kolonunu seçiyoruz; mevcut işler yerinde kalacak.',
109
+ example: 'Dış servisten yanıt beklediğin için işi beklemeye aldığında kullanılacak kolon.',
110
+ },
111
+ },
112
+ modes: { automatic: 'Otomatik', confirm: 'Onay sor', ignore: 'Olayı dikkate alma' },
113
+ page: 'Sayfa',
114
+ next: ['Sonraki kolonlar', 'Aynı kararın sonraki kolonlarını göster.'],
115
+ previous: ['Önceki kolonlar', 'Aynı kararın önceki kolonlarına dön.'],
116
+ defer: ['Şimdi kaydetme', 'Seçimleri uygulama; bu deneme ertelenmiş olarak kalır.'],
117
+ approve: [
118
+ 'Bu ayarları kaydet',
119
+ 'Gösterilen beş tercihi birlikte kaydet; mevcut işleri taşıma.',
120
+ ],
121
+ review: {
122
+ message: 'Bu akış ayarlarını birlikte kaydedelim mi?',
123
+ context: 'Beş kolon ve davranış aşağıda. Taslak PR ve yeni işin başlangıç kolonu değişmez. Bu işlem yalnızca ayarları kaydeder; olay çalıştırmaz.',
124
+ example: 'Beklemeye alınan işler için Beklemede kolonunu seçtiysen proje bundan sonra bu tercihi saklar; önceki işler yerinde kalır.',
125
+ },
126
+ },
127
+ en: {
128
+ roles: {
129
+ work_started: {
130
+ title: 'Development',
131
+ message: 'Which stage should represent starting development?',
132
+ context: 'This is separate from the stage where new work is created.',
133
+ example: 'Choose the stage used when you begin fixing a payment bug.',
134
+ },
135
+ pr_opened: {
136
+ title: 'PR ready for review',
137
+ message: 'Which stage should represent a PR ready for review?',
138
+ context: 'Draft PR behavior stays unchanged; this configures the ready-for-review step.',
139
+ example: 'Use this stage when your completed change is ready for a teammate to review.',
140
+ },
141
+ all_prs_merged: {
142
+ title: 'All PRs merged',
143
+ message: 'Which stage should represent all PRs being merged?',
144
+ context: 'This configures the step after every PR belonging to the work has merged.',
145
+ example: 'Both the web change and its backend change have been merged.',
146
+ },
147
+ test_failed: {
148
+ title: 'Failed test',
149
+ message: 'Which stage should represent a failed test?',
150
+ context: 'Choose the stage for work that needs changes after a failed test.',
151
+ example: 'A payment test found a problem and the work needs a correction.',
152
+ },
153
+ blocked_declared: {
154
+ title: 'Parked work',
155
+ message: 'Which stage should hold parked work?',
156
+ context: 'Choose the active parked stage; existing work stays where it is.',
157
+ example: 'You put a task on hold while waiting for an external service.',
158
+ },
159
+ },
160
+ modes: { automatic: 'Automatic', confirm: 'Ask for approval', ignore: 'Ignore this event' },
161
+ page: 'Page',
162
+ next: ['Next stages', 'Show the next stages for this same decision.'],
163
+ previous: ['Previous stages', 'Return to the previous stages for this same decision.'],
164
+ defer: ['Do not save now', 'Apply nothing; this attempt remains deferred.'],
165
+ approve: [
166
+ 'Save these settings',
167
+ 'Save the five displayed choices together without moving existing work.',
168
+ ],
169
+ review: {
170
+ message: 'Save these workflow settings together?',
171
+ context: 'The five stages and behaviors appear below. Draft PR and new-work initial settings stay unchanged. This saves configuration only; no event is executed.',
172
+ example: 'Choosing On Hold for parked work stores that preference for the project; earlier tasks remain in place.',
173
+ },
174
+ },
175
+ };
176
+ function stageQuestion(input, text, language, stages, recommendation, setupId, selected, page, visit) {
177
+ const recommended = stages.find((item) => item.id === recommendation.statusId);
178
+ const others = stages.filter((item) => item.id !== recommendation.statusId);
179
+ const visible = [recommended, ...others.slice(page * 8, page * 8 + 8)];
180
+ const count = Math.max(1, Math.ceil(others.length / 8));
181
+ const question = text.roles[recommendation.event];
182
+ const options = visible.map((item) => ({
183
+ id: item.id,
184
+ label: item.name + ' (' + item.slug + ')',
185
+ description: [
186
+ text.modes[recommendation.mode],
187
+ item.id === recommendation.statusId ? recommendation.reason : '',
188
+ item.meaning,
189
+ ]
190
+ .filter(Boolean)
191
+ .join(' · ')
192
+ .slice(0, 400),
193
+ }));
194
+ if (page > 0)
195
+ options.push({ id: 'previous', label: text.previous[0], description: text.previous[1] });
196
+ if (page + 1 < count)
197
+ options.push({ id: 'next', label: text.next[0], description: text.next[1] });
198
+ options.push({ id: 'defer', label: text.defer[0], description: text.defer[1] });
199
+ return {
200
+ questionnaireId: 'workflow-role-' +
201
+ sha256(stableStringify({ setupId, selected, event: recommendation.event, page, visit })),
202
+ language,
203
+ impact: 'critical',
204
+ message: question.message,
205
+ context: question.context + ' ' + text.page + ' ' + (page + 1) + '/' + count + '.',
206
+ example: question.example,
207
+ options,
208
+ binding: {
209
+ setupId,
210
+ projectId: input.projectId,
211
+ expectedSnapshot: input.expectedSnapshot,
212
+ event: recommendation.event,
213
+ mode: recommendation.mode,
214
+ page,
215
+ visit,
216
+ },
217
+ };
218
+ }
219
+ export function registerWorkflowTools(server, service) {
220
+ server.registerTool('work_item.setup_workflow', {
221
+ description: 'Review five project workflow roles in durable native forms and save them together. First read work_item.workflow and supply its exact snapshot plus five deliberate recommendations, explicit modes and reasons in the user language. The current task must be resumed in this repository. All active stages are reachable through pages; existing work and draft PR settings stay unchanged. Questions are critical and follow the task mode; feedback is not consent. Retry identical input to resume. After deferral or a mode-invalidated agent answer, use a new decisionAttempt only when reconsideration is authorized. Use copy for languages other than Turkish/English; translate every field. Never silently replace a stale snapshot. This does not fire events.',
222
+ inputSchema: setupInput,
223
+ }, async (input, context) => setupWorkflow(server, service, input, context));
224
+ }
225
+ async function setupWorkflow(server, service, input, context) {
226
+ const scope = await service.workItemSetupContext(input);
227
+ if (!scope.ok)
228
+ return output(scope);
229
+ const { repoRoot: _repoRoot, ...retryArguments } = input;
230
+ try {
231
+ assertSafeToPersist(retryArguments, '', '', true);
232
+ }
233
+ catch {
234
+ return refusal('Remove sensitive content from the workflow question and its retry arguments before continuing.', 'work_item.setup_workflow');
235
+ }
236
+ const language = input.language ?? (await service.language());
237
+ if (!language)
238
+ return refusal('Read session.entry with the conversation language first.', 'session.entry');
239
+ const baseLanguage = language.split('-')[0];
240
+ const text = input.copy ?? (baseLanguage === 'tr' || baseLanguage === 'en' ? copy[baseLanguage] : undefined);
241
+ if (!text)
242
+ return refusal('Supply every copy field in the conversation language, preserving this snapshot and requestKey.', 'work_item.setup_workflow');
243
+ const stages = [];
244
+ let total;
245
+ for (let offset = 0; total === undefined || offset < total; offset += 100) {
246
+ const response = await service.workItemWorkflow({
247
+ projectId: input.projectId,
248
+ snapshot: input.expectedSnapshot,
249
+ limit: 100,
250
+ offset,
251
+ });
252
+ if (!response.ok)
253
+ return output(response);
254
+ const parsed = workflowPage.safeParse(response.data);
255
+ if (!parsed.success)
256
+ return refusal('The workflow response is incomplete. Reload its contract before opening choices.', 'work_item.workflow');
257
+ const page = parsed.data;
258
+ if (page.snapshot !== input.expectedSnapshot ||
259
+ page.offset !== offset ||
260
+ page.limit !== 100 ||
261
+ (total !== undefined && page.total !== total) ||
262
+ page.items.some((item) => item.archivedAt !== null) ||
263
+ page.items.length !== Math.min(100, Math.max(0, page.total - offset)))
264
+ return refusal('The workflow pages changed or were incomplete. Reload every page before reviewing choices.', 'work_item.workflow');
265
+ total = page.total;
266
+ stages.push(...page.items);
267
+ }
268
+ if (new Set(stages.map((item) => item.id)).size !== stages.length ||
269
+ input.recommendations.some((entry) => !stages.some((item) => item.id === entry.statusId)))
270
+ return refusal('Each recommendation must name an active stage from this complete project workflow.', 'work_item.workflow');
271
+ const setupId = sha256(stableStringify({
272
+ namespace: scope.data.namespace,
273
+ projectId: input.projectId,
274
+ externalTaskId: input.externalTaskId,
275
+ requestKey: input.requestKey,
276
+ snapshot: input.expectedSnapshot,
277
+ recommendations: input.recommendations,
278
+ decisionAttempt: input.decisionAttempt ?? 0,
279
+ language,
280
+ copy: text,
281
+ }));
282
+ const owner = {
283
+ tool: 'work_item.setup_workflow',
284
+ externalTaskId: input.externalTaskId,
285
+ decisionAttempt: input.decisionAttempt,
286
+ retryArguments,
287
+ };
288
+ const selected = [];
289
+ const selectionQuestionnaireIds = [];
290
+ for (const event of role.options) {
291
+ const recommendation = input.recommendations.find((entry) => entry.event === event);
292
+ let page = 0;
293
+ let visit = 0;
294
+ for (;;) {
295
+ if (context.mcpReq.signal.aborted)
296
+ return output({ ok: true, data: { status: 'pending', reason: 'interrupted' } });
297
+ const question = stageQuestion(input, text, language, stages, recommendation, setupId, selected, page, visit);
298
+ const answer = await askQuestionnaire(server, service, { ...question, repoRoot: input.repoRoot, presentation: input.presentation }, context, [], owner);
299
+ const choice = answerChoice(answer);
300
+ if (!choice)
301
+ return answer;
302
+ if (choice === 'defer')
303
+ return deferred(input);
304
+ if (choice === 'next' || choice === 'previous') {
305
+ page += choice === 'next' ? 1 : -1;
306
+ visit++;
307
+ continue;
308
+ }
309
+ if (!question.options.some((option) => option.id === choice) ||
310
+ !stages.some((item) => item.id === choice))
311
+ return refusal('The selected stage does not belong to this exact question.', 'questionnaire.resume');
312
+ selected.push({ event, toStatusId: choice, mode: recommendation.mode });
313
+ selectionQuestionnaireIds.push(question.questionnaireId);
314
+ break;
315
+ }
316
+ }
317
+ const proposal = {
318
+ projectId: input.projectId,
319
+ expectedSnapshot: input.expectedSnapshot,
320
+ rules: selected,
321
+ };
322
+ const proposalDigest = sha256(stableStringify(proposal));
323
+ const questionnaireId = 'workflow-apply-' +
324
+ sha256(stableStringify({ setupId, proposalDigest, selectionQuestionnaireIds }));
325
+ const review = {
326
+ questionnaireId,
327
+ language,
328
+ impact: 'critical',
329
+ context: text.review.context,
330
+ message: text.review.message +
331
+ '\n' +
332
+ selected
333
+ .map((item) => {
334
+ const target = stages.find((entry) => entry.id === item.toStatusId);
335
+ return (text.roles[item.event].title +
336
+ ' ' +
337
+ target.name +
338
+ ' (' +
339
+ target.slug +
340
+ ') · ' +
341
+ text.modes[item.mode]);
342
+ })
343
+ .join('\n'),
344
+ example: text.review.example,
345
+ options: [
346
+ { id: 'approve', label: text.approve[0], description: text.approve[1] },
347
+ { id: 'defer', label: text.defer[0], description: text.defer[1] },
348
+ ],
349
+ binding: {
350
+ setupId,
351
+ proposalDigest,
352
+ selectionQuestionnaireIds,
353
+ namespace: scope.data.namespace,
354
+ },
355
+ };
356
+ const approval = await askQuestionnaire(server, service, { ...review, repoRoot: input.repoRoot, presentation: input.presentation }, context, [], owner);
357
+ if (answerChoice(approval) === 'defer')
358
+ return deferred(input);
359
+ if (answerChoice(approval) !== 'approve')
360
+ return approval;
361
+ return output(await service.workItemApplyWorkflowSetup({
362
+ ...proposal,
363
+ repoRoot: input.repoRoot,
364
+ externalTaskId: input.externalTaskId,
365
+ questionnaireId,
366
+ selectionQuestionnaireIds,
367
+ }));
368
+ }
369
+ function deferred(input) {
370
+ return output({
371
+ ok: true,
372
+ data: {
373
+ status: 'deferred',
374
+ applied: false,
375
+ ...(input.decisionAttempt === 10000
376
+ ? {}
377
+ : {
378
+ reconsider: {
379
+ tool: 'work_item.setup_workflow',
380
+ arguments: { ...input, decisionAttempt: (input.decisionAttempt ?? 0) + 1 },
381
+ },
382
+ }),
383
+ nextAction: input.decisionAttempt === 10000
384
+ ? 'This attempt remains deferred. Use a new requestKey only after reconsideration is explicitly authorized.'
385
+ : 'No workflow settings were changed. Identical retries retain this deferral. Reconsider only after the user or current delegated decision explicitly resumes it.',
386
+ },
387
+ });
388
+ }
389
+ function refusal(message, recovery) {
390
+ return output({
391
+ ok: false,
392
+ error: { kind: 'workflow_setup', message, recovery, retryable: false },
393
+ });
394
+ }
395
+ function output(result) {
396
+ return {
397
+ content: [{ type: 'text', text: JSON.stringify(result) }],
398
+ isError: !result.ok,
399
+ };
400
+ }
401
+ //# sourceMappingURL=workflow-tools.js.map
@@ -228,6 +228,23 @@ const baseSchema = z.discriminatedUnion('kind', [
228
228
  z.strictObject({ kind: z.literal('local'), ref: branch }),
229
229
  ]);
230
230
  export function registerWorktreeTools(server, service) {
231
+ server.registerTool('worktree.prepare_files', {
232
+ description: "Inspect the current managed task worktree's paged Git-ignored file candidates without reading private values. After inspecting tracked runtime/build references, submit necessary relative paths and the exact expectedInventory to prepare them through normal same-clone, generation, staging and no-overwrite protections. An empty list records that no additional ignored files are needed. This is local file preparation, not a request to publish secrets. Resolve runtimeReview.required before claiming the worktree is ready.",
233
+ inputSchema: z
234
+ .strictObject({
235
+ repoRoot: repo,
236
+ externalTaskId: z.string().min(1).max(160),
237
+ generation: z.string().uuid(),
238
+ paths: z.array(z.string().min(1).max(500)).max(100).optional(),
239
+ expectedInventory: z
240
+ .string()
241
+ .regex(/^[a-f0-9]{64}$/)
242
+ .optional(),
243
+ offset: z.number().int().min(0).optional(),
244
+ limit: z.number().int().min(1).max(100).optional(),
245
+ })
246
+ .refine((input) => input.paths === undefined || input.expectedInventory !== undefined, 'A file selection requires expectedInventory.'),
247
+ }, async (input) => output(await service.worktreePrepareFiles(input)));
231
248
  server.registerTool('project.git_preferences', {
232
249
  description: 'Read the project development/production/test branch preferences and which questions remain unanswered. Only canManage actors may change shared choices; other members may select a task-specific base.',
233
250
  inputSchema: z.strictObject({
@@ -21,6 +21,7 @@ export const backendRecoveryOperationNames = [
21
21
  'project.member_add',
22
22
  'work_item.list',
23
23
  'work_item.statuses',
24
+ 'work_item.workflow',
24
25
  'work_item.get',
25
26
  'session.bootstrap',
26
27
  'session.resume',
@@ -95,6 +96,7 @@ export class ApiClient {
95
96
  return sha256(this.options.baseUrl.replace(/\/+$/, ''));
96
97
  }
97
98
  async request(path, request = {}) {
99
+ request.signal?.throwIfAborted();
98
100
  const method = request.method ?? 'GET';
99
101
  const cacheKey = request.cacheKey ?? this.cacheKey(method, path, request.body);
100
102
  const cached = request.cacheKey ? await this.options.cache.get(cacheKey) : null;
@@ -118,6 +120,12 @@ export class ApiClient {
118
120
  }
119
121
  headers.Authorization = `Bearer ${accessToken}`;
120
122
  }
123
+ const requestSignal = request.networkTimeoutMs === undefined
124
+ ? request.signal
125
+ : AbortSignal.any([
126
+ AbortSignal.timeout(request.networkTimeoutMs),
127
+ ...(request.signal ? [request.signal] : []),
128
+ ]);
121
129
  const controller = new AbortController();
122
130
  const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs);
123
131
  let response;
@@ -126,7 +134,9 @@ export class ApiClient {
126
134
  method,
127
135
  headers,
128
136
  ...(request.body === undefined ? {} : { body: JSON.stringify(request.body) }),
129
- signal: controller.signal,
137
+ signal: requestSignal
138
+ ? AbortSignal.any([controller.signal, requestSignal])
139
+ : controller.signal,
130
140
  });
131
141
  }
132
142
  catch (error) {
@@ -151,7 +161,7 @@ export class ApiClient {
151
161
  }
152
162
  let envelope;
153
163
  try {
154
- envelope = await this.parseEnvelope(response);
164
+ envelope = await this.parseEnvelope(response, request.maxResponseBytes);
155
165
  }
156
166
  catch (error) {
157
167
  if (request.allowStaleOnUnavailable &&
@@ -252,10 +262,11 @@ export class ApiClient {
252
262
  await this.options.credentials.set('refresh-token', envelope.data.refreshToken);
253
263
  }
254
264
  }
255
- async parseEnvelope(response) {
265
+ async parseEnvelope(response, maxBytes) {
256
266
  let value;
257
267
  try {
258
- value = await response.json();
268
+ value =
269
+ maxBytes === undefined ? await response.json() : await this.boundedJson(response, maxBytes);
259
270
  }
260
271
  catch {
261
272
  throw new ApiResponseError(`Backend returned a non-JSON response with status ${response.status}`, response.status, null, response.status >= 500, null, null);
@@ -265,6 +276,29 @@ export class ApiClient {
265
276
  }
266
277
  return value;
267
278
  }
279
+ async boundedJson(response, maxBytes) {
280
+ const reader = response.body?.getReader();
281
+ if (!reader)
282
+ throw new Error('Empty response');
283
+ const chunks = [];
284
+ let size = 0;
285
+ try {
286
+ while (true) {
287
+ const part = await reader.read();
288
+ if (part.done)
289
+ break;
290
+ size += part.value.byteLength;
291
+ if (size > maxBytes)
292
+ throw new Error('Response exceeds the byte limit');
293
+ chunks.push(part.value);
294
+ }
295
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
296
+ }
297
+ finally {
298
+ await reader.cancel().catch(() => undefined);
299
+ reader.releaseLock();
300
+ }
301
+ }
268
302
  cacheKey(method, path, body) {
269
303
  return sha256(`${method}\n${path}\n${stableStringify(body ?? null)}`);
270
304
  }