@polymorphism-tech/morph-spec 4.16.0 → 4.17.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 (98) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +3 -0
  3. package/bin/task-manager.js +999 -962
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/agents/ai-agents/vector-search-expert.md +247 -233
  8. package/framework/agents/backend/ms-agent-expert.md +1 -1
  9. package/framework/agents/backend/neon-expert.md +397 -0
  10. package/framework/agents/frontend/gsap-animator.md +131 -0
  11. package/framework/agents.json +1175 -1085
  12. package/framework/commands/morph-preflight.md +200 -192
  13. package/framework/hooks/claude-code/session-start/inject-morph-context.js +1 -1
  14. package/framework/rules/gsap-standards.md +13 -0
  15. package/framework/rules/neon-standards.md +25 -0
  16. package/framework/skills/level-0-meta/mcp-registry.json +1 -1
  17. package/framework/skills/level-0-meta/morph-agent-teams/SKILL.md +472 -472
  18. package/framework/skills/level-0-meta/morph-init/SKILL.md +11 -11
  19. package/framework/skills/level-0-meta/morph-post-implementation/SKILL.md +5 -5
  20. package/framework/skills/level-0-meta/morph-tool-usage-guide/SKILL.md +5 -5
  21. package/framework/skills/level-0-meta/morph-tool-usage-guide/references/tools-per-phase.md +7 -7
  22. package/framework/skills/level-1-workflows/morph-phase-codebase-analysis/SKILL.md +24 -24
  23. package/framework/skills/level-1-workflows/morph-phase-design/SKILL.md +12 -12
  24. package/framework/skills/level-1-workflows/morph-phase-implement/SKILL.md +9 -9
  25. package/framework/skills/level-1-workflows/morph-phase-plan/SKILL.md +1 -1
  26. package/framework/skills/level-1-workflows/morph-phase-tasks/SKILL.md +1 -1
  27. package/framework/skills/level-1-workflows/morph-phase-tasks/references/task-planning-patterns.md +143 -143
  28. package/framework/skills/level-1-workflows/morph-phase-uiux/SKILL.md +1 -1
  29. package/framework/skills/level-1-workflows/morph-scope-escalation/SKILL.md +1 -1
  30. package/framework/standards/STANDARDS.json +184 -107
  31. package/framework/standards/ai-agents/production.md +1 -0
  32. package/framework/standards/ai-agents/setup.md +3 -2
  33. package/framework/standards/ai-agents/workflows.md +1 -0
  34. package/framework/standards/architecture/vertical-slice/vertical-slice.md +4 -4
  35. package/framework/standards/backend/database/postgresql/database.md +58 -78
  36. package/framework/standards/backend/dotnet/scalar-ui.md +172 -172
  37. package/framework/standards/backend/dotnet/vsa-handler-patterns.md +274 -274
  38. package/framework/standards/backend/integrations/asaas/asaas-api.md +1 -1
  39. package/framework/standards/backend/integrations/clerk/clerk-auth.md +23 -21
  40. package/framework/standards/frontend/design-system/animations.md +9 -0
  41. package/framework/standards/frontend/gsap/gsap-core.md +463 -0
  42. package/framework/standards/frontend/gsap/gsap-plugins.md +294 -0
  43. package/framework/standards/frontend/gsap/gsap-scrolltrigger.md +318 -0
  44. package/framework/standards/frontend/nextjs/motion-patterns.md +1 -0
  45. package/framework/standards/frontend/nextjs/nextjs-patterns.md +47 -37
  46. package/framework/standards/infrastructure/docker/easypanel-deploy.md +4 -8
  47. package/framework/standards/infrastructure/neon/neon-pgrag.md +239 -0
  48. package/framework/standards/infrastructure/neon/neon-pgvector.md +236 -0
  49. package/framework/standards/infrastructure/neon/neon-semantic-kernel.md +310 -0
  50. package/framework/standards/infrastructure/neon/neon-setup.md +186 -0
  51. package/framework/standards/integration/mcp/mcp-tools.md +32 -34
  52. package/framework/templates/README.md +18 -18
  53. package/framework/templates/REGISTRY.json +49 -96
  54. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  55. package/framework/templates/code/dotnet/backend/repository.cs +148 -148
  56. package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +2 -2
  57. package/framework/templates/code/dotnet/database/vector-entity.cs +93 -0
  58. package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +1 -1
  59. package/framework/templates/code/sql/{supabase-migration.sql → postgresql-migration.sql} +8 -17
  60. package/framework/templates/code/sql/{supabase-migration.template.sql → postgresql-migration.template.sql} +10 -13
  61. package/framework/templates/code/sql/{rls-policy.sql → postgresql-rls-policy.sql} +8 -8
  62. package/framework/templates/code/typescript/contracts.ts +167 -168
  63. package/framework/templates/context/CONTEXT.md +3 -3
  64. package/framework/templates/docs/proposal.md +6 -6
  65. package/framework/templates/docs/ui-components.md +1 -1
  66. package/framework/templates/feature/decisions.md +121 -121
  67. package/framework/templates/feature/recap.md +145 -145
  68. package/framework/templates/feature/tasks.md +199 -199
  69. package/framework/templates/frontend/nextjs/gsap-scroll-section.tsx.hbs +44 -0
  70. package/framework/templates/infrastructure/docker/docker-compose.template.yml +50 -67
  71. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  72. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  73. package/framework/templates/infrastructure/docker/easypanel.template.json +46 -54
  74. package/framework/templates/infrastructure/github/README.md +56 -56
  75. package/framework/templates/integrations/semantic-kernel-neon.cs +125 -0
  76. package/framework/workflows/README.md +1 -1
  77. package/framework/workflows/docs/full-morph.md +69 -69
  78. package/package.json +1 -1
  79. package/src/commands/agents/dispatch-agents.js +19 -15
  80. package/src/commands/state/advance-phase.js +5 -0
  81. package/src/commands/tasks/task.js +3 -0
  82. package/src/core/state/state-manager.js +45 -1
  83. package/src/core/templates/template-registry.js +1 -66
  84. package/src/lib/detectors/index.js +2 -2
  85. package/src/lib/generators/architecture-scanner.js +640 -640
  86. package/src/lib/installers/mcp-installer.js +1 -1
  87. package/src/lib/tasks/task-classifier.js +15 -1
  88. package/src/lib/tasks/test-runner.js +5 -1
  89. package/src/lib/validators/content/content-validator.js +9 -10
  90. package/src/lib/validators/validation-runner.js +7 -5
  91. package/framework/agents/backend/supabase-expert.md +0 -256
  92. package/framework/rules/supabase-standards.md +0 -29
  93. package/framework/standards/infrastructure/supabase/mcp-setup.md +0 -142
  94. package/framework/standards/infrastructure/supabase/supabase-auth.md +0 -176
  95. package/framework/standards/infrastructure/supabase/supabase-custom-jwt.md +0 -206
  96. package/framework/standards/infrastructure/supabase/supabase-pgvector.md +0 -169
  97. package/framework/standards/infrastructure/supabase/supabase-rls.md +0 -184
  98. package/framework/standards/infrastructure/supabase/supabase-storage.md +0 -153
