@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.
- package/README.md +2 -2
- package/bin/morph-spec.js +9 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +46 -43
- package/framework/MORPH.md +120 -168
- package/framework/commands/morph-hotfix.md +1 -1
- package/framework/docs/CLI.md +41 -0
- package/framework/hooks/shared/phase-utils.js +0 -2
- package/framework/phases.json +1 -1
- package/framework/rules/morph-workflow.md +1 -1
- package/framework/schemas/feature.schema.json +12 -0
- package/framework/skills/README.md +1 -1
- 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 +12 -20
- package/framework/skills/core/morph-brainstorming/SKILL.md +11 -32
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +4 -4
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +13 -27
- package/framework/skills/core/morph-proposal/SKILL.md +3 -15
- package/framework/skills/core/morph-review/SKILL.md +15 -8
- package/framework/skills/core/morph-stack-scan/SKILL.md +10 -26
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/project/doctor.js +11 -4
- package/src/commands/project/init.js +1 -1
- package/src/commands/project/status.js +2 -2
- package/src/commands/project/update.js +91 -7
- package/src/commands/state/gate-check.js +223 -0
- package/src/commands/state/gate-decision.js +46 -10
- package/src/commands/validation/verify.js +18 -1
- package/src/core/paths/output-schema.js +0 -1
- package/src/lib/agents/output-collector.js +0 -1
- package/src/lib/git/default-branch.js +65 -0
- package/src/lib/risk-detector.js +13 -0
- package/src/lib/stack-filter.js +7 -2
- package/src/scripts/setup-infra.js +27 -8
- package/src/utils/skills-installer.js +28 -15
- 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
|
|
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.
|
|
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
|
-
|
|
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 —
|
|
120
|
+
{ou: Migração necessária antes de prosseguir — ver Desvios acima}
|
|
133
121
|
```
|
|
134
122
|
|
|
135
|
-
4. Se houver desvios bloqueantes ou altos,
|
|
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
|
-
-
|
|
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.
|
|
7
|
+
cliVersion: "8.19.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-standards
|
package/package.json
CHANGED
|
@@ -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) {
|
|
@@ -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'
|
|
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'
|
|
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
|
-
|
|
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');
|
|
@@ -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
|
-
/**
|
|
17
|
-
|
|
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
|
|
115
|
+
reason: options.reason,
|
|
77
116
|
signals,
|
|
78
|
-
trust: options.trust
|
|
79
|
-
|
|
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:
|
|
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' },
|