trackfw 1.1.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
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ function defaults() {
7
+ return {
8
+ adrDirs: ['docs/adr'],
9
+ reqDir: 'docs/req',
10
+ roadmapDir: 'docs/roadmaps',
11
+ roadmapNamespacing: 'flat',
12
+ agents: [],
13
+ governanceMode: '',
14
+ lenientUntil: '',
15
+ wipLimit: 1,
16
+ wipBySquad: false,
17
+ requireReqInCommit: false,
18
+ };
19
+ }
20
+
21
+ let _instance = null;
22
+
23
+ function load(cwd) {
24
+ if (_instance) return _instance;
25
+ _instance = defaults();
26
+ const yamlPath = path.join(cwd || process.cwd(), 'trackfw.yaml');
27
+ if (!fs.existsSync(yamlPath)) return _instance;
28
+ const content = fs.readFileSync(yamlPath, 'utf8');
29
+ parse(content, _instance);
30
+ return _instance;
31
+ }
32
+
33
+ function reset() {
34
+ _instance = null;
35
+ }
36
+
37
+ function parse(content, cfg) {
38
+ const lines = content.split('\n');
39
+ let inAdrDirs = false;
40
+ let inAgents = false;
41
+ let adrDirs = [];
42
+ let agents = [];
43
+
44
+ for (const rawLine of lines) {
45
+ const line = rawLine.trim();
46
+
47
+ if (inAdrDirs) {
48
+ if (line.startsWith('- ')) {
49
+ adrDirs.push(line.slice(2).trim());
50
+ continue;
51
+ }
52
+ inAdrDirs = false;
53
+ if (adrDirs.length) cfg.adrDirs = adrDirs;
54
+ }
55
+ if (inAgents) {
56
+ if (line.startsWith('- ')) {
57
+ agents.push(line.slice(2).trim());
58
+ continue;
59
+ }
60
+ inAgents = false;
61
+ if (agents.length) cfg.agents = agents;
62
+ }
63
+
64
+ const colonIdx = line.indexOf(':');
65
+ if (colonIdx < 0) continue;
66
+ const key = line.slice(0, colonIdx).trim();
67
+ const val = line.slice(colonIdx + 1).trim();
68
+ if (!key) continue;
69
+
70
+ switch (key) {
71
+ case 'adr_dirs': inAdrDirs = true; adrDirs = []; break;
72
+ case 'req_dir': cfg.reqDir = val; break;
73
+ case 'roadmap_dir': cfg.roadmapDir = val; break;
74
+ case 'roadmap_namespacing': cfg.roadmapNamespacing = val; break;
75
+ case 'agents': inAgents = true; agents = []; break;
76
+ case 'governance_mode': cfg.governanceMode = val; break;
77
+ case 'lenient_until': cfg.lenientUntil = val; break;
78
+ case 'wip_limit': { const n = parseInt(val, 10); if (n > 0) cfg.wipLimit = n; break; }
79
+ case 'wip_by_squad': cfg.wipBySquad = val === 'true'; break;
80
+ case 'require_req_in_commit': cfg.requireReqInCommit = val === 'true'; break;
81
+ }
82
+ }
83
+
84
+ // flush pending lists at EOF
85
+ if (inAdrDirs && adrDirs.length) cfg.adrDirs = adrDirs;
86
+ if (inAgents && agents.length) cfg.agents = agents;
87
+ }
88
+
89
+ const NAMESPACING_FLAT = 'flat'
90
+ const NAMESPACING_BY_AGENT = 'by_agent'
91
+
92
+ module.exports = { load, reset, defaults, NAMESPACING_FLAT, NAMESPACING_BY_AGENT };
@@ -40,18 +40,25 @@ function today() {
40
40
  * @returns {Promise<void>}
41
41
  */
42
42
  async function newADR(content) {
43
- fs.mkdirSync('docs/adr', { recursive: true })
43
+ const adrDir = require('../config').load().adrDirs[0]
44
+ fs.mkdirSync(adrDir, { recursive: true })
44
45
 
45
46
  const slug = toSlug(content.title)
46
47
  const date = today()
47
- const filename = `docs/adr/ADR-${date}-${slug}.md`
48
+ const filename = `${adrDir}/ADR-${date}-${slug}.md`
48
49
 
49
50
  const contextSection = content.context || '<!-- What is the situation that motivates this decision? -->'
50
51
  const decisionSection = content.decision || '<!-- What was decided? -->'
51
52
  const consequencesSection = content.consequences || '<!-- What are the positive and negative consequences of this decision? -->'
52
53
  const alternativesSection = content.alternatives || '<!-- What other options were evaluated and why were they rejected? -->'
53
54
 
54
- const body = `# ADR: ${content.title}
55
+ const body = `---
56
+ status: Proposed
57
+ date: ${date}
58
+ author: ""
59
+ ---
60
+
61
+ # ADR: ${content.title}
55
62
 
56
63
  > Date: ${date} | Status: Proposed
57
64
 
@@ -129,10 +136,10 @@ function parseADRStatus(filepath) {
129
136
  * @returns {Promise<string>} basename do arquivo criado
130
137
  */
131
138
  async function newADRDraft(slug) {
132
- fs.mkdirSync('docs/adr', { recursive: true })
139
+ const adrDir = require('../config').load().adrDirs[0]
140
+ fs.mkdirSync(adrDir, { recursive: true })
133
141
 
134
142
  // Verificar idempotência: buscar arquivo existente com o mesmo slug
135
- const adrDir = 'docs/adr'
136
143
  const existing = fs.existsSync(adrDir)
137
144
  ? fs.readdirSync(adrDir).find((f) => f.match(new RegExp(`^ADR-.*-${slug}\\.md$`)))
138
145
  : null
@@ -144,10 +151,16 @@ async function newADRDraft(slug) {
144
151
 
145
152
  const date = today()
146
153
  const filename = `ADR-${date}-${slug}.md`
147
- const filepath = path.join('docs/adr', filename)
154
+ const filepath = path.join(adrDir, filename)
148
155
  const title = slugToTitle(slug)
149
156
 
150
- const body = `# ADR: ${title}
157
+ const body = `---
158
+ status: Draft
159
+ date: ${date}
160
+ author: ""
161
+ ---
162
+
163
+ # ADR: ${title}
151
164
 
152
165
  > Date: ${date} | Status: Draft
153
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,7 +49,19 @@ 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'}
53
+
54
+ # governance paths (edit to match your project structure)
55
+ adr_dirs:
56
+ - docs/adr
57
+ req_dir: docs/req
58
+ roadmap_dir: docs/roadmaps
59
+ roadmap_namespacing: flat
51
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
+ }
52
65
  fs.writeFileSync('trackfw.yaml', content, 'utf8')
53
66
  console.log(' ✓ trackfw.yaml')
54
67
  }
@@ -195,6 +208,42 @@ function generateLefthookHook() {
195
208
  console.log(' ✓ lefthook.yml')
196
209
  }
197
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
+
198
247
  // ---------------------------------------------------------------------------
199
248
  // pom.xml (Java / Spring Boot)
200
249
  // ---------------------------------------------------------------------------
@@ -691,6 +740,7 @@ module.exports = {
691
740
  generateValidateScript,
692
741
  generateCIWorkflow,
693
742
  generateGitHooks,
743
+ generateCommitMsgHook,
694
744
  generateClaudeMD,
695
745
  generateClaudeCommands,
696
746
  installAgents,
@@ -69,11 +69,12 @@ function toSlug(s) {
69
69
  * @returns {Promise<void>}
70
70
  */
71
71
  async function newREQ(content) {
72
- fs.mkdirSync('docs/req', { recursive: true })
72
+ const reqDir = require('../config').load().reqDir
73
+ fs.mkdirSync(reqDir, { recursive: true })
73
74
 
74
75
  const slug = toSlug(content.title)
75
76
  const date = new Date().toISOString().slice(0, 10)
76
- const filename = `docs/req/REQ-${date}-${slug}.md`
77
+ const filename = `${reqDir}/REQ-${date}-${slug}.md`
77
78
 
78
79
  const motivationSection = content.motivation || '<!-- Why is this requirement needed? What problem does it solve? -->'
79
80
  const criteriaSection = content.criteria || '- [ ]\n- [ ]'
@@ -100,7 +101,15 @@ async function newREQ(content) {
100
101
  blockedSection = lines.join('\n')
101
102
  }
102
103
 
103
- 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}
104
113
 
105
114
  ${statusLine}
106
115