@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.
Files changed (37) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +9 -0
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +8 -15
  7. package/framework/MORPH.md +56 -58
  8. package/framework/commands/morph-hotfix.md +1 -1
  9. package/framework/docs/CLI.md +3 -2
  10. package/framework/hooks/shared/phase-utils.js +0 -2
  11. package/framework/phases.json +2 -2
  12. package/framework/rules/morph-workflow.md +1 -1
  13. package/framework/schemas/feature.schema.json +12 -0
  14. package/framework/skills/README.md +1 -1
  15. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  16. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  17. package/framework/skills/core/morph-apply/SKILL.md +10 -14
  18. package/framework/skills/core/morph-brainstorming/SKILL.md +11 -22
  19. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  20. package/framework/skills/core/morph-hotfix/SKILL.md +4 -4
  21. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  22. package/framework/skills/core/morph-plan/SKILL.md +14 -20
  23. package/framework/skills/core/morph-proposal/SKILL.md +12 -15
  24. package/framework/skills/core/morph-review/SKILL.md +15 -8
  25. package/framework/skills/core/morph-stack-scan/SKILL.md +9 -13
  26. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  27. package/framework/skills/core/morph-uiux/SKILL.md +135 -0
  28. package/package.json +1 -1
  29. package/src/commands/project/status.js +2 -2
  30. package/src/commands/state/gate-check.js +223 -0
  31. package/src/commands/state/gate-decision.js +46 -10
  32. package/src/commands/validation/verify.js +18 -1
  33. package/src/core/paths/output-schema.js +0 -1
  34. package/src/lib/agents/output-collector.js +0 -1
  35. package/src/lib/git/default-branch.js +65 -0
  36. package/src/lib/risk-detector.js +13 -0
  37. 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
