@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
@@ -28,6 +28,8 @@ import {
28
28
  pendingGatesFrom,
29
29
  } from '../../../framework/hooks/shared/state-reader.js';
30
30
  import { listClaims } from '../../../framework/hooks/shared/claim-registry.js';
31
+ import { buildPrIndex } from '../git/pr-index.js';
32
+ import { pendingPrFeatures } from '../git/pending-prs.js';
31
33
 
32
34
  function countFrom(raw) {
33
35
  const data = JSON.parse(raw);
@@ -133,32 +135,18 @@ function remoteHeads(root) {
133
135
  /**
134
136
  * One `gh pr list` for the whole scan → Map<headRefName, {number, state}>.
135
137
  *
136
- * Gated on the origin URL actually pointing at github.com — `gh` against any
137
- * other host is a guaranteed error, and paying its start-up cost to learn that
138
- * on every `fleet` call is pure latency. Fail-open: no `gh`, not authenticated,
139
- * or a timeout all yield `null`, which just leaves the PR half of the column
140
- * empty while `pushed` still works.
138
+ * Delegates to the shared `buildPrIndex` so `fleet`, `branch prune` and
139
+ * `doctor --worktrees` cannot answer "does this branch have a PR" differently
140
+ * — the destructive two read it to decide what NOT to delete.
141
+ *
142
+ * Fail-open: `null` (no `gh`, not authenticated, not GitHub, timeout) just
143
+ * leaves the PR half of the column empty while `pushed` still works.
141
144
  *
142
145
  * @param {string} root
143
146
  * @returns {Map<string,{number:number,state:string}>|null}
144
147
  */
145
148
  function remotePrs(root) {
146
- try {
147
- const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: root, stdio: 'pipe' }).toString();
148
- if (!/github\.com/i.test(url)) return null;
149
- const out = execFileSync('gh', ['pr', 'list', '--state', 'all', '--limit', '100', '--json', 'number,state,headRefName'], {
150
- cwd: root, stdio: 'pipe', timeout: 8000,
151
- }).toString();
152
- const map = new Map();
153
- for (const pr of JSON.parse(out)) {
154
- // First wins: `gh` lists newest-first, so an OPEN PR shadows an older
155
- // closed one on the same branch.
156
- if (pr?.headRefName && !map.has(pr.headRefName)) map.set(pr.headRefName, { number: pr.number, state: pr.state });
157
- }
158
- return map;
159
- } catch {
160
- return null;
161
- }
149
+ return buildPrIndex(root, { limit: 100 });
162
150
  }
163
151
 
164
152
  /**
@@ -297,5 +285,47 @@ export function scanFleet(cwd = process.cwd()) {
297
285
  }
298
286
  } catch { /* fail-open */ }
299
287
 
288
+ // Features that exist ONLY as an open PR: no registered worktree, no
289
+ // `.morph/features/*` folder in this tree. This is exactly what
290
+ // `finish --pr` leaves behind — and without this pass `fleet` would say
291
+ // "nothing in flight" about work that is under review on GitHub.
292
+ //
293
+ // `prs` was attempted above whenever `heads` is truthy (`remotePrs` runs —
294
+ // see `const prs = heads ? remotePrs(root) : null;` — regardless of
295
+ // whether that `gh` call itself succeeded). Gate on the SAME condition
296
+ // `remotePrs` used, not on `prs !== null`: `remotePrs`/`buildPrIndex` can
297
+ // fail (timeout, not authenticated, malformed JSON) and legitimately
298
+ // return `null` even after really invoking `gh`. Passing that `null`
299
+ // through lets `pendingPrFeatures` short-circuit on its own documented
300
+ // contract (explicit `null` index = known unknown, never a fallback build)
301
+ // instead of retrying `gh` a second time in the same scan. Only when
302
+ // `remotePrs` was never attempted at all (`heads` falsy) does
303
+ // `pendingPrFeatures` get no index, so it makes the first (and only) `gh`
304
+ // call itself.
305
+ try {
306
+ const seen = new Set(entries.map((e) => e.feature));
307
+ const pendOpts = heads ? { index: prs } : {};
308
+ for (const p of pendingPrFeatures(root, pendOpts) || []) {
309
+ if (seen.has(p.feature)) continue;
310
+ entries.push({
311
+ feature: p.feature,
312
+ workType: null,
313
+ phase: 'aguardando-merge',
314
+ status: 'in_progress',
315
+ pendingGates: [],
316
+ location: 'pr-pending',
317
+ path: null,
318
+ branch: p.branch,
319
+ tasks: null,
320
+ env: null,
321
+ handoff: { exists: false, generatedAt: null },
322
+ lastCommit: null,
323
+ dirty: null,
324
+ remote: { branch: p.branch, pushed: true, sha: null, pr: { number: p.number, state: p.state } },
325
+ claim: null,
326
+ });
327
+ }
328
+ } catch { /* fail-open — a pendency not listed must never take down the scan */ }
329
+
300
330
  return { root, entries };
