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 +25 -4
- package/package.json +17 -2
- package/src/commands/index.js +1 -0
- package/src/commands/init.js +17 -1
- package/src/commands/plugins.js +16 -2
- package/src/commands/serve.js +6 -0
- package/src/commands/update.js +7 -0
- package/src/commands/version.js +10 -0
- package/src/generators/codex.js +111 -0
- package/src/generators/hooks.js +48 -21
- package/src/generators/init.js +12 -12
- package/src/serve/api_attention.js +24 -0
- package/src/serve/static/app.js +193 -6
- package/src/serve/static/index.html +50 -11
- package/src/serve/static/style.css +65 -0
- package/src/validator/index.js +42 -12
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
|
[](https://github.com/kgsaran/trackfw/releases/latest)
|
|
6
|
-
[](go.mod)
|
|
7
7
|
[](https://www.npmjs.com/package/trackfw)
|
|
8
8
|
[](https://pypi.org/project/trackfw/)
|
|
9
9
|
[](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
|
|
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
|
|
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.
|
|
4
|
-
"description": "CLI
|
|
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"
|
package/src/commands/index.js
CHANGED
|
@@ -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())
|
package/src/commands/init.js
CHANGED
|
@@ -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.
|
|
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
|
package/src/commands/plugins.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/src/commands/serve.js
CHANGED
|
@@ -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
|
})
|
package/src/commands/update.js
CHANGED
|
@@ -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,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 }
|
package/src/generators/hooks.js
CHANGED
|
@@ -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 —
|
|
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 // ("
|
|
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'
|
|
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 '
|
|
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.
|
|
146
|
-
if (!data.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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 (!
|
|
152
|
-
data.PostToolUse.push({
|
|
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.
|
|
169
|
-
if (!
|
|
170
|
-
|
|
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 (!
|
|
175
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
{
|
|
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
|
}
|
package/src/generators/init.js
CHANGED
|
@@ -104,7 +104,7 @@ trackfw validate
|
|
|
104
104
|
break
|
|
105
105
|
}
|
|
106
106
|
case 'python':
|
|
107
|
-
base += "echo \"→ build check (python)...\"\
|
|
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
|
-
"
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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 }
|
package/src/serve/static/app.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
<!--
|
|
53
|
-
<div
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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; }
|
package/src/validator/index.js
CHANGED
|
@@ -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 && !
|
|
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 && !
|
|
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 && !
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
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
|
-
|
|
772
|
+
wipFiles.push(...files)
|
|
773
|
+
if (files.some(file => normalizeBranchSlug(file).includes(branchSlug))) return []
|
|
757
774
|
}
|
|
758
775
|
|
|
759
|
-
if (
|
|
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.
|