@polymorphism-tech/morph-spec 8.25.0 → 8.27.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 (45) hide show
  1. package/README.md +3 -3
  2. package/bin/morph-spec.js +2 -1
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +3 -3
  6. package/framework/CLAUDE.md +2 -2
  7. package/framework/MORPH.md +22 -21
  8. package/framework/agents.json +1 -1
  9. package/framework/commands/create-umbraco-lp.md +1 -1
  10. package/framework/commands/migrate-lp-to-umbraco.md +1 -1
  11. package/framework/commands/morph-apply.md +4 -4
  12. package/framework/commands/morph-hotfix.md +3 -3
  13. package/framework/commands/morph-proposal.md +5 -5
  14. package/framework/docs/CLI.md +3 -3
  15. package/framework/evals/rubrics/condensed.md +2 -2
  16. package/framework/evals/rubrics/test-coverage.md +7 -0
  17. package/framework/hooks/claude-code/core/protect-spec-files.js +115 -4
  18. package/framework/hooks/shared/payload-utils.js +19 -4
  19. package/framework/rules/dotnet-templates.md +2 -1
  20. package/framework/rules/morph-task-tracking.md +11 -34
  21. package/framework/rules/morph-workflow.md +42 -29
  22. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  23. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  24. package/framework/skills/core/morph-apply/SKILL.md +8 -8
  25. package/framework/skills/core/morph-brainstorming/SKILL.md +2 -2
  26. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  27. package/framework/skills/core/morph-hotfix/SKILL.md +7 -7
  28. package/framework/skills/core/morph-implement/SKILL.md +21 -6
  29. package/framework/skills/core/morph-plan/SKILL.md +15 -3
  30. package/framework/skills/core/morph-proposal/SKILL.md +7 -7
  31. package/framework/skills/core/morph-review/SKILL.md +10 -3
  32. package/framework/skills/core/morph-stack-scan/SKILL.md +3 -3
  33. package/framework/skills/core/morph-standards/SKILL.md +4 -4
  34. package/framework/skills/core/morph-uiux/SKILL.md +3 -3
  35. package/framework/templates/README.md +72 -643
  36. package/framework/templates/docs/onboarding.md +4 -4
  37. package/framework/templates/examples/spec-examples.md +2 -1
  38. package/package.json +1 -1
  39. package/src/commands/project/claim.js +19 -3
  40. package/src/commands/state/approve.js +17 -3
  41. package/src/commands/state/finish.js +11 -1
  42. package/src/commands/state/gate-check.js +9 -20
  43. package/src/commands/state/report-check.js +157 -5
  44. package/src/lib/evaluator-report.js +72 -0
  45. package/src/lib/risk-detector.js +35 -0
@@ -76,10 +76,10 @@ morph-spec archive <feature>
76
76
  morph-spec doctor
