@polymorphism-tech/morph-spec 4.11.0 → 4.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CLAUDE.md +106 -2
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +31 -3
  4. package/bin/task-manager.js +391 -35
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +1 -1
  7. package/docs/QUICKSTART.md +1 -1
  8. package/framework/commands/morph-apply.md +21 -1
  9. package/framework/commands/morph-proposal.md +44 -1
  10. package/framework/hooks/claude-code/post-tool-use/dispatch.js +47 -2
  11. package/framework/hooks/claude-code/session-start/inject-morph-context.js +4 -3
  12. package/framework/hooks/shared/dispatch-helpers.js +14 -0
  13. package/framework/hooks/shared/state-reader.js +7 -3
  14. package/framework/rules/morph-task-tracking.md +49 -0
  15. package/framework/skills/level-0-meta/morph-brainstorming/SKILL.md +6 -10
  16. package/framework/skills/level-1-workflows/morph-phase-clarify/SKILL.md +10 -1
  17. package/framework/skills/level-1-workflows/morph-phase-codebase-analysis/SKILL.md +1 -1
  18. package/framework/skills/level-1-workflows/morph-phase-design/SKILL.md +7 -13
  19. package/framework/skills/level-1-workflows/morph-phase-implement/SKILL.md +7 -1
  20. package/framework/skills/level-1-workflows/morph-phase-plan/SKILL.md +9 -12
  21. package/framework/skills/level-1-workflows/morph-phase-setup/SKILL.md +7 -10
  22. package/framework/skills/level-1-workflows/morph-phase-tasks/SKILL.md +10 -18
  23. package/framework/skills/level-1-workflows/morph-phase-uiux/SKILL.md +9 -1
  24. package/framework/skills/level-1-workflows/morph-scope-escalation/SKILL.md +1 -1
  25. package/package.json +1 -1
  26. package/src/commands/agents/dispatch-agents.js +25 -1
  27. package/src/commands/tasks/task.js +54 -0
  28. package/src/lib/build/build-runner.js +99 -0
  29. package/src/lib/checkpoints/checkpoint-hooks.js +110 -0
  30. package/src/lib/generators/recap-generator.js +27 -4
  31. package/src/lib/stack/stack-profile.js +3 -0
  32. package/src/lib/tasks/task-parser.js +23 -9
  33. package/src/utils/file-copier.js +1 -0
  34. package/framework/hooks/claude-code/pre-tool-use/protect-readonly-files.js +0 -7
@@ -19,7 +19,7 @@
19
19
  import { readFileSync, existsSync } from 'fs';
20
20
  import { join, dirname } from 'path';
21
21
  import { fileURLToPath } from 'url';
22
- import { loadState, stateExists } from '../../core/state/state-manager.js';
22
+ import { loadState, stateExists, logDispatch } from '../../core/state/state-manager.js';
23
23
  import { buildAgentBriefing, buildValidatorPrompt } from '../../lib/standards/digest-builder.js';
24
24
  import { agentIdToSubagentType } from '../../utils/agent-id-mapper.js';
25
25
  import { shouldInstall, getProjectTags } from '../../lib/stack-filter.js';
