@polymorphism-tech/morph-spec 4.12.0 → 4.14.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 (316) hide show
  1. package/CLAUDE.md +7 -7
  2. package/README.md +28 -34
  3. package/bin/morph-spec.js +41 -24
  4. package/bin/task-manager.js +962 -1341
  5. package/bin/validate.js +1 -1
  6. package/claude-plugin.json +1 -1
  7. package/docs/CHEATSHEET.md +14 -16
  8. package/docs/QUICKSTART.md +15 -15
  9. package/framework/CLAUDE.md +5 -2
  10. package/framework/agents/README.md +3 -4
  11. package/framework/agents/ai-agents/ai-system-architect.md +6 -10
  12. package/framework/agents/architecture/seo-growth-hacker.md +166 -326
  13. package/framework/agents/architecture/standards-architect.md +21 -31
  14. package/framework/agents/backend/dotnet-senior.md +160 -86
  15. package/framework/agents/backend/ef-modeler.md +2 -6
  16. package/framework/agents/backend/hangfire-orchestrator.md +8 -9
  17. package/framework/agents/backend/ms-agent-expert.md +1 -1
  18. package/framework/agents/frontend/nextjs-expert.md +198 -133
  19. package/framework/agents/frontend/ui-ux-designer.md +226 -267
  20. package/framework/agents/infrastructure/infra-architect.md +191 -27
  21. package/framework/agents/integrations/clerk-auth.md +19 -26
  22. package/framework/agents/integrations/hangfire-integration.md +1 -4
  23. package/framework/agents.json +1084 -2278
  24. package/framework/commands/morph-apply.md +4 -6
  25. package/framework/commands/morph-preflight.md +192 -269
  26. package/framework/commands/morph-proposal.md +210 -238
  27. package/framework/commands/morph-status.md +72 -92
  28. package/framework/commands/morph-troubleshoot.md +6 -13
  29. package/framework/hooks/README.md +1 -6
  30. package/framework/hooks/claude-code/post-tool-use/validator-feedback.js +4 -6
  31. package/framework/hooks/claude-code/pre-compact/save-morph-context.js +12 -7
  32. package/framework/hooks/claude-code/pre-tool-use/enforce-phase-writes.js +2 -2
  33. package/framework/hooks/claude-code/pre-tool-use/protect-spec-files.js +2 -2
  34. package/framework/hooks/claude-code/pre-tool-use/task-tracking-guard.js +5 -10
  35. package/framework/hooks/claude-code/session-start/inject-morph-context.js +63 -30
  36. package/framework/hooks/claude-code/session-start/resume-context.js +77 -0
  37. package/framework/hooks/claude-code/statusline.py +4 -5
  38. package/framework/hooks/claude-code/stop/validate-completion.js +4 -4
  39. package/framework/hooks/claude-code/user-prompt/enrich-prompt.js +8 -8
  40. package/framework/hooks/shared/compact-restore.js +28 -5
  41. package/framework/hooks/shared/phase-utils.js +4 -8
  42. package/framework/hooks/shared/state-reader.js +34 -3
  43. package/framework/phases.json +162 -200
  44. package/framework/rules/ai-agents-standards.md +15 -0
  45. package/framework/rules/asaas-standards.md +13 -0
  46. package/framework/rules/clerk-standards.md +13 -0
  47. package/framework/rules/csharp-standards.md +8 -4
  48. package/framework/rules/data-standards.md +18 -0
  49. package/framework/rules/dotnet-templates.md +87 -0
  50. package/framework/rules/frontend-standards.md +13 -13
  51. package/framework/rules/hangfire-standards.md +13 -0
  52. package/framework/rules/infrastructure-standards.md +27 -16
  53. package/framework/rules/morph-workflow.md +73 -13
  54. package/framework/rules/nextjs-standards.md +1 -0
  55. package/framework/rules/nextjs-templates.md +40 -0
  56. package/framework/rules/resend-standards.md +12 -0
  57. package/framework/rules/supabase-standards.md +29 -0
  58. package/framework/skills/README.md +6 -8
  59. package/framework/skills/level-0-meta/mcp-registry.json +21 -99
  60. package/framework/{standards/ai-agents/team-orchestration.md → skills/level-0-meta/morph-agent-teams/SKILL.md} +472 -479
  61. package/framework/skills/level-0-meta/morph-brainstorming/SKILL.md +28 -1
  62. package/framework/skills/level-0-meta/morph-checklist/SKILL.md +2 -2
  63. package/framework/skills/level-0-meta/morph-code-review/SKILL.md +1 -5
  64. package/framework/skills/level-0-meta/morph-code-review/scripts/scan-csharp.mjs +1 -1
  65. package/framework/skills/level-0-meta/morph-frontend-review/SKILL.md +23 -41
  66. package/framework/skills/level-0-meta/morph-init/SKILL.md +18 -14
  67. package/framework/skills/level-0-meta/morph-parallel-dispatch/SKILL.md +225 -0
  68. package/framework/skills/level-0-meta/morph-post-implementation/SKILL.md +20 -0
  69. package/framework/skills/level-0-meta/morph-tool-usage-guide/SKILL.md +4 -8
  70. package/framework/skills/level-0-meta/morph-tool-usage-guide/references/tools-per-phase.md +15 -59
  71. package/framework/skills/level-0-meta/morph-verification-before-completion/SKILL.md +7 -19
  72. package/framework/skills/level-0-meta/morph-verification-before-completion/scripts/check-phase-outputs.mjs +8 -8
  73. package/framework/skills/level-1-workflows/README.md +1 -1
  74. package/framework/skills/level-1-workflows/morph-phase-codebase-analysis/SKILL.md +49 -50
  75. package/framework/skills/level-1-workflows/morph-phase-design/SKILL.md +57 -123
  76. package/framework/skills/level-1-workflows/morph-phase-design/references/architecture-analysis-guide.md +5 -50
  77. package/framework/skills/level-1-workflows/morph-phase-design/references/spec-example.md +1 -1
  78. package/framework/skills/level-1-workflows/morph-phase-implement/SKILL.md +127 -159
  79. package/framework/skills/level-1-workflows/morph-phase-implement/prompts/code-quality-reviewer-prompt.md +1 -1
  80. package/framework/skills/level-1-workflows/morph-phase-implement/prompts/implementer-prompt.md +4 -3
  81. package/framework/skills/level-1-workflows/morph-phase-implement/prompts/spec-reviewer-prompt.md +2 -2
  82. package/framework/skills/level-1-workflows/morph-phase-implement/references/recap-example.md +1 -1
  83. package/framework/skills/level-1-workflows/morph-phase-plan/SKILL.md +17 -13
  84. package/framework/skills/level-1-workflows/morph-phase-tasks/SKILL.md +22 -40
  85. package/framework/skills/level-1-workflows/morph-phase-tasks/references/task-planning-patterns.md +143 -172
  86. package/framework/skills/level-1-workflows/morph-phase-tasks/references/tasks-example.md +5 -5
  87. package/framework/skills/level-1-workflows/morph-phase-uiux/SKILL.md +57 -77
  88. package/framework/skills/level-1-workflows/morph-scope-escalation/SKILL.md +1 -1
  89. package/framework/squad-templates/backend-only.json +6 -6
  90. package/framework/squad-templates/frontend-only.json +17 -8
  91. package/framework/squad-templates/full-stack.json +29 -24
  92. package/framework/standards/STANDARDS.json +1021 -2369
  93. package/framework/standards/backend/dotnet/fluent-validation-vsa.md +200 -0
  94. package/framework/standards/backend/dotnet/result-pattern.md +213 -0
  95. package/framework/standards/backend/dotnet/scalar-ui.md +172 -0
  96. package/framework/standards/backend/dotnet/scrutor.md +158 -0
  97. package/framework/standards/backend/dotnet/vsa-handler-patterns.md +274 -0
  98. package/framework/standards/frontend/nextjs/motion-patterns.md +1 -1
  99. package/framework/standards/infrastructure/supabase/mcp-setup.md +83 -193
  100. package/framework/standards/integration/mcp/mcp-tools.md +91 -77
  101. package/framework/templates/IDE-SNIPPETS.md +2 -6
  102. package/framework/templates/README.md +709 -814
  103. package/framework/templates/REGISTRY.json +223 -762
  104. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -0
  105. package/framework/templates/code/dotnet/abstractions/error.cs +60 -0
  106. package/framework/templates/code/dotnet/abstractions/handler.cs +12 -0
  107. package/framework/templates/code/dotnet/abstractions/result.cs +74 -0
  108. package/framework/templates/code/dotnet/backend/repository.cs +148 -141
  109. package/framework/templates/code/dotnet/constants/api-tags.cs +6 -0
  110. package/framework/templates/code/dotnet/exceptions/exception-handler.cs +32 -0
  111. package/framework/templates/code/dotnet/extensions/database-extensions.cs +20 -0
  112. package/framework/templates/code/dotnet/extensions/handler-registration.cs +38 -0
  113. package/framework/templates/code/dotnet/extensions/health-checks.cs +24 -0
  114. package/framework/templates/code/dotnet/extensions/map-endpoints.cs +35 -0
  115. package/framework/templates/code/dotnet/extensions/result-extensions.cs +29 -0
  116. package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +21 -0
  117. package/framework/templates/code/dotnet/feature/entity.cs.hbs +8 -0
  118. package/framework/templates/code/dotnet/feature/errors.cs.hbs +8 -0
  119. package/framework/templates/code/dotnet/feature/handler.cs.hbs +24 -0
  120. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -0
  121. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -0
  122. package/framework/templates/code/dotnet/feature/validator.cs.hbs +15 -0
  123. package/framework/templates/code/dotnet/jobs/job.cs +21 -17
  124. package/framework/templates/code/dotnet/pipelines/logging-decorator.cs +31 -0
  125. package/framework/templates/code/dotnet/pipelines/validation-decorator.cs +97 -0
  126. package/framework/templates/code/dotnet/test.cs +192 -106
  127. package/framework/templates/context/ARCHITECTURE.md +89 -0
  128. package/framework/templates/context/CONTEXT-FEATURE.md +2 -2
  129. package/framework/templates/context/CONTEXT.md +180 -181
  130. package/framework/templates/context/design-system.md +1 -5
  131. package/framework/templates/docs/onboarding.md +2 -3
  132. package/framework/templates/docs/proposal.md +171 -182
  133. package/framework/templates/docs/spec.md +1 -3
  134. package/framework/templates/docs/ui-components.md +0 -72
  135. package/framework/templates/examples/design-system-examples.md +4 -4
  136. package/framework/templates/feature/decisions.md +0 -66
  137. package/framework/templates/feature/recap.md +1 -2
  138. package/framework/templates/infrastructure/github/README.md +56 -593
  139. package/framework/templates/integrations/clerk-config.cs +0 -41
  140. package/framework/templates/meta-prompts/parallel-workers/parallel-worker.md +2 -2
  141. package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +1 -2
  142. package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +83 -126
  143. package/framework/templates/meta-prompts/validators/pre-commit-validator.md +2 -2
  144. package/framework/templates/project/validate.js +2 -4
  145. package/framework/templates/ui/design-system.css +1 -1
  146. package/framework/workflows/README.md +8 -8
  147. package/framework/workflows/configs/fast-track.json +51 -0
  148. package/framework/workflows/configs/full.json +68 -0
  149. package/framework/workflows/configs/standard.json +7 -3
  150. package/framework/workflows/docs/STORY-DRIVEN-DEVELOPMENT.md +1 -1
  151. package/framework/workflows/docs/enforcement-pipeline.md +18 -19
  152. package/framework/workflows/docs/full-morph.md +69 -76
  153. package/framework/workflows/docs/standard.md +49 -44
  154. package/framework/workflows/docs/ui-refresh.md +1 -1
  155. package/package.json +3 -3
  156. package/src/commands/agents/dispatch-agents.js +55 -116
  157. package/src/commands/context/context-refresh.js +79 -0
  158. package/src/commands/phase/phase-reset.js +1 -1
  159. package/src/commands/project/doctor.js +10 -32
  160. package/src/commands/project/init.js +12 -0
  161. package/src/commands/project/status.js +6 -12
  162. package/src/commands/scaffold/scaffold.js +131 -0
  163. package/src/commands/scope/escalate.js +1 -1
  164. package/src/commands/state/advance-phase.js +15 -29
  165. package/src/commands/state/state.js +20 -5
  166. package/src/commands/tasks/task.js +0 -30
  167. package/src/commands/validation/validate-feature.js +3 -10
  168. package/src/core/paths/output-schema.js +12 -13
  169. package/src/core/state/phase-state-machine.js +211 -217
  170. package/src/core/state/state-manager.js +9 -9
  171. package/src/lib/agents/agents-manager.js +2 -2
  172. package/src/lib/agents/output-collector.js +1 -2
  173. package/src/lib/detectors/design-system-detector.js +4 -4
  174. package/src/lib/detectors/index.js +1 -11
  175. package/src/lib/generators/architecture-scanner.js +640 -0
  176. package/src/lib/generators/context-generator.js +3 -3
  177. package/src/lib/generators/design-system-generator.js +508 -587
  178. package/src/lib/generators/index.js +12 -10
  179. package/src/lib/generators/metadata-extractor.js +386 -387
  180. package/src/lib/generators/recap-generator.js +4 -4
  181. package/src/lib/generators/scaffold-generator.js +317 -0
  182. package/src/lib/generators/settings-generator.js +0 -1
  183. package/src/lib/phase-chain/phase-validator.js +64 -42
  184. package/src/lib/stack/stack-profile.js +2 -2
  185. package/src/lib/standards/digest-builder.js +72 -285
  186. package/src/lib/tasks/task-parser.js +11 -5
  187. package/src/lib/validators/architecture/architecture-validator.js +259 -387
  188. package/src/lib/validators/content/content-validator.js +287 -287
  189. package/src/lib/validators/css/css-validator.js +340 -352
  190. package/src/lib/validators/packages/package-validator.js +347 -360
  191. package/src/lib/validators/shared/issue-counter.js +14 -18
  192. package/src/lib/validators/validation-runner.js +3 -19
  193. package/src/scripts/setup-infra.js +1 -1
  194. package/src/utils/agents-installer.js +61 -10
  195. package/src/utils/file-copier.js +1 -0
  196. package/src/utils/hooks-installer.js +11 -19
  197. package/src/utils/skills-installer.js +178 -178
  198. package/framework/agents/architecture/prompt-engineer.md +0 -195
  199. package/framework/agents/backend/api-designer.md +0 -106
  200. package/framework/agents/frontend/blazor-builder.md +0 -229
  201. package/framework/agents/frontend/nextjs-ui-specialist.md +0 -218
  202. package/framework/agents/infrastructure/azure-architect.md +0 -150
  203. package/framework/agents/infrastructure/azure-deploy-specialist.md +0 -707
  204. package/framework/agents/infrastructure/bicep-architect.md +0 -132
  205. package/framework/agents/infrastructure/container-specialist.md +0 -137
  206. package/framework/agents/infrastructure/devops-engineer.md +0 -126
  207. package/framework/agents/integrations/azure-identity.md +0 -148
  208. package/framework/agents/quality/code-analyzer.md +0 -239
  209. package/framework/agents/quality/testing-specialist.md +0 -130
  210. package/framework/hooks/claude-code/notification/approval-reminder.js +0 -57
  211. package/framework/hooks/claude-code/post-tool-use/context-refresh.js +0 -109
  212. package/framework/hooks/claude-code/post-tool-use/skill-reminder.js +0 -78
  213. package/framework/index/troubleshooting-index.json +0 -184
  214. package/framework/rules/testing-standards.md +0 -14
  215. package/framework/skills/level-0-meta/morph-replicate/SKILL.md +0 -230
  216. package/framework/skills/level-0-meta/morph-replicate/references/blazor-html-mapping.md +0 -70
  217. package/framework/skills/level-1-workflows/morph-phase-clarify/SKILL.md +0 -256
  218. package/framework/skills/level-1-workflows/morph-phase-clarify/references/clarifications-example.md +0 -117
  219. package/framework/skills/level-1-workflows/morph-phase-setup/SKILL.md +0 -269
  220. package/framework/standards/ai-agents/blazor-ui.md +0 -364
  221. package/framework/standards/architecture/ddd/aggregates.md +0 -120
  222. package/framework/standards/architecture/ddd/bounded-contexts.md +0 -105
  223. package/framework/standards/architecture/ddd/complexity-levels.md +0 -108
  224. package/framework/standards/architecture/ddd/entities.md +0 -99
  225. package/framework/standards/architecture/ddd/ubiquitous-language.md +0 -58
  226. package/framework/standards/architecture/ddd/value-objects.md +0 -124
  227. package/framework/standards/backend/api/minimal-api.md +0 -494
  228. package/framework/standards/backend/api/rest.md +0 -492
  229. package/framework/standards/backend/authentication/passkeys.md +0 -428
  230. package/framework/standards/backend/database/ef-core.md +0 -199
  231. package/framework/standards/backend/database/migrations.md +0 -393
  232. package/framework/standards/backend/database/repository-patterns.md +0 -528
  233. package/framework/standards/backend/dotnet/async.md +0 -366
  234. package/framework/standards/backend/dotnet/core.md +0 -117
  235. package/framework/standards/backend/dotnet/di.md +0 -439
  236. package/framework/standards/backend/dotnet/program-cs-checklist.md +0 -92
  237. package/framework/standards/context/analytics.md +0 -96
  238. package/framework/standards/context/bundles.md +0 -110
  239. package/framework/standards/context/priming.md +0 -78
  240. package/framework/standards/core/architecture.md +0 -185
  241. package/framework/standards/core/coding.md +0 -214
  242. package/framework/standards/core/git-branching-strategy.md +0 -403
  243. package/framework/standards/core/git.md +0 -185
  244. package/framework/standards/core/testing.md +0 -295
  245. package/framework/standards/data/nosql/blob-storage.md +0 -102
  246. package/framework/standards/data/vector-search/azure-ai-search.md +0 -121
  247. package/framework/standards/frontend/blazor/design-checklist.md +0 -222
  248. package/framework/standards/frontend/blazor/fluent-ui-setup.md +0 -595
  249. package/framework/standards/frontend/blazor/fluent-ui.md +0 -137
  250. package/framework/standards/frontend/blazor/html-conversion.md +0 -184
  251. package/framework/standards/frontend/blazor/lifecycle.md +0 -195
  252. package/framework/standards/frontend/blazor/pitfalls.md +0 -198
  253. package/framework/standards/frontend/blazor/state.md +0 -191
  254. package/framework/standards/frontend/design-system/aesthetic-direction.md +0 -266
  255. package/framework/standards/frontend/design-system/design-trends.md +0 -171
  256. package/framework/standards/infrastructure/azure/azure.md +0 -624
  257. package/framework/standards/infrastructure/azure/bicep/bicep-patterns.md +0 -422
  258. package/framework/standards/infrastructure/azure/devops/azure-devops-setup.md +0 -516
  259. package/framework/standards/infrastructure/azure/devops/local-development.md +0 -520
  260. package/framework/standards/infrastructure/azure/services/functions.md +0 -486
  261. package/framework/standards/infrastructure/azure/services/service-bus.md +0 -459
  262. package/framework/standards/infrastructure/azure/services/storage.md +0 -407
  263. package/framework/standards/infrastructure/vercel/vercel-database.md +0 -106
  264. package/framework/standards/integration/api/graphql.md +0 -91
  265. package/framework/standards/integration/api/grpc.md +0 -114
  266. package/framework/standards/integration/api/rest-design.md +0 -95
  267. package/framework/standards/integration/event-driven/cqrs.md +0 -101
  268. package/framework/standards/integration/event-driven/event-sourcing.md +0 -124
  269. package/framework/standards/integration/event-driven/service-bus.md +0 -95
  270. package/framework/standards/observability/logging.md +0 -131
  271. package/framework/standards/observability/metrics.md +0 -121
  272. package/framework/standards/observability/monitoring.md +0 -114
  273. package/framework/standards/observability/tracing.md +0 -132
  274. package/framework/standards/workflows/parallel-execution.md +0 -112
  275. package/framework/standards/workflows/thread-management.md +0 -113
  276. package/framework/templates/code/dotnet/backend/service.cs +0 -139
  277. package/framework/templates/code/dotnet/contracts/Commands.cs +0 -74
  278. package/framework/templates/code/dotnet/contracts/Entities.cs +0 -25
  279. package/framework/templates/code/dotnet/contracts/Queries.cs +0 -74
  280. package/framework/templates/code/dotnet/contracts/api-contracts.cs +0 -173
  281. package/framework/templates/code/dotnet/contracts/contracts-level1.cs +0 -69
  282. package/framework/templates/code/dotnet/contracts/contracts-level2.cs +0 -86
  283. package/framework/templates/code/dotnet/contracts/contracts-level3.cs +0 -41
  284. package/framework/templates/code/dotnet/frontend/component.razor +0 -239
  285. package/framework/templates/infrastructure/azure/Dockerfile.example +0 -82
  286. package/framework/templates/infrastructure/azure/README.md +0 -286
  287. package/framework/templates/infrastructure/azure/app-insights.bicep +0 -63
  288. package/framework/templates/infrastructure/azure/app-service.bicep +0 -164
  289. package/framework/templates/infrastructure/azure/container-app-env.bicep +0 -49
  290. package/framework/templates/infrastructure/azure/container-app.bicep +0 -156
  291. package/framework/templates/infrastructure/azure/deploy-checklist.md +0 -426
  292. package/framework/templates/infrastructure/azure/deploy.ps1 +0 -229
  293. package/framework/templates/infrastructure/azure/deploy.sh +0 -208
  294. package/framework/templates/infrastructure/azure/key-vault.bicep +0 -91
  295. package/framework/templates/infrastructure/azure/main.bicep +0 -189
  296. package/framework/templates/infrastructure/azure/parameters.dev.json +0 -29
  297. package/framework/templates/infrastructure/azure/parameters.prod.json +0 -29
  298. package/framework/templates/infrastructure/azure/parameters.staging.json +0 -29
  299. package/framework/templates/infrastructure/azure/sql-database.bicep +0 -103
  300. package/framework/templates/infrastructure/azure/storage.bicep +0 -106
  301. package/framework/templates/infrastructure/github/actions/azure-auth/action.yml.hbs +0 -22
  302. package/framework/templates/infrastructure/github/workflows/deploy-azure-app-service.yml.hbs +0 -61
  303. package/framework/templates/integrations/azure-identity-config.cs +0 -288
  304. package/framework/templates/project-structure/dotnet-ddd.md +0 -70
  305. package/framework/templates/saas/subscription.cs +0 -347
  306. package/framework/templates/saas/tenant.cs +0 -338
  307. package/framework/templates/ui/FluentDesignTheme.cs +0 -149
  308. package/framework/templates/ui/MudTheme.cs +0 -281
  309. package/framework/workflows/configs/nodejs-cli.json +0 -40
  310. package/src/commands/standards/context-budget.js +0 -89
  311. package/src/lib/standards/index.js +0 -7
  312. package/src/lib/standards/standards-context-injector.js +0 -302
  313. package/src/lib/validators/blazor/blazor-concurrency-analyzer.js +0 -316
  314. package/src/lib/validators/blazor/blazor-state-validator.js +0 -279
  315. package/src/lib/validators/blazor/blazor-validator.js +0 -369
  316. package/src/lib/validators/blazor/index.js +0 -9
