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