@polymorphism-tech/morph-spec 8.15.1 → 8.17.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 (53) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +19 -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 +10 -4
  7. package/framework/MORPH.md +4 -4
  8. package/framework/evals/rubrics/condensed.md +49 -0
  9. package/framework/hooks/README.md +3 -3
  10. package/framework/hooks/claude-code/core/chain.js +123 -0
  11. package/framework/hooks/claude-code/core/enforce-phase-writes.js +84 -77
  12. package/framework/hooks/claude-code/core/gate-guard.js +21 -25
  13. package/framework/hooks/claude-code/core/loop-detect.js +70 -64
  14. package/framework/hooks/claude-code/core/post-edit-typecheck.js +94 -45
  15. package/framework/hooks/claude-code/core/pre-bash-denylist.js +21 -15
  16. package/framework/hooks/claude-code/core/protect-spec-files.js +119 -103
  17. package/framework/hooks/claude-code/core/state-sync.js +32 -40
  18. package/framework/hooks/claude-code/core/tasks-json-guard.js +44 -44
  19. package/framework/hooks/claude-code/core/telemetry-log.js +151 -74
  20. package/framework/hooks/claude-code/core/trace-autogen.js +116 -6
  21. package/framework/hooks/claude-code/core/validate-completion.js +147 -107
  22. package/framework/hooks/claude-code/core/worktree-context.js +25 -22
  23. package/framework/hooks/claude-code/statusline.py +26 -4
  24. package/framework/hooks/shared/activity-logger.js +120 -69
  25. package/framework/hooks/shared/claim-registry.js +61 -1
  26. package/framework/hooks/shared/git-paths.js +94 -0
  27. package/framework/hooks/shared/hook-response.js +67 -0
  28. package/framework/hooks/shared/state-reader.js +95 -29
  29. package/framework/hooks/shared/state-writer.js +16 -0
  30. package/framework/rules/morph-task-tracking.md +4 -2
  31. package/framework/schemas/tasks.schema.json +10 -0
  32. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  33. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  34. package/framework/skills/core/morph-apply/SKILL.md +40 -28
  35. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  36. package/framework/skills/core/morph-eval/SKILL.md +16 -13
  37. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  38. package/framework/skills/core/morph-implement/SKILL.md +26 -25
  39. package/framework/skills/core/morph-plan/SKILL.md +14 -3
  40. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  41. package/framework/skills/core/morph-review/SKILL.md +1 -1
  42. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  43. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  44. package/package.json +2 -2
  45. package/src/commands/observability/cost.js +137 -0
  46. package/src/commands/project/standards.js +220 -0
  47. package/src/commands/state/approve.js +30 -9
  48. package/src/dashboard/scanner/hooks-scanner.js +11 -2
  49. package/src/lib/installers/memory-installer.js +1 -1
  50. package/src/lib/tasks/tasks-json-parser.js +26 -0
  51. package/src/lib/telemetry/telemetry-reader.js +213 -0
  52. package/src/utils/hooks-installer.js +84 -79
  53. package/framework/hooks/shared/session-snapshot.js +0 -105
