@polymorphism-tech/morph-spec 8.31.0 → 8.32.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 (37) hide show
  1. package/CLAUDE.md +5 -0
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +19 -1
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +2 -1
  8. package/framework/MORPH.md +2 -2
  9. package/framework/docs/CLI.md +68 -63
  10. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  11. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  12. package/framework/skills/core/morph-apply/SKILL.md +3 -2
  13. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  14. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  15. package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
  16. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  17. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  18. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  19. package/framework/skills/core/morph-review/SKILL.md +2 -2
  20. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  21. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  22. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  23. package/package.json +1 -1
  24. package/src/commands/project/branch.js +170 -0
  25. package/src/commands/project/doctor.js +137 -9
  26. package/src/commands/project/fleet.js +10 -3
  27. package/src/commands/project/worktree.js +73 -50
  28. package/src/commands/state/finish.js +41 -26
  29. package/src/commands/state/list.js +79 -6
  30. package/src/core/state/lifecycle-audit.js +127 -0
  31. package/src/lib/git/branch-classifier.js +177 -0
  32. package/src/lib/git/gh.js +30 -0
  33. package/src/lib/git/pending-prs.js +89 -0
  34. package/src/lib/git/pr-index.js +70 -0
  35. package/src/lib/worktree/fleet-scanner.js +51 -21
  36. package/src/lib/worktree/ignored-triage.js +197 -0
  37. package/src/lib/worktree/worktree-audit.js +200 -35
@@ -13,6 +13,7 @@ import {
13
13
  cwdIsInsideWorktree,
14
14
  } from '../project/worktree.js';
15
15
  import { detectDefaultBranch, hasOriginRemote } from '../../lib/git/default-branch.js';
16
+ import { resolveGh } from '../../lib/git/gh.js';
16
17
  import { releaseClaim } from '../../../framework/hooks/shared/claim-registry.js';
17
18
  import {
18
19
  composeProjectName,
@@ -326,7 +327,7 @@ export async function finishCommand(feature, options = {}) {
326
327
  branch: expectedBranch,
327
328
  base: target,
328
329
  archived: archivePath.replace(/\\/g, '/'),
329
- ...(typeof openPr === 'string' ? { pr: openPr } : {}),
330
+ ...(typeof openPr === 'string' ? { pr: { number: prNumberFromUrl(openPr), url: openPr } } : {}),
330
331
  hint: `'${feature}' já foi arquivada e ${expectedBranch} já tem PR aberto — o finish anterior concluiu. Falta apenas mergear o PR.`,
331
332
  });
332
333
  process.exit(1);
@@ -487,7 +488,7 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
487
488
  // VALID (it still names a live directory), so nothing to clean up here —
488
489
  // only report where it lives and how to close it later.
489
490
  emit({
490
- finished: true, mode: 'merge', feature, merged: expectedBranch, kept: true, worktreeRemoved: false,
491
+ finished: true, integrated: true, mode: 'merge', feature, merged: expectedBranch, kept: true, worktreeRemoved: false,
491
492
  worktree: { path: worktreePath, branch: expectedBranch },
492
493
  hint: `worktree preservado em ${worktreePath} — feche depois com \`morph-spec worktree remove ${feature}\`.`,
493
494
  ...backupNote, ...ucNote,
@@ -526,10 +527,26 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
526
527
  }
527
528
  tryGit(['branch', '-d', expectedBranch], root);
528
529
  // The root index (gitignored) was already updated by the archive (same cwd).
529
- emit({ finished: true, mode: 'merge', feature, merged: expectedBranch, into: defaultBranch, worktreeRemoved: false, nextStep: MERGE_NEXT_STEP, ...ucNote });
530
+ emit({ finished: true, integrated: true, mode: 'merge', feature, merged: expectedBranch, into: defaultBranch, worktreeRemoved: false, nextStep: MERGE_NEXT_STEP, ...ucNote });
530
531
  process.exit(0);
531
532
  }
532
533
 
