trackfw 7.6.0 → 8.0.0-rc1

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 (123) hide show
  1. package/bin/trackfw.js +81 -0
  2. package/package.json +14 -8
  3. package/bin/trackfw +0 -41
  4. package/src/branch/prune.js +0 -450
  5. package/src/branch/runner.js +0 -153
  6. package/src/changelog/index.js +0 -114
  7. package/src/commands/adr.js +0 -56
  8. package/src/commands/agents.js +0 -3
  9. package/src/commands/audit-surface.js +0 -520
  10. package/src/commands/barrier.js +0 -822
  11. package/src/commands/baseline.js +0 -13
  12. package/src/commands/branch.js +0 -98
  13. package/src/commands/changelog.js +0 -29
  14. package/src/commands/commit.js +0 -66
  15. package/src/commands/configure.js +0 -106
  16. package/src/commands/context.js +0 -215
  17. package/src/commands/discover.js +0 -628
  18. package/src/commands/doctor.js +0 -66
  19. package/src/commands/help.js +0 -358
  20. package/src/commands/identity-wizard.js +0 -258
  21. package/src/commands/index.js +0 -68
  22. package/src/commands/init.js +0 -296
  23. package/src/commands/integrations.js +0 -305
  24. package/src/commands/log.js +0 -31
  25. package/src/commands/metrics.js +0 -242
  26. package/src/commands/note.js +0 -24
  27. package/src/commands/push.js +0 -51
  28. package/src/commands/release.js +0 -47
  29. package/src/commands/req.js +0 -98
  30. package/src/commands/roadmap.js +0 -44
  31. package/src/commands/serve.js +0 -345
  32. package/src/commands/ship.js +0 -58
  33. package/src/commands/skills.js +0 -3
  34. package/src/commands/status.js +0 -12
  35. package/src/commands/sync.js +0 -348
  36. package/src/commands/thirdparty.js +0 -390
  37. package/src/commands/update-harness.js +0 -1002
  38. package/src/commands/update.js +0 -500
  39. package/src/commands/validate.js +0 -57
  40. package/src/commands/version.js +0 -10
  41. package/src/commit/runner.js +0 -352
  42. package/src/config/index.js +0 -457
  43. package/src/forge/adapter.js +0 -189
  44. package/src/forge/resolve.js +0 -191
  45. package/src/generators/adr.js +0 -198
  46. package/src/generators/codex.js +0 -79
  47. package/src/generators/date.js +0 -23
  48. package/src/generators/hooks.js +0 -2414
  49. package/src/generators/init.js +0 -1563
  50. package/src/generators/note.js +0 -154
  51. package/src/generators/req.js +0 -436
  52. package/src/generators/roadmap.js +0 -841
  53. package/src/homedir.js +0 -30
  54. package/src/i18n/index.js +0 -53
  55. package/src/i18n/locales/en-US.json +0 -124
  56. package/src/i18n/locales/es-ES.json +0 -124
  57. package/src/i18n/locales/pt-BR.json +0 -124
  58. package/src/identity/config.js +0 -134
  59. package/src/identity/index.js +0 -19
  60. package/src/identity/preset.js +0 -219
  61. package/src/identity/slug.js +0 -62
  62. package/src/integrations/assets/agents/architect.md +0 -61
  63. package/src/integrations/assets/agents/backend.md +0 -41
  64. package/src/integrations/assets/agents/code-quality.md +0 -48
  65. package/src/integrations/assets/agents/data.md +0 -41
  66. package/src/integrations/assets/agents/dba.md +0 -41
  67. package/src/integrations/assets/agents/frontend.md +0 -41
  68. package/src/integrations/assets/agents/iac.md +0 -46
  69. package/src/integrations/assets/agents/infra.md +0 -43
  70. package/src/integrations/assets/agents/qa.md +0 -41
  71. package/src/integrations/assets/agents/security.md +0 -51
  72. package/src/integrations/assets/agents/tooling.md +0 -47
  73. package/src/integrations/assets/agents/ux.md +0 -48
  74. package/src/integrations/assets/catalog.json +0 -48
  75. package/src/integrations/assets/skills/architecture.md +0 -66
  76. package/src/integrations/assets/skills/backend.md +0 -67
  77. package/src/integrations/assets/skills/code-quality.md +0 -78
  78. package/src/integrations/assets/skills/data.md +0 -75
  79. package/src/integrations/assets/skills/dba.md +0 -77
  80. package/src/integrations/assets/skills/frontend.md +0 -70
  81. package/src/integrations/assets/skills/governance.md +0 -6
  82. package/src/integrations/assets/skills/iac.md +0 -90
  83. package/src/integrations/assets/skills/implement.md +0 -6
  84. package/src/integrations/assets/skills/infra.md +0 -62
  85. package/src/integrations/assets/skills/plan.md +0 -6
  86. package/src/integrations/assets/skills/qa.md +0 -63
  87. package/src/integrations/assets/skills/release.md +0 -6
  88. package/src/integrations/assets/skills/review.md +0 -6
  89. package/src/integrations/assets/skills/security.md +0 -74
  90. package/src/integrations/assets/skills/tooling.md +0 -80
  91. package/src/integrations/assets/skills/ux.md +0 -70
  92. package/src/integrations/catalog.js +0 -58
  93. package/src/integrations/doctor.js +0 -0
  94. package/src/integrations/doctor_remote.js +0 -224
  95. package/src/integrations/index.js +0 -171
  96. package/src/integrations/legacy.js +0 -35
  97. package/src/integrations/manager.js +0 -438
  98. package/src/integrations/register-agent.js +0 -106
  99. package/src/integrations/render.js +0 -563
  100. package/src/integrations/scaffold_doctor.js +0 -395
  101. package/src/lib/fatal-error.js +0 -64
  102. package/src/lib/pathfmt.js +0 -43
  103. package/src/lib/unknown-command.js +0 -82
  104. package/src/lib/update-engine.js +0 -242
  105. package/src/push/runner.js +0 -226
  106. package/src/release/runner.js +0 -474
  107. package/src/serve/api_attention.js +0 -24
  108. package/src/serve/api_board.js +0 -105
  109. package/src/serve/api_chain.js +0 -246
  110. package/src/serve/api_file.js +0 -117
  111. package/src/serve/api_metrics.js +0 -139
  112. package/src/serve/static/app.js +0 -1151
  113. package/src/serve/static/index.html +0 -338
  114. package/src/serve/static/style.css +0 -297
  115. package/src/ship/runner.js +0 -775
  116. package/src/thirdparty/fetch.js +0 -140
  117. package/src/thirdparty/markers.js +0 -207
  118. package/src/thirdparty/provenance.js +0 -114
  119. package/src/thirdparty/quarantine.js +0 -113
  120. package/src/thirdparty/references.js +0 -194
  121. package/src/validator/git-exec.js +0 -72
  122. package/src/validator/index.js +0 -4086
  123. package/src/validator/traceid.js +0 -155
