@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.
Files changed (43) hide show
  1. package/README.md +2 -2
  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 +1 -1
  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 +4 -4
  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-workflow.md +42 -29
  21. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  22. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  23. package/framework/skills/core/morph-apply/SKILL.md +6 -6
  24. package/framework/skills/core/morph-brainstorming/SKILL.md +2 -2
  25. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  26. package/framework/skills/core/morph-hotfix/SKILL.md +7 -7
  27. package/framework/skills/core/morph-implement/SKILL.md +21 -6
  28. package/framework/skills/core/morph-plan/SKILL.md +15 -3
  29. package/framework/skills/core/morph-proposal/SKILL.md +7 -7
  30. package/framework/skills/core/morph-review/SKILL.md +10 -3
  31. package/framework/skills/core/morph-stack-scan/SKILL.md +3 -3
  32. package/framework/skills/core/morph-standards/SKILL.md +4 -4
  33. package/framework/skills/core/morph-uiux/SKILL.md +3 -3
  34. package/framework/templates/README.md +72 -643
  35. package/framework/templates/examples/spec-examples.md +2 -1
  36. package/package.json +1 -1
  37. package/src/commands/project/claim.js +19 -3
  38. package/src/commands/state/approve.js +17 -3
  39. package/src/commands/state/finish.js +202 -15
  40. package/src/commands/state/gate-check.js +9 -20
  41. package/src/commands/state/report-check.js +157 -5
  42. package/src/lib/evaluator-report.js +72 -0
  43. 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
- **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.24.3",
3
+ "version": "8.26.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 —'));
@@ -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 BE there. Without this, a missing
215
- // feature.json makes the optional chaining below fall through to
216
- // `review-not-approved`, which collapses three different situations into one
217
- // wrong diagnosis: gate genuinely unapproved, feature already archived, and
218
- // feature living on a branch we are not on. The second one is what a user
219
- // hits when retrying an interrupted finish, and being told "Gate 3 not
220
- // approved" about an approved gate sends them looking in the wrong place.
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
- emit({ finished: false, error: 'feature-not-found', feature, ...featureElsewhereHint(featureCwd, root, feature) });
227
- process.exit(1);
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
- const fj = readFeatureJson(featureCwd, feature);
232
- if (!options.force && !(fj?.approvalGates?.review?.approved === true || fj?.approvals?.review?.approved === true)) {
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: null, ...ucNote });
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
- function readFeatureJson(cwd, feature) {
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
- return JSON.parse(readFileSync(join(cwd, '.morph', 'features', feature, 'feature.json'), 'utf8'));
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) 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.',