@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.
Files changed (33) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +11 -0
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +45 -38
  7. package/framework/MORPH.md +119 -167
  8. package/framework/docs/CLI.md +40 -0
  9. package/framework/evals/rubrics/condensed.md +49 -0
  10. package/framework/hooks/claude-code/core/protect-spec-files.js +9 -0
  11. package/framework/rules/morph-task-tracking.md +4 -2
  12. package/framework/schemas/tasks.schema.json +10 -0
  13. package/framework/skills/core/create-umbraco-lp/SKILL.md +3 -1
  14. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +3 -1
  15. package/framework/skills/core/morph-apply/SKILL.md +42 -34
  16. package/framework/skills/core/morph-brainstorming/SKILL.md +2 -12
  17. package/framework/skills/core/morph-eval/SKILL.md +16 -13
  18. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  19. package/framework/skills/core/morph-implement/SKILL.md +26 -25
  20. package/framework/skills/core/morph-plan/SKILL.md +15 -10
  21. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  22. package/framework/skills/core/morph-review/SKILL.md +1 -1
  23. package/framework/skills/core/morph-stack-scan/SKILL.md +2 -14
  24. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  25. package/package.json +2 -2
  26. package/src/commands/project/doctor.js +11 -4
  27. package/src/commands/project/init.js +1 -1
  28. package/src/commands/project/standards.js +220 -0
  29. package/src/commands/project/update.js +91 -7
  30. package/src/lib/stack-filter.js +7 -2
  31. package/src/lib/tasks/tasks-json-parser.js +26 -0
  32. package/src/scripts/setup-infra.js +27 -8
  33. 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.16.0"
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 — inclua sempre os defaults abaixo mais qualquer específico da feature:
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.16.0"
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.16.0"
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.16.0"
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
- | Camada | Esperado |
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.16.0"
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.16.0",
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: 'missing' });
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/, templates/, hooks/)`);
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
- join(morphPath, 'framework', 'templates'),
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
- // Update templates
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 = 'Updating templates...';
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
- await copyDirectory(templatesSrc, templatesDest);
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) logger.dim(' ✓ .morph/framework/templates/');
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');
@@ -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].split(',').map(s => s.trim()).filter(Boolean);
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. Copy framework/templates → .morph/framework/templates ---
268
- log('Step 5: Copying framework templates...');
269
- const frameworkTemplatesSrc = join(FRAMEWORK_DIR, 'templates');
270
- const templatesDest = join(frameworkDestDir, 'templates');
271
- if (await pathExists(frameworkTemplatesSrc)) {
272
- await copyDirectory(frameworkTemplatesSrc, templatesDest);
273
- } else {
274
- await ensureDir(templatesDest);
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');