package/bin/trackfw.js ADDED
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // trackfw shim — resolves the platform binary at runtime.
5
+ // Design: NO postinstall. Binary is located here, at require() time.
6
+ // Prior art: esbuild (https://esbuild.github.io/getting-started/#download-using-npm)
7
+ //
8
+ // Resolution strategy: platform packages carry NO `bin` field — that field would
9
+ // create a collision in node_modules/.bin/trackfw (npm install-order dependent,
10
+ // nondeterministic). Instead, the shim computes the binary subpath from os.platform()
11
+ // directly (same pattern as esbuild), then joins with the already-known pkgDir.
12
+ // No package metadata consulted for the path.
13
+ //
14
+ // Ported from prototype/packages/trackfw-shim/bin/trackfw.js (validated in
15
+ // darwin/arm64, win32/arm64, win32/x64) — ML-1B, ROADMAP-2026-09-12-v8.
16
+
17
+ const path = require("path");
18
+ const fs = require("fs");
19
+ const { spawnSync } = require("child_process");
20
+ const os = require("os");
21
+
22
+ function getPlatformPackage() {
23
+ const platform = os.platform(); // 'linux', 'darwin', 'win32'
24
+ const arch = os.arch(); // 'x64', 'arm64'
25
+ // Dynamic: package name is constructed from platform+arch.
26
+ // No hardcoded allowlist — new platforms are supported automatically once published.
27
+ return `@trackfw-bin/${platform}-${arch}`;
28
+ }
29
+
30
+ function resolveBinaryPath(pkg) {
31
+ // Resolve the platform package from node_modules relative to this shim.
32
+ // This works with or without postinstall, under --ignore-scripts.
33
+ let pkgPath;
34
+ try {
35
+ pkgPath = require.resolve(`${pkg}/package.json`);
36
+ } catch (e) {
37
+ const platform = os.platform();
38
+ const arch = os.arch();
39
+ process.stderr.write(
40
+ `trackfw: platform package for ${platform}/${arch} (${pkg}) is not installed.\n` +
41
+ `This usually means the optional dependency was skipped during install.\n` +
42
+ `Try: npm install (without --no-optional) or install ${pkg} directly.\n`
43
+ );
44
+ process.exit(1);
45
+ }
46
+
47
+ const pkgDir = path.dirname(pkgPath);
48
+
49
+ // Compute binary subpath from platform — platform packages carry no `bin` field
50
+ // (avoids nondeterministic collision in node_modules/.bin/ from install order).
51
+ const binaryName = os.platform() === "win32" ? "trackfw.exe" : "trackfw";
52
+ const resolvedBin = path.join(pkgDir, "bin", binaryName);
53
+
54
+ // Fail loudly and name the cause — AC7: no MODULE_NOT_FOUND surprises.
55
+ if (!fs.existsSync(resolvedBin)) {
56
+ process.stderr.write(
57
+ `trackfw: platform binary not found in ${pkg}.\n` +
58
+ `Expected: ${resolvedBin}\n` +
59
+ `The platform package may be corrupted or missing the binary in bin/${binaryName}.\n`
60
+ );
61
+ process.exit(1);
62
+ }
63
+
64
+ return resolvedBin;
65
+ }
66
+
67
+ const pkg = getPlatformPackage();
68
+ const binPath = resolveBinaryPath(pkg);
69
+
70
+ // Pass all argv through to the binary.
71
+ const result = spawnSync(binPath, process.argv.slice(2), {
72
+ stdio: "inherit",
73
+ env: process.env,
74
+ });
75
+
76
+ if (result.error) {
77
+ process.stderr.write(`trackfw: failed to run binary: ${result.error.message}\n`);
78
+ process.exit(1);
79
+ }
80
+
81
+ process.exit(result.status ?? 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "7.6.0",
3
+ "version": "8.0.0-rc1",
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",
@@ -45,23 +45,29 @@
45
45
  },
