trackfw 7.1.0 → 7.2.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.
@@ -18,6 +18,68 @@ const { evaluateBranchIntegration, DECISION: BRANCH_PRUNE_DECISION } = require('
18
18
  // In --dry-run mode these are printed but not executed.
19
19
  const GIT_WRITE_COMMANDS = new Set(['commit', 'push', 'fetch'])
20
20
 
21
+ // force-with-lease refusal messages. Named constants so the ML-1B parity gate has a single
22
+ // place to compare byte-for-byte across the 3 CLIs. Byte-identical to Go's
23
+ // forceLease*Msg/Fmt constants (internal/commands/ship.go) and Python's FORCE_LEASE_* below.
24
+ // Never expose "--force" (raw) as a flag — see
25
+ // ADR-2026-08-19-caminho-governado-para-push-forcado-e-tag-de-release.md.
26
+ const FORCE_LEASE_NO_FORGE_CLI_MSG =
27
+ 'trackfw ship --force-with-lease requires a forge CLI (gh, glab, or az) to confirm an open pull/merge request before rewriting remote history. No forge CLI is available for this repository — install and authenticate it, or push without --force-with-lease.'
28
+
29
+ function forceLeaseNoPROpenMsg(branch) {
30
+ return `trackfw ship --force-with-lease refuses to run: branch "${branch}" has no open pull/merge request. Open the PR/MR first (trackfw ship without --force-with-lease, or your forge's web UI), then retry.`
31
+ }
32
+
33
+ function forceLeaseCannotVerifyMsg(branch, cliName, errMessage) {
34
+ return `trackfw ship --force-with-lease could not verify whether branch "${branch}" has an open pull/merge request (${cliName} CLI error: ${errMessage}). Refusing rather than risking a force push without a verified PR — check your ${cliName} CLI authentication and retry.`
35
+ }
36
+
37
+ /**
38
+ * defaultCheckPROpen queries the resolved forge CLI for an open PR/MR whose source branch is
39
+ * `branch`, using the same list-based query shape for every forge: empty result means "no PR"
40
+ * (exit 0), any non-zero exit or unparseable output means "cannot verify" (thrown — never
41
+ * conflated with "no PR"). bitbucket and "manual" never reach here: runShip only calls
42
+ * checkPROpen when adapter.available is true, and bitbucket's adapter is always available=false.
43
+ * @param {object} adapter
44
+ * @param {string} branch
45
+ * @returns {boolean}
46
+ */
47
+ function defaultCheckPROpen(adapter, branch) {
48
+ let args
49
+ switch (adapter.forge) {
50
+ case 'github':
51
+ args = ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'number']
52
+ break
53
+ case 'gitlab':
54
+ // glab mr list: --source-branch filters by source branch, --state opened matches
55
+ // gh's "open" state, -F json requests machine-readable output (glab's own flag,
56
+ // not an external jq/GNU dependency).
57
+ args = ['mr', 'list', '--source-branch', branch, '--state', 'opened', '-F', 'json']
58
+ break
59
+ case 'azure':
60
+ // az defaults to --output json; passed explicitly here for clarity, not reliance
61
+ // on the ambient default.
62
+ args = ['repos', 'pr', 'list', '--source-branch', branch, '--status', 'active', '--output', 'json']
63
+ break
64
+ default:
65
+ throw new Error(`no PR/MR query defined for forge "${adapter.forge}"`)
66
+ }
67
+
68
+ const result = spawnSync(adapter.cliName, args, { encoding: 'utf8' })
69
+ if (result.status !== 0) {
70
+ const msg = (result.stderr || '').trim() || `${adapter.cliName} exited with ${result.status}`
71
+ throw new Error(msg)
72
+ }
73
+
74
+ let parsed
75
+ try {
76
+ parsed = JSON.parse(result.stdout || '[]')
77
+ } catch (e) {
78
+ throw new Error(`could not parse ${adapter.cliName} output: ${e.message}`)
79
+ }
80
+ return Array.isArray(parsed) && parsed.length > 0
81
+ }
82
+
21
83
  /**
22
84
  * isGitWriteCmd returns true when the first arg is a write-mode git subcommand.
23
85
  * @param {string[]} args
@@ -240,10 +302,17 @@ function allDocOnly(files) {
240
302
  */
241
303
  const COMMIT_MESSAGE_SEP = '\x1e'
242
304
 
