trackfw 2.12.0 → 2.12.3

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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > The AI-native governance layer for software delivery — ADR → REQ → ROADMAP → backlog / wip / blocked / done / abandoned
4
4
 
5
5
  [![Release](https://img.shields.io/github/v/release/kgsaran/trackfw)](https://github.com/kgsaran/trackfw/releases/latest)
6
- [![Go](https://img.shields.io/badge/go-1.21+-00ADD8?logo=go)](go.mod)
6
+ [![Go](https://img.shields.io/badge/go-1.25+-00ADD8?logo=go)](go.mod)
7
7
  [![npm](https://img.shields.io/npm/v/trackfw?logo=npm&color=CB3837)](https://www.npmjs.com/package/trackfw)
8
8
  [![PyPI](https://img.shields.io/pypi/v/trackfw?logo=python&color=3776AB)](https://pypi.org/project/trackfw/)
9
9
  [![License](https://img.shields.io/github/license/kgsaran/trackfw)](LICENSE)
@@ -90,7 +90,9 @@ go install github.com/kgsaran/trackfw/cmd/trackfw@latest
90
90
  npm install -g trackfw
91
91
  ```
92
92
 
93
- The npm package is pure Node.js — no compiled binary, no postinstall download. Works everywhere Node.js ≥ 18 is installed, including corporate Windows environments where unsigned `.exe` files are blocked by antivirus. Full feature parity with the Go CLI — all commands, all validation rules, all by_agent features.
93
+ The npm package is pure Node.js — no compiled binary or postinstall download.
94
+ It works wherever Node.js ≥ 18 is installed. Shared behavior and intentional
95
+ Go-only integration installers follow the [CLI parity contract](docs/cli-parity.md).
94
96
 
95
97
  ### pip
96
98
 
@@ -98,7 +100,9 @@ The npm package is pure Node.js — no compiled binary, no postinstall download.
98
100
  pip install trackfw
99
101
  ```
100
102
 
101
- The pip package is pure Python — no compiled binary, no postinstall download. Full feature parity with the Go CLI — all commands, all validation rules, all by_agent features.
103
+ The pip package is pure Python 3.10+ — no compiled binary or postinstall
104
+ download. Shared commands, validation rules, and by_agent behavior follow the
105
+ [CLI parity contract](docs/cli-parity.md).
102
106
 
103
107
  ---
104
108
 
@@ -323,6 +327,7 @@ $ trackfw status
323
327
 
324
328
  | Command | Installs | Format |
325
329
  |---|---|---|
330
+ | `trackfw init --ai-tools codex` | `AGENTS.md`, 5 repository skills, 6 custom agents, Codex config and hooks | `.agents/skills/` + `.codex/` |
326
331
  | `trackfw agents` | 10 subagents in `~/.claude/agents/` | Claude Code `.md` with frontmatter |
327
332
  | `trackfw gemini` | GEMINI.md + 10 skills + 3 commands | `~/.gemini/` + project root |
328
333
  | `trackfw cursor` | 10 rules in `.cursor/rules/` | `.mdc` with YAML frontmatter |
@@ -345,11 +350,13 @@ The 10 roles installed for each tool: **architect · backend · frontend · qa
345
350
  ? Package manager? npm / pnpm / yarn / bun
346
351
  ? Git hooks? husky / lefthook / none
347
352
  ? CI system? GitHub Actions / GitLab CI / none
348
- ? Which AI assistants? Claude Code / Gemini CLI / Cursor / Copilot / Windsurf / Amazon Q
353
+ ? Which AI assistants? Claude Code / OpenAI Codex / Gemini CLI / Cursor / Copilot / Windsurf / Amazon Q
349
354
  ```
350
355
 
351
356
  The governance structure (`docs/adr/`, `docs/req/`, `docs/roadmaps/`) is always identical — stack-agnostic. The generated hooks, workflows, and AI integrations adapt to your answers.
352
357
 
358
+ The Codex integration is repository-scoped: `AGENTS.md` carries persistent instructions, `.agents/skills/` provides governance workflows, `.codex/agents/` provides specialist subagents, and `.codex/hooks.json` signals permission requests to the local dashboard.
359
+
353
360
  ---
354
361
 
355
362
  ## Design principles
@@ -374,6 +381,20 @@ The governance structure (`docs/adr/`, `docs/req/`, `docs/roadmaps/`) is always
374
381
 
375
382
  ---
376
383
 
384
+ ## Compared to alternatives
385
+
386
+ | Tool | What it does | What's missing |
387
+ |---|---|---|
388
+ | **adr-tools** | Creates ADR files | No link to requirements or roadmaps |
389
+ | **madr** | ADR template format | No enforcement, no delivery tracking |
390
+ | **Linear / Jira** | Task tracking | No traceability to architectural decisions |
391
+ | **Kosli** | SDLC compliance for regulated industries | SaaS, accounts, cost — not for every team |
392
+ | **trackfw** | Enforces the full chain: decision → requirement → roadmap → delivery | — |
393
+
394
+ trackfw is the only open-source CLI that links ADRs to requirements, requirements to roadmaps, and enforces the chain as a pre-commit and CI gate — with native support for AI coding assistants.
395
+
396
+ ---
397
+
377
398
  ## Contributing
378
399
 
379
400
  ```bash
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "2.12.0",
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).",
3
+ "version": "2.12.3",
4
+ "description": "Governance CLI for AI-native software delivery. 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",
7
7
  "adr",
@@ -13,6 +13,17 @@
13
13
  "ai-agents",
14
14
  "developer-tools",
15
15
  "kanban",
16
+ "traceability",
17
+ "software-governance",
18
+ "ai-governance",
19
+ "pre-commit",
20
+ "developer-experience",
21
+ "technical-debt",
22
+ "claude-code",
23
+ "openai-codex",
24
+ "gemini",
25
+ "cursor",
26
+ "copilot",
16
27
  "req",
17
28
  "trackfw"
18
29
  ],
@@ -30,6 +41,10 @@
30
41
  "bin/",
31
42
  "src/"
32
43
  ],
44
+ "scripts": {
45
+ "test": "node --test tests/*.test.js",
46
+ "smoke": "node bin/trackfw --help && node bin/trackfw version"
47
+ },
33
48
  "dependencies": {
34
49
  "commander": "^12.0.0",
35
50
  "@inquirer/prompts": "^5.0.0"
@@ -26,6 +26,7 @@ function createProgram() {
26
26
  program.addCommand(require('./baseline'))
27
27
  program.addCommand(require('./help'))
28
28
  program.addCommand(require('./configure'))
29
+ program.addCommand(require('./version'))
29
30
 
30
31
  const { createServeCommand } = require('./serve')
31
32
  program.addCommand(createServeCommand())
@@ -4,7 +4,8 @@ const { t } = require('../i18n')
4
4
 
5
5
  const cmd = new Command('init')
6
6
  cmd.description(t('init.description'))
7
- cmd.action(async () => {
7
+ cmd.option('--ai-tools <tools>', 'Comma-separated AI tools to configure (codex,claude,gemini,cursor,copilot,windsurf,amazonq)', '')
8
+ cmd.action(async (options) => {
8
9
  const path = require('path')
9
10
  const generators = require('../generators/init')
10
11
 
@@ -20,6 +21,19 @@ cmd.action(async () => {
20
21
  ci: 'none',
21
22
  }
22
23
  await generators.scaffold(cfg)
24
+ const aiTools = String(options.aiTools || '').split(',').map(tool => tool.trim()).filter(Boolean)
25
+ for (const tool of aiTools) {
26
+ switch (tool) {
27
+ case 'codex': require('../generators/codex').installCodex(process.cwd()); break
28
+ case 'claude': await generators.installAgents(); break
29
+ case 'gemini': await generators.installGemini(); break
30
+ case 'cursor': await generators.installCursor(); break
31
+ case 'copilot': await generators.installCopilot(); break
32
+ case 'windsurf': await generators.installWindsurf(); break
33
+ case 'amazonq': await generators.installAmazonQ(); break
34
+ default: throw new Error(`Unsupported AI tool: ${tool}`)
35
+ }
36
+ }
23
37
  console.log(`\n${t('init.success')}`)
24
38
  require('../generators/init').printArchitectNextSteps(process.cwd())
25
39
  return
@@ -141,6 +155,7 @@ cmd.action(async () => {
141
155
  message: t('init.prompt.aiTools'),
142
156
  choices: [
143
157
  { name: 'Claude Code', value: 'claude' },
158
+ { name: 'OpenAI Codex', value: 'codex' },
144
159
  { name: 'Gemini CLI', value: 'gemini' },
145
160
  { name: 'Cursor', value: 'cursor' },
146
161
  { name: 'GitHub Copilot', value: 'copilot' },
@@ -170,6 +185,7 @@ cmd.action(async () => {
170
185
 
171
186
  for (const tool of (aiTools || [])) {
172
187
  switch (tool) {
188
+ case 'codex': require('../generators/codex').installCodex(process.cwd()); break
173
189
  case 'claude': await generators.installAgents(); break
174
190
  case 'gemini': await generators.installGemini(); break
175
191
  case 'cursor': await generators.installCursor(); break
@@ -7,6 +7,8 @@ const https = require('https')
7
7
  const { t } = require('../i18n')
8
8
 
9
9
  const REGISTRY_URL = 'https://raw.githubusercontent.com/kgsaran/trackfw-plugins/main/registry.yaml'
10
+ const MAX_PLUGIN_SIZE = 50 * 1024 * 1024
11
+ const REQUEST_TIMEOUT_MS = 30000
10
12
 
11
13
  function fetchRegistry() {
12
14
  return new Promise((resolve, reject) => {
@@ -91,15 +93,27 @@ async function installPlugin(repo) {
91
93
  ? `https://github.com/${base}/releases/latest/download/${assetName}`
92
94
  : `https://github.com/${base}/releases/download/${tag}/${assetName}`
93
95
 
94
- const res = await fetch(url)
96
+ const res = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) })
95
97
  if (!res.ok) throw new Error(t('errors.downloadFailed', { status: res.status, url }))
98
+ const declaredSize = Number(res.headers.get('content-length') || 0)
99
+ if (declaredSize > MAX_PLUGIN_SIZE) throw new Error(`Plugin exceeds ${MAX_PLUGIN_SIZE} bytes`)
96
100
 
97
101
  const dir = pluginsDir()
98
102
  fs.mkdirSync(dir, { recursive: true })
99
- fs.writeFileSync(path.join(dir, pluginName), Buffer.from(await res.arrayBuffer()), { mode: 0o755 })
103
+ const content = Buffer.from(await res.arrayBuffer())
104
+ if (content.length > MAX_PLUGIN_SIZE) throw new Error(`Plugin exceeds ${MAX_PLUGIN_SIZE} bytes`)
105
+ const destination = path.join(dir, pluginName)
106
+ const temporary = path.join(dir, `.${pluginName}-${process.pid}.tmp`)
107
+ try {
108
+ fs.writeFileSync(temporary, content, { mode: 0o755 })
109
+ fs.renameSync(temporary, destination)
110
+ } finally {
111
+ if (fs.existsSync(temporary)) fs.unlinkSync(temporary)
112
+ }
100
113
  }
101
114
 
102
115
  function removePlugin(name) {
116
+ if (!name || path.basename(name) !== name) throw new Error(`Invalid plugin name: ${name}`)
103
117
  const filePath = path.join(pluginsDir(), name)
104
118
  if (!fs.existsSync(filePath)) throw new Error(t('errors.pluginNotFound', { name }))
105
119
  fs.unlinkSync(filePath)
@@ -9,6 +9,7 @@ const { handleBoard } = require('../serve/api_board')
9
9
  const { handleChain } = require('../serve/api_chain')
10
10
  const { handleMetrics } = require('../serve/api_metrics')
11
11
  const { handleFile } = require('../serve/api_file')
12
+ const { handleAttention } = require('../serve/api_attention')
12
13
 
13
14
  const STATIC_DIR = path.join(__dirname, '..', 'serve', 'static')
14
15
 
@@ -121,6 +122,11 @@ function createServer(cfg, port) {
121
122
  return
122
123
  }
123
124
 
125
+ if (pathname === '/api/attention') {
126
+ handleAttention(cfg, req, res)
127
+ return
128
+ }
129
+
124
130
  res.writeHead(404, { 'Content-Type': 'text/plain' })
125
131
  res.end('Not found')
126
132
  })
@@ -71,6 +71,13 @@ cmd.action(() => {
71
71
  } catch (e) {
72
72
  console.log(` ⚠ agent rules: ${e.message}`);
73
73
  }
74
+ if (fs.existsSync(path.join(cwd, 'AGENTS.md')) || fs.existsSync(path.join(cwd, '.codex'))) {
75
+ try {
76
+ require('../generators/codex').installCodex(cwd);
77
+ } catch (e) {
78
+ console.warn(` ⚠ Codex integration: ${e.message}`);
79
+ }
80
+ }
74
81
 
75
82
  // 1b. Agent hooks (attention signal/cleanup)
76
83
  try {
@@ -0,0 +1,10 @@
1
+ 'use strict'
2
+
3
+ const { Command } = require('commander')
4
+ const { version } = require('../../package.json')
5
+
6
+ module.exports = new Command('version')
7
+ .description('Print version')
8
+ .action(() => {
9
+ console.log(`trackfw ${version}`)
10
+ })
@@ -0,0 +1,111 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { injectCodexHooks } = require('./hooks')
6
+ const { injectRulesForTool } = require('./init')
7
+
8
+ const skills = {
9
+ 'trackfw-governance': `---
10
+ name: trackfw-governance
11
+ description: Inspect or maintain ADR, REQ, ROADMAP, traceability, lifecycle state, and trackfw validation. Use for governance questions and artifact changes.
12
+ ---
13
+ Run \`trackfw context --format=json\`, preserve the ADR → REQ → ROADMAP chain, and finish with \`trackfw validate --json\`.
14
+ `,
15
+ 'trackfw-plan': `---
16
+ name: trackfw-plan
17
+ description: Plan a trackfw-governed implementation before code changes. Use for ADRs, REQs, roadmaps, microbatches, acceptance criteria, and validation commands.
18
+ ---
19
+ Inspect existing artifacts and code. Create required decisions and requirements before a decision-complete roadmap. Keep the roadmap in analyzing until coding starts.
20
+ `,
21
+ 'trackfw-implement': `---
22
+ name: trackfw-implement
23
+ description: Implement work governed by an existing trackfw roadmap. Use for features and fixes that must follow lifecycle and validation gates.
24
+ ---
25
+ Identify the matching REQ and roadmap, move it to wip, implement one microbatch at a time, run project tests and \`trackfw validate --json\`, then update lifecycle state.
26
+ `,
27
+ 'trackfw-review': `---
28
+ name: trackfw-review
29
+ description: Review a change for governance traceability, correctness, security, regressions, missing tests, and roadmap acceptance.
30
+ ---
31
+ Stay read-only unless fixes are requested. Report evidence-backed findings by severity and verify the linked REQ, roadmap, acceptance criteria, tests, and validation output.
32
+ `,
33
+ 'trackfw-release': `---
34
+ name: trackfw-release
35
+ description: Prepare and verify a trackfw release. Use for versioning, changelog, packaging, parity, and publication gates.
36
+ ---
37
+ Run all quality and parity gates, derive SemVer from commits, and stop before tags or publication unless explicitly authorized.
38
+ `,
39
+ }
40
+
41
+ const agents = {
42
+ 'trackfw-architect.toml': `name = "trackfw_architect"
43
+ description = "Architecture and implementation-planning specialist for governed changes."
44
+ sandbox_mode = "read-only"
45
+ developer_instructions = """
46
+ Map architecture, contracts, constraints, and traceability. Require ADRs for unresolved material decisions. Produce decision-complete plans and do not edit files unless explicitly assigned.
47
+ """
48
+ `,
49
+ 'trackfw-backend.toml': `name = "trackfw_backend"
50
+ description = "Backend implementation specialist for APIs, domain logic, integrations, Go, Java, Node.js, and Python."
51
+ developer_instructions = """
52
+ Implement only the assigned backend scope, preserve contracts and traceability, run focused tests, and report changed files and evidence.
53
+ """
54
+ `,
55
+ 'trackfw-frontend.toml': `name = "trackfw_frontend"
56
+ description = "Frontend implementation specialist focused on accessibility, i18n, UX consistency, and browser behavior."
57
+ developer_instructions = """
58
+ Implement only the assigned frontend scope, preserve accessibility and localization, and run focused build and UI tests.
59
+ """
60
+ `,
61
+ 'trackfw-qa.toml': `name = "trackfw_qa"
62
+ description = "Read-only QA specialist for regressions, flaky tests, critical flows, and acceptance coverage."
63
+ sandbox_mode = "read-only"
64
+ developer_instructions = """
65
+ Trace critical flows and compare behavior against roadmap acceptance criteria. Report reproducible failures and test gaps.
66
+ """
67
+ `,
68
+ 'trackfw-security.toml': `name = "trackfw_security"
69
+ description = "Read-only security reviewer for trust boundaries, secrets, injection, permissions, and unsafe defaults."
70
+ sandbox_mode = "read-only"
71
+ developer_instructions = """
72
+ Perform evidence-backed threat analysis. Report concrete exploit paths and mitigations by severity.
73
+ """
74
+ `,
75
+ 'trackfw-reviewer.toml': `name = "trackfw_reviewer"
76
+ description = "Owner-level reviewer for correctness, governance, maintainability, security, and missing tests."
77
+ sandbox_mode = "read-only"
78
+ developer_instructions = """
79
+ Review the branch as an owner. Return findings first, ordered by severity, with file references and governance evidence.
80
+ """
81
+ `,
82
+ }
83
+
84
+ function writeManaged(filePath, content) {
85
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
86
+ fs.writeFileSync(filePath, content.trim() + '\n', 'utf8')
87
+ }
88
+
89
+ function installCodex(cwd) {
90
+ const root = cwd || process.cwd()
91
+ injectRulesForTool('codex', root)
92
+
93
+ const configPath = path.join(root, '.codex', 'config.toml')
94
+ fs.mkdirSync(path.dirname(configPath), { recursive: true })
95
+ let config = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : ''
96
+ if (!/^\[agents\]\s*$/m.test(config)) {
97
+ config = config.replace(/\s*$/, '') + '\n\n[agents]\nmax_threads = 6\nmax_depth = 1\n'
98
+ fs.writeFileSync(configPath, config, 'utf8')
99
+ }
100
+
101
+ for (const [name, content] of Object.entries(skills)) {
102
+ writeManaged(path.join(root, '.agents', 'skills', name, 'SKILL.md'), content)
103
+ }
104
+ for (const [name, content] of Object.entries(agents)) {
105
+ writeManaged(path.join(root, '.codex', 'agents', name), content)
106
+ }
107
+ injectCodexHooks(root)
108
+ console.log(' ✓ Codex: AGENTS.md, skills, custom agents and hooks')
109
+ }
110
+
111
+ module.exports = { installCodex }
@@ -33,19 +33,21 @@ function hasEntry(arr, field, value) {
33
33
  // ---------------------------------------------------------------------------
34
34
 
35
35
  const SIGNAL_SCRIPT = `#!/usr/bin/env bash
36
- # trackfw attention signal — PreToolUse/BeforeTool hook
36
+ # trackfw attention signal — permission/notification hook
37
37
  # Writes .trackfw-attention.json so trackfw serve board shows a banner.
38
38
  set -euo pipefail
39
39
 
40
40
  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"
41
43
  [ -f "trackfw.yaml" ] || exit 0
42
44
 
43
45
  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
+ 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]')
46
48
  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
+ 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")
49
51
  fi
50
52
 
51
53
  ROADMAP_DIR=$(grep '^roadmap_dir:' trackfw.yaml 2>/dev/null | awk '{print $2}' | tr -d "\"'" | head -1)
@@ -66,6 +68,9 @@ const CLEANUP_SCRIPT = `#!/usr/bin/env bash
66
68
  # trackfw attention cleanup — PostToolUse/AfterTool hook
67
69
  set -euo pipefail
68
70
 
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"
69
74
  [ -f "trackfw.yaml" ] || exit 0
70
75
 
71
76
  ROADMAP_DIR=$(grep '^roadmap_dir:' trackfw.yaml 2>/dev/null | awk '{print $2}' | tr -d "\"'" | head -1)
@@ -142,14 +147,24 @@ function injectCodexHooks(cwd) {
142
147
  const filePath = path.join(cwd, '.codex', 'hooks.json')
143
148
  const data = readJSON(filePath)
144
149
 
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
+ 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
+ })
150
162
  }
151
- if (!hasEntry(data.PostToolUse, 'command', CLEANUP_CMD)) {
152
- data.PostToolUse.push({ matcher: '.*', command: CLEANUP_CMD })
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
+ })
153
168
  }
154
169
 
155
170
  writeJSON(filePath, data)
@@ -165,14 +180,25 @@ function injectGeminiHooks(cwd) {
165
180
 
166
181
  if (!data.hooks) data.hooks = {}
167
182
 
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 })
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
+ })
171
192
  }
172
193
 
173
194
  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 })
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
+ })
176
202
  }
177
203
 
178
204
  writeJSON(filePath, data)
@@ -210,10 +236,11 @@ function injectKiroHooks(cwd) {
210
236
  function injectCopilotHooks(cwd) {
211
237
  const filePath = path.join(cwd, '.github', 'hooks', 'trackfw-attention.json')
212
238
  const data = {
213
- hooks: [
214
- { event: 'preToolUse', run: SIGNAL_CMD },
215
- { event: 'postToolUse', run: CLEANUP_CMD },
216
- ],
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
+ },
217
244
  }
218
245
  writeJSON(filePath, data)
219
246
  }
@@ -104,7 +104,7 @@ trackfw validate
104
104
  break
105
105
  }
106
106
  case 'python':
107
- base += "echo \"→ build check (python)...\"\npython -m py_compile $(find . -name '*.py' -not -path './.venv/*' -not -path './venv/*')\n"
107
+ base += "echo \"→ build check (python)...\"\npython3 -c \"import pathlib, py_compile; [py_compile.compile(str(p), doraise=True) for p in pathlib.Path('.').rglob('*.py') if '.venv' not in p.parts and 'venv' not in p.parts]\"\n"
108
108
  break
109
109
  }
110
110
 
@@ -583,7 +583,7 @@ function backendCommands(cfg) {
583
583
  return [`${pm} run build`, `${pm} test`, `${pm} run lint`]
584
584
  case 'python':
585
585
  return [
586
- "python -m py_compile $(find . -name '*.py' -not -path './.venv/*' -not -path './venv/*')",
586
+ "python3 -c \"import pathlib, py_compile; [py_compile.compile(str(p), doraise=True) for p in pathlib.Path('.').rglob('*.py') if '.venv' not in p.parts and 'venv' not in p.parts]\"",
587
587
  'python -m pytest',
588
588
  'ruff check .',
589
589
  ]
@@ -941,45 +941,45 @@ async function installAgents() {
941
941
  console.log(' ✓ claude (agentes — execute `trackfw install agents` com o binário Go para instalar os templates)')
942
942
  }
943
943
 
944
- async function installGemini() {
944
+ async function installGemini(cwd = process.cwd()) {
945
945
  try {
946
- injectRulesForTool('gemini', process.cwd())
946
+ injectRulesForTool('gemini', cwd)
947
947
  console.log(' ✓ trackfw rules → GEMINI.md')
948
948
  } catch (e) {
949
949
  console.log(` ⚠ gemini rules inject: ${e.message}`)
950
950
  }
951
951
  }
952
952
 
953
- async function installCursor() {
953
+ async function installCursor(cwd = process.cwd()) {
954
954
  try {
955
- injectRulesForTool('cursor', process.cwd())
955
+ injectRulesForTool('cursor', cwd)
956
956
  console.log(' ✓ trackfw rules → .cursor/rules/trackfw.mdc')
957
957
  } catch (e) {
958
958
  console.log(` ⚠ cursor rules inject: ${e.message}`)
959
959
  }
960
960
  }
961
961
 
962
- async function installCopilot() {
962
+ async function installCopilot(cwd = process.cwd()) {
963
963
  try {
964
- injectRulesForTool('copilot', process.cwd())
964
+ injectRulesForTool('copilot', cwd)
965
965
  console.log(' ✓ trackfw rules → .github/copilot-instructions.md')
966
966
  } catch (e) {
967
967
  console.log(` ⚠ copilot rules inject: ${e.message}`)
968
968
  }
969
969
  }
970
970
 
971
- async function installWindsurf() {
971
+ async function installWindsurf(cwd = process.cwd()) {
972
972
  try {
973
- injectRulesForTool('windsurf', process.cwd())
973
+ injectRulesForTool('windsurf', cwd)
974
974
  console.log(' ✓ trackfw rules → .windsurfrules')
975
975
  } catch (e) {
976
976
  console.log(` ⚠ windsurf rules inject: ${e.message}`)
977
977
  }
978
978
  }
979
979
 
980
- async function installAmazonQ() {
980
+ async function installAmazonQ(cwd = process.cwd()) {
981
981
  try {
982
- injectRulesForTool('amazonq', process.cwd())
982
+ injectRulesForTool('amazonq', cwd)
983
983
  console.log(' ✓ trackfw rules → .amazonq/developer/guidelines.md')
984
984
  } catch (e) {
985
985
  console.log(` ⚠ amazonq rules inject: ${e.message}`)
@@ -0,0 +1,24 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ function getAttention(cfg) {
7
+ const attentionPath = path.join(cfg.roadmapDir, '.trackfw-attention.json')
8
+ try {
9
+ const payload = JSON.parse(fs.readFileSync(attentionPath, 'utf8'))
10
+ return { ...payload, active: true }
11
+ } catch (_) {
12
+ return { active: false }
13
+ }
14
+ }
15
+
16
+ function handleAttention(cfg, req, res) {
17
+ res.writeHead(200, {
18
+ 'Content-Type': 'application/json',
19
+ 'Access-Control-Allow-Origin': '*'
20
+ })
21
+ res.end(JSON.stringify(getAttention(cfg)))
22
+ }
23
+
24
+ module.exports = { getAttention, handleAttention }
@@ -14,10 +14,20 @@ let _burnChart = null; // instância Chart.js burndown
14
14
  let _d3Sim = null; // simulação D3 force
15
15
  let _drawerPath = null; // path atualmente aberto no drawer
16
16
 
17
+ // ─── Auto-refresh ─────────────────────────────────────────────────────────────
18
+ let _refreshInterval = parseInt(localStorage.getItem('trackfw_refresh_interval') || '60');
19
+ let _refreshPaused = localStorage.getItem('trackfw_refresh_paused') === 'true';
20
+ let _refreshTimer = null;
21
+
22
+ // ─── Atenção de agente ────────────────────────────────────────────────────────
23
+ let _attentionTimer = null;
24
+ let _attentionDismissed = localStorage.getItem('trackfw_attention_dismissed') || null;
25
+
17
26
  // Cores canonicas por estado
18
27
  const STATE_COLORS = {
19
28
  wip: '#3b82f6',
20
29
  backlog: '#6b7280',
30
+ analyzing: '#f59e0b',
21
31
  blocked: '#ef4444',
22
32
  done: '#22c55e',
23
33
  abandoned: '#78716c',
@@ -39,7 +49,7 @@ function truncate(str, max = 30) {
39
49
 
40
50
  function stateLabel(state) {
41
51
  const labels = {
42
- wip: 'WIP', backlog: 'BACKLOG', blocked: 'BLOCKED', done: 'DONE', abandoned: 'ABANDONED',
52
+ wip: 'WIP', backlog: 'BACKLOG', analyzing: 'Analisando', blocked: 'BLOCKED', done: 'DONE', abandoned: 'ABANDONED',
43
53
  };
44
54
  return labels[state] || state.toUpperCase();
45
55
  }
@@ -93,9 +103,9 @@ function switchView(view) {
93
103
 
94
104
  // ─── View: Board / Kanban ─────────────────────────────────────────────────────
95
105
 
96
- const COLUMNS_ORDER = ['backlog', 'wip', 'blocked', 'done', 'abandoned'];
106
+ const COLUMNS_ORDER = ['backlog', 'analyzing', 'wip', 'blocked', 'done', 'abandoned'];
97
107
  const COLUMNS_LABEL = {
98
- wip: 'WIP', backlog: 'Backlog', blocked: 'Blocked', done: 'Done', abandoned: 'Abandoned',
108
+ wip: 'WIP', backlog: 'Backlog', analyzing: 'Analisando', blocked: 'Blocked', done: 'Done', abandoned: 'Abandoned',
99
109
  };
100
110
 
101
111
  async function loadBoard() {
@@ -162,6 +172,9 @@ function renderBoard(data, agentFilter) {
162
172
  if (agentFilter) {
163
173
  cards = cards.filter(c => c.agent === agentFilter);
164
174
  }
175
+ if (state === 'wip') {
176
+ cards = [...cards].sort((a, b) => (b.active_ml ? 1 : 0) - (a.active_ml ? 1 : 0));
177
+ }
165
178
 
166
179
  const col = document.createElement('div');
167
180
  col.className = 'kanban-column bg-white rounded-lg border border-gray-200 shadow-sm flex flex-col';
@@ -197,18 +210,52 @@ function renderBoard(data, agentFilter) {
197
210
  }
198
211
 
199
212
  function createCard(card) {
213
+ const isActive = !!(card.active_ml) && card.state === 'wip';
214
+
200
215
  const div = document.createElement('div');
201
- div.className = 'kanban-card bg-gray-50 border border-gray-200 rounded-md p-3 hover:bg-white';
216
+ div.className = `kanban-card bg-gray-50 border border-gray-200 rounded-md p-3 hover:bg-white${isActive ? ' card-active' : ''}`;
202
217
  div.setAttribute('tabindex', '0');
203
218
  div.setAttribute('role', 'button');
204
219
  div.setAttribute('aria-label', `Abrir: ${card.title || card.file}`);
220
+ div.setAttribute('data-file', card.file || '');
221
+
222
+ const total = card.ml_total || 0;
223
+ const done = card.ml_done || 0;
224
+ const activeML = card.active_ml || '';
225
+ const nextML = card.next_ml || '';
226
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0;
227
+
228
+ let barColor = 'bg-blue-500';
229
+ if (card.state === 'blocked') barColor = 'bg-red-400';
230
+ else if (done === total && total > 0) barColor = 'bg-green-500';
231
+
232
+ const mlLabel = activeML
233
+ ? `<p class="text-xs text-blue-600 mt-1 leading-snug truncate" title="${escapeHtml(activeML)}">▶ ${escapeHtml(activeML)}</p>`
234
+ : nextML
235
+ ? `<p class="text-xs text-gray-400 mt-1 leading-snug truncate" title="${escapeHtml(nextML)}">→ ${escapeHtml(nextML)}</p>`
236
+ : '';
237
+
238
+ const progressHTML = total > 0 ? `
239
+ <div class="mt-2 pt-2 border-t border-gray-100">
240
+ <div class="flex items-center justify-between mb-1">
241
+ <span class="text-xs text-gray-400">MLs</span>
242
+ <span class="text-xs font-medium text-gray-600">${done}/${total}</span>
243
+ </div>
244
+ <div class="w-full bg-gray-200 rounded-full h-1.5">
245
+ <div class="${barColor} h-1.5 rounded-full transition-all" style="width:${pct}%"></div>
246
+ </div>
247
+ ${mlLabel}
248
+ </div>` : '';
205
249
 
206
250
  div.innerHTML = `
251
+ ${isActive ? '<span class="live-dot" aria-hidden="true"></span>' : ''}
207
252
  <p class="text-xs font-semibold text-gray-800 mb-2 leading-snug">${escapeHtml(card.title || card.file)}</p>
208
253
  <div class="flex flex-wrap items-center gap-1">
209
254
  ${card.agent ? `<span class="inline-block text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded font-medium">${escapeHtml(card.agent)}</span>` : ''}
210
255
  <span class="badge-${card.state} inline-block text-xs px-1.5 py-0.5 rounded font-medium">${stateLabel(card.state)}</span>
211
- </div>`;
256
+ ${isActive ? '<span class="inline-block text-xs bg-green-100 text-green-700 px-1.5 py-0.5 rounded font-medium">ATIVO</span>' : ''}
257
+ </div>
258
+ ${progressHTML}`;
212
259
 
213
260
  div.addEventListener('click', () => openDrawer(card.path));
214
261
  div.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openDrawer(card.path); } });
@@ -222,6 +269,144 @@ function filterByAgent(agent) {
222
269
  }
223
270
  }
224
271
 
272
+ // ─── Auto-refresh ─────────────────────────────────────────────────────────────
273
+
274
+ function startRefreshTimer() {
275
+ clearTimeout(_refreshTimer);
276
+ if (_refreshPaused || _refreshInterval <= 0) return;
277
+ _refreshTimer = setTimeout(() => {
278
+ if (_currentView === 'board') {
279
+ _boardData = null;
280
+ loadBoard();
281
+ }
282
+ startRefreshTimer();
283
+ }, _refreshInterval * 1000);
284
+ }
285
+
286
+ function setRefreshInterval(seconds) {
287
+ _refreshInterval = seconds;
288
+ localStorage.setItem('trackfw_refresh_interval', String(seconds));
289
+ startRefreshTimer();
290
+ }
291
+
292
+ function toggleRefreshPause() {
293
+ _refreshPaused = !_refreshPaused;
294
+ localStorage.setItem('trackfw_refresh_paused', String(_refreshPaused));
295
+ if (_refreshPaused) {
296
+ clearTimeout(_refreshTimer);
297
+ } else {
298
+ startRefreshTimer();
299
+ }
300
+ updateRefreshUI();
301
+ }
302
+
303
+ function manualRefresh() {
304
+ _boardData = null;
305
+ loadBoard();
306
+ startRefreshTimer();
307
+ }
308
+
309
+ function updateRefreshUI() {
310
+ const btn = el('refresh-toggle');
311
+ if (!btn) return;
312
+ btn.textContent = _refreshPaused ? '▶ Retomar' : '⏸ Pausar';
313
+ }
314
+
315
+ function initRefreshUI() {
316
+ const sel = el('refresh-interval');
317
+ if (sel) sel.value = String(_refreshInterval);
318
+ updateRefreshUI();
319
+ }
320
+
321
+ // ─── Atenção de agente ────────────────────────────────────────────────────────
322
+
323
+ function startAttentionPolling() {
324
+ clearInterval(_attentionTimer);
325
+ _attentionTimer = setInterval(pollAttention, 8000);
326
+ pollAttention();
327
+ }
328
+
329
+ async function pollAttention() {
330
+ try {
331
+ const res = await fetch('/api/attention');
332
+ if (!res.ok) return;
333
+ const data = await res.json();
334
+ if (data.active && data.timestamp !== _attentionDismissed) {
335
+ showAttentionBanner(data);
336
+ } else if (!data.active) {
337
+ hideAttentionBanner();
338
+ }
339
+ } catch (_) { /* ignorar erros de rede */ }
340
+ }
341
+
342
+ function showAttentionBanner(data) {
343
+ const banner = el('attention-banner');
344
+ if (!banner) return;
345
+
346
+ const isAction = data.level !== 'info';
347
+ banner.className = `attention-banner ${isAction ? 'attention-banner-action' : 'attention-banner-info'}`;
348
+ banner.setAttribute('data-timestamp', data.timestamp || '');
349
+
350
+ const iconEl = el('attention-icon');
351
+ const labelEl = el('attention-label');
352
+ const ctxEl = el('attention-context');
353
+ const msgEl = el('attention-message');
354
+
355
+ if (iconEl) iconEl.textContent = isAction ? '⚠️' : 'ℹ️';
356
+ if (labelEl) labelEl.textContent = isAction ? 'Ação necessária' : 'Aviso';
357
+ if (ctxEl) ctxEl.textContent = [data.roadmap, data.ml].filter(Boolean).join(' · ');
358
+ if (msgEl) msgEl.textContent = data.message || '';
359
+
360
+ if (data.roadmap) markCardAttention(data.roadmap);
361
+ }
362
+
363
+ function hideAttentionBanner() {
364
+ const banner = el('attention-banner');
365
+ if (banner) banner.className = 'hidden attention-banner attention-banner-action';
366
+ clearCardAttention();
367
+ }
368
+
369
+ function dismissAttention() {
370
+ const banner = el('attention-banner');
371
+ if (!banner) return;
372
+ const ts = banner.getAttribute('data-timestamp');
373
+ if (ts) {
374
+ _attentionDismissed = ts;
375
+ localStorage.setItem('trackfw_attention_dismissed', ts);
376
+ }
377
+ hideAttentionBanner();
378
+ }
379
+
380
+ function markCardAttention(roadmapFile) {
381
+ document.querySelectorAll('.kanban-card[data-file]').forEach(c => {
382
+ if (c.getAttribute('data-file') !== roadmapFile) return;
383
+ c.classList.add('card-attention');
384
+ const live = c.querySelector('.live-dot');
385
+ if (live) {
386
+ live.className = 'attention-dot';
387
+ } else {
388
+ const dot = document.createElement('span');
389
+ dot.className = 'attention-dot';
390
+ dot.setAttribute('aria-hidden', 'true');
391
+ c.appendChild(dot);
392
+ }
393
+ });
394
+ }
395
+
396
+ function clearCardAttention() {
397
+ document.querySelectorAll('.card-attention').forEach(c => {
398
+ c.classList.remove('card-attention');
399
+ const dot = c.querySelector('.attention-dot');
400
+ if (dot) {
401
+ if (c.classList.contains('card-active')) {
402
+ dot.className = 'live-dot';
403
+ } else {
404
+ dot.remove();
405
+ }
406
+ }
407
+ });
408
+ }
409
+
225
410
  // ─── View: Chain (D3 force-directed graph) ────────────────────────────────────
226
411
 
227
412
  async function loadChain() {
@@ -682,6 +867,8 @@ document.addEventListener('keydown', e => {
682
867
  // ─── Init ─────────────────────────────────────────────────────────────────────
683
868
 
684
869
  document.addEventListener('DOMContentLoaded', () => {
685
- // Ativar tab Board e carregar dados iniciais
686
870
  switchView('board');
871
+ initRefreshUI();
872
+ startRefreshTimer();
873
+ startAttentionPolling();
687
874
  });
@@ -43,23 +43,62 @@
43
43
  </nav>
44
44
  </header>
45
45
 
46
+ <!-- Banner de atenção de agente (visível apenas quando /api/attention retornar active:true) -->
47
+ <div id="attention-banner" class="hidden attention-banner attention-banner-action" role="alert" aria-live="assertive" aria-atomic="true">
48
+ <span id="attention-icon" aria-hidden="true">⚠️</span>
49
+ <span class="font-semibold" id="attention-label"></span>
50
+ <span class="opacity-80 font-mono text-xs" id="attention-context"></span>
51
+ <span class="mx-1 opacity-60">—</span>
52
+ <span id="attention-message" class="flex-1"></span>
53
+ <button onclick="dismissAttention()" class="ml-auto text-sm font-medium underline opacity-80 hover:opacity-100 whitespace-nowrap" aria-label="Dispensar alerta">✕ Dispensar</button>
54
+ </div>
55
+
46
56
  <!-- Conteúdo principal -->
47
57
  <main class="flex-1 relative overflow-hidden flex">
48
58
 
49
59
  <!-- View: Board -->
50
60
  <section id="view-board" class="view-section w-full p-6 overflow-auto" aria-label="Board kanban">
51
61
 
52
- <!-- Filtro por agente -->
53
- <div id="agent-filter-container" class="mb-4 hidden">
54
- <label for="agent-filter" class="text-sm font-medium text-gray-600 mr-2">Filtrar por agente:</label>
55
- <select
56
- id="agent-filter"
57
- onchange="filterByAgent(this.value)"
58
- class="text-sm border border-gray-300 rounded px-2 py-1 bg-white"
59
- aria-label="Filtrar roadmaps por agente"
60
- >
61
- <option value="">Todos</option>
62
- </select>
62
+ <!-- Toolbar: filtro por agente + controles de auto-refresh -->
63
+ <div class="flex items-center gap-3 flex-wrap mb-4">
64
+ <div id="agent-filter-container" class="hidden">
65
+ <label for="agent-filter" class="text-sm font-medium text-gray-600 mr-2">Filtrar por agente:</label>
66
+ <select
67
+ id="agent-filter"
68
+ onchange="filterByAgent(this.value)"
69
+ class="text-sm border border-gray-300 rounded px-2 py-1 bg-white"
70
+ aria-label="Filtrar roadmaps por agente"
71
+ >
72
+ <option value="">Todos</option>
73
+ </select>
74
+ </div>
75
+ <!-- Auto-refresh controls -->
76
+ <div class="flex items-center gap-2 ml-auto">
77
+ <span class="text-xs text-gray-500">Auto-refresh:</span>
78
+ <select
79
+ id="refresh-interval"
80
+ onchange="setRefreshInterval(parseInt(this.value))"
81
+ class="text-xs border border-gray-300 rounded px-2 py-1 bg-white"
82
+ aria-label="Intervalo de atualização automática"
83
+ >
84
+ <option value="15">15s</option>
85
+ <option value="30">30s</option>
86
+ <option value="60">1 min</option>
87
+ <option value="120">2 min</option>
88
+ <option value="300">5 min</option>
89
+ </select>
90
+ <button
91
+ id="refresh-toggle"
92
+ onclick="toggleRefreshPause()"
93
+ class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded hover:bg-gray-200 transition-colors"
94
+ >⏸ Pausar</button>
95
+ <button
96
+ onclick="manualRefresh()"
97
+ class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded hover:bg-gray-200 transition-colors"
98
+ aria-label="Atualizar board agora"
99
+ title="Atualizar agora"
100
+ >↻</button>
101
+ </div>
63
102
  </div>
64
103
 
65
104
  <!-- Kanban columns -->
@@ -36,6 +36,7 @@
36
36
  /* Cards kanban */
37
37
  .kanban-card {
38
38
  cursor: pointer;
39
+ position: relative;
39
40
  transition: box-shadow 0.15s ease, transform 0.1s ease;
40
41
  }
41
42
 
@@ -60,6 +61,11 @@
60
61
  color: #374151;
61
62
  }
62
63
 
64
+ .badge-analyzing {
65
+ background-color: #fef3c7;
66
+ color: #92400e;
67
+ }
68
+
63
69
  .badge-blocked {
64
70
  background-color: #fee2e2;
65
71
  color: #991b1b;
@@ -151,3 +157,62 @@
151
157
  width: 100% !important;
152
158
  }
153
159
  }
160
+
161
+ /* ─── Live pulse — card ativo no WIP ─────────────────────────────────────── */
162
+ @keyframes livePulse {
163
+ 0%, 100% { opacity: 1; transform: scale(1); }
164
+ 50% { opacity: 0.3; transform: scale(0.65); }
165
+ }
166
+
167
+ .live-dot {
168
+ position: absolute;
169
+ top: 8px;
170
+ right: 8px;
171
+ width: 8px;
172
+ height: 8px;
173
+ border-radius: 50%;
174
+ background: #22c55e;
175
+ animation: livePulse 1.4s ease-in-out infinite;
176
+ }
177
+
178
+ .card-active {
179
+ border-left: 3px solid #22c55e !important;
180
+ background-color: #f0fdf4 !important;
181
+ }
182
+ .card-active:hover {
183
+ background-color: #dcfce7 !important;
184
+ }
185
+
186
+ /* ─── Atenção de agente ───────────────────────────────────────────────────── */
187
+ @keyframes attentionRing {
188
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.5); }
189
+ 50% { box-shadow: 0 0 0 8px rgba(245, 158, 11, 0); }
190
+ }
191
+
192
+ .attention-dot {
193
+ position: absolute;
194
+ top: 8px;
195
+ right: 8px;
196
+ width: 8px;
197
+ height: 8px;
198
+ border-radius: 50%;
199
+ background: #f59e0b;
200
+ animation: livePulse 0.9s ease-in-out infinite;
201
+ }
202
+
203
+ .card-attention {
204
+ border-left: 3px solid #f59e0b !important;
205
+ animation: attentionRing 2s ease-in-out infinite;
206
+ }
207
+
208
+ /* Banner de atenção (entre header e main) */
209
+ .attention-banner {
210
+ display: flex;
211
+ align-items: center;
212
+ gap: 12px;
213
+ padding: 10px 24px;
214
+ font-size: 0.875rem;
215
+ line-height: 1.4;
216
+ }
217
+ .attention-banner-action { background-color: #f59e0b; color: #fff; }
218
+ .attention-banner-info { background-color: #3b82f6; color: #fff; }
@@ -606,7 +606,7 @@ function validateRefTargetsExist() {
606
606
  try {
607
607
  const content = fs.readFileSync(path.join(dir, name), 'utf8')
608
608
  const ref = extractRefPath(content, 'REQ')
609
- if (ref && !fs.existsSync(ref)) {
609
+ if (ref && !referenceExists(ref, [cfg.reqDir])) {
610
610
  warnings.push(`roadmap "${name}" links to REQ "${ref}" which does not exist`)
611
611
  }
612
612
  } catch (_) {}
@@ -619,11 +619,11 @@ function validateRefTargetsExist() {
619
619
  const content = fs.readFileSync(filePath, 'utf8')
620
620
  const name = path.basename(filePath)
621
621
  const adrRef = extractRefPath(content, 'ADR')
622
- if (adrRef && !fs.existsSync(adrRef)) {
622
+ if (adrRef && !referenceExists(adrRef, cfg.adrDirs)) {
623
623
  warnings.push(`req "${name}" links to ADR "${adrRef}" which does not exist`)
624
624
  }
625
625
  const roadmapRef = extractRefPath(content, 'Roadmap')
626
- if (roadmapRef && !fs.existsSync(roadmapRef)) {
626
+ if (roadmapRef && !referenceExists(roadmapRef, [cfg.roadmapDir])) {
627
627
  warnings.push(`req "${name}" links to Roadmap "${roadmapRef}" which does not exist`)
628
628
  }
629
629
  } catch (_) {}
@@ -632,6 +632,15 @@ function validateRefTargetsExist() {
632
632
  return warnings
633
633
  }
634
634
 
635
+ function referenceExists(ref, roots) {
636
+ if (fs.existsSync(ref)) return true
637
+ const basename = path.basename(ref)
638
+ for (const root of roots || []) {
639
+ if (walkDirMd(root).some(filePath => path.basename(filePath) === basename)) return true
640
+ }
641
+ return false
642
+ }
643
+
635
644
  // FOLDER_TO_STATUS mapeia pasta de estado para os valores válidos de status no frontmatter
636
645
  const FOLDER_TO_STATUS = {
637
646
  wip: ['WIP', 'wip', 'In Progress'],
@@ -738,28 +747,49 @@ function validateFilenameUniqueness() {
738
747
  // validateBranchHasWIPRoadmap — verifica que branch feat/fix/refactor tem ao menos um roadmap em wip/
739
748
  function validateBranchHasWIPRoadmap() {
740
749
  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
750
+ let branch = process.env.TRACKFW_BRANCH || ''
751
+ if (!branch && isGitWorktree(process.cwd())) {
752
+ try {
753
+ branch = execSync('git symbolic-ref --short HEAD', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
754
+ } catch {
755
+ branch = ''
756
+ }
757
+ if (!branch) {
758
+ branch = process.env.GITHUB_HEAD_REF || process.env.CI_COMMIT_REF_NAME || process.env.GITHUB_REF_NAME || ''
759
+ }
746
760
  }
761
+ if (!branch) return []
747
762
  if (!branch.startsWith('feat/') && !branch.startsWith('fix/') && !branch.startsWith('refactor/')) {
748
763
  return []
749
764
  }
750
765
 
751
766
  const cfg = config.load()
752
767
  const wipDirs = resolveWIPDirs(cfg)
753
- let total = 0
768
+ const branchSlug = normalizeBranchSlug(branch.split('/', 2)[1])
769
+ const wipFiles = []
754
770
  for (const wipDir of wipDirs) {
755
771
  const files = listDir(wipDir).filter(f => f.endsWith('.md'))
756
- total += files.length
772
+ wipFiles.push(...files)
773
+ if (files.some(file => normalizeBranchSlug(file).includes(branchSlug))) return []
757
774
  }
758
775
 
759
- if (total === 0) {
776
+ if (wipFiles.length === 0) {
760
777
  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
778
  }
762
- return []
779
+ return [`branch "${branch}" has no matching roadmap in wip/ (found: ${wipFiles.join(', ')}) — include the branch slug in the roadmap filename or set TRACKFW_BRANCH explicitly in CI`]
780
+ }
781
+
782
+ function normalizeBranchSlug(value) {
783
+ return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
784
+ }
785
+
786
+ function isGitWorktree(dir) {
787
+ try {
788
+ const out = execSync('git rev-parse --is-inside-work-tree', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], cwd: dir })
789
+ return String(out).trim() === 'true'
790
+ } catch {
791
+ return false
792
+ }
763
793
  }
764
794
 
765
795
  // _itemMeta: mapa de message → { rule, file } para enriquecer saída JSON.