@syntax-syllogism/aloop 0.7.0 → 0.8.2

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/src/reporter.mjs CHANGED
@@ -1,14 +1,49 @@
1
1
  import { createReadStream } from 'node:fs';
2
2
  import { createInterface } from 'node:readline/promises';
3
3
 
4
+ // Renderer seam: `log`/`banner` fan out to whichever renderer is active so a
5
+ // second renderer (e.g. the TUI in tui.mjs) can slot in without runner.mjs or
6
+ // pipeline.mjs knowing which one is live. `report` is deliberately left alone
7
+ // — callers stop the alternate renderer and reset to plain before calling it,
8
+ // so the final summary always prints as normal scrollback text.
9
+ let activeRenderer = null;
10
+
11
+ export function setRenderer(renderer) {
12
+ activeRenderer = renderer;
13
+ }
14
+
15
+ export function resetRenderer() {
16
+ activeRenderer = null;
17
+ }
18
+
4
19
  export function log(message) {
20
+ if (activeRenderer) {
21
+ activeRenderer.log(message);
22
+ return;
23
+ }
5
24
  console.log(message);
6
25
  }
7
26
 
8
27
  export function banner(text) {
28
+ if (activeRenderer) {
29
+ activeRenderer.banner(text);
30
+ return;
31
+ }
9
32
  log(`\n── ${text} ${'─'.repeat(Math.max(0, 60 - text.length))}`);
10
33
  }
11
34
 
35
+ // Raw child-process/engine output (gate commands, setup commands, agent
36
+ // streams) goes through this seam too, not straight to process.stdout: a
37
+ // renderer that repaints the screen (the TUI) needs to be the only writer,
38
+ // or its repaints interleave with the raw bytes into a garbled display.
39
+ export function writeOutput(text) {
40
+ if (activeRenderer) {
41
+ activeRenderer.output?.(text);
42
+ return;
43
+ }
44
+ process.stdout.write(text);
45
+ }
46
+
12
47
  export function describeAgent(agent) {
13
48
  return `${agent.name}${agent.model ? ` model=${agent.model}` : ''}${agent.effort ? ` effort=${agent.effort}` : ''}`;
14
49
  }
@@ -18,7 +53,8 @@ export function formatPhase(phase) {
18
53
  return ` ✓ ${phase.name}${detail}`;
19
54
  }
20
55
 
