@polymorphism-tech/morph-spec 4.11.0 → 4.12.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.
- package/CLAUDE.md +106 -2
- package/README.md +2 -2
- package/bin/morph-spec.js +31 -3
- package/bin/task-manager.js +391 -35
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/agents.json +16 -6
- package/framework/commands/morph-apply.md +21 -1
- package/framework/commands/morph-proposal.md +44 -1
- package/framework/hooks/claude-code/post-tool-use/agent-complete.js +122 -0
- package/framework/hooks/claude-code/post-tool-use/dispatch.js +47 -2
- package/framework/hooks/claude-code/session-start/inject-morph-context.js +4 -3
- package/framework/hooks/shared/dispatch-helpers.js +31 -0
- package/framework/hooks/shared/state-reader.js +7 -3
- package/framework/rules/morph-task-tracking.md +49 -0
- package/framework/skills/level-0-meta/morph-brainstorming/SKILL.md +6 -10
- package/framework/skills/level-1-workflows/morph-phase-clarify/SKILL.md +10 -1
- package/framework/skills/level-1-workflows/morph-phase-codebase-analysis/SKILL.md +1 -1
- package/framework/skills/level-1-workflows/morph-phase-design/SKILL.md +7 -13
- package/framework/skills/level-1-workflows/morph-phase-implement/SKILL.md +7 -1
- package/framework/skills/level-1-workflows/morph-phase-plan/SKILL.md +9 -12
- package/framework/skills/level-1-workflows/morph-phase-setup/SKILL.md +7 -10
- package/framework/skills/level-1-workflows/morph-phase-tasks/SKILL.md +10 -18
- package/framework/skills/level-1-workflows/morph-phase-uiux/SKILL.md +256 -14
- package/framework/skills/level-1-workflows/morph-scope-escalation/SKILL.md +1 -1
- package/framework/standards/STANDARDS.json +31 -0
- package/framework/standards/frontend/design-system/aesthetic-direction.md +60 -7
- package/framework/standards/frontend/design-system/design-trends.md +171 -0
- package/framework/templates/context/CONTEXT.md +2 -2
- package/framework/templates/context/design-system.md +346 -211
- package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +1 -1
- package/package.json +1 -1
- package/src/commands/agents/dispatch-agents.js +25 -1
- package/src/commands/mcp/mcp-setup.js +28 -6
- package/src/commands/project/init.js +20 -1
- package/src/commands/tasks/task.js +54 -0
- package/src/lib/build/build-runner.js +99 -0
- package/src/lib/checkpoints/checkpoint-hooks.js +110 -0
- package/src/lib/generators/design-system-generator.js +340 -51
- package/src/lib/generators/recap-generator.js +27 -4
- package/src/lib/stack/stack-profile.js +3 -0
- package/src/lib/tasks/task-parser.js +23 -9
- package/src/lib/validators/design-system/design-system-validator.js +11 -5
- package/src/utils/file-copier.js +1 -0
- package/src/utils/hooks-installer.js +11 -1
- package/framework/hooks/claude-code/pre-tool-use/protect-readonly-files.js +0 -7
package/bin/task-manager.js
CHANGED
|
@@ -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
|
|
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 +=
|
|
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
|
|
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
|
-
|
|
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 —
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
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 (
|
|
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
|
|
423
|
+
if (nextTask.dependencies?.length > 0) {
|
|
346
424
|
console.log(chalk.gray(` Dependencies: ${nextTask.dependencies.join(', ')}`));
|
|
347
425
|
}
|
|
348
426
|
} else {
|
|
@@ -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
|
|
1034
|
+
const readyTasks = this.getReadyTasks(taskList);
|
|
813
1035
|
|
|
814
1036
|
if (allCompleted) {
|
|
815
1037
|
console.log(chalk.green('🎉 All tasks completed!'));
|
|
816
|
-
} else if (
|
|
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
|
|
1053
|
+
if (nextTask.dependencies?.length > 0) {
|
|
819
1054
|
console.log(chalk.gray(` Dependencies: ${nextTask.dependencies.join(', ')}`));
|
|
820
1055
|
}
|
|
821
|
-
if (nextTask.files
|
|
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
|
|
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: 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
|
|
868
|
-
|
|
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: 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
|
|
|
@@ -894,6 +1196,27 @@ 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
|
|
|
@@ -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
|
|
917
|
-
console.log(chalk.gray('
|
|
918
|
-
console.log(chalk.gray('
|
|
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
|
*
|
package/claude-plugin.json
CHANGED
package/docs/CHEATSHEET.md
CHANGED
package/docs/QUICKSTART.md
CHANGED