trackfw 6.10.0 → 7.1.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.
Files changed (38) hide show
  1. package/README.md +0 -3
  2. package/bin/trackfw +38 -1
  3. package/package.json +1 -1
  4. package/src/branch/prune.js +450 -0
  5. package/src/branch/runner.js +33 -20
  6. package/src/changelog/index.js +114 -0
  7. package/src/commands/branch.js +59 -9
  8. package/src/commands/changelog.js +29 -0
  9. package/src/commands/commit.js +60 -0
  10. package/src/commands/discover.js +75 -0
  11. package/src/commands/doctor.js +32 -0
  12. package/src/commands/index.js +23 -4
  13. package/src/commands/integrations.js +23 -1
  14. package/src/commands/serve.js +52 -4
  15. package/src/commands/ship.js +4 -3
  16. package/src/commands/thirdparty.js +384 -0
  17. package/src/commands/update-harness.js +320 -12
  18. package/src/commands/update.js +3 -1
  19. package/src/commit/runner.js +352 -0
  20. package/src/config/index.js +27 -0
  21. package/src/generators/hooks.js +925 -6
  22. package/src/generators/init.js +28 -6
  23. package/src/i18n/locales/en-US.json +0 -21
  24. package/src/i18n/locales/es-ES.json +0 -21
  25. package/src/i18n/locales/pt-BR.json +0 -21
  26. package/src/integrations/doctor.js +0 -0
  27. package/src/integrations/index.js +19 -2
  28. package/src/integrations/manager.js +133 -19
  29. package/src/lib/fatal-error.js +64 -0
  30. package/src/lib/unknown-command.js +82 -0
  31. package/src/ship/runner.js +259 -95
  32. package/src/thirdparty/fetch.js +140 -0
  33. package/src/thirdparty/markers.js +207 -0
  34. package/src/thirdparty/provenance.js +114 -0
  35. package/src/thirdparty/quarantine.js +113 -0
  36. package/src/thirdparty/references.js +194 -0
  37. package/src/validator/index.js +1060 -27
  38. package/src/commands/plugins.js +0 -184
package/README.md CHANGED
@@ -157,9 +157,6 @@ trackfw ship -m "feat(auth): add login flow"
157
157
  | `trackfw serve` | Start a local governance dashboard (no cloud, no accounts) |
158
158
  | `trackfw status` | Show wip, blocked, REQs waiting on ADRs |
159
159
  | `trackfw log [--tail N]` | Show roadmap state transition history |
160
- | `trackfw plugins list` | List installed plugins |
161
- | `trackfw plugins add <user/repo>` | Install a plugin from GitHub Releases |
162
- | `trackfw plugins remove <name>` | Remove an installed plugin |
163
160
  | `trackfw agents list` | List available agents and deployment state across AI CLIs |
164
161
  | `trackfw agents install` | Install selected specialist agents |
165
162
  | `trackfw agents update` | Safely update managed agents |
package/bin/trackfw CHANGED
@@ -1,4 +1,41 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict'
3
3
 