77
77
  ```
78
78
 
79
- > **Task tracking is native.** During the implement phase Claude tracks its own
80
- > work with the `TaskCreate`/`TaskUpdate` tools and persists progress by writing
81
- > `tasks.json` (only the `status`, `outputs`, and `notes` fields). State is synced
82
- > automatically by the `state-sync` hook — task tracking is native, not a CLI command.
79
+ > **Task tracking lives in `tasks.json`.** During the implement phase Claude records
80
+ > progress by writing `tasks.json` directly (only the `status`, `outputs`, and `notes`
81
+ > fields). State is synced automatically by the `state-sync` hook — there is no CLI
82
+ > command to mark a task done.
83
83
 
84
84
  ---
85
85
 
@@ -2,7 +2,8 @@
2
2
 
3
3
  > **Layer:** 3 | **Load:** on-demand | **Keywords:** spec, specification, template
4
4
 
5
- **Use the stack's `.morph/framework/templates/spec.md` as the canonical template.**
5
+ **The canonical template is `docs/spec.md` of the template system** (REGISTRY id `spec`),
6
+ resolved from the installed package — see `framework/templates/README.md`.
6
7
 
7
8
  This file contains extended examples for each spec section.
8
9
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polymorphism-tech/morph-spec",
3
- "version": "8.25.0",
3
+ "version": "8.27.0",
4
4
  "description": "MORPH-SPEC: NLH (Natural Language Harness) for spec-driven development with Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -132,7 +132,17 @@ export function claimCommand(feature, options = {}) {
132
132
 
133
133
  // No feature (or explicit --list): the read-only board, whole or filtered.
134
134
  const all = listClaims(gitCommonDir);
135
- const rows = feature ? all.filter((c) => c.feature === feature) : all;
135
+ const scoped = feature ? all.filter((c) => c.feature === feature) : all;
136
+
137
+ // Um claim morto NÃO é limpo do registro — ele só para de avisar (o TTL de
138
+ // 30 min o mata, `live` é computado na leitura). O efeito colateral é que o
139
+ // board acumula: features encerradas há 20h e 73h apareciam no `--list` como
140
+ // se fossem informação. Um quadro de avisos onde quase nada é aviso ensina a
141
+ // não olhar o quadro. Por padrão mostramos só o que ainda avisa; `--all`
142
+ // devolve o registro inteiro, e o rodapé conta o que ficou de fora para a
143
+ // omissão nunca ser silenciosa.
144
+ const rows = options.all ? scoped : scoped.filter((c) => c.live);
145
+ const hidden = scoped.length - rows.length;
136
146
 
137
147
  if (options.json) {
138
148
  console.log(JSON.stringify(rows));
@@ -141,8 +151,11 @@ export function claimCommand(feature, options = {}) {
141
151
 
142
152
  if (!rows.length) {
143
153
  console.log(chalk.gray(feature
144
- ? `Nenhum claim para "${feature}".`
145
- : 'Nenhuma feature reclamada — nenhuma sessão registrada.'));
154
+ ? `Nenhum claim vivo para "${feature}".`
155
+ : 'Nenhuma feature reclamada — nenhuma sessão viva registrada.'));
156
+ if (hidden > 0) {
157
+ console.log(chalk.gray(`(${hidden} claim(s) morto(s) omitido(s) — \`--all\` para ver, \`--release\` para limpar)`));
158
+ }
146
159
  process.exit(0);
147
160
  }
148
161
 
@@ -155,6 +168,9 @@ export function claimCommand(feature, options = {}) {
155
168
  + col(claimAge(c), 12) + col(c.pid, 9) + (c.path || '-'),
156
169
  );
157
170
  }
171
+ if (hidden > 0) {
172
+ console.log(chalk.gray(`\n${hidden} claim(s) morto(s) omitido(s) — \`--all\` para ver.`));
173
+ }
158
174
  console.log(chalk.gray('\nClaims são AVISOS, nunca bloqueios. Claim órfão: `morph-spec claim <f> --release`'));
159
175
  process.exit(0);
160
176
  }
@@ -5,6 +5,7 @@ import { existsSync, readFileSync } from 'fs';
5
5
  import { join } from 'path';
6
6
  import { getFeature, setApprovalGate, getApprovalGate, getApprovalHistory, derivePhase } from '../../core/state/state-manager.js';
7
7
  import { unmetPrereqs } from '../../core/state/gate-order.js';
8
+ import { evaluatorReportState, COMPOSITE_SCORE_TOKEN } from '../../lib/evaluator-report.js';
8
9
 
