trackfw 2.0.0 → 2.1.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.
@@ -0,0 +1,362 @@
1
+ 'use strict'
2
+
3
+ const { Command } = require('commander')
4
+ const https = require('https')
5
+ const http = require('http')
6
+ const fs = require('fs')
7
+ const path = require('path')
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Config helpers
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /**
14
+ * Lê um campo de trackfw.yaml (parse linha a linha, sem dependências externas).
15
+ * @param {string} field
16
+ * @returns {string}
17
+ */
18
+ function readConfigField(field) {
19
+ try {
20
+ const data = fs.readFileSync('trackfw.yaml', 'utf8')
21
+ const prefix = field + ':'
22
+ for (const line of data.split('\n')) {
23
+ const trimmed = line.trimStart()
24
+ if (trimmed.startsWith(prefix)) {
25
+ let value = trimmed.slice(prefix.length).trim()
26
+ if (value.length >= 2 &&
27
+ ((value[0] === '"' && value[value.length - 1] === '"') ||
28
+ (value[0] === "'" && value[value.length - 1] === "'"))) {
29
+ value = value.slice(1, -1)
30
+ }
31
+ return value
32
+ }
33
+ }
34
+ } catch (_) { /* sem arquivo */ }
35
+ return ''
36
+ }
37
+
38
+ function getConfig(field, envVar) {
39
+ return readConfigField(field) || process.env[envVar] || ''
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // HTTP helper
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /**
47
+ * Faz uma requisição HTTP/HTTPS simples com corpo JSON.
48
+ * @returns {Promise<{status: number, body: string}>}
49
+ */
50
+ function request(url, options, bodyStr) {
51
+ return new Promise((resolve, reject) => {
52
+ const parsed = new URL(url)
53
+ const lib = parsed.protocol === 'https:' ? https : http
54
+ const reqOptions = {
55
+ hostname: parsed.hostname,
56
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
57
+ path: parsed.pathname + parsed.search,
58
+ method: options.method || 'GET',
59
+ headers: options.headers || {}
60
+ }
61
+ const req = lib.request(reqOptions, (res) => {
62
+ let data = ''
63
+ res.on('data', (chunk) => { data += chunk })
64
+ res.on('end', () => resolve({ status: res.statusCode, body: data }))
65
+ })
66
+ req.on('error', reject)
67
+ if (bodyStr) req.write(bodyStr)
68
+ req.end()
69
+ })
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Linear client
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Cria issue no Linear via GraphQL.
78
+ * @param {string} apiKey
79
+ * @param {string} teamId
80
+ * @param {string} title
81
+ * @param {string} description
82
+ * @returns {Promise<string>} issue identifier (ex: "ENG-123")
83
+ */
84
+ async function linearCreateIssue(apiKey, teamId, title, description) {
85
+ const query = `mutation IssueCreate($title: String!, $description: String!, $teamId: String!) {
86
+ issueCreate(input: {title: $title, description: $description, teamId: $teamId}) {
87
+ success
88
+ issue { id identifier }
89
+ }
90
+ }`
91
+ const payload = JSON.stringify({
92
+ query,
93
+ variables: { title, description, teamId }
94
+ })
95
+
96
+ const res = await request('https://api.linear.app/graphql', {
97
+ method: 'POST',
98
+ headers: {
99
+ 'Content-Type': 'application/json',
100
+ 'Authorization': apiKey,
101
+ 'Content-Length': Buffer.byteLength(payload)
102
+ }
103
+ }, payload)
104
+
105
+ if (res.status !== 200) {
106
+ throw new Error(`Linear: unexpected status ${res.status}: ${res.body}`)
107
+ }
108
+
109
+ const data = JSON.parse(res.body)
110
+ if (data.errors && data.errors.length > 0) {
111
+ throw new Error(`Linear API error: ${data.errors[0].message}`)
112
+ }
113
+ if (!data.data.issueCreate.success) {
114
+ throw new Error('Linear: issueCreate returned success=false')
115
+ }
116
+ return data.data.issueCreate.issue.identifier
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Jira client
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /**
124
+ * Cria issue no Jira via REST API v3.
125
+ * @returns {Promise<string>} issue key (ex: "ENG-456")
126
+ */
127
+ async function jiraCreateIssue(baseUrl, email, token, project, title, description) {
128
+ const payload = JSON.stringify({
129
+ fields: {
130
+ project: { key: project },
131
+ summary: title,
132
+ description: {
133
+ type: 'doc',
134
+ version: 1,
135
+ content: [{
136
+ type: 'paragraph',
137
+ content: [{ type: 'text', text: description }]
138
+ }]
139
+ },
140
+ issuetype: { name: 'Story' }
141
+ }
142
+ })
143
+
144
+ const creds = Buffer.from(`${email}:${token}`).toString('base64')
145
+ const url = baseUrl.replace(/\/$/, '') + '/rest/api/3/issue'
146
+
147
+ const res = await request(url, {
148
+ method: 'POST',
149
+ headers: {
150
+ 'Content-Type': 'application/json',
151
+ 'Accept': 'application/json',
152
+ 'Authorization': `Basic ${creds}`,
153
+ 'Content-Length': Buffer.byteLength(payload)
154
+ }
155
+ }, payload)
156
+
157
+ if (res.status !== 201) {
158
+ throw new Error(`Jira: unexpected status ${res.status}: ${res.body}`)
159
+ }
160
+
161
+ const data = JSON.parse(res.body)
162
+ if (!data.key) {
163
+ throw new Error('Jira: response missing issue key')
164
+ }
165
+ return data.key
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // REQ file helpers
170
+ // ---------------------------------------------------------------------------
171
+
172
+ function isStatusOpen(text) {
173
+ for (const line of text.split('\n')) {
174
+ if (line.includes('| Status:')) {
175
+ return line.includes('Status: Open')
176
+ }
177
+ }
178
+ return false
179
+ }
180
+
181
+ function extractField(text, field) {
182
+ const prefix = '| ' + field + ':'
183
+ for (const line of text.split('\n')) {
184
+ const trimmed = line.trim()
185
+ if (trimmed.startsWith(prefix)) {
186
+ return trimmed.slice(prefix.length).trim()
187
+ }
188
+ }
189
+ return ''
190
+ }
191
+
192
+ function extractTitle(text) {
193
+ for (const line of text.split('\n')) {
194
+ if (line.startsWith('# REQ: ')) {
195
+ return line.slice('# REQ: '.length)
196
+ }
197
+ }
198
+ return ''
199
+ }
200
+
201
+ function extractMotivation(text) {
202
+ const lines = text.split('\n')
203
+ let inSection = false
204
+ const parts = []
205
+ for (const line of lines) {
206
+ if (line.startsWith('## Motivation') || line.startsWith('## Motivação')) {
207
+ inSection = true
208
+ continue
209
+ }
210
+ if (inSection) {
211
+ if (line.startsWith('## ')) break
212
+ parts.push(line)
213
+ }
214
+ }
215
+ return parts.join('\n').trim()
216
+ }
217
+
218
+ function injectField(text, field, value) {
219
+ const prefix = '| ' + field + ':'
220
+ const lines = text.split('\n')
221
+
222
+ // se campo já existe, substituir
223
+ for (let i = 0; i < lines.length; i++) {
224
+ if (lines[i].trim().startsWith(prefix)) {
225
+ lines[i] = `| ${field}: ${value}`
226
+ return lines.join('\n')
227
+ }
228
+ }
229
+
230
+ // inserir após a linha com | Status:
231
+ for (let i = 0; i < lines.length; i++) {
232
+ if (lines[i].includes('| Status:')) {
233
+ lines.splice(i + 1, 0, `| ${field}: ${value}`)
234
+ return lines.join('\n')
235
+ }
236
+ }
237
+
238
+ return text
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Core sync logic
243
+ // ---------------------------------------------------------------------------
244
+
245
+ /**
246
+ * @param {Function} createFn (title, desc) => Promise<issueId>
247
+ * @param {string} issueField
248
+ * @returns {Promise<Array<{reqPath, issueId, skipped, error}>>}
249
+ */
250
+ async function syncToProvider(createFn, issueField) {
251
+ const reqDir = 'docs/req'
252
+ let files = []
253
+ try {
254
+ files = fs.readdirSync(reqDir)
255
+ .filter(f => f.endsWith('.md'))
256
+ .map(f => path.join(reqDir, f))
257
+ } catch (_) {
258
+ return []
259
+ }
260
+
261
+ const results = []
262
+ for (const f of files) {
263
+ let text
264
+ try {
265
+ text = fs.readFileSync(f, 'utf8')
266
+ } catch (e) {
267
+ results.push({ reqPath: f, skipped: false, error: e })
268
+ continue
269
+ }
270
+
271
+ // pular se não é Open
272
+ if (!isStatusOpen(text)) {
273
+ results.push({ reqPath: f, skipped: true })
274
+ continue
275
+ }
276
+
277
+ // pular se já tem issue vinculado
278
+ if (extractField(text, issueField) !== '') {
279
+ results.push({ reqPath: f, skipped: true })
280
+ continue
281
+ }
282
+
283
+ const title = extractTitle(text)
284
+ const desc = extractMotivation(text)
285
+
286
+ try {
287
+ const issueId = await createFn(title, desc)
288
+ const updated = injectField(text, issueField, issueId)
289
+ fs.writeFileSync(f, updated, 'utf8')
290
+ results.push({ reqPath: f, issueId, skipped: false })
291
+ } catch (e) {
292
+ results.push({ reqPath: f, skipped: false, error: e })
293
+ }
294
+ }
295
+ return results
296
+ }
297
+
298
+ async function syncToLinear() {
299
+ const apiKey = getConfig('linear_api_key', 'LINEAR_API_KEY')
300
+ const teamId = getConfig('linear_team_id', 'LINEAR_TEAM_ID')
301
+ if (!apiKey) throw new Error('Linear API key not found. Set LINEAR_API_KEY env var or linear_api_key in trackfw.yaml')
302
+ if (!teamId) throw new Error('Linear Team ID not found. Set LINEAR_TEAM_ID env var or linear_team_id in trackfw.yaml')
303
+ return syncToProvider((title, desc) => linearCreateIssue(apiKey, teamId, title, desc), 'linear_issue')
304
+ }
305
+
306
+ async function syncToJira() {
307
+ const baseUrl = getConfig('jira_base_url', 'JIRA_BASE_URL')
308
+ const email = getConfig('jira_email', 'JIRA_EMAIL')
309
+ const token = getConfig('jira_token', 'JIRA_TOKEN')
310
+ const project = getConfig('jira_project', 'JIRA_PROJECT')
311
+ if (!baseUrl) throw new Error('Jira base URL not found. Set JIRA_BASE_URL env var or jira_base_url in trackfw.yaml')
312
+ if (!email) throw new Error('Jira email not found. Set JIRA_EMAIL env var or jira_email in trackfw.yaml')
313
+ if (!token) throw new Error('Jira API token not found. Set JIRA_TOKEN env var or jira_token in trackfw.yaml')
314
+ if (!project) throw new Error('Jira project key not found. Set JIRA_PROJECT env var or jira_project in trackfw.yaml')
315
+ return syncToProvider((title, desc) => jiraCreateIssue(baseUrl, email, token, project, title, desc), 'jira_issue')
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Commander command
320
+ // ---------------------------------------------------------------------------
321
+
322
+ const syncCmd = new Command('sync')
323
+ .description('Sync Open REQs to a project management tool')
324
+ .requiredOption('--to <target>', 'Target PM tool: linear or jira')
325
+ .action(async (options) => {
326
+ let results
327
+ try {
328
+ switch (options.to) {
329
+ case 'linear':
330
+ results = await syncToLinear()
331
+ break
332
+ case 'jira':
333
+ results = await syncToJira()
334
+ break
335
+ default:
336
+ console.error(`Unknown target "${options.to}" — use --to=linear or --to=jira`)
337
+ process.exit(1)
338
+ }
339
+ } catch (e) {
340
+ console.error(e.message)
341
+ process.exit(1)
342
+ }
343
+
344
+ if (!results || results.length === 0) {
345
+ console.log('No REQs found in docs/req/')
346
+ return
347
+ }
348
+
349
+ console.log(`${'REQ'.padEnd(55)} ISSUE`)
350
+ console.log(`${'-'.repeat(54)} ${'-'.repeat(10)}`)
351
+ for (const r of results) {
352
+ if (r.skipped) {
353
+ console.log(`${r.reqPath.padEnd(55)} (skipped)`)
354
+ } else if (r.error) {
355
+ console.log(`${r.reqPath.padEnd(55)} ERROR: ${r.error.message}`)
356
+ } else {
357
+ console.log(`${r.reqPath.padEnd(55)} ${r.issueId}`)
358
+ }
359
+ }
360
+ })
361
+
362
+ module.exports = syncCmd
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
  const { Command } = require('commander')
3
- const { validate } = require('../validator')
3
+ const { validate, isLenient, lenientUntilDate } = require('../validator')
4
4
  const { t } = require('../i18n')
5
5
 
6
6
  const cmd = new Command('validate')
@@ -8,6 +8,14 @@ cmd.description(t('validate.description'))
8
8
  cmd.action(async () => {
9
9
  const { violations, warnings } = await validate()
10
10
 
11
+ // Informar usuário sobre modo lenient
12
+ if (isLenient()) {
13
+ const until = lenientUntilDate()
14
+ if (until) {
15
+ console.log(`[LENIENT MODE] ${t('validate.lenient_mode', { date: until })}`)
16
+ }
17
+ }
18
+
11
19
  if (violations.length === 0 && warnings.length === 0) {
12
20
  console.log(t('validate.ok'))
13
21
  return
@@ -52,7 +52,13 @@ async function newADR(content) {
52
52
  const consequencesSection = content.consequences || '<!-- What are the positive and negative consequences of this decision? -->'
53
53
  const alternativesSection = content.alternatives || '<!-- What other options were evaluated and why were they rejected? -->'
54
54
 
55
- const body = `# ADR: ${content.title}
55
+ const body = `---
56
+ status: Proposed
57
+ date: ${date}
58
+ author: ""
59
+ ---
60
+
61
+ # ADR: ${content.title}
56
62
 
57
63
  > Date: ${date} | Status: Proposed
58
64
 
@@ -148,7 +154,13 @@ async function newADRDraft(slug) {
148
154
  const filepath = path.join(adrDir, filename)
149
155
  const title = slugToTitle(slug)
150
156
 
151
- const body = `# ADR: ${title}
157
+ const body = `---
158
+ status: Draft
159
+ date: ${date}
160
+ author: ""
161
+ ---
162
+
163
+ # ADR: ${title}
152
164
 
153
165
  > Date: ${date} | Status: Draft
154
166
 
@@ -28,6 +28,7 @@ async function scaffold(cfg) {
28
28
  generateValidateScript(cfg)
29
29
  generateCIWorkflow(cfg)
30
30
  generateGitHooks(cfg)
31
+ generateCommitMsgHook(cfg)
31
32
  generateClaudeMD(cfg)
32
33
  if (cfg.backend === 'java') generatePomXml(cfg)
33
34
  generateClaudeCommands()
@@ -39,7 +40,7 @@ async function scaffold(cfg) {
39
40
 
40
41
  function writeTrackfwConfig(cfg) {
41
42
  const today = new Date().toISOString().slice(0, 10)
42
- const content = `# trackfw configuration
43
+ let content = `# trackfw configuration
43
44
  # generated: ${today}
44
45
 
45
46
  frontend: ${cfg.frontend || ''}
@@ -48,6 +49,7 @@ backend_framework: ${cfg.backendFramework || ''}
48
49
  pkg_manager: ${cfg.pkgManager || ''}
49
50
  hooks: ${cfg.hooks || ''}
50
51
  ci: ${cfg.ci || ''}
52
+ require_req_in_commit: ${cfg.requireReqInCommit ? 'true' : 'false'}
51
53
 
52
54
  # governance paths (edit to match your project structure)
53
55
  adr_dirs:
@@ -56,6 +58,10 @@ req_dir: docs/req
56
58
  roadmap_dir: docs/roadmaps
57
59
  roadmap_namespacing: flat
58
60
  `
61
+ if (cfg.brownfieldMode) {
62
+ const until = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
63
+ content += `governance_mode: lenient\nlenient_until: ${until}\n`
64
+ }
59
65
  fs.writeFileSync('trackfw.yaml', content, 'utf8')
60
66
  console.log(' ✓ trackfw.yaml')
61
67
  }
@@ -202,6 +208,42 @@ function generateLefthookHook() {
202
208
  console.log(' ✓ lefthook.yml')
203
209
  }
204
210
 
211
+ function generateCommitMsgHook(cfg) {
212
+ if (!cfg.requireReqInCommit || cfg.hooks === 'none') return
213
+
214
+ const script = [
215
+ '#!/bin/sh',
216
+ '# trackfw: require REQ reference in feat/* and fix/* branches',
217
+ 'BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")',
218
+ 'case "$BRANCH" in',
219
+ ' feat/*|fix/*)',
220
+ ' if ! grep -qE "^(REQ|req): " "$1"; then',
221
+ ' echo "ERROR: Commits in feat/* and fix/* branches require a REQ reference."',
222
+ ' echo " Add to commit body: REQ: REQ-YYYY-MM-DD-your-req-slug"',
223
+ ' exit 1',
224
+ ' fi',
225
+ ' ;;',
226
+ 'esac',
227
+ '',
228
+ ].join('\n')
229
+
230
+ if (cfg.hooks === 'husky') {
231
+ fs.mkdirSync('.husky', { recursive: true })
232
+ fs.writeFileSync('.husky/commit-msg', script, { encoding: 'utf8', mode: 0o755 })
233
+ console.log(' ✓ .husky/commit-msg')
234
+ } else if (cfg.hooks === 'lefthook') {
235
+ const lefthookPath = 'lefthook.yml'
236
+ const existing = fs.existsSync(lefthookPath) ? fs.readFileSync(lefthookPath, 'utf8') : ''
237
+ if (!existing.includes('commit-msg:')) {
238
+ const addition = '\ncommit-msg:\n scripts:\n "trackfw-req-check.sh":\n runner: sh\n'
239
+ fs.writeFileSync(lefthookPath, existing + addition, 'utf8')
240
+ }
241
+ fs.mkdirSync('.lefthook/commit-msg', { recursive: true })
242
+ fs.writeFileSync('.lefthook/commit-msg/trackfw-req-check.sh', script, { encoding: 'utf8', mode: 0o755 })
243
+ console.log(' ✓ .lefthook/commit-msg/trackfw-req-check.sh')
244
+ }
245
+ }
246
+
205
247
  // ---------------------------------------------------------------------------
206
248
  // pom.xml (Java / Spring Boot)
207
249
  // ---------------------------------------------------------------------------
@@ -698,6 +740,7 @@ module.exports = {
698
740
  generateValidateScript,
699
741
  generateCIWorkflow,
700
742
  generateGitHooks,
743
+ generateCommitMsgHook,
701
744
  generateClaudeMD,
702
745
  generateClaudeCommands,
703
746
  installAgents,
@@ -101,7 +101,15 @@ async function newREQ(content) {
101
101
  blockedSection = lines.join('\n')
102
102
  }
103
103
 
104
- const body = `# REQ: ${content.title}
104
+ const body = `---
105
+ status: Open
106
+ date: ${date}
107
+ author: ""
108
+ adr: ""
109
+ roadmap: ""
110
+ ---
111
+
112
+ # REQ: ${content.title}
105
113
 
106
114
  ${statusLine}
107
115
 
@@ -224,7 +224,14 @@ function newRoadmap(title, reqPath) {
224
224
  const filename = `${backlogDir}/ROADMAP-${date}-${slug}.md`
225
225
  fs.mkdirSync(backlogDir, { recursive: true })
226
226
 
227
- const body = `# Roadmap: ${title}
227
+ const body = `---
228
+ status: backlog
229
+ date: ${date}
230
+ req: ""
231
+ squad: ""
232
+ ---
233
+
234
+ # Roadmap: ${title}
228
235
 
229
236
  > Created: ${date} | Status: backlog
230
237
 
@@ -249,6 +256,139 @@ REQ: ${reqPath || ''}
249
256
  console.log(`✓ created ${filename}`)
250
257
  }
251
258
 
259
+ /**
260
+ * newRoadmapFromReq — lê uma REQ e gera roadmap pré-preenchido com MLs extraídos
261
+ * dos critérios de aceite.
262
+ */
263
+ function newRoadmapFromReq(reqPath) {
264
+ let data
265
+ try {
266
+ data = fs.readFileSync(reqPath, 'utf8')
267
+ } catch (err) {
268
+ console.error(`reading REQ: ${err.message}`)
269
+ process.exitCode = 1
270
+ return
271
+ }
272
+
273
+ const { title: parsedTitle, criteria, linkedADR } = parseReqForRoadmap(data)
274
+ const basename = path.basename(reqPath)
275
+ const title = parsedTitle || basename.replace(/\.md$/, '').replace(/^REQ-/, '')
276
+
277
+ const cfg = config.load()
278
+ const now = new Date()
279
+ const yyyy = now.getFullYear()
280
+ const mm = String(now.getMonth() + 1).padStart(2, '0')
281
+ const dd = String(now.getDate()).padStart(2, '0')
282
+ const date = `${yyyy}-${mm}-${dd}`
283
+ const slug = toSlug(title)
284
+
285
+ let backlogDir
286
+ if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
287
+ backlogDir = agentStateDir(null, 'backlog')
288
+ if (!backlogDir) {
289
+ console.error('cannot resolve backlog dir in by_agent mode')
290
+ process.exitCode = 1
291
+ return
292
+ }
293
+ } else {
294
+ backlogDir = cfg.roadmapDir + '/backlog'
295
+ }
296
+
297
+ const filename = `${backlogDir}/ROADMAP-${date}-${slug}.md`
298
+ try { fs.mkdirSync(backlogDir, { recursive: true }) } catch (_) {}
299
+
300
+ // Gerar seção de MLs a partir dos critérios de aceite
301
+ const mlLines = ['## Wave 1 — Implementation (derived from REQ criteria)', '> Dependencies: none']
302
+ for (let i = 0; i < criteria.length; i++) {
303
+ const mlLabel = `ML-1${String.fromCharCode(65 + i)}`
304
+ const crit = criteria[i]
305
+ mlLines.push(`\n### ${mlLabel} — ${crit}`)
306
+ mlLines.push('**Status:** pending')
307
+ mlLines.push('**Files affected:**')
308
+ mlLines.push('**Actions:**')
309
+ mlLines.push('**Acceptance criteria:**')
310
+ mlLines.push(`- [ ] ${crit}`)
311
+ mlLines.push('- [ ] build passes')
312
+ mlLines.push('- [ ] tests green')
313
+ }
314
+ const mlSection = mlLines.join('\n')
315
+
316
+ const adrRef = linkedADR ? `\nADR: ${linkedADR}` : ''
317
+
318
+ const body = `---
319
+ status: backlog
320
+ date: ${date}
321
+ req: "${basename}"
322
+ squad: ""
323
+ ---
324
+
325
+ # Roadmap: ${title}
326
+
327
+ > Created: ${date} | Status: backlog
328
+
329
+ ## Context
330
+ <!-- Derived from REQ: ${basename} -->
331
+ REQ: ${reqPath}${adrRef}
332
+
333
+ ${mlSection}
334
+ `
335
+
336
+ fs.writeFileSync(filename, body, 'utf8')
337
+ console.log(`✓ created ${filename}`)
338
+ }
339
+
340
+ /**
341
+ * parseReqForRoadmap — extrai título, critérios de aceite e ADR linkada de conteúdo REQ.
342
+ */
343
+ function parseReqForRoadmap(content) {
344
+ const lines = content.split('\n')
345
+ let title = ''
346
+ let linkedADR = ''
347
+ const criteria = []
348
+ let inCriteria = false
349
+
350
+ for (const line of lines) {
351
+ if (line.startsWith('# REQ: ')) {
352
+ title = line.replace('# REQ: ', '').trim()
353
+ continue
354
+ }
355
+ if (line.startsWith('# REQ — ')) {
356
+ title = line.replace('# REQ — ', '').trim()
357
+ continue
358
+ }
359
+ if (line.startsWith('# REQ - ')) {
360
+ title = line.replace('# REQ - ', '').trim()
361
+ continue
362
+ }
363
+ if (line.startsWith('**ADR:**')) {
364
+ linkedADR = line.replace('**ADR:**', '').trim()
365
+ continue
366
+ }
367
+
368
+ const lower = line.trim().toLowerCase()
369
+ if (lower === '## critérios de aceite' || lower === '## acceptance criteria') {
370
+ inCriteria = true
371
+ continue
372
+ }
373
+ if (inCriteria && line.startsWith('## ')) {
374
+ inCriteria = false
375
+ continue
376
+ }
377
+ if (inCriteria) {
378
+ const trimmed = line.trim()
379
+ const checkboxPrefixes = ['- [ ]', '- [x]', '- [X]']
380
+ for (const prefix of checkboxPrefixes) {
381
+ if (trimmed.startsWith(prefix)) {
382
+ const item = trimmed.slice(prefix.length).trim().replace(/`/g, '')
383
+ if (item) criteria.push(item)
384
+ break
385
+ }
386
+ }
387
+ }
388
+ }
389
+ return { title, criteria, linkedADR }
390
+ }
391
+
252
392
  // --- helpers ---
253
393
 
254
394
  /**
@@ -312,6 +452,7 @@ module.exports = {
312
452
  moveRoadmap,
313
453
  appendTransitionLog,
314
454
  newRoadmap,
455
+ newRoadmapFromReq,
315
456
  stateDir,
316
457
  agentStateDir,
317
458
  }