trackfw 7.0.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.
@@ -30,6 +30,14 @@ class IntegrationManager {
30
30
  constructor({ projectRoot = process.cwd(), homeRoot = os.homedir() } = {}, { onSkip } = {}) {
31
31
  this.roots = { project: path.resolve(projectRoot), global: path.resolve(homeRoot) }
32
32
  this.onSkip = onSkip
33
+ // _afterManifestPersist, when set, runs immediately after manifests are
34
+ // persisted and before artifact bytes are written during install/update
35
+ // (never during uninstall, which is intentionally not inverted — see the
36
+ // comment in mutate()). It exists only so tests can simulate an
37
+ // interruption exactly at the ADR-2026-08-18 ordering seam. Production
38
+ // code never assigns it. Mirrors internal/integrations/manager.go's
39
+ // afterManifestPersist package var.
40
+ this._afterManifestPersist = undefined
33
41
  }
34
42
 
35
43
  manifestPath(scope) { return path.join(this.roots[scope], '.trackfw', 'integrations-manifest.json') }
@@ -118,7 +126,16 @@ class IntegrationManager {
118
126
  const file = this.resolve(scope, plan.destination)
119
127
  const record = manifests.get(scope).artifacts[file]
120
128
  const managed = Boolean(record && record.claims.some(claim => claimKey(claim) === claimKey(plan.claim)))
121
- if (!fs.existsSync(file)) return { ...plan, destination: file, state: 'not-installed', managed }
129
+ // registered reports whether the manifest has ANY entry for this
130
+ // destination, regardless of claim ownership — unlike managed, which
131
+ // additionally requires this exact claim to own that entry. doctor
132
+ // (ML-2A) needs this distinction: a destination registered under a
133
+ // *different* claim must never be reported as an "unregistered
134
+ // write" — that would be the dominant false-positive doctor exists to
135
+ // avoid. Additive field; mirrors Inspection.Registered
136
+ // (internal/integrations/manager.go).
137
+ const registered = Boolean(record)
138
+ if (!fs.existsSync(file)) return { ...plan, destination: file, state: 'not-installed', managed, registered }
122
139
  const actual = sha256(fs.readFileSync(file))
123
140
  const desired = sha256(plan.content)
124
141
  let state
@@ -129,7 +146,7 @@ class IntegrationManager {
129
146
  } else if (actual === desired) state = 'current'
130
147
  else if ((plan.legacyHashes || []).includes(actual)) state = 'outdated'
131
148
  else state = 'modified'
132
- return { ...plan, destination: file, state, managed }
149
+ return { ...plan, destination: file, state, managed, registered }
133
150
  })
134
151
  }
135
152
 
