@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,20 +1,5 @@
1
- /**
2
- * Work Type Detector — deterministic keyword classifier for `text`/`declaredScope`.
3
- *
4
- * Mirrors risk-detector.js's style: pure, deterministic, no I/O. This module
5
- * is the TESTABLE MIRROR of the natural-language classification rules that
6
- * live in MORPH.md — behavior described here must stay in sync with those
7
- * rules, but this file is what `create`/`retype` call at runtime.
8
- *
9
- * Precedence: hotfix keywords win outright (a production incident never
10
- * ties down against a bug or chore signal). Otherwise bug vs. chore is
11
- * decided by keyword-match count, nudged by two weak signals:
12
- * - estimatedFiles <= 2 -> +1 toward bug (small, surgical diffs)
13
- * - declaredScope <= 6 words -> +1 toward chore (a narrowly-scoped ask)
14
- * A genuine tie between bug and chore is surfaced to the caller (LLM/HCOP
15
- * router) rather than guessed at. Zero keyword matches -> no opinion; the
16
- * caller defaults to `feature`.
17
- */
1
+ // Testable mirror of the classification rules in MORPH.md: the two must stay in sync.
2
+ // Hotfix wins outright; a bug/chore tie is returned to the caller, never guessed.
18
3
 
19
4
  // Plural/inflected forms are listed EXPLICITLY: matching is whole-word, so
20
5
  // "incidentes" is not "incidente". Recall lives here, in a list that is visible
@@ -37,38 +22,8 @@ const CHORE_KEYWORDS = [
37
22
  'limpeza', 'docs', 'documentação', 'lint', 'formatação',
38
23
  ];
39
24
 
