trackfw 3.0.0 → 3.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 (52) hide show
  1. package/README.md +9 -2
  2. package/package.json +1 -1
  3. package/src/commands/discover.js +18 -0
  4. package/src/commands/index.js +2 -0
  5. package/src/commands/init.js +40 -2
  6. package/src/commands/log.js +2 -1
  7. package/src/commands/note.js +24 -0
  8. package/src/commands/req.js +7 -1
  9. package/src/commands/roadmap.js +4 -3
  10. package/src/commands/ship.js +49 -0
  11. package/src/config/index.js +5 -2
  12. package/src/forge/adapter.js +189 -0
  13. package/src/forge/resolve.js +191 -0
  14. package/src/generators/adr.js +13 -4
  15. package/src/generators/date.js +23 -0
  16. package/src/generators/init.js +105 -3
  17. package/src/generators/note.js +154 -0
  18. package/src/generators/req.js +88 -5
  19. package/src/generators/roadmap.js +96 -20
  20. package/src/i18n/locales/en-US.json +2 -1
  21. package/src/i18n/locales/es-ES.json +2 -1
  22. package/src/i18n/locales/pt-BR.json +2 -1
  23. package/src/identity/preset.js +22 -0
  24. package/src/integrations/assets/agents/architect.md +32 -0
  25. package/src/integrations/assets/agents/backend.md +32 -0
  26. package/src/integrations/assets/agents/code-quality.md +29 -0
  27. package/src/integrations/assets/agents/data.md +32 -0
  28. package/src/integrations/assets/agents/dba.md +32 -0
  29. package/src/integrations/assets/agents/frontend.md +32 -0
  30. package/src/integrations/assets/agents/iac.md +46 -0
  31. package/src/integrations/assets/agents/infra.md +34 -0
  32. package/src/integrations/assets/agents/qa.md +32 -0
  33. package/src/integrations/assets/agents/security.md +29 -0
  34. package/src/integrations/assets/agents/tooling.md +47 -0
  35. package/src/integrations/assets/agents/ux.md +29 -0
  36. package/src/integrations/assets/catalog.json +16 -2
  37. package/src/integrations/assets/skills/architecture.md +66 -0
  38. package/src/integrations/assets/skills/backend.md +67 -0
  39. package/src/integrations/assets/skills/code-quality.md +78 -0
  40. package/src/integrations/assets/skills/data.md +75 -0
  41. package/src/integrations/assets/skills/dba.md +77 -0
  42. package/src/integrations/assets/skills/frontend.md +70 -0
  43. package/src/integrations/assets/skills/iac.md +90 -0
  44. package/src/integrations/assets/skills/infra.md +62 -0
  45. package/src/integrations/assets/skills/qa.md +63 -0
  46. package/src/integrations/assets/skills/security.md +74 -0
  47. package/src/integrations/assets/skills/tooling.md +80 -0
  48. package/src/integrations/assets/skills/ux.md +70 -0
  49. package/src/integrations/render.js +57 -6
  50. package/src/ship/runner.js +454 -0
  51. package/src/validator/index.js +321 -111
  52. package/src/validator/traceid.js +1 -1
