@polymorphism-tech/morph-spec 7.6.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 (42) 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 +12 -4
  9. package/framework/hooks/README.md +8 -10
  10. package/framework/hooks/claude-code/core/gate-guard.js +36 -16
  11. package/framework/hooks/claude-code/core/protect-spec-files.js +20 -5
  12. package/framework/hooks/claude-code/core/tasks-json-guard.js +81 -0
  13. package/framework/hooks/shared/tasks-validator.js +68 -0
  14. package/framework/mcp/registry.json +5 -48
  15. package/framework/rubrics/backend.json +3 -3
  16. package/framework/rubrics/default.json +3 -3
  17. package/framework/rubrics/frontend.json +3 -3
  18. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  19. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  20. package/framework/skills/core/morph-apply/SKILL.md +53 -40
  21. package/framework/skills/core/morph-brainstorming/SKILL.md +7 -12
  22. package/framework/skills/core/morph-eval/SKILL.md +10 -8
  23. package/framework/skills/core/morph-implement/SKILL.md +27 -21
  24. package/framework/skills/core/morph-plan/SKILL.md +12 -21
  25. package/framework/skills/core/morph-proposal/SKILL.md +3 -8
  26. package/framework/skills/core/morph-review/SKILL.md +30 -14
  27. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  28. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  29. package/framework/standards/integration/mcp/mcp-tools.md +38 -43
  30. package/package.json +1 -1
  31. package/src/commands/project/doctor.js +36 -1
  32. package/src/commands/state/approve.js +29 -4
  33. package/src/commands/state/score.js +88 -0
  34. package/src/commands/tasks/dag.js +68 -0
  35. package/src/dashboard/scanner/rubrics-scanner.js +1 -1
  36. package/src/lib/generators/recap-generator.js +1 -1
  37. package/src/lib/tasks/dispatch-planner.js +201 -0
  38. package/src/lib/validators/score-aggregator.js +3 -3
  39. package/src/scripts/setup-infra.js +42 -0
  40. package/src/utils/agents-installer.js +142 -10
  41. package/src/utils/hooks-installer.js +13 -5
  42. package/framework/rubrics/eval-rubric.json +0 -66
@@ -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) ---
@@ -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
- const description = buildDescription(agent);
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
- function buildFrontmatter(agent, description) {
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
- `name: ${agent.id}`,
146
+ // Canonical 1:1 naming: frontmatter name === filename === subagent_type.
147
+ `name: morph-${agent.id}`,
78
148
  `description: ${description}`,
79
- `model: sonnet`,
80
- `tools: Read, Write, Edit, Bash, Glob, Grep`,
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
- type: 'command',
111
- command: 'node framework/hooks/claude-code/core/post-edit-typecheck.js'
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
- }