9
10
  /**
10
11
  * The literal token `review-report.md` carries to declare which tree it
@@ -303,10 +304,23 @@ export async function approveCommand(featureName, gateName, options = {}) {
303
304
  process.exit(1);
304
305
  }
305
306
  } else {
306
- const evaluatorReport = join(process.cwd(), '.morph', 'features', featureName, '4-review', 'evaluator-report.md');
307
- if (!existsSync(evaluatorReport)) {
307
+ // `existsSync` puro não bastava: um evaluator-report SEM o token
308
+ // `**Composite-Score:**` passava aqui E era descartado pelo gate-check
309
+ // (que então caía no proxy de taskScores). O resultado era um Gate 3
310
+ // carimbado `approvedBy: "auto"` sobre a média das auto-avaliações de
311
+ // quem implementou — precisamente o que MORPH.md §5 proíbe. Arquivo
312
+ // presente sem veredito legível é relatório OCO, não relatório.
313
+ const report = evaluatorReportState(process.cwd(), featureName);
314
+ if (report.status !== 'ok') {
308
315
  if (!options.selfAssessed) {
309
- console.error(chalk.red('\n❌ Gate 3 exige o avaliador independente — `4-review/evaluator-report.md` não existe.'));
316
+ if (report.status === 'missing') {
317
+ console.error(chalk.red('\n❌ Gate 3 exige o avaliador independente — `4-review/evaluator-report.md` não existe.'));
318
+ } else {
319
+ console.error(chalk.red('\n❌ Gate 3: o `4-review/evaluator-report.md` existe mas está OCO —'));
320
+ console.error(chalk.red(` falta a linha \`${COMPOSITE_SCORE_TOKEN} {n.n}\`, o veredito legível por máquina.`));
321
+ console.error(chalk.gray(' Sem ela o gate-check não lê o score real e cai na média das auto-avaliações.'));
322
+ console.error(chalk.gray(' Peça ao avaliador o relatório completo (morph-review §4) ou complete a linha.'));
323
+ }
310
324
  console.error(chalk.gray(' O score composto da feature vem do avaliador (persona `evaluator`, contexto fresco)'));
311
325
  console.error(chalk.gray(' despachado pela morph-review, nunca de auto-avaliação de quem implementou.'));
312
326
  console.error(chalk.gray(' Se o dispatch for impossível nesta sessão, aprove com --self-assessed —'));
@@ -518,7 +518,7 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
518
518
  }
519
519
  tryGit(['branch', '-d', expectedBranch], root);
520
520
  // The root index (gitignored) was already updated by the archive (same cwd).
521
- emit({ finished: true, mode: 'merge', feature, merged: expectedBranch, into: defaultBranch, worktreeRemoved: false, nextStep: null, ...ucNote });
521
+ emit({ finished: true, mode: 'merge', feature, merged: expectedBranch, into: defaultBranch, worktreeRemoved: false, nextStep: MERGE_NEXT_STEP, ...ucNote });
522
522
  process.exit(0);
523
523
  }
524
524
 
@@ -596,6 +596,15 @@ function promotedNote(arch) {
596
596
  };
597
597
  }
598
598
 
599
+ /**
600
+ * O comando se chama `finish`, e no modo `--merge` ele NÃO publica nada: o
601
+ * merge é local, o remoto continua sem a feature. Quem lê "finished: true"
602
+ * conclui o contrário — foi pegadinha repetida em sessões diferentes. O modo
603
+ * `--pr` empurra e abre o PR; o `--merge` deixa o push para o humano, e é isso
604
+ * que esta linha diz em voz alta.
605
+ */
606
+ const MERGE_NEXT_STEP = 'integração LOCAL — nada foi publicado. Rode `git push` para levar ao remoto.';
607
+
599
608
  function mergeOutput({ feature, branch, worktreePath, teardown, shedBackup }) {
600
609
  return {
601
610
  finished: true,
@@ -603,6 +612,7 @@ function mergeOutput({ feature, branch, worktreePath, teardown, shedBackup }) {
603
612
  feature,
604
613
  merged: branch,
605
614
  worktreeRemoved: teardown.worktreeRemoved,
615
+ nextStep: MERGE_NEXT_STEP,
606
616
  ...(shedBackup ? { shedBackup } : {}),
607
617
  ...teardownExtras(teardown, worktreePath, branch, true),
608
618
  };
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'fs';
3
3
  import { getFeature } from '../../core/state/state-manager.js';
4
4
  import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
5
5
  import { shouldPauseGate, getRiskSignals } from '../../lib/risk-detector.js';
6
+ import { evaluatorCompositeScore } from '../../lib/evaluator-report.js';
6
7
  import { persistGateDecision, VALID_GATES, readTrustFromConfig } from './gate-decision.js';
7
8
  import { detectDefaultBranch, getHeadSha, getDiffStat } from '../../lib/git/default-branch.js';
8
9
 
@@ -61,26 +62,6 @@ function compositeEvalScore(feature) {
61
62
  return Math.round(avg * 10) / 10;
62
63
  }
63
64
 
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
65
  /**
85
66
  * Collect every signal `shouldPauseGate()` (risk-detector.js) is documented
86
67
  * to consume, straight off disk — the deterministic call site risk-detector
@@ -198,6 +179,14 @@ export async function gateCheckCommand(featureName, gateName, options = {}) {
198
179
  for (const key of OVERRIDABLE_SIGNAL_KEYS) {
199
180
  if (key in parsed) overrides[key] = parsed[key];
200
181
  }
182
+
183
+ // Um `evalScore` vindo de --signals substitui o VALOR mas não a
184
+ // PROCEDÊNCIA: sem isto o merge cego abaixo deixaria `evalScoreSource`
185
+ // com o que collectSignals escreveu ('taskScores-proxy'), e o sinal de
186
+ // auditoria passaria a mentir sobre de onde veio o número. `signals-
187
+ // override` não está em OVERRIDABLE_SIGNAL_KEYS — o caller não pode
188
+ // forjá-lo, mesmo anti-spoof de `gate`/`workType`.
189
+ if ('evalScore' in overrides) overrides.evalScoreSource = 'signals-override';
201
190
  }
202
191
 
203
192
  const cwd = process.cwd();
@@ -26,8 +26,9 @@
26
26
  */
27
27
 
28
28
  import chalk from 'chalk';
29
- import { existsSync, readFileSync } from 'fs';
29
+ import { existsSync, readFileSync, statSync } from 'fs';
30
30
  import { join } from 'path';
31
+ import { readTelemetryEvents } from '../../../framework/hooks/shared/telemetry-logger.js';
31
32
 
32
33
  /**
33
34
  * The four mandatory items of the report contract, as detectable markers.
@@ -54,6 +55,22 @@ function reportPath(cwd, feature, taskId) {
54
55
  return join(cwd, '.morph', 'features', feature, '3-implement', 'reports', `${taskId}.md`);
55
56
  }
56
57
 
58
+ /**
59
+ * `.morph/logs/task-times.json` — `{ [feature]: { [taskId]: {status,start,end} } }`,
60
+ * escrito pelo hook `trace-autogen` nas transições de status da task. É a
61
+ * janela temporal de cada task; sem ela não há como recortar quais writes do
62
+ * `events.jsonl` pertencem a qual task.
63
+ */
64
+ function readTaskTimes(cwd) {
65
+ try {
66
+ const p = join(cwd, '.morph', 'logs', 'task-times.json');
67
+ if (!existsSync(p)) return null;
68
+ return JSON.parse(readFileSync(p, 'utf8'));
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
57
74
  function evaluatorPath(cwd, feature) {
58
75
  return join(cwd, '.morph', 'features', feature, '4-review', 'evaluator-report.md');
59
76
  }
@@ -98,9 +115,140 @@ function outputEvidence(cwd, taskOutputs) {
98
115
  return { outputsExpected: expected.length, outputsPresent: present };
99
116
  }
100
117
 
118
+ /**
119
+ * Folga entre o `mtime` do relatório e o `ts` do último write observado.
120
+ *
121
+ * São DOIS RELÓGIOS: `mtime` vem do filesystem, `ts` vem de
122
+ * `new Date().toISOString()` dentro do hook de telemetria. O write do próprio
123
+ * relatório e o evento que o registra caem a milissegundos de distância, com
124
+ * arredondamentos diferentes — sem folga, um relatório perfeitamente fresco
125
+ * seria acusado de velho. Dois segundos são muito mais que o skew real e muito
126
+ * menos que qualquer trabalho de verdade feito depois de relatar.
127
+ */
128
+ export const STALE_TOLERANCE_MS = 2000;
129
+
130
+ /**
131
+ * Traz o target do evento para a MESMA forma que `task.outputs`.
132
+ *
133
+ * Os dois lados chegam em convenções diferentes, e cruzá-los crus não casa
134
+ * nunca: `event.target` é o `file_path` como a ferramenta o recebeu, e
135
+ * `Write`/`Edit` exigem caminho ABSOLUTO (`telemetry-log.js` só normaliza as
136
+ * barras, não relativiza); `task.outputs` é relativo à raiz — é assim que
137
+ * `outputEvidence` o resolve, com `join(cwd, rel)`. Sem este passo,
138
+ * `undeclared`/`unobserved` listariam TUDO em toda task, e um sinal que sempre
139
+ * grita é um sinal que se aprende a ignorar.
140
+ *
141
+ * Um target que não fica sob a raiz (outra árvore) volta como veio, em vez de
142
+ * virar `../../..` — fora da árvore ele é evidência legítima de escrita alheia,
143
+ * não um output que faltou declarar.
144
+ */
145
+ function toRepoRelative(target, cwd) {
146
+ const norm = String(target || '').replace(/\\/g, '/');
147
+ const root = String(cwd || '').replace(/\\/g, '/').replace(/\/+$/, '');
148
+ if (!root) return norm;
149
+ // Prefixo comparado sem caixa: no Windows a mesma raiz chega como `R:/repo`
150
+ // ou `r:/repo` conforme quem chamou o comando.
151
+ return norm.toLowerCase().startsWith(`${root.toLowerCase()}/`) ? norm.slice(root.length + 1) : norm;
152
+ }
153
+
154
+ /**
155
+ * Arquivos que NÃO contam como "trabalho feito depois do relatório".
156
+ *
157
+ * Sem este recorte o veredito vira máquina de falso positivo, e um aviso que
158
+ * dispara à toa é um aviso que se aprende a ignorar:
159
+ * - o próprio relatório: gravá-lo É um Write, e ele cai na janela da task por
160
+ * construção — todo relatório bem-comportado pareceria stale;
161
+ * - qualquer coisa sob `.morph/`: `tasks.json`, `feature.json`, `recap.md`,
162
+ * `decisions.md` são escritas do ORQUESTRADOR, depois que o sub-agent
163
+ * terminou. São o fluxo normal, não trabalho não relatado.
164
+ *
165
+ * `includes` e não `startsWith`, de propósito: o alvo já vem relativizado, mas
166
+ * um write no `.morph/` de OUTRA árvore não fica sob esta raiz e continua
167
+ * absoluto — `startsWith('.morph/')` deixaria esse passar como trabalho. Não há
168
+ * código-fonte sob `.morph/` para o `includes` pegar por engano: a pasta é
169
+ * estado, por design.
170
+ */
171
+ function isBookkeepingWrite(target, reportRel) {
172
+ if (!target) return true;
173
+ if (target === reportRel) return true;
174
+ return target.includes('.morph/');
175
+ }
176
+
177
+ /**
178
+ * Evidência OBSERVADA: arquivos que a task de fato escreveu, segundo a
179
+ * telemetria — não segundo o que o agente declarou em `outputs`.
180
+ *
181
+ * A distinção é o ponto todo. `outputs` é auto-declarado pelo agente, e
182
+ * `trace-autogen` lê a MESMA lista — as duas checagens que pareciam
183
+ * independentes eram a mesma afirmação lida duas vezes. `events.jsonl` registra
184
+ * cada `Write`/`Edit` no momento em que aconteceu, com `ts`; `task-times.json`
185
+ * dá a janela `[start, end]` da task. Cruzando os dois sai o que de fato foi
186
+ * tocado, sem depender da palavra de ninguém.
187
+ *
188
+ * Fail-open: sem telemetria (projeto legado, hook desligado) devolve
189
+ * `{ available: false }` e o comando volta ao comportamento anterior.
190
+ */
191
+ function observedEvidence(cwd, feature, task) {
192
+ const times = readTaskTimes(cwd)?.[feature]?.[task.id];
193
+ if (!times?.start) return { available: false };
194
+
195
+ const events = readTelemetryEvents(cwd);
196
+ if (!events.length) return { available: false };
197
+
198
+ const startMs = Date.parse(times.start);
199
+ // Janela aberta à direita quando a task não fechou: uma task ainda em
200
+ // andamento tem trabalho observável, e recusar auditá-la esconde exatamente
201
+ // o caso do relatório escrito cedo demais.
202
+ const endMs = times.end ? Date.parse(times.end) : Number.POSITIVE_INFINITY;
203
+ if (!Number.isFinite(startMs)) return { available: false };
204
+
205
+ const reportRel = `.morph/features/${feature}/3-implement/reports/${task.id}.md`;
206
+ const writes = [];
207
+ for (const e of events) {
208
+ if (e?.tool !== 'Write' && e?.tool !== 'Edit') continue;
209
+ const ts = Date.parse(e?.ts ?? '');
210
+ if (!Number.isFinite(ts) || ts < startMs || ts > endMs) continue;
211
+ const target = toRepoRelative(e.target, cwd);
212
+ if (isBookkeepingWrite(target, reportRel)) continue;
213
+ writes.push({ target, ts });
214
+ }
215
+
216
+ const declared = new Set((Array.isArray(task.outputs) ? task.outputs : [])
217
+ .map((p) => String(p).replace(/\\/g, '/')));
218
+ const observed = [...new Set(writes.map((w) => w.target))];
219
+
220
+ return {
221
+ available: true,
222
+ observed,
223
+ lastWriteAt: writes.length ? new Date(Math.max(...writes.map((w) => w.ts))).toISOString() : null,
224
+ undeclared: observed.filter((p) => !declared.has(p)),
225
+ unobserved: [...declared].filter((p) => !observed.includes(p)),
226
+ };
227
+ }
228
+
229
+ /** O relatório é mais velho que o último trabalho observado da própria task? */
230
+ function isReportStale(reportFsPath, observed) {
231
+ if (!observed?.available || !observed.lastWriteAt) return false;
232
+ try {
233
+ const writtenAt = statSync(reportFsPath).mtimeMs;
234
+ return writtenAt + STALE_TOLERANCE_MS < Date.parse(observed.lastWriteAt);
235
+ } catch {
236
+ return false;
237
+ }
238
+ }
239
+
101
240
  /** Combine report state + evidence into a verdict and the action it implies. */
102
- function decide(report, evidence) {
103
- if (report.exists && report.complete) return { verdict: 'ok', action: 'proceed' };
241
+ function decide(report, evidence, stale) {
242
+ if (report.exists && report.complete) {
243
+ // A integridade estrutural (os 4 itens presentes) responde "o relatório
244
+ // está completo?", nunca "o relatório ainda é verdade?". Caso real: um
245
+ // sub-agent gravou o esqueleto dizendo "teste ainda não escrito — em
246
+ // andamento", seguiu trabalhando, escreveu os 9 testes e foi cortado. O
247
+ // arquivo ficou completo e MENTINDO, e saía `ok` aqui.
248
+ return stale
249
+ ? { verdict: 'report-stale', action: 'refresh-from-evidence' }
250
+ : { verdict: 'ok', action: 'proceed' };
251
+ }
104
252
  if (report.exists) return { verdict: 'report-incomplete', action: 'complete-degraded' };
105
253
  if (evidence.outputsExpected === 0) {
106
254
  // No declared outputs means no evidence either way. Re-dispatching costs a
@@ -113,13 +261,17 @@ function decide(report, evidence) {
113
261
  }
114
262
 
115
263
  function checkTask(cwd, feature, task) {
116
- const report = inspectReport(reportPath(cwd, feature, task.id));
264
+ const fsPath = reportPath(cwd, feature, task.id);
265
+ const report = inspectReport(fsPath);
117
266
  const evidence = outputEvidence(cwd, task.outputs);
118
- return { task: task.id, report, evidence, ...decide(report, evidence) };
267
+ const observed = observedEvidence(cwd, feature, task);
268
+ const stale = report.exists && isReportStale(fsPath, observed);
269
+ return { task: task.id, report, evidence, observed, ...decide(report, evidence, stale) };
119
270
  }
120
271
 
121
272
  const ACTION_HINT = {
122
273
  proceed: 'relatório íntegro — siga o fluxo normal.',
274
+ 'refresh-from-evidence': 'o relatório está COMPLETO mas VELHO: a task seguiu escrevendo arquivos depois que ele foi gravado, então ele descreve um estado já ultrapassado. NÃO re-despache e não confie no texto — confira os arquivos em `observed` (evidência real, não auto-declarada) e atualize o relatório a partir deles.',
123
275
  'complete-degraded': 'complete os itens faltantes a partir da mensagem de retorno do sub-agent, carimbe o relatório como DEGRADADO e registre a falha de protocolo no recap.',
124
276
  'accept-degraded': 'o TRABALHO entrou — não re-despache. Grave você mesmo o relatório canônico a partir da mensagem de retorno, carimbado como DEGRADADO, e registre a falha de protocolo no recap e nas `notes` da task.',
125
277
  redispatch: 're-despache a task UMA vez com o mesmo prompt. Segunda ausência sem trabalho entregue = falha real: escale ao humano.',
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Leitura do `4-review/evaluator-report.md` — o relatório BRUTO do avaliador
3
+ * independente despachado pela `morph-review` no Gate 3.
4
+ *
5
+ * Existe como módulo próprio porque DOIS comandos dependem do mesmo arquivo por
6
+ * razões diferentes, e antes o liam de maneiras diferentes:
7
+ *
8
+ * - `gate-check` lê o token `**Composite-Score:**` para saber o score REAL da
9
+ * feature (em vez do proxy de médias de auto-avaliação);
10
+ * - `approve` exige o arquivo como precondição do Gate 3.
11
+ *
12
+ * A divergência era concreta: `approve.js` montava o path à mão com `join(...)`
13
+ * enquanto `gate-check.js` resolvia por `getAbsoluteOutputPath`. Dois caminhos
14
+ * para o mesmo arquivo é a receita de drift silencioso (a lição A5), e a
15
+ * checagem por `existsSync` puro do `approve` aceitava um relatório SEM o token
16
+ * que o `gate-check` então descartava — o Gate 3 passava carimbado `auto` sobre
17
+ * uma média de auto-avaliação, exatamente o que MORPH.md §5 proíbe.
18
+ */
19
+
20
+ import { existsSync, readFileSync } from 'fs';
21
+ import { getAbsoluteOutputPath } from '../core/paths/output-schema.js';
22
+
23
+ /**
24
+ * O token machine-readable que o avaliador grava por contrato (`morph-review`
25
+ * §4). Espelho de `REVIEWED_SHA_TOKEN` do approve.js — mesma ideia: um relatório
26
+ * de LLM só é auditável por máquina se carregar um campo que a máquina lê.
27
+ */
28
+ export const COMPOSITE_SCORE_TOKEN = '**Composite-Score:**';
29
+
30
+ const COMPOSITE_SCORE_RE = /\*\*Composite-Score:\*\*\s*([0-9]+(?:\.[0-9]+)?)/;
31
+
32
+ /** Path canônico do evaluator-report da feature. Fonte única para os dois comandos. */
33
+ export function evaluatorReportPath(cwd, featureName) {
34
+ return getAbsoluteOutputPath(cwd, featureName, 'evaluatorReport');
35
+ }
36
+
37
+ /**
38
+ * Score composto REAL da feature, do token gravado pelo avaliador independente.
39
+ *
40
+ * @returns {number|undefined} undefined quando o relatório não existe, não é
41
+ * legível, ou existe sem o token (relatório legado ou oco). `undefined` é
42
+ * deliberado e não 0: quem chama distingue "sem veredito" de "veredito ruim".
43
+ */
44
+ export function evaluatorCompositeScore(cwd, featureName) {
45
+ try {
46
+ const p = evaluatorReportPath(cwd, featureName);
47
+ if (!existsSync(p)) return undefined;
48
+ const m = readFileSync(p, 'utf8').match(COMPOSITE_SCORE_RE);
49
+ return m ? Number(m[1]) : undefined;
50
+ } catch {
51
+ return undefined;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Estado do relatório para a precondição do Gate 3.
57
+ *
58
+ * Três valores em vez de booleano PORQUE AS TRÊS SITUAÇÕES PEDEM MENSAGENS
59
+ * DIFERENTES: arquivo ausente é "despache o avaliador"; arquivo sem token é
60
+ * "o avaliador rodou mas o relatório está oco — ele não seguiu o contrato";
61
+ * e só o terceiro caso é aprovável.
62
+ *
63
+ * @returns {{ status: 'ok'|'missing'|'untokenized', score?: number, path: string }}
64
+ */
65
+ export function evaluatorReportState(cwd, featureName) {
66
+ const path = evaluatorReportPath(cwd, featureName);
67
+ if (!existsSync(path)) return { status: 'missing', path };
68
+ const score = evaluatorCompositeScore(cwd, featureName);
69
+ return score === undefined
70
+ ? { status: 'untokenized', path }
71
+ : { status: 'ok', score, path };
72
+ }
@@ -11,6 +11,8 @@
11
11
  * - verificationStatus: string — último `morph-spec verify` ('fail' pausa, qualquer gate)
12
12
  * - verificationStale + gate: bool/str — stamp com sha != HEAD pausa SÓ no
13
13
  * ship (gate 'review') — código mudou depois do último verify
14
+ * - evalScoreSource + gate: str — no ship, score que NÃO veio do avaliador
15
+ * independente pausa (auto-avaliação não decide o Gate 3)
14
16
  * - evalScore: number — morph-eval score (pause if < 9)
15
17
  * - taskCount: number — total tasks (pause if > 20)
16
18
  */
@@ -18,6 +20,13 @@
18
20
  const SCOPE_TASK_THRESHOLD = 20;
19
21
  const MIN_EVAL_SCORE = 9;
20
22
 
23
+ // A ÚNICA procedência de `evalScore` que pode decidir o gate de ship. Escrita
24
+ // por gate-check.js quando o token `**Composite-Score:**` foi lido do
25
+ // evaluator-report.md — o veredito do avaliador independente em contexto
26
+ // fresco. Toda outra procedência (proxy de taskScores, override de --signals,
27
+ // ou nenhuma) é auto-avaliação ou palavra de quem já estava no contexto.
28
+ const TRUSTED_EVAL_SOURCE = 'evaluator-report';
29
+
21
30
  // Work types whose ship gate (review) always pauses for a human, regardless
22
31
  // of trust mode — a type policy, not a per-feature risk signal. Mirrors
23
32
  // `forcedPauseGates` in src/core/work-types.js (hotfix.review); kept here too
@@ -80,6 +89,28 @@ export function shouldPauseGate(config, signals) {
80
89
  };
81
90
  }
82
91
 
92
+ // No gate de SHIP, um `evalScore` que não veio do avaliador independente NÃO
93
+ // é sinal de qualidade da feature — é sinal de qualidade das tasks, dado por
94
+ // quem as implementou. Observado em campo: 7 tasks com auto-score 9.0-9.3
95
+ // levaram o gate-check a imprimir "auto-pass, nenhum sinal de risco" sobre um
96
+ // produto que o avaliador independente pontuou 7.2 e reprovou com 3 P1.
97
+ //
98
+ // O predicado é por AUSÊNCIA da fonte confiável, não por presença do proxy,
99
+ // porque há três maneiras de chegar aqui sem avaliador e todas devem pausar:
100
+ // proxy de taskScores; override manual via --signals; e nenhuma fonte (sem
101
+ // task pontuada e sem relatório, `evalScore` é OMITIDO e a regra de score
102
+ // abaixo nem chega a rodar).
103
+ //
104
+ // Fora do review não vale: em plan/uiux/proposal o proxy é exatamente o sinal
105
+ // certo — não existe avaliador independente antes de haver implementação.
106
+ if (signals.gate === 'review' && signals.evalScoreSource !== TRUSTED_EVAL_SOURCE) {
107
+ return {
108
+ pause: true,
109
+ reason: `Gate 3 sem score do avaliador independente (fonte: ${signals.evalScoreSource ?? 'nenhuma'})`
110
+ + ' — auto-avaliação de quem implementou não decide o ship',
111
+ };
112
+ }
113
+
83
114
  if (typeof signals.evalScore === 'number' && signals.evalScore < MIN_EVAL_SCORE) {
84
115
  return {
85
116
  pause: true,
@@ -112,6 +143,9 @@ export function getRiskSignals(signals) {
112
143
  if (signals.gate === 'review' && signals.verificationStale === true) {
113
144
  out.push('verification stamp desatualizado (stale) no ship');
114
145
  }
146
+ if (signals.gate === 'review' && signals.evalScoreSource !== TRUSTED_EVAL_SOURCE) {
147
+ out.push(`score do Gate 3 sem avaliador independente (fonte: ${signals.evalScoreSource ?? 'nenhuma'})`);
148
+ }
115
149
  if (typeof signals.evalScore === 'number' && signals.evalScore < MIN_EVAL_SCORE) {
116
150
  out.push(`eval score ${signals.evalScore}/10`);
117
151
  }
@@ -125,4 +159,5 @@ export const RISK_CONSTANTS = {
125
159
  SCOPE_TASK_THRESHOLD,
126
160
  MIN_EVAL_SCORE,
127
161
  FORCED_PAUSE_WORK_TYPES,
162
+ TRUSTED_EVAL_SOURCE,
128
163
  };