@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.
Files changed (61) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +18 -0
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +12 -4
  7. package/framework/MORPH.md +18 -10
  8. package/framework/agents.json +13 -5
  9. package/framework/commands/morph-preflight.md +10 -5
  10. package/framework/hooks/README.md +8 -10
  11. package/framework/hooks/claude-code/core/gate-guard.js +36 -16
  12. package/framework/hooks/claude-code/core/protect-spec-files.js +20 -5
  13. package/framework/hooks/claude-code/core/tasks-json-guard.js +81 -0
  14. package/framework/hooks/shared/tasks-validator.js +68 -0
  15. package/framework/mcp/registry.json +5 -48
  16. package/framework/rubrics/backend.json +3 -3
  17. package/framework/rubrics/default.json +3 -3
  18. package/framework/rubrics/frontend.json +3 -3
  19. package/framework/rules/infrastructure-standards.md +4 -4
  20. package/framework/skills/core/create-umbraco-lp/SKILL.md +22 -2
  21. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +84 -5
  22. package/framework/skills/core/morph-apply/SKILL.md +53 -40
  23. package/framework/skills/core/morph-brainstorming/SKILL.md +7 -12
  24. package/framework/skills/core/morph-eval/SKILL.md +10 -8
  25. package/framework/skills/core/morph-implement/SKILL.md +27 -21
  26. package/framework/skills/core/morph-plan/SKILL.md +12 -21
  27. package/framework/skills/core/morph-proposal/SKILL.md +3 -8
  28. package/framework/skills/core/morph-review/SKILL.md +30 -14
  29. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  30. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  31. package/framework/standards/STANDARDS.json +80 -8
  32. package/framework/standards/ai-agents/durable-workflows-hitl.md +191 -0
  33. package/framework/standards/ai-agents/rag-custom-pgvector.md +246 -243
  34. package/framework/standards/ai-agents/setup.md +3 -0
  35. package/framework/standards/ai-agents/vector-data-extensions.md +170 -0
  36. package/framework/standards/ai-agents/workflows.md +190 -187
  37. package/framework/standards/infrastructure/docker/{easypanel-deploy.md → coolify-deploy.md} +49 -20
  38. package/framework/standards/integration/mcp/mcp-tools.md +38 -43
  39. package/framework/templates/README.md +7 -7
  40. package/framework/templates/REGISTRY.json +14 -14
  41. package/framework/templates/context/CONTEXT.md +3 -3
  42. package/framework/templates/docs/proposal.md +2 -2
  43. package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +1 -1
  44. package/framework/templates/infrastructure/docker/coolify.template.json +49 -0
  45. package/framework/templates/infrastructure/github/README.md +6 -6
  46. package/framework/templates/infrastructure/github/workflows/deploy-coolify.yml.hbs +33 -0
  47. package/package.json +1 -1
  48. package/src/commands/project/doctor.js +36 -1
  49. package/src/commands/state/approve.js +29 -4
  50. package/src/commands/state/score.js +88 -0
  51. package/src/commands/tasks/dag.js +68 -0
  52. package/src/dashboard/scanner/rubrics-scanner.js +1 -1
  53. package/src/lib/generators/recap-generator.js +1 -1
  54. package/src/lib/tasks/dispatch-planner.js +201 -0
  55. package/src/lib/validators/score-aggregator.js +3 -3
  56. package/src/scripts/setup-infra.js +42 -0
  57. package/src/utils/agents-installer.js +142 -10
  58. package/src/utils/hooks-installer.js +13 -5
  59. package/framework/rubrics/eval-rubric.json +0 -66
  60. package/framework/templates/infrastructure/docker/easypanel.template.json +0 -46
  61. 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 { getFeature, setApprovalGate, getApprovalGate, getApprovalHistory } from '../../core/state/state-manager.js';
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: feature.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: ${feature.phase || 'unknown'}`));
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
- console.log(` ${chalk.red('✗')} ${gateName}: ${chalk.red('Rejected / Not Approved')}`);
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: 8.0, retry: 6.0 } };
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 ?? 8.0;
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: 8.0, retry: 6.0 }
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 ?? 8.0;
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 = 8.0) {
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) ---