46
46
  "license": "MIT",
47
47
  "bin": {
48
- "trackfw": "./bin/trackfw"
48
+ "trackfw": "./bin/trackfw.js"
49
49
  },
50
- "main": "./src/commands/index.js",
51
50
  "files": [
52
- "bin/",
53
- "src/"
51
+ "bin/trackfw.js"
54
52
  ],
55
53
  "scripts": {
56
54
  "test": "node --test tests/*.test.js",
57
- "smoke": "node bin/trackfw --help && node bin/trackfw version"
55
+ "smoke": "node --check bin/trackfw.js && echo 'shim syntax ok'"
58
56
  },
59
- "dependencies": {
60
- "commander": "^12.0.0",
57
+ "devDependencies": {
61
58
  "@inquirer/prompts": "^5.0.0",
59
+ "commander": "^12.0.0",
62
60
  "yaml": "^2.9.0"
63
61
  },
64
62
  "engines": {
65
63
  "node": ">=18"
64
+ },
65
+ "optionalDependencies": {
66
+ "@trackfw-bin/darwin-arm64": "8.0.0-rc1",
67
+ "@trackfw-bin/darwin-x64": "8.0.0-rc1",
68
+ "@trackfw-bin/linux-arm64": "8.0.0-rc1",
69
+ "@trackfw-bin/linux-x64": "8.0.0-rc1",
70
+ "@trackfw-bin/win32-arm64": "8.0.0-rc1",
71
+ "@trackfw-bin/win32-x64": "8.0.0-rc1"
66
72
  }
67
73
  }
package/bin/trackfw DELETED
@@ -1,41 +0,0 @@
1
- #!/usr/bin/env node
2
- 'use strict'
3
-
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
- })
@@ -1,450 +0,0 @@
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
- }