@polymorphism-tech/morph-spec 4.10.10 → 4.11.1

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 (62) hide show
  1. package/CLAUDE.md +106 -2
  2. package/README.md +5 -11
  3. package/bin/morph-spec.js +30 -2
  4. package/bin/task-manager.js +394 -38
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +1 -1
  7. package/docs/QUICKSTART.md +2 -8
  8. package/framework/agents/frontend/css-specialist.md +1 -1
  9. package/framework/agents/infrastructure/azure-deploy-specialist.md +4 -4
  10. package/framework/commands/morph-apply.md +39 -19
  11. package/framework/commands/morph-preflight.md +3 -3
  12. package/framework/commands/morph-proposal.md +59 -16
  13. package/framework/commands/morph-status.md +3 -3
  14. package/framework/commands/morph-troubleshoot.md +1 -1
  15. package/framework/hooks/claude-code/post-tool-use/dispatch.js +47 -2
  16. package/framework/hooks/claude-code/pre-tool-use/task-tracking-guard.js +2 -2
  17. package/framework/hooks/claude-code/session-start/inject-morph-context.js +5 -4
  18. package/framework/hooks/claude-code/teammate-idle/teammate-idle.js +3 -3
  19. package/framework/hooks/claude-code/user-prompt/enrich-prompt.js +2 -2
  20. package/framework/hooks/git/pre-commit/agents.sh +1 -1
  21. package/framework/hooks/shared/dispatch-helpers.js +14 -0
  22. package/framework/hooks/shared/state-reader.js +7 -3
  23. package/framework/rules/morph-task-tracking.md +49 -0
  24. package/framework/skills/level-0-meta/morph-brainstorming/SKILL.md +14 -18
  25. package/framework/skills/level-0-meta/morph-checklist/SKILL.md +3 -3
  26. package/framework/skills/level-0-meta/morph-frontend-review/SKILL.md +1 -1
  27. package/framework/skills/level-0-meta/morph-init/SKILL.md +11 -11
  28. package/framework/skills/level-0-meta/morph-post-implementation/SKILL.md +3 -3
  29. package/framework/skills/level-0-meta/morph-replicate/SKILL.md +1 -1
  30. package/framework/skills/level-0-meta/morph-simulation-checklist/SKILL.md +4 -4
  31. package/framework/skills/level-0-meta/morph-tool-usage-guide/references/tools-per-phase.md +25 -25
  32. package/framework/skills/level-0-meta/morph-verification-before-completion/SKILL.md +8 -8
  33. package/framework/skills/level-1-workflows/morph-phase-clarify/SKILL.md +16 -7
  34. package/framework/skills/level-1-workflows/morph-phase-codebase-analysis/SKILL.md +4 -4
  35. package/framework/skills/level-1-workflows/morph-phase-design/SKILL.md +29 -35
  36. package/framework/skills/level-1-workflows/morph-phase-design/references/architecture-analysis-guide.md +1 -1
  37. package/framework/skills/level-1-workflows/morph-phase-implement/SKILL.md +35 -29
  38. package/framework/skills/level-1-workflows/morph-phase-implement/prompts/implementer-prompt.md +4 -4
  39. package/framework/skills/level-1-workflows/morph-phase-plan/SKILL.md +17 -20
  40. package/framework/skills/level-1-workflows/morph-phase-setup/SKILL.md +23 -26
  41. package/framework/skills/level-1-workflows/morph-phase-tasks/SKILL.md +21 -29
  42. package/framework/skills/level-1-workflows/morph-phase-uiux/SKILL.md +24 -16
  43. package/framework/skills/level-1-workflows/morph-scope-escalation/SKILL.md +5 -5
  44. package/framework/workflows/README.md +18 -18
  45. package/framework/workflows/docs/enforcement-pipeline.md +1 -1
  46. package/framework/workflows/docs/full-morph.md +2 -2
  47. package/package.json +1 -1
  48. package/src/commands/agents/dispatch-agents.js +25 -1
  49. package/src/commands/project/doctor.js +3 -4
  50. package/src/commands/project/status.js +8 -1
  51. package/src/commands/scope/escalate.js +5 -3
  52. package/src/commands/tasks/task.js +54 -0
  53. package/src/commands/validation/validate-feature.js +32 -3
  54. package/src/lib/build/build-runner.js +99 -0
  55. package/src/lib/checkpoints/checkpoint-hooks.js +110 -0
  56. package/src/lib/generators/recap-generator.js +27 -4
  57. package/src/lib/stack/stack-profile.js +3 -0
  58. package/src/lib/tasks/task-parser.js +23 -9
  59. package/src/scripts/global-install.js +57 -3
  60. package/src/utils/file-copier.js +1 -0
  61. package/src/utils/version-checker.js +1 -7
  62. package/framework/hooks/claude-code/pre-tool-use/protect-readonly-files.js +0 -7
@@ -13,7 +13,7 @@ import { access } from 'fs/promises';
13
13
  import { execSync } from 'child_process';
14
14
  import chalk from 'chalk';
15
15
 