@@ -167,9 +184,44 @@ class IntegrationManager {
167
184
  const snapshots = new Map()
168
185
  for (const item of active) this.snapshot(snapshots, item.file)
169
186
  for (const scope of manifests.keys()) this.snapshot(snapshots, this.manifestPath(scope))
170
- try {
171
- for (const item of active) this.apply(operation, item, manifests.get(item.plan.claim.scope), force)
187
+ const saveManifests = () => {
172
188
  for (const [scope, manifest] of [...manifests].sort(([a], [b]) => a.localeCompare(b))) this.saveManifest(scope, manifest)
189
+ }
190
+ try {
191
+ // ADR-2026-08-18: install/update persist the manifest before writing
192
+ // artifact bytes (self-healing direction on interruption); uninstall
193
+ // keeps the original artifacts-before-manifest order. The two are
194
+ // deliberately NOT symmetric.
195
+ if (operation === 'uninstall') {
196
+ // Uninstall is not inverted. Removing bytes first and persisting the
197
+ // manifest last means an interruption leaves the manifest still
198
+ // declaring an artifact whose file is now absent — inspectResolved
199
+ // resolves that as 'not-installed', the same self-healing direction
200
+ // install/update get from the inversion below. Inverting uninstall
201
+ // the same way (drop the manifest entry, then remove bytes) would
202
+ // instead leave, on interruption, a file on disk whose content still
203
+ // matches the catalog template but with no manifest entry at all —
204
+ // reported as an orphaned state='current'/managed=false artifact
205
+ // that looks legitimate and that nothing detects or repairs
206
+ // automatically. That is exactly the "disk ahead of manifest" bad
207
+ // direction the ADR exists to eliminate, so uninstall must not be
208
+ // simetrized with install/update here. Mirrors the comment in
209
+ // internal/integrations/manager.go:mutate.
210
+ for (const item of active) this.applyUninstall(item, manifests.get(item.plan.claim.scope))
211
+ saveManifests()
212
+ } else {
213
+ // Install/Update: compute the manifest update for every active item
214
+ // in memory (no bytes touched yet), persist all manifests, and only
215
+ // then write the artifact bytes.
216
+ const pendingWrites = []
217
+ for (const item of active) {
218
+ const write = this.planArtifactWrite(operation, item, manifests.get(item.plan.claim.scope), force)
219
+ if (write) pendingWrites.push(write)
220
+ }
221
+ saveManifests()
222
+ if (typeof this._afterManifestPersist === 'function') this._afterManifestPersist()
223
+ for (const write of pendingWrites) this.atomicWrite(write.file, write.content, write.mode)
224
+ }
173
225
  } catch (error) {
174
226
  this.rollback(snapshots)
175
227
  throw error
@@ -186,7 +238,7 @@ class IntegrationManager {
186
238
  if (status.state === 'modified' && !force) throw new Error(`Artifact is modified; use --force: ${file}`)
187
239
  if (status.state === 'outdated' && owned && !force) return true // skip: bytes preserved, batch continues
188
240
  } else if (operation === 'update') {
189
- if (!owned && status.state === 'modified') throw new Error(`Unmanaged artifact does not match a trackfw template: ${file}`)
241
+ if (!owned && status.state === 'modified') throw new Error(this.unmanagedArtifactError(file, plan.claim))
190
242
  if (status.state === 'modified' && !force) throw new Error(`Artifact is modified; use --force: ${file}`)
191
243
  } else if (operation === 'uninstall' && owned && status.state === 'modified' && !force) {
192
244
  throw new Error(`Artifact is modified; use --force: ${file}`)
@@ -256,19 +308,42 @@ class IntegrationManager {
256
308
  return { state: 'modified', managed: false }
257
309
  }
258
310
 
259
- apply(operation, { plan, file }, manifest, force) {
311
+ // applyUninstall removes ownership of one artifact from the manifest, and —
312
+ // once no claim remains — the artifact's bytes and any empty ancestor
313
+ // directories it managed. It mutates disk directly (not deferred), because
314
+ // uninstall deliberately keeps the pre-ADR-2026-08-18 ordering: see the
315
+ // comment in mutate() for why this is not simetrized with
316
+ // planArtifactWrite. Mirrors internal/integrations/manager.go:applyUninstall.
317
+ applyUninstall({ plan, file }, manifest) {
318
+ const record = manifest.artifacts[file]
319
+ const key = claimKey(plan.claim)
320
+ const owned = Boolean(record && record.claims.some(claim => claimKey(claim) === key))
321
+ if (!owned) return
322
+ record.claims = record.claims.filter(claim => claimKey(claim) !== key)
323
+ if (record.claims.length) return
324
+ if (fs.existsSync(file)) fs.unlinkSync(file)
325
+ delete manifest.artifacts[file]
326
+ this.cleanEmpty(path.dirname(file), this.roots[plan.claim.scope])
327
+ }
328
+
329
+ // planArtifactWrite computes the manifest update for one install/update
330
+ // item entirely in memory — it never touches the artifact's bytes on disk.
331
+ // The caller (mutate) persists every manifest in the batch first, and only
332
+ // then applies the returned pending write (ADR-2026-08-18). Returns null
333
+ // when no byte write is needed.
334
+ //
335
+ // The manifest values it stores are deliberately *optimistic* when a write
336
+ // is pending: sha256/catalog_version describe the content this call is
337
+ // about to write, not what is currently on disk. If interrupted before the
338
+ // pending write lands, the manifest already declares the target state and
339
+ // inspectResolved resolves the (absent or stale) file to
340
+ // 'not-installed'/'modified', both self-repairable by a later
341
+ // install/update, never modified+unowned ("unmanaged"). Mirrors
342
+ // internal/integrations/manager.go:planArtifactWrite.
343
+ planArtifactWrite(operation, { plan, file }, manifest, force) {
260
344
  let record = manifest.artifacts[file]
261
345
  const key = claimKey(plan.claim)
262
346
  const owned = Boolean(record && record.claims.some(claim => claimKey(claim) === key))
263
- if (operation === 'uninstall') {
264
- if (!owned) return
265
- record.claims = record.claims.filter(claim => claimKey(claim) !== key)
266
- if (record.claims.length) return
267
- if (fs.existsSync(file)) fs.unlinkSync(file)
268
- delete manifest.artifacts[file]
269
- this.cleanEmpty(path.dirname(file), this.roots[plan.claim.scope])
270
- return
271
- }
272
347
 
273
348
  const exists = fs.existsSync(file)
274
349
  let actual = exists ? sha256(fs.readFileSync(file)) : ''
@@ -278,16 +353,29 @@ class IntegrationManager {
278
353
  if (exists && !owned) writeDesired = (operation === 'update' && actual !== desired) || (force && actual !== desired)
279
354
  else if (exists && owned) writeDesired = actual !== desired
280
355
  if (!record) record = { destination: file, sha256: '', catalog_version: '', claims: [] }
356
+ let pending = null
281
357
  if (writeDesired) {
282
- this.atomicWrite(file, plan.content, 0o644)
358
+ // Optimistic: bytes have not moved yet, but the manifest must already
359
+ // describe the content we are about to write (see doc comment above).
283
360
  actual = desired
361
+ pending = { file, content: plan.content, mode: 0o644 }
284
362
  } else if (exists && !owned && actual !== desired && !knownLegacy && !force) {
285
- throw new Error(`Unmanaged artifact does not match a trackfw template: ${file}`)
363
+ // Defense-in-depth: preflight already rejects this exact case for
364
+ // 'update' (unconditionally) and for 'install' without --force (any
365
+ // state === 'modified' is blocked before an active item ever reaches
366
+ // planArtifactWrite()). This branch is therefore not reachable via
367
+ // install/update today, but stays as a second line of defense in case
368
+ // preflight's guard is ever loosened — hence the identical remediation
369
+ // text, so a user who somehow hits it still gets the same actionable
370
+ // message. No manifest mutation happens on this path. Mirrors
371
+ // internal/integrations/manager.go:planArtifactWrite.
372
+ throw new Error(this.unmanagedArtifactError(file, plan.claim))
286
373
  }
287
374
  if (!record.claims.some(claim => claimKey(claim) === key)) record.claims.push(cleanClaim(plan.claim))
288
375
  record.sha256 = actual
289
376
  record.catalog_version = actual === desired ? plan.catalogVersion : 'legacy'
290
377
  manifest.artifacts[file] = record
378
+ return pending
291
379
  }
292
380
 
293
381
  snapshot(snapshots, file) {
@@ -305,6 +393,19 @@ class IntegrationManager {
305
393
  }
306
394
  }
307
395
 
396
+ // unmanagedArtifactError — mensagem de erro para update/uninstall (ou,
397
+ // defensivamente, install) recusando bytes que o trackfw não escreveu.
398
+ // Nomeia o remédio — trackfw did not write these bytes, então a única forma
399
+ // segura de trazer o artefato sob gestão é `<kind> install --force`, que
400
+ // explicitamente autoriza adotar/substituir conteúdo unmanaged — com as
401
+ // flags exatas para reproduzir o claim deste plano (item, target, scope),
402
+ // prontas para copiar e colar. Espelha
403
+ // internal/integrations/manager.go:unmanagedArtifactError — fonte canônica
404
+ // do texto, byte a byte idêntica nos 3 CLIs.
405
+ unmanagedArtifactError(file, claim) {
406
+ return `unmanaged artifact "${file}" does not match a trackfw template — trackfw did not write these bytes.\nAdopt it with: trackfw ${claim.kind} install --force --items ${claim.item} --targets ${claim.target} --scope ${claim.scope}`
407
+ }
408
+
308
409
  // tildeAbbrev — retorna o caminho de exibição abreviado para uso em mensagens
309
410
  // de aviso de skip. Espelha internal/integrations/manager.go:tildeAbbrev.
310
411
  // - scope global: substitui homeRoot por '~' (via tildeify, com salvaguarda
@@ -0,0 +1,64 @@
1
+ 'use strict'
2
+
3
+ const fs = require('node:fs')
4
+
5
+ // fatal-error.js — global backstop for errors that escape every commander
6
+ // action (sync or async, since npm/bin/trackfw drives the program via
7
+ // parseAsync()). Installed ONCE at the entrypoint, never per-command:
8
+ // the real leak pattern (REQ-2026-08-16-erro-nao-tratado-no-cli-node-vaza-
9
+ // stack-trace-caminhos-absolutos-e-versao-do-runtime) was per-command —
10
+ // commands that catch internally (e.g. `roadmap move`) already print a
11
+ // clean message via console.error + process.exitCode, but any command that
12
+ // doesn't catch let Node's own uncaught-exception/unhandled-rejection
13
+ // printer dump the full stack, absolute install path and runtime version.
14
+ // A single handler at the entrypoint closes every one of those gaps at
15
+ // once instead of relying on every future command remembering a try/catch.
16
+ //
17
+ // TRACKFW_DEBUG=1 restores the full stack — this must never blind someone
18
+ // who genuinely needs to debug a crash.
19
+ //
20
+ // Writes with fs.writeSync(2, ...) — NOT process.stderr.write() — on
21
+ // purpose: installGlobalHandlers() below calls process.exit() right after
22
+ // this returns, and when stderr is a pipe (exactly what every spawnSync-
23
+ // based test in this repo uses, and what a piped CI log is) Node's stream
24
+ // writes are asynchronous; process.exit() can drop a still-pending write
25
+ // before the OS ever sees it, silently eating the message (failure mode
26
+ // #2 in the REQ: "engolir a mensagem" — worse than the leak this handler
27
+ // exists to fix). fs.writeSync is a raw, synchronous syscall — it cannot
28
+ // race process.exit().
29
+ function reportFatalError(err) {
30
+ // TRACKFW_DEBUG=1: print the full stack (its first line already is
31
+ // "Error: <message>", so this is the debug-only superset of the clean
32
+ // path below, not a duplicate of it).
33
+ if (process.env.TRACKFW_DEBUG === '1' && err && err.stack) {
34
+ fs.writeSync(2, `${err.stack}\n`)
35
+ return
36
+ }
37
+ const message = err && err.message !== undefined ? err.message : String(err)
38
+ fs.writeSync(2, `Error: ${message}\n`)
39
+ }
40
+
41
+ // installGlobalHandlers — defense in depth beyond the parseAsync().catch()
42
+ // in bin/trackfw. Covers errors that reach the process outside that single
43
+ // promise chain (e.g. a rejection from a detached/floating promise started
44
+ // by a command, or a genuine uncaughtException). Per Node's own contract,
45
+ // once a listener is attached Node no longer prints its default trace and
46
+ // no longer auto-exits — the listener owns both the message and the exit.
47
+ // Both listeners call process.exit() immediately after reportFatalError()
48
+ // returns — safe here specifically because reportFatalError writes
49
+ // synchronously (see comment above); process.exitCode (which waits for the
50
+ // event loop to drain) would be wrong here regardless, since long-running
51
+ // commands (e.g. `serve`) hold the loop open and the process would hang
52
+ // instead of exiting on a genuinely fatal error.
53
+ function installGlobalHandlers() {
54
+ process.on('unhandledRejection', (err) => {
55
+ reportFatalError(err)
56
+ process.exit(1)
57
+ })
58
+ process.on('uncaughtException', (err) => {
59
+ reportFatalError(err)
60
+ process.exit(1)
61
+ })
62
+ }
63
+
64
+ module.exports = { reportFatalError, installGlobalHandlers }
@@ -8,11 +8,11 @@
8
8
  */
9
9
 
10
10
  const { spawnSync } = require('child_process')
11
- const fs = require('fs')
12
- const path = require('path')
13
11
  const { load: loadConfig, reset: resetConfig } = require('../config')
14
12
  const { resolve: forgeResolve } = require('../forge/resolve')
15
13
  const { forgeAdapter } = require('../forge/adapter')
14
+ const validator = require('../validator')
15
+ const { evaluateBranchIntegration, DECISION: BRANCH_PRUNE_DECISION } = require('../branch/prune')
16
16
 
17
17
  // Git subcommands that modify local or remote state.
18
18
  // In --dry-run mode these are printed but not executed.
@@ -77,66 +77,25 @@ function defaultCheckGovernance() {
77
77
 
78
78
  /**
79
79
  * checkShipGovernance — hard gate (bypasses config/baseline/lenient).
80
- * Checks:
81
- * 1. Current branch has a matching roadmap in wip/
82
- * 2. WIP roadmaps have a linked REQ
80
+ *
81
+ * Delegates entirely to the shared validator functions already used by `trackfw validate`,
82
+ * `trackfw branch new` and `trackfw commit` — never reimplement this logic locally. Byte-
83
+ * identical to Go's CheckShipGovernance (internal/validator/validator.go), which has the same
84
+ * no-args shape and the same two checks:
85
+ * 1. validateBranchHasWIPRoadmap — current branch (feat/fix/refactor only; re-derived
86
+ * internally from TRACKFW_BRANCH/git, same as Go) has a matching roadmap in wip/ OR done/
87
+ * 2. validateWIPHasREQ — every roadmap in wip/ has a linked REQ
88
+ * Before this ML, this function reimplemented both checks locally with its own wording (no
89
+ * "nor done/" clause, no done/ directory scan at all) — see
90
+ * vault/notes/ship-checkgovernance-error-stream-wording-divergence-2026-08-16.md. Duplicating
91
+ * the check was the actual root cause of the drift, not just the wording.
83
92
  * @returns {string[]} violation messages
84
93
  */
85
94
  function checkShipGovernance() {
86
- const violations = []
87
-
88
- // Resolve roadmap dir via config module (single source of truth; default: docs/roadmaps)
89
- const roadmapDir = resolveRoadmapDir()
90
- const wipDir = path.join(roadmapDir, 'wip')
91
-
92
- // Get branch name
93
- const branchResult = defaultExecGit(['symbolic-ref', '--short', 'HEAD'])
94
- const branch = branchResult.error ? '' : branchResult.stdout.trim()
95
-
96
- // Check branch has matching roadmap in wip/ — gated types only (feat/fix/refactor); chore/docs
97
- // are exempted by the outer runShip Step 2 skip and never reach this function in practice, but
98
- // isGatedShipBranch keeps this check correct in isolation too.
99
- if (branch && isGatedShipBranch(branch)) {
100
- const slug = normalizeBranchSlug(branch.split('/').slice(1).join('/'))
101
- let hasMatch = false
102
- let wipFiles = []
103
-
104
- if (fs.existsSync(wipDir)) {
105
- wipFiles = fs.readdirSync(wipDir).filter(f => f.endsWith('.md'))
106
- for (const f of wipFiles) {
107
- if (normalizeBranchSlug(f).includes(slug)) {
108
- hasMatch = true
109
- break
110
- }
111
- }
112
- }
113
-
114
- if (wipFiles.length === 0) {
115
- violations.push(
116
- `branch "${branch}" is a feat/fix/refactor branch but no roadmap is in wip/ — create governance artifacts first:\n` +
117
- ' trackfw req new "<title>"\n' +
118
- ' trackfw roadmap new "<title>"\n' +
119
- ' trackfw roadmap move <name> wip'
120
- )
121
- } else if (!hasMatch) {
122
- violations.push(
123
- `branch "${branch}" has no matching roadmap in wip/ (found: ${wipFiles.join(', ')}) — ` +
124
- 'include the branch slug in the roadmap filename'
125
- )
126
- }
127
-
128
- // Check WIP roadmaps have a linked REQ
129
- if (hasMatch && fs.existsSync(wipDir)) {
130
- for (const f of wipFiles) {
131
- const content = fs.readFileSync(path.join(wipDir, f), 'utf8')
132
- if (!content.includes('REQ:') && !content.includes('req:')) {
133
- violations.push(`roadmap "${f}" is in wip but has no linked REQ`)
134
- }
135
- }
136
- }
137
- }
138
-
139
- return violations
95
+ return [
96
+ ...validator.validateBranchHasWIPRoadmap(),
97
+ ...validator.validateWIPHasREQ(),
98
+ ]
140
99
  }
141
100
 
142
101
  /**
@@ -172,8 +131,17 @@ function normalizeBranchSlug(value) {
172
131
  }
173
132
 
174
133
  /**
175
- * detectPendingSquashMerges warns about remote branches with non-empty diffs vs origin/main.
176
- * Non-blocking.
134
+ * detectPendingSquashMerges warns about remote branches with genuinely pending work vs
135
+ * origin/main. Non-blocking.
136
+ *
137
+ * Reuses evaluateBranchIntegration (branch/prune.js) — the same touched-files heuristic
138
+ * `trackfw branch prune` uses — instead of a naive bidirectional `git diff origin/main <branch>
139
+ * --stat`. The naive check false-positives on a branch that was squash-merged and is now merely
140
+ * stale (main advanced further afterwards): it always shows a non-empty diff even though nothing
141
+ * from the branch is actually missing from main. Only DECISION.PENDING_WORK — genuine,
142
+ * unintegrated work — surfaces this warning; every other decision (no_own_work,
143
+ * content_identical, review_doc_config, no_merge_base, eval_error) stays silent, the same
144
+ * posture the naive check had on error (skip, no warning).
177
145
  * @param {string} currentBranch
178
146
  * @param {function} execGit
179
147
  * @param {function} writeln
@@ -188,9 +156,8 @@ function detectPendingSquashMerges(currentBranch, execGit, writeln) {
188
156
  const shortName = candidate.replace(/^origin\//, '')
189
157
  if (shortName === currentBranch) continue
190
158
 
191
- const { stdout: diff, error: derr } = execGit(['diff', 'origin/main', candidate, '--stat'])
192
- if (derr) continue
193
- if (diff.trim()) {
159
+ const evalResult = evaluateBranchIntegration(candidate, execGit)
160
+ if (evalResult.decision === BRANCH_PRUNE_DECISION.PENDING_WORK) {
194
161
  writeln(`Warning: branch "${shortName}" appears to have unmerged changes vs origin/main.`)
195
162
  }
196
163
  }
@@ -396,6 +363,15 @@ function runShip(opts, deps = {}) {
396
363
  const execGit = deps.execGit || defaultExecGit
397
364
  const checkGovernanceFn = deps.checkGovernance || defaultCheckGovernance
398
365
  const writeln = deps.writeln || ((s) => process.stdout.write(s + '\n'))
366
+ // writeErr — the terminal error line for every abort path below, written to STDERR with the
367
+ // "Error: " prefix. Mirrors Go exactly: ship.go returns these same messages as a bare
368
+ // fmt.Errorf(...), and internal/commands/root.go's Execute() wrapper prints them to stderr as
369
+ // `fmt.Fprintln(os.Stderr, cmd.ErrPrefix(), err.Error())` — cmd.ErrPrefix() is "Error:", and
370
+ // Fprintln inserts exactly one space between operands, so the wire format is
371
+ // "Error: <message>\n". Every multi-line detail printed BEFORE the abort (violation lists,
372
+ // remediation hints, "Note: ..." blocks) stays on stdout via writeln, same as Go's deps.out —
373
+ // only this final one-line (or one-block) summary moves to stderr.
374
+ const writeErr = deps.writeErr || ((s) => process.stderr.write(`Error: ${s}\n`))
399
375
 
400
376
  // Inner git wrapper: skips write commands in dry-run mode.
401
377
  function git(args) {
@@ -416,20 +392,20 @@ function runShip(opts, deps = {}) {
416
392
  // ─── Step 1: Branch validation ─────────────────────────────────────────────
417
393
  const branchResult = execGit(['symbolic-ref', '--short', 'HEAD'])
418
394
  if (branchResult.error) {
419
- writeln(`error: could not determine current branch (are you in a git repo?): ${branchResult.error.message}`)
395
+ writeErr(`could not determine current branch (are you in a git repo?): ${branchResult.error.message}`)
420
396
  return 1
421
397
  }
422
398
  const branch = branchResult.stdout.trim()
423
399
 
424
400
  // main/master is blocked unconditionally — the doc-only exception never applies here.
425
401
  if (branch === 'main' || branch === 'master') {
426
- writeln(`error: trackfw ship cannot run on "${branch}" — use a feature branch:\n git checkout -b feat/<slug>`)
402
+ writeErr(`trackfw ship cannot run on "${branch}" — use a feature branch:\n git checkout -b feat/<slug>`)
427
403
  return 1
428
404
  }
429
405
 
430
406
  if (!docOnly && !isShipBranch(branch)) {
431
- writeln(
432
- `error: branch "${branch}" does not match the required pattern feat|fix|refactor|chore|docs/<slug>\n` +
407
+ writeErr(
408
+ `branch "${branch}" does not match the required pattern feat|fix|refactor|chore|docs/<slug>\n` +
433
409
  'Rename your branch or create a new one:\n git checkout -b feat/<slug>'
434
410
  )
435
411
  return 1
@@ -461,7 +437,7 @@ function runShip(opts, deps = {}) {
461
437
  writeln("mode or per-rule severity configured in trackfw.yaml. If 'trackfw validate'")
462
438
  writeln("passes but 'trackfw ship' aborts here, you likely have lenient mode")
463
439
  writeln("configured — ship always requires REQ + roadmap in wip/.")
464
- writeln(`\nerror: governance check failed: ${violations.length} violation(s)`)
440
+ writeErr(`governance check failed: ${violations.length} violation(s)`)
465
441
  return 1
466
442
  }
467
443
 
@@ -491,8 +467,8 @@ function runShip(opts, deps = {}) {
491
467
 
492
468
  // Reuses stagedFiles read at the top of the function (Step 0) — never re-query git here.
493
469
  if (stagedFiles.length === 0) {
494
- writeln(
495
- 'error: nothing is staged — stage your files explicitly before running ship:\n' +
470
+ writeErr(
471
+ 'nothing is staged — stage your files explicitly before running ship:\n' +
496
472
  ' git add <file1> <file2> ...\n' +
497
473
  "Never use 'git add .' or 'git add -A'"
498
474
  )
@@ -501,8 +477,8 @@ function runShip(opts, deps = {}) {
501
477
 
502
478
  // ─── Step 5: Commit ────────────────────────────────────────────────────────
503
479
  if (!opts.message) {
504
- writeln(
505
- 'error: commit message is required — use -m:\n' +
480
+ writeErr(
481
+ 'commit message is required — use -m:\n' +
506
482
  ' trackfw ship -m "feat(<scope>): <description>"'
507
483
  )
508
484
  return 1
@@ -510,7 +486,7 @@ function runShip(opts, deps = {}) {
510
486
 
511
487
  const { error: commitErr } = git(['commit', '-m', opts.message])
512
488
  if (commitErr) {
513
- writeln(`error: git commit failed: ${commitErr.message}`)
489
+ writeErr(`git commit failed: ${commitErr.message}`)
514
490
  return 1
515
491
  }
516
492
 
@@ -522,7 +498,7 @@ function runShip(opts, deps = {}) {
522
498
  const pushArgs = buildPushArgs(branch, execGit)
523
499
  const { error: pushErr } = git(pushArgs)
524
500
  if (pushErr) {
525
- writeln(`error: git push failed: ${pushErr.message}`)
501
+ writeErr(`git push failed: ${pushErr.message}`)
526
502
  return 1
527
503
  }
528
504
 
@@ -638,4 +614,5 @@ module.exports = {
638
614
  gitCommitsSince,
639
615
  buildPRBody,
640
616
  COMMIT_MESSAGE_SEP,
617
+ detectPendingSquashMerges,
641
618
  }