4
- require('../src/commands/index').createProgram().parseAsync(process.argv)
4
+ const { reportFatalError, installGlobalHandlers } = require('../src/lib/fatal-error')
5
+
6
+ // Registered before any command runs — see fatal-error.js for why this is a
7
+ // single entrypoint-level handler instead of a try/catch per command.
8
+ installGlobalHandlers()
9
+
10
+ const program = require('../src/commands/index').createProgram()
11
+
12
+ // trackfw sem argumento é uso legítimo (pedir ajuda), não erro — decisão do
13
+ // arquiteto no ML-1C (ROADMAP-2026-08-16-higiene-...). Sem intervenção,
14
+ // commander trata "nenhum operando" como "provavelmente faltou um
15
+ // subcomando" (Command._parseCommand: `this.commands.length && this.args
16
+ // .length === 0 && !this._actionHandler` → `this.help({error: true})`) —
17
+ // help em stderr, exit 1. Interceptar SÓ o caso de zero argumentos aqui,
18
+ // antes de entrar no parser, reusa o MESMO texto de ajuda (outputHelp) na
19
+ // saída canônica (stdout, exit 0) sem tocar no parsing normal — em
20
+ // particular sem registrar uma .action() no root, que reclassificaria um
21
+ // comando desconhecido (ex.: "trackfw naoexiste") como argumento posicional
22
+ // da action em vez de disparar o listener 'command:*' (regressão descartada
23
+ // em favor desta abordagem: ver ROADMAP ML-1C).
24
+ //
25
+ // Fica ANTES do parseAsync e DEPOIS de installGlobalHandlers(): o caminho de
26
+ // zero argumentos é sucesso e nunca deve passar pelo reportFatalError abaixo.
27
+ if (process.argv.length <= 2) {
28
+ program.outputHelp()
29
+ process.exit(0)
30
+ }
31
+
32
+ program
33
+ .parseAsync(process.argv)
34
+ .catch((err) => {
35
+ // The primary path: an action (sync or async) threw, and since the
36
+ // program is driven by parseAsync() that surfaces here as a rejected
37
+ // promise — not as an uncaughtException/unhandledRejection, which is
38
+ // why this .catch exists in addition to installGlobalHandlers() above.
39
+ reportFatalError(err)
40
+ process.exitCode = 1
41
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "6.10.0",
3
+ "version": "7.1.0",
4
4
  "description": "Governance CLI for AI-native software delivery and AI coding agents. Enforces traceability: ADR → REQ → ROADMAP → kanban. Native support for Codex, Claude Code, Gemini CLI, Cursor, Copilot, Windsurf, Amazon Q, OpenCode, and Kiro.",
5
5
  "keywords": [
6
6
  "cli",
@@ -0,0 +1,450 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * branch/prune.js — Core implementation of `trackfw branch prune`.
5
+ *
6
+ * Mirrors internal/commands/branch_prune.go byte-for-byte in behavior and message text — Go is
7
+ * the behavioral reference (docs/cli-parity.md: "Go is the behavioral reference").
8
+ *
9
+ * Decides whether a local branch is safe to delete using the touched-files heuristic documented
10
+ * in CLAUDE.md §1 and REQ-2026-08-18-trackfw-branch-prune-apaga-branch-local-ja-integrada-com-
11
+ * deteccao-correta-de-squash-merge.md — NOT git's own ancestry check (`git branch -d`, which
12
+ * always refuses squash-merged branches) and NOT a naive bidirectional diff against origin/main
13
+ * (which false-positives on a branch that is merged but stale, once main has advanced further).
14
+ *
15
+ * evaluateBranchIntegration is the single shared decision function — ML-2A
16
+ * (npm/src/ship/runner.js's detectPendingSquashMerges) is expected to call it instead of its own
17
+ * bidirectional diff.
18
+ */
19
+
20
+ const { spawnSync } = require('child_process')
21
+
22
+ // The only source of truth this command consults: the local tracking ref for the default branch.
23
+ // Per REQ-2026-08-18 decision 2, there is no forge lookup and no network call — offline and
24
+ // deterministic by construction. If this ref cannot be resolved (no remote configured, or never
25
+ // fetched), the whole command refuses and deletes nothing.
26
+ const DEFAULT_REMOTE_REF = 'origin/main'
27
+
28
+ // The local branch name matching DEFAULT_REMOTE_REF. Always excluded as a prune candidate —
29
+ // evaluating it against itself would report "no own work" and offer to delete the branch the user
30
+ // is meant to keep (merge-base origin/main main == main's own tip, so "touched" is trivially
31
+ // empty). This is the highest-severity bug the naive heuristic contains.
32
+ const DEFAULT_LOCAL_NAME = 'main'
33
+
34
+ const DECISION = {
35
+ DEFAULT_BRANCH: 'default_branch',
36
+ CURRENT_BRANCH: 'current_branch',
37
+ WORKTREE: 'worktree_branch',
38
+ NO_OWN_WORK: 'no_own_work',
39
+ IDENTICAL: 'content_identical',
40
+ PENDING_WORK: 'pending_work',
41
+ NO_MERGE_BASE: 'no_merge_base',
42
+ EVAL_ERROR: 'eval_error',
43
+ // REVIEW_DOC_CONFIG is a NON-deletable category, distinct from PENDING_WORK: every diverging
44
+ // file is doc/config (isDocOrConfigPath), so it is probably housekeeping residue from a
45
+ // squash-merge rather than genuine pending work — but it is never auto-deleted. CLAUDE.md §1's
46
+ // own procedure treats this case as "housekeeping, apagar" but with a human already in the
47
+ // loop reading the diff; a destructive command has no human in the loop by construction, so
48
+ // REQ-2026-08-18 (ML-1B) deliberately narrows that step to "flag for confirmation" instead of
49
+ // "delete automatically". See isDeletable below.
50
+ REVIEW_DOC_CONFIG: 'review_doc_config',
51
+ }
52
+
53
+ // DOC_CONFIG_EXTENSIONS/DOC_CONFIG_BASENAMES mirror internal/commands/branch_prune.go's
54
+ // branchPruneDocConfigExtensions/branchPruneDocConfigBasenames — deliberately conservative and
55
+ // best-effort. Misclassifying a file here never causes a deletion: it only changes which
56
+ // non-deletable category (REVIEW_DOC_CONFIG vs PENDING_WORK) a kept branch is reported under.
57
+ const DOC_CONFIG_EXTENSIONS = ['.yaml', '.yml', '.json', '.toml', '.ini', '.cfg']
58
+ const DOC_CONFIG_BASENAMES = new Set(['.gitignore', '.gitattributes', '.editorconfig', 'trackfw.yaml', 'LICENSE'])
59
+
60
+ /**
61
+ * isDocsFile reports whether path lives under docs/ or vault/, or has a .md extension. Mirrors
62
+ * internal/commands/commit.go's isDocsFile (same package there; standalone here).
63
+ * @param {string} path
64
+ * @returns {boolean}
65
+ */
66
+ function isDocsFile(path) {
67
+ if (path.startsWith('docs/') || path.startsWith('vault/')) return true
68
+ return path.endsWith('.md')
69
+ }
70
+
71
+ /**
72
+ * isDocOrConfigPath reports whether path is a doc file (isDocsFile) or a well-known non-runtime
73
+ * config file/extension. Used only to route an otherwise PENDING_WORK branch into the
74
+ * REVIEW_DOC_CONFIG category — never to make anything deletable.
75
+ * @param {string} path
76
+ * @returns {boolean}
77
+ */
78
+ function isDocOrConfigPath(path) {
79
+ if (isDocsFile(path)) return true
80
+ const base = path.includes('/') ? path.slice(path.lastIndexOf('/') + 1) : path
81
+ if (DOC_CONFIG_BASENAMES.has(base)) return true
82
+ return DOC_CONFIG_EXTENSIONS.some((ext) => path.endsWith(ext))
83
+ }
84
+
85
+ /**
86
+ * allDocOrConfig reports whether every path in paths is doc/config (isDocOrConfigPath). Empty
87
+ * input returns false.
88
+ * @param {string[]} paths
89
+ * @returns {boolean}
90
+ */
91
+ function allDocOrConfig(paths) {
92
+ if (paths.length === 0) return false
93
+ return paths.every(isDocOrConfigPath)
94
+ }
95
+
96
+ /**
97
+ * isDeletable reports whether decision, on its own, makes a branch a deletion candidate. Both
98
+ * no_own_work (squash-merge with no ancestry — the `git branch -d` false negative) and
99
+ * content_identical (defasada porém integrada — the naive `git diff` false positive) are safe to
100
+ * delete; every other decision keeps the branch.
101
+ * @param {string} decision
102
+ * @returns {boolean}
103
+ */
104
+ function isDeletable(decision) {
105
+ return decision === DECISION.NO_OWN_WORK || decision === DECISION.IDENTICAL
106
+ }
107
+
108
+ /**
109
+ * defaultExecGit runs `git <args...>` and returns { stdout, error }. stdout is trimmed the same
110
+ * way internal/commands/ship.go's defaultGitExec trims via strings.TrimSpace — NUL bytes (used as
111
+ * the -z separator below) are not whitespace and are never stripped.
112
+ * @param {string[]} args
113
+ * @returns {{ stdout: string, error: Error|null }}
114
+ */
115
+ function defaultExecGit(args) {
116
+ const result = spawnSync('git', args, { encoding: 'utf8' })
117
+ if (result.status !== 0) {
118
+ const msg = (result.stderr || '').trim() || `git ${args.join(' ')} exited with ${result.status}`
119
+ return { stdout: '', error: new Error(msg) }
120
+ }
121
+ return { stdout: (result.stdout || '').trim(), error: null }
122
+ }
123
+
124
+ /**
125
+ * splitNulPaths splits a NUL-separated `git diff --name-only -z` output into a sorted, non-empty
126
+ * path list. A trailing NUL (git always emits one after the last entry) produces one empty
127
+ * trailing element, which is dropped.
128
+ * @param {string} raw
129
+ * @returns {string[]}
130
+ */
131
+ function splitNulPaths(raw) {
132
+ return raw.split('\x00').filter((p) => p !== '').sort()
133
+ }
134
+
135
+ /**
136
+ * evaluateBranchIntegration decides whether branch is safe to delete relative to
137
+ * DEFAULT_REMOTE_REF, using the touched-files heuristic:
138
+ *
139
+ * mb = git merge-base origin/main <branch>
140
+ * touched = git diff --name-only mb <branch> (what the branch touched)
141
+ * diverg = git diff --name-only origin/main <branch> -- touched (what still differs there)
142
+ *
143
+ * touched empty -> no_own_work (deletable) -- the squash-merge / -d false negative
144
+ * diverg empty -> content_identical (deletable) -- the naive-diff false positive (stale
145
+ * branch, main advanced by other PRs)
146
+ * diverg non-empty -> pending_work (kept, explained)
147
+ *
148
+ * Both diff calls use -z (NUL-separated, unquoted paths) so filenames with spaces or non-ASCII
149
+ * bytes are never mis-split — the exact class of bug that would make a branch with pending work in
150
+ * "foo bar.md" read as an empty diverg and get deleted.
151
+ *
152
+ * @param {string} branch
153
+ * @param {function(string[]): {stdout: string, error: Error|null}} execGit
154
+ * @returns {{ name: string, decision: string, reason: string, touched: string[], diverged: string[] }}
155
+ */
156
+ function evaluateBranchIntegration(branch, execGit) {
157
+ const mbResult = execGit(['merge-base', DEFAULT_REMOTE_REF, branch])
158
+ const mb = (mbResult.stdout || '').trim()
159
+ if (mbResult.error || mb === '') {
160
+ return {
161
+ name: branch,
162
+ decision: DECISION.NO_MERGE_BASE,
163
+ reason: `no merge-base with ${DEFAULT_REMOTE_REF} — refusing (unrelated history or bad ref)`,
164
+ touched: [],
165
+ diverged: [],
166
+ }
167
+ }
168
+
169
+ const touchedResult = execGit(['diff', '--name-only', '-z', mb, branch])
170
+ if (touchedResult.error) {
171
+ return {
172
+ name: branch,
173
+ decision: DECISION.EVAL_ERROR,
174
+ reason: `git diff --name-only -z ${mb} ${branch} failed: ${touchedResult.error.message}`,
175
+ touched: [],
176
+ diverged: [],
177
+ }
178
+ }
179
+ const touched = splitNulPaths(touchedResult.stdout)
180
+
181
+ if (touched.length === 0) {
182
+ return {
183
+ name: branch,
184
+ decision: DECISION.NO_OWN_WORK,
185
+ reason: `no own work relative to ${DEFAULT_REMOTE_REF} — safe to delete`,
186
+ touched: [],
187
+ diverged: [],
188
+ }
189
+ }
190
+
191
+ const divergResult = execGit(['diff', '--name-only', '-z', DEFAULT_REMOTE_REF, branch, '--', ...touched])
192
+ if (divergResult.error) {
193
+ return {
194
+ name: branch,
195
+ decision: DECISION.EVAL_ERROR,
196
+ reason: `git diff --name-only -z ${DEFAULT_REMOTE_REF} ${branch} -- <touched> failed: ${divergResult.error.message}`,
197
+ touched,
198
+ diverged: [],
199
+ }
200
+ }
201
+ const diverg = splitNulPaths(divergResult.stdout)
202
+
203
+ if (diverg.length === 0) {
204
+ return {
205
+ name: branch,
206
+ decision: DECISION.IDENTICAL,
207
+ reason: `squash-merged into ${DEFAULT_REMOTE_REF} — content identical in touched files, safe to delete`,
208
+ touched,
209
+ diverged: [],
210
+ }
211
+ }
212
+
213
+ // review_doc_config requires diverg to be a PROPER subset of touched (diverg.length <
214
+ // touched.length) — not just "all doc/config". diverg is always a subset of touched by
215
+ // construction (the second git diff is scoped `-- touched`), so this is the same as: at
216
+ // least one touched file made it into main. That distinguishes genuine housekeeping residue
217
+ // from a squash-merge from a branch whose ENTIRE touched set — doc/config or not — never
218
+ // reached main (diverg == touched): that is pending work, regardless of file type.
219
+ if (diverg.length < touched.length && allDocOrConfig(diverg)) {
220
+ return {
221
+ name: branch,
222
+ decision: DECISION.REVIEW_DOC_CONFIG,
223
+ reason: `only doc/config files diverge from ${DEFAULT_REMOTE_REF} (${diverg.join(', ')}) — probable housekeeping, confirm and delete manually`,
224
+ touched,
225
+ diverged: diverg,
226
+ }
227
+ }
228
+
229
+ return {
230
+ name: branch,
231
+ decision: DECISION.PENDING_WORK,
232
+ reason: `pending work vs ${DEFAULT_REMOTE_REF}: ${diverg.join(', ')}`,
233
+ touched,
234
+ diverged: diverg,
235
+ }
236
+ }
237
+
238
+ /**
239
+ * defaultListLocalBranches runs `git branch --format=%(refname:short)` and returns one name per
240
+ * non-empty line.
241
+ * @param {function(string[]): {stdout: string, error: Error|null}} execGit
242
+ * @returns {{ branches: string[], error: Error|null }}
243
+ */
244
+ function defaultListLocalBranches(execGit) {
245
+ const result = execGit(['branch', '--format=%(refname:short)'])
246
+ if (result.error) return { branches: [], error: result.error }
247
+ const branches = (result.stdout || '')
248
+ .split('\n')
249
+ .map((l) => l.trim())
250
+ .filter((l) => l !== '')
251
+ return { branches, error: null }
252
+ }
253
+
254
+ /**
255
+ * defaultCurrentBranch returns the checked-out branch's short name, or '' on detached HEAD.
256
+ * @param {function(string[]): {stdout: string, error: Error|null}} execGit
257
+ * @returns {string}
258
+ */
259
+ function defaultCurrentBranch(execGit) {
260
+ const result = execGit(['symbolic-ref', '--quiet', '--short', 'HEAD'])
261
+ if (result.error) return ''
262
+ return (result.stdout || '').trim()
263
+ }
264
+
265
+ /**
266
+ * defaultWorktreeBranches parses `git worktree list --porcelain` and returns the set of branch
267
+ * short names checked out in any worktree. Uses the porcelain "branch refs/heads/<name>" line, not
268
+ * the human-readable format.
269
+ * @param {function(string[]): {stdout: string, error: Error|null}} execGit
270
+ * @returns {Set<string>}
271
+ */
272
+ function defaultWorktreeBranches(execGit) {
273
+ const result = execGit(['worktree', 'list', '--porcelain'])
274
+ const set = new Set()
275
+ if (result.error) return set
276
+ const prefix = 'branch refs/heads/'
277
+ for (const rawLine of (result.stdout || '').split('\n')) {
278
+ const line = rawLine.trim()
279
+ if (line.startsWith(prefix)) {
280
+ set.add(line.slice(prefix.length))
281
+ }
282
+ }
283
+ return set
284
+ }
285
+
286
+ /**
287
+ * defaultDeleteBranch tries `git branch -d <name>` first. When the branch happens to have
288
+ * fast-forward ancestry with main too (a plain merge, not a squash), -d succeeds and confirms the
289
+ * integration via git's own independent check — no need for -D at all. Falls back to
290
+ * `git branch -D <name>` only when -d refuses, the expected outcome for squash-merged branches
291
+ * (no ancestry by construction): all safety already lives in evaluateBranchIntegration and the
292
+ * re-check immediately before this call, not in which flag performs the deletion.
293
+ * @param {function(string[]): {stdout: string, error: Error|null}} execGit
294
+ * @param {string} name
295
+ * @returns {Error|null}
296
+ */
297
+ function defaultDeleteBranch(execGit, name) {
298
+ const dResult = execGit(['branch', '-d', name])
299
+ if (!dResult.error) return null
300
+ return execGit(['branch', '-D', name]).error
301
+ }
302
+
303
+ /**
304
+ * runBranchPrune implements `trackfw branch prune`.
305
+ *
306
+ * --dry-run is the default (`apply === false`): without --apply, nothing is ever deleted, even
307
+ * the clearly integrated. The current branch, any branch checked out in another worktree, and the
308
+ * default branch (main) are always kept and never evaluated for deletion. Without origin/main
309
+ * resolvable (offline, no remote, never fetched), the whole command refuses and deletes nothing.
310
+ *
311
+ * @param {boolean} apply
312
+ * @param {{ execGit?: function, listLocalBranches?: function, currentBranch?: function,
313
+ * worktreeBranches?: function, deleteBranch?: function, writeln?: function,
314
+ * writeErr?: function }} deps
315
+ * @returns {number} exit code (0 = ran to completion, 1 = origin/main unresolvable)
316
+ */
317
+ function runBranchPrune(apply, deps = {}) {
318
+ const execGit = deps.execGit || defaultExecGit
319
+ const listLocalBranches = deps.listLocalBranches || defaultListLocalBranches
320
+ const currentBranchFn = deps.currentBranch || defaultCurrentBranch
321
+ const worktreeBranchesFn = deps.worktreeBranches || defaultWorktreeBranches
322
+ const deleteBranch = deps.deleteBranch || defaultDeleteBranch
323
+ const writeln = deps.writeln || ((s) => process.stdout.write(s + '\n'))
324
+ const writeErr = deps.writeErr || ((s) => process.stderr.write(s + '\n'))
325
+
326
+ // Best-effort `git fetch origin --prune`, per CLAUDE.md §1 step 1. Failure is non-blocking —
327
+ // offline is a legitimate use case, the same posture `trackfw ship`'s squash-merge check
328
+ // already takes (ship/runner.js) — but unlike ship (which skips its check entirely on fetch
329
+ // failure), evaluation below still proceeds against whatever origin/main ref is already
330
+ // resolvable locally: a stale ref only ever makes the result MORE conservative, never less.
331
+ const fetchResult = execGit(['fetch', 'origin', '--prune'])
332
+ if (fetchResult.error) {
333
+ writeln(
334
+ 'Warning: could not fetch origin (offline, no remote, or fetch failed) — evaluating with possibly stale data; a branch merged upstream since the last fetch may still be reported as pending.'
335
+ )
336
+ }
337
+
338
+ const originCheck = execGit(['rev-parse', '--verify', '-q', DEFAULT_REMOTE_REF])
339
+ if (originCheck.error) {
340
+ writeln(
341
+ `trackfw branch prune: ${DEFAULT_REMOTE_REF} not found — offline, no remote configured, or never fetched. Refusing to evaluate any branch; nothing deleted.`
342
+ )
343
+ // Mirrors internal/commands/branch_prune.go: the human-readable message goes to stdout
344
+ // above, and the bare error (matching Go's cmd.SilenceErrors, no "Error: " prefix,
345
+ // propagated once by Execute()) goes to stderr — same split branch new already uses.
346
+ writeErr(`branch prune: ${DEFAULT_REMOTE_REF} not resolvable`)
347
+ return 1
348
+ }
349
+
350
+ const { branches: rawBranches, error: listErr } = listLocalBranches(execGit)
351
+ if (listErr) {
352
+ writeln(`trackfw branch prune: failed to list local branches: ${listErr.message}`)
353
+ // Mirrors Go: returns the raw error, propagated bare by Execute() — not a re-wrapped string.
354
+ writeErr(listErr.message)
355
+ return 1
356
+ }
357
+ const branches = [...rawBranches].sort()
358
+
359
+ const current = currentBranchFn(execGit)
360
+ const worktreed = worktreeBranchesFn(execGit)
361
+
362
+ writeln(`trackfw branch prune — evaluating ${branches.length} local branch(es) against ${DEFAULT_REMOTE_REF}\n`)
363
+
364
+ const toDelete = []
365
+ const toReview = []
366
+ for (const b of branches) {
367
+ let evalResult
368
+ if (b === DEFAULT_LOCAL_NAME) {
369
+ evalResult = { name: b, decision: DECISION.DEFAULT_BRANCH, reason: 'default branch — never pruned' }
370
+ } else if (b === current) {
371
+ evalResult = { name: b, decision: DECISION.CURRENT_BRANCH, reason: 'current branch — never pruned' }
372
+ } else if (worktreed.has(b)) {
373
+ evalResult = { name: b, decision: DECISION.WORKTREE, reason: 'checked out in another worktree — never pruned' }
374
+ } else {
375
+ evalResult = evaluateBranchIntegration(b, execGit)
376
+ }
377
+
378
+ let action = 'keep'
379
+ if (isDeletable(evalResult.decision)) {
380
+ action = 'delete'
381
+ toDelete.push(b)
382
+ } else if (evalResult.decision === DECISION.REVIEW_DOC_CONFIG) {
383
+ action = 'review'
384
+ toReview.push(b)
385
+ }
386
+ writeln(` ${evalResult.name.padEnd(30)} ${action.padEnd(7)} ${evalResult.reason}`)
387
+ }
388
+
389
+ writeln('')
390
+ if (toReview.length > 0) {
391
+ writeln(`${toReview.length} branch(es) need manual review (only doc/config diverges, never auto-deleted): ${toReview.join(', ')}`)
392
+ }
393
+ if (!apply) {
394
+ if (toDelete.length === 0) {
395
+ writeln('[dry-run] nothing to delete.')
396
+ } else {
397
+ writeln(`[dry-run] would delete ${toDelete.length} branch(es): ${toDelete.join(', ')}. Rerun with --apply to delete.`)
398
+ }
399
+ return 0
400
+ }
401
+
402
+ if (toDelete.length === 0) {
403
+ writeln('nothing to delete.')
404
+ return 0
405
+ }
406
+
407
+ const deleted = []
408
+ for (const b of toDelete) {
409
+ // Re-check current/worktree status immediately before each delete — belt-and-suspenders
410
+ // against the branch changing state between the report above and this loop.
411
+ if (b === currentBranchFn(execGit)) {
412
+ writeln(`skip ${b}: became the current branch — refusing to delete`)
413
+ continue
414
+ }
415
+ if (worktreeBranchesFn(execGit).has(b)) {
416
+ writeln(`skip ${b}: became checked out in a worktree — refusing to delete`)
417
+ continue
418
+ }
419
+ const delErr = deleteBranch(execGit, b)
420
+ if (delErr) {
421
+ writeln(`failed to delete ${b}: ${delErr.message}`)
422
+ continue
423
+ }
424
+ deleted.push(b)
425
+ }
426
+
427
+ if (deleted.length === 0) {
428
+ writeln('deleted 0 branch(es).')
429
+ } else {
430
+ writeln(`deleted ${deleted.length} branch(es): ${deleted.join(', ')}`)
431
+ }
432
+ return 0
433
+ }
434
+
435
+ module.exports = {
436
+ DEFAULT_REMOTE_REF,
437
+ DEFAULT_LOCAL_NAME,
438
+ DECISION,
439
+ isDeletable,
440
+ isDocOrConfigPath,
441
+ allDocOrConfig,
442
+ splitNulPaths,
443
+ evaluateBranchIntegration,
444
+ defaultExecGit,
445
+ defaultListLocalBranches,
446
+ defaultCurrentBranch,
447
+ defaultWorktreeBranches,
448
+ defaultDeleteBranch,
449
+ runBranchPrune,
450
+ }
@@ -16,12 +16,20 @@ const { spawnSync } = require('child_process')
16
16
  const config = require('../config')
17
17
  const validator = require('../validator')
18
18
 
19
- // branchValidTypes mirrors the vocabulary already enforced by `trackfw ship` (feat|fix|refactor).
20
- const branchValidTypes = new Set(['feat', 'fix', 'refactor'])
19
+ // branchValidTypes is the full vocabulary accepted by `trackfw branch new`. feat/fix/refactor are
20
+ // gated on a matching REQ + roadmap already in wip/ or done/ (branchGatedTypes below); chore/docs
21
+ // are housekeeping types — already treated as roadmap-exempt by `trackfw ship` and `trackfw
22
+ // commit` — and create the branch without that gate.
23
+ const branchValidTypes = new Set(['feat', 'fix', 'refactor', 'chore', 'docs'])
24
+
25
+ // branchGatedTypes is the subset of branchValidTypes that requires a matching REQ + roadmap
26
+ // already in wip/ or done/ before the branch is created. Keep this in sync with the pattern
27
+ // `trackfw ship`/`trackfw commit` use to decide when the branch_has_wip_roadmap gate applies.
28
+ const branchGatedTypes = new Set(['feat', 'fix', 'refactor'])
21
29
 
22
30
  /**
23
31
  * parseBranchSpec splits "<type>/<slug>" and validates both parts. type must be one of
24
- * feat, fix, refactor (branchValidTypes); slug must be non-empty.
32
+ * feat, fix, refactor, chore, docs (branchValidTypes); slug must be non-empty.
25
33
  * @param {string} spec
26
34
  * @returns {{ branchType: string, slug: string }}
27
35
  * @throws {Error} when spec is malformed
@@ -30,12 +38,12 @@ function parseBranchSpec(spec) {
30
38
  const value = String(spec)
31
39
  const idx = value.indexOf('/')
32
40
  if (idx === -1 || value.slice(0, idx) === '') {
33
- throw new Error(`invalid branch spec "${spec}" — expected <type>/<slug> with type in feat, fix, refactor`)
41
+ throw new Error(`invalid branch spec "${spec}" — expected <type>/<slug> with type in feat, fix, refactor, chore, docs`)
34
42
  }
35
43
  const branchType = value.slice(0, idx)
36
44
  const slug = value.slice(idx + 1)
37
45
  if (!branchValidTypes.has(branchType)) {
38
- throw new Error(`invalid branch type "${branchType}" — must be one of feat, fix, refactor`)
46
+ throw new Error(`invalid branch type "${branchType}" — must be one of feat, fix, refactor, chore, docs`)
39
47
  }
40
48
  if (slug.trim() === '') {
41
49
  throw new Error(`branch slug is required — expected <type>/<slug>, got "${spec}"`)
@@ -102,24 +110,28 @@ function runBranchNew(spec, dryRun, deps = {}) {
102
110
 
103
111
  const branchName = `${branchType}/${slug}`
104
112
 
105
- const cfg = loadConfig()
106
- const wipDirs = resolveWIPDirs(cfg)
107
- const doneDirs = resolveDoneDirs(cfg)
113
+ // chore/docs are housekeeping types — already treated as roadmap-exempt by `trackfw ship`
114
+ // and `trackfw commit` — so the branch_has_wip_roadmap gate below does not apply to them.
115
+ if (branchGatedTypes.has(branchType)) {
116
+ const cfg = loadConfig()
117
+ const wipDirs = resolveWIPDirs(cfg)
118
+ const doneDirs = resolveDoneDirs(cfg)
108
119
 
109
- const normalizedSlug = validator.normalizeBranchSlug(slug)
110
- const { matched, candidates } = matchSlug(normalizedSlug, wipDirs, doneDirs)
120
+ const normalizedSlug = validator.normalizeBranchSlug(slug)
121
+ const { matched, candidates } = matchSlug(normalizedSlug, wipDirs, doneDirs)
111
122
 
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)
123
+ if (!matched) {
124
+ const msg = (!candidates || candidates.length === 0)
125
+ ? validator.branchGovernanceOrientation(branchName)
126
+ : validator.branchNoMatchingRoadmapMessage(branchName, candidates)
127
+ if (dryRun) {
128
+ writeln(`[dry-run] would block: ${msg}`)
129
+ } else {
130
+ writeln(msg)
131
+ }
132
+ writeErr(`blocked: no matching roadmap in wip/ nor done/ for "${branchName}"`)
133
+ return 1
120
134
  }
121
- writeErr(`blocked: no matching roadmap in wip/ nor done/ for "${branchName}"`)
122
- return 1
123
135
  }
124
136
 
125
137
  if (dryRun) {
@@ -134,6 +146,7 @@ function runBranchNew(spec, dryRun, deps = {}) {
134
146
 
135
147
  module.exports = {
136
148
  branchValidTypes,
149
+ branchGatedTypes,
137
150
  parseBranchSpec,
138
151
  defaultGitCheckout,
139
152
  runBranchNew,