@polymorphism-tech/morph-spec 8.18.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 +5 -4
- package/framework/MORPH.md +3 -3
- package/framework/commands/morph-hotfix.md +1 -1
- package/framework/docs/CLI.md +2 -1
- 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 +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +10 -14
- package/framework/skills/core/morph-brainstorming/SKILL.md +10 -21
- 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 +12 -20
- 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 +9 -13
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/project/status.js +2 -2
- 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/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
|
|
@@ -108,29 +108,25 @@ Antes de despachar Explore, verifique se o projeto é greenfield:
|
|
|
108
108
|
| Runtime | .NET X | .NET 10 | ✓ / ✗ |
|
|
109
109
|
...
|
|
110
110
|
|
|
111
|
-
## Desvios
|
|
111
|
+
## Desvios (ordenados por severidade)
|
|
112
112
|
### Bloqueantes
|
|
113
|
-
- {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}
|
|
114
114
|
|
|
115
115
|
### Não bloqueantes
|
|
116
|
-
- {desvio}: {onde foi encontrado} — migração: {como resolver}
|
|
116
|
+
- {desvio}: {onde foi encontrado} — migração: {como resolver} — esforço: {estimativa}
|
|
117
117
|
|
|
118
118
|
## Recomendação
|
|
119
119
|
{Em compliance — prosseguir para morph-brainstorming}
|
|
120
|
-
{ou: Migração necessária antes de prosseguir —
|
|
120
|
+
{ou: Migração necessária antes de prosseguir — ver Desvios acima}
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
4. Se houver desvios bloqueantes ou altos,
|
|
124
|
-
- Lista de desvios ordenados por severidade
|
|
125
|
-
- Para cada desvio: o que migrar, como migrar, estimativa de esforço, ordem sugerida
|
|
126
|
-
- 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.
|
|
127
124
|
|
|
128
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.
|
|
129
126
|
|
|
130
127
|
## Output esperado
|
|
131
|
-
- `.morph/features/{feature}/0-proposal/stack-scan.md` — resultado da varredura
|
|
132
|
-
-
|
|
133
|
-
- 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)
|
|
134
130
|
|
|
135
131
|
## Referências
|
|
136
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
|
@@ -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'],
|
|
@@ -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' },
|
|
@@ -71,3 +71,68 @@ export function hasOriginRemote(cwd = process.cwd()) {
|
|
|
71
71
|
return false;
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve HEAD's full commit SHA. Fail-open: null when cwd isn't a git repo,
|
|
77
|
+
* or the repo has no commits yet (unborn HEAD) — never throws.
|
|
78
|
+
*
|
|
79
|
+
* Shared by `morph-spec verify` (the `verification` stamp) and
|
|
80
|
+
* `morph-spec gate-check` (signals + staleness-vs-code checks) — both need
|
|
81
|
+
* the exact commit a result covers, mirroring the same fail-open pattern
|
|
82
|
+
* `reviewStaleness()` in approve.js uses for the same `git rev-parse HEAD` call.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} [cwd]
|
|
85
|
+
* @returns {string|null}
|
|
86
|
+
*/
|
|
87
|
+
export function getHeadSha(cwd = process.cwd()) {
|
|
88
|
+
try {
|
|
89
|
+
return execFileSync('git', ['rev-parse', 'HEAD'], { cwd, stdio: 'pipe' }).toString().trim();
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse `git diff --shortstat` output ("3 files changed, 45 insertions(+), 2
|
|
97
|
+
* deletions(-)") into counts. Any line missing (e.g. only insertions, no
|
|
98
|
+
* deletions) contributes 0 for that field. Empty input (no diff) is all-zero,
|
|
99
|
+
* not an error.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} text
|
|
102
|
+
* @returns {{ filesChanged: number, insertions: number, deletions: number }}
|
|
103
|
+
*/
|
|
104
|
+
function parseShortstat(text) {
|
|
105
|
+
const files = /(\d+) files? changed/.exec(text || '');
|
|
106
|
+
const ins = /(\d+) insertions?\(\+\)/.exec(text || '');
|
|
107
|
+
const del = /(\d+) deletions?\(-\)/.exec(text || '');
|
|
108
|
+
return {
|
|
109
|
+
filesChanged: files ? Number(files[1]) : 0,
|
|
110
|
+
insertions: ins ? Number(ins[1]) : 0,
|
|
111
|
+
deletions: del ? Number(del[1]) : 0,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `git diff --shortstat <baseRef>` against the CURRENT WORKING TREE (tracked
|
|
117
|
+
* edits — staged, unstaged, and everything committed since baseRef —
|
|
118
|
+
* deliberately not `baseRef...HEAD`, so a gate-check mid-implement still sees
|
|
119
|
+
* uncommitted work). Same as plain `git diff`, this does NOT count brand-new
|
|
120
|
+
* UNTRACKED files (never `git add`-ed) — a real, known gap if a gate check
|
|
121
|
+
* runs before `git add`; not fixed here since covering it needs `--no-index`
|
|
122
|
+
* or `git add -N` bookkeeping this shared helper doesn't own. Fail-open: null
|
|
123
|
+
* when `baseRef` is falsy, cwd isn't a git repo, or the diff itself fails
|
|
124
|
+
* (e.g. baseRef doesn't resolve).
|
|
125
|
+
*
|
|
126
|
+
* @param {string|null} baseRef - typically `detectDefaultBranch(cwd)`
|
|
127
|
+
* @param {string} [cwd]
|
|
128
|
+
* @returns {{ filesChanged: number, insertions: number, deletions: number }|null}
|
|
129
|
+
*/
|
|
130
|
+
export function getDiffStat(baseRef, cwd = process.cwd()) {
|
|
131
|
+
if (!baseRef) return null;
|
|
132
|
+
try {
|
|
133
|
+
const out = execFileSync('git', ['diff', '--shortstat', baseRef], { cwd, stdio: 'pipe' }).toString().trim();
|
|
134
|
+
return parseShortstat(out);
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/lib/risk-detector.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* Signals checked (in order):
|
|
9
9
|
* - designSystemDivergence: boolean — conflicting design sources detected
|
|
10
10
|
* - standardsViolations: string[] — violations of mandate.md proibições
|
|
11
|
+
* - verificationStatus: string — último `morph-spec verify` ('fail' pausa)
|
|
11
12
|
* - evalScore: number — morph-eval score (pause if < 9)
|
|
12
13
|
* - taskCount: number — total tasks (pause if > 20)
|
|
13
14
|
*/
|
|
@@ -52,6 +53,17 @@ export function shouldPauseGate(config, signals) {
|
|
|
52
53
|
};
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
// Verify vermelho no momento do gate = a barreira determinística (build/
|
|
57
|
+
// testes/validadores) está falhando — código antes do juiz vale também
|
|
58
|
+
// para a decisão de pausa. Só 'fail' pausa: ausência de stamp não é sinal
|
|
59
|
+
// (features antigas não têm o campo).
|
|
60
|
+
if (signals.verificationStatus === 'fail') {
|
|
61
|
+
return {
|
|
62
|
+
pause: true,
|
|
63
|
+
reason: 'último morph-spec verify está VERMELHO — rode verify e corrija antes de aprovar o gate',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
55
67
|
if (typeof signals.evalScore === 'number' && signals.evalScore < MIN_EVAL_SCORE) {
|
|
56
68
|
return {
|
|
57
69
|
pause: true,
|
|
@@ -80,6 +92,7 @@ export function getRiskSignals(signals) {
|
|
|
80
92
|
if (Array.isArray(signals.standardsViolations) && signals.standardsViolations.length > 0) {
|
|
81
93
|
out.push(`standards violations: ${signals.standardsViolations.join(', ')}`);
|
|
82
94
|
}
|
|
95
|
+
if (signals.verificationStatus === 'fail') out.push('verify vermelho');
|
|
83
96
|
if (typeof signals.evalScore === 'number' && signals.evalScore < MIN_EVAL_SCORE) {
|
|
84
97
|
out.push(`eval score ${signals.evalScore}/10`);
|
|
85
98
|
}
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Validator Prompt Builder
|
|
3
|
-
*
|
|
4
|
-
* Generates read-only validation task prompts for Tier-4 validator agents
|
|
5
|
-
* from agents.json hook_behavior configuration.
|
|
6
|
-
*
|
|
7
|
-
* Used by dispatch-agents.js in --mode validate.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { readFileSync, existsSync } from 'fs';
|
|
11
|
-
import { join, dirname } from 'path';
|
|
12
|
-
import { fileURLToPath } from 'url';
|
|
13
|
-
|
|
14
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
-
|
|
16
|
-
const AGENTS_JSON_PATH = join(__dirname, '../../../framework/agents.json');
|
|
17
|
-
|
|
18
|
-
let _agentsCache = null;
|
|
19
|
-
|
|
20
|
-
/** Load and cache agents.json */
|
|
21
|
-
function loadAgents() {
|
|
22
|
-
if (_agentsCache) return _agentsCache;
|
|
23
|
-
if (!existsSync(AGENTS_JSON_PATH)) return { agents: {} };
|
|
24
|
-
try {
|
|
25
|
-
_agentsCache = JSON.parse(readFileSync(AGENTS_JSON_PATH, 'utf8'));
|
|
26
|
-
return _agentsCache;
|
|
27
|
-
} catch {
|
|
28
|
-
return { agents: {} };
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Build a read-only validation task prompt for Tier-4 validator agents.
|
|
34
|
-
* Uses hook_behavior.validates[] from agents.json.
|
|
35
|
-
*
|
|
36
|
-
* @param {string} agentId - Agent ID (must be tier 4 with hook_behavior)
|
|
37
|
-
* @param {string} [taskId] - Optional task ID being validated
|
|
38
|
-
* @returns {string} Validator task prompt or ''
|
|
39
|
-
*/
|
|
40
|
-
export function buildValidatorPrompt(agentId, taskId) {
|
|
41
|
-
const agents = loadAgents();
|
|
42
|
-
const agentData = agents.agents?.[agentId];
|
|
43
|
-
if (!agentData?.hook_behavior) return '';
|
|
44
|
-
|
|
45
|
-
const validates = agentData.hook_behavior.validates || [];
|
|
46
|
-
const severity = agentData.hook_behavior.severity || 'error';
|
|
47
|
-
const blocksOnFail = agentData.hook_behavior.blocks_on_fail ?? true;
|
|
48
|
-
const taskSuffix = taskId ? ` for task ${taskId}` : '';
|
|
49
|
-
|
|
50
|
-
const lines = [
|
|
51
|
-
`You are ${agentData.title || agentId} (Tier 4 Validator). Mode: READ-ONLY validation${taskSuffix}.`,
|
|
52
|
-
`DO NOT edit any files. Review the current implementation for the following violations:`,
|
|
53
|
-
``,
|
|
54
|
-
`Checks (severity: ${severity}):`,
|
|
55
|
-
...validates.map(v => ` - ${v}`),
|
|
56
|
-
``,
|
|
57
|
-
`Output a single JSON line: { "passed": boolean, "issues": [{ "file": "path/to/file", "line": 0, "message": "description", "rule": "rule-name" }] }`,
|
|
58
|
-
blocksOnFail
|
|
59
|
-
? `BLOCKING: if any issues found, implementation must be fixed before task can complete.`
|
|
60
|
-
: `NON-BLOCKING: issues are warnings only — task completion is not blocked.`,
|
|
61
|
-
];
|
|
62
|
-
|
|
63
|
-
return lines.join('\n');
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Invalidate module-level caches.
|
|
68
|
-
* Used in tests to ensure a fresh load of agents.json.
|
|
69
|
-
*/
|
|
70
|
-
export function clearCache() {
|
|
71
|
-
_agentsCache = null;
|
|
72
|
-
}
|