@polymorphism-tech/morph-spec 8.17.0 → 8.19.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 (42) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +9 -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 +46 -43
  7. package/framework/MORPH.md +120 -168
  8. package/framework/commands/morph-hotfix.md +1 -1
  9. package/framework/docs/CLI.md +41 -0
  10. package/framework/hooks/shared/phase-utils.js +0 -2
  11. package/framework/phases.json +1 -1
  12. package/framework/rules/morph-workflow.md +1 -1
  13. package/framework/schemas/feature.schema.json +12 -0
  14. package/framework/skills/README.md +1 -1
  15. package/framework/skills/core/create-umbraco-lp/SKILL.md +3 -1
  16. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +3 -1
  17. package/framework/skills/core/morph-apply/SKILL.md +12 -20
  18. package/framework/skills/core/morph-brainstorming/SKILL.md +11 -32
  19. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  20. package/framework/skills/core/morph-hotfix/SKILL.md +4 -4
  21. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  22. package/framework/skills/core/morph-plan/SKILL.md +13 -27
  23. package/framework/skills/core/morph-proposal/SKILL.md +3 -15
  24. package/framework/skills/core/morph-review/SKILL.md +15 -8
  25. package/framework/skills/core/morph-stack-scan/SKILL.md +10 -26
  26. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  27. package/package.json +1 -1
  28. package/src/commands/project/doctor.js +11 -4
  29. package/src/commands/project/init.js +1 -1
  30. package/src/commands/project/status.js +2 -2
  31. package/src/commands/project/update.js +91 -7
  32. package/src/commands/state/gate-check.js +223 -0
  33. package/src/commands/state/gate-decision.js +46 -10
  34. package/src/commands/validation/verify.js +18 -1
  35. package/src/core/paths/output-schema.js +0 -1
  36. package/src/lib/agents/output-collector.js +0 -1
  37. package/src/lib/git/default-branch.js +65 -0
  38. package/src/lib/risk-detector.js +13 -0
  39. package/src/lib/stack-filter.js +7 -2
  40. package/src/scripts/setup-infra.js +27 -8
  41. package/src/utils/skills-installer.js +28 -15
  42. package/src/lib/standards/digest-builder.js +0 -72
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: morph-stack-scan
3
- description: Varre o codebase existente, detecta stack atual, compara com o stack padrão e gera migration-plan.md se houver desvios
3
+ description: Varre o codebase existente, detecta stack atual, compara com o stack padrão e registra desvios em stack-scan.md
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.17.0"
7
+ cliVersion: "8.19.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
 
@@ -120,29 +108,25 @@ Antes de despachar Explore, verifique se o projeto é greenfield:
120
108
  | Runtime | .NET X | .NET 10 | ✓ / ✗ |
121
109
  ...
122
110
 
123
- ## Desvios
111
+ ## Desvios (ordenados por severidade)
124
112
  ### Bloqueantes
125
- - {desvio}: {onde foi encontrado} — impacto: {o que quebra se não migrar}
113
+ - {desvio}: {onde foi encontrado} — impacto: {o que quebra se não migrar} — esforço: {estimativa}
126
114
 
127
115
  ### Não bloqueantes
128
- - {desvio}: {onde foi encontrado} — migração: {como resolver}
116
+ - {desvio}: {onde foi encontrado} — migração: {como resolver} — esforço: {estimativa}
129
117
 
130
118
  ## Recomendação
131
119
  {Em compliance — prosseguir para morph-brainstorming}
