@polymorphism-tech/morph-spec 8.16.0 → 8.18.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 +11 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +45 -38
- package/framework/MORPH.md +119 -167
- package/framework/docs/CLI.md +40 -0
- package/framework/evals/rubrics/condensed.md +49 -0
- package/framework/hooks/claude-code/core/protect-spec-files.js +9 -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 +3 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +3 -1
- package/framework/skills/core/morph-apply/SKILL.md +42 -34
- package/framework/skills/core/morph-brainstorming/SKILL.md +2 -12
- 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 +15 -10
- 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 +2 -14
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/package.json +2 -2
- package/src/commands/project/doctor.js +11 -4
- package/src/commands/project/init.js +1 -1
- package/src/commands/project/standards.js +220 -0
- package/src/commands/project/update.js +91 -7
- package/src/lib/stack-filter.js +7 -2
- package/src/lib/tasks/tasks-json-parser.js +26 -0
- package/src/scripts/setup-infra.js +27 -8
- package/src/utils/skills-installer.js +28 -15
|
@@ -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.18.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-plan
|
|
@@ -88,13 +88,7 @@ Ao iniciar a fase plan — após o `proposal.md` ser aprovado e antes de qualque
|
|
|
88
88
|
4. Produza `mandate.md` em `.morph/features/{feature}/2-plan/`:
|
|
89
89
|
- Stack exata do projeto (leia de `.morph/config/config.json` → `project.stack` ou do CLAUDE.md).
|
|
90
90
|
- Standards obrigatórios: liste os IDs relevantes de `.morph/framework/standards/STANDARDS.json`.
|
|
91
|
-
- Patterns proibidos —
|
|
92
|
-
- `Guid.NewGuid()` → usar `Guid.CreateVersion7()`
|
|
93
|
-
- Classes não-sealed sem justificativa documentada
|
|
94
|
-
- Exceptions como fluxo de controle → usar result pattern com `result.Match()`
|
|
95
|
-
- Camada de Service/Manager entre handler e dados → handler usa `ApplicationDbContext` direto no slice
|
|
96
|
-
- Repository genérico (`IRepository<T>`/`IUnitOfWork`) → redundante sobre EF Core: `DbSet<T>` já é repository, `DbContext` já é unit of work
|
|
97
|
-
- SQL cru (`FromSqlRaw`, `ExecuteQuery(string)`) sem justificativa em `decisions.md` → usar LINQ
|
|
91
|
+
- Patterns proibidos — **copie por extenso no mandate.md** a lista canônica de `MORPH.md` § "Stack baked-in" (`Guid.NewGuid()`→`Guid.CreateVersion7()`, classes não-sealed sem justificativa, exceptions como fluxo de controle, camada de Service/Manager entre handler e dados, repository genérico `IRepository<T>`/`IUnitOfWork`, SQL cru sem justificativa) mais qualquer proibição específica da feature. Copie o texto completo — o mandate.md é lido por implementadores em contexto fresco, sem `MORPH.md` carregado, então um ponteiro sozinho não basta aqui.
|
|
98
92
|
- Política de acesso a dados (registre no mandate): `ApplicationDbContext` injetado direto no slice — para CRUD e para queries específicas (joins, projeção, paginação). Para concorrência ou SQL específico, store dedicado hand-written com justificativa em `decisions.md`.
|
|
99
93
|
- **Features com agentes de IA (MAF):** se a feature envolve um agente (Microsoft Agent Framework), **use a tool Read** nos standards relevantes de `framework/standards/ai-agents/*.md` (ex.: `setup`, `agent-session`, `middleware-patterns`, `mcp-server`, `mcp-tools`, `multi-agent-patterns`, `production`) — filtre pelo que a feature exige via STANDARDS.json. Esses standards carregam as **convenções de projeto** (single-agent por padrão — Workflows só para fan-in/out real; provider, caching, observability, A2A) que uma fonte externa de docs (ex.: context7) não conhece. Padrão de divisão: **superfície da API** pode vir de docs ao vivo/context7 (mais frescas para um SDK em evolução), mas **as convenções vêm dos standards** — registre ambas no mandate. Não pule os `ai-agents/*.md` só porque o STANDARDS.json index lista que eles existem.
|
|
100
94
|
- Checklist de done com critérios verificáveis por tarefa.
|
|
@@ -118,7 +112,9 @@ Ao iniciar a fase plan — após o `proposal.md` ser aprovado e antes de qualque
|
|
|
118
112
|
"status": "pending",
|
|
119
113
|
"outputs": [],
|
|
120
114
|
"group": "backend",
|
|
121
|
-
"usecase": "UC-07"
|
|
115
|
+
"usecase": "UC-07",
|
|
116
|
+
"agent": "dotnet-senior",
|
|
117
|
+
"standards": ["standards/backend/dotnet/vsa-handler-patterns.md"]
|
|
122
118
|
}
|
|
123
119
|
]
|
|
124
120
|
}
|
|
@@ -132,6 +128,14 @@ Ao iniciar a fase plan — após o `proposal.md` ser aprovado e antes de qualque
|
|
|
132
128
|
- `group` (opcional): backend, frontend, tests, infra, docs. Default backend.
|
|
133
129
|
- `outputs` (opcional): array de paths que serão criados/modificados.
|
|
134
130
|
- `usecase` (opcional): o **ID** do use case que a task realiza — `UC-07`, nunca o nome do arquivo. Task puramente técnica (build, config, migração) não tem.
|
|
131
|
+
- `agent` (opcional): o `id` da persona de `agents.json` responsável pelo domínio da task — **sem** o prefixo `morph-` (ex.: `"dotnet-senior"`, não `"morph-dotnet-senior"`). Carimbado no passo 5a abaixo; ausente numa task sem domínio de agente claro (config/docs) ou num plano legado.
|
|
132
|
+
- `standards` (opcional): array de paths relativos de `framework/standards/` (ex.: `["standards/backend/dotnet/vsa-handler-patterns.md"]`) — os standards da persona resolvida em `agent`, mais qualquer standard específico que a task exija.
|
|
133
|
+
|
|
134
|
+
**5a. Carimbe `agent` e `standards` — uma leitura de `agents.json`, N tasks.** Antes de fechar o array `tasks`, **use a tool Read UMA VEZ** em `.morph/framework/agents.json` (não releia por task — é essa releitura repetida por task, hoje feita em `morph-implement`/`morph-apply` a cada dispatch, que este passo existe para eliminar). Para cada task do plano, resolva:
|
|
135
|
+
- `agent`: o `id` da persona (chave do objeto `agents`, sem prefixo `morph-`) cujo `domains` cobre o `group`/domínio da task — o mesmo casamento que `morph-implement` §3 e `morph-apply` §3b fariam manualmente a cada dispatch; aqui você faz uma vez, no plano.
|
|
136
|
+
- `standards`: o array `standards` dessa persona, mais qualquer standard adicional que a task especificamente exija (some, não substitua).
|
|
137
|
+
|
|
138
|
+
Uma task cujo domínio não bate com nenhuma persona registrada (ex.: task pura de config/docs) fica sem `agent` — `morph-implement` cai no fallback de Read de `agents.json` nesse caso.
|
|
135
139
|
|
|
136
140
|
**O `doneCriteria` carrega a pós-condição LITERAL.** Este é o elo que amarra a cadeia inteira: a pós-condição escrita em linguagem de negócio chega **sem tradução** ao critério que o `morph-spec verify` e o `morph-eval` cobram. Copie o texto da pós-condição, prefixado pelo seu rótulo (`"P2 — Cada veterinário traz..."`). Reescrever com outras palavras aqui é exatamente o ponto em que o requisito se perde hoje.
|
|
137
141
|
|
|
@@ -193,7 +197,7 @@ Ao iniciar a fase plan — após o `proposal.md` ser aprovado e antes de qualque
|
|
|
193
197
|
- `.morph/features/{feature}/2-plan/usecases/UC-{nn}-{slug}.md` — um arquivo por use case, com pós-condições testáveis
|
|
194
198
|
- `.morph/features/{feature}/2-plan/spec.md` — especificação técnica completa
|
|
195
199
|
- `.morph/features/{feature}/2-plan/mandate.md` — contrato de implementação
|
|
196
|
-
- `.morph/features/{feature}/2-plan/tasks.json` — task list estruturada válida contra schema, com o `doneCriteria` carregando a pós-condição literal
|
|
200
|
+
- `.morph/features/{feature}/2-plan/tasks.json` — task list estruturada válida contra schema, com o `doneCriteria` carregando a pós-condição literal e, por task, `agent`/`standards` carimbados a partir de `agents.json` (passo 5a)
|
|
197
201
|
- Gate 2 registrado aprovado em `.morph/features/{feature}/feature.json` (autoritativo)
|
|
198
202
|
|
|
199
203
|
Ao registrar a aprovação do Gate 2, atualize o frontmatter de cada use case de `status: draft` para `status: approved`. O ciclo fecha na integração: `morph-spec finish` promove os use cases para o acervo permanente `.morph/specs/usecases/` já com `status: implemented`. O status é **descritivo** — quem trava fase é o gate, não este campo.
|
|
@@ -202,6 +206,7 @@ Ao registrar a aprovação do Gate 2, atualize o frontmatter de cada use case de
|
|
|
202
206
|
|
|
203
207
|
- `.morph/framework/MORPH.md` — seções 2 (Fases), 3 (Contratos de Output), 5 (Gates), Trust Mode
|
|
204
208
|
- `.morph/framework/standards/STANDARDS.json` — para identificar standards relevantes ao mandate
|
|
209
|
+
- `.morph/framework/agents.json` — personas por domínio; lido UMA VEZ no passo 5a para carimbar `agent`/`standards` por task
|
|
205
210
|
- `.morph/framework/schemas/tasks.schema.json` — schema obrigatório de tasks.json
|
|
206
211
|
- `.morph/specs/usecases/` — acervo permanente de use cases do projeto (leia antes de numerar; em `bug`, é onde mora o UC a corrigir)
|
|
207
212
|
- `.morph/features/{feature}/0-proposal/proposal.md` — escopo da feature
|
|
@@ -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.18.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.18.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.18.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-stack-scan
|
|
@@ -17,19 +17,7 @@ Quando o projeto já tem código existente e você precisa entender o estado atu
|
|
|
17
17
|
|
|
18
18
|
## Stack padrão de referência
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|--------|----------|
|
|
22
|
-
| Runtime | .NET 10 |
|
|
23
|
-
| Arquitetura | VSA — feature-folder: `{Feature}/` com Handler, Validator, Endpoint, Errors, Tests |
|
|
24
|
-
| ORM | EF Core + Neon (Postgres) |
|
|
25
|
-
| IDs | `Guid.CreateVersion7()` |
|
|
26
|
-
| Classes | `sealed` por padrão |
|
|
27
|
-
| Erros | Result pattern (`result.Match()`), não exceptions não tratadas |
|
|
28
|
-
| IA | Microsoft Agent Framework (MAF) |
|
|
29
|
-
| Frontend | Next.js App Router + Tailwind + Framer Motion + Shadcn |
|
|
30
|
-
| Jobs | Hangfire (apenas quando necessário) |
|
|
31
|
-
|
|
32
|
-
**Padrões proibidos:** `Guid.NewGuid()`, classes não-sealed sem justificativa, exceptions como fluxo de controle, camada de Service/Manager entre handler e dados, repository genérico (`IRepository<T>`/`IUnitOfWork`), SQL cru sem justificativa. Acesso a dados em VSA: `ApplicationDbContext` direto no slice (`DbSet<T>` já é repository, `DbContext` já é unit of work); para concorrência ou SQL específico, store dedicado hand-written com justificativa em `decisions.md`.
|
|
20
|
+
O "Esperado" da comparação do passo 2 é o stack baked-in (runtime .NET 10 + VSA, EF Core/Neon, `Guid.CreateVersion7()`, `sealed`, result pattern, MAF, Next.js/Tailwind/Shadcn, Hangfire) e a lista de padrões proibidos (`Guid.NewGuid()`, classes não-sealed, Service/Manager layer, repository genérico, SQL cru sem justificativa) — regra canônica em `MORPH.md` § "Stack baked-in". Os templates de `stack-scan.md` abaixo (greenfield e brownfield) escrevem essa lista por extenso no artefato da feature, para que quem ler o `stack-scan.md` depois não precise reabrir o `MORPH.md`.
|
|
33
21
|
|
|
34
22
|
## Greenfield short-circuit
|
|
35
23
|
|
|
@@ -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.18.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-standards
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polymorphism-tech/morph-spec",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.18.0",
|
|
4
4
|
"description": "MORPH-SPEC: NLH (Natural Language Harness) for spec-driven development with Claude Code",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"generate:check": "node scripts/generate-refs.js --check",
|
|
46
46
|
"generate-standards": "node scripts/generate-standards-registry.js",
|
|
47
47
|
"generate-standards:check": "node scripts/generate-standards-registry.js --check",
|
|
48
|
-
"validate": "node scripts/validate-framework.js",
|
|
48
|
+
"validate": "node scripts/validate-framework.js && node scripts/check-rubric-parity.js",
|
|
49
49
|
"freshness": "node scripts/check-freshness.js",
|
|
50
50
|
"verify": "npm run generate:check && npm run generate-standards:check && npm run validate && npm run freshness && npm test",
|
|
51
51
|
"test": "node scripts/run-tests.mjs",
|
|
@@ -826,13 +826,20 @@ export async function doctorCommand(options = {}) {
|
|
|
826
826
|
hasErrors = true;
|
|
827
827
|
}
|
|
828
828
|
|
|
829
|
-
// Check templates
|
|
829
|
+
// Check templates — as of the R3 perf plan (Task 2), setup-infra/update no
|
|
830
|
+
// longer pre-populate .morph/framework/templates/: template resolution is
|
|
831
|
+
// layered (template-registry.js / architecture-scanner.js resolveTemplatePath)
|
|
832
|
+
// and falls back to the npm package's own framework/templates/ when the
|
|
833
|
+
// project-local copy is absent, which is now the expected default rather
|
|
834
|
+
// than an error. The project-local copy remains a supported override — if
|
|
835
|
+
// present, report it as such. The "key templates" check further below
|
|
836
|
+
// verifies the package copy itself is intact, which is the check that
|
|
837
|
+
// actually matters now.
|
|
830
838
|
const templatesPath = join(morphPath, 'framework', 'templates');
|
|
831
839
|
if (await pathExists(templatesPath)) {
|
|
832
|
-
checks.push({ name: 'templates/', status: 'ok' });
|
|
840
|
+
checks.push({ name: 'templates/', status: 'ok', msg: 'local override present' });
|
|
833
841
|
} else {
|
|
834
|
-
checks.push({ name: 'templates/', status: '
|
|
835
|
-
hasErrors = true;
|
|
842
|
+
checks.push({ name: 'templates/', status: 'ok', msg: 'resolved from package (no local override)' });
|
|
836
843
|
}
|
|
837
844
|
|
|
838
845
|
// Check .claude folder
|
|
@@ -486,7 +486,7 @@ export async function initCommand(options) {
|
|
|
486
486
|
logger.info('Files installed:');
|
|
487
487
|
logger.dim(` ✓ CLAUDE.md`);
|
|
488
488
|
logger.dim(` ✓ .morph/config/ (config.json)`);
|
|
489
|
-
logger.dim(` ✓ .morph/framework/ (agents.json, standards/,
|
|
489
|
+
logger.dim(` ✓ .morph/framework/ (agents.json, standards/, hooks/)`);
|
|
490
490
|
logger.dim(` ✓ .morph/context/ (project context)`);
|
|
491
491
|
logger.dim(` ✓ .morph/features/ (feature outputs)`);
|
|
492
492
|
if (commandsCopied) {
|
|
@@ -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
|
+
}
|
|
@@ -24,7 +24,8 @@ import {
|
|
|
24
24
|
checkProjectOutdated,
|
|
25
25
|
saveProjectMorphVersion,
|
|
26
26
|
getUpdateInstructions,
|
|
27
|
-
detectInstallMethod
|
|
27
|
+
detectInstallMethod,
|
|
28
|
+
compareVersions
|
|
28
29
|
} from '../../utils/version-checker.js';
|
|
29
30
|
import { installClaudeHooks, installGlobalStatusline } from '../../utils/claude-settings-manager.js';
|
|
30
31
|
import { installSkills } from '../../utils/skills-installer.js';
|
|
@@ -59,7 +60,12 @@ async function backupUserConfig(morphPath) {
|
|
|
59
60
|
async function cleanFrameworkDirs(morphPath, targetPath) {
|
|
60
61
|
const claudeDir = join(targetPath, '.claude');
|
|
61
62
|
const dirsToClean = [
|
|
62
|
-
|
|
63
|
+
// NOTE: framework/templates is intentionally NOT in this list (R3 perf
|
|
64
|
+
// plan, Task 2). It's no longer copied/refreshed here — see
|
|
65
|
+
// reconcileLegacyTemplatesCopy() below, which decides per-run whether a
|
|
66
|
+
// pre-existing local copy is safe to remove or must be preserved as a
|
|
67
|
+
// user override. Blindly deleting it here (the old behavior) destroyed
|
|
68
|
+
// any customization on every single `update` run, override or not.
|
|
63
69
|
join(morphPath, 'framework', 'standards'),
|
|
64
70
|
join(morphPath, 'framework', 'hooks'),
|
|
65
71
|
join(morphPath, 'framework', 'schemas'),
|
|
@@ -83,6 +89,67 @@ async function cleanFrameworkDirs(morphPath, targetPath) {
|
|
|
83
89
|
}
|
|
84
90
|
}
|
|
85
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Last CLI version whose `update`/`setup-infra` still wrote
|
|
94
|
+
* `.morph/framework/templates/` unconditionally, before Task 2 of the R3
|
|
95
|
+
* perf plan removed that copy. Used as the decision boundary in
|
|
96
|
+
* `reconcileLegacyTemplatesCopy` below — see its doc comment for why a
|
|
97
|
+
* version check is used instead of diffing file content.
|
|
98
|
+
*/
|
|
99
|
+
const LAST_VERSION_THAT_COPIED_TEMPLATES = '8.17.0';
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Reconcile a pre-existing `.morph/framework/templates/` copy now that
|
|
103
|
+
* `update` no longer writes it (Task 2, R3 perf plan — resolution falls
|
|
104
|
+
* back to the npm package's own framework/templates/, see
|
|
105
|
+
* template-registry.js / architecture-scanner.js's layered resolver).
|
|
106
|
+
*
|
|
107
|
+
* Decided from the project's PRIOR recorded CLI version (before THIS
|
|
108
|
+
* update writes a new one), not by diffing content against the package.
|
|
109
|
+
* A content diff can't tell "stale copy from an older CLI version" apart
|
|
110
|
+
* from "deliberate user override" — and staleness is the dominant case for
|
|
111
|
+
* anyone who actually runs `update`, since template content does change
|
|
112
|
+
* between releases. The version boundary is unambiguous instead:
|
|
113
|
+
*
|
|
114
|
+
* - No local copy → nothing to do.
|
|
115
|
+
* - Prior version is unrecorded, OR <= LAST_VERSION_THAT_COPIED_TEMPLATES
|
|
116
|
+
* → the copy could ONLY have been put there by the OLD installer:
|
|
117
|
+
* `.morph/framework/**` is Edit-denied to the agent (MORPH_DENY in
|
|
118
|
+
* hooks-installer.js — "installer-managed, immutable at runtime"),
|
|
119
|
+
* the directory is gitignored, and every `update` before this fix
|
|
120
|
+
* unconditionally wiped + recopied it. There is no path by which a
|
|
121
|
+
* project on an old version could have a genuine override sitting
|
|
122
|
+
* here. Safe to delete unconditionally — it is dead weight, not data.
|
|
123
|
+
* - Prior version > LAST_VERSION_THAT_COPIED_TEMPLATES → `update` and
|
|
124
|
+
* `setup-infra` have never written this directory for this project.
|
|
125
|
+
* Anything found here was placed deliberately (by the user or other
|
|
126
|
+
* tooling) — this is the forward-looking override use case the
|
|
127
|
+
* layered resolver exists for. Preserve untouched, unconditionally,
|
|
128
|
+
* with no diffing.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} templatesDest - .morph/framework/templates (may not exist)
|
|
131
|
+
* @param {string|null} priorProjectVersion - project's recorded morph-spec
|
|
132
|
+
* version BEFORE this update run (checkProjectOutdated().current)
|
|
133
|
+
* @returns {'absent'|'removed-legacy'|'preserved-override'}
|
|
134
|
+
*/
|
|
135
|
+
export function reconcileLegacyTemplatesCopy(templatesDest, priorProjectVersion) {
|
|
136
|
+
if (!existsSync(templatesDest)) return 'absent';
|
|
137
|
+
|
|
138
|
+
const isLegacy = !priorProjectVersion
|
|
139
|
+
|| compareVersions(priorProjectVersion, LAST_VERSION_THAT_COPIED_TEMPLATES) <= 0;
|
|
140
|
+
|
|
141
|
+
if (!isLegacy) return 'preserved-override';
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
fs.removeSync(templatesDest);
|
|
145
|
+
return 'removed-legacy';
|
|
146
|
+
} catch {
|
|
147
|
+
// Couldn't remove — don't risk a half-broken state; leave it and let
|
|
148
|
+
// the layered resolver's override-first check pick it up as before.
|
|
149
|
+
return 'preserved-override';
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
86
153
|
/**
|
|
87
154
|
* Restore user's config.json after reinstallation
|
|
88
155
|
* @param {string} morphPath - Path to .morph directory
|
|
@@ -240,12 +307,14 @@ export async function updateCommand(options) {
|
|
|
240
307
|
const updateTemplates = !options.standards || options.templates;
|
|
241
308
|
const updateStandards = !options.templates || options.standards;
|
|
242
309
|
|
|
243
|
-
//
|
|
310
|
+
// Templates are no longer copied to .morph/framework/templates/ (Task 2,
|
|
311
|
+
// R3 perf plan) — see reconcileLegacyTemplatesCopy()'s doc comment for
|
|
312
|
+
// why. Only reconcile a leftover copy from a pre-R3 install.
|
|
313
|
+
let templatesReconcile = 'absent';
|
|
244
314
|
if (updateTemplates) {
|
|
245
|
-
updateSpinner.text = '
|
|
246
|
-
const templatesSrc = join(contentDir, 'framework', 'templates');
|
|
315
|
+
updateSpinner.text = 'Checking for a legacy templates copy...';
|
|
247
316
|
const templatesDest = join(morphPath, 'framework', 'templates');
|
|
248
|
-
|
|
317
|
+
templatesReconcile = reconcileLegacyTemplatesCopy(templatesDest, projectCheck.current);
|
|
249
318
|
}
|
|
250
319
|
|
|
251
320
|
// Update standards
|
|
@@ -289,6 +358,13 @@ export async function updateCommand(options) {
|
|
|
289
358
|
await copyDirectory(src, join(morphPath, 'framework', dir));
|
|
290
359
|
}
|
|
291
360
|
}
|
|
361
|
+
// CLI.md vive em framework/docs/ na fonte mas instala achatado em
|
|
362
|
+
// .morph/framework/CLI.md (o CLAUDE.md residente aponta para lá) —
|
|
363
|
+
// por isso não entra no loop de nomes acima.
|
|
364
|
+
const cliMdSrc = join(canonicalFrameworkDir, 'docs', 'CLI.md');
|
|
365
|
+
if (await pathExists(cliMdSrc)) {
|
|
366
|
+
await copyFile(cliMdSrc, join(morphPath, 'framework', 'CLI.md'));
|
|
367
|
+
}
|
|
292
368
|
|
|
293
369
|
// Regenerate derived files (phase-utils.js, skill output tables) from output-schema.js
|
|
294
370
|
// Only runs when the generator script is present (framework dev installs via npm link)
|
|
@@ -415,7 +491,15 @@ export async function updateCommand(options) {
|
|
|
415
491
|
|
|
416
492
|
logger.blank();
|
|
417
493
|
logger.info('Updated files:');
|
|
418
|
-
if (updateTemplates)
|
|
494
|
+
if (updateTemplates) {
|
|
495
|
+
if (templatesReconcile === 'removed-legacy') {
|
|
496
|
+
logger.dim(' ✓ .morph/framework/templates/ (legacy installer copy removed — resolved from package)');
|
|
497
|
+
} else if (templatesReconcile === 'preserved-override') {
|
|
498
|
+
logger.dim(' ⚠ .morph/framework/templates/ (local override present — preserved untouched, not updated)');
|
|
499
|
+
} else {
|
|
500
|
+
logger.dim(' · .morph/framework/templates/ (not present — resolved from package, no local override)');
|
|
501
|
+
}
|
|
502
|
+
}
|
|
419
503
|
if (updateStandards) logger.dim(' ✓ .morph/framework/standards/');
|
|
420
504
|
logger.dim(' ✓ .morph/framework/hooks/');
|
|
421
505
|
logger.dim(' ✓ .morph/framework/agents.json');
|
package/src/lib/stack-filter.js
CHANGED
|
@@ -16,10 +16,15 @@ export function parseStacks(content) {
|
|
|
16
16
|
|
|
17
17
|
const fm = fmMatch[1];
|
|
18
18
|
|
|
19
|
-
// Try inline format: stacks: [dotnet, blazor]
|
|
19
|
+
// Try inline format: stacks: [dotnet, blazor] — quotes stripped like the
|
|
20
|
+
// list branch below; `["umbraco"]` sem isso parseava como a string literal
|
|
21
|
+
// `"umbraco"` (com aspas) e nunca casava tag nenhuma.
|
|
20
22
|
const inlineMatch = fm.match(/^stacks:\s*\[([^\]]*)\]/m);
|
|
21
23
|
if (inlineMatch) {
|
|
22
|
-
const items = inlineMatch[1]
|
|
24
|
+
const items = inlineMatch[1]
|
|
25
|
+
.split(',')
|
|
26
|
+
.map(s => s.replace(/["']/g, '').trim())
|
|
27
|
+
.filter(Boolean);
|
|
23
28
|
return items.length > 0 ? items : ['*'];
|
|
24
29
|
}
|
|
25
30
|
|
|
@@ -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 };
|
|
@@ -264,14 +264,23 @@ export async function setupInfra(targetPath, { _exec = execSync, reinstall = fal
|
|
|
264
264
|
await writeFile(contextReadmePath, readmeContent);
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
-
// --- 5.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
267
|
+
// --- 5. framework/templates is intentionally NOT copied to
|
|
268
|
+
// .morph/framework/templates anymore (R3 perf plan, Task 2). Template
|
|
269
|
+
// resolution is layered (template-registry.js, architecture-scanner.js
|
|
270
|
+
// resolveTemplatePath): a project-local .morph/framework/templates/{path}
|
|
271
|
+
// is still checked FIRST as an explicit override, then falls back to the
|
|
272
|
+
// npm package's own framework/templates/ — which is what every template
|
|
273
|
+
// actually rendered from until now anyway, since the installed copy was
|
|
274
|
+
// never read as anything but that override. Pre-populating it was a
|
|
275
|
+
// byte-for-byte redundant 348KB copy on every install/update.
|
|
276
|
+
log('Step 5: Skipping framework templates copy (resolved from package; local override still supported)...');
|
|
277
|
+
// Cópia legada de instalação pré-R3: se existir, ela SOMBREIA os templates
|
|
278
|
+
// do pacote (override-first). O `morph-spec update` reconcilia (remove cópia
|
|
279
|
+
// stale por fronteira de versão); aqui só avisamos — init não conhece a
|
|
280
|
+
// versão anterior do projeto para decidir com segurança.
|
|
281
|
+
const legacyTemplatesDir = join(frameworkDestDir, 'templates');
|
|
282
|
+
if (await pathExists(legacyTemplatesDir)) {
|
|
283
|
+
log(' ⚠ .morph/framework/templates/ existe (instalação antiga) e tem precedência sobre os templates do pacote — rode `morph-spec update` para reconciliar, ou apague se não for customização sua.');
|
|
275
284
|
}
|
|
276
285
|
|
|
277
286
|
// --- 6. Copy framework/standards → .morph/framework/standards ---
|
|
@@ -324,6 +333,16 @@ export async function setupInfra(targetPath, { _exec = execSync, reinstall = fal
|
|
|
324
333
|
await copyFile(morphMdSrc, morphMdDest);
|
|
325
334
|
}
|
|
326
335
|
|
|
336
|
+
// --- 8b2. Copy framework/docs/CLI.md → .morph/framework/CLI.md ---
|
|
337
|
+
// Referência completa dos comandos (flags, semântica, exemplos) — o
|
|
338
|
+
// CLAUDE.md residente carrega só a tabela compacta e aponta para cá.
|
|
339
|
+
log('Step 8b2: Copying CLI.md (command reference)...');
|
|
340
|
+
const cliMdSrc = join(FRAMEWORK_DIR, 'docs', 'CLI.md');
|
|
341
|
+
const cliMdDest = join(frameworkDestDir, 'CLI.md');
|
|
342
|
+
if (await pathExists(cliMdSrc)) {
|
|
343
|
+
await copyFile(cliMdSrc, cliMdDest);
|
|
344
|
+
}
|
|
345
|
+
|
|
327
346
|
// --- 8c. Copy framework/phases.json → .morph/framework/phases.json ---
|
|
328
347
|
log('Step 8c: Copying phases.json...');
|
|
329
348
|
const phasesSrc = join(FRAMEWORK_DIR, 'phases.json');
|