trackfw 2.14.0 → 2.15.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,6 +1,6 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "2.14.0",
3
+ "version": "2.15.1",
4
4
  "description": "Governance CLI for AI-native software delivery and AI coding agents. Enforces traceability: ADR → REQ → ROADMAP → kanban. Native support for Codex, Claude Code, Gemini CLI, Cursor, Copilot, Windsurf, and Amazon Q.",
5
5
  "keywords": [
6
6
  "cli",
@@ -89,8 +89,9 @@ cmd.action(() => {
89
89
 
90
90
  // 1b. Agent hooks (attention signal/cleanup)
91
91
  try {
92
- const { injectHooksDetected } = require('../generators/hooks');
92
+ const { injectHooksDetected, generateAttentionScripts } = require('../generators/hooks');
93
93
  injectHooksDetected(cwd);
94
+ generateAttentionScripts(cfg, cwd);
94
95
  console.log(' ✓ agent hooks atualizados');
95
96
  } catch (e) {
96
97
  console.warn(` ⚠ agent hooks: ${e.message}`);
@@ -1,13 +1,25 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
+ const os = require('os');
4
5
  const path = require('path');
5
6
 
7
+ function expandPath(filePath) {
8
+ if (!filePath || typeof filePath !== 'string') return filePath;
9
+ if (filePath === '~') {
10
+ return os.homedir();
11
+ }
12
+ if (filePath.startsWith('~/') || filePath.startsWith('~\\')) {
13
+ return path.join(os.homedir(), filePath.slice(2));
14
+ }
15
+ return filePath;
16
+ }
17
+
6
18
  function defaults() {
7
19
  return {
8
- adrDirs: ['docs/adr'],
9
- reqDir: 'docs/req',
10
- roadmapDir: 'docs/roadmaps',
20
+ adrDirs: ['docs/adr'].map(expandPath),
21
+ reqDir: expandPath('docs/req'),
22
+ roadmapDir: expandPath('docs/roadmaps'),
11
23
  roadmapNamespacing: 'flat',
12
24
  agents: [],
13
25
  governanceMode: '',
@@ -15,6 +27,7 @@ function defaults() {
15
27
  wipLimit: 1,
16
28
  wipBySquad: false,
17
29
  requireReqInCommit: false,
30
+ strictCiPaths: false,
18
31
  traceIdField: '',
19
32
  // NOVOS campos:
20
33
  linkFields: {
@@ -78,7 +91,7 @@ function parse(content, cfg) {
78
91
  let rules = {};
79
92
 
80
93
  function flushBlocks() {
81
- if (inAdrDirs && adrDirs.length) cfg.adrDirs = adrDirs;
94
+ if (inAdrDirs && adrDirs.length) cfg.adrDirs = adrDirs.map(expandPath);
82
95
  if (inAgents && agents.length) cfg.agents = agents;
83
96
  if (inLinkFields) {
84
97
  if (inLinkFieldsReq && linkFieldsReq.length) cfg.linkFields.req = linkFieldsReq;
@@ -108,7 +121,11 @@ function parse(content, cfg) {
108
121
 
109
122
  if (hasIndent) {
110
123
  if (inAdrDirs) {
111
- if (line.startsWith('- ')) adrDirs.push(line.slice(2).trim());
124
+ if (line.startsWith('- ')) {
125
+ let val = line.slice(2).trim();
126
+ val = val.replace(/^["']|["']$/g, '');
127
+ adrDirs.push(expandPath(val));
128
+ }
112
129
  continue;
113
130
  }
114
131
  if (inAgents) {
@@ -166,8 +183,8 @@ function parse(content, cfg) {
166
183
 
167
184
  switch (key) {
168
185
  case 'adr_dirs': inAdrDirs = true; adrDirs = []; break;
169
- case 'req_dir': cfg.reqDir = val.replace(/^["']|["']$/g, ''); break;
170
- case 'roadmap_dir': cfg.roadmapDir = val.replace(/^["']|["']$/g, ''); break;
186
+ case 'req_dir': cfg.reqDir = expandPath(val.replace(/^["']|["']$/g, '')); break;
187
+ case 'roadmap_dir': cfg.roadmapDir = expandPath(val.replace(/^["']|["']$/g, '')); break;
171
188
  case 'roadmap_namespacing': cfg.roadmapNamespacing = val; break;
172
189
  case 'agents': inAgents = true; agents = []; break;
173
190
  case 'governance_mode': cfg.governanceMode = val; break;
@@ -175,6 +192,7 @@ function parse(content, cfg) {
175
192
  case 'wip_limit': { const n = parseInt(val, 10); if (n > 0) cfg.wipLimit = n; break; }
176
193
  case 'wip_by_squad': cfg.wipBySquad = val === 'true'; break;
177
194
  case 'require_req_in_commit': cfg.requireReqInCommit = val === 'true'; break;
195
+ case 'strict_ci_paths': cfg.strictCiPaths = val === 'true'; break;
178
196
  case 'trace_id_field': cfg.traceIdField = val.replace(/^["']|["']$/g, ''); break;
179
197
  case 'link_fields': inLinkFields = true; break;
180
198
  case 'acceptance_markers': inAcceptanceMarkers = true; acceptanceMarkers = []; break;
@@ -189,4 +207,5 @@ function parse(content, cfg) {
189
207
  const NAMESPACING_FLAT = 'flat';
190
208
  const NAMESPACING_BY_AGENT = 'by_agent';
191
209
 
192
- module.exports = { load, reset, defaults, NAMESPACING_FLAT, NAMESPACING_BY_AGENT };
210
+ module.exports = { load, reset, defaults, expandPath, NAMESPACING_FLAT, NAMESPACING_BY_AGENT };
211
+
@@ -28,37 +28,67 @@ function hasEntry(arr, field, value) {
28
28
  return Array.isArray(arr) && arr.some(e => e && e[field] === value)
29
29
  }
30
30
 
31
+ /** Merge helper para arrays de hooks tipo Claude / Codex / Gemini */
32
+ function mergeClaudeHookArray(existing, matcher, command) {
33
+ const arr = Array.isArray(existing) ? existing : []
34
+
35
+ for (const item of arr) {
36
+ if (!item || item.matcher !== matcher) continue
37
+ const innerHooks = Array.isArray(item.hooks) ? item.hooks : []
38
+ if (innerHooks.some(h => h && h.command === command)) {
39
+ return arr
40
+ }
41
+ }
42
+
43
+ let entry = arr.find(e => e && e.matcher === matcher)
44
+ if (!entry) {
45
+ entry = { matcher, hooks: [] }
46
+ arr.push(entry)
47
+ }
48
+ if (!Array.isArray(entry.hooks)) entry.hooks = []
49
+ if (!entry.hooks.some(h => h && h.command === command)) {
50
+ entry.hooks.push({ type: 'command', command })
51
+ }
52
+
53
+ return arr
54
+ }
55
+
31
56
  // ---------------------------------------------------------------------------
32
57
  // Scripts content
33
58
  // ---------------------------------------------------------------------------
34
59
 
35
60
  const SIGNAL_SCRIPT = `#!/usr/bin/env bash
36
- # trackfw attention signal — permission/notification hook
37
- # Writes .trackfw-attention.json so trackfw serve board shows a banner.
61
+ # trackfw attention signal — PreToolUse/BeforeTool hook
38
62
  set -euo pipefail
39
63
 
40
64
  INPUT=$(cat)
41
- HOOK_CWD=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('cwd',''))" 2>/dev/null || true)
42
- [ -n "$HOOK_CWD" ] && cd "$HOOK_CWD"
65
+
66
+ # Script is intentionally a no-op when executed outside the project root
43
67
  [ -f "trackfw.yaml" ] || exit 0
44
68
 
45
69
  if command -v jq &>/dev/null; then
46
- TOOL=$(echo "$INPUT" | jq -r '.tool_name // .notification_type // ""')
47
- MSG=$(echo "$INPUT" | jq -r '(.message // .tool_input.description // .tool_input.question // .tool_input.command // ("Approval required for: " + (.tool_name // .notification_type // "unknown"))) | .[0:300]')
70
+ TOOL=$(echo "$INPUT" | jq -r '.tool_name // ""')
71
+ MSG=$(echo "$INPUT" | jq -r '(.tool_input.question // .tool_input.command // "Agent is executing: \\(.tool_name // "unknown")") | .[0:300]')
48
72
  else
49
- TOOL=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_name') or d.get('notification_type') or '')" 2>/dev/null || echo "")
50
- MSG=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); ti=d.get('tool_input',{}); print((d.get('message') or ti.get('description') or ti.get('question') or ti.get('command') or 'Approval required for: '+(d.get('tool_name') or d.get('notification_type') or 'unknown'))[:300])" 2>/dev/null || echo "Agent needs attention")
73
+ TOOL=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_name',''))" 2>/dev/null || echo "")
74
+ 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 is executing: '+d.get('tool_name','unknown'))[:300])" 2>/dev/null || echo "Agent needs attention")
51
75
  fi
52
76
 
53
- ROADMAP_DIR=$(grep '^roadmap_dir:' trackfw.yaml 2>/dev/null | awk '{print $2}' | tr -d "\"'" | head -1)
77
+ ROADMAP_DIR=$(grep '^roadmap_dir:' trackfw.yaml 2>/dev/null | head -1 | sed 's/^roadmap_dir:[[:space:]]*//; s/[[:space:]]*#.*$//' | tr -d '"' | tr -d "'" || true)
54
78
  ROADMAP_DIR=\${ROADMAP_DIR:-docs/roadmaps}
55
79
 
80
+ case "$ROADMAP_DIR" in
81
+ /*|../*|*/../*|*/..|..) ROADMAP_DIR="docs/roadmaps" ;;
82
+ esac
83
+
56
84
  TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
85
+ TOOL_ESC=$(echo "$TOOL" | tr -d '\\000-\\037' | sed -e 's/\\\\/\\\\\\\\/g' -e 's/"/\\\\"/g')
86
+ MSG_ESC=$(echo "$MSG" | tr -d '\\000-\\037' | sed -e 's/\\\\/\\\\\\\\/g' -e 's/"/\\\\"/g')
57
87
 
58
88
  mkdir -p "$ROADMAP_DIR"
59
89
  printf '{"tool":"%s","message":"%s","level":"action_required","timestamp":"%s"}\\n' \\
60
- "$(echo "$TOOL" | sed 's/"/\\\\"/g')" \\
61
- "$(echo "$MSG" | sed 's/"/\\\\"/g; s/$//' | tr -d '\\n')" \\
90
+ "$TOOL_ESC" \\
91
+ "$MSG_ESC" \\
62
92
  "$TIMESTAMP" > "$ROADMAP_DIR/.trackfw-attention.json"
63
93
 
64
94
  exit 0
@@ -68,14 +98,16 @@ const CLEANUP_SCRIPT = `#!/usr/bin/env bash
68
98
  # trackfw attention cleanup — PostToolUse/AfterTool hook
69
99
  set -euo pipefail
70
100
 
71
- INPUT=$(cat)
72
- HOOK_CWD=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('cwd',''))" 2>/dev/null || true)
73
- [ -n "$HOOK_CWD" ] && cd "$HOOK_CWD"
101
+ # Script is intentionally a no-op when executed outside the project root
74
102
  [ -f "trackfw.yaml" ] || exit 0
75
103
 
76
- ROADMAP_DIR=$(grep '^roadmap_dir:' trackfw.yaml 2>/dev/null | awk '{print $2}' | tr -d "\"'" | head -1)
104
+ ROADMAP_DIR=$(grep '^roadmap_dir:' trackfw.yaml 2>/dev/null | head -1 | sed 's/^roadmap_dir:[[:space:]]*//; s/[[:space:]]*#.*$//' | tr -d '"' | tr -d "'" || true)
77
105
  ROADMAP_DIR=\${ROADMAP_DIR:-docs/roadmaps}
78
106
 
107
+ case "$ROADMAP_DIR" in
108
+ /*|../*|*/../*|*/..|..) ROADMAP_DIR="docs/roadmaps" ;;
109
+ esac
110
+
79
111
  rm -f "$ROADMAP_DIR/.trackfw-attention.json"
80
112
  exit 0
81
113
  `
@@ -111,30 +143,8 @@ function injectClaudeHooks(cwd) {
111
143
  const data = readJSON(filePath)
112
144
 
113
145
  if (!data.hooks) data.hooks = {}
114
-
115
- // PreToolUse — AskUserQuestion
116
- if (!data.hooks.PreToolUse) data.hooks.PreToolUse = []
117
- let preEntry = data.hooks.PreToolUse.find(e => e && e.matcher === 'AskUserQuestion')
118
- if (!preEntry) {
119
- preEntry = { matcher: 'AskUserQuestion', hooks: [] }
120
- data.hooks.PreToolUse.push(preEntry)
121
- }
122
- if (!Array.isArray(preEntry.hooks)) preEntry.hooks = []
123
- if (!hasEntry(preEntry.hooks, 'command', SIGNAL_CMD)) {
124
- preEntry.hooks.push({ type: 'command', command: SIGNAL_CMD })
125
- }
126
-
127
- // PostToolUse — cleanup
128
- if (!data.hooks.PostToolUse) data.hooks.PostToolUse = []
129
- let postEntry = data.hooks.PostToolUse.find(e => e && e.matcher === 'AskUserQuestion')
130
- if (!postEntry) {
131
- postEntry = { matcher: 'AskUserQuestion', hooks: [] }
132
- data.hooks.PostToolUse.push(postEntry)
133
- }
134
- if (!Array.isArray(postEntry.hooks)) postEntry.hooks = []
135
- if (!hasEntry(postEntry.hooks, 'command', CLEANUP_CMD)) {
136
- postEntry.hooks.push({ type: 'command', command: CLEANUP_CMD })
137
- }
146
+ data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'AskUserQuestion', SIGNAL_CMD)
147
+ data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'AskUserQuestion', CLEANUP_CMD)
138
148
 
139
149
  writeJSON(filePath, data)
140
150
  }
@@ -148,24 +158,8 @@ function injectCodexHooks(cwd) {
148
158
  const data = readJSON(filePath)
149
159
 
150
160
  if (!data.hooks) data.hooks = {}
151
- if (!Array.isArray(data.hooks.PermissionRequest)) data.hooks.PermissionRequest = []
152
- if (!Array.isArray(data.hooks.PostToolUse)) data.hooks.PostToolUse = []
153
-
154
- const hasNestedCommand = (entries, command) => entries.some(
155
- entry => Array.isArray(entry && entry.hooks) && entry.hooks.some(h => h && h.command === command)
156
- )
157
- if (!hasNestedCommand(data.hooks.PermissionRequest, SIGNAL_CMD)) {
158
- data.hooks.PermissionRequest.push({
159
- matcher: '.*',
160
- hooks: [{ type: 'command', command: SIGNAL_CMD, timeout: 10, statusMessage: 'Waiting for approval' }],
161
- })
162
- }
163
- if (!hasNestedCommand(data.hooks.PostToolUse, CLEANUP_CMD)) {
164
- data.hooks.PostToolUse.push({
165
- matcher: '.*',
166
- hooks: [{ type: 'command', command: CLEANUP_CMD, timeout: 10 }],
167
- })
168
- }
161
+ data.hooks.PermissionRequest = mergeClaudeHookArray(data.hooks.PermissionRequest, '.*', SIGNAL_CMD)
162
+ data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, '.*', CLEANUP_CMD)
169
163
 
170
164
  writeJSON(filePath, data)
171
165
  }
@@ -179,27 +173,8 @@ function injectGeminiHooks(cwd) {
179
173
  const data = readJSON(filePath)
180
174
 
181
175
  if (!data.hooks) data.hooks = {}
182
-
183
- if (!Array.isArray(data.hooks.Notification)) data.hooks.Notification = []
184
- if (!data.hooks.Notification.some(entry =>
185
- entry && entry.matcher === 'ToolPermission' &&
186
- Array.isArray(entry.hooks) && entry.hooks.some(hook => hook && hook.command === SIGNAL_CMD)
187
- )) {
188
- data.hooks.Notification.push({
189
- matcher: 'ToolPermission',
190
- hooks: [{ name: 'trackfw-attention-signal', type: 'command', command: SIGNAL_CMD, timeout: 10000 }],
191
- })
192
- }
193
-
194
- if (!Array.isArray(data.hooks.AfterTool)) data.hooks.AfterTool = []
195
- if (!data.hooks.AfterTool.some(entry =>
196
- Array.isArray(entry && entry.hooks) && entry.hooks.some(hook => hook && hook.command === CLEANUP_CMD)
197
- )) {
198
- data.hooks.AfterTool.push({
199
- matcher: '*',
200
- hooks: [{ name: 'trackfw-attention-cleanup', type: 'command', command: CLEANUP_CMD, timeout: 10000 }],
201
- })
202
- }
176
+ data.hooks.Notification = mergeClaudeHookArray(data.hooks.Notification, 'ToolPermission', SIGNAL_CMD)
177
+ data.hooks.AfterTool = mergeClaudeHookArray(data.hooks.AfterTool, '*', CLEANUP_CMD)
203
178
 
204
179
  writeJSON(filePath, data)
205
180
  }
@@ -214,12 +189,14 @@ function injectKiroHooks(cwd) {
214
189
  hooks: [
215
190
  {
216
191
  name: 'trackfw-attention-signal',
192
+ description: 'Signals trackfw board when agent executes a tool',
217
193
  event: 'PreToolUse',
218
194
  matcher: { tool_name: '.*' },
219
195
  action: { type: 'command', command: SIGNAL_CMD },
220
196
  },
221
197
  {
222
198
  name: 'trackfw-attention-cleanup',
199
+ description: 'Clears trackfw board attention after tool completes',
223
200
  event: 'PostToolUse',
224
201
  matcher: { tool_name: '.*' },
225
202
  action: { type: 'command', command: CLEANUP_CMD },
@@ -236,11 +213,16 @@ function injectKiroHooks(cwd) {
236
213
  function injectCopilotHooks(cwd) {
237
214
  const filePath = path.join(cwd, '.github', 'hooks', 'trackfw-attention.json')
238
215
  const data = {
239
- version: 1,
240
- hooks: {
241
- preToolUse: [{ type: 'command', bash: SIGNAL_CMD, cwd: '.', timeoutSec: 10 }],
242
- postToolUse: [{ type: 'command', bash: CLEANUP_CMD, cwd: '.', timeoutSec: 10 }],
243
- },
216
+ hooks: [
217
+ {
218
+ event: 'preToolUse',
219
+ run: SIGNAL_CMD,
220
+ },
221
+ {
222
+ event: 'postToolUse',
223
+ run: CLEANUP_CMD,
224
+ },
225
+ ],
244
226
  }
245
227
  writeJSON(filePath, data)
246
228
  }
@@ -266,6 +248,15 @@ function injectCursorHooks(cwd) {
266
248
  writeJSON(filePath, data)
267
249
  }
268
250
 
251
+ // ---------------------------------------------------------------------------
252
+ // Windsurf — update .windsurfrules with attention instruction
253
+ // ---------------------------------------------------------------------------
254
+
255
+ function injectWindsurfHooks(cwd) {
256
+ const { injectRulesForTool } = require('./init')
257
+ return injectRulesForTool('windsurf', cwd)
258
+ }
259
+
269
260
  // ---------------------------------------------------------------------------
270
261
  // injectHooksDetected — public entry point
271
262
  // ---------------------------------------------------------------------------
@@ -298,13 +289,18 @@ function injectHooksDetected(cwd) {
298
289
  },
299
290
  copilot: {
300
291
  check: () =>
301
- fs.existsSync(path.join(root, '.github', 'copilot-instructions.md')),
292
+ fs.existsSync(path.join(root, '.github', 'copilot-instructions.md')) ||
293
+ fs.existsSync(path.join(root, '.github', 'hooks')),
302
294
  fn: injectCopilotHooks,
303
295
  },
304
296
  cursor: {
305
297
  check: () => fs.existsSync(path.join(root, '.cursor')),
306
298
  fn: injectCursorHooks,
307
299
  },
300
+ windsurf: {
301
+ check: () => fs.existsSync(path.join(root, '.windsurfrules')),
302
+ fn: injectWindsurfHooks,
303
+ },
308
304
  }
309
305
 
310
306
  for (const [name, { check, fn }] of Object.entries(detections)) {
@@ -325,5 +321,6 @@ module.exports = {
325
321
  injectKiroHooks,
326
322
  injectCopilotHooks,
327
323
  injectCursorHooks,
324
+ injectWindsurfHooks,
328
325
  injectHooksDetected,
329
326
  }
@@ -15,25 +15,29 @@ const GOV_DIRS = [
15
15
  'docs/roadmaps/abandoned',
16
16
  ]
17
17
 
18
+ const GLOBAL_ADRS_DIRECTIVE = 'Obrigatório: Inspecione e respeite todos os ADRs globais nos diretórios listados em adr_dirs (inclusive caminhos ~/...) antes de propor alterações de arquitetura.'
19
+
18
20
  /**
19
21
  * scaffold(cfg) — cria diretórios de governança e gera arquivos de configuração.
20
22
  * cfg = { projectName, projectType, frontend, backend, pkgManager, hooks, ci }
21
23
  */
22
- async function scaffold(cfg) {
24
+ async function scaffold(cfg, cwd) {
25
+ const root = cwd || process.cwd()
23
26
  for (const dir of GOV_DIRS) {
24
- fs.mkdirSync(dir, { recursive: true })
27
+ fs.mkdirSync(path.join(root, dir), { recursive: true })
25
28
  console.log(` ✓ ${dir}`)
26
29
  }
27
30
 
28
- writeTrackfwConfig(cfg)
29
- generateValidateScript(cfg)
30
- generateAttentionScripts(cfg)
31
- generateCIWorkflow(cfg)
32
- generateGitHooks(cfg)
33
- generateCommitMsgHook(cfg)
34
- generateClaudeMD(cfg)
35
- if (cfg.backend === 'java') generatePomXml(cfg)
36
- generateClaudeCommands()
31
+ writeTrackfwConfig(cfg, root)
32
+ generateValidateScript(cfg, root)
33
+ generateAttentionScripts(cfg, root)
34
+ generateCIWorkflow(cfg, root)
35
+ generateGitHooks(cfg, root)
36
+ generateCommitMsgHook(cfg, root)
37
+ generateClaudeMD(cfg, root)
38
+ if (cfg.backend === 'java') generatePomXml(cfg, root)
39
+ generateClaudeCommands(root)
40
+ injectHooksDetected(root)
37
41
  }
38
42
 
39
43
  // ---------------------------------------------------------------------------
@@ -126,6 +130,11 @@ function generateAttentionScripts(cfg, cwd) {
126
130
  gen(cfg, cwd || process.cwd())
127
131
  }
128
132
 
133
+ function injectHooksDetected(cwd) {
134
+ const { injectHooksDetected: inject } = require('./hooks')
135
+ inject(cwd || process.cwd())
136
+ }
137
+
129
138
  // ---------------------------------------------------------------------------
130
139
  // CI workflows
131
140
  // ---------------------------------------------------------------------------
@@ -354,6 +363,7 @@ Chain: \`ADR → REQ → ROADMAP\` · States: \`backlog / analyzing / wip / bloc
354
363
  - Starting a ML: edit roadmap \`**Status:** ⬜ Pendente\` → \`**Status:** 🔄 Em andamento\` + commit.
355
364
  - Completing a ML: edit roadmap → \`**Status:** ✅ Concluído\` + include in ML commit.
356
365
  - Analyzing a roadmap: move from \`backlog/\` to \`analyzing/\`; to \`wip/\` only when coding starts.
366
+ 5. **${GLOBAL_ADRS_DIRECTIVE}**
357
367
 
358
368
  ### Attention Signal (when you need user input during a task)
359
369
  Write \`docs/roadmaps/.trackfw-attention.json\`:
@@ -361,14 +371,22 @@ Write \`docs/roadmaps/.trackfw-attention.json\`:
361
371
  {"roadmap":"file.md","ml":"ML-1A","message":"what you need","level":"action_required","timestamp":"ISO8601Z"}
362
372
  \`\`\`
363
373
  Delete the file when resolved. Visible as a live banner in \`trackfw serve\`.
364
- ` +
365
- '\n### Architecture Directives (mandatory)\n' +
366
- '- **3-layer arch + no in-memory data:** frontend / backend / database; always DB + ORM — never arrays/globals\n' +
367
- '- **Auth + Docker + .env from day 1:** never defer auth; containerize early; all config via env vars\n' +
368
- '- **2-layer validation + API-first:** frontend (UX) + backend (security); define OpenAPI contract first\n' +
369
- '- **Security wave + test coverage:** red-team review in every roadmap; TDD for critical; min 60%/80%\n' +
370
- '- Use `/trackfw:architect` to define stack before the first REQ\n' +
371
- RULES_END
374
+
375
+ > **Windsurf users:** before asking the user a question or requesting approval, write
376
+ > \`<roadmap_dir>/.trackfw-attention.json\` manually — there is no automatic hook for this.
377
+ > Delete the file after the user responds.
378
+
379
+ ### Architecture Directives (mandatory)
380
+ - **3-layer separation:** frontend / backend / database — never mix concerns
381
+ - **No in-memory data:** always database + ORM (never arrays/globals for persistence)
382
+ - **Auth from day 1:** never defer — refactoring auth later is very costly
383
+ - **Docker + .env from day 1:** containerize early; all config via env vars
384
+ - **2-layer validation:** frontend (UX) + backend (security) — never only one
385
+ - **API-first:** define OpenAPI contract before coding frontend/backend integration
386
+ - **Security wave:** include a red-team review wave in every feature roadmap
387
+ - **Test coverage:** TDD for critical logic; min 60% (prototype) / 80% (production)
388
+ - Use \`/trackfw:architect\` to define stack before the first REQ
389
+ ` + RULES_END
372
390
  }
373
391
 
374
392
  /**
@@ -473,11 +491,13 @@ function generateClaudeMD(cfg) {
473
491
  content += ' - Ao **concluir** um ML: edite o roadmap alterando `**Status:** 🔄 Em andamento` → `**Status:** ✅ Concluído` e inclua essa mudança no commit do ML.\n'
474
492
  content += ' - Ao **analisar** um roadmap antes de iniciar: mova o arquivo de `backlog/` para `analyzing/`; só mova para `wip/` ao começar a codificar de fato.\n'
475
493
  content += '5. **Run `trackfw validate` before every commit.** Zero violations required.\n'
476
- content += '6. **ADRs before decisions.** Any architectural or technical decision must have an ADR (`/trackfw:adr`).\n\n'
494
+ content += '6. **ADRs before decisions.** Any architectural or technical decision must have an ADR (`/trackfw:adr`).\n'
495
+ content += `7. **${GLOBAL_ADRS_DIRECTIVE}**\n\n`
477
496
 
478
497
  content += '## Slash commands (Claude Code)\n\n'
479
498
  content += '| Command | When to use |\n'
480
499
  content += '|---|---|\n'
500
+ content += '| `/trackfw:architect` | Guide stack and architecture decisions |\n'
481
501
  content += '| `/trackfw:implement <req>` | **Start here** — orchestrates full implementation flow |\n'
482
502
  content += '| `/trackfw:adr <title>` | Before any architectural decision |\n'
483
503
  content += '| `/trackfw:req <title>` | Before any implementation work |\n'
@@ -1085,6 +1105,7 @@ function printArchitectNextSteps(cwd) {
1085
1105
 
1086
1106
  module.exports = {
1087
1107
  GOV_DIRS,
1108
+ GLOBAL_ADRS_DIRECTIVE,
1088
1109
  scaffold,
1089
1110
  writeTrackfwConfig,
1090
1111
  generateValidateScript,
@@ -1105,6 +1126,7 @@ module.exports = {
1105
1126
  installIntegrationTarget,
1106
1127
  injectRulesForTool,
1107
1128
  injectRulesDetected,
1129
+ injectHooksDetected,
1108
1130
  trackfwRulesBlock,
1109
1131
  printArchitectNextSteps,
1110
1132
  }
@@ -28,7 +28,7 @@ class IntegrationManager {
28
28
  } else if (path.isAbsolute(destination)) {
29
29
  resolved = path.normalize(destination)
30
30
  } else {
31
- if (!destination || path.normalize(destination) !== destination || destination === '.' || destination.startsWith(`..${path.sep}`)) throw new Error(`Unsafe destination: ${destination}`)
31
+ if (!destination || path.posix.normalize(destination) !== destination || destination === '.' || destination.startsWith('../')) throw new Error(`Unsafe destination: ${destination}`)
32
32
  resolved = path.resolve(root, destination)
33
33
  }
34
34
  const rel = path.relative(root, resolved)
@@ -11,10 +11,11 @@ const STALE_WIP_DAYS = 7
11
11
  // listDir retorna array de nomes de arquivo (não-diretórios) em dir.
12
12
  // Retorna [] se o diretório não existir.
13
13
  function listDir(dir) {
14
+ const expanded = config.expandPath ? config.expandPath(dir) : dir
14
15
  try {
15
- return fs.readdirSync(dir).filter(name => {
16
+ return fs.readdirSync(expanded).filter(name => {
16
17
  try {
17
- return !fs.statSync(path.join(dir, name)).isDirectory()
18
+ return !fs.statSync(path.join(expanded, name)).isDirectory()
18
19
  } catch (_) {
19
20
  return false
20
21
  }
@@ -24,9 +25,17 @@ function listDir(dir) {
24
25
  }
25
26
  }
26
27
 
27
- // walkDirMd retorna basenames de todos .md recursivamente dentro de dir.
28
- function walkDirMd(dir) {
28
+ // isInsideDir retorna true se childPath estiver contido ou for igual a parentDir.
29
+ function isInsideDir(parentDir, childPath) {
30
+ if (!parentDir || !childPath) return false
31
+ const rel = path.relative(path.resolve(parentDir), path.resolve(childPath))
32
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
33
+ }
34
+
35
+ // walkDirMdWithPaths retorna { name, fullPath } de todos .md recursivamente dentro de dir.
36
+ function walkDirMdWithPaths(dir) {
29
37
  const results = []
38
+ const expandedDir = config.expandPath ? config.expandPath(dir) : dir
30
39
  function walk(d) {
31
40
  let entries
32
41
  try { entries = fs.readdirSync(d) } catch (_) { return }
@@ -34,19 +43,25 @@ function walkDirMd(dir) {
34
43
  const full = path.join(d, name)
35
44
  try {
36
45
  if (fs.statSync(full).isDirectory()) { walk(full) }
37
- else if (name.endsWith('.md')) { results.push(name) }
46
+ else if (name.endsWith('.md')) { results.push({ name, fullPath: full }) }
38
47
  } catch (_) {}
39
48
  }
40
49
  }
41
- walk(dir)
50
+ walk(expandedDir)
42
51
  return results
43
52
  }
44
53
 
54
+ // walkDirMd retorna basenames de todos .md recursivamente dentro de dir.
55
+ function walkDirMd(dir) {
56
+ return walkDirMdWithPaths(dir).map(item => item.name)
57
+ }
58
+
45
59
  // findAdrFile busca o basename recursivamente em todos os adrDirs configurados.
46
60
  // Retorna o caminho completo se encontrado, ou null.
47
61
  function findAdrFile(basename) {
48
62
  const cfg = config.load()
49
- for (const adrDir of cfg.adrDirs) {
63
+ const adrDirs = (cfg.adrDirs || []).map(d => config.expandPath ? config.expandPath(d) : d)
64
+ for (const adrDir of adrDirs) {
50
65
  function search(d) {
51
66
  let entries
52
67
  try { entries = fs.readdirSync(d) } catch (_) { return null }
@@ -260,12 +275,45 @@ function validateREQsHaveRoadmap() {
260
275
  return violations
261
276
  }
262
277
 
263
- // validateADRsAreReferenced — ADRs em adrDirs não referenciados em nenhuma REQ → violation
278
+ // validateADRDirsExist — verifica se todos adrDirs existem.
279
+ // Retorna { violations: [], warnings: [] } respeitando strictCiPaths.
280
+ function validateADRDirsExist() {
281
+ const cfg = config.load()
282
+ const violations = []
283
+ const warnings = []
284
+ for (const adrDir of cfg.adrDirs || []) {
285
+ const expanded = config.expandPath ? config.expandPath(adrDir) : adrDir
286
+ const absDir = path.resolve(expanded)
287
+ if (!fs.existsSync(absDir)) {
288
+ const msg = `adr directory "${adrDir}" does not exist`
289
+ if (cfg.strictCiPaths) {
290
+ violations.push(msg)
291
+ } else {
292
+ warnings.push(msg)
293
+ }
294
+ }
295
+ }
296
+ return { violations, warnings }
297
+ }
298
+
299
+ // validateADRsAreReferenced — ADRs em adrDirs não referenciados em nenhuma REQ → violation.
300
+ // ADRs localizados fora do projeto local (cwd) são isentos desta validação.
264
301
  function validateADRsAreReferenced() {
265
302
  const cfg = config.load()
303
+ const cwd = process.cwd()
266
304
  let adrs = []
267
- for (const adrDir of cfg.adrDirs) {
268
- adrs = adrs.concat(walkDirMd(adrDir))
305
+ for (const adrDir of cfg.adrDirs || []) {
306
+ const expanded = config.expandPath ? config.expandPath(adrDir) : adrDir
307
+ const absDir = path.resolve(expanded)
308
+ // Isentar diretórios fora do cwd local
309
+ if (!isInsideDir(cwd, absDir)) {
310
+ continue
311
+ }
312
+ for (const item of walkDirMdWithPaths(absDir)) {
313
+ if (isInsideDir(cwd, item.fullPath)) {
314
+ adrs.push(item.name)
315
+ }
316
+ }
269
317
  }
270
318
 
271
319
  const reqFiles = resolveReqFiles(cfg)
@@ -633,10 +681,12 @@ function validateRefTargetsExist() {
633
681
  }
634
682
 
635
683
  function referenceExists(ref, roots) {
636
- if (fs.existsSync(ref)) return true
684
+ const expandedRef = config.expandPath ? config.expandPath(ref) : ref
685
+ if (fs.existsSync(expandedRef)) return true
637
686
  const basename = path.basename(ref)
638
687
  for (const root of roots || []) {
639
- if (walkDirMd(root).some(filePath => path.basename(filePath) === basename)) return true
688
+ const expandedRoot = config.expandPath ? config.expandPath(root) : root
689
+ if (walkDirMd(expandedRoot).some(filePath => path.basename(filePath) === basename)) return true
640
690
  }
641
691
  return false
642
692
  }
@@ -884,6 +934,11 @@ async function validateUnfiltered() {
884
934
  // Regra direta (sem configuração de severidade): violation sempre
885
935
  for (const msg of validateFrontmatterPresence()) { _setMeta(msg, 'frontmatter_presence'); violations.push(msg) }
886
936
 
937
+ // Validação de existência dos adr_dirs (retorna violations se strictCiPaths, senão warnings)
938
+ const adrDirsExistResult = validateADRDirsExist()
939
+ for (const msg of adrDirsExistResult.violations) { _setMeta(msg, 'adr_dirs_exist'); violations.push(msg) }
940
+ for (const msg of adrDirsExistResult.warnings) { _setMeta(msg, 'adr_dirs_exist'); warnings.push(msg) }
941
+
887
942
  // warnings diretos do WIP limit (não configuráveis)
888
943
  for (const msg of wipLimitResult.warnings) { _setMeta(msg, 'wip_limit'); warnings.push(msg) }
889
944
 
@@ -1045,4 +1100,8 @@ module.exports = {
1045
1100
  // novas funções ML-1B (v2.5.1)
1046
1101
  getItemMeta,
1047
1102
  resetMeta,
1103
+ // novas funções ML-2B (governança global)
1104
+ isInsideDir,
1105
+ walkDirMdWithPaths,
1106
+ validateADRDirsExist,
1048
1107
  }