@polymorphism-tech/morph-spec 8.46.0 → 9.0.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 +53 -44
  2. package/README.md +3 -4
  3. package/bin/morph-spec.js +38 -55
  4. package/bin/validate.js +5 -134
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +2 -2
  7. package/docs/QUICKSTART.md +1 -1
  8. package/framework/CLAUDE.md +41 -101
  9. package/framework/MORPH.md +95 -252
  10. package/framework/agents.json +75 -18
  11. package/framework/commands/create-umbraco-lp.md +2 -0
  12. package/framework/commands/migrate-lp-to-umbraco.md +2 -0
  13. package/framework/commands/morph-apply.md +5 -28
  14. package/framework/commands/morph-archive.md +2 -2
  15. package/framework/commands/morph-hotfix.md +8 -8
  16. package/framework/commands/morph-infra.md +17 -203
  17. package/framework/commands/morph-media.md +3 -0
  18. package/framework/commands/morph-preflight.md +7 -31
  19. package/framework/commands/morph-proposal.md +2 -50
  20. package/framework/commands/morph-status.md +3 -3
  21. package/framework/commands/morph-troubleshoot.md +2 -14
  22. package/framework/docs/CLI.md +585 -286
  23. package/framework/evals/review-checklist.md +82 -41
  24. package/framework/hooks/README.md +10 -11
  25. package/framework/hooks/claude-code/core/comment-guard.js +1 -1
  26. package/framework/hooks/claude-code/core/denylist.json +33 -3
  27. package/framework/hooks/claude-code/core/enforce-phase-writes.js +16 -20
  28. package/framework/hooks/claude-code/core/gate-guard.js +2 -11
  29. package/framework/hooks/claude-code/core/pre-bash-denylist.js +38 -0
  30. package/framework/hooks/claude-code/core/protect-spec-files.js +30 -242
  31. package/framework/hooks/claude-code/core/state-sync.js +17 -19
  32. package/framework/hooks/claude-code/core/telemetry-log.js +3 -3
  33. package/framework/hooks/claude-code/core/validate-completion.js +33 -97
  34. package/framework/hooks/claude-code/core/warn-bash-source-write.js +31 -0
  35. package/framework/hooks/claude-code/core/worktree-context.js +5 -4
  36. package/framework/hooks/claude-code/statusline.py +55 -41
  37. package/framework/hooks/package.json +3 -0
  38. package/framework/hooks/shared/activity-logger.js +1 -1
  39. package/framework/hooks/shared/build-check.js +10 -6
  40. package/framework/hooks/shared/command-tokens.js +169 -0
  41. package/framework/hooks/shared/comment-rules.js +1 -1
  42. package/framework/hooks/shared/comment-scanner.js +10 -23
  43. package/framework/hooks/shared/comment-stripper.js +1 -1
  44. package/framework/hooks/shared/e2e-check.js +67 -31
  45. package/framework/hooks/shared/errata.js +72 -0
  46. package/framework/hooks/shared/feature-locator.js +1 -1
  47. package/framework/hooks/shared/frontend-locations.js +6 -0
  48. package/framework/hooks/shared/gate-question.js +18 -0
  49. package/framework/hooks/shared/phase-utils.js +15 -32
  50. package/framework/hooks/shared/slice-progress.js +118 -0
  51. package/framework/hooks/shared/spawn-command.js +23 -0
  52. package/framework/hooks/shared/state-reader.js +61 -87
  53. package/framework/hooks/shared/state-writer.js +3 -3
  54. package/framework/mcp/registry.json +3 -4
  55. package/framework/phases.json +23 -39
  56. package/framework/rules/ai-agents-standards.md +18 -16
  57. package/framework/rules/asaas-standards.md +3 -1
  58. package/framework/rules/csharp-standards.md +10 -8
  59. package/framework/rules/data-standards.md +5 -5
  60. package/framework/rules/dotnet-templates.md +1 -1
  61. package/framework/rules/frontend-standards.md +4 -2
  62. package/framework/rules/gsap-standards.md +10 -5
  63. package/framework/rules/hangfire-standards.md +3 -1
  64. package/framework/rules/infrastructure-standards.md +4 -2
  65. package/framework/rules/neon-auth-standards.md +3 -1
  66. package/framework/rules/neon-standards.md +6 -4
  67. package/framework/rules/nextjs-standards.md +13 -11
  68. package/framework/rules/nextjs-templates.md +1 -1
  69. package/framework/rules/resend-standards.md +3 -1
  70. package/framework/schemas/config.schema.json +14 -9
  71. package/framework/schemas/feature.schema.json +15 -52
  72. package/framework/skills/README.md +4 -5
  73. package/framework/skills/core/create-umbraco-lp/SKILL.md +3 -3
  74. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +3 -3
  75. package/framework/skills/core/morph-apply/SKILL.md +150 -223
  76. package/framework/skills/core/morph-hotfix/SKILL.md +34 -43
  77. package/framework/skills/core/morph-machine/SKILL.md +4 -4
  78. package/framework/skills/core/morph-media/SKILL.md +3 -3
  79. package/framework/skills/core/morph-plan/SKILL.md +138 -300
  80. package/framework/skills/core/morph-review/SKILL.md +112 -137
  81. package/framework/skills/core/morph-standards/SKILL.md +29 -41
  82. package/framework/skills/core/morph-uiux/SKILL.md +78 -153
  83. package/framework/standards/STANDARDS.json +90 -1219
  84. package/framework/standards/STANDARDS.meta.json +1305 -0
  85. package/framework/standards/ai-agents/evals-with-cache.md +2 -3
  86. package/framework/standards/ai-agents/llm-runtime-defaults.md +1 -1
  87. package/framework/standards/ai-agents/modalities-speech-to-text.md +36 -26
  88. package/framework/standards/ai-agents/prompt-sources.md +6 -4
  89. package/framework/standards/ai-agents/providers/model-registry.md +1 -1
  90. package/framework/standards/ai-agents/rag-custom-pgvector.md +67 -39
  91. package/framework/standards/ai-agents/setup.md +2 -2
  92. package/framework/standards/ai-agents/structured-output.md +1 -1
  93. package/framework/standards/ai-agents/sweet-spot.md +1 -1
  94. package/framework/standards/architecture/code-comments.md +8 -8
  95. package/framework/standards/architecture/vertical-slice/vertical-slice.md +18 -9
  96. package/framework/standards/backend/api/validation.md +135 -47
  97. package/framework/standards/backend/database/postgresql/database.md +11 -7
  98. package/framework/standards/backend/database/vector-search-rag.md +207 -324
  99. package/framework/standards/backend/dotnet/fluent-validation-vsa.md +14 -8
  100. package/framework/standards/backend/dotnet/result-pattern.md +40 -6
  101. package/framework/standards/backend/dotnet/scalar-ui.md +13 -7
  102. package/framework/standards/backend/dotnet/scrutor.md +1 -1
  103. package/framework/standards/backend/dotnet/testing.md +1 -1
  104. package/framework/standards/backend/dotnet/vsa-handler-patterns.md +7 -7
  105. package/framework/standards/backend/integrations/asaas/asaas-api.md +309 -165
  106. package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +134 -154
  107. package/framework/standards/data/nosql/cache/redis.md +120 -44
  108. package/framework/standards/data/nosql/cosmos-db.md +114 -71
  109. package/framework/standards/frontend/design-system/animations.md +1 -1
  110. package/framework/standards/frontend/design-system/naming.md +1 -1
  111. package/framework/standards/frontend/nextjs/motion-patterns.md +45 -29
  112. package/framework/standards/frontend/nextjs/naming-conventions.md +1 -1
  113. package/framework/standards/infrastructure/docker/coolify-deploy.md +8 -6
  114. package/framework/standards/infrastructure/neon/neon-pgvector.md +120 -75
  115. package/framework/standards/infrastructure/neon/neon-setup.md +53 -31
  116. package/framework/standards/integration/mcp/mcp-tools.md +7 -7
  117. package/framework/templates/README.md +2 -3
  118. package/framework/templates/REGISTRY.json +6 -170
  119. package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +2 -2
  120. package/framework/templates/code/dotnet/extensions/database-extensions.cs +1 -1
  121. package/framework/templates/code/dotnet/extensions/result-extensions.cs +27 -0
  122. package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +2 -2
  123. package/framework/templates/docs/plan.md +72 -0
  124. package/framework/templates/dotnet/ai-kit/README.md +1 -9
  125. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaDisclosureTests.cs +1 -1
  126. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +0 -10
  127. package/framework/templates/infrastructure/docker/coolify.template.json +3 -3
  128. package/framework/templates/infrastructure/github/README.md +2 -1
  129. package/framework/templates/infrastructure/github/workflows/deploy-coolify.yml.hbs +2 -1
  130. package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +1 -1
  131. package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +1 -1
  132. package/framework/templates/meta-prompts/squad-leaders/squad-leader.md +1 -1
  133. package/framework/templates/meta-prompts/validators/pre-commit-validator.md +3 -4
  134. package/package.json +1 -1
  135. package/src/commands/e2e/e2e.js +2 -2
  136. package/src/commands/observability/cost.js +18 -60
  137. package/src/commands/observability/telemetry.js +3 -5
  138. package/src/commands/project/doctor-procs.js +1 -1
  139. package/src/commands/project/doctor.js +9 -9
  140. package/src/commands/project/fleet.js +4 -4
  141. package/src/commands/project/heavy.js +3 -3
  142. package/src/commands/project/init.js +6 -1
  143. package/src/commands/project/setup-infra-cmd.js +2 -1
  144. package/src/commands/project/stack-scan.js +58 -0
  145. package/src/commands/project/standards.js +79 -25
  146. package/src/commands/project/status.js +29 -69
  147. package/src/commands/project/update.js +33 -19
  148. package/src/commands/project/worktree.js +1 -1
  149. package/src/commands/state/advance.js +62 -46
  150. package/src/commands/state/approve.js +95 -138
  151. package/src/commands/state/archive.js +20 -84
  152. package/src/commands/state/auto-approval.js +68 -0
  153. package/src/commands/state/create.js +28 -1
  154. package/src/commands/state/delete.js +1 -1
  155. package/src/commands/state/finish.js +11 -23
  156. package/src/commands/state/gate-check.js +86 -95
  157. package/src/commands/state/gate-decision.js +6 -4
  158. package/src/commands/state/handoff.js +1 -1
  159. package/src/commands/state/list.js +9 -9
  160. package/src/commands/state/report-check.js +157 -311
  161. package/src/commands/state/retype.js +9 -3
  162. package/src/commands/validation/mutate.js +77 -14
  163. package/src/commands/validation/plan-check.js +81 -0
  164. package/src/commands/validation/validate-feature.js +60 -135
  165. package/src/commands/validation/verify.js +41 -82
  166. package/src/core/paths/output-schema.js +20 -67
  167. package/src/core/state/gate-order.js +5 -6
  168. package/src/core/state/lifecycle-audit.js +2 -2
  169. package/src/core/state/state-manager.js +17 -98
  170. package/src/core/templates/template-registry.js +2 -2
  171. package/src/core/templates/template-seed.js +22 -0
  172. package/src/core/work-types.js +10 -10
  173. package/src/dashboard/app/App.jsx +0 -2
  174. package/src/dashboard/app/components/ActivityFeed.jsx +2 -3
  175. package/src/dashboard/app/components/CommandPalette.jsx +7 -8
  176. package/src/dashboard/app/components/EntityLink.jsx +1 -2
  177. package/src/dashboard/app/components/Markdown.jsx +1 -1
  178. package/src/dashboard/app/components/Sidebar.jsx +1 -2
  179. package/src/dashboard/app/hooks/useMorphQuery.js +1 -1
  180. package/src/dashboard/app/lib/phases.js +2 -4
  181. package/src/dashboard/app/pages/Activity.jsx +1 -1
  182. package/src/dashboard/app/pages/FeatureDetail.jsx +139 -129
  183. package/src/dashboard/app/pages/Features.jsx +9 -9
  184. package/src/dashboard/app/pages/Guide.jsx +2 -2
  185. package/src/dashboard/app/pages/PhasePipeline.jsx +3 -4
  186. package/src/dashboard/app/pages/RulesMap.jsx +3 -3
  187. package/src/dashboard/app/pages/SkillsLibrary.jsx +3 -3
  188. package/src/dashboard/app/pages/Standards.jsx +1 -1
  189. package/src/dashboard/scanner/activity-scanner.js +6 -16
  190. package/src/dashboard/scanner/agents-scanner.js +1 -1
  191. package/src/dashboard/scanner/docs-scanner.js +5 -23
  192. package/src/dashboard/scanner/features-scanner.js +95 -60
  193. package/src/dashboard/scanner/index.js +0 -2
  194. package/src/dashboard/scanner/project-api.js +1 -1
  195. package/src/dashboard/scanner/rules-scanner.js +15 -10
  196. package/src/dashboard/scanner/skills-scanner.js +2 -3
  197. package/src/dashboard/scanner/standards-scanner.js +2 -1
  198. package/src/lib/ci/ci-verify.js +17 -1
  199. package/src/lib/ci/github-ci.js +1 -1
  200. package/src/lib/detectors/index.js +3 -8
  201. package/src/lib/generators/handoff-generator.js +34 -65
  202. package/src/lib/generators/settings-generator.js +11 -89
  203. package/src/lib/git/evidence-staleness.js +1 -1
  204. package/src/lib/git/tree-fingerprint.js +19 -7
  205. package/src/lib/git/tree-state.js +2 -2
  206. package/src/lib/heavy/cleanup.js +1 -5
  207. package/src/lib/installers/memory-installer.js +6 -8
  208. package/src/lib/mutation/commands.js +3 -2
  209. package/src/lib/{tasks/dotnet-test-introspection.js → mutation/dotnet-introspection.js} +17 -282
  210. package/src/lib/mutation/plan.js +14 -6
  211. package/src/lib/mutation/runner.js +1 -1
  212. package/src/lib/mutation/subset.js +46 -9
  213. package/src/lib/plan/artifact-vocabulary.js +60 -0
  214. package/src/lib/plan/checks.js +234 -0
  215. package/src/lib/plan/plan-parser.js +209 -0
  216. package/src/lib/plan/plan-template.js +18 -0
  217. package/src/lib/review-reports.js +222 -0
  218. package/src/lib/risk-detector.js +134 -135
  219. package/src/lib/specs/adr-index.js +50 -69
  220. package/src/lib/specs/design-seed.js +18 -0
  221. package/src/lib/specs/frontmatter.js +44 -0
  222. package/src/lib/specs/gfm-table.js +17 -0
  223. package/src/lib/specs/old-format.js +68 -0
  224. package/src/lib/specs/revocations.js +37 -95
  225. package/src/lib/specs/screen-checklist.js +18 -17
  226. package/src/lib/stack/dotnet-build-ignores.js +47 -0
  227. package/src/lib/stack/stack-profile.js +1 -1
  228. package/src/lib/stack/stack-scan.js +267 -0
  229. package/src/lib/standards/registry.js +43 -0
  230. package/src/lib/standards/sections.js +58 -0
  231. package/src/lib/tasks/eval-node.js +4 -12
  232. package/src/lib/tasks/test-nodes.js +1 -74
  233. package/src/lib/tasks/test-runner.js +75 -117
  234. package/src/lib/telemetry/metrics.js +8 -69
  235. package/src/lib/telemetry/telemetry-reader.js +5 -118
  236. package/src/lib/telemetry/verify-event.js +5 -7
  237. package/src/lib/validators/code-budget/code-budget-validator.js +1 -1
  238. package/src/lib/validators/contracts/contract-compliance-validator.js +2 -2
  239. package/src/lib/validators/reuse/reuse-validator.js +24 -48
  240. package/src/lib/validators/reuse/symbol-scanner.js +2 -4
  241. package/src/lib/validators/traceability-check.js +85 -129
  242. package/src/lib/validators/validation-runner.js +8 -88
  243. package/src/lib/validators/verify-runner.js +134 -301
  244. package/src/lib/worktree/docker-audit.js +1 -2
  245. package/src/lib/worktree/fleet-scanner.js +25 -38
  246. package/src/scripts/setup-infra.js +42 -22
  247. package/src/utils/agents-installer.js +92 -13
  248. package/src/utils/claude-settings-manager.js +0 -35
  249. package/src/utils/file-copier.js +34 -6
  250. package/src/utils/hooks-installer.js +16 -9
  251. package/src/utils/skills-installer.js +1 -2
  252. package/framework/commands/commit.md +0 -166
  253. package/framework/evals/trace-schema.md +0 -67
  254. package/framework/hooks/claude-code/core/tasks-json-guard.js +0 -83
  255. package/framework/hooks/claude-code/core/trace-autogen.js +0 -277
  256. package/framework/hooks/git/pre-commit/specs.sh +0 -50
  257. package/framework/hooks/shared/tasks-validator.js +0 -111
  258. package/framework/rubrics/backend.json +0 -41
  259. package/framework/rubrics/default.json +0 -36
  260. package/framework/rubrics/frontend.json +0 -36
  261. package/framework/rules/morph-task-tracking.md +0 -54
  262. package/framework/rules/morph-workflow.md +0 -244
  263. package/framework/schemas/tasks.schema.json +0 -79
  264. package/framework/skills/core/morph-brainstorming/SKILL.md +0 -161
  265. package/framework/skills/core/morph-implement/SKILL.md +0 -194
  266. package/framework/skills/core/morph-proposal/SKILL.md +0 -152
  267. package/framework/skills/core/morph-stack-scan/SKILL.md +0 -145
  268. package/framework/templates/docs/proposal.md +0 -192
  269. package/framework/templates/docs/spec.md +0 -57
  270. package/framework/templates/docs/ui-components.md +0 -52
  271. package/framework/templates/docs/ui-design-system.md +0 -76
  272. package/framework/templates/docs/ui-flows.md +0 -167
  273. package/framework/templates/examples/spec-examples.md +0 -91
  274. package/framework/templates/feature/decisions.md +0 -124
  275. package/framework/templates/feature/recap.md +0 -145
  276. package/framework/templates/project/validate.js +0 -97
  277. package/framework/templates/tasks.template.json +0 -17
  278. package/src/commands/state/usecase.js +0 -143
  279. package/src/commands/tasks/dag.js +0 -181
  280. package/src/dashboard/app/pages/Rubrics.jsx +0 -97
  281. package/src/dashboard/scanner/rubrics-scanner.js +0 -12
  282. package/src/lib/agents/output-collector.js +0 -76
  283. package/src/lib/evaluator-report.js +0 -91
  284. package/src/lib/specs/acervo-format.js +0 -70
  285. package/src/lib/specs/acervo-lifecycle.js +0 -264
  286. package/src/lib/specs/done-criteria-check.js +0 -200
  287. package/src/lib/specs/usecase-check.js +0 -301
  288. package/src/lib/specs/usecase-promoter.js +0 -301
  289. package/src/lib/specs/usecase-registry.js +0 -237
  290. package/src/lib/tasks/dispatch-planner.js +0 -319
  291. package/src/lib/tasks/mermaid-renderer.js +0 -132
  292. package/src/lib/tasks/task-classifier.js +0 -30
  293. package/src/lib/tasks/task-parser.js +0 -209
  294. package/src/lib/tasks/tasks-json-parser.js +0 -296
  295. package/src/lib/tasks/test-scope.js +0 -597
  296. package/src/lib/validators/architecture/architecture-validator.js +0 -270
  297. package/src/lib/validators/architecture/index.js +0 -7
  298. package/src/lib/validators/css/css-validator.js +0 -340
  299. package/src/lib/validators/css/index.js +0 -7
  300. package/src/lib/validators/design-system/design-system-validator.js +0 -237
  301. package/src/lib/validators/design-system/index.js +0 -7
  302. package/src/lib/validators/score-aggregator.js +0 -244
  303. package/src/lib/validators/ui/index.js +0 -7
  304. package/src/lib/validators/ui/ui-contrast-validator.js +0 -422
