@polymorphism-tech/morph-spec 8.15.0 → 8.16.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 +8 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +2 -1
- package/framework/MORPH.md +4 -3
- 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 +110 -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/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 +1 -1
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- 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 +1 -1
- package/src/commands/observability/cost.js +137 -0
- package/src/commands/project/doctor.js +28 -1
- package/src/commands/state/approve.js +30 -9
- package/src/commands/state/archive.js +29 -3
- package/src/commands/state/delete.js +36 -2
- package/src/dashboard/scanner/hooks-scanner.js +11 -2
- package/src/lib/installers/memory-installer.js +1 -1
- 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
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, mkdirSync } from 'fs';
|
|
14
14
|
import { join, dirname } from 'path';
|
|
15
|
+
import { clearStateCache } from './state-reader.js';
|
|
15
16
|
|
|
16
17
|
const STATE_FILE = '.morph/state.json';
|
|
17
18
|
const STATE_VERSION = '5.0.0';
|
|
@@ -73,6 +74,21 @@ export function persistFeature(featureName, feature, projectPath) {
|
|
|
73
74
|
root.metadata = root.metadata || {};
|
|
74
75
|
root.metadata.totalFeatures = Object.keys(root.features).length;
|
|
75
76
|
atomicWriteJson(statePath, root);
|
|
77
|
+
|
|
78
|
+
// R1's loadState() memoizes per projectPath for the lifetime of the
|
|
79
|
+
// process. That was always safe when every hook was its own short-lived
|
|
80
|
+
// process (README history), but core/chain.js now runs multiple hooks
|
|
81
|
+
// — e.g. state-sync then gate-guard — IN ONE process. Without this,
|
|
82
|
+
// a loadState() call earlier in that same process (state-sync's
|
|
83
|
+
// handleWriteEdit reads via loadState before this write) would leave a
|
|
84
|
+
// stale snapshot cached, and gate-guard's loadState() right after this
|
|
85
|
+
// write would silently read the PRE-write state instead of what was
|
|
86
|
+
// just persisted — inverting the "gate-guard runs after state-sync so it
|
|
87
|
+
// sees the gate state-sync just recorded" ordering guarantee the
|
|
88
|
+
// registry comment documents. Invalidating here (the one place
|
|
89
|
+
// feature.json/state.json are actually written) covers every current
|
|
90
|
+
// and future caller, not just this one chain.
|
|
91
|
+
clearStateCache();
|
|
76
92
|
return true;
|
|
77
93
|
} catch {
|
|
78
94
|
return false;
|
|
@@ -4,7 +4,7 @@ description: Guia a criação de uma landing page nova ponta-a-ponta a partir do
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar um novo projeto de LP com o template Umbraco genérico
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.16.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# create-umbraco-lp
|
|
@@ -4,7 +4,7 @@ description: Migra uma landing page existente (React/Vite, Next.js estático ou
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao receber instrução "use /migrate-lp-to-umbraco {projeto}" do usuário, ou quando o usuário descrever necessidade de migrar uma LP para Umbraco
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.16.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# migrate-lp-to-umbraco
|
|
@@ -4,7 +4,7 @@ description: Orquestra a execução autônoma de tasks de uma feature morph-spec
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: após Gate 2 aprovado, ao receber instrução "use /morph-apply" ou "implementar feature"
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.16.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-apply
|
|
@@ -4,7 +4,7 @@ description: Entende o negócio, os usuários e a arquitetura antes de qualquer
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao receber um novo request de feature, antes de morph-plan
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.16.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-brainstorming
|
|
@@ -4,7 +4,7 @@ description: Avalia a qualidade de uma tarefa ou fase com rubricas graduadas 0-1
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao completar uma tarefa ou ao finalizar uma fase de implementação
|
|
6
6
|
user-invokable: false
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.16.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-eval
|
|
@@ -4,7 +4,7 @@ description: ADW cirúrgico de hotfix — resolve uma queda/bug crítico de prod
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao receber "/morph-hotfix {feature}", quando a morph-proposal classifica o pedido como workType hotfix (Passo 0), ou o usuário diz "produção caiu / hotfix / urgente"
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.16.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-hotfix
|
|
@@ -4,7 +4,7 @@ description: Guia a execução de cada tarefa de implementação com standard e
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar cada task de implementação listada em tasks.json
|
|
6
6
|
user-invokable: false
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.16.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-implement
|
|
@@ -4,7 +4,7 @@ description: Guia a produção dos artefatos de planejamento de uma feature (use
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar a fase plan de uma feature, após o Gate 1 (proposal aprovado)
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.16.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-plan
|
|
@@ -4,7 +4,7 @@ description: Orquestra a fase de proposta+plano de uma feature morph-spec. Encad
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao receber instrução "use /morph-proposal" do usuário, ou ao iniciar uma nova feature
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.16.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-proposal
|
|
@@ -4,7 +4,7 @@ description: Conduz a fase de revisão e produz o review-report.md para o Gate 3
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar a fase review, após todas as tasks de tasks.json estarem com status `done`
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.16.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-review
|
|
@@ -4,7 +4,7 @@ description: Varre o codebase existente, detecta stack atual, compara com o stac
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar uma feature em projeto brownfield (codebase já existente)
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.16.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-stack-scan
|
|
@@ -4,7 +4,7 @@ description: Localiza e carrega um standard específico do STANDARDS.json sob de
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: quando precisar consultar um standard específico durante a implementação
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.16.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-standards
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -991,7 +991,34 @@ export async function doctorCommand(options = {}) {
|
|
|
991
991
|
check('context7 plugin', pluginStatus.context7, false,
|
|
992
992
|
'Run: morph-spec install-plugin context7');
|
|
993
993
|
} else {
|
|
994
|
-
|
|
994
|
+
// `.claude/` exists but lacks commands/ and/or skills/ — ranges from a
|
|
995
|
+
// partial install to the full-wipe incident this command exists to catch
|
|
996
|
+
// (folder survives, 0 items — doctor used to shrug this off as a vague
|
|
997
|
+
// warning with no fix). Count what IS still there: near-total loss is an
|
|
998
|
+
// ERROR with the actionable command, not a warning that reads as optional.
|
|
999
|
+
const pieces = ['agents', 'rules', 'skills', 'commands'];
|
|
1000
|
+
const present = [];
|
|
1001
|
+
for (const piece of pieces) {
|
|
1002
|
+
if (await pathExists(join(claudePath, piece))) present.push(piece);
|
|
1003
|
+
}
|
|
1004
|
+
if (await pathExists(join(claudePath, 'CLAUDE.md'))) present.push('CLAUDE.md');
|
|
1005
|
+
if (await pathExists(join(claudePath, 'settings.local.json'))) present.push('settings.local.json');
|
|
1006
|
+
|
|
1007
|
+
if (present.length <= 1) {
|
|
1008
|
+
checks.push({
|
|
1009
|
+
name: '.claude/',
|
|
1010
|
+
status: 'error',
|
|
1011
|
+
msg: `severely damaged — only ${present.length ? present.join(', ') : 'nothing'} present (expected agents/, rules/, skills/, commands/, CLAUDE.md, settings.local.json) — run 'morph-spec setup-infra' (NOT --reinstall, which only covers stack-aware assets) to fully restore`,
|
|
1012
|
+
});
|
|
1013
|
+
hasErrors = true;
|
|
1014
|
+
} else {
|
|
1015
|
+
checks.push({
|
|
1016
|
+
name: '.claude/',
|
|
1017
|
+
status: 'warn',
|
|
1018
|
+
msg: `incomplete structure — present: ${present.join(', ')} — run 'morph-spec setup-infra' to restore the rest`,
|
|
1019
|
+
});
|
|
1020
|
+
hasWarnings = true;
|
|
1021
|
+
}
|
|
995
1022
|
}
|
|
996
1023
|
} else {
|
|
997
1024
|
checks.push({ name: '.claude/', status: 'missing' });
|
|
@@ -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
|
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { existsSync, renameSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
3
|
-
import { join } from 'path';
|
|
3
|
+
import { join, resolve, sep } from 'path';
|
|
4
4
|
import { execFileSync } from 'child_process';
|
|
5
5
|
import { promoteUseCases, SPECS_USECASES_PATHSPEC } from '../../lib/specs/usecase-promoter.js';
|
|
6
|
+
import { isValidFeatureName } from '../project/worktree.js';
|
|
7
|
+
|
|
8
|
+
/** Refuse a move whose resolved target escapes `scopeDir`. */
|
|
9
|
+
function assertContained(scopeDir, name, target) {
|
|
10
|
+
const scopeAbs = resolve(scopeDir) + sep;
|
|
11
|
+
const targetAbs = resolve(target);
|
|
12
|
+
if (targetAbs !== resolve(scopeDir) && !(targetAbs + sep).startsWith(scopeAbs)) {
|
|
13
|
+
throw new Error(`recusado: "${name}" resolve para fora de ${scopeDir}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
6
16
|
|
|
7
17
|
/**
|
|
8
18
|
* `morph-spec archive <feature>` — atomic, git-aware archive of a completed
|
|
@@ -57,9 +67,22 @@ function readReviewApproved(fjPath) {
|
|
|
57
67
|
*/
|
|
58
68
|
export function runArchive(feature, opts = {}) {
|
|
59
69
|
const cwd = opts.cwd || process.cwd();
|
|
60
|
-
|
|
70
|
+
|
|
71
|
+
// Gate 0: valid kebab name — refuses BEFORE any path is built from it. Same
|
|
72
|
+
// gate `finish` already applies upstream (defense-in-depth here); the
|
|
73
|
+
// standalone `archiveCommand` has no other gate at all, so without this an
|
|
74
|
+
// unvalidated `feature` reaches `renameSync` and can MOVE a directory outside
|
|
75
|
+
// .morph/features/ (`../../.claude` resolves to `cwd/.claude`).
|
|
76
|
+
if (!isValidFeatureName(feature)) {
|
|
77
|
+
return { ok: false, error: 'invalid-feature-name' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const featuresDir = join(cwd, '.morph', 'features');
|
|
61
81
|
const archiveDir = join(cwd, '.morph', 'archive');
|
|
82
|
+
const featuresPath = join(featuresDir, feature);
|
|
62
83
|
const archivePath = join(archiveDir, feature);
|
|
84
|
+
assertContained(featuresDir, feature, featuresPath);
|
|
85
|
+
assertContained(archiveDir, feature, archivePath);
|
|
63
86
|
const featPathspec = join('.morph', 'features', feature);
|
|
64
87
|
const archivePathspec = join('.morph', 'archive', feature);
|
|
65
88
|
|
|
@@ -197,7 +220,10 @@ export async function archiveCommand(featureName, options = {}) {
|
|
|
197
220
|
});
|
|
198
221
|
|
|
199
222
|
if (!result.ok) {
|
|
200
|
-
if (result.error === 'feature-
|
|
223
|
+
if (result.error === 'invalid-feature-name') {
|
|
224
|
+
console.error(chalk.red(`\n❌ Invalid feature name: "${featureName}"`));
|
|
225
|
+
console.error(chalk.gray('Use kebab-case: lowercase letters, digits and hyphens (e.g. "user-onboarding")\n'));
|
|
226
|
+
} else if (result.error === 'feature-folder-not-found') {
|
|
201
227
|
console.error(chalk.red(`\n❌ Feature folder not found: .morph/features/${featureName}\n`));
|
|
202
228
|
} else if (result.error === 'review-not-approved') {
|
|
203
229
|
console.error(chalk.red(`\n❌ Gate 3 (review) is not approved for "${featureName}".`));
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { existsSync, rmSync, renameSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
3
|
-
import { join } from 'path';
|
|
3
|
+
import { join, resolve, sep } from 'path';
|
|
4
4
|
import { execSync } from 'child_process';
|
|
5
|
+
import { isValidFeatureName } from '../project/worktree.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* `morph-spec delete <feature>` — remove a feature. Hard by default (folder is
|
|
@@ -17,8 +18,27 @@ import { execSync } from 'child_process';
|
|
|
17
18
|
*
|
|
18
19
|
* Refuses when any gate is approved (real work, not a dead shell), unless
|
|
19
20
|
* `--force`.
|
|
21
|
+
*
|
|
22
|
+
* SECURITY: `featureName` is an unsanitized CLI argument that flows straight
|
|
23
|
+
* into `rmSync(..., { recursive: true })` — the same class of bug the worktree
|
|
24
|
+
* teardown guards against, but here the danger is path TRAVERSAL rather than a
|
|
25
|
+
* followed symlink. `join(cwd, '.morph', 'features', '../../.claude')`
|
|
26
|
+
* resolves to `cwd/.claude`, so an unvalidated name can point the hard-delete
|
|
27
|
+
* at the shared `.claude/` install (or anything else relative to cwd). Gated
|
|
28
|
+
* by `isValidFeatureName` (same kebab-slug rule `create` stamps at birth) PLUS
|
|
29
|
+
* a resolved-path containment assert — validated by path, not by the
|
|
30
|
+
* convention that a real feature name never contains `..`.
|
|
20
31
|
*/
|
|
21
32
|
|
|
33
|
+
/** Refuse a delete/archive whose resolved target escapes `.morph/features/{scope}/{name}`. */
|
|
34
|
+
function assertContained(scopeDir, name, target) {
|
|
35
|
+
const scopeAbs = resolve(scopeDir) + sep;
|
|
36
|
+
const targetAbs = resolve(target);
|
|
37
|
+
if (targetAbs !== resolve(scopeDir) && !(targetAbs + sep).startsWith(scopeAbs)) {
|
|
38
|
+
throw new Error(`recusado: "${name}" resolve para fora de ${scopeDir}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
22
42
|
function isGitRepo(cwd) {
|
|
23
43
|
try {
|
|
24
44
|
execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'pipe' });
|
|
@@ -53,7 +73,21 @@ function dropFromIndex(cwd, featureName) {
|
|
|
53
73
|
export async function deleteCommand(featureName, options = {}) {
|
|
54
74
|
try {
|
|
55
75
|
const cwd = options.cwd || process.cwd();
|
|
56
|
-
|
|
76
|
+
|
|
77
|
+
// Gate 0: valid kebab name — refuses BEFORE any path is built from it, so a
|
|
78
|
+
// traversal attempt (`../../.claude`) never reaches existsSync/rmSync. Same
|
|
79
|
+
// gate `create` stamps at birth and `finish` re-checks; unlike those two,
|
|
80
|
+
// delete/archive take a FRESH argument every invocation, so this cannot rely
|
|
81
|
+
// on "every real feature folder is already a valid slug".
|
|
82
|
+
if (!isValidFeatureName(featureName)) {
|
|
83
|
+
console.error(chalk.red(`\n❌ Invalid feature name: "${featureName}"`));
|
|
84
|
+
console.error(chalk.gray('Use kebab-case: lowercase letters, digits and hyphens (e.g. "user-onboarding")\n'));
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const featuresDir = join(cwd, '.morph', 'features');
|
|
89
|
+
const featuresPath = join(featuresDir, featureName);
|
|
90
|
+
assertContained(featuresDir, featureName, featuresPath);
|
|
57
91
|
const fjPath = join(featuresPath, 'feature.json');
|
|
58
92
|
|
|
59
93
|
if (!existsSync(featuresPath)) {
|
|
@@ -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
|
/**
|