forge-workflow 0.0.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/.claude/commands/dev.md +314 -0
- package/.claude/commands/plan.md +389 -0
- package/.claude/commands/premerge.md +179 -0
- package/.claude/commands/research.md +42 -0
- package/.claude/commands/review.md +442 -0
- package/.claude/commands/rollback.md +721 -0
- package/.claude/commands/ship.md +134 -0
- package/.claude/commands/sonarcloud.md +152 -0
- package/.claude/commands/status.md +77 -0
- package/.claude/commands/validate.md +237 -0
- package/.claude/commands/verify.md +221 -0
- package/.claude/rules/greptile-review-process.md +285 -0
- package/.claude/rules/workflow.md +105 -0
- package/.claude/scripts/greptile-resolve.sh +526 -0
- package/.claude/scripts/load-env.sh +32 -0
- package/.forge/hooks/check-tdd.js +240 -0
- package/.github/PLUGIN_TEMPLATE.json +32 -0
- package/.mcp.json.example +12 -0
- package/AGENTS.md +169 -0
- package/CLAUDE.md +99 -0
- package/LICENSE +21 -0
- package/README.md +414 -0
- package/bin/forge-cmd.js +313 -0
- package/bin/forge-validate.js +303 -0
- package/bin/forge.js +4228 -0
- package/docs/AGENT_INSTALL_PROMPT.md +342 -0
- package/docs/ENHANCED_ONBOARDING.md +602 -0
- package/docs/EXAMPLES.md +482 -0
- package/docs/GREPTILE_SETUP.md +400 -0
- package/docs/MANUAL_REVIEW_GUIDE.md +106 -0
- package/docs/ROADMAP.md +359 -0
- package/docs/SETUP.md +632 -0
- package/docs/TOOLCHAIN.md +849 -0
- package/docs/VALIDATION.md +363 -0
- package/docs/WORKFLOW.md +400 -0
- package/docs/planning/PROGRESS.md +396 -0
- package/docs/plans/.gitkeep +0 -0
- package/docs/plans/2026-02-27-forge-test-suite-v2-decisions.md +21 -0
- package/docs/plans/2026-02-27-forge-test-suite-v2-design.md +362 -0
- package/docs/plans/2026-02-27-forge-test-suite-v2-tasks.md +343 -0
- package/docs/plans/2026-03-02-superpowers-gaps-decisions.md +26 -0
- package/docs/plans/2026-03-02-superpowers-gaps-design.md +239 -0
- package/docs/plans/2026-03-02-superpowers-gaps-tasks.md +260 -0
- package/docs/plans/2026-03-04-agent-command-parity-design.md +163 -0
- package/docs/plans/2026-03-04-verify-worktree-cleanup-decisions.md +7 -0
- package/docs/plans/2026-03-04-verify-worktree-cleanup-design.md +165 -0
- package/docs/plans/2026-03-05-forge-uto-decisions.md +6 -0
- package/docs/plans/2026-03-05-forge-uto-design.md +116 -0
- package/docs/plans/2026-03-05-forge-uto-tasks.md +244 -0
- package/docs/plans/2026-03-10-command-creator-and-eval-decisions.md +52 -0
- package/docs/plans/2026-03-10-command-creator-and-eval-design.md +350 -0
- package/docs/plans/2026-03-10-command-creator-and-eval-tasks.md +426 -0
- package/docs/plans/2026-03-10-stale-workflow-refs-decisions.md +8 -0
- package/docs/plans/2026-03-10-stale-workflow-refs-design.md +80 -0
- package/docs/plans/2026-03-10-stale-workflow-refs-tasks.md +90 -0
- package/docs/plans/2026-03-14-beads-plan-context-decisions.md +9 -0
- package/docs/plans/2026-03-14-beads-plan-context-design.md +171 -0
- package/docs/plans/2026-03-14-beads-plan-context-tasks.md +160 -0
- package/docs/plans/2026-03-14-skill-eval-loop-decisions.md +33 -0
- package/docs/plans/2026-03-14-skill-eval-loop-design.md +118 -0
- package/docs/plans/2026-03-14-skill-eval-loop-results.md +78 -0
- package/docs/plans/2026-03-14-skill-eval-loop-tasks.md +160 -0
- package/docs/plans/2026-03-15-agent-command-parity-v2-decisions.md +11 -0
- package/docs/plans/2026-03-15-agent-command-parity-v2-design.md +145 -0
- package/docs/plans/2026-03-15-agent-command-parity-v2-tasks.md +211 -0
- package/docs/research/TEMPLATE.md +292 -0
- package/docs/research/advanced-testing.md +297 -0
- package/docs/research/agent-permissions.md +167 -0
- package/docs/research/dependency-chain.md +328 -0
- package/docs/research/forge-workflow-v2.md +550 -0
- package/docs/research/plugin-architecture.md +772 -0
- package/docs/research/pr4-cli-automation.md +326 -0
- package/docs/research/premerge-verify-restructure.md +205 -0
- package/docs/research/skills-restructure.md +508 -0
- package/docs/research/sonarcloud-perfection-plan.md +166 -0
- package/docs/research/sonarcloud-quality-gate.md +184 -0
- package/docs/research/superpowers-integration.md +403 -0
- package/docs/research/superpowers.md +319 -0
- package/docs/research/test-environment.md +519 -0
- package/install.sh +1062 -0
- package/lefthook.yml +39 -0
- package/lib/agents/README.md +198 -0
- package/lib/agents/claude.plugin.json +28 -0
- package/lib/agents/cline.plugin.json +22 -0
- package/lib/agents/codex.plugin.json +19 -0
- package/lib/agents/copilot.plugin.json +24 -0
- package/lib/agents/cursor.plugin.json +25 -0
- package/lib/agents/kilocode.plugin.json +22 -0
- package/lib/agents/opencode.plugin.json +20 -0
- package/lib/agents/roo.plugin.json +23 -0
- package/lib/agents-config.js +2112 -0
- package/lib/commands/dev.js +513 -0
- package/lib/commands/plan.js +696 -0
- package/lib/commands/recommend.js +119 -0
- package/lib/commands/ship.js +377 -0
- package/lib/commands/status.js +378 -0
- package/lib/commands/validate.js +602 -0
- package/lib/context-merge.js +359 -0
- package/lib/plugin-catalog.js +360 -0
- package/lib/plugin-manager.js +166 -0
- package/lib/plugin-recommender.js +141 -0
- package/lib/project-discovery.js +491 -0
- package/lib/setup.js +118 -0
- package/lib/workflow-profiles.js +203 -0
- package/package.json +115 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic Merge for Context Files
|
|
3
|
+
*
|
|
4
|
+
* Intelligently merges existing CLAUDE.md/AGENTS.md files with Forge workflow templates
|
|
5
|
+
* by understanding the semantic meaning of markdown sections.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { distance: levenshteinDistance } = require('fastest-levenshtein');
|
|
9
|
+
|
|
10
|
+
// Section category definitions
|
|
11
|
+
const SECTION_CATEGORIES = {
|
|
12
|
+
preserve: [
|
|
13
|
+
'Project Description',
|
|
14
|
+
'Project Instructions',
|
|
15
|
+
'Project Overview',
|
|
16
|
+
'Project Background',
|
|
17
|
+
'Domain Knowledge',
|
|
18
|
+
'Domain Concepts',
|
|
19
|
+
'Coding Standards',
|
|
20
|
+
'Code Standards',
|
|
21
|
+
'Architecture',
|
|
22
|
+
'Tech Stack',
|
|
23
|
+
'Technology Stack',
|
|
24
|
+
'Build Commands',
|
|
25
|
+
'Team Conventions',
|
|
26
|
+
'Migration Strategy',
|
|
27
|
+
'Setup',
|
|
28
|
+
'Installation',
|
|
29
|
+
'Quick Start',
|
|
30
|
+
'Getting Started'
|
|
31
|
+
],
|
|
32
|
+
|
|
33
|
+
replace: [
|
|
34
|
+
'Workflow',
|
|
35
|
+
'Development Workflow',
|
|
36
|
+
'Our Workflow',
|
|
37
|
+
'Workflow Process',
|
|
38
|
+
'Development Process',
|
|
39
|
+
'Process',
|
|
40
|
+
'TDD',
|
|
41
|
+
'Test-Driven Development',
|
|
42
|
+
'TDD Approach',
|
|
43
|
+
'Testing Approach',
|
|
44
|
+
'Git Workflow',
|
|
45
|
+
'Git Conventions',
|
|
46
|
+
'Commit Conventions',
|
|
47
|
+
'Git Strategy',
|
|
48
|
+
'Forge Workflow',
|
|
49
|
+
'Core Principles',
|
|
50
|
+
'Development Principles'
|
|
51
|
+
],
|
|
52
|
+
|
|
53
|
+
merge: [
|
|
54
|
+
'Toolchain',
|
|
55
|
+
'Tools',
|
|
56
|
+
'MCP Servers',
|
|
57
|
+
'Integrations',
|
|
58
|
+
'Dependencies',
|
|
59
|
+
'Libraries'
|
|
60
|
+
]
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parse markdown content into semantic sections
|
|
65
|
+
* @param {string} markdownContent - Raw markdown content
|
|
66
|
+
* @returns {Array} Array of section objects with structure:
|
|
67
|
+
* { level, header, content, raw, startLine, endLine }
|
|
68
|
+
*/
|
|
69
|
+
function parseSemanticSections(markdownContent) {
|
|
70
|
+
if (!markdownContent || typeof markdownContent !== 'string') {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const lines = markdownContent.split('\n');
|
|
75
|
+
const sections = [];
|
|
76
|
+
let currentSection = null;
|
|
77
|
+
|
|
78
|
+
for (let i = 0; i < lines.length; i++) {
|
|
79
|
+
const line = lines[i];
|
|
80
|
+
// Use RegExp.exec() instead of String.match() per S5852 recommendation
|
|
81
|
+
const headerMatch = /^(#{1,6})\s+([^\r\n]+)$/.exec(line); // NOSONAR S5852 - uses [^\r\n]+ (bounded, no backtracking), developer tool context
|
|
82
|
+
|
|
83
|
+
if (headerMatch) {
|
|
84
|
+
// Save previous section if exists
|
|
85
|
+
if (currentSection) {
|
|
86
|
+
currentSection.endLine = i - 1;
|
|
87
|
+
currentSection.content = currentSection.content.trim();
|
|
88
|
+
sections.push(currentSection);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Start new section
|
|
92
|
+
currentSection = {
|
|
93
|
+
level: headerMatch[1].length,
|
|
94
|
+
header: headerMatch[2].trim(),
|
|
95
|
+
content: '',
|
|
96
|
+
raw: line,
|
|
97
|
+
startLine: i
|
|
98
|
+
};
|
|
99
|
+
} else if (currentSection) {
|
|
100
|
+
// Add content to current section
|
|
101
|
+
currentSection.content += line + '\n';
|
|
102
|
+
currentSection.raw += '\n' + line;
|
|
103
|
+
} else if (line.trim() !== '') {
|
|
104
|
+
// Content before first header (preamble)
|
|
105
|
+
sections.push({
|
|
106
|
+
level: 0,
|
|
107
|
+
header: null,
|
|
108
|
+
content: line,
|
|
109
|
+
raw: line,
|
|
110
|
+
startLine: i,
|
|
111
|
+
endLine: i
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Save last section
|
|
117
|
+
if (currentSection) {
|
|
118
|
+
currentSection.endLine = lines.length - 1;
|
|
119
|
+
currentSection.content = currentSection.content.trim();
|
|
120
|
+
sections.push(currentSection);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return sections;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Calculate similarity between normalized text and keyword
|
|
128
|
+
* Reduces cognitive complexity by extracting matching logic (S3776)
|
|
129
|
+
* @param {string} normalized - Normalized header text
|
|
130
|
+
* @param {string} keywordNorm - Normalized keyword
|
|
131
|
+
* @returns {number} - Similarity score 0-1
|
|
132
|
+
*/
|
|
133
|
+
function calculateKeywordSimilarity(normalized, keywordNorm) {
|
|
134
|
+
// Fuzzy match using Levenshtein distance
|
|
135
|
+
const distance = levenshteinDistance(normalized, keywordNorm);
|
|
136
|
+
const maxLen = Math.max(normalized.length, keywordNorm.length);
|
|
137
|
+
const similarity = 1 - (distance / maxLen);
|
|
138
|
+
|
|
139
|
+
// Also check if normalized contains the keyword
|
|
140
|
+
if (normalized.includes(keywordNorm) || keywordNorm.includes(normalized)) {
|
|
141
|
+
const containsSimilarity = Math.min(normalized.length, keywordNorm.length) / maxLen;
|
|
142
|
+
return Math.max(similarity, containsSimilarity);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return similarity;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Detect the category of a section based on its header
|
|
150
|
+
* @param {string} headerText - Section header text
|
|
151
|
+
* @returns {Object} { category: 'preserve'|'replace'|'merge'|'unknown', confidence: 0-1 }
|
|
152
|
+
*/
|
|
153
|
+
function detectCategory(headerText) {
|
|
154
|
+
if (!headerText || typeof headerText !== 'string') {
|
|
155
|
+
return { category: 'unknown', confidence: 0 };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const normalized = headerText.toLowerCase().trim();
|
|
159
|
+
let bestMatch = { category: 'unknown', confidence: 0 };
|
|
160
|
+
|
|
161
|
+
// Check each category
|
|
162
|
+
for (const [category, keywords] of Object.entries(SECTION_CATEGORIES)) {
|
|
163
|
+
for (const keyword of keywords) {
|
|
164
|
+
const keywordNorm = keyword.toLowerCase().trim();
|
|
165
|
+
|
|
166
|
+
// Exact match = highest confidence
|
|
167
|
+
if (normalized === keywordNorm) {
|
|
168
|
+
return { category, confidence: 1 };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Calculate similarity and update best match
|
|
172
|
+
const similarity = calculateKeywordSimilarity(normalized, keywordNorm);
|
|
173
|
+
if (similarity > bestMatch.confidence) {
|
|
174
|
+
bestMatch = { category, confidence: similarity };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return bestMatch;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Build merged document from categorized sections
|
|
184
|
+
* @param {Array} existingSections - Sections from existing file
|
|
185
|
+
* @param {Array} forgeSections - Sections from Forge template
|
|
186
|
+
* @param {Object} options - Merge options
|
|
187
|
+
* @returns {string} Merged markdown content
|
|
188
|
+
*/
|
|
189
|
+
function buildMergedDocument(existingSections, forgeSections, _options = {}) {
|
|
190
|
+
const result = [];
|
|
191
|
+
const processedExisting = new Set();
|
|
192
|
+
|
|
193
|
+
// Process forge sections first to establish structure
|
|
194
|
+
forgeSections.forEach(forgeSection => {
|
|
195
|
+
if (!forgeSection.header) {
|
|
196
|
+
return; // Skip preamble from forge
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const forgeCategory = detectCategory(forgeSection.header);
|
|
200
|
+
|
|
201
|
+
if (forgeCategory.category === 'replace' && forgeCategory.confidence > 0.6) {
|
|
202
|
+
// This is a workflow/TDD section - use forge version
|
|
203
|
+
result.push(forgeSection.raw);
|
|
204
|
+
|
|
205
|
+
// Mark any similar existing sections as processed
|
|
206
|
+
existingSections.forEach((existingSection, idx) => {
|
|
207
|
+
if (existingSection.header) {
|
|
208
|
+
const existingCategory = detectCategory(existingSection.header);
|
|
209
|
+
if (existingCategory.category === 'replace' && existingCategory.confidence > 0.6) {
|
|
210
|
+
// Check if headers are similar enough
|
|
211
|
+
const normalized1 = forgeSection.header.toLowerCase();
|
|
212
|
+
const normalized2 = existingSection.header.toLowerCase();
|
|
213
|
+
const distance = levenshteinDistance(normalized1, normalized2);
|
|
214
|
+
const similarity = 1 - (distance / Math.max(normalized1.length, normalized2.length));
|
|
215
|
+
|
|
216
|
+
if (similarity > 0.5) {
|
|
217
|
+
processedExisting.add(idx);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
} else if (forgeCategory.category === 'merge' && forgeCategory.confidence > 0.6) {
|
|
223
|
+
// Merge section - combine both
|
|
224
|
+
result.push(forgeSection.raw);
|
|
225
|
+
|
|
226
|
+
// Find and add corresponding existing section
|
|
227
|
+
existingSections.forEach((existingSection, idx) => {
|
|
228
|
+
if (existingSection.header) {
|
|
229
|
+
const existingCategory = detectCategory(existingSection.header);
|
|
230
|
+
if (existingCategory.category === 'merge' && existingCategory.confidence > 0.6) {
|
|
231
|
+
const normalized1 = forgeSection.header.toLowerCase();
|
|
232
|
+
const normalized2 = existingSection.header.toLowerCase();
|
|
233
|
+
const distance = levenshteinDistance(normalized1, normalized2);
|
|
234
|
+
const similarity = 1 - (distance / Math.max(normalized1.length, normalized2.length));
|
|
235
|
+
|
|
236
|
+
if (similarity > 0.5) {
|
|
237
|
+
// Add existing content under forge header
|
|
238
|
+
result.push('\n' + existingSection.content);
|
|
239
|
+
processedExisting.add(idx);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Add preserved sections from existing file
|
|
248
|
+
existingSections.forEach((section, idx) => {
|
|
249
|
+
if (processedExisting.has(idx)) {
|
|
250
|
+
return; // Already processed
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!section.header) {
|
|
254
|
+
// Preserve preamble content
|
|
255
|
+
if (section.content && section.content.trim() !== '') {
|
|
256
|
+
result.unshift(section.raw); // Add to beginning
|
|
257
|
+
}
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const category = detectCategory(section.header);
|
|
262
|
+
|
|
263
|
+
// Preserve sections unless explicitly marked for replacement with high confidence
|
|
264
|
+
// Categories: preserve, merge, unknown all get preserved (safety first)
|
|
265
|
+
const shouldPreserve = category.category !== 'replace' || category.confidence <= 0.6;
|
|
266
|
+
|
|
267
|
+
if (shouldPreserve) {
|
|
268
|
+
result.push(section.raw);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
return result.join('\n\n');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Semantic merge of existing and forge content
|
|
277
|
+
* @param {string} existingContent - Existing file content
|
|
278
|
+
* @param {string} forgeContent - Forge template content
|
|
279
|
+
* @param {Object} options - { addMarkers: boolean }
|
|
280
|
+
* @returns {string} Merged content
|
|
281
|
+
*/
|
|
282
|
+
function semanticMerge(existingContent, forgeContent, options = {}) {
|
|
283
|
+
// Normalize line endings to LF for consistent parsing across platforms (Windows CRLF vs Unix LF)
|
|
284
|
+
const normalizeLineEndings = (str) => str ? str.replaceAll('\r\n', '\n').replaceAll('\r', '\n') : str;
|
|
285
|
+
|
|
286
|
+
existingContent = normalizeLineEndings(existingContent);
|
|
287
|
+
forgeContent = normalizeLineEndings(forgeContent);
|
|
288
|
+
|
|
289
|
+
// Handle empty cases
|
|
290
|
+
if (!existingContent || existingContent.trim() === '') {
|
|
291
|
+
return forgeContent || '';
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (!forgeContent || forgeContent.trim() === '') {
|
|
295
|
+
return existingContent;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Parse both documents
|
|
299
|
+
const existingSections = parseSemanticSections(existingContent);
|
|
300
|
+
const forgeSections = parseSemanticSections(forgeContent);
|
|
301
|
+
|
|
302
|
+
// Build merged document
|
|
303
|
+
const merged = buildMergedDocument(existingSections, forgeSections, options);
|
|
304
|
+
|
|
305
|
+
// Add markers if requested
|
|
306
|
+
if (options.addMarkers) {
|
|
307
|
+
// Separate preserved (user) and forge sections
|
|
308
|
+
const userSections = existingSections.filter(s => {
|
|
309
|
+
if (!s.header) return false;
|
|
310
|
+
const category = detectCategory(s.header);
|
|
311
|
+
return category.category === 'preserve' && category.confidence > 0.6;
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const forgeSectionsFiltered = forgeSections.filter(s => {
|
|
315
|
+
if (!s.header) return false;
|
|
316
|
+
const category = detectCategory(s.header);
|
|
317
|
+
return category.category === 'replace' && category.confidence > 0.6;
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
return wrapWithMarkers({
|
|
321
|
+
user: userSections.map(s => s.raw).join('\n\n'),
|
|
322
|
+
forge: forgeSectionsFiltered.map(s => s.raw).join('\n\n')
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return merged;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Wrap content with USER and FORGE markers
|
|
331
|
+
* @param {Object} content - { user: string, forge: string }
|
|
332
|
+
* @returns {string} Content wrapped with markers
|
|
333
|
+
*/
|
|
334
|
+
function wrapWithMarkers(content) {
|
|
335
|
+
const parts = [];
|
|
336
|
+
|
|
337
|
+
if (content.forge && content.forge.trim() !== '') {
|
|
338
|
+
parts.push('<!-- FORGE:START -->', content.forge.trim(), '<!-- FORGE:END -->');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (content.user && content.user.trim() !== '') {
|
|
342
|
+
parts.push('', '<!-- USER:START -->', content.user.trim(), '<!-- USER:END -->');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return parts.join('\n');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
module.exports = {
|
|
349
|
+
parseSemanticSections,
|
|
350
|
+
detectCategory,
|
|
351
|
+
semanticMerge,
|
|
352
|
+
wrapWithMarkers,
|
|
353
|
+
// Export for testing
|
|
354
|
+
__internal: {
|
|
355
|
+
levenshteinDistance,
|
|
356
|
+
buildMergedDocument,
|
|
357
|
+
SECTION_CATEGORIES
|
|
358
|
+
}
|
|
359
|
+
};
|