@@ -0,0 +1,137 @@
1
+ /**
2
+ * `morph-spec cost [feature] [--by task|phase] [--json]`
3
+ *
4
+ * A cost-facing view over the same harness event stream `telemetry` reads
5
+ * (.morph/logs/events.jsonl): how much wall-clock time was spent inside
6
+ * measured tasks (TaskStart→TaskEnd), how many context bytes were
7
+ * re-ingested (grouped by kind: rubric/standard/skill/agents/plan-artifact/
8
+ * code), and how many sub-agent dispatches happened (with total prompt
9
+ * chars).
10
+ *
11
+ * IMPORTANT: bytes re-ingested is a DETERMINISTIC PROXY for cost, not a
12
+ * token count. Hooks only see what tools read off disk — never the model's
13
+ * actual token usage — so this measures re-ingestion pressure, not spend.
14
+ *
15
+ * Read-only, always exits 0 on a normal (even empty) run — fail-open, an
16
+ * empty stream is an answer, not an error. Only `--by` with an unknown value
17
+ * is a usage error (exit 1).
18
+ */
19
+
20
+ import chalk from 'chalk';
21
+ import { loadTelemetry, aggregateCost } from '../../lib/telemetry/telemetry-reader.js';
22
+
23
+ const BYTES_CAVEAT =
24
+ 'Bytes re-ingeridos são um PROXY determinístico de custo (o que as ferramentas leram do disco) — NÃO são tokens reais. Hooks não enxergam o uso real de tokens do modelo.';
25
+
26
+ const BY_MODES = ['task', 'phase'];
27
+
28
+ function fmtBytes(n) {
29
+ if (!n) return '0 B';
30
+ if (n < 1024) return `${n} B`;
31
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
32
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
33
+ }
34
+
35
+ function fmtDuration(s) {
36
+ if (s === null || s === undefined) return '-';
37
+ if (s < 60) return `${s.toFixed(1)}s`;
38
+ const m = Math.floor(s / 60);
39
+ const rem = Math.round(s % 60);
40
+ return `${m}m${rem}s`;
41
+ }
42
+
43
+ function printBytesByKind(bytesByKind) {
44
+ const entries = Object.entries(bytesByKind);
45
+ if (!entries.length) {
46
+ console.log(chalk.gray(' (0 bytes re-ingeridos — nenhum Read com bytes/kind neste escopo)'));
47
+ return;
48
+ }
49
+ for (const [kind, bytes] of entries.sort((a, b) => b[1] - a[1])) {
50
+ console.log(` ${chalk.yellow(kind.padEnd(14))} ${fmtBytes(bytes)}`);
51
+ }
52
+ }
53
+
54
+ function printTasks(tasks) {
55
+ if (!tasks.length) {
56
+ console.log(chalk.gray('\nNenhuma task com TaskStart/TaskEnd capturada.'));
57
+ return;
58
+ }
59
+ for (const t of tasks) {
60
+ const status = t.open ? chalk.yellow('[em aberto]') : (t.status || '-');
61
+ console.log('\n' + chalk.white.bold(`Task ${t.task}`) + chalk.gray(` · ${t.feature ?? '(sem feature)'}`) + ` · ${status}`);
62
+ console.log(` duração: ${fmtDuration(t.durationSeconds)}`);
63
+ console.log(` dispatches: ${t.dispatches.count} (${t.dispatches.promptChars} chars de prompt)`);
64
+ console.log(' bytes re-ingeridos por kind:');
65
+ printBytesByKind(t.bytesByKind);
66
+ }
67
+ }
68
+
69
+ function printPhases(phases) {
70
+ if (!phases.length) {
71
+ console.log(chalk.gray('\nNenhum byte de Read com phase capturado.'));
72
+ return;
73
+ }
74
+ for (const p of phases) {
75
+ console.log('\n' + chalk.white.bold(`Fase ${p.phase}`) + chalk.gray(` · ${fmtBytes(p.bytesTotal)} total`));
76
+ printBytesByKind(p.bytesByKind);
77
+ }
78
+ }
79
+
80
+ function printSummary(summary) {
81
+ console.log(`\n duração dentro de tasks medidas: ${fmtDuration(summary.taskDurationSeconds)}`);
82
+ console.log(` dispatches: ${summary.dispatches.count} (${summary.dispatches.promptChars} chars de prompt)`);
83
+
84
+ console.log('\n' + chalk.white.bold('Bytes re-ingeridos por kind') + ` (${fmtBytes(summary.bytesTotal)} total)`);
85
+ printBytesByKind(summary.bytesByKind);
86
+
87
+ if (summary.tools.length) {
88
+ console.log('\n' + chalk.white.bold('Mix de tools'));
89
+ for (const t of summary.tools) console.log(` ${t.name.padEnd(16)} ${t.count}`);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * @param {string} [feature] - narrow to a single feature
95
+ * @param {{ json?: boolean, by?: 'task'|'phase', cwd?: string }} [options]
96
+ */
97
+ export function costCommand(feature, options = {}) {
98
+ if (options.by && !BY_MODES.includes(options.by)) {
99
+ console.error(chalk.red(`\n❌ Invalid --by: "${options.by}"`));
100
+ console.error(chalk.gray(`Valid modes: ${BY_MODES.join(', ')}\n`));
101
+ process.exitCode = 1;
102
+ return;
103
+ }
104
+
105
+ const events = loadTelemetry(options.cwd || process.cwd());
106
+ const summary = aggregateCost(events, { feature });
107
+
108
+ if (options.json) {
109
+ // No forced process.exit() here: on Windows a piped stdout write is
110
+ // async, and exit()ing right after a large console.log can race the
111
+ // flush and truncate output. Letting the event loop drain naturally
112
+ // (nothing else keeps it alive in this command) exits 0 without risk.
113
+ console.log(JSON.stringify(summary));
114
+ return;
115
+ }
116
+
117
+ if (summary.total === 0) {
118
+ const scope = feature ? ` para a feature "${feature}"` : '';
119
+ console.log(chalk.gray(`Telemetria vazia${scope} — nenhum evento capturado ainda em .morph/logs/events.jsonl.`));
120
+ return;
121
+ }
122
+
123
+ const scope = feature ? chalk.cyan(` · feature ${feature}`) : '';
124
+ console.log(chalk.white.bold(`Custo do harness (proxy determinística) — ${summary.total} eventos${scope}`));
125
+ console.log(chalk.gray(`⚠ ${BYTES_CAVEAT}`));
126
+ if (summary.features.length) {
127
+ console.log(chalk.gray(`Features: ${summary.features.join(', ')}`));
128
+ }
129
+
130
+ if (options.by === 'task') {
131
+ printTasks(summary.tasks);
132
+ } else if (options.by === 'phase') {
133
+ printPhases(summary.phases);
134
+ } else {
135
+ printSummary(summary);
136
+ }
137
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * `morph-spec standards --find "<query>" [--category <c>] [--tags <a,b>] [--json]`
3
+ *
4
+ * Server-side replacement for "read the whole STANDARDS.json to pick 1-3
5
+ * standards" (R2 of the performance plan — token cut per task). Today
6
+ * morph-implement loads the full 65 KB / 60-entry registry (with a `digest`
7
+ * per entry) just to choose a couple of relevant standards: ~16k tokens per
8
+ * task. This command searches server-side and returns only the TOP 3 hits —
9
+ * `id · path · digest` — where the digest is the FULL text: it is what
10
+ * substitutes for actually reading the standard's .md file.
11
+ *
12
+ * Without `--find` (a bare call): lists categories + counts — a cheap
13
+ * orientation view, no scoring.
14
+ *
15
+ * A query with no hit exits 0 (this is advisory tooling, not a gate) with a
16
+ * human-readable warning + category suggestions on stderr, and — under
17
+ * `--json` — a stable `{ query, results: [], suggestion }` shape on stdout so
18
+ * a JSON consumer never has to special-case "no results" as a parse error.
19
+ */
20
+
21
+ import { existsSync, readFileSync } from 'fs';
22
+ import { join } from 'path';
23
+ import chalk from 'chalk';
24
+
25
+ /**
26
+ * Resolve STANDARDS.json the same way doctor.js resolves the standards root:
27
+ * an installed project first (`.morph/framework/standards/`), falling back
28
+ * to this package's own bundled copy (`framework/standards/`) — the framework
29
+ * repo in dev, or the npm package's own copy before `setup-infra` has run.
30
+ *
31
+ * @param {string} targetPath - project root to look for `.morph/` under
32
+ * @returns {string|null} absolute path to STANDARDS.json, or null if neither exists
33
+ */
34
+ export function resolveStandardsJsonPath(targetPath) {
35
+ const installed = join(targetPath, '.morph', 'framework', 'standards', 'STANDARDS.json');
36
+ if (existsSync(installed)) return installed;
37
+
38
+ const packageFallback = join(import.meta.dirname, '..', '..', '..', 'framework', 'standards', 'STANDARDS.json');
39
+ if (existsSync(packageFallback)) return packageFallback;
40
+
41
+ return null;
42
+ }
43
+
44
+ /** Load + parse STANDARDS.json. Throws on missing/invalid file — caller decides how to report it. */
45
+ function loadRegistry(standardsJsonPath) {
46
+ const raw = readFileSync(standardsJsonPath, 'utf8');
47
+ return JSON.parse(raw);
48
+ }
49
+
50
+ /**
51
+ * category → count, sorted by count desc then name asc.
52
+ * @param {object[]} standards
53
+ * @returns {{ category: string, count: number }[]}
54
+ */
55
+ export function categoryCounts(standards) {
56
+ const counts = {};
57
+ for (const std of standards) {
58
+ const cat = std.category || 'uncategorized';
59
+ counts[cat] = (counts[cat] || 0) + 1;
60
+ }
61
+ return Object.entries(counts)
62
+ .map(([category, count]) => ({ category, count }))
63
+ .sort((a, b) => b.count - a.count || a.category.localeCompare(b.category));
64
+ }
65
+
66
+ /**
67
+ * Simple weighted relevance score for one standard against a query.
68
+ * Tiers, as specified: name/id > tags(+aliases) > digest. category/subcategory
69
+ * ride alongside the tags tier (lighter). A token-level fallback keeps
70
+ * multi-word queries findable even without a verbatim phrase match anywhere.
71
+ *
72
+ * @param {object} std - one STANDARDS.json entry
73
+ * @param {string} query - raw user query (already trimmed, non-empty)
74
+ * @returns {number} score, 0 = no match
75
+ */
76
+ export function scoreStandard(std, query) {
77
+ const q = query.toLowerCase();
78
+ const tokens = q.split(/\s+/).filter((t) => t.length >= 3);
79
+
80
+ const name = String(std.name || '').toLowerCase();
81
+ const id = String(std.id || '').toLowerCase();
82
+ const category = String(std.category || '').toLowerCase();
83
+ const subcategory = String(std.subcategory || '').toLowerCase();
84
+ const tags = (std.tags || []).map((t) => String(t).toLowerCase());
85
+ const aliases = (std.aliases || []).map((a) => String(a).toLowerCase());
86
+ const digest = String(std.digest || '').toLowerCase();
87
+
88
+ let score = 0;
89
+
90
+ // Tier 1: name / id — the strongest signal.
91
+ if (id === q || name === q) score += 20;
92
+ else if (id.includes(q) || name.includes(q)) score += 12;
93
+
94
+ // Tier 2: tags / aliases (curated search terms) + category/subcategory.
95
+ if (tags.includes(q) || aliases.includes(q)) score += 8;
96
+ else if (tags.some((t) => t.includes(q)) || aliases.some((a) => a.includes(q))) score += 5;
97
+ if (category === q || subcategory === q) score += 4;
98
+
99
+ // Tier 3: digest — weakest signal, full-phrase substring only.
100
+ if (digest.includes(q)) score += 2;
101
+
102
+ // Token-level fallback for multi-word queries with no verbatim match above.
103
+ for (const tok of tokens) {
104
+ if (name.includes(tok) || id.includes(tok)) score += 2;
105
+ if (tags.some((t) => t.includes(tok)) || aliases.some((a) => a.includes(tok))) score += 1;
106
+ if (digest.includes(tok)) score += 0.5;
107
+ }
108
+
109
+ return score;
110
+ }
111
+
112
+ /**
113
+ * Rank standards against a query, applying optional category/tags filters
114
+ * first. Returns the TOP 3 with score > 0, sorted desc (ties broken by id).
115
+ *
116
+ * @param {object[]} standards
117
+ * @param {string} query
118
+ * @param {{ category?: string, tags?: string[] }} [filters]
119
+ * @returns {Array<object & { score: number }>}
120
+ */
121
+ export function findStandards(standards, query, filters = {}) {
122
+ const category = filters.category?.toLowerCase();
123
+ const tags = (filters.tags || []).map((t) => t.toLowerCase());
124
+
125
+ let candidates = standards;
126
+ if (category) {
127
+ candidates = candidates.filter((s) => String(s.category || '').toLowerCase() === category);
128
+ }
129
+ if (tags.length) {
130
+ candidates = candidates.filter((s) =>
131
+ (s.tags || []).some((t) => tags.includes(String(t).toLowerCase())));
132
+ }
133
+
134
+ return candidates
135
+ .map((s) => ({ ...s, score: scoreStandard(s, query) }))
136
+ .filter((s) => s.score > 0)
137
+ .sort((a, b) => b.score - a.score || String(a.id).localeCompare(String(b.id)))
138
+ .slice(0, 3);
139
+ }
140
+
141
+ /** Parse `--tags a,b,c` into a trimmed, non-empty array. */
142
+ function parseTagsOption(raw) {
143
+ if (!raw) return [];
144
+ return String(raw).split(',').map((t) => t.trim()).filter(Boolean);
145
+ }
146
+
147
+ /**
148
+ * @param {{ find?: string, category?: string, tags?: string, json?: boolean, cwd?: string }} [options]
149
+ */
150
+ export function standardsCommand(options = {}) {
151
+ const cwd = options.cwd || process.cwd();
152
+ const standardsJsonPath = resolveStandardsJsonPath(cwd);
153
+
154
+ if (!standardsJsonPath) {
155
+ console.error(chalk.red('\n❌ STANDARDS.json não encontrado — nem em .morph/framework/standards/ nem no pacote.'));
156
+ console.error(chalk.gray(' Rode `morph-spec setup-infra` (projeto instalado) ou verifique framework/standards/ (repo do framework).\n'));
157
+ process.exit(1);
158
+ }
159
+
160
+ let registry;
161
+ try {
162
+ registry = loadRegistry(standardsJsonPath);
163
+ } catch (err) {
164
+ console.error(chalk.red(`\n❌ STANDARDS.json inválido em ${standardsJsonPath}: ${err.message}\n`));
165
+ process.exit(1);
166
+ }
167
+
168
+ const standards = Array.isArray(registry.standards) ? registry.standards : [];
169
+ const query = (options.find || '').trim();
170
+
171
+ // ── Bare call (no --find): categories + counts, cheap orientation ────────
172
+ if (!query) {
173
+ const counts = categoryCounts(standards);
174
+ if (options.json) {
175
+ console.log(JSON.stringify({ total: standards.length, categories: counts }));
176
+ process.exit(0);
177
+ }
178
+ console.log(chalk.bold(`\nSTANDARDS.json — ${standards.length} standards, ${counts.length} categorias\n`));
179
+ const col = (s, w) => String(s).padEnd(w);
180
+ for (const { category, count } of counts) {
181
+ console.log(` ${col(category, 20)}${count}`);
182
+ }
183
+ console.log(chalk.gray('\nUse --find "<query>" para buscar (top 3, com digest completo).\n'));
184
+ process.exit(0);
185
+ }
186
+
187
+ // ── --find: score + filter ────────────────────────────────────────────
188
+ const filters = { category: options.category, tags: parseTagsOption(options.tags) };
189
+ const results = findStandards(standards, query, filters);
190
+
191
+ if (!results.length) {
192
+ const counts = categoryCounts(standards);
193
+ if (options.json) {
194
+ console.log(JSON.stringify({ query, results: [], suggestion: { categories: counts } }));
195
+ process.exit(0);
196
+ }
197
+ console.error(chalk.yellow(`\n⚠️ Nenhum standard encontrado para "${query}".`));
198
+ console.error(chalk.gray(' Categorias disponíveis: ' +
199
+ counts.map((c) => `${c.category} (${c.count})`).join(', ') + '\n'));
200
+ process.exit(0);
201
+ }
202
+
203
+ const shaped = results.map((r) => ({
204
+ id: r.id, name: r.name, path: r.path, category: r.category, digest: r.digest, score: r.score,
205
+ }));
206
+
207
+ if (options.json) {
208
+ console.log(JSON.stringify({ query, results: shaped }));
209
+ process.exit(0);
210
+ }
211
+
212
+ console.log(chalk.bold(`\nTOP ${shaped.length} para "${query}"\n`));
213
+ shaped.forEach((r, i) => {
214
+ console.log(chalk.cyan(`${i + 1}. ${r.id}`) + chalk.gray(` (${r.category})`));
215
+ console.log(` ${r.path}`);
216
+ console.log(chalk.gray(` ${r.digest}`));
217
+ console.log('');
218
+ });
219
+ process.exit(0);
220
+ }
@@ -283,17 +283,38 @@ export async function approveCommand(featureName, gateName, options = {}) {
283
283
 
284
284
  let selfAssessed = false;
285
285
  if (gateName === 'review') {
286
- const evaluatorReport = join(process.cwd(), '.morph', 'features', featureName, '4-review', 'evaluator-report.md');
287
- if (!existsSync(evaluatorReport)) {
288
- if (!options.selfAssessed) {
289
- console.error(chalk.red('\n❌ Gate 3 exige o avaliador independente — `4-review/evaluator-report.md` não existe.'));
290
- console.error(chalk.gray(' O score composto da feature vem do avaliador (persona `evaluator`, contexto fresco)'));
291
- console.error(chalk.gray(' despachado pela morph-review, nunca de auto-avaliação de quem implementou.'));
292
- console.error(chalk.gray(' Se o dispatch for impossível nesta sessão, aprove com --self-assessed —'));
293
- console.error(chalk.gray(' a degradação fica registrada no feature.json e aparece no `list`/`fleet`.\n'));
286
+ if (feature.workType === 'hotfix') {
287
+ // Hotfix Gate 3 does NOT require the independent evaluator: `morph-hotfix`
288
+ // (SKILL.md §7) never dispatches `morph-evaluator` — speed over ceremony
289
+ // is the point of a hotfix — and instead writes `4-review/review-report.md`
290
+ // (diff + real test evidence) before asking the human to approve. Requiring
291
+ // evaluator-report.md here made the skill's own documented flow unable to
292
+ // clear its own Gate 3 (the only escape was --self-assessed, which stamps
293
+ // a degradation for a report that was never supposed to exist). The human
294
+ // signature is already enforced above (line ~215) — this only checks the
295
+ // artifact the skill actually produces.
296
+ const reviewReport = join(process.cwd(), '.morph', 'features', featureName, '4-review', 'review-report.md');
297
+ if (!existsSync(reviewReport)) {
298
+ console.error(chalk.red('\n❌ Gate 3 de hotfix exige `4-review/review-report.md` — não existe.'));
299
+ console.error(chalk.gray(' É o artefato que a `morph-hotfix` produz (diff do fix + evidência real de'));
300
+ console.error(chalk.gray(' teste do `morph-spec verify`), não o `evaluator-report.md` do avaliador'));
301
+ console.error(chalk.gray(' independente — hotfix nunca despacha o avaliador. Escreva o relatório antes'));
302
+ console.error(chalk.gray(' de aprovar; a assinatura humana já é exigida pelo backstop acima.\n'));
294
303
  process.exit(1);
295
304
  }
296
- selfAssessed = true;
305
+ } else {
306
+ const evaluatorReport = join(process.cwd(), '.morph', 'features', featureName, '4-review', 'evaluator-report.md');
307
+ if (!existsSync(evaluatorReport)) {
308
+ if (!options.selfAssessed) {
309
+ console.error(chalk.red('\n❌ Gate 3 exige o avaliador independente — `4-review/evaluator-report.md` não existe.'));
310
+ console.error(chalk.gray(' O score composto da feature vem do avaliador (persona `evaluator`, contexto fresco)'));
311
+ console.error(chalk.gray(' despachado pela morph-review, nunca de auto-avaliação de quem implementou.'));
312
+ console.error(chalk.gray(' Se o dispatch for impossível nesta sessão, aprove com --self-assessed —'));
313
+ console.error(chalk.gray(' a degradação fica registrada no feature.json e aparece no `list`/`fleet`.\n'));
314
+ process.exit(1);
315
+ }
316
+ selfAssessed = true;
317
+ }
297
318
  }
298
319
  }
299
320
 
@@ -63,8 +63,17 @@ export function hookRegistrations(definitions = getHookDefinitions()) {
63
63
  for (const h of def.hooks || []) {
64
64
  const file = basename(String(h.command || '').trim());
65
65
  if (!file.endsWith('.js')) continue;
66
- if (!byFile.has(file)) byFile.set(file, []);
67
- byFile.get(file).push({ event: key, rawEvent: def.event, matcher: def.matcher || null });
66
+ // Grupos consolidados apontam para chain.js com os hooks reais em
67
+ // `args` (base names, na ordem de execução) — registre cada elo, não o
68
+ // runner: é o elo que o Inspector precisa mostrar. chain.js em si não é
69
+ // um hook de domínio e ficaria como nó fantasma em todo evento.
70
+ const files = file === 'chain.js' && Array.isArray(h.args) && h.args.length
71
+ ? h.args.map(a => `${a}.js`)
72
+ : [file];
73
+ for (const f of files) {
74
+ if (!byFile.has(f)) byFile.set(f, []);
75
+ byFile.get(f).push({ event: key, rawEvent: def.event, matcher: def.matcher || null });
76
+ }
68
77
  }
69
78
  }
70
79
  return byFile;
@@ -87,7 +87,7 @@ metadata:
87
87
  - \`.morph/framework/schemas/tasks.schema.json\` — tasks.json schema reference
88
88
  - \`.morph/framework/phases.json\` — phase folder mapping and skill associations
89
89
  - \`.morph/framework/agents.json\` — domain persona definitions (dotnet-senior, ef-modeler, etc.)
90
- - \`.morph/framework/rubrics/\` — eval rubrics for morph-eval scoring
90
+ - \`.morph/framework/evals/rubrics/\` — eval rubrics for morph-eval scoring (architecture/contracts/code-quality/test-coverage)
91
91
  `;
92
92
 
93
93
  /**
@@ -87,6 +87,32 @@ export function validateTasksJson(data) {
87
87
  if (task.usecase !== undefined && (typeof task.usecase !== 'string' || !USECASE_RE.test(task.usecase))) {
88
88
  errors.push(`${ctx}.usecase must match /^UC-\\d+$/ (the ID alone, e.g. "UC-07")`);
89
89
  }
90
+
91
+ // Optional: morph-plan may stamp a persona + standards per task (R2 token
92
+ // cut — `morph-spec standards --find` replaces reading the whole
93
+ // STANDARDS.json). Both are part of the plan contract and frozen after
94
+ // Gate 2 by protect-spec-files (any key outside MUTABLE_SPEC_FIELDS is
95
+ // blocked by construction — see that hook's top comment). Type-check
96
+ // only here: no cross-check against agents.json, since stack-filtered
97
+ // installs carry fewer personas than the full set and would false-fail.
98
+ if (task.agent !== undefined) {
99
+ if (typeof task.agent !== 'string' || !task.agent.trim()) {
100
+ errors.push(`${ctx}.agent must be a non-empty string (persona id, e.g. "dotnet-senior")`);
101
+ } else if (task.agent.startsWith('morph-')) {
102
+ errors.push(`${ctx}.agent must be the bare persona id — no "morph-" prefix (got "${task.agent}")`);
103
+ }
104
+ }
105
+ if (task.standards !== undefined) {
106
+ if (!Array.isArray(task.standards)) {
107
+ errors.push(`${ctx}.standards must be an array of paths`);
108
+ } else {
109
+ task.standards.forEach((s, i) => {
110
+ if (typeof s !== 'string' || !s.trim()) {
111
+ errors.push(`${ctx}.standards[${i}] must be a non-empty string path`);
112
+ }
113
+ });
114
+ }
115
+ }
90
116
  }
91
117
 
92
118
  return { valid: errors.length === 0, errors };