@polymorphism-tech/morph-spec 9.0.0 → 9.1.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 (304) hide show
  1. package/CLAUDE.md +8 -9
  2. package/README.md +38 -36
  3. package/bin/morph-spec.js +4 -62
  4. package/bin/validate.js +1 -39
  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 +1 -1
  9. package/framework/MORPH.md +1 -1
  10. package/framework/agents.json +12 -4
  11. package/framework/commands/morph-apply.md +1 -1
  12. package/framework/commands/morph-status.md +1 -1
  13. package/framework/docs/CLI.md +6 -17
  14. package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +6 -49
  15. package/framework/hooks/claude-code/core/block-raw-worktree-add.js +2 -35
  16. package/framework/hooks/claude-code/core/chain.js +4 -65
  17. package/framework/hooks/claude-code/core/comment-guard.js +2 -16
  18. package/framework/hooks/claude-code/core/enforce-phase-writes.js +6 -50
  19. package/framework/hooks/claude-code/core/gate-guard.js +7 -67
  20. package/framework/hooks/claude-code/core/graph-refresh-kick.js +3 -24
  21. package/framework/hooks/claude-code/core/heavy-gate.js +3 -21
  22. package/framework/hooks/claude-code/core/heavy-release.js +2 -13
  23. package/framework/hooks/claude-code/core/loop-detect.js +2 -31
  24. package/framework/hooks/claude-code/core/pre-bash-denylist.js +0 -4
  25. package/framework/hooks/claude-code/core/protect-spec-files.js +7 -67
  26. package/framework/hooks/claude-code/core/state-sync.js +11 -67
  27. package/framework/hooks/claude-code/core/telemetry-log.js +12 -93
  28. package/framework/hooks/claude-code/core/validate-completion.js +11 -57
  29. package/framework/hooks/claude-code/core/warn-bash-source-write.js +1 -9
  30. package/framework/hooks/claude-code/core/worktree-context.js +10 -110
  31. package/framework/hooks/claude-code/core/worktree-create.js +6 -70
  32. package/framework/hooks/claude-code/core/worktree-env-inject.js +5 -56
  33. package/framework/hooks/claude-code/core/worktree-remove.js +5 -31
  34. package/framework/hooks/shared/activity-logger.js +9 -89
  35. package/framework/hooks/shared/agent-reap.js +2 -17
  36. package/framework/hooks/shared/build-check.js +6 -75
  37. package/framework/hooks/shared/claim-registry.js +9 -119
  38. package/framework/hooks/shared/command-tokens.js +6 -51
  39. package/framework/hooks/shared/comment-rules.js +5 -20
  40. package/framework/hooks/shared/comment-scanner.js +6 -25
  41. package/framework/hooks/shared/comment-stripper.js +11 -85
  42. package/framework/hooks/shared/e2e-check.js +0 -0
  43. package/framework/hooks/shared/errata.js +2 -15
  44. package/framework/hooks/shared/feature-locator.js +11 -95
  45. package/framework/hooks/shared/frontend-locations.js +1 -3
  46. package/framework/hooks/shared/gate-question.js +1 -7
  47. package/framework/hooks/shared/git-exclude.js +3 -20
  48. package/framework/hooks/shared/git-paths.js +8 -104
  49. package/framework/hooks/shared/graph-freshness.js +22 -166
  50. package/framework/hooks/shared/graph-refresh.js +21 -149
  51. package/framework/hooks/shared/heavy-command.js +4 -12
  52. package/framework/hooks/shared/hook-response.js +8 -83
  53. package/framework/hooks/shared/morph-cli.js +8 -52
  54. package/framework/hooks/shared/payload-utils.js +10 -132
  55. package/framework/hooks/shared/phase-utils.js +1 -42
  56. package/framework/hooks/shared/redact-secrets.js +4 -15
  57. package/framework/hooks/shared/reparse-scan.js +6 -66
  58. package/framework/hooks/shared/slice-progress.js +4 -23
  59. package/framework/hooks/shared/source-files.js +4 -17
  60. package/framework/hooks/shared/state-reader.js +13 -183
  61. package/framework/hooks/shared/state-writer.js +8 -52
  62. package/framework/hooks/shared/stdin-reader.js +1 -11
  63. package/framework/hooks/shared/telemetry-logger.js +7 -67
  64. package/framework/hooks/shared/transcript-usage.js +7 -29
  65. package/framework/hooks/shared/worktree-root.js +7 -29
  66. package/framework/rules/csharp-standards.md +1 -1
  67. package/framework/rules/frontend-standards.md +1 -0
  68. package/framework/rules/hangfire-standards.md +1 -1
  69. package/framework/rules/neon-standards.md +1 -1
  70. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  71. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  72. package/framework/skills/core/morph-apply/SKILL.md +2 -2
  73. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  74. package/framework/skills/core/morph-machine/SKILL.md +1 -1
  75. package/framework/skills/core/morph-media/SKILL.md +1 -1
  76. package/framework/skills/core/morph-plan/SKILL.md +2 -2
  77. package/framework/skills/core/morph-review/SKILL.md +1 -1
  78. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  79. package/framework/skills/core/morph-uiux/SKILL.md +29 -12
  80. package/framework/standards/STANDARDS.json +34 -12
  81. package/framework/standards/STANDARDS.meta.json +16 -2
  82. package/framework/standards/ai-agents/agent-archetypes.md +2 -2
  83. package/framework/standards/ai-agents/agent-session.md +1 -1
  84. package/framework/standards/ai-agents/conversational-agent-with-phases.md +4 -4
  85. package/framework/standards/ai-agents/durable-workflows-hitl.md +3 -3
  86. package/framework/standards/ai-agents/guardrails.md +1 -1
  87. package/framework/standards/ai-agents/multi-agent-patterns.md +2 -2
  88. package/framework/standards/ai-agents/observability-patterns.md +1 -1
  89. package/framework/standards/ai-agents/production.md +1 -1
  90. package/framework/standards/ai-agents/prompt-sources.md +2 -2
  91. package/framework/standards/ai-agents/providers/model-registry.md +1 -1
  92. package/framework/standards/ai-agents/rag-custom-pgvector.md +2 -2
  93. package/framework/standards/ai-agents/setup.md +2 -2
  94. package/framework/standards/ai-agents/sweet-spot.md +2 -2
  95. package/framework/standards/ai-agents/tool-adapters.md +0 -2
  96. package/framework/standards/ai-agents/vector-data-extensions.md +4 -4
  97. package/framework/standards/ai-agents/workflows.md +1 -1
  98. package/framework/standards/architecture/code-comments.md +1 -1
  99. package/framework/standards/architecture/vertical-slice/vertical-slice.md +1 -1
  100. package/framework/standards/backend/api/validation.md +1 -1
  101. package/framework/standards/backend/dotnet/vsa-handler-patterns.md +5 -5
  102. package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +1 -1
  103. package/framework/standards/backend/integrations/neon-auth/neon-auth.md +2 -2
  104. package/framework/standards/data/nosql/cache/redis.md +1 -1
  105. package/framework/standards/data/nosql/cosmos-db.md +3 -3
  106. package/framework/standards/frontend/admin-app/forms.md +1 -1
  107. package/framework/standards/frontend/design-system/art-direction.md +88 -0
  108. package/framework/standards/frontend/design-system/premium-finish.md +77 -2
  109. package/framework/standards/infrastructure/neon/neon-pgvector.md +5 -5
  110. package/framework/templates/CLAUDE.md.core.template +3 -3
  111. package/framework/templates/REGISTRY.json +9 -9
  112. package/framework/templates/docs/ui-mockups.md +4 -0
  113. package/package.json +1 -1
  114. package/src/commands/context/context-refresh.js +0 -16
  115. package/src/commands/dashboard/dashboard.js +2 -8
  116. package/src/commands/e2e/e2e.js +17 -104
  117. package/src/commands/graph/affected.js +0 -14
  118. package/src/commands/graph/build.js +11 -50
  119. package/src/commands/graph/explain.js +0 -16
  120. package/src/commands/graph/status.js +1 -12
  121. package/src/commands/mcp/mcp-setup.js +4 -62
  122. package/src/commands/observability/cost.js +5 -57
  123. package/src/commands/observability/telemetry.js +1 -14
  124. package/src/commands/project/branch.js +4 -45
  125. package/src/commands/project/claim.js +4 -48
  126. package/src/commands/project/doctor.js +48 -302
  127. package/src/commands/project/env.js +1 -27
  128. package/src/commands/project/fleet.js +3 -32
  129. package/src/commands/project/init.js +4 -68
  130. package/src/commands/project/install-plugin-cmd.js +0 -1
  131. package/src/commands/project/standards.js +1 -62
  132. package/src/commands/project/status.js +0 -53
  133. package/src/commands/project/update.js +31 -239
  134. package/src/commands/project/worktree.js +108 -882
  135. package/src/commands/scaffold/scaffold.js +0 -25
  136. package/src/commands/state/advance.js +14 -85
  137. package/src/commands/state/approve.js +14 -96
  138. package/src/commands/state/archive.js +12 -64
  139. package/src/commands/state/auto-approval.js +2 -5
  140. package/src/commands/state/create.js +22 -109
  141. package/src/commands/state/delete.js +11 -63
  142. package/src/commands/state/finish.js +98 -475
  143. package/src/commands/state/gate-check.js +22 -132
  144. package/src/commands/state/gate-decision.js +3 -39
  145. package/src/commands/state/handoff.js +3 -30
  146. package/src/commands/state/list.js +14 -84
  147. package/src/commands/state/report-check.js +8 -58
  148. package/src/commands/state/retype.js +4 -26
  149. package/src/commands/validation/index.js +0 -3
  150. package/src/commands/validation/mutate.js +2 -5
  151. package/src/commands/validation/validate-feature.js +5 -94
  152. package/src/commands/validation/verify.js +15 -98
  153. package/src/core/paths/output-schema.js +2 -101
  154. package/src/core/state/feature-identity.js +1 -25
  155. package/src/core/state/gate-order.js +0 -27
  156. package/src/core/state/lifecycle-audit.js +8 -100
  157. package/src/core/state/state-manager.js +22 -304
  158. package/src/core/templates/template-registry.js +2 -97
  159. package/src/core/templates/template-renderer.js +13 -93
  160. package/src/core/templates/template-seed.js +0 -1
  161. package/src/core/work-types.js +1 -36
  162. package/src/dashboard/app/App.jsx +1 -8
  163. package/src/dashboard/app/components/ActivityFeed.jsx +0 -5
  164. package/src/dashboard/app/components/CommandPalette.jsx +0 -7
  165. package/src/dashboard/app/components/DetailPanel.jsx +1 -5
  166. package/src/dashboard/app/components/EntityLink.jsx +0 -12
  167. package/src/dashboard/app/components/ErrorBoundary.jsx +1 -8
  168. package/src/dashboard/app/components/KnowledgeMarkdown.jsx +0 -11
  169. package/src/dashboard/app/components/Markdown.jsx +0 -9
  170. package/src/dashboard/app/components/PhaseRail.jsx +0 -11
  171. package/src/dashboard/app/components/SearchFilter.jsx +0 -4
  172. package/src/dashboard/app/components/Sidebar.jsx +0 -5
  173. package/src/dashboard/app/hooks/useMorphQuery.js +1 -11
  174. package/src/dashboard/app/hooks/usePref.js +1 -5
  175. package/src/dashboard/app/lib/format.js +0 -2
  176. package/src/dashboard/app/lib/phases.js +1 -5
  177. package/src/dashboard/app/lib/project.js +0 -12
  178. package/src/dashboard/app/pages/AgentMap.jsx +1 -8
  179. package/src/dashboard/app/pages/FeatureDetail.jsx +1 -5
  180. package/src/dashboard/app/pages/Features.jsx +1 -8
  181. package/src/dashboard/app/pages/Guide.jsx +0 -1
  182. package/src/dashboard/app/pages/HookInspector.jsx +2 -23
  183. package/src/dashboard/app/pages/PhasePipeline.jsx +0 -3
  184. package/src/dashboard/app/pages/Portfolio.jsx +0 -1
  185. package/src/dashboard/app/pages/RulesMap.jsx +1 -16
  186. package/src/dashboard/app/pages/SkillsLibrary.jsx +1 -16
  187. package/src/dashboard/app/pages/Standards.jsx +2 -17
  188. package/src/dashboard/scanner/activity-scanner.js +10 -69
  189. package/src/dashboard/scanner/agents-scanner.js +1 -23
  190. package/src/dashboard/scanner/docs-scanner.js +0 -6
  191. package/src/dashboard/scanner/feature-status.js +2 -5
  192. package/src/dashboard/scanner/features-scanner.js +10 -66
  193. package/src/dashboard/scanner/hooks-scanner.js +5 -28
  194. package/src/dashboard/scanner/index.js +0 -4
  195. package/src/dashboard/scanner/portfolio.js +5 -29
  196. package/src/dashboard/scanner/project-api.js +8 -54
  197. package/src/dashboard/scanner/rules-scanner.js +1 -2
  198. package/src/dashboard/scanner/skills-scanner.js +2 -12
  199. package/src/dashboard/scanner/standards-scanner.js +2 -6
  200. package/src/dashboard/vite-plugin-morph-data.js +5 -19
  201. package/src/lib/ai/ai-checks.js +7 -52
  202. package/src/lib/ai/ai-project-scan.js +14 -108
  203. package/src/lib/ai/code-audit.js +7 -63
  204. package/src/lib/ai/pin-drift.js +8 -51
  205. package/src/lib/ai/registry-audit.js +11 -90
  206. package/src/lib/ci/ci-verify.js +4 -19
  207. package/src/lib/ci/github-ci.js +9 -37
  208. package/src/lib/ci/workflow-scaffold.js +2 -10
  209. package/src/lib/detectors/claude-config-detector.js +13 -74
  210. package/src/lib/detectors/index.js +4 -50
  211. package/src/lib/e2e/compose-env.js +5 -66
  212. package/src/lib/e2e/stack-teardown.js +6 -40
  213. package/src/lib/generators/architecture-scanner.js +18 -156
  214. package/src/lib/generators/handoff-generator.js +9 -53
  215. package/src/lib/generators/scaffold-generator.js +5 -101
  216. package/src/lib/generators/settings-generator.js +4 -37
  217. package/src/lib/git/branch-classifier.js +11 -84
  218. package/src/lib/git/default-branch.js +14 -169
  219. package/src/lib/git/evidence-staleness.js +1 -0
  220. package/src/lib/git/gh.js +2 -17
  221. package/src/lib/git/pending-prs.js +6 -37
  222. package/src/lib/git/pr-files.js +3 -28
  223. package/src/lib/git/pr-index.js +3 -34
  224. package/src/lib/git/tree-fingerprint.js +3 -15
  225. package/src/lib/git/tree-state.js +6 -33
  226. package/src/lib/graph/god-nodes.js +2 -22
  227. package/src/lib/graph/graph-health.js +3 -24
  228. package/src/lib/graph/graph-loader.js +5 -56
  229. package/src/lib/graph/graphify-runtime.js +7 -37
  230. package/src/lib/graph/graphifyignore.js +8 -74
  231. package/src/lib/graph/sanitize.js +16 -136
  232. package/src/lib/heavy/cleanup.js +1 -1
  233. package/src/lib/installers/effects.js +8 -93
  234. package/src/lib/installers/mcp-installer.js +9 -169
  235. package/src/lib/installers/memory-installer.js +4 -27
  236. package/src/lib/installers/templates-reconcile.js +5 -60
  237. package/src/lib/mutation/dotnet-introspection.js +20 -106
  238. package/src/lib/mutation/subset.js +1 -4
  239. package/src/lib/plan/artifact-vocabulary.js +5 -25
  240. package/src/lib/plan/checks.js +5 -31
  241. package/src/lib/plan/plan-parser.js +1 -6
  242. package/src/lib/projects/registry.js +7 -44
  243. package/src/lib/review-reports.js +5 -23
  244. package/src/lib/risk-detector.js +39 -173
  245. package/src/lib/specs/adr-index.js +6 -34
  246. package/src/lib/specs/frontmatter.js +2 -15
  247. package/src/lib/specs/gfm-table.js +1 -7
  248. package/src/lib/specs/revocations.js +1 -14
  249. package/src/lib/specs/screen-checklist.js +47 -124
  250. package/src/lib/stack/dotnet-build-ignores.js +2 -6
  251. package/src/lib/stack/stack-profile.js +1 -28
  252. package/src/lib/stack/stack-scan.js +1 -8
  253. package/src/lib/stack-filter.js +4 -27
  254. package/src/lib/standards/registry.js +1 -7
  255. package/src/lib/standards/sections.js +2 -13
  256. package/src/lib/standards/verified-against.js +19 -191
  257. package/src/lib/tasks/eval-node.js +21 -169
  258. package/src/lib/tasks/linux-runner.js +18 -142
  259. package/src/lib/tasks/test-nodes.js +43 -240
  260. package/src/lib/tasks/test-runner.js +43 -341
  261. package/src/lib/telemetry/metrics.js +7 -36
  262. package/src/lib/telemetry/telemetry-reader.js +13 -125
  263. package/src/lib/telemetry/verify-event.js +2 -30
  264. package/src/lib/validators/code-budget/code-budget-validator.js +3 -43
  265. package/src/lib/validators/code-budget/index.js +0 -7
  266. package/src/lib/validators/contracts/contract-compliance-validator.js +2 -24
  267. package/src/lib/validators/contracts/index.js +0 -6
  268. package/src/lib/validators/nextjs/index.js +0 -5
  269. package/src/lib/validators/nextjs/next-component-validator.js +4 -81
  270. package/src/lib/validators/packages/index.js +0 -6
  271. package/src/lib/validators/packages/package-validator.js +6 -56
  272. package/src/lib/validators/reuse/index.js +0 -8
  273. package/src/lib/validators/reuse/reuse-validator.js +11 -101
  274. package/src/lib/validators/reuse/symbol-scanner.js +6 -62
  275. package/src/lib/validators/traceability-check.js +5 -63
  276. package/src/lib/validators/validation-runner.js +20 -125
  277. package/src/lib/validators/verify-runner.js +52 -358
  278. package/src/lib/work-type-detector.js +5 -67
  279. package/src/lib/worktree/docker-audit.js +17 -136
  280. package/src/lib/worktree/env-allocator.js +10 -98
  281. package/src/lib/worktree/feature-truth.js +12 -115
  282. package/src/lib/worktree/finish-backup.js +32 -242
  283. package/src/lib/worktree/fleet-scanner.js +13 -95
  284. package/src/lib/worktree/ignored-triage.js +15 -91
  285. package/src/lib/worktree/vscode-settings.js +2 -3
  286. package/src/lib/worktree/worktree-audit.js +21 -213
  287. package/src/lib/worktree/worktree-include.js +3 -5
  288. package/src/scripts/global-install.js +3 -9
  289. package/src/scripts/install-plugin.js +1 -13
  290. package/src/scripts/setup-infra.js +17 -147
  291. package/src/utils/agents-installer.js +28 -275
  292. package/src/utils/banner.js +4 -25
  293. package/src/utils/claude-md-injector.js +1 -37
  294. package/src/utils/claude-settings-manager.js +1 -52
  295. package/src/utils/file-copier.js +11 -95
  296. package/src/utils/hooks-installer.js +21 -298
  297. package/src/utils/skills-installer.js +4 -101
  298. package/src/utils/version-checker.js +4 -83
  299. package/framework/commands/morph-proposal.md +0 -9
  300. package/src/commands/utils/index.js +0 -3
  301. package/src/lib/detectors/design-system-detector.js +0 -188
  302. package/src/lib/generators/design-system-generator.js +0 -508
  303. package/src/lib/specs/old-format.js +0 -68
  304. package/src/utils/agent-id-mapper.js +0 -11