@@ -434,6 +434,30 @@ export async function dispatchAgentsCommand(featureName, phase, options = {}) {
434
434
  if (options.mode) dispatchOpts.mode = options.mode;
435
435
  const config = await buildDispatchConfig(process.cwd(), featureName, normalizedPhase, dispatchOpts);
436
436
 
437
+ // Log dispatch to state.json so enforcement gates can verify it happened
438
+ if (config.agents?.length > 0) {
439
+ try {
440
+ logDispatch(featureName, {
441
+ timestamp: new Date().toISOString(),
442
+ phase: normalizedPhase,
443
+ mode: config.mode,
444
+ agents: config.agents.map(a => {
445
+ const groupEntry = Object.entries(config.taskGroups || {}).find(
446
+ ([, g]) => g.agentId === a.id
447
+ );
448
+ return {
449
+ id: a.id,
450
+ subagentType: a.subagentType,
451
+ group: a.parallelGroup,
452
+ tasks: groupEntry ? groupEntry[1].tasks : [],
453
+ };
454
+ }),
455
+ });
456
+ } catch {
457
+ // Non-blocking — dispatch output still succeeds even if log fails
458
+ }
459
+ }
460
+
437
461
  if (options.table) {
438
462
  // Human-readable table format
439
463
  console.log(chalk.bold(`\n Dispatch Config — ${featureName} (${normalizedPhase})`));
@@ -42,6 +42,8 @@ export async function taskDoneCommand(featureName, taskIds, options) {
42
42
  try {
43
43
  const args = ['done', featureName, ...taskIds];
44
44
  if (options.skipValidation) args.push('--skip-validation');
45
+ if (options.reason) args.push('--reason', options.reason);
46
+ if (options.note) args.push('--note', options.note);
45
47
  if (options.dryRun) args.push('--dry-run');
46
48
  await executeTaskManager(args);
47
49
  } catch (error) {
@@ -64,6 +66,58 @@ export async function taskStartCommand(featureName, taskId, options) {
64
66
  }
65
67
  }
66
68
 
69
+ /**
70
+ * Bulk-start tasks command
71
+ */
72
+ export async function taskBulkStartCommand(featureName, range, options) {
73
+ try {
74
+ const args = ['bulk-start', featureName];
75
+ if (range) args.push(range);
76
+ if (options.all) args.push('--all');
77
+ if (options.from) args.push('--from', options.from);
78
+ if (options.to) args.push('--to', options.to);
79
+ await executeTaskManager(args);
80
+ } catch (error) {
81
+ console.error(chalk.red(`Error: ${error.message}`));
82
+ process.exit(1);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Bulk-done tasks command
88
+ */
89
+ export async function taskBulkDoneCommand(featureName, range, options) {
90
+ try {
91
+ const args = ['bulk-done', featureName];
92
+ if (range) args.push(range);
93
+ if (options.all) args.push('--all');
94
+ if (options.from) args.push('--from', options.from);
95
+ if (options.to) args.push('--to', options.to);
96
+ if (options.skipValidation) args.push('--skip-validation');
97
+ if (options.reason) args.push('--reason', options.reason);
98
+ if (options.note) args.push('--note', options.note);
99
+ if (options.dryRun) args.push('--dry-run');
100
+ await executeTaskManager(args);
101
+ } catch (error) {
102
+ console.error(chalk.red(`Error: ${error.message}`));
103
+ process.exit(1);
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Annotate a task with a note
109
+ */
110
+ export async function taskAnnotateCommand(featureName, taskId, text, options) {
111
+ try {
112
+ const args = ['annotate', featureName, taskId, text];
113
+ if (options.type) args.push('--type', options.type);
114
+ await executeTaskManager(args);
115
+ } catch (error) {
116
+ console.error(chalk.red(`Error: ${error.message}`));
117
+ process.exit(1);
118
+ }
119
+ }
120
+
67
121
  /**
68
122
  * Get next task command
69
123
  */
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Build Runner — Resolves and executes the project's build command.
3
+ *
4
+ * Resolution order for build command:
5
+ * 1. .morph/config/config.json → project.buildCommand
6
+ * 2. Stack profile → profile.buildCommand
7
+ * 3. package.json → scripts.build → "npm run build"
8
+ * 4. tsconfig.json at root → "npx tsc --noEmit"
9
+ * 5. null (no build command detected)
10
+ */
11
+
12
+ import { execSync } from 'child_process';
13
+ import { existsSync, readFileSync } from 'fs';
14
+ import { join } from 'path';
15
+ import { getStackProfile } from '../stack/stack-profile.js';
16
+
17
+ /**
18
+ * Resolve the build command for a project.
19
+ *
20
+ * @param {string} [projectPath] - Project root directory (defaults to '.')
21
+ * @returns {string | null} The build command to run, or null if none found
22
+ */
23
+ export function resolveBuildCommand(projectPath = '.') {
24
+ // 1. Explicit override from .morph/config/config.json
25
+ const configPath = join(projectPath, '.morph', 'config', 'config.json');
26
+ if (existsSync(configPath)) {
27
+ try {
28
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
29
+ const cmd = config?.project?.buildCommand;
30
+ if (cmd && typeof cmd === 'string') {
31
+ return cmd;
32
+ }
33
+ } catch {
34
+ // Malformed config — fall through to detection
35
+ }
36
+ }
37
+
38
+ // 2. Stack profile default (only when an actual stack is configured)
39
+ const profile = getStackProfile(projectPath);
40
+ if (profile.stack !== 'unknown' && profile.buildCommand) {
41
+ return profile.buildCommand;
42
+ }
43
+
44
+ // 3. package.json with a build script
45
+ const packageJsonPath = join(projectPath, 'package.json');
46
+ if (existsSync(packageJsonPath)) {
47
+ try {
48
+ const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
49
+ if (pkg?.scripts?.build) {
50
+ return 'npm run build';
51
+ }
52
+ } catch {
53
+ // Malformed package.json — fall through
54
+ }
55
+ }
56
+
57
+ // 4. tsconfig.json at root → generic type-check
58
+ if (existsSync(join(projectPath, 'tsconfig.json'))) {
59
+ return 'npx tsc --noEmit';
60
+ }
61
+
62
+ // 5. Nothing found
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * Run the project's build check.
68
+ *
69
+ * @param {string} [projectPath] - Project root directory (defaults to '.')
70
+ * @param {object} [opts] - Optional overrides for testing
71
+ * @param {Function} [opts._execSync] - Override execSync for unit testing
72
+ * @returns {{ passed?: boolean, output?: string, exitCode?: number, command?: string, skipped?: boolean, reason?: string }}
73
+ */
74
+ export function runBuildCheck(projectPath = '.', opts = {}) {
75
+ const cmd = resolveBuildCommand(projectPath);
76
+
77
+ if (!cmd) {
78
+ return { skipped: true, reason: 'No buildCommand configured and no known project file detected' };
79
+ }
80
+
81
+ const exec = opts._execSync ?? execSync;
82
+
83
+ try {
84
+ const output = exec(cmd, {
85
+ cwd: projectPath,
86
+ timeout: 120_000,
87
+ encoding: 'utf8',
88
+ stdio: 'pipe'
89
+ });
90
+ return { passed: true, output: output ?? '', exitCode: 0, command: cmd };
91
+ } catch (err) {
92
+ return {
93
+ passed: false,
94
+ output: (err.stdout || '') + (err.stderr || ''),
95
+ exitCode: err.status ?? 1,
96
+ command: cmd
97
+ };
98
+ }
99
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Checkpoint Hooks — Orchestrates validation, build, and test phases at checkpoints.
3
+ *
4
+ * This is the module that task-manager.js imports at:
5
+ * import('../src/lib/checkpoints/checkpoint-hooks.js')
6
+ *
7
+ * All three phases run independently (failures don't short-circuit) so the user
8
+ * gets a complete picture of all issues in a single checkpoint report.
9
+ */
10
+
11
+ import chalk from 'chalk';
12
+ import { runValidation } from '../validators/validation-runner.js';
13
+ import { runBuildCheck } from '../build/build-runner.js';
14
+ import { runTestSuite } from '../tasks/test-runner.js';
15
+ import { persistCheckpointValidation } from '../validators/checkpoint-persistence.js';
16
+
17
+ /**
18
+ * Determine whether a checkpoint should run based on task count and frequency.
19
+ *
20
+ * @param {number} tasksCompleted - Number of tasks completed so far
21
+ * @param {number} frequency - Run every N tasks (default: 3)
22
+ * @returns {boolean}
23
+ */
24
+ export function shouldRunCheckpoint(tasksCompleted, frequency = 3) {
25
+ return tasksCompleted > 0 && tasksCompleted % frequency === 0;
26
+ }
27
+
28
+ /**
29
+ * Run all checkpoint hooks: static validation, build check, and test suite.
30
+ *
31
+ * @param {string} featureName - The feature being worked on
32
+ * @param {number} checkpointNum - Checkpoint sequence number
33
+ * @param {object} [opts] - Options
34
+ * @param {string} [opts.projectPath] - Project root (defaults to cwd)
35
+ * @returns {Promise<{ passed: boolean, summary: { errors: number, warnings: number }, results: object }>}
36
+ */
37
+ export async function runCheckpointHooks(featureName, checkpointNum, opts = {}) {
38
+ const projectPath = opts.projectPath ?? process.cwd();
39
+ const results = {};
40
+ let totalErrors = 0;
41
+ let totalWarnings = 0;
42
+
43
+ // Phase 1: Static validators
44
+ console.log(chalk.gray('\n Phase 1/3: Running static validators...'));
45
+ try {
46
+ const valResult = await runValidation(projectPath, featureName, { verbose: false, mode: 'full' });
47
+ results.validators = {
48
+ passed: valResult.passed,
49
+ errors: valResult.errors,
50
+ warnings: valResult.warnings,
51
+ details: valResult.results
52
+ };
53
+ totalErrors += valResult.errors;
54
+ totalWarnings += valResult.warnings;
55
+ } catch (err) {
56
+ results.validators = { passed: false, errors: 1, warnings: 0, details: {}, error: err.message };
57
+ totalErrors += 1;
58
+ }
59
+
60
+ // Phase 2: Build check
61
+ console.log(chalk.gray(' Phase 2/3: Running build check...'));
62
+ const buildResult = runBuildCheck(projectPath);
63
+ results.build = buildResult;
64
+ if (buildResult.skipped) {
65
+ console.log(chalk.gray(` Skipped: ${buildResult.reason}`));
66
+ } else if (buildResult.passed) {
67
+ console.log(chalk.green(` ✓ Build passed (${buildResult.command})`));
68
+ } else {
69
+ console.log(chalk.red(` ✗ Build failed (${buildResult.command})`));
70
+ totalErrors += 1;
71
+ }
72
+
73
+ // Phase 3: Test suite
74
+ console.log(chalk.gray(' Phase 3/3: Running test suite...'));
75
+ const testResult = runTestSuite(projectPath);
76
+ results.tests = testResult;
77
+ if (testResult.skipped) {
78
+ console.log(chalk.gray(` Skipped: ${testResult.reason}`));
79
+ } else if (testResult.passed) {
80
+ console.log(chalk.green(' ✓ Tests passed'));
81
+ } else {
82
+ console.log(chalk.red(' ✗ Tests failed'));
83
+ totalErrors += 1;
84
+ }
85
+
86
+ // Persist results
87
+ const summaryText = [
88
+ `Validators: ${results.validators?.errors || 0} errors, ${results.validators?.warnings || 0} warnings`,
89
+ buildResult.skipped ? 'Build: skipped' : `Build: ${buildResult.passed ? 'passed' : 'FAILED'}`,
90
+ testResult.skipped ? 'Tests: skipped' : `Tests: ${testResult.passed ? 'passed' : 'FAILED'}`
91
+ ].join(' | ');
92
+
93
+ try {
94
+ persistCheckpointValidation(projectPath, featureName, {
95
+ checkpointNum,
96
+ timestamp: new Date().toISOString(),
97
+ passed: totalErrors === 0,
98
+ validators: results,
99
+ summary: summaryText
100
+ });
101
+ } catch {
102
+ // Non-blocking — persistence failure shouldn't break the checkpoint
103
+ }
104
+
105
+ return {
106
+ passed: totalErrors === 0,
107
+ summary: { errors: totalErrors, warnings: totalWarnings },
108
+ results
109
+ };
110
+ }
@@ -233,11 +233,34 @@ function buildRecapMarkdown(data) {
233
233
 
234
234
  // Tasks Detail
235
235
  if (tasksSummary.items.length > 0) {
236
+ const anyNotes = tasksSummary.items.some(t => t.notes?.length > 0);
236
237
  md += `## Tasks\n\n`;
237
- md += `| ID | Title | Status |\n|----|-------|--------|\n`;
238
- for (const task of tasksSummary.items) {
239
- const statusIcon = task.status === 'completed' ? '✅' : task.status === 'in_progress' ? '🔄' : '⏳';
240
- md += `| ${task.id} | ${task.title || '-'} | ${statusIcon} ${task.status} |\n`;
238
+ if (anyNotes) {
239
+ md += `| ID | Title | Status | Notes |\n|----|-------|--------|-------|\n`;
240
+ for (const task of tasksSummary.items) {
241
+ const statusIcon = task.status === 'completed' ? '✅' : task.status === 'in_progress' ? '🔄' : '⏳';
242
+ const noteCount = task.notes?.length || 0;
243
+ const notesCell = noteCount > 0 ? `${noteCount} note${noteCount > 1 ? 's' : ''}` : '-';
244
+ md += `| ${task.id} | ${task.title || '-'} | ${statusIcon} ${task.status} | ${notesCell} |\n`;
245
+ }
246
+ } else {
247
+ md += `| ID | Title | Status |\n|----|-------|--------|\n`;
248
+ for (const task of tasksSummary.items) {
249
+ const statusIcon = task.status === 'completed' ? '✅' : task.status === 'in_progress' ? '🔄' : '⏳';
250
+ md += `| ${task.id} | ${task.title || '-'} | ${statusIcon} ${task.status} |\n`;
251
+ }
252
+ }
253
+ md += `\n`;
254
+ }
255
+
256
+ // Deviations from Plan
257
+ const deviations = tasksSummary.items
258
+ .filter(t => t.notes?.some(n => n.type === 'deviation'))
259
+ .flatMap(t => t.notes.filter(n => n.type === 'deviation').map(n => ({ taskId: t.id, text: n.text })));
260
+ if (deviations.length > 0) {
261
+ md += `## Deviations from Plan\n\n`;
262
+ for (const d of deviations) {
263
+ md += `- **${d.taskId}**: ${d.text}\n`;
241
264
  }
242
265
  md += `\n`;
243
266
  }
@@ -24,6 +24,7 @@ const PROFILES = {
24
24
  isDotnet: true, isNextjs: false,
25
25
  defaultValidators: ['architecture', 'packages'],
26
26
  checkpointValidators: ['architecture', 'packages', 'design-system', 'security'],
27
+ buildCommand: 'dotnet build --no-restore',
27
28
  contractsOutputType: 'contracts',
28
29
  contractsFilename: 'contracts.cs',
29
30
  contractsLanguage: 'csharp',
@@ -32,6 +33,7 @@ const PROFILES = {
32
33
  isDotnet: false, isNextjs: true,
33
34
  defaultValidators: ['nextjs-component'],
34
35
  checkpointValidators: ['nextjs-component', 'css'],
36
+ buildCommand: 'npx tsc --noEmit',
35
37
  contractsOutputType: 'contractsTs',
36
38
  contractsFilename: 'contracts.ts',
37
39
  contractsLanguage: 'typescript',
@@ -40,6 +42,7 @@ const PROFILES = {
40
42
  isDotnet: false, isNextjs: false,
41
43
  defaultValidators: [],
42
44
  checkpointValidators: [],
45
+ buildCommand: null,
43
46
  contractsOutputType: 'contracts',
44
47
  contractsFilename: 'contracts.cs',
45
48
  contractsLanguage: 'csharp',
@@ -122,18 +122,32 @@ export async function parseTasksMd(featureName, cwd = process.cwd()) {
122
122
  return [];
123
123
  }
124
124
 
125
- const tasks = [];
125
+ // Phase 1: collect heading positions
126
126
  const headingRe = /^###\s+(T\d+)\s*[—–:\-]\s*(.+)$/gm;
127
+ const depRe = /\*\*Dependenc(?:y|ies):\*\*\s*(.+)/i;
128
+ const positions = [];
127
129
  let match;
128
130
  while ((match = headingRe.exec(content)) !== null) {
129
- tasks.push({
130
- id: match[1],
131
- title: match[2].trim(),
132
- status: 'pending',
133
- dependencies: [],
134
- files: [],
135
- checkpoint: null,
136
- });
131
+ positions.push({ id: match[1], title: match[2].trim(), pos: match.index });
132
+ }
133
+
134
+ // Phase 2: extract body and parse dependencies
135
+ const tasks = [];
136
+ for (let i = 0; i < positions.length; i++) {
137
+ const { id, title, pos } = positions[i];
138
+ const end = i + 1 < positions.length ? positions[i + 1].pos : content.length;
139
+ const block = content.slice(pos, end);
140
+
141
+ let dependencies = [];
142
+ const depMatch = depRe.exec(block);
143
+ if (depMatch) {
144
+ const val = depMatch[1].trim();
145
+ if (val.toLowerCase() !== 'none') {
146
+ dependencies = val.split(/[,\s]+/).filter(s => /^T\d+$/.test(s));
147
+ }
148
+ }
149
+
150
+ tasks.push({ id, title, status: 'pending', dependencies, files: [], checkpoint: null });
137
151
  }
138
152
  return tasks;
139
153
  }
@@ -149,6 +149,7 @@ export async function updateGitignore(projectPath) {
149
149
  '.claude/CLAUDE.md',
150
150
  '.claude/settings.local.json',
151
151
  '.worktrees/',
152
+ 'docs/superpowers/',
152
153
  ''
153
154
  ];
154
155
 
@@ -1,7 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * DEPRECATED — This hook has been replaced by native permissions.deny in settings.local.json.
4
- * The file is kept as an empty stub. It is NOT registered in hooks-installer.js.
5
- * Safe to delete.
6
- */
7
- process.exit(0);