@polymorphism-tech/morph-spec 8.18.0 → 8.20.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 +8 -15
- package/framework/MORPH.md +56 -58
- package/framework/commands/morph-hotfix.md +1 -1
- package/framework/docs/CLI.md +3 -2
- package/framework/hooks/shared/phase-utils.js +0 -2
- package/framework/phases.json +2 -2
- 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 +11 -22
- 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 +14 -20
- package/framework/skills/core/morph-proposal/SKILL.md +12 -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/framework/skills/core/morph-uiux/SKILL.md +135 -0
- 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
|
@@ -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
|
-
}
|