132
- {ou: Migração necessária antes de prosseguir — veja migration-plan.md}
120
+ {ou: Migração necessária antes de prosseguir — ver Desvios acima}
133
121
  ```
134
122
 
135
- 4. Se houver desvios bloqueantes ou altos, produza `migration-plan.md` em `.morph/features/{feature}/0-proposal/` com:
136
- - Lista de desvios ordenados por severidade
137
- - Para cada desvio: o que migrar, como migrar, estimativa de esforço, ordem sugerida
138
- - Pergunta única via `AskUserQuestion`: "Esta migração deve ser incluída na feature atual ou tratada numa feature separada?"
123
+ 4. Se houver desvios bloqueantes ou altos, pergunte via `AskUserQuestion`: "Esta migração deve ser incluída na feature atual ou tratada numa feature separada?" A resposta é decisão humana — registre-a na seção "Compliance de stack" do `proposal.md` (produzido pela `morph-brainstorming`), não num artefato novo: a lista de desvios com impacto/esforço já vive no `stack-scan.md` acima.
139
124
 
140
125
  5. Se não houver desvios (ou apenas baixos): informe "codebase em compliance com o stack padrão" e prossiga para morph-brainstorming ou morph-plan conforme o contexto.
141
126
 
142
127
  ## Output esperado
143
- - `.morph/features/{feature}/0-proposal/stack-scan.md` — resultado da varredura
144
- - `.morph/features/{feature}/0-proposal/migration-plan.md` — apenas se houver desvios altos/bloqueantes
145
- - Decisão documentada: incluir migração nesta feature ou separar
128
+ - `.morph/features/{feature}/0-proposal/stack-scan.md` — resultado da varredura, com desvios ordenados por severidade e esforço estimado
129
+ - Decisão documentada: incluir migração nesta feature ou separar (registrada no proposal.md pela morph-brainstorming)
146
130
 
147
131
  ## Referências
148
132
  - `framework/MORPH.md` — seção 4 (Standards), seção 2 (Fases)
@@ -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.17.0"
7
+ cliVersion: "8.19.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.17.0",
3
+ "version": "8.19.0",
4
4
  "description": "MORPH-SPEC: NLH (Natural Language Harness) for spec-driven development with Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -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) {
@@ -62,7 +62,7 @@ function getPhaseStatus(phaseKey, currentPhase, feature) {
62
62
  */
63
63
  function checkPhaseOutputs(phaseKey, feature) {
64
64
  const phaseOutputMap = {
65
- proposal: ['proposal', 'stackScan', 'migrationPlan'],
65
+ proposal: ['proposal', 'stackScan'],
66
66
  uiux: ['uiDesignSystem', 'uiMockups', 'uiComponents', 'uiFlows'],
67
67
  plan: ['spec', 'mandate', 'tasks', 'decisions'],
68
68
  implement: ['recap'],
@@ -100,7 +100,7 @@ function formatPhase(phaseKey, currentPhase, feature, options = {}) {
100
100
  // Add output files if verbose
101
101
  if (options.verbose && status !== 'pending' && status !== 'skipped') {
102
102
  const phaseOutputMap = {
103
- proposal: ['proposal', 'stackScan', 'migrationPlan'],
103
+ proposal: ['proposal', 'stackScan'],
104
104
  uiux: ['uiDesignSystem', 'uiMockups', 'uiComponents', 'uiFlows'],
105
105
  plan: ['spec', 'mandate', 'tasks', 'decisions'],
106
106
  implement: ['recap'],
@@ -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');
@@ -0,0 +1,223 @@
1
+ import chalk from 'chalk';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { getFeature } from '../../core/state/state-manager.js';
4
+ import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
5
+ import { shouldPauseGate, getRiskSignals } from '../../lib/risk-detector.js';
6
+ import { persistGateDecision, VALID_GATES, readTrustFromConfig } from './gate-decision.js';
7
+ import { detectDefaultBranch, getHeadSha, getDiffStat } from '../../lib/git/default-branch.js';
8
+
9
+ /**
10
+ * `--signals` on `gate-check` is NOT a general override like `gate-decision`'s
11
+ * — everything else here is read straight off disk (workType, taskCount, sha,
12
+ * diffstat, verification), and letting a caller override those would let a
13
+ * careless/hostile `--signals` silently defeat the policy this command exists
14
+ * to make deterministic (e.g. overriding `workType` away from "hotfix" would
15
+ * suppress risk-detector's forced Gate-3 pause). Only the signals that are
16
+ * genuinely NOT derivable from disk today are accepted here:
17
+ * - standardsViolations / designSystemDivergence: still an LLM's
18
+ * natural-language read (see the R4 report — no deterministic detector
19
+ * exists in JS yet).
20
+ * - evalScore: escape hatch for the TRUE Gate-3 composite score. Since R4
21
+ * the evaluator-report carries a machine-readable `**Composite-Score:**`
22
+ * token that this command parses by itself (see evaluatorCompositeScore),
23
+ * so the override is only needed for legacy reports without the token —
24
+ * there the taskScores-average proxy is the fallback.
25
+ */
26
+ const OVERRIDABLE_SIGNAL_KEYS = ['standardsViolations', 'designSystemDivergence', 'evalScore'];
27
+
28
+ /** Read tasks.json's task list for a feature; [] if missing/corrupt. Mirrors verify.js's readTasks(). */
29
+ function readTasks(featureName) {
30
+ try {
31
+ const tasksPath = getAbsoluteOutputPath(process.cwd(), featureName, 'tasks');
32
+ if (!existsSync(tasksPath)) return [];
33
+ return JSON.parse(readFileSync(tasksPath, 'utf8')).tasks || [];
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Composite eval score PROXY: the average of `lastScore` across every task
41
+ * recorded in `feature.taskScores`, rounded to 1 decimal. `lastScore` (not
42
+ * `bestScore`) on purpose — a gate check reports current-state risk, and
43
+ * `bestScore` can be a stale high-water mark from an attempt the code no
44
+ * longer matches. "average" mirrors morph-apply's own Gate-3 risk-signal
45
+ * prose ("Score médio < 9 no review-report" — SKILL.md §5).
46
+ *
47
+ * This is explicitly NOT the true Gate-3 composite score (see
48
+ * OVERRIDABLE_SIGNAL_KEYS doc above) — undefined (key omitted) when no task
49
+ * has been scored yet, so shouldPauseGate's `typeof === 'number'` check
50
+ * naturally skips it instead of pausing on a false 0.
51
+ *
52
+ * @param {object} feature
53
+ * @returns {number|undefined}
54
+ */
55
+ function compositeEvalScore(feature) {
56
+ const scores = Object.values(feature.taskScores || {})
57
+ .map((t) => t.lastScore)
58
+ .filter((n) => typeof n === 'number');
59
+ if (scores.length === 0) return undefined;
60
+ const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
61
+ return Math.round(avg * 10) / 10;
62
+ }
63
+
64
+ /**
65
+ * Parse the machine-readable `**Composite-Score:** N.N` token out of
66
+ * evaluator-report.md — the mirror of approve.js's `**Reviewed-SHA:**`
67
+ * pattern. This is the TRUE Gate-3 composite (written by the independent
68
+ * evaluator, per morph-review's dispatch contract), preferred over the
69
+ * taskScores-average proxy whenever present. Returns undefined when the
70
+ * report or the token is absent (legacy report) — the proxy stays the
71
+ * fallback, `--signals evalScore` still overrides both.
72
+ */
73
+ function evaluatorCompositeScore(cwd, featureName) {
74
+ try {
75
+ const p = getAbsoluteOutputPath(cwd, featureName, 'evaluatorReport');
76
+ if (!existsSync(p)) return undefined;
77
+ const m = readFileSync(p, 'utf8').match(/\*\*Composite-Score:\*\*\s*([0-9]+(?:\.[0-9]+)?)/);
78
+ return m ? Number(m[1]) : undefined;
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Collect every signal `shouldPauseGate()` (risk-detector.js) is documented
86
+ * to consume, straight off disk — the deterministic call site risk-detector
87
+ * never had (it shipped fully tested with zero callers; see the R4 report).
88
+ *
89
+ * `verificationStatus` feeds a real pause rule (verify vermelho pausa —
90
+ * política adicionada em risk-detector.js no fecho do R4); `verification`
91
+ * completo, verificationStale, diffstat e sha seguem como contexto de
92
+ * auditoria persistido, sem regra própria.
93
+ *
94
+ * @param {string} featureName
95
+ * @param {object} feature
96
+ * @param {string} cwd
97
+ * @param {string} gateName
98
+ * @returns {object}
99
+ */
100
+ function collectSignals(featureName, feature, cwd, gateName) {
101
+ const tasks = readTasks(featureName);
102
+ const defaultBranch = detectDefaultBranch(cwd);
103
+ const sha = getHeadSha(cwd);
104
+ const verification = feature.verification || null;
105
+
106
+ const signals = {
107
+ workType: feature.workType || 'feature',
108
+ taskCount: tasks.length,
109
+ standardsViolations: [],
110
+ designSystemDivergence: false,
111
+ };
112
+
113
+ const evalScore = compositeEvalScore(feature);
114
+ if (evalScore !== undefined) {
115
+ signals.evalScore = evalScore;
116
+ signals.evalScoreSource = 'taskScores-proxy';
117
+ }
118
+
119
+ // No Gate 3, o score verdadeiro do avaliador independente (token
120
+ // Composite-Score no evaluator-report.md) tem precedência sobre o proxy.
121
+ if (gateName === 'review') {
122
+ const trueComposite = evaluatorCompositeScore(cwd, featureName);
123
+ if (trueComposite !== undefined) {
124
+ signals.evalScore = trueComposite;
125
+ signals.evalScoreSource = 'evaluator-report';
126
+ }
127
+ }
128
+
129
+ // verificationStatus alimenta a regra "verify vermelho pausa"; o resto do
130
+ // stamp é auditoria. verificationStale é fail-open na direção "not stale":
131
+ // stamp com sha null (checkout sem git) ou stamp ausente lê como NOT stale.
132
+ signals.verification = verification;
133
+ if (verification?.status) signals.verificationStatus = verification.status;
134
+ signals.verificationStale = Boolean(verification?.sha && sha && verification.sha !== sha);
135
+ signals.diffstat = getDiffStat(defaultBranch, cwd);
136
+ signals.sha = sha;
137
+
138
+ return signals;
139
+ }
140
+
141
+ /**
142
+ * `morph-spec gate-check <feature> <gate> [--signals <json>] [--trust <mode>]
143
+ * [--json]` — the deterministic, disk-derived counterpart to `gate-decision`:
144
+ * collects the signals `shouldPauseGate()` (risk-detector.js) already knows
145
+ * how to score, calls it, and persists the result through the SAME write path
146
+ * as `gate-decision` ({@link persistGateDecision}) so the two commands can
147
+ * never produce a differently-shaped `gateDecisions.{gate}` entry.
148
+ *
149
+ * Golden rule: this command does NOT change gate policy. It is a faithful
150
+ * caller of risk-detector.js's existing, already-tested rules — nothing here
151
+ * adds a new pause condition. Where the skills' natural-language risk tables
152
+ * (morph-brainstorming/morph-plan/morph-review/morph-apply) diverge from
153
+ * risk-detector.js, risk-detector.js wins (see the R4 report for the full list).
154
+ */
155
+ export async function gateCheckCommand(featureName, gateName, options = {}) {
156
+ try {
157
+ if (!VALID_GATES.includes(gateName)) {
158
+ console.error(chalk.red(`\n❌ Invalid gate: ${gateName}`));
159
+ console.error(chalk.gray(`Valid gates: ${VALID_GATES.join(', ')}\n`));
160
+ process.exit(1);
161
+ }
162
+
163
+ const feature = getFeature(featureName);
164
+ if (!feature) {
165
+ console.error(chalk.red(`\n❌ Feature not found: ${featureName}\n`));
166
+ process.exit(1);
167
+ }
168
+
169
+ let overrides = {};
170
+ if (options.signals !== undefined) {
171
+ let parsed;
172
+ try {
173
+ parsed = JSON.parse(options.signals);
174
+ } catch (err) {
175
+ console.error(chalk.red(`\n❌ Invalid --signals: not valid JSON (${err.message})\n`));
176
+ process.exit(1);
177
+ }
178
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
179
+ console.error(chalk.red('\n❌ Invalid --signals: expected a JSON object, e.g. \'{"evalScore":9.2}\'\n'));
180
+ process.exit(1);
181
+ }
182
+
183
+ const rejected = Object.keys(parsed).filter((k) => !OVERRIDABLE_SIGNAL_KEYS.includes(k));
184
+ if (rejected.length > 0) {
185
+ console.error(chalk.yellow(
186
+ `\n⚠️ --signals ignored for keys not derived from disk-facts you may safely override: ${rejected.join(', ')}\n` +
187
+ ` Only ${OVERRIDABLE_SIGNAL_KEYS.join(', ')} may be overridden here — everything else is read from feature.json/tasks.json/git.\n` +
188
+ ` Use \`morph-spec gate-decision\` directly for a fully manual decision.\n`
189
+ ));
190
+ }
191
+ for (const key of OVERRIDABLE_SIGNAL_KEYS) {
192
+ if (key in parsed) overrides[key] = parsed[key];
193
+ }
194
+ }
195
+
196
+ const cwd = process.cwd();
197
+ const signals = { ...collectSignals(featureName, feature, cwd, gateName), ...overrides };
198
+
199
+ const trust = options.trust || readTrustFromConfig();
200
+ const { pause, reason } = shouldPauseGate({ trust }, signals);
201
+
202
+ const entry = await persistGateDecision(featureName, gateName, { pause, reason, signals, trust });
203
+
204
+ if (options.json) {
205
+ console.log(JSON.stringify({ feature: featureName, gate: gateName, ...entry }, null, 2));
206
+ return;
207
+ }
208
+
209
+ const allSignals = getRiskSignals(signals);
210
+ console.log(entry.pause
211
+ ? chalk.yellow(`\n⏸️ Gate "${gateName}" — PAUSE (${featureName})`)
212
+ : chalk.green(`\n▶️ Gate "${gateName}" — auto-pass (${featureName})`));
213
+ console.log(chalk.gray(` reason: ${entry.reason} | trust: ${entry.trust} | decidedAt: ${entry.decidedAt}`));
214
+ if (allSignals.length > 0) {
215
+ console.log(chalk.gray(` risk signals present: ${allSignals.join('; ')}`));
216
+ }
217
+ console.log(chalk.gray(` signals: ${JSON.stringify(entry.signals)}`));
218
+ console.log('');
219
+ } catch (error) {
220
+ console.error(chalk.red(`\n❌ Error running gate check: ${error.message}\n`));
221
+ process.exit(1);
222
+ }
223
+ }
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { getFeature, updateFeature } from '../../core/state/state-manager.js';
5
5
 
