@goodandready/dsh-goal 0.1.3 → 0.1.5

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.
@@ -47,8 +47,10 @@ export class GoalEngine {
47
47
  this.defaultMaxIterations = options.defaultMaxIterations ?? 25;
48
48
  this.autoDrive = options.autoDrive ?? true;
49
49
  this.enableSound = options.enableSound ?? true;
50
- this.currentGoal = null;
50
+ this.maxSessions = options.maxSessions ?? 100;
51
+ this.goals = new Map();
51
52
  this.listeners = new Set();
53
+ this.saveTimer = null;
52
54
 
53
55
  const defaultStorageDir = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
54
56
  this.storagePath = options.storagePath ?? null;
@@ -56,14 +58,34 @@ export class GoalEngine {
56
58
  this.loadStateFromDisk();
57
59
  }
58
60
 
61
+ get currentGoal() {
62
+ return this.goals.get('default') || null;
63
+ }
64
+
65
+ set currentGoal(val) {
66
+ if (val) {
67
+ this.goals.set('default', val);
68
+ } else {
69
+ this.goals.delete('default');
70
+ }
71
+ }
72
+
59
73
  loadStateFromDisk() {
60
74
  if (!this.storagePath) return;
61
75
  try {
62
76
  if (fs.existsSync(this.storagePath)) {
63
77
  const raw = fs.readFileSync(this.storagePath, 'utf8');
64
78
  const data = JSON.parse(raw);
65
- if (data && typeof data === 'object' && data.id && data.title) {
66
- this.currentGoal = data;
79
+ if (data && typeof data === 'object') {
80
+ if (data.sessions && typeof data.sessions === 'object') {
81
+ for (const [sid, goal] of Object.entries(data.sessions)) {
82
+ if (goal && goal.id && goal.title) {
83
+ this.goals.set(sid, goal);
84
+ }
85
+ }
86
+ } else if (data.id && data.title) {
87
+ this.goals.set('default', data);
88
+ }
67
89
  }
68
90
  }
69
91
  } catch (err) {
@@ -71,19 +93,63 @@ export class GoalEngine {
71
93
  }
72
94
  }
73
95
 
74
- saveStateToDisk() {
96
+ scheduleSave(immediate = false) {
97
+ if (!this.storagePath) return;
98
+ if (immediate) {
99
+ if (this.saveTimer) {
100
+ clearTimeout(this.saveTimer);
101
+ this.saveTimer = null;
102
+ }
103
+ this.writeStateToDiskSync();
104
+ return;
105
+ }
106
+ if (!this.saveTimer) {
107
+ this.saveTimer = setTimeout(() => {
108
+ this.saveTimer = null;
109
+ this.writeStateToDiskSync();
110
+ }, 250);
111
+ if (typeof this.saveTimer.unref === 'function') {
112
+ this.saveTimer.unref();
113
+ }
114
+ }
115
+ }
116
+
117
+ writeStateToDiskSync() {
75
118
  if (!this.storagePath) return;
76
119
  try {
77
- if (this.currentGoal) {
78
- fs.writeFileSync(this.storagePath, JSON.stringify(this.currentGoal, null, 2), 'utf8');
79
- } else {
120
+ if (this.goals.size === 0) {
80
121
  if (fs.existsSync(this.storagePath)) {
81
122
  fs.unlinkSync(this.storagePath);
82
123
  }
124
+ return;
125
+ }
126
+ const sessionsObj = {};
127
+ for (const [sid, goal] of this.goals.entries()) {
128
+ sessionsObj[sid] = goal;
83
129
  }
130
+ const payload = {
131
+ version: 2,
132
+ sessions: sessionsObj,
133
+ ...(this.goals.has('default') ? this.goals.get('default') : {}),
134
+ };
135
+ const tmp = `${this.storagePath}.tmp.${Date.now()}`;
136
+ fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
137
+ fs.renameSync(tmp, this.storagePath);
84
138
  } catch (err) {
85
- console.warn('[GoalEngine] Failed to save state to disk:', err);
139
+ console.warn('[GoalEngine] Failed to write state to disk:', err);
140
+ }
141
+ }
142
+
143
+ flushSync() {
144
+ if (this.saveTimer) {
145
+ clearTimeout(this.saveTimer);
146
+ this.saveTimer = null;
86
147
  }
148
+ this.writeStateToDiskSync();
149
+ }
150
+
151
+ saveStateToDisk() {
152
+ this.flushSync();
87
153
  }
88
154
 
89
155
  /**
@@ -96,12 +162,13 @@ export class GoalEngine {
96
162
  return () => this.listeners.delete(callback);
97
163
  }
98
164
 
99
- emit() {
100
- this.saveStateToDisk();
101
- const snapshot = this.getSnapshot();
165
+ emit(sessionId = 'default', immediate = false) {
166
+ this.scheduleSave(immediate);
167
+ const sid = sessionId || 'default';
168
+ const snapshot = this.getSnapshot(sid);
102
169
  for (const listener of this.listeners) {
103
170
  try {
104
- listener(snapshot);
171
+ listener(snapshot, sid);
105
172
  } catch (err) {
106
173
  console.error('[GoalEngine] Listener error:', err);
107
174
  }
@@ -116,8 +183,10 @@ export class GoalEngine {
116
183
  if (typeof config.defaultMaxIterations === 'number' && config.defaultMaxIterations >= 1) {
117
184
  const prev = this.defaultMaxIterations;
118
185
  this.defaultMaxIterations = config.defaultMaxIterations;
119
- if (this.currentGoal && this.currentGoal.maxIterations === prev) {
120
- this.currentGoal.maxIterations = config.defaultMaxIterations;
186
+ for (const [_, goal] of this.goals) {
187
+ if (goal && goal.maxIterations === prev) {
188
+ goal.maxIterations = config.defaultMaxIterations;
189
+ }
121
190
  }
122
191
  }
123
192
  if (typeof config.autoDrive === 'boolean') {
@@ -132,16 +201,50 @@ export class GoalEngine {
132
201
  /**
133
202
  * Запуск новой цели
134
203
  */
135
- startGoal(title, options = {}) {
204
+ getGoal(sessionId = 'default') {
205
+ return this.goals.get(sessionId || 'default') || null;
206
+ }
207
+
208
+ /**
209
+ * Очистка старых неактивных сессий во избежание утечек памяти
210
+ */
211
+ pruneInactiveSessions() {
212
+ if (this.goals.size < this.maxSessions) return;
213
+ const inactive = [];
214
+ for (const [sid, goal] of this.goals.entries()) {
215
+ if (sid === 'default') continue;
216
+ if (goal.state === GoalState.COMPLETED || goal.state === GoalState.CANCELLED || goal.state === GoalState.FAILED) {
217
+ inactive.push({ sid, completedAt: goal.completedAt || goal.startedAt || 0 });
218
+ }
219
+ }
220
+ // Сортируем от самых старых к новым
221
+ inactive.sort((a, b) => a.completedAt - b.completedAt);
222
+ while (this.goals.size >= this.maxSessions && inactive.length > 0) {
223
+ const oldest = inactive.shift();
224
+ this.goals.delete(oldest.sid);
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Запуск новой цели
230
+ * @param {string} title
231
+ * @param {Object} options
232
+ * @param {string} [sessionId='default']
233
+ */
234
+ startGoal(title, options = {}, sessionId = 'default') {
136
235
  if (!title || typeof title !== 'string' || !title.trim()) {
137
236
  throw new Error('Goal title cannot be empty');
138
237
  }
139
238
 
239
+ this.pruneInactiveSessions();
240
+
140
241
  const cleanTitle = title.trim();
141
242
  const now = Date.now();
243
+ const sid = sessionId || 'default';
142
244
 
143
- this.currentGoal = {
245
+ const goal = {
144
246
  id: `goal-${now}-${Math.random().toString(36).substring(2, 7)}`,
247
+ sessionId: sid,
145
248
  title: cleanTitle,
146
249
  description: options.description?.trim() || '',
147
250
  state: GoalState.RUNNING,
@@ -162,125 +265,154 @@ export class GoalEngine {
162
265
  resultSummary: '',
163
266
  };
164
267
 
268
+ this.goals.set(sid, goal);
269
+
165
270
  if (Array.isArray(options.milestones) && options.milestones.length > 0) {
166
- this.addMilestones(options.milestones, false);
271
+ this.addMilestones(options.milestones, false, sid);
167
272
  }
168
273
 
169
- this.emit();
170
- return this.getSnapshot();
274
+ this.emit(sid, true);
275
+ return this.getSnapshot(sid);
171
276
  }
172
277
 
173
278
  /**
174
279
  * Приостановка автономного цикла цели
175
280
  */
176
- pause(reason = 'User requested pause') {
177
- if (!this.currentGoal || this.currentGoal.state !== GoalState.RUNNING) {
178
- return this.getSnapshot();
281
+ pause(reason = 'User requested pause', sessionId = 'default') {
282
+ const sid = sessionId || 'default';
283
+ const goal = this.goals.get(sid);
284
+ if (!goal || goal.state !== GoalState.RUNNING) {
285
+ return this.getSnapshot(sid);
179
286
  }
180
287
 
181
- this.currentGoal.state = GoalState.PAUSED;
182
- this.currentGoal.pausedAt = Date.now();
183
- this.currentGoal.logs.push({
288
+ goal.state = GoalState.PAUSED;
289
+ goal.pausedAt = Date.now();
290
+ goal.logs.push({
184
291
  timestamp: Date.now(),
185
292
  type: 'warning',
186
293
  message: `Paused: ${reason}`,
187
294
  });
188
295
 
189
- this.emit();
190
- return this.getSnapshot();
296
+ if (goal.logs.length > 100) {
297
+ goal.logs = goal.logs.slice(-100);
298
+ }
299
+
300
+ this.emit(sid, true);
301
+ return this.getSnapshot(sid);
191
302
  }
192
303
 
193
304
  /**
194
305
  * Возобновление выполнения цели
195
306
  */
196
- resume() {
197
- if (!this.currentGoal || this.currentGoal.state !== GoalState.PAUSED) {
198
- return this.getSnapshot();
307
+ resume(sessionId = 'default') {
308
+ const sid = sessionId || 'default';
309
+ const goal = this.goals.get(sid);
310
+ if (!goal || goal.state !== GoalState.PAUSED) {
311
+ return this.getSnapshot(sid);
199
312
  }
200
313
 
201
314
  const now = Date.now();
202
- if (this.currentGoal.pausedAt) {
203
- this.currentGoal.totalPausedDurationMs += now - this.currentGoal.pausedAt;
204
- this.currentGoal.pausedAt = null;
315
+ if (goal.pausedAt) {
316
+ goal.totalPausedDurationMs += now - goal.pausedAt;
317
+ goal.pausedAt = null;
205
318
  }
206
319
 
207
- this.currentGoal.state = GoalState.RUNNING;
208
- this.currentGoal.logs.push({
320
+ goal.state = GoalState.RUNNING;
321
+ goal.logs.push({
209
322
  timestamp: now,
210
323
  type: 'info',
211
324
  message: 'Goal resumed',
212
325
  });
213
326
 
214
- this.emit();
215
- return this.getSnapshot();
327
+ if (goal.logs.length > 100) {
328
+ goal.logs = goal.logs.slice(-100);
329
+ }
330
+
331
+ this.emit(sid, true);
332
+ return this.getSnapshot(sid);
216
333
  }
217
334
 
218
335
  /**
219
336
  * Отмена цели
220
337
  */
221
- cancel(reason = 'Cancelled by user') {
222
- if (!this.currentGoal) return null;
223
-
224
- this.currentGoal.state = GoalState.CANCELLED;
225
- this.currentGoal.completedAt = Date.now();
226
- this.currentGoal.logs.push({
338
+ cancel(reason = 'Cancelled by user', sessionId = 'default') {
339
+ const sid = sessionId || 'default';
340
+ const goal = this.goals.get(sid);
341
+ if (!goal) return null;
342
+
343
+ goal.state = GoalState.CANCELLED;
344
+ goal.completedAt = Date.now();
345
+ goal.logs.push({
227
346
  timestamp: Date.now(),
228
347
  type: 'warning',
229
348
  message: `Cancelled: ${reason}`,
230
349
  });
231
350
 
232
- this.emit();
233
- return this.getSnapshot();
351
+ if (goal.logs.length > 100) {
352
+ goal.logs = goal.logs.slice(-100);
353
+ }
354
+
355
+ this.emit(sid, true);
356
+ return this.getSnapshot(sid);
234
357
  }
235
358
 
236
359
  /**
237
360
  * Очистка / сброс цели в IDLE
238
361
  */
239
- clear() {
240
- this.currentGoal = null;
241
- this.emit();
242
- return this.getSnapshot();
362
+ clear(sessionId = 'default') {
363
+ const sid = sessionId || 'default';
364
+ this.goals.delete(sid);
365
+ this.emit(sid, true);
366
+ return this.getSnapshot(sid);
243
367
  }
244
368
 
245
369
  /**
246
370
  * Успешное завершение цели
247
371
  */
248
- completeGoal(summary = '') {
249
- if (!this.currentGoal) return null;
372
+ completeGoal(summary = '', sessionId = 'default') {
373
+ const sid = sessionId || 'default';
374
+ const goal = this.goals.get(sid);
375
+ if (!goal) return null;
250
376
 
251
377
  const now = Date.now();
252
- this.currentGoal.state = GoalState.COMPLETED;
253
- this.currentGoal.completedAt = now;
254
- this.currentGoal.resultSummary = summary;
255
- this.currentGoal.logs.push({
378
+ goal.state = GoalState.COMPLETED;
379
+ goal.completedAt = now;
380
+ goal.resultSummary = summary;
381
+ goal.logs.push({
256
382
  timestamp: now,
257
383
  type: 'info',
258
384
  message: `Goal completed successfully: ${summary || 'All objectives met.'}`,
259
385
  });
260
386
 
387
+ if (goal.logs.length > 100) {
388
+ goal.logs = goal.logs.slice(-100);
389
+ }
390
+
261
391
  // Завершаем все активные milestones
262
- for (const m of this.currentGoal.milestones) {
392
+ for (const m of goal.milestones) {
263
393
  if (m.status === MilestoneStatus.IN_PROGRESS || m.status === MilestoneStatus.PENDING) {
264
394
  m.status = MilestoneStatus.COMPLETED;
265
395
  }
266
396
  }
267
397
 
268
- this.emit();
269
- return this.getSnapshot();
398
+ this.emit(sid, true);
399
+ return this.getSnapshot(sid);
270
400
  }
271
401
 
272
402
  /**
273
403
  * Добавление вех (milestones)
274
404
  */
275
- addMilestones(milestonesList, shouldEmit = true) {
276
- if (!this.currentGoal || !Array.isArray(milestonesList)) return;
405
+ addMilestones(milestonesList, shouldEmit = true, sessionId = 'default') {
406
+ const sid = sessionId || 'default';
407
+ const goal = this.goals.get(sid);
408
+ if (!goal || !Array.isArray(milestonesList)) return;
277
409
 
278
410
  for (const item of milestonesList) {
279
411
  const itemTitle = typeof item === 'string' ? item : item.title;
280
412
  if (!itemTitle || !itemTitle.trim()) continue;
281
413
 
282
- const mId = (typeof item === 'object' && item.id) ? item.id : `m-${this.currentGoal.milestones.length + 1}`;
283
- this.currentGoal.milestones.push({
414
+ const mId = (typeof item === 'object' && item.id) ? item.id : `m-${goal.milestones.length + 1}`;
415
+ goal.milestones.push({
284
416
  id: String(mId),
285
417
  title: itemTitle.trim(),
286
418
  status: (typeof item === 'object' && item.status) ? item.status : MilestoneStatus.PENDING,
@@ -288,66 +420,79 @@ export class GoalEngine {
288
420
  });
289
421
  }
290
422
 
291
- if (shouldEmit) this.emit();
423
+ if (shouldEmit) this.emit(sid);
292
424
  }
293
425
 
294
426
  /**
295
427
  * Обновление конкретной вехи
296
428
  */
297
- updateMilestone(id, status, notes = '') {
298
- if (!this.currentGoal) return false;
429
+ updateMilestone(id, status, notes = '', sessionId = 'default') {
430
+ const sid = sessionId || 'default';
431
+ const goal = this.goals.get(sid);
432
+ if (!goal) return false;
299
433
 
300
- const target = this.currentGoal.milestones.find((m) => m.id === String(id));
434
+ const target = goal.milestones.find((m) => m.id === String(id));
301
435
  if (!target) return false;
302
436
 
303
437
  if (status && Object.values(MilestoneStatus).includes(status)) {
304
438
  target.status = status;
305
439
  }
306
440
  if (notes) {
307
- target.notes = notes;
441
+ target.notes = String(notes);
308
442
  }
309
443
 
310
- this.currentGoal.logs.push({
444
+ goal.logs.push({
311
445
  timestamp: Date.now(),
312
446
  type: 'milestone',
313
447
  message: `Milestone [${target.title}] status -> ${target.status}`,
314
448
  });
315
449
 
316
- this.emit();
450
+ if (goal.logs.length > 100) {
451
+ goal.logs = goal.logs.slice(-100);
452
+ }
453
+
454
+ this.emit(sid);
317
455
  return true;
318
456
  }
319
457
 
320
458
  /**
321
459
  * Увеличение счётчика итераций turn
322
460
  */
323
- incrementIteration() {
324
- if (!this.currentGoal || this.currentGoal.state !== GoalState.RUNNING) {
461
+ incrementIteration(sessionId = 'default') {
462
+ const sid = sessionId || 'default';
463
+ const goal = this.goals.get(sid);
464
+ if (!goal || goal.state !== GoalState.RUNNING) {
325
465
  return false;
326
466
  }
327
467
 
328
- this.currentGoal.iterationsCount += 1;
468
+ goal.iterationsCount += 1;
329
469
 
330
- if (this.currentGoal.iterationsCount >= this.currentGoal.maxIterations) {
331
- this.currentGoal.state = GoalState.FAILED;
332
- this.currentGoal.logs.push({
470
+ if (goal.iterationsCount >= goal.maxIterations) {
471
+ goal.state = GoalState.FAILED;
472
+ goal.logs.push({
333
473
  timestamp: Date.now(),
334
474
  type: 'error',
335
- message: `Safety limit reached: maximum ${this.currentGoal.maxIterations} iterations exceeded.`,
475
+ message: `Safety limit reached: maximum ${goal.maxIterations} iterations exceeded.`,
336
476
  });
337
- this.emit();
477
+ if (goal.logs.length > 100) {
478
+ goal.logs = goal.logs.slice(-100);
479
+ }
480
+ this.emit(sid, true);
338
481
  return false;
339
482
  }
340
483
 
341
- this.emit();
484
+ this.emit(sid);
342
485
  return true;
343
486
  }
344
487
 
345
488
  /**
346
489
  * Подсчёт времени в секундах
347
490
  */
348
- getElapsedSeconds() {
349
- if (!this.currentGoal) return 0;
350
- const { startedAt, pausedAt, totalPausedDurationMs, completedAt } = this.currentGoal;
491
+ getElapsedSeconds(sessionId = 'default') {
492
+ const sid = sessionId || 'default';
493
+ const goal = this.goals.get(sid);
494
+ if (!goal) return 0;
495
+ const { startedAt, pausedAt, totalPausedDurationMs, completedAt } = goal;
351
496
  const endTime = completedAt || (pausedAt || Date.now());
352
497
  const elapsedMs = Math.max(0, endTime - startedAt - totalPausedDurationMs);
353
498
  return Math.floor(elapsedMs / 1000);
@@ -356,9 +501,12 @@ export class GoalEngine {
356
501
  /**
357
502
  * Снимок состояния для передачи клиенту / API
358
503
  */
359
- getSnapshot() {
360
- if (!this.currentGoal) {
504
+ getSnapshot(sessionId = 'default') {
505
+ const sid = sessionId || 'default';
506
+ const goal = this.goals.get(sid);
507
+ if (!goal) {
361
508
  return {
509
+ sessionId: sid,
362
510
  hasActiveGoal: false,
363
511
  state: GoalState.IDLE,
364
512
  title: '',
@@ -377,29 +525,30 @@ export class GoalEngine {
377
525
  };
378
526
  }
379
527
 
380
- const elapsed = this.getElapsedSeconds();
381
- const milestones = this.currentGoal.milestones;
528
+ const elapsed = this.getElapsedSeconds(sid);
529
+ const milestones = goal.milestones;
382
530
  const completedCount = milestones.filter((m) => m.status === MilestoneStatus.COMPLETED).length;
383
531
  const progressPercent = milestones.length > 0 ? Math.round((completedCount / milestones.length) * 100) : 0;
384
532
 
385
533
  return {
534
+ sessionId: sid,
386
535
  hasActiveGoal: true,
387
- id: this.currentGoal.id,
388
- state: this.currentGoal.state,
389
- title: this.currentGoal.title,
390
- description: this.currentGoal.description,
391
- startedAt: this.currentGoal.startedAt,
392
- pausedAt: this.currentGoal.pausedAt,
393
- totalPausedDurationMs: this.currentGoal.totalPausedDurationMs,
394
- completedAt: this.currentGoal.completedAt,
536
+ id: goal.id,
537
+ state: goal.state,
538
+ title: goal.title,
539
+ description: goal.description,
540
+ startedAt: goal.startedAt,
541
+ pausedAt: goal.pausedAt,
542
+ totalPausedDurationMs: goal.totalPausedDurationMs,
543
+ completedAt: goal.completedAt,
395
544
  elapsedSeconds: elapsed,
396
545
  formattedElapsed: formatElapsed(elapsed),
397
- iterationsCount: this.currentGoal.iterationsCount,
398
- maxIterations: this.currentGoal.maxIterations,
546
+ iterationsCount: goal.iterationsCount,
547
+ maxIterations: goal.maxIterations,
399
548
  milestones,
400
549
  progressPercent,
401
- logs: this.currentGoal.logs,
402
- resultSummary: this.currentGoal.resultSummary,
550
+ logs: goal.logs,
551
+ resultSummary: goal.resultSummary,
403
552
  autoDrive: this.autoDrive,
404
553
  enableSound: this.enableSound,
405
554
  };
@@ -408,12 +557,14 @@ export class GoalEngine {
408
557
  /**
409
558
  * Формирование системного контекста для инжекта модели
410
559
  */
411
- getStatePromptInjection() {
412
- if (!this.currentGoal || this.currentGoal.state !== GoalState.RUNNING) {
560
+ getStatePromptInjection(sessionId = 'default') {
561
+ const sid = sessionId || 'default';
562
+ const goal = this.goals.get(sid);
563
+ if (!goal || goal.state !== GoalState.RUNNING) {
413
564
  return '';
414
565
  }
415
566
 
416
- const snapshot = this.getSnapshot();
567
+ const snapshot = this.getSnapshot(sid);
417
568
  const hasMilestones = snapshot.milestones.length > 0;
418
569
  const milestonesText = hasMilestones
419
570
  ? snapshot.milestones.map((m, i) => ` ${i + 1}. [${m.status.toUpperCase()}] ${m.title}${m.notes ? ` (${m.notes})` : ''}`).join('\n')
@@ -425,9 +576,9 @@ export class GoalEngine {
425
576
  План работ:
426
577
  ${milestonesText}
427
578
 
428
- Инструкции Goal Mode:
429
- 1. ${hasMilestones ? 'Выполняй текущий активный пункт плана работ.' : 'ТВОЙ ПЕРВЫЙ ШАГ: Немедленно вызови инструмент goal_set_milestones со списком пунктов плана работ.'}
430
- 2. По мере выполнения каждого шага отмечай его статус через инструмент goal_update_progress (status: "in_progress" перед началом, "completed" по завершении).
431
- 3. Когда все пункты плана будут выполнены, вызови инструмент goal_finish с итоговым резюме.`;
579
+ Инструкции Goal Mode (СТРОГО ОБЯЗАТЕЛЬНЫ К ВЫПОЛНЕНИЮ):
580
+ 1. ${hasMilestones ? 'Выполняй текущий активный пункт плана работ.' : 'ТВОЙ ПЕРВЫЙ ШАГ: Немедленно вызови инструмент goal_set_milestones со списком пунктов плана работ (3-7 конкретных шагов). Запрещено выполнять работу или завершать turn без вызова goal_set_milestones!'}
581
+ 2. По мере выполнения каждого шага обязательно отмечай его статус через инструмент goal_update_progress (status: "in_progress" перед началом шага, status: "completed" по его завершении с кратким notes).
582
+ 3. Когда все пункты плана будут выполнены, вызови инструмент goal_finish с подробным итоговым резюме достигнутых результатов.`;
432
583
  }
433
584
  }