@polymorphism-tech/morph-spec 8.24.3 → 8.26.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 +2 -1
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- 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 +4 -4
- 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-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 +6 -6
- 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/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 +202 -15
- 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
|
@@ -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 —'));
|
|
@@ -78,6 +78,14 @@ export function buildPrTitle(feature, fj) {
|
|
|
78
78
|
* `finished` is the integration (the actual value); `worktreeRemoved` is the
|
|
79
79
|
* best-effort teardown — two independent signals, as before.
|
|
80
80
|
*
|
|
81
|
+
* RESUMABLE. `merge-conflict`, `checkout-default-failed`, `push-failed` and
|
|
82
|
+
* `pr-create-failed` all fail AFTER the archive, and legitimately so: the
|
|
83
|
+
* archive commit is what travels with the branch. Re-running then found no
|
|
84
|
+
* `features/{f}` and refused from scratch, so every mid-way failure demanded a
|
|
85
|
+
* hand repair. Now a second run that finds the feature ARCHIVED on its own
|
|
86
|
+
* branch, not yet merged into the default, picks up at the integration and says
|
|
87
|
+
* `resumed: true` in its output — see Validation 0c.
|
|
88
|
+
*
|
|
81
89
|
* @param {string} feature
|
|
82
90
|
* @param {{ pr?: boolean, merge?: boolean, keep?: boolean, force?: boolean,
|
|
83
91
|
* discardIgnored?: boolean, cwd?: string }} [options]
|
|
@@ -98,7 +106,15 @@ export async function finishCommand(feature, options = {}) {
|
|
|
98
106
|
// containers and their ports when the feature closes. Best-effort by
|
|
99
107
|
// contract — a stack that refuses to come down must never fail a finish that
|
|
100
108
|
// already merged.
|
|
109
|
+
// Set by Validation 0c when this run picks up an already-archived feature.
|
|
110
|
+
// Stamped onto EVERY payload emitted from that point on, success or refusal:
|
|
111
|
+
// a resume merges a branch the user did not archive in this run, and that has
|
|
112
|
+
// to be visible. Someone who mistypes a name and lands on an archived feature
|
|
113
|
+
// would otherwise get a silent merge they never asked for.
|
|
114
|
+
let resumed = false;
|
|
115
|
+
|
|
101
116
|
const emit = (obj) => {
|
|
117
|
+
if (resumed) obj = { ...obj, resumed: true };
|
|
102
118
|
if (obj?.finished === true) {
|
|
103
119
|
try {
|
|
104
120
|
const common = execFileSync('git', ['rev-parse', '--git-common-dir'], { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
@@ -211,25 +227,128 @@ export async function finishCommand(feature, options = {}) {
|
|
|
211
227
|
featureCwd = root;
|
|
212
228
|
}
|
|
213
229
|
|
|
214
|
-
// Validation 0c: the feature must still
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
230
|
+
// Validation 0c: the feature must still be in `features/` — or, if it is not,
|
|
231
|
+
// this run has to decide which of three states it is in before doing anything:
|
|
232
|
+
// gone entirely, already ARCHIVED and awaiting integration (resume), or
|
|
233
|
+
// already integrated (nothing to do).
|
|
234
|
+
//
|
|
235
|
+
// Originally all three arrived as `review-not-approved`, because a missing
|
|
236
|
+
// feature.json parses to null and the optional chaining below fell straight
|
|
237
|
+
// through — being told "Gate 3 not approved" about an approved gate is what
|
|
238
|
+
// sent the user looking in the wrong place after an interrupted finish.
|
|
221
239
|
//
|
|
222
240
|
// Keyed on the DIRECTORY, not on the parse: readFeatureJson also returns null
|
|
223
241
|
// for malformed JSON, and a legacy feature with a folder but no (or broken)
|
|
224
242
|
// feature.json is a real thing that `--force` is expected to push through.
|
|
225
243
|
if (!existsSync(join(featureCwd, '.morph', 'features', feature))) {
|
|
226
|
-
|
|
227
|
-
|
|
244
|
+
const archivePath = join(featureCwd, '.morph', 'archive', feature);
|
|
245
|
+
if (!existsSync(archivePath)) {
|
|
246
|
+
emit({ finished: false, error: 'feature-not-found', feature, ...featureElsewhereHint(featureCwd, root, feature) });
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── RESUME ──────────────────────────────────────────────────────────────
|
|
251
|
+
// Archived HERE, on the branch this finish would merge — every path that
|
|
252
|
+
// reaches this line has already proved we are standing on `morph/{f}`
|
|
253
|
+
// (worktree flow: found.branch === expectedBranch; common flow: branchExists
|
|
254
|
+
// + currentBranch === expectedBranch). So the archive step is done and only
|
|
255
|
+
// the integration is missing. Which is exactly what a failed merge/push
|
|
256
|
+
// leaves behind, and what used to require repairing by hand.
|
|
257
|
+
//
|
|
258
|
+
// The one thing still to establish is whether the integration ALSO
|
|
259
|
+
// happened. A branch already contained in the default is a finish that
|
|
260
|
+
// COMPLETED (`--keep`, or a `--pr` whose PR was merged) — re-merging it is
|
|
261
|
+
// the hazard this check exists to stop, and it is why resume is keyed on
|
|
262
|
+
// the branch's relationship to the default rather than on the archive alone.
|
|
263
|
+
const target = detectDefaultBranch(root);
|
|
264
|
+
if (!target) {
|
|
265
|
+
emit({ finished: false, error: 'no-default-branch', hint: 'não foi possível resolver a branch default (origin/HEAD, main ou master)', feature });
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
const merged = branchMergedInto(expectedBranch, target, root);
|
|
269
|
+
if (merged === null) {
|
|
270
|
+
// PROVE-or-DECLINE, same discipline as the worktree teardown: we could not
|
|
271
|
+
// establish the branch's state, so we do not act on a guess. Refusing is
|
|
272
|
+
// recoverable; a duplicate merge is not.
|
|
273
|
+
emit({
|
|
274
|
+
finished: false,
|
|
275
|
+
error: 'resume-undecidable',
|
|
276
|
+
feature,
|
|
277
|
+
branch: expectedBranch,
|
|
278
|
+
base: target,
|
|
279
|
+
archived: archivePath.replace(/\\/g, '/'),
|
|
280
|
+
hint: `'${feature}' está arquivada, mas não foi possível comparar ${expectedBranch} com ${target} (ref ausente?) — sem saber se o merge já aconteceu, retomar arriscaria mergear duas vezes. Verifique o estado do git antes de integrar à mão`,
|
|
281
|
+
});
|
|
282
|
+
process.exit(1);
|
|
283
|
+
}
|
|
284
|
+
if (merged) {
|
|
285
|
+
emit({
|
|
286
|
+
finished: false,
|
|
287
|
+
error: 'already-finished',
|
|
288
|
+
feature,
|
|
289
|
+
branch: expectedBranch,
|
|
290
|
+
base: target,
|
|
291
|
+
archived: archivePath.replace(/\\/g, '/'),
|
|
292
|
+
hint: `'${feature}' já foi arquivada E ${expectedBranch} já está contida em ${target} — o finish anterior concluiu. Nada a integrar.`,
|
|
293
|
+
});
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// "Already integrated" is MODE-SPECIFIC, and the ancestor test above only
|
|
298
|
+
// answers it for `--merge`. A `--pr` that completed leaves its branch
|
|
299
|
+
// deliberately UNMERGED (that is what the open PR is for), so the ancestor
|
|
300
|
+
// test says "not merged" and would wave the resume through — straight into
|
|
301
|
+
// a second `gh pr create` on a head that already has one. The common flow
|
|
302
|
+
// makes that trivially reachable: integratePr never leaves `morph/{f}`, so
|
|
303
|
+
// simply re-running the command lands here.
|
|
304
|
+
//
|
|
305
|
+
// `gh pr create` refuses a duplicate, so the damage was never two PRs — it
|
|
306
|
+
// was `pr-create-failed` standing in for "this already finished", which is
|
|
307
|
+
// the same collapsed diagnosis the `feature-not-found` split removed.
|
|
308
|
+
if (mode === 'pr') {
|
|
309
|
+
const openPr = openPrFor(expectedBranch, root);
|
|
310
|
+
if (openPr === null) {
|
|
311
|
+
emit({
|
|
312
|
+
finished: false,
|
|
313
|
+
error: 'resume-undecidable',
|
|
314
|
+
feature,
|
|
315
|
+
branch: expectedBranch,
|
|
316
|
+
archived: archivePath.replace(/\\/g, '/'),
|
|
317
|
+
hint: `'${feature}' está arquivada e ${expectedBranch} não foi mergeada, mas não foi possível consultar PRs abertos com \`gh\` — retomar às cegas abriria um PR duplicado. Verifique com \`gh pr list --head ${expectedBranch}\``,
|
|
318
|
+
});
|
|
319
|
+
process.exit(1);
|
|
320
|
+
}
|
|
321
|
+
if (openPr) {
|
|
322
|
+
emit({
|
|
323
|
+
finished: false,
|
|
324
|
+
error: 'already-finished',
|
|
325
|
+
feature,
|
|
326
|
+
branch: expectedBranch,
|
|
327
|
+
base: target,
|
|
328
|
+
archived: archivePath.replace(/\\/g, '/'),
|
|
329
|
+
...(typeof openPr === 'string' ? { pr: openPr } : {}),
|
|
330
|
+
hint: `'${feature}' já foi arquivada e ${expectedBranch} já tem PR aberto — o finish anterior concluiu. Falta apenas mergear o PR.`,
|
|
331
|
+
});
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
resumed = true;
|
|
228
337
|
}
|
|
229
338
|
|
|
230
|
-
// Validation 1: Gate 3 (review) approved on the feature's own feature.json
|
|
231
|
-
|
|
232
|
-
|
|
339
|
+
// Validation 1: Gate 3 (review) approved on the feature's own feature.json —
|
|
340
|
+
// which on a resume lives in `archive/{f}`, where runArchive moved it.
|
|
341
|
+
//
|
|
342
|
+
// NOT re-checked on a resume, deliberately. The archive IS the gate's receipt:
|
|
343
|
+
// runArchive refuses `review-not-approved` itself, so a feature that reached
|
|
344
|
+
// `archive/` either passed Gate 3 or was explicitly forced past it. Re-reading
|
|
345
|
+
// it here would only add a way to fail — an archive whose feature.json is
|
|
346
|
+
// absent or unparseable (legacy shapes, a forced close) parses to null and
|
|
347
|
+
// falls straight through to `review-not-approved`, which is the exact
|
|
348
|
+
// collapsed diagnosis the `feature-not-found` fix removed. Resume integrates
|
|
349
|
+
// what was already archived; it does not re-litigate why.
|
|
350
|
+
const fj = readFeatureJson(featureCwd, feature, resumed ? 'archive' : 'features');
|
|
351
|
+
if (!resumed && !options.force && !(fj?.approvalGates?.review?.approved === true || fj?.approvals?.review?.approved === true)) {
|
|
233
352
|
emit({ finished: false, error: 'review-not-approved', feature });
|
|
234
353
|
process.exit(1);
|
|
235
354
|
}
|
|
@@ -399,7 +518,7 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
|
|
|
399
518
|
}
|
|
400
519
|
tryGit(['branch', '-d', expectedBranch], root);
|
|
401
520
|
// The root index (gitignored) was already updated by the archive (same cwd).
|
|
402
|
-
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 });
|
|
403
522
|
process.exit(0);
|
|
404
523
|
}
|
|
405
524
|
|
|
@@ -477,6 +596,15 @@ function promotedNote(arch) {
|
|
|
477
596
|
};
|
|
478
597
|
}
|
|
479
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
|
+
|
|
480
608
|
function mergeOutput({ feature, branch, worktreePath, teardown, shedBackup }) {
|
|
481
609
|
return {
|
|
482
610
|
finished: true,
|
|
@@ -484,6 +612,7 @@ function mergeOutput({ feature, branch, worktreePath, teardown, shedBackup }) {
|
|
|
484
612
|
feature,
|
|
485
613
|
merged: branch,
|
|
486
614
|
worktreeRemoved: teardown.worktreeRemoved,
|
|
615
|
+
nextStep: MERGE_NEXT_STEP,
|
|
487
616
|
...(shedBackup ? { shedBackup } : {}),
|
|
488
617
|
...teardownExtras(teardown, worktreePath, branch, true),
|
|
489
618
|
};
|
|
@@ -627,9 +756,67 @@ function featureElsewhereHint(featureCwd, root, feature) {
|
|
|
627
756
|
return { hint: `'${feature}' não existe em .morph/features/ nesta árvore. Confira o nome com \`morph-spec list --all\`.` };
|
|
628
757
|
}
|
|
629
758
|
|
|
630
|
-
|
|
759
|
+
/**
|
|
760
|
+
* @param {string} cwd
|
|
761
|
+
* @param {string} feature
|
|
762
|
+
* @param {'features'|'archive'} [bucket] - `archive` on a resume, where the
|
|
763
|
+
* feature.json has already been moved (and stamped `completed`).
|
|
764
|
+
*/
|
|
765
|
+
function readFeatureJson(cwd, feature, bucket = 'features') {
|
|
766
|
+
try {
|
|
767
|
+
return JSON.parse(readFileSync(join(cwd, '.morph', bucket, feature, 'feature.json'), 'utf8'));
|
|
768
|
+
} catch {
|
|
769
|
+
return null;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Is `branch` already contained in `target` — i.e. did an earlier finish get its
|
|
775
|
+
* merge in?
|
|
776
|
+
*
|
|
777
|
+
* Three-valued ON PURPOSE. `git merge-base --is-ancestor` answers by exit code,
|
|
778
|
+
* and the two failure modes mean opposite things: **1** is a real "no, not
|
|
779
|
+
* merged", while **128** is "one of these refs does not resolve" and proves
|
|
780
|
+
* nothing at all. Collapsing them to a boolean would read an unresolvable ref as
|
|
781
|
+
* "not merged" and let a resume re-merge a completed feature.
|
|
782
|
+
*
|
|
783
|
+
* @param {string} branch
|
|
784
|
+
* @param {string} target
|
|
785
|
+
* @param {string} cwd
|
|
786
|
+
* @returns {boolean|null} null when the comparison could not be made
|
|
787
|
+
*/
|
|
788
|
+
export function branchMergedInto(branch, target, cwd) {
|
|
789
|
+
try {
|
|
790
|
+
execFileSync('git', ['merge-base', '--is-ancestor', branch, target], { cwd, stdio: 'pipe' });
|
|
791
|
+
return true;
|
|
792
|
+
} catch (err) {
|
|
793
|
+
return err?.status === 1 ? false : null;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Does `branch` already have an OPEN pull request? The `--pr` half of
|
|
799
|
+
* "already integrated" — a completed `--pr` leaves the branch unmerged on
|
|
800
|
+
* purpose, so `branchMergedInto` cannot see it.
|
|
801
|
+
*
|
|
802
|
+
* Three-valued for the same reason: an unanswerable query (no `gh`, no auth, no
|
|
803
|
+
* network) is not evidence of "no PR", and treating it as such would resume
|
|
804
|
+
* into a duplicate `gh pr create`.
|
|
805
|
+
*
|
|
806
|
+
* @param {string} branch
|
|
807
|
+
* @param {string} cwd
|
|
808
|
+
* @returns {string|boolean|null} the PR url, `true` (open PR, url unavailable),
|
|
809
|
+
* `false` (none open), or `null` when the question could not be asked
|
|
810
|
+
*/
|
|
811
|
+
function openPrFor(branch, cwd) {
|
|
812
|
+
const { bin, prefix } = resolveGh();
|
|
631
813
|
try {
|
|
632
|
-
|
|
814
|
+
const out = execFileSync(bin, [...prefix, 'pr', 'list', '--head', branch, '--state', 'open', '--json', 'url'], { cwd, stdio: 'pipe' })
|
|
815
|
+
.toString()
|
|
816
|
+
.trim();
|
|
817
|
+
const arr = JSON.parse(out || '[]');
|
|
818
|
+
if (!Array.isArray(arr) || !arr.length) return false;
|
|
819
|
+
return typeof arr[0]?.url === 'string' ? arr[0].url : true;
|
|
633
820
|
} catch {
|
|
634
821
|
return null;
|
|
635
822
|
}
|
|
@@ -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.',
|