301
331
  }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * ignored-triage.js — decide, per gitignored leftover, whether removing a
3
+ * worktree would DESTROY something or merely delete a copy.
4
+ *
5
+ * WHY THIS EXISTS: `git worktree remove` deletes gitignored files outright and
6
+ * the clean-check that gates it (`git status --porcelain`) omits them by
7
+ * design, so a user's `.env` dies silently while the command reports success.
8
+ * The first fix for that refused the removal whenever ANY unrecognised
9
+ * gitignored path was present — correct, but blind: in the observed session a
10
+ * worktree carried `.env` and `.env.e2e` that were BYTE-IDENTICAL to the
11
+ * primary root's, alongside a `keys/key-*.xml` that existed nowhere else. The
12
+ * refusal treated them alike, and the only escape it offered
13
+ * (`--discard-ignored`) destroyed the unique one together with the replicas.
14
+ *
15
+ * The triage below replaces "recognised vs not" with the question that actually
16
+ * decides whether bytes are lost:
17
+ *
18
+ * REPLICABLE — the primary root has the same relative path with identical
19
+ * content. Deleting the worktree's copy loses nothing; the
20
+ * original stays where it always was.
21
+ * DISPOSABLE — a well-known build artifact (`bin/`, `obj/`, `node_modules/`,
22
+ * `__pycache__/`, `test-results/`, `.auth/`, `*.tsbuildinfo`).
23
+ * Reproducible from source, which is a STRONGER guarantee than
24
+ * backed up.
25
+ * EXCLUSIVE — everything else: no counterpart in the root, or a counterpart
26
+ * whose content differs. These are the only bytes at stake, and
27
+ * the caller copies them to `finish-backup` before proceeding.
28
+ *
29
+ * Fail-CLOSED on every uncertainty: an unreadable file, a hash that cannot be
30
+ * computed, a directory too large to compare, a counterpart that is a link
31
+ * rather than a real file — all classify as EXCLUSIVE. Being wrong in that
32
+ * direction costs a backup copy; being wrong the other way costs the file.
33
+ */
34
+
35
+ import { createHash } from 'crypto';
36
+ import { existsSync, lstatSync, readdirSync, readFileSync } from 'fs';
37
+ import { join } from 'path';
38
+
39
+ /**
40
+ * Well-known, regenerable build artifacts. Deliberately narrow — do NOT add
41
+ * speculative patterns (`dist/`, `.next/`) that a project might legitimately
42
+ * hand-edit; anything unlisted still reaches the EXCLUSIVE bucket, where it is
43
+ * backed up rather than assumed disposable.
44
+ *
45
+ * `.auth/` (Playwright storage state) belongs here for a reason worth stating:
46
+ * it holds a captured session, so it LOOKS like precious local state — but it
47
+ * is regenerated by the auth setup project on the next run, and it expires on
48
+ * its own. Keeping it out of the backup avoids copying credentials around.
49
+ */
50
+ const DISPOSABLE_DIR_NAMES = new Set([
51
+ 'bin', 'obj', 'node_modules', '__pycache__', 'test-results', '.auth',
52
+ ]);
53
+ const DISPOSABLE_FILE_RE = /\.tsbuildinfo$/;
54
+
55
+ /**
56
+ * Comparing a directory recursively is only worth doing while it stays small:
57
+ * the point is to spare `.env`-sized replicas from a needless refusal, not to
58
+ * checksum a data set. Past either cap the answer becomes EXCLUSIVE (the safe
59
+ * direction), and the caller backs it up.
60
+ */
61
+ const REPLICABLE_MAX_FILES = 500;
62
+ const REPLICABLE_MAX_BYTES = 8 * 1024 * 1024;
63
+
64
+ /**
65
+ * @param {string} p - forward-slash relative path
66
+ * @returns {boolean}
67
+ */
68
+ export function isDisposableBuildArtifact(p) {
69
+ if (DISPOSABLE_FILE_RE.test(p)) return true;
70
+ return p.split('/').filter(Boolean).some((seg) => DISPOSABLE_DIR_NAMES.has(seg));
71
+ }
72
+
73
+ function sha256OfFile(path) {
74
+ try {
75
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Real file (not a link, not a directory)? A reparse point must never be read
83
+ * THROUGH — following one is the exact failure mode the whole teardown path
84
+ * exists to prevent — so a link on either side makes the pair incomparable.
85
+ */
86
+ function isRealFile(path) {
87
+ try {
88
+ return lstatSync(path).isFile();
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ function isRealDir(path) {
95
+ try {
96
+ return lstatSync(path).isDirectory();
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Every real file under `dir`, relative to it. Returns null when the walk hits
104
+ * a link, an unreadable entry, or either cap — "cannot enumerate" and "too big
105
+ * to compare" both mean "do not claim this is replicable".
106
+ */
107
+ function walkFiles(dir, budget) {
108
+ const out = [];
109
+ const stack = [''];
110
+ while (stack.length) {
111
+ const rel = stack.pop();
112
+ let entries;
113
+ try {
114
+ entries = readdirSync(join(dir, rel), { withFileTypes: true });
115
+ } catch {
116
+ return null;
117
+ }
118
+ for (const e of entries) {
119
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
120
+ const childAbs = join(dir, childRel);
121
+ if (e.isSymbolicLink()) return null; // never compare through a link
122
+ if (e.isDirectory()) { stack.push(childRel); continue; }
123
+ if (!e.isFile()) return null; // socket/fifo/unknown — incomparable
124
+ let size;
125
+ try { size = lstatSync(childAbs).size; } catch { return null; }
126
+ budget.files += 1;
127
+ budget.bytes += size;
128
+ if (budget.files > REPLICABLE_MAX_FILES || budget.bytes > REPLICABLE_MAX_BYTES) return null;
129
+ out.push(childRel);
130
+ }
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * Does `root/rel` hold exactly the same content as `worktree/rel`?
137
+ *
138
+ * Files compare by SHA-256; directories compare by having the SAME set of
139
+ * files, each with an identical hash — a root copy that is merely a SUPERSET
140
+ * is not a replica of the worktree's, because the worktree's own extra files
141
+ * would still be lost.
142
+ *
143
+ * @returns {boolean} false on ANY uncertainty
144
+ */
145
+ export function isReplicatedInRoot(worktreePath, rootPath, rel) {
146
+ const src = join(worktreePath, rel);
147
+ const dst = join(rootPath, rel);
148
+ if (!existsSync(src) || !existsSync(dst)) return false;
149
+
150
+ if (isRealFile(src)) {
151
+ if (!isRealFile(dst)) return false;
152
+ const a = sha256OfFile(src);
153
+ const b = sha256OfFile(dst);
154
+ return a !== null && a === b;
155
+ }
156
+
157
+ if (isRealDir(src)) {
158
+ if (!isRealDir(dst)) return false;
159
+ const srcFiles = walkFiles(src, { files: 0, bytes: 0 });
160
+ if (srcFiles === null) return false;
161
+ const dstFiles = walkFiles(dst, { files: 0, bytes: 0 });
162
+ if (dstFiles === null) return false;
163
+ if (srcFiles.length !== dstFiles.length) return false;
164
+ const dstSet = new Set(dstFiles);
165
+ for (const f of srcFiles) {
166
+ if (!dstSet.has(f)) return false;
167
+ const a = sha256OfFile(join(src, f));
168
+ const b = sha256OfFile(join(dst, f));
169
+ if (a === null || a !== b) return false;
170
+ }
171
+ return true;
172
+ }
173
+
174
+ return false; // a link, or something that is neither — incomparable
175
+ }
176
+
177
+ /**
178
+ * Split gitignored worktree paths into the three buckets above.
179
+ *
180
+ * ORDER IS DELIBERATE: disposable is checked FIRST, so a `bin/` that happens to
181
+ * be byte-identical to the root's is discarded rather than hashed, and a
182
+ * `node_modules` never enters the comparison budget at all.
183
+ *
184
+ * @param {{ worktreePath: string, rootPath: string, paths: string[] }} args
185
+ * @returns {{ replicable: string[], disposable: string[], exclusive: string[] }}
186
+ */
187
+ export function triageIgnoredPaths({ worktreePath, rootPath, paths }) {
188
+ const replicable = [];
189
+ const disposable = [];
190
+ const exclusive = [];
191
+ for (const rel of paths) {
192
+ if (isDisposableBuildArtifact(rel)) { disposable.push(rel); continue; }
193
+ if (isReplicatedInRoot(worktreePath, rootPath, rel)) { replicable.push(rel); continue; }
194
+ exclusive.push(rel);
195
+ }
196
+ return { replicable, disposable, exclusive };
197
+ }
@@ -15,14 +15,16 @@
15
15
  *
16
16
  * Read-only and fail-open: anything unresolvable yields no issue rather than a
17
17
  * throw. A diagnostic that invents problems is worse than one that misses some.
18
- * The one mutation lives in `pruneWorktrees`, and it is deliberately the safest
19
- * possible: `git worktree prune` drops ADMIN RECORDS for directories that are
20
- * already gone. Deleting a directory that still exists stays a human decision —
21
- * the same PROVE-or-DECLINE discipline `teardownWorktree` follows.
18
+ * The mutations all live in `pruneWorktrees`, and each is deliberately the
19
+ * safest possible: `git worktree prune` drops ADMIN RECORDS for directories
20
+ * that are already gone, and the empty-residue sweep removes only directories
21
+ * with zero entries inside — nothing that can hold work. Deleting a directory
22
+ * that still HOLDS something stays a human decision, the same PROVE-or-DECLINE
23
+ * discipline `teardownWorktree` follows.
22
24
  */
23
25
 
24
26
  import { execFileSync } from 'child_process';
25
- import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
27
+ import { existsSync, readdirSync, readFileSync, rmdirSync, statSync } from 'fs';
26
28
  import { join, resolve } from 'path';
27
29
  import {
28
30
  isGitRepo,
@@ -31,6 +33,9 @@ import {
31
33
  WORKTREE_DIR_NAMES,
32
34
  clearDanglingWorktreeRegistration,
33
35
  } from '../../commands/project/worktree.js';
36
+ import { detectDefaultBranch } from '../git/default-branch.js';
37
+ import { buildPrIndex } from '../git/pr-index.js';
38
+ import { classifyBranch, commitsAhead, resolveBaseRef } from '../git/branch-classifier.js';
34
39
 
35
40
  const norm = (p) => resolve(p).replace(/\\/g, '/');
36
41
 
@@ -66,6 +71,54 @@ function featureCommittedOnBranch(root, feature) {
66
71
  }
67
72
  }
68
73
 
74
+ /**
75
+ * Does this worktree's branch still hold work that is NOT in the default
76
+ * branch, or an OPEN PR?
77
+ *
78
+ * This is the predicate that separates "an orphan directory to clean up" from
79
+ * "the only copy of somebody's work". Two independent pieces of evidence, and
80
+ * ANY of them is enough to protect the worktree:
81
+ *
82
+ * 1. commits the default branch does not have (`git rev-list base..branch`)
83
+ * 2. an OPEN pull request on that branch
84
+ *
85
+ * Fail-CLOSED, and that asymmetry is the whole design: whenever the answer
86
+ * cannot be established — no default branch, no origin, git errored — the
87
+ * worktree counts as UNINTEGRATED. The cost of being wrong that way is a
88
+ * diagnostic that stays quiet; the cost of the opposite is a suggestion to
89
+ * delete work that exists nowhere else.
90
+ *
91
+ * A squash-merged branch legitimately reports commits ahead, so the tree
92
+ * comparison (`branch-classifier.js`) is consulted for the same reason
93
+ * `branch prune` needs it — the two must not disagree about "is this in".
94
+ *
95
+ * @returns {{ unintegrated: boolean, evidence: string, pr: number|null }}
96
+ */
97
+ function unintegratedWork(root, branchRef, prIndex) {
98
+ const branch = (branchRef || '').replace(/^refs\/heads\//, '');
99
+ if (!branch) return { unintegrated: true, evidence: 'não foi possível resolver a branch do worktree', pr: null };
100
+
101
+ const pr = prIndex?.get(branch) ?? null;
102
+ if (pr?.state === 'OPEN') return { unintegrated: true, evidence: `PR #${pr.number} OPEN`, pr: pr.number };
103
+
104
+ const defaultBranch = detectDefaultBranch(root);
105
+ const baseRef = resolveBaseRef(defaultBranch, root);
106
+ if (!baseRef) return { unintegrated: true, evidence: 'sem branch default resolvível para comparar', pr: null };
107
+
108
+ const ahead = commitsAhead(baseRef, branch, root);
109
+ if (ahead === null) return { unintegrated: true, evidence: `não foi possível comparar com ${baseRef}`, pr: null };
110
+ if (ahead === 0) return { unintegrated: false, evidence: `nenhum commit fora de ${baseRef}`, pr: pr?.number ?? null };
111
+
112
+ // Commits ahead, but the work may still be IN: a squash-merge leaves exactly
113
+ // that shape. Reuse the classifier so `doctor` and `branch prune` cannot
114
+ // reach opposite conclusions about the same branch.
115
+ const verdict = classifyBranch({ name: branch, sha: '', current: false, worktreePath: null }, { baseRef, cwd: root, pr });
116
+ if (verdict.kind === 'merged-ancestral' || verdict.kind === 'merged-squash') {
117
+ return { unintegrated: false, evidence: verdict.reason, pr: pr?.number ?? null };
118
+ }
119
+ return { unintegrated: true, evidence: `${ahead} commit(s) fora de ${baseRef}`, pr: pr?.number ?? null };
120
+ }
121
+
69
122
  /**
70
123
  * Every feature's `worktree` registration in the primary root — the OTHER
71
124
  * direction from `knownFeatures`/`dirsOnDisk`: not "does git/disk know about
@@ -96,6 +149,22 @@ function featureRegistrations(root) {
96
149
  return out;
97
150
  }
98
151
 
152
+ /**
153
+ * Is this directory literally empty — zero entries, dotfiles included?
154
+ *
155
+ * The whole point of the distinction: an empty residue holds nothing that can
156
+ * be lost, so it can be removed without a human looking at it. Anything else —
157
+ * including a directory we cannot read — is reported as NON-empty, because the
158
+ * safe answer to "might there be work in here?" is yes.
159
+ */
160
+ function isEmptyDir(path) {
161
+ try {
162
+ return readdirSync(path).length === 0;
163
+ } catch {
164
+ return false; // unreadable → treat as occupied
165
+ }
166
+ }
167
+
99
168
  /** Directories physically present under either worktree root. */
100
169
  function dirsOnDisk(root) {
101
170
  const found = [];
@@ -106,7 +175,9 @@ function dirsOnDisk(root) {
106
175
  for (const name of readdirSync(base)) {
107
176
  const full = join(base, name);
108
177
  try {
109
- if (statSync(full).isDirectory()) found.push({ name, path: norm(full), container: dirName });
178
+ if (statSync(full).isDirectory()) {
179
+ found.push({ name, path: norm(full), container: dirName, empty: isEmptyDir(full) });
180
+ }
110
181
  } catch { /* unreadable entry — skip */ }
111
182
  }
112
183
  } catch { /* fail-open */ }
@@ -125,7 +196,13 @@ function dirsOnDisk(root) {
125
196
  * • unregistered-dir — a directory under worktrees/ git never
126
197
  * registered
127
198
  * • no-feature — a `morph/{f}` worktree for a feature morph
128
- * does not know
199
+ * does not know AND whose branch carries
200
+ * nothing that is not already integrated
201
+ * • unintegrated-work (info) — the same shape, except the branch still holds
202
+ * commits the default does not, or an OPEN PR.
203
+ * Reported so the reader knows why no removal
204
+ * is suggested — this worktree may be the ONLY
205
+ * copy of the work
129
206
  * • dangling-registration — a feature's ROOT feature.json still points
130
207
  * at a `worktree.path` that no longer exists
131
208
  * (the symmetric OTHER direction from
@@ -144,6 +221,11 @@ export function auditWorktrees(cwd = process.cwd()) {
144
221
  const rootNorm = norm(root);
145
222
  const issues = [];
146
223
 
224
+ // ONE `gh pr list` for the whole audit — null means UNKNOWN (no gh, not
225
+ // GitHub, not authenticated), never "no PRs"; `unintegratedWork` treats the
226
+ // two differently on purpose.
227
+ const prIndex = buildPrIndex(root);
228
+
147
229
  const registered = parseWorktreeList(root);
148
230
  const registeredPaths = new Set();
149
231
  const features = knownFeatures(root);
@@ -169,27 +251,65 @@ export function auditWorktrees(cwd = process.cwd()) {
169
251
  // a worktree the user keeps elsewhere is none of morph's business.
170
252
  const inContainer = WORKTREE_DIR_NAMES.some((d) => path.startsWith(`${rootNorm}/${d}/`));
171
253
  if (inContainer) {
172
- issues.push({
173
- type: 'non-morph-branch',
174
- path,
175
- detail: `${path} está em ${entry.branch || 'HEAD destacado'}, não numa branch morph/* — o \`fleet\` não o enxerga`,
176
- // NEVER raw `git worktree remove` here: it follows the infra junctions
177
- // out and empties the primary root's .claude/ and .morph/framework/.
178
- // `worktree remove` drops them first (PROVE-or-DECLINE) and takes
179
- // --target precisely for worktrees like this one, with no feature.
180
- fix: `morph-spec worktree remove --target "${path}" (se já não serve)`,
181
- });
254
+ // Same hazard as `no-feature` below, and it needs the same guard:
255
+ // "invisible to fleet" says nothing about whether the WORK is safe
256
+ // elsewhere. A worktree on a `feat/*` branch with an open PR is the
257
+ // most normal thing in the world, and suggesting its removal is a
258
+ // suggestion to destroy the only copy.
259
+ const work = unintegratedWork(root, entry.branch, prIndex);
260
+ issues.push(work.unintegrated
261
+ ? {
262
+ type: 'unintegrated-work',
263
+ severity: 'info',
264
+ path,
265
+ detail: `${path} está em ${entry.branch || 'HEAD destacado'} (o \`fleet\` não o enxerga) e carrega trabalho NÃO integrado (${work.evidence})`,
266
+ fix: work.pr
267
+ ? `nada a fazer enquanto o PR #${work.pr} estiver aberto`
268
+ : `integre a branch antes de remover o worktree — ele é o único exemplar`,
269
+ }
270
+ : {
271
+ type: 'non-morph-branch',
272
+ path,
273
+ detail: `${path} está em ${entry.branch || 'HEAD destacado'}, não numa branch morph/* — o \`fleet\` não o enxerga (e não há trabalho pendente: ${work.evidence})`,
274
+ // NEVER raw `git worktree remove` here: it follows the infra junctions
275
+ // out and empties the primary root's .claude/ and .morph/framework/.
276
+ // `worktree remove` drops them first (PROVE-or-DECLINE) and takes
277
+ // --target precisely for worktrees like this one, with no feature.
278
+ fix: `morph-spec worktree remove --target "${path}" (se já não serve)`,
279
+ });
182
280
  }
183
281
  continue;
184
282
  }
185
283
 
186
284
  if (!features.has(feature) && !featureCommittedOnBranch(root, feature)) {
187
- issues.push({
188
- type: 'no-feature',
189
- path,
190
- detail: `worktree de "${feature}" sem feature conhecida (nem em .morph/features/, nem na branch)`,
191
- fix: `morph-spec worktree remove --target "${path}"`,
192
- });
285
+ // "sem feature conhecida" is a bookkeeping fact, NOT a verdict on the
286
+ // work: a `finish` that archived the feature leaves exactly this shape,
287
+ // and so does a worktree whose PR is still open. In the observed session
288
+ // this branch recommended `worktree remove --target` for a worktree
289
+ // holding the ONLY copy of an open PR with 12 tasks and a 9.5 at Gate 3.
290
+ // So the diagnosis now depends on whether the WORK is integrated, and the
291
+ // suggestion to destroy appears only when it provably is.
292
+ const work = unintegratedWork(root, entry.branch, prIndex);
293
+ if (work.unintegrated) {
294
+ issues.push({
295
+ type: 'unintegrated-work',
296
+ severity: 'info',
297
+ path,
298
+ feature,
299
+ detail: `worktree com trabalho NÃO integrado (${work.evidence}) — a feature foi arquivada, o trabalho não`,
300
+ fix: work.pr
301
+ ? `nada a fazer enquanto o PR #${work.pr} estiver aberto; depois de mergeado, \`morph-spec worktree remove --target "${path}"\``
302
+ : `integre a branch (\`morph-spec finish\` ou um PR) antes de remover o worktree — ele é o único exemplar`,
303
+ });
304
+ } else {
305
+ issues.push({
306
+ type: 'no-feature',
307
+ path,
308
+ feature,
309
+ detail: `worktree de "${feature}" sem feature conhecida (nem em .morph/features/, nem na branch) e sem trabalho pendente (${work.evidence})`,
310
+ fix: `morph-spec worktree remove --target "${path}"`,
311
+ });
312
+ }
193
313
  }
194
314
  }
195
315
 
@@ -198,8 +318,16 @@ export function auditWorktrees(cwd = process.cwd()) {
198
318
  issues.push({
199
319
  type: 'unregistered-dir',
200
320
  path: d.path,
201
- detail: `${d.container}/${d.name} existe no disco mas o git não o registra — resíduo de um finish anterior`,
202
- fix: `apague o diretório à mão depois de conferir que nada importante ficou nele`,
321
+ empty: d.empty,
322
+ detail: d.empty
323
+ ? `${d.container}/${d.name} existe no disco, está VAZIO e o git não o registra — resíduo de um finish anterior`
324
+ : `${d.container}/${d.name} existe no disco mas o git não o registra — resíduo de um finish anterior`,
325
+ // An empty folder has nothing to lose, so it gets the automatic repair.
326
+ // One with content keeps the manual chore: there may be work in there,
327
+ // and the whole PROVE-or-DECLINE discipline exists for exactly that.
328
+ fix: d.empty
329
+ ? `morph-spec doctor --worktrees --prune`
330
+ : `apague o diretório à mão depois de conferir que nada importante ficou nele`,
203
331
  });
204
332
  }
205
333
 
@@ -232,24 +360,59 @@ export function auditWorktrees(cwd = process.cwd()) {
232
360
  }
233
361
 
234
362
  /**
235
- * Drop git's admin records for worktrees whose directory is already gone, AND
236
- * clear any feature's `worktree` registration that names one of those same
237
- * gone directories.
363
+ * Remove the EMPTY directories under `worktrees/`/`.worktrees/` that git does
364
+ * not register — the residue a finish leaves behind.
365
+ *
366
+ * "Empty" is the whole licence to act: a folder with zero entries cannot be
367
+ * hiding uncommitted work, so asking a human about it is pure ceremony, and
368
+ * the audit that only warns leaves it there forever. A directory with anything
369
+ * in it keeps the manual path.
370
+ *
371
+ * Two guards beyond the emptiness check:
372
+ * • only directories directly inside morph's own worktree containers, never
373
+ * the containers themselves and never a path git registers;
374
+ * • `rmdirSync`, not `rm -rf` — it REFUSES a non-empty directory, so a file
375
+ * that appears between the check and the call is a failure, not a loss.
376
+ *
377
+ * @param {string} root
378
+ * @returns {string[]} paths actually removed
379
+ */
380
+ function removeEmptyUnregisteredDirs(root) {
381
+ const registeredPaths = new Set(parseWorktreeList(root).map((e) => norm(e.path)));
382
+ const removed = [];
383
+ for (const d of dirsOnDisk(root)) {
384
+ if (!d.empty || registeredPaths.has(d.path)) continue;
385
+ try {
386
+ rmdirSync(d.path);
387
+ removed.push(d.path);
388
+ } catch { /* raced, locked or no longer empty — leave it to the next audit */ }
389
+ }
390
+ return removed;
391
+ }
392
+
393
+ /**
394
+ * Drop git's admin records for worktrees whose directory is already gone,
395
+ * remove the EMPTY unregistered residue directories, AND clear any feature's
396
+ * `worktree` registration that names a directory that is now gone.
397
+ *
398
+ * These three are the ONLY prune offered, on purpose: none can destroy
399
+ * anything, because everything they touch is either already absent from disk
400
+ * or provably empty. Removing a directory that still HOLDS something is
401
+ * exactly the operation that needs a human looking at it.
238
402
  *
239
- * Both halves are the ONLY prune offered, on purpose: neither can destroy
240
- * anything, because everything they touch is already absent from disk.
241
- * Removing a directory that still exists is exactly the operation that needs
242
- * a human looking at it.
403
+ * Order is load-bearing: the empty directories go first, so a root pointer
404
+ * naming one of them becomes dangling in time for the registration sweep below
405
+ * to clear it — one command, not two runs.
243
406
  *
244
407
  * `clearDanglingWorktreeRegistration` re-checks the dangling predicate itself
245
408
  * (path unset, or still exists → no-op) rather than trusting a pre-computed
246
409
  * list, so this stays correct even called against a stale audit snapshot.
247
410
  *
248
411
  * @param {string} [cwd]
249
- * @returns {Promise<{ pruned: boolean, error?: string, clearedRegistrations: Array<{feature: string, cleared: boolean}> }>}
412
+ * @returns {Promise<{ pruned: boolean, error?: string, removedEmptyDirs: string[], clearedRegistrations: Array<{feature: string, cleared: boolean}> }>}
250
413
  */
251
414
  export async function pruneWorktrees(cwd = process.cwd()) {
252
- if (!isGitRepo(cwd)) return { pruned: false, error: 'not-a-git-repo', clearedRegistrations: [] };
415
+ if (!isGitRepo(cwd)) return { pruned: false, error: 'not-a-git-repo', removedEmptyDirs: [], clearedRegistrations: [] };
253
416
  const root = resolvePrimaryRoot(cwd);
254
417
 
255
418
  let pruned = false;
@@ -261,11 +424,13 @@ export async function pruneWorktrees(cwd = process.cwd()) {
261
424
  error = (err.message || 'unknown').slice(0, 200);
262
425
  }
263
426
 
427
+ const removedEmptyDirs = removeEmptyUnregisteredDirs(root);
428
+
264
429
  const clearedRegistrations = [];
265
430
  for (const reg of featureRegistrations(root)) {
266
431
  const { cleared } = await clearDanglingWorktreeRegistration(root, reg.feature);
267
432
  if (cleared) clearedRegistrations.push({ feature: reg.feature, cleared: true });
268
433
  }
269
434
 
270
- return { pruned, ...(error ? { error } : {}), clearedRegistrations };
435
+ return { pruned, ...(error ? { error } : {}), removedEmptyDirs, clearedRegistrations };
271
436
  }