305
+ // GIT_SYMBOLIC_REF_ORIGIN_HEAD_PREFIX is the fixed prefix `git symbolic-ref
306
+ // refs/remotes/origin/HEAD` always returns before the branch name, because "origin" is the
307
+ // literal ref namespace queried — never derived from the output itself. Stripping this exact
308
+ // prefix (instead of cutting at the last '/') is what makes defaultBaseBranch correct for a
309
+ // default branch that itself contains a slash (e.g. "release/7.2"): lastIndexOf('/') used to
310
+ // cut at "7.2", discarding "release/". Mirrors Go's ship.go gitSymbolicRefOriginHeadPrefix.
311
+ const GIT_SYMBOLIC_REF_ORIGIN_HEAD_PREFIX = 'refs/remotes/origin/'
312
+
243
313
  /**
244
314
  * defaultBaseBranch resolves the repository's default branch for `git log <base>..HEAD`.
245
- * Tries `git symbolic-ref refs/remotes/origin/HEAD` (format "refs/remotes/origin/main" —
246
- * only the name after the last slash is kept) and falls back to "main" when that fails or
315
+ * Tries `git symbolic-ref refs/remotes/origin/HEAD` and falls back to "main" when that fails or
247
316
  * yields nothing.
248
317
  * @param {function} execGit
249
318
  * @returns {string}
@@ -252,9 +321,9 @@ function defaultBaseBranch(execGit) {
252
321
  const { stdout, error } = execGit(['symbolic-ref', 'refs/remotes/origin/HEAD'])
253
322
  if (error) return 'main'
254
323
  const out = (stdout || '').trim()
255
- const idx = out.lastIndexOf('/')
256
- if (idx < 0 || idx + 1 >= out.length) return 'main'
257
- return out.slice(idx + 1)
324
+ if (!out.startsWith(GIT_SYMBOLIC_REF_ORIGIN_HEAD_PREFIX)) return 'main'
325
+ const name = out.slice(GIT_SYMBOLIC_REF_ORIGIN_HEAD_PREFIX.length)
326
+ return name === '' ? 'main' : name
258
327
  }
259
328
 
260
329
  /**
@@ -444,6 +513,59 @@ function runShip(opts, deps = {}) {
444
513
  writeln('Governance: OK')
445
514
  }
446
515
 
516
+ // ─── Step 2.5: force-with-lease gate ───────────────────────────────────────
517
+ // Runs before any write (commit/push) — a refusal here must never leave a local
518
+ // commit the caller cannot push. Read-only, so it runs in --dry-run too, same
519
+ // posture as the read-only calls in Step 0 / Step 7.
520
+ //
521
+ // forceLeaseAdapter/forceLeaseResolution are reused by Step 7 below to avoid a
522
+ // second forge resolution and a duplicate "Forge: ..." line, and because Step 7
523
+ // must skip PR/MR creation entirely once this gate has confirmed one is already
524
+ // open — creating a second one would be a spurious failure on every successful
525
+ // force push.
526
+ let forceLeaseAdapter = null
527
+ let forceLeaseResolution = null
528
+ if (opts.forceWithLease) {
529
+ const { stdout: gateRemoteURLRaw } = execGit(['remote', 'get-url', 'origin'])
530
+ const gateRemoteURL = (gateRemoteURLRaw || '').trim()
531
+
532
+ let resolution
533
+ try {
534
+ resolution = forgeResolve({
535
+ flagForge: opts.forge || '',
536
+ configForge: deps.configForge || '',
537
+ remoteURL: gateRemoteURL,
538
+ repoDir: deps.repoDir || '',
539
+ })
540
+ } catch (resErr) {
541
+ writeErr(resErr.message)
542
+ return 1
543
+ }
544
+
545
+ const adapter = forgeAdapter(resolution.forge, deps.availFn || undefined)
546
+ if (resolution.forge === 'manual' || !adapter.available) {
547
+ writeErr(FORCE_LEASE_NO_FORGE_CLI_MSG)
548
+ return 1
549
+ }
550
+
551
+ const checkPROpen = deps.checkPROpen || defaultCheckPROpen
552
+ let open
553
+ try {
554
+ open = checkPROpen(adapter, branch)
555
+ } catch (prErr) {
556
+ writeErr(forceLeaseCannotVerifyMsg(branch, adapter.cliName, prErr.message))
557
+ return 1
558
+ }
559
+ if (!open) {
560
+ writeErr(forceLeaseNoPROpenMsg(branch))
561
+ return 1
562
+ }
563
+
564
+ writeln(`force-with-lease: open ${adapter.noun} confirmed for "${branch}" (${resolution.forge}).`)
565
+ forceLeaseAdapter = adapter
566
+ forceLeaseResolution = resolution
567
+ }
568
+
447
569
  // ─── Step 3: Squash-merge detection ────────────────────────────────────────
448
570
  if (opts.dryRun) {
449
571
  writeln('[dry-run] git fetch origin --prune')
@@ -466,7 +588,14 @@ function runShip(opts, deps = {}) {
466
588
  writeln('────────────────────────────────────────────────────────\n')
467
589
 
468
590
  // Reuses stagedFiles read at the top of the function (Step 0) — never re-query git here.
469
- if (stagedFiles.length === 0) {
591
+ //
592
+ // --force-with-lease push-only mode: a rebase that resolved conflicts already
593
+ // committed the result (the index is clean afterwards) — there is nothing left to
594
+ // stage or commit, only to push. Only --force-with-lease grants this exception;
595
+ // without it, "nothing staged" still aborts exactly as before (non-regression).
596
+ const pushOnly = opts.forceWithLease && stagedFiles.length === 0
597
+
598
+ if (stagedFiles.length === 0 && !opts.forceWithLease) {
470
599
  writeErr(
471
600
  'nothing is staged — stage your files explicitly before running ship:\n' +
472
601
  ' git add <file1> <file2> ...\n' +
@@ -476,26 +605,35 @@ function runShip(opts, deps = {}) {
476
605
  }
477
606
 
478
607
  // ─── Step 5: Commit ────────────────────────────────────────────────────────
479
- if (!opts.message) {
480
- writeErr(
481
- 'commit message is required — use -m:\n' +
482
- ' trackfw ship -m "feat(<scope>): <description>"'
483
- )
484
- return 1
485
- }
608
+ if (pushOnly) {
609
+ writeln('Nothing staged — --force-with-lease pushes existing commits only, no new commit.')
610
+ } else {
611
+ if (!opts.message) {
612
+ writeErr(
613
+ 'commit message is required — use -m:\n' +
614
+ ' trackfw ship -m "feat(<scope>): <description>"'
615
+ )
616
+ return 1
617
+ }
486
618
 
487
- const { error: commitErr } = git(['commit', '-m', opts.message])
488
- if (commitErr) {
489
- writeErr(`git commit failed: ${commitErr.message}`)
490
- return 1
491
- }
619
+ const { error: commitErr } = git(['commit', '-m', opts.message])
620
+ if (commitErr) {
621
+ writeErr(`git commit failed: ${commitErr.message}`)
622
+ return 1
623
+ }
492
624
 
493
- if (!opts.dryRun) {
494
- writeln(`Committed: ${opts.message}`)
625
+ if (!opts.dryRun) {
626
+ writeln(`Committed: ${opts.message}`)
627
+ }
495
628
  }
496
629
 
497
630
  // ─── Step 6: Push ──────────────────────────────────────────────────────────
498
- const pushArgs = buildPushArgs(branch, execGit)
631
+ let pushArgs = buildPushArgs(branch, execGit)
632
+ if (opts.forceWithLease) {
633
+ // Fixed position: push --force-with-lease [-u] origin <branch> — identical
634
+ // across the 3 CLIs (ML-1B's parity gate compares this literally).
635
+ pushArgs = [pushArgs[0], '--force-with-lease', ...pushArgs.slice(1)]
636
+ }
499
637
  const { error: pushErr } = git(pushArgs)
500
638
  if (pushErr) {
501
639
  writeErr(`git push failed: ${pushErr.message}`)
@@ -507,6 +645,17 @@ function runShip(opts, deps = {}) {
507
645
  }
508
646
 
509
647
  // ─── Step 7: Open PR/MR ────────────────────────────────────────────────────
648
+ // --force-with-lease only ever reaches here after Step 2.5 confirmed a PR/MR is
649
+ // already open on this branch — creating another one would be a spurious failure
650
+ // on every successful force push. Reuses the adapter/resolution Step 2.5 already
651
+ // computed instead of resolving the forge a second time.
652
+ if (opts.forceWithLease) {
653
+ writeln(`Forge: ${forceLeaseResolution.forge} (source: ${forceLeaseResolution.source})`)
654
+ writeln(`${forceLeaseAdapter.noun} already open — skipping creation (--force-with-lease).`)
655
+ writeln('\nship complete.')
656
+ return 0
657
+ }
658
+
510
659
  const { stdout: remoteURLRaw } = execGit(['remote', 'get-url', 'origin'])
511
660
  const remoteURL = (remoteURLRaw || '').trim()
512
661
 
@@ -615,4 +764,11 @@ module.exports = {
615
764
  buildPRBody,
616
765
  COMMIT_MESSAGE_SEP,
617
766
  detectPendingSquashMerges,
767
+ defaultCheckPROpen,
768
+ FORCE_LEASE_NO_FORGE_CLI_MSG,
769
+ forceLeaseNoPROpenMsg,
770
+ forceLeaseCannotVerifyMsg,
771
+ // Exported for reuse by push/runner.js (AC2 of REQ-2026-08-22-trackfw-push).
772
+ buildPushArgs,
773
+ defaultExecGit,
618
774
  }
@@ -1174,13 +1174,21 @@ const GIT_BRANCH_GUARD_SCRIPT_MARKER = 'trackfw-git-branch-guard.sh'
1174
1174
  // sibling is a structurally malformed entry the CLI silently never executes (hades-tf ML-4A
1175
1175
  // barrier finding), not merely "absent". false only for Cursor, whose schema
1176
1176
  // ({"command":...}) never carries a "type" field at all.
1177
+ //
1178
+ // requiresVarOrShellPrefix (ROADMAP-2026-08-21 ML-1B, port of Go's
1179
+ // credentialGuardHookFile.requiresVarOrShellPrefix): true for Claude/Codex/Gemini — the
1180
+ // ADR-2026-08-11 requires these CLIs to anchor the hook path with $VAR/... or "$(git ...)/..."
1181
+ // because their hooks run from the agent's cwd, not necessarily the project root. A bare
1182
+ // relative path ("scripts/...") silently fails from any subdirectory (REQ-2026-08-17 root
1183
+ // cause). false for Cursor/Copilot/Kiro, where the bare relative path IS the correct form —
1184
+ // flagging them would be a false-positive (the dominant risk of this REQ).
1177
1185
  const CREDENTIAL_GUARD_HOOK_FILES = [
1178
- { relPath: '.claude/settings.json', cli: 'Claude Code', requiresCommandType: true },
1179
- { relPath: '.codex/hooks.json', cli: 'Codex CLI', requiresCommandType: true },
1180
- { relPath: '.gemini/settings.json', cli: 'Gemini CLI', requiresCommandType: true },
1181
- { relPath: '.cursor/hooks.json', cli: 'Cursor', requiresCommandType: false },
1182
- { relPath: '.github/hooks/trackfw-attention.json', cli: 'GitHub Copilot CLI', requiresCommandType: true },
1183
- { relPath: '.kiro/hooks/trackfw-attention.json', cli: 'Kiro', requiresCommandType: true },
1186
+ { relPath: '.claude/settings.json', cli: 'Claude Code', requiresCommandType: true, requiresVarOrShellPrefix: true },
1187
+ { relPath: '.codex/hooks.json', cli: 'Codex CLI', requiresCommandType: true, requiresVarOrShellPrefix: true },
1188
+ { relPath: '.gemini/settings.json', cli: 'Gemini CLI', requiresCommandType: true, requiresVarOrShellPrefix: true },
1189
+ { relPath: '.cursor/hooks.json', cli: 'Cursor', requiresCommandType: false, requiresVarOrShellPrefix: false },
1190
+ { relPath: '.github/hooks/trackfw-attention.json', cli: 'GitHub Copilot CLI', requiresCommandType: true, requiresVarOrShellPrefix: false },
1191
+ { relPath: '.kiro/hooks/trackfw-attention.json', cli: 'Kiro', requiresCommandType: true, requiresVarOrShellPrefix: false },
1184
1192
  ]
1185
1193
 
1186
1194
  // resolveCredentialGuardHookPath resolve o valor bruto de um comando de hook (string extraída do
@@ -1212,14 +1220,84 @@ function resolveCredentialGuardHookPath(raw, root) {
1212
1220
  const inner = raw.slice(codexPrefix.length, raw.length - 1)
1213
1221
  return path.join(root, inner)
1214
1222
  }
1215
- if (!raw.startsWith('$') && !raw.startsWith('"') && !path.isAbsolute(raw)) {
1223
+ if (!raw.startsWith('$') && !raw.startsWith('"') && !path.isAbsolute(raw) && !raw.startsWith('~/')) {
1216
1224
  // Caminho relativo puro — Cursor (beforeShellExecution/preToolUse), GitHub Copilot CLI
1217
1225
  // (campo "bash"), Kiro (action.command).
1226
+ // ~/… é excluído: é classe 1 (tilde expande para $HOME — ancorado) mas o validator não expande
1227
+ // o til; ok=false (null) silencia sem acusar.
1218
1228
  return path.join(root, raw)
1219
1229
  }
1220
1230
  return null
1221
1231
  }
1222
1232
 
1233
+ // HOOK_ANCHORAGE_CLASS_* classifica a semântica de ancoragem de um valor de comando de hook.
1234
+ // Avaliada apenas para CLIs com requiresVarOrShellPrefix=true. Decisão: ADR-2026-08-22.
1235
+ const HOOK_ANCHORAGE_CLASS_ANCHORED = 1
1236
+ const HOOK_ANCHORAGE_CLASS_CWD_DEPENDENT = 2
1237
+ const HOOK_ANCHORAGE_CLASS_UNDECIDABLE = 3
1238
+
1239
+ // stripOuterQuotesForClassify remove aspas duplas envolventes de raw, se presentes. Necessário
1240
+ // porque "$PWD/scripts/…" entre aspas (achado D.3) deve receber o mesmo veredito que $PWD/…
1241
+ // sem aspas: as aspas são sintaxe, não semântica de ancoragem.
1242
+ function stripOuterQuotesForClassify(raw) {
1243
+ if (raw.length >= 2 && raw[0] === '"' && raw[raw.length - 1] === '"') {
1244
+ return raw.slice(1, raw.length - 1)
1245
+ }
1246
+ return raw
1247
+ }
1248
+
1249
+ // hookValueWasQuoted reporta se raw tinha aspas duplas externas que stripOuterQuotesForClassify
1250
+ // removeria. Usado para distinguir ~/… (tilde expande para $HOME — classe 1) de "~/…" (tilde
1251
+ // NÃO expande dentro de aspas duplas — classe 2).
1252
+ function hookValueWasQuoted(raw) {
1253
+ return raw.length >= 2 && raw[0] === '"' && raw[raw.length - 1] === '"'
1254
+ }
1255
+
1256
+ // classifyHookAnchorage retorna a classe de ancoragem de rawStripped (com aspas externas já
1257
+ // removidas). wasQuoted indica se o valor original tinha aspas externas (obtido com
1258
+ // hookValueWasQuoted antes de chamar stripOuterQuotesForClassify). Ver HOOK_ANCHORAGE_CLASS_*
1259
+ // e ADR-2026-08-22.
1260
+ function classifyHookAnchorage(rawStripped, wasQuoted) {
1261
+ // Classe 1 — ancora na raiz do projeto.
1262
+ if (
1263
+ rawStripped.startsWith('$CLAUDE_PROJECT_DIR/') ||
1264
+ rawStripped.startsWith('$GEMINI_PROJECT_DIR/') ||
1265
+ rawStripped.startsWith('$(git rev-parse --show-toplevel)/') ||
1266
+ path.isAbsolute(rawStripped)
1267
+ ) {
1268
+ return HOOK_ANCHORAGE_CLASS_ANCHORED
1269
+ }
1270
+ // ~/… sem aspas: tilde expande para $HOME em qualquer shell POSIX — semanticamente ancorado.
1271
+ // "~/…" com aspas: tilde NÃO expande dentro de aspas duplas, logo a forma quebra — classe 2.
1272
+ if (rawStripped.startsWith('~/') && !wasQuoted) {
1273
+ return HOOK_ANCHORAGE_CLASS_ANCHORED
1274
+ }
1275
+ // Classe 2 — expande a partir do cwd.
1276
+ // ${PWD}/… tem a mesma semântica de $PWD/… (PWD é mandado pelo POSIX, sempre o cwd).
1277
+ if (
1278
+ rawStripped.startsWith('$PWD/') ||
1279
+ rawStripped.startsWith('${PWD}/') ||
1280
+ rawStripped.startsWith('./') ||
1281
+ rawStripped.startsWith('../') ||
1282
+ (!rawStripped.startsWith('$') && !path.isAbsolute(rawStripped))
1283
+ ) {
1284
+ return HOOK_ANCHORAGE_CLASS_CWD_DEPENDENT
1285
+ }
1286
+ // Classe 3 — indecidível; silêncio declarado.
1287
+ return HOOK_ANCHORAGE_CLASS_UNDECIDABLE
1288
+ }
1289
+
1290
+ // cwdDependentReason retorna o sufixo de mensagem específico por forma para violações de
1291
+ // classe 2, iniciando em "with a …". Dois formatos: formas contendo $PWD ou ${PWD} (em qualquer
1292
+ // posição, inclusive em wrappers sh -c / env) recebem a mensagem do $PWD; demais recebem
1293
+ // "bare relative path". Usa includes (não startsWith) para cobrir sh -c "$PWD/…" e env FOO=x $PWD/….
1294
+ function cwdDependentReason(rawStripped) {
1295
+ if (rawStripped.includes('$PWD') || rawStripped.includes('${PWD}')) {
1296
+ return 'with a $PWD path — $PWD expands to the current working directory, not the project root; run `trackfw update` to fix it'
1297
+ }
1298
+ return "with a bare relative path — this command only resolves from the project root and will silently fail when the agent's cwd is a subdirectory; run `trackfw update` to fix it"
1299
+ }
1300
+
1223
1301
  // collectCommandsWithMarker percorre recursivamente um valor JSON já decodificado e coleta todo
1224
1302
  // valor-string que contém marker, independentemente do nome do campo que o contém — junto com um
1225
1303
  // sinal estrutural (typeIsCommand) de que o objeto imediato que o contém também tem
@@ -1323,6 +1401,18 @@ function validateGuardHookResolvable(scriptMarker, cwd) {
1323
1401
  if (seen.has(seenKey)) continue
1324
1402
  seen.add(seenKey)
1325
1403
 
1404
+ // ADR-2026-08-22: classificar por ancoragem ANTES de resolver. Aspas externas são
1405
+ // sintaxe, não semântica — removê-las antes garante que "$PWD/…" receba o mesmo veredito.
1406
+ // wasQuoted distingue ~/… (classe 1) de "~/…" (classe 2).
1407
+ const wasQuoted = hookValueWasQuoted(m.raw)
1408
+ const rawStripped = stripOuterQuotesForClassify(m.raw)
1409
+ const anchorageClass = classifyHookAnchorage(rawStripped, wasQuoted)
1410
+ if (hf.requiresVarOrShellPrefix && anchorageClass === HOOK_ANCHORAGE_CLASS_CWD_DEPENDENT) {
1411
+ msgs.push(`${hf.relPath} (${hf.cli}) references ${scriptMarker} ${cwdDependentReason(rawStripped)}`)
1412
+ continue
1413
+ }
1414
+
1415
+ // Classe 1 (ancorado) e classe 3 (indecidível) prosseguem para a resolução existente.
1326
1416
  const resolved = resolveCredentialGuardHookPath(m.raw, root)
1327
1417
  if (resolved === null) continue
1328
1418
 
@@ -1901,6 +1991,24 @@ match_subcommand() {
1901
1991
  ;;
1902
1992
  esac
1903
1993
  done
1994
+ # git checkout -- <path> | git checkout . descarta alterações não commitadas do
1995
+ # caminho indicado, de forma irreversível, no worktree compartilhado — bloqueia
1996
+ # quando '--' aparece em qualquer posição (forma explícita de pathspec) ou quando
1997
+ # '.' aparece como token isolado. 'git checkout <branch>' sem nenhum dos dois
1998
+ # segue liberado por decisão (distinguir branch de caminho sem '--' é ambíguo, e
1999
+ # adivinhar produziria falso-positivo).
2000
+ checkout_path=0
2001
+ for tok2 in "$@"; do
2002
+ case "$tok2" in
2003
+ --|.)
2004
+ checkout_path=1
2005
+ ;;
2006
+ esac
2007
+ done
2008
+ if [ "$checkout_path" = "1" ]; then
2009
+ echo "checkout-path"
2010
+ return 0
2011
+ fi
1904
2012
  ;;
1905
2013
  switch)
1906
2014
  for tok2 in "$@"; do
@@ -1912,6 +2020,87 @@ match_subcommand() {
1912
2020
  esac
1913
2021
  done
1914
2022
  ;;
2023
+ stash)
2024
+ # git stash: liberado só para leitura (list/show) — bloqueia a forma bare
2025
+ # (equivale a "push"), push, save, clear e drop. Decisão de KG: bloquear a
2026
+ # classe inteira, não só os literais medidos (ver REQ). Repositório com um único
2027
+ # worktree compartilhado entre subagentes paralelos — um stash de um agente
2028
+ # remove as alterações não commitadas de todos os outros.
2029
+ stash_sub="\${1:-}"
2030
+ case "$stash_sub" in
2031
+ list|show)
2032
+ ;;
2033
+ *)
2034
+ echo "stash"
2035
+ return 0
2036
+ ;;
2037
+ esac
2038
+ ;;
2039
+ reset)
2040
+ # Só --hard bloqueia, em qualquer posição de token — --soft/--mixed (inclusive
2041
+ # sem flag, que é --mixed implícito) seguem liberados: --soft é o contorno
2042
+ # padrão para reempurrar trabalho staged via ` + "`" + `trackfw ship -m "..."` + "`" + ` (ainda falta commitar após --soft).
2043
+ for tok2 in "$@"; do
2044
+ case "$tok2" in
2045
+ --hard)
2046
+ echo "reset-hard"
2047
+ return 0
2048
+ ;;
2049
+ esac
2050
+ done
2051
+ ;;
2052
+ clean)
2053
+ # Bloqueia qualquer forma com force (-f, -fd, -fx, --force) ou -x/-X, EXCETO
2054
+ # quando -n/--dry-run também está presente (dry-run nunca apaga nada).
2055
+ clean_dry=0
2056
+ clean_force=0
2057
+ for tok2 in "$@"; do
2058
+ case "$tok2" in
2059
+ -n|--dry-run)
2060
+ clean_dry=1
2061
+ ;;
2062
+ -f*|--force|--force=*|-x|-X)
2063
+ clean_force=1
2064
+ ;;
2065
+ esac
2066
+ done
2067
+ if [ "$clean_dry" != "1" ] && [ "$clean_force" = "1" ]; then
2068
+ echo "clean-force"
2069
+ return 0
2070
+ fi
2071
+ ;;
2072
+ restore)
2073
+ # git restore --staged SOZINHO nunca toca o working tree (mexe só no
2074
+ # index), então segue liberado mesmo com path. Mas --worktree/-W (com ou
2075
+ # sem --staged junto) SEMPRE afeta o working tree — inclusive
2076
+ # "--staged --worktree", que restaura os dois — então bloqueia sempre que
2077
+ # --worktree/-W aparecer, e também no caso padrão (sem --staged em
2078
+ # nenhuma forma) com um argumento posicional (o path).
2079
+ restore_staged=0
2080
+ restore_worktree=0
2081
+ restore_positional=0
2082
+ for tok2 in "$@"; do
2083
+ case "$tok2" in
2084
+ --staged)
2085
+ restore_staged=1
2086
+ ;;
2087
+ --worktree|-W)
2088
+ restore_worktree=1
2089
+ ;;
2090
+ -*)
2091
+ ;;
2092
+ *)
2093
+ restore_positional=1
2094
+ ;;
2095
+ esac
2096
+ done
2097
+ if [ "$restore_positional" = "1" ]; then
2098
+ if [ "$restore_worktree" = "1" ] || [ "$restore_staged" != "1" ]; then
2099
+ echo "restore-path"
2100
+ return 0
2101
+ fi
2102
+ fi
2103
+ ;;
1915
2104
  branch)
1916
2105
  # git branch é majoritariamente leitura (sem args, -a, -r, -l, --list, -v/-vv,
1917
2106
  # --show-current, --contains, --no-contains, --merged, --no-merged, --sort=,
@@ -1967,8 +2156,44 @@ match_subcommand() {
1967
2156
  ;;
1968
2157
  esac
1969
2158
  done
2159
+ elif [ "\${1:-}" = "remove" ]; then
2160
+ # git worktree remove SEM -f/--force já recusa sozinho quando há alteração não
2161
+ # commitada no worktree indicado — só a forma com force é irreversível o bastante
2162
+ # para bloquear aqui.
2163
+ shift
2164
+ for tok2 in "$@"; do
2165
+ case "$tok2" in
2166
+ -f|--force)
2167
+ echo "worktree-remove-force"
2168
+ return 0
2169
+ ;;
2170
+ esac
2171
+ done
1970
2172
  fi
1971
2173
  ;;
2174
+ update-ref)
2175
+ # git update-ref reescreve um ref (inclusive refs/remotes/origin/*) sem tocar o
2176
+ # objeto apontado nem exigir push — foi o mecanismo que tornou alcançável o exploit
2177
+ # descrito no ADR-2026-08-19-caminho-governado-para-push-forcado-e-tag-de-release.md
2178
+ # (Emenda 1): forjar origin/<base> localmente para desviar o commit-alvo de trackfw
2179
+ # release tag. Sem forma de leitura equivalente a bloquear seletivamente — a
2180
+ # subcommand inteira é escrita — bloqueia sempre, sem exceção de token.
2181
+ echo "update-ref"
2182
+ return 0
2183
+ ;;
2184
+ rm)
2185
+ # git rm -f/--force apaga do working tree e do index de forma irreversível, mesma
2186
+ # classe de git clean -f/git reset --hard já bloqueados acima — sem exceção para
2187
+ # --cached (destrancar do index sem -f já segue liberado por não precisar de force).
2188
+ for tok2 in "$@"; do
2189
+ case "$tok2" in
2190
+ -f*|--force|--force=*)
2191
+ echo "rm-force"
2192
+ return 0
2193
+ ;;
2194
+ esac
2195
+ done
2196
+ ;;
1972
2197
  esac
1973
2198
  done <<EOF
1974
2199
  $normalized
@@ -1980,22 +2205,46 @@ SUBCOMMAND=$(match_subcommand "$CMD_RAW") || exit 0
1980
2205
 
1981
2206
  case "$SUBCOMMAND" in
1982
2207
  checkout-b)
1983
- REASON="trackfw: git checkout -b bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_REF_BACKTICK + `. Ver CLAUDE.md §1."
2208
+ REASON="trackfw: git checkout -b bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_REF_BACKTICK + `. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1984
2209
  ;;
1985
2210
  switch-c)
1986
- REASON="trackfw: git switch -c bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_REF_BACKTICK + `. Ver CLAUDE.md §1."
2211
+ REASON="trackfw: git switch -c bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_REF_BACKTICK + `. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1987
2212
  ;;
1988
2213
  branch-create)
1989
- REASON="trackfw: git branch bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_REF_BACKTICK + `. Ver CLAUDE.md §1."
2214
+ REASON="trackfw: git branch bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_REF_BACKTICK + `. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1990
2215
  ;;
1991
2216
  worktree-add-b)
1992
- REASON="trackfw: git worktree add -b bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_REF_BACKTICK + `. Ver CLAUDE.md §1."
2217
+ REASON="trackfw: git worktree add -b bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw branch new <type>/<slug>` + GBG_REF_BACKTICK + `. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1993
2218
  ;;
1994
2219
  commit)
1995
- REASON="trackfw: git commit bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw commit -m '<mensagem>'` + GBG_REF_BACKTICK + `. Ver CLAUDE.md §1."
2220
+ REASON="trackfw: git commit bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw commit -m '<mensagem>'` + GBG_REF_BACKTICK + `. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1996
2221
  ;;
1997
2222
  push)
1998
- REASON="trackfw: git push bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw ship` + GBG_REF_BACKTICK + `. Ver CLAUDE.md §1."
2223
+ REASON="trackfw: git push bruto bloqueado. Use ` + GBG_REF_BACKTICK + `trackfw push` + GBG_REF_BACKTICK + ` (para empurrar commits já criados), ` + GBG_REF_BACKTICK + `trackfw ship` + GBG_REF_BACKTICK + ` (para commit+push+PR em uma etapa) ou ` + GBG_REF_BACKTICK + `trackfw release tag` + GBG_REF_BACKTICK + ` (para publicar uma tag de release). Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
2224
+ ;;
2225
+ stash)
2226
+ REASON="trackfw: git stash bruto bloqueado — worktree compartilhado entre subagentes, um stash remove as alterações não commitadas de todos os outros. ` + GBG_REF_BACKTICK + `git stash list` + GBG_REF_BACKTICK + `/` + GBG_REF_BACKTICK + `git stash show` + GBG_REF_BACKTICK + ` seguem liberados; para guardar trabalho em progresso, use uma branch própria via ` + GBG_REF_BACKTICK + `trackfw branch new` + GBG_REF_BACKTICK + ` e commit nela. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
2227
+ ;;
2228
+ reset-hard)
2229
+ REASON="trackfw: git reset --hard bruto bloqueado — descarta de forma irreversível as alterações não commitadas de todo o worktree compartilhado. ` + GBG_REF_BACKTICK + `git reset --soft` + GBG_REF_BACKTICK + `/` + GBG_REF_BACKTICK + `--mixed` + GBG_REF_BACKTICK + ` seguem liberados (ex.: ` + GBG_REF_BACKTICK + `git reset --soft HEAD~1` + GBG_REF_BACKTICK + ` é o caminho padrão; use ` + GBG_REF_BACKTICK + `trackfw ship -m "..."` + GBG_REF_BACKTICK + ` para commitar e empurrar). Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
2230
+ ;;
2231
+ clean-force)
2232
+ REASON="trackfw: git clean -f/-x bruto bloqueado — apaga arquivos não rastreados do worktree compartilhado, de forma irreversível. ` + GBG_REF_BACKTICK + `git clean -n` + GBG_REF_BACKTICK + `/` + GBG_REF_BACKTICK + `--dry-run` + GBG_REF_BACKTICK + ` segue liberado para revisar antes o que seria apagado. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
2233
+ ;;
2234
+ restore-path)
2235
+ REASON="trackfw: git restore <path> bruto bloqueado — descarta de forma irreversível as alterações não commitadas do caminho indicado. ` + GBG_REF_BACKTICK + `git restore --staged` + GBG_REF_BACKTICK + ` (não toca o working tree) segue liberado; para descartar de fato, confirme antes com o usuário. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
2236
+ ;;
2237
+ checkout-path)
2238
+ REASON="trackfw: git checkout -- <path>/git checkout . bruto bloqueado — descarta de forma irreversível as alterações não commitadas do caminho indicado. ` + GBG_REF_BACKTICK + `git checkout <branch>` + GBG_REF_BACKTICK + `/` + GBG_REF_BACKTICK + `git switch <branch>` + GBG_REF_BACKTICK + ` seguem liberados; para descartar de fato, confirme antes com o usuário. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
2239
+ ;;
2240
+ update-ref)
2241
+ REASON="trackfw: git update-ref bruto bloqueado — reescreve um ref (inclusive refs/remotes/origin/*) sem tocar o objeto apontado nem exigir push, o que permite forjar o commit-alvo que ` + GBG_REF_BACKTICK + `trackfw release tag` + GBG_REF_BACKTICK + ` publicaria. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
2242
+ ;;
2243
+ worktree-remove-force)
2244
+ REASON="trackfw: git worktree remove -f/--force bruto bloqueado — remove um worktree e descarta de forma irreversível qualquer alteração não commitada nele. ` + GBG_REF_BACKTICK + `git worktree remove` + GBG_REF_BACKTICK + ` sem force segue liberado (recusa sozinho quando há algo não commitado). Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
2245
+ ;;
2246
+ rm-force)
2247
+ REASON="trackfw: git rm -f/--force bruto bloqueado — apaga arquivos do working tree e do index de forma irreversível, mesma classe de ` + GBG_REF_BACKTICK + `git clean -f` + GBG_REF_BACKTICK + `/` + GBG_REF_BACKTICK + `git reset --hard` + GBG_REF_BACKTICK + ` já bloqueados. Nada antes deste comando foi executado (comando composto é bloqueado por inteiro). Ver CLAUDE.md §1."
1999
2248
  ;;
2000
2249
  *)
2001
2250
  exit 0
@@ -3021,6 +3270,14 @@ module.exports = {
3021
3270
  resolveAdrStatus,
3022
3271
  // ROADMAP-2026-08-12-mitigacao-do-fail-open-do-credential-guard, ML-1A
3023
3272
  resolveCredentialGuardHookPath,
3273
+ // ADR-2026-08-22 (ML-1A): classificação por ancoragem
3274
+ HOOK_ANCHORAGE_CLASS_ANCHORED,
3275
+ HOOK_ANCHORAGE_CLASS_CWD_DEPENDENT,
3276
+ HOOK_ANCHORAGE_CLASS_UNDECIDABLE,
3277
+ stripOuterQuotesForClassify,
3278
+ hookValueWasQuoted,
3279
+ classifyHookAnchorage,
3280
+ cwdDependentReason,
3024
3281
  collectCredentialGuardCommands,
3025
3282
  validateCredentialGuardHookResolvable,
3026
3283
  // ROADMAP-2026-08-12-deteccao-de-adulteracao-do-credential-guard-regra-de-validate, ML-1A