@polymorphism-tech/morph-spec 8.29.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 (183) hide show
  1. package/CLAUDE.md +11 -1
  2. package/README.md +13 -2
  3. package/bin/morph-spec.js +67 -1
  4. package/bin/validate.js +379 -379
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +1 -1
  7. package/docs/QUICKSTART.md +1 -1
  8. package/framework/CLAUDE.md +4 -1
  9. package/framework/MORPH.md +4 -3
  10. package/framework/agents.json +6 -6
  11. package/framework/commands/morph-status.md +1 -1
  12. package/framework/docs/CLI.md +68 -45
  13. package/framework/evals/rubrics/code-quality.md +17 -0
  14. package/framework/evals/rubrics/condensed.md +3 -1
  15. package/framework/hooks/claude-code/statusline.sh +11 -11
  16. package/framework/hooks/git/commit-msg/conventional-commits.sh +33 -33
  17. package/framework/hooks/git/pre-commit/agents.sh +25 -25
  18. package/framework/hooks/git/pre-commit/orchestrator.sh +64 -64
  19. package/framework/hooks/git/pre-commit/specs.sh +50 -50
  20. package/framework/hooks/git/pre-push/run-tests.sh +44 -44
  21. package/framework/hooks/shared/phase-utils.js +2 -0
  22. package/framework/hooks/shared/stdin-reader.js +26 -26
  23. package/framework/mcp/registry.json +39 -4
  24. package/framework/rubrics/backend.json +2 -2
  25. package/framework/rubrics/default.json +2 -2
  26. package/framework/rubrics/frontend.json +2 -2
  27. package/framework/schemas/tasks.schema.json +25 -0
  28. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  29. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  30. package/framework/skills/core/morph-apply/SKILL.md +14 -4
  31. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  32. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  33. package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
  34. package/framework/skills/core/morph-implement/SKILL.md +12 -2
  35. package/framework/skills/core/morph-plan/SKILL.md +45 -3
  36. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  37. package/framework/skills/core/morph-review/SKILL.md +12 -4
  38. package/framework/skills/core/morph-stack-scan/SKILL.md +13 -1
  39. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  40. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  41. package/framework/standards/ai-agents/agent-session.md +160 -160
  42. package/framework/standards/ai-agents/batching.md +193 -193
  43. package/framework/standards/ai-agents/context-providers.md +188 -188
  44. package/framework/standards/ai-agents/mcp-server.md +250 -250
  45. package/framework/standards/ai-agents/mcp-tools.md +197 -197
  46. package/framework/standards/ai-agents/middleware-patterns.md +207 -207
  47. package/framework/standards/ai-agents/modalities-image-gen.md +173 -173
  48. package/framework/standards/ai-agents/modalities-speech-to-text.md +171 -171
  49. package/framework/standards/ai-agents/modalities-text-to-speech.md +181 -181
  50. package/framework/standards/ai-agents/modalities-vision.md +201 -201
  51. package/framework/standards/ai-agents/multi-agent-patterns.md +155 -155
  52. package/framework/standards/ai-agents/production.md +231 -231
  53. package/framework/standards/ai-agents/prompts-in-db.md +214 -214
  54. package/framework/standards/ai-agents/providers/model-registry.md +158 -158
  55. package/framework/standards/ai-agents/service-tier-flex.md +135 -135
  56. package/framework/standards/ai-agents/structured-output.md +249 -249
  57. package/framework/standards/ai-agents/sweet-spot.md +109 -109
  58. package/framework/templates/.idea/morph-templates.xml +92 -0
  59. package/framework/templates/.vscode/morph-templates.code-snippets +186 -0
  60. package/framework/templates/IDE-SNIPPETS.md +262 -262
  61. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  62. package/framework/templates/code/dotnet/ai-agents/AgentBootstrap.cs.template +39 -39
  63. package/framework/templates/code/dotnet/ai-agents/EmbeddingService.cs.template +68 -68
  64. package/framework/templates/code/dotnet/ai-agents/ModelRegistry.cs.template +117 -117
  65. package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +95 -95
  66. package/framework/templates/code/dotnet/ai-agents/ResilientToolMiddleware.cs.template +61 -61
  67. package/framework/templates/code/dotnet/ai-agents/VectorSearchTool.cs.template +107 -107
  68. package/framework/templates/code/dotnet/ai-agents/model-registry.json.template +58 -58
  69. package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +281 -281
  70. package/framework/templates/code/dotnet/database/migration.cs +83 -83
  71. package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +21 -21
  72. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  73. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  74. package/framework/templates/code/dotnet/feature/validator.cs.hbs +15 -15
  75. package/framework/templates/code/sql/ai-agents/agent-prompts-migration.sql.template +58 -58
  76. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  77. package/framework/templates/code/typescript/contracts.ts +167 -167
  78. package/framework/templates/context/ARCHITECTURE.md +20 -0
  79. package/framework/templates/docs/clarifications.md +253 -253
  80. package/framework/templates/docs/schema-analysis.md +119 -119
  81. package/framework/templates/docs/ui-components.md +52 -52
  82. package/framework/templates/docs/ui-design-system.md +76 -76
  83. package/framework/templates/docs/ui-flows.md +167 -167
  84. package/framework/templates/docs/ui-mockups.md +98 -98
  85. package/framework/templates/feature/decisions.md +122 -122
  86. package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +43 -43
  87. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  88. package/framework/templates/frontend/nextjs/env.mjs.hbs +32 -32
  89. package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +56 -56
  90. package/framework/templates/frontend/nextjs/page.tsx.hbs +22 -22
  91. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  92. package/framework/templates/frontend/nextjs/use-feature.ts.hbs +54 -54
  93. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  94. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  95. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  96. package/framework/templates/infrastructure/github/README.md +56 -56
  97. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  98. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  99. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  100. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  101. package/framework/templates/integrations/asaas-client.cs +387 -387
  102. package/framework/templates/integrations/asaas-webhook.cs +351 -351
  103. package/framework/templates/meta-prompts/fusion/fusion-agent.md +76 -76
  104. package/framework/templates/meta-prompts/fusion/fusion-aggregator.md +100 -100
  105. package/framework/templates/meta-prompts/validators/pre-commit-validator.md +95 -95
  106. package/framework/templates/project/validate.js +120 -120
  107. package/framework/templates/state.template.json +17 -17
  108. package/framework/templates/ui/design-system.css +226 -226
  109. package/package.json +1 -1
  110. package/src/commands/dashboard/dashboard.js +53 -53
  111. package/src/commands/graph/affected.js +89 -0
  112. package/src/commands/graph/build.js +94 -0
  113. package/src/commands/graph/explain.js +96 -0
  114. package/src/commands/graph/status.js +93 -0
  115. package/src/commands/index.js +13 -13
  116. package/src/commands/project/branch.js +170 -0
  117. package/src/commands/project/checkpoint.js +209 -209
  118. package/src/commands/project/doctor.js +148 -9
  119. package/src/commands/project/fleet.js +10 -3
  120. package/src/commands/project/worktree.js +73 -50
  121. package/src/commands/state/finish.js +41 -26
  122. package/src/commands/state/list.js +79 -6
  123. package/src/core/paths/output-schema.js +7 -0
  124. package/src/core/state/lifecycle-audit.js +127 -0
  125. package/src/core/state/phase-state-machine.js +197 -197
  126. package/src/core/templates/template-renderer.js +477 -477
  127. package/src/dashboard/app/components/DetailPanel.jsx +11 -11
  128. package/src/dashboard/app/components/FlowGraph.jsx +17 -17
  129. package/src/dashboard/app/components/SearchFilter.jsx +22 -22
  130. package/src/dashboard/app/hooks/useDetailPanel.js +15 -15
  131. package/src/dashboard/app/main.jsx +13 -13
  132. package/src/dashboard/app/pages/AgentMap.jsx +370 -370
  133. package/src/dashboard/app/pages/HookInspector.jsx +418 -418
  134. package/src/dashboard/app/pages/PhasePipeline.jsx +408 -408
  135. package/src/dashboard/app/pages/RulesMap.jsx +273 -273
  136. package/src/dashboard/app/pages/SkillsLibrary.jsx +366 -366
  137. package/src/dashboard/app/pages/Standards.jsx +332 -332
  138. package/src/dashboard/app/styles/index.css +47 -47
  139. package/src/dashboard/index.html +12 -12
  140. package/src/dashboard/package.json +24 -24
  141. package/src/dashboard/scanner/phases-scanner.js +15 -15
  142. package/src/dashboard/scanner/rubrics-scanner.js +12 -12
  143. package/src/dashboard/scanner/rules-scanner.js +37 -37
  144. package/src/dashboard/scanner/skills-scanner.js +43 -43
  145. package/src/dashboard/scanner/squads-scanner.js +13 -13
  146. package/src/dashboard/vite-plugin-morph-data.js +17 -17
  147. package/src/dashboard/vite.config.js +24 -24
  148. package/src/lib/detectors/design-system-detector.js +188 -188
  149. package/src/lib/detectors/index.js +217 -217
  150. package/src/lib/generators/architecture-scanner.js +45 -4
  151. package/src/lib/generators/design-system-generator.js +508 -508
  152. package/src/lib/generators/settings-generator.js +209 -209
  153. package/src/lib/git/branch-classifier.js +177 -0
  154. package/src/lib/git/gh.js +30 -0
  155. package/src/lib/git/pending-prs.js +89 -0
  156. package/src/lib/git/pr-index.js +70 -0
  157. package/src/lib/graph/god-nodes.js +75 -0
  158. package/src/lib/graph/graph-health.js +61 -0
  159. package/src/lib/graph/graph-loader.js +86 -0
  160. package/src/lib/graph/graphify-runtime.js +100 -0
  161. package/src/lib/graph/graphifyignore.js +86 -0
  162. package/src/lib/graph/sanitize.js +280 -0
  163. package/src/lib/hooks/hook-executor.js +167 -167
  164. package/src/lib/installers/mcp-installer.js +719 -719
  165. package/src/lib/tasks/task-classifier.js +30 -30
  166. package/src/lib/tasks/task-parser.js +209 -209
  167. package/src/lib/tasks/tasks-json-parser.js +26 -0
  168. package/src/lib/validators/checkpoint-persistence.js +39 -39
  169. package/src/lib/validators/css/css-validator.js +340 -340
  170. package/src/lib/validators/reuse/index.js +10 -0
  171. package/src/lib/validators/reuse/reuse-validator.js +324 -0
  172. package/src/lib/validators/reuse/symbol-scanner.js +144 -0
  173. package/src/lib/validators/shared/emit-validator-dispatch.js +14 -14
  174. package/src/lib/validators/shared/issue-counter.js +14 -14
  175. package/src/lib/validators/validation-runner.js +40 -1
  176. package/src/lib/worktree/fleet-scanner.js +51 -21
  177. package/src/lib/worktree/ignored-triage.js +197 -0
  178. package/src/lib/worktree/worktree-audit.js +200 -35
  179. package/src/scripts/setup-infra.js +60 -1
  180. package/src/utils/agent-id-mapper.js +11 -11
  181. package/src/utils/claude-settings-manager.js +300 -300
  182. package/src/utils/file-copier.js +40 -0
  183. package/src/utils/skills-installer.js +187 -187
