up-cc 2.0.3 → 2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "up-cc",
3
- "version": "2.0.3",
3
+ "version": "2.2.0",
4
4
  "description": "Spec-driven development for Claude Code, Gemini, OpenCode and OpenAI Codex. Brainstorm-first, GitHub-native, TDD por tipo.",
5
5
  "bin": {
6
6
  "up-cc": "up/bin/install.js"
package/up/CHANGELOG.md CHANGED
@@ -4,6 +4,85 @@ Todas as mudancas relevantes do `up-cc` ficam documentadas aqui. O formato segue
4
4
  o espirito de [Keep a Changelog](https://keepachangelog.com/) e o versionamento
5
5
  e [SemVer](https://semver.org/). v2.0.0 e um **major** (breaking change).
6
6
 
7
+ ## 2.2.0
8
+
9
+ > O `.plano/` sempre sobreviveu ao `/clear` no DISCO, mas a RETOMADA era pull: o
10
+ > hook reinjetava so a doutrina e o agente tinha que escolher ler o STATE.md. Agora
11
+ > e push: o SessionStart injeta a posicao atual sozinho.
12
+
13
+ ### Corrigido
14
+
15
+ - **SessionStart agora injeta `.plano/STATE.md`, nao so a doutrina.** O hook
16
+ `up-session-start.js` le `<cwd>/.plano/STATE.md` (cwd do payload, fallback
17
+ `process.cwd()`) e injeta um trecho capado (2800 chars) num bloco
18
+ `<UP-ESTADO-ATUAL>` junto do bootstrap. Resultado: a cada `/clear` (e no
19
+ startup) a posicao do projeto volta pro contexto **deterministicamente**, sem
20
+ depender do agente escolher ler. Resolve o "as vezes nao sobrevive ao /clear".
21
+ Fail-open mantido: sem `.plano`, so a doutrina (comportamento anterior). Head
22
+ bruto, independe do formato PT/EN do STATE.md.
23
+
24
+ ### Testes
25
+
26
+ - `up/hooks/up-session-start.test.cjs`: 5 casos red-green invocando o hook como
27
+ subprocesso (stdin->stdout), cobrindo clear/startup com e sem `.plano`, skip de
28
+ `resume`, e cap de STATE.md gigante.
29
+
30
+ ### Conhecido (nao corrigido aqui)
31
+
32
+ - `up-tools state-snapshot` parseia rotulos em INGLES e os STATE.md reais sao em
33
+ PORTUGUES, entao retorna campos null em projeto real. O hook NAO usa
34
+ state-snapshot (le STATE.md direto), entao a retomada nao e afetada. Fica
35
+ registrado pra um fix futuro do parser.
36
+
37
+ ## 2.1.0
38
+
39
+ > GitHub e a interacao humana viram **eixos separados**. Antes, `--solo` desligava o
40
+ > GitHub (zero worktree/issue/PR) E pulava a cerimonia. Num run autonomo/loop isso fazia
41
+ > o projeto inteiro ir pro `main` sem nenhum artefato GitHub, mesmo com remote conectado.
42
+ > Agora `--solo` NAO desliga o GitHub: e so autonomia. Pra pular o GitHub use `--local`.
43
+
44
+ ### Mudado (atencao: semantica de `--solo`)
45
+
46
+ - **`--solo` agora MANTEM o GitHub-nativo.** Solo virou "autonomo total": cria
47
+ worktree + branch + issue + PR e auto-mergeia, SEM menu e SEM gate visual (pra
48
+ loop/headless onde ninguem aprova). Nao desliga mais o GitHub.
49
+ - **`--local` e o novo escape hatch sem GitHub** (o comportamento que `--solo` tinha
50
+ ate a 2.0.x): commit atomico na branch atual, zero worktree/issue/PR/rede. `/up:rapido`
51
+ segue como o atalho nomeado pro mesmo modo.
52
+ - **`--auto`** continua igual: pula so o menu de fim de fase, mantem GitHub, e o gate
53
+ visual ainda roda se `require_visual_test=true`.
54
+
55
+ ### Adicionado
56
+
57
+ - **Deteccao de GitHub por `gh` CLI OU MCP do GitHub.** O eixo GitHub liga sempre que
58
+ ha remote + (`gh` autenticado OU MCP do GitHub conectado). Transporte de issue/PR:
59
+ `gh` (CLI cria direto), `mcp` (o workflow cria via `mcp__...github__*` e grava de
60
+ volta), `none` (sem remote -> git local). Worktree/branch/push sao git local e
61
+ acontecem sempre, offline-ok.
62
+ - **Subcomandos `github record-issue` / `github record-pr`** no `up-tools.cjs` pra
63
+ gravar no `git-map.json` artefatos criados fora do `.cjs` (ex: via MCP). `record-pr
64
+ --merged` fecha a fase e limpa worktree+branch.
65
+ - **Flag `--local`** no `/up:build` e `start-phase`/`finish-phase` (`--mode local`).
66
+ - **Config builder grava `github_native: true` explicito** (up-arquiteto, workflows/up),
67
+ deixando a intencao visivel em run autonomo (antes dependia do default implicito).
68
+ - **Testes red-green** do `github.cjs` em `up/bin/lib/github.test.cjs` (10 casos; repo
69
+ git temp + remote bare local + seam `UP_FORCE_NO_GH`).
70
+
71
+ ### Corrigido
72
+
73
+ - **Bug de doc em `workflows/up.md`** que dizia `--solo (default, commit na branch atual)`
74
+ e descrevia `--pr` como o fluxo GitHub (vocabulario v1). Era a unica fonte que podia
75
+ induzir um agente autonomo a escolher `--solo` e pular o GitHub. Reescrito pro v2.
76
+ - **`--local` numa fase nao polui mais o `github_native` global** do `git-map.json`
77
+ (poderia degradar fases nao-local seguintes). `finish-phase auto` passou a consultar
78
+ o config, nao o flag global do mapa.
79
+
80
+ ### Migracao
81
+
82
+ - Quem usava `--solo` pra **commit local sem GitHub** deve trocar para `--local`.
83
+ - Quem quer **autonomo mantendo GitHub** (loop/headless): use `--solo` (sem gate visual)
84
+ ou `--auto` (com gate visual se `require_visual_test=true`).
85
+
7
86
  ## 2.0.0
8
87
 
9
88
  > Redesign completo do UP. **Breaking change.** Os comandos antigos somem (nao ha
@@ -305,11 +305,14 @@ Modo builder ativo. Proxima acao: planejar fase 1.
305
305
  "granularity": "standard",
306
306
  "parallelization": true,
307
307
  "commit_docs": true,
308
- "builder_mode": true
308
+ "builder_mode": true,
309
+ "github_native": true
309
310
  }
310
311
  ```
311
312
 
312
- Note: `builder_mode: true` sinaliza que o projeto foi criado em modo autonomo.
313
+ Note: `builder_mode: true` sinaliza que o projeto foi criado em modo autonomo. `github_native: true`
314
+ explicito deixa a intencao gravada no arquivo (o build usa true por default; gravar evita ambiguidade
315
+ em run autonomo/loop). So mude pra `false` se o projeto e local sem GitHub.
313
316
  </state_and_config>
314
317
 
315
318
  <brownfield_mode>
@@ -114,8 +114,9 @@ function loadConfig(cwd) {
114
114
  auto_advance: false,
115
115
  instrumentation: { enabled: true },
116
116
  budget_ceiling: null,
117
- // Fase 4: GitHub-native e o PADRAO. --solo no comando forca github_native=false
118
- // so na execucao (o build.md le a flag e roteia para o modo solo, sem persistir).
117
+ // GitHub-native e o PADRAO. So --local (ou este flag = false) desliga. --solo NAO
118
+ // desliga: solo = autonomia (sem menu/gate visual), GitHub segue ligado. Transporte
119
+ // de issue/PR: gh CLI OU MCP do GitHub (worktree/branch sao git local, sempre rodam).
119
120
  github_native: true,
120
121
  merge_strategy: 'squash',
121
122
  // Fim de fase: se a fase tem UI, sobe dev server e exige aprovacao visual ANTES do merge.
@@ -34,8 +34,13 @@ function getRemoteUrl(cwd) {
34
34
  return res.exitCode === 0 ? res.stdout.trim() : null;
35
35
  }
36
36
 
37
- /** `gh` instalado E autenticado? */
37
+ /**
38
+ * `gh` instalado E autenticado?
39
+ * Seam de teste: UP_FORCE_NO_GH=1 forca indisponivel; UP_FORCE_GH=1 forca disponivel.
40
+ */
38
41
  function ghAvailable(cwd) {
42
+ if (process.env.UP_FORCE_NO_GH === '1') return false;
43
+ if (process.env.UP_FORCE_GH === '1') return true;
39
44
  try {
40
45
  execSync('gh auth status', { cwd, stdio: 'pipe' });
41
46
  return true;
@@ -69,12 +74,34 @@ function execGh(cwd, args) {
69
74
  }
70
75
  }
71
76
 
72
- /** github-native ativo? (config.github_native default true) AND gh+remote disponiveis. */
73
- function githubMode(cwd, solo) {
74
- if (solo) return false;
77
+ /**
78
+ * EIXO A — queremos artefatos GitHub (worktree/branch sempre; issue/PR se remote)?
79
+ * Ligado a menos que: `--local`, config.github_native=false, ou sem remote origin.
80
+ * NAO depende de `gh`: gh-vs-MCP e o transporte (eixo separado), nao o liga/desliga.
81
+ */
82
+ function gitWantsRemote(cwd, local) {
83
+ if (local) return false;
75
84
  const config = loadConfig(cwd);
76
85
  if (config.github_native === false) return false;
77
- return ghAvailable(cwd) && gitHasRemote(cwd);
86
+ return gitHasRemote(cwd);
87
+ }
88
+
89
+ /**
90
+ * Transporte pra criar issue/PR remoto: 'gh' | 'mcp' | 'none'.
91
+ * - 'gh': gh CLI disponivel -> github.cjs cria direto (deterministico).
92
+ * - 'mcp': remote existe mas sem gh -> o WORKFLOW (LLM) cria via MCP do GitHub,
93
+ * porque um subprocesso Node nao chama tools MCP. github.cjs sinaliza e
94
+ * grava de volta via recordIssue/recordPr.
95
+ * - 'none': sem remote (ou --local / config off) -> git local puro, sem issue/PR.
96
+ */
97
+ function issueTransport(cwd, local) {
98
+ if (!gitWantsRemote(cwd, local)) return 'none';
99
+ return ghAvailable(cwd) ? 'gh' : 'mcp';
100
+ }
101
+
102
+ /** Compat: github-native "classico" = transporte gh disponivel. */
103
+ function githubMode(cwd, local) {
104
+ return issueTransport(cwd, local) === 'gh';
78
105
  }
79
106
 
80
107
  // =====================================================================
@@ -189,24 +216,27 @@ function phaseKey(phase) {
189
216
  // =====================================================================
190
217
 
191
218
  /**
192
- * startPhase({cwd, phase, slug, solo})
193
- * - Cria branch up/fase-NN-slug e worktree FORA do repo.
194
- * - Se github_native e gh+remote disponiveis: cria gh issue.
195
- * - Com --solo: degrada (sem worktree, sem issue) trabalha na branch atual.
196
- * - Sem gh/remote: cria worktree+branch local, issue=null (fail-open, nunca crasha).
197
- * - Escreve .plano/git-map.json. Retorna {branch, worktree, issue, issue_url, mode, warnings}.
219
+ * startPhase({cwd, phase, slug, local, solo})
220
+ * EIXO A (GitHub) e EIXO B (interacao) sao SEPARADOS:
221
+ * - `local=true` (flag --local / config off / /up:rapido): SEM worktree/issue, commit
222
+ * na branch atual. E o unico jeito de desligar o GitHub aqui.
223
+ * - `solo` NAO desliga o GitHub: solo so afeta o gate visual e o merge (no build.md).
224
+ * Por isso `solo` e aceito mas ignorado neste passo (compat).
225
+ * - Caso contrario: cria worktree+branch SEMPRE (git local, offline-ok). Issue conforme
226
+ * o transporte: 'gh' cria direto; 'mcp' deixa pro workflow criar via MCP (retorna
227
+ * `pending.issue` e mode 'github-mcp'); 'none' (sem remote) so git local.
228
+ * - Escreve .plano/git-map.json. Retorna {branch, worktree, issue, issue_url, mode, transport, warnings, pending?}.
198
229
  */
199
- function startPhase({ cwd, phase, slug, solo = false }) {
230
+ function startPhase({ cwd, phase, slug, local = false, solo = false }) {
231
+ void solo; // solo nao desliga GitHub (eixo de interacao, tratado no build.md)
200
232
  const warnings = [];
201
233
  const key = phaseKey(phase);
202
234
  const branch = branchName(phase, slug);
203
- const ghOn = githubMode(cwd, solo);
204
235
 
205
236
  const map = readGitMap(cwd);
206
- if (solo) map.github_native = false;
207
237
 
208
- // --- modo SOLO: nada de worktree/issue, trabalha na branch atual ---
209
- if (solo) {
238
+ // --- LOCAL: nada de worktree/issue, trabalha na branch atual ---
239
+ if (local) {
210
240
  const cur = execGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
211
241
  const currentBranch = cur.exitCode === 0 ? cur.stdout.trim() : null;
212
242
  map.phases[key] = {
@@ -225,22 +255,21 @@ function startPhase({ cwd, phase, slug, solo = false }) {
225
255
  worktree: null,
226
256
  issue: null,
227
257
  issue_url: null,
228
- mode: 'solo',
258
+ mode: 'local',
259
+ transport: 'none',
229
260
  warnings,
230
261
  };
231
262
  }
232
263
 
233
- // --- modo GitHub-native ou git-local: criar worktree+branch ---
264
+ // --- worktree+branch SEMPRE (git local, independe de gh/MCP) ---
234
265
  const wt = worktreePath(cwd, phase, slug);
235
266
  shieldWorktreeDir(cwd);
236
267
 
237
- // Worktree e branch existem? (idempotente / recovery)
238
268
  const existing = (map.phases[key] && map.phases[key].worktree) || null;
239
269
  let worktreeCreated = false;
240
270
 
241
271
  if (!fs.existsSync(wt)) {
242
272
  fs.mkdirSync(path.dirname(wt), { recursive: true });
243
- // Branch ja existe? Anexa sem -b. Senao cria com -b.
244
273
  const branchExists = execGit(cwd, ['rev-parse', '--verify', '--quiet', branch]).exitCode === 0;
245
274
  const wtArgs = branchExists
246
275
  ? ['worktree', 'add', wt, branch]
@@ -248,7 +277,6 @@ function startPhase({ cwd, phase, slug, solo = false }) {
248
277
  const wtRes = execGit(cwd, wtArgs);
249
278
  if (wtRes.exitCode !== 0) {
250
279
  warnings.push('worktree_failed: ' + (wtRes.stderr || wtRes.stdout));
251
- // FAIL-OPEN: cai pra branch local sem worktree
252
280
  const co = execGit(cwd, branchExists ? ['checkout', branch] : ['checkout', '-b', branch]);
253
281
  if (co.exitCode !== 0) warnings.push('branch_checkout_failed: ' + (co.stderr || co.stdout));
254
282
  } else {
@@ -259,13 +287,16 @@ function startPhase({ cwd, phase, slug, solo = false }) {
259
287
  }
260
288
  const worktree = worktreeCreated || existing ? wt : null;
261
289
 
262
- // --- issue (so em github-native) ---
290
+ // --- issue: depende do transporte (gh | mcp | none) ---
291
+ const transport = issueTransport(cwd, local);
263
292
  let issue = null;
264
293
  let issueUrl = null;
265
- if (ghOn) {
266
- const title = `[fase ${padPhase(phase)}] ${slug || branch}`;
267
- const body = buildIssueBody(cwd, phase, slug);
268
- const res = execGh(cwd, ['issue', 'create', '--title', title, '--body', body]);
294
+ let pending = null;
295
+ const issueTitle = `[fase ${padPhase(phase)}] ${slug || branch}`;
296
+ const issueBody = buildIssueBody(cwd, phase, slug);
297
+
298
+ if (transport === 'gh') {
299
+ const res = execGh(cwd, ['issue', 'create', '--title', issueTitle, '--body', issueBody]);
269
300
  if (res.exitCode === 0) {
270
301
  issueUrl = res.stdout.trim().split('\n').pop().trim() || null;
271
302
  const m = issueUrl && issueUrl.match(/\/issues\/(\d+)/);
@@ -273,9 +304,13 @@ function startPhase({ cwd, phase, slug, solo = false }) {
273
304
  } else {
274
305
  warnings.push('issue_create_failed: ' + (res.stderr || res.stdout));
275
306
  }
276
- } else if (!solo) {
277
- if (!ghAvailable(cwd)) warnings.push('gh indisponivel ou nao autenticado: degradando para git local (sem issue/PR)');
278
- else if (!gitHasRemote(cwd)) warnings.push('sem remote origin: degradando para git local (sem issue/PR)');
307
+ } else if (transport === 'mcp') {
308
+ // Node nao chama MCP: o workflow cria a issue via mcp__...github__issue_write
309
+ // e grava de volta com `up-tools github record-issue`. Worktree/branch ja existem.
310
+ pending = { issue: { title: issueTitle, body: issueBody } };
311
+ warnings.push('gh indisponivel: criar issue via MCP (github-mcp) e gravar com record-issue. worktree/branch ja prontos.');
312
+ } else {
313
+ warnings.push('sem remote origin: git local (sem issue/PR).');
279
314
  }
280
315
 
281
316
  map.phases[key] = {
@@ -290,14 +325,10 @@ function startPhase({ cwd, phase, slug, solo = false }) {
290
325
  };
291
326
  writeGitMap(cwd, map);
292
327
 
293
- return {
294
- branch,
295
- worktree,
296
- issue,
297
- issue_url: issueUrl,
298
- mode: ghOn ? 'github' : 'git-local',
299
- warnings,
300
- };
328
+ const mode = transport === 'gh' ? 'github' : transport === 'mcp' ? 'github-mcp' : 'git-local';
329
+ const ret = { branch, worktree, issue, issue_url: issueUrl, mode, transport, warnings };
330
+ if (pending) ret.pending = pending;
331
+ return ret;
301
332
  }
302
333
 
303
334
  /** Body da issue: goal + criterios do ROADMAP, se disponiveis. */
@@ -317,12 +348,34 @@ function buildIssueBody(cwd, phase, slug) {
317
348
  // finishPhase — solo | menu | auto/pr
318
349
  // =====================================================================
319
350
 
351
+ /** Remove worktree + branch local (best-effort). Usado por finishPhase e recordPr. */
352
+ function cleanupWorktreeBranch(cwd, worktree, branch, warnings) {
353
+ if (worktree && fs.existsSync(worktree)) {
354
+ const rm = execGit(cwd, ['worktree', 'remove', worktree]);
355
+ if (rm.exitCode !== 0) {
356
+ const rmF = execGit(cwd, ['worktree', 'remove', '--force', worktree]);
357
+ if (rmF.exitCode !== 0) warnings.push('worktree_remove_failed: ' + (rmF.stderr || rmF.stdout));
358
+ }
359
+ try { if (fs.existsSync(worktree)) fs.rmdirSync(worktree); } catch (e) { /* nao-vazio: ignora */ }
360
+ try {
361
+ const parent = path.dirname(worktree);
362
+ if (fs.existsSync(parent) && fs.readdirSync(parent).length === 0) fs.rmdirSync(parent);
363
+ } catch (e) { /* ignora */ }
364
+ }
365
+ if (branch) {
366
+ execGit(cwd, ['branch', '-D', branch]); // silencioso: gh/merge ja pode ter removido
367
+ }
368
+ }
369
+
320
370
  /**
321
371
  * finishPhase({cwd, phase, mode, strategy})
322
- * - solo: nao faz nada (ja committado na branch atual). Marca status=done.
372
+ * - local (alias 'solo'): nao faz nada (ja committado na branch atual). status=done.
323
373
  * - menu: imprime as 4 opcoes pro orquestrador perguntar (nao age).
324
- * - auto (a.k.a. pr): gh pr create (body "Closes #<issue>") -> merge (squash default)
325
- * -> cleanup worktree+branch. Sem remote: merge LOCAL da branch na base, cleanup.
374
+ * - auto (a.k.a. pr): conforme o transporte ('gh' | 'mcp' | 'none'):
375
+ * 'gh' -> push + gh pr create (Closes #issue) + merge + cleanup.
376
+ * 'mcp' -> push e PARA: retorna action 'needs-mcp-pr' + pr_payload pro workflow
377
+ * criar/mergear o PR via MCP e fechar com `record-pr --merged` (que limpa).
378
+ * 'none' -> merge LOCAL fail-open + cleanup.
326
379
  * Atualiza git-map.json. Retorna estado da operacao.
327
380
  */
328
381
  function finishPhase({ cwd, phase, mode = 'menu', strategy }) {
@@ -332,12 +385,12 @@ function finishPhase({ cwd, phase, mode = 'menu', strategy }) {
332
385
  const entry = map.phases[key] || {};
333
386
  const mergeStrategy = strategy || map.merge_strategy || loadConfig(cwd).merge_strategy || 'squash';
334
387
 
335
- // --- SOLO: nada a fazer, ja committado ---
336
- if (mode === 'solo') {
388
+ // --- LOCAL / SOLO: nada a fazer, ja committado ---
389
+ if (mode === 'local' || mode === 'solo') {
337
390
  entry.status = 'done';
338
391
  map.phases[key] = entry;
339
392
  writeGitMap(cwd, map);
340
- return { mode: 'solo', action: 'none', status: 'done', warnings };
393
+ return { mode, action: 'none', status: 'done', warnings };
341
394
  }
342
395
 
343
396
  // --- MENU: imprime as 4 opcoes, nao age ---
@@ -361,7 +414,10 @@ function finishPhase({ cwd, phase, mode = 'menu', strategy }) {
361
414
  if (mode === 'auto' || mode === 'pr') {
362
415
  const branch = entry.branch || branchName(phase, '');
363
416
  const worktree = entry.worktree || null;
364
- const ghOn = ghAvailable(cwd) && gitHasRemote(cwd) && map.github_native !== false;
417
+ // mode 'auto' ja significa "quero GitHub": consulta o config (intencao do projeto),
418
+ // nao o flag global do git-map (que uma fase --local anterior poderia ter mexido).
419
+ const wants = gitHasRemote(cwd) && loadConfig(cwd).github_native !== false;
420
+ const transport = wants ? (ghAvailable(cwd) ? 'gh' : 'mcp') : 'none';
365
421
 
366
422
  // Base de merge (default: branch ativa na main do repo)
367
423
  const baseRes = execGit(cwd, ['symbolic-ref', '--short', 'HEAD']);
@@ -370,16 +426,41 @@ function finishPhase({ cwd, phase, mode = 'menu', strategy }) {
370
426
  let pr = entry.pr || null;
371
427
  let prUrl = entry.pr_url || null;
372
428
 
373
- if (ghOn) {
374
- // push da branch (necessario pro PR). Worktree ja tem a branch checada.
429
+ const issueRef = entry.issue ? `\n\nCloses #${entry.issue}` : '';
430
+ const prTitle = `Fase ${padPhase(phase)}` + (entry.branch ? `: ${entry.branch.replace(/^up\/fase-\d+-?/, '')}` : '');
431
+ const prBody = `Entrega da fase ${padPhase(phase)} (UP github-native).${issueRef}`;
432
+
433
+ // push da branch (necessario pro PR, gh OU MCP). Worktree tem a branch checada.
434
+ if (transport === 'gh' || transport === 'mcp') {
375
435
  const pushCwd = worktree && fs.existsSync(worktree) ? worktree : cwd;
376
436
  const push = execGit(pushCwd, ['push', '-u', 'origin', branch]);
377
437
  if (push.exitCode !== 0) warnings.push('push_failed: ' + (push.stderr || push.stdout));
438
+ }
378
439
 
379
- // gh pr create (Closes #issue no body)
380
- const issueRef = entry.issue ? `\n\nCloses #${entry.issue}` : '';
381
- const prTitle = `Fase ${padPhase(phase)}` + (entry.branch ? `: ${entry.branch.replace(/^up\/fase-\d+-?/, '')}` : '');
382
- const prBody = `Entrega da fase ${padPhase(phase)} (UP github-native).${issueRef}`;
440
+ // --- transporte MCP: push feito, PR fica pro workflow (LLM) via MCP ---
441
+ if (transport === 'mcp') {
442
+ map.phases[key] = entry; // status segue in_progress ate record-pr
443
+ writeGitMap(cwd, map);
444
+ return {
445
+ mode,
446
+ action: 'needs-mcp-pr',
447
+ transport: 'mcp',
448
+ pr_payload: {
449
+ base,
450
+ head: branch,
451
+ title: prTitle,
452
+ body: prBody,
453
+ issue: entry.issue || null,
454
+ strategy: mergeStrategy,
455
+ worktree,
456
+ },
457
+ branch,
458
+ status: entry.status || 'in_progress',
459
+ warnings,
460
+ };
461
+ }
462
+
463
+ if (transport === 'gh') {
383
464
  const create = execGh(cwd, ['pr', 'create', '--base', base, '--head', branch, '--title', prTitle, '--body', prBody]);
384
465
  if (create.exitCode === 0) {
385
466
  prUrl = create.stdout.trim().split('\n').pop().trim() || null;
@@ -389,7 +470,6 @@ function finishPhase({ cwd, phase, mode = 'menu', strategy }) {
389
470
  warnings.push('pr_create_failed: ' + (create.stderr || create.stdout));
390
471
  }
391
472
 
392
- // merge via gh
393
473
  const stratFlag = mergeStrategy === 'merge' ? '--merge' : mergeStrategy === 'rebase' ? '--rebase' : '--squash';
394
474
  const mergeTarget = pr ? String(pr) : branch;
395
475
  const merge = execGh(cwd, ['pr', 'merge', mergeTarget, stratFlag, '--delete-branch']);
@@ -401,7 +481,6 @@ function finishPhase({ cwd, phase, mode = 'menu', strategy }) {
401
481
  } else {
402
482
  // FAIL-OPEN: merge LOCAL (sem remote/gh)
403
483
  warnings.push('sem gh/remote: merge local da branch ' + branch + ' em ' + base);
404
- // Garante base checada no repo principal
405
484
  const co = execGit(cwd, ['checkout', base]);
406
485
  if (co.exitCode !== 0) warnings.push('checkout_base_failed: ' + (co.stderr || co.stdout));
407
486
  const stratArgs = mergeStrategy === 'squash'
@@ -421,35 +500,19 @@ function finishPhase({ cwd, phase, mode = 'menu', strategy }) {
421
500
  }
422
501
  }
423
502
 
424
- // cleanup: remove worktree e branch local
425
- if (worktree && fs.existsSync(worktree)) {
426
- const rm = execGit(cwd, ['worktree', 'remove', worktree]);
427
- if (rm.exitCode !== 0) {
428
- const rmF = execGit(cwd, ['worktree', 'remove', '--force', worktree]);
429
- if (rmF.exitCode !== 0) warnings.push('worktree_remove_failed: ' + (rmF.stderr || rmF.stdout));
430
- }
431
- // best-effort: remove a casca vazia que git deixa, e o dir-pai .up-worktrees/<repo> se vazio
432
- try { if (fs.existsSync(worktree)) fs.rmdirSync(worktree); } catch (e) { /* nao-vazio: ignora */ }
433
- try {
434
- const parent = path.dirname(worktree);
435
- if (fs.existsSync(parent) && fs.readdirSync(parent).length === 0) fs.rmdirSync(parent);
436
- } catch (e) { /* ignora */ }
437
- }
438
- // remove branch local (se ainda existir e nao for a branch ativa)
439
- const delBranch = execGit(cwd, ['branch', '-D', branch]);
440
- if (delBranch.exitCode !== 0 && !/not found|não encontrad/i.test(delBranch.stderr)) {
441
- // silencioso: gh --delete-branch ja pode ter removido
442
- }
503
+ cleanupWorktreeBranch(cwd, worktree, branch, warnings);
443
504
 
444
505
  entry.pr = pr;
445
506
  entry.pr_url = prUrl;
446
- if (!entry.status || entry.status === 'in_progress') entry.status = ghOn ? 'merged' : entry.status;
507
+ entry.worktree = null;
508
+ if (!entry.status || entry.status === 'in_progress') entry.status = transport === 'gh' ? 'merged' : entry.status;
447
509
  map.phases[key] = entry;
448
510
  writeGitMap(cwd, map);
449
511
 
450
512
  return {
451
- mode: mode,
513
+ mode,
452
514
  action: 'merged',
515
+ transport,
453
516
  pr,
454
517
  pr_url: prUrl,
455
518
  branch,
@@ -459,10 +522,47 @@ function finishPhase({ cwd, phase, mode = 'menu', strategy }) {
459
522
  };
460
523
  }
461
524
 
462
- warnings.push('modo desconhecido: ' + mode + ' (use solo|menu|auto)');
525
+ warnings.push('modo desconhecido: ' + mode + ' (use local|menu|auto)');
463
526
  return { mode, action: 'noop', warnings };
464
527
  }
465
528
 
529
+ // =====================================================================
530
+ // recordIssue / recordPr — gravam artefatos criados FORA (via MCP no workflow)
531
+ // =====================================================================
532
+
533
+ /** Grava no git-map a issue criada externamente (ex: via MCP). */
534
+ function recordIssue({ cwd, phase, issue, url }) {
535
+ const key = phaseKey(phase);
536
+ const map = readGitMap(cwd);
537
+ const entry = map.phases[key] || {};
538
+ if (issue != null && issue !== '') entry.issue = parseInt(issue, 10);
539
+ if (url) entry.issue_url = url;
540
+ map.phases[key] = entry;
541
+ writeGitMap(cwd, map);
542
+ return { recorded: true, phase: key, issue: entry.issue ?? null, issue_url: entry.issue_url || null };
543
+ }
544
+
545
+ /**
546
+ * Grava no git-map o PR criado externamente (ex: via MCP).
547
+ * Com `merged=true`: marca status=merged e limpa worktree+branch local.
548
+ */
549
+ function recordPr({ cwd, phase, pr, url, merged = false }) {
550
+ const warnings = [];
551
+ const key = phaseKey(phase);
552
+ const map = readGitMap(cwd);
553
+ const entry = map.phases[key] || {};
554
+ if (pr != null && pr !== '') entry.pr = parseInt(pr, 10);
555
+ if (url) entry.pr_url = url;
556
+ if (merged) {
557
+ entry.status = 'merged';
558
+ cleanupWorktreeBranch(cwd, entry.worktree, entry.branch, warnings);
559
+ entry.worktree = null;
560
+ }
561
+ map.phases[key] = entry;
562
+ writeGitMap(cwd, map);
563
+ return { recorded: true, phase: key, pr: entry.pr ?? null, pr_url: entry.pr_url || null, status: entry.status || null, merged: !!merged, warnings };
564
+ }
565
+
466
566
  // =====================================================================
467
567
  // status — mostra git-map.json
468
568
  // =====================================================================
@@ -470,12 +570,16 @@ function finishPhase({ cwd, phase, mode = 'menu', strategy }) {
470
570
  /** status({cwd}) — retorna o git-map.json atual (ou defaults se nao existir). Nunca crasha. */
471
571
  function status({ cwd }) {
472
572
  const map = readGitMap(cwd);
573
+ const hasRemote = gitHasRemote(cwd);
574
+ const ghOk = ghAvailable(cwd);
473
575
  return {
474
576
  github_native: map.github_native,
475
577
  merge_strategy: map.merge_strategy,
476
- gh_available: ghAvailable(cwd),
477
- has_remote: gitHasRemote(cwd),
578
+ gh_available: ghOk,
579
+ has_remote: hasRemote,
478
580
  remote: getRemoteUrl(cwd),
581
+ // transporte efetivo pra issue/PR: 'gh' | 'mcp' | 'none' (assumindo nao-local)
582
+ transport: hasRemote && map.github_native !== false ? (ghOk ? 'gh' : 'mcp') : 'none',
479
583
  phases: map.phases,
480
584
  };
481
585
  }
@@ -483,13 +587,18 @@ function status({ cwd }) {
483
587
  module.exports = {
484
588
  startPhase,
485
589
  finishPhase,
590
+ recordIssue,
591
+ recordPr,
486
592
  status,
487
593
  // helpers expostos para teste/reuso
488
594
  branchName,
489
595
  worktreePath,
490
596
  gitHasRemote,
491
597
  ghAvailable,
598
+ gitWantsRemote,
599
+ issueTransport,
492
600
  githubMode,
601
+ cleanupWorktreeBranch,
493
602
  readGitMap,
494
603
  writeGitMap,
495
604
  };