@polymorphism-tech/morph-spec 4.8.12 → 4.8.15

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 (76) hide show
  1. package/README.md +379 -379
  2. package/bin/morph-spec.js +23 -2
  3. package/bin/{task-manager.cjs → task-manager.js} +249 -172
  4. package/claude-plugin.json +14 -14
  5. package/docs/CHEATSHEET.md +203 -203
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/agents.json +224 -140
  8. package/framework/hooks/README.md +202 -202
  9. package/framework/hooks/claude-code/post-tool-use/dispatch.js +48 -2
  10. package/framework/hooks/claude-code/post-tool-use/validator-feedback.js +151 -0
  11. package/framework/hooks/claude-code/pre-tool-use/enforce-phase-writes.js +12 -0
  12. package/framework/hooks/claude-code/pre-tool-use/protect-spec-files.js +6 -0
  13. package/framework/hooks/claude-code/session-start/inject-morph-context.js +34 -0
  14. package/framework/hooks/claude-code/statusline.py +6 -0
  15. package/framework/hooks/claude-code/stop/validate-completion.js +38 -4
  16. package/framework/hooks/claude-code/teammate-idle/teammate-idle.js +87 -0
  17. package/framework/hooks/claude-code/user-prompt/set-terminal-title.js +58 -0
  18. package/framework/hooks/shared/phase-utils.js +4 -1
  19. package/framework/hooks/shared/state-reader.js +1 -0
  20. package/framework/skills/README.md +1 -0
  21. package/framework/skills/level-0-meta/brainstorming/SKILL.md +2 -0
  22. package/framework/skills/level-0-meta/code-review/SKILL.md +16 -0
  23. package/framework/skills/level-0-meta/code-review/references/review-guidelines.md +100 -0
  24. package/framework/skills/level-0-meta/code-review/scripts/scan-csharp.mjs +36 -6
  25. package/framework/skills/level-0-meta/code-review-nextjs/SKILL.md +16 -0
  26. package/framework/skills/level-0-meta/code-review-nextjs/scripts/scan-nextjs.mjs +189 -0
  27. package/framework/skills/level-0-meta/frontend-review/SKILL.md +359 -0
  28. package/framework/skills/level-0-meta/frontend-review/scripts/scan-accessibility.mjs +376 -0
  29. package/framework/skills/level-0-meta/morph-checklist/SKILL.md +1 -1
  30. package/framework/skills/level-0-meta/morph-replicate/SKILL.md +10 -8
  31. package/framework/skills/level-0-meta/morph-replicate/references/blazor-html-mapping.md +70 -0
  32. package/framework/skills/level-0-meta/post-implementation/SKILL.md +315 -0
  33. package/framework/skills/level-0-meta/post-implementation/scripts/detect-dev-server.mjs +153 -0
  34. package/framework/skills/level-0-meta/post-implementation/scripts/detect-stack.mjs +234 -0
  35. package/framework/skills/level-0-meta/terminal-title/SKILL.md +61 -0
  36. package/framework/skills/level-0-meta/terminal-title/scripts/set_title.sh +65 -0
  37. package/framework/skills/level-0-meta/tool-usage-guide/SKILL.md +50 -188
  38. package/framework/skills/level-0-meta/tool-usage-guide/references/tools-per-phase.md +213 -0
  39. package/framework/skills/level-0-meta/verification-before-completion/SKILL.md +2 -0
  40. package/framework/skills/level-1-workflows/phase-clarify/SKILL.md +4 -7
  41. package/framework/skills/level-1-workflows/phase-codebase-analysis/SKILL.md +1 -1
  42. package/framework/skills/level-1-workflows/phase-design/SKILL.md +71 -109
  43. package/framework/skills/level-1-workflows/phase-design/references/architecture-analysis-guide.md +89 -0
  44. package/framework/skills/level-1-workflows/phase-design/references/spec-authoring-guide.md +55 -0
  45. package/framework/skills/level-1-workflows/phase-implement/SKILL.md +171 -114
  46. package/framework/skills/level-1-workflows/phase-implement/references/vsa-implementation-guide.md +92 -0
  47. package/framework/skills/level-1-workflows/phase-setup/SKILL.md +1 -2
  48. package/framework/skills/level-1-workflows/phase-tasks/SKILL.md +35 -159
  49. package/framework/skills/level-1-workflows/phase-tasks/references/task-planning-patterns.md +172 -0
  50. package/framework/skills/level-1-workflows/phase-uiux/SKILL.md +42 -3
  51. package/framework/squad-templates/backend-only.json +14 -1
  52. package/framework/squad-templates/frontend-only.json +14 -1
  53. package/framework/squad-templates/full-stack.json +25 -8
  54. package/framework/standards/STANDARDS.json +631 -86
  55. package/framework/standards/frontend/design-system/aesthetic-direction.md +213 -0
  56. package/framework/templates/project/validate.js +122 -0
  57. package/framework/workflows/configs/zero-touch.json +7 -0
  58. package/package.json +87 -87
  59. package/src/commands/agents/dispatch-agents.js +53 -10
  60. package/src/commands/state/advance-phase.js +88 -13
  61. package/src/commands/state/index.js +2 -1
  62. package/src/commands/state/phase-runner.js +215 -0
  63. package/src/commands/tasks/task.js +25 -4
  64. package/src/core/paths/output-schema.js +2 -1
  65. package/src/lib/detectors/design-system-detector.js +5 -4
  66. package/src/lib/generators/recap-generator.js +16 -0
  67. package/src/lib/orchestration/team-orchestrator.js +171 -89
  68. package/src/lib/phase-chain/eligibility-checker.js +243 -0
  69. package/src/lib/standards/digest-builder.js +231 -0
  70. package/src/lib/tasks/task-parser.js +94 -0
  71. package/src/lib/validators/blazor/blazor-concurrency-analyzer.js +39 -0
  72. package/src/lib/validators/content/content-validator.js +34 -106
  73. package/src/lib/validators/nextjs/next-component-validator.js +2 -0
  74. package/src/lib/validators/validation-runner.js +2 -2
  75. package/src/utils/file-copier.js +1 -0
  76. package/src/utils/hooks-installer.js +31 -7