@@ -1,1341 +1,962 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * MORPH-SPEC Task Manager
5
- *
6
- * Manages task completion, dependencies, checkpoints, and progress tracking.
7
- * Part of MORPH-SPEC 3.0 - Event-driven state management.
8
- */
9
-
10
- import { fileURLToPath } from 'url';
11
- import { join, dirname } from 'path';
12
- import { access } from 'fs/promises';
13
- import { execSync } from 'child_process';
14
- import chalk from 'chalk';
15
-
16
- import { loadState, saveState, resolveAgentForTask, updateDispatchAgentTiming, derivePhase } from '../src/core/state/state-manager.js';
17
- import { parseTasksMd, ensureTaskList, syncCounters } from '../src/lib/tasks/task-parser.js';
18
- import { isTestTask } from '../src/lib/tasks/task-classifier.js';
19
- import { runTestSuite } from '../src/lib/tasks/test-runner.js';
20
- import { STALE_MS, isStaleTask } from '../framework/hooks/shared/stale-task-reset.js';
21
-
22
- const __filename = fileURLToPath(import.meta.url);
23
- const __dirname = dirname(__filename);
24
-
25
- // ============================================================================
26
- // Breaking-Change Detection
27
- // ============================================================================
28
-
29
- /**
30
- * Detect potentially broken consumers of recently removed exports.
31
- *
32
- * Strategy:
33
- * 1. Parse `git diff HEAD` (staged + unstaged) for removed export declarations.
34
- * 2. Use `git grep` to find files that still reference each removed symbol.
35
- * 3. Return warnings (non-blocking) so Claude Code can review before marking done.
36
- *
37
- * Returns null when git is unavailable, not in a repo, or no removed exports found.
38
- * Returns [] when removed exports exist but no consumers found.
39
- * Returns [{export, consumers[]}] when consumers are found.
40
- *
41
- * @returns {Promise<Array<{export: string, consumers: string[]}>|null>}
42
- */
43
- export async function detectBreakingChanges(opts = {}) {
44
- try {
45
- const exec = opts._execSync ?? execSync;
46
- const cwd = opts._cwd ?? process.cwd();
47
- const execOpts = { cwd, stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 5 * 1024 * 1024 };
48
-
49
- // Collect diff from staged + unstaged changes in source files
50
- let diff = '';
51
- const diffPatterns = ['*.ts', '*.tsx', '*.js', '*.jsx', '*.cs'];
52
- for (const flag of ['--cached', '']) {
53
- try {
54
- const args = ['git', 'diff', '--unified=0', flag, '--', ...diffPatterns].filter(Boolean);
55
- diff += exec(args.join(' '), execOpts).toString();
56
- } catch {
57
- // git not available or no changes — continue
58
- }
59
- }
60
-
61
- if (!diff.trim()) return null;
62
-
63
- // Extract source files touched by the diff
64
- const sourceFiles = new Set();
65
- const diffHeaderRe = /^diff --git a\/(.+?) b\//gm;
66
- let m;
67
- while ((m = diffHeaderRe.exec(diff)) !== null) sourceFiles.add(m[1]);
68
-
69
- // Extract removed export symbol names (lines starting with -)
70
- const removedExports = new Set();
71
- const exportRe = /^-\s*export\s+(?:(?:default\s+)?(?:async\s+)?function|const|let|class|interface|type|enum)\s+(\w+)/gm;
72
- let match;
73
- while ((match = exportRe.exec(diff)) !== null) {
74
- removedExports.add(match[1]);
75
- }
76
-
77
- if (removedExports.size === 0) return null;
78
-
79
- // For each removed symbol, check if any tracked file still imports/uses it
80
- const warnings = [];
81
- const defaultExcludes = [':!.morph/archive/', ':!.morph/framework/', ':!node_modules/'];
82
- const pathspecs = [...diffPatterns, ...defaultExcludes];
83
-
84
- for (const sym of removedExports) {
85
- try {
86
- const grepCmd = ['git', 'grep', '-l', `"${sym}"`, '--', ...pathspecs].join(' ');
87
- const result = exec(grepCmd, execOpts).toString().trim();
88
- if (result) {
89
- const consumers = result.split('\n').filter(Boolean).filter(f => !sourceFiles.has(f));
90
- if (consumers.length > 0) {
91
- warnings.push({ export: sym, consumers });
92
- }
93
- }
94
- } catch {
95
- // No match found — safe
96
- }
97
- }
98
-
99
- return warnings;
100
- } catch {
101
- return null; // Non-blocking: any unexpected error → skip detection
102
- }
103
- }
104
-
105
- // ============================================================================
106
- // TaskManager
107
- // ============================================================================
108
-
109
- class TaskManager {
110
- /**
111
- * Complete one or more tasks (runs validation first)
112
- * @param {string} featureName - Feature name
113
- * @param {string[]} taskIds - Task IDs to complete
114
- * @param {Object} options - Options
115
- * @param {boolean} options.skipValidation - Skip code validation (budget-limited)
116
- * @param {string} [options.reason] - Reason for skipping validation (required with skipValidation)
117
- * @param {string} [options.note] - Deviation note explaining why task was done differently than planned
118
- * @param {boolean} options.dryRun - Dry run (no state changes)
119
- * @param {Function} [options._runTestSuite] - Inject a mock runTestSuite for testing
120
- */
121
- async completeTasks(featureName, taskIds, options = {}) {
122
- const state = loadState();
123
- const feature = state.features[featureName];
124
-
125
- if (!feature) {
126
- throw new Error(`Feature '${featureName}' not found in state.json`);
127
- }
128
-
129
- const taskList = await ensureTaskList(feature, featureName);
130
-
131
- if (taskList.length === 0) {
132
- const tasksPath = join(process.cwd(), `.morph/features/${featureName}/4-tasks/tasks.md`);
133
- const tasksExist = await access(tasksPath).then(() => true).catch(() => false);
134
- if (!tasksExist) {
135
- throw new Error(`No tasks found for '${featureName}' — tasks.md not generated yet.\n Complete the tasks phase first: run /phase-tasks`);
136
- }
137
- throw new Error(`tasks.md found but no tasks could be parsed for '${featureName}'.\n Ensure tasks use the format: ### T001 — Title or ### T001: Title`);
138
- }
139
-
140
- // Persist initial taskList so counters are visible in `state get` even if
141
- // validation fails later and the completion save never runs.
142
- syncCounters(feature);
143
- feature.updatedAt = new Date().toISOString();
144
- saveState(state);
145
-
146
- const results = [];
147
- const tasksToComplete = [];
148
-
149
- for (const taskId of taskIds) {
150
- const task = taskList.find(t => t.id === taskId);
151
-
152
- if (!task) {
153
- console.error(chalk.red(`❌ Task ${taskId} not found (available: ${taskList.map(t => t.id).join(', ')})`));
154
- continue;
155
- }
156
-
157
- if (task.status === 'completed') {
158
- console.log(chalk.yellow(`⚠️ Task ${taskId} already completed`));
159
- continue;
160
- }
161
-
162
- // Validate dependencies
163
- const missingDeps = this.checkDependencies(task, taskList);
164
- if (missingDeps.length > 0) {
165
- console.error(chalk.red(`❌ Cannot complete ${taskId}: missing dependencies: ${missingDeps.join(', ')}`));
166
- continue;
167
- }
168
-
169
- tasksToComplete.push(task);
170
- }
171
-
172
- // ─── Test Task Guard ────────────────────────────────────────────────────────
173
- // When any of the tasks being completed is a test/validation task (e.g.
174
- // "Testes e Validação"), run the project's test suite BEFORE code validation.
175
- // The guard is skipped when --skip-validation or --dry-run is active.
176
- runTestGuard(tasksToComplete, process.cwd(), options);
177
-
178
- // ─── Skip-Validation Budget Enforcement ───────────────────────────────────
179
- if (options.skipValidation && tasksToComplete.length > 0 && !options.dryRun) {
180
- // Require --reason with minimum 5 characters
181
- if (!options.reason || options.reason.trim().length < 5) {
182
- console.error(chalk.red('❌ --skip-validation requires --reason <text> (minimum 5 characters)'));
183
- console.error(chalk.gray(' Example: morph-spec task done feat T001 --skip-validation --reason "CSS-only change, no logic"'));
184
- process.exit(1);
185
- }
186
-
187
- // Check budget
188
- const budget = computeSkipBudget(feature);
189
- const used = (feature.skipValidationLog || []).length;
190
- if (used >= budget) {
191
- console.error(chalk.red(`❌ Skip-validation budget exhausted (${used}/${budget})`));
192
- console.error(chalk.gray(' All remaining tasks must pass Tier-4 validation.'));
193
- console.error(chalk.gray(' Budget formula: min(5, ceil(totalTasks × 0.20))'));
194
- process.exit(1);
195
- }
196
-
197
- console.log(chalk.yellow(`⚠ --skip-validation: using budget (${used + tasksToComplete.length}/${budget})`));
198
- }
199
-
200
- // Run validation BEFORE marking tasks as complete
201
- if (tasksToComplete.length > 0 && !options.skipValidation) {
202
- const validationResult = await this.runValidation(featureName, { mode: 'light' });
203
- if (options.dryRun) {
204
- console.log(chalk.cyan('\n ℹ️ Dry-run — tasks NOT marked as complete'));
205
- return [];
206
- }
207
- if (!validationResult.passed) {
208
- // Reload state to ensure we have the latest (may have been modified by validation)
209
- const currentState = loadState();
210
- const currentFeature = currentState.features[featureName];
211
- if (currentFeature) {
212
- for (const task of tasksToComplete) {
213
- this.persistValidationHistory(currentFeature, task.id, validationResult);
214
- }
215
- saveState(currentState);
216
-
217
- // Check escalation status
218
- const blockedTasks = tasksToComplete.filter(t => {
219
- const hist = currentFeature.validationHistory?.[t.id];
220
- return hist?.status === 'blocked';
221
- });
222
- if (blockedTasks.length > 0) {
223
- console.error(chalk.red(`\n⛔ ESCALATION — Task(s) ${blockedTasks.map(t => t.id).join(', ')} have failed 3 times`));
224
- console.error(chalk.red(' Human review required. Mark resolved with: morph-spec state set <feature> validationHistory.<taskId>.status passed'));
225
- } else {
226
- const attempt = currentFeature.validationHistory?.[tasksToComplete[0]?.id]?.attempt || 1;
227
- console.error(chalk.red(`\n❌ Validation failed (attempt ${attempt}/3) — tasks NOT marked as complete`));
228
- console.log(chalk.gray(' Fix the issues above, then run task done again'));
229
- if (attempt >= 2) {
230
- console.log(chalk.yellow(` ⚠️ Next failure will escalate to human review`));
231
- }
232
- }
233
- } else {
234
- console.error(chalk.red('\n❌ Validation failed — tasks NOT marked as complete'));
235
- console.log(chalk.gray(' Fix the issues above, then run task done again'));
236
- }
237
- console.log(chalk.gray(' Or use --skip-validation to bypass (not recommended)\n'));
238
- process.exit(1);
239
- }
240
-
241
- // Validation passed — always record passed status in validationHistory.
242
- // Update in-place on `feature` (same object as state.features[featureName]) so
243
- // the single saveState(state) at the end of the function persists everything
244
- // atomically. A separate saveState here would trigger a second renameSync on
245
- // Windows, risking EPERM and leaving state.json without the task completions
246
- // (the count bug), and would also be clobbered by the final save anyway.
247
- if (!feature.validationHistory) feature.validationHistory = {};
248
- for (const task of tasksToComplete) {
249
- feature.validationHistory[task.id] = {
250
- ...(feature.validationHistory[task.id] || {}),
251
- status: 'passed',
252
- updatedAt: new Date().toISOString(),
253
- };
254
- }
255
- } else if (options.dryRun) {
256
- console.log(chalk.cyan('\n ℹ️ Dry-run — tasks NOT marked as complete (validation skipped)'));
257
- return [];
258
- } else if (shouldLogSkipValidationWarning(tasksToComplete, options.skipValidation, options.dryRun)) {
259
- // Record skip in state: skipValidationLog + validationHistory
260
- if (!feature.skipValidationLog) feature.skipValidationLog = [];
261
- if (!feature.validationHistory) feature.validationHistory = {};
262
- for (const task of tasksToComplete) {
263
- feature.skipValidationLog.push({
264
- taskId: task.id,
265
- reason: options.reason,
266
- timestamp: new Date().toISOString(),
267
- });
268
- feature.validationHistory[task.id] = {
269
- ...(feature.validationHistory[task.id] || {}),
270
- attempt: 0,
271
- status: 'skipped',
272
- reason: options.reason,
273
- updatedAt: new Date().toISOString(),
274
- };
275
- }
276
-
277
- console.log(chalk.yellow('\n⚠ --skip-validation: Tier-4 code validators bypassed — tasks will be marked complete without validation'));
278
- console.log(chalk.gray(' Re-run without --skip-validation before committing.\n'));
279
- }
280
-
281
- // Breaking change detection (non-blocking warning)
282
- if (tasksToComplete.length > 0) {
283
- const _detectBreaking = options._detectBreakingChanges ?? detectBreakingChanges;
284
- const breakingChanges = await _detectBreaking();
285
- if (breakingChanges && breakingChanges.length > 0) {
286
- console.log(chalk.yellow('\n⚠️ BREAKING CHANGE DETECTION:'));
287
- console.log(chalk.yellow(' Removed exports with active consumers found — review before completing:\n'));
288
- for (const { export: sym, consumers } of breakingChanges) {
289
- console.log(chalk.yellow(` • "${sym}" removed — used by:`));
290
- consumers.slice(0, 5).forEach(f => console.log(chalk.gray(` - ${f}`)));
291
- if (consumers.length > 5) {
292
- console.log(chalk.gray(` … and ${consumers.length - 5} more files`));
293
- }
294
- }
295
- console.log(chalk.yellow('\n Fix broken imports before the smoke test, or use --skip-validation to bypass.\n'));
296
- }
297
- }
298
-
299
- // Mark tasks as completed (only after validation passes)
300
- // Mark tasks as completed with agent correlation
301
- for (const task of tasksToComplete) {
302
- task.status = 'completed';
303
- task.completedAt = new Date().toISOString();
304
-
305
- // Resolve agent from dispatch (falls back to 'claude')
306
- const agentId = resolveAgentForTask(featureName, task.id);
307
- task.completedBy = agentId || 'claude';
308
-
309
- // Add deviation note if provided
310
- if (options.note) {
311
- if (!task.notes) task.notes = [];
312
- task.notes.push({
313
- text: options.note,
314
- type: 'deviation',
315
- addedAt: new Date().toISOString(),
316
- addedBy: agentId || 'claude'
317
- });
318
- }
319
-
320
- results.push(task);
321
-
322
- console.log(chalk.green(`✅ Task ${task.id} completed!`));
323
- if (options.note) {
324
- console.log(chalk.gray(` Note: ${options.note}`));
325
- }
326
-
327
- // Register checkpoint if task has one
328
- if (task.checkpoint) {
329
- this.registerCheckpoint(feature, task);
330
- }
331
- }
332
-
333
- // Update dispatch agent timing for completed tasks
334
- const agentCompletions = new Map();
335
- for (const task of results) {
336
- const aid = task.completedBy;
337
- if (aid && aid !== 'claude') {
338
- if (!agentCompletions.has(aid)) agentCompletions.set(aid, []);
339
- agentCompletions.get(aid).push(task.id);
340
- }
341
- }
342
- for (const [aid, taskIds] of agentCompletions) {
343
- const lastDispatch = feature.dispatchLog?.[feature.dispatchLog.length - 1];
344
- const dispatchAgent = lastDispatch?.agents?.find(a => a.id === aid);
345
- const existing = dispatchAgent?.tasksCompleted || [];
346
- updateDispatchAgentTiming(featureName, aid, {
347
- completedAt: new Date().toISOString(),
348
- tasksCompleted: [...new Set([...existing, ...taskIds])]
349
- });
350
- }
351
-
352
- // Sync v3 counters then compute progress
353
- syncCounters(feature);
354
- feature.progress = this.calculateProgress(taskList);
355
-
356
- // Auto-checkpoint every 3 tasks (at exact multiples of 3, not after every task).
357
- // Uses feature._lastAutoCheckpointNum to track which threshold already fired.
358
- const completedCount = taskList.filter(t => t.status === 'completed').length;
359
- if (completedCount > 0 && completedCount % 3 === 0) {
360
- const checkpointNum = Math.floor(completedCount / 3);
361
- const lastAutoNum = feature._lastAutoCheckpointNum || 0;
362
- if (checkpointNum > lastAutoNum) {
363
- const recentCompleted = this.getRecentCompleted(taskList, 3);
364
- await this.autoCheckpoint(feature, recentCompleted, featureName);
365
- feature._lastAutoCheckpointNum = checkpointNum;
366
- }
367
- }
368
-
369
- // Persist state (atomic write via state-manager)
370
- feature.updatedAt = new Date().toISOString();
371
- saveState(state);
372
-
373
- // Run TaskCompleted agent-teams hook for each completed task (non-blocking)
374
- for (const task of results) {
375
- try {
376
- const { executeHook, formatHookResults } = await import('../src/lib/hooks/hook-executor.js');
377
- const hookResult = await executeHook(process.cwd(), featureName, 'TaskCompleted', {
378
- taskId: task.id
379
- });
380
- if (!hookResult.passed && hookResult.errors.length > 0) {
381
- console.log(formatHookResults(hookResult, 'TaskCompleted'));
382
- }
383
- } catch {
384
- // Hook executor unavailable — non-blocking
385
- }
386
- }
387
-
388
- // Auto-generate metadata.json (for quick LLM access)
389
- await this.generateMetadata(featureName, feature);
390
-
391
- // Display progress
392
- this.displayProgress(feature);
393
-
394
- // Emit VALIDATION DISPATCH for PostToolUse hook (strategy 1 parsing).
395
- // Skipped when --skip-validation is active (no validators needed).
396
- if (!options.skipValidation && results.length > 0) {
397
- try {
398
- const { emitValidatorDispatch } = await import('../src/lib/validators/shared/emit-validator-dispatch.js');
399
- const phase = feature.phase || 'implement';
400
- await emitValidatorDispatch(featureName, phase, process.cwd());
401
- } catch {
402
- // Non-blocking
403
- }
404
- }
405
-
406
- // Suggest next task(s)
407
- const activeTasks = taskList.filter(t => t.status !== 'expanded');
408
- const allCompleted = activeTasks.length > 0 && activeTasks.every(t => t.status === 'completed');
409
- const readyTasks = this.getReadyTasks(taskList);
410
-
411
- if (allCompleted) {
412
- console.log(chalk.green('\n🎉 All tasks completed!'));
413
- } else if (readyTasks.length > 1) {
414
- const first = readyTasks[0];
415
- console.log(chalk.cyan(`\n⏭️ Next: ${first.id} - ${first.title}`));
416
- if (first.dependencies?.length > 0) {
417
- console.log(chalk.gray(` Dependencies: ${first.dependencies.join(', ')}`));
418
- }
419
- console.log(chalk.magenta(`🔀 Parallelizable (${readyTasks.length} tasks ready): ${readyTasks.map(t => t.id).join(', ')}`));
420
- } else if (readyTasks.length === 1) {
421
- const nextTask = readyTasks[0];
422
- console.log(chalk.cyan(`\n⏭️ Next: ${nextTask.id} - ${nextTask.title}`));
423
- if (nextTask.dependencies?.length > 0) {
424
- console.log(chalk.gray(` Dependencies: ${nextTask.dependencies.join(', ')}`));
425
- }
426
- } else {
427
- const remaining = activeTasks.filter(t => t.status !== 'completed');
428
- console.log(chalk.yellow(`\n⏸️ No tasks available — ${remaining.length} task(s) have unmet dependencies`));
429
- }
430
-
431
- return results;
432
- }
433
-
434
- /**
435
- * Run validation for a feature using the ValidationRunner (ESM dynamic import).
436
- * Returns a structured result with per-validator breakdown for validationHistory.
437
- *
438
- * @param {string} featureName - Feature name
439
- * @returns {{ passed: boolean, validators: Object, passRate: number }}
440
- */
441
- async runValidation(featureName, opts = {}) {
442
- try {
443
- console.log(chalk.cyan('\n🔍 Running code validation...'));
444
-
445
- const { runValidation, formatValidationResults } = await import('../src/lib/validators/validation-runner.js');
446
- const result = await runValidation('.', featureName, { verbose: true, ...opts });
447
-
448
- formatValidationResults(result);
449
-
450
- // Build structured validators map from result
451
- const validators = {};
452
- if (result.results && typeof result.results === 'object') {
453
- for (const [name, vResult] of Object.entries(result.results)) {
454
- const issues = (vResult.errors || []).map(e => ({
455
- message: typeof e === 'string' ? e : (e.message || String(e)),
456
- file: e.file,
457
- line: e.line,
458
- rule: e.rule,
459
- }));
460
- validators[name] = {
461
- passed: vResult.passed ?? (issues.length === 0),
462
- issues,
463
- };
464
- }
465
- } else if (!result.passed && result.errors?.length > 0) {
466
- // Flat format fallback — wrap as single 'validation' validator
467
- validators['validation'] = {
468
- passed: false,
469
- issues: result.errors.map(e => ({
470
- message: typeof e === 'string' ? e : (e.message || String(e)),
471
- file: e.file,
472
- line: e.line,
473
- rule: e.rule || 'validation',
474
- })),
475
- };
476
- }
477
-
478
- const validatorEntries = Object.values(validators);
479
- const passRate = validatorEntries.length > 0
480
- ? validatorEntries.filter(v => v.passed).length / validatorEntries.length
481
- : (result.passed ? 1.0 : 0.0);
482
-
483
- return { passed: result.passed, validators, passRate };
484
- } catch (error) {
485
- // If validation runner fails to load, warn but don't block task completion.
486
- // This is fail-open by design: a broken validator shouldn't block commits.
487
- // Common cause: missing optional deps.
488
- console.log(chalk.yellow(`\n⚠️ Validation skipped (${error.message})`));
489
- console.log(chalk.gray(` Run manually: morph-spec validate-feature ${featureName}`));
490
- return { passed: true, validators: {}, passRate: 1.0 };
491
- }
492
- }
493
-
494
- /**
495
- * Persist a validation result to feature.validationHistory[taskId].
496
- * Increments attempt counter and sets status to 'failed' or 'blocked' (attempt >= 3).
497
- *
498
- * @param {Object} feature - Mutable feature state object
499
- * @param {string} taskId - Task ID
500
- * @param {{ passed: boolean, validators: Object, passRate: number }} validationResult
501
- */
502
- persistValidationHistory(feature, taskId, validationResult) {
503
- if (!feature.validationHistory) {
504
- feature.validationHistory = {};
505
- }
506
- const existing = feature.validationHistory[taskId] || { attempt: 0 };
507
- const attempt = (existing.attempt || 0) + 1;
508
- const status = attempt >= 3 ? 'blocked' : 'failed';
509
-
510
- feature.validationHistory[taskId] = {
511
- attempt,
512
- validators: validationResult.validators || {},
513
- passRate: validationResult.passRate || 0,
514
- status,
515
- updatedAt: new Date().toISOString(),
516
- };
517
- }
518
-
519
- /**
520
- * Check if task dependencies are met
521
- */
522
- checkDependencies(task, allTasks) {
523
- if (!task.dependencies || task.dependencies.length === 0) {
524
- return [];
525
- }
526
-
527
- return task.dependencies.filter(depId => {
528
- const dep = allTasks.find(t => t.id === depId);
529
- return !dep || dep.status !== 'completed';
530
- });
531
- }
532
-
533
- /**
534
- * Calculate progress
535
- */
536
- calculateProgress(tasks) {
537
- // Exclude expanded tasks — they are replaced by their sub-tasks
538
- const activeTasks = tasks.filter(t => t.status !== 'expanded');
539
- const total = activeTasks.length;
540
- const completed = activeTasks.filter(t => t.status === 'completed').length;
541
- const inProgress = activeTasks.filter(t => t.status === 'in_progress').length;
542
- const pending = activeTasks.filter(t => t.status === 'pending').length;
543
- const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
544
-
545
- return { total, completed, inProgress, pending, percentage };
546
- }
547
-
548
- /**
549
- * Register checkpoint (from task flag)
550
- */
551
- registerCheckpoint(feature, task) {
552
- const checkpoint = {
553
- id: task.checkpoint,
554
- timestamp: new Date().toISOString(),
555
- tasksCompleted: [task.id],
556
- note: `Checkpoint: ${task.title}`,
557
- passed: true // reaching a named checkpoint means the task completed successfully
558
- };
559
-
560
- feature.checkpoints = feature.checkpoints || [];
561
- feature.checkpoints.push(checkpoint);
562
-
563
- console.log(chalk.magenta(`\n🎯 ${task.checkpoint} reached!`));
564
- }
565
-
566
- /**
567
- * Auto-checkpoint every 3 tasks (includes validation summary)
568
- */
569
- async autoCheckpoint(feature, tasks, featureName) {
570
- const tasksCompleted = feature.tasks?.completed || tasks.length;
571
- const checkpointNum = Math.floor(tasksCompleted / 3);
572
-
573
- console.log(chalk.magenta(`\n🔍 CHECKPOINT ${checkpointNum} - Running automated validation...`));
574
-
575
- let checkpointResult = null;
576
- let validationNote = '';
577
-
578
- try {
579
- const { runCheckpointHooks, shouldRunCheckpoint } = await import('../src/lib/checkpoints/checkpoint-hooks.js');
580
-
581
- if (shouldRunCheckpoint(tasksCompleted, 3)) {
582
- checkpointResult = await runCheckpointHooks(featureName, checkpointNum);
583
-
584
- if (checkpointResult.passed) {
585
- validationNote = ' | ✓ All validations passed';
586
- } else {
587
- validationNote = ` | ✗ ${checkpointResult.summary.errors} errors, ${checkpointResult.summary.warnings} warnings`;
588
-
589
- const config = await this.loadCheckpointConfig();
590
- if (config.checkpoints?.onFailure?.blockProgress && checkpointResult.summary.errors > 0) {
591
- console.log(chalk.red('\n❌ Checkpoint FAILED - Fix violations before proceeding'));
592
- throw new Error('Checkpoint validation failed');
593
- }
594
- }
595
- }
596
- } catch (error) {
597
- if (error.message === 'Checkpoint validation failed') {
598
- throw error;
599
- }
600
- // Fallback to old validation if checkpoint-hooks not available
601
- console.log(chalk.yellow('⚠️ Checkpoint hooks not available, using legacy validation'));
602
- try {
603
- const { runValidation } = await import('../src/lib/validators/validation-runner.js');
604
- const result = await runValidation('.', featureName, { verbose: false, mode: 'full' });
605
- validationNote = result.passed
606
- ? ' | Validation: PASSED'
607
- : ` | Validation: ${result.errors} errors, ${result.warnings} warnings`;
608
-
609
- // Persist checkpoint validation results to disk
610
- try {
611
- const { persistCheckpointValidation } = await import('../src/lib/validators/checkpoint-persistence.js');
612
- persistCheckpointValidation(process.cwd(), featureName, {
613
- checkpointNum,
614
- timestamp: new Date().toISOString(),
615
- passed: result.passed,
616
- validators: result.results || {},
617
- summary: result.summary || '',
618
- });
619
- } catch {
620
- // Non-blocking
621
- }
622
- } catch {
623
- // No validation available
624
- }
625
- }
626
-
627
- const checkpoint = {
628
- id: `CHECKPOINT_AUTO_${Date.now()}`,
629
- timestamp: new Date().toISOString(),
630
- tasksCompleted: tasks.map(t => t.id),
631
- note: `Auto-checkpoint: ${tasks.length} tasks completed${validationNote}`,
632
- passed: checkpointResult?.passed ?? true, // null checkpointResult means no validator ran → assume passed
633
- validationResults: checkpointResult?.results || null
634
- };
635
-
636
- feature.checkpoints = feature.checkpoints || [];
637
- feature.checkpoints.push(checkpoint);
638
-
639
- if (checkpointResult?.passed) {
640
- console.log(chalk.green(`\n✅ Checkpoint ${checkpointNum} PASSED`));
641
- } else if (checkpointResult) {
642
- console.log(chalk.yellow(`\n⚠️ Checkpoint ${checkpointNum} completed with warnings`));
643
- } else {
644
- console.log(chalk.magenta(`\n🎉 Auto-checkpoint ${checkpointNum} reached!`));
645
- }
646
- }
647
-
648
- /**
649
- * Load checkpoint configuration from llm-interaction.json
650
- */
651
- async loadCheckpointConfig() {
652
- try {
653
- const { readFile } = await import('fs/promises');
654
- const configPath = join(process.cwd(), '.morph/config/llm-interaction.json');
655
- const content = await readFile(configPath, 'utf-8');
656
- return JSON.parse(content);
657
- } catch {
658
- return {
659
- checkpoints: {
660
- frequency: 3,
661
- autoValidate: true,
662
- onFailure: { blockProgress: false }
663
- }
664
- };
665
- }
666
- }
667
-
668
- /**
669
- * Auto-generate metadata.json after task completion
670
- */
671
- async generateMetadata(featureName, feature) {
672
- try {
673
- const config = await this.loadCheckpointConfig();
674
-
675
- if (!config.metadata?.autoGenerate) {
676
- return;
677
- }
678
-
679
- const { extractFeatureMetadata } = await import('../src/lib/generators/metadata-extractor.js');
680
- const metadata = extractFeatureMetadata(feature);
681
-
682
- const outputPath = join(process.cwd(), `.morph/features/${featureName}/metadata.json`);
683
-
684
- const { mkdir, writeFile } = await import('fs/promises');
685
- await mkdir(dirname(outputPath), { recursive: true });
686
- await writeFile(outputPath, JSON.stringify(metadata, null, 2), 'utf-8');
687
-
688
- console.log(chalk.gray(` 📊 Metadata updated: .morph/features/${featureName}/metadata.json`));
689
- } catch (error) {
690
- console.log(chalk.yellow(` ⚠️ Metadata generation failed: ${error.message}`));
691
- }
692
- }
693
-
694
- /**
695
- * Get recently completed tasks (last N)
696
- */
697
- getRecentCompleted(tasks, count) {
698
- const completed = tasks
699
- .filter(t => t.status === 'completed' && t.completedAt)
700
- .sort((a, b) => new Date(b.completedAt) - new Date(a.completedAt));
701
-
702
- return completed.slice(0, count);
703
- }
704
-
705
- /**
706
- * Get next available task (based on dependencies).
707
- * Stale in_progress tasks (older than 1 hour, or missing startedAt) are
708
- * treated as effectively pending and included as candidates.
709
- */
710
- getNextTask(tasks) {
711
- // Include stale in_progress tasks as effectively pending candidates
712
- const isEffectivelyAvailable = (t) => {
713
- if (t.status === 'expanded') return false;
714
- if (t.status === 'pending') return true;
715
- if (t.status === 'in_progress') return isStaleTask(t);
716
- return false;
717
- };
718
-
719
- const candidates = tasks.filter(isEffectivelyAvailable);
720
-
721
- for (const task of candidates) {
722
- const missingDeps = this.checkDependencies(task, tasks);
723
- if (missingDeps.length === 0) {
724
- return task;
725
- }
726
- }
727
-
728
- return null;
729
- }
730
-
731
- /**
732
- * Get ALL tasks that are currently ready (no unmet dependencies).
733
- */
734
- getReadyTasks(tasks) {
735
- const isEffectivelyAvailable = (t) => {
736
- if (t.status === 'expanded') return false;
737
- if (t.status === 'pending') return true;
738
- if (t.status === 'in_progress') return isStaleTask(t);
739
- return false;
740
- };
741
-
742
- return tasks.filter(t =>
743
- isEffectivelyAvailable(t) && this.checkDependencies(t, tasks).length === 0
744
- );
745
- }
746
-
747
- /**
748
- * Display progress
749
- */
750
- displayProgress(feature) {
751
- const { total, completed, inProgress, pending, percentage } = feature.progress;
752
-
753
- console.log(chalk.bold(`\n📊 Progress: ${percentage}% (${completed}/${total})`));
754
- console.log(chalk.gray(` Completed: ${completed} | In Progress: ${inProgress} | Pending: ${pending}`));
755
-
756
- const barLength = 30;
757
- const filledLength = Math.round((percentage / 100) * barLength);
758
- const bar = '█'.repeat(filledLength) + '░'.repeat(barLength - filledLength);
759
- console.log(chalk.cyan(` [${bar}] ${percentage}%`));
760
-
761
- // Warn about tasks flagged for review (from scope escalation)
762
- const taskList = feature.taskList || [];
763
- const needsReviewTasks = taskList.filter(t => t.needsReview && (t.status === 'done' || t.status === 'completed'));
764
- if (needsReviewTasks.length > 0) {
765
- console.log(chalk.yellow(`\n ⚠ ${needsReviewTasks.length} completed task(s) flagged for review (scope escalation)`));
766
- }
767
- }
768
-
769
- /**
770
- * Start a task (mark as in_progress)
771
- */
772
- async startTask(featureName, taskId) {
773
- const state = loadState();
774
- const feature = state.features[featureName];
775
-
776
- if (!feature) {
777
- throw new Error(`Feature '${featureName}' not found`);
778
- }
779
-
780
- // Dispatch enforcement gate: block task start in implement phase
781
- // unless dispatch-agents has been run (recorded in dispatchLog)
782
- const featurePath = join(process.cwd(), '.morph', 'features', featureName);
783
- const currentPhase = feature.phase || derivePhase(featurePath);
784
- if (currentPhase === 'implement') {
785
- const hasImplementDispatch = Array.isArray(feature.dispatchLog) &&
786
- feature.dispatchLog.some(entry => entry.phase === 'implement');
787
- if (!hasImplementDispatch) {
788
- console.error(chalk.red('\n✗ Cannot start task — agent dispatch not recorded for implement phase'));
789
- console.error(chalk.yellow(
790
- `\n Run this command first:\n` +
791
- ` morph-spec dispatch-agents ${featureName} implement\n\n` +
792
- ` This configures typed specialist agents with enriched prompts,\n` +
793
- ` task assignments, and quality standards for parallel execution.\n`
794
- ));
795
- process.exit(1);
796
- }
797
- }
798
-
799
- const taskList = await ensureTaskList(feature, featureName);
800
-
801
- if (taskList.length === 0) {
802
- const tasksPath = join(process.cwd(), `.morph/features/${featureName}/4-tasks/tasks.md`);
803
- const tasksExist = await access(tasksPath).then(() => true).catch(() => false);
804
- if (!tasksExist) {
805
- throw new Error(`No tasks found for '${featureName}' — tasks.md not generated yet.\n Complete the tasks phase first: run /phase-tasks`);
806
- }
807
- throw new Error(`tasks.md found but no tasks could be parsed for '${featureName}'.\n Ensure tasks use the format: ### T001 — Title or ### T001: Title`);
808
- }
809
-
810
- const task = taskList.find(t => t.id === taskId);
811
-
812
- if (!task) {
813
- throw new Error(`Task ${taskId} not found in '${featureName}' (${taskList.length} tasks available: ${taskList.map(t => t.id).join(', ')})`);
814
- }
815
-
816
- if (task.status === 'completed') {
817
- console.log(chalk.yellow(`⚠️ Task ${taskId} already completed`));
818
- return;
819
- }
820
-
821
- const missingDeps = this.checkDependencies(task, taskList);
822
- if (missingDeps.length > 0) {
823
- throw new Error(`Cannot start ${taskId}: missing dependencies: ${missingDeps.join(', ')}`);
824
- }
825
-
826
- // Reset any sibling tasks stuck in in_progress from a previous session.
827
- // Only tasks OTHER than the one being started, and only if stale (>1h).
828
- for (const t of taskList) {
829
- if (t.id !== taskId && isStaleTask(t)) {
830
- t.status = 'pending';
831
- delete t.startedAt;
832
- console.log(chalk.yellow(`⚠️ Task ${t.id} auto-reset to pending (stale from previous session)`));
833
- }
834
- }
835
-
836
- task.status = 'in_progress';
837
- task.startedAt = new Date().toISOString();
838
-
839
- // Agent correlation: resolve which agent is assigned this task from the latest dispatch
840
- const agentId = resolveAgentForTask(featureName, taskId);
841
- if (agentId) {
842
- task.assignedAgent = agentId;
843
- // Record agent startedAt only for the first task (don't overwrite)
844
- const lastDispatch = feature.dispatchLog?.[feature.dispatchLog.length - 1];
845
- const dispatchAgent = lastDispatch?.agents?.find(a => a.id === agentId);
846
- if (dispatchAgent && !dispatchAgent.startedAt) {
847
- updateDispatchAgentTiming(featureName, agentId, { startedAt: task.startedAt });
848
- }
849
- }
850
-
851
- syncCounters(feature);
852
- feature.updatedAt = new Date().toISOString();
853
-
854
- saveState(state);
855
-
856
- console.log(chalk.blue(`▶️ Task ${taskId} started: ${task.title}`));
857
- }
858
-
859
- /**
860
- * Bulk-start tasks (mark as in_progress in a single operation).
861
- * @param {string} featureName
862
- * @param {Object} opts
863
- * @param {boolean} [opts.all] - Start all pending tasks with met dependencies
864
- * @param {string} [opts.from] - Start of range (e.g. T001)
865
- * @param {string} [opts.to] - End of range (e.g. T053)
866
- * @param {string} [opts.range] - Compact range string (e.g. T001..T082)
867
- */
868
- async bulkStartTasks(featureName, opts = {}) {
869
- const state = loadState();
870
- const feature = state.features[featureName];
871
- if (!feature) throw new Error(`Feature '${featureName}' not found in state.json`);
872
-
873
- // Dispatch enforcement gate (same as startTask)
874
- const featurePath = join(process.cwd(), '.morph', 'features', featureName);
875
- const currentPhase = feature.phase || derivePhase(featurePath);
876
- if (currentPhase === 'implement') {
877
- const hasImplementDispatch = Array.isArray(feature.dispatchLog) &&
878
- feature.dispatchLog.some(entry => entry.phase === 'implement');
879
- if (!hasImplementDispatch) {
880
- console.error(chalk.red('\n✗ Cannot start tasks — agent dispatch not recorded for implement phase'));
881
- console.error(chalk.yellow(
882
- `\n Run this command first:\n` +
883
- ` morph-spec dispatch-agents ${featureName} implement\n`
884
- ));
885
- process.exit(1);
886
- }
887
- }
888
-
889
- const taskList = await ensureTaskList(feature, featureName);
890
- if (taskList.length === 0) throw new Error(`No tasks found for '${featureName}'`);
891
-
892
- // Resolve target IDs from range options
893
- let targetIds;
894
- if (opts.all) {
895
- targetIds = taskList.filter(t => t.status === 'pending').map(t => t.id);
896
- } else if (opts.from && opts.to) {
897
- targetIds = this.expandRange(taskList, opts.from, opts.to);
898
- } else if (opts.range && opts.range.includes('..')) {
899
- const [from, to] = opts.range.split('..');
900
- targetIds = this.expandRange(taskList, from.trim(), to.trim());
901
- } else {
902
- throw new Error('bulk-start requires --all, --from/--to, or a T001..T014 range argument');
903
- }
904
-
905
- if (targetIds.length === 0) {
906
- console.log(chalk.yellow('⚠️ No tasks matched the given range'));
907
- return;
908
- }
909
-
910
- // Reset stale siblings ONCE upfront
911
- for (const t of taskList) {
912
- if (!targetIds.includes(t.id) && isStaleTask(t)) {
913
- t.status = 'pending';
914
- delete t.startedAt;
915
- console.log(chalk.yellow(`⚠️ Task ${t.id} auto-reset to pending (stale from previous session)`));
916
- }
917
- }
918
-
919
- let started = 0;
920
- let skipped = 0;
921
- for (const id of targetIds) {
922
- const task = taskList.find(t => t.id === id);
923
- if (!task) continue;
924
-
925
- if (task.status === 'completed') {
926
- console.log(chalk.gray(` ⏩ ${id} already completed — skipping`));
927
- skipped++;
928
- continue;
929
- }
930
-
931
- if (task.status === 'in_progress' && !isStaleTask(task)) {
932
- console.log(chalk.gray(` ⏩ ${id} already in_progress — skipping`));
933
- skipped++;
934
- continue;
935
- }
936
-
937
- const missingDeps = this.checkDependencies(task, taskList);
938
- if (missingDeps.length > 0) {
939
- console.log(chalk.yellow(` ⚠️ ${id} blocked by: ${missingDeps.join(', ')} — skipping`));
940
- skipped++;
941
- continue;
942
- }
943
-
944
- task.status = 'in_progress';
945
- task.startedAt = new Date().toISOString();
946
-
947
- // Agent correlation
948
- const agentId = resolveAgentForTask(featureName, id);
949
- if (agentId) {
950
- task.assignedAgent = agentId;
951
- const lastDispatch = feature.dispatchLog?.[feature.dispatchLog.length - 1];
952
- const dispatchAgent = lastDispatch?.agents?.find(a => a.id === agentId);
953
- if (dispatchAgent && !dispatchAgent.startedAt) {
954
- updateDispatchAgentTiming(featureName, agentId, { startedAt: task.startedAt });
955
- }
956
- }
957
-
958
- started++;
959
- }
960
-
961
- syncCounters(feature);
962
- feature.updatedAt = new Date().toISOString();
963
- saveState(state);
964
-
965
- console.log(chalk.blue(`\n▶️ Bulk-start: ${started} task(s) started` + (skipped ? `, ${skipped} skipped` : '')));
966
- }
967
-
968
- /**
969
- * Bulk-complete tasks with a single validation pass.
970
- * @param {string} featureName
971
- * @param {Object} opts
972
- * @param {boolean} [opts.all] - Complete all pending tasks
973
- * @param {string} [opts.from] - Start of range (e.g. T001)
974
- * @param {string} [opts.to] - End of range (e.g. T053)
975
- * @param {string} [opts.range] - Compact range string (e.g. T001..T082)
976
- * @param {boolean} [opts.skipValidation]
977
- * @param {boolean} [opts.dryRun]
978
- */
979
- async bulkCompleteTasks(featureName, opts = {}) {
980
- const state = loadState();
981
- const feature = state.features[featureName];
982
- if (!feature) throw new Error(`Feature '${featureName}' not found in state.json`);
983
-
984
- const taskList = await ensureTaskList(feature, featureName);
985
- if (taskList.length === 0) throw new Error(`No tasks found for '${featureName}'`);
986
-
987
- // Resolve task IDs from range options
988
- let targetIds;
989
- if (opts.all) {
990
- targetIds = taskList.filter(t => t.status !== 'completed').map(t => t.id);
991
- } else if (opts.from && opts.to) {
992
- targetIds = this.expandRange(taskList, opts.from, opts.to);
993
- } else if (opts.range && opts.range.includes('..')) {
994
- const [from, to] = opts.range.split('..');
995
- targetIds = this.expandRange(taskList, from.trim(), to.trim());
996
- } else {
997
- throw new Error('bulk-done requires --all, --from/--to, or a T001..T082 range argument');
998
- }
999
-
1000
- console.log(chalk.cyan(`\n📦 Bulk-done: ${targetIds.length} task(s) targeted`));
1001
- await this.completeTasks(featureName, targetIds, { skipValidation: opts.skipValidation, reason: opts.reason, note: opts.note, dryRun: opts.dryRun });
1002
- }
1003
-
1004
- /**
1005
- * Expand a task range like T001..T053 using zero-padded numeric IDs.
1006
- */
1007
- expandRange(taskList, from, to) {
1008
- const numOf = id => parseInt(id.replace(/\D/g, ''), 10);
1009
- const prefix = from.replace(/\d+$/, '');
1010
- const padLen = from.replace(prefix, '').length;
1011
- const fromNum = numOf(from);
1012
- const toNum = numOf(to);
1013
- const ids = [];
1014
- for (let n = fromNum; n <= toNum; n++) {
1015
- ids.push(prefix + String(n).padStart(padLen, '0'));
1016
- }
1017
- return ids.filter(id => taskList.some(t => t.id === id));
1018
- }
1019
-
1020
- /**
1021
- * Get next task suggestion
1022
- */
1023
- async getNext(featureName) {
1024
- const state = loadState();
1025
- const feature = state.features[featureName];
1026
-
1027
- if (!feature) {
1028
- throw new Error(`Feature '${featureName}' not found`);
1029
- }
1030
-
1031
- const taskList = await ensureTaskList(feature, featureName);
1032
- const activeTasks = taskList.filter(t => t.status !== 'expanded');
1033
- const allCompleted = activeTasks.length > 0 && activeTasks.every(t => t.status === 'completed');
1034
- const readyTasks = this.getReadyTasks(taskList);
1035
-
1036
- if (allCompleted) {
1037
- console.log(chalk.green('🎉 All tasks completed!'));
1038
- } else if (readyTasks.length > 1) {
1039
- const first = readyTasks[0];
1040
- console.log(chalk.cyan(`\n⏭️ Next task: ${first.id} - ${first.title}`));
1041
- if (first.dependencies?.length > 0) {
1042
- console.log(chalk.gray(` Dependencies: ${first.dependencies.join(', ')}`));
1043
- }
1044
- if (first.files?.length > 0) {
1045
- console.log(chalk.gray(` Files:`));
1046
- first.files.forEach(f => console.log(chalk.gray(` - ${f}`)));
1047
- }
1048
- this._showDependencyDeviations(first, taskList);
1049
- console.log(chalk.magenta(`🔀 Parallelizable (${readyTasks.length} tasks ready): ${readyTasks.map(t => t.id).join(', ')}`));
1050
- } else if (readyTasks.length === 1) {
1051
- const nextTask = readyTasks[0];
1052
- console.log(chalk.cyan(`\n⏭️ Next task: ${nextTask.id} - ${nextTask.title}`));
1053
- if (nextTask.dependencies?.length > 0) {
1054
- console.log(chalk.gray(` Dependencies: ${nextTask.dependencies.join(', ')}`));
1055
- }
1056
- if (nextTask.files?.length > 0) {
1057
- console.log(chalk.gray(` Files:`));
1058
- nextTask.files.forEach(f => console.log(chalk.gray(` - ${f}`)));
1059
- }
1060
- this._showDependencyDeviations(nextTask, taskList);
1061
- } else {
1062
- const remaining = activeTasks.filter(t => t.status !== 'completed');
1063
- console.log(chalk.yellow(`\n⏸️ No tasks available — ${remaining.length} task(s) have unmet dependencies`));
1064
- }
1065
- }
1066
-
1067
- /**
1068
- * Show deviation notes from completed dependencies (context for next agent)
1069
- * @param {Object} task - The next task
1070
- * @param {Object[]} taskList - Full task list
1071
- */
1072
- _showDependencyDeviations(task, taskList) {
1073
- const deps = task.dependencies || [];
1074
- const completedWithNotes = taskList.filter(t =>
1075
- deps.includes(t.id) && t.status === 'completed' && t.notes?.length > 0
1076
- );
1077
- for (const t of completedWithNotes) {
1078
- for (const n of t.notes.filter(n => n.type === 'deviation')) {
1079
- console.log(chalk.yellow(` ⚠ ${t.id} deviation: ${n.text}`));
1080
- }
1081
- }
1082
- }
1083
-
1084
- /**
1085
- * Add a note to a task (deviation, context, or blocker-resolved)
1086
- * @param {string} featureName - Feature name
1087
- * @param {string} taskId - Task ID
1088
- * @param {string} text - Note text
1089
- * @param {string} [type='deviation'] - Note type: deviation | context | blocker-resolved
1090
- */
1091
- async annotateTask(featureName, taskId, text, type = 'deviation') {
1092
- const validTypes = ['deviation', 'context', 'blocker-resolved'];
1093
- if (!validTypes.includes(type)) {
1094
- throw new Error(`Invalid note type '${type}'. Valid types: ${validTypes.join(', ')}`);
1095
- }
1096
-
1097
- const state = loadState();
1098
- const feature = state.features[featureName];
1099
- if (!feature) throw new Error(`Feature '${featureName}' not found`);
1100
-
1101
- const taskList = await ensureTaskList(feature, featureName);
1102
- const task = taskList.find(t => t.id === taskId);
1103
- if (!task) throw new Error(`Task ${taskId} not found in '${featureName}'`);
1104
-
1105
- if (!task.notes) task.notes = [];
1106
- task.notes.push({
1107
- text,
1108
- type,
1109
- addedAt: new Date().toISOString(),
1110
- addedBy: 'user'
1111
- });
1112
-
1113
- feature.updatedAt = new Date().toISOString();
1114
- saveState(state);
1115
-
1116
- console.log(chalk.green(`Added ${type} note to ${taskId}`));
1117
- console.log(chalk.gray(` "${text}"`));
1118
- }
1119
- }
1120
-
1121
- // ============================================================================
1122
- // CLI entry point
1123
- // ============================================================================
1124
-
1125
- async function main() {
1126
- const args = process.argv.slice(2);
1127
- const command = args[0];
1128
-
1129
- const manager = new TaskManager();
1130
-
1131
- try {
1132
- switch (command) {
1133
- case 'done':
1134
- case 'complete': {
1135
- const skipValidation = args.includes('--skip-validation');
1136
- const dryRun = args.includes('--dry-run');
1137
- const reasonIdx = args.indexOf('--reason');
1138
- const reason = reasonIdx !== -1 ? args[reasonIdx + 1] : null;
1139
- const noteIdx = args.indexOf('--note');
1140
- const note = noteIdx !== -1 ? args[noteIdx + 1] : null;
1141
- const filteredArgs = args.filter((a, i) =>
1142
- !['--skip-validation', '--dry-run', '--reason', '--note'].includes(a) &&
1143
- !(i > 0 && args[i - 1] === '--reason') &&
1144
- !(i > 0 && args[i - 1] === '--note'));
1145
- const featureName = filteredArgs[1];
1146
- const taskIds = filteredArgs.slice(2);
1147
-
1148
- if (!featureName || taskIds.length === 0) {
1149
- console.error(chalk.red('Usage: morph-spec task done <feature> <task-id> [task-id...] [--note <text>] [--skip-validation] [--reason <text>] [--dry-run]'));
1150
- process.exit(1);
1151
- }
1152
-
1153
- await manager.completeTasks(featureName, taskIds, { skipValidation, reason, note, dryRun });
1154
- break;
1155
- }
1156
-
1157
- case 'bulk-done': {
1158
- const skipValidation = args.includes('--skip-validation');
1159
- const dryRun = args.includes('--dry-run');
1160
- const allFlag = args.includes('--all');
1161
- const fromIdx = args.indexOf('--from');
1162
- const toIdx = args.indexOf('--to');
1163
- const reasonIdx = args.indexOf('--reason');
1164
- const reason = reasonIdx !== -1 ? args[reasonIdx + 1] : null;
1165
- const noteIdx = args.indexOf('--note');
1166
- const note = noteIdx !== -1 ? args[noteIdx + 1] : null;
1167
- const filteredArgs = args.filter((a, i) => !['--skip-validation', '--dry-run', '--all', '--reason', '--note'].includes(a)
1168
- && !a.startsWith('--from') && !a.startsWith('--to')
1169
- && !(i > 0 && args[i - 1] === '--reason')
1170
- && !(i > 0 && args[i - 1] === '--note'));
1171
- const featureName = filteredArgs[1];
1172
- const rangeArg = filteredArgs[2]; // e.g. T001..T082
1173
-
1174
- if (!featureName) {
1175
- console.error(chalk.red('Usage: morph-spec task bulk-done <feature> [--all | --from T001 --to T053 | T001..T082] [--note <text>] [--skip-validation] [--reason <text>] [--dry-run]'));
1176
- process.exit(1);
1177
- }
1178
-
1179
- const fromId = fromIdx !== -1 ? args[fromIdx + 1] : null;
1180
- const toId = toIdx !== -1 ? args[toIdx + 1] : null;
1181
-
1182
- await manager.bulkCompleteTasks(featureName, { all: allFlag, from: fromId, to: toId, range: rangeArg, skipValidation, reason, note, dryRun });
1183
- break;
1184
- }
1185
-
1186
- case 'start': {
1187
- const featureName = args[1];
1188
- const taskId = args[2];
1189
-
1190
- if (!featureName || !taskId) {
1191
- console.error(chalk.red('Usage: morph-spec task start <feature> <task-id>'));
1192
- process.exit(1);
1193
- }
1194
-
1195
- await manager.startTask(featureName, taskId);
1196
- break;
1197
- }
1198
-
1199
- case 'bulk-start': {
1200
- const allFlag = args.includes('--all');
1201
- const fromIdx = args.indexOf('--from');
1202
- const toIdx = args.indexOf('--to');
1203
- const filteredArgs = args.filter(a => !['--all'].includes(a)
1204
- && !a.startsWith('--from') && !a.startsWith('--to'));
1205
- const featureName = filteredArgs[1];
1206
- const rangeArg = filteredArgs[2]; // e.g. T007..T014
1207
-
1208
- if (!featureName) {
1209
- console.error(chalk.red('Usage: morph-spec task bulk-start <feature> [--all | --from T001 --to T014 | T007..T014]'));
1210
- process.exit(1);
1211
- }
1212
-
1213
- const fromId = fromIdx !== -1 ? args[fromIdx + 1] : null;
1214
- const toId = toIdx !== -1 ? args[toIdx + 1] : null;
1215
-
1216
- await manager.bulkStartTasks(featureName, { all: allFlag, from: fromId, to: toId, range: rangeArg });
1217
- break;
1218
- }
1219
-
1220
- case 'next': {
1221
- const featureName = args[1];
1222
-
1223
- if (!featureName) {
1224
- console.error(chalk.red('Usage: morph-spec task next <feature>'));
1225
- process.exit(1);
1226
- }
1227
-
1228
- await manager.getNext(featureName);
1229
- break;
1230
- }
1231
-
1232
- case 'annotate': {
1233
- const featureName = args[1];
1234
- const taskId = args[2];
1235
- const text = args[3];
1236
- const typeIdx = args.indexOf('--type');
1237
- const type = typeIdx !== -1 ? args[typeIdx + 1] : 'deviation';
1238
-
1239
- if (!featureName || !taskId || !text) {
1240
- console.error(chalk.red('Usage: morph-spec task annotate <feature> <task-id> <text> [--type deviation|context|blocker-resolved]'));
1241
- process.exit(1);
1242
- }
1243
-
1244
- await manager.annotateTask(featureName, taskId, text, type);
1245
- break;
1246
- }
1247
-
1248
- default:
1249
- console.error(chalk.red(`Unknown command: ${command}`));
1250
- console.log(chalk.gray('\nAvailable commands:'));
1251
- console.log(chalk.gray(' done <feature> <task-id...> - Mark tasks as completed'));
1252
- console.log(chalk.gray(' bulk-done <feature> --all - Mark all pending tasks as completed'));
1253
- console.log(chalk.gray(' bulk-done <feature> --from T001 --to T053 - Mark range as completed'));
1254
- console.log(chalk.gray(' bulk-done <feature> T001..T082 - Mark range as completed'));
1255
- console.log(chalk.gray(' start <feature> <task-id> - Start a task (mark as in_progress)'));
1256
- console.log(chalk.gray(' bulk-start <feature> --all - Start all pending tasks'));
1257
- console.log(chalk.gray(' bulk-start <feature> --from T007 --to T014 - Start range of tasks'));
1258
- console.log(chalk.gray(' bulk-start <feature> T007..T014 - Start range of tasks'));
1259
- console.log(chalk.gray(' next <feature> - Show next suggested task'));
1260
- console.log(chalk.gray(' annotate <feature> <task-id> <text> - Add a note to a task'));
1261
- console.log(chalk.gray('\nFlags: --skip-validation --reason <text>, --note <text>, --dry-run'));
1262
- process.exit(1);
1263
- }
1264
- } catch (error) {
1265
- console.error(chalk.red(`\n❌ Error: ${error.message}`));
1266
- process.exit(1);
1267
- }
1268
- }
1269
-
1270
- // ESM equivalent of `if (require.main === module)`
1271
- if (process.argv[1] === fileURLToPath(import.meta.url)) {
1272
- main();
1273
- }
1274
-
1275
- /**
1276
- * Pure predicate: should a --skip-validation bypass warning be logged?
1277
- *
1278
- * @param {Array} tasksToComplete - Tasks that will be marked complete
1279
- * @param {boolean} skipValidation - Whether --skip-validation flag is active
1280
- * @param {boolean} dryRun - Whether --dry-run is active (has its own message)
1281
- * @returns {boolean}
1282
- */
1283
- export function shouldLogSkipValidationWarning(tasksToComplete, skipValidation, dryRun) {
1284
- return tasksToComplete.length > 0 && !!skipValidation && !dryRun;
1285
- }
1286
-
1287
- /**
1288
- * Compute the skip-validation budget for a feature.
1289
- * Formula: clamp(ceil(totalTasks * 0.20), 1, 5)
1290
- *
1291
- * @param {Object} feature - Feature state object
1292
- * @returns {number} Maximum allowed skip-validation uses
1293
- */
1294
- export function computeSkipBudget(feature) {
1295
- const total = feature.tasks?.total || 0;
1296
- if (total === 0) return 1;
1297
- return Math.max(1, Math.min(5, Math.ceil(total * 0.2)));
1298
- }
1299
-
1300
- /**
1301
- * Test Task Guard — run project test suite before completing test-keyword tasks.
1302
- *
1303
- * Extracted as a pure, injectable function so it can be unit-tested without
1304
- * spawning a full TaskManager or writing state to disk.
1305
- *
1306
- * @param {Array} tasksToComplete - Tasks about to be marked complete
1307
- * @param {string} projectPath - Project root (process.cwd() in production)
1308
- * @param {object} [opts]
1309
- * @param {boolean} [opts.skipValidation] - Skip the guard entirely
1310
- * @param {boolean} [opts.dryRun] - Skip the guard entirely
1311
- * @param {Function} [opts._runTestSuite] - Override runTestSuite for testing
1312
- * @param {Function} [opts._exit] - Override process.exit for testing
1313
- * @returns {void} Calls _exit/process.exit(1) on failure; returns normally otherwise
1314
- */
1315
- export function runTestGuard(tasksToComplete, projectPath, opts = {}) {
1316
- if (!tasksToComplete.length || opts.skipValidation || opts.dryRun) return;
1317
-
1318
- const _runTS = opts._runTestSuite ?? runTestSuite;
1319
- const hasTestTask = tasksToComplete.some(t => isTestTask(t.title));
1320
- if (!hasTestTask) return;
1321
-
1322
- const suiteResult = _runTS(projectPath);
1323
-
1324
- if (suiteResult.passed === false) {
1325
- console.error(chalk.red('\n❌ Test suite FAILED — tasks NOT marked as complete'));
1326
- if (suiteResult.output) {
1327
- console.error(chalk.gray(suiteResult.output.slice(-2000)));
1328
- }
1329
- console.log(chalk.gray(' Fix failing tests, then run task done again'));
1330
- console.log(chalk.gray(' Or use --skip-validation to bypass (not recommended)\n'));
1331
- (opts._exit ?? process.exit)(1);
1332
- } else if (suiteResult.skipped) {
1333
- console.log(chalk.yellow('\n⚠️ Test suite skipped — no testCommand configured'));
1334
- console.log(chalk.gray(` Reason: ${suiteResult.reason}`));
1335
- console.log(chalk.gray(' Set project.testCommand in .morph/config/config.json to enable auto-run\n'));
1336
- } else {
1337
- console.log(chalk.green('\n✅ Test suite passed'));
1338
- }
1339
- }
1340
-
1341
- export default TaskManager;
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * MORPH-SPEC Task Manager (Simplified — Native-First)
5
+ *
6
+ * Validation gates for task completion, checkpoints, and state persistence.
7
+ * Progress tracking and dependency management are handled by Claude Code native tasks.
8
+ */
9
+
10
+ import { fileURLToPath } from 'url';
11
+ import { join, dirname } from 'path';
12
+ import { access } from 'fs/promises';
13
+ import { execSync } from 'child_process';
14
+ import chalk from 'chalk';
15
+
16
+ import { loadState, saveState, resolveAgentForTask, derivePhase } from '../src/core/state/state-manager.js';
17
+ import { ensureTaskList, syncCounters } from '../src/lib/tasks/task-parser.js';
18
+ import { isTestTask } from '../src/lib/tasks/task-classifier.js';
19
+ import { runTestSuite } from '../src/lib/tasks/test-runner.js';
20
+
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = dirname(__filename);
23
+
24
+ // ============================================================================
25
+ // Breaking-Change Detection
26
+ // ============================================================================
27
+
28
+ /**
29
+ * Detect potentially broken consumers of recently removed exports.
30
+ *
31
+ * Strategy:
32
+ * 1. Parse `git diff HEAD` (staged + unstaged) for removed export declarations.
33
+ * 2. Use `git grep` to find files that still reference each removed symbol.
34
+ * 3. Return warnings (non-blocking) so Claude Code can review before marking done.
35
+ *
36
+ * Returns null when git is unavailable, not in a repo, or no removed exports found.
37
+ * Returns [] when removed exports exist but no consumers found.
38
+ * Returns [{export, consumers[]}] when consumers are found.
39
+ *
40
+ * @returns {Promise<Array<{export: string, consumers: string[]}>|null>}
41
+ */
42
+ export async function detectBreakingChanges(opts = {}) {
43
+ try {
44
+ const exec = opts._execSync ?? execSync;
45
+ const cwd = opts._cwd ?? process.cwd();
46
+ const execOpts = { cwd, stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 5 * 1024 * 1024 };
47
+
48
+ // Collect diff from staged + unstaged changes in source files
49
+ let diff = '';
50
+ const diffPatterns = ['*.ts', '*.tsx', '*.js', '*.jsx', '*.cs'];
51
+ for (const flag of ['--cached', '']) {
52
+ try {
53
+ const args = ['git', 'diff', '--unified=0', flag, '--', ...diffPatterns].filter(Boolean);
54
+ diff += exec(args.join(' '), execOpts).toString();
55
+ } catch {
56
+ // git not available or no changes — continue
57
+ }
58
+ }
59
+
60
+ if (!diff.trim()) return null;
61
+
62
+ // Extract source files touched by the diff
63
+ const sourceFiles = new Set();
64
+ const diffHeaderRe = /^diff --git a\/(.+?) b\//gm;
65
+ let m;
66
+ while ((m = diffHeaderRe.exec(diff)) !== null) sourceFiles.add(m[1]);
67
+
68
+ // Extract removed export symbol names (lines starting with -)
69
+ const removedExports = new Set();
70
+ const exportRe = /^-\s*export\s+(?:(?:default\s+)?(?:async\s+)?function|const|let|class|interface|type|enum)\s+(\w+)/gm;
71
+ let match;
72
+ while ((match = exportRe.exec(diff)) !== null) {
73
+ removedExports.add(match[1]);
74
+ }
75
+
76
+ if (removedExports.size === 0) return null;
77
+
78
+ // For each removed symbol, check if any tracked file still imports/uses it
79
+ const warnings = [];
80
+ const defaultExcludes = [':!.morph/archive/', ':!.morph/framework/', ':!node_modules/'];
81
+ const pathspecs = [...diffPatterns, ...defaultExcludes];
82
+
83
+ for (const sym of removedExports) {
84
+ try {
85
+ const grepCmd = ['git', 'grep', '-l', `"${sym}"`, '--', ...pathspecs].join(' ');
86
+ const result = exec(grepCmd, execOpts).toString().trim();
87
+ if (result) {
88
+ const consumers = result.split('\n').filter(Boolean).filter(f => !sourceFiles.has(f));
89
+ if (consumers.length > 0) {
90
+ warnings.push({ export: sym, consumers });
91
+ }
92
+ }
93
+ } catch {
94
+ // No match found — safe
95
+ }
96
+ }
97
+
98
+ return warnings;
99
+ } catch {
100
+ return null; // Non-blocking: any unexpected error → skip detection
101
+ }
102
+ }
103
+
104
+ // ============================================================================
105
+ // TaskManager
106
+ // ============================================================================
107
+
108
+ class TaskManager {
109
+ /**
110
+ * Complete one or more tasks (runs validation first)
111
+ * @param {string} featureName - Feature name
112
+ * @param {string[]} taskIds - Task IDs to complete
113
+ * @param {Object} options - Options
114
+ * @param {boolean} options.skipValidation - Skip code validation (budget-limited)
115
+ * @param {string} [options.reason] - Reason for skipping validation (required with skipValidation)
116
+ * @param {string} [options.note] - Deviation note explaining why task was done differently than planned
117
+ * @param {boolean} options.dryRun - Dry run (no state changes)
118
+ * @param {Function} [options._runTestSuite] - Inject a mock runTestSuite for testing
119
+ */
120
+ async completeTasks(featureName, taskIds, options = {}) {
121
+ const state = loadState();
122
+ const feature = state.features[featureName];
123
+
124
+ if (!feature) {
125
+ throw new Error(`Feature '${featureName}' not found in state.json`);
126
+ }
127
+
128
+ const taskList = await ensureTaskList(feature, featureName);
129
+
130
+ if (taskList.length === 0) {
131
+ const tasksPath = join(process.cwd(), `.morph/features/${featureName}/4-tasks/tasks.md`);
132
+ const tasksExist = await access(tasksPath).then(() => true).catch(() => false);
133
+ if (!tasksExist) {
134
+ throw new Error(`No tasks found for '${featureName}' — tasks.md not generated yet.\n Complete the tasks phase first: run /phase-tasks`);
135
+ }
136
+ throw new Error(`tasks.md found but no tasks could be parsed for '${featureName}'.\n Ensure tasks use the format: ### T001 — Title or ### T001: Title`);
137
+ }
138
+
139
+ // Persist initial taskList so counters are visible in `state get` even if
140
+ // validation fails later and the completion save never runs.
141
+ syncCounters(feature);
142
+ feature.updatedAt = new Date().toISOString();
143
+ saveState(state);
144
+
145
+ const results = [];
146
+ const tasksToComplete = [];
147
+
148
+ for (const taskId of taskIds) {
149
+ const task = taskList.find(t => t.id === taskId);
150
+
151
+ if (!task) {
152
+ console.error(chalk.red(`❌ Task ${taskId} not found (available: ${taskList.map(t => t.id).join(', ')})`));
153
+ continue;
154
+ }
155
+
156
+ if (task.status === 'completed') {
157
+ console.log(chalk.yellow(`⚠️ Task ${taskId} already completed`));
158
+ continue;
159
+ }
160
+
161
+ tasksToComplete.push(task);
162
+ }
163
+
164
+ // ─── Test Task Guard ────────────────────────────────────────────────────────
165
+ // When any of the tasks being completed is a test/validation task (e.g.
166
+ // "Testes e Validação"), run the project's test suite BEFORE code validation.
167
+ // The guard is skipped when --skip-validation or --dry-run is active.
168
+ runTestGuard(tasksToComplete, process.cwd(), options);
169
+
170
+ // ─── Skip-Validation Budget Enforcement ───────────────────────────────────
171
+ if (options.skipValidation && tasksToComplete.length > 0 && !options.dryRun) {
172
+ // Require --reason with minimum 5 characters
173
+ if (!options.reason || options.reason.trim().length < 5) {
174
+ console.error(chalk.red('❌ --skip-validation requires --reason <text> (minimum 5 characters)'));
175
+ console.error(chalk.gray(' Example: morph-spec task done feat T001 --skip-validation --reason "CSS-only change, no logic"'));
176
+ process.exit(1);
177
+ }
178
+
179
+ // Check budget
180
+ const budget = computeSkipBudget(feature);
181
+ const used = (feature.skipValidationLog || []).length;
182
+ if (used >= budget) {
183
+ console.error(chalk.red(`❌ Skip-validation budget exhausted (${used}/${budget})`));
184
+ console.error(chalk.gray(' All remaining tasks must pass Tier-4 validation.'));
185
+ console.error(chalk.gray(' Budget formula: min(5, ceil(totalTasks × 0.20))'));
186
+ process.exit(1);
187
+ }
188
+
189
+ console.log(chalk.yellow(`⚠ --skip-validation: using budget (${used + tasksToComplete.length}/${budget})`));
190
+ }
191
+
192
+ // Run validation BEFORE marking tasks as complete
193
+ if (tasksToComplete.length > 0 && !options.skipValidation) {
194
+ const validationResult = await this.runValidation(featureName, { mode: 'light' });
195
+ if (options.dryRun) {
196
+ console.log(chalk.cyan('\n ℹ️ Dry-run — tasks NOT marked as complete'));
197
+ return [];
198
+ }
199
+ if (!validationResult.passed) {
200
+ // Reload state to ensure we have the latest (may have been modified by validation)
201
+ const currentState = loadState();
202
+ const currentFeature = currentState.features[featureName];
203
+ if (currentFeature) {
204
+ for (const task of tasksToComplete) {
205
+ this.persistValidationHistory(currentFeature, task.id, validationResult);
206
+ }
207
+ saveState(currentState);
208
+
209
+ // Check escalation status
210
+ const blockedTasks = tasksToComplete.filter(t => {
211
+ const hist = currentFeature.validationHistory?.[t.id];
212
+ return hist?.status === 'blocked';
213
+ });
214
+ if (blockedTasks.length > 0) {
215
+ console.error(chalk.red(`\n⛔ ESCALATION — Task(s) ${blockedTasks.map(t => t.id).join(', ')} have failed 3 times`));
216
+ console.error(chalk.red(' Human review required. Mark resolved with: morph-spec state set <feature> validationHistory.<taskId>.status passed'));
217
+ } else {
218
+ const attempt = currentFeature.validationHistory?.[tasksToComplete[0]?.id]?.attempt || 1;
219
+ console.error(chalk.red(`\n❌ Validation failed (attempt ${attempt}/3) — tasks NOT marked as complete`));
220
+ console.log(chalk.gray(' Fix the issues above, then run task done again'));
221
+ if (attempt >= 2) {
222
+ console.log(chalk.yellow(` ⚠️ Next failure will escalate to human review`));
223
+ }
224
+ }
225
+ } else {
226
+ console.error(chalk.red('\n❌ Validation failed — tasks NOT marked as complete'));
227
+ console.log(chalk.gray(' Fix the issues above, then run task done again'));
228
+ }
229
+ console.log(chalk.gray(' Or use --skip-validation to bypass (not recommended)\n'));
230
+ process.exit(1);
231
+ }
232
+
233
+ // Validation passed — always record passed status in validationHistory.
234
+ // Update in-place on `feature` (same object as state.features[featureName]) so
235
+ // the single saveState(state) at the end of the function persists everything
236
+ // atomically. A separate saveState here would trigger a second renameSync on
237
+ // Windows, risking EPERM and leaving state.json without the task completions
238
+ // (the count bug), and would also be clobbered by the final save anyway.
239
+ if (!feature.validationHistory) feature.validationHistory = {};
240
+ for (const task of tasksToComplete) {
241
+ feature.validationHistory[task.id] = {
242
+ ...(feature.validationHistory[task.id] || {}),
243
+ status: 'passed',
244
+ updatedAt: new Date().toISOString(),
245
+ };
246
+ }
247
+ } else if (options.dryRun) {
248
+ console.log(chalk.cyan('\n ℹ️ Dry-run — tasks NOT marked as complete (validation skipped)'));
249
+ return [];
250
+ } else if (shouldLogSkipValidationWarning(tasksToComplete, options.skipValidation, options.dryRun)) {
251
+ // Record skip in state: skipValidationLog + validationHistory
252
+ if (!feature.skipValidationLog) feature.skipValidationLog = [];
253
+ if (!feature.validationHistory) feature.validationHistory = {};
254
+ for (const task of tasksToComplete) {
255
+ feature.skipValidationLog.push({
256
+ taskId: task.id,
257
+ reason: options.reason,
258
+ timestamp: new Date().toISOString(),
259
+ });
260
+ feature.validationHistory[task.id] = {
261
+ ...(feature.validationHistory[task.id] || {}),
262
+ attempt: 0,
263
+ status: 'skipped',
264
+ reason: options.reason,
265
+ updatedAt: new Date().toISOString(),
266
+ };
267
+ }
268
+
269
+ console.log(chalk.yellow('\n⚠ --skip-validation: Tier-4 code validators bypassed — tasks will be marked complete without validation'));
270
+ console.log(chalk.gray(' Re-run without --skip-validation before committing.\n'));
271
+ }
272
+
273
+ // Breaking change detection (non-blocking warning)
274
+ if (tasksToComplete.length > 0) {
275
+ const _detectBreaking = options._detectBreakingChanges ?? detectBreakingChanges;
276
+ const breakingChanges = await _detectBreaking();
277
+ if (breakingChanges && breakingChanges.length > 0) {
278
+ console.log(chalk.yellow('\n⚠️ BREAKING CHANGE DETECTION:'));
279
+ console.log(chalk.yellow(' Removed exports with active consumers found — review before completing:\n'));
280
+ for (const { export: sym, consumers } of breakingChanges) {
281
+ console.log(chalk.yellow(` • "${sym}" removed — used by:`));
282
+ consumers.slice(0, 5).forEach(f => console.log(chalk.gray(` - ${f}`)));
283
+ if (consumers.length > 5) {
284
+ console.log(chalk.gray(` … and ${consumers.length - 5} more files`));
285
+ }
286
+ }
287
+ console.log(chalk.yellow('\n Fix broken imports before the smoke test, or use --skip-validation to bypass.\n'));
288
+ }
289
+ }
290
+
291
+ // Mark tasks as completed with agent correlation
292
+ for (const task of tasksToComplete) {
293
+ task.status = 'completed';
294
+ task.completedAt = new Date().toISOString();
295
+
296
+ // Resolve agent from dispatch (falls back to 'claude')
297
+ const agentId = resolveAgentForTask(featureName, task.id);
298
+ task.completedBy = agentId || 'claude';
299
+
300
+ // Add deviation note if provided
301
+ if (options.note) {
302
+ if (!task.notes) task.notes = [];
303
+ task.notes.push({
304
+ text: options.note,
305
+ type: 'deviation',
306
+ addedAt: new Date().toISOString(),
307
+ addedBy: agentId || 'claude'
308
+ });
309
+ }
310
+
311
+ results.push(task);
312
+
313
+ console.log(chalk.green(`✅ Task ${task.id} completed!`));
314
+ if (options.note) {
315
+ console.log(chalk.gray(` Note: ${options.note}`));
316
+ }
317
+
318
+ // Register checkpoint if task has one
319
+ if (task.checkpoint) {
320
+ this.registerCheckpoint(feature, task);
321
+ }
322
+ }
323
+
324
+ // Sync v3 counters
325
+ syncCounters(feature);
326
+
327
+ // Auto-checkpoint every 3 tasks (at exact multiples of 3, not after every task).
328
+ // Uses feature._lastAutoCheckpointNum to track which threshold already fired.
329
+ const activeTasks = taskList.filter(t => t.status !== 'expanded');
330
+ const completedCount = activeTasks.filter(t => t.status === 'completed').length;
331
+ if (completedCount > 0 && completedCount % 3 === 0) {
332
+ const checkpointNum = Math.floor(completedCount / 3);
333
+ const lastAutoNum = feature._lastAutoCheckpointNum || 0;
334
+ if (checkpointNum > lastAutoNum) {
335
+ const recentCompleted = this.getRecentCompleted(taskList, 3);
336
+ await this.autoCheckpoint(feature, recentCompleted, featureName);
337
+ feature._lastAutoCheckpointNum = checkpointNum;
338
+ }
339
+ }
340
+
341
+ // Persist state (atomic write via state-manager)
342
+ feature.updatedAt = new Date().toISOString();
343
+ saveState(state);
344
+
345
+ // Run TaskCompleted agent-teams hook for each completed task (non-blocking)
346
+ for (const task of results) {
347
+ try {
348
+ const { executeHook, formatHookResults } = await import('../src/lib/hooks/hook-executor.js');
349
+ const hookResult = await executeHook(process.cwd(), featureName, 'TaskCompleted', {
350
+ taskId: task.id
351
+ });
352
+ if (!hookResult.passed && hookResult.errors.length > 0) {
353
+ console.log(formatHookResults(hookResult, 'TaskCompleted'));
354
+ }
355
+ } catch {
356
+ // Hook executor unavailable — non-blocking
357
+ }
358
+ }
359
+
360
+ // Auto-generate metadata.json (for quick LLM access)
361
+ await this.generateMetadata(featureName, feature);
362
+
363
+ // Emit VALIDATION DISPATCH for PostToolUse hook (strategy 1 parsing).
364
+ // Skipped when --skip-validation is active (no validators needed).
365
+ if (!options.skipValidation && results.length > 0) {
366
+ try {
367
+ const { emitValidatorDispatch } = await import('../src/lib/validators/shared/emit-validator-dispatch.js');
368
+ const phase = feature.phase || 'implement';
369
+ await emitValidatorDispatch(featureName, phase, process.cwd());
370
+ } catch {
371
+ // Non-blocking
372
+ }
373
+ }
374
+
375
+ return results;
376
+ }
377
+
378
+ /**
379
+ * Run validation for a feature using the ValidationRunner (ESM dynamic import).
380
+ * Returns a structured result with per-validator breakdown for validationHistory.
381
+ *
382
+ * @param {string} featureName - Feature name
383
+ * @returns {{ passed: boolean, validators: Object, passRate: number }}
384
+ */
385
+ async runValidation(featureName, opts = {}) {
386
+ try {
387
+ console.log(chalk.cyan('\n🔍 Running code validation...'));
388
+
389
+ const { runValidation, formatValidationResults } = await import('../src/lib/validators/validation-runner.js');
390
+ const result = await runValidation('.', featureName, { verbose: true, ...opts });
391
+
392
+ formatValidationResults(result);
393
+
394
+ // Build structured validators map from result
395
+ const validators = {};
396
+ if (result.results && typeof result.results === 'object') {
397
+ for (const [name, vResult] of Object.entries(result.results)) {
398
+ const issues = (vResult.errors || []).map(e => ({
399
+ message: typeof e === 'string' ? e : (e.message || String(e)),
400
+ file: e.file,
401
+ line: e.line,
402
+ rule: e.rule,
403
+ }));
404
+ validators[name] = {
405
+ passed: vResult.passed ?? (issues.length === 0),
406
+ issues,
407
+ };
408
+ }
409
+ } else if (!result.passed && result.errors?.length > 0) {
410
+ // Flat format fallback — wrap as single 'validation' validator
411
+ validators['validation'] = {
412
+ passed: false,
413
+ issues: result.errors.map(e => ({
414
+ message: typeof e === 'string' ? e : (e.message || String(e)),
415
+ file: e.file,
416
+ line: e.line,
417
+ rule: e.rule || 'validation',
418
+ })),
419
+ };
420
+ }
421
+
422
+ const validatorEntries = Object.values(validators);
423
+ const passRate = validatorEntries.length > 0
424
+ ? validatorEntries.filter(v => v.passed).length / validatorEntries.length
425
+ : (result.passed ? 1.0 : 0.0);
426
+
427
+ return { passed: result.passed, validators, passRate };
428
+ } catch (error) {
429
+ // If validation runner fails to load, warn but don't block task completion.
430
+ // This is fail-open by design: a broken validator shouldn't block commits.
431
+ // Common cause: missing optional deps.
432
+ console.log(chalk.yellow(`\n⚠️ Validation skipped (${error.message})`));
433
+ console.log(chalk.gray(` Run manually: morph-spec validate-feature ${featureName}`));
434
+ return { passed: true, validators: {}, passRate: 1.0 };
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Persist a validation result to feature.validationHistory[taskId].
440
+ * Increments attempt counter and sets status to 'failed' or 'blocked' (attempt >= 3).
441
+ *
442
+ * @param {Object} feature - Mutable feature state object
443
+ * @param {string} taskId - Task ID
444
+ * @param {{ passed: boolean, validators: Object, passRate: number }} validationResult
445
+ */
446
+ persistValidationHistory(feature, taskId, validationResult) {
447
+ if (!feature.validationHistory) {
448
+ feature.validationHistory = {};
449
+ }
450
+ const existing = feature.validationHistory[taskId] || { attempt: 0 };
451
+ const attempt = (existing.attempt || 0) + 1;
452
+ const status = attempt >= 3 ? 'blocked' : 'failed';
453
+
454
+ feature.validationHistory[taskId] = {
455
+ attempt,
456
+ validators: validationResult.validators || {},
457
+ passRate: validationResult.passRate || 0,
458
+ status,
459
+ updatedAt: new Date().toISOString(),
460
+ };
461
+ }
462
+
463
+ /**
464
+ * Register checkpoint (from task flag)
465
+ */
466
+ registerCheckpoint(feature, task) {
467
+ const checkpoint = {
468
+ id: task.checkpoint,
469
+ timestamp: new Date().toISOString(),
470
+ tasksCompleted: [task.id],
471
+ note: `Checkpoint: ${task.title}`,
472
+ passed: true // reaching a named checkpoint means the task completed successfully
473
+ };
474
+
475
+ feature.checkpoints = feature.checkpoints || [];
476
+ feature.checkpoints.push(checkpoint);
477
+
478
+ console.log(chalk.magenta(`\n🎯 ${task.checkpoint} reached!`));
479
+ }
480
+
481
+ /**
482
+ * Auto-checkpoint every 3 tasks (includes validation summary)
483
+ */
484
+ async autoCheckpoint(feature, tasks, featureName) {
485
+ const tasksCompleted = feature.tasks?.completed || tasks.length;
486
+ const checkpointNum = Math.floor(tasksCompleted / 3);
487
+
488
+ console.log(chalk.magenta(`\n🔍 CHECKPOINT ${checkpointNum} - Running automated validation...`));
489
+
490
+ let checkpointResult = null;
491
+ let validationNote = '';
492
+
493
+ try {
494
+ const { runCheckpointHooks, shouldRunCheckpoint } = await import('../src/lib/checkpoints/checkpoint-hooks.js');
495
+
496
+ if (shouldRunCheckpoint(tasksCompleted, 3)) {
497
+ checkpointResult = await runCheckpointHooks(featureName, checkpointNum);
498
+
499
+ if (checkpointResult.passed) {
500
+ validationNote = ' | ✓ All validations passed';
501
+ } else {
502
+ validationNote = ` | ✗ ${checkpointResult.summary.errors} errors, ${checkpointResult.summary.warnings} warnings`;
503
+
504
+ const config = await this.loadCheckpointConfig();
505
+ if (config.checkpoints?.onFailure?.blockProgress && checkpointResult.summary.errors > 0) {
506
+ console.log(chalk.red('\n❌ Checkpoint FAILED - Fix violations before proceeding'));
507
+ throw new Error('Checkpoint validation failed');
508
+ }
509
+ }
510
+ }
511
+ } catch (error) {
512
+ if (error.message === 'Checkpoint validation failed') {
513
+ throw error;
514
+ }
515
+ // Fallback to old validation if checkpoint-hooks not available
516
+ console.log(chalk.yellow('⚠️ Checkpoint hooks not available, using legacy validation'));
517
+ try {
518
+ const { runValidation } = await import('../src/lib/validators/validation-runner.js');
519
+ const result = await runValidation('.', featureName, { verbose: false, mode: 'full' });
520
+ validationNote = result.passed
521
+ ? ' | Validation: PASSED'
522
+ : ` | Validation: ${result.errors} errors, ${result.warnings} warnings`;
523
+
524
+ // Persist checkpoint validation results to disk
525
+ try {
526
+ const { persistCheckpointValidation } = await import('../src/lib/validators/checkpoint-persistence.js');
527
+ persistCheckpointValidation(process.cwd(), featureName, {
528
+ checkpointNum,
529
+ timestamp: new Date().toISOString(),
530
+ passed: result.passed,
531
+ validators: result.results || {},
532
+ summary: result.summary || '',
533
+ });
534
+ } catch {
535
+ // Non-blocking
536
+ }
537
+ } catch {
538
+ // No validation available
539
+ }
540
+ }
541
+
542
+ const checkpoint = {
543
+ id: `CHECKPOINT_AUTO_${Date.now()}`,
544
+ timestamp: new Date().toISOString(),
545
+ tasksCompleted: tasks.map(t => t.id),
546
+ note: `Auto-checkpoint: ${tasks.length} tasks completed${validationNote}`,
547
+ passed: checkpointResult?.passed ?? true, // null checkpointResult means no validator ran → assume passed
548
+ validationResults: checkpointResult?.results || null
549
+ };
550
+
551
+ feature.checkpoints = feature.checkpoints || [];
552
+ feature.checkpoints.push(checkpoint);
553
+
554
+ if (checkpointResult?.passed) {
555
+ console.log(chalk.green(`\n✅ Checkpoint ${checkpointNum} PASSED`));
556
+ } else if (checkpointResult) {
557
+ console.log(chalk.yellow(`\n⚠️ Checkpoint ${checkpointNum} completed with warnings`));
558
+ } else {
559
+ console.log(chalk.magenta(`\n🎉 Auto-checkpoint ${checkpointNum} reached!`));
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Load checkpoint configuration from llm-interaction.json
565
+ */
566
+ async loadCheckpointConfig() {
567
+ try {
568
+ const { readFile } = await import('fs/promises');
569
+ const configPath = join(process.cwd(), '.morph/config/llm-interaction.json');
570
+ const content = await readFile(configPath, 'utf-8');
571
+ return JSON.parse(content);
572
+ } catch {
573
+ return {
574
+ checkpoints: {
575
+ frequency: 3,
576
+ autoValidate: true,
577
+ onFailure: { blockProgress: false }
578
+ }
579
+ };
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Auto-generate metadata.json after task completion
585
+ */
586
+ async generateMetadata(featureName, feature) {
587
+ try {
588
+ const config = await this.loadCheckpointConfig();
589
+
590
+ if (!config.metadata?.autoGenerate) {
591
+ return;
592
+ }
593
+
594
+ const { extractFeatureMetadata } = await import('../src/lib/generators/metadata-extractor.js');
595
+ const metadata = extractFeatureMetadata(feature);
596
+
597
+ const outputPath = join(process.cwd(), `.morph/features/${featureName}/metadata.json`);
598
+
599
+ const { mkdir, writeFile } = await import('fs/promises');
600
+ await mkdir(dirname(outputPath), { recursive: true });
601
+ await writeFile(outputPath, JSON.stringify(metadata, null, 2), 'utf-8');
602
+
603
+ console.log(chalk.gray(` 📊 Metadata updated: .morph/features/${featureName}/metadata.json`));
604
+ } catch (error) {
605
+ console.log(chalk.yellow(` ⚠️ Metadata generation failed: ${error.message}`));
606
+ }
607
+ }
608
+
609
+ /**
610
+ * Get recently completed tasks (last N)
611
+ */
612
+ getRecentCompleted(tasks, count) {
613
+ const completed = tasks
614
+ .filter(t => t.status === 'completed' && t.completedAt)
615
+ .sort((a, b) => new Date(b.completedAt) - new Date(a.completedAt));
616
+
617
+ return completed.slice(0, count);
618
+ }
619
+
620
+ /**
621
+ * Start a task (mark as in_progress).
622
+ * Primarily serves as dispatch enforcement gate during implement phase.
623
+ */
624
+ async startTask(featureName, taskId) {
625
+ const state = loadState();
626
+ const feature = state.features[featureName];
627
+
628
+ if (!feature) {
629
+ throw new Error(`Feature '${featureName}' not found`);
630
+ }
631
+
632
+ // Dispatch enforcement gate: block task start in implement phase
633
+ // unless dispatch-agents has been run (recorded in dispatchLog)
634
+ const featurePath = join(process.cwd(), '.morph', 'features', featureName);
635
+ const currentPhase = feature.phase || derivePhase(featurePath);
636
+ if (currentPhase === 'implement') {
637
+ const hasImplementDispatch = Array.isArray(feature.dispatchLog) &&
638
+ feature.dispatchLog.some(entry => entry.phase === 'implement');
639
+ if (!hasImplementDispatch) {
640
+ console.error(chalk.red('\n✗ Cannot start task — agent dispatch not recorded for implement phase'));
641
+ console.error(chalk.yellow(
642
+ `\n Run this command first:\n` +
643
+ ` morph-spec dispatch-agents ${featureName} implement\n\n` +
644
+ ` This configures typed specialist agents with enriched prompts,\n` +
645
+ ` task assignments, and quality standards for parallel execution.\n`
646
+ ));
647
+ process.exit(1);
648
+ }
649
+ }
650
+
651
+ const taskList = await ensureTaskList(feature, featureName);
652
+
653
+ if (taskList.length === 0) {
654
+ const tasksPath = join(process.cwd(), `.morph/features/${featureName}/4-tasks/tasks.md`);
655
+ const tasksExist = await access(tasksPath).then(() => true).catch(() => false);
656
+ if (!tasksExist) {
657
+ throw new Error(`No tasks found for '${featureName}' — tasks.md not generated yet.\n Complete the tasks phase first: run /phase-tasks`);
658
+ }
659
+ throw new Error(`tasks.md found but no tasks could be parsed for '${featureName}'.\n Ensure tasks use the format: ### T001 — Title or ### T001: Title`);
660
+ }
661
+
662
+ const task = taskList.find(t => t.id === taskId);
663
+
664
+ if (!task) {
665
+ throw new Error(`Task ${taskId} not found in '${featureName}' (${taskList.length} tasks available: ${taskList.map(t => t.id).join(', ')})`);
666
+ }
667
+
668
+ if (task.status === 'completed') {
669
+ console.log(chalk.yellow(`⚠️ Task ${taskId} already completed`));
670
+ return;
671
+ }
672
+
673
+ task.status = 'in_progress';
674
+ task.startedAt = new Date().toISOString();
675
+
676
+ // Agent correlation: resolve which agent is assigned this task from the latest dispatch
677
+ const agentId = resolveAgentForTask(featureName, taskId);
678
+ if (agentId) {
679
+ task.assignedAgent = agentId;
680
+ }
681
+
682
+ syncCounters(feature);
683
+ feature.updatedAt = new Date().toISOString();
684
+
685
+ saveState(state);
686
+
687
+ console.log(chalk.blue(`▶️ Task ${taskId} started: ${task.title}`));
688
+ }
689
+
690
+ /**
691
+ * Bulk-complete tasks with a single validation pass.
692
+ * @param {string} featureName
693
+ * @param {Object} opts
694
+ * @param {boolean} [opts.all] - Complete all pending tasks
695
+ * @param {string} [opts.from] - Start of range (e.g. T001)
696
+ * @param {string} [opts.to] - End of range (e.g. T053)
697
+ * @param {string} [opts.range] - Compact range string (e.g. T001..T082)
698
+ * @param {boolean} [opts.skipValidation]
699
+ * @param {boolean} [opts.dryRun]
700
+ */
701
+ async bulkCompleteTasks(featureName, opts = {}) {
702
+ const state = loadState();
703
+ const feature = state.features[featureName];
704
+ if (!feature) throw new Error(`Feature '${featureName}' not found in state.json`);
705
+
706
+ const taskList = await ensureTaskList(feature, featureName);
707
+ if (taskList.length === 0) throw new Error(`No tasks found for '${featureName}'`);
708
+
709
+ // Resolve task IDs from range options
710
+ let targetIds;
711
+ if (opts.all) {
712
+ targetIds = taskList.filter(t => t.status !== 'completed' && t.status !== 'expanded').map(t => t.id);
713
+ } else if (opts.from && opts.to) {
714
+ targetIds = this.expandRange(taskList, opts.from, opts.to);
715
+ } else if (opts.range && opts.range.includes('..')) {
716
+ const [from, to] = opts.range.split('..');
717
+ targetIds = this.expandRange(taskList, from.trim(), to.trim());
718
+ } else {
719
+ throw new Error('bulk-done requires --all, --from/--to, or a T001..T082 range argument');
720
+ }
721
+
722
+ console.log(chalk.cyan(`\n📦 Bulk-done: ${targetIds.length} task(s) targeted`));
723
+ await this.completeTasks(featureName, targetIds, { skipValidation: opts.skipValidation, reason: opts.reason, note: opts.note, dryRun: opts.dryRun });
724
+ }
725
+
726
+ /**
727
+ * Expand a task range like T001..T053 using zero-padded numeric IDs.
728
+ */
729
+ expandRange(taskList, from, to) {
730
+ const numOf = id => parseInt(id.replace(/\D/g, ''), 10);
731
+ const prefix = from.replace(/\d+$/, '');
732
+ const padLen = from.replace(prefix, '').length;
733
+ const fromNum = numOf(from);
734
+ const toNum = numOf(to);
735
+ const ids = [];
736
+ for (let n = fromNum; n <= toNum; n++) {
737
+ ids.push(prefix + String(n).padStart(padLen, '0'));
738
+ }
739
+ return ids.filter(id => taskList.some(t => t.id === id));
740
+ }
741
+
742
+ /**
743
+ * Add a note to a task (deviation, context, or blocker-resolved)
744
+ * @param {string} featureName - Feature name
745
+ * @param {string} taskId - Task ID
746
+ * @param {string} text - Note text
747
+ * @param {string} [type='deviation'] - Note type: deviation | context | blocker-resolved
748
+ */
749
+ async annotateTask(featureName, taskId, text, type = 'deviation') {
750
+ const validTypes = ['deviation', 'context', 'blocker-resolved'];
751
+ if (!validTypes.includes(type)) {
752
+ throw new Error(`Invalid note type '${type}'. Valid types: ${validTypes.join(', ')}`);
753
+ }
754
+
755
+ const state = loadState();
756
+ const feature = state.features[featureName];
757
+ if (!feature) throw new Error(`Feature '${featureName}' not found`);
758
+
759
+ const taskList = await ensureTaskList(feature, featureName);
760
+ const task = taskList.find(t => t.id === taskId);
761
+ if (!task) throw new Error(`Task ${taskId} not found in '${featureName}'`);
762
+
763
+ if (!task.notes) task.notes = [];
764
+ task.notes.push({
765
+ text,
766
+ type,
767
+ addedAt: new Date().toISOString(),
768
+ addedBy: 'user'
769
+ });
770
+
771
+ feature.updatedAt = new Date().toISOString();
772
+ saveState(state);
773
+
774
+ console.log(chalk.green(`Added ${type} note to ${taskId}`));
775
+ console.log(chalk.gray(` "${text}"`));
776
+ }
777
+ }
778
+
779
+ // ============================================================================
780
+ // CLI entry point
781
+ // ============================================================================
782
+
783
+ async function main() {
784
+ const args = process.argv.slice(2);
785
+ const command = args[0];
786
+
787
+ const manager = new TaskManager();
788
+
789
+ try {
790
+ switch (command) {
791
+ case 'done':
792
+ case 'complete': {
793
+ const skipValidation = args.includes('--skip-validation');
794
+ const dryRun = args.includes('--dry-run');
795
+ const reasonIdx = args.indexOf('--reason');
796
+ const reason = reasonIdx !== -1 ? args[reasonIdx + 1] : null;
797
+ const noteIdx = args.indexOf('--note');
798
+ const note = noteIdx !== -1 ? args[noteIdx + 1] : null;
799
+ const filteredArgs = args.filter((a, i) =>
800
+ !['--skip-validation', '--dry-run', '--reason', '--note'].includes(a) &&
801
+ !(i > 0 && args[i - 1] === '--reason') &&
802
+ !(i > 0 && args[i - 1] === '--note'));
803
+ const featureName = filteredArgs[1];
804
+ const taskIds = filteredArgs.slice(2);
805
+
806
+ if (!featureName || taskIds.length === 0) {
807
+ console.error(chalk.red('Usage: morph-spec task done <feature> <task-id> [task-id...] [--note <text>] [--skip-validation] [--reason <text>] [--dry-run]'));
808
+ process.exit(1);
809
+ }
810
+
811
+ await manager.completeTasks(featureName, taskIds, { skipValidation, reason, note, dryRun });
812
+ break;
813
+ }
814
+
815
+ case 'bulk-done': {
816
+ const skipValidation = args.includes('--skip-validation');
817
+ const dryRun = args.includes('--dry-run');
818
+ const allFlag = args.includes('--all');
819
+ const fromIdx = args.indexOf('--from');
820
+ const toIdx = args.indexOf('--to');
821
+ const reasonIdx = args.indexOf('--reason');
822
+ const reason = reasonIdx !== -1 ? args[reasonIdx + 1] : null;
823
+ const noteIdx = args.indexOf('--note');
824
+ const note = noteIdx !== -1 ? args[noteIdx + 1] : null;
825
+ const filteredArgs = args.filter((a, i) => !['--skip-validation', '--dry-run', '--all', '--reason', '--note'].includes(a)
826
+ && !a.startsWith('--from') && !a.startsWith('--to')
827
+ && !(i > 0 && args[i - 1] === '--reason')
828
+ && !(i > 0 && args[i - 1] === '--note'));
829
+ const featureName = filteredArgs[1];
830
+ const rangeArg = filteredArgs[2]; // e.g. T001..T082
831
+
832
+ if (!featureName) {
833
+ console.error(chalk.red('Usage: morph-spec task bulk-done <feature> [--all | --from T001 --to T053 | T001..T082] [--note <text>] [--skip-validation] [--reason <text>] [--dry-run]'));
834
+ process.exit(1);
835
+ }
836
+
837
+ const fromId = fromIdx !== -1 ? args[fromIdx + 1] : null;
838
+ const toId = toIdx !== -1 ? args[toIdx + 1] : null;
839
+
840
+ await manager.bulkCompleteTasks(featureName, { all: allFlag, from: fromId, to: toId, range: rangeArg, skipValidation, reason, note, dryRun });
841
+ break;
842
+ }
843
+
844
+ case 'start': {
845
+ const featureName = args[1];
846
+ const taskId = args[2];
847
+
848
+ if (!featureName || !taskId) {
849
+ console.error(chalk.red('Usage: morph-spec task start <feature> <task-id>'));
850
+ process.exit(1);
851
+ }
852
+
853
+ await manager.startTask(featureName, taskId);
854
+ break;
855
+ }
856
+
857
+ case 'annotate': {
858
+ const featureName = args[1];
859
+ const taskId = args[2];
860
+ const text = args[3];
861
+ const typeIdx = args.indexOf('--type');
862
+ const type = typeIdx !== -1 ? args[typeIdx + 1] : 'deviation';
863
+
864
+ if (!featureName || !taskId || !text) {
865
+ console.error(chalk.red('Usage: morph-spec task annotate <feature> <task-id> <text> [--type deviation|context|blocker-resolved]'));
866
+ process.exit(1);
867
+ }
868
+
869
+ await manager.annotateTask(featureName, taskId, text, type);
870
+ break;
871
+ }
872
+
873
+ default:
874
+ console.error(chalk.red(`Unknown command: ${command}`));
875
+ console.log(chalk.gray('\nAvailable commands:'));
876
+ console.log(chalk.gray(' done <feature> <task-id...> - Mark tasks as completed (validation gate)'));
877
+ console.log(chalk.gray(' bulk-done <feature> --all - Mark all pending tasks as completed'));
878
+ console.log(chalk.gray(' bulk-done <feature> --from T001 --to T053 - Mark range as completed'));
879
+ console.log(chalk.gray(' bulk-done <feature> T001..T082 - Mark range as completed'));
880
+ console.log(chalk.gray(' start <feature> <task-id> - Start a task (dispatch gate + state tracking)'));
881
+ console.log(chalk.gray(' annotate <feature> <task-id> <text> - Add a note to a task'));
882
+ console.log(chalk.gray('\nFlags: --skip-validation --reason <text>, --note <text>, --dry-run'));
883
+ process.exit(1);
884
+ }
885
+ } catch (error) {
886
+ console.error(chalk.red(`\n❌ Error: ${error.message}`));
887
+ process.exit(1);
888
+ }
889
+ }
890
+
891
+ // ESM equivalent of `if (require.main === module)`
892
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
893
+ main();
894
+ }
895
+
896
+ /**
897
+ * Pure predicate: should a --skip-validation bypass warning be logged?
898
+ *
899
+ * @param {Array} tasksToComplete - Tasks that will be marked complete
900
+ * @param {boolean} skipValidation - Whether --skip-validation flag is active
901
+ * @param {boolean} dryRun - Whether --dry-run is active (has its own message)
902
+ * @returns {boolean}
903
+ */
904
+ export function shouldLogSkipValidationWarning(tasksToComplete, skipValidation, dryRun) {
905
+ return tasksToComplete.length > 0 && !!skipValidation && !dryRun;
906
+ }
907
+
908
+ /**
909
+ * Compute the skip-validation budget for a feature.
910
+ * Formula: clamp(ceil(totalTasks * 0.20), 1, 5)
911
+ *
912
+ * @param {Object} feature - Feature state object
913
+ * @returns {number} Maximum allowed skip-validation uses
914
+ */
915
+ export function computeSkipBudget(feature) {
916
+ const total = feature.tasks?.total || 0;
917
+ if (total === 0) return 1;
918
+ return Math.max(1, Math.min(5, Math.ceil(total * 0.2)));
919
+ }
920
+
921
+ /**
922
+ * Test Task Guard — run project test suite before completing test-keyword tasks.
923
+ *
924
+ * Extracted as a pure, injectable function so it can be unit-tested without
925
+ * spawning a full TaskManager or writing state to disk.
926
+ *
927
+ * @param {Array} tasksToComplete - Tasks about to be marked complete
928
+ * @param {string} projectPath - Project root (process.cwd() in production)
929
+ * @param {object} [opts]
930
+ * @param {boolean} [opts.skipValidation] - Skip the guard entirely
931
+ * @param {boolean} [opts.dryRun] - Skip the guard entirely
932
+ * @param {Function} [opts._runTestSuite] - Override runTestSuite for testing
933
+ * @param {Function} [opts._exit] - Override process.exit for testing
934
+ * @returns {void} Calls _exit/process.exit(1) on failure; returns normally otherwise
935
+ */
936
+ export function runTestGuard(tasksToComplete, projectPath, opts = {}) {
937
+ if (!tasksToComplete.length || opts.skipValidation || opts.dryRun) return;
938
+
939
+ const _runTS = opts._runTestSuite ?? runTestSuite;
940
+ const hasTestTask = tasksToComplete.some(t => isTestTask(t.title));
941
+ if (!hasTestTask) return;
942
+
943
+ const suiteResult = _runTS(projectPath);
944
+
945
+ if (suiteResult.passed === false) {
946
+ console.error(chalk.red('\n❌ Test suite FAILED — tasks NOT marked as complete'));
947
+ if (suiteResult.output) {
948
+ console.error(chalk.gray(suiteResult.output.slice(-2000)));
949
+ }
950
+ console.log(chalk.gray(' Fix failing tests, then run task done again'));
951
+ console.log(chalk.gray(' Or use --skip-validation to bypass (not recommended)\n'));
952
+ (opts._exit ?? process.exit)(1);
953
+ } else if (suiteResult.skipped) {
954
+ console.log(chalk.yellow('\n⚠️ Test suite skipped — no testCommand configured'));
955
+ console.log(chalk.gray(` Reason: ${suiteResult.reason}`));
956
+ console.log(chalk.gray(' Set project.testCommand in .morph/config/config.json to enable auto-run\n'));
957
+ } else {
958
+ console.log(chalk.green('\n✅ Test suite passed'));
959
+ }
960
+ }
961
+
962
+ export default TaskManager;