@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.
- package/README.md +3 -3
- package/bin/morph-spec.js +2 -1
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +3 -3
- package/framework/CLAUDE.md +2 -2
- package/framework/MORPH.md +22 -21
- package/framework/agents.json +1 -1
- package/framework/commands/create-umbraco-lp.md +1 -1
- package/framework/commands/migrate-lp-to-umbraco.md +1 -1
- package/framework/commands/morph-apply.md +4 -4
- package/framework/commands/morph-hotfix.md +3 -3
- package/framework/commands/morph-proposal.md +5 -5
- package/framework/docs/CLI.md +3 -3
- package/framework/evals/rubrics/condensed.md +2 -2
- package/framework/evals/rubrics/test-coverage.md +7 -0
- package/framework/hooks/claude-code/core/protect-spec-files.js +115 -4
- package/framework/hooks/shared/payload-utils.js +19 -4
- package/framework/rules/dotnet-templates.md +2 -1
- package/framework/rules/morph-task-tracking.md +11 -34
- package/framework/rules/morph-workflow.md +42 -29
- 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 +8 -8
- package/framework/skills/core/morph-brainstorming/SKILL.md +2 -2
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +7 -7
- package/framework/skills/core/morph-implement/SKILL.md +21 -6
- package/framework/skills/core/morph-plan/SKILL.md +15 -3
- package/framework/skills/core/morph-proposal/SKILL.md +7 -7
- package/framework/skills/core/morph-review/SKILL.md +10 -3
- package/framework/skills/core/morph-stack-scan/SKILL.md +3 -3
- package/framework/skills/core/morph-standards/SKILL.md +4 -4
- package/framework/skills/core/morph-uiux/SKILL.md +3 -3
- package/framework/templates/README.md +72 -643
- package/framework/templates/docs/onboarding.md +4 -4
- package/framework/templates/examples/spec-examples.md +2 -1
- package/package.json +1 -1
- package/src/commands/project/claim.js +19 -3
- package/src/commands/state/approve.js +17 -3
- package/src/commands/state/finish.js +11 -1
- package/src/commands/state/gate-check.js +9 -20
- package/src/commands/state/report-check.js +157 -5
- package/src/lib/evaluator-report.js +72 -0
- 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
|
|
80
|
-
>
|
|
81
|
-
>
|
|
82
|
-
>
|
|
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
|
-
**
|
|
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
|
@@ -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
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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:
|
|
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)
|
|
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
|
|
264
|
+
const fsPath = reportPath(cwd, feature, task.id);
|
|
265
|
+
const report = inspectReport(fsPath);
|
|
117
266
|
const evidence = outputEvidence(cwd, task.outputs);
|
|
118
|
-
|
|
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
|
+
}
|
package/src/lib/risk-detector.js
CHANGED
|
@@ -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
|
};
|