trackfw 2.11.0 → 2.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "2.11.0",
3
+ "version": "2.12.0",
4
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",
@@ -211,11 +211,32 @@ function installHook(framework, rootDir) {
211
211
  if (fs.existsSync(pkgJson)) {
212
212
  installHusky(rootDir);
213
213
  } else {
214
+ // Node.js disponível mas sem package.json → husky via npx
215
+ try {
216
+ execSync('node --version', { stdio: 'pipe' });
217
+ installHuskyNPX(rootDir);
218
+ return;
219
+ } catch (_) {}
220
+ // fallback: lefthook
214
221
  installLefthook(rootDir);
215
222
  }
216
223
  }
217
224
  }
218
225
 
226
+ function installHuskyNPX(rootDir) {
227
+ console.log('ℹ node detected — using husky via npx (no package.json required)');
228
+ try {
229
+ execSync('npx husky init', { cwd: rootDir, stdio: 'inherit' });
230
+ } catch (_) {
231
+ console.log('⚠ npx husky init failed — install manually: npx husky init');
232
+ }
233
+ const huskyDir = path.join(rootDir, '.husky');
234
+ fs.mkdirSync(huskyDir, { recursive: true });
235
+ const hookPath = path.join(huskyDir, 'pre-commit');
236
+ fs.appendFileSync(hookPath, '\nscripts/trackfw-validate.sh\n', { mode: 0o755 });
237
+ console.log('✓ trackfw entry added to .husky/pre-commit');
238
+ }
239
+
219
240
  function installHusky(rootDir) {
220
241
  try {
221
242
  execSync('npm install --save-dev husky', { cwd: rootDir, stdio: 'inherit' });
@@ -396,6 +417,13 @@ cmd.action((opts) => {
396
417
  } catch (e) {
397
418
  console.log(`⚠ agent rules inject partial: ${e.message}`);
398
419
  }
420
+ try {
421
+ const { injectHooksDetected } = require('../generators/hooks');
422
+ injectHooksDetected(cwd);
423
+ console.log('✓ agent hooks injected');
424
+ } catch (e) {
425
+ console.warn(`⚠ agent hooks inject partial: ${e.message}`);
426
+ }
399
427
  try {
400
428
  const generators = require('../generators/init')
401
429
  generators.printArchitectNextSteps(cwd)
@@ -72,6 +72,15 @@ cmd.action(() => {
72
72
  console.log(` ⚠ agent rules: ${e.message}`);
73
73
  }
74
74
 
75
+ // 1b. Agent hooks (attention signal/cleanup)
76
+ try {
77
+ const { injectHooksDetected } = require('../generators/hooks');
78
+ injectHooksDetected(cwd);
79
+ console.log(' ✓ agent hooks atualizados');
80
+ } catch (e) {
81
+ console.warn(` ⚠ agent hooks: ${e.message}`);
82
+ }
83
+
75
84
  // 2. Validate script (trackfw-owned, overwrite)
76
85
  try {
77
86
  discover.writeValidateScript(cwd);
@@ -0,0 +1,302 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Helpers
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /** Lê JSON de arquivo (retorna {} se não existir ou inválido) */
11
+ function readJSON(filePath) {
12
+ try {
13
+ const raw = fs.readFileSync(filePath, 'utf8')
14
+ return JSON.parse(raw)
15
+ } catch (_) {
16
+ return {}
17
+ }
18
+ }
19
+
20
+ /** Escreve JSON com indent 2 */
21
+ function writeJSON(filePath, data) {
22
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
23
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8')
24
+ }
25
+
26
+ /** Verifica se array já tem entry com determinado campo=valor */
27
+ function hasEntry(arr, field, value) {
28
+ return Array.isArray(arr) && arr.some(e => e && e[field] === value)
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Scripts content
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const SIGNAL_SCRIPT = `#!/usr/bin/env bash
36
+ # trackfw attention signal — PreToolUse/BeforeTool hook
37
+ # Writes .trackfw-attention.json so trackfw serve board shows a banner.
38
+ set -euo pipefail
39
+
40
+ INPUT=$(cat)
41
+ [ -f "trackfw.yaml" ] || exit 0
42
+
43
+ if command -v jq &>/dev/null; then
44
+ TOOL=$(echo "$INPUT" | jq -r '.tool_name // ""')
45
+ MSG=$(echo "$INPUT" | jq -r '(.tool_input.question // .tool_input.command // ("Agent executing: " + (.tool_name // "unknown"))) | .[0:300]')
46
+ else
47
+ TOOL=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_name',''))" 2>/dev/null || echo "")
48
+ MSG=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); ti=d.get('tool_input',{}); print((ti.get('question') or ti.get('command') or 'Agent executing: '+d.get('tool_name','unknown'))[:300])" 2>/dev/null || echo "Agent needs attention")
49
+ fi
50
+
51
+ ROADMAP_DIR=$(grep '^roadmap_dir:' trackfw.yaml 2>/dev/null | awk '{print $2}' | tr -d "\"'" | head -1)
52
+ ROADMAP_DIR=\${ROADMAP_DIR:-docs/roadmaps}
53
+
54
+ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
55
+
56
+ mkdir -p "$ROADMAP_DIR"
57
+ printf '{"tool":"%s","message":"%s","level":"action_required","timestamp":"%s"}\\n' \\
58
+ "$(echo "$TOOL" | sed 's/"/\\\\"/g')" \\
59
+ "$(echo "$MSG" | sed 's/"/\\\\"/g; s/$//' | tr -d '\\n')" \\
60
+ "$TIMESTAMP" > "$ROADMAP_DIR/.trackfw-attention.json"
61
+
62
+ exit 0
63
+ `
64
+
65
+ const CLEANUP_SCRIPT = `#!/usr/bin/env bash
66
+ # trackfw attention cleanup — PostToolUse/AfterTool hook
67
+ set -euo pipefail
68
+
69
+ [ -f "trackfw.yaml" ] || exit 0
70
+
71
+ ROADMAP_DIR=$(grep '^roadmap_dir:' trackfw.yaml 2>/dev/null | awk '{print $2}' | tr -d "\"'" | head -1)
72
+ ROADMAP_DIR=\${ROADMAP_DIR:-docs/roadmaps}
73
+
74
+ rm -f "$ROADMAP_DIR/.trackfw-attention.json"
75
+ exit 0
76
+ `
77
+
78
+ const SIGNAL_CMD = 'scripts/trackfw-attention-signal.sh'
79
+ const CLEANUP_CMD = 'scripts/trackfw-attention-cleanup.sh'
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // generateAttentionScripts — writes the two shell scripts to scripts/
83
+ // ---------------------------------------------------------------------------
84
+
85
+ function generateAttentionScripts(cfg, cwd) {
86
+ const root = cwd || process.cwd()
87
+ const scriptsDir = path.join(root, 'scripts')
88
+ fs.mkdirSync(scriptsDir, { recursive: true })
89
+
90
+ const signalPath = path.join(scriptsDir, 'trackfw-attention-signal.sh')
91
+ fs.writeFileSync(signalPath, SIGNAL_SCRIPT, { encoding: 'utf8', mode: 0o755 })
92
+
93
+ const cleanupPath = path.join(scriptsDir, 'trackfw-attention-cleanup.sh')
94
+ fs.writeFileSync(cleanupPath, CLEANUP_SCRIPT, { encoding: 'utf8', mode: 0o755 })
95
+
96
+ console.log(' ✓ scripts/trackfw-attention-signal.sh')
97
+ console.log(' ✓ scripts/trackfw-attention-cleanup.sh')
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Claude Code — .claude/settings.json
102
+ // ---------------------------------------------------------------------------
103
+
104
+ function injectClaudeHooks(cwd) {
105
+ const filePath = path.join(cwd, '.claude', 'settings.json')
106
+ const data = readJSON(filePath)
107
+
108
+ if (!data.hooks) data.hooks = {}
109
+
110
+ // PreToolUse — AskUserQuestion
111
+ if (!data.hooks.PreToolUse) data.hooks.PreToolUse = []
112
+ let preEntry = data.hooks.PreToolUse.find(e => e && e.matcher === 'AskUserQuestion')
113
+ if (!preEntry) {
114
+ preEntry = { matcher: 'AskUserQuestion', hooks: [] }
115
+ data.hooks.PreToolUse.push(preEntry)
116
+ }
117
+ if (!Array.isArray(preEntry.hooks)) preEntry.hooks = []
118
+ if (!hasEntry(preEntry.hooks, 'command', SIGNAL_CMD)) {
119
+ preEntry.hooks.push({ type: 'command', command: SIGNAL_CMD })
120
+ }
121
+
122
+ // PostToolUse — cleanup
123
+ if (!data.hooks.PostToolUse) data.hooks.PostToolUse = []
124
+ let postEntry = data.hooks.PostToolUse.find(e => e && e.matcher === 'AskUserQuestion')
125
+ if (!postEntry) {
126
+ postEntry = { matcher: 'AskUserQuestion', hooks: [] }
127
+ data.hooks.PostToolUse.push(postEntry)
128
+ }
129
+ if (!Array.isArray(postEntry.hooks)) postEntry.hooks = []
130
+ if (!hasEntry(postEntry.hooks, 'command', CLEANUP_CMD)) {
131
+ postEntry.hooks.push({ type: 'command', command: CLEANUP_CMD })
132
+ }
133
+
134
+ writeJSON(filePath, data)
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Codex — .codex/hooks.json
139
+ // ---------------------------------------------------------------------------
140
+
141
+ function injectCodexHooks(cwd) {
142
+ const filePath = path.join(cwd, '.codex', 'hooks.json')
143
+ const data = readJSON(filePath)
144
+
145
+ if (!data.PreToolUse) data.PreToolUse = []
146
+ if (!data.PostToolUse) data.PostToolUse = []
147
+
148
+ if (!hasEntry(data.PreToolUse, 'command', SIGNAL_CMD)) {
149
+ data.PreToolUse.push({ matcher: '.*', command: SIGNAL_CMD })
150
+ }
151
+ if (!hasEntry(data.PostToolUse, 'command', CLEANUP_CMD)) {
152
+ data.PostToolUse.push({ matcher: '.*', command: CLEANUP_CMD })
153
+ }
154
+
155
+ writeJSON(filePath, data)
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Gemini — .gemini/settings.json
160
+ // ---------------------------------------------------------------------------
161
+
162
+ function injectGeminiHooks(cwd) {
163
+ const filePath = path.join(cwd, '.gemini', 'settings.json')
164
+ const data = readJSON(filePath)
165
+
166
+ if (!data.hooks) data.hooks = {}
167
+
168
+ if (!Array.isArray(data.hooks.BeforeTool)) data.hooks.BeforeTool = []
169
+ if (!hasEntry(data.hooks.BeforeTool, 'command', SIGNAL_CMD)) {
170
+ data.hooks.BeforeTool.push({ command: SIGNAL_CMD })
171
+ }
172
+
173
+ if (!Array.isArray(data.hooks.AfterTool)) data.hooks.AfterTool = []
174
+ if (!hasEntry(data.hooks.AfterTool, 'command', CLEANUP_CMD)) {
175
+ data.hooks.AfterTool.push({ command: CLEANUP_CMD })
176
+ }
177
+
178
+ writeJSON(filePath, data)
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Kiro — .kiro/hooks/trackfw-attention.json (dedicated file, safe overwrite)
183
+ // ---------------------------------------------------------------------------
184
+
185
+ function injectKiroHooks(cwd) {
186
+ const filePath = path.join(cwd, '.kiro', 'hooks', 'trackfw-attention.json')
187
+ const data = {
188
+ hooks: [
189
+ {
190
+ name: 'trackfw-attention-signal',
191
+ event: 'PreToolUse',
192
+ matcher: { tool_name: '.*' },
193
+ action: { type: 'command', command: SIGNAL_CMD },
194
+ },
195
+ {
196
+ name: 'trackfw-attention-cleanup',
197
+ event: 'PostToolUse',
198
+ matcher: { tool_name: '.*' },
199
+ action: { type: 'command', command: CLEANUP_CMD },
200
+ },
201
+ ],
202
+ }
203
+ writeJSON(filePath, data)
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Copilot — .github/hooks/trackfw-attention.json (dedicated file, safe overwrite)
208
+ // ---------------------------------------------------------------------------
209
+
210
+ function injectCopilotHooks(cwd) {
211
+ const filePath = path.join(cwd, '.github', 'hooks', 'trackfw-attention.json')
212
+ const data = {
213
+ hooks: [
214
+ { event: 'preToolUse', run: SIGNAL_CMD },
215
+ { event: 'postToolUse', run: CLEANUP_CMD },
216
+ ],
217
+ }
218
+ writeJSON(filePath, data)
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Cursor — .cursor/hooks.json
223
+ // ---------------------------------------------------------------------------
224
+
225
+ function injectCursorHooks(cwd) {
226
+ const filePath = path.join(cwd, '.cursor', 'hooks.json')
227
+ const data = readJSON(filePath)
228
+
229
+ if (!Array.isArray(data.preToolUse)) data.preToolUse = []
230
+ if (!hasEntry(data.preToolUse, 'command', SIGNAL_CMD)) {
231
+ data.preToolUse.push({ command: SIGNAL_CMD })
232
+ }
233
+
234
+ if (!Array.isArray(data.postToolUse)) data.postToolUse = []
235
+ if (!hasEntry(data.postToolUse, 'command', CLEANUP_CMD)) {
236
+ data.postToolUse.push({ command: CLEANUP_CMD })
237
+ }
238
+
239
+ writeJSON(filePath, data)
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // injectHooksDetected — public entry point
244
+ // ---------------------------------------------------------------------------
245
+
246
+ function injectHooksDetected(cwd) {
247
+ const root = cwd || process.cwd()
248
+
249
+ const detections = {
250
+ claude: {
251
+ check: () =>
252
+ fs.existsSync(path.join(root, '.claude')) ||
253
+ fs.existsSync(path.join(root, 'CLAUDE.md')),
254
+ fn: injectClaudeHooks,
255
+ },
256
+ codex: {
257
+ check: () =>
258
+ fs.existsSync(path.join(root, 'AGENTS.md')) ||
259
+ fs.existsSync(path.join(root, '.codex')),
260
+ fn: injectCodexHooks,
261
+ },
262
+ gemini: {
263
+ check: () =>
264
+ fs.existsSync(path.join(root, 'GEMINI.md')) ||
265
+ fs.existsSync(path.join(root, '.gemini')),
266
+ fn: injectGeminiHooks,
267
+ },
268
+ kiro: {
269
+ check: () => fs.existsSync(path.join(root, '.kiro')),
270
+ fn: injectKiroHooks,
271
+ },
272
+ copilot: {
273
+ check: () =>
274
+ fs.existsSync(path.join(root, '.github', 'copilot-instructions.md')),
275
+ fn: injectCopilotHooks,
276
+ },
277
+ cursor: {
278
+ check: () => fs.existsSync(path.join(root, '.cursor')),
279
+ fn: injectCursorHooks,
280
+ },
281
+ }
282
+
283
+ for (const [name, { check, fn }] of Object.entries(detections)) {
284
+ if (!check()) continue
285
+ try {
286
+ fn(root)
287
+ } catch (e) {
288
+ console.warn(` ⚠ hooks (${name}): ${e.message}`)
289
+ }
290
+ }
291
+ }
292
+
293
+ module.exports = {
294
+ generateAttentionScripts,
295
+ injectClaudeHooks,
296
+ injectCodexHooks,
297
+ injectGeminiHooks,
298
+ injectKiroHooks,
299
+ injectCopilotHooks,
300
+ injectCursorHooks,
301
+ injectHooksDetected,
302
+ }
@@ -27,6 +27,7 @@ async function scaffold(cfg) {
27
27
 
28
28
  writeTrackfwConfig(cfg)
29
29
  generateValidateScript(cfg)
30
+ generateAttentionScripts(cfg)
30
31
  generateCIWorkflow(cfg)
31
32
  generateGitHooks(cfg)
32
33
  generateCommitMsgHook(cfg)
@@ -116,6 +117,15 @@ trackfw validate
116
117
  return base
117
118
  }
118
119
 
120
+ // ---------------------------------------------------------------------------
121
+ // scripts/trackfw-attention-signal.sh + trackfw-attention-cleanup.sh
122
+ // ---------------------------------------------------------------------------
123
+
124
+ function generateAttentionScripts(cfg, cwd) {
125
+ const { generateAttentionScripts: gen } = require('./hooks')
126
+ gen(cfg, cwd || process.cwd())
127
+ }
128
+
119
129
  // ---------------------------------------------------------------------------
120
130
  // CI workflows
121
131
  // ---------------------------------------------------------------------------
@@ -1069,6 +1079,7 @@ module.exports = {
1069
1079
  scaffold,
1070
1080
  writeTrackfwConfig,
1071
1081
  generateValidateScript,
1082
+ generateAttentionScripts,
1072
1083
  generateCIWorkflow,
1073
1084
  generateGitHooks,
1074
1085
  generateCommitMsgHook,
@@ -735,6 +735,33 @@ function validateFilenameUniqueness() {
735
735
  return violations
736
736
  }
737
737
 
738
+ // validateBranchHasWIPRoadmap — verifica que branch feat/fix/refactor tem ao menos um roadmap em wip/
739
+ function validateBranchHasWIPRoadmap() {
740
+ const { execSync } = require('child_process')
741
+ let branch
742
+ try {
743
+ branch = execSync('git symbolic-ref --short HEAD', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
744
+ } catch {
745
+ return [] // não é git repo ou HEAD detached
746
+ }
747
+ if (!branch.startsWith('feat/') && !branch.startsWith('fix/') && !branch.startsWith('refactor/')) {
748
+ return []
749
+ }
750
+
751
+ const cfg = config.load()
752
+ const wipDirs = resolveWIPDirs(cfg)
753
+ let total = 0
754
+ for (const wipDir of wipDirs) {
755
+ const files = listDir(wipDir).filter(f => f.endsWith('.md'))
756
+ total += files.length
757
+ }
758
+
759
+ if (total === 0) {
760
+ return [`branch "${branch}" is a feat/fix/refactor branch but no roadmap is in wip/ — create governance artifacts first:\n trackfw req new "title"\n trackfw roadmap new "title"\n trackfw roadmap move <name> wip`]
761
+ }
762
+ return []
763
+ }
764
+
738
765
  // _itemMeta: mapa de message → { rule, file } para enriquecer saída JSON.
739
766
  // Populado em applyRule e nos pushs diretos do validateUnfiltered.
740
767
  // Permanece em memória apenas durante a execução de uma chamada validate*.
@@ -816,6 +843,7 @@ async function validateUnfiltered() {
816
843
  applyRule('ref_targets_exist', validateRefTargetsExist(), violations, warnings)
817
844
  applyRule('folder_status', validateFolderStatusCoherence(), violations, warnings)
818
845
  applyRule('filename_uniqueness', validateFilenameUniqueness(), violations, warnings)
846
+ applyRule('branch_has_wip_roadmap', validateBranchHasWIPRoadmap(), violations, warnings)
819
847
  applyRule('blocked_by_draft_adr', validateREQsNotBlockedByDraftADRs(), violations, warnings)
820
848
 
821
849
  // Regras configuráveis via applyRule (popula _itemMeta automaticamente)
@@ -979,6 +1007,7 @@ module.exports = {
979
1007
  validateRefTargetsExist,
980
1008
  validateFolderStatusCoherence,
981
1009
  validateFilenameUniqueness,
1010
+ validateBranchHasWIPRoadmap,
982
1011
  // novas funções ML-2B
983
1012
  contentHasMarker,
984
1013
  ruleSeverity,