534
+ /**
535
+ * O número do PR a partir da url que o `gh pr create` imprime.
536
+ *
537
+ * Existe porque o número é o que um humano usa para falar do PR ("mergeia o
538
+ * #51"), e a url é a única coisa que o `gh` devolve. Um formato inesperado
539
+ * devolve `null` em vez de um palpite: o payload prefere admitir que não sabe
540
+ * o número a afirmar um errado.
541
+ *
542
+ * @param {string|null} url
543
+ * @returns {number|null}
544
+ */
545
+ export function prNumberFromUrl(url) {
546
+ const m = typeof url === 'string' ? url.match(/\/pull\/(\d+)(?:[/?#]|$)/) : null;
547
+ return m ? Number(m[1]) : null;
548
+ }
549
+
533
550
  // ── PR integration (push + gh, no local merge) ───────────────────────────────
534
551
 
535
552
  async function integratePr({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, prTitle, prBody, arch }) {
@@ -571,13 +588,23 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
571
588
  dropFromRootIndex(root, feature);
572
589
  }
573
590
 
591
+ const prNumber = prNumberFromUrl(prUrl);
592
+ const prLabel = prNumber ? `#${prNumber}` : 'o PR';
593
+
574
594
  const out = {
575
595
  finished: true,
596
+ // O comando terminou; a INTEGRAÇÃO não aconteceu. Eram a mesma coisa no
597
+ // payload antigo, e é dessa colisão que sai o "shipped" para uma feature
598
+ // cujo PR seguia aberto: quem lê `finished: true` sem mais nada conclui
599
+ // que acabou. A default só recebe archive/{f} quando o PR mergear.
600
+ integrated: false,
601
+ awaiting: 'merge',
576
602
  mode: 'pr',
577
603
  feature,
578
604
  pushed: expectedBranch,
579
605
  base: defaultBranch,
580
- pr: prUrl,
606
+ pr: { number: prNumber, url: prUrl },
607
+ nextStep: `mergeie ${prLabel} — a feature só estará encerrada quando ${defaultBranch} receber o archive pelo merge`,
581
608
  ...promotedNote(arch),
582
609
  ...(shedBackup ? { shedBackup } : {}),
583
610
  ...(teardown
@@ -626,6 +653,7 @@ const MERGE_NEXT_STEP = 'integração LOCAL — nada foi publicado. Rode `git pu
626
653
  function mergeOutput({ feature, branch, worktreePath, teardown, shedBackup }) {
627
654
  return {
628
655
  finished: true,
656
+ integrated: true,
629
657
  mode: 'merge',
630
658
  feature,
631
659
  merged: branch,
@@ -641,6 +669,15 @@ function teardownExtras(teardown, worktreePath, branch, deleteBranch) {
641
669
  const out = {};
642
670
  if (teardown.ignoredBackup) out.worktreeIgnoredBackup = teardown.ignoredBackup;
643
671
  if (teardown.worktreeRemoved && teardown.discarded.length) out.discardedIgnored = teardown.discarded;
672
+ // WHAT was copied out, and what needed no copy because the primary root holds
673
+ // the identical bytes. Reported on both paths: after a removal these two lists
674
+ // are the whole answer to "did I just lose something?", and the user should
675
+ // not have to open the backup directory to find out.
676
+ if (teardown.shieldable?.length) out.worktreeIgnoredBackedUp = teardown.shieldable;
677
+ if (teardown.replicable?.length) out.replicatedInRoot = teardown.replicable;
678
+ // The infra reparse points dropped BEFORE git touched the directory — the
679
+ // proof the primary root's real `.claude/` was unlinked, never followed.
680
+ if (teardown.unlinked?.length) out.unlinked = teardown.unlinked;
644
681
  if (!teardown.worktreeRemoved) {
645
682
  out.path = worktreePath;
646
683
  out.branch = branch;
@@ -688,28 +725,6 @@ function gitStatusPorcelain(cwd) {
688
725
  }
689
726
  }
690
727
 
691
- /**
692
- * Resolve the `gh` invocation. Defaults to the `gh` binary on PATH. Overridable
693
- * via MORPH_GH_BIN: a plain string names a different binary (a corporate wrapper,
694
- * `hub`, …); a JSON array is a full argv prefix — e.g.
695
- * `["/path/to/node","/path/to/gh-shim.js"]` — which is how the tests inject a
696
- * cross-platform stub that execFileSync can actually spawn (Node ≥20 refuses to
697
- * run `.cmd`/`.bat` shims without `shell:true`, and shell:true would reopen the
698
- * arg-injection hole the whole file avoids).
699
- *
700
- * @returns {{ bin: string, prefix: string[] }}
701
- */
702
- function resolveGh() {
703
- const raw = process.env.MORPH_GH_BIN;
704
- if (raw && raw.trim().startsWith('[')) {
705
- try {
706
- const arr = JSON.parse(raw);
707
- if (Array.isArray(arr) && arr.length) return { bin: String(arr[0]), prefix: arr.slice(1).map(String) };
708
- } catch { /* not JSON — treat as a plain binary name below */ }
709
- }
710
- return { bin: raw && raw.trim() ? raw.trim() : 'gh', prefix: [] };
711
- }
712
-
713
728
  function ghAuthOk() {
714
729
  const { bin, prefix } = resolveGh();
715
730
  try {
@@ -7,6 +7,8 @@ import { missingIdentity } from '../../core/state/feature-identity.js';
7
7
  import { derivePhaseFrom } from '../../../framework/hooks/shared/state-reader.js';
8
8
  import { isGitRepo } from '../project/worktree.js';
9
9
  import { morphBranches, currentBranch, branchView } from '../../lib/worktree/feature-truth.js';
10
+ import { buildPrIndex } from '../../lib/git/pr-index.js';
11
+ import { pendingPrFeatures, prForFeature } from '../../lib/git/pending-prs.js';
10
12
 
11
13
  /**
12
14
  * `morph-spec list [--all] [--json] [--no-branch]` — the feature audit board.
@@ -176,16 +178,51 @@ function flagArchivedElsewhere(cwd, rows) {
176
178
  }
177
179
  }
178
180
 
181
+ /**
182
+ * A linha de uma feature que existe SÓ como PR aberto — sem pasta ativa nem
183
+ * arquivada nesta árvore. É o estado que o `finish --pr` deixa: o scaffold do
184
+ * root foi apagado pelo shed e o archive viaja no diff do PR, chegando na
185
+ * default apenas no merge. Sem esta linha o trabalho fica sem dono no quadro,
186
+ * e uma branch sem dono é candidata a poda.
187
+ */
188
+ function pendingRow(p) {
189
+ return {
190
+ feature: p.feature,
191
+ name: null,
192
+ description: null,
193
+ workType: null,
194
+ status: 'in_progress',
195
+ phase: 'aguardando-merge',
196
+ gates: { proposal: false, uiux: false, plan: false, review: false },
197
+ selfAssessed: false,
198
+ flags: ['pr-pending'],
199
+ location: 'pr-pending',
200
+ createdAt: p.createdAt,
201
+ updatedAt: null,
202
+ source: 'pr',
203
+ branch: p.branch,
204
+ worktree: null,
205
+ pr: { number: p.number, state: p.state, url: p.url, ageDays: p.ageDays },
206
+ };
207
+ }
208
+
179
209
  const mark = (on) => (on ? chalk.green('✓') : chalk.gray('·'));
180
210
  const truncate = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
181
211
 
182
212
  function whereLabel(row) {
213
+ if (row.location === 'pr-pending') return `branch ${row.branch}`;
183
214
  if (row.location === 'archived') return '-';
184
215
  if (row.worktree) return row.worktree.exists ? `worktree ${basename(row.worktree.path)}` : '⚠ worktree ausente';
185
216
  if (row.source === 'branch') return `branch ${row.branch}`;
186
217
  return 'raiz';
187
218
  }
188
219
 
220
+ const prCell = (pr) => {
221
+ if (!pr) return '-';
222
+ const state = String(pr.state || '').toLowerCase();
223
+ return `#${pr.number} ${state}`;
224
+ };
225
+
189
226
  export function listCommand(options = {}) {
190
227
  const cwd = options.cwd || process.cwd();
191
228
  const rows = readFeaturesIn(cwd, 'features', 'active');
@@ -195,6 +232,32 @@ export function listCommand(options = {}) {
195
232
  // Commander's `--no-branch` sets options.branch = false; anything else (undefined/true) reads the branch.
196
233
  if (options.branch !== false) resolveBranchTruth(cwd, rows);
197
234
 
235
+ // Estado do PR — uma consulta ao `gh` por invocação, compartilhada pela
236
+ // coluna e pela síntese. `--no-branch` (o modo offline rápido) pula as duas.
237
+ //
238
+ // `prIndex` continua `null` quando a pergunta não pôde ser feita (sem `gh`,
239
+ // sem auth, remoto não-GitHub, ou `--no-branch`). É o mesmo sinal que
240
+ // `--json` já expõe como `pr: null` por linha — a tabela texto usa o mesmo
241
+ // fato para decidir se a coluna PR sequer aparece (achado #2): índice `null`
242
+ // degrada para o comportamento de antes, sem coluna e sem `-` que afirme
243
+ // ausência.
244
+ let prIndex = null;
245
+ if (options.branch !== false) {
246
+ prIndex = buildPrIndex(cwd);
247
+ for (const row of rows) {
248
+ const pr = prForFeature(prIndex, row.feature);
249
+ row.pr = pr ? { number: pr.number, state: pr.state, url: pr.url, ageDays: null } : null;
250
+ }
251
+ const known = new Set(rows.map((r) => r.feature));
252
+ // `null` propaga desconhecido: nenhuma linha sintetizada, e nenhuma
253
+ // afirmação de que não há pendência.
254
+ for (const p of pendingPrFeatures(cwd, { index: prIndex }) || []) {
255
+ if (!known.has(p.feature)) rows.push(pendingRow(p));
256
+ }
257
+ } else {
258
+ for (const row of rows) row.pr = null;
259
+ }
260
+
198
261
  rows.sort((a, b) => a.feature.localeCompare(b.feature));
199
262
 
200
263
  if (options.json) {
@@ -208,22 +271,32 @@ export function listCommand(options = {}) {
208
271
  }
209
272
 
210
273
  const col = (s, w) => String(s ?? '-').padEnd(w);
274
+ // A coluna PR só aparece quando o índice foi construído com sucesso (achado
275
+ // #2) — um índice `null` não vira uma coluna cheia de `-`, que afirmaria
276
+ // "sem PR" para algo que na verdade é "não sabemos".
277
+ const showPr = Boolean(prIndex);
278
+ // `pr-pending` é estado normal de trabalho em voo, não inconsistência —
279
+ // não deve marcar `⚠` nem contar no rodapé (achado #6). Continua em
280
+ // `flags` no `--json`: é metadado real, só não é anomalia.
281
+ const warnFlags = (r) => r.flags.filter((f) => f !== 'pr-pending');
211
282
  console.log(chalk.white.bold(
212
- col('FEATURE', 22) + col('TYPE', 9) + col('STATUS', 13) + col('PHASE', 11) + col('WHERE', 22)
283
+ col('FEATURE', 22) + col('TYPE', 9) + col('STATUS', 13) + col('PHASE', 17) + col('WHERE', 22) + (showPr ? col('PR', 12) : '')
213
284
  + col('G1', 4) + col('G2', 4) + col('G3', 4) + col('', 3) + 'DESCRIPTION',
214
285
  ));
215
286
  for (const r of rows) {
216
- const flag = r.flags.length ? chalk.yellow('⚠') : ' ';
217
- const desc = (r.selfAssessed ? chalk.yellow('[G3 self-assessed] ') : '')
218
- + (r.description ? truncate(r.description, 34) : chalk.yellow('(sem descrição)'));
287
+ const flag = warnFlags(r).length ? chalk.yellow('⚠') : ' ';
288
+ const desc = r.location === 'pr-pending'
289
+ ? chalk.yellow(`aguardando merge${r.pr?.ageDays != null ? ` há ${r.pr.ageDays}d` : ''} — sem cópia local`)
290
+ : (r.selfAssessed ? chalk.yellow('[G3 self-assessed] ') : '')
291
+ + (r.description ? truncate(r.description, 34) : chalk.yellow('(sem descrição)'));
219
292
  // padEnd counts the invisible color codes; pad the plain marks manually.
220
293
  console.log(
221
- col(r.feature, 22) + col(r.workType, 9) + col(r.status, 13) + col(r.phase, 11) + col(whereLabel(r), 22)
294
+ col(r.feature, 22) + col(r.workType, 9) + col(r.status, 13) + col(r.phase, 17) + col(whereLabel(r), 22) + (showPr ? col(prCell(r.pr), 12) : '')
222
295
  + mark(r.gates.proposal) + ' ' + mark(r.gates.plan) + ' ' + mark(r.gates.review) + ' '
223
296
  + flag + ' ' + desc,
224
297
  );
225
298
  }
226
- const flagged = rows.filter((r) => r.flags.length);
299
+ const flagged = rows.filter((r) => warnFlags(r).length);
227
300
  if (flagged.length) {
228
301
  console.log(chalk.gray(`\n⚠ ${flagged.length} com inconsistência — rode 'morph-spec doctor' para detalhes.`));
229
302
  }
@@ -12,6 +12,9 @@
12
12
  * the next loadState, but surfaced so it's not a mystery)
13
13
  * • branch-drift — the root's copy of a feature.json disagrees with the copy
14
14
  * committed on `morph/{f}` (B5 — the root's view lags)
15
+ * • abandoned-scaffold — an `in_progress` feature that never produced
16
+ * anything: no artifacts, no approved gate, and a branch
17
+ * with no commit of its own (see abandonedScaffold)
15
18
  * • corrupt — a feature.json that won't parse
16
19
  */
17
20
 
@@ -20,6 +23,8 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
20
23
  import { join } from 'path';
21
24
  import { gateOrderViolations } from './gate-order.js';
22
25
  import { missingIdentity } from './feature-identity.js';
26
+ import { detectDefaultBranch } from '../../lib/git/default-branch.js';
27
+ import { commitsAhead, resolveBaseRef } from '../../lib/git/branch-classifier.js';
23
28
 
24
29
  /** The operational truth of a feature.json: its status and which gates are approved. */
25
30
  function operationalShape(fj) {
@@ -72,6 +77,125 @@ function branchDrift(baseDir, feature, rootFj) {
72
77
  }
73
78
  }
74
79
 
80
+ /**
81
+ * How long a scaffold has to sit untouched before it counts as abandoned.
82
+ *
83
+ * A dead scaffold and a feature born five minutes ago have the SAME shape:
84
+ * `create` forks `morph/{f}` off the default branch without committing, so at
85
+ * birth the branch has no commit of its own and the folder holds nothing but
86
+ * `feature.json`. Only elapsed time separates the two — and a check that fires
87
+ * on every fresh `create` is one people learn to scroll past.
88
+ */
89
+ const ABANDONED_SCAFFOLD_MIN_AGE_MS = 24 * 60 * 60 * 1000;
90
+
91
+ /** Newest timestamp the feature carries, in ms; falls back to the folder's mtime. */
92
+ function lastTouched(fdir, fj) {
93
+ let newest = 0;
94
+ for (const ts of [fj?.updatedAt, fj?.createdAt]) {
95
+ const t = ts ? Date.parse(ts) : NaN;
96
+ if (!Number.isNaN(t)) newest = Math.max(newest, t);
97
+ }
98
+ if (newest) return newest;
99
+ try { return statSync(fdir).mtimeMs; } catch { return 0; }
100
+ }
101
+
102
+ /**
103
+ * Does the feature dir hold any FILE other than its own `feature.json`?
104
+ *
105
+ * Files, not entries: `create` scaffolds the phase folders empty, so an empty
106
+ * `0-proposal/` is the absence of work, not evidence of it.
107
+ */
108
+ function hasArtifacts(fdir) {
109
+ let entries;
110
+ try { entries = readdirSync(fdir, { withFileTypes: true }); } catch { return false; }
111
+ for (const e of entries) {
112
+ if (e.isDirectory()) {
113
+ if (hasArtifacts(join(fdir, e.name))) return true;
114
+ continue;
115
+ }
116
+ if (e.name === 'feature.json') continue;
117
+ return true;
118
+ }
119
+ return false;
120
+ }
121
+
122
+ /**
123
+ * Commits on the feature's branch that the base does not already contain, or
124
+ * `null` when git cannot answer (no repo, no resolvable base, no branch, no
125
+ * rev-list). "Cannot prove" must never read as "proved zero" — the predicate
126
+ * below hinges on this number, so an unanswerable repo has to stay silent.
127
+ *
128
+ * The counting itself is `branch-classifier.js`'s `commitsAhead` against its
129
+ * `resolveBaseRef`, NOT a second copy: `origin/{default}` is the authority on
130
+ * what actually landed, and two drifting answers to "does this branch carry
131
+ * anything of its own?" is exactly what the `doctor`/`branch prune` split was
132
+ * unified to avoid.
133
+ *
134
+ * Note the field evidence this exists for: the branch pointed at the merge
135
+ * commit of somebody else's PR. That is the base's own tip — reachable from
136
+ * the base, and therefore zero commits of its own.
137
+ */
138
+ function ownCommitCount(baseDir, branch) {
139
+ try {
140
+ execFileSync('git', ['rev-parse', '--git-dir'], { cwd: baseDir, stdio: 'pipe' });
141
+ } catch {
142
+ return null;
143
+ }
144
+ const baseRef = resolveBaseRef(detectDefaultBranch(baseDir), baseDir);
145
+ if (!baseRef) return null;
146
+ try {
147
+ execFileSync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], { cwd: baseDir, stdio: 'pipe' });
148
+ } catch {
149
+ return 0; // the branch never existed — nothing was ever committed for it
150
+ }
151
+ return commitsAhead(baseRef, branch, baseDir);
152
+ }
153
+
154
+ /**
155
+ * The orphan skeleton `create --worktree` can leave behind: a feature.json on
156
+ * its own, `status: in_progress`, no tasks, no gates, and a branch parked on
157
+ * somebody else's commit. Every one of those alone is normal at some point in
158
+ * a feature's life; together, and only after the feature has gone stale, they
159
+ * mean nobody ever came back to it.
160
+ *
161
+ * The artifact test is deliberately broader than the "0 tasks" the report
162
+ * named: a proposal drafted and waiting on Gate 1 also has zero tasks and is
163
+ * very much alive. What a dead scaffold has is NOTHING — checked in the root
164
+ * and, if the feature registered one, in its worktree, where uncommitted work
165
+ * would otherwise be invisible to both the disk scan and `rev-list`.
166
+ *
167
+ * @returns {string|null} the issue detail, or null when the feature is alive
168
+ */
169
+ function abandonedScaffold(baseDir, feature, fdir, fj) {
170
+ if (fj?.status !== 'in_progress') return null;
171
+
172
+ // "0 gates": a gate stamped `policy:{workType}` at birth (chore/hotfix) was
173
+ // never a decision anyone took, so it must not read as progress.
174
+ const decided = Object.values(fj.approvalGates || {}).some(
175
+ (g) => g?.approved === true && !String(g?.approvedBy ?? '').startsWith('policy:'),
176
+ );
177
+ if (decided) return null;
178
+
179
+ if (hasArtifacts(fdir)) return null;
180
+ const wtFeatureDir = fj.worktree?.path
181
+ ? join(fj.worktree.path, '.morph', 'features', feature)
182
+ : null;
183
+ if (wtFeatureDir && existsSync(wtFeatureDir) && hasArtifacts(wtFeatureDir)) return null;
184
+
185
+ const age = Date.now() - lastTouched(fdir, fj);
186
+ if (age < ABANDONED_SCAFFOLD_MIN_AGE_MS) return null;
187
+
188
+ // The registered branch when the feature has one, `morph/{f}` otherwise —
189
+ // same name `branchDrift` above assumes.
190
+ const branch = fj.worktree?.branch || `morph/${feature}`;
191
+ if (ownCommitCount(baseDir, branch) !== 0) return null;
192
+
193
+ const days = Math.floor(age / (24 * 60 * 60 * 1000));
194
+ return `in_progress há ${days}d sem nada produzido: 0 artefatos, 0 gates aprovados `
195
+ + `e \`${branch}\` sem commit próprio — rode \`morph-spec delete ${feature} --archive\` `
196
+ + 'ou retome a feature';
197
+ }
198
+
75
199
  /**
76
200
  * @param {string} [baseDir] - project root (defaults to cwd)
77
201
  * @returns {Array<{ feature: string, type: string, detail: string }>} issues
@@ -106,6 +230,9 @@ export function auditFeatureLifecycle(baseDir = process.cwd()) {
106
230
  const drift = branchDrift(baseDir, name, fj);
107
231
  if (drift) issues.push({ feature: name, type: 'branch-drift', detail: drift });
108
232
 
233
+ const dead = abandonedScaffold(baseDir, name, fdir, fj);
234
+ if (dead) issues.push({ feature: name, type: 'abandoned-scaffold', detail: dead });
235
+
109
236
  const miss = missingIdentity(fj);
110
237
  if (miss.length) {
111
238
  issues.push({ feature: name, type: 'identity', detail: `missing ${miss.join(', ')}` });
@@ -0,0 +1,177 @@
1
+ /**
2
+ * branch-classifier.js — answer "is this branch's work already in the default
3
+ * branch?" for a repository that squash-merges.
4
+ *
5
+ * WHY THIS EXISTS: a squash-merge puts the WORK in `origin/master` while
6
+ * leaving none of the branch's own commits reachable from it. So
7
+ * `git branch --merged origin/master` does not list the branch, and
8
+ * `git branch -d` refuses it as unmerged. The only remaining command is
9
+ * `git branch -D`, which is byte-identical to the command that discards real,
10
+ * never-integrated work — the tool gives the user no way to tell the two apart,
11
+ * and after enough of these the habit becomes "just -D it", which is how work
12
+ * gets lost.
13
+ *
14
+ * Every branch lands in exactly one class, and only the two MERGED ones are
15
+ * ever deletable:
16
+ *
17
+ * merged-ancestral — git itself proves it: the branch tip is an ancestor of
18
+ * the default. A plain merge, or a fast-forward.
19
+ * merged-squash — git cannot see it, but the evidence is positive: either
20
+ * GitHub reports the PR as MERGED, or the branch's tree is
21
+ * IDENTICAL to the default's (every change it carries is
22
+ * already there under a different commit).
23
+ * open — a PR is OPEN for it. Never deleted: the PR is the whole
24
+ * reason the branch must survive.
25
+ * in-worktree — checked out by a live worktree. Never deleted.
26
+ * unmerged — none of the above could be PROVEN. This is the fail-safe
27
+ * default and covers "no `gh`", "no origin", and "genuinely
28
+ * unintegrated work" alike, because from here they are the
29
+ * same thing: not proven integrated.
30
+ *
31
+ * Nothing here mutates. `pruneBranches` in commands/project/branch.js does the
32
+ * deleting, and it deletes ONLY what this file classified as merged.
33
+ */
34
+
35
+ import { execFileSync } from 'child_process';
36
+
37
+ function git(args, cwd) {
38
+ return execFileSync('git', args, { cwd, stdio: 'pipe' }).toString();
39
+ }
40
+
41
+ /**
42
+ * Local branches with their tips. Excludes nothing — the caller decides.
43
+ *
44
+ * @returns {Array<{ name: string, sha: string, current: boolean, worktreePath: string|null }>}
45
+ */
46
+ export function listLocalBranches(cwd) {
47
+ try {
48
+ const out = git(['for-each-ref', '--format=%(refname:short)%09%(objectname)%09%(HEAD)%09%(worktreepath)', 'refs/heads/'], cwd);
49
+ return out.split('\n').filter(Boolean).map((line) => {
50
+ const [name, sha, head, worktreePath] = line.split('\t');
51
+ return { name, sha, current: head === '*', worktreePath: worktreePath || null };
52
+ });
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
57
+
58
+ /** Is `ancestor` reachable from `descendant`? False on any git error. */
59
+ function isAncestor(ancestor, descendant, cwd) {
60
+ try {
61
+ git(['merge-base', '--is-ancestor', ancestor, descendant], cwd);
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Does the branch introduce NOTHING the base does not already have?
70
+ *
71
+ * `git diff --quiet <base> <branch>` compares the two TREES: an empty diff
72
+ * means every file the branch touched already reads exactly as the branch left
73
+ * it in the base. That is precisely the state a squash-merge produces, and it
74
+ * is a positive proof rather than an inference from history shape.
75
+ *
76
+ * Deliberately NOT `base...branch` (the three-dot form diffs against the merge
77
+ * base, which for a squash-merged branch still shows the branch's own changes).
78
+ *
79
+ * Fail-CLOSED: any git error reads as "introduces something", never as merged.
80
+ */
81
+ function treeIsIdentical(base, branch, cwd) {
82
+ try {
83
+ execFileSync('git', ['diff', '--quiet', base, branch], { cwd, stdio: 'pipe' });
84
+ return true;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * How many commits the branch carries that the base does not. Used only for
92
+ * REPORTING — the classification never turns on it, because a squash-merged
93
+ * branch legitimately has commits ahead while carrying no unintegrated work.
94
+ *
95
+ * @returns {number|null} null when it cannot be computed
96
+ */
97
+ export function commitsAhead(base, branch, cwd) {
98
+ try {
99
+ const out = git(['rev-list', '--count', `${base}..${branch}`], cwd).trim();
100
+ const n = Number(out);
101
+ return Number.isFinite(n) ? n : null;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Resolve the ref to compare against: `origin/<default>` when the remote-tracking
109
+ * ref exists (the authority on what has actually landed), else the local branch.
110
+ *
111
+ * @returns {string|null} null when neither resolves — the caller must then
112
+ * classify everything as `unmerged`, because there is no baseline to prove
113
+ * integration against.
114
+ */
115
+ export function resolveBaseRef(defaultBranch, cwd) {
116
+ if (!defaultBranch) return null;
117
+ for (const ref of [`refs/remotes/origin/${defaultBranch}`, `refs/heads/${defaultBranch}`]) {
118
+ try {
119
+ git(['show-ref', '--verify', '--quiet', ref], cwd);
120
+ return ref.replace(/^refs\/(remotes|heads)\//, '');
121
+ } catch { /* try the next */ }
122
+ }
123
+ return null;
124
+ }
125
+
126
+ /**
127
+ * Classify one branch. Pure decision logic over already-gathered facts, so the
128
+ * table above is testable without a GitHub account.
129
+ *
130
+ * ORDER IS THE POLICY, and the two protective classes come FIRST: a branch that
131
+ * is checked out, or that carries an OPEN PR, is never deleted even if its tree
132
+ * happens to match the base — a PR being open means a human still has business
133
+ * with it.
134
+ *
135
+ * @param {object} b - from listLocalBranches
136
+ * @param {{ baseRef: string|null, cwd: string, pr: {number:number,state:string}|null|undefined }} ctx
137
+ * @returns {{ name: string, kind: string, reason: string, pr: number|null, ahead: number|null, deletable: boolean }}
138
+ */
139
+ export function classifyBranch(b, { baseRef, cwd, pr }) {
140
+ const prNum = pr?.number ?? null;
141
+ const mk = (kind, reason, deletable) => ({
142
+ name: b.name, kind, reason, pr: prNum, ahead: baseRef ? commitsAhead(baseRef, b.name, cwd) : null, deletable,
143
+ });
144
+
145
+ if (b.current || b.worktreePath) {
146
+ return mk('in-worktree', b.current ? 'é a branch atual (HEAD)' : `está em uso pelo worktree ${b.worktreePath}`, false);
147
+ }
148
+ if (pr?.state === 'OPEN') {
149
+ return mk('open', `PR #${prNum} está OPEN — a branch é o que o PR aponta`, false);
150
+ }
151
+ if (!baseRef) {
152
+ return mk('unmerged', 'não há branch default resolvível (origin/HEAD, main ou master) para comparar', false);
153
+ }
154
+ if (isAncestor(b.name, baseRef, cwd)) {
155
+ return mk('merged-ancestral', `já é ancestral de ${baseRef}`, true);
156
+ }
157
+ if (pr?.state === 'MERGED') {
158
+ return mk('merged-squash', `PR #${prNum} está MERGED (squash-merge: os commits não entram em ${baseRef}, o trabalho sim)`, true);
159
+ }
160
+ if (treeIsIdentical(baseRef, b.name, cwd)) {
161
+ return mk('merged-squash', `a árvore é idêntica à de ${baseRef} — tudo que a branch muda já está lá`, true);
162
+ }
163
+ return mk('unmerged', `carrega mudanças que não estão em ${baseRef} e nenhum PR MERGED comprova o contrário`, false);
164
+ }
165
+
166
+ /**
167
+ * Classify every local branch except the default itself.
168
+ *
169
+ * @param {{ cwd: string, defaultBranch: string|null, prIndex: Map|null }} ctx
170
+ * @returns {Array} one entry per branch, in `for-each-ref` order
171
+ */
172
+ export function classifyBranches({ cwd, defaultBranch, prIndex }) {
173
+ const baseRef = resolveBaseRef(defaultBranch, cwd);
174
+ return listLocalBranches(cwd)
175
+ .filter((b) => b.name !== defaultBranch)
176
+ .map((b) => classifyBranch(b, { baseRef, cwd, pr: prIndex?.get(b.name) ?? null }));
177
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * gh.js — como o `gh` é invocado, num lugar só.
3
+ *
4
+ * Extraído de `finish.js`, onde nasceu, porque o `pr-index.js` chamava `'gh'`
5
+ * cru: o caminho de SUCESSO do índice era intestável, já que não havia por
6
+ * onde injetar um stub. Duas formas de chamar o mesmo binário também são duas
7
+ * formas de discordar sobre qual binário é.
8
+ */
9
+
10
+ /**
11
+ * Resolve a invocação do `gh`. Por padrão, o `gh` do PATH. Sobrescrevível via
12
+ * MORPH_GH_BIN: uma string simples nomeia outro binário (um wrapper corporativo,
13
+ * `hub`, …); um array JSON é um prefixo de argv COMPLETO — por exemplo
14
+ * `["/path/to/node","/path/to/gh-shim.js"]` — que é como os testes injetam um
15
+ * stub cross-platform que o execFileSync consegue spawnar (Node ≥20 recusa
16
+ * rodar shims `.cmd`/`.bat` sem `shell:true`, e `shell:true` reabriria o buraco
17
+ * de injeção de argumento que estes caminhos evitam).
18
+ *
19
+ * @returns {{ bin: string, prefix: string[] }}
20
+ */
21
+ export function resolveGh() {
22
+ const raw = process.env.MORPH_GH_BIN;
23
+ if (raw && raw.trim().startsWith('[')) {
24
+ try {
25
+ const arr = JSON.parse(raw);
26
+ if (Array.isArray(arr) && arr.length) return { bin: String(arr[0]), prefix: arr.slice(1).map(String) };
27
+ } catch { /* não é JSON — trata como nome de binário abaixo */ }
28
+ }
29
+ return { bin: raw && raw.trim() ? raw.trim() : 'gh', prefix: [] };
30
+ }