trackfw 6.3.0 → 6.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -144,7 +144,8 @@ trackfw ship -m "feat(auth): add login flow"
144
144
  | `trackfw adr new "title"` | Create a new Architecture Decision Record |
145
145
  | `trackfw adr list` | List all ADRs with status |
146
146
  | `trackfw req new "title"` | Create a REQ with guided ADR discovery |
147
- | `trackfw req list` | List all REQs with status |
147
+ | `trackfw req list` | List all REQs with status, discovered across flat, per-state, and by_agent layouts |
148
+ | `trackfw req move <name> <status>` | Update a REQ's status; physically relocates the file when it already lives in a recognized state subfolder (see [Multi-agent namespacing](#multi-agent-namespacing)) |
148
149
  | `trackfw roadmap new "title"` | Create a roadmap in `backlog/` |
149
150
  | `trackfw roadmap show <name>` | Print a roadmap with its current state |
150
151
  | `trackfw roadmap move <name> <state>` | Move roadmap between states |
@@ -210,6 +211,29 @@ agents: [claude, gemini, copilot]
210
211
 
211
212
  Artifacts are organized by agent: `docs/roadmaps/claude/wip/`, `docs/req/gemini/done/`. `trackfw validate` and `trackfw context` are fully by_agent-aware — no false positives.
212
213
 
214
+ REQs reuse this same `roadmap_namespacing` setting — there is no separate `req_namespacing` key. When
215
+ `by_agent` is configured, REQs may live under `req_dir/<agent>/<state>/` just like roadmaps do.
216
+
217
+ `trackfw req list` and `trackfw req move` discover REQs across three layouts at once (each is a fixed,
218
+ non-recursive glob — a REQ nested deeper than these patterns is not found), with no extra flag required:
219
+
220
+ - **Flat (legacy):** `req_dir/*.md` — REQs loose directly in the REQ directory.
221
+ - **Per-state:** `req_dir/<state>/*.md` — organized by state, without an agent segment.
222
+ - **By agent:** `req_dir/<agent>/<state>/*.md` — used when `roadmap_namespacing: by_agent` is set.
223
+
224
+ `trackfw req move <name> <status>` behaves conditionally depending on where the REQ currently lives:
225
+
226
+ - If the REQ already sits inside a recognized state subfolder (per-state or by_agent layout above), the
227
+ move **physically relocates the file** to the target state's folder, mirroring `trackfw roadmap move` —
228
+ the folder is the source of truth for state. In this mode `<status>` must be one of the six governance
229
+ states (`backlog`, `analyzing`, `wip`, `blocked`, `done`, `abandoned`) — any other value is rejected
230
+ with `invalid state`.
231
+ - If the REQ is loose in `req_dir/` (flat legacy layout), the move rewrites the `status:` frontmatter
232
+ field **in place** and does not move or create any folder. `<status>` is written verbatim in this
233
+ mode — it accepts the free-form values existing REQs already use (`Open`, `Done`, ...), not just the
234
+ six governance state names. Existing flat REQs are never migrated automatically — you are not forced
235
+ to reorganize a project's existing REQs to adopt this behavior.
236
+
213
237
  ### Bidirectional traceability (`trace_id_field`)
214
238
 
