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 +1 -1
- package/src/commands/update.js +2 -1
- package/src/config/index.js +27 -8
- package/src/generators/hooks.js +81 -84
- package/src/generators/init.js +42 -20
- package/src/integrations/manager.js +1 -1
- package/src/validator/index.js +71 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackfw",
|
|
3
|
-
"version": "2.
|
|
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",
|
package/src/commands/update.js
CHANGED
|
@@ -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}`);
|
package/src/config/index.js
CHANGED
|
@@ -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('- '))
|
|
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
|
+
|
package/src/generators/hooks.js
CHANGED
|
@@ -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 —
|
|
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
|
-
|
|
42
|
-
|
|
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 //
|
|
47
|
-
MSG=$(echo "$INPUT" | jq -r '(.
|
|
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'
|
|
50
|
-
MSG=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); ti=d.get('tool_input',{}); print((
|
|
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 |
|
|
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
|
-
"$
|
|
61
|
-
"$
|
|
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
|
-
|
|
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 |
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
}
|
package/src/generators/init.js
CHANGED
|
@@ -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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
|
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(
|
|
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)
|
package/src/validator/index.js
CHANGED
|
@@ -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(
|
|
16
|
+
return fs.readdirSync(expanded).filter(name => {
|
|
16
17
|
try {
|
|
17
|
-
return !fs.statSync(path.join(
|
|
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
|
-
//
|
|
28
|
-
function
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|