@@ -1,962 +1,999 @@
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;
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, withStateLock } from '../src/core/state/state-manager.js';
17
+ import { ensureTaskList, syncCounters } from '../src/lib/tasks/task-parser.js';
18
+ import { isTestTask, isInfrastructureTask } 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
+ // Auto-start if not already in_progress (BUG-007)
162
+ if (task.status !== 'in_progress') {
163
+ task.status = 'in_progress';
164
+ task.startedAt = new Date().toISOString();
165
+ const agentId = resolveAgentForTask(featureName, task.id);
166
+ if (agentId) task.assignedAgent = agentId;
167
+ console.log(chalk.gray(` ▶ Auto-started ${task.id} (was ${task.status || 'pending'})`));
168
+ }
169
+
170
+ tasksToComplete.push(task);
171
+ }
172
+
173
+ // ─── Test Task Guard ────────────────────────────────────────────────────────
174
+ // When any of the tasks being completed is a test/validation task (e.g.
175
+ // "Testes e Validação"), run the project's test suite BEFORE code validation.
176
+ // The guard is skipped when --skip-validation or --dry-run is active.
177
+ runTestGuard(tasksToComplete, process.cwd(), options);
178
+
179
+ // ─── Skip-Validation Budget Enforcement ───────────────────────────────────
180
+ if (options.skipValidation && tasksToComplete.length > 0 && !options.dryRun) {
181
+ // Require --reason with minimum 5 characters
182
+ if (!options.reason || options.reason.trim().length < 5) {
183
+ console.error(chalk.red('❌ --skip-validation requires --reason <text> (minimum 5 characters)'));
184
+ console.error(chalk.gray(' Example: morph-spec task done feat T001 --skip-validation --reason "CSS-only change, no logic"'));
185
+ process.exit(1);
186
+ }
187
+
188
+ // Check budget
189
+ const budget = computeSkipBudget(feature);
190
+ const used = (feature.skipValidationLog || []).length;
191
+ if (used >= budget) {
192
+ console.error(chalk.red(`❌ Skip-validation budget exhausted (${used}/${budget})`));
193
+ console.error(chalk.gray(' All remaining tasks must pass Tier-4 validation.'));
194
+ console.error(chalk.gray(' Budget formula: min(5, ceil(totalTasks × 0.20))'));
195
+ process.exit(1);
196
+ }
197
+
198
+ console.log(chalk.yellow(`⚠ --skip-validation: using budget (${used + tasksToComplete.length}/${budget})`));
199
+ }
200
+
201
+ // Run validation BEFORE marking tasks as complete
202
+ const allInfra = tasksToComplete.every(t => isInfrastructureTask(t.title));
203
+ if (tasksToComplete.length > 0 && !options.skipValidation) {
204
+ const validationOpts = { mode: 'light' };
205
+ if (allInfra) validationOpts.skipContracts = true;
206
+ const validationResult = await this.runValidation(featureName, validationOpts);
207
+ if (options.dryRun) {
208
+ console.log(chalk.cyan('\n ℹ️ Dry-run — tasks NOT marked as complete'));
209
+ return [];
210
+ }
211
+ if (!validationResult.passed) {
212
+ // Reload state to ensure we have the latest (may have been modified by validation)
213
+ const currentState = loadState();
214
+ const currentFeature = currentState.features[featureName];
215
+ if (currentFeature) {
216
+ for (const task of tasksToComplete) {
217
+ this.persistValidationHistory(currentFeature, task.id, validationResult);
218
+ }
219
+ saveState(currentState);
220
+
221
+ // Check escalation status
222
+ const blockedTasks = tasksToComplete.filter(t => {
223
+ const hist = currentFeature.validationHistory?.[t.id];
224
+ return hist?.status === 'blocked';
225
+ });
226
+ if (blockedTasks.length > 0) {
227
+ console.error(chalk.red(`\n⛔ ESCALATION — Task(s) ${blockedTasks.map(t => t.id).join(', ')} have failed 3 times`));
228
+ console.error(chalk.red(' Human review required. Mark resolved with: morph-spec state set <feature> validationHistory.<taskId>.status passed'));
229
+ } else {
230
+ const attempt = currentFeature.validationHistory?.[tasksToComplete[0]?.id]?.attempt || 1;
231
+ console.error(chalk.red(`\n❌ Validation failed (attempt ${attempt}/3) — tasks NOT marked as complete`));
232
+ console.log(chalk.gray(' Fix the issues above, then run task done again'));
233
+ if (attempt >= 2) {
234
+ console.log(chalk.yellow(` ⚠️ Next failure will escalate to human review`));
235
+ }
236
+ }
237
+ } else {
238
+ console.error(chalk.red('\n❌ Validation failed — tasks NOT marked as complete'));
239
+ console.log(chalk.gray(' Fix the issues above, then run task done again'));
240
+ }
241
+ console.log(chalk.gray(' Or use --skip-validation to bypass (not recommended)\n'));
242
+ process.exit(1);
243
+ }
244
+
245
+ // Validation passed — always record passed status in validationHistory.
246
+ // Update in-place on `feature` (same object as state.features[featureName]) so
247
+ // the single saveState(state) at the end of the function persists everything
248
+ // atomically. A separate saveState here would trigger a second renameSync on
249
+ // Windows, risking EPERM and leaving state.json without the task completions
250
+ // (the count bug), and would also be clobbered by the final save anyway.
251
+ if (!feature.validationHistory) feature.validationHistory = {};
252
+ for (const task of tasksToComplete) {
253
+ feature.validationHistory[task.id] = {
254
+ ...(feature.validationHistory[task.id] || {}),
255
+ status: 'passed',
256
+ updatedAt: new Date().toISOString(),
257
+ };
258
+ }
259
+ } else if (options.dryRun) {
260
+ console.log(chalk.cyan('\n ℹ️ Dry-run — tasks NOT marked as complete (validation skipped)'));
261
+ return [];
262
+ } else if (shouldLogSkipValidationWarning(tasksToComplete, options.skipValidation, options.dryRun)) {
263
+ // Record skip in state: skipValidationLog + validationHistory
264
+ if (!feature.skipValidationLog) feature.skipValidationLog = [];
265
+ if (!feature.validationHistory) feature.validationHistory = {};
266
+ for (const task of tasksToComplete) {
267
+ feature.skipValidationLog.push({
268
+ taskId: task.id,
269
+ reason: options.reason,
270
+ timestamp: new Date().toISOString(),
271
+ });
272
+ feature.validationHistory[task.id] = {
273
+ ...(feature.validationHistory[task.id] || {}),
274
+ attempt: 0,
275
+ status: 'skipped',
276
+ reason: options.reason,
277
+ updatedAt: new Date().toISOString(),
278
+ };
279
+ }
280
+
281
+ console.log(chalk.yellow('\n⚠ --skip-validation: Tier-4 code validators bypassed — tasks will be marked complete without validation'));
282
+ console.log(chalk.gray(' Re-run without --skip-validation before committing.\n'));
283
+ }
284
+
285
+ // Breaking change detection (non-blocking warning)
286
+ if (tasksToComplete.length > 0 && !allInfra) {
287
+ const _detectBreaking = options._detectBreakingChanges ?? detectBreakingChanges;
288
+ const breakingChanges = await _detectBreaking();
289
+ if (breakingChanges && breakingChanges.length > 0) {
290
+ console.log(chalk.yellow('\n⚠️ BREAKING CHANGE DETECTION:'));
291
+ console.log(chalk.yellow(' Removed exports with active consumers found — review before completing:\n'));
292
+ for (const { export: sym, consumers } of breakingChanges) {
293
+ console.log(chalk.yellow(` • "${sym}" removed — used by:`));
294
+ consumers.slice(0, 5).forEach(f => console.log(chalk.gray(` - ${f}`)));
295
+ if (consumers.length > 5) {
296
+ console.log(chalk.gray(` … and ${consumers.length - 5} more files`));
297
+ }
298
+ }
299
+ console.log(chalk.yellow('\n Fix broken imports before the smoke test, or use --skip-validation to bypass.\n'));
300
+ }
301
+ }
302
+
303
+ // Mark tasks as completed with agent correlation
304
+ for (const task of tasksToComplete) {
305
+ task.status = 'completed';
306
+ task.completedAt = new Date().toISOString();
307
+
308
+ // Resolve agent from dispatch (falls back to 'claude')
309
+ const agentId = resolveAgentForTask(featureName, task.id);
310
+ task.completedBy = agentId || 'claude';
311
+
312
+ // Add deviation note if provided
313
+ if (options.note) {
314
+ if (!task.notes) task.notes = [];
315
+ task.notes.push({
316
+ text: options.note,
317
+ type: 'deviation',
318
+ addedAt: new Date().toISOString(),
319
+ addedBy: agentId || 'claude'
320
+ });
321
+ }
322
+
323
+ results.push(task);
324
+
325
+ console.log(chalk.green(`✅ Task ${task.id} completed!`));
326
+ if (options.note) {
327
+ console.log(chalk.gray(` Note: ${options.note}`));
328
+ }
329
+
330
+ // Register checkpoint if task has one
331
+ if (task.checkpoint) {
332
+ this.registerCheckpoint(feature, task);
333
+ }
334
+ }
335
+
336
+ // Sync v3 counters
337
+ syncCounters(feature);
338
+
339
+ // Auto-checkpoint every 3 tasks (at exact multiples of 3, not after every task).
340
+ // Uses feature._lastAutoCheckpointNum to track which threshold already fired.
341
+ const activeTasks = taskList.filter(t => t.status !== 'expanded');
342
+ const completedCount = activeTasks.filter(t => t.status === 'completed').length;
343
+ if (completedCount > 0 && completedCount % 3 === 0) {
344
+ const checkpointNum = Math.floor(completedCount / 3);
345
+ const lastAutoNum = feature._lastAutoCheckpointNum || 0;
346
+ if (checkpointNum > lastAutoNum) {
347
+ const recentCompleted = this.getRecentCompleted(taskList, 3);
348
+ await this.autoCheckpoint(feature, recentCompleted, featureName);
349
+ feature._lastAutoCheckpointNum = checkpointNum;
350
+ }
351
+ }
352
+
353
+ // Persist state (atomic write via state-manager)
354
+ feature.updatedAt = new Date().toISOString();
355
+ saveState(state);
356
+
357
+ // Run TaskCompleted agent-teams hook for each completed task (non-blocking)
358
+ for (const task of results) {
359
+ try {
360
+ const { executeHook, formatHookResults } = await import('../src/lib/hooks/hook-executor.js');
361
+ const hookResult = await executeHook(process.cwd(), featureName, 'TaskCompleted', {
362
+ taskId: task.id
363
+ });
364
+ if (!hookResult.passed && hookResult.errors.length > 0) {
365
+ console.log(formatHookResults(hookResult, 'TaskCompleted'));
366
+ }
367
+ } catch {
368
+ // Hook executor unavailable — non-blocking
369
+ }
370
+ }
371
+
372
+ // Auto-generate metadata.json (for quick LLM access)
373
+ await this.generateMetadata(featureName, feature);
374
+
375
+ // Emit VALIDATION DISPATCH for PostToolUse hook (strategy 1 parsing).
376
+ // Skipped when --skip-validation is active (no validators needed).
377
+ if (!options.skipValidation && results.length > 0) {
378
+ try {
379
+ const { emitValidatorDispatch } = await import('../src/lib/validators/shared/emit-validator-dispatch.js');
380
+ const phase = feature.phase || 'implement';
381
+ await emitValidatorDispatch(featureName, phase, process.cwd());
382
+ } catch {
383
+ // Non-blocking
384
+ }
385
+ }
386
+
387
+ return results;
388
+ }
389
+
390
+ /**
391
+ * Run validation for a feature using the ValidationRunner (ESM dynamic import).
392
+ * Returns a structured result with per-validator breakdown for validationHistory.
393
+ *
394
+ * @param {string} featureName - Feature name
395
+ * @returns {{ passed: boolean, validators: Object, passRate: number }}
396
+ */
397
+ async runValidation(featureName, opts = {}) {
398
+ try {
399
+ console.log(chalk.cyan('\n🔍 Running code validation...'));
400
+
401
+ const { runValidation, formatValidationResults } = await import('../src/lib/validators/validation-runner.js');
402
+ const result = await runValidation('.', featureName, { verbose: true, ...opts });
403
+
404
+ formatValidationResults(result);
405
+
406
+ // Build structured validators map from result
407
+ const validators = {};
408
+ if (result.results && typeof result.results === 'object') {
409
+ for (const [name, vResult] of Object.entries(result.results)) {
410
+ const issues = (vResult.errors || []).map(e => ({
411
+ message: typeof e === 'string' ? e : (e.message || String(e)),
412
+ file: e.file,
413
+ line: e.line,
414
+ rule: e.rule,
415
+ }));
416
+ validators[name] = {
417
+ passed: vResult.passed ?? (issues.length === 0),
418
+ issues,
419
+ };
420
+ }
421
+ } else if (!result.passed && result.errors?.length > 0) {
422
+ // Flat format fallback — wrap as single 'validation' validator
423
+ validators['validation'] = {
424
+ passed: false,
425
+ issues: result.errors.map(e => ({
426
+ message: typeof e === 'string' ? e : (e.message || String(e)),
427
+ file: e.file,
428
+ line: e.line,
429
+ rule: e.rule || 'validation',
430
+ })),
431
+ };
432
+ }
433
+
434
+ const validatorEntries = Object.values(validators);
435
+ const passRate = validatorEntries.length > 0
436
+ ? validatorEntries.filter(v => v.passed).length / validatorEntries.length
437
+ : (result.passed ? 1.0 : 0.0);
438
+
439
+ return { passed: result.passed, validators, passRate };
440
+ } catch (error) {
441
+ // If validation runner fails to load, warn but don't block task completion.
442
+ // This is fail-open by design: a broken validator shouldn't block commits.
443
+ // Common cause: missing optional deps.
444
+ console.log(chalk.yellow(`\n⚠️ Validation skipped (${error.message})`));
445
+ console.log(chalk.gray(` Run manually: morph-spec validate-feature ${featureName}`));
446
+ return { passed: true, validators: {}, passRate: 1.0 };
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Persist a validation result to feature.validationHistory[taskId].
452
+ * Increments attempt counter and sets status to 'failed' or 'blocked' (attempt >= 3).
453
+ *
454
+ * @param {Object} feature - Mutable feature state object
455
+ * @param {string} taskId - Task ID
456
+ * @param {{ passed: boolean, validators: Object, passRate: number }} validationResult
457
+ */
458
+ persistValidationHistory(feature, taskId, validationResult) {
459
+ if (!feature.validationHistory) {
460
+ feature.validationHistory = {};
461
+ }
462
+ const existing = feature.validationHistory[taskId] || { attempt: 0 };
463
+ const attempt = (existing.attempt || 0) + 1;
464
+ const status = attempt >= 3 ? 'blocked' : 'failed';
465
+
466
+ feature.validationHistory[taskId] = {
467
+ attempt,
468
+ validators: validationResult.validators || {},
469
+ passRate: validationResult.passRate || 0,
470
+ status,
471
+ updatedAt: new Date().toISOString(),
472
+ };
473
+ }
474
+
475
+ /**
476
+ * Register checkpoint (from task flag)
477
+ */
478
+ registerCheckpoint(feature, task) {
479
+ const checkpoint = {
480
+ id: task.checkpoint,
481
+ timestamp: new Date().toISOString(),
482
+ tasksCompleted: [task.id],
483
+ note: `Checkpoint: ${task.title}`,
484
+ passed: true // reaching a named checkpoint means the task completed successfully
485
+ };
486
+
487
+ feature.checkpoints = feature.checkpoints || [];
488
+ feature.checkpoints.push(checkpoint);
489
+
490
+ console.log(chalk.magenta(`\n🎯 ${task.checkpoint} reached!`));
491
+ }
492
+
493
+ /**
494
+ * Auto-checkpoint every 3 tasks (includes validation summary)
495
+ */
496
+ async autoCheckpoint(feature, tasks, featureName) {
497
+ const tasksCompleted = feature.tasks?.completed || tasks.length;
498
+ const checkpointNum = Math.floor(tasksCompleted / 3);
499
+
500
+ console.log(chalk.magenta(`\n🔍 CHECKPOINT ${checkpointNum} - Running automated validation...`));
501
+
502
+ let checkpointResult = null;
503
+ let validationNote = '';
504
+
505
+ try {
506
+ const { runCheckpointHooks, shouldRunCheckpoint } = await import('../src/lib/checkpoints/checkpoint-hooks.js');
507
+
508
+ if (shouldRunCheckpoint(tasksCompleted, 3)) {
509
+ checkpointResult = await runCheckpointHooks(featureName, checkpointNum);
510
+
511
+ if (checkpointResult.passed) {
512
+ validationNote = ' | ✓ All validations passed';
513
+ } else {
514
+ validationNote = ` | ✗ ${checkpointResult.summary.errors} errors, ${checkpointResult.summary.warnings} warnings`;
515
+
516
+ const config = await this.loadCheckpointConfig();
517
+ if (config.checkpoints?.onFailure?.blockProgress && checkpointResult.summary.errors > 0) {
518
+ console.log(chalk.red('\n❌ Checkpoint FAILED - Fix violations before proceeding'));
519
+ throw new Error('Checkpoint validation failed');
520
+ }
521
+ }
522
+ }
523
+ } catch (error) {
524
+ if (error.message === 'Checkpoint validation failed') {
525
+ throw error;
526
+ }
527
+ // Fallback to old validation if checkpoint-hooks not available
528
+ console.log(chalk.yellow('⚠️ Checkpoint hooks not available, using legacy validation'));
529
+ try {
530
+ const { runValidation } = await import('../src/lib/validators/validation-runner.js');
531
+ const result = await runValidation('.', featureName, { verbose: false, mode: 'full' });
532
+ validationNote = result.passed
533
+ ? ' | Validation: PASSED'
534
+ : ` | Validation: ${result.errors} errors, ${result.warnings} warnings`;
535
+
536
+ // Persist checkpoint validation results to disk
537
+ try {
538
+ const { persistCheckpointValidation } = await import('../src/lib/validators/checkpoint-persistence.js');
539
+ persistCheckpointValidation(process.cwd(), featureName, {
540
+ checkpointNum,
541
+ timestamp: new Date().toISOString(),
542
+ passed: result.passed,
543
+ validators: result.results || {},
544
+ summary: result.summary || '',
545
+ });
546
+ } catch {
547
+ // Non-blocking
548
+ }
549
+ } catch {
550
+ // No validation available
551
+ }
552
+ }
553
+
554
+ const checkpoint = {
555
+ id: `CHECKPOINT_AUTO_${Date.now()}`,
556
+ timestamp: new Date().toISOString(),
557
+ tasksCompleted: tasks.map(t => t.id),
558
+ note: `Auto-checkpoint: ${tasks.length} tasks completed${validationNote}`,
559
+ passed: checkpointResult?.passed ?? true, // null checkpointResult means no validator ran → assume passed
560
+ validationResults: checkpointResult?.results || null
561
+ };
562
+
563
+ feature.checkpoints = feature.checkpoints || [];
564
+ feature.checkpoints.push(checkpoint);
565
+
566
+ if (checkpointResult?.passed) {
567
+ console.log(chalk.green(`\n✅ Checkpoint ${checkpointNum} PASSED`));
568
+ } else if (checkpointResult) {
569
+ console.log(chalk.yellow(`\n⚠️ Checkpoint ${checkpointNum} completed with warnings`));
570
+ } else {
571
+ console.log(chalk.magenta(`\n🎉 Auto-checkpoint ${checkpointNum} reached!`));
572
+ }
573
+ }
574
+
575
+ /**
576
+ * Load checkpoint configuration from llm-interaction.json
577
+ */
578
+ async loadCheckpointConfig() {
579
+ try {
580
+ const { readFile } = await import('fs/promises');
581
+ const configPath = join(process.cwd(), '.morph/config/llm-interaction.json');
582
+ const content = await readFile(configPath, 'utf-8');
583
+ return JSON.parse(content);
584
+ } catch {
585
+ return {
586
+ checkpoints: {
587
+ frequency: 3,
588
+ autoValidate: true,
589
+ onFailure: { blockProgress: false }
590
+ }
591
+ };
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Auto-generate metadata.json after task completion
597
+ */
598
+ async generateMetadata(featureName, feature) {
599
+ try {
600
+ const config = await this.loadCheckpointConfig();
601
+
602
+ if (!config.metadata?.autoGenerate) {
603
+ return;
604
+ }
605
+
606
+ const { extractFeatureMetadata } = await import('../src/lib/generators/metadata-extractor.js');
607
+ const metadata = extractFeatureMetadata(feature);
608
+
609
+ const outputPath = join(process.cwd(), `.morph/features/${featureName}/metadata.json`);
610
+
611
+ const { mkdir, writeFile } = await import('fs/promises');
612
+ await mkdir(dirname(outputPath), { recursive: true });
613
+ await writeFile(outputPath, JSON.stringify(metadata, null, 2), 'utf-8');
614
+
615
+ console.log(chalk.gray(` 📊 Metadata updated: .morph/features/${featureName}/metadata.json`));
616
+ } catch (error) {
617
+ console.log(chalk.yellow(` ⚠️ Metadata generation failed: ${error.message}`));
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Get recently completed tasks (last N)
623
+ */
624
+ getRecentCompleted(tasks, count) {
625
+ const completed = tasks
626
+ .filter(t => t.status === 'completed' && t.completedAt)
627
+ .sort((a, b) => new Date(b.completedAt) - new Date(a.completedAt));
628
+
629
+ return completed.slice(0, count);
630
+ }
631
+
632
+ /**
633
+ * Start a task (mark as in_progress).
634
+ * Primarily serves as dispatch enforcement gate during implement phase.
635
+ */
636
+ async startTask(featureName, taskId) {
637
+ const state = loadState();
638
+ const feature = state.features[featureName];
639
+
640
+ if (!feature) {
641
+ throw new Error(`Feature '${featureName}' not found`);
642
+ }
643
+
644
+ // Dispatch enforcement gate: block task start in implement phase
645
+ // unless dispatch-agents has been run (recorded in dispatchLog)
646
+ const featurePath = join(process.cwd(), '.morph', 'features', featureName);
647
+ const currentPhase = feature.phase || derivePhase(featurePath);
648
+ if (currentPhase === 'implement') {
649
+ const hasImplementDispatch = Array.isArray(feature.dispatchLog) &&
650
+ feature.dispatchLog.some(entry => entry.phase === 'implement');
651
+ if (!hasImplementDispatch) {
652
+ console.error(chalk.red('\n✗ Cannot start task — agent dispatch not recorded for implement phase'));
653
+ console.error(chalk.yellow(
654
+ `\n Run this command first:\n` +
655
+ ` morph-spec dispatch-agents ${featureName} implement\n\n` +
656
+ ` This configures typed specialist agents with enriched prompts,\n` +
657
+ ` task assignments, and quality standards for parallel execution.\n`
658
+ ));
659
+ process.exit(1);
660
+ }
661
+ }
662
+
663
+ const taskList = await ensureTaskList(feature, featureName);
664
+
665
+ if (taskList.length === 0) {
666
+ const tasksPath = join(process.cwd(), `.morph/features/${featureName}/4-tasks/tasks.md`);
667
+ const tasksExist = await access(tasksPath).then(() => true).catch(() => false);
668
+ if (!tasksExist) {
669
+ throw new Error(`No tasks found for '${featureName}' — tasks.md not generated yet.\n Complete the tasks phase first: run /phase-tasks`);
670
+ }
671
+ 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`);
672
+ }
673
+
674
+ const task = taskList.find(t => t.id === taskId);
675
+
676
+ if (!task) {
677
+ throw new Error(`Task ${taskId} not found in '${featureName}' (${taskList.length} tasks available: ${taskList.map(t => t.id).join(', ')})`);
678
+ }
679
+
680
+ if (task.status === 'completed') {
681
+ console.log(chalk.yellow(`⚠️ Task ${taskId} already completed`));
682
+ return;
683
+ }
684
+
685
+ task.status = 'in_progress';
686
+ task.startedAt = new Date().toISOString();
687
+
688
+ // Agent correlation: resolve which agent is assigned this task from the latest dispatch
689
+ const agentId = resolveAgentForTask(featureName, taskId);
690
+ if (agentId) {
691
+ task.assignedAgent = agentId;
692
+ }
693
+
694
+ syncCounters(feature);
695
+ feature.updatedAt = new Date().toISOString();
696
+
697
+ saveState(state);
698
+
699
+ console.log(chalk.blue(`▶️ Task ${taskId} started: ${task.title}`));
700
+ }
701
+
702
+ /**
703
+ * Bulk-complete tasks with a single validation pass.
704
+ * @param {string} featureName
705
+ * @param {Object} opts
706
+ * @param {boolean} [opts.all] - Complete all pending tasks
707
+ * @param {string} [opts.from] - Start of range (e.g. T001)
708
+ * @param {string} [opts.to] - End of range (e.g. T053)
709
+ * @param {string} [opts.range] - Compact range string (e.g. T001..T082)
710
+ * @param {boolean} [opts.skipValidation]
711
+ * @param {boolean} [opts.dryRun]
712
+ */
713
+ async bulkCompleteTasks(featureName, opts = {}) {
714
+ const state = loadState();
715
+ const feature = state.features[featureName];
716
+ if (!feature) throw new Error(`Feature '${featureName}' not found in state.json`);
717
+
718
+ const taskList = await ensureTaskList(feature, featureName);
719
+ if (taskList.length === 0) throw new Error(`No tasks found for '${featureName}'`);
720
+
721
+ // Resolve task IDs from range options
722
+ let targetIds;
723
+ if (opts.all) {
724
+ targetIds = taskList.filter(t => t.status !== 'completed' && t.status !== 'expanded').map(t => t.id);
725
+ } else if (opts.from && opts.to) {
726
+ targetIds = this.expandRange(taskList, opts.from, opts.to);
727
+ } else if (opts.range && opts.range.includes('..')) {
728
+ const [from, to] = opts.range.split('..');
729
+ targetIds = this.expandRange(taskList, from.trim(), to.trim());
730
+ } else {
731
+ throw new Error('bulk-done requires --all, --from/--to, or a T001..T082 range argument');
732
+ }
733
+
734
+ console.log(chalk.cyan(`\n📦 Bulk-done: ${targetIds.length} task(s) targeted`));
735
+ await this.completeTasks(featureName, targetIds, { skipValidation: opts.skipValidation, reason: opts.reason, note: opts.note, dryRun: opts.dryRun });
736
+ }
737
+
738
+ /**
739
+ * Expand a task range like T001..T053 using zero-padded numeric IDs.
740
+ */
741
+ expandRange(taskList, from, to) {
742
+ const numOf = id => parseInt(id.replace(/\D/g, ''), 10);
743
+ const prefix = from.replace(/\d+$/, '');
744
+ const padLen = from.replace(prefix, '').length;
745
+ const fromNum = numOf(from);
746
+ const toNum = numOf(to);
747
+ const ids = [];
748
+ for (let n = fromNum; n <= toNum; n++) {
749
+ ids.push(prefix + String(n).padStart(padLen, '0'));
750
+ }
751
+ return ids.filter(id => taskList.some(t => t.id === id));
752
+ }
753
+
754
+ /**
755
+ * Add a note to a task (deviation, context, or blocker-resolved)
756
+ * @param {string} featureName - Feature name
757
+ * @param {string} taskId - Task ID
758
+ * @param {string} text - Note text
759
+ * @param {string} [type='deviation'] - Note type: deviation | context | blocker-resolved
760
+ */
761
+ async annotateTask(featureName, taskId, text, type = 'deviation', markDone = false) {
762
+ const validTypes = ['deviation', 'context', 'blocker-resolved'];
763
+ if (!validTypes.includes(type)) {
764
+ throw new Error(`Invalid note type '${type}'. Valid types: ${validTypes.join(', ')}`);
765
+ }
766
+
767
+ const state = loadState();
768
+ const feature = state.features[featureName];
769
+ if (!feature) throw new Error(`Feature '${featureName}' not found`);
770
+
771
+ const taskList = await ensureTaskList(feature, featureName);
772
+ const task = taskList.find(t => t.id === taskId);
773
+ if (!task) throw new Error(`Task ${taskId} not found in '${featureName}'`);
774
+
775
+ if (!task.notes) task.notes = [];
776
+ task.notes.push({
777
+ text,
778
+ type,
779
+ addedAt: new Date().toISOString(),
780
+ addedBy: 'user'
781
+ });
782
+
783
+ if (markDone && task.status !== 'completed') {
784
+ if (task.status !== 'in_progress') {
785
+ task.status = 'in_progress';
786
+ task.startedAt = new Date().toISOString();
787
+ }
788
+ task.status = 'completed';
789
+ task.completedAt = new Date().toISOString();
790
+ syncCounters(feature);
791
+ }
792
+
793
+ feature.updatedAt = new Date().toISOString();
794
+ saveState(state);
795
+
796
+ console.log(chalk.green(`Added ${type} note to ${taskId}`));
797
+ console.log(chalk.gray(` "${text}"`));
798
+ if (markDone && task.status === 'completed') {
799
+ console.log(chalk.green(`✅ Task ${taskId} marked as completed`));
800
+ }
801
+ }
802
+ }
803
+
804
+ // ============================================================================
805
+ // CLI entry point
806
+ // ============================================================================
807
+
808
+ async function main() {
809
+ const args = process.argv.slice(2);
810
+ const command = args[0];
811
+
812
+ const manager = new TaskManager();
813
+
814
+ try {
815
+ switch (command) {
816
+ case 'done':
817
+ case 'complete': {
818
+ const skipValidation = args.includes('--skip-validation');
819
+ const skipBuild = args.includes('--skip-build');
820
+ const dryRun = args.includes('--dry-run');
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) =>
826
+ !['--skip-validation', '--skip-build', '--dry-run', '--reason', '--note'].includes(a) &&
827
+ !(i > 0 && args[i - 1] === '--reason') &&
828
+ !(i > 0 && args[i - 1] === '--note'));
829
+ const featureName = filteredArgs[1];
830
+ const taskIds = filteredArgs.slice(2);
831
+
832
+ if (!featureName || taskIds.length === 0) {
833
+ console.error(chalk.red('Usage: morph-spec task done <feature> <task-id> [task-id...] [--note <text>] [--skip-validation] [--skip-build] [--reason <text>] [--dry-run]'));
834
+ process.exit(1);
835
+ }
836
+
837
+ await withStateLock(() => manager.completeTasks(featureName, taskIds, { skipValidation, skipBuild, reason, note, dryRun }));
838
+ break;
839
+ }
840
+
841
+ case 'bulk-done': {
842
+ const skipValidation = args.includes('--skip-validation');
843
+ const dryRun = args.includes('--dry-run');
844
+ const allFlag = args.includes('--all');
845
+ const fromIdx = args.indexOf('--from');
846
+ const toIdx = args.indexOf('--to');
847
+ const reasonIdx = args.indexOf('--reason');
848
+ const reason = reasonIdx !== -1 ? args[reasonIdx + 1] : null;
849
+ const noteIdx = args.indexOf('--note');
850
+ const note = noteIdx !== -1 ? args[noteIdx + 1] : null;
851
+ const filteredArgs = args.filter((a, i) => !['--skip-validation', '--dry-run', '--all', '--reason', '--note'].includes(a)
852
+ && !a.startsWith('--from') && !a.startsWith('--to')
853
+ && !(i > 0 && args[i - 1] === '--reason')
854
+ && !(i > 0 && args[i - 1] === '--note'));
855
+ const featureName = filteredArgs[1];
856
+ const rangeArg = filteredArgs[2]; // e.g. T001..T082
857
+
858
+ if (!featureName) {
859
+ 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]'));
860
+ process.exit(1);
861
+ }
862
+
863
+ const fromId = fromIdx !== -1 ? args[fromIdx + 1] : null;
864
+ const toId = toIdx !== -1 ? args[toIdx + 1] : null;
865
+
866
+ await withStateLock(() => manager.bulkCompleteTasks(featureName, { all: allFlag, from: fromId, to: toId, range: rangeArg, skipValidation, reason, note, dryRun }));
867
+ break;
868
+ }
869
+
870
+ case 'start': {
871
+ const featureName = args[1];
872
+ const taskId = args[2];
873
+
874
+ if (!featureName || !taskId) {
875
+ console.error(chalk.red('Usage: morph-spec task start <feature> <task-id>'));
876
+ process.exit(1);
877
+ }
878
+
879
+ await withStateLock(() => manager.startTask(featureName, taskId));
880
+ break;
881
+ }
882
+
883
+ case 'annotate': {
884
+ const featureName = args[1];
885
+ const taskId = args[2];
886
+ const text = args[3];
887
+ const typeIdx = args.indexOf('--type');
888
+ const type = typeIdx !== -1 ? args[typeIdx + 1] : 'deviation';
889
+ const markDone = args.includes('--mark-done');
890
+
891
+ if (!featureName || !taskId || !text) {
892
+ console.error(chalk.red('Usage: morph-spec task annotate <feature> <task-id> <text> [--type deviation|context|blocker-resolved] [--mark-done]'));
893
+ process.exit(1);
894
+ }
895
+
896
+ await manager.annotateTask(featureName, taskId, text, type, markDone);
897
+ break;
898
+ }
899
+
900
+ default:
901
+ console.error(chalk.red(`Unknown command: ${command}`));
902
+ console.log(chalk.gray('\nAvailable commands:'));
903
+ console.log(chalk.gray(' done <feature> <task-id...> - Mark tasks as completed (validation gate)'));
904
+ console.log(chalk.gray(' bulk-done <feature> --all - Mark all pending tasks as completed'));
905
+ console.log(chalk.gray(' bulk-done <feature> --from T001 --to T053 - Mark range as completed'));
906
+ console.log(chalk.gray(' bulk-done <feature> T001..T082 - Mark range as completed'));
907
+ console.log(chalk.gray(' start <feature> <task-id> - Start a task (dispatch gate + state tracking)'));
908
+ console.log(chalk.gray(' annotate <feature> <task-id> <text> - Add a note to a task'));
909
+ console.log(chalk.gray('\nFlags: --skip-validation --reason <text>, --note <text>, --dry-run'));
910
+ process.exit(1);
911
+ }
912
+ } catch (error) {
913
+ console.error(chalk.red(`\n❌ Error: ${error.message}`));
914
+ process.exit(1);
915
+ }
916
+ }
917
+
918
+ // ESM equivalent of `if (require.main === module)`
919
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
920
+ main();
921
+ }
922
+
923
+ /**
924
+ * Pure predicate: should a --skip-validation bypass warning be logged?
925
+ *
926
+ * @param {Array} tasksToComplete - Tasks that will be marked complete
927
+ * @param {boolean} skipValidation - Whether --skip-validation flag is active
928
+ * @param {boolean} dryRun - Whether --dry-run is active (has its own message)
929
+ * @returns {boolean}
930
+ */
931
+ export function shouldLogSkipValidationWarning(tasksToComplete, skipValidation, dryRun) {
932
+ return tasksToComplete.length > 0 && !!skipValidation && !dryRun;
933
+ }
934
+
935
+ /**
936
+ * Compute the skip-validation budget for a feature.
937
+ * Formula: clamp(ceil(totalTasks * 0.20), 1, 5)
938
+ *
939
+ * @param {Object} feature - Feature state object
940
+ * @returns {number} Maximum allowed skip-validation uses
941
+ */
942
+ export function computeSkipBudget(feature) {
943
+ const total = feature.tasks?.total || 0;
944
+ if (total === 0) return 1;
945
+ const isMigration = feature.workflow === 'migration' || feature.workflow === 'refactor'
946
+ || (feature.tags || []).some(t => ['migration', 'refactor'].includes(t));
947
+ const cap = isMigration ? 20 : 5;
948
+ const rate = isMigration ? 0.40 : 0.20;
949
+ return Math.max(1, Math.min(cap, Math.ceil(total * rate)));
950
+ }
951
+
952
+ /**
953
+ * Test Task Guard — run project test suite before completing test-keyword tasks.
954
+ *
955
+ * Extracted as a pure, injectable function so it can be unit-tested without
956
+ * spawning a full TaskManager or writing state to disk.
957
+ *
958
+ * @param {Array} tasksToComplete - Tasks about to be marked complete
959
+ * @param {string} projectPath - Project root (process.cwd() in production)
960
+ * @param {object} [opts]
961
+ * @param {boolean} [opts.skipValidation] - Skip the guard entirely
962
+ * @param {boolean} [opts.dryRun] - Skip the guard entirely
963
+ * @param {Function} [opts._runTestSuite] - Override runTestSuite for testing
964
+ * @param {Function} [opts._exit] - Override process.exit for testing
965
+ * @returns {void} Calls _exit/process.exit(1) on failure; returns normally otherwise
966
+ */
967
+ export function runTestGuard(tasksToComplete, projectPath, opts = {}) {
968
+ if (!tasksToComplete.length || opts.skipValidation || opts.skipBuild || opts.dryRun) return;
969
+
970
+ const _runTS = opts._runTestSuite ?? runTestSuite;
971
+ const hasTestTask = tasksToComplete.some(t => isTestTask(t.title));
972
+ if (!hasTestTask) return;
973
+
974
+ const suiteResult = _runTS(projectPath);
975
+
976
+ if (suiteResult.passed === false && suiteResult.buildFailed) {
977
+ console.log(chalk.yellow('\n⚠️ Test build failed — skipping test guard'));
978
+ console.log(chalk.gray(' Ensure the project builds before running tests'));
979
+ if (suiteResult.output) {
980
+ console.log(chalk.gray(suiteResult.output.slice(-500)));
981
+ }
982
+ } else if (suiteResult.passed === false) {
983
+ console.error(chalk.red('\n❌ Test suite FAILED — tasks NOT marked as complete'));
984
+ if (suiteResult.output) {
985
+ console.error(chalk.gray(suiteResult.output.slice(-2000)));
986
+ }
987
+ console.log(chalk.gray(' Fix failing tests, then run task done again'));
988
+ console.log(chalk.gray(' Or use --skip-validation to bypass (not recommended)\n'));
989
+ (opts._exit ?? process.exit)(1);
990
+ } else if (suiteResult.skipped) {
991
+ console.log(chalk.yellow('\n⚠️ Test suite skipped — no testCommand configured'));
992
+ console.log(chalk.gray(` Reason: ${suiteResult.reason}`));
993
+ console.log(chalk.gray(' Set project.testCommand in .morph/config/config.json to enable auto-run\n'));
994
+ } else {
995
+ console.log(chalk.green('\n✅ Test suite passed'));
996
+ }
997
+ }
998
+
999
+ export default TaskManager;