40
- /**
41
- * @returns {string[]} the subset of `keywords` present in `haystack` as WHOLE
42
- * words (or whole phrases), never as a fragment of a larger word.
43
- *
44
- * Substring matching routed real requests to the wrong pipeline: `p0` is two
45
- * characters with outright hotfix precedence, so "endpoint group0" matched it
46
- * and skipped Gates 1 and 2 by policy — no human, and not even the LLM router,
47
- * since a substring hit reports `confidence: 'high'`. Same shape for `fix`
48
- * inside "prefix"/"fixtures" and `bug` inside "debug".
49
- *
50
- * The failure directions are asymmetric, which is what settles the trade-off: a
51
- * false POSITIVE silently skips approval gates, while a false negative usually
52
- * only lowers confidence — and low confidence defers to the LLM router (exit 2).
53
- * Matching strictly is therefore the safe error, and provably so for the vector
54
- * that motivated it: whole-word matches are a strict SUBSET of substring matches
55
- * per keyword, so narrowing can never invent a hotfix hit that substring didn't
56
- * already have. No misroute INTO the only gate-skipping pipeline can be created
57
- * here, only removed.
58
- *
59
- * Being precise about the limit of that argument, since overclaiming in a
60
- * comment is what this file is fixing: the asymmetry holds per KEYWORD, not per
61
- * CLASSIFICATION. Dropping a match can still shift the bug-vs-chore balance —
62
- * losing a bug hit may promote a chore from `tie` to `high`, and chore also
63
- * skips Gates 1 and 2. Whole-word matching DID cost recall that substring had:
64
- * "bugs" contains "bug" but is not the word "bug". The answer is to list the
65
- * forms (below), not to match loosely — recall belongs in the vocabulary, where
66
- * it is explicit and testable, not in a matcher that fires on fragments.
67
- *
68
- * `\b` is not usable here: JS word boundaries are ASCII-only, so `\bnão\b`
69
- * would find a boundary between `n` and `ã` and misbehave on half of these
70
- * pt-BR keywords. Unicode letter/number lookarounds are the portable form.
71
- */
25
+ // Whole words only: a false positive silently skips approval gates (`p0` in "group0", `fix` in
26
+ // "prefix"). `\b` is ASCII-only in JS and breaks on pt-BR letters, hence the Unicode lookarounds.
72
27
  function matchKeywords(haystack, keywords) {
73
28
  return keywords.filter((kw) => {
74
29
  const escaped = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -81,18 +36,6 @@ function wordCount(str) {
81
36
  return trimmed.length === 0 ? 0 : trimmed.split(/\s+/).length;
82
37
  }
83
38
 
84
- /**
85
- * @param {object} input
86
- * @param {string} [input.text]
87
- * @param {string} [input.declaredScope]
88
- * @param {number} [input.estimatedFiles]
89
- * @returns {{
90
- * workType: string|null,
91
- * confidence: 'high'|'tie'|'none',
92
- * matched: { hotfix: string[], bug: string[], chore: string[], feature: string[] },
93
- * candidates: string[],
94
- * }}
95
- */
96
39
  export function detectWorkType({ text = '', declaredScope = '', estimatedFiles } = {}) {
97
40
  const haystack = `${text} ${declaredScope}`.toLowerCase();
98
41
 
@@ -107,7 +50,6 @@ export function detectWorkType({ text = '', declaredScope = '', estimatedFiles }
107
50
  feature: [], // no keywords of its own — the catch-all default
108
51
  };
109
52
 
110
- // Hard precedence: any hotfix signal wins outright, never ties down.
111
53
  if (hotfixMatched.length > 0) {
112
54
  return { workType: 'hotfix', confidence: 'high', matched, candidates: ['hotfix'] };
113
55
  }
@@ -119,11 +61,7 @@ export function detectWorkType({ text = '', declaredScope = '', estimatedFiles }
119
61
  let bugScore = bugMatched.length;
120
62
  let choreScore = choreMatched.length;
121
63
 
122
- // The weak nudges act ONLY as tie-breakers — apply them only when the raw
123
- // keyword counts are already equal. Otherwise a clean single-keyword match
124
- // on one type would be dragged into a tie by the other type's weak signal
125
- // (e.g. a lone "bug" keyword + a short declaredScope), routing an
126
- // unambiguous request to the LLM and risking a gate-skipping misroute.
64
+ // Weak nudges only break ties: applied otherwise, they drag a clean single-keyword match into a tie.
127
65
  if (bugMatched.length === choreMatched.length) {
128
66
  if (typeof estimatedFiles === 'number' && estimatedFiles <= 2) {
129
67
  bugScore += 1;
@@ -1,41 +1,8 @@
1
- /**
2
- * docker-audit.js — the part of "which worktrees exist" that the filesystem
3
- * cannot answer: the docker stacks still running for them.
4
- *
5
- * `worktree-audit.js` reconciles git's registry, the disk and morph's features.
6
- * None of the three can see a CONTAINER. A stack survives everything they look
7
- * at: `finish` removes the worktree and the branch, the directory disappears,
8
- * git forgets the registration — and `morph-{feature}` keeps running, holding
9
- * the feature's whole port block (and its named volumes) against the next
10
- * worktree that lands on the same block. The failure surfaces later, somewhere
11
- * else, as `port is already allocated` during an `e2e up` that has nothing to
12
- * do with the stack that is actually holding the port.
13
- *
14
- * Two findings, both `severity: 'warn'` — reported, never a failure of
15
- * `doctor --worktrees` (`doctor.js` treats `info`/`warn` as non-problems):
16
- *
17
- * • docker-stack-orphan — a `morph-*` compose project of THIS repository
18
- * running for a feature this tree does not know
19
- * • docker-port-held — a port from a feature's allocated block published
20
- * by a container belonging to a DIFFERENT compose project
21
- *
22
- * This module does not go into `doctor --full`, on purpose (D10): every check
23
- * there must be satisfiable in the framework repo itself, and a docker check in
24
- * a repo with no compose file could only be permanently yellow. A check that
25
- * can never pass where it runs teaches nothing.
26
- *
27
- * Nothing here mutates anything — not the registry it reads (see
28
- * `allocatedFeatures`), and certainly not docker. The `fix` strings name the
29
- * destructive command; a human runs it.
30
- *
31
- * @module lib/worktree/docker-audit
32
- */
33
-
1
+ // Stays out of `doctor --full`: a docker check in a repo with no compose file
2
+ // could only be permanently yellow. Read-only; the `fix` strings are for a human.
34
3
  import { execFileSync } from 'child_process';
35
4
  import { resolve } from 'path';
36
5
 
37
- // src/ → framework/hooks/shared/ is the allowed direction (hooks are installed
38
- // standalone into target projects, where src/ does not exist).
39
6
  import {
40
7
  composeProjectName,
41
8
  defaultExec,
@@ -44,19 +11,9 @@ import {
44
11
  } from '../../../framework/hooks/shared/e2e-check.js';
45
12
  import { allocateEnvBlock, allocatedFeatures, portLayout, resolveFleetConfig } from './env-allocator.js';
46
13
 
47
- /**
48
- * The prefix `composeProjectName()` stamps on every stack morph brings up,
49
- * derived from the function itself rather than retyped — the string `morph-`
50
- * living in two places is how the two stop agreeing.
51
- */
52
14
  const PROJECT_PROBE = 'probe';
53
15
  const MORPH_PROJECT_PREFIX = composeProjectName(PROJECT_PROBE).slice(0, -PROJECT_PROBE.length);
54
16
 
55
- /**
56
- * Same fail-open resolution the other callers use: the COMMON git dir is the
57
- * one location every linked worktree shares, and it is where the port-block
58
- * registry lives.
59
- */
60
17
  function resolveGitCommonDir(cwd) {
61
18
  try {
62
19
  const common = execFileSync('git', ['rev-parse', '--git-common-dir'], {
@@ -68,31 +25,12 @@ function resolveGitCommonDir(cwd) {
68
25
  }
69
26
  }
70
27
 
71
- /**
72
- * Path comparison that matches the host's own rules. Windows paths differ in
73
- * drive-letter case between what git reports and what docker echoes back;
74
- * treating those as different paths would make every stack look foreign.
75
- *
76
- * Exported as the ONE copy of that rule: `doctor.js` compares a declared test
77
- * node `cwd` with a discovered `package.json` folder through it.
78
- */
28
+ // On Windows git and docker report the drive letter in different case.
79
29
  export const samePathCase = process.platform === 'win32' ? (s) => s.toLowerCase() : (s) => s;
80
30
  const norm = (p) => samePathCase(resolve(String(p)).replace(/\\/g, '/'));
81
31
 
82
- /**
83
- * Is this compose project OURS to talk about?
84
- *
85
- * The `morph-` prefix is global to the host, not to a repository: two different
86
- * repos that use morph-spec and both have a feature named `triagem` produce the
87
- * SAME compose project name. Without this test, `doctor` in repo A would report
88
- * repo B's live stack as an orphan and hand the reader a `down -v` — volumes
89
- * included — for somebody else's running database. The compose file's path is
90
- * what ties a stack to a repository, and it keeps pointing inside the root even
91
- * after the worktree directory it named is gone.
92
- *
93
- * Unattributable (no config file in the listing) counts as NOT ours: an
94
- * unknown owner is not evidence of ownership.
95
- */
32
+ // The `morph-` prefix is host-global: two repos with the same feature name share
33
+ // a project name, so ownership comes from the compose file path. No path = not ours.
96
34
  function belongsToRoot(entry, rootNorm) {
97
35
  const configFiles = String(entry?.ConfigFiles ?? '').split(',').map((s) => s.trim()).filter(Boolean);
98
36
  if (configFiles.length === 0) return false;
@@ -105,14 +43,6 @@ function belongsToRoot(entry, rootNorm) {
105
43
  });
106
44
  }
107
45
 
108
- /**
109
- * Host ports a `docker ps`/`docker compose ps` entry publishes.
110
- * Both output shapes are accepted rather than pinning a docker version:
111
- * `Ports` as the human string (`0.0.0.0:4002->5432/tcp`) and `Publishers` as
112
- * the structured array compose emits.
113
- *
114
- * @returns {Set<number>}
115
- */
116
46
  function publishedPorts(entry) {
117
47
  const ports = new Set();
118
48
  for (const pub of Array.isArray(entry?.Publishers) ? entry.Publishers : []) {
@@ -127,7 +57,6 @@ function publishedPorts(entry) {
127
57
  return ports;
128
58
  }
129
59
 
130
- /** The compose project a container belongs to, from its labels. `null` = unknown. */
131
60
  function composeProjectOf(entry) {
132
61
  const labels = String(entry?.Labels ?? '');
133
62
  for (const part of labels.split(',')) {
@@ -138,83 +67,43 @@ function composeProjectOf(entry) {
138
67
  return null;
139
68
  }
140
69
 
141
- /** Readable container name for a `docker ps` entry (Names may be a list). */
142
70
  function containerNameOf(entry) {
143
71
  const names = String(entry?.Names ?? entry?.Name ?? '').split(',').map((s) => s.trim()).filter(Boolean);
144
72
  return names[0] || String(entry?.ID ?? 'container desconhecido');
145
73
  }
146
74
 
147
- /**
148
- * Parse a docker JSON listing that may be an array or NDJSON.
149
- * `null` means the output could not be read — DISTINCT from `[]`, which means
150
- * docker answered and there was nothing running. A caller that conflated the
151
- * two would turn "could not ask" into "nothing to report", or worse, into a
152
- * finding.
153
- *
154
- * @returns {Array<object>|null}
155
- */
75
+ // `null` = unreadable, distinct from `[]` = docker answered with nothing running.
156
76
  function parseDockerJson(raw) {
157
77
  const text = String(raw ?? '').trim();
158
78
  if (!text) return [];
159
79
  const parsed = parseComposePs(text);
160
80
  if (parsed.length > 0) return parsed;
161
- // parseComposePs (the ONE docker-JSON parser in this codebase) collapses two
162
- // very different answers into `[]`: "docker listed nothing" and "this is not
163
- // JSON". Only a literal empty array is the first one; everything else that
164
- // parsed to nothing is garbage, and garbage must not read as "all clear".
81
+ // parseComposePs returns `[]` for non-JSON too; only a literal `[]` is "nothing".
165
82
  return /^\[\s*\]$/.test(text) ? [] : null;
166
83
  }
167
84
 
168
85
  /**
169
- * Audit the docker stacks around this repository.
170
- *
171
- * Returns `[]` — asking docker nothing at all — in three situations, and the
172
- * silence is the point: this module is loaded by `doctor --worktrees` in every
173
- * repo, including ones that never containerize anything.
174
- *
175
- * 1. no feature ever allocated a port block (no registry): there is no
176
- * isolation contract to compare a running stack against;
177
- * 2. the docker CLI is not usable here;
178
- * 3. any docker call exits non-zero or answers with something that is not
179
- * JSON — fail-open, the standing rule of this repo's diagnostics.
180
- *
181
- * `[]` here reads as "nothing to report", not as "unknown": with no docker or
182
- * no blocks, nothing CAN be colliding. That is why this is not the `null`-is-
183
- * UNKNOWN shape `pr-index.js` uses — there the question stays open, here the
184
- * absence is itself the answer.
185
- *
186
- * @param {string} root - primary root of the repository
187
- * @param {object} [opts]
188
- * @param {(file: string, args: string[], o?: object) => {code: number, output: string}} [opts.exec]
189
- * injectable executor, exactly like `e2e-check.js` — the CI runners have no
190
- * guaranteed daemon, so every path here must be testable without docker
191
- * @param {Iterable<string>} [opts.features] - feature names this tree knows.
192
- * Passed IN rather than re-derived: `auditWorktrees` already read
193
- * `.morph/features/` and `git worktree list`, and two scans of the same fact
194
- * are two chances to disagree about whether a stack is orphaned
195
- * @param {string|null} [opts.gitCommonDir]
196
- * @returns {Array<{type: string, severity: string, detail: string, fix: string, feature?: string}>}
86
+ * `[]` means "nothing to report", not unknown: with no port block allocated or
87
+ * no docker, nothing can collide. Docker errors are fail-open.
88
+ * `features` is passed in so the audit and `auditWorktrees` share one scan.
197
89
  */
198
90
  export function auditDockerStacks(root, { exec = defaultExec, features = [], gitCommonDir } = {}) {
199
91
  const commonDir = gitCommonDir === undefined ? resolveGitCommonDir(root) : gitCommonDir;
200
92
  if (!commonDir) return [];
201
93
 
202
- // (1) No port block was ever allocated → no fleet isolation to protect.
203
94
  const allocated = allocatedFeatures(commonDir);
204
95
  if (allocated.length === 0) return [];
205
96
 
206
- // (2) No docker → nothing can be holding anything.
207
97
  if (!isDockerAvailable(root, exec)) return [];
208
98
 
209
99
  const known = new Set(features);
210
100
  const rootNorm = norm(root);
211
101
  const issues = [];
212
102
 
213
- // ---- docker-stack-orphan ----------------------------------------------
214
103
  const lsResult = exec('docker', ['compose', 'ls', '--format', 'json'], { cwd: root });
215
- if (lsResult.code !== 0) return []; // (3) fail-open
104
+ if (lsResult.code !== 0) return [];
216
105
  const projects = parseDockerJson(lsResult.output);
217
- if (projects === null) return []; // (3) unreadable answer is not a finding
106
+ if (projects === null) return [];
218
107
 
219
108
  const knownProjects = new Set([...known].map((f) => composeProjectName(f)));
220
109
  for (const entry of projects) {
@@ -229,35 +118,27 @@ export function auditDockerStacks(root, { exec = defaultExec, features = [], git
229
118
  severity: 'warn',
230
119
  feature: slug || null,
231
120
  detail: `stack docker "${name}" está de pé (${String(entry?.Status ?? 'estado desconhecido')}) e não corresponde a nenhuma feature desta árvore — sobra de outra sessão, segurando o bloco de portas e os volumes de "${slug}"`,
232
- // NEVER `morph-spec e2e down {slug}`: that command resolves the feature's
233
- // compose file, and this stack's feature no longer exists here. The
234
- // project name is the only handle that still works.
121
+ // Not `e2e down {slug}`: the feature's compose file no longer resolves.
235
122
  fix: `docker compose -p ${name} down -v`,
236
123
  });
237
124
  }
238
125
 
239
- // ---- docker-port-held --------------------------------------------------
240
126
  const psResult = exec('docker', ['ps', '--format', 'json'], { cwd: root });
241
- if (psResult.code !== 0) return []; // (3) fail-open
127
+ if (psResult.code !== 0) return [];
242
128
  const containers = parseDockerJson(psResult.output);
243
129
  if (containers === null) return [];
244
130
 
245
131
  const { basePort, blockSize } = resolveFleetConfig(root);
246
132
  for (const feature of allocated) {
247
- // A block allocated by a feature this tree does not know belongs to another
248
- // worktree of the repo; its collisions are that worktree's to report.
249
133
  if (!known.has(feature)) continue;
250
134
 
251
135
  let layout;
252
136
  try {
253
- // `allocateEnvBlock` for a feature that is ALREADY in the registry takes
254
- // the early-return path and writes nothing. The expected ports are
255
- // DERIVED from portLayout — the same function that writes worktree.env
256
- // and the compose env — never recomputed here.
137
+ // Feature is already in the registry, so allocateEnvBlock writes nothing.
257
138
  const block = allocateEnvBlock(feature, { gitCommonDir: commonDir, basePort, blockSize });
258
139
  layout = portLayout(block, { feature });
259
140
  } catch {
260
- continue; // block out of range / corrupt entry — nothing to compare
141
+ continue;
261
142
  }
262
143
 
263
144
  const ownProject = layout.COMPOSE_PROJECT_NAME;
@@ -269,7 +150,7 @@ export function auditDockerStacks(root, { exec = defaultExec, features = [], git
269
150
 
270
151
  for (const container of containers) {
271
152
  const project = composeProjectOf(container);
272
- if (project === ownProject) continue; // the feature's own stack, as intended
153
+ if (project === ownProject) continue;
273
154
  for (const port of publishedPorts(container)) {
274
155
  const key = expected.get(port);
275
156
  if (!key) continue;
@@ -1,34 +1,14 @@
1
- /**
2
- * env-allocator.js — deterministic per-worktree port-block allocation (fleet).
3
- *
4
- * Two parallel features that both start `next dev` (or `dotnet run`) collide on
5
- * the same default port, and the failure is invisible until one server kills
6
- * the other. Worktrees isolate files and branches, not runtime — this module
7
- * closes that gap by giving every feature worktree its own stable block of
8
- * ports.
9
- *
10
- * The registry lives at `<git-common-dir>/morph-spec/env-registry.json` — under
11
- * `.git/` on purpose, for the same reasons as `finish-backup/`: it can never
12
- * dirty the `git status` the worktree-finish gates read, it survives
13
- * `git clean -fdx`, and the COMMON dir is the one location every linked
14
- * worktree of the repo shares.
15
- *
16
- * Allocation is deterministic (first free index), idempotent per feature, and
17
- * released only when `morph-spec finish` actually removed the worktree. Writes
18
- * are tmp+rename so a crash never leaves a torn registry; a corrupt registry
19
- * is treated as empty (fail-open — the cost is a re-allocation, never a crash).
20
- */
1
+ // The registry lives under the COMMON git dir: shared by every linked worktree,
2
+ // invisible to the `git status` the finish gates read, and survives `git clean -fdx`.
21
3
 
22
4
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
23
5
  import { join } from 'path';
24
- // src/ → hooks/shared/ is the allowed direction (hooks are installed standalone).
25
6
  import { composeProjectName } from '../../../framework/hooks/shared/e2e-check.js';
26
7
 
27
8
  const DEFAULT_BASE_PORT = 4000;
28
9
  const DEFAULT_BLOCK_SIZE = 10;
29
10
  const MAX_PORT = 65535;
30
11
 
31
- /** Registry file path for a repo's common git dir. */
32
12
  export function registryPath(gitCommonDir) {
33
13
  return join(gitCommonDir, 'morph-spec', 'env-registry.json');
34
14
  }
@@ -40,7 +20,7 @@ function readRegistry(gitCommonDir) {
40
20
  if (parsed && typeof parsed.allocations === 'object' && parsed.allocations !== null) {
41
21
  return parsed;
42
22
  }
43
- } catch { /* fail-open: corrupt or absent registry restarts empty */ }
23
+ } catch { /* fail-open: a re-allocation, never a crash */ }
44
24
  return { version: 1, allocations: {} };
45
25
  }
46
26
 
@@ -52,13 +32,6 @@ function writeRegistry(gitCommonDir, registry) {
52
32
  renameSync(tmp, path);
53
33
  }
54
34
 
55
- /**
56
- * Read fleet overrides from the project's committed config.json.
57
- * Fail-open: missing/malformed config (or a Windows BOM) → defaults.
58
- *
59
- * @param {string} rootPath - primary root of the project
60
- * @returns {{ basePort: number, blockSize: number }}
61
- */
62
35
  export function resolveFleetConfig(rootPath) {
63
36
  const defaults = { basePort: DEFAULT_BASE_PORT, blockSize: DEFAULT_BLOCK_SIZE };
64
37
  try {
@@ -74,15 +47,7 @@ export function resolveFleetConfig(rootPath) {
74
47
  }
75
48
  }
76
49
 
77
- /**
78
- * Allocate (or return the existing) port block for a feature.
79
- * First-free-index policy: released slots are reused, so long-lived repos
80
- * don't creep toward the port ceiling.
81
- *
82
- * @param {string} feature
83
- * @param {{ gitCommonDir: string, basePort?: number, blockSize?: number }} opts
84
- * @returns {{ index: number, base: number, range: [number, number], created: boolean }}
85
- */
50
+ /** First free index wins, so released slots are reused instead of creeping toward the port ceiling. */
86
51
  export function allocateEnvBlock(feature, { gitCommonDir, basePort = DEFAULT_BASE_PORT, blockSize = DEFAULT_BLOCK_SIZE }) {
87
52
  const registry = readRegistry(gitCommonDir);
88
53
 
@@ -110,23 +75,7 @@ export function allocateEnvBlock(feature, { gitCommonDir, basePort = DEFAULT_BAS
110
75
  return { index, base, range: [base, base + blockSize - 1], created: true };
111
76
  }
112
77
 
113
- /**
114
- * The features that ALREADY own a block — read-only, allocating nothing.
115
- *
116
- * `allocateEnvBlock` is the only sanctioned way to get a block, and it WRITES
117
- * when the feature has none. A diagnostic that has to ask "who owns a block?"
118
- * must not answer by handing one out: `doctor` is read-only, and an audit that
119
- * mutates the registry it audits invents the very state it then reports. So
120
- * this reader exists to bound the question, and the block itself still comes
121
- * from `allocateEnvBlock` (which, for a feature listed here, takes the
122
- * early-return path and writes nothing).
123
- *
124
- * An absent or corrupt registry yields `[]` — "nobody allocated anything",
125
- * which for every caller is the same as "nothing to compare against".
126
- *
127
- * @param {string} gitCommonDir
128
- * @returns {string[]} feature names with an allocated block
129
- */
78
+ /** Read-only on purpose: `allocateEnvBlock` writes, and an audit that allocates invents the state it reports. */
130
79
  export function allocatedFeatures(gitCommonDir) {
131
80
  const registry = readRegistry(gitCommonDir);
132
81
  return Object.entries(registry.allocations)
@@ -134,13 +83,6 @@ export function allocatedFeatures(gitCommonDir) {
134
83
  .map(([feature]) => feature);
135
84
  }
136
85
 
137
- /**
138
- * Release a feature's block. Returns false when nothing was allocated.
139
- *
140
- * @param {string} feature
141
- * @param {{ gitCommonDir: string }} opts
142
- * @returns {boolean}
143
- */
144
86
  export function releaseEnvBlock(feature, { gitCommonDir }) {
145
87
  const registry = readRegistry(gitCommonDir);
146
88
  if (!registry.allocations[feature]) return false;
@@ -149,27 +91,12 @@ export function releaseEnvBlock(feature, { gitCommonDir }) {
149
91
  return true;
150
92
  }
151
93
 
152
- /**
153
- * Fixed offsets inside a block. The block has 10 ports; four are named, the
154
- * rest (+4..+9) are the project's to use. A new offset added here MUST also be
155
- * published by the compose template (`${VAR:-default}:container`), or it is a
156
- * port nobody listens on.
157
- */
94
+ // A new offset here must also be published by the compose template, or it is a port nobody listens on.
158
95
  export const PORT_OFFSETS = Object.freeze({ web: 0, api: 1, postgres: 2, redis: 3 });
159
96
 
160
97
  /**
161
- * The ONE port-layout contract. Two writers derive from it — the
162
- * `.morph/worktree.env` manifest (`buildEnvFileContent`) and the env handed to
163
- * `docker compose` (`src/lib/e2e/compose-env.js`) — so a worktree's file and
164
- * its running stack can never describe different worlds.
165
- *
166
- * `COMPOSE_PROJECT_NAME` is part of the layout on purpose: a hand-run
167
- * `docker compose up` inside the worktree must land in the isolated project,
168
- * not in the root's. Every value is a string (env-safe); key order is stable.
169
- *
170
- * @param {{ base: number, range: [number, number] }} block
171
- * @param {{ stack?: string, feature?: string, aliases?: Record<string, string> }} [opts] - aliases: resolvePortAliases()
172
- * @returns {Record<string,string>}
98
+ * The single layout both `worktree.env` and the compose env derive from. `COMPOSE_PROJECT_NAME`
99
+ * is in it so a hand-run `docker compose up` in the worktree lands in the isolated project.
173
100
  */
174
101
  export function portLayout(block, { stack = '', feature = '', aliases = {} } = {}) {
175
102
  const env = {};
@@ -194,9 +121,6 @@ const RESERVED_ENV_NAMES = new Set([
194
121
  'MORPH_FEATURE',
195
122
  ]);
196
123
 
197
- // e2e.portEnvNames: extra env names for the same four ports, so a compose file reading
198
- // ${PG_PORT} is fed by the block, not by its own default. An entry that cannot be
199
- // honoured is dropped and named in `problems`.
200
124
  export function resolvePortAliases(config) {
201
125
  const declared = config?.e2e?.portEnvNames;
202
126
  const aliases = {};
@@ -217,20 +141,8 @@ export function resolvePortAliases(config) {
217
141
  }
218
142
 
219
143
  /**
220
- * Render the `.morph/worktree.env` contents for a block: a header, the
221
- * feature, and exactly the `portLayout()` pairs — nothing else, so the file
222
- * cannot drift from what compose receives.
223
- *
224
- * `morphFeature` is for a task tree born from `morph/<f>`: `MORPH_FEATURE`
225
- * names the feature it works for, while the block and `COMPOSE_PROJECT_NAME`
226
- * stay keyed by `feature` (`task/<n>`) — keying them by `<f>` would hand the
227
- * sub-agent the feature's own ports and compose project.
228
- *
229
- * @param {string} feature - the env-registry key: `<feature>` or `task/<name>`
230
- * @param {{ base: number, range: [number, number] }} block
231
- * @param {string} [stack] - config.json `project.stack` (e.g. "nextjs+dotnet")
232
- * @param {{ morphFeature?: string|null, aliases?: Record<string, string> }} [opts]
233
- * @returns {string}
144
+ * `morphFeature` names the feature a `task/<n>` tree works for; the block and compose
145
+ * project stay keyed by `feature`, or the sub-agent would get the feature's own ports.
234
146
  */
235
147
  export function buildEnvFileContent(feature, block, stack = '', { morphFeature = null, aliases = {} } = {}) {
236
148
  const lines = [