@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.
- package/README.md +2 -2
- package/bin/morph-spec.js +19 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +10 -4
- package/framework/MORPH.md +4 -4
- package/framework/evals/rubrics/condensed.md +49 -0
- package/framework/hooks/README.md +3 -3
- package/framework/hooks/claude-code/core/chain.js +123 -0
- package/framework/hooks/claude-code/core/enforce-phase-writes.js +84 -77
- package/framework/hooks/claude-code/core/gate-guard.js +21 -25
- package/framework/hooks/claude-code/core/loop-detect.js +70 -64
- package/framework/hooks/claude-code/core/post-edit-typecheck.js +94 -45
- package/framework/hooks/claude-code/core/pre-bash-denylist.js +21 -15
- package/framework/hooks/claude-code/core/protect-spec-files.js +119 -103
- package/framework/hooks/claude-code/core/state-sync.js +32 -40
- package/framework/hooks/claude-code/core/tasks-json-guard.js +44 -44
- package/framework/hooks/claude-code/core/telemetry-log.js +151 -74
- package/framework/hooks/claude-code/core/trace-autogen.js +116 -6
- package/framework/hooks/claude-code/core/validate-completion.js +147 -107
- package/framework/hooks/claude-code/core/worktree-context.js +25 -22
- package/framework/hooks/claude-code/statusline.py +26 -4
- package/framework/hooks/shared/activity-logger.js +120 -69
- package/framework/hooks/shared/claim-registry.js +61 -1
- package/framework/hooks/shared/git-paths.js +94 -0
- package/framework/hooks/shared/hook-response.js +67 -0
- package/framework/hooks/shared/state-reader.js +95 -29
- package/framework/hooks/shared/state-writer.js +16 -0
- package/framework/rules/morph-task-tracking.md +4 -2
- package/framework/schemas/tasks.schema.json +10 -0
- 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 +40 -28
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +16 -13
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +26 -25
- package/framework/skills/core/morph-plan/SKILL.md +14 -3
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/package.json +2 -2
- package/src/commands/observability/cost.js +137 -0
- package/src/commands/project/standards.js +220 -0
- package/src/commands/state/approve.js +30 -9
- package/src/dashboard/scanner/hooks-scanner.js +11 -2
- package/src/lib/installers/memory-installer.js +1 -1
- package/src/lib/tasks/tasks-json-parser.js +26 -0
- package/src/lib/telemetry/telemetry-reader.js +213 -0
- package/src/utils/hooks-installer.js +84 -79
- 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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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 };
|