@@ -80,8 +80,8 @@ function toolsFor(itemId) {
80
80
  // identidade configurada. Sem apelido configurado, apenas o display name do
81
81
  // agente é mencionado. Espelha internal/integrations/render.go:greetingLine.
82
82
  function greetingLine(displayName, nickname) {
83
- if (!nickname) return `Você é ${displayName}.`
84
- return `Você é ${displayName}. Trate o usuário como ${nickname}.`
83
+ if (!nickname) return `You are ${displayName}.`
84
+ return `You are ${displayName}. Address the user as ${nickname}.`
85
85
  }
86
86
 
87
87
  // insertBodyPrefix insere prefix como a nova primeira linha da seção de
@@ -103,6 +103,54 @@ function insertBodyPrefix(source, prefix) {
103
103
  return `${head}\n\n${prefix}\n\n${rest}`
104
104
  }
105
105
 
106
+ // rewriteSignatureLine reescreve a última linha da seção de corpo de um
107
+ // markdown cru (frontmatter + corpo) que casa com o padrão de assinatura
108
+ // `^— <nome>, <título>$` (travessão em-dash U+2014, espaço, nome, vírgula,
109
+ // espaço, título). Apenas o grupo de nome (primeiro) é substituído por
110
+ // displayName; o título (segundo grupo) é preservado byte a byte.
111
+ //
112
+ // Escopo: a função opera somente no corpo (após o fechamento do frontmatter).
113
+ // Uma linha de assinatura dentro do frontmatter nunca é tocada — a detecção
114
+ // de fronteira espelha rewriteFrontmatterFields exatamente.
115
+ //
116
+ // Se nenhuma linha do corpo casar com o padrão, source é retornado inalterado.
117
+ // Se displayName for vazio, source é retornado inalterado. A função nunca
118
+ // inventa uma assinatura que não estava presente.
119
+ //
120
+ // Espelha internal/integrations/render.go:rewriteSignatureLine.
121
+ function rewriteSignatureLine(source, displayName) {
122
+ if (!displayName) return source
123
+ const trimmed = String(source).trim()
124
+
125
+ // Localiza o início do corpo — espelha a detecção de fronteira de
126
+ // rewriteFrontmatterFields para que o escopo de ambas as funções coincida.
127
+ let bodyStart = 0
128
+ if (trimmed.startsWith('---\n')) {
129
+ const end = trimmed.indexOf('\n---', 4)
130
+ if (end >= 0) {
131
+ bodyStart = end + 4 // aponta para o char imediatamente após "\n---"
132
+ }
133
+ }
134
+ const head = trimmed.slice(0, bodyStart)
135
+ const bodySection = trimmed.slice(bodyStart)
136
+
137
+ const lines = bodySection.split('\n')
138
+ // Percorre de trás para frente para encontrar a ÚLTIMA linha candidata.
139
+ for (let i = lines.length - 1; i >= 0; i--) {
140
+ const line = lines[i]
141
+ if (!line.startsWith('— ')) continue
142
+ const rest = line.slice('— '.length) // pula "— "
143
+ const commaIdx = rest.indexOf(', ')
144
+ if (commaIdx < 0) continue
145
+ const title = rest.slice(commaIdx + 2)
146
+ if (!title) continue
147
+ lines[i] = `— ${displayName}, ${title}`
148
+ return head + lines.join('\n')
149
+ }
150
+ // Nenhuma linha de assinatura encontrada — retorna source inalterado.
151
+ return source
152
+ }
153
+
106
154
  // rewriteFrontmatterFields substitui as linhas "name:" e "description:" do
107
155
  // frontmatter de um markdown cru por name e description, preservando toda
108
156
  // outra linha do frontmatter byte a byte (ordem, espaçamento, estilo de
@@ -176,8 +224,10 @@ function frontmatterName(content) {
176
224
  // gemini, cursor, copilot, kiro-ide, windsurf), retorna o source cru
177
225
  // normalizado com o frontmatter ainda anexado. Quando há identidade
178
226
  // configurada, suas linhas "name:"/"description:" são reescritas no
179
- // lugar (ver rewriteFrontmatterFields) — necessário porque a seleção de
180
- // subagent do Claude Code lê apenas o frontmatter, nunca o corpo.
227
+ // lugar (ver rewriteFrontmatterFields) e a última linha de assinatura do
228
+ // corpo com padrão `^— <nome>, <título>$` é reescrita com o display name
229
+ // da identidade (ver rewriteSignatureLine) — necessário porque a seleção
230
+ // de subagent do Claude Code lê apenas o frontmatter, nunca o corpo.
181
231
  //
182
232
  // Ambas as rotas devem receber a injeção de identidade. Quando não há
183
233
  // identidade configurada para item.id, name/description/body ficam
@@ -231,7 +281,8 @@ function render({ kind, content, capability, item, identity: cfg }) {
231
281
  if (!id) return normalize(content)
232
282
  const withBody = insertBodyPrefix(content, greeting)
233
283
  const withFrontmatter = rewriteFrontmatterFields(withBody, name, description)
234
- return normalize(withFrontmatter)
284
+ const withSignature = rewriteSignatureLine(withFrontmatter, id.display_name)
285
+ return normalize(withSignature)
235
286
  }
236
287
 
237
- module.exports = { render, markdownParts, frontmatterName, greetingLine, insertBodyPrefix, rewriteFrontmatterFields }
288
+ module.exports = { render, markdownParts, frontmatterName, greetingLine, insertBodyPrefix, rewriteFrontmatterFields, rewriteSignatureLine }
@@ -0,0 +1,454 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * ship/runner.js — Core implementation of `trackfw ship`.
5
+ *
6
+ * All git write operations are injectable for testability.
7
+ * Never passes "add ." or "add -A" to any git executor.
8
+ */
9
+
10
+ const { spawnSync } = require('child_process')
11
+ const fs = require('fs')
12
+ const path = require('path')
13
+ const { load: loadConfig, reset: resetConfig } = require('../config')
14
+ const { resolve: forgeResolve } = require('../forge/resolve')
15
+ const { forgeAdapter } = require('../forge/adapter')
16
+
17
+ // Git subcommands that modify local or remote state.
18
+ // In --dry-run mode these are printed but not executed.
19
+ const GIT_WRITE_COMMANDS = new Set(['commit', 'push', 'fetch'])
20
+
21
+ /**
22
+ * isGitWriteCmd returns true when the first arg is a write-mode git subcommand.
23
+ * @param {string[]} args
24
+ * @returns {boolean}
25
+ */
26
+ function isGitWriteCmd(args) {
27
+ return args.length > 0 && GIT_WRITE_COMMANDS.has(args[0])
28
+ }
29
+
30
+ /**
31
+ * isShipBranch returns true when branch matches feat|fix|refactor/<slug>.
32
+ * @param {string} branch
33
+ * @returns {boolean}
34
+ */
35
+ function isShipBranch(branch) {
36
+ return /^(feat|fix|refactor)\/.+/.test(branch)
37
+ }
38
+
39
+ /**
40
+ * defaultExecGit runs git with the provided args and returns { stdout, error }.
41
+ * @param {string[]} args
42
+ * @returns {{ stdout: string, error: Error|null }}
43
+ */
44
+ function defaultExecGit(args) {
45
+ const result = spawnSync('git', args, { encoding: 'utf8' })
46
+ if (result.status !== 0) {
47
+ const msg = (result.stderr || '').trim() || `git ${args.join(' ')} exited with ${result.status}`
48
+ return { stdout: '', error: new Error(msg) }
49
+ }
50
+ return { stdout: (result.stdout || '').trim(), error: null }
51
+ }
52
+
53
+ /**
54
+ * defaultCheckGovernance returns violation messages from the governance gate.
55
+ * Uses validator equivalent: looks for wip roadmap and req link in the filesystem.
56
+ * Returns [] when governance passes.
57
+ * @returns {string[]}
58
+ */
59
+ function defaultCheckGovernance() {
60
+ return checkShipGovernance()
61
+ }
62
+
63
+ /**
64
+ * checkShipGovernance — hard gate (bypasses config/baseline/lenient).
65
+ * Checks:
66
+ * 1. Current branch has a matching roadmap in wip/
67
+ * 2. WIP roadmaps have a linked REQ
68
+ * @returns {string[]} violation messages
69
+ */
70
+ function checkShipGovernance() {
71
+ const violations = []
72
+
73
+ // Resolve roadmap dir via config module (single source of truth; default: docs/roadmaps)
74
+ const roadmapDir = resolveRoadmapDir()
75
+ const wipDir = path.join(roadmapDir, 'wip')
76
+
77
+ // Get branch name
78
+ const branchResult = defaultExecGit(['symbolic-ref', '--short', 'HEAD'])
79
+ const branch = branchResult.error ? '' : branchResult.stdout.trim()
80
+
81
+ // Check branch has matching roadmap in wip/
82
+ if (branch && isShipBranch(branch)) {
83
+ const slug = normalizeBranchSlug(branch.split('/').slice(1).join('/'))
84
+ let hasMatch = false
85
+ let wipFiles = []
86
+
87
+ if (fs.existsSync(wipDir)) {
88
+ wipFiles = fs.readdirSync(wipDir).filter(f => f.endsWith('.md'))
89
+ for (const f of wipFiles) {
90
+ if (normalizeBranchSlug(f).includes(slug)) {
91
+ hasMatch = true
92
+ break
93
+ }
94
+ }
95
+ }
96
+
97
+ if (wipFiles.length === 0) {
98
+ violations.push(
99
+ `branch "${branch}" is a feat/fix/refactor branch but no roadmap is in wip/ — create governance artifacts first:\n` +
100
+ ' trackfw req new "<title>"\n' +
101
+ ' trackfw roadmap new "<title>"\n' +
102
+ ' trackfw roadmap move <name> wip'
103
+ )
104
+ } else if (!hasMatch) {
105
+ violations.push(
106
+ `branch "${branch}" has no matching roadmap in wip/ (found: ${wipFiles.join(', ')}) — ` +
107
+ 'include the branch slug in the roadmap filename'
108
+ )
109
+ }
110
+
111
+ // Check WIP roadmaps have a linked REQ
112
+ if (hasMatch && fs.existsSync(wipDir)) {
113
+ for (const f of wipFiles) {
114
+ const content = fs.readFileSync(path.join(wipDir, f), 'utf8')
115
+ if (!content.includes('REQ:') && !content.includes('req:')) {
116
+ violations.push(`roadmap "${f}" is in wip but has no linked REQ`)
117
+ }
118
+ }
119
+ }
120
+ }
121
+
122
+ return violations
123
+ }
124
+
125
+ /**
126
+ * resolveRoadmapDir delegates to config.load() — single source of truth for roadmap_dir.
127
+ * Accepts an optional cwd for testability (passed through to config.load).
128
+ * Default when no trackfw.yaml is present: docs/roadmaps.
129
+ * @param {string} [cwd]
130
+ * @returns {string}
131
+ */
132
+ function resolveRoadmapDir(cwd) {
133
+ return loadConfig(cwd).roadmapDir
134
+ }
135
+
136
+ /**
137
+ * normalizeBranchSlug converts a string to a lowercase dash-only slug
138
+ * (same algorithm as Go's normalizeBranchSlug).
139
+ * @param {string} value
140
+ * @returns {string}
141
+ */
142
+ function normalizeBranchSlug(value) {
143
+ let out = ''
144
+ let lastDash = false
145
+ for (const ch of value.toLowerCase()) {
146
+ if (/[a-z0-9]/.test(ch)) {
147
+ out += ch
148
+ lastDash = false
149
+ } else if (!lastDash) {
150
+ out += '-'
151
+ lastDash = true
152
+ }
153
+ }
154
+ return out.replace(/^-|-$/g, '')
155
+ }
156
+
157
+ /**
158
+ * detectPendingSquashMerges warns about remote branches with non-empty diffs vs origin/main.
159
+ * Non-blocking.
160
+ * @param {string} currentBranch
161
+ * @param {function} execGit
162
+ * @param {function} writeln
163
+ */
164
+ function detectPendingSquashMerges(currentBranch, execGit, writeln) {
165
+ const { stdout, error } = execGit(['branch', '-r', '--no-merged', 'origin/main'])
166
+ if (error || !stdout.trim()) return
167
+
168
+ for (const raw of stdout.split('\n')) {
169
+ const candidate = raw.trim()
170
+ if (!candidate || candidate.includes('HEAD')) continue
171
+ const shortName = candidate.replace(/^origin\//, '')
172
+ if (shortName === currentBranch) continue
173
+
174
+ const { stdout: diff, error: derr } = execGit(['diff', 'origin/main', candidate, '--stat'])
175
+ if (derr) continue
176
+ if (diff.trim()) {
177
+ writeln(`Warning: branch "${shortName}" appears to have unmerged changes vs origin/main.`)
178
+ }
179
+ }
180
+ }
181
+
182
+ /**
183
+ * buildPushArgs returns the push args, adding -u if no upstream is configured.
184
+ * @param {string} branch
185
+ * @param {function} execGit
186
+ * @returns {string[]}
187
+ */
188
+ function buildPushArgs(branch, execGit) {
189
+ const { error } = execGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])
190
+ if (error) {
191
+ return ['push', '-u', 'origin', branch]
192
+ }
193
+ return ['push', 'origin', branch]
194
+ }
195
+
196
+ /**
197
+ * defaultExecForgeCLI invokes a forge CLI (gh, glab, az) inheriting stdio.
198
+ * @param {string} name
199
+ * @param {string[]} args
200
+ * @returns {Error|null}
201
+ */
202
+ function defaultExecForgeCLI(name, args) {
203
+ const result = spawnSync(name, args, { stdio: 'inherit' })
204
+ if (result.status !== 0) {
205
+ return new Error(`${name} exited with ${result.status}`)
206
+ }
207
+ return null
208
+ }
209
+
210
+ /**
211
+ * firstLine returns only the first line of s.
212
+ * @param {string} s
213
+ * @returns {string}
214
+ */
215
+ function firstLine(s) {
216
+ const idx = s.indexOf('\n')
217
+ return idx >= 0 ? s.slice(0, idx) : s
218
+ }
219
+
220
+ /**
221
+ * buildForgeCreateArgs appends --title and --body (or --description for azure)
222
+ * to a copy of adapter.cliArgs. Never mutates the original array.
223
+ * @param {object} adapter
224
+ * @param {string} title
225
+ * @param {string} body
226
+ * @returns {string[]}
227
+ */
228
+ function buildForgeCreateArgs(adapter, title, body) {
229
+ const args = [...adapter.cliArgs, '--title', title]
230
+ if (adapter.forge === 'azure') {
231
+ args.push('--description', body)
232
+ } else {
233
+ args.push('--body', body)
234
+ }
235
+ return args
236
+ }
237
+
238
+ /**
239
+ * runShip executes the seven-step ship sequence.
240
+ *
241
+ * @param {{ message: string, dryRun: boolean, noPR?: boolean, forge?: string }} opts
242
+ * @param {{ execGit?: function, checkGovernance?: function, writeln?: function,
243
+ * configForge?: string, repoDir?: string,
244
+ * availFn?: function, execForgeCLI?: function }} deps
245
+ * @returns {number} exit code (0 = success)
246
+ */
247
+ function runShip(opts, deps = {}) {
248
+ const execGit = deps.execGit || defaultExecGit
249
+ const checkGovernanceFn = deps.checkGovernance || defaultCheckGovernance
250
+ const writeln = deps.writeln || ((s) => process.stdout.write(s + '\n'))
251
+
252
+ // Inner git wrapper: skips write commands in dry-run mode.
253
+ function git(args) {
254
+ if (opts.dryRun && isGitWriteCmd(args)) {
255
+ writeln(`[dry-run] git ${args.join(' ')}`)
256
+ return { stdout: '', error: null }
257
+ }
258
+ return execGit(args)
259
+ }
260
+
261
+ // ─── Step 1: Branch validation ─────────────────────────────────────────────
262
+ const branchResult = execGit(['symbolic-ref', '--short', 'HEAD'])
263
+ if (branchResult.error) {
264
+ writeln(`error: could not determine current branch (are you in a git repo?): ${branchResult.error.message}`)
265
+ return 1
266
+ }
267
+ const branch = branchResult.stdout.trim()
268
+
269
+ if (branch === 'main' || branch === 'master') {
270
+ writeln(`error: trackfw ship cannot run on "${branch}" — use a feature branch:\n git checkout -b feat/<slug>`)
271
+ return 1
272
+ }
273
+
274
+ if (!isShipBranch(branch)) {
275
+ writeln(
276
+ `error: branch "${branch}" does not match the required pattern feat|fix|refactor/<slug>\n` +
277
+ 'Rename your branch or create a new one:\n git checkout -b feat/<slug>'
278
+ )
279
+ return 1
280
+ }
281
+
282
+ writeln(`Branch: ${branch}`)
283
+
284
+ // ─── Step 2: Governance ────────────────────────────────────────────────────
285
+ const violations = checkGovernanceFn()
286
+ if (violations.length > 0) {
287
+ writeln('\nGovernance check failed:')
288
+ for (const v of violations) {
289
+ writeln(` ${v}`)
290
+ }
291
+ writeln('\nCreate the required artifacts before running ship:')
292
+ writeln(' trackfw req new "<title>"')
293
+ writeln(' trackfw roadmap new "<title>"')
294
+ writeln(' trackfw roadmap move <name> wip')
295
+ writeln("\nNote: this governance check is a hard gate — it is not affected by lenient")
296
+ writeln("mode or per-rule severity configured in trackfw.yaml. If 'trackfw validate'")
297
+ writeln("passes but 'trackfw ship' aborts here, you likely have lenient mode")
298
+ writeln("configured — ship always requires REQ + roadmap in wip/.")
299
+ writeln(`\nerror: governance check failed: ${violations.length} violation(s)`)
300
+ return 1
301
+ }
302
+
303
+ writeln('Governance: OK')
304
+
305
+ // ─── Step 3: Squash-merge detection ────────────────────────────────────────
306
+ if (opts.dryRun) {
307
+ writeln('[dry-run] git fetch origin --prune')
308
+ } else {
309
+ const { error: fetchErr } = execGit(['fetch', 'origin', '--prune'])
310
+ if (fetchErr) {
311
+ writeln('Warning: could not fetch origin (offline or no remote); skipping squash-merge check.')
312
+ } else {
313
+ detectPendingSquashMerges(branch, execGit, writeln)
314
+ }
315
+ }
316
+
317
+ // ─── Step 4: Review staged ─────────────────────────────────────────────────
318
+ const { stdout: statusOut } = execGit(['status', '--short'])
319
+ const { stdout: diffStatOut } = execGit(['diff', '--cached', '--stat'])
320
+
321
+ writeln('\n── Staged changes ──────────────────────────────────────')
322
+ if (statusOut) writeln(statusOut)
323
+ if (diffStatOut) writeln(diffStatOut)
324
+ writeln('────────────────────────────────────────────────────────\n')
325
+
326
+ const { stdout: cachedFiles } = execGit(['diff', '--cached', '--name-only'])
327
+ if (!cachedFiles.trim()) {
328
+ writeln(
329
+ 'error: nothing is staged — stage your files explicitly before running ship:\n' +
330
+ ' git add <file1> <file2> ...\n' +
331
+ "Never use 'git add .' or 'git add -A'"
332
+ )
333
+ return 1
334
+ }
335
+
336
+ // ─── Step 5: Commit ────────────────────────────────────────────────────────
337
+ if (!opts.message) {
338
+ writeln(
339
+ 'error: commit message is required — use -m:\n' +
340
+ ' trackfw ship -m "feat(<scope>): <description>"'
341
+ )
342
+ return 1
343
+ }
344
+
345
+ const { error: commitErr } = git(['commit', '-m', opts.message])
346
+ if (commitErr) {
347
+ writeln(`error: git commit failed: ${commitErr.message}`)
348
+ return 1
349
+ }
350
+
351
+ if (!opts.dryRun) {
352
+ writeln(`Committed: ${opts.message}`)
353
+ }
354
+
355
+ // ─── Step 6: Push ──────────────────────────────────────────────────────────
356
+ const pushArgs = buildPushArgs(branch, execGit)
357
+ const { error: pushErr } = git(pushArgs)
358
+ if (pushErr) {
359
+ writeln(`error: git push failed: ${pushErr.message}`)
360
+ return 1
361
+ }
362
+
363
+ if (!opts.dryRun) {
364
+ writeln(`Pushed: ${branch} → origin/${branch}`)
365
+ }
366
+
367
+ // ─── Step 7: Open PR/MR ────────────────────────────────────────────────────
368
+ const { stdout: remoteURLRaw } = execGit(['remote', 'get-url', 'origin'])
369
+ const remoteURL = (remoteURLRaw || '').trim()
370
+
371
+ let resolution
372
+ try {
373
+ resolution = forgeResolve({
374
+ flagForge: opts.forge || '',
375
+ configForge: deps.configForge || '',
376
+ remoteURL,
377
+ repoDir: deps.repoDir || '',
378
+ })
379
+ } catch (resErr) {
380
+ writeln(`Warning: forge resolution error: ${resErr.message} — open PR/MR manually.`)
381
+ writeln('\nship complete.')
382
+ return 0
383
+ }
384
+
385
+ const adapter = forgeAdapter(resolution.forge, deps.availFn || undefined)
386
+ writeln(`Forge: ${resolution.forge} (source: ${resolution.source})`)
387
+
388
+ if (opts.noPR) {
389
+ writeln(`--no-pr: skipping ${adapter.noun} creation.`)
390
+ writeln('\nship complete.')
391
+ return 0
392
+ }
393
+
394
+ if (opts.dryRun) {
395
+ if (!adapter.available && resolution.forge !== 'manual') {
396
+ const url = adapter.fallbackURL(remoteURL, branch)
397
+ if (url) {
398
+ writeln(`[dry-run] ${adapter.noun} CLI (${adapter.cliName}) not available — would open in browser:\n ${url}`)
399
+ } else {
400
+ writeln(`[dry-run] ${adapter.noun} CLI (${adapter.cliName}) not available — would open ${adapter.noun} manually`)
401
+ }
402
+ } else {
403
+ writeln(`[dry-run] would open ${adapter.noun} via ${resolution.forge} CLI`)
404
+ }
405
+ return 0
406
+ }
407
+
408
+ if (resolution.forge === 'manual') {
409
+ writeln(`\nOpen your ${adapter.noun} manually at:\n ${remoteURL}`)
410
+ writeln('\nship complete.')
411
+ return 0
412
+ }
413
+
414
+ if (!adapter.available) {
415
+ const url = adapter.fallbackURL(remoteURL, branch)
416
+ if (url) {
417
+ writeln(`${adapter.noun} CLI (${adapter.cliName}) not available — open in browser:\n ${url}`)
418
+ } else {
419
+ writeln(`${adapter.noun} CLI (${adapter.cliName}) not available — open ${adapter.noun} manually.`)
420
+ }
421
+ writeln('\nship complete.')
422
+ return 0
423
+ }
424
+
425
+ // CLI is available — invoke it.
426
+ const title = firstLine(opts.message || '')
427
+ const body = `Branch: ${branch}\n\nCreated by trackfw ship.`
428
+ const cliArgs = buildForgeCreateArgs(adapter, title, body)
429
+ const execForgeCLI = deps.execForgeCLI || defaultExecForgeCLI
430
+ const cliErr = execForgeCLI(adapter.cliName, cliArgs)
431
+ if (cliErr) {
432
+ const url = adapter.fallbackURL(remoteURL, branch)
433
+ writeln(`Warning: ${adapter.noun} CLI failed (${cliErr.message}).`)
434
+ if (url) writeln(`Open in browser:\n ${url}`)
435
+ } else {
436
+ writeln(`${adapter.noun} created.`)
437
+ }
438
+
439
+ writeln('\nship complete.')
440
+ return 0
441
+ }
442
+
443
+ module.exports = {
444
+ runShip,
445
+ isShipBranch,
446
+ isGitWriteCmd,
447
+ normalizeBranchSlug,
448
+ checkShipGovernance,
449
+ resolveRoadmapDir,
450
+ resetConfig,
451
+ GIT_WRITE_COMMANDS,
452
+ buildForgeCreateArgs,
453
+ firstLine,
454
+ }