215
239
  ```yaml
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "6.3.0",
4
- "description": "Governance CLI for AI-native software delivery and AI coding agents. Enforces traceability: ADR → REQ → ROADMAP → kanban. Native support for Codex, Claude Code, Gemini CLI, Cursor, Copilot, Windsurf, and Amazon Q.",
3
+ "version": "6.4.0",
4
+ "description": "Governance CLI for AI-native software delivery and AI coding agents. Enforces traceability: ADR → REQ → ROADMAP → kanban. Native support for Codex, Claude Code, Gemini CLI, Cursor, Copilot, Windsurf, Amazon Q, OpenCode, and Kiro.",
5
5
  "keywords": [
6
6
  "cli",
7
7
  "adr",
@@ -0,0 +1,140 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * branch/runner.js — Core implementation of `trackfw branch new`.
5
+ *
6
+ * Moves the branch_has_wip_roadmap governance gate (already enforced by `trackfw validate` and
7
+ * `trackfw ship`) to before branch creation, instead of after. Mirrors internal/commands/branch.go
8
+ * byte-for-byte in behavior and message text — Go is the behavioral reference
9
+ * (docs/cli-parity.md: "Go is the behavioral reference").
10
+ *
11
+ * All git write operations are injectable for testability — never runs a real `git checkout -b`
12
+ * in unit tests.
13
+ */
14
+
15
+ const { spawnSync } = require('child_process')
16
+ const config = require('../config')
17
+ const validator = require('../validator')
18
+
19
+ // branchValidTypes mirrors the vocabulary already enforced by `trackfw ship` (feat|fix|refactor).
20
+ const branchValidTypes = new Set(['feat', 'fix', 'refactor'])
21
+
22
+ /**
23
+ * parseBranchSpec splits "<type>/<slug>" and validates both parts. type must be one of
24
+ * feat, fix, refactor (branchValidTypes); slug must be non-empty.
25
+ * @param {string} spec
26
+ * @returns {{ branchType: string, slug: string }}
27
+ * @throws {Error} when spec is malformed
28
+ */
29
+ function parseBranchSpec(spec) {
30
+ const value = String(spec)
31
+ const idx = value.indexOf('/')
32
+ if (idx === -1 || value.slice(0, idx) === '') {
33
+ throw new Error(`invalid branch spec "${spec}" — expected <type>/<slug> with type in feat, fix, refactor`)
34
+ }
35
+ const branchType = value.slice(0, idx)
36
+ const slug = value.slice(idx + 1)
37
+ if (!branchValidTypes.has(branchType)) {
38
+ throw new Error(`invalid branch type "${branchType}" — must be one of feat, fix, refactor`)
39
+ }
40
+ if (slug.trim() === '') {
41
+ throw new Error(`branch slug is required — expected <type>/<slug>, got "${spec}"`)
42
+ }
43
+ return { branchType, slug }
44
+ }
45
+
46
+ /**
47
+ * defaultGitCheckout runs `git checkout -b <branchName>` with inherited stdio, so Git's own output
48
+ * (including branch-already-exists errors) reaches the user unmodified.
49
+ *
50
+ * Returns Git's own numeric exit code (0 on success), never a generic 1 — this command's contract
51
+ * is to propagate Git's exit status literally (see internal/commands/branch.go's
52
+ * defaultGitCheckout, the behavioral reference: it exits the process directly with
53
+ * exitErr.ExitCode() rather than a hardcoded 1). A spawn failure (e.g. the git binary is missing)
54
+ * or a signal-terminated process has no Git-produced numeric code to propagate, so both fall back
55
+ * to 1 — the same convention already used for signal-killed gate commands
56
+ * (docs/cli-parity.md, `trackfw barrier`, "Gate process terminated by a signal ... Recorded as exit 1").
57
+ * @param {string} branchName
58
+ * @returns {number} exit code
59
+ */
60
+ function defaultGitCheckout(branchName) {
61
+ const result = spawnSync('git', ['checkout', '-b', branchName], { stdio: 'inherit' })
62
+ if (result.error) return 1
63
+ if (result.status === null) return 1
64
+ return result.status
65
+ }
66
+
67
+ /**
68
+ * runBranchNew implements the `trackfw branch new <type>/<slug>` flow described in
69
+ * docs/req/REQ-2026-08-04-comando-trackfw-branch-new-para-bloquear-criacao-de-branch-sem-req-roadmap-em-wip.md.
70
+ *
71
+ * Mirrors Go's stdout/stderr split exactly (internal/commands/branch.go + root.go Execute()):
72
+ * the primary governance/dry-run message goes to stdout via `writeln`; usage errors and the
73
+ * "blocked: ..." wrapper (Go's `Execute()` printing the returned error) go to stderr via
74
+ * `writeErr`. Git's own output is inherited directly by the child process and is never touched
75
+ * here.
76
+ *
77
+ * @param {string} spec "<type>/<slug>"
78
+ * @param {boolean} dryRun
79
+ * @param {{ loadConfig?: function, resolveWIPDirs?: function, resolveDoneDirs?: function,
80
+ * matchSlug?: function(string, string[], string[]): {matched: boolean, candidates: string[]},
81
+ * execGitCheckout?: function(string): number, writeln?: function(string): void,
82
+ * writeErr?: function(string): void }} deps
83
+ * @returns {number} exit code (0 = success, 1 = blocked/usage error, or Git's own exit code when
84
+ * `git checkout -b` fails — propagated literally, never hardcoded)
85
+ */
86
+ function runBranchNew(spec, dryRun, deps = {}) {
87
+ const loadConfig = deps.loadConfig || config.load
88
+ const resolveWIPDirs = deps.resolveWIPDirs || validator.resolveWIPDirs
89
+ const resolveDoneDirs = deps.resolveDoneDirs || validator.resolveDoneDirs
90
+ const matchSlug = deps.matchSlug || validator.branchSlugMatchesRoadmap
91
+ const execGitCheckout = deps.execGitCheckout || defaultGitCheckout
92
+ const writeln = deps.writeln || ((s) => process.stdout.write(s + '\n'))
93
+ const writeErr = deps.writeErr || ((s) => process.stderr.write(s + '\n'))
94
+
95
+ let branchType, slug
96
+ try {
97
+ ({ branchType, slug } = parseBranchSpec(spec))
98
+ } catch (err) {
99
+ writeErr(err.message)
100
+ return 1
101
+ }
102
+
103
+ const branchName = `${branchType}/${slug}`
104
+
105
+ const cfg = loadConfig()
106
+ const wipDirs = resolveWIPDirs(cfg)
107
+ const doneDirs = resolveDoneDirs(cfg)
108
+
109
+ const normalizedSlug = validator.normalizeBranchSlug(slug)
110
+ const { matched, candidates } = matchSlug(normalizedSlug, wipDirs, doneDirs)
111
+
112
+ if (!matched) {
113
+ const msg = (!candidates || candidates.length === 0)
114
+ ? validator.branchGovernanceOrientation(branchName)
115
+ : validator.branchNoMatchingRoadmapMessage(branchName, candidates)
116
+ if (dryRun) {
117
+ writeln(`[dry-run] would block: ${msg}`)
118
+ } else {
119
+ writeln(msg)
120
+ }
121
+ writeErr(`blocked: no matching roadmap in wip/ nor done/ for "${branchName}"`)
122
+ return 1
123
+ }
124
+
125
+ if (dryRun) {
126
+ writeln(`[dry-run] would create branch "${branchName}" (git checkout -b ${branchName})`)
127
+ return 0
128
+ }
129
+
130
+ // Git's own stderr (inherited stdio) already reached the user unmodified — do not reformat,
131
+ // duplicate, or replace this exit code with a generic 1.
132
+ return execGitCheckout(branchName)
133
+ }
134
+
135
+ module.exports = {
136
+ branchValidTypes,
137
+ parseBranchSpec,
138
+ defaultGitCheckout,
139
+ runBranchNew,
140
+ }
@@ -0,0 +1,46 @@
1
+ 'use strict'
2
+
3
+ // trackfw branch new <type>/<slug>
4
+ //
5
+ // CLI wiring for the `trackfw branch` command group. The testable logic lives in
6
+ // ../branch/runner.js (mirrors the ship.js / ship/runner.js split already used in this CLI).
7
+
8
+ const { Command } = require('commander')
9
+ const { runBranchNew } = require('../branch/runner')
10
+
11
+ function createBranchCommand() {
12
+ const cmd = new Command('branch')
13
+ cmd.description('Manage governed feature branches')
14
+
15
+ const newCmd = new Command('new')
16
+ newCmd
17
+ .description(
18
+ 'Create a feat/fix/refactor branch, gated on a matching REQ + roadmap already in wip/ or done/\n\n' +
19
+ 'trackfw branch new moves the branch_has_wip_roadmap governance gate (already enforced\n' +
20
+ "by 'trackfw validate' and 'trackfw ship') to before branch creation, instead of after:\n\n" +
21
+ ' 1. Validates <type> is one of feat, fix, refactor and <slug> is non-empty.\n' +
22
+ ' 2. Checks whether a roadmap in wip/ or done/ matches the given slug — the exact matching\n' +
23
+ " logic 'trackfw validate' already uses (normalized slug, filename contains match).\n" +
24
+ " 3. Without a match: blocks — 'git checkout -b' is never executed — and prints the same\n" +
25
+ " governance orientation message 'trackfw validate' already prints for this rule.\n" +
26
+ " 4. With a match: runs 'git checkout -b <type>/<slug>', propagating Git's own output and\n" +
27
+ ' exit status literally.\n\n' +
28
+ 'Create the governance artifacts first if this blocks you:\n' +
29
+ ' trackfw req new "title"\n' +
30
+ ' trackfw roadmap new "title"\n' +
31
+ ' trackfw roadmap move <name> wip'
32
+ )
33
+ .argument('<spec>', '<type>/<slug>, type in feat, fix, refactor')
34
+ .option('--dry-run', 'Report whether the branch would be created or blocked, without executing git', false)
35
+ .action((spec, options) => {
36
+ const exitCode = runBranchNew(spec, !!options.dryRun, {})
37
+ if (exitCode !== 0) {
38
+ process.exit(exitCode)
39
+ }
40
+ })
41
+
42
+ cmd.addCommand(newCmd)
43
+ return cmd
44
+ }
45
+
46
+ module.exports = createBranchCommand()
@@ -435,6 +435,12 @@ cmd.action((opts) => {
435
435
  } catch (e) {
436
436
  console.log(`⚠ agent rules inject partial: ${e.message}`);
437
437
  }
438
+ try {
439
+ const { generateAttentionScripts } = require('../generators/hooks');
440
+ generateAttentionScripts({}, cwd);
441
+ } catch (e) {
442
+ console.warn(`⚠ attention scripts: ${e.message}`);
443
+ }
438
444
  try {
439
445
  const { injectHooksDetected } = require('../generators/hooks');
440
446
  injectHooksDetected(cwd);
@@ -32,6 +32,7 @@ function createProgram() {
32
32
  program.addCommand(require('./note'))
33
33
  program.addCommand(require('./ship'))
34
34
  program.addCommand(require('./barrier'))
35
+ program.addCommand(require('./branch'))
35
36
 
36
37
  const { createServeCommand } = require('./serve')
37
38
  program.addCommand(createServeCommand())
@@ -8,7 +8,7 @@ const { resolveScope } = require('./integrations')
8
8
 
9
9
  const cmd = new Command('init')
10
10
  cmd.description(t('init.description'))
11
- cmd.option('--ai-tools <tools>', 'Comma-separated AI tools to configure (claude,codex,gemini,antigravity,cursor,copilot,windsurf,amazonq,kiro)', '')
11
+ cmd.option('--ai-tools <tools>', 'Comma-separated AI tools to configure (claude,codex,gemini,antigravity,cursor,copilot,windsurf,amazonq,opencode,kiro)', '')
12
12
  cmd.option('--identity-preset <preset>', `Agent identity preset: none, neutral, ${identityStore.presetNames().join(', ')}`, 'none')
13
13
  cmd.option('--forge <value>', 'Forge platform: github, gitlab, bitbucket, azure', '')
14
14
  cmd.action(async (options, command) => {
@@ -58,7 +58,7 @@ cmd.action(async (options, command) => {
58
58
  }
59
59
  await generators.scaffold(cfg)
60
60
  const aiTools = String(options.aiTools || '').split(',').map(tool => tool.trim()).filter(Boolean)
61
- const supported = new Set(['claude', 'codex', 'gemini', 'antigravity', 'cursor', 'copilot', 'windsurf', 'amazonq', 'kiro'])
61
+ const supported = new Set(['claude', 'codex', 'gemini', 'antigravity', 'cursor', 'copilot', 'windsurf', 'amazonq', 'opencode', 'kiro'])
62
62
  // Sem TTY, o escopo nunca é perguntado: default `global` (ADR D1/D4).
63
63
  const scope = await resolveScope({}, { interactive: false })
64
64
  const makeOnSkip = () => (_destination, reason) => {
@@ -222,6 +222,7 @@ cmd.action(async (options, command) => {
222
222
  { name: 'GitHub Copilot', value: 'copilot' },
223
223
  { name: 'Windsurf', value: 'windsurf' },
224
224
  { name: 'Amazon Q Developer', value: 'amazonq' },
225
+ { name: 'OpenCode', value: 'opencode' },
225
226
  { name: 'Kiro', value: 'kiro' },
226
227
  ],
227
228
  })
@@ -64,13 +64,18 @@ cmd.command('new <title>')
64
64
  cmd.command('list')
65
65
  .description(t('req.list.description'))
66
66
  .action(async () => {
67
- listREQs(require('../config').load().reqDir)
67
+ listREQs(require('../config').load())
68
68
  })
69
69
 
70
70
  cmd.command('move <name> <status>')
71
71
  .description('Update a REQ status in place')
72
72
  .action(async (name, status) => {
73
- moveREQ(name, status)
73
+ try {
74
+ moveREQ(name, status)
75
+ } catch (err) {
76
+ console.error(`Error: ${err.message}`)
77
+ process.exitCode = 1
78
+ }
74
79
  })
75
80
 
76
81
  module.exports = cmd
@@ -82,8 +82,9 @@ case "$ROADMAP_DIR" in
82
82
  esac
83
83
 
84
84
  TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
85
- TOOL_ESC=$(echo "$TOOL" | tr -d '\\000-\\037' | sed -e 's/\\\\/\\\\\\\\/g' -e 's/"/\\\\"/g')
86
- MSG_ESC=$(echo "$MSG" | tr -d '\\000-\\037' | sed -e 's/\\\\/\\\\\\\\/g' -e 's/"/\\\\"/g')
85
+
86
+ TOOL_ESC=$(echo "$TOOL" | tr -d '\\000-\\037' | sed 's/\\\\/\\\\\\\\/g; s/"/\\\\"/g')
87
+ MSG_ESC=$(echo "$MSG" | tr -d '\\000-\\037' | sed 's/\\\\/\\\\\\\\/g; s/"/\\\\"/g')
87
88
 
88
89
  mkdir -p "$ROADMAP_DIR"
89
90
  printf '{"tool":"%s","message":"%s","level":"action_required","timestamp":"%s"}\\n' \\
@@ -2,27 +2,86 @@
2
2
  const fs = require('fs')
3
3
  const path = require('path')
4
4
  const { localDateISO } = require('./date')
5
+ const roadmapGen = require('./roadmap')
6
+ const config = require('../config')
7
+
8
+ const VALID_STATES = roadmapGen.VALID_STATES
9
+ const STATE_ORDER = roadmapGen.STATE_ORDER
5
10
 
6
11
  /**
7
- * listREQs — lista arquivos .md em dir, imprimindo filename e status (coluna 60 chars).
8
- * Extrai status da linha `> Date: ... | Status: ...`.
9
- * Se dir não existe ou vazio: imprime "No REQs found in <dir>".
12
+ * listREQFiles — descoberta recursiva de REQs nos 3 layouts suportados, mesmo algoritmo usado
13
+ * em Go/Python (ADR-2026-08-04): flat (reqDir/*.md), por-estado (reqDir/<estado>/*.md) e,
14
+ * quando roadmapNamespacing === 'by_agent', by_agent (reqDir/<agente>/<estado>/*.md).
15
+ * Os três conjuntos não são mutuamente exclusivos — concatena todos.
16
+ * @param {object} cfg — config completo (ver npm/src/config)
17
+ * @returns {string[]} paths completos, na ordem flat → por-estado → by_agent
10
18
  */
11
- function listREQs(dir) {
12
- let files = []
19
+ function listREQFiles(cfg) {
20
+ const reqDir = cfg.reqDir
21
+ const files = []
22
+
23
+ // (a) flat legado — reqDir/*.md
13
24
  try {
14
- files = fs.readdirSync(dir).filter(f => f.endsWith('.md'))
15
- } catch (_) {
16
- // dir não existe
25
+ for (const f of fs.readdirSync(reqDir)) {
26
+ if (!f.endsWith('.md')) continue
27
+ const full = path.join(reqDir, f)
28
+ try {
29
+ if (!fs.statSync(full).isDirectory()) files.push(full)
30
+ } catch (_) { /* ignora */ }
31
+ }
32
+ } catch (_) { /* reqDir não existe */ }
33
+
34
+ // (b) por-estado, sem agente — reqDir/<estado>/*.md
35
+ for (const state of STATE_ORDER) {
36
+ const dir = path.join(reqDir, state)
37
+ try {
38
+ for (const f of fs.readdirSync(dir)) {
39
+ if (f.endsWith('.md')) files.push(path.join(dir, f))
40
+ }
41
+ } catch (_) { /* estado não existe */ }
42
+ }
43
+
44
+ // (c) by_agent — reqDir/<agente>/<estado>/*.md
45
+ if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
46
+ let agents = cfg.agents || []
47
+ if (agents.length === 0) {
48
+ try {
49
+ agents = fs.readdirSync(reqDir).filter(f => {
50
+ try { return fs.statSync(path.join(reqDir, f)).isDirectory() } catch (_) { return false }
51
+ })
52
+ } catch (_) { agents = [] }
53
+ }
54
+ for (const agent of agents) {
55
+ for (const state of STATE_ORDER) {
56
+ const dir = path.join(reqDir, agent, state)
57
+ try {
58
+ for (const f of fs.readdirSync(dir)) {
59
+ if (f.endsWith('.md')) files.push(path.join(dir, f))
60
+ }
61
+ } catch (_) { /* agente/estado não existe */ }
62
+ }
63
+ }
17
64
  }
18
65
 
66
+ return files
67
+ }
68
+
69
+ /**
70
+ * listREQs — lista REQs (recursivo nos 3 layouts), imprimindo filename e status (coluna 60 chars).
71
+ * Extrai status da linha `> Date: ... | Status: ...`.
72
+ * Se nenhum REQ encontrado: imprime "No REQs found in <reqDir>".
73
+ * @param {object} cfg — config completo (ver npm/src/config)
74
+ */
75
+ function listREQs(cfg) {
76
+ const files = listREQFiles(cfg)
77
+
19
78
  if (files.length === 0) {
20
- console.log(`No REQs found in ${dir}`)
79
+ console.log(`No REQs found in ${cfg.reqDir}`)
21
80
  return
22
81
  }
23
82
 
24
- for (const filename of files) {
25
- const filepath = path.join(dir, filename)
83
+ for (const filepath of files) {
84
+ const filename = path.basename(filepath)
26
85
  const status = parseREQStatus(filepath)
27
86
  console.log(`${filename.padEnd(60)} ${status}`)
28
87
  }
@@ -105,30 +164,105 @@ function rewriteREQStatus(source, status) {
105
164
  return { content: `---\n${lines.join('\n')}${rest}`, changed: true }
106
165
  }
107
166
 
108
- function findREQ(name, reqDir) {
109
- let files = []
110
- try {
111
- files = fs.readdirSync(reqDir).filter(f => f.endsWith('.md'))
112
- } catch (e) {
113
- throw new Error(`reading REQ dir: ${e.message}`)
114
- }
167
+ /**
168
+ * findREQ — busca recursiva nos 3 layouts (flat → por-estado → by_agent), retornando o primeiro
169
+ * path cujo basename contém `name` (case-insensitive).
170
+ * @param {string} name
171
+ * @param {object} cfg — config completo (ver npm/src/config)
172
+ * @returns {string} path completo
173
+ */
174
+ function findREQ(name, cfg) {
175
+ const files = listREQFiles(cfg)
115
176
  const lower = name.toLowerCase()
116
- const found = files.find(f => f.toLowerCase().includes(lower))
117
- if (!found) throw new Error(`REQ "${name}" not found in ${reqDir}`)
118
- return path.join(reqDir, found)
177
+ const found = files.find(f => path.basename(f).toLowerCase().includes(lower))
178
+ if (!found) throw new Error(`REQ "${name}" not found in ${cfg.reqDir}`)
179
+ return found
119
180
  }
120
181
 
182
+ /**
183
+ * appendREQTransitionLog — append em <reqDir>/.trackfw-log, mesmo formato de
184
+ * appendTransitionLog (roadmap.js), em arquivo de log separado (escopo de REQ, não roadmap).
185
+ */
186
+ function appendREQTransitionLog(cfg, basename, fromState, toState) {
187
+ const now = new Date()
188
+ const yyyy = now.getFullYear()
189
+ const mm = String(now.getMonth() + 1).padStart(2, '0')
190
+ const dd = String(now.getDate()).padStart(2, '0')
191
+ const hh = String(now.getHours()).padStart(2, '0')
192
+ const min = String(now.getMinutes()).padStart(2, '0')
193
+ const timestamp = `${yyyy}-${mm}-${dd} ${hh}:${min}`
194
+ const line = `${timestamp} ${basename.padEnd(50)} ${fromState} → ${toState}\n`
195
+
196
+ try {
197
+ const lp = path.join(cfg.reqDir, '.trackfw-log')
198
+ fs.mkdirSync(path.dirname(lp), { recursive: true })
199
+ fs.appendFileSync(lp, line, 'utf8')
200
+ } catch (_) { /* best-effort, mesmo padrão do roadmap */ }
201
+ }
202
+
203
+ /**
204
+ * moveREQ — reescreve status: e, condicionalmente, move fisicamente o arquivo
205
+ * (ADR-2026-08-04, decisão D3):
206
+ * - REQ solta em `reqDir/` → modo in-place (comportamento legado: só reescreve status, sem mover).
207
+ * - REQ já organizada em `reqDir/<estado>/` ou `reqDir/<agente>/<estado>/` → move fisicamente
208
+ * para o novo estado, preservando o layout (por-estado ou by_agent), e loga a transição.
209
+ */
121
210
  function moveREQ(name, status) {
122
211
  if (!String(status || '').trim()) throw new Error('status is required')
123
- const reqDir = require('../config').load().reqDir
124
- const filepath = findREQ(name, reqDir)
212
+ const cfg = require('../config').load()
213
+ const filepath = findREQ(name, cfg)
125
214
  const source = fs.readFileSync(filepath, 'utf8')
126
215
  const result = rewriteREQStatus(source, status)
127
216
  if (!result.changed) {
128
217
  throw new Error(`REQ "${path.basename(filepath)}" has no frontmatter status/header Status to update`)
129
218
  }
130
- fs.writeFileSync(filepath, result.content, 'utf8')
131
- console.log(`✓ updated ${path.basename(filepath)} status → ${status}`)
219
+
220
+ const basename = path.basename(filepath)
221
+ const parentDir = path.dirname(filepath)
222
+ const grandparentDir = path.dirname(parentDir)
223
+ const greatGrandparentDir = path.dirname(grandparentDir)
224
+ const reqDirAbs = path.resolve(cfg.reqDir)
225
+ const fromState = path.basename(parentDir)
226
+
227
+ if (path.resolve(parentDir) === reqDirAbs) {
228
+ // modo in-place — REQ solta em reqDir/, comportamento legado (sem mover)
229
+ fs.writeFileSync(filepath, result.content, 'utf8')
230
+ console.log(`✓ updated ${basename} status → ${status}`)
231
+ return
232
+ }
233
+
234
+ if (!VALID_STATES.includes(status)) {
235
+ throw new Error(`invalid state "${status}" — valid states: ${VALID_STATES.join(', ')}`)
236
+ }
237
+
238
+ let targetDir = null
239
+ let logBasename = basename
240
+
241
+ if (path.resolve(grandparentDir) === reqDirAbs && VALID_STATES.includes(fromState)) {
242
+ // layout por-estado — reqDir/<estado>/
243
+ targetDir = path.join(cfg.reqDir, status)
244
+ } else if (VALID_STATES.includes(fromState) && path.resolve(greatGrandparentDir) === reqDirAbs) {
245
+ // layout by_agent — reqDir/<agente>/<estado>/
246
+ const agent = path.basename(grandparentDir)
247
+ targetDir = path.join(cfg.reqDir, agent, status)
248
+ logBasename = `${agent}/${basename}`
249
+ }
250
+
251
+ if (!targetDir) {
252
+ // layout não reconhecido — fallback seguro para in-place (não inventa destino)
253
+ fs.writeFileSync(filepath, result.content, 'utf8')
254
+ console.log(`✓ updated ${basename} status → ${status}`)
255
+ return
256
+ }
257
+
258
+ fs.mkdirSync(targetDir, { recursive: true })
259
+ const dst = path.join(targetDir, basename)
260
+ fs.writeFileSync(dst, result.content, 'utf8')
261
+ if (path.resolve(dst) !== path.resolve(filepath)) {
262
+ fs.unlinkSync(filepath)
263
+ }
264
+ appendREQTransitionLog(cfg, logBasename, fromState, status)
265
+ console.log(`✓ moved ${basename} → ${targetDir}`)
132
266
  }
133
267
 
134
268
  /**
@@ -328,4 +462,4 @@ function detectDomains(intention) {
328
462
  )
329
463
  }
330
464
 
331
- module.exports = { listREQs, parseREQStatus, rewriteREQStatus, moveREQ, newREQ, PROBES_CATALOG, detectDomains, localDateISO, toSlug }
465
+ module.exports = { listREQs, listREQFiles, findREQ, parseREQStatus, rewriteREQStatus, moveREQ, newREQ, PROBES_CATALOG, detectDomains, localDateISO, toSlug }
@@ -35,6 +35,9 @@ Analyze real dependencies between microbatches before assigning work. Microbatch
35
35
  ## Workflow
36
36
  Analyze the codebase and requirements; record material decisions in an ADR; create the REQ with an explicit negative scope; produce a roadmap of waves and microbatches with measurable acceptance criteria; create the branch; commit the governance artifacts before any handoff; dispatch the wave; audit each microbatch against its acceptance criteria; update the roadmap; open the pull request only on request.
37
37
 
38
+ ## Dispatch contract
39
+ Naming a specialist in prose or in a roadmap's `squad:` field is documentation, not delegation — it does not route the Agent tool call by itself. Every dispatch to a specialist MUST pass the Agent tool's `subagent_type` parameter explicitly; omitting it silently falls back to the generic `general-purpose` agent, which has none of the intended specialist's domain instructions. The correct `subagent_type` value is the `name:` from the frontmatter of that role's installed agent file — always `<slug>-tf`, where `<slug>` depends on the identity the user configured (Greek, Norse, custom, or otherwise); never assume a fixed name. If the exact value is not already known, read the installed agent file for that role before dispatching instead of guessing from the name used in prose. Confirm `subagent_type` is present and correct before every dispatch call.
40
+
38
41
  ## Post-microbatch audit
39
42
  Before releasing the next wave, verify each acceptance criterion yourself: read the changed files, confirm the build, tests and gates, and check that no forbidden file was touched. Green gates are not proof that the intended behavior was delivered — validate the real artifact, not only the test fixtures. A failed audit blocks the next wave.
40
43
 
@@ -42,6 +42,7 @@
42
42
  {"id":"copilot","name":"GitHub Copilot","surfaces":[{"id":"ide","name":"GitHub Copilot","scopes":["global","project"],"capabilities":{"agents":{"support_level":"native","representation":"custom-agent"},"skills":{"support_level":"native","representation":"agent-skill"}},"paths":{"agents":[{"scope":"global","path":"~/.copilot/agents/trackfw-{{id}}.agent.md","extension":".agent.md"},{"scope":"project","path":".github/agents/trackfw-{{id}}.agent.md","extension":".agent.md"}],"skills":[{"scope":"global","path":"~/.copilot/skills/trackfw-{{id}}/SKILL.md","extension":".md"},{"scope":"project","path":".github/skills/trackfw-{{id}}/SKILL.md","extension":".md"}]}}],"auxiliary_artifacts":[{"id":"instructions","surface":"ide","scope":"project","path":".github/copilot-instructions.md","purpose":"repository instructions","required":false},{"id":"hooks","surface":"ide","scope":"project","path":".github/hooks/trackfw-attention.json","purpose":"attention hooks","required":false}]},
43
43
  {"id":"windsurf","name":"Windsurf","surfaces":[{"id":"ide","name":"Windsurf IDE","scopes":["global","project"],"capabilities":{"agents":{"support_level":"fallback","representation":"skill","fallback_representation":"specialist skill"},"skills":{"support_level":"native","representation":"skill"}},"paths":{"agents":[{"scope":"global","path":"~/.codeium/windsurf/skills/trackfw-agent-{{id}}/SKILL.md","extension":".md"},{"scope":"project","path":".windsurf/skills/trackfw-agent-{{id}}/SKILL.md","extension":".md"}],"skills":[{"scope":"global","path":"~/.codeium/windsurf/skills/trackfw-{{id}}/SKILL.md","extension":".md"},{"scope":"project","path":".windsurf/skills/trackfw-{{id}}/SKILL.md","extension":".md"}]}}],"auxiliary_artifacts":[{"id":"global-rules","surface":"ide","scope":"project","path":".windsurfrules","purpose":"governance rule discovery","required":false}]},
44
44
  {"id":"amazonq","name":"Amazon Q Developer","surfaces":[{"id":"cli","name":"Amazon Q Developer CLI","scopes":["global","project"],"capabilities":{"agents":{"support_level":"native","representation":"cli-agent-json"},"skills":{"support_level":"fallback","representation":"rule","fallback_representation":"workflow rule"}},"paths":{"agents":[{"scope":"global","path":"~/.aws/amazonq/cli-agents/trackfw-{{id}}.json","extension":".json"},{"scope":"project","path":".amazonq/cli-agents/trackfw-{{id}}.json","extension":".json"}],"skills":[{"scope":"global","path":"~/.aws/amazonq/rules/trackfw-{{id}}.md","extension":".md"},{"scope":"project","path":".amazonq/rules/trackfw-{{id}}.md","extension":".md"}]}}],"auxiliary_artifacts":[{"id":"guidelines","surface":"cli","scope":"project","path":".amazonq/developer/guidelines.md","purpose":"project governance guidelines","required":false}]},
45
+ {"id":"opencode","name":"OpenCode","surfaces":[{"id":"cli","name":"OpenCode CLI","scopes":["global","project"],"capabilities":{"agents":{"support_level":"native","representation":"opencode-agent"},"skills":{"support_level":"native","representation":"skill"}},"paths":{"agents":[{"scope":"global","path":"~/.config/opencode/agents/trackfw-{{id}}.md","extension":".md"},{"scope":"project","path":".opencode/agents/trackfw-{{id}}.md","extension":".md"}],"skills":[{"scope":"global","path":"~/.config/opencode/skills/trackfw-{{id}}/SKILL.md","extension":".md"},{"scope":"project","path":".opencode/skills/trackfw-{{id}}/SKILL.md","extension":".md"}]}}],"auxiliary_artifacts":[{"id":"instructions","surface":"cli","scope":"project","path":"AGENTS.md","purpose":"repository instructions (shared with Codex/AGENTS.md convention)","required":false}]},
45
46
  {"id":"kiro","name":"Kiro","surfaces":[{"id":"ide","name":"Kiro IDE","scopes":["global","project"],"capabilities":{"agents":{"support_level":"native","representation":"agent-markdown"},"skills":{"support_level":"native","representation":"agent-skill"}},"paths":{"agents":[{"scope":"global","path":"~/.kiro/agents/trackfw-{{id}}.md","extension":".md"},{"scope":"project","path":".kiro/agents/trackfw-{{id}}.md","extension":".md"}],"skills":[{"scope":"global","path":"~/.kiro/skills/trackfw-{{id}}/SKILL.md","extension":".md"},{"scope":"project","path":".kiro/skills/trackfw-{{id}}/SKILL.md","extension":".md"}]}},{"id":"cli","name":"Kiro CLI","scopes":["global","project"],"capabilities":{"agents":{"support_level":"native","representation":"agent-json"},"skills":{"support_level":"native","representation":"agent-skill"}},"paths":{"agents":[{"scope":"global","path":"~/.kiro/agents/trackfw-{{id}}.json","extension":".json"},{"scope":"project","path":".kiro/agents/trackfw-{{id}}.json","extension":".json"}],"skills":[{"scope":"global","path":"~/.kiro/skills/trackfw-{{id}}/SKILL.md","extension":".md"},{"scope":"project","path":".kiro/skills/trackfw-{{id}}/SKILL.md","extension":".md"}]}}],"auxiliary_artifacts":[{"id":"hooks","surface":"ide","scope":"project","path":".kiro/hooks/trackfw-attention.json","purpose":"attention hooks","required":false}]}
46
47
  ]
47
48
  }
@@ -278,6 +278,38 @@ function render({ kind, content, capability, item, identity: cfg }) {
278
278
  return out
279
279
  }
280
280
 
281
+ if (capability.representation === 'opencode-agent') {
282
+ // Reconstrói o frontmatter para o OpenCode CLI (opencode.ai), seguindo o
283
+ // mesmo padrão de reconstrução-do-zero do case "agent-directory".
284
+ // Decisão registrada na Wave 1 do roadmap
285
+ // ROADMAP-2026-08-04-compatibilidade-com-opencode-opencode-ai (achado #3,
286
+ // pesquisa contra o binário real 1.18.13):
287
+ // - "tools:" é uma chave RESERVADA no schema de agente do OpenCode
288
+ // (espera um objeto de overrides por-ferramenta, ex. {bash: false},
289
+ // não uma lista de nomes estilo Claude Code) — reutilizar o
290
+ // frontmatter original faz o OpenCode recusar o carregamento INTEIRO
291
+ // do projeto ("Configuration is invalid"), não só daquele agente. Por
292
+ // isso "tools:" nunca é emitido aqui.
293
+ // - sem "mode:" explícito, o OpenCode assume mode "all" (agente
294
+ // selecionável como persona primária de chat) — os agentes trackfw
295
+ // devem ser sempre subagentes puros, nunca primários, para paridade
296
+ // com o comportamento nos demais targets. Por isso "mode: subagent" é
297
+ // sempre fixo, nunca omitido.
298
+ // - "model:" é deliberadamente OMITIDO (decisão de produto do
299
+ // orquestrador, não uma limitação técnica): o OpenCode espera
300
+ // "provider/model-id" (ex. "anthropic/claude-sonnet-4-5"), não os
301
+ // aliases curtos do catálogo canônico ("opus"/"sonnet"), e mapear
302
+ // para um provider fixo contradiria a motivação de negócio do REQ
303
+ // (permitir que o usuário roteie os agentes trackfw para o modelo
304
+ // open-source/local que ele já configurou em opencode.json). Omitir
305
+ // deixa o OpenCode resolver pelo default já configurado pelo usuário.
306
+ // - "memory:" também não faz sentido no schema do OpenCode e é
307
+ // descartado junto com "tools:".
308
+ let out = `---\ndescription: ${description}\nmode: subagent\n---\n`
309
+ if (body) out += `${body}\n`
310
+ return out
311
+ }
312
+
281
313
  if (!id) return normalize(content)
282
314
  const withBody = insertBodyPrefix(content, greeting)
283
315
  const withFrontmatter = rewriteFrontmatterFields(withBody, name, description)
@@ -1042,6 +1042,45 @@ function validateFilenameUniqueness() {
1042
1042
  return violations
1043
1043
  }
1044
1044
 
1045
+ // branchSlugMatchesRoadmap verifica se branchSlug (já normalizado via normalizeBranchSlug) casa com o
1046
+ // nome de algum roadmap .md encontrado em wipDirs ou doneDirs. Reutilizada por
1047
+ // validateBranchHasWIPRoadmap e pelo comando `trackfw branch new` — nunca duplicar esta lógica.
1048
+ //
1049
+ // Espelha internal/validator/validator.go:BranchSlugMatchesRoadmap. Retorna { matched, candidates }:
1050
+ // matched indica se algum candidato casou com o slug; candidates lista todos os roadmaps .md
1051
+ // encontrados em wipDirs+doneDirs (para diagnóstico/mensagem de orientação quando matched é false).
1052
+ function branchSlugMatchesRoadmap(branchSlug, wipDirs, doneDirs) {
1053
+ const dirs = [...wipDirs, ...doneDirs]
1054
+ const candidates = []
1055
+ let matched = false
1056
+ for (const dir of dirs) {
1057
+ const files = listDir(dir).filter(f => f.endsWith('.md'))
1058
+ candidates.push(...files)
1059
+ if (files.some(file => normalizeBranchSlug(file).includes(branchSlug))) matched = true
1060
+ }
1061
+ return { matched, candidates }
1062
+ }
1063
+
1064
+ // branchGovernanceOrientation is the guidance message printed when a feat/fix/refactor branch has
1065
+ // no roadmap in wip/ nor done/ at all (candidates is empty). Shared by validateBranchHasWIPRoadmap
1066
+ // and `trackfw branch new` — never duplicate this string. Byte-identical to Go's
1067
+ // BranchGovernanceOrientation.
1068
+ function branchGovernanceOrientation(branch) {
1069
+ return `branch "${branch}" is a feat/fix/refactor branch but no roadmap is in wip/ nor done/ — create governance artifacts first:\n trackfw req new "title"\n trackfw roadmap new "title"\n trackfw roadmap move <name> wip`
1070
+ }
1071
+
1072
+ // branchNoMatchingRoadmapMessage is the guidance message printed when roadmaps exist in wip/ or
1073
+ // done/ but none of them match the branch's slug. Shared by validateBranchHasWIPRoadmap and
1074
+ // `trackfw branch new` — never duplicate this string. Byte-identical to Go's
1075
+ // BranchNoMatchingRoadmapMessage. Does not mutate candidates.
1076
+ function branchNoMatchingRoadmapMessage(branch, candidates) {
1077
+ // P3: sort for deterministic output regardless of filesystem ordering.
1078
+ const sorted = [...candidates].sort()
1079
+ const display = sorted.slice(0, 3)
1080
+ const suffix = sorted.length > 3 ? `, e mais ${sorted.length - 3}` : ''
1081
+ return `branch "${branch}" has no matching roadmap in wip/ nor done/ (found: ${display.join(', ')}${suffix}) — include the branch slug in the roadmap filename or set TRACKFW_BRANCH explicitly in CI`
1082
+ }
1083
+
1045
1084
  // validateBranchHasWIPRoadmap — verifica que branch feat/fix/refactor tem ao menos um roadmap em
1046
1085
  // wip/ ou done/ cujo slug case com a branch. Aceita done/ para permitir encerramento do roadmap na
1047
1086
  // própria branch, conforme a Definition of Done, sem reprovar o gate.
@@ -1067,22 +1106,13 @@ function validateBranchHasWIPRoadmap() {
1067
1106
  const wipDirs = resolveWIPDirs(cfg)
1068
1107
  const doneDirs = resolveDoneDirs(cfg)
1069
1108
  const branchSlug = normalizeBranchSlug(branch.split('/', 2)[1])
1070
- // candidates reúne todos os roadmaps encontrados em wip/ e done/.
1071
- const candidates = []
1072
- for (const dir of [...wipDirs, ...doneDirs]) {
1073
- const files = listDir(dir).filter(f => f.endsWith('.md'))
1074
- candidates.push(...files)
1075
- if (files.some(file => normalizeBranchSlug(file).includes(branchSlug))) return []
1076
- }
1109
+ const { matched, candidates } = branchSlugMatchesRoadmap(branchSlug, wipDirs, doneDirs)
1110
+ if (matched) return []
1077
1111
 
1078
1112
  if (candidates.length === 0) {
1079
- return [`branch "${branch}" is a feat/fix/refactor branch but no roadmap is in wip/ nor done/ — create governance artifacts first:\n trackfw req new "title"\n trackfw roadmap new "title"\n trackfw roadmap move <name> wip`]
1113
+ return [branchGovernanceOrientation(branch)]
1080
1114
  }
1081
- // P3: sort for deterministic output regardless of filesystem ordering.
1082
- const sorted = [...candidates].sort()
1083
- const display = sorted.slice(0, 3)
1084
- const suffix = sorted.length > 3 ? `, e mais ${sorted.length - 3}` : ''
1085
- return [`branch "${branch}" has no matching roadmap in wip/ nor done/ (found: ${display.join(', ')}${suffix}) — include the branch slug in the roadmap filename or set TRACKFW_BRANCH explicitly in CI`]
1115
+ return [branchNoMatchingRoadmapMessage(branch, candidates)]
1086
1116
  }
1087
1117
 
1088
1118
  function normalizeBranchSlug(value) {
@@ -1451,6 +1481,11 @@ module.exports = {
1451
1481
  validateFolderStatusCoherence,
1452
1482
  validateFilenameUniqueness,
1453
1483
  validateBranchHasWIPRoadmap,
1484
+ // novas funções — trackfw branch new (extraídas do gate branch_has_wip_roadmap)
1485
+ branchSlugMatchesRoadmap,
1486
+ branchGovernanceOrientation,
1487
+ branchNoMatchingRoadmapMessage,
1488
+ normalizeBranchSlug,
1454
1489
  // novas funções ML-2B
1455
1490
  contentHasMarker,
1456
1491
  ruleSeverity,