@@ -1,52 +1,6 @@
1
- /**
2
- * finish-backup.js — the backups `finish` and `worktree remove` leave under
3
- * `.git/morph-spec/finish-backup/`: how one is written, how they are listed,
4
- * and how long each one lives.
5
- *
6
- * A backup exists so that removing a worktree (or shedding the root-side
7
- * scaffold) never destroys the only exemplar of a file. That is a reason to keep
8
- * the last ones, not all of them, and a worktree's `.env.e2e` — the e2e stack's
9
- * credentials — has no business living inside `.git/` indefinitely. So every
10
- * backup is subject to a retention policy, on by default:
11
- *
12
- * maxCount — the newest this many backups per namespace always stay
13
- * maxAgeDays — no age cut unless configured: a quiet month must never
14
- * empty the backups of the last features closed
15
- * secretMaxAgeDays — `.env*` files inside a backup go away after this
16
- *
17
- * `config.json → finishBackup.retention` can change the numbers or turn the
18
- * whole thing off (`false`); a project without the key gets the defaults, and a
19
- * value that cannot be read skips the prune for that run.
20
- *
21
- * A backup without its manifest was written before this retention existed. The
22
- * automatic prune (end of `finish`, end of `worktree remove`) never deletes it,
23
- * never strips its `.env*` and does not count it against the budget — it only
24
- * reports how many wait (`legacyPending`). The first prune of those is the
25
- * explicit `doctor --worktrees --prune`, which ranks them with the rest and
26
- * gives each one it keeps a manifest, so from then on they are ordinary backups.
27
- *
28
- * Layout, and what counts as ONE backup:
29
- *
30
- * {feature}-{stamp}/ the feature scope the shed discards
31
- * {feature}-{stamp}-worktree-ignored/ the worktree's exclusive gitignored
32
- * {folder}.manifest.json sidecar: what was copied, the secrets
33
- * task/{name}-{stamp}-worktree-ignored/ a namespace (task isolation trees)
34
- *
35
- * One finish writes both folders with the SAME stamp, so they are one backup —
36
- * the unit the count applies to; a folder with no same-stamp sibling counts
37
- * alone. The `task/` namespace has its own budget: a fan-out of twenty
38
- * sub-agent worktrees must not evict the backup of the feature finished a
39
- * minute earlier. The backup the calling run just wrote is never pruned by it.
40
- *
41
- * Deletion is PROVE-or-DECLINE, as everywhere else near a worktree: a backup
42
- * holding a reparse point is never removed, links are never descended, and
43
- * nothing outside the backup directory is touched. Not because `rmSync` would
44
- * follow a junction — it unlinks the link itself; what once emptied a primary
45
- * root was `git worktree remove` — but because a link inside a backup is not
46
- * something this code put there. A first-level entry that is neither a backup
47
- * nor `task/` is left alone, even empty — it is not ours to judge.
48
- */
49
-
1
+ // Folders with the SAME stamp are one backup; `task/` has its own count budget.
2
+ // A backup without manifest predates retention: only the explicit `--prune` touches it.
3
+ // Deletion is prove-or-decline: a backup holding a reparse point is never removed.
50
4
  import {
51
5
  cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync,
52
6
  rmdirSync, rmSync, unlinkSync,
@@ -62,41 +16,30 @@ export const FINISH_BACKUP_RETENTION_DEFAULTS = Object.freeze({
62
16
  secretMaxAgeDays: 7,
63
17
  });
64
18
 
65
- /** The keys whose `null` means "no limit" rather than an invalid value. */
66
19
  const NULLABLE_RETENTION_KEYS = new Set(['maxAgeDays']);
67
20
 
68
21
  const DAY_MS = 86_400_000;
69
22
  const WORKTREE_IGNORED_SUFFIX = '-worktree-ignored';
70
23
  const MANIFEST_SUFFIX = '.manifest.json';
71
24
  const NAME_RE = /^(.+)-(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z(-worktree-ignored)?$/;
72
- /** How many names a payload carries; the counts are always complete. */
73
25
  const SAMPLE = 5;
74
26
 
75
- /** @param {string} root - the primary root */
76
27
  export function morphSpecStoreDir(root) {
77
28
  return join(root, '.git', 'morph-spec');
78
29
  }
79
30
 
80
- /** @param {string} root - the primary root */
81
31
  export function finishBackupDir(root) {
82
32
  return join(morphSpecStoreDir(root), 'finish-backup');
83
33
  }
84
34
 
85
- /** The stamp in a backup's name — filesystem-safe ISO-8601. */
86
35
  export function newBackupStamp(date = new Date()) {
87
36
  return date.toISOString().replace(/[:.]/g, '-');
88
37
  }
89
38
 
90
- /** `.env`, `.env.e2e`, `.env.local`… — the files that hold a stack's secrets. */
91
39
  export function isSecretFileName(name) {
92
40
  return name.startsWith('.env');
93
41
  }
94
42
 
95
- /**
96
- * @param {string} name - one directory name under finish-backup/ (or under a namespace)
97
- * @returns {{ base: string, feature: string, stamp: string,
98
- * kind: 'feature-scope'|'worktree-ignored', createdAt: number }|null}
99
- */
100
43
  export function parseBackupName(name) {
101
44
  const m = NAME_RE.exec(name);
102
45
  if (!m) return null;
@@ -122,16 +65,8 @@ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArr
122
65
  const shown = (v) => JSON.stringify(v) ?? String(v);
123
66
  const skippedPolicy = (reason) => ({ enabled: false, skipped: reason, ...FINISH_BACKUP_RETENTION_DEFAULTS });
124
67
 
125
- /**
126
- * The policy in force, from a parsed config.json. A config without the key
127
- * gets the defaults (retention on) and an explicit `false` turns it off. What
128
- * this cannot recognise SKIPS the prune for the run instead: the operation
129
- * deletes, and a value nobody can read — `{ enabled: false }`, `"false"`, a
130
- * `maxCount` of `0` — may well have been meant as "keep everything".
131
- *
132
- * @param {object|null} config - null when there is no config.json at all
133
- * @returns {{ enabled: boolean, skipped?: string, maxCount: number, maxAgeDays: number|null, secretMaxAgeDays: number }}
134
- */
68
+ // A value this cannot recognise SKIPS the prune instead of falling back to the
69
+ // defaults: the operation deletes, and `"false"` or `maxCount: 0` may mean "keep all".
135
70
  export function resolveBackupRetention(config) {
136
71
  const d = FINISH_BACKUP_RETENTION_DEFAULTS;
137
72
  const skip = skippedPolicy;
@@ -156,13 +91,8 @@ export function resolveBackupRetention(config) {
156
91
  return policy;
157
92
  }
158
93
 
159
- /**
160
- * Unlike `readMorphConfig`, a config.json that exists and does not parse is
161
- * not "no config": the defaults would turn on a prune its author may have
162
- * switched off in the part that failed to parse.
163
- *
164
- * @param {string} root
165
- */
94
+ // Unlike `readMorphConfig`, an unparseable config.json is not "no config": the
95
+ // defaults would turn on a prune its author may have switched off.
166
96
  export function readBackupRetention(root) {
167
97
  const path = join(root, CONFIG_FILE_NAME);
168
98
  if (!existsSync(path)) return resolveBackupRetention(null);
@@ -196,13 +126,6 @@ function isStrictlyInside(parent, child) {
196
126
  return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
197
127
  }
198
128
 
199
- /**
200
- * Every `.env*` REAL file under `dir`, relative to it. Links are neither
201
- * followed nor reported: a link is not bytes this backup holds.
202
- *
203
- * @param {string} dir
204
- * @returns {string[]}
205
- */
206
129
  export function secretFilesIn(dir) {
207
130
  const out = [];
208
131
  const stack = [''];
@@ -224,13 +147,7 @@ export function secretFilesIn(dir) {
224
147
  return out.sort();
225
148
  }
226
149
 
227
- /**
228
- * Bytes and files under `dir`, by lstat — a link counts as itself, never as
229
- * what it points at. `null` when the walk could not start.
230
- *
231
- * @param {string} dir
232
- * @returns {{ bytes: number, files: number }|null}
233
- */
150
+ /** By lstat: a link counts as itself. `null` when the walk could not start. */
234
151
  export function measureDir(dir) {
235
152
  if (!isRealDir(dir)) return null;
236
153
  let bytes = 0;
@@ -247,7 +164,7 @@ export function measureDir(dir) {
247
164
  for (const e of entries) {
248
165
  const p = join(current, e.name);
249
166
  if (e.isDirectory() && !e.isSymbolicLink()) { stack.push(p); continue; }
250
- try { bytes += lstatSync(p).size; files += 1; } catch { /* vanished */ }
167
+ try { bytes += lstatSync(p).size; files += 1; } catch {}
251
168
  }
252
169
  }
253
170
  return { bytes, files };
@@ -265,30 +182,16 @@ function readManifest(backupPath) {
265
182
  }
266
183
  }
267
184
 
268
- /**
269
- * Bookkeeping, never a precondition: a backup whose bytes are on disk is a
270
- * backup, with or without its manifest. One whose manifest failed to be written
271
- * reads as legacy, which is the safe side — the automatic prune leaves it alone.
272
- */
185
+ // Fail-open: a backup whose manifest failed reads as legacy, the safe side.
273
186
  function writeManifest(backupPath, manifest) {
274
187
  try {
275
188
  atomicWriteJson(manifestPathFor(backupPath), { version: 1, ...manifest });
276
- } catch { /* fail-open */ }
189
+ } catch {}
277
190
  }
278
191
 
279
- /**
280
- * Copy the worktree's exclusive gitignored leftovers out before the removal
281
- * deletes them. Caches are excluded at every depth, not only as top-level
282
- * entries: git reports an ignored directory as ONE entry when the directory
283
- * itself matches a pattern, so an exclusive `apps/x/` would otherwise drag its
284
- * `node_modules/` and `.next/` along. They are regenerable, and the removal
285
- * deletes them either way.
286
- *
287
- * @param {{ worktreePath: string, root: string, feature: string, rels: string[], stamp?: string }} args
288
- * @returns {string|null} the backup path, or null if ANY copy failed (the caller
289
- * must then decline the removal — a partial backup authorizes nothing) or if
290
- * none of `rels` exists anymore
291
- */
192
+ // Caches are excluded at every depth: git reports an ignored dir as ONE entry,
193
+ // so `apps/x/` would drag its `node_modules/` along.
194
+ /** `null` if ANY copy failed — the caller must then decline the removal. */
292
195
  export function writeWorktreeIgnoredBackup({ worktreePath, root, feature, rels, stamp = newBackupStamp() }) {
293
196
  const dest = join(finishBackupDir(root), `${feature}-${stamp}${WORKTREE_IGNORED_SUFFIX}`);
294
197
  const copied = [];
@@ -297,7 +200,7 @@ export function writeWorktreeIgnoredBackup({ worktreePath, root, feature, rels,
297
200
  try {
298
201
  for (const rel of rels) {
299
202
  const src = join(worktreePath, rel);
300
- if (!existsSync(src)) continue; // a directory entry already covered by its children
203
+ if (!existsSync(src)) continue;
301
204
  if (isDisposableBuildArtifact(relOf(src))) {
302
205
  excludedCaches.push(relOf(src));
303
206
  continue;
@@ -332,17 +235,8 @@ export function writeWorktreeIgnoredBackup({ worktreePath, root, feature, rels,
332
235
  return dest;
333
236
  }
334
237
 
335
- /**
336
- * Copy the feature's root-side scope to a backup OUTSIDE the working tree, so
337
- * the shed can never be the reason something is lost.
338
- *
339
- * Lives under `.git/` on purpose: it is not a working-tree path, so it can never
340
- * dirty `git status` (which the very next validation reads) and is never merged,
341
- * committed, or cleaned by `git clean`.
342
- *
343
- * @param {{ root: string, feature: string, stamp?: string }} args
344
- * @returns {string|null} absolute backup path, or null if the copy failed
345
- */
238
+ // Under `.git/` so it never dirties the `git status` the next validation reads.
239
+ /** `null` if the copy failed: the caller must not shed. */
346
240
  export function writeFeatureScopeBackup({ root, feature, stamp = newBackupStamp() }) {
347
241
  const src = join(root, '.morph', 'features', feature);
348
242
  if (!existsSync(src)) return null;
@@ -351,7 +245,7 @@ export function writeFeatureScopeBackup({ root, feature, stamp = newBackupStamp(
351
245
  mkdirSync(dirname(dest), { recursive: true });
352
246
  cpSync(src, dest, { recursive: true });
353
247
  } catch {
354
- return null; // caller MUST treat this as "do not shed"
248
+ return null;
355
249
  }
356
250
  writeManifest(dest, {
357
251
  kind: 'feature-scope',
@@ -363,26 +257,10 @@ export function writeFeatureScopeBackup({ root, feature, stamp = newBackupStamp(
363
257
  return dest;
364
258
  }
365
259
 
366
- /**
367
- * The first-level directories that hold backups instead of being one: `task/`
368
- * is where the `task/{name}` key of a task isolation tree (see
369
- * `envKeyForWorktree` in worktree.js) lands. Any other first-level directory
370
- * that is not a backup was put there by someone else — never listed, never
371
- * removed, not even empty.
372
- */
260
+ // Any other first-level directory that is not a backup is not ours: never removed.
373
261
  const BACKUP_NAMESPACES = new Set(['task']);
374
262
 
375
- /**
376
- * Every backup folder on disk: the first level, plus one level down inside a
377
- * namespace of BACKUP_NAMESPACES. A link is never descended, at either level.
378
- *
379
- * @param {string} root
380
- * `managed` is whether the folder has its manifest — the one criterion that
381
- * tells a backup of this retention from one written before it.
382
- *
383
- * @returns {Array<{ name: string, path: string, namespace: string, set: string,
384
- * feature: string, stamp: string, kind: string, createdAt: number, managed: boolean }>}
385
- */
263
+ /** `managed` = has its manifest; without one the backup predates retention. */
386
264
  export function listFinishBackups(root) {
387
265
  const base = finishBackupDir(root);
388
266
  if (!isRealDir(base)) return [];
@@ -396,7 +274,7 @@ export function listFinishBackups(root) {
396
274
  }
397
275
  for (const e of entries) {
398
276
  const path = join(dir, e.name);
399
- if (!isRealDir(path)) continue; // manifests, temp files and links
277
+ if (!isRealDir(path)) continue;
400
278
  const parsed = parseBackupName(e.name);
401
279
  if (parsed) {
402
280
  const prefix = namespace ? `${namespace}/` : '';
@@ -420,30 +298,8 @@ export function listFinishBackups(root) {
420
298
  return out;
421
299
  }
422
300
 
423
- /**
424
- * Which backups the policy removes, and which it keeps but strips of `.env*`.
425
- * Pure: the caller supplies the listing and the clock.
426
- *
427
- * Two kinds of set are never removed. The ones stamped with `protectStamps` —
428
- * the backup the calling run just wrote: a retention that runs after a backup
429
- * exists to make room for it, not to take it. And the ones stamped AFTER `now`
430
- * (a clock that ran ahead and was corrected): their age is unknowable, so they
431
- * stay and do not take a place in the count — otherwise a handful of them
432
- * would push every new backup out the moment it is written.
433
- *
434
- * A set is legacy unless EVERY folder in it has its manifest. Without
435
- * `includeLegacy` (the automatic prune) legacy sets are out of the plan
436
- * altogether — no removal, no strip, no place in the count — and only counted
437
- * in `legacy`. With it (the explicit prune) they are ranked like the rest, and
438
- * the ones kept are listed in `adopt`.
439
- *
440
- * @param {ReturnType<typeof listFinishBackups>} backups
441
- * @param {ReturnType<typeof resolveBackupRetention>} retention
442
- * @param {number} now - epoch ms
443
- * @param {{ protectStamps?: string[], includeLegacy?: boolean }} [opts]
444
- * @returns {{ sets: number, legacy: number, remove: object[], stripSecrets: object[], adopt: object[] }}
445
- * each entry a `{ set, namespace, stamp, createdAt, managed, members }`
446
- */
301
+ // Sets stamped after `now` (clock ran ahead) stay and take no place in the count,
302
+ // or a handful of them would evict every new backup the moment it is written.
447
303
  export function planBackupRetention(backups, retention, now, { protectStamps = [], includeLegacy = false } = {}) {
448
304
  const sets = new Map();
449
305
  for (const b of backups) {
@@ -486,27 +342,8 @@ export function planBackupRetention(backups, retention, now, { protectStamps = [
486
342
  return plan;
487
343
  }
488
344
 
489
- /**
490
- * Apply the policy: remove what it drops, strip `.env*` from what it keeps past
491
- * the short retention, and record the stripped names in the manifest. A backup
492
- * whose removal is declined (a reparse point inside, a locked file) is stripped
493
- * instead: the policy wanted it gone, and its secrets are the part that must not
494
- * linger. A policy that could not be read (`skipped`) prunes nothing. Never
495
- * throws — a retention that fails must never be the reason a `finish` fails.
496
- *
497
- * Legacy backups (no manifest) are touched only with `includeLegacy` — the
498
- * explicit `doctor --worktrees --prune`; otherwise they are counted in
499
- * `legacyPending`. The explicit prune writes a manifest into every legacy
500
- * backup it keeps (`adopted`), which makes it an ordinary backup from then on.
501
- *
502
- * @param {string} root
503
- * @param {{ now?: number, retention?: ReturnType<typeof resolveBackupRetention>,
504
- * protectStamps?: string[], includeLegacy?: boolean }} [opts]
505
- * `protectStamps`: the stamp(s) the calling run wrote its backup under
506
- * @returns {{ enabled: boolean, skipped?: string, removed: string[],
507
- * secretsStripped: Array<{ backup: string, files: string[] }>, declined: Array<{ backup: string, reason: string }>,
508
- * legacyPending: number, adopted: string[] }}
509
- */
345
+ // A backup whose removal is declined still loses its `.env*`. Never throws:
346
+ // a failed retention must never be the reason a `finish` fails.
510
347
  export function applyBackupRetention(root, { now = Date.now(), retention, protectStamps = [], includeLegacy = false } = {}) {
511
348
  const result = { enabled: true, removed: [], secretsStripped: [], declined: [], legacyPending: 0, adopted: [] };
512
349
  try {
@@ -539,7 +376,7 @@ export function applyBackupRetention(root, { now = Date.now(), retention, protec
539
376
  toStrip.push(m);
540
377
  continue;
541
378
  }
542
- try { rmSync(manifestPathFor(m.path), { force: true }); } catch { /* the sidecar is bookkeeping */ }
379
+ try { rmSync(manifestPathFor(m.path), { force: true }); } catch {}
543
380
  result.removed.push(m.name);
544
381
  }
545
382
  }
@@ -550,7 +387,7 @@ export function applyBackupRetention(root, { now = Date.now(), retention, protec
550
387
  try {
551
388
  unlinkSync(join(m.path, rel));
552
389
  stripped.push(rel);
553
- } catch { /* locked — the next run tries again */ }
390
+ } catch {}
554
391
  }
555
392
  if (!stripped.length) continue;
556
393
  const manifest = readManifest(m.path) ?? { kind: m.kind, feature: m.feature, createdAt: new Date(m.createdAt).toISOString() };
@@ -567,27 +404,16 @@ export function applyBackupRetention(root, { now = Date.now(), retention, protec
567
404
  result.adopted.push(m.name);
568
405
  }
569
406
 
570
- // A namespace emptied by the removals goes too; rmdir refuses anything else.
571
407
  for (const name of BACKUP_NAMESPACES) {
572
408
  const p = join(base, name);
573
409
  if (isRealDir(p)) {
574
- try { rmdirSync(p); } catch { /* not empty */ }
410
+ try { rmdirSync(p); } catch {}
575
411
  }
576
412
  }
577
- } catch { /* fail-open */ }
413
+ } catch {}
578
414
  return result;
579
415
  }
580
416
 
581
- /**
582
- * What a prune WOULD do now, without doing it. `includeLegacy` picks which
583
- * prune: without it, the automatic one (what `finish --dry-run` announces);
584
- * with it, the explicit `doctor --worktrees --prune` (what the doctor reports).
585
- *
586
- * @param {string} root
587
- * @param {{ now?: number, retention?: ReturnType<typeof resolveBackupRetention>, includeLegacy?: boolean }} [opts]
588
- * @returns {{ policy: object, sets: number, folders: number, legacy: number,
589
- * outsideRetention: number, overdueSecrets: string[] }}
590
- */
591
417
  export function inspectFinishBackups(root, { now = Date.now(), retention, includeLegacy = false } = {}) {
592
418
  const policy = retention ?? readBackupRetention(root);
593
419
  const backups = listFinishBackups(root);
@@ -603,7 +429,6 @@ export function inspectFinishBackups(root, { now = Date.now(), retention, includ
603
429
  };
604
430
  }
605
431
 
606
- /** The sentence both the audit and the store line say about what `--prune` would do. */
607
432
  function pendingParts(report, { long = false } = {}) {
608
433
  const { maxCount, maxAgeDays, secretMaxAgeDays } = report.policy;
609
434
  const parts = [];
@@ -632,16 +457,7 @@ function formatSize(bytes) {
632
457
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
633
458
  }
634
459
 
635
- /**
636
- * The default `doctor`'s line about `.git/morph-spec/`: its size and the
637
- * backups in it, in the check shape the doctor renders. The size rides in the
638
- * NAME because an `ok` line prints only its name. `ok` or `warn`, never a
639
- * failure; `null` — no line — when nothing was ever stored there.
640
- *
641
- * @param {string} root - the primary root
642
- * @param {{ now?: number }} [opts]
643
- * @returns {{ name: string, status: 'ok'|'warn', msg?: string }|null}
644
- */
460
+ // The size rides in the NAME because the doctor prints only the name of an `ok` line.
645
461
  export function morphStoreCheck(root, { now = Date.now() } = {}) {
646
462
  let store;
647
463
  let report;
@@ -659,17 +475,6 @@ export function morphStoreCheck(root, { now = Date.now() } = {}) {
659
475
  return { name, status: 'warn', msg: `${pending.join(', ')} — rode morph-spec doctor --worktrees --prune` };
660
476
  }
661
477
 
662
- /**
663
- * The retention as an audit finding, in the shape `auditWorktrees` returns:
664
- * what the explicit `--prune` would do, including the legacy backups only it
665
- * prunes. `warn`, never a failure — backups waiting for a prune are
666
- * housekeeping. A policy that could not be read is a finding too: until it is
667
- * fixed, nothing is ever pruned.
668
- *
669
- * @param {string} root
670
- * @param {{ now?: number }} [opts]
671
- * @returns {Array<{ type: string, severity: string, detail: string, fix: string }>}
672
- */
673
478
  export function auditFinishBackups(root, opts = {}) {
674
479
  let report;
675
480
  try {
@@ -697,14 +502,7 @@ export function auditFinishBackups(root, opts = {}) {
697
502
  }];
698
503
  }
699
504
 
700
- /**
701
- * A payload-sized account of one retention run: complete counts, a short
702
- * sample of names; `{ skipped }` when the policy could not be read. `null` when
703
- * the run changed nothing and nothing waits for the explicit prune.
704
- *
705
- * @param {ReturnType<typeof applyBackupRetention>} result
706
- * @returns {object|null}
707
- */
505
+ /** `null` when the run changed nothing and nothing waits for the explicit prune. */
708
506
  export function summarizeRetention(result) {
709
507
  if (!result) return null;
710
508
  if (result.skipped) return { skipped: result.skipped };
@@ -721,15 +519,7 @@ export function summarizeRetention(result) {
721
519
  return Object.keys(out).length ? out : null;
722
520
  }
723
521
 
724
- /**
725
- * The retention step every writer of backups runs after its own backup:
726
- * apply + summarize, fail-open. `protectStamps` is the stamp that backup was
727
- * written under — never pruned by this run.
728
- *
729
- * @param {string} root
730
- * @param {{ protectStamps?: string[] }} [opts]
731
- * @returns {object|null}
732
- */
522
+ /** `protectStamps`: the stamp the caller just wrote its backup under, never pruned by this run. */
733
523
  export function enforceBackupRetention(root, { protectStamps = [] } = {}) {
734
524
  try {
735
525
  return summarizeRetention(applyBackupRetention(root, { protectStamps }));
@@ -1,17 +1,4 @@
1
- /**
2
- * fleet-scanner.js — aggregate view of every active feature across the fleet.
3
- *
4
- * A fleet is N Claude Code instances, one per feature worktree, plus whatever
5
- * is active in the primary root. Each instance only sees its own cwd; the
6
- * operator loses track of "what is each session doing, what is waiting on me".
7
- * This scanner answers that from the primary root's vantage point: one entry
8
- * per active feature, worktree or root-resident, with the operational facts
9
- * (phase, pending gates, slice counts, port block, handoff freshness, last
10
- * commit, dirtiness) resolved per entry.
11
- *
12
- * Read-only and fail-open per FIELD: a corrupt feature.json or a git hiccup
13
- * nulls that field, never the scan.
14
- */
1
+ // Fail-open per FIELD: a corrupt feature.json or a git hiccup nulls that field, never the scan.
15
2
 
16
3
  import { execSync, execFileSync } from 'child_process';
17
4
  import { existsSync, readdirSync, readFileSync } from 'fs';
@@ -35,15 +22,7 @@ import { sliceProgress, sliceProgressFrom } from '../../../framework/hooks/share
35
22
 
36
23
  const counts = (progress) => (progress ? { done: progress.done, total: progress.total } : null);
37
24
 
38
- /**
39
- * Slice progress for one feature, read from the copy that holds the work: a
40
- * root-resident feature's plan checks and reports are committed on its own
41
- * `morph/{f}` branch, while the root working tree only holds the checked-out
42
- * branch's (pre-work) copy.
43
- *
44
- * @param {string} featureDir - the feature dir inside `path`
45
- * @param {{ root: string, feature: string, featureBranch: string|null }} ctx
46
- */
25
+ // A root-resident feature's reports live on `morph/{f}`; the root tree only has the pre-work copy.
47
26
  function sliceCounts(featureDir, { root, feature, featureBranch } = {}) {
48
27
  if (featureBranch && root && feature) {
49
28
  const show = (rel) => {
@@ -88,20 +67,7 @@ function dirtyCount(worktreePath) {
88
67
  }
89
68
  }
90
69
 
91
- /**
92
- * One `git ls-remote --heads origin` for the WHOLE scan → Map<branch, sha>.
93
- *
94
- * The fleet used to read only local state, so a feature whose branch (and PR)
95
- * already lived on origin still showed up as "pending, nothing pushed" — that
96
- * reading is what made a second session redo finished work. Resolved once per
97
- * scan, never per entry.
98
- *
99
- * Returns `null` when there is no origin, or the probe fails/times out: the
100
- * entries then carry `remote: null` ("unknown"), never a false `pushed: false`.
101
- *
102
- * @param {string} root
103
- * @returns {Map<string,string>|null}
104
- */
70
+ // null = unknown (no origin, or the probe failed): entries get `remote: null`, never a false `pushed: false`.
105
71
  function remoteHeads(root) {
106
72
  if (!hasOriginRemote(root)) return null;
107
73
  try {
@@ -119,28 +85,11 @@ function remoteHeads(root) {
119
85
  }
120
86
  }
121
87
 
122
- /**
123
- * One `gh pr list` for the whole scan → Map<headRefName, {number, state}>.
124
- *
125
- * Delegates to the shared `buildPrIndex` so `fleet`, `branch prune` and
126
- * `doctor --worktrees` cannot answer "does this branch have a PR" differently
127
- * — the destructive two read it to decide what NOT to delete.
128
- *
129
- * Fail-open: `null` (no `gh`, not authenticated, not GitHub, timeout) just
130
- * leaves the PR half of the column empty while `pushed` still works.
131
- *
132
- * @param {string} root
133
- * @returns {Map<string,{number:number,state:string}>|null}
134
- */
135
88
  function remotePrs(root) {
136
89
  return buildPrIndex(root, { limit: 100 });
137
90
  }
138
91
 
139
- /**
140
- * The remote fact for one feature. Worktree entries know their real branch
141
- * (which may carry a `--fresh` date suffix); root-resident features fall back
142
- * to the canonical `morph/{feature}` every feature is born on.
143
- */
92
+ // A worktree's real branch may carry a `--fresh` date suffix; only root entries fall back to `morph/{f}`.
144
93
  function buildRemote(feature, branch, heads, prs) {
145
94
  if (!heads) return null;
146
95
  const remoteBranch = branch || `morph/${feature}`;
@@ -154,15 +103,9 @@ function buildEntry(feature, { location, path, branch, root, allocations, blockS
154
103
  let phase = 'plan';
155
104
  let pendingGates = [];
156
105
 
157
- // Resolved once and shared with sliceCounts — see branchToReadFrom.
158
106
  const featureBranch = branchToReadFrom(root, feature, location, branch);
159
107
 
160
108
  try {
161
- // B3b: phase and gates suffered the same branch-staleness B3 fixed for
162
- // progress. They read `.morph/features/{f}/` through the working tree, so a
163
- // root parked on the default reported the PRE-WORK phase for a feature
164
- // whose branch was already at implement — and re-asked for a gate the
165
- // feature branch had already approved.
166
109
  const view = featureBranch ? branchView(root, feature, featureBranch) : null;
167
110
  if (view) {
168
111
  workType = view.fj?.workType || null;
@@ -176,7 +119,7 @@ function buildEntry(feature, { location, path, branch, root, allocations, blockS
176
119
  phase = derivePhaseForFeature(feature, path);
177
120
  pendingGates = getPendingGates(feature, path);
178
121
  }
179
- } catch { /* field-level fail-open */ }
122
+ } catch { /* fail-open */ }
180
123
 
181
124
  const featureDir = join(path, '.morph', 'features', feature);
182
125
  const alloc = allocations[feature];
@@ -197,20 +140,11 @@ function buildEntry(feature, { location, path, branch, root, allocations, blockS
197
140
  lastCommit: location === 'worktree' && branch ? lastCommitRelative(root, branch) : null,
198
141
  dirty: location === 'worktree' ? dirtyCount(path) : null,
199
142
  remote: buildRemote(feature, branch, heads, prs),
200
- // Advisory ownership (B1): which session is on this feature right now.
201
- // `live` is what matters for the operator — a stale claim is reported as
202
- // stale rather than hidden, so "nobody is here" and "somebody died here"
203
- // stay distinguishable.
143
+ // A stale claim is reported, not hidden: "nobody is here" ≠ "somebody died here".
204
144
  claim: claims?.[feature] || null,
205
145
  };
206
146
  }
207
147
 
208
- /**
209
- * Scan the fleet from anywhere inside the repo.
210
- *
211
- * @param {string} [cwd]
212
- * @returns {{ root: string, entries: object[] }}
213
- */
214
148
  export function scanFleet(cwd = process.cwd()) {
215
149
  if (!isGitRepo(cwd)) {
216
150
  return { root: resolve(cwd).replace(/\\/g, '/'), entries: [] };
@@ -226,13 +160,12 @@ export function scanFleet(cwd = process.cwd()) {
226
160
  try {
227
161
  const reg = JSON.parse(readFileSync(registryPath(gitCommonDir), 'utf8'));
228
162
  if (reg && typeof reg.allocations === 'object' && reg.allocations !== null) allocations = reg.allocations;
229
- } catch { /* no env registry yet — env stays null per entry */ }
163
+ } catch { /* env stays null per entry */ }
230
164
  for (const c of listClaims(gitCommonDir)) claims[c.feature] = c;
231
165
  }
232
- } catch { /* no common dir — env and claim both stay null per entry */ }
166
+ } catch { /* env and claim stay null per entry */ }
233
167
  const { blockSize } = resolveFleetConfig(root);
234
168
 
235
- // Remote awareness, resolved ONCE for the whole scan (see remoteHeads/remotePrs).
236
169
  const heads = remoteHeads(root);
237
170
  const prs = heads ? remotePrs(root) : null;
238
171
 
@@ -241,7 +174,7 @@ export function scanFleet(cwd = process.cwd()) {
241
174
 
242
175
  for (const e of parseWorktreeList(root)) {
243
176
  const m = e.branch && e.branch.match(/^refs\/heads\/morph\/(.+)$/);
244
- if (!m) continue; // primary root entry and non-morph worktrees
177
+ if (!m) continue;
245
178
  inWorktree.add(m[1]);
246
179
  entries.push(buildEntry(m[1], {
247
180
  location: 'worktree',
@@ -256,7 +189,6 @@ export function scanFleet(cwd = process.cwd()) {
256
189
  }));
257
190
  }
258
191
 
259
- // Root-resident features (no live worktree): whatever has a feature.json.
260
192
  try {
261
193
  const featuresDir = join(root, '.morph', 'features');
262
194
  if (existsSync(featuresDir)) {
@@ -272,23 +204,9 @@ export function scanFleet(cwd = process.cwd()) {
272
204
  }
273
205
  } catch { /* fail-open */ }
274
206
 
275
- // Features that exist ONLY as an open PR: no registered worktree, no
276
- // `.morph/features/*` folder in this tree. This is exactly what
277
- // `finish --pr` leaves behind — and without this pass `fleet` would say
278
- // "nothing in flight" about work that is under review on GitHub.
279
- //
280
- // `prs` was attempted above whenever `heads` is truthy (`remotePrs` runs —
281
- // see `const prs = heads ? remotePrs(root) : null;` — regardless of
282
- // whether that `gh` call itself succeeded). Gate on the SAME condition
283
- // `remotePrs` used, not on `prs !== null`: `remotePrs`/`buildPrIndex` can
284
- // fail (timeout, not authenticated, malformed JSON) and legitimately
285
- // return `null` even after really invoking `gh`. Passing that `null`
286
- // through lets `pendingPrFeatures` short-circuit on its own documented
287
- // contract (explicit `null` index = known unknown, never a fallback build)
288
- // instead of retrying `gh` a second time in the same scan. Only when
289
- // `remotePrs` was never attempted at all (`heads` falsy) does
290
- // `pendingPrFeatures` get no index, so it makes the first (and only) `gh`
291
- // call itself.
207
+ // Features that exist only as an open PR (what `finish --pr` leaves). Gate on `heads`, not
208
+ // `prs !== null`: a `gh` that already ran and failed passes an explicit `null` index, so
209
+ // `pendingPrFeatures` does not call `gh` a second time.
292
210
  try {
293
211
  const seen = new Set(entries.map((e) => e.feature));
294
212
  const pendOpts = heads ? { index: prs } : {};
@@ -312,7 +230,7 @@ export function scanFleet(cwd = process.cwd()) {
312
230
  claim: null,
313
231
  });
314
232
  }
315
- } catch { /* fail-open — a pendency not listed must never take down the scan */ }
233
+ } catch { /* fail-open */ }
316
234
 
317
235
  return { root, entries };
318
236
  }