trackfw 2.0.0 → 2.1.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/package.json CHANGED
@@ -1,13 +1,20 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "2.0.0",
4
- "description": "Governed software delivery framework: ADR → REQ → ROADMAP → kanban",
3
+ "version": "2.1.1",
4
+ "description": "CLI de governança para entrega de software: ADR → REQ → ROADMAP → kanban. Suporte nativo a agentes de IA (Claude Code, Gemini CLI, Cursor).",
5
5
  "keywords": [
6
6
  "cli",
7
7
  "adr",
8
+ "architecture-decision-records",
8
9
  "roadmap",
9
10
  "governance",
10
- "delivery"
11
+ "software-delivery",
12
+ "devops",
13
+ "ai-agents",
14
+ "developer-tools",
15
+ "kanban",
16
+ "req",
17
+ "trackfw"
11
18
  ],
12
19
  "homepage": "https://github.com/kgsaran/trackfw",
13
20
  "repository": {
@@ -0,0 +1,189 @@
1
+ 'use strict'
2
+
3
+ const { Command } = require('commander')
4
+ const fs = require('fs')
5
+ const path = require('path')
6
+ const config = require('../config')
7
+ const { validate } = require('../validator')
8
+
9
+ /**
10
+ * extractFrontmatterField — extrai valor de campo YAML dentro de bloco --- ... ---.
11
+ * Retorna string vazia se não encontrado ou valor '""'.
12
+ * @param {string} content
13
+ * @param {string} field
14
+ * @returns {string}
15
+ */
16
+ function extractFrontmatterField(content, field) {
17
+ const lines = content.split('\n')
18
+ let started = false
19
+ let inFrontmatter = false
20
+ for (const line of lines) {
21
+ const trimmed = line.trim()
22
+ if (trimmed === '---') {
23
+ if (!started) {
24
+ started = true
25
+ inFrontmatter = true
26
+ continue
27
+ }
28
+ break // segundo --- fecha o bloco
29
+ }
30
+ if (!inFrontmatter) break
31
+ const key = field + ':'
32
+ if (trimmed.startsWith(key)) {
33
+ let val = trimmed.slice(key.length).trim()
34
+ val = val.replace(/^["']|["']$/g, '') // remover aspas
35
+ return val
36
+ }
37
+ }
38
+ return ''
39
+ }
40
+
41
+ /**
42
+ * extractInlineStatus — extrai status da linha "| Status: ..." do markdown.
43
+ * @param {string} content
44
+ * @returns {string}
45
+ */
46
+ function extractInlineStatus(content) {
47
+ for (const line of content.split('\n')) {
48
+ const idx = line.indexOf('| Status: ')
49
+ if (idx >= 0) {
50
+ let rest = line.slice(idx + '| Status: '.length)
51
+ const pipeIdx = rest.indexOf(' |')
52
+ if (pipeIdx >= 0) rest = rest.slice(0, pipeIdx)
53
+ rest = rest.replace(/[\s>|]+$/, '').trim()
54
+ return rest || 'unknown'
55
+ }
56
+ }
57
+ return 'unknown'
58
+ }
59
+
60
+ /**
61
+ * collectEntries — lê diretório e retorna lista de entradas com type, file, status, state.
62
+ * @param {string} dir
63
+ * @param {string} type - 'ADR' | 'REQ' | 'ROADMAP'
64
+ * @param {string} [state] - estado kanban (somente ROADMAP)
65
+ * @returns {Array<{type: string, file: string, status: string, state?: string}>}
66
+ */
67
+ function collectEntries(dir, type, state) {
68
+ const entries = []
69
+ let files = []
70
+ try {
71
+ files = fs.readdirSync(dir).filter(f => f.endsWith('.md') && !fs.statSync(path.join(dir, f)).isDirectory())
72
+ } catch (_) {
73
+ return entries
74
+ }
75
+ for (const file of files) {
76
+ let content = ''
77
+ try { content = fs.readFileSync(path.join(dir, file), 'utf8') } catch (_) {}
78
+ let status = extractFrontmatterField(content, 'status')
79
+ if (!status) status = extractInlineStatus(content)
80
+ if (!status) status = state || 'unknown'
81
+ const entry = { type, file, status }
82
+ if (state) entry.state = state
83
+ entries.push(entry)
84
+ }
85
+ return entries
86
+ }
87
+
88
+ /**
89
+ * getContext — coleta governança e imprime em md ou json.
90
+ * @param {string} format - 'md' | 'json'
91
+ */
92
+ function getContext(format) {
93
+ const cfg = config.load()
94
+
95
+ // ADRs
96
+ const adrs = []
97
+ for (const adrDir of (cfg.adrDirs || ['docs/adr'])) {
98
+ adrs.push(...collectEntries(adrDir, 'ADR'))
99
+ }
100
+
101
+ // REQs
102
+ const reqs = collectEntries(cfg.reqDir || 'docs/req', 'REQ')
103
+
104
+ // Roadmaps
105
+ const roadmaps = []
106
+ const states = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
107
+ if (cfg.roadmapNamespacing === 'by_agent') {
108
+ let agents = cfg.agents || []
109
+ if (agents.length === 0) {
110
+ try {
111
+ agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
112
+ try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
113
+ })
114
+ } catch (_) { agents = [] }
115
+ }
116
+ for (const agent of agents) {
117
+ for (const state of states) {
118
+ const dir = path.join(cfg.roadmapDir, agent, state)
119
+ roadmaps.push(...collectEntries(dir, 'ROADMAP', state))
120
+ }
121
+ }
122
+ } else {
123
+ for (const state of states) {
124
+ const dir = path.join(cfg.roadmapDir, state)
125
+ roadmaps.push(...collectEntries(dir, 'ROADMAP', state))
126
+ }
127
+ }
128
+
129
+ // Validate
130
+ const { violations, warnings } = validate()
131
+
132
+ // Score
133
+ let score = 0
134
+ if (adrs.length > 0) score += 20
135
+ if (reqs.length > 0) score += 20
136
+ if (roadmaps.length > 0) score += 20
137
+ if (violations.length === 0) score += 40
138
+
139
+ if (format === 'json') {
140
+ console.log(JSON.stringify({ score, violations, warnings, adrs, reqs, roadmaps }, null, 2))
141
+ return
142
+ }
143
+
144
+ // Markdown
145
+ console.log('# trackfw governance context\n')
146
+ console.log(`**Governance score:** ${score}/100\n`)
147
+
148
+ console.log(`## ADRs (${adrs.length})`)
149
+ if (adrs.length === 0) {
150
+ console.log('- (none)')
151
+ } else {
152
+ for (const a of adrs) console.log(`- ${a.file} [${a.status}]`)
153
+ }
154
+
155
+ console.log(`\n## REQs (${reqs.length})`)
156
+ if (reqs.length === 0) {
157
+ console.log('- (none)')
158
+ } else {
159
+ for (const r of reqs) console.log(`- ${r.file} [${r.status}]`)
160
+ }
161
+
162
+ console.log(`\n## Roadmaps (${roadmaps.length})`)
163
+ if (roadmaps.length === 0) {
164
+ console.log('- (none)')
165
+ } else {
166
+ for (const r of roadmaps) console.log(`- ${r.file} [${r.state}]`)
167
+ }
168
+
169
+ if (violations.length > 0) {
170
+ console.log(`\n## Violations (${violations.length})`)
171
+ for (const v of violations) console.log(`- ${v}`)
172
+ }
173
+
174
+ if (warnings.length > 0) {
175
+ console.log(`\n## Warnings (${warnings.length})`)
176
+ for (const w of warnings) console.log(`- ${w}`)
177
+ }
178
+ }
179
+
180
+ module.exports = (function () {
181
+ const cmd = new Command('context')
182
+ cmd
183
+ .description('Print governance context for LLM consumption')
184
+ .option('--format <fmt>', 'Output format: md or json', 'md')
185
+ .action((opts) => {
186
+ getContext(opts.format)
187
+ })
188
+ return cmd
189
+ })()
@@ -19,6 +19,9 @@ function createProgram() {
19
19
  program.addCommand(require('./log'))
20
20
  program.addCommand(require('./plugins'))
21
21
  program.addCommand(require('./discover'))
22
+ program.addCommand(require('./metrics'))
23
+ program.addCommand(require('./sync'))
24
+ program.addCommand(require('./context'))
22
25
 
23
26
  // plugin dispatch — comandos desconhecidos tentam executar plugin
24
27
  program.hook('preSubcommand', () => {})
@@ -26,7 +26,7 @@ cmd.action(async () => {
26
26
 
27
27
  const { input, select, checkbox } = require('@inquirer/prompts')
28
28
 
29
- let projectName, projectType, frontend, pkgManager, backend, backendFramework, hooks, ci, aiTools
29
+ let projectName, projectType, frontend, pkgManager, backend, backendFramework, hooks, ci, aiTools, requireReqInCommit
30
30
 
31
31
  try {
32
32
  projectName = await input({
@@ -127,6 +127,15 @@ cmd.action(async () => {
127
127
  ],
128
128
  })
129
129
 
130
+ requireReqInCommit = false
131
+ if (hooks !== 'none') {
132
+ const { confirm: confirmPrompt } = require('@inquirer/prompts')
133
+ requireReqInCommit = await confirmPrompt({
134
+ message: t('init.prompt.require_req_in_commit'),
135
+ default: false,
136
+ })
137
+ }
138
+
130
139
  aiTools = await checkbox({
131
140
  message: t('init.prompt.aiTools'),
132
141
  choices: [
@@ -154,7 +163,7 @@ cmd.action(async () => {
154
163
  return
155
164
  }
156
165
 
157
- const cfg = { projectName, projectType, frontend, backend, backendFramework, pkgManager, hooks, ci }
166
+ const cfg = { projectName, projectType, frontend, backend, backendFramework, pkgManager, hooks, ci, requireReqInCommit }
158
167
  await generators.scaffold(cfg)
159
168
 
160
169
  for (const tool of (aiTools || [])) {
@@ -0,0 +1,235 @@
1
+ 'use strict'
2
+
3
+ const { Command } = require('commander')
4
+ const fs = require('fs')
5
+ const path = require('path')
6
+ const { t } = require('../i18n')
7
+
8
+ // lineRe: faz match do formato do .trackfw-log
9
+ // 2026-06-12 14:30 ROADMAP-2026-06-12-auth.md backlog → wip
10
+ const LINE_RE = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\s{2,}(\S+)\s{2,}(\S+)\s+→\s+(\S+)/
11
+
12
+ /**
13
+ * parseLog lê o arquivo .trackfw-log e retorna array de transições.
14
+ * Retorna [] se o arquivo não existe.
15
+ * @param {string} filePath
16
+ * @returns {{ timestamp: Date, basename: string, from: string, to: string }[]}
17
+ */
18
+ function parseLog(filePath) {
19
+ if (!fs.existsSync(filePath)) return []
20
+ const content = fs.readFileSync(filePath, 'utf8')
21
+ const lines = content.split('\n')
22
+ const transitions = []
23
+ for (const line of lines) {
24
+ if (!line.trim()) continue
25
+ const m = LINE_RE.exec(line)
26
+ if (!m) continue
27
+ const [, tsStr, basename, from, to] = m
28
+ const timestamp = new Date(tsStr.replace(' ', 'T') + ':00')
29
+ if (isNaN(timestamp.getTime())) continue
30
+ transitions.push({ timestamp, basename: basename.trim(), from: from.trim(), to: to.trim() })
31
+ }
32
+ return transitions
33
+ }
34
+
35
+ /**
36
+ * filter retorna transições com timestamp >= sinceMs.
37
+ * @param {Array} transitions
38
+ * @param {number} sinceMs - timestamp em milissegundos
39
+ * @returns {Array}
40
+ */
41
+ function filter(transitions, sinceMs) {
42
+ return transitions.filter(t => t.timestamp.getTime() >= sinceMs)
43
+ }
44
+
45
+ /**
46
+ * calculate computa cycle time médio, throughput e WIP age.
47
+ * @param {Array} transitions
48
+ * @returns {{ cycleTimeMeanMs: number, throughput: number, wipEntries: Array }}
49
+ */
50
+ function calculate(transitions) {
51
+ // Agrupar por basename
52
+ const byName = new Map()
53
+ for (const tr of transitions) {
54
+ if (!byName.has(tr.basename)) byName.set(tr.basename, [])
55
+ byName.get(tr.basename).push(tr)
56
+ }
57
+
58
+ // Cycle time: da entrada em backlog ou wip até done
59
+ const cycleTimes = []
60
+ for (const [, entries] of byName) {
61
+ let startTs = null
62
+ let doneTs = null
63
+ for (const e of entries) {
64
+ if ((e.to === 'backlog' || e.to === 'wip') && startTs === null) {
65
+ startTs = e.timestamp.getTime()
66
+ }
67
+ if (e.to === 'done') {
68
+ doneTs = e.timestamp.getTime()
69
+ }
70
+ }
71
+ if (startTs !== null && doneTs !== null) {
72
+ cycleTimes.push(doneTs - startTs)
73
+ }
74
+ }
75
+
76
+ let cycleTimeMeanMs = 0
77
+ if (cycleTimes.length > 0) {
78
+ cycleTimeMeanMs = cycleTimes.reduce((a, b) => a + b, 0) / cycleTimes.length
79
+ }
80
+
81
+ // Throughput: roadmaps done por semana
82
+ let doneCount = 0
83
+ let minTs = Infinity
84
+ let maxTs = -Infinity
85
+ for (const tr of transitions) {
86
+ const ms = tr.timestamp.getTime()
87
+ if (tr.to === 'done') doneCount++
88
+ if (ms < minTs) minTs = ms
89
+ if (ms > maxTs) maxTs = ms
90
+ }
91
+
92
+ let throughput = 0
93
+ if (doneCount > 0) {
94
+ const msPerWeek = 7 * 24 * 60 * 60 * 1000
95
+ let weeks = (maxTs - minTs) / msPerWeek
96
+ if (weeks < 1) weeks = 1
97
+ throughput = doneCount / weeks
98
+ }
99
+
100
+ // WIP age: basenames em wip sem done ou abandoned posterior
101
+ const wipEntries = []
102
+ const now = Date.now()
103
+ for (const [basename, entries] of byName) {
104
+ let wipTs = null
105
+ let concluded = false
106
+ for (const e of entries) {
107
+ if (e.to === 'wip') wipTs = e.timestamp.getTime()
108
+ if (e.to === 'done' || e.to === 'abandoned') concluded = true
109
+ }
110
+ if (wipTs !== null && !concluded) {
111
+ wipEntries.push({ basename, ageMs: now - wipTs })
112
+ }
113
+ }
114
+
115
+ return { cycleTimeMeanMs, throughput, wipEntries }
116
+ }
117
+
118
+ /**
119
+ * exportCSV grava transições e métricas em um arquivo CSV.
120
+ * @param {{ cycleTimeMeanMs: number, throughput: number, wipEntries: Array }} metrics
121
+ * @param {Array} transitions
122
+ * @param {string} filePath
123
+ */
124
+ function exportCSV(metrics, transitions, filePath) {
125
+ const rows = []
126
+ rows.push('basename,from,to,timestamp')
127
+ for (const tr of transitions) {
128
+ const ts = tr.timestamp.toISOString().slice(0, 16).replace('T', ' ')
129
+ rows.push(`${tr.basename},${tr.from},${tr.to},${ts}`)
130
+ }
131
+ rows.push('')
132
+ rows.push('metric,value')
133
+ const cycleHours = (metrics.cycleTimeMeanMs / (1000 * 3600)).toFixed(2)
134
+ rows.push(`cycle_time_mean_hours,${cycleHours}`)
135
+ rows.push(`throughput_per_week,${metrics.throughput.toFixed(2)}`)
136
+ rows.push(`wip_count,${metrics.wipEntries.length}`)
137
+ fs.writeFileSync(filePath, rows.join('\n') + '\n', 'utf8')
138
+ }
139
+
140
+ /**
141
+ * parseSinceDuration converte "7d", "30d", "90d" em milissegundos.
142
+ * @param {string} s
143
+ * @returns {number}
144
+ */
145
+ function parseSinceDuration(s) {
146
+ if (!s || s.length < 2) throw new Error(`formato inválido: "${s}"`)
147
+ const unit = s[s.length - 1]
148
+ if (unit !== 'd') throw new Error(`unidade não suportada "${unit}" (use 'd' para dias)`)
149
+ const n = parseInt(s.slice(0, -1), 10)
150
+ if (isNaN(n) || n <= 0) throw new Error(`número inválido em "${s}"`)
151
+ return n * 24 * 60 * 60 * 1000
152
+ }
153
+
154
+ /**
155
+ * formatDuration formata milissegundos em string legível.
156
+ * @param {number} ms
157
+ * @returns {string}
158
+ */
159
+ function formatDuration(ms) {
160
+ const totalHours = Math.floor(ms / (1000 * 3600))
161
+ const days = Math.floor(totalHours / 24)
162
+ const hours = totalHours % 24
163
+ if (days > 0) return `${days} days ${hours} hours`
164
+ return `${hours} hours`
165
+ }
166
+
167
+ /**
168
+ * printMetrics imprime as métricas em formato tabela ASCII.
169
+ * @param {{ cycleTimeMeanMs: number, throughput: number, wipEntries: Array }} metrics
170
+ */
171
+ function printMetrics(metrics) {
172
+ console.log('── trackfw metrics ──────────────────────')
173
+
174
+ if (metrics.cycleTimeMeanMs > 0) {
175
+ console.log(` Cycle Time Mean : ${formatDuration(metrics.cycleTimeMeanMs)}`)
176
+ } else {
177
+ console.log(' Cycle Time Mean : n/a (no completed cycles)')
178
+ }
179
+
180
+ if (metrics.throughput > 0) {
181
+ console.log(` Throughput : ${metrics.throughput.toFixed(2)} roadmaps/week`)
182
+ } else {
183
+ console.log(' Throughput : n/a (no completed roadmaps)')
184
+ }
185
+
186
+ if (metrics.wipEntries.length === 0) {
187
+ console.log(' WIP Age : no items in progress')
188
+ } else {
189
+ console.log(` WIP Age (${metrics.wipEntries.length} items) :`)
190
+ for (const w of metrics.wipEntries) {
191
+ console.log(` - ${w.basename}: ${formatDuration(w.ageMs)}`)
192
+ }
193
+ }
194
+
195
+ console.log('─────────────────────────────────────────')
196
+ }
197
+
198
+ const cmd = new Command('metrics')
199
+ cmd.description(t('metrics.description'))
200
+ cmd.option('--since <period>', t('metrics.since'))
201
+ cmd.option('--export <file>', t('metrics.export'))
202
+ cmd.action((opts) => {
203
+ const logPath = path.join('docs', 'roadmaps', '.trackfw-log')
204
+ let transitions = parseLog(logPath)
205
+
206
+ if (transitions.length === 0) {
207
+ console.log(t('metrics.no_data'))
208
+ return
209
+ }
210
+
211
+ if (opts.since) {
212
+ let sinceMs
213
+ try {
214
+ sinceMs = parseSinceDuration(opts.since)
215
+ } catch (err) {
216
+ console.error(`invalid --since format (use: 7d, 30d, 90d): ${err.message}`)
217
+ process.exit(1)
218
+ }
219
+ transitions = filter(transitions, Date.now() - sinceMs)
220
+ if (transitions.length === 0) {
221
+ console.log(t('metrics.no_data'))
222
+ return
223
+ }
224
+ }
225
+
226
+ const m = calculate(transitions)
227
+ printMetrics(m)
228
+
229
+ if (opts.export) {
230
+ exportCSV(m, transitions, opts.export)
231
+ console.log(`exported to ${opts.export}`)
232
+ }
233
+ })
234
+
235
+ module.exports = cmd
@@ -3,8 +3,59 @@ const { Command } = require('commander')
3
3
  const os = require('os')
4
4
  const path = require('path')
5
5
  const fs = require('fs')
6
+ const https = require('https')
6
7
  const { t } = require('../i18n')
7
8
 
9
+ const REGISTRY_URL = 'https://raw.githubusercontent.com/kgsaran/trackfw-plugins/main/registry.yaml'
10
+
11
+ function fetchRegistry() {
12
+ return new Promise((resolve, reject) => {
13
+ https.get(REGISTRY_URL, (res) => {
14
+ let data = ''
15
+ res.on('data', chunk => { data += chunk })
16
+ res.on('end', () => resolve(data))
17
+ }).on('error', reject)
18
+ })
19
+ }
20
+
21
+ function parseRegistryYAML(text) {
22
+ const entries = []
23
+ let current = null
24
+ const lines = text.split('\n')
25
+ for (const line of lines) {
26
+ const trimmed = line.trim()
27
+ if (!trimmed || trimmed === 'plugins:') continue
28
+ if (trimmed.startsWith('- name:')) {
29
+ if (current) entries.push(current)
30
+ current = { name: trimmed.slice('- name:'.length).trim(), repo: '', description: '', tags: [] }
31
+ continue
32
+ }
33
+ if (!current) continue
34
+ if (trimmed.startsWith('repo:')) {
35
+ current.repo = trimmed.slice('repo:'.length).trim()
36
+ } else if (trimmed.startsWith('description:')) {
37
+ let desc = trimmed.slice('description:'.length).trim()
38
+ desc = desc.replace(/^"|"$/g, '')
39
+ current.description = desc
40
+ } else if (trimmed.startsWith('tags:')) {
41
+ const raw = trimmed.slice('tags:'.length).trim().replace(/^\[|\]$/g, '')
42
+ current.tags = raw.split(',').map(s => s.trim()).filter(Boolean)
43
+ }
44
+ }
45
+ if (current) entries.push(current)
46
+ return entries
47
+ }
48
+
49
+ function matchesKeyword(entry, kw) {
50
+ const lkw = kw.toLowerCase()
51
+ if (entry.name.toLowerCase().includes(lkw)) return true
52
+ if (entry.description.toLowerCase().includes(lkw)) return true
53
+ for (const tag of entry.tags) {
54
+ if (tag.toLowerCase().includes(lkw)) return true
55
+ }
56
+ return false
57
+ }
58
+
8
59
  function pluginsDir() {
9
60
  return path.join(os.homedir(), '.trackfw', 'plugins')
10
61
  }
@@ -94,4 +145,26 @@ cmd.command('remove <name>')
94
145
  }
95
146
  })
96
147
 
148
+ cmd.command('search <keyword>')
149
+ .description('Search the plugin registry')
150
+ .action(async (keyword) => {
151
+ let entries
152
+ try {
153
+ const body = await fetchRegistry()
154
+ entries = parseRegistryYAML(body).filter(e => matchesKeyword(e, keyword))
155
+ } catch (err) {
156
+ console.log(`Registry unavailable: ${err.message}`)
157
+ return
158
+ }
159
+ if (entries.length === 0) {
160
+ console.log(`No plugins found for "${keyword}"`)
161
+ return
162
+ }
163
+ console.log(String('NAME').padEnd(30) + String('REPO').padEnd(30) + 'DESCRIPTION')
164
+ console.log('-'.repeat(90))
165
+ for (const e of entries) {
166
+ console.log(e.name.padEnd(30) + e.repo.padEnd(30) + e.description)
167
+ }
168
+ })
169
+
97
170
  module.exports = cmd
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
  const { Command } = require('commander')
3
- const { listRoadmaps, showRoadmap, moveRoadmap, newRoadmap } = require('../generators/roadmap')
3
+ const { listRoadmaps, showRoadmap, moveRoadmap, newRoadmap, newRoadmapFromReq } = require('../generators/roadmap')
4
4
  const { t } = require('../i18n')
5
5
 
6
6
  const cmd = new Command('roadmap')
@@ -10,7 +10,12 @@ cmd.command('new')
10
10
  .description(t('roadmap.new.description'))
11
11
  .option('-t, --title <title>', 'Roadmap title')
12
12
  .option('-r, --req <path>', 'Path to the linked REQ')
13
+ .option('--from-req <path>', 'Generate roadmap with ML stubs from REQ acceptance criteria')
13
14
  .action(async (opts) => {
15
+ if (opts.fromReq) {
16
+ newRoadmapFromReq(opts.fromReq)
17
+ return
18
+ }
14
19
  const title = opts.title || 'New Roadmap'
15
20
  const reqPath = opts.req || ''
16
21
  newRoadmap(title, reqPath)