@@ -7,84 +7,21 @@
7
7
  * Part of MORPH-SPEC 3.0 - Event-driven state management.
8
8
  */
9
9
 
10
- const fs = require('fs').promises;
11
- const fsSync = require('fs');
12
- const path = require('path');
13
-
14
- // Simple ANSI color helpers (chalk v5 is ESM-only, can't require in CJS)
15
- const chalk = {
16
- red: (s) => `\x1b[31m${s}\x1b[0m`,
17
- green: (s) => `\x1b[32m${s}\x1b[0m`,
18
- yellow: (s) => `\x1b[33m${s}\x1b[0m`,
19
- blue: (s) => `\x1b[34m${s}\x1b[0m`,
20
- magenta: (s) => `\x1b[35m${s}\x1b[0m`,
21
- cyan: (s) => `\x1b[36m${s}\x1b[0m`,
22
- gray: (s) => `\x1b[90m${s}\x1b[0m`,
23
- bold: (s) => `\x1b[1m${s}\x1b[0m`,
24
- };
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';
25
15
 
26
- // ============================================================================
27
- // v3 Schema Helpers
28
- // ============================================================================
29
-
30
- /**
31
- * Parse tasks.md to extract task stubs for v3 state format.
32
- * Looks for headings like: ### T001 — Task title
33
- */
34
- async function parseTasksMd(featureName) {
35
- const tasksPath = path.join(process.cwd(), `.morph/features/${featureName}/3-tasks/tasks.md`);
36
- let content = '';
37
- try {
38
- content = await fs.readFile(tasksPath, 'utf-8');
39
- } catch {
40
- return [];
41
- }
42
-
43
- const tasks = [];
44
- const headingRe = /^###\s+(T\d+)\s+[—–-]\s+(.+)$/gm;
45
- let match;
46
- while ((match = headingRe.exec(content)) !== null) {
47
- tasks.push({
48
- id: match[1],
49
- title: match[2].trim(),
50
- status: 'pending',
51
- dependencies: [],
52
- files: [],
53
- checkpoint: null
54
- });
55
- }
56
- return tasks;
57
- }
16
+ import { loadState, saveState } from '../src/core/state/state-manager.js';
17
+ import { parseTasksMd, ensureTaskList, syncCounters } from '../src/lib/tasks/task-parser.js';
58
18
 
59
- /**
60
- * Ensure feature.taskList exists (array of individual task objects).
61
- * In v3 state, feature.tasks is a counter object {total, completed, ...}.
62
- * Individual task objects live in feature.taskList.
63
- */
64
- async function ensureTaskList(feature, featureName) {
65
- if (Array.isArray(feature.tasks)) {
66
- // v2 format: tasks IS the array
67
- return feature.tasks;
68
- }
69
- // v3 format: use taskList or build from tasks.md
70
- if (!feature.taskList || feature.taskList.length === 0) {
71
- feature.taskList = await parseTasksMd(featureName);
72
- }
73
- return feature.taskList;
74
- }
19
+ const __filename = fileURLToPath(import.meta.url);
20
+ const __dirname = dirname(__filename);
75
21
 