- /** trust from the committed config (BOM-tolerant), defaulting to "auto". */
17
- function readTrustFromConfig() {
16
+ /**
17
+ * trust from the committed config (BOM-tolerant), defaulting to "auto".
18
+ * Shared by `gate-decision` (recorded as metadata) and `gate-check`
19
+ * (drives shouldPauseGate's decision directly).
20
+ */
21
+ export function readTrustFromConfig() {
18
22
  try {
19
23
  const path = join(process.cwd(), '.morph', 'config', 'config.json');
20
24
  if (!existsSync(path)) return 'auto';
@@ -25,6 +29,35 @@ function readTrustFromConfig() {
25
29
  }
26
30
  }
27
31
 
32
+ /**
33
+ * Write the canonical `gateDecisions.{gate}` entry to a feature's
34
+ * authoritative feature.json — the ONE write path both `gate-decision` (a
35
+ * caller-supplied pause/reason/signals, e.g. an LLM's natural-language risk
36
+ * read, or a manual override) and `gate-check` (a `shouldPauseGate()`-derived
37
+ * pause/reason/signals) feed into, so the two commands can never drift into
38
+ * two different persisted shapes for the same field. Does NOT validate
39
+ * `gateName` — callers that need a friendly CLI error check `VALID_GATES`
40
+ * themselves before calling this.
41
+ *
42
+ * @param {string} featureName
43
+ * @param {string} gateName
44
+ * @param {{ pause: boolean, reason?: string, signals?: object, trust?: string }} decision
45
+ * @returns {Promise<{ pause: boolean, reason: string, signals: object, trust: string, decidedAt: string }>}
46
+ * the exact entry written (decidedAt is server-side, generated here).
47
+ */
48
+ export async function persistGateDecision(featureName, gateName, { pause, reason, signals = {}, trust } = {}) {
49
+ const entry = {
50
+ pause,
51
+ reason: reason || (pause ? 'risco detectado' : 'nenhum sinal de risco'),
52
+ signals,
53
+ trust: trust || readTrustFromConfig(),
54
+ decidedAt: new Date().toISOString(),
55
+ };
56
+
57
+ await updateFeature(featureName, `gateDecisions.${gateName}`, entry);
58
+ return entry;
59
+ }
60
+
28
61
  /**
29
62
  * `morph-spec gate-decision <feature> <gate> --pause <bool> [--reason <txt>]
30
63
  * [--signals <json>] [--trust <mode>]` — record the trust decision for a gate
@@ -36,6 +69,12 @@ function readTrustFromConfig() {
36
69
  * state-sync hook rewriting the file between the agent's Read and Edit
37
70
  * ("file modified since read"). The canonical shape is documented in
38
71
  * framework/schemas/feature.schema.json and consumed by gate-guard.js.
72
+ *
73
+ * The manual escape hatch: `gate-check` (src/commands/state/gate-check.js) is
74
+ * the default, disk-derived path via `shouldPauseGate()`; this command stays
75
+ * for a caller that already knows the decision (an LLM's natural-language
76
+ * risk read a script can't see, or a deliberate override) — both write
77
+ * through {@link persistGateDecision}, so the persisted shape never diverges.
39
78
  */
40
79
  export async function gateDecisionCommand(featureName, gateName, options = {}) {
41
80
  try {
@@ -71,15 +110,12 @@ export async function gateDecisionCommand(featureName, gateName, options = {}) {
71
110
  }
72
111
  }
73
112
 
74
- const entry = {
113
+ const entry = await persistGateDecision(featureName, gateName, {
75
114
  pause,
76
- reason: options.reason || (pause ? 'risco detectado' : 'nenhum sinal de risco'),
115
+ reason: options.reason,
77
116
  signals,
78
- trust: options.trust || readTrustFromConfig(),
79
- decidedAt: new Date().toISOString(),
80
- };
81
-
82
- await updateFeature(featureName, `gateDecisions.${gateName}`, entry);
117
+ trust: options.trust,
118
+ });
83
119
 
84
120
  if (options.json) {
85
121
  console.log(JSON.stringify({ feature: featureName, gate: gateName, ...entry }, null, 2));
@@ -5,6 +5,7 @@ import { getFeature, updateFeature } from '../../core/state/state-manager.js';
5
5
  import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
6
6
  import { runVerify } from '../../lib/validators/verify-runner.js';
7
7
  import { resolvePrimaryRoot } from '../project/worktree.js';
8
+ import { getHeadSha } from '../../lib/git/default-branch.js';
8
9
 
9
10
  /** ✓ pass · ✗ fail · – skip */
10
11
  function statusIcon(status) {
@@ -160,6 +161,7 @@ export async function verifyCommand(featureName, taskId, options = {}) {
160
161
  let compact = null;
161
162
  if (persist) {
162
163
  const prevEntry = taskId ? feature.taskVerifications?.[taskId] : feature.verifications?.feature;
164
+ const now = new Date().toISOString();
163
165
  compact = {
164
166
  status: result.status,
165
167
  nodes: {
@@ -169,9 +171,24 @@ export async function verifyCommand(featureName, taskId, options = {}) {
169
171
  },
170
172
  score: result.nodes.validators.score ?? null,
171
173
  attempts: (prevEntry?.attempts ?? 0) + 1,
172
- updatedAt: new Date().toISOString(),
174
+ updatedAt: now,
173
175
  };
174
176
  await updateFeature(featureName, scopeKey, compact);
177
+
178
+ // Singular, scope-agnostic "latest stamp" — distinct from the
179
+ // verifications/taskVerifications history above. `sha` is what lets a
180
+ // consumer (morph-spec gate-check) correlate this pass/fail with the
181
+ // EXACT commit it covers, the same way review-report.md's
182
+ // `**Reviewed-SHA:**` token lets approve.js detect a stale review. null
183
+ // in a non-git checkout (or before the first commit) — fail-open, never
184
+ // blocks persistence.
185
+ const stamp = {
186
+ status: result.status,
187
+ sha: getHeadSha(process.cwd()),
188
+ ts: now,
189
+ scope: taskId ? `task:${taskId}` : 'feature',
190
+ };
191
+ await updateFeature(featureName, 'verification', stamp);
175
192
  }
176
193
 
177
194
  if (options.json) {
@@ -37,7 +37,6 @@ export const OUTPUT_SCHEMA = {
37
37
  // 0-proposal/
38
38
  proposal: { filename: 'proposal.md', phaseDir: '0-proposal', phase: 'proposal', protected: false },
39
39
  stackScan: { filename: 'stack-scan.md', phaseDir: '0-proposal', phase: 'proposal', protected: false },
40
- migrationPlan: { filename: 'migration-plan.md', phaseDir: '0-proposal', phase: 'proposal', protected: false },
41
40
 
42
41
  // 1-design/ (optional)
43
42
  uiDesignSystem: { filename: 'design-system.md', phaseDir: '1-design', phase: 'uiux', protected: true, approvalGate: 'uiux' },
@@ -15,7 +15,6 @@ import { OUTPUT_SCHEMA, getAbsoluteOutputPath } from '../../core/paths/output-sc
15
15
  export const SOURCE_WEIGHTS = {
16
16
  proposal: 1.0,
17
17
  stackScan: 0.8,
18
- migrationPlan: 0.8,
19
18
  spec: 1.5,
20
19
  mandate: 1.3,
21
20
  tasks: 1.3,
@@ -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
+ }
@@ -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
- }