@@ -1,237 +0,0 @@
1
- /**
2
- * usecase-registry.js — atomic, cross-worktree UC-{nn} number allocation.
3
- *
4
- * Two parallel worktrees planning at the same time each read the acervo
5
- * (`.morph/specs/usecases/`) plus their OWN feature's `2-plan/usecases/`, take
6
- * the max `UC-{nn}` and add one — but a worktree only ever sees its OWN tree.
7
- * Sibling features' use cases live in SIBLING worktrees (feature.json and every
8
- * UC file travel on the branch, not through any shared filesystem). Two
9
- * worktrees both computing max+1 from the same stale max both mint `UC-07` —
10
- * and at promotion time `usecase-promoter.js` overwrites by ID: the SECOND
11
- * feature to close silently destroys the FIRST one's use case. A user lost
12
- * work this way in production.
13
- *
14
- * This module closes the gap the same way `env-allocator.js` (port blocks) and
15
- * `claim-registry.js` (feature ownership) already do: a registry file under the
16
- * git COMMON dir, the one location every linked worktree of a repo shares —
17
- * `<git-common-dir>/morph-spec/uc-registry.json`. Writes are tmp+rename; a
18
- * corrupt registry reads as empty (fail-open — the cost is a re-seed from disk,
19
- * never a crash).
20
- *
21
- * Unlike the other two registries, correctness here cannot be advisory: the
22
- * whole point is that two concurrent callers must never receive the same
23
- * number. `env-allocator`/`claim-registry` key by FEATURE, so two different
24
- * features racing never contend for the same key; two worktrees reserving UC
25
- * numbers race for the SAME counter. So allocation runs inside a real mutual
26
- * exclusion lock (`uc-registry.lock`, `open(..., 'wx')` — atomic create-if-absent
27
- * on every platform Node targets, including Windows) instead of the bare
28
- * read-modify-write the other two registries use.
29
- *
30
- * No git (no common dir resolvable): falls back to a project-local registry
31
- * under `.morph/logs/` (already gitignored) — this still prevents a single
32
- * session from reserving the same number twice across repeated calls, but
33
- * carries none of the cross-worktree guarantee. Callers must surface that
34
- * degradation to the user.
35
- */
36
-
37
- import {
38
- closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync,
39
- renameSync, statSync, unlinkSync, writeFileSync,
40
- } from 'fs';
41
- import { join } from 'path';
42
-
43
- // From acervo-format.js, not usecase-promoter.js: the promoter depends on THIS
44
- // module (to look up who reserved a number), and a two-way import is circular.
45
- import { useCaseId } from './acervo-format.js';
46
-
47
- /** `UC-7` (or any width) -> `UC-07` — the archive's zero-padded convention. */
48
- function formatUcId(n) {
49
- return `UC-${String(n).padStart(2, '0')}`;
50
- }
51
-
52
- /** Directory holding both the registry file and its lock. */
53
- export function registryDir({ gitCommonDir, cwd }) {
54
- return gitCommonDir ? join(gitCommonDir, 'morph-spec') : join(cwd, '.morph', 'logs');
55
- }
56
-
57
- /** Registry file path — shared (git common dir) or local-fallback (no git). */
58
- export function ucRegistryPath({ gitCommonDir, cwd }) {
59
- return join(registryDir({ gitCommonDir, cwd }), 'uc-registry.json');
60
- }
61
-
62
- function readRegistry(path) {
63
- try {
64
- const raw = readFileSync(path, 'utf8').replace(/^/, '');
65
- const parsed = JSON.parse(raw);
66
- if (parsed && typeof parsed.reservations === 'object' && parsed.reservations !== null) {
67
- return {
68
- version: 1,
69
- counter: Number.isInteger(parsed.counter) ? parsed.counter : 0,
70
- reservations: parsed.reservations,
71
- };
72
- }
73
- } catch { /* fail-open: absent or corrupt registry restarts empty */ }
74
- return { version: 1, counter: 0, reservations: {} };
75
- }
76
-
77
- function writeRegistry(path, registry) {
78
- mkdirSync(join(path, '..'), { recursive: true });
79
- const tmp = `${path}.tmp.${process.pid}`;
80
- writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
81
- renameSync(tmp, path);
82
- }
83
-
84
- /** Synchronous sleep — Node (unlike browsers) allows `Atomics.wait` on the main thread. */
85
- function sleepSync(ms) {
86
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
87
- }
88
-
89
- /**
90
- * Run `fn` while holding an exclusive filesystem lock at `lockPath`.
91
- *
92
- * `openSync(path, 'wx')` is `O_CREAT | O_EXCL` — atomic create-or-fail, the
93
- * only primitive this needs and one that behaves identically on POSIX and
94
- * Windows. A lock file whose mtime is older than `staleMs` is assumed to be
95
- * left behind by a crashed process and is force-recovered — an orphaned lock
96
- * must never strand every future reservation.
97
- *
98
- * @param {string} lockPath
99
- * @param {() => T} fn
100
- * @returns {T}
101
- * @template T
102
- */
103
- function withLock(lockPath, fn, { timeoutMs = 5000, pollMs = 20, staleMs = 15000 } = {}) {
104
- mkdirSync(join(lockPath, '..'), { recursive: true });
105
- const deadline = Date.now() + timeoutMs;
106
- for (;;) {
107
- let fd;
108
- try {
109
- fd = openSync(lockPath, 'wx');
110
- } catch (err) {
111
- if (err.code !== 'EEXIST') throw err;
112
- try {
113
- const st = statSync(lockPath);
114
- if (Date.now() - st.mtimeMs > staleMs) {
115
- try { unlinkSync(lockPath); } catch { /* raced someone else's cleanup */ }
116
- continue; // retry immediately — the stale lock is (probably) gone now
117
- }
118
- } catch { continue; } // lock vanished between openSync and statSync — retry
119
- if (Date.now() >= deadline) {
120
- throw new Error(`usecase-registry: could not acquire lock at ${lockPath} within ${timeoutMs}ms`);
121
- }
122
- sleepSync(pollMs);
123
- continue;
124
- }
125
- try {
126
- closeSync(fd);
127
- return fn();
128
- } finally {
129
- try { unlinkSync(lockPath); } catch { /* best-effort */ }
130
- }
131
- }
132
- }
133
-
134
- /** Highest `UC-{nn}` number found among files matching the pattern in `dir`. 0 if none/unreadable. */
135
- function maxUcNumberInDir(dir) {
136
- let max = 0;
137
- try {
138
- if (!existsSync(dir)) return 0;
139
- for (const name of readdirSync(dir)) {
140
- // Any `UC-{nn}-` prefix counts, canonical or not: a number already on disk is never handed out again.
141
- const id = useCaseId(name);
142
- if (!id) continue;
143
- const n = parseInt(id.slice('UC-'.length), 10);
144
- if (Number.isFinite(n) && n > max) max = n;
145
- }
146
- } catch { /* unreadable dir — treat as empty, never fatal */ }
147
- return max;
148
- }
149
-
150
- /** Highest UC number across every feature's `2-plan/usecases/`, under both `.morph/features/` and `.morph/archive/`, in THIS tree. */
151
- function maxUcNumberLocalTree(cwd) {
152
- let max = 0;
153
- for (const bucket of ['features', 'archive']) {
154
- const bucketDir = join(cwd, '.morph', bucket);
155
- try {
156
- if (!existsSync(bucketDir)) continue;
157
- for (const entry of readdirSync(bucketDir, { withFileTypes: true })) {
158
- if (!entry.isDirectory()) continue;
159
- max = Math.max(max, maxUcNumberInDir(join(bucketDir, entry.name, '2-plan', 'usecases')));
160
- }
161
- } catch { /* unreadable bucket — treat as empty */ }
162
- }
163
- return max;
164
- }
165
-
166
- /**
167
- * The highest UC number this project tree can see right now: the permanent
168
- * acervo, plus every feature (active OR archived-but-not-yet-promoted, see
169
- * `usecase-promoter.js`'s refusal path) in THIS tree. Exported for tests and
170
- * for callers that want to preview the seed without reserving anything.
171
- *
172
- * @param {string} cwd
173
- * @returns {number}
174
- */
175
- export function scanLocalMax(cwd) {
176
- const acervoMax = maxUcNumberInDir(join(cwd, '.morph', 'specs', 'usecases'));
177
- return Math.max(acervoMax, maxUcNumberLocalTree(cwd));
178
- }
179
-
180
- /**
181
- * Atomically reserve the next `count` UC numbers, shared across every worktree
182
- * of this repo when a git common dir is resolvable.
183
- *
184
- * The seed is recomputed on EVERY call as `max(registry.counter, scanLocalMax(cwd))`
185
- * — monotonic by construction (a max can only grow), so this doubles as both
186
- * "seed the counter on the first-ever reservation" and "never regress even if
187
- * the registry were somehow behind disk" without needing a separate seeded flag.
188
- *
189
- * @param {string} cwd
190
- * @param {{ feature?: string|null, count?: number, gitCommonDir?: string|null }} opts
191
- * @returns {{ numbers: string[], base: number, count: number, shared: boolean, registryPath: string }}
192
- */
193
- export function reserveUseCases(cwd, { feature = null, count = 1, gitCommonDir = null } = {}) {
194
- const n = Number.isInteger(count) && count > 0 ? count : 1;
195
- const dir = registryDir({ gitCommonDir, cwd });
196
- const path = ucRegistryPath({ gitCommonDir, cwd });
197
- mkdirSync(dir, { recursive: true });
198
-
199
- return withLock(join(dir, 'uc-registry.lock'), () => {
200
- const registry = readRegistry(path);
201
- const base = Math.max(registry.counter || 0, scanLocalMax(cwd));
202
-
203
- // Test-only: widen the critical section so a concurrency test can prove
204
- // two REAL processes racing for this lock actually serialize, instead of
205
- // just happening not to overlap. Mirrors `finish.js`'s MORPH_GH_BIN test
206
- // injection. Unset in every real invocation.
207
- const testDelay = Number.parseInt(process.env.MORPH_UC_RESERVE_TEST_DELAY_MS, 10);
208
- if (Number.isFinite(testDelay) && testDelay > 0) sleepSync(testDelay);
209
-
210
- const numbers = [];
211
- const now = new Date().toISOString();
212
- for (let i = 1; i <= n; i += 1) {
213
- const id = formatUcId(base + i);
214
- numbers.push(id);
215
- registry.reservations[id] = { feature: feature || null, reservedAt: now };
216
- }
217
- registry.counter = base + n;
218
- writeRegistry(path, registry);
219
-
220
- return { numbers, base, count: n, shared: !!gitCommonDir, registryPath: path };
221
- });
222
- }
223
-
224
- /**
225
- * Which feature (if any) holds the reservation for a given `UC-{nn}` id.
226
- * Read-only — no lock, a slightly stale read is harmless for this use (the
227
- * promoter's collision backstop, never the allocation itself).
228
- *
229
- * @param {string} id - e.g. "UC-07"
230
- * @param {{ cwd: string, gitCommonDir?: string|null }} opts
231
- * @returns {string|null}
232
- */
233
- export function reservationOwner(id, { cwd, gitCommonDir = null }) {
234
- const path = ucRegistryPath({ gitCommonDir, cwd });
235
- const registry = readRegistry(path);
236
- return registry.reservations?.[id]?.feature ?? null;
237
- }
@@ -1,319 +0,0 @@
1
- /**
2
- * dispatch-planner.js — effort-weighted sequential vs parallel decision for a
3
- * feature's tasks.json DAG.
4
- *
5
- * Encodes the dispatch rules from morph-apply §2 ("Análise de DAG") so the
6
- * decision is deterministic and CLI-driven (`morph-spec dag <feature>`)
7
- * instead of re-derived by the LLM each session:
8
- *
9
- * 1. <= 3 tasks total → sequential (dispatch overhead beats the work).
10
- * 2. >= 2 independent group flows with enough weight → one agent per flow.
11
- * 3. >= 3 independent roots with effort M or heavier → parallel root batch.
12
- * 4. otherwise → sequential with auto-advance.
13
- *
14
- * Effort weighting: S=1, M=2, L=4, XL=8. S-only roots and light flows never
15
- * earn their own agent — they are returned as `inline` (the orchestrator runs
16
- * them in the sequential lane, or folds them into a single grouped agent), so
17
- * dispatch overhead never exceeds the work being dispatched.
18
- *
19
- * Pure module — operates on a parsed tasks.json document, no I/O.
20
- */
21
-
22
- export const EFFORT_WEIGHT = { S: 1, M: 2, L: 4, XL: 8 };
23
-
24
- /** A root must weigh at least this (effort M) to earn its own agent. */
25
- export const ROOT_DISPATCH_MIN_WEIGHT = 2;
26
-
27
- /** A flow must weigh at least this (an L, or 2×M…) to earn its own agent. */
28
- export const FLOW_DISPATCH_MIN_WEIGHT = 4;
29
-
30
- /** Mirror of the morph-apply cap on simultaneous agents. */
31
- export const MAX_PARALLEL_AGENTS = 4;
32
-
33
- function weightOf(task) {
34
- return EFFORT_WEIGHT[task.effort] ?? EFFORT_WEIGHT.M;
35
- }
36
-
37
- function groupOf(task) {
38
- // Same fallback as groupTasks() in tasks-json-parser.js
39
- return task.group || 'backend';
40
- }
41
-
42
- /**
43
- * Normalize a planned output path for collision comparison: forward slashes,
44
- * collapsed repeated slashes, no leading `./` or `/`, no trailing slash,
45
- * lowercased (Windows/macOS filesystems are case-insensitive). Returns '' for
46
- * empty/nullish input. `..`/`.` segments are left as-is on purpose (resolving
47
- * them would risk false-positives; two genuinely different relative spellings
48
- * stay distinct).
49
- */
50
- export function normalizeOutputPath(p) {
51
- return String(p ?? '')
52
- .trim()
53
- .replace(/\\/g, '/')
54
- .replace(/\/{2,}/g, '/')
55
- .replace(/^\.\//, '')
56
- .replace(/^\/+/, '')
57
- .replace(/\/+$/, '')
58
- .toLowerCase();
59
- }
60
-
61
- /**
62
- * Two normalized paths "overlap" if they are equal, or one is a directory
63
- * ancestor of the other — boundary-aware so `src/a` contains `src/a/b` but
64
- * NOT `src/ab`. Empty paths never overlap.
65
- */
66
- export function pathsOverlap(a, b) {
67
- if (!a || !b) return false;
68
- if (a === b) return true;
69
- const [shorter, longer] = a.length <= b.length ? [a, b] : [b, a];
70
- return longer.startsWith(`${shorter}/`);
71
- }
72
-
73
- /**
74
- * Decide whether a parallel batch's units would write to overlapping paths,
75
- * and therefore whether each agent should run in its own git worktree
76
- * (`isolation: "worktree"`, per morph-apply §3b-w) instead of the shared
77
- * working tree.
78
- *
79
- * A *unit* is the set of task ids a single agent owns (one flow, or one batch
80
- * root). Tasks WITHIN a unit run serially under that agent, so their outputs
81
- * never race — only CROSS-unit overlaps matter. The recommendation is global
82
- * per batch: any collision → isolate the whole batch. Units whose tasks
83
- * declare no `outputs` are an *unknown footprint* — they do NOT force
84
- * isolation (fail-open) but are flagged so the caller knows the dag can't
85
- * prove the batch is collision-free.
86
- *
87
- * @param {Array<{ key: string, taskIds: string[] }>} units
88
- * @param {Map<string, object>} tasksById
89
- * @returns {{ recommended: 'none'|'worktree', collisions: Array<{path: string, units: string[], tasks: string[]}>, unknownFootprint: string[], reason: string }}
90
- */
91
- export function buildIsolation(units, tasksById) {
92
- const unknownFootprint = [];
93
- const unitOutputs = units.map(unit => {
94
- const outs = [];
95
- for (const id of unit.taskIds) {
96
- const task = tasksById.get(id);
97
- const declared = Array.isArray(task?.outputs) ? task.outputs : [];
98
- let validForTask = 0;
99
- for (const raw of declared) {
100
- const path = normalizeOutputPath(raw);
101
- if (path) { outs.push({ path, task: id }); validForTask += 1; }
102
- }
103
- // No usable output paths (none declared, or all normalize to '') → the
104
- // dag can't prove this task's footprint. Flag it (fail-open).
105
- if (validForTask === 0) unknownFootprint.push(id);
106
- }
107
- return { key: unit.key, outs };
108
- });
109
-
110
- const collisions = [];
111
- for (let i = 0; i < unitOutputs.length; i++) {
112
- for (let j = i + 1; j < unitOutputs.length; j++) {
113
- for (const a of unitOutputs[i].outs) {
114
- for (const b of unitOutputs[j].outs) {
115
- if (pathsOverlap(a.path, b.path)) {
116
- collisions.push({
117
- path: a.path === b.path ? a.path : `${a.path} ∩ ${b.path}`,
118
- units: [unitOutputs[i].key, unitOutputs[j].key],
119
- tasks: [a.task, b.task],
120
- });
121
- }
122
- }
123
- }
124
- }
125
- }
126
-
127
- const recommended = collisions.length > 0 ? 'worktree' : 'none';
128
- let reason;
129
- if (collisions.length > 0) {
130
- reason =
131
- `${collisions.length} colisão(ões) de outputs entre unidades paralelas — ` +
132
- `isole cada agente num worktree (morph-apply §3b-w)`;
133
- } else if (unknownFootprint.length > 0) {
134
- reason =
135
- `sem colisão detectada, mas ${unknownFootprint.length} task(s) paralela(s) sem ` +
136
- `outputs declarados (footprint desconhecido) — o dag não garante ausência de colisão`;
137
- } else {
138
- reason = 'nenhuma colisão de outputs entre unidades paralelas — batch na working tree compartilhada';
139
- }
140
-
141
- return { recommended, collisions, unknownFootprint, reason };
142
- }
143
-
144
- /**
145
- * Ids of tasks whose dependency chain crosses group boundaries — integration
146
- * tasks and everything downstream of them. These never belong to a flow: they
147
- * run in the sequential lane after the parallel batch converges.
148
- */
149
- function crossGroupTaskIds(tasks) {
150
- const byId = new Map(tasks.map(t => [t.id, t]));
151
- const cross = new Set();
152
-
153
- for (const task of tasks) {
154
- for (const dep of task.dependencies) {
155
- const depTask = byId.get(dep);
156
- if (depTask && groupOf(depTask) !== groupOf(task)) {
157
- cross.add(task.id);
158
- break;
159
- }
160
- }
161
- }
162
-
163
- // Propagate downstream: a task depending (transitively) on a cross-group
164
- // task cannot run inside a single-group flow either.
165
- let changed = true;
166
- while (changed) {
167
- changed = false;
168
- for (const task of tasks) {
169
- if (cross.has(task.id)) continue;
170
- if (task.dependencies.some(dep => cross.has(dep))) {
171
- cross.add(task.id);
172
- changed = true;
173
- }
174
- }
175
- }
176
-
177
- return cross;
178
- }
179
-
180
- function buildStats(tasks, pending) {
181
- const groups = {};
182
- const weights = {};
183
- for (const task of pending) {
184
- const g = groupOf(task);
185
- groups[g] = (groups[g] || 0) + 1;
186
- weights[g] = (weights[g] || 0) + weightOf(task);
187
- }
188
- return { total: tasks.length, pending: pending.length, groups, weights };
189
- }
190
-
191
- /**
192
- * Decide how to execute the remaining tasks of a parsed tasks.json document.
193
- *
194
- * @param {object} data - parsed tasks.json ({ version, feature, tasks[] })
195
- * @returns {{
196
- * mode: 'sequential' | 'parallel-flows' | 'parallel-roots',
197
- * reason: string,
198
- * stats: { total: number, pending: number, groups: object, weights: object },
199
- * flows: Array<{ group: string, taskIds: string[], weight: number }>,
200
- * batch: string[],
201
- * inline: string[],
202
- * sequentialRemainder: string[],
203
- * isolation: { recommended: 'none'|'worktree', collisions: Array<{path: string, units: string[], tasks: string[]}>, unknownFootprint: string[], reason: string }
204
- * }}
205
- */
206
- export function planDispatch(data) {
207
- const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
208
- const pending = tasks.filter(t => t.status === 'pending');
209
- const stats = buildStats(tasks, pending);
210
- const empty = { flows: [], batch: [], inline: [] };
211
- const tasksById = new Map(tasks.map(t => [t.id, t]));
212
- // A batch with no parallel units can't have concurrent-write collisions.
213
- const noIsolation = buildIsolation([], tasksById);
214
-
215
- if (pending.length === 0) {
216
- return {
217
- mode: 'sequential',
218
- reason: 'nenhuma task pendente — nada a despachar',
219
- stats, ...empty, sequentialRemainder: [], isolation: noIsolation,
220
- };
221
- }
222
-
223
- // Rule 1 — tiny plans never pay for dispatch.
224
- if (tasks.length <= 3) {
225
- return {
226
- mode: 'sequential',
227
- reason: `${tasks.length} task(s) no total (<= 3) — overhead de Agent dispatch não compensa`,
228
- stats, ...empty, sequentialRemainder: pending.map(t => t.id), isolation: noIsolation,
229
- };
230
- }
231
-
232
- // Rule 2 — independent domain flows (backend ‖ frontend …).
233
- const cross = crossGroupTaskIds(tasks);
234
- const flowsMap = new Map();
235
- for (const task of pending) {
236
- if (cross.has(task.id)) continue;
237
- const g = groupOf(task);
238
- if (!flowsMap.has(g)) flowsMap.set(g, { group: g, taskIds: [], weight: 0 });
239
- const flow = flowsMap.get(g);
240
- flow.taskIds.push(task.id);
241
- flow.weight += weightOf(task);
242
- }
243
- const allFlows = [...flowsMap.values()];
244
- const worthyFlows = allFlows
245
- .filter(f => f.weight >= FLOW_DISPATCH_MIN_WEIGHT)
246
- .sort((a, b) => b.weight - a.weight);
247
- const lightFlows = allFlows.filter(f => f.weight < FLOW_DISPATCH_MIN_WEIGHT);
248
-
249
- if (worthyFlows.length >= 2) {
250
- const flows = worthyFlows.slice(0, MAX_PARALLEL_AGENTS);
251
- const overflow = worthyFlows.slice(MAX_PARALLEL_AGENTS);
252
- const inline = [
253
- ...lightFlows.flatMap(f => f.taskIds),
254
- ...overflow.flatMap(f => f.taskIds),
255
- ];
256
- const sequentialRemainder = pending
257
- .filter(t => cross.has(t.id))
258
- .map(t => t.id);
259
-
260
- const flowDesc = flows.map(f => `${f.group}:${f.taskIds.length} (peso ${f.weight})`).join(' ‖ ');
261
- return {
262
- mode: 'parallel-flows',
263
- reason:
264
- `${flows.length} fluxos de domínio independentes com peso suficiente — ${flowDesc} → ` +
265
- `1 agente por fluxo` +
266
- (inline.length ? `; ${inline.length} task(s) leve(s) inline (sem dispatch próprio)` : '') +
267
- (sequentialRemainder.length ? `; integração cross-group sequencial no fim (${sequentialRemainder.join(', ')})` : ''),
268
- stats, flows, batch: [], inline, sequentialRemainder,
269
- // Units = the dispatched flows (one agent each). Inline/overflow/cross-
270
- // group remainder run in the sequential lane, never concurrently.
271
- isolation: buildIsolation(
272
- flows.map(f => ({ key: f.group, taskIds: f.taskIds })),
273
- tasksById,
274
- ),
275
- };
276
- }
277
-
278
- // Rule 3 — root fan-out, weighted by effort (S roots never earn an agent).
279
- const roots = pending.filter(t => t.dependencies.length === 0);
280
- const weightedRoots = roots
281
- .filter(t => weightOf(t) >= ROOT_DISPATCH_MIN_WEIGHT)
282
- .sort((a, b) => weightOf(b) - weightOf(a));
283
- const sRoots = roots.filter(t => weightOf(t) < ROOT_DISPATCH_MIN_WEIGHT);
284
-
285
- if (weightedRoots.length >= 3) {
286
- const batch = weightedRoots.slice(0, MAX_PARALLEL_AGENTS).map(t => t.id);
287
- const inline = sRoots.map(t => t.id);
288
- const sequentialRemainder = pending
289
- .filter(t => !batch.includes(t.id) && !inline.includes(t.id))
290
- .map(t => t.id);
291
-
292
- return {
293
- mode: 'parallel-roots',
294
- reason:
295
- `${weightedRoots.length} raízes independentes com effort M+ → batch paralelo (${batch.join(', ')})` +
296
- (inline.length ? `; raízes S inline — não ganham dispatch próprio (${inline.join(', ')})` : '') +
297
- `; restante sequencial`,
298
- stats, flows: [], batch, inline, sequentialRemainder,
299
- // Units = each batch root (one agent each); inline S-roots and the
300
- // sequential remainder don't run concurrently with the batch.
301
- isolation: buildIsolation(
302
- batch.map(id => ({ key: id, taskIds: [id] })),
303
- tasksById,
304
- ),
305
- };
306
- }
307
-
308
- // Rule 4 — sequential.
309
- const reason = roots.length >= 3
310
- ? `${roots.length} raízes independentes, mas apenas ${weightedRoots.length} com effort M+ ` +
311
- `(raízes S não ganham dispatch próprio — overhead > trabalho) → sequencial com auto-advance`
312
- : 'sem fluxos ou raízes independentes suficientes → sequencial com auto-advance';
313
-
314
- return {
315
- mode: 'sequential',
316
- reason,
317
- stats, ...empty, sequentialRemainder: pending.map(t => t.id), isolation: noIsolation,
318
- };
319
- }
@@ -1,132 +0,0 @@
1
- /**
2
- * mermaid-renderer.js — pure renderer: tasks.json DAG -> Mermaid flowchart TD.
3
- *
4
- * Backs `morph-spec dag <feature> --mermaid` (S8-T1), so the plan's DAG can be
5
- * visualized at Gate 2 (IndyDevDan rec. 2: draw the workflow in Mermaid before
6
- * automating it).
7
- *
8
- * STRICT Mermaid rules enforced here (see project CLAUDE.md — violating these
9
- * breaks rendering in Obsidian):
10
- * - every node label wrapped in double quotes, no exceptions
11
- * - labels never start with a numeric-with-dot prefix ("1. ", "2. ...") —
12
- * Obsidian's markdown-list parser chokes on it even inside quotes
13
- * - `&` inside labels -> `e`
14
- * - line breaks inside labels -> `<br/>`, never a literal `\n`
15
- * - double quotes inside titles are stripped so they can't break out of the
16
- * surrounding label quotes
17
- *
18
- * Pure module: no fs, no Date.now()/Math.random(). Tasks are sorted by
19
- * numeric id so output is stable regardless of input order.
20
- */
21
-
22
- const MAX_TITLE_LENGTH = 40;
23
-
24
- const VALID_STATUS_CLASSES = ['done', 'in_progress', 'blocked', 'skipped', 'pending'];
25
-
26
- // Web-safe fill/stroke pairs, one per task status.
27
- const CLASS_DEFS = [
28
- 'classDef done fill:#c6f6d5,stroke:#2f855a,color:#22543d',
29
- 'classDef in_progress fill:#bee3f8,stroke:#2b6cb0,color:#2a4365',
30
- 'classDef blocked fill:#feb2b2,stroke:#c53030,color:#63171b',
31
- 'classDef skipped fill:#e2e8f0,stroke:#718096,color:#2d3748',
32
- 'classDef pending fill:#edf2f7,stroke:#a0aec0,color:#2d3748',
33
- ];
34
-
35
- function numericId(id) {
36
- const match = /^T(\d+)$/.exec(id ?? '');
37
- return match ? parseInt(match[1], 10) : Number.POSITIVE_INFINITY;
38
- }
39
-
40
- /**
41
- * Sanitize arbitrary text for embedding inside a double-quoted Mermaid node
42
- * label, per the project's strict Mermaid rules.
43
- *
44
- * - double quotes stripped (they would close the label's own quotes)
45
- * - `&` -> `e`
46
- * - truncated to MAX_TITLE_LENGTH chars + `…`
47
- * - line breaks -> `<br/>`
48
- *
49
- * Ordering matters: truncation runs on the raw text BEFORE newlines expand to
50
- * `<br/>`. Newline->`<br/>` is the only length-changing substitution (1 char ->
51
- * 5); truncating after it could slice a tag mid-way ("...<b…"), so we truncate
52
- * first — every emitted `<br/>` is then guaranteed whole. The char-for-char
53
- * substitutions (`"`->`'`, `&`->`e`) don't move the truncation boundary.
54
- */
55
- export function sanitizeLabel(text) {
56
- let out = String(text ?? '');
57
- out = out.replace(/"/g, "'");
58
- out = out.replace(/&/g, 'e');
59
- if (out.length > MAX_TITLE_LENGTH) {
60
- out = `${out.slice(0, MAX_TITLE_LENGTH)}…`;
61
- }
62
- out = out.replace(/\r\n|\r|\n/g, '<br/>');
63
- return out;
64
- }
65
-
66
- /**
67
- * Same fallback as groupOf() in dispatch-planner.js / groupTasks() in
68
- * tasks-json-parser.js — tasks without a `group` field are treated as
69
- * 'backend'.
70
- */
71
- function groupOf(task) {
72
- return task.group || 'backend';
73
- }
74
-
75
- function statusClass(status) {
76
- return VALID_STATUS_CLASSES.includes(status) ? status : 'pending';
77
- }
78
-
79
- /**
80
- * Render a parsed tasks.json document as a Mermaid `flowchart TD`.
81
- *
82
- * @param {{ tasks?: Array<{ id: string, title: string, dependencies?: string[], effort?: string, group?: string, status?: string }> }} tasksData
83
- * @returns {string}
84
- */
85
- export function renderMermaidFlowchart(tasksData) {
86
- const tasks = Array.isArray(tasksData?.tasks) ? tasksData.tasks : [];
87
-
88
- if (tasks.length === 0) {
89
- return ['flowchart TD', ' empty["sem tasks"]'].join('\n');
90
- }
91
-
92
- const sorted = [...tasks].sort((a, b) => numericId(a.id) - numericId(b.id));
93
- const knownIds = new Set(sorted.map(t => t.id));
94
-
95
- // Group tasks by `group`, preserving the deterministic sorted-by-id order
96
- // in which each group is first encountered.
97
- const groups = new Map();
98
- for (const task of sorted) {
99
- const g = groupOf(task);
100
- if (!groups.has(g)) groups.set(g, []);
101
- groups.get(g).push(task);
102
- }
103
-
104
- const lines = ['flowchart TD'];
105
-
106
- for (const [group, groupTasks] of groups) {
107
- lines.push(` subgraph ${group}["${sanitizeLabel(group)}"]`);
108
- for (const task of groupTasks) {
109
- const label = `${task.id} · ${sanitizeLabel(task.title)} (${task.effort})`;
110
- lines.push(` ${task.id}["${label}"]`);
111
- }
112
- lines.push(' end');
113
- }
114
-
115
- // Edges — one per dependency. Dependencies pointing at a non-existent id
116
- // are silently skipped (never throw on a dangling reference).
117
- for (const task of sorted) {
118
- const deps = Array.isArray(task.dependencies) ? task.dependencies : [];
119
- for (const dep of deps) {
120
- if (!knownIds.has(dep)) continue;
121
- lines.push(` ${dep} --> ${task.id}`);
122
- }
123
- }
124
-
125
- lines.push(...CLASS_DEFS);
126
-
127
- for (const task of sorted) {
128
- lines.push(` class ${task.id} ${statusClass(task.status)}`);
129
- }
130
-
131
- return lines.join('\n');
132
- }