16
- import { loadState, saveState, resolveAgentForTask, updateDispatchAgentTiming } from '../src/core/state/state-manager.js';
16
+ import { loadState, saveState, resolveAgentForTask, updateDispatchAgentTiming, derivePhase } from '../src/core/state/state-manager.js';
17
17
  import { parseTasksMd, ensureTaskList, syncCounters } from '../src/lib/tasks/task-parser.js';
18
18
  import { isTestTask } from '../src/lib/tasks/task-classifier.js';
19
19
  import { runTestSuite } from '../src/lib/tasks/test-runner.js';
@@ -40,9 +40,11 @@ const __dirname = dirname(__filename);
40
40
  *
41
41
  * @returns {Promise<Array<{export: string, consumers: string[]}>|null>}
42
42
  */
43
- async function detectBreakingChanges() {
43
+ export async function detectBreakingChanges(opts = {}) {
44
44
  try {
45
- const execOpts = { cwd: process.cwd(), stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 5 * 1024 * 1024 };
45
+ const exec = opts._execSync ?? execSync;
46
+ const cwd = opts._cwd ?? process.cwd();
47
+ const execOpts = { cwd, stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 5 * 1024 * 1024 };
46
48
 
47
49
  // Collect diff from staged + unstaged changes in source files
48
50
  let diff = '';
@@ -50,7 +52,7 @@ async function detectBreakingChanges() {
50
52
  for (const flag of ['--cached', '']) {
51
53
  try {
52
54
  const args = ['git', 'diff', '--unified=0', flag, '--', ...diffPatterns].filter(Boolean);
53
- diff += execSync(args.join(' '), execOpts).toString();
55
+ diff += exec(args.join(' '), execOpts).toString();
54
56
  } catch {
55
57
  // git not available or no changes — continue
56
58
  }
@@ -58,6 +60,12 @@ async function detectBreakingChanges() {
58
60
 
59
61
  if (!diff.trim()) return null;
60
62
 
63
+ // Extract source files touched by the diff
64
+ const sourceFiles = new Set();
65
+ const diffHeaderRe = /^diff --git a\/(.+?) b\//gm;
66
+ let m;
67
+ while ((m = diffHeaderRe.exec(diff)) !== null) sourceFiles.add(m[1]);
68
+
61
69
  // Extract removed export symbol names (lines starting with -)
62
70
  const removedExports = new Set();
63
71
  const exportRe = /^-\s*export\s+(?:(?:default\s+)?(?:async\s+)?function|const|let|class|interface|type|enum)\s+(\w+)/gm;
@@ -70,12 +78,18 @@ async function detectBreakingChanges() {
70
78
 
71
79
  // For each removed symbol, check if any tracked file still imports/uses it
72
80
  const warnings = [];
81
+ const defaultExcludes = [':!.morph/archive/', ':!.morph/framework/', ':!node_modules/'];
82
+ const pathspecs = [...diffPatterns, ...defaultExcludes];
83
+
73
84
  for (const sym of removedExports) {
74
85
  try {
75
- const result = execSync(`git grep -l "${sym}"`, execOpts).toString().trim();
86
+ const grepCmd = ['git', 'grep', '-l', `"${sym}"`, '--', ...pathspecs].join(' ');
87
+ const result = exec(grepCmd, execOpts).toString().trim();
76
88
  if (result) {
77
- const consumers = result.split('\n').filter(Boolean);
78
- warnings.push({ export: sym, consumers });
89
+ const consumers = result.split('\n').filter(Boolean).filter(f => !sourceFiles.has(f));
90
+ if (consumers.length > 0) {
91
+ warnings.push({ export: sym, consumers });
92
+ }
79
93
  }
80
94
  } catch {
81
95
  // No match found — safe
@@ -98,7 +112,9 @@ class TaskManager {
98
112
  * @param {string} featureName - Feature name
99
113
  * @param {string[]} taskIds - Task IDs to complete
100
114
  * @param {Object} options - Options
101
- * @param {boolean} options.skipValidation - Skip code validation
115
+ * @param {boolean} options.skipValidation - Skip code validation (budget-limited)
116
+ * @param {string} [options.reason] - Reason for skipping validation (required with skipValidation)
117
+ * @param {string} [options.note] - Deviation note explaining why task was done differently than planned
102
118
  * @param {boolean} options.dryRun - Dry run (no state changes)
103
119
  * @param {Function} [options._runTestSuite] - Inject a mock runTestSuite for testing
104
120
  */
@@ -159,6 +175,28 @@ class TaskManager {
159
175
  // The guard is skipped when --skip-validation or --dry-run is active.
160
176
  runTestGuard(tasksToComplete, process.cwd(), options);
161
177
 
178
+ // ─── Skip-Validation Budget Enforcement ───────────────────────────────────
179
+ if (options.skipValidation && tasksToComplete.length > 0 && !options.dryRun) {
180
+ // Require --reason with minimum 5 characters
181
+ if (!options.reason || options.reason.trim().length < 5) {
182
+ console.error(chalk.red('❌ --skip-validation requires --reason <text> (minimum 5 characters)'));
183
+ console.error(chalk.gray(' Example: morph-spec task done feat T001 --skip-validation --reason "CSS-only change, no logic"'));
184
+ process.exit(1);
185
+ }
186
+
187
+ // Check budget
188
+ const budget = computeSkipBudget(feature);
189
+ const used = (feature.skipValidationLog || []).length;
190
+ if (used >= budget) {
191
+ console.error(chalk.red(`❌ Skip-validation budget exhausted (${used}/${budget})`));
192
+ console.error(chalk.gray(' All remaining tasks must pass Tier-4 validation.'));
193
+ console.error(chalk.gray(' Budget formula: min(5, ceil(totalTasks × 0.20))'));
194
+ process.exit(1);
195
+ }
196
+
197
+ console.log(chalk.yellow(`⚠ --skip-validation: using budget (${used + tasksToComplete.length}/${budget})`));
198
+ }
199
+
162
200
  // Run validation BEFORE marking tasks as complete
163
201
  if (tasksToComplete.length > 0 && !options.skipValidation) {
164
202
  const validationResult = await this.runValidation(featureName, { mode: 'light' });
@@ -200,24 +238,42 @@ class TaskManager {
200
238
  process.exit(1);
201
239
  }
202
240
 
203
- // Validation passed — mark any pending history as passed.
241
+ // Validation passed — always record passed status in validationHistory.
204
242
  // Update in-place on `feature` (same object as state.features[featureName]) so
205
243
  // the single saveState(state) at the end of the function persists everything
206
244
  // atomically. A separate saveState here would trigger a second renameSync on
207
245
  // Windows, risking EPERM and leaving state.json without the task completions
208
246
  // (the count bug), and would also be clobbered by the final save anyway.
209
- if (feature.validationHistory) {
210
- for (const task of tasksToComplete) {
211
- if (feature.validationHistory[task.id]) {
212
- feature.validationHistory[task.id].status = 'passed';
213
- feature.validationHistory[task.id].updatedAt = new Date().toISOString();
214
- }
215
- }
247
+ if (!feature.validationHistory) feature.validationHistory = {};
248
+ for (const task of tasksToComplete) {
249
+ feature.validationHistory[task.id] = {
250
+ ...(feature.validationHistory[task.id] || {}),
251
+ status: 'passed',
252
+ updatedAt: new Date().toISOString(),
253
+ };
216
254
  }
217
255
  } else if (options.dryRun) {
218
256
  console.log(chalk.cyan('\n ℹ️ Dry-run — tasks NOT marked as complete (validation skipped)'));
219
257
  return [];
220
258
  } else if (shouldLogSkipValidationWarning(tasksToComplete, options.skipValidation, options.dryRun)) {
259
+ // Record skip in state: skipValidationLog + validationHistory
260
+ if (!feature.skipValidationLog) feature.skipValidationLog = [];
261
+ if (!feature.validationHistory) feature.validationHistory = {};
262
+ for (const task of tasksToComplete) {
263
+ feature.skipValidationLog.push({
264
+ taskId: task.id,
265
+ reason: options.reason,
266
+ timestamp: new Date().toISOString(),
267
+ });
268
+ feature.validationHistory[task.id] = {
269
+ ...(feature.validationHistory[task.id] || {}),
270
+ attempt: 0,
271
+ status: 'skipped',
272
+ reason: options.reason,
273
+ updatedAt: new Date().toISOString(),
274
+ };
275
+ }
276
+
221
277
  console.log(chalk.yellow('\n⚠ --skip-validation: Tier-4 code validators bypassed — tasks will be marked complete without validation'));
222
278
  console.log(chalk.gray(' Re-run without --skip-validation before committing.\n'));
223
279
  }
@@ -250,9 +306,23 @@ class TaskManager {
250
306
  const agentId = resolveAgentForTask(featureName, task.id);
251
307
  task.completedBy = agentId || 'claude';
252
308
 
309
+ // Add deviation note if provided
310
+ if (options.note) {
311
+ if (!task.notes) task.notes = [];
312
+ task.notes.push({
313
+ text: options.note,
314
+ type: 'deviation',
315
+ addedAt: new Date().toISOString(),
316
+ addedBy: agentId || 'claude'
317
+ });
318
+ }
319
+
253
320
  results.push(task);
254
321
 
255
322
  console.log(chalk.green(`✅ Task ${task.id} completed!`));
323
+ if (options.note) {
324
+ console.log(chalk.gray(` Note: ${options.note}`));
325
+ }
256
326
 
257
327
  // Register checkpoint if task has one
258
328
  if (task.checkpoint) {
@@ -333,16 +403,24 @@ class TaskManager {
333
403
  }
334
404
  }
335
405
 
336
- // Suggest next task
406
+ // Suggest next task(s)
337
407
  const activeTasks = taskList.filter(t => t.status !== 'expanded');
338
408
  const allCompleted = activeTasks.length > 0 && activeTasks.every(t => t.status === 'completed');
339
- const nextTask = this.getNextTask(taskList);
409
+ const readyTasks = this.getReadyTasks(taskList);
340
410
 
341
411
  if (allCompleted) {
342
412
  console.log(chalk.green('\n🎉 All tasks completed!'));
343
- } else if (nextTask) {
413
+ } else if (readyTasks.length > 1) {
414
+ const first = readyTasks[0];
415
+ console.log(chalk.cyan(`\n⏭️ Next: ${first.id} - ${first.title}`));
416
+ if (first.dependencies?.length > 0) {
417
+ console.log(chalk.gray(` Dependencies: ${first.dependencies.join(', ')}`));
418
+ }
419
+ console.log(chalk.magenta(`🔀 Parallelizable (${readyTasks.length} tasks ready): ${readyTasks.map(t => t.id).join(', ')}`));
420
+ } else if (readyTasks.length === 1) {
421
+ const nextTask = readyTasks[0];
344
422
  console.log(chalk.cyan(`\n⏭️ Next: ${nextTask.id} - ${nextTask.title}`));
345
- if (nextTask.dependencies && nextTask.dependencies.length > 0) {
423
+ if (nextTask.dependencies?.length > 0) {
346
424
  console.log(chalk.gray(` Dependencies: ${nextTask.dependencies.join(', ')}`));
347
425
  }
348
426
  } else {
@@ -408,7 +486,7 @@ class TaskManager {
408
486
  // This is fail-open by design: a broken validator shouldn't block commits.
409
487
  // Common cause: missing optional deps.
410
488
  console.log(chalk.yellow(`\n⚠️ Validation skipped (${error.message})`));
411
- console.log(chalk.gray(` Run manually: npx morph-spec validate-feature ${featureName}`));
489
+ console.log(chalk.gray(` Run manually: morph-spec validate-feature ${featureName}`));
412
490
  return { passed: true, validators: {}, passRate: 1.0 };
413
491
  }
414
492
  }
@@ -650,6 +728,22 @@ class TaskManager {
650
728
  return null;
651
729
  }
652
730
 
731
+ /**
732
+ * Get ALL tasks that are currently ready (no unmet dependencies).
733
+ */
734
+ getReadyTasks(tasks) {
735
+ const isEffectivelyAvailable = (t) => {
736
+ if (t.status === 'expanded') return false;
737
+ if (t.status === 'pending') return true;
738
+ if (t.status === 'in_progress') return isStaleTask(t);
739
+ return false;
740
+ };
741
+
742
+ return tasks.filter(t =>
743
+ isEffectivelyAvailable(t) && this.checkDependencies(t, tasks).length === 0
744
+ );
745
+ }
746
+
653
747
  /**
654
748
  * Display progress
655
749
  */
@@ -683,6 +777,25 @@ class TaskManager {
683
777
  throw new Error(`Feature '${featureName}' not found`);
684
778
  }
685
779
 
780
+ // Dispatch enforcement gate: block task start in implement phase
781
+ // unless dispatch-agents has been run (recorded in dispatchLog)
782
+ const featurePath = join(process.cwd(), '.morph', 'features', featureName);
783
+ const currentPhase = feature.phase || derivePhase(featurePath);
784
+ if (currentPhase === 'implement') {
785
+ const hasImplementDispatch = Array.isArray(feature.dispatchLog) &&
786
+ feature.dispatchLog.some(entry => entry.phase === 'implement');
787
+ if (!hasImplementDispatch) {
788
+ console.error(chalk.red('\n✗ Cannot start task — agent dispatch not recorded for implement phase'));
789
+ console.error(chalk.yellow(
790
+ `\n Run this command first:\n` +
791
+ ` morph-spec dispatch-agents ${featureName} implement\n\n` +
792
+ ` This configures typed specialist agents with enriched prompts,\n` +
793
+ ` task assignments, and quality standards for parallel execution.\n`
794
+ ));
795
+ process.exit(1);
796
+ }
797
+ }
798
+
686
799
  const taskList = await ensureTaskList(feature, featureName);
687
800
 
688
801
  if (taskList.length === 0) {
@@ -743,6 +856,115 @@ class TaskManager {
743
856
  console.log(chalk.blue(`▶️ Task ${taskId} started: ${task.title}`));
744
857
  }
745
858
 
859
+ /**
860
+ * Bulk-start tasks (mark as in_progress in a single operation).
861
+ * @param {string} featureName
862
+ * @param {Object} opts
863
+ * @param {boolean} [opts.all] - Start all pending tasks with met dependencies
864
+ * @param {string} [opts.from] - Start of range (e.g. T001)
865
+ * @param {string} [opts.to] - End of range (e.g. T053)
866
+ * @param {string} [opts.range] - Compact range string (e.g. T001..T082)
867
+ */
868
+ async bulkStartTasks(featureName, opts = {}) {
869
+ const state = loadState();
870
+ const feature = state.features[featureName];
871
+ if (!feature) throw new Error(`Feature '${featureName}' not found in state.json`);
872
+
873
+ // Dispatch enforcement gate (same as startTask)
874
+ const featurePath = join(process.cwd(), '.morph', 'features', featureName);
875
+ const currentPhase = feature.phase || derivePhase(featurePath);
876
+ if (currentPhase === 'implement') {
877
+ const hasImplementDispatch = Array.isArray(feature.dispatchLog) &&
878
+ feature.dispatchLog.some(entry => entry.phase === 'implement');
879
+ if (!hasImplementDispatch) {
880
+ console.error(chalk.red('\n✗ Cannot start tasks — agent dispatch not recorded for implement phase'));
881
+ console.error(chalk.yellow(
882
+ `\n Run this command first:\n` +
883
+ ` morph-spec dispatch-agents ${featureName} implement\n`
884
+ ));
885
+ process.exit(1);
886
+ }
887
+ }
888
+
889
+ const taskList = await ensureTaskList(feature, featureName);
890
+ if (taskList.length === 0) throw new Error(`No tasks found for '${featureName}'`);
891
+
892
+ // Resolve target IDs from range options
893
+ let targetIds;
894
+ if (opts.all) {
895
+ targetIds = taskList.filter(t => t.status === 'pending').map(t => t.id);
896
+ } else if (opts.from && opts.to) {
897
+ targetIds = this.expandRange(taskList, opts.from, opts.to);
898
+ } else if (opts.range && opts.range.includes('..')) {
899
+ const [from, to] = opts.range.split('..');
900
+ targetIds = this.expandRange(taskList, from.trim(), to.trim());
901
+ } else {
902
+ throw new Error('bulk-start requires --all, --from/--to, or a T001..T014 range argument');
903
+ }
904
+
905
+ if (targetIds.length === 0) {
906
+ console.log(chalk.yellow('⚠️ No tasks matched the given range'));
907
+ return;
908
+ }
909
+
910
+ // Reset stale siblings ONCE upfront
911
+ for (const t of taskList) {
912
+ if (!targetIds.includes(t.id) && isStaleTask(t)) {
913
+ t.status = 'pending';
914
+ delete t.startedAt;
915
+ console.log(chalk.yellow(`⚠️ Task ${t.id} auto-reset to pending (stale from previous session)`));
916
+ }
917
+ }
918
+
919
+ let started = 0;
920
+ let skipped = 0;
921
+ for (const id of targetIds) {
922
+ const task = taskList.find(t => t.id === id);
923
+ if (!task) continue;
924
+
925
+ if (task.status === 'completed') {
926
+ console.log(chalk.gray(` ⏩ ${id} already completed — skipping`));
927
+ skipped++;
928
+ continue;
929
+ }
930
+
931
+ if (task.status === 'in_progress' && !isStaleTask(task)) {
932
+ console.log(chalk.gray(` ⏩ ${id} already in_progress — skipping`));
933
+ skipped++;
934
+ continue;
935
+ }
936
+
937
+ const missingDeps = this.checkDependencies(task, taskList);
938
+ if (missingDeps.length > 0) {
939
+ console.log(chalk.yellow(` ⚠️ ${id} blocked by: ${missingDeps.join(', ')} — skipping`));
940
+ skipped++;
941
+ continue;
942
+ }
943
+
944
+ task.status = 'in_progress';
945
+ task.startedAt = new Date().toISOString();
946
+
947
+ // Agent correlation
948
+ const agentId = resolveAgentForTask(featureName, id);
949
+ if (agentId) {
950
+ task.assignedAgent = agentId;
951
+ const lastDispatch = feature.dispatchLog?.[feature.dispatchLog.length - 1];
952
+ const dispatchAgent = lastDispatch?.agents?.find(a => a.id === agentId);
953
+ if (dispatchAgent && !dispatchAgent.startedAt) {
954
+ updateDispatchAgentTiming(featureName, agentId, { startedAt: task.startedAt });
955
+ }
956
+ }
957
+
958
+ started++;
959
+ }
960
+
961
+ syncCounters(feature);
962
+ feature.updatedAt = new Date().toISOString();
963
+ saveState(state);
964
+
965
+ console.log(chalk.blue(`\n▶️ Bulk-start: ${started} task(s) started` + (skipped ? `, ${skipped} skipped` : '')));
966
+ }
967
+
746
968
  /**
747
969
  * Bulk-complete tasks with a single validation pass.
748
970
  * @param {string} featureName
@@ -776,7 +998,7 @@ class TaskManager {
776
998
  }
777
999
 
778
1000
  console.log(chalk.cyan(`\n📦 Bulk-done: ${targetIds.length} task(s) targeted`));
779
- await this.completeTasks(featureName, targetIds, { skipValidation: opts.skipValidation, dryRun: opts.dryRun });
1001
+ await this.completeTasks(featureName, targetIds, { skipValidation: opts.skipValidation, reason: opts.reason, note: opts.note, dryRun: opts.dryRun });
780
1002
  }
781
1003
 
782
1004
  /**
@@ -809,24 +1031,91 @@ class TaskManager {
809
1031
  const taskList = await ensureTaskList(feature, featureName);
810
1032
  const activeTasks = taskList.filter(t => t.status !== 'expanded');
811
1033
  const allCompleted = activeTasks.length > 0 && activeTasks.every(t => t.status === 'completed');
812
- const nextTask = this.getNextTask(taskList);
1034
+ const readyTasks = this.getReadyTasks(taskList);
813
1035
 
814
1036
  if (allCompleted) {
815
1037
  console.log(chalk.green('🎉 All tasks completed!'));
816
- } else if (nextTask) {
1038
+ } else if (readyTasks.length > 1) {
1039
+ const first = readyTasks[0];
1040
+ console.log(chalk.cyan(`\n⏭️ Next task: ${first.id} - ${first.title}`));
1041
+ if (first.dependencies?.length > 0) {
1042
+ console.log(chalk.gray(` Dependencies: ${first.dependencies.join(', ')}`));
1043
+ }
1044
+ if (first.files?.length > 0) {
1045
+ console.log(chalk.gray(` Files:`));
1046
+ first.files.forEach(f => console.log(chalk.gray(` - ${f}`)));
1047
+ }
1048
+ this._showDependencyDeviations(first, taskList);
1049
+ console.log(chalk.magenta(`🔀 Parallelizable (${readyTasks.length} tasks ready): ${readyTasks.map(t => t.id).join(', ')}`));
1050
+ } else if (readyTasks.length === 1) {
1051
+ const nextTask = readyTasks[0];
817
1052
  console.log(chalk.cyan(`\n⏭️ Next task: ${nextTask.id} - ${nextTask.title}`));
818
- if (nextTask.dependencies && nextTask.dependencies.length > 0) {
1053
+ if (nextTask.dependencies?.length > 0) {
819
1054
  console.log(chalk.gray(` Dependencies: ${nextTask.dependencies.join(', ')}`));
820
1055
  }
821
- if (nextTask.files && nextTask.files.length > 0) {
1056
+ if (nextTask.files?.length > 0) {
822
1057
  console.log(chalk.gray(` Files:`));
823
1058
  nextTask.files.forEach(f => console.log(chalk.gray(` - ${f}`)));
824
1059
  }
1060
+ this._showDependencyDeviations(nextTask, taskList);
825
1061
  } else {
826
1062
  const remaining = activeTasks.filter(t => t.status !== 'completed');
827
1063
  console.log(chalk.yellow(`\n⏸️ No tasks available — ${remaining.length} task(s) have unmet dependencies`));
828
1064
  }
829
1065
  }
1066
+
1067
+ /**
1068
+ * Show deviation notes from completed dependencies (context for next agent)
1069
+ * @param {Object} task - The next task
1070
+ * @param {Object[]} taskList - Full task list
1071
+ */
1072
+ _showDependencyDeviations(task, taskList) {
1073
+ const deps = task.dependencies || [];
1074
+ const completedWithNotes = taskList.filter(t =>
1075
+ deps.includes(t.id) && t.status === 'completed' && t.notes?.length > 0
1076
+ );
1077
+ for (const t of completedWithNotes) {
1078
+ for (const n of t.notes.filter(n => n.type === 'deviation')) {
1079
+ console.log(chalk.yellow(` ⚠ ${t.id} deviation: ${n.text}`));
1080
+ }
1081
+ }
1082
+ }
1083
+
1084
+ /**
1085
+ * Add a note to a task (deviation, context, or blocker-resolved)
1086
+ * @param {string} featureName - Feature name
1087
+ * @param {string} taskId - Task ID
1088
+ * @param {string} text - Note text
1089
+ * @param {string} [type='deviation'] - Note type: deviation | context | blocker-resolved
1090
+ */
1091
+ async annotateTask(featureName, taskId, text, type = 'deviation') {
1092
+ const validTypes = ['deviation', 'context', 'blocker-resolved'];
1093
+ if (!validTypes.includes(type)) {
1094
+ throw new Error(`Invalid note type '${type}'. Valid types: ${validTypes.join(', ')}`);
1095
+ }
1096
+
1097
+ const state = loadState();
1098
+ const feature = state.features[featureName];
1099
+ if (!feature) throw new Error(`Feature '${featureName}' not found`);
1100
+
1101
+ const taskList = await ensureTaskList(feature, featureName);
1102
+ const task = taskList.find(t => t.id === taskId);
1103
+ if (!task) throw new Error(`Task ${taskId} not found in '${featureName}'`);
1104
+
1105
+ if (!task.notes) task.notes = [];
1106
+ task.notes.push({
1107
+ text,
1108
+ type,
1109
+ addedAt: new Date().toISOString(),
1110
+ addedBy: 'user'
1111
+ });
1112
+
1113
+ feature.updatedAt = new Date().toISOString();
1114
+ saveState(state);
1115
+
1116
+ console.log(chalk.green(`Added ${type} note to ${taskId}`));
1117
+ console.log(chalk.gray(` "${text}"`));
1118
+ }
830
1119
  }
831
1120
 
832
1121
  // ============================================================================
@@ -845,16 +1134,23 @@ async function main() {
845
1134
  case 'complete': {
846
1135
  const skipValidation = args.includes('--skip-validation');
847
1136
  const dryRun = args.includes('--dry-run');
848
- const filteredArgs = args.filter(a => !['--skip-validation', '--dry-run'].includes(a));
1137
+ const reasonIdx = args.indexOf('--reason');
1138
+ const reason = reasonIdx !== -1 ? args[reasonIdx + 1] : null;
1139
+ const noteIdx = args.indexOf('--note');
1140
+ const note = noteIdx !== -1 ? args[noteIdx + 1] : null;
1141
+ const filteredArgs = args.filter((a, i) =>
1142
+ !['--skip-validation', '--dry-run', '--reason', '--note'].includes(a) &&
1143
+ !(i > 0 && args[i - 1] === '--reason') &&
1144
+ !(i > 0 && args[i - 1] === '--note'));
849
1145
  const featureName = filteredArgs[1];
850
1146
  const taskIds = filteredArgs.slice(2);
851
1147
 
852
1148
  if (!featureName || taskIds.length === 0) {
853
- console.error(chalk.red('Usage: npx morph-spec task done <feature> <task-id> [task-id...] [--skip-validation] [--dry-run]'));
1149
+ console.error(chalk.red('Usage: morph-spec task done <feature> <task-id> [task-id...] [--note <text>] [--skip-validation] [--reason <text>] [--dry-run]'));
854
1150
  process.exit(1);
855
1151
  }
856
1152
 
857
- await manager.completeTasks(featureName, taskIds, { skipValidation, dryRun });
1153
+ await manager.completeTasks(featureName, taskIds, { skipValidation, reason, note, dryRun });
858
1154
  break;
859
1155
  }
860
1156
 
@@ -864,20 +1160,26 @@ async function main() {
864
1160
  const allFlag = args.includes('--all');
865
1161
  const fromIdx = args.indexOf('--from');
866
1162
  const toIdx = args.indexOf('--to');
867
- const filteredArgs = args.filter(a => !['--skip-validation', '--dry-run', '--all'].includes(a)
868
- && !a.startsWith('--from') && !a.startsWith('--to'));
1163
+ const reasonIdx = args.indexOf('--reason');
1164
+ const reason = reasonIdx !== -1 ? args[reasonIdx + 1] : null;
1165
+ const noteIdx = args.indexOf('--note');
1166
+ const note = noteIdx !== -1 ? args[noteIdx + 1] : null;
1167
+ const filteredArgs = args.filter((a, i) => !['--skip-validation', '--dry-run', '--all', '--reason', '--note'].includes(a)
1168
+ && !a.startsWith('--from') && !a.startsWith('--to')
1169
+ && !(i > 0 && args[i - 1] === '--reason')
1170
+ && !(i > 0 && args[i - 1] === '--note'));
869
1171
  const featureName = filteredArgs[1];
870
1172
  const rangeArg = filteredArgs[2]; // e.g. T001..T082
871
1173
 
872
1174
  if (!featureName) {
873
- console.error(chalk.red('Usage: npx morph-spec task bulk-done <feature> [--all | --from T001 --to T053 | T001..T082] [--skip-validation] [--dry-run]'));
1175
+ console.error(chalk.red('Usage: morph-spec task bulk-done <feature> [--all | --from T001 --to T053 | T001..T082] [--note <text>] [--skip-validation] [--reason <text>] [--dry-run]'));
874
1176
  process.exit(1);
875
1177
  }
876
1178
 
877
1179
  const fromId = fromIdx !== -1 ? args[fromIdx + 1] : null;
878
1180
  const toId = toIdx !== -1 ? args[toIdx + 1] : null;
879
1181
 
880
- await manager.bulkCompleteTasks(featureName, { all: allFlag, from: fromId, to: toId, range: rangeArg, skipValidation, dryRun });
1182
+ await manager.bulkCompleteTasks(featureName, { all: allFlag, from: fromId, to: toId, range: rangeArg, skipValidation, reason, note, dryRun });
881
1183
  break;
882
1184
  }
883
1185
 
@@ -886,7 +1188,7 @@ async function main() {
886
1188
  const taskId = args[2];
887
1189
 
888
1190
  if (!featureName || !taskId) {
889
- console.error(chalk.red('Usage: npx morph-spec task start <feature> <task-id>'));
1191
+ console.error(chalk.red('Usage: morph-spec task start <feature> <task-id>'));
890
1192
  process.exit(1);
891
1193
  }
892
1194
 
@@ -894,11 +1196,32 @@ async function main() {
894
1196
  break;
895
1197
  }
896
1198
 
1199
+ case 'bulk-start': {
1200
+ const allFlag = args.includes('--all');
1201
+ const fromIdx = args.indexOf('--from');
1202
+ const toIdx = args.indexOf('--to');
1203
+ const filteredArgs = args.filter(a => !['--all'].includes(a)
1204
+ && !a.startsWith('--from') && !a.startsWith('--to'));
1205
+ const featureName = filteredArgs[1];
1206
+ const rangeArg = filteredArgs[2]; // e.g. T007..T014
1207
+
1208
+ if (!featureName) {
1209
+ console.error(chalk.red('Usage: morph-spec task bulk-start <feature> [--all | --from T001 --to T014 | T007..T014]'));
1210
+ process.exit(1);
1211
+ }
1212
+
1213
+ const fromId = fromIdx !== -1 ? args[fromIdx + 1] : null;
1214
+ const toId = toIdx !== -1 ? args[toIdx + 1] : null;
1215
+
1216
+ await manager.bulkStartTasks(featureName, { all: allFlag, from: fromId, to: toId, range: rangeArg });
1217
+ break;
1218
+ }
1219
+
897
1220
  case 'next': {
898
1221
  const featureName = args[1];
899
1222
 
900
1223
  if (!featureName) {
901
- console.error(chalk.red('Usage: npx morph-spec task next <feature>'));
1224
+ console.error(chalk.red('Usage: morph-spec task next <feature>'));
902
1225
  process.exit(1);
903
1226
  }
904
1227
 
@@ -906,6 +1229,22 @@ async function main() {
906
1229
  break;
907
1230
  }
908
1231
 
1232
+ case 'annotate': {
1233
+ const featureName = args[1];
1234
+ const taskId = args[2];
1235
+ const text = args[3];
1236
+ const typeIdx = args.indexOf('--type');
1237
+ const type = typeIdx !== -1 ? args[typeIdx + 1] : 'deviation';
1238
+
1239
+ if (!featureName || !taskId || !text) {
1240
+ console.error(chalk.red('Usage: morph-spec task annotate <feature> <task-id> <text> [--type deviation|context|blocker-resolved]'));
1241
+ process.exit(1);
1242
+ }
1243
+
1244
+ await manager.annotateTask(featureName, taskId, text, type);
1245
+ break;
1246
+ }
1247
+
909
1248
  default:
910
1249
  console.error(chalk.red(`Unknown command: ${command}`));
911
1250
  console.log(chalk.gray('\nAvailable commands:'));
@@ -913,9 +1252,13 @@ async function main() {
913
1252
  console.log(chalk.gray(' bulk-done <feature> --all - Mark all pending tasks as completed'));
914
1253
  console.log(chalk.gray(' bulk-done <feature> --from T001 --to T053 - Mark range as completed'));
915
1254
  console.log(chalk.gray(' bulk-done <feature> T001..T082 - Mark range as completed'));
916
- console.log(chalk.gray(' start <feature> <task-id> - Start a task (mark as in_progress)'));
917
- console.log(chalk.gray(' next <feature> - Show next suggested task'));
918
- console.log(chalk.gray('\nFlags: --skip-validation, --dry-run'));
1255
+ console.log(chalk.gray(' start <feature> <task-id> - Start a task (mark as in_progress)'));
1256
+ console.log(chalk.gray(' bulk-start <feature> --all - Start all pending tasks'));
1257
+ console.log(chalk.gray(' bulk-start <feature> --from T007 --to T014 - Start range of tasks'));
1258
+ console.log(chalk.gray(' bulk-start <feature> T007..T014 - Start range of tasks'));
1259
+ console.log(chalk.gray(' next <feature> - Show next suggested task'));
1260
+ console.log(chalk.gray(' annotate <feature> <task-id> <text> - Add a note to a task'));
1261
+ console.log(chalk.gray('\nFlags: --skip-validation --reason <text>, --note <text>, --dry-run'));
919
1262
  process.exit(1);
920
1263
  }
921
1264
  } catch (error) {
@@ -941,6 +1284,19 @@ export function shouldLogSkipValidationWarning(tasksToComplete, skipValidation,
941
1284
  return tasksToComplete.length > 0 && !!skipValidation && !dryRun;
942
1285
  }
943
1286
 
1287
+ /**
1288
+ * Compute the skip-validation budget for a feature.
1289
+ * Formula: clamp(ceil(totalTasks * 0.20), 1, 5)
1290
+ *
1291
+ * @param {Object} feature - Feature state object
1292
+ * @returns {number} Maximum allowed skip-validation uses
1293
+ */
1294
+ export function computeSkipBudget(feature) {
1295
+ const total = feature.tasks?.total || 0;
1296
+ if (total === 0) return 1;
1297
+ return Math.max(1, Math.min(5, Math.ceil(total * 0.2)));
1298
+ }
1299
+
944
1300
  /**
945
1301
  * Test Task Guard — run project test suite before completing test-keyword tasks.
946
1302
  *
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morph-spec",
3
- "version": "4.10.10",
3
+ "version": "4.11.1",
4
4
  "displayName": "MORPH-SPEC Framework",
5
5
  "description": "Spec-driven development with 38 agents and 8-phase workflow for .NET/Blazor/Next.js/Azure",
6
6
  "publisher": "polymorphism-tech",
@@ -198,4 +198,4 @@ These files are never edited directly. Use CLI commands or `morph-spec update` i
198
198
 
199
199
  ---
200
200
 
201
- *morph-spec v4.10.10 by Polymorphism Tech*
201
+ *morph-spec v4.11.1 by Polymorphism Tech*