@@ -55,14 +55,21 @@ export function fleetCommand(options = {}) {
55
55
  };
56
56
 
57
57
  console.log(chalk.white.bold(
58
- col('FEATURE', 20) + col('TYPE', 9) + col('PHASE', 11) + col('TASKS', 8)
58
+ // PHASE at 18: `aguardando-merge` (the pr-pending synthesized phase, see
59
+ // fleet-scanner.js) is 16 chars — 1 wider than the col width `list.js`
60
+ // already uses (17) for the identical string. A narrower column here
61
+ // would misalign every column after it just for that one row.
62
+ col('FEATURE', 20) + col('TYPE', 9) + col('PHASE', 18) + col('TASKS', 8)
59
63
  + col('GATES PEND.', 20) + col('WHERE', 24) + col('REMOTO', 16) + col('CLAIM', 16)
60
64
  + col('PORTS', 12) + col('HANDOFF', 9) + 'ATIVIDADE',
61
65
  ));
62
66
  for (const e of fleet.entries) {
63
67
  const tasks = e.tasks ? `${e.tasks.done}/${e.tasks.total}` : '-';
64
68
  const gates = e.pendingGates.length ? e.pendingGates.join(',') : '-';
65
- const where = e.location === 'worktree' ? e.branch : 'raiz';
69
+ // pr-pending has no worktree and isn't root-resident either — its branch
70
+ // is the only place it exists, so show that instead of the misleading
71
+ // 'raiz' a location-blind check would print.
72
+ const where = (e.location === 'worktree' || e.location === 'pr-pending') ? e.branch : 'raiz';
66
73
  const ports = e.env ? `${e.env.range[0]}-${e.env.range[1]}` : '-';
67
74
  const handoff = e.handoff?.exists ? 'sim' : '-';
68
75
  const activity = [
@@ -73,7 +80,7 @@ export function fleetCommand(options = {}) {
73
80
  // length and append the padding by hand.
74
81
  const claimPlain = e.claim ? `${e.claim.live ? 'sessão' : 'morta'} (${claimAge(e.claim)})` : '-';
75
82
  console.log(
76
- col(e.feature, 20) + col(e.workType || '-', 9) + col(e.phase, 11) + col(tasks, 8)
83
+ col(e.feature, 20) + col(e.workType || '-', 9) + col(e.phase, 18) + col(tasks, 8)
77
84
  + col(gates, 20) + col(where, 24) + col(remoteCell(e.remote), 16)
78
85
  + claimCell(e.claim) + ' '.repeat(Math.max(1, 16 - claimPlain.length))
79
86
  + col(ports, 12) + col(handoff, 9) + activity,
@@ -35,6 +35,7 @@ import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, append
35
35
  import { join, resolve, dirname } from 'path';
36
36
  import chalk from 'chalk';
37
37
  import { detectDefaultBranch } from '../../lib/git/default-branch.js';
38
+ import { triageIgnoredPaths } from '../../lib/worktree/ignored-triage.js';
38
39
  // The reparse scan lives in hooks/shared because the PreToolUse guardrail needs
39
40
  // the SAME answer this file does, and hooks can never import from `src/`.
40
41
  import { findReparsePoints } from '../../../framework/hooks/shared/reparse-scan.js';
@@ -767,23 +768,6 @@ export function unlinkWorktreeInfraLinks(worktreePath, mainCwd) {
767
768
  */
768
769
  const IGNORED_SHIELDABLE_PREFIXES = ['.morph/'];
769
770
 
770
- /**
771
- * Well-known, regenerable build artifacts — `bin/`, `obj/` (dotnet), a real
772
- * `node_modules` left behind outside the setup-created junction, and
773
- * `*.tsbuildinfo` (tsc incremental cache). These are the everyday leftovers of
774
- * running a build/test cycle inside a worktree, not user data: they are fully
775
- * reproducible from source, and requiring `--discard-ignored` for every finish
776
- * that ever ran a build turns the flag from "explicit consent for a real risk"
777
- * into a rubber stamp nobody reads anymore. Auto-discarding them (like any
778
- * `discarded` entry, WITHOUT a backup — see the note below) is safe precisely
779
- * because "regenerable" is a stronger guarantee than "backed up".
780
- *
781
- * Deliberately narrow — do NOT add speculative patterns (`dist/`, `.next/`)
782
- * beyond what's proven to be pure noise; anything else stays in `blocking` by
783
- * default, requiring the explicit `--discard-ignored` consent.
784
- *
785
- * @param {string} p - forward-slash relative path from `git status --ignored`
786
- */
787
771
  /**
788
772
  * Whether a dirty root-side path is EXACTLY a sibling feature's live worktree
789
773
  * registration — see the call site (primaryRootUnexplainedDirt, the root-clean
@@ -811,16 +795,9 @@ function isLiveSiblingWorktreeRegistration(relPath, root, feature) {
811
795
  }
812
796
  }
813
797
 
814
- function isDisposableBuildArtifact(p) {
815
- if (/\.tsbuildinfo$/.test(p)) return true;
816
- const segments = p.split('/').filter(Boolean);
817
- return segments.some((s) => s === 'bin' || s === 'obj' || s === 'node_modules');
818
- }
819
-
820
798
  /**
821
- * Split the worktree's GITIGNORED leftovers into what this command may remove
822
- * (morph's own, after a backup, or a known disposable build artifact, without
823
- * one) and what it must refuse to destroy.
799
+ * Split the worktree's GITIGNORED leftovers into what this command may delete
800
+ * outright, what it must COPY OUT first, and what it cannot even enumerate.
824
801
  *
825
802
  * Why this exists: `git worktree remove` deletes gitignored files outright, and
826
803
  * the clean-check that gates it uses `git status --porcelain`, which omits them
@@ -829,14 +806,33 @@ function isDisposableBuildArtifact(p) {
829
806
  * disposable; morph must not, because "not in git" is exactly the property that
830
807
  * makes a file unrecoverable.
831
808
  *
832
- * Fail-CLOSED: if the status call fails we cannot enumerate what is at stake, so
833
- * everything is reported as blocking.
809
+ * The buckets, and why refusing is no longer one of them: the earlier version
810
+ * blocked on ANY unrecognised gitignored path, which in practice meant blocking
811
+ * on `.env` files that were byte-identical to the primary root's — nothing was
812
+ * at stake, and the only escape offered (`--discard-ignored`) also destroyed the
813
+ * genuinely unique `keys/` next to them. `triageIgnoredPaths` answers the
814
+ * question that actually decides whether bytes are lost (see ignored-triage.js),
815
+ * so the command can PROCEED in every case, backing up only what exists nowhere
816
+ * else. A refusal survives for exactly one reason now: the backup FAILED.
817
+ *
818
+ * shieldable — morph's own bookkeeping + anything EXCLUSIVE to this worktree.
819
+ * Copied to `finish-backup` before the removal; a failed copy is
820
+ * what declines the removal.
821
+ * discarded — build artifacts (regenerable) and REPLICABLE files (the root
822
+ * holds the same path with the same SHA-256). Deleted without a
823
+ * backup because neither loses bytes.
824
+ * blocking — the fail-CLOSED sentinel only: `git status --ignored` itself
825
+ * failed, so we cannot enumerate what is at stake.
826
+ *
827
+ * `--discard-ignored` no longer means "destroy the unique ones too, blind"; it
828
+ * means SKIP THE BACKUP — the same removal, without the copy.
834
829
  *
835
830
  * @param {string} worktreePath
831
+ * @param {string} rootPath - the primary root, for the replica comparison
836
832
  * @param {{ discardIgnored?: boolean }} options
837
- * @returns {{ shieldable: string[], blocking: string[], discarded: string[] }}
833
+ * @returns {{ shieldable: string[], blocking: string[], discarded: string[], replicable: string[] }}
838
834
  */
839
- function classifyIgnoredLeftovers(worktreePath, options = {}) {
835
+ function classifyIgnoredLeftovers(worktreePath, rootPath, options = {}) {
840
836
  let out;
841
837
  try {
842
838
  // `-z` + execFile: NUL-delimited, no shell, no C-style quoting to undo —
@@ -850,30 +846,30 @@ function classifyIgnoredLeftovers(worktreePath, options = {}) {
850
846
  // empirically, not assumed.
851
847
  out = execFileSync('git', ['status', '--porcelain', '--ignored=matching', '-z'], { cwd: worktreePath, stdio: 'pipe' }).toString();
852
848
  } catch {
853
- return { shieldable: [], blocking: ['<git status --ignored falhou — não foi possível enumerar o que seria destruído>'], discarded: [] };
849
+ return { shieldable: [], blocking: ['<git status --ignored falhou — não foi possível enumerar o que seria destruído>'], discarded: [], replicable: [] };
854
850
  }
855
851
 
856
- const shieldable = [];
857
- const blockingRest = [];
858
- const disposable = [];
852
+ const morphOwned = [];
853
+ const rest = [];
859
854
  for (const record of out.split('\0')) {
860
855
  if (!record || record.length < 4) continue;
861
856
  if (record.slice(0, 2) !== '!!') continue; // only ignored entries; the rest is the clean-check's job
862
857
  const p = record.slice(3).replace(/\\/g, '/');
863
- if (IGNORED_SHIELDABLE_PREFIXES.some((prefix) => p.startsWith(prefix))) shieldable.push(p);
864
- else if (isDisposableBuildArtifact(p)) disposable.push(p);
865
- else blockingRest.push(p);
858
+ if (IGNORED_SHIELDABLE_PREFIXES.some((prefix) => p.startsWith(prefix))) morphOwned.push(p);
859
+ else rest.push(p);
860
+ }
861
+
862
+ const { replicable, disposable, exclusive } = triageIgnoredPaths({ worktreePath, rootPath, paths: rest });
863
+ const shieldable = [...morphOwned, ...exclusive];
864
+
865
+ // Explicit consent = SKIP THE BACKUP, not "widen what dies". The same paths
866
+ // are removed either way; the flag only decides whether a copy is made first.
867
+ // The list is never thrown away — these are the bytes that exist nowhere
868
+ // else, and reporting them is the least this can do.
869
+ if (options.discardIgnored) {
870
+ return { shieldable: [], blocking: [], discarded: [...disposable, ...replicable, ...shieldable], replicable };
866
871
  }
867
- // Explicit consent clears the rest of the block too — but the list is NOT
868
- // thrown away. These are the files that exist nowhere else, and they are the
869
- // ones about to die; reporting them is the least this can do. (Backing them
870
- // up instead is not an option: this bucket is where arbitrarily large
871
- // leftovers land, so a copy is unbounded. The user declared them
872
- // disposable — they still get told.)
873
- if (options.discardIgnored) return { shieldable, blocking: [], discarded: [...disposable, ...blockingRest] };
874
- // Known build artifacts are discarded by default, no flag required — they
875
- // are reproducible from source, unlike anything else in `blockingRest`.
876
- return { shieldable, blocking: blockingRest, discarded: disposable };
872
+ return { shieldable, blocking: [], discarded: [...disposable, ...replicable], replicable };
877
873
  }
878
874
 
879
875
  /**
@@ -1177,7 +1173,7 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1177
1173
  const unlinked = unlinkWorktreeInfraLinks(worktreePath, root);
1178
1174
 
1179
1175
  const scan = findReparsePoints(worktreePath);
1180
- const ignored = classifyIgnoredLeftovers(worktreePath, options);
1176
+ const ignored = classifyIgnoredLeftovers(worktreePath, root, options);
1181
1177
  const blockers = [...scan.points, ...scan.unprovable];
1182
1178
 
1183
1179
  // Back up ONLY once the removal is otherwise cleared: nothing is destroyed on
@@ -1257,14 +1253,22 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1257
1253
  const removeCmd = (addressByTarget || !isValidFeatureName(feature))
1258
1254
  ? `morph-spec worktree remove --target "${worktreePath}"`
1259
1255
  : `morph-spec worktree remove ${feature}`;
1256
+ const backupDir = join(root, '.git', 'morph-spec', 'finish-backup');
1257
+ const sample = (list) => `${list.slice(0, 5).join(', ')}${list.length > 5 ? `, +${list.length - 5}` : ''}`;
1260
1258
  const declineReason = worktreeRemoved
1261
1259
  ? null
1262
1260
  : ignored.blocking.length
1263
- ? `worktree NÃO removido: contém arquivo(s) gitignored que existem só ali e a remoção apagaria (${ignored.blocking.slice(0, 5).join(', ')}${ignored.blocking.length > 5 ? `, +${ignored.blocking.length - 5}` : ''}). Salve o que importa e rode \`${removeCmd}\`, ou \`${removeCmd} --discard-ignored\` para descartá-los deliberadamente`
1261
+ ? `worktree NÃO removido: não foi possível enumerar os arquivos gitignored (${sample(ignored.blocking)}), então não dá para provar que a remoção não destrói nada. Rode \`git status --ignored\` dentro do worktree para ver o que está travando e tente de novo com \`${removeCmd}\``
1264
1262
  : blockers.length
1265
1263
  ? `worktree NÃO removido: ainda contém link(s)/caminho(s) não verificáveis (${blockers.join(', ')}) que a remoção seguiria para fora, apagando o alvo na raiz. Remova-os e rode \`${removeCmd}\``
1266
1264
  : ignored.shieldable.length && ignoredBackup === null
1267
- ? `worktree NÃO removido: o backup do gitignored do worktree falhou, e sem ele a remoção seria destrutiva. Libere espaço/permissão em \`.git/morph-spec/\` e rode \`${removeCmd}\`, ou \`${removeCmd} --discard-ignored\` se não precisar desse conteúdo`
1265
+ // The ONLY gitignored refusal left. The old hint was circular — it
1266
+ // said "salve o que importa e rode <the command that just failed>".
1267
+ // This one names WHAT is exclusive, what was replicable (so the
1268
+ // reader stops worrying about those), and WHERE the copy was going.
1269
+ ? `worktree NÃO removido: o backup falhou. Estes arquivos existem só neste worktree e a remoção os apagaria: ${sample(ignored.shieldable)}. `
1270
+ + (ignored.replicable.length ? `(${sample(ignored.replicable)} são idênticos aos da raiz — esses não são o problema.) ` : '')
1271
+ + `O backup iria para \`${backupDir}\`; libere espaço/permissão ali e rode \`${removeCmd}\`, ou copie os arquivos você mesmo e rode \`${removeCmd} --discard-ignored\` para pular o backup`
1268
1272
  : removalFailure?.kind === 'in-use'
1269
1273
  ? `worktree NÃO removido: o diretório está EM USO por outro processo (instância do Claude Code, VS Code, dev server ou terminal com cwd em "${worktreePath}"). A infra compartilhada ficou DESMONTADA de propósito — feche o que está usando o diretório e rode \`${removeCmd}\`. Se preferir continuar trabalhando nele, restaure a infra com \`morph-spec worktree link --all\``
1270
1274
  : `worktree NÃO removido: a remoção falhou${removalFailure?.detail ? ` (${removalFailure.detail})` : ''}. A infra compartilhada ficou DESMONTADA de propósito — rode \`${removeCmd}\` para tentar de novo, ou \`morph-spec worktree link --all\` para voltar a trabalhar nele`;
@@ -1273,6 +1277,14 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1273
1277
  worktreeRemoved,
1274
1278
  ignoredBackup,
1275
1279
  discarded: ignored.discarded,
1280
+ replicable: ignored.replicable,
1281
+ // The infra reparse points this teardown dropped BEFORE letting git near
1282
+ // the directory. Reported because it is the load-bearing safety step of the
1283
+ // whole operation and was previously invisible: the user had no way to see
1284
+ // that `.claude` was a junction into the primary root, nor that it was
1285
+ // unlinked rather than followed. On a removal it is the proof the root's
1286
+ // real `.claude/` was never at risk.
1287
+ unlinked,
1276
1288
  blockers,
1277
1289
  ignoredBlocking: ignored.blocking,
1278
1290
  shieldable: ignored.shieldable,
@@ -1405,13 +1417,24 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
1405
1417
  ...(branch ? { branch } : {}),
1406
1418
  ...(feature ? { feature } : {}),
1407
1419
  };
1420
+ // Reported on BOTH paths: on a removal it is the proof the primary root's
1421
+ // real `.claude/`/`.morph/framework/` were unlinked rather than followed; on
1422
+ // a refusal it is what `relinked` did or did not put back.
1423
+ if (teardown.unlinked.length) out.unlinked = teardown.unlinked;
1408
1424
  if (teardown.worktreeRemoved) {
1409
1425
  if (teardown.discarded.length) out.discardedIgnored = teardown.discarded;
1426
+ // Broken out of `discardedIgnored` on purpose: "deleted" and "deleted
1427
+ // because the identical bytes are still in the root" are not the same
1428
+ // report, and the second is the one that needs no follow-up from the user.
1429
+ if (teardown.replicable.length) out.replicatedInRoot = teardown.replicable;
1410
1430
  if (teardown.ignoredBackup) out.ignoredBackup = teardown.ignoredBackup;
1431
+ if (teardown.shieldable.length) out.backedUp = teardown.shieldable;
1411
1432
  } else {
1412
1433
  out.refused = teardown.refused;
1413
1434
  if (teardown.blockers.length) out.reparsePoints = teardown.blockers;
1414
1435
  if (teardown.ignoredBlocking.length) out.ignoredFiles = teardown.ignoredBlocking;
1436
+ if (teardown.shieldable.length) out.exclusiveIgnored = teardown.shieldable;
1437
+ if (teardown.replicable.length) out.replicatedInRoot = teardown.replicable;
1415
1438
  if (teardown.infraLeftUnlinked.length) out.infraLeftUnlinked = teardown.infraLeftUnlinked;
1416
1439
  out.hint = teardown.declineReason;
1417
1440
  }
@@ -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
  }
@@ -49,6 +49,13 @@ export const OUTPUT_SCHEMA = {
49
49
  mandate: { filename: 'mandate.md', phaseDir: '2-plan', phase: 'plan', protected: true, approvalGate: 'plan' },
50
50
  tasks: { filename: 'tasks.json', phaseDir: '2-plan', phase: 'plan', protected: true, approvalGate: 'plan', mutableFields: ['status', 'outputs', 'notes'] },
51
51
  decisions: { filename: 'decisions.md', phaseDir: '2-plan', phase: 'plan', protected: false },
52
+ // Veredito REUSAR/ESTENDER/CRIAR por conceito que a feature introduz, com a
53
+ // justificativa de cada CRIAR. É o ARTEFATO do ciclo de reuso (a prosa está
54
+ // em morph-plan §2b, a cobrança em src/lib/validators/reuse/). NÃO protegido,
55
+ // como decisions.md: é registro vivo — o implementador que descobre um alvo
56
+ // melhor no meio da task precisa poder anotar, e congelá-lo transformaria a
57
+ // correção num bloqueio de gate.
58
+ reuseMap: { filename: 'reuse-map.md', phaseDir: '2-plan', phase: 'plan', protected: false },
52
59
  // N files, one per system use case — the behaviour layer the plan distils
53
60
  // BEFORE spec.md/tasks.json. Deliberately NOT protected: editing a use case
54
61
  // after Gate 2 is the supported way to change behaviour and re-run implement;