@polymorphism-tech/morph-spec 7.5.0 → 7.7.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 +18 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +12 -4
- package/framework/MORPH.md +18 -10
- package/framework/agents.json +13 -5
- package/framework/commands/morph-preflight.md +10 -5
- package/framework/hooks/README.md +8 -10
- package/framework/hooks/claude-code/core/gate-guard.js +36 -16
- package/framework/hooks/claude-code/core/protect-spec-files.js +20 -5
- package/framework/hooks/claude-code/core/tasks-json-guard.js +81 -0
- package/framework/hooks/shared/tasks-validator.js +68 -0
- package/framework/mcp/registry.json +5 -48
- package/framework/rubrics/backend.json +3 -3
- package/framework/rubrics/default.json +3 -3
- package/framework/rubrics/frontend.json +3 -3
- package/framework/rules/infrastructure-standards.md +4 -4
- package/framework/skills/core/create-umbraco-lp/SKILL.md +22 -2
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +84 -5
- package/framework/skills/core/morph-apply/SKILL.md +53 -40
- package/framework/skills/core/morph-brainstorming/SKILL.md +7 -12
- package/framework/skills/core/morph-eval/SKILL.md +10 -8
- package/framework/skills/core/morph-implement/SKILL.md +27 -21
- package/framework/skills/core/morph-plan/SKILL.md +12 -21
- package/framework/skills/core/morph-proposal/SKILL.md +3 -8
- package/framework/skills/core/morph-review/SKILL.md +30 -14
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/standards/STANDARDS.json +80 -8
- package/framework/standards/ai-agents/durable-workflows-hitl.md +191 -0
- package/framework/standards/ai-agents/rag-custom-pgvector.md +246 -243
- package/framework/standards/ai-agents/setup.md +3 -0
- package/framework/standards/ai-agents/vector-data-extensions.md +170 -0
- package/framework/standards/ai-agents/workflows.md +190 -187
- package/framework/standards/infrastructure/docker/{easypanel-deploy.md → coolify-deploy.md} +49 -20
- package/framework/standards/integration/mcp/mcp-tools.md +38 -43
- package/framework/templates/README.md +7 -7
- package/framework/templates/REGISTRY.json +14 -14
- package/framework/templates/context/CONTEXT.md +3 -3
- package/framework/templates/docs/proposal.md +2 -2
- package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +1 -1
- package/framework/templates/infrastructure/docker/coolify.template.json +49 -0
- package/framework/templates/infrastructure/github/README.md +6 -6
- package/framework/templates/infrastructure/github/workflows/deploy-coolify.yml.hbs +33 -0
- package/package.json +1 -1
- package/src/commands/project/doctor.js +36 -1
- package/src/commands/state/approve.js +29 -4
- package/src/commands/state/score.js +88 -0
- package/src/commands/tasks/dag.js +68 -0
- package/src/dashboard/scanner/rubrics-scanner.js +1 -1
- package/src/lib/generators/recap-generator.js +1 -1
- package/src/lib/tasks/dispatch-planner.js +201 -0
- package/src/lib/validators/score-aggregator.js +3 -3
- package/src/scripts/setup-infra.js +42 -0
- package/src/utils/agents-installer.js +142 -10
- package/src/utils/hooks-installer.js +13 -5
- package/framework/rubrics/eval-rubric.json +0 -66
- package/framework/templates/infrastructure/docker/easypanel.template.json +0 -46
- package/framework/templates/infrastructure/github/workflows/deploy-easypanel.yml.hbs +0 -31
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
getInstalledCLIVersion
|
|
13
13
|
} from '../../utils/version-checker.js';
|
|
14
14
|
import { resetMorphSettings } from '../../utils/claude-settings-manager.js';
|
|
15
|
-
import { checkMissingAgents } from '../../utils/agents-installer.js';
|
|
15
|
+
import { checkMissingAgents, checkAgentsDrift } from '../../utils/agents-installer.js';
|
|
16
16
|
import { getProjectTags } from '../../lib/stack-filter.js';
|
|
17
17
|
|
|
18
18
|
const isWindows = platform() === 'win32';
|
|
@@ -186,6 +186,41 @@ async function doctorFullCommand(frameworkRoot) {
|
|
|
186
186
|
} catch { /* ignore parse errors — already caught above */ }
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
// ── 6b. Generated personas drift (.claude/agents ↔ agents.json) ─────────
|
|
190
|
+
// agents.json is the single source of truth; the .claude/agents/morph-*.md
|
|
191
|
+
// files are derived artifacts (ADR-001). Missing = broken dispatch (error);
|
|
192
|
+
// stale/hand-edited or orphan = drift (warning).
|
|
193
|
+
if (await pathExists(agentsPath)) {
|
|
194
|
+
console.log(chalk.cyan('\n .claude/agents/ — Generated personas (drift vs agents.json)'));
|
|
195
|
+
try {
|
|
196
|
+
let projectTags = [];
|
|
197
|
+
const cfgPath = join(frameworkRoot, '.morph/config/config.json');
|
|
198
|
+
if (await pathExists(cfgPath)) {
|
|
199
|
+
try { projectTags = getProjectTags(JSON.parse(await fs.readFile(cfgPath, 'utf8'))); } catch { /* no tags */ }
|
|
200
|
+
}
|
|
201
|
+
const drift = checkAgentsDrift(frameworkRoot, join(frameworkRoot, '.morph/framework'), projectTags);
|
|
202
|
+
check(`Personas installed (${drift.expected - drift.missing.length}/${drift.expected})`,
|
|
203
|
+
drift.missing.length === 0, false,
|
|
204
|
+
drift.missing.length > 0 ? `missing: ${drift.missing.join(', ')} — run 'morph-spec setup-infra --reinstall'` : '');
|
|
205
|
+
check('Personas up-to-date (no drift)', drift.stale.length === 0, true,
|
|
206
|
+
drift.stale.length > 0 ? `stale/hand-edited: ${drift.stale.join(', ')} — regenerate via 'morph-spec setup-infra --reinstall'` : '');
|
|
207
|
+
check('No orphan morph-* agents', drift.orphans.length === 0, true,
|
|
208
|
+
drift.orphans.length > 0 ? `orphans: ${drift.orphans.join(', ')}` : '');
|
|
209
|
+
} catch {
|
|
210
|
+
check('personas drift check', false, true, 'failed to compute');
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── 6c. GitHub tooling — gh CLI (canonical; morph-spec does not use a GitHub MCP) ──
|
|
215
|
+
console.log(chalk.cyan('\n Tooling — GitHub via gh CLI'));
|
|
216
|
+
try {
|
|
217
|
+
execSync('gh --version', { stdio: 'pipe', timeout: 5000 });
|
|
218
|
+
check('gh CLI available', true);
|
|
219
|
+
} catch {
|
|
220
|
+
check('gh CLI available', false, true,
|
|
221
|
+
"GitHub ops (issues/PRs/releases) usam o gh CLI — instale https://cli.github.com e rode 'gh auth login'");
|
|
222
|
+
}
|
|
223
|
+
|
|
189
224
|
// ── 7. state.json schema version ────────────────────────────────────────
|
|
190
225
|
console.log(chalk.cyan('\n .morph/state.json — Schema Version'));
|
|
191
226
|
const statePath = join(frameworkRoot, '.morph/state.json');
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import {
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { getFeature, setApprovalGate, getApprovalGate, getApprovalHistory, derivePhase } from '../../core/state/state-manager.js';
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Unapprove action function - Revoke a previously approved gate
|
|
@@ -158,12 +160,23 @@ export async function approvalStatusCommand(featureName, options = {}) {
|
|
|
158
160
|
process.exit(1);
|
|
159
161
|
}
|
|
160
162
|
|
|
163
|
+
// v5 removed the stored feature.phase — derive it from the filesystem +
|
|
164
|
+
// approved gates (same as `morph-spec status`), never print "unknown".
|
|
165
|
+
const featurePath = join(process.cwd(), '.morph', 'features', featureName);
|
|
166
|
+
const phase = feature.phase || derivePhase(featurePath);
|
|
167
|
+
|
|
168
|
+
// The uiux branch is optional: a feature that never took it has no
|
|
169
|
+
// 1-design/ folder and no uiux approval — that is "n/a", not "rejected".
|
|
170
|
+
const uiuxGate = getApprovalGate(featureName, 'uiux');
|
|
171
|
+
const uiuxTaken = uiuxGate?.approved === true || existsSync(join(featurePath, '1-design'));
|
|
172
|
+
|
|
161
173
|
// JSON output
|
|
162
174
|
if (options.json) {
|
|
163
175
|
const status = {
|
|
164
176
|
feature: featureName,
|
|
165
|
-
phase
|
|
177
|
+
phase,
|
|
166
178
|
status: feature.status,
|
|
179
|
+
uiuxTaken,
|
|
167
180
|
gates: {}
|
|
168
181
|
};
|
|
169
182
|
|
|
@@ -179,7 +192,7 @@ export async function approvalStatusCommand(featureName, options = {}) {
|
|
|
179
192
|
console.log('━'.repeat(60));
|
|
180
193
|
|
|
181
194
|
// Show current phase
|
|
182
|
-
console.log(chalk.cyan(`\nCurrent Phase: ${
|
|
195
|
+
console.log(chalk.cyan(`\nCurrent Phase: ${phase}`));
|
|
183
196
|
console.log(chalk.gray(`Feature Status: ${feature.status || 'unknown'}\n`));
|
|
184
197
|
|
|
185
198
|
// Show each gate
|
|
@@ -193,6 +206,11 @@ export async function approvalStatusCommand(featureName, options = {}) {
|
|
|
193
206
|
continue;
|
|
194
207
|
}
|
|
195
208
|
|
|
209
|
+
if (gateName === 'uiux' && !uiuxTaken) {
|
|
210
|
+
console.log(` ${chalk.gray('—')} ${gateName}: ${chalk.gray('n/a (ramo de design não tomado)')}`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
196
214
|
if (gateStatus.approved) {
|
|
197
215
|
console.log(` ${chalk.green('✓')} ${gateName}: ${chalk.green('Approved')}`);
|
|
198
216
|
if (gateStatus.timestamp) {
|
|
@@ -202,7 +220,14 @@ export async function approvalStatusCommand(featureName, options = {}) {
|
|
|
202
220
|
console.log(chalk.gray(` Approved by: ${gateStatus.approvedBy}`));
|
|
203
221
|
}
|
|
204
222
|
} else {
|
|
205
|
-
|
|
223
|
+
// Distinguish "never evaluated" (initialized approved:false, no
|
|
224
|
+
// timestamp) from an explicit rejection/revocation (has timestamp).
|
|
225
|
+
const neverEvaluated = !gateStatus.timestamp && !gateStatus.rejectedAt && !gateStatus.revokedAt;
|
|
226
|
+
if (neverEvaluated) {
|
|
227
|
+
console.log(` ${chalk.gray('○')} ${gateName}: ${chalk.gray('Pending (not yet evaluated)')}`);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
console.log(` ${chalk.red('✗')} ${gateName}: ${chalk.red('Rejected / Revoked')}`);
|
|
206
231
|
if (gateStatus.reason) {
|
|
207
232
|
console.log(chalk.gray(` Reason: ${gateStatus.reason}`));
|
|
208
233
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { existsSync, readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { getFeature, updateFeature } from '../../core/state/state-manager.js';
|
|
5
|
+
|
|
6
|
+
/** Fixed --dims order: a,c,q,t (mirrors the morph-eval dimension weights). */
|
|
7
|
+
const DIM_KEYS = ['architecture', 'contracts', 'codeQuality', 'testCoverage'];
|
|
8
|
+
|
|
9
|
+
function parseScoreValue(raw, label) {
|
|
10
|
+
const value = Number(raw);
|
|
11
|
+
if (!Number.isFinite(value) || value < 0 || value > 10) {
|
|
12
|
+
console.error(chalk.red(`\n❌ Invalid ${label}: "${raw}" — expected a number between 0 and 10\n`));
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `morph-spec score <feature> <task> <score> [--dims a,c,q,t]` — record a
|
|
20
|
+
* task eval score under `taskScores` in the authoritative feature.json.
|
|
21
|
+
*
|
|
22
|
+
* The timestamp is stamped server-side (`updatedAt`), the write is atomic
|
|
23
|
+
* (state-manager), attempts auto-increment and bestScore is kept — this
|
|
24
|
+
* replaces the fragile manual Edit of feature.json from the skills.
|
|
25
|
+
*/
|
|
26
|
+
export async function scoreCommand(featureName, taskId, scoreArg, options = {}) {
|
|
27
|
+
try {
|
|
28
|
+
const score = parseScoreValue(scoreArg, 'score');
|
|
29
|
+
|
|
30
|
+
const feature = getFeature(featureName);
|
|
31
|
+
if (!feature) {
|
|
32
|
+
console.error(chalk.red(`\n❌ Feature not found: ${featureName}\n`));
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let dimensions;
|
|
37
|
+
if (options.dims) {
|
|
38
|
+
const parts = String(options.dims).split(',').map(s => s.trim());
|
|
39
|
+
if (parts.length !== DIM_KEYS.length) {
|
|
40
|
+
console.error(chalk.red(`\n❌ --dims expects ${DIM_KEYS.length} comma-separated values in order a,c,q,t (architecture,contracts,code-quality,test-coverage)\n`));
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
dimensions = {};
|
|
44
|
+
DIM_KEYS.forEach((key, i) => {
|
|
45
|
+
dimensions[key] = parseScoreValue(parts[i], `--dims ${key}`);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Non-fatal sanity check: warn when the task id is unknown to tasks.json.
|
|
50
|
+
const tasksPath = join(process.cwd(), '.morph', 'features', featureName, '2-plan', 'tasks.json');
|
|
51
|
+
if (existsSync(tasksPath)) {
|
|
52
|
+
try {
|
|
53
|
+
const tasks = JSON.parse(readFileSync(tasksPath, 'utf8')).tasks || [];
|
|
54
|
+
if (!tasks.some(t => t.id === taskId)) {
|
|
55
|
+
console.error(chalk.yellow(`⚠️ Task "${taskId}" not found in tasks.json — recording anyway`));
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// unreadable tasks.json — the score is still worth recording
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const prev = feature.taskScores?.[taskId];
|
|
63
|
+
const entry = {
|
|
64
|
+
lastScore: score,
|
|
65
|
+
bestScore: prev?.bestScore != null ? Math.max(prev.bestScore, score) : score,
|
|
66
|
+
attempts: (prev?.attempts ?? 0) + 1,
|
|
67
|
+
...(dimensions ? { dimensions } : {}),
|
|
68
|
+
updatedAt: new Date().toISOString(),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
await updateFeature(featureName, `taskScores.${taskId}`, entry);
|
|
72
|
+
|
|
73
|
+
if (options.json) {
|
|
74
|
+
console.log(JSON.stringify({ feature: featureName, task: taskId, ...entry }, null, 2));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
console.log(chalk.green(`\n✅ Score recorded: ${featureName}/${taskId} → ${entry.lastScore}`));
|
|
79
|
+
console.log(chalk.gray(` best: ${entry.bestScore} | attempts: ${entry.attempts} | updatedAt: ${entry.updatedAt}`));
|
|
80
|
+
if (dimensions) {
|
|
81
|
+
console.log(chalk.gray(` dims: arch=${dimensions.architecture} contracts=${dimensions.contracts} quality=${dimensions.codeQuality} tests=${dimensions.testCoverage}`));
|
|
82
|
+
}
|
|
83
|
+
console.log('');
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.error(chalk.red(`\n❌ Error recording score: ${error.message}\n`));
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { parseTasksJson } from '../../lib/tasks/tasks-json-parser.js';
|
|
5
|
+
import { planDispatch } from '../../lib/tasks/dispatch-planner.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `morph-spec dag <feature>` — analyze the tasks.json DAG and print the
|
|
9
|
+
* sequential vs parallel dispatch decision, weighted by effort.
|
|
10
|
+
*
|
|
11
|
+
* This is the command morph-apply §2 runs instead of re-deriving the rules
|
|
12
|
+
* (and instead of the old inline JS snippets the LLM could not execute).
|
|
13
|
+
*/
|
|
14
|
+
export async function dagCommand(featureName, options = {}) {
|
|
15
|
+
try {
|
|
16
|
+
const tasksPath = join(process.cwd(), '.morph', 'features', featureName, '2-plan', 'tasks.json');
|
|
17
|
+
if (!existsSync(tasksPath)) {
|
|
18
|
+
console.error(chalk.red(`\n❌ tasks.json not found for feature "${featureName}"`));
|
|
19
|
+
console.error(chalk.gray(`Expected at: .morph/features/${featureName}/2-plan/tasks.json\n`));
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// parseTasksJson validates the schema and throws with the error list.
|
|
24
|
+
const data = await parseTasksJson(tasksPath);
|
|
25
|
+
const plan = planDispatch(data);
|
|
26
|
+
|
|
27
|
+
if (options.json) {
|
|
28
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log(chalk.bold(`\n📊 DAG — ${featureName}`));
|
|
33
|
+
console.log('━'.repeat(60));
|
|
34
|
+
|
|
35
|
+
const { stats } = plan;
|
|
36
|
+
console.log(`\nTasks: ${stats.total} total, ${stats.pending} pendente(s)`);
|
|
37
|
+
const groupLine = Object.keys(stats.groups)
|
|
38
|
+
.map(g => `${g}:${stats.groups[g]} (peso ${stats.weights[g]})`)
|
|
39
|
+
.join(' | ');
|
|
40
|
+
if (groupLine) console.log(chalk.gray(`Grupos pendentes: ${groupLine}`));
|
|
41
|
+
|
|
42
|
+
const modeLabel = {
|
|
43
|
+
'sequential': 'SEQUENCIAL (auto-advance)',
|
|
44
|
+
'parallel-flows': 'PARALELO por fluxo de domínio',
|
|
45
|
+
'parallel-roots': 'PARALELO por raízes independentes',
|
|
46
|
+
}[plan.mode];
|
|
47
|
+
console.log(chalk.cyan(`\nDecisão: ${modeLabel}`));
|
|
48
|
+
console.log(` → ${plan.reason}`);
|
|
49
|
+
|
|
50
|
+
for (const flow of plan.flows) {
|
|
51
|
+
console.log(` ${chalk.green('▶')} Fluxo ${chalk.bold(flow.group)} (peso ${flow.weight}): ${flow.taskIds.join(', ')}`);
|
|
52
|
+
}
|
|
53
|
+
if (plan.batch.length > 0) {
|
|
54
|
+
console.log(` ${chalk.green('▶')} Batch paralelo: ${plan.batch.join(', ')}`);
|
|
55
|
+
}
|
|
56
|
+
if (plan.inline.length > 0) {
|
|
57
|
+
console.log(` ${chalk.yellow('○')} Inline (sem dispatch próprio): ${plan.inline.join(', ')}`);
|
|
58
|
+
}
|
|
59
|
+
if (plan.sequentialRemainder.length > 0) {
|
|
60
|
+
console.log(` ${chalk.gray('→')} Sequencial: ${plan.sequentialRemainder.join(', ')}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log('\n' + '━'.repeat(60) + '\n');
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error(chalk.red(`\n❌ Error analyzing DAG: ${error.message}\n`));
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -7,6 +7,6 @@ export function scanRubrics(frameworkDir) {
|
|
|
7
7
|
return readdirSync(dir).filter(f => f.endsWith('.json')).map(f => {
|
|
8
8
|
const d = JSON.parse(readFileSync(join(dir, f), 'utf8'));
|
|
9
9
|
return { id: d.id, version: d.version || '1.0.0', description: d.description || '',
|
|
10
|
-
categories: d.categories || {}, thresholds: d.thresholds || { approve:
|
|
10
|
+
categories: d.categories || {}, thresholds: d.thresholds || { approve: 9.0, retry: 6.0 } };
|
|
11
11
|
});
|
|
12
12
|
}
|
|
@@ -183,7 +183,7 @@ function buildRecapMarkdown(data) {
|
|
|
183
183
|
const scores = validationResult.scores;
|
|
184
184
|
md += `### Quality Score: ${scores.score}/10\n\n`;
|
|
185
185
|
md += `| Category | Score | Weight | Status |\n|----------|-------|--------|--------|\n`;
|
|
186
|
-
const approveThreshold = scores.approveThreshold ??
|
|
186
|
+
const approveThreshold = scores.approveThreshold ?? 9.0;
|
|
187
187
|
for (const [category, data] of Object.entries(scores.breakdown)) {
|
|
188
188
|
if (category === '_unmapped') continue;
|
|
189
189
|
const pct = Math.round(data.weight * 100);
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dispatch-planner.js — effort-weighted sequential vs parallel decision for a
|
|
3
|
+
* feature's tasks.json DAG.
|
|
4
|
+
*
|
|
5
|
+
* Encodes the dispatch rules from morph-apply §2 ("Análise de DAG") so the
|
|
6
|
+
* decision is deterministic and CLI-driven (`morph-spec dag <feature>`)
|
|
7
|
+
* instead of re-derived by the LLM each session:
|
|
8
|
+
*
|
|
9
|
+
* 1. <= 3 tasks total → sequential (dispatch overhead beats the work).
|
|
10
|
+
* 2. >= 2 independent group flows with enough weight → one agent per flow.
|
|
11
|
+
* 3. >= 3 independent roots with effort M or heavier → parallel root batch.
|
|
12
|
+
* 4. otherwise → sequential with auto-advance.
|
|
13
|
+
*
|
|
14
|
+
* Effort weighting: S=1, M=2, L=4, XL=8. S-only roots and light flows never
|
|
15
|
+
* earn their own agent — they are returned as `inline` (the orchestrator runs
|
|
16
|
+
* them in the sequential lane, or folds them into a single grouped agent), so
|
|
17
|
+
* dispatch overhead never exceeds the work being dispatched.
|
|
18
|
+
*
|
|
19
|
+
* Pure module — operates on a parsed tasks.json document, no I/O.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const EFFORT_WEIGHT = { S: 1, M: 2, L: 4, XL: 8 };
|
|
23
|
+
|
|
24
|
+
/** A root must weigh at least this (effort M) to earn its own agent. */
|
|
25
|
+
export const ROOT_DISPATCH_MIN_WEIGHT = 2;
|
|
26
|
+
|
|
27
|
+
/** A flow must weigh at least this (an L, or 2×M…) to earn its own agent. */
|
|
28
|
+
export const FLOW_DISPATCH_MIN_WEIGHT = 4;
|
|
29
|
+
|
|
30
|
+
/** Mirror of the morph-apply cap on simultaneous agents. */
|
|
31
|
+
export const MAX_PARALLEL_AGENTS = 4;
|
|
32
|
+
|
|
33
|
+
function weightOf(task) {
|
|
34
|
+
return EFFORT_WEIGHT[task.effort] ?? EFFORT_WEIGHT.M;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function groupOf(task) {
|
|
38
|
+
// Same fallback as groupTasks() in tasks-json-parser.js
|
|
39
|
+
return task.group || 'backend';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Ids of tasks whose dependency chain crosses group boundaries — integration
|
|
44
|
+
* tasks and everything downstream of them. These never belong to a flow: they
|
|
45
|
+
* run in the sequential lane after the parallel batch converges.
|
|
46
|
+
*/
|
|
47
|
+
function crossGroupTaskIds(tasks) {
|
|
48
|
+
const byId = new Map(tasks.map(t => [t.id, t]));
|
|
49
|
+
const cross = new Set();
|
|
50
|
+
|
|
51
|
+
for (const task of tasks) {
|
|
52
|
+
for (const dep of task.dependencies) {
|
|
53
|
+
const depTask = byId.get(dep);
|
|
54
|
+
if (depTask && groupOf(depTask) !== groupOf(task)) {
|
|
55
|
+
cross.add(task.id);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Propagate downstream: a task depending (transitively) on a cross-group
|
|
62
|
+
// task cannot run inside a single-group flow either.
|
|
63
|
+
let changed = true;
|
|
64
|
+
while (changed) {
|
|
65
|
+
changed = false;
|
|
66
|
+
for (const task of tasks) {
|
|
67
|
+
if (cross.has(task.id)) continue;
|
|
68
|
+
if (task.dependencies.some(dep => cross.has(dep))) {
|
|
69
|
+
cross.add(task.id);
|
|
70
|
+
changed = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return cross;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildStats(tasks, pending) {
|
|
79
|
+
const groups = {};
|
|
80
|
+
const weights = {};
|
|
81
|
+
for (const task of pending) {
|
|
82
|
+
const g = groupOf(task);
|
|
83
|
+
groups[g] = (groups[g] || 0) + 1;
|
|
84
|
+
weights[g] = (weights[g] || 0) + weightOf(task);
|
|
85
|
+
}
|
|
86
|
+
return { total: tasks.length, pending: pending.length, groups, weights };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Decide how to execute the remaining tasks of a parsed tasks.json document.
|
|
91
|
+
*
|
|
92
|
+
* @param {object} data - parsed tasks.json ({ version, feature, tasks[] })
|
|
93
|
+
* @returns {{
|
|
94
|
+
* mode: 'sequential' | 'parallel-flows' | 'parallel-roots',
|
|
95
|
+
* reason: string,
|
|
96
|
+
* stats: { total: number, pending: number, groups: object, weights: object },
|
|
97
|
+
* flows: Array<{ group: string, taskIds: string[], weight: number }>,
|
|
98
|
+
* batch: string[],
|
|
99
|
+
* inline: string[],
|
|
100
|
+
* sequentialRemainder: string[]
|
|
101
|
+
* }}
|
|
102
|
+
*/
|
|
103
|
+
export function planDispatch(data) {
|
|
104
|
+
const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
|
|
105
|
+
const pending = tasks.filter(t => t.status === 'pending');
|
|
106
|
+
const stats = buildStats(tasks, pending);
|
|
107
|
+
const empty = { flows: [], batch: [], inline: [] };
|
|
108
|
+
|
|
109
|
+
if (pending.length === 0) {
|
|
110
|
+
return {
|
|
111
|
+
mode: 'sequential',
|
|
112
|
+
reason: 'nenhuma task pendente — nada a despachar',
|
|
113
|
+
stats, ...empty, sequentialRemainder: [],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Rule 1 — tiny plans never pay for dispatch.
|
|
118
|
+
if (tasks.length <= 3) {
|
|
119
|
+
return {
|
|
120
|
+
mode: 'sequential',
|
|
121
|
+
reason: `${tasks.length} task(s) no total (<= 3) — overhead de Agent dispatch não compensa`,
|
|
122
|
+
stats, ...empty, sequentialRemainder: pending.map(t => t.id),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Rule 2 — independent domain flows (backend ‖ frontend …).
|
|
127
|
+
const cross = crossGroupTaskIds(tasks);
|
|
128
|
+
const flowsMap = new Map();
|
|
129
|
+
for (const task of pending) {
|
|
130
|
+
if (cross.has(task.id)) continue;
|
|
131
|
+
const g = groupOf(task);
|
|
132
|
+
if (!flowsMap.has(g)) flowsMap.set(g, { group: g, taskIds: [], weight: 0 });
|
|
133
|
+
const flow = flowsMap.get(g);
|
|
134
|
+
flow.taskIds.push(task.id);
|
|
135
|
+
flow.weight += weightOf(task);
|
|
136
|
+
}
|
|
137
|
+
const allFlows = [...flowsMap.values()];
|
|
138
|
+
const worthyFlows = allFlows
|
|
139
|
+
.filter(f => f.weight >= FLOW_DISPATCH_MIN_WEIGHT)
|
|
140
|
+
.sort((a, b) => b.weight - a.weight);
|
|
141
|
+
const lightFlows = allFlows.filter(f => f.weight < FLOW_DISPATCH_MIN_WEIGHT);
|
|
142
|
+
|
|
143
|
+
if (worthyFlows.length >= 2) {
|
|
144
|
+
const flows = worthyFlows.slice(0, MAX_PARALLEL_AGENTS);
|
|
145
|
+
const overflow = worthyFlows.slice(MAX_PARALLEL_AGENTS);
|
|
146
|
+
const inline = [
|
|
147
|
+
...lightFlows.flatMap(f => f.taskIds),
|
|
148
|
+
...overflow.flatMap(f => f.taskIds),
|
|
149
|
+
];
|
|
150
|
+
const sequentialRemainder = pending
|
|
151
|
+
.filter(t => cross.has(t.id))
|
|
152
|
+
.map(t => t.id);
|
|
153
|
+
|
|
154
|
+
const flowDesc = flows.map(f => `${f.group}:${f.taskIds.length} (peso ${f.weight})`).join(' ‖ ');
|
|
155
|
+
return {
|
|
156
|
+
mode: 'parallel-flows',
|
|
157
|
+
reason:
|
|
158
|
+
`${flows.length} fluxos de domínio independentes com peso suficiente — ${flowDesc} → ` +
|
|
159
|
+
`1 agente por fluxo` +
|
|
160
|
+
(inline.length ? `; ${inline.length} task(s) leve(s) inline (sem dispatch próprio)` : '') +
|
|
161
|
+
(sequentialRemainder.length ? `; integração cross-group sequencial no fim (${sequentialRemainder.join(', ')})` : ''),
|
|
162
|
+
stats, flows, batch: [], inline, sequentialRemainder,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Rule 3 — root fan-out, weighted by effort (S roots never earn an agent).
|
|
167
|
+
const roots = pending.filter(t => t.dependencies.length === 0);
|
|
168
|
+
const weightedRoots = roots
|
|
169
|
+
.filter(t => weightOf(t) >= ROOT_DISPATCH_MIN_WEIGHT)
|
|
170
|
+
.sort((a, b) => weightOf(b) - weightOf(a));
|
|
171
|
+
const sRoots = roots.filter(t => weightOf(t) < ROOT_DISPATCH_MIN_WEIGHT);
|
|
172
|
+
|
|
173
|
+
if (weightedRoots.length >= 3) {
|
|
174
|
+
const batch = weightedRoots.slice(0, MAX_PARALLEL_AGENTS).map(t => t.id);
|
|
175
|
+
const inline = sRoots.map(t => t.id);
|
|
176
|
+
const sequentialRemainder = pending
|
|
177
|
+
.filter(t => !batch.includes(t.id) && !inline.includes(t.id))
|
|
178
|
+
.map(t => t.id);
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
mode: 'parallel-roots',
|
|
182
|
+
reason:
|
|
183
|
+
`${weightedRoots.length} raízes independentes com effort M+ → batch paralelo (${batch.join(', ')})` +
|
|
184
|
+
(inline.length ? `; raízes S inline — não ganham dispatch próprio (${inline.join(', ')})` : '') +
|
|
185
|
+
`; restante sequencial`,
|
|
186
|
+
stats, flows: [], batch, inline, sequentialRemainder,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Rule 4 — sequential.
|
|
191
|
+
const reason = roots.length >= 3
|
|
192
|
+
? `${roots.length} raízes independentes, mas apenas ${weightedRoots.length} com effort M+ ` +
|
|
193
|
+
`(raízes S não ganham dispatch próprio — overhead > trabalho) → sequencial com auto-advance`
|
|
194
|
+
: 'sem fluxos ou raízes independentes suficientes → sequencial com auto-advance';
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
mode: 'sequential',
|
|
198
|
+
reason,
|
|
199
|
+
stats, ...empty, sequentialRemainder: pending.map(t => t.id),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -24,7 +24,7 @@ const FALLBACK_RUBRIC = {
|
|
|
24
24
|
description: 'All validators (fallback — no rubric configured)'
|
|
25
25
|
}
|
|
26
26
|
},
|
|
27
|
-
thresholds: { approve:
|
|
27
|
+
thresholds: { approve: 9.0, retry: 6.0 }
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
/**
|
|
@@ -176,7 +176,7 @@ export function aggregateScores(validationResult, rubric, configOverrides = {})
|
|
|
176
176
|
const score = Math.round(weightedScore * 10) / 10;
|
|
177
177
|
|
|
178
178
|
// Determine action
|
|
179
|
-
const approveThreshold = configOverrides.approve ?? rubric.thresholds?.approve ??
|
|
179
|
+
const approveThreshold = configOverrides.approve ?? rubric.thresholds?.approve ?? 9.0;
|
|
180
180
|
const retryThreshold = configOverrides.retry ?? rubric.thresholds?.retry ?? 6.0;
|
|
181
181
|
|
|
182
182
|
let action;
|
|
@@ -210,7 +210,7 @@ export function aggregateScores(validationResult, rubric, configOverrides = {})
|
|
|
210
210
|
* @param {number} [approveThreshold] - For display
|
|
211
211
|
* @returns {string}
|
|
212
212
|
*/
|
|
213
|
-
export function formatScoreBreakdown(scoreResult, approveThreshold =
|
|
213
|
+
export function formatScoreBreakdown(scoreResult, approveThreshold = 9.0) {
|
|
214
214
|
const lines = [];
|
|
215
215
|
|
|
216
216
|
lines.push(`Score: ${scoreResult.score}/10 (threshold: ${approveThreshold})`);
|
|
@@ -118,6 +118,33 @@ async function installStackAwareAssets(targetPath, log) {
|
|
|
118
118
|
return { agentCounts, domainCounts, projectTags };
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Rewrite the managed personas block in .claude/CLAUDE.md with the agents
|
|
123
|
+
* ACTUALLY installed (derived list — never a hand-written promise).
|
|
124
|
+
* No markers / no file → no-op (fail-open).
|
|
125
|
+
*
|
|
126
|
+
* @param {string} claudeMdPath - Absolute path to the installed .claude/CLAUDE.md
|
|
127
|
+
* @param {Array<{ name: string, title: string, domains: string[] }>} agents
|
|
128
|
+
*/
|
|
129
|
+
async function updateClaudeMdAgentsBlock(claudeMdPath, agents = []) {
|
|
130
|
+
if (!(await pathExists(claudeMdPath))) return;
|
|
131
|
+
const START = '<!-- morph-agents:start -->';
|
|
132
|
+
const END = '<!-- morph-agents:end -->';
|
|
133
|
+
const content = readFileSync(claudeMdPath, 'utf-8');
|
|
134
|
+
if (!content.includes(START) || !content.includes(END)) return;
|
|
135
|
+
|
|
136
|
+
const list = agents.length > 0
|
|
137
|
+
? agents.map(a => `- \`${a.name}\` — ${a.title}${a.domains?.length ? ` (${a.domains.join(', ')})` : ''}`).join('\n')
|
|
138
|
+
: '_Nenhuma persona instalada — configure `project.stack` em `.morph/config/config.json` e rode `morph-spec setup-infra --reinstall`._';
|
|
139
|
+
|
|
140
|
+
const block = `${START}\n**Personas instaladas neste projeto** (derivado de agents.json na última instalação):\n\n${list}\n${END}`;
|
|
141
|
+
const updated = content.replace(
|
|
142
|
+
new RegExp(`${START}[\\s\\S]*?${END}`),
|
|
143
|
+
block
|
|
144
|
+
);
|
|
145
|
+
await writeFile(claudeMdPath, updated);
|
|
146
|
+
}
|
|
147
|
+
|
|
121
148
|
/**
|
|
122
149
|
* Installs MORPH-SPEC infrastructure into the target project directory.
|
|
123
150
|
* Headless — no prompts, no spinner (suppressed when MORPH_QUIET=1), no stack detection.
|
|
@@ -138,6 +165,9 @@ export async function setupInfra(targetPath, { _exec = execSync, reinstall = fal
|
|
|
138
165
|
if (reinstall) {
|
|
139
166
|
log('setup-infra (reinstall): re-installing stack-aware assets...');
|
|
140
167
|
const { agentCounts, domainCounts } = await installStackAwareAssets(targetPath, log);
|
|
168
|
+
// Keep the derived personas list in .claude/CLAUDE.md in sync with what
|
|
169
|
+
// was actually (re)installed.
|
|
170
|
+
await updateClaudeMdAgentsBlock(join(targetPath, '.claude', 'CLAUDE.md'), agentCounts?.agents ?? []);
|
|
141
171
|
log('setup-infra (reinstall): complete.');
|
|
142
172
|
return {
|
|
143
173
|
agents: {
|
|
@@ -236,6 +266,16 @@ export async function setupInfra(targetPath, { _exec = execSync, reinstall = fal
|
|
|
236
266
|
await copyDirectory(rubricsSrc, rubricsDest);
|
|
237
267
|
}
|
|
238
268
|
|
|
269
|
+
// --- 7c. Copy framework/evals → .morph/framework/evals ---
|
|
270
|
+
// Rubricas .md (bandas 0-10) + trace-schema.md — referenciadas por
|
|
271
|
+
// morph-eval/morph-review/agents.json via .morph/framework/evals/.
|
|
272
|
+
log('Step 7c: Copying evals (rubrics .md + trace schema)...');
|
|
273
|
+
const evalsSrc = join(FRAMEWORK_DIR, 'evals');
|
|
274
|
+
const evalsDest = join(frameworkDestDir, 'evals');
|
|
275
|
+
if (await pathExists(evalsSrc)) {
|
|
276
|
+
await copyDirectory(evalsSrc, evalsDest);
|
|
277
|
+
}
|
|
278
|
+
|
|
239
279
|
// --- 8. Copy framework/agents.json → .morph/framework/agents.json ---
|
|
240
280
|
log('Step 8: Copying agents.json...');
|
|
241
281
|
const agentsSrc = join(FRAMEWORK_DIR, 'agents.json');
|
|
@@ -286,6 +326,8 @@ export async function setupInfra(targetPath, { _exec = execSync, reinstall = fal
|
|
|
286
326
|
const runtimeClaudeMdDest = join(claudeDest, 'CLAUDE.md');
|
|
287
327
|
if (await pathExists(claudeMdSrc)) {
|
|
288
328
|
await copyFile(claudeMdSrc, runtimeClaudeMdDest);
|
|
329
|
+
// Derive the personas section from what step 10-13 actually installed.
|
|
330
|
+
await updateClaudeMdAgentsBlock(runtimeClaudeMdDest, agentCounts?.agents ?? []);
|
|
289
331
|
}
|
|
290
332
|
|
|
291
333
|
// --- 15. installGlobalStatusline (non-critical) ---
|