76
- /**
77
- * After modifying taskList, sync counts back to feature.tasks counter.
78
- * tasks.total is derived from the actual taskList length (Problem 3 fix).
79
- */
80
- function syncCounters(feature) {
81
- if (Array.isArray(feature.tasks)) return; // v2, nothing to sync
82
- const list = feature.taskList || [];
83
- feature.tasks.total = list.length; // Auto-sync total from parsed taskList
84
- feature.tasks.completed = list.filter(t => t.status === 'completed').length;
85
- feature.tasks.inProgress = list.filter(t => t.status === 'in_progress').length;
86
- feature.tasks.pending = list.filter(t => t.status === 'pending').length;
87
- }
22
+ // ============================================================================
23
+ // Breaking-Change Detection
24
+ // ============================================================================
88
25
 
89
26
  /**
90
27
  * Detect potentially broken consumers of recently removed exports.
@@ -102,7 +39,6 @@ function syncCounters(feature) {
102
39
  */
103
40
  async function detectBreakingChanges() {
104
41
  try {
105
- const { execSync } = require('child_process');
106
42
  const execOpts = { cwd: process.cwd(), stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 5 * 1024 * 1024 };
107
43
 
108
44
  // Collect diff from staged + unstaged changes in source files
@@ -133,7 +69,6 @@ async function detectBreakingChanges() {
133
69
  const warnings = [];
134
70
  for (const sym of removedExports) {
135
71
  try {
136
- // git grep returns exit code 1 when nothing found — that's handled by catch
137
72
  const result = execSync(`git grep -l "${sym}"`, execOpts).toString().trim();
138
73
  if (result) {
139
74
  const consumers = result.split('\n').filter(Boolean);
@@ -150,39 +85,11 @@ async function detectBreakingChanges() {
150
85
  }
151
86
  }
152
87
 
153
- class TaskManager {
154
- constructor(statePath = '.morph/state.json') {
155
- this.statePath = statePath;
156
- }
157
-
158
- /**
159
- * Load state.json
160
- */
161
- async loadState() {
162
- try {
163
- const content = await fs.readFile(this.statePath, 'utf-8');
164
- return JSON.parse(content);
165
- } catch (error) {
166
- if (error.code === 'ENOENT') {
167
- throw new Error(`State file not found: ${this.statePath}. Run 'npx morph-spec state init' first.`);
168
- }
169
- throw error;
170
- }
171
- }
172
-
173
- /**
174
- * Save state.json
175
- */
176
- async saveState(state) {
177
- // v3 state uses metadata.lastUpdated, v2 used project.updatedAt
178
- if (state.metadata) {
179
- state.metadata.lastUpdated = new Date().toISOString();
180
- } else if (state.project) {
181
- state.project.updatedAt = new Date().toISOString();
182
- }
183
- await fs.writeFile(this.statePath, JSON.stringify(state, null, 2), 'utf-8');
184
- }
88
+ // ============================================================================
89
+ // TaskManager
90
+ // ============================================================================
185
91
 
92
+ class TaskManager {
186
93
  /**
187
94
  * Complete one or more tasks (runs validation first)
188
95
  * @param {string} featureName - Feature name
@@ -191,7 +98,7 @@ class TaskManager {
191
98
  * @param {boolean} options.skipValidation - Skip code validation
192
99
  */
193
100
  async completeTasks(featureName, taskIds, options = {}) {
194
- const state = await this.loadState();
101
+ const state = loadState();
195
102
  const feature = state.features[featureName];
196
103
 
197
104
  if (!feature) {
@@ -201,12 +108,12 @@ class TaskManager {
201
108
  const taskList = await ensureTaskList(feature, featureName);
202
109
 
203
110
  if (taskList.length === 0) {
204
- const tasksPath = path.join(process.cwd(), `.morph/features/${featureName}/3-tasks/tasks.md`);
205
- const tasksExist = await fs.access(tasksPath).then(() => true).catch(() => false);
111
+ const tasksPath = join(process.cwd(), `.morph/features/${featureName}/3-tasks/tasks.md`);
112
+ const tasksExist = await access(tasksPath).then(() => true).catch(() => false);
206
113
  if (!tasksExist) {
207
114
  throw new Error(`No tasks found for '${featureName}' — tasks.md not generated yet.\n Complete the tasks phase first: run /phase-tasks`);
208
115
  }
209
- throw new Error(`tasks.md found but no tasks could be parsed for '${featureName}'.\n Ensure tasks use the format: ### T001 — Title`);
116
+ 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`);
210
117
  }
211
118
 
212
119
  const results = [];
@@ -237,13 +144,60 @@ class TaskManager {
237
144
 
238
145
  // Run validation BEFORE marking tasks as complete
239
146
  if (tasksToComplete.length > 0 && !options.skipValidation) {
240
- const validationPassed = await this.runValidation(featureName);
241
- if (!validationPassed) {
242
- console.error(chalk.red('\n Validation failed — tasks NOT marked as complete'));
243
- console.log(chalk.gray(' Fix the issues above, then run task done again'));
147
+ const validationResult = await this.runValidation(featureName);
148
+ if (options.dryRun) {
149
+ console.log(chalk.cyan('\n ℹ️ Dry-run — tasks NOT marked as complete'));
150
+ return [];
151
+ }
152
+ if (!validationResult.passed) {
153
+ // Reload state to ensure we have the latest (may have been modified by validation)
154
+ const currentState = loadState();
155
+ const currentFeature = currentState.features[featureName];
156
+ if (currentFeature) {
157
+ for (const task of tasksToComplete) {
158
+ this.persistValidationHistory(currentFeature, task.id, validationResult);
159
+ }
160
+ saveState(currentState);
161
+
162
+ // Check escalation status
163
+ const blockedTasks = tasksToComplete.filter(t => {
164
+ const hist = currentFeature.validationHistory?.[t.id];
165
+ return hist?.status === 'blocked';
166
+ });
167
+ if (blockedTasks.length > 0) {
168
+ console.error(chalk.red(`\n⛔ ESCALATION — Task(s) ${blockedTasks.map(t => t.id).join(', ')} have failed 3 times`));
169
+ console.error(chalk.red(' Human review required. Mark resolved with: morph-spec state set <feature> validationHistory.<taskId>.status passed'));
170
+ } else {
171
+ const attempt = currentFeature.validationHistory?.[tasksToComplete[0]?.id]?.attempt || 1;
172
+ console.error(chalk.red(`\n❌ Validation failed (attempt ${attempt}/3) — tasks NOT marked as complete`));
173
+ console.log(chalk.gray(' Fix the issues above, then run task done again'));
174
+ if (attempt >= 2) {
175
+ console.log(chalk.yellow(` ⚠️ Next failure will escalate to human review`));
176
+ }
177
+ }
178
+ } else {
179
+ console.error(chalk.red('\n❌ Validation failed — tasks NOT marked as complete'));
180
+ console.log(chalk.gray(' Fix the issues above, then run task done again'));
181
+ }
244
182
  console.log(chalk.gray(' Or use --skip-validation to bypass (not recommended)\n'));
245
183
  process.exit(1);
246
184
  }
185
+
186
+ // Validation passed — mark any pending history as passed
187
+ const successState = loadState();
188
+ const successFeature = successState.features[featureName];
189
+ if (successFeature) {
190
+ for (const task of tasksToComplete) {
191
+ if (successFeature.validationHistory?.[task.id]) {
192
+ successFeature.validationHistory[task.id].status = 'passed';
193
+ successFeature.validationHistory[task.id].updatedAt = new Date().toISOString();
194
+ }
195
+ }
196
+ saveState(successState);
197
+ }
198
+ } else if (options.dryRun) {
199
+ console.log(chalk.cyan('\n ℹ️ Dry-run — tasks NOT marked as complete (validation skipped)'));
200
+ return [];
247
201
  }
248
202
 
249
203
  // Breaking change detection (non-blocking warning)
@@ -296,8 +250,8 @@ class TaskManager {
296
250
  }
297
251
  }
298
252
 
299
- // Save state
300
- await this.saveState(state);
253
+ // Persist state (atomic write via state-manager)
254
+ saveState(state);
301
255
 
302
256
  // Run TaskCompleted agent-teams hook for each completed task (non-blocking)
303
257
  for (const task of results) {
@@ -335,9 +289,11 @@ class TaskManager {
335
289
  }
336
290
 
337
291
  /**
338
- * Run validation for a feature using the ValidationRunner (ESM dynamic import)
292
+ * Run validation for a feature using the ValidationRunner (ESM dynamic import).
293
+ * Returns a structured result with per-validator breakdown for validationHistory.
294
+ *
339
295
  * @param {string} featureName - Feature name
340
- * @returns {boolean} True if validation passed
296
+ * @returns {{ passed: boolean, validators: Object, passRate: number }}
341
297
  */
342
298
  async runValidation(featureName) {
343
299
  try {
@@ -347,15 +303,74 @@ class TaskManager {
347
303
  const result = await runValidation('.', featureName, { verbose: true });
348
304
 
349
305
  formatValidationResults(result);
350
- return result.passed;
306
+
307
+ // Build structured validators map from result
308
+ const validators = {};
309
+ if (result.results && typeof result.results === 'object') {
310
+ for (const [name, vResult] of Object.entries(result.results)) {
311
+ const issues = (vResult.errors || []).map(e => ({
312
+ message: typeof e === 'string' ? e : (e.message || String(e)),
313
+ file: e.file,
314
+ line: e.line,
315
+ rule: e.rule,
316
+ }));
317
+ validators[name] = {
318
+ passed: vResult.passed ?? (issues.length === 0),
319
+ issues,
320
+ };
321
+ }
322
+ } else if (!result.passed && result.errors?.length > 0) {
323
+ // Flat format fallback — wrap as single 'validation' validator
324
+ validators['validation'] = {
325
+ passed: false,
326
+ issues: result.errors.map(e => ({
327
+ message: typeof e === 'string' ? e : (e.message || String(e)),
328
+ file: e.file,
329
+ line: e.line,
330
+ rule: e.rule || 'validation',
331
+ })),
332
+ };
333
+ }
334
+
335
+ const validatorEntries = Object.values(validators);
336
+ const passRate = validatorEntries.length > 0
337
+ ? validatorEntries.filter(v => v.passed).length / validatorEntries.length
338
+ : (result.passed ? 1.0 : 0.0);
339
+
340
+ return { passed: result.passed, validators, passRate };
351
341
  } catch (error) {
352
342
  // If validation runner fails to load, warn but don't block task completion.
353
343
  // This is fail-open by design: a broken validator shouldn't block commits.
354
- // Common cause: ESM import failure on Windows or missing optional deps.
344
+ // Common cause: missing optional deps.
355
345
  console.log(chalk.yellow(`\n⚠️ Validation skipped (${error.message})`));
356
346
  console.log(chalk.gray(' Run manually: npx morph-spec validate --verbose'));
357
- return true;
347
+ return { passed: true, validators: {}, passRate: 1.0 };
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Persist a validation result to feature.validationHistory[taskId].
353
+ * Increments attempt counter and sets status to 'failed' or 'blocked' (attempt >= 3).
354
+ *
355
+ * @param {Object} feature - Mutable feature state object
356
+ * @param {string} taskId - Task ID
357
+ * @param {{ passed: boolean, validators: Object, passRate: number }} validationResult
358
+ */
359
+ persistValidationHistory(feature, taskId, validationResult) {
360
+ if (!feature.validationHistory) {
361
+ feature.validationHistory = {};
358
362
  }
363
+ const existing = feature.validationHistory[taskId] || { attempt: 0 };
364
+ const attempt = (existing.attempt || 0) + 1;
365
+ const status = attempt >= 3 ? 'blocked' : 'failed';
366
+
367
+ feature.validationHistory[taskId] = {
368
+ attempt,
369
+ validators: validationResult.validators || {},
370
+ passRate: validationResult.passRate || 0,
371
+ status,
372
+ updatedAt: new Date().toISOString(),
373
+ };
359
374
  }
360
375
 
361
376
  /**
@@ -382,13 +397,7 @@ class TaskManager {
382
397
  const pending = tasks.filter(t => t.status === 'pending').length;
383
398
  const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
384
399
 
385
- return {
386
- total,
387
- completed,
388
- inProgress,
389
- pending,
390
- percentage
391
- };
400
+ return { total, completed, inProgress, pending, percentage };
392
401
  }
393
402
 
394
403
  /**
@@ -420,7 +429,6 @@ class TaskManager {
420
429
  let checkpointResult = null;
421
430
  let validationNote = '';
422
431
 
423
- // Run checkpoint hooks (new enhanced validation system)
424
432
  try {
425
433
  const { runCheckpointHooks, shouldRunCheckpoint } = await import('../src/lib/checkpoints/checkpoint-hooks.js');
426
434
 
@@ -432,7 +440,6 @@ class TaskManager {
432
440
  } else {
433
441
  validationNote = ` | ✗ ${checkpointResult.summary.errors} errors, ${checkpointResult.summary.warnings} warnings`;
434
442
 
435
- // Check if we should block progress
436
443
  const config = await this.loadCheckpointConfig();
437
444
  if (config.checkpoints?.onFailure?.blockProgress && checkpointResult.summary.errors > 0) {
438
445
  console.log(chalk.red('\n❌ Checkpoint FAILED - Fix violations before proceeding'));
@@ -442,7 +449,7 @@ class TaskManager {
442
449
  }
443
450
  } catch (error) {
444
451
  if (error.message === 'Checkpoint validation failed') {
445
- throw error; // Re-throw to block task completion
452
+ throw error;
446
453
  }
447
454
  // Fallback to old validation if checkpoint-hooks not available
448
455
  console.log(chalk.yellow('⚠️ Checkpoint hooks not available, using legacy validation'));
@@ -457,7 +464,6 @@ class TaskManager {
457
464
  }
458
465
  }
459
466
 
460
- // Create checkpoint record
461
467
  const checkpoint = {
462
468
  id: `CHECKPOINT_AUTO_${Date.now()}`,
463
469
  timestamp: new Date().toISOString(),
@@ -483,18 +489,16 @@ class TaskManager {
483
489
  */
484
490
  async loadCheckpointConfig() {
485
491
  try {
486
- const configPath = path.join(process.cwd(), '.morph/config/llm-interaction.json');
487
- const content = await fs.readFile(configPath, 'utf-8');
492
+ const { readFile } = await import('fs/promises');
493
+ const configPath = join(process.cwd(), '.morph/config/llm-interaction.json');
494
+ const content = await readFile(configPath, 'utf-8');
488
495
  return JSON.parse(content);
489
496
  } catch {
490
- // Return defaults if config doesn't exist
491
497
  return {
492
498
  checkpoints: {
493
499
  frequency: 3,
494
500
  autoValidate: true,
495
- onFailure: {
496
- blockProgress: false
497
- }
501
+ onFailure: { blockProgress: false }
498
502
  }
499
503
  };
500
504
  }
@@ -507,7 +511,6 @@ class TaskManager {
507
511
  try {
508
512
  const config = await this.loadCheckpointConfig();
509
513
 
510
- // Check if auto-generation is enabled
511
514
  if (!config.metadata?.autoGenerate) {
512
515
  return;
513
516
  }
@@ -515,21 +518,14 @@ class TaskManager {
515
518
  const { extractFeatureMetadata } = await import('../src/lib/generators/metadata-extractor.js');
516
519
  const metadata = extractFeatureMetadata(feature);
517
520
 
518
- const outputPath = path.join(
519
- process.cwd(),
520
- `.morph/features/${featureName}/metadata.json`
521
- );
522
-
523
- // Ensure directory exists
524
- const outputDir = path.dirname(outputPath);
525
- await fs.mkdir(outputDir, { recursive: true });
521
+ const outputPath = join(process.cwd(), `.morph/features/${featureName}/metadata.json`);
526
522
 
527
- // Write metadata
528
- await fs.writeFile(outputPath, JSON.stringify(metadata, null, 2), 'utf-8');
523
+ const { mkdir, writeFile } = await import('fs/promises');
524
+ await mkdir(dirname(outputPath), { recursive: true });
525
+ await writeFile(outputPath, JSON.stringify(metadata, null, 2), 'utf-8');
529
526
 
530
- console.log(chalk.gray(` 📊 Metadata updated: ${path.relative(process.cwd(), outputPath)}`));
527
+ console.log(chalk.gray(` 📊 Metadata updated: .morph/features/${featureName}/metadata.json`));
531
528
  } catch (error) {
532
- // Don't block task completion if metadata generation fails
533
529
  console.log(chalk.yellow(` ⚠️ Metadata generation failed: ${error.message}`));
534
530
  }
535
531
  }
@@ -551,7 +547,6 @@ class TaskManager {
551
547
  getNextTask(tasks) {
552
548
  const pending = tasks.filter(t => t.status === 'pending');
553
549
 
554
- // Find first task with all dependencies completed
555
550
  for (const task of pending) {
556
551
  const missingDeps = this.checkDependencies(task, tasks);
557
552
  if (missingDeps.length === 0) {
@@ -571,7 +566,6 @@ class TaskManager {
571
566
  console.log(chalk.bold(`\n📊 Progress: ${percentage}% (${completed}/${total})`));
572
567
  console.log(chalk.gray(` Completed: ${completed} | In Progress: ${inProgress} | Pending: ${pending}`));
573
568
 
574
- // Progress bar
575
569
  const barLength = 30;
576
570
  const filledLength = Math.round((percentage / 100) * barLength);
577
571
  const bar = '█'.repeat(filledLength) + '░'.repeat(barLength - filledLength);
@@ -582,7 +576,7 @@ class TaskManager {
582
576
  * Start a task (mark as in_progress)
583
577
  */
584
578
  async startTask(featureName, taskId) {
585
- const state = await this.loadState();
579
+ const state = loadState();
586
580
  const feature = state.features[featureName];
587
581
 
588
582
  if (!feature) {
@@ -592,12 +586,12 @@ class TaskManager {
592
586
  const taskList = await ensureTaskList(feature, featureName);
593
587
 
594
588
  if (taskList.length === 0) {
595
- const tasksPath = path.join(process.cwd(), `.morph/features/${featureName}/3-tasks/tasks.md`);
596
- const tasksExist = await fs.access(tasksPath).then(() => true).catch(() => false);
589
+ const tasksPath = join(process.cwd(), `.morph/features/${featureName}/3-tasks/tasks.md`);
590
+ const tasksExist = await access(tasksPath).then(() => true).catch(() => false);
597
591
  if (!tasksExist) {
598
592
  throw new Error(`No tasks found for '${featureName}' — tasks.md not generated yet.\n Complete the tasks phase first: run /phase-tasks`);
599
593
  }
600
- throw new Error(`tasks.md found but no tasks could be parsed for '${featureName}'.\n Ensure tasks use the format: ### T001 — Title`);
594
+ 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`);
601
595
  }
602
596
 
603
597
  const task = taskList.find(t => t.id === taskId);
@@ -611,7 +605,6 @@ class TaskManager {
611
605
  return;
612
606
  }
613
607
 
614
- // Validate dependencies
615
608
  const missingDeps = this.checkDependencies(task, taskList);
616
609
  if (missingDeps.length > 0) {
617
610
  throw new Error(`Cannot start ${taskId}: missing dependencies: ${missingDeps.join(', ')}`);
@@ -621,16 +614,68 @@ class TaskManager {
621
614
  task.startedAt = new Date().toISOString();
622
615
  syncCounters(feature);
623
616
 
624
- await this.saveState(state);
617
+ saveState(state);
625
618
 
626
619
  console.log(chalk.blue(`▶️ Task ${taskId} started: ${task.title}`));
627
620
  }
628
621
 
622
+ /**
623
+ * Bulk-complete tasks with a single validation pass.
624
+ * @param {string} featureName
625
+ * @param {Object} opts
626
+ * @param {boolean} [opts.all] - Complete all pending tasks
627
+ * @param {string} [opts.from] - Start of range (e.g. T001)
628
+ * @param {string} [opts.to] - End of range (e.g. T053)
629
+ * @param {string} [opts.range] - Compact range string (e.g. T001..T082)
630
+ * @param {boolean} [opts.skipValidation]
631
+ * @param {boolean} [opts.dryRun]
632
+ */
633
+ async bulkCompleteTasks(featureName, opts = {}) {
634
+ const state = loadState();
635
+ const feature = state.features[featureName];
636
+ if (!feature) throw new Error(`Feature '${featureName}' not found in state.json`);
637
+
638
+ const taskList = await ensureTaskList(feature, featureName);
639
+ if (taskList.length === 0) throw new Error(`No tasks found for '${featureName}'`);
640
+
641
+ // Resolve task IDs from range options
642
+ let targetIds;
643
+ if (opts.all) {
644
+ targetIds = taskList.filter(t => t.status !== 'completed').map(t => t.id);
645
+ } else if (opts.from && opts.to) {
646
+ targetIds = this.expandRange(taskList, opts.from, opts.to);
647
+ } else if (opts.range && opts.range.includes('..')) {
648
+ const [from, to] = opts.range.split('..');
649
+ targetIds = this.expandRange(taskList, from.trim(), to.trim());
650
+ } else {
651
+ throw new Error('bulk-done requires --all, --from/--to, or a T001..T082 range argument');
652
+ }
653
+
654
+ console.log(chalk.cyan(`\n📦 Bulk-done: ${targetIds.length} task(s) targeted`));
655
+ await this.completeTasks(featureName, targetIds, { skipValidation: opts.skipValidation, dryRun: opts.dryRun });
656
+ }
657
+
658
+ /**
659
+ * Expand a task range like T001..T053 using zero-padded numeric IDs.
660
+ */
661
+ expandRange(taskList, from, to) {
662
+ const numOf = id => parseInt(id.replace(/\D/g, ''), 10);
663
+ const prefix = from.replace(/\d+$/, '');
664
+ const padLen = from.replace(prefix, '').length;
665
+ const fromNum = numOf(from);
666
+ const toNum = numOf(to);
667
+ const ids = [];
668
+ for (let n = fromNum; n <= toNum; n++) {
669
+ ids.push(prefix + String(n).padStart(padLen, '0'));
670
+ }
671
+ return ids.filter(id => taskList.some(t => t.id === id));
672
+ }
673
+
629
674
  /**
630
675
  * Get next task suggestion
631
676
  */
632
677
  async getNext(featureName) {
633
- const state = await this.loadState();
678
+ const state = loadState();
634
679
  const feature = state.features[featureName];
635
680
 
636
681
  if (!feature) {
@@ -655,7 +700,10 @@ class TaskManager {
655
700
  }
656
701
  }
657
702
 
658
- // CLI
703
+ // ============================================================================
704
+ // CLI entry point
705
+ // ============================================================================
706
+
659
707
  async function main() {
660
708
  const args = process.argv.slice(2);
661
709
  const command = args[0];
@@ -667,16 +715,40 @@ async function main() {
667
715
  case 'done':
668
716
  case 'complete': {
669
717
  const skipValidation = args.includes('--skip-validation');
670
- const filteredArgs = args.filter(a => a !== '--skip-validation');
718
+ const dryRun = args.includes('--dry-run');
719
+ const filteredArgs = args.filter(a => !['--skip-validation', '--dry-run'].includes(a));
671
720
  const featureName = filteredArgs[1];
672
721
  const taskIds = filteredArgs.slice(2);
673
722
 
674
723
  if (!featureName || taskIds.length === 0) {
675
- console.error(chalk.red('Usage: npx morph-spec task done <feature> <task-id> [task-id...] [--skip-validation]'));
724
+ console.error(chalk.red('Usage: npx morph-spec task done <feature> <task-id> [task-id...] [--skip-validation] [--dry-run]'));
676
725
  process.exit(1);
677
726
  }
678
727
 
679
- await manager.completeTasks(featureName, taskIds, { skipValidation });
728
+ await manager.completeTasks(featureName, taskIds, { skipValidation, dryRun });
729
+ break;
730
+ }
731
+
732
+ case 'bulk-done': {
733
+ const skipValidation = args.includes('--skip-validation');
734
+ const dryRun = args.includes('--dry-run');
735
+ const allFlag = args.includes('--all');
736
+ const fromIdx = args.indexOf('--from');
737
+ const toIdx = args.indexOf('--to');
738
+ const filteredArgs = args.filter(a => !['--skip-validation', '--dry-run', '--all'].includes(a)
739
+ && !a.startsWith('--from') && !a.startsWith('--to'));
740
+ const featureName = filteredArgs[1];
741
+ const rangeArg = filteredArgs[2]; // e.g. T001..T082
742
+
743
+ if (!featureName) {
744
+ console.error(chalk.red('Usage: npx morph-spec task bulk-done <feature> [--all | --from T001 --to T053 | T001..T082] [--skip-validation] [--dry-run]'));
745
+ process.exit(1);
746
+ }
747
+
748
+ const fromId = fromIdx !== -1 ? args[fromIdx + 1] : null;
749
+ const toId = toIdx !== -1 ? args[toIdx + 1] : null;
750
+
751
+ await manager.bulkCompleteTasks(featureName, { all: allFlag, from: fromId, to: toId, range: rangeArg, skipValidation, dryRun });
680
752
  break;
681
753
  }
682
754
 
@@ -708,9 +780,13 @@ async function main() {
708
780
  default:
709
781
  console.error(chalk.red(`Unknown command: ${command}`));
710
782
  console.log(chalk.gray('\nAvailable commands:'));
711
- console.log(chalk.gray(' done <feature> <task-id...> - Mark tasks as completed'));
712
- console.log(chalk.gray(' start <feature> <task-id> - Start a task (mark as in_progress)'));
713
- console.log(chalk.gray(' next <feature> - Show next suggested task'));
783
+ console.log(chalk.gray(' done <feature> <task-id...> - Mark tasks as completed'));
784
+ console.log(chalk.gray(' bulk-done <feature> --all - Mark all pending tasks as completed'));
785
+ console.log(chalk.gray(' bulk-done <feature> --from T001 --to T053 - Mark range as completed'));
786
+ console.log(chalk.gray(' bulk-done <feature> T001..T082 - Mark range as completed'));
787
+ console.log(chalk.gray(' start <feature> <task-id> - Start a task (mark as in_progress)'));
788
+ console.log(chalk.gray(' next <feature> - Show next suggested task'));
789
+ console.log(chalk.gray('\nFlags: --skip-validation, --dry-run'));
714
790
  process.exit(1);
715
791
  }
716
792
  } catch (error) {
@@ -719,8 +795,9 @@ async function main() {
719
795
  }
720
796
  }
721
797
 
722
- if (require.main === module) {
798
+ // ESM equivalent of `if (require.main === module)`
799
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
723
800
  main();
724
801
  }
725
802
 
726
- module.exports = TaskManager;
803
+ export default TaskManager;
@@ -1,14 +1,14 @@
1
- {
2
- "name": "morph-spec",
3
- "version": "4.8.12",
4
- "displayName": "MORPH-SPEC Framework",
5
- "description": "Spec-driven development with 38 agents and 8-phase workflow for .NET/Blazor/Next.js/Azure",
6
- "publisher": "polymorphism-tech",
7
- "skills": {
8
- "directory": "framework/skills",
9
- "namespace": "morph-spec"
10
- },
11
- "hooks": {
12
- "directory": "framework/hooks/claude-code"
13
- }
14
- }
1
+ {
2
+ "name": "morph-spec",
3
+ "version": "4.8.15",
4
+ "displayName": "MORPH-SPEC Framework",
5
+ "description": "Spec-driven development with 38 agents and 8-phase workflow for .NET/Blazor/Next.js/Azure",
6
+ "publisher": "polymorphism-tech",
7
+ "skills": {
8
+ "directory": "framework/skills",
9
+ "namespace": "morph-spec"
10
+ },
11
+ "hooks": {
12
+ "directory": "framework/hooks/claude-code"
13
+ }
14
+ }