6
- const VALID_GATES = ['proposal', 'uiux', 'plan', 'review'];
6
+ export const VALID_GATES = ['proposal', 'uiux', 'plan', 'review'];
7
7
 
8
8
  function parsePauseValue(raw) {
9
9
  const v = String(raw).toLowerCase();
@@ -13,8 +13,12 @@ function parsePauseValue(raw) {
13
13
  process.exit(1);
14
14
  }
15
15
 
16
- /** trust from the committed config (BOM-tolerant), defaulting to "auto". */
17
- function readTrustFromConfig() {
16
+ /**
17
+ * trust from the committed config (BOM-tolerant), defaulting to "auto".
18
+ * Shared by `gate-decision` (recorded as metadata) and `gate-check`
19
+ * (drives shouldPauseGate's decision directly).
20
+ */
21
+ export function readTrustFromConfig() {
18
22
  try {
19
23
  const path = join(process.cwd(), '.morph', 'config', 'config.json');
20
24
  if (!existsSync(path)) return 'auto';
@@ -25,6 +29,35 @@ function readTrustFromConfig() {
25
29
  }
26
30
  }
27
31
 
32
+ /**
33
+ * Write the canonical `gateDecisions.{gate}` entry to a feature's
34
+ * authoritative feature.json — the ONE write path both `gate-decision` (a
35
+ * caller-supplied pause/reason/signals, e.g. an LLM's natural-language risk
36
+ * read, or a manual override) and `gate-check` (a `shouldPauseGate()`-derived
37
+ * pause/reason/signals) feed into, so the two commands can never drift into
38
+ * two different persisted shapes for the same field. Does NOT validate
39
+ * `gateName` — callers that need a friendly CLI error check `VALID_GATES`
40
+ * themselves before calling this.
41
+ *
42
+ * @param {string} featureName
43
+ * @param {string} gateName
44
+ * @param {{ pause: boolean, reason?: string, signals?: object, trust?: string }} decision
45
+ * @returns {Promise<{ pause: boolean, reason: string, signals: object, trust: string, decidedAt: string }>}
46
+ * the exact entry written (decidedAt is server-side, generated here).
47
+ */
48
+ export async function persistGateDecision(featureName, gateName, { pause, reason, signals = {}, trust } = {}) {
49
+ const entry = {
50
+ pause,
51
+ reason: reason || (pause ? 'risco detectado' : 'nenhum sinal de risco'),
52
+ signals,
53
+ trust: trust || readTrustFromConfig(),
54
+ decidedAt: new Date().toISOString(),
55
+ };
56
+
57
+ await updateFeature(featureName, `gateDecisions.${gateName}`, entry);
58
+ return entry;
59
+ }
60
+
28
61
  /**
29
62
  * `morph-spec gate-decision <feature> <gate> --pause <bool> [--reason <txt>]
30
63
  * [--signals <json>] [--trust <mode>]` — record the trust decision for a gate
@@ -36,6 +69,12 @@ function readTrustFromConfig() {
36
69
  * state-sync hook rewriting the file between the agent's Read and Edit
37
70
  * ("file modified since read"). The canonical shape is documented in
38
71
  * framework/schemas/feature.schema.json and consumed by gate-guard.js.
72
+ *
73
+ * The manual escape hatch: `gate-check` (src/commands/state/gate-check.js) is
74
+ * the default, disk-derived path via `shouldPauseGate()`; this command stays
75
+ * for a caller that already knows the decision (an LLM's natural-language
76
+ * risk read a script can't see, or a deliberate override) — both write
77
+ * through {@link persistGateDecision}, so the persisted shape never diverges.
39
78
  */
40
79
  export async function gateDecisionCommand(featureName, gateName, options = {}) {
41
80
  try {
@@ -71,15 +110,12 @@ export async function gateDecisionCommand(featureName, gateName, options = {}) {
71
110
  }
72
111
  }
73
112
 
74
- const entry = {
113
+ const entry = await persistGateDecision(featureName, gateName, {
75
114
  pause,
76
- reason: options.reason || (pause ? 'risco detectado' : 'nenhum sinal de risco'),
115
+ reason: options.reason,
77
116
  signals,
78
- trust: options.trust || readTrustFromConfig(),
79
- decidedAt: new Date().toISOString(),
80
- };
81
-
82
- await updateFeature(featureName, `gateDecisions.${gateName}`, entry);
117
+ trust: options.trust,
118
+ });
83
119
 
84
120
  if (options.json) {
85
121
  console.log(JSON.stringify({ feature: featureName, gate: gateName, ...entry }, null, 2));
@@ -5,6 +5,7 @@ import { getFeature, updateFeature } from '../../core/state/state-manager.js';
5
5
  import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
6
6
  import { runVerify } from '../../lib/validators/verify-runner.js';
7
7
  import { resolvePrimaryRoot } from '../project/worktree.js';
8
+ import { getHeadSha } from '../../lib/git/default-branch.js';
8
9
 
9
10
  /** ✓ pass · ✗ fail · – skip */
10
11
  function statusIcon(status) {
@@ -160,6 +161,7 @@ export async function verifyCommand(featureName, taskId, options = {}) {
160
161
  let compact = null;
161
162
  if (persist) {
162
163
  const prevEntry = taskId ? feature.taskVerifications?.[taskId] : feature.verifications?.feature;
164
+ const now = new Date().toISOString();
163
165
  compact = {
164
166
  status: result.status,
165
167
  nodes: {
@@ -169,9 +171,24 @@ export async function verifyCommand(featureName, taskId, options = {}) {
169
171
  },
170
172
  score: result.nodes.validators.score ?? null,
171
173
  attempts: (prevEntry?.attempts ?? 0) + 1,
172
- updatedAt: new Date().toISOString(),
174
+ updatedAt: now,
173
175
  };
174
176
  await updateFeature(featureName, scopeKey, compact);
177
+
178
+ // Singular, scope-agnostic "latest stamp" — distinct from the
179
+ // verifications/taskVerifications history above. `sha` is what lets a
180
+ // consumer (morph-spec gate-check) correlate this pass/fail with the
181
+ // EXACT commit it covers, the same way review-report.md's
182
+ // `**Reviewed-SHA:**` token lets approve.js detect a stale review. null
183
+ // in a non-git checkout (or before the first commit) — fail-open, never
184
+ // blocks persistence.
185
+ const stamp = {
186
+ status: result.status,
187
+ sha: getHeadSha(process.cwd()),
188
+ ts: now,
189
+ scope: taskId ? `task:${taskId}` : 'feature',
190
+ };
191
+ await updateFeature(featureName, 'verification', stamp);
175
192
  }
176
193
 
177
194
  if (options.json) {
@@ -37,7 +37,6 @@ export const OUTPUT_SCHEMA = {
37
37
  // 0-proposal/
38
38
  proposal: { filename: 'proposal.md', phaseDir: '0-proposal', phase: 'proposal', protected: false },
39
39
  stackScan: { filename: 'stack-scan.md', phaseDir: '0-proposal', phase: 'proposal', protected: false },
40
- migrationPlan: { filename: 'migration-plan.md', phaseDir: '0-proposal', phase: 'proposal', protected: false },
41
40
 
42
41
  // 1-design/ (optional)
43
42
  uiDesignSystem: { filename: 'design-system.md', phaseDir: '1-design', phase: 'uiux', protected: true, approvalGate: 'uiux' },
@@ -15,7 +15,6 @@ import { OUTPUT_SCHEMA, getAbsoluteOutputPath } from '../../core/paths/output-sc
15
15
  export const SOURCE_WEIGHTS = {
16
16
  proposal: 1.0,
17
17
  stackScan: 0.8,
18
- migrationPlan: 0.8,
19
18
  spec: 1.5,
20
19
  mandate: 1.3,
21
20
  tasks: 1.3,