@polymorphism-tech/morph-spec 7.6.0 → 7.7.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.
- 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 +12 -4
- 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/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- 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/integration/mcp/mcp-tools.md +38 -43
- package/package.json +1 -1
- package/src/commands/project/doctor.js +36 -1
- package/src/commands/project/update.js +26 -2
- 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 +43 -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
|
@@ -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,34 @@ 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
|
+
* Exported so `morph-spec update` derives the same block on upgrades.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} claudeMdPath - Absolute path to the installed .claude/CLAUDE.md
|
|
128
|
+
* @param {Array<{ name: string, title: string, domains: string[] }>} agents
|
|
129
|
+
*/
|
|
130
|
+
export async function updateClaudeMdAgentsBlock(claudeMdPath, agents = []) {
|
|
131
|
+
if (!(await pathExists(claudeMdPath))) return;
|
|
132
|
+
const START = '<!-- morph-agents:start -->';
|
|
133
|
+
const END = '<!-- morph-agents:end -->';
|
|
134
|
+
const content = readFileSync(claudeMdPath, 'utf-8');
|
|
135
|
+
if (!content.includes(START) || !content.includes(END)) return;
|
|
136
|
+
|
|
137
|
+
const list = agents.length > 0
|
|
138
|
+
? agents.map(a => `- \`${a.name}\` — ${a.title}${a.domains?.length ? ` (${a.domains.join(', ')})` : ''}`).join('\n')
|
|
139
|
+
: '_Nenhuma persona instalada — configure `project.stack` em `.morph/config/config.json` e rode `morph-spec setup-infra --reinstall`._';
|
|
140
|
+
|
|
141
|
+
const block = `${START}\n**Personas instaladas neste projeto** (derivado de agents.json na última instalação):\n\n${list}\n${END}`;
|
|
142
|
+
const updated = content.replace(
|
|
143
|
+
new RegExp(`${START}[\\s\\S]*?${END}`),
|
|
144
|
+
block
|
|
145
|
+
);
|
|
146
|
+
await writeFile(claudeMdPath, updated);
|
|
147
|
+
}
|
|
148
|
+
|
|
121
149
|
/**
|
|
122
150
|
* Installs MORPH-SPEC infrastructure into the target project directory.
|
|
123
151
|
* Headless — no prompts, no spinner (suppressed when MORPH_QUIET=1), no stack detection.
|
|
@@ -138,6 +166,9 @@ export async function setupInfra(targetPath, { _exec = execSync, reinstall = fal
|
|
|
138
166
|
if (reinstall) {
|
|
139
167
|
log('setup-infra (reinstall): re-installing stack-aware assets...');
|
|
140
168
|
const { agentCounts, domainCounts } = await installStackAwareAssets(targetPath, log);
|
|
169
|
+
// Keep the derived personas list in .claude/CLAUDE.md in sync with what
|
|
170
|
+
// was actually (re)installed.
|
|
171
|
+
await updateClaudeMdAgentsBlock(join(targetPath, '.claude', 'CLAUDE.md'), agentCounts?.agents ?? []);
|
|
141
172
|
log('setup-infra (reinstall): complete.');
|
|
142
173
|
return {
|
|
143
174
|
agents: {
|
|
@@ -236,6 +267,16 @@ export async function setupInfra(targetPath, { _exec = execSync, reinstall = fal
|
|
|
236
267
|
await copyDirectory(rubricsSrc, rubricsDest);
|
|
237
268
|
}
|
|
238
269
|
|
|
270
|
+
// --- 7c. Copy framework/evals → .morph/framework/evals ---
|
|
271
|
+
// Rubricas .md (bandas 0-10) + trace-schema.md — referenciadas por
|
|
272
|
+
// morph-eval/morph-review/agents.json via .morph/framework/evals/.
|
|
273
|
+
log('Step 7c: Copying evals (rubrics .md + trace schema)...');
|
|
274
|
+
const evalsSrc = join(FRAMEWORK_DIR, 'evals');
|
|
275
|
+
const evalsDest = join(frameworkDestDir, 'evals');
|
|
276
|
+
if (await pathExists(evalsSrc)) {
|
|
277
|
+
await copyDirectory(evalsSrc, evalsDest);
|
|
278
|
+
}
|
|
279
|
+
|
|
239
280
|
// --- 8. Copy framework/agents.json → .morph/framework/agents.json ---
|
|
240
281
|
log('Step 8: Copying agents.json...');
|
|
241
282
|
const agentsSrc = join(FRAMEWORK_DIR, 'agents.json');
|
|
@@ -286,6 +327,8 @@ export async function setupInfra(targetPath, { _exec = execSync, reinstall = fal
|
|
|
286
327
|
const runtimeClaudeMdDest = join(claudeDest, 'CLAUDE.md');
|
|
287
328
|
if (await pathExists(claudeMdSrc)) {
|
|
288
329
|
await copyFile(claudeMdSrc, runtimeClaudeMdDest);
|
|
330
|
+
// Derive the personas section from what step 10-13 actually installed.
|
|
331
|
+
await updateClaudeMdAgentsBlock(runtimeClaudeMdDest, agentCounts?.agents ?? []);
|
|
289
332
|
}
|
|
290
333
|
|
|
291
334
|
// --- 15. installGlobalStatusline (non-critical) ---
|
|
@@ -4,6 +4,15 @@
|
|
|
4
4
|
* Installs morph-spec domain personas as native Claude Code subagents
|
|
5
5
|
* in .claude/agents/morph-{id}.md.
|
|
6
6
|
*
|
|
7
|
+
* agents.json is the SINGLE SOURCE OF TRUTH (ADR-001, docs/specs/
|
|
8
|
+
* dispatchable-personas/decisions.md). The .md files are DERIVED artifacts:
|
|
9
|
+
* generated with a MANAGED header, regenerated on update/--reinstall, and
|
|
10
|
+
* drift-checked by `morph-spec doctor` (checkAgentsDrift). Never hand-edit
|
|
11
|
+
* them — that is exactly the v4 dual-source drift the v5 migration removed.
|
|
12
|
+
*
|
|
13
|
+
* Canonical naming: subagent_type = `morph-{id}` — the frontmatter `name`
|
|
14
|
+
* matches the filename 1:1, and the skills dispatch that exact type.
|
|
15
|
+
*
|
|
7
16
|
* In v5 the harness has 7 domain personas (no tiers) — each persona is a
|
|
8
17
|
* subagent the Specialist (Claude in implementer role) can dispatch when
|
|
9
18
|
* working in that domain. The 3 roles (Orchestrator/Specialist/Evaluator)
|
|
@@ -14,6 +23,33 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, unlink
|
|
|
14
23
|
import { join } from 'path';
|
|
15
24
|
import { shouldInstall } from '../lib/stack-filter.js';
|
|
16
25
|
|
|
26
|
+
/** Default tool scope for domain personas (override per agent via agents.json `tools`). */
|
|
27
|
+
export const DEFAULT_AGENT_TOOLS = 'Read, Write, Edit, Bash, Glob, Grep';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Load the ids of MCP servers known to the framework registry
|
|
31
|
+
* (framework/mcp/registry.json). Used to validate agents.json `mcp` entries:
|
|
32
|
+
* only registry-known servers become tool grants (fail-open on any error).
|
|
33
|
+
*
|
|
34
|
+
* @param {string} frameworkDir
|
|
35
|
+
* @returns {Set<string>|null} null = no registry found (accept nothing)
|
|
36
|
+
*/
|
|
37
|
+
export function loadKnownMcpIds(frameworkDir) {
|
|
38
|
+
try {
|
|
39
|
+
const registryPath = join(frameworkDir, 'mcp', 'registry.json');
|
|
40
|
+
if (!existsSync(registryPath)) return null;
|
|
41
|
+
const registry = JSON.parse(readFileSync(registryPath, 'utf-8'));
|
|
42
|
+
return new Set(Object.keys(registry.mcps ?? {}));
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const MANAGED_HEADER =
|
|
49
|
+
'<!-- MANAGED BY morph-spec — generated from .morph/framework/agents.json (single source of truth).\n' +
|
|
50
|
+
' Do NOT edit by hand: changes are overwritten on `morph-spec update` / `setup-infra --reinstall`.\n' +
|
|
51
|
+
' To change this persona, edit agents.json and regenerate. Drift is flagged by `morph-spec doctor`. -->';
|
|
52
|
+
|
|
17
53
|
/**
|
|
18
54
|
* Install morph-spec domain personas as native Claude Code subagents.
|
|
19
55
|
*
|
|
@@ -37,6 +73,8 @@ export async function installAgents(projectDir, frameworkDir = 'framework', opti
|
|
|
37
73
|
const targetDir = join(projectDir, '.claude', 'agents');
|
|
38
74
|
mkdirSync(targetDir, { recursive: true });
|
|
39
75
|
|
|
76
|
+
const knownMcpIds = loadKnownMcpIds(frameworkDir);
|
|
77
|
+
|
|
40
78
|
// Filter by stack
|
|
41
79
|
const eligible = agents.filter(a => {
|
|
42
80
|
const stack = a.stack ?? ['*'];
|
|
@@ -48,17 +86,20 @@ export async function installAgents(projectDir, frameworkDir = 'framework', opti
|
|
|
48
86
|
|
|
49
87
|
let installed = 0;
|
|
50
88
|
const installedFilenames = new Set();
|
|
89
|
+
const installedAgents = [];
|
|
51
90
|
|
|
52
91
|
for (const agent of eligible) {
|
|
53
92
|
const filename = `morph-${agent.id}.md`;
|
|
54
93
|
const targetPath = join(targetDir, filename);
|
|
55
94
|
|
|
56
|
-
|
|
57
|
-
const body = buildBody(agent);
|
|
58
|
-
const frontmatter = buildFrontmatter(agent, description);
|
|
59
|
-
|
|
60
|
-
writeFileSync(targetPath, `---\n${frontmatter}---\n\n${body}\n`, 'utf-8');
|
|
95
|
+
writeFileSync(targetPath, renderAgentMd(agent, knownMcpIds), 'utf-8');
|
|
61
96
|
installedFilenames.add(filename);
|
|
97
|
+
installedAgents.push({
|
|
98
|
+
id: agent.id,
|
|
99
|
+
name: `morph-${agent.id}`,
|
|
100
|
+
title: agent.title ?? agent.id,
|
|
101
|
+
domains: agent.domains ?? [],
|
|
102
|
+
});
|
|
62
103
|
installed++;
|
|
63
104
|
}
|
|
64
105
|
|
|
@@ -69,15 +110,46 @@ export async function installAgents(projectDir, frameworkDir = 'framework', opti
|
|
|
69
110
|
}
|
|
70
111
|
}
|
|
71
112
|
|
|
72
|
-
return { installed };
|
|
113
|
+
return { installed, agents: installedAgents };
|
|
73
114
|
}
|
|
74
115
|
|
|
75
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Render the full .md content for a persona — deterministic, so the same
|
|
118
|
+
* function serves the installer AND the doctor drift check (a hand-edited or
|
|
119
|
+
* stale file differs byte-for-byte from this output).
|
|
120
|
+
*
|
|
121
|
+
* @param {object} agent - agents.json entry with `id` merged in
|
|
122
|
+
* @param {Set<string>|null} [knownMcpIds] - registry-known MCP ids; agents.json
|
|
123
|
+
* `mcp` entries outside this set are ignored (null = ignore all)
|
|
124
|
+
* @returns {string} complete file content
|
|
125
|
+
*/
|
|
126
|
+
export function renderAgentMd(agent, knownMcpIds = null) {
|
|
127
|
+
const description = buildDescription(agent);
|
|
128
|
+
const frontmatter = buildFrontmatter(agent, description, knownMcpIds);
|
|
129
|
+
const body = buildBody(agent);
|
|
130
|
+
return `---\n${frontmatter}---\n\n${MANAGED_HEADER}\n\n${body}\n`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function buildToolsLine(agent, knownMcpIds) {
|
|
134
|
+
const base = agent.tools ?? DEFAULT_AGENT_TOOLS;
|
|
135
|
+
// Domain-relevant MCP servers (agents.json `mcp: [ids]`) become server-level
|
|
136
|
+
// grants (`mcp__{id}`). Only registry-known ids are emitted; in projects
|
|
137
|
+
// without that server configured the grant is inert.
|
|
138
|
+
const mcpGrants = (agent.mcp ?? [])
|
|
139
|
+
.filter(id => knownMcpIds?.has(id))
|
|
140
|
+
.map(id => `mcp__${id}`);
|
|
141
|
+
return [base, ...mcpGrants].join(', ');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function buildFrontmatter(agent, description, knownMcpIds) {
|
|
76
145
|
const lines = [
|
|
77
|
-
|
|
146
|
+
// Canonical 1:1 naming: frontmatter name === filename === subagent_type.
|
|
147
|
+
`name: morph-${agent.id}`,
|
|
78
148
|
`description: ${description}`,
|
|
79
|
-
|
|
80
|
-
|
|
149
|
+
// Domain implementers inherit the session model by default — a hardcoded
|
|
150
|
+
// downgrade here would silently lower quality. Override via agents.json.
|
|
151
|
+
`model: ${agent.model ?? 'inherit'}`,
|
|
152
|
+
`tools: ${buildToolsLine(agent, knownMcpIds)}`,
|
|
81
153
|
`maxTurns: 20`,
|
|
82
154
|
];
|
|
83
155
|
if (agent.color) lines.push(`color: ${agent.color}`);
|
|
@@ -171,6 +243,66 @@ export function checkMissingAgents(projectDir, frameworkDir, projectTags = []) {
|
|
|
171
243
|
return { missing, installed: installedFiles.size, expected };
|
|
172
244
|
}
|
|
173
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Drift check between agents.json (source of truth) and the generated
|
|
248
|
+
* .claude/agents/morph-*.md files. Consumed by `morph-spec doctor`.
|
|
249
|
+
*
|
|
250
|
+
* - missing: eligible persona with no .md installed → run setup-infra --reinstall
|
|
251
|
+
* - stale: .md exists but differs from renderAgentMd() output (hand-edited
|
|
252
|
+
* or generated by an older CLI) → regenerate
|
|
253
|
+
* - orphans: morph-*.md files with no matching agents.json entry
|
|
254
|
+
*
|
|
255
|
+
* @param {string} projectDir
|
|
256
|
+
* @param {string} frameworkDir - directory containing agents.json
|
|
257
|
+
* @param {string[]} projectTags
|
|
258
|
+
* @returns {{ missing: string[], stale: string[], orphans: string[], expected: number }}
|
|
259
|
+
*/
|
|
260
|
+
export function checkAgentsDrift(projectDir, frameworkDir, projectTags = []) {
|
|
261
|
+
const targetDir = join(projectDir, '.claude', 'agents');
|
|
262
|
+
const agentsJsonPath = join(frameworkDir, 'agents.json');
|
|
263
|
+
if (!existsSync(agentsJsonPath)) {
|
|
264
|
+
return { missing: [], stale: [], orphans: [], expected: 0 };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const agentsJson = JSON.parse(readFileSync(agentsJsonPath, 'utf-8'));
|
|
268
|
+
const agents = Object.entries(agentsJson.agents ?? {})
|
|
269
|
+
.filter(([key]) => !key.startsWith('_comment'))
|
|
270
|
+
.map(([id, data]) => ({ id, ...data }));
|
|
271
|
+
|
|
272
|
+
const knownMcpIds = loadKnownMcpIds(frameworkDir);
|
|
273
|
+
const knownFilenames = new Set(agents.map(a => `morph-${a.id}.md`));
|
|
274
|
+
const missing = [];
|
|
275
|
+
const stale = [];
|
|
276
|
+
let expected = 0;
|
|
277
|
+
|
|
278
|
+
for (const agent of agents) {
|
|
279
|
+
const stack = agent.stack ?? ['*'];
|
|
280
|
+
if (!shouldInstall(stack, projectTags)) continue;
|
|
281
|
+
expected++;
|
|
282
|
+
const filename = `morph-${agent.id}.md`;
|
|
283
|
+
const filePath = join(targetDir, filename);
|
|
284
|
+
if (!existsSync(filePath)) {
|
|
285
|
+
missing.push(filename);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const onDisk = readFileSync(filePath, 'utf-8').replace(/\r\n/g, '\n');
|
|
289
|
+
if (onDisk !== renderAgentMd(agent, knownMcpIds)) {
|
|
290
|
+
stale.push(filename);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const orphans = [];
|
|
295
|
+
if (existsSync(targetDir)) {
|
|
296
|
+
for (const file of readdirSync(targetDir)) {
|
|
297
|
+
if (file.startsWith('morph-') && file.endsWith('.md') && !knownFilenames.has(file)) {
|
|
298
|
+
orphans.push(file);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return { missing, stale, orphans, expected };
|
|
304
|
+
}
|
|
305
|
+
|
|
174
306
|
/**
|
|
175
307
|
* Compatibility export — kept so existing tests/CLI code that imports
|
|
176
308
|
* formatStandardRef or parseSkillFile / collectSkillFiles still work,
|
|
@@ -102,14 +102,22 @@ const MORPH_HOOKS = [
|
|
|
102
102
|
]
|
|
103
103
|
},
|
|
104
104
|
|
|
105
|
-
// === PostToolUse: Write|Edit — typecheck (debounced, silent on success)
|
|
105
|
+
// === PostToolUse: Write|Edit — typecheck (debounced, silent on success) +
|
|
106
|
+
// tasks.json schema guard (validate-and-warn; status enum, effort enum) ===
|
|
106
107
|
{
|
|
107
108
|
event: 'PostToolUse',
|
|
108
109
|
matcher: 'Write|Edit',
|
|
109
|
-
hooks: [
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
110
|
+
hooks: [
|
|
111
|
+
{
|
|
112
|
+
type: 'command',
|
|
113
|
+
command: 'node framework/hooks/claude-code/core/post-edit-typecheck.js'
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
type: 'command',
|
|
117
|
+
command: 'node framework/hooks/claude-code/core/tasks-json-guard.js',
|
|
118
|
+
timeout: 3000
|
|
119
|
+
}
|
|
120
|
+
]
|
|
113
121
|
},
|
|
114
122
|
|
|
115
123
|
// === PostToolUse: Write|Edit|AskUserQuestion — state-sync (autonomous state.json updates) ===
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "eval",
|
|
3
|
-
"version": "1.0.0",
|
|
4
|
-
"description": "Eval rubric for morph-eval system. Scores phase outputs across structural and semantic dimensions.",
|
|
5
|
-
"categories": {
|
|
6
|
-
"structural_compliance": {
|
|
7
|
-
"weight": 0.25,
|
|
8
|
-
"description": "Required sections, headers, formatting present in phase outputs",
|
|
9
|
-
"dimensions": {
|
|
10
|
-
"proposal": ["has_problem_statement", "has_proposed_solution", "min_length_300"],
|
|
11
|
-
"spec": ["has_overview", "has_functional_requirements", "has_fr_sections", "has_data_model"],
|
|
12
|
-
"tasks": ["has_task_headers", "has_fr_references", "no_duplicate_ids"],
|
|
13
|
-
"contracts": ["has_type_definitions", "correct_naming_conventions"]
|
|
14
|
-
}
|
|
15
|
-
},
|
|
16
|
-
"content_quality": {
|
|
17
|
-
"weight": 0.30,
|
|
18
|
-
"description": "LLM-as-judge: completeness, specificity, actionability of outputs",
|
|
19
|
-
"dimensions": {
|
|
20
|
-
"completeness": "Covers all aspects of the feature request",
|
|
21
|
-
"specificity": "Requirements are concrete and testable, not vague",
|
|
22
|
-
"actionability": "A developer could implement from this alone",
|
|
23
|
-
"architecture_depth": "Component boundaries, data flow, error handling clear"
|
|
24
|
-
}
|
|
25
|
-
},
|
|
26
|
-
"cross_phase_consistency": {
|
|
27
|
-
"weight": 0.25,
|
|
28
|
-
"description": "Alignment between spec, tasks, mandate, and contracts",
|
|
29
|
-
"dimensions": {
|
|
30
|
-
"fr_coverage": "Every spec FR referenced by at least one task",
|
|
31
|
-
"mandate_integrity": "Mandate includes all tasks with acceptance criteria",
|
|
32
|
-
"contract_alignment": "Spec Data Model entities appear in contracts",
|
|
33
|
-
"dependency_ordering": "Task dependencies are topologically valid"
|
|
34
|
-
}
|
|
35
|
-
},
|
|
36
|
-
"stack_appropriateness": {
|
|
37
|
-
"weight": 0.20,
|
|
38
|
-
"description": "Outputs match the target technology stack",
|
|
39
|
-
"dimensions": {
|
|
40
|
-
"correct_language": "Contracts use the right language for the stack (TS vs CS)",
|
|
41
|
-
"framework_patterns": "Architecture follows stack conventions (App Router, VSA, etc.)",
|
|
42
|
-
"tool_chain": "Build/test commands match the stack"
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
},
|
|
46
|
-
"thresholds": {
|
|
47
|
-
"approve": 8.0,
|
|
48
|
-
"retry": 6.0,
|
|
49
|
-
"max_iterations": 3,
|
|
50
|
-
"regression_tolerance": 0.5
|
|
51
|
-
},
|
|
52
|
-
"scoring": {
|
|
53
|
-
"structural": {
|
|
54
|
-
"method": "binary_checklist",
|
|
55
|
-
"description": "Each dimension is pass/fail. Score = (passed / total) * 10"
|
|
56
|
-
},
|
|
57
|
-
"llm_judge": {
|
|
58
|
-
"method": "evidence_based",
|
|
59
|
-
"description": "LLM scores 1-10 per dimension with evidence quotes and gap descriptions"
|
|
60
|
-
},
|
|
61
|
-
"cross_phase": {
|
|
62
|
-
"method": "ratio_based",
|
|
63
|
-
"description": "FR coverage ratio, mandate inclusion ratio. Score = ratio * 10"
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|