21
- export function openTerminalInput({ platform = process.platform, input = process.stdin, createInput = createReadStream } = {}) {
56
+ export function openTerminalInput(options = {}) {
57
+ const { platform = process.platform, input = process.stdin, createInput = createReadStream } = /** @type {any} */ (options);
22
58
  if (platform === 'win32' && input.isTTY) return Promise.resolve(input);
23
59
  const terminalPath = platform === 'win32' ? '\\\\.\\CONIN$' : '/dev/tty';
24
60
  return new Promise((resolveInput, rejectInput) => {
package/src/runner.mjs CHANGED
@@ -1,3 +1,8 @@
1
+ /** @typedef {import('./types.js').Config} Config */
2
+ /** @typedef {import('./types.js').Operations} Operations */
3
+ /** @typedef {import('./types.js').RunState} RunState */
4
+ /** @typedef {import('./types.js').Summary} Summary */
5
+
1
6
  import { stat, unlink } from 'node:fs/promises';
2
7
  import { join } from 'node:path';
3
8
  import { codePhasePostcondition, hasPostcondition, phaseIsSkippable, publishingAttestation } from './policy.mjs';
@@ -60,12 +65,33 @@ async function invalidateGateCompletion(state, reviewedSha) {
60
65
  await state.record({ completed: state.data.completed.filter((name) => name !== gate.phase) });
61
66
  }
62
67
 
63
- async function runRepairs(phase, ctx, baseVariables, pending, operations) {
68
+ async function invalidateFollowingPhaseState(state, phases, phaseName) {
69
+ const phaseIndex = phases.findIndex((phase) => phase.name === phaseName);
70
+ const followingNames = new Set(phases.slice(phaseIndex + 1).map((phase) => phase.name));
71
+ if (!followingNames.size) return;
72
+
73
+ const completed = state.data.completed.filter((name) => !followingNames.has(name));
74
+ const phaseState = Object.fromEntries(
75
+ Object.entries(state.data.phases ?? {}).filter(([name]) => !followingNames.has(name)),
76
+ );
77
+ const pendingRepairs = Object.fromEntries(
78
+ Object.entries(state.data.pendingRepairs ?? {}).filter(([name]) => !followingNames.has(name)),
79
+ );
80
+ const rounds = Object.fromEntries(
81
+ Object.entries(state.data.rounds ?? {}).filter(([name]) => !followingNames.has(name)),
82
+ );
83
+ const reviewedShas = Object.fromEntries(
84
+ Object.entries(state.data.reviewedShas ?? {}).filter(([name]) => !followingNames.has(name)),
85
+ );
86
+ await state.record({ completed, phases: phaseState, pendingRepairs, rounds, reviewedShas });
87
+ }
88
+
89
+ async function runRepairs(phase, ctx, baseVariables, pending, operations, { retainCheckpoint = false } = {}) {
64
90
  const {
65
91
  budgetStatus, manifestEntry, recordBudgetStall, recordManifest, runAgent, runGate, withRetries,
66
92
  } = operations;
67
- let gateOk = true;
68
- let gateFailure = null;
93
+ let gateOk = pending.gateOk ?? true;
94
+ let gateFailure = pending.gateFailure ?? null;
69
95
  for (let index = pending.nextRepair; index < phase.repair.length; index += 1) {
70
96
  const repair = phase.repair[index];
71
97
  const budget = budgetStatus(ctx);
@@ -108,12 +134,15 @@ async function runRepairs(phase, ctx, baseVariables, pending, operations) {
108
134
  const headBefore = !ctx.dryRun && (hasPostcondition(repair, 'head-advanced') || hasPostcondition(repair, 'head-advanced-or-rebuttal'))
109
135
  ? await ctx.git.revParse()
110
136
  : null;
111
- const result = await withRetries(repair, () => runAgent(repair, ctx, {
137
+ const repairCtx = ctx.note?.targetPhase === phase.name
138
+ ? { ...ctx, note: { ...ctx.note, repairPhase: repair.name } }
139
+ : ctx;
140
+ const result = await withRetries(repair, () => runAgent(repair, repairCtx, {
112
141
  ...baseVariables,
113
142
  ROUND: pending.round,
114
143
  MAX_ROUNDS: phase.maxRounds,
115
144
  VERDICT_FILE: ctx.state.verdictPath(pending.round),
116
- FINDINGS: formatFindings(pending.verdict.blocking),
145
+ FINDINGS: formatFindings(pending.verdict?.blocking ?? []),
117
146
  SINCE_SHA: pending.reviewedSha,
118
147
  GATE_STATUS: gateOk ? 'passing' : `FAILING\n${gateFailure}`,
119
148
  }));
@@ -144,10 +173,77 @@ async function runRepairs(phase, ctx, baseVariables, pending, operations) {
144
173
  },
145
174
  });
146
175
  }
147
- await clearPendingRepair(phase, ctx.state);
176
+ if (!retainCheckpoint) await clearPendingRepair(phase, ctx.state);
148
177
  return { gateOk, gateFailure };
149
178
  }
150
179
 
180
+ async function runGateAttempt(phase, ctx, operations, options = {}) {
181
+ const { round } = /** @type {any} */ (options);
182
+ const { manifestEntry, recordManifest, runGate, withRetries } = operations;
183
+ const startedAt = new Date().toISOString();
184
+ const inputSha = await ctx.git.revParse();
185
+ const result = await withRetries(phase, () => runGate(phase, ctx), { failed: (attempt) => !attempt.ok });
186
+ await recordManifest(ctx, manifestEntry(phase, ctx, {
187
+ inputSha,
188
+ outputSha: await ctx.git.revParse(),
189
+ artifacts: [`${phase.name}.log`],
190
+ gateReceipts: result.gateReceipts,
191
+ execution: result.execution,
192
+ startedAt,
193
+ completedAt: new Date().toISOString(),
194
+ durationMs: result.durationMs,
195
+ status: result.ok ? 'completed' : 'stalled',
196
+ ...(round === undefined ? {} : { round }),
197
+ }));
198
+ return result;
199
+ }
200
+
201
+ async function runGateRepairLoop(phase, ctx, variables, operations) {
202
+ let result = null;
203
+ let pending = ctx.resume ? ctx.state.data.pendingRepairs?.[phase.name] : null;
204
+ let round = pending?.round ?? 1;
205
+
206
+ while (round <= phase.maxRounds) {
207
+ if (pending) {
208
+ result = {
209
+ ok: false,
210
+ command: phase.name,
211
+ output: pending.gateFailure ?? '',
212
+ };
213
+ const repairResult = await runRepairs(phase, ctx, variables, pending, operations, { retainCheckpoint: true });
214
+ if (repairResult.stalled) {
215
+ return { result, stalled: repairResult.stalled, stalledPhase: repairResult.stalledPhase, budgetStall: repairResult.budgetStall };
216
+ }
217
+ // The checkpoint's repairs have completed, so re-gate deliberately before
218
+ // deciding whether another repair round is needed.
219
+ result = await runGateAttempt(phase, ctx, operations, { round });
220
+ if (result.ok) {
221
+ await clearPendingRepair(phase, ctx.state);
222
+ return { result };
223
+ }
224
+ pending = null;
225
+ round += 1;
226
+ continue;
227
+ }
228
+
229
+ result ??= await runGateAttempt(phase, ctx, operations);
230
+ if (result.ok) return { result };
231
+
232
+ pending = {
233
+ round,
234
+ reviewedSha: await ctx.git.revParse(),
235
+ verdict: { blocking: [] },
236
+ gateOk: false,
237
+ gateFailure: `${result.command}\n${result.output}`,
238
+ nextRepair: 0,
239
+ };
240
+ await ctx.state.record({
241
+ pendingRepairs: { ...ctx.state.data.pendingRepairs, [phase.name]: pending },
242
+ });
243
+ }
244
+ return { result };
245
+ }
246
+
151
247
  /**
152
248
  * Run a verdict phase and its repair phases until the verdict clears.
153
249
  *
@@ -327,7 +423,16 @@ async function runVerdictLoop(phase, ctx, baseVariables, operations) {
327
423
  }
328
424
  }
329
425
 
330
- /** Drive phase descriptors using operations supplied by the composition root. */
426
+ /**
427
+ * Drive phase descriptors using operations supplied by the composition root.
428
+ *
429
+ * @param {{
430
+ * args: Record<string, any>, config: Config, state: RunState, ctx: any,
431
+ * variables: Record<string, unknown>, summary: Summary, remote: string,
432
+ * branch: string, baseBranch: string, confirmInput: any,
433
+ * terminalOpener: any, operations: Operations,
434
+ * }} input
435
+ */
331
436
  export async function runPhases({
332
437
  args, config, state, ctx, variables, summary, remote, branch, baseBranch,
333
438
  confirmInput, terminalOpener, operations,
@@ -343,9 +448,22 @@ export async function runPhases({
343
448
  }
344
449
 
345
450
  const phasesToRun = config.resolvedPhases.slice(startIndex);
451
+ const noteTarget = args.from ?? (args.resume
452
+ ? phasesToRun.find((phase) => !state.isComplete(phase.name))?.name
453
+ : phasesToRun[0]?.name);
454
+ if (ctx.note) {
455
+ const target = config.resolvedPhases.find((phase) => phase.name === noteTarget);
456
+ if (!target) throw new Error('--note needs a phase to run; use --from to target a completed phase.');
457
+ const hasAgentRepair = target.kind === 'gate' && target.repair?.some((repair) => repair.kind === 'agent');
458
+ if (target.kind !== 'agent' && !hasAgentRepair) {
459
+ throw new Error(`--note cannot target ${target.kind} phase "${target.name}" without an agent repair phase.`);
460
+ }
461
+ ctx.note = { ...ctx.note, targetPhase: target.name };
462
+ }
346
463
  for (const [phaseIndex, phase] of phasesToRun.entries()) {
347
464
  const isFinalPhase = phaseIndex === phasesToRun.length - 1;
348
- if (args.resume && state.data.phases?.[phase.name]?.budgetExhausted) {
465
+ const forceNotedPhase = args.resume && args.from === phase.name && ctx.note?.targetPhase === phase.name;
466
+ if (args.resume && state.data.phases?.[phase.name]?.budgetExhausted && !forceNotedPhase) {
349
467
  const budget = budgetStatus(ctx);
350
468
  if (budget) {
351
469
  summary.stalled = await recordBudgetStall(ctx, phase, budget);
@@ -356,7 +474,7 @@ export async function runPhases({
356
474
  summary.phases.push({ name: phase.name, ok: true });
357
475
  continue;
358
476
  }
359
- if (args.resume && state.isComplete(phase.name)) {
477
+ if (args.resume && state.isComplete(phase.name) && !forceNotedPhase) {
360
478
  log(`\n── ${phase.name}: already complete, skipping`);
361
479
  await recordManifest(ctx, manifestEntry(phase, ctx, { status: 'skipped' }));
362
480
  continue;
@@ -393,6 +511,9 @@ export async function runPhases({
393
511
  continue;
394
512
  }
395
513
  }
514
+ if (forceNotedPhase) {
515
+ await invalidateFollowingPhaseState(state, config.resolvedPhases, phase.name);
516
+ }
396
517
  if (!phase.verdict) banner(phase.name);
397
518
  if (phase.requiresCleanTree && !ctx.dryRun) {
398
519
  const pending = await ctx.git.status();
@@ -439,18 +560,29 @@ export async function runPhases({
439
560
  log(` would run: ${(phase.commands ?? config.gate).join(' && ')}`);
440
561
  continue;
441
562
  }
442
- const startedAt = new Date().toISOString();
443
- const inputSha = await ctx.git.revParse();
444
- const result = await withRetries(phase, () => runGate(phase, ctx), { failed: (attempt) => !attempt.ok });
445
- await recordManifest(ctx, manifestEntry(phase, ctx, {
446
- inputSha, outputSha: await ctx.git.revParse(), artifacts: [`${phase.name}.log`],
447
- gateReceipts: result.gateReceipts, startedAt, completedAt: new Date().toISOString(),
448
- execution: result.execution,
449
- durationMs: result.durationMs, status: result.ok ? 'completed' : 'stalled',
450
- }));
451
- if (!result.ok) {
452
- summary.stalled = { phase: phase.name, reason: `gate failed: ${result.command}`, output: result.output };
453
- break;
563
+ if (phase.repair?.length) {
564
+ const repaired = await runGateRepairLoop(phase, ctx, variables, operations);
565
+ if (repaired.stalled) {
566
+ if (!repaired.budgetStall) {
567
+ await markStalledManifest(ctx, phase, repaired, state.manifest.entries.length - 1);
568
+ }
569
+ summary.stalled = {
570
+ phase: repaired.stalledPhase ?? phase.name,
571
+ reason: repaired.stalled,
572
+ output: repaired.result.output,
573
+ };
574
+ break;
575
+ }
576
+ if (!repaired.result.ok) {
577
+ summary.stalled = { phase: phase.name, reason: `gate failed: ${repaired.result.command}`, output: repaired.result.output };
578
+ break;
579
+ }
580
+ } else {
581
+ const result = await runGateAttempt(phase, ctx, operations);
582
+ if (!result.ok) {
583
+ summary.stalled = { phase: phase.name, reason: `gate failed: ${result.command}`, output: result.output };
584
+ break;
585
+ }
454
586
  }
455
587
  if (isFinalPhase) {
456
588
  const budget = budgetStatus(ctx);
package/src/state.mjs CHANGED
@@ -1,3 +1,6 @@
1
+ /** @typedef {import('./types.js').RunData} RunData */
2
+ /** @typedef {import('./types.js').ManifestEntry} ManifestEntry */
3
+
1
4
  import { randomUUID } from 'node:crypto';
2
5
  import { access, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
3
6
  import { readdirSync, renameSync, rmSync } from 'node:fs';
@@ -145,6 +148,7 @@ export function slugFor(input) {
145
148
  }
146
149
 
147
150
  export class RunState {
151
+ /** @param {string} dir @param {RunData} data @param {boolean} [readOnly] */
148
152
  constructor(dir, data, readOnly = false, manifest = new Manifest(), lockToken = null, manifestMetadata = {}) {
149
153
  this.dir = dir;
150
154
  this.data = data;
@@ -210,7 +214,7 @@ export class RunState {
210
214
  if (create) {
211
215
  try {
212
216
  const saved = JSON.parse(await readFile(join(dir, 'manifest.json'), 'utf8'));
213
- manifest = new Manifest(saved.phases ?? []);
217
+ manifest = new Manifest(/** @type {ManifestEntry[]} */ (saved.phases ?? []));
214
218
  manifestMetadata = saved;
215
219
  } catch (error) {
216
220
  if (error.code !== 'ENOENT') throw error;
package/src/tui.mjs ADDED
@@ -0,0 +1,171 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { computeRunMetrics } from './metrics.mjs';
3
+ import { formatFindings } from './verdict.mjs';
4
+
5
+ const ESC = '\x1b';
6
+ const CLEAR_SCREEN = `${ESC}[2J${ESC}[H`;
7
+ const HIDE_CURSOR = `${ESC}[?25l`;
8
+ const SHOW_CURSOR = `${ESC}[?25h`;
9
+ const RECENT_LOG_LIMIT = 8;
10
+
11
+ /**
12
+ * A live TUI is only a view over the same run; it never changes what the run
13
+ * does. It is therefore only worth showing where someone can watch it, and
14
+ * must get out of the way the moment that stops being true (piped output/CI, or
15
+ * an explicit `--no-tui`). `--yes` only skips per-phase confirmation, so an
16
+ * unattended run on a real terminal can still be watched live.
17
+ */
18
+ export function tuiEnabled(options = {}) {
19
+ const { isTTY, noTui } = /** @type {any} */ (options);
20
+ return Boolean(isTTY) && !noTui;
21
+ }
22
+
23
+ function statusSymbol(status) {
24
+ if (status === 'completed') return '✓';
25
+ if (status === 'stalled') return '✗';
26
+ if (status === 'active') return '▶';
27
+ return ' ';
28
+ }
29
+
30
+ function matchPhaseName(resolvedPhases, text) {
31
+ const found = resolvedPhases.find((phase) => text === phase.name || text.startsWith(`${phase.name} (`));
32
+ return found ? found.name : null;
33
+ }
34
+
35
+ function phaseRows(resolvedPhases, manifestEntries, completed, activeName) {
36
+ return resolvedPhases.map((phase) => {
37
+ const entries = manifestEntries.filter((entry) => entry.phase === phase.name);
38
+ const last = entries.at(-1);
39
+ let status = 'pending';
40
+ if (last?.status === 'stalled') status = 'stalled';
41
+ else if (completed.includes(phase.name)) status = 'completed';
42
+ else if (phase.name === activeName) status = 'active';
43
+ const rounds = entries.filter((entry) => Number.isInteger(entry.round)).length;
44
+ return { name: phase.name, status, rounds, verdict: last?.verdict?.verdict ?? null };
45
+ });
46
+ }
47
+
48
+ export function defaultDiffStat(worktree) {
49
+ if (!worktree) return '';
50
+ try {
51
+ const result = spawnSync('git', ['-C', worktree, 'diff', '--stat', 'HEAD'], { encoding: 'utf8' });
52
+ if (result.status !== 0 || result.error) return '';
53
+ return result.stdout.trim();
54
+ } catch {
55
+ return '';
56
+ }
57
+ }
58
+
59
+ function formatValue(value) {
60
+ return value === null || value === undefined ? '?' : String(value);
61
+ }
62
+
63
+ function latestVerdictEntry(manifestEntries) {
64
+ return [...manifestEntries].reverse().find((entry) => entry.verdict);
65
+ }
66
+
67
+ function renderFrame({ config, summary, state, activeName, recentLog, diff }) {
68
+ const manifestEntries = state.manifest.entries;
69
+ const completed = state.data.completed ?? [];
70
+ const rows = phaseRows(config.resolvedPhases, manifestEntries, completed, activeName);
71
+ const metrics = computeRunMetrics(state.manifest);
72
+ const artifacts = [...new Set(manifestEntries.flatMap((entry) => entry.artifacts ?? []))];
73
+ const verdictEntry = latestVerdictEntry(manifestEntries);
74
+
75
+ const lines = [];
76
+ lines.push(`aloop · ${summary.task} branch=${summary.branch}`);
77
+ lines.push('');
78
+ lines.push('Phases:');
79
+ for (const row of rows) {
80
+ const roundDetail = row.rounds ? ` (round ${row.rounds}${row.verdict ? `, ${row.verdict}` : ''})` : '';
81
+ lines.push(` ${statusSymbol(row.status)} ${row.name}${roundDetail}`);
82
+ }
83
+ lines.push('');
84
+ lines.push(
85
+ `Cost: duration=${formatValue(metrics.total.durationMs)}ms tokens=${formatValue(metrics.total.tokens)} cost=${formatValue(metrics.total.cost)}`,
86
+ );
87
+ if (diff) {
88
+ lines.push('');
89
+ lines.push('Diff:');
90
+ lines.push(...diff.split('\n'));
91
+ }
92
+ if (verdictEntry) {
93
+ lines.push('');
94
+ lines.push(`Findings (${verdictEntry.phase}, ${verdictEntry.verdict.verdict}):`);
95
+ lines.push(formatFindings([...(verdictEntry.verdict.blocking ?? []), ...(verdictEntry.verdict.nits ?? [])]));
96
+ }
97
+ if (artifacts.length) {
98
+ lines.push('');
99
+ lines.push(`Artifacts: ${artifacts.join(', ')}`);
100
+ }
101
+ if (recentLog.length) {
102
+ lines.push('');
103
+ lines.push('Recent:');
104
+ for (const entry of recentLog) lines.push(` ${entry}`);
105
+ }
106
+ return lines.join('\n');
107
+ }
108
+
109
+ /**
110
+ * A second renderer behind the `banner`/`log` seam in reporter.mjs. It reads
111
+ * the same manifest/state the run already persists after every phase, so it
112
+ * needs no separate event stream — just a repaint on every `banner`/`log`
113
+ * call, which is exactly when the run's visible state can have changed.
114
+ *
115
+ * `git diff --stat` is a subprocess spawn, not free, and the diff it reports
116
+ * only changes between phases (each phase is what edits the worktree) — so
117
+ * it's computed once per `banner` and reused by every repaint until the next
118
+ * one, instead of being re-spawned on every `log`/`output` call.
119
+ */
120
+ export function createTuiRenderer(options = {}) {
121
+ const { config, state, summary, output = process.stdout, diffStat = defaultDiffStat } = /** @type {any} */ (options);
122
+ let activeName = null;
123
+ const recentLog = [];
124
+ let pendingLine = '';
125
+ let cachedDiff = '';
126
+
127
+ function repaint() {
128
+ const frame = renderFrame({ config, summary, state, activeName, recentLog, diff: cachedDiff });
129
+ output.write(`${HIDE_CURSOR}${CLEAR_SCREEN}${frame}\n`);
130
+ }
131
+
132
+ function pushRecent(text) {
133
+ const trimmed = text.trim();
134
+ if (!trimmed) return;
135
+ recentLog.push(trimmed);
136
+ if (recentLog.length > RECENT_LOG_LIMIT) recentLog.shift();
137
+ }
138
+
139
+ return {
140
+ banner(text) {
141
+ activeName = matchPhaseName(config.resolvedPhases, text) ?? activeName;
142
+ recentLog.length = 0;
143
+ recentLog.push(text.trim());
144
+ cachedDiff = diffStat(summary.worktree);
145
+ repaint();
146
+ },
147
+ log(message) {
148
+ pushRecent(message);
149
+ repaint();
150
+ },
151
+ // Raw engine/gate/setup output arrives in arbitrary chunks, not lines;
152
+ // buffer until a newline so the "Recent" pane shows whole lines instead
153
+ // of chopped fragments, then fold it into the same repaint the other
154
+ // renderer calls use so nothing else writes to the terminal directly.
155
+ // A chunk with no newline yet changes nothing visible (the fragment
156
+ // isn't shown until it completes a line), so it's buffered without
157
+ // triggering a repaint.
158
+ output(text) {
159
+ pendingLine += text;
160
+ const lines = pendingLine.split('\n');
161
+ pendingLine = lines.pop();
162
+ if (lines.length === 0) return;
163
+ for (const line of lines) pushRecent(line);
164
+ repaint();
165
+ },
166
+ stop() {
167
+ repaint();
168
+ output.write(SHOW_CURSOR);
169
+ },
170
+ };
171
+ }