opencastle 0.28.0 → 0.30.0
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/README.md +12 -3
- package/dist/cli/convoy/engine.d.ts.map +1 -1
- package/dist/cli/convoy/engine.js +1 -10
- package/dist/cli/convoy/engine.js.map +1 -1
- package/dist/cli/convoy/engine.test.js +1 -0
- package/dist/cli/convoy/engine.test.js.map +1 -1
- package/dist/cli/convoy/export.d.ts +1 -3
- package/dist/cli/convoy/export.d.ts.map +1 -1
- package/dist/cli/convoy/export.js +9 -88
- package/dist/cli/convoy/export.js.map +1 -1
- package/dist/cli/convoy/export.test.js +7 -186
- package/dist/cli/convoy/export.test.js.map +1 -1
- package/dist/cli/convoy/pipeline.d.ts.map +1 -1
- package/dist/cli/convoy/pipeline.js +0 -21
- package/dist/cli/convoy/pipeline.js.map +1 -1
- package/dist/cli/convoy/pipeline.test.js +0 -21
- package/dist/cli/convoy/pipeline.test.js.map +1 -1
- package/dist/cli/dashboard.d.ts.map +1 -1
- package/dist/cli/dashboard.js +32 -8
- package/dist/cli/dashboard.js.map +1 -1
- package/dist/cli/destroy.d.ts.map +1 -1
- package/dist/cli/destroy.js +13 -0
- package/dist/cli/destroy.js.map +1 -1
- package/dist/cli/dispute.d.ts +3 -0
- package/dist/cli/dispute.d.ts.map +1 -0
- package/dist/cli/dispute.js +25 -0
- package/dist/cli/dispute.js.map +1 -0
- package/dist/cli/doctor.d.ts +1 -1
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +14 -1
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/eject.d.ts.map +1 -1
- package/dist/cli/eject.js +14 -0
- package/dist/cli/eject.js.map +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +14 -0
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/log.d.ts +0 -11
- package/dist/cli/log.d.ts.map +1 -1
- package/dist/cli/log.js +2 -114
- package/dist/cli/log.js.map +1 -1
- package/dist/cli/pipeline.d.ts +17 -0
- package/dist/cli/pipeline.d.ts.map +1 -1
- package/dist/cli/pipeline.js +259 -24
- package/dist/cli/pipeline.js.map +1 -1
- package/dist/cli/pipeline.test.d.ts +2 -0
- package/dist/cli/pipeline.test.d.ts.map +1 -0
- package/dist/cli/pipeline.test.js +178 -0
- package/dist/cli/pipeline.test.js.map +1 -0
- package/dist/cli/run.js +2 -2
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/update.d.ts.map +1 -1
- package/dist/cli/update.js +16 -0
- package/dist/cli/update.js.map +1 -1
- package/dist/cli/watch.d.ts.map +1 -1
- package/dist/cli/watch.js +1 -3
- package/dist/cli/watch.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/convoy/engine.test.ts +1 -0
- package/src/cli/convoy/engine.ts +1 -4
- package/src/cli/convoy/export.test.ts +7 -224
- package/src/cli/convoy/export.ts +10 -106
- package/src/cli/convoy/pipeline.test.ts +0 -25
- package/src/cli/convoy/pipeline.ts +0 -19
- package/src/cli/dashboard.ts +33 -8
- package/src/cli/destroy.ts +15 -0
- package/src/cli/dispute.ts +28 -0
- package/src/cli/doctor.ts +16 -1
- package/src/cli/eject.ts +16 -0
- package/src/cli/init.ts +16 -0
- package/src/cli/log.ts +2 -120
- package/src/cli/pipeline.test.ts +191 -0
- package/src/cli/pipeline.ts +326 -26
- package/src/cli/run.ts +2 -2
- package/src/cli/update.ts +18 -0
- package/src/cli/watch.ts +1 -3
- package/src/dashboard/dist/_astro/index.Je1YjU_y.css +1 -0
- package/src/dashboard/dist/index.html +537 -1394
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
- package/src/dashboard/scripts/etl.test.ts +4 -62
- package/src/dashboard/scripts/etl.ts +13 -33
- package/src/dashboard/src/pages/index.astro +684 -1624
- package/src/dashboard/src/styles/dashboard.css +473 -7
- package/src/orchestrator/agents/team-lead.agent.md +13 -0
- package/src/orchestrator/prompts/brainstorm.prompt.md +1 -0
- package/src/orchestrator/prompts/fix-prd.prompt.md +58 -0
- package/src/orchestrator/prompts/generate-convoy.prompt.md +30 -0
- package/src/orchestrator/prompts/generate-prd.prompt.md +38 -0
- package/dist/cli/convoy/log-merge.test.d.ts +0 -2
- package/dist/cli/convoy/log-merge.test.d.ts.map +0 -1
- package/dist/cli/convoy/log-merge.test.js +0 -147
- package/dist/cli/convoy/log-merge.test.js.map +0 -1
- package/src/cli/convoy/log-merge.test.ts +0 -179
- package/src/dashboard/dist/_astro/index.6L3_HsPT.css +0 -1
package/src/cli/log.ts
CHANGED
|
@@ -1,29 +1,18 @@
|
|
|
1
1
|
import { mkdir, appendFile, stat } from 'node:fs/promises'
|
|
2
|
-
import { readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
3
2
|
import { join, dirname } from 'node:path'
|
|
4
3
|
import type { CliContext } from './types.js'
|
|
5
4
|
|
|
6
5
|
const HELP = `
|
|
7
6
|
opencastle log [options]
|
|
8
|
-
opencastle log merge [--since <ISO-date>] [--until <ISO-date>] [--output <path>]
|
|
9
7
|
|
|
10
|
-
Append a structured event to the observability log (events.ndjson)
|
|
11
|
-
or merge per-convoy NDJSON files into a single file.
|
|
8
|
+
Append a structured event to the observability log (events.ndjson).
|
|
12
9
|
|
|
13
|
-
|
|
14
|
-
merge Merge all .opencastle/logs/convoys/*.ndjson into convoy-events.ndjson
|
|
15
|
-
|
|
16
|
-
Options (log append):
|
|
10
|
+
Options:
|
|
17
11
|
--type <type> Event type (required): session|delegation|review|panel|dispute
|
|
18
12
|
--<field> <value> Any field from the event schema (see documentation)
|
|
19
13
|
--logs-dir <path> Override the logs directory path
|
|
20
14
|
--help, -h Show this help
|
|
21
15
|
|
|
22
|
-
Options (merge):
|
|
23
|
-
--since <ISO-date> Only include records at or after this date
|
|
24
|
-
--until <ISO-date> Only include records at or before this date
|
|
25
|
-
--output <path> Output path (default: .opencastle/logs/convoy-events.ndjson)
|
|
26
|
-
|
|
27
16
|
Array fields (comma-separated): file_partition, lessons_added, discoveries, reviewing_agents
|
|
28
17
|
Boolean fields: escalated, weighted
|
|
29
18
|
Numeric fields: auto-detected from value
|
|
@@ -32,8 +21,6 @@ const HELP = `
|
|
|
32
21
|
opencastle log --type session --agent Developer --model claude-sonnet-4-6 --task "Fix bug" --outcome success
|
|
33
22
|
opencastle log --type delegation --session_id feat/prj-1 --agent Developer --tier fast --mechanism sub-agent --outcome success
|
|
34
23
|
opencastle log --type panel --panel_key auth-review --verdict pass --pass_count 3 --block_count 0
|
|
35
|
-
opencastle log merge --since 2026-01-01 --output /tmp/merged.ndjson
|
|
36
|
-
opencastle log merge
|
|
37
24
|
`
|
|
38
25
|
|
|
39
26
|
const VALID_TYPES = ['session', 'delegation', 'review', 'panel', 'dispute']
|
|
@@ -72,94 +59,6 @@ export async function resolveLogsDir(override?: string | null): Promise<string>
|
|
|
72
59
|
return join(process.cwd(), '.opencastle', 'logs')
|
|
73
60
|
}
|
|
74
61
|
|
|
75
|
-
/** Merge per-convoy NDJSON files into a single deduplicated, sorted file. */
|
|
76
|
-
export async function mergeConvoyLogs(options: {
|
|
77
|
-
since?: string
|
|
78
|
-
until?: string
|
|
79
|
-
output?: string
|
|
80
|
-
basePath?: string
|
|
81
|
-
}): Promise<{ merged: number; deduplicated: number; written: number }> {
|
|
82
|
-
const base = options.basePath ?? process.cwd()
|
|
83
|
-
const convoysDir = join(base, '.opencastle', 'logs', 'convoys')
|
|
84
|
-
|
|
85
|
-
let files: string[] = []
|
|
86
|
-
try {
|
|
87
|
-
files = readdirSync(convoysDir)
|
|
88
|
-
.filter(f => f.endsWith('.ndjson'))
|
|
89
|
-
.map(f => join(convoysDir, f))
|
|
90
|
-
} catch {
|
|
91
|
-
return { merged: 0, deduplicated: 0, written: 0 }
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (files.length === 0) {
|
|
95
|
-
return { merged: 0, deduplicated: 0, written: 0 }
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const allRecords: Array<Record<string, unknown>> = []
|
|
99
|
-
let totalRead = 0
|
|
100
|
-
|
|
101
|
-
for (const file of files) {
|
|
102
|
-
const content = readFileSync(file, 'utf8')
|
|
103
|
-
const lines = content.split('\n').filter(l => l.trim())
|
|
104
|
-
for (const line of lines) {
|
|
105
|
-
try {
|
|
106
|
-
allRecords.push(JSON.parse(line) as Record<string, unknown>)
|
|
107
|
-
totalRead++
|
|
108
|
-
} catch {
|
|
109
|
-
// skip malformed lines
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Deduplicate by _event_id — keep first occurrence
|
|
115
|
-
const seen = new Set<unknown>()
|
|
116
|
-
const unique: Array<Record<string, unknown>> = []
|
|
117
|
-
for (const record of allRecords) {
|
|
118
|
-
const id = record['_event_id']
|
|
119
|
-
if (id !== undefined) {
|
|
120
|
-
if (seen.has(id)) continue
|
|
121
|
-
seen.add(id)
|
|
122
|
-
}
|
|
123
|
-
unique.push(record)
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const deduplicatedCount = totalRead - unique.length
|
|
127
|
-
|
|
128
|
-
// Filter by since/until
|
|
129
|
-
let filtered = unique
|
|
130
|
-
if (options.since) {
|
|
131
|
-
const since = options.since
|
|
132
|
-
filtered = filtered.filter(r => {
|
|
133
|
-
const ts = r['timestamp'] as string | undefined
|
|
134
|
-
return ts !== undefined && ts >= since
|
|
135
|
-
})
|
|
136
|
-
}
|
|
137
|
-
if (options.until) {
|
|
138
|
-
const until = options.until
|
|
139
|
-
filtered = filtered.filter(r => {
|
|
140
|
-
const ts = r['timestamp'] as string | undefined
|
|
141
|
-
return ts !== undefined && ts <= until
|
|
142
|
-
})
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Sort by timestamp ascending
|
|
146
|
-
filtered.sort((a, b) => {
|
|
147
|
-
const ta = (a['timestamp'] as string) ?? ''
|
|
148
|
-
const tb = (b['timestamp'] as string) ?? ''
|
|
149
|
-
return ta < tb ? -1 : ta > tb ? 1 : 0
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
if (filtered.length === 0) {
|
|
153
|
-
return { merged: totalRead, deduplicated: deduplicatedCount, written: 0 }
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const outputPath = options.output ?? join(base, '.opencastle', 'logs', 'convoy-events.ndjson')
|
|
157
|
-
mkdirSync(dirname(outputPath), { recursive: true })
|
|
158
|
-
writeFileSync(outputPath, filtered.map(r => JSON.stringify(r)).join('\n') + '\n', 'utf8')
|
|
159
|
-
|
|
160
|
-
return { merged: totalRead, deduplicated: deduplicatedCount, written: filtered.length }
|
|
161
|
-
}
|
|
162
|
-
|
|
163
62
|
/** Append a structured event record to events.ndjson. */
|
|
164
63
|
export async function appendEvent(
|
|
165
64
|
record: Record<string, unknown>,
|
|
@@ -178,23 +77,6 @@ export default async function log({ args }: CliContext): Promise<void> {
|
|
|
178
77
|
return
|
|
179
78
|
}
|
|
180
79
|
|
|
181
|
-
// merge subcommand
|
|
182
|
-
if (args[0] === 'merge') {
|
|
183
|
-
const mergeArgs = args.slice(1)
|
|
184
|
-
let since: string | undefined
|
|
185
|
-
let until: string | undefined
|
|
186
|
-
let output: string | undefined
|
|
187
|
-
for (let i = 0; i < mergeArgs.length; i++) {
|
|
188
|
-
const a = mergeArgs[i]
|
|
189
|
-
if (a === '--since' && i + 1 < mergeArgs.length) { since = mergeArgs[++i]; continue }
|
|
190
|
-
if (a === '--until' && i + 1 < mergeArgs.length) { until = mergeArgs[++i]; continue }
|
|
191
|
-
if (a === '--output' && i + 1 < mergeArgs.length) { output = mergeArgs[++i]; continue }
|
|
192
|
-
}
|
|
193
|
-
const result = await mergeConvoyLogs({ since, until, output })
|
|
194
|
-
console.log(` Merged: ${result.merged} records, Deduplicated: ${result.deduplicated}, Written: ${result.written}`)
|
|
195
|
-
return
|
|
196
|
-
}
|
|
197
|
-
|
|
198
80
|
let type: string | null = null
|
|
199
81
|
let logsDir: string | null = null
|
|
200
82
|
const fields: Record<string, unknown> = {}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { parseComplexityAssessment } from './pipeline.js'
|
|
3
|
+
|
|
4
|
+
const SINGLE_PRD = `# My Feature — PRD
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
Some overview.
|
|
8
|
+
|
|
9
|
+
## Risks & Open Questions
|
|
10
|
+
None identified.
|
|
11
|
+
|
|
12
|
+
## Complexity Assessment
|
|
13
|
+
|
|
14
|
+
\`\`\`json
|
|
15
|
+
{
|
|
16
|
+
"total_tasks": 4,
|
|
17
|
+
"total_phases": 2,
|
|
18
|
+
"domains": ["api", "frontend"],
|
|
19
|
+
"estimated_duration_minutes": 60,
|
|
20
|
+
"complexity": "medium",
|
|
21
|
+
"recommended_strategy": "single",
|
|
22
|
+
"chain_rationale": "",
|
|
23
|
+
"convoy_groups": [
|
|
24
|
+
{
|
|
25
|
+
"name": "full-implementation",
|
|
26
|
+
"description": "All phases in a single convoy",
|
|
27
|
+
"phases": [1, 2],
|
|
28
|
+
"depends_on": []
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
\`\`\`
|
|
33
|
+
`
|
|
34
|
+
|
|
35
|
+
const CHAIN_PRD = `# Big Feature — PRD
|
|
36
|
+
|
|
37
|
+
## Overview
|
|
38
|
+
Big feature.
|
|
39
|
+
|
|
40
|
+
## Risks & Open Questions
|
|
41
|
+
None identified.
|
|
42
|
+
|
|
43
|
+
## Complexity Assessment
|
|
44
|
+
|
|
45
|
+
\`\`\`json
|
|
46
|
+
{
|
|
47
|
+
"total_tasks": 12,
|
|
48
|
+
"total_phases": 4,
|
|
49
|
+
"domains": ["database", "api", "frontend", "testing"],
|
|
50
|
+
"estimated_duration_minutes": 240,
|
|
51
|
+
"complexity": "high",
|
|
52
|
+
"recommended_strategy": "chain",
|
|
53
|
+
"chain_rationale": "Database schema changes have no frontend dependencies and can be validated independently.",
|
|
54
|
+
"convoy_groups": [
|
|
55
|
+
{
|
|
56
|
+
"name": "database-setup",
|
|
57
|
+
"description": "Schema changes and migrations",
|
|
58
|
+
"phases": [1],
|
|
59
|
+
"depends_on": []
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"name": "api-integration",
|
|
63
|
+
"description": "API routes and server logic",
|
|
64
|
+
"phases": [2],
|
|
65
|
+
"depends_on": ["database-setup"]
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"name": "frontend-testing",
|
|
69
|
+
"description": "UI components and test suite",
|
|
70
|
+
"phases": [3, 4],
|
|
71
|
+
"depends_on": ["api-integration"]
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
\`\`\`
|
|
76
|
+
`
|
|
77
|
+
|
|
78
|
+
const NO_SECTION_PRD = `# Feature — PRD
|
|
79
|
+
|
|
80
|
+
## Overview
|
|
81
|
+
Plain prd with no complexity section.
|
|
82
|
+
|
|
83
|
+
## Risks & Open Questions
|
|
84
|
+
None.
|
|
85
|
+
`
|
|
86
|
+
|
|
87
|
+
const MALFORMED_JSON_PRD = `# Feature — PRD
|
|
88
|
+
|
|
89
|
+
## Complexity Assessment
|
|
90
|
+
|
|
91
|
+
\`\`\`json
|
|
92
|
+
{ "total_tasks": 3, "broken":
|
|
93
|
+
\`\`\`
|
|
94
|
+
`
|
|
95
|
+
|
|
96
|
+
const UNFENCED_JSON_PRD = `# Feature — PRD
|
|
97
|
+
|
|
98
|
+
## Complexity Assessment
|
|
99
|
+
|
|
100
|
+
The complexity is medium.
|
|
101
|
+
|
|
102
|
+
total_tasks: 3, total_phases: 2
|
|
103
|
+
`
|
|
104
|
+
|
|
105
|
+
const OTHER_JSON_PRD = `# Feature — PRD
|
|
106
|
+
|
|
107
|
+
## Overview
|
|
108
|
+
Some feature with json-like content: \`{"key": "value"}\`
|
|
109
|
+
|
|
110
|
+
Another block:
|
|
111
|
+
\`\`\`json
|
|
112
|
+
{"not": "complexity"}
|
|
113
|
+
\`\`\`
|
|
114
|
+
|
|
115
|
+
## Complexity Assessment
|
|
116
|
+
|
|
117
|
+
\`\`\`json
|
|
118
|
+
{
|
|
119
|
+
"total_tasks": 6,
|
|
120
|
+
"total_phases": 3,
|
|
121
|
+
"domains": ["api", "frontend"],
|
|
122
|
+
"complexity": "medium",
|
|
123
|
+
"recommended_strategy": "single",
|
|
124
|
+
"convoy_groups": [
|
|
125
|
+
{
|
|
126
|
+
"name": "impl",
|
|
127
|
+
"description": "All phases",
|
|
128
|
+
"phases": [1, 2, 3],
|
|
129
|
+
"depends_on": []
|
|
130
|
+
}
|
|
131
|
+
]
|
|
132
|
+
}
|
|
133
|
+
\`\`\`
|
|
134
|
+
`
|
|
135
|
+
|
|
136
|
+
describe('parseComplexityAssessment', () => {
|
|
137
|
+
it('returns null when PRD has no Complexity Assessment section', () => {
|
|
138
|
+
expect(parseComplexityAssessment(NO_SECTION_PRD)).toBeNull()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('returns null when JSON is malformed', () => {
|
|
142
|
+
expect(parseComplexityAssessment(MALFORMED_JSON_PRD)).toBeNull()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('parses a valid single strategy assessment', () => {
|
|
146
|
+
const result = parseComplexityAssessment(SINGLE_PRD)
|
|
147
|
+
expect(result).not.toBeNull()
|
|
148
|
+
expect(result?.recommended_strategy).toBe('single')
|
|
149
|
+
expect(result?.complexity).toBe('medium')
|
|
150
|
+
expect(result?.total_tasks).toBe(4)
|
|
151
|
+
expect(result?.total_phases).toBe(2)
|
|
152
|
+
expect(result?.domains).toEqual(['api', 'frontend'])
|
|
153
|
+
expect(result?.convoy_groups).toHaveLength(1)
|
|
154
|
+
expect(result?.convoy_groups[0].name).toBe('full-implementation')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('parses a valid chain strategy assessment with multiple groups', () => {
|
|
158
|
+
const result = parseComplexityAssessment(CHAIN_PRD)
|
|
159
|
+
expect(result).not.toBeNull()
|
|
160
|
+
expect(result?.recommended_strategy).toBe('chain')
|
|
161
|
+
expect(result?.complexity).toBe('high')
|
|
162
|
+
expect(result?.convoy_groups).toHaveLength(3)
|
|
163
|
+
expect(result?.convoy_groups[0].name).toBe('database-setup')
|
|
164
|
+
expect(result?.convoy_groups[1].depends_on).toEqual(['database-setup'])
|
|
165
|
+
expect(result?.convoy_groups[2].phases).toEqual([3, 4])
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('handles missing optional fields gracefully', () => {
|
|
169
|
+
const prd = `# Feature — PRD\n\n## Complexity Assessment\n\n\`\`\`json\n{\n "total_tasks": 3,\n "total_phases": 1,\n "domains": ["api"],\n "complexity": "low",\n "recommended_strategy": "single",\n "convoy_groups": [{"name": "all", "description": "All", "phases": [1], "depends_on": []}]\n}\n\`\`\`\n`
|
|
170
|
+
const result = parseComplexityAssessment(prd)
|
|
171
|
+
expect(result).not.toBeNull()
|
|
172
|
+
expect(result?.estimated_duration_minutes).toBeUndefined()
|
|
173
|
+
expect(result?.chain_rationale).toBeUndefined()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('returns null when JSON block is not fenced properly', () => {
|
|
177
|
+
expect(parseComplexityAssessment(UNFENCED_JSON_PRD)).toBeNull()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('correctly extracts JSON even when PRD has other JSON-like content elsewhere', () => {
|
|
181
|
+
const result = parseComplexityAssessment(OTHER_JSON_PRD)
|
|
182
|
+
expect(result).not.toBeNull()
|
|
183
|
+
expect(result?.total_tasks).toBe(6)
|
|
184
|
+
expect(result?.recommended_strategy).toBe('single')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('returns null when required fields are missing from JSON', () => {
|
|
188
|
+
const prd = `# Feature — PRD\n\n## Complexity Assessment\n\n\`\`\`json\n{"total_tasks": 3}\n\`\`\`\n`
|
|
189
|
+
expect(parseComplexityAssessment(prd)).toBeNull()
|
|
190
|
+
})
|
|
191
|
+
})
|