@polymorphism-tech/morph-spec 8.37.0 → 8.38.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 (130) hide show
  1. package/CLAUDE.md +19 -6
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +16 -7
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +3 -3
  8. package/framework/MORPH.md +25 -22
  9. package/framework/agents.json +76 -27
  10. package/framework/docs/CLI.md +55 -18
  11. package/framework/evals/rubrics/code-quality.md +5 -0
  12. package/framework/evals/rubrics/condensed.md +6 -2
  13. package/framework/evals/rubrics/contracts.md +20 -0
  14. package/framework/evals/rubrics/test-coverage.md +17 -6
  15. package/framework/hooks/README.md +9 -3
  16. package/framework/hooks/claude-code/core/graph-refresh-kick.js +52 -0
  17. package/framework/hooks/claude-code/core/telemetry-log.js +172 -87
  18. package/framework/hooks/claude-code/core/validate-completion.js +16 -90
  19. package/framework/hooks/claude-code/core/worktree-context.js +10 -0
  20. package/framework/hooks/shared/command-tokens.js +96 -0
  21. package/framework/hooks/shared/git-exclude.js +44 -0
  22. package/framework/hooks/shared/git-paths.js +52 -0
  23. package/framework/hooks/shared/graph-freshness.js +380 -0
  24. package/framework/hooks/shared/graph-refresh.js +416 -0
  25. package/framework/hooks/shared/morph-cli.js +17 -5
  26. package/framework/hooks/shared/redact-secrets.js +55 -0
  27. package/framework/hooks/shared/telemetry-logger.js +25 -2
  28. package/framework/hooks/shared/transcript-usage.js +80 -0
  29. package/framework/rubrics/backend.json +2 -2
  30. package/framework/rubrics/default.json +2 -2
  31. package/framework/rubrics/frontend.json +2 -2
  32. package/framework/rules/csharp-standards.md +1 -0
  33. package/framework/rules/morph-workflow.md +1 -0
  34. package/framework/rules/nextjs-standards.md +1 -0
  35. package/framework/schemas/config.schema.json +41 -1
  36. package/framework/schemas/feature.schema.json +5 -1
  37. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  38. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  39. package/framework/skills/core/morph-apply/SKILL.md +36 -11
  40. package/framework/skills/core/morph-brainstorming/SKILL.md +16 -7
  41. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  42. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  43. package/framework/skills/core/morph-implement/SKILL.md +39 -10
  44. package/framework/skills/core/morph-media/SKILL.md +1 -1
  45. package/framework/skills/core/morph-plan/SKILL.md +72 -15
  46. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  47. package/framework/skills/core/morph-review/SKILL.md +51 -13
  48. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  49. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  50. package/framework/skills/core/morph-uiux/SKILL.md +34 -9
  51. package/framework/standards/STANDARDS.json +187 -12
  52. package/framework/standards/ai-agents/rag-custom-pgvector.md +7 -3
  53. package/framework/standards/ai-agents/testing-ai.md +2 -1
  54. package/framework/standards/architecture/code-comments.md +64 -0
  55. package/framework/standards/backend/dotnet/testing.md +22 -0
  56. package/framework/standards/frontend/admin-app/data-table.md +562 -0
  57. package/framework/standards/frontend/admin-app/feedback.md +236 -0
  58. package/framework/standards/frontend/admin-app/forms.md +232 -0
  59. package/framework/standards/frontend/admin-app/page-structure.md +203 -0
  60. package/framework/standards/frontend/nextjs/components.md +37 -58
  61. package/framework/standards/frontend/nextjs/data-fetching.md +1 -0
  62. package/framework/standards/frontend/nextjs/forms.md +31 -30
  63. package/framework/standards/frontend/nextjs/state-management.md +2 -1
  64. package/framework/standards/frontend/nextjs/testing.md +18 -0
  65. package/framework/templates/context/design-system.md +3 -3
  66. package/framework/templates/docs/proposal.md +11 -0
  67. package/framework/templates/docs/spec.md +15 -0
  68. package/framework/templates/docs/ui-mockups.md +115 -98
  69. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaPlan.cs +12 -6
  70. package/framework/templates/feature/decisions.md +124 -122
  71. package/package.json +1 -1
  72. package/src/commands/graph/affected.js +4 -3
  73. package/src/commands/graph/build.js +109 -18
  74. package/src/commands/graph/explain.js +8 -5
  75. package/src/commands/graph/status.js +14 -8
  76. package/src/commands/observability/cost.js +148 -18
  77. package/src/commands/project/doctor.js +82 -10
  78. package/src/commands/project/update.js +25 -0
  79. package/src/commands/project/worktree.js +73 -90
  80. package/src/commands/state/advance.js +18 -0
  81. package/src/commands/state/approve.js +15 -0
  82. package/src/commands/state/archive.js +62 -9
  83. package/src/commands/state/finish.js +62 -14
  84. package/src/commands/state/gate-check.js +8 -1
  85. package/src/commands/state/gate-decision.js +8 -4
  86. package/src/commands/state/report-check.js +90 -9
  87. package/src/commands/state/usecase.js +66 -0
  88. package/src/commands/tasks/dag.js +17 -2
  89. package/src/commands/validation/validate-feature.js +6 -1
  90. package/src/commands/validation/verify.js +20 -1
  91. package/src/dashboard/scanner/agents-scanner.js +5 -1
  92. package/src/dashboard/scanner/hooks-scanner.js +8 -1
  93. package/src/dashboard/scanner/standards-scanner.js +2 -1
  94. package/src/lib/ai/comment-stripper.js +101 -4
  95. package/src/lib/evaluator-report.js +25 -0
  96. package/src/lib/generators/handoff-generator.js +4 -18
  97. package/src/lib/git/default-branch.js +52 -0
  98. package/src/lib/git/tree-fingerprint.js +58 -0
  99. package/src/lib/git/tree-state.js +167 -0
  100. package/src/lib/graph/god-nodes.js +12 -3
  101. package/src/lib/graph/graph-health.js +48 -2
  102. package/src/lib/graph/graph-loader.js +99 -25
  103. package/src/lib/graph/graphifyignore.js +145 -28
  104. package/src/lib/graph/sanitize.js +117 -11
  105. package/src/lib/risk-detector.js +90 -0
  106. package/src/lib/specs/acervo-format.js +70 -0
  107. package/src/lib/specs/acervo-lifecycle.js +264 -0
  108. package/src/lib/specs/adr-index.js +169 -0
  109. package/src/lib/specs/revocations.js +156 -0
  110. package/src/lib/specs/screen-checklist.js +357 -0
  111. package/src/lib/specs/usecase-check.js +20 -16
  112. package/src/lib/specs/usecase-promoter.js +103 -38
  113. package/src/lib/specs/usecase-registry.js +7 -7
  114. package/src/lib/tasks/tasks-json-parser.js +2 -3
  115. package/src/lib/telemetry/metrics.js +386 -0
  116. package/src/lib/telemetry/telemetry-reader.js +161 -51
  117. package/src/lib/telemetry/verify-event.js +79 -0
  118. package/src/lib/validators/code-budget/code-budget-validator.js +172 -0
  119. package/src/lib/validators/code-budget/comment-scanner.js +75 -0
  120. package/src/lib/validators/code-budget/index.js +9 -0
  121. package/src/lib/validators/reuse/reuse-validator.js +4 -2
  122. package/src/lib/validators/reuse/symbol-scanner.js +27 -22
  123. package/src/lib/validators/validation-runner.js +21 -5
  124. package/src/lib/validators/verify-runner.js +11 -0
  125. package/src/lib/worktree/finish-backup.js +739 -0
  126. package/src/lib/worktree/ignored-triage.js +15 -8
  127. package/src/lib/worktree/worktree-audit.js +5 -0
  128. package/src/scripts/setup-infra.js +29 -18
  129. package/src/utils/agents-installer.js +281 -12
  130. package/src/utils/hooks-installer.js +75 -15
@@ -21,6 +21,8 @@ import { releaseClaim } from '../../../framework/hooks/shared/claim-registry.js'
21
21
  import { tearDownFeatureStack } from '../../lib/e2e/stack-teardown.js';
22
22
  import { findReparsePoints } from '../../../framework/hooks/shared/reparse-scan.js';
23
23
  import { runArchive } from './archive.js';
24
+ import { dropLeftoverIgnore, withoutLeftoverIgnore } from '../../../framework/hooks/shared/graph-freshness.js';
25
+ import { newBackupStamp, enforceBackupRetention, inspectFinishBackups } from '../../lib/worktree/finish-backup.js';
24
26
 
25
27
  /** workType → conventional-commit type. `hotfix` IS a fix; anything else is a chore. */
26
28
  const PR_TYPE_BY_WORK_TYPE = { feature: 'feat', bug: 'fix', hotfix: 'fix', chore: 'chore' };
@@ -119,6 +121,14 @@ export async function finishCommand(feature, options = {}) {
119
121
  // would otherwise get a silent merge they never asked for.
120
122
  let resumed = false;
121
123
 
124
+ // ONE stamp for every backup this run writes: the shed's and the teardown's
125
+ // folders then count as a single backup under the retention.
126
+ const backupStamp = newBackupStamp();
127
+
128
+ // The backup retention rides the same single point, AFTER this run's own
129
+ // backups exist (the shed and the teardown both precede every success emit).
130
+ // This run's stamp is protected: the prune never takes the backup it just
131
+ // made room for, even when backups stamped by a clock running ahead outrank it.
122
132
  const emit = (obj) => {
123
133
  if (resumed) obj = { ...obj, resumed: true };
124
134
  if (obj?.finished === true) {
@@ -126,6 +136,8 @@ export async function finishCommand(feature, options = {}) {
126
136
  const common = execFileSync('git', ['rev-parse', '--git-common-dir'], { cwd: root, stdio: 'pipe' }).toString().trim();
127
137
  if (common) releaseClaim(feature, { gitCommonDir: resolve(root, common) });
128
138
  } catch { /* fail-open */ }
139
+ const retention = enforceBackupRetention(root, { protectStamps: [backupStamp] });
140
+ if (retention) obj = { ...obj, backupRetention: retention };
129
141
  }
130
142
  console.log(JSON.stringify(obj));
131
143
  };
@@ -413,7 +425,7 @@ export async function finishCommand(feature, options = {}) {
413
425
  emit({ finished: false, error: 'status-failed', feature });
414
426
  process.exit(1);
415
427
  }
416
- if (dirty.trim() !== '') {
428
+ if (withoutLeftoverIgnore(cleanCwd, dirty.split(/\r?\n/).filter((l) => l.trim())).length) {
417
429
  emit({ finished: false, error: isWorktreeFlow ? 'worktree-dirty' : 'working-dir-dirty', hint: 'commit seu trabalho na branch antes do finish', feature });
418
430
  process.exit(1);
419
431
  }
@@ -475,6 +487,9 @@ export async function finishCommand(feature, options = {}) {
475
487
  }
476
488
 
477
489
  // ── ARCHIVE (inside morph/{f}) ────────────────────────────────────────────
490
+ // Every refusal is behind us: the leftover of a killed `graph refresh` goes now,
491
+ // before the archive — never before a precondition that could still refuse.
492
+ dropLeftoverIgnore(cleanCwd);
478
493
  const arch = runArchive(feature, { cwd: featureCwd, force: options.force, git: true });
479
494
  if (!arch.ok) {
480
495
  emit({ finished: false, error: `archive-${arch.error}`, feature });
@@ -489,9 +504,9 @@ export async function finishCommand(feature, options = {}) {
489
504
 
490
505
  // ── INTEGRATE ─────────────────────────────────────────────────────────────
491
506
  if (mode === 'merge') {
492
- return integrateMerge({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, arch });
507
+ return integrateMerge({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, arch, backupStamp });
493
508
  }
494
- return integratePr({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, prTitle, prBody, arch });
509
+ return integratePr({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, prTitle, prBody, arch, backupStamp });
495
510
  }
496
511
 
497
512
  /**
@@ -582,6 +597,30 @@ export function finishPreflight({ feature, root, worktreePath, cwd, mode, branch
582
597
 
583
598
  if (defaultBranch) out.defaultBranch = defaultBranch;
584
599
 
600
+ // A retenção dos backups também é mutação do finish real — a AUTOMÁTICA, que
601
+ // não toca backup sem manifesto (esses só o `doctor --worktrees --prune`
602
+ // poda). A contagem é a de AGORA: o backup que este finish gravar ainda pode
603
+ // empurrar mais um para fora.
604
+ try {
605
+ const retention = inspectFinishBackups(root);
606
+ if (retention.policy.skipped) {
607
+ out.backupRetention = { skipped: retention.policy.skipped };
608
+ warnings.push(`a retenção dos backups do finish será PULADA: ${retention.policy.skipped}`);
609
+ } else if (retention.outsideRetention || retention.overdueSecrets.length || retention.legacy) {
610
+ out.backupRetention = {
611
+ outsideRetention: retention.outsideRetention,
612
+ overdueSecrets: retention.overdueSecrets.length,
613
+ ...(retention.legacy ? { legacyPending: retention.legacy } : {}),
614
+ };
615
+ if (retention.outsideRetention || retention.overdueSecrets.length) {
616
+ out.wouldDo.push(
617
+ `aplicar a retenção de .git/morph-spec/finish-backup: apagar ${retention.outsideRetention} backup(s) fora da política`
618
+ + ` e ${retention.overdueSecrets.length} arquivo(s) .env* além de ${retention.policy.secretMaxAgeDays} dias`,
619
+ );
620
+ }
621
+ }
622
+ } catch { /* o pré-voo nunca lança */ }
623
+
585
624
  // ── Seções de worktree — omitidas quando não há worktree (UC-16 A1) ───────
586
625
  if (worktreePath) {
587
626
  out.worktreePath = worktreePath.replace(/\\/g, '/');
@@ -664,14 +703,14 @@ export function finishPreflight({ feature, root, worktreePath, cwd, mode, branch
664
703
 
665
704
  // ── merge integration (local, no push) ───────────────────────────────────────
666
705
 
667
- async function integrateMerge({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, arch }) {
706
+ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, arch, backupStamp }) {
668
707
  const mergeMsg = `morph-spec finish: merge ${expectedBranch}`;
669
708
  const ucNote = promotedNote(arch);
670
709
 
671
710
  if (isWorktreeFlow) {
672
711
  // The root stays on the default; shed this feature's own root-side scaffold,
673
712
  // then guard the whole root before landing the merge commit.
674
- const { shedBackup } = shedRootFeatureScope(root, feature);
713
+ const { shedBackup } = shedRootFeatureScope(root, feature, { stamp: backupStamp });
675
714
  const backupNote = shedBackup ? { shedBackup } : {};
676
715
 
677
716
  // Final gate before the merge commit lands. The pre-archive check above
@@ -721,7 +760,7 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
721
760
  const deferred = sessionAnchoredInWorktree(worktreePath);
722
761
  const teardown = deferred
723
762
  ? null
724
- : await teardownWorktree(worktreePath, { root, feature, branch: expectedBranch, options, deleteBranch: true });
763
+ : await teardownWorktree(worktreePath, { root, feature, branch: expectedBranch, options, deleteBranch: true, stamp: backupStamp });
725
764
  dropFromRootIndex(root, feature);
726
765
  emit({ ...mergeOutput({ feature, branch: expectedBranch, defaultBranch, publish, worktreePath, teardown, shedBackup, deferred }), ...ucNote, e2e });
727
766
  process.exit(0);
@@ -776,7 +815,7 @@ export function prNumberFromUrl(url) {
776
815
 
777
816
  // ── PR integration (push + gh, no local merge) ───────────────────────────────
778
817
 
779
- async function integratePr({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, prTitle, prBody, arch }) {
818
+ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, prTitle, prBody, arch, backupStamp }) {
780
819
  try {
781
820
  execFileSync('git', ['push', '-u', 'origin', expectedBranch], { cwd: root, stdio: 'pipe' });
782
821
  } catch (err) {
@@ -812,13 +851,13 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
812
851
  // later merges (carrying features/{f} → archive/{f}), the default would hold
813
852
  // the feature in BOTH features/ and archive/ at once — active and archived.
814
853
  // The branch/PR owns the authoritative copy; the root scaffold is transient.
815
- shedBackup = shedRootFeatureScope(root, feature).shedBackup;
854
+ shedBackup = shedRootFeatureScope(root, feature, { stamp: backupStamp }).shedBackup;
816
855
  // Same deferral as --merge: never tear down the worktree the running
817
856
  // session is anchored in (see sessionAnchoredInWorktree).
818
857
  if (sessionAnchoredInWorktree(worktreePath)) {
819
858
  teardownDeferred = true;
820
859
  } else {
821
- teardown = await teardownWorktree(worktreePath, { root, feature, branch: expectedBranch, options, deleteBranch: false });
860
+ teardown = await teardownWorktree(worktreePath, { root, feature, branch: expectedBranch, options, deleteBranch: false, stamp: backupStamp });
822
861
  }
823
862
  // The shed removed the root's features/{f} scaffold from disk, but runArchive
824
863
  // dropped the feature only from the WORKTREE's index (its cwd). Drop it from
@@ -874,16 +913,25 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
874
913
  // ── output helpers ────────────────────────────────────────────────────────
875
914
 
876
915
  /**
877
- * Report the use cases the archive promoted into `.morph/specs/usecases/`, and
878
- * any that were REFUSED for a numbering collision (`ucCollisions` — see
879
- * `usecase-promoter.js`'s reservation-backed guard). Both absent when there is
880
- * nothing to report, so a feature with no use cases keeps the exact output
881
- * shape it had before either existed.
916
+ * Report what the archive did to the acervo: use cases promoted into
917
+ * `.morph/specs/usecases/`, any REFUSED for a numbering collision
918
+ * (`ucCollisions` — see `usecase-promoter.js`'s reservation-backed guard), the
919
+ * promoted copies that stayed superseded because the acervo said so
920
+ * (`keptSuperseded`), the ones the feature's spec.md revoked
921
+ * (`supersededUseCases`), the ADR index files it moved (`adrIndex`) and the
922
+ * revocations it could not apply
923
+ * (`revocationProblems`). Every key is absent when there is nothing to report,
924
+ * so a feature that touches no acervo keeps the exact output shape it had
925
+ * before any of them existed.
882
926
  */
883
927
  function promotedNote(arch) {
884
928
  return {
885
929
  ...(arch?.promotedUseCases?.length ? { promotedUseCases: arch.promotedUseCases } : {}),
886
930
  ...(arch?.refusedUseCases?.length ? { ucCollisions: arch.refusedUseCases } : {}),
931
+ ...(arch?.keptSuperseded?.length ? { keptSuperseded: arch.keptSuperseded } : {}),
932
+ ...(arch?.supersededUseCases?.length ? { supersededUseCases: arch.supersededUseCases } : {}),
933
+ ...(arch?.adrIndex ? { adrIndex: arch.adrIndex } : {}),
934
+ ...(arch?.revocationProblems?.length ? { revocationProblems: arch.revocationProblems } : {}),
887
935
  };
888
936
  }
889
937
 
@@ -4,6 +4,7 @@ import { getFeature } from '../../core/state/state-manager.js';
4
4
  import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
5
5
  import { shouldPauseGate, getRiskSignals, classifyE2eSkip } from '../../lib/risk-detector.js';
6
6
  import { evaluatorCompositeScore, evaluatorProofState, minProofScore, mediaProofScore } from '../../lib/evaluator-report.js';
7
+ import { collectScreenChecklist } from '../../lib/specs/screen-checklist.js';
7
8
  import { persistGateDecision, VALID_GATES, readTrustFromConfig } from './gate-decision.js';
8
9
  import { minimatch } from 'minimatch';
9
10
  import { detectDefaultBranch, getHeadSha, getDiffStat, changedPathsAgainst } from '../../lib/git/default-branch.js';
@@ -195,6 +196,12 @@ function collectSignals(featureName, feature, cwd, gateName) {
195
196
  designSystemDivergence: false,
196
197
  };
197
198
 
199
+ // Só onde o checklist decide algo: no fecho do design e no ship. Fato de
200
+ // disco, fora de OVERRIDABLE_SIGNAL_KEYS como `mutationProof`.
201
+ if (gateName === 'uiux' || gateName === 'review') {
202
+ signals.screenChecklist = collectScreenChecklist(cwd, featureName, gateName);
203
+ }
204
+
198
205
  const evalScore = compositeEvalScore(feature);
199
206
  if (evalScore !== undefined) {
200
207
  signals.evalScore = evalScore;
@@ -392,7 +399,7 @@ export async function gateCheckCommand(featureName, gateName, options = {}) {
392
399
  const trust = options.trust || readTrustFromConfig();
393
400
  const { pause, reason } = shouldPauseGate({ trust }, signals);
394
401
 
395
- const entry = await persistGateDecision(featureName, gateName, { pause, reason, signals, trust });
402
+ const entry = await persistGateDecision(featureName, gateName, { pause, reason, signals, trust, cwd });
396
403
 
397
404
  if (options.json) {
398
405
  console.log(JSON.stringify({ feature: featureName, gate: gateName, ...entry }, null, 2));
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { getFeature, updateFeature } from '../../core/state/state-manager.js';
5
5
  import { assertOwnerTree, ownerTreeRefusalLines } from '../../lib/worktree/feature-truth.js';
6
+ import { mockupsHash } from '../../lib/specs/screen-checklist.js';
6
7
 
7
8
  export const VALID_GATES = ['proposal', 'uiux', 'plan', 'review'];
8
9
 
@@ -42,17 +43,20 @@ export function readTrustFromConfig() {
42
43
  *
43
44
  * @param {string} featureName
44
45
  * @param {string} gateName
45
- * @param {{ pause: boolean, reason?: string, signals?: object, trust?: string }} decision
46
- * @returns {Promise<{ pause: boolean, reason: string, signals: object, trust: string, decidedAt: string }>}
47
- * the exact entry written (decidedAt is server-side, generated here).
46
+ * @param {{ pause: boolean, reason?: string, signals?: object, trust?: string, cwd?: string }} decision
47
+ * @returns {Promise<{ pause: boolean, reason: string, signals: object, trust: string, decidedAt: string, mockupsHash?: string|null }>}
48
+ * the exact entry written (decidedAt is server-side, generated here). A uiux
49
+ * decision also carries the hash of the mockup it saw: it answers that
50
+ * mockup's pause, not the next one's.
48
51
  */
49
- export async function persistGateDecision(featureName, gateName, { pause, reason, signals = {}, trust } = {}) {
52
+ export async function persistGateDecision(featureName, gateName, { pause, reason, signals = {}, trust, cwd = process.cwd() } = {}) {
50
53
  const entry = {
51
54
  pause,
52
55
  reason: reason || (pause ? 'risco detectado' : 'nenhum sinal de risco'),
53
56
  signals,
54
57
  trust: trust || readTrustFromConfig(),
55
58
  decidedAt: new Date().toISOString(),
59
+ ...(gateName === 'uiux' ? { mockupsHash: mockupsHash(cwd, featureName) } : {}),
56
60
  };
57
61
 
58
62
  await updateFeature(featureName, `gateDecisions.${gateName}`, entry);
@@ -23,12 +23,18 @@
23
23
  *
24
24
  * Exit 0 only for `ok` — anything needing an action exits non-zero so a caller
25
25
  * can branch on it. `--json` always prints the machine-readable verdict.
26
+ *
27
+ * The output also carries `tree` (see tree-state.js): the working tree the
28
+ * orchestrator would otherwise ask git for, call by call, right after the same
29
+ * dispatch. It is information only and never changes the verdict.
26
30
  */
27
31
 
28
32
  import chalk from 'chalk';
29
33
  import { existsSync, readFileSync, statSync } from 'fs';
30
34
  import { join } from 'path';
31
- import { readTelemetryEvents } from '../../../framework/hooks/shared/telemetry-logger.js';
35
+ import { readTelemetryEvents, isFailedToolCall } from '../../../framework/hooks/shared/telemetry-logger.js';
36
+ import { treeState, snapshotTree } from '../../lib/git/tree-state.js';
37
+ import { atomicWriteJson } from '../../core/state/state-manager.js';
32
38
 
33
39
  /**
34
40
  * The four mandatory items of the report contract, as detectable markers.
@@ -51,6 +57,16 @@ const REPORT_MARKERS = [
51
57
  /** (1) "what was done" — prose beyond the structural markers. */
52
58
  const MIN_PROSE_CHARS = 80;
53
59
 
60
+ /**
61
+ * The partial-report marker of the dispatch contract (clause 5, ADR-004): a
62
+ * sub-agent nearing its `maxTurns` ceiling writes `PARCIAL` on the FIRST line.
63
+ * Read on purpose, never inferred from a missing score: the score marker above
64
+ * is loose by design, and a partial saying "auto-score: pendente (0-10 no
65
+ * final)" matches it — with the other items present it would audit as `ok`.
66
+ * Tolerates heading/bold/quote typography (`# PARCIAL`, `**PARCIAL**`).
67
+ */
68
+ const PARTIAL_MARKER = /^[\s#>*_]*PARCIAL\b/;
69
+
54
70
  function reportPath(cwd, feature, taskId) {
55
71
  return join(cwd, '.morph', 'features', feature, '3-implement', 'reports', `${taskId}.md`);
56
72
  }
@@ -99,7 +115,11 @@ export function inspectReport(path) {
99
115
  .trim();
100
116
  if (prose.length < MIN_PROSE_CHARS) missing.unshift('what');
101
117
 
102
- return { exists: true, complete: missing.length === 0, missing };
118
+ const firstLine = body.split('\n').find((l) => l.trim() !== '') ?? '';
119
+ const partial = PARTIAL_MARKER.test(firstLine);
120
+ if (partial) missing.push('partial');
121
+
122
+ return { exists: true, complete: missing.length === 0, missing, partial };
103
123
  }
104
124
 
105
125
  /** How many of a task's declared outputs actually exist on disk. */
@@ -206,6 +226,7 @@ function observedEvidence(cwd, feature, task) {
206
226
  const writes = [];
207
227
  for (const e of events) {
208
228
  if (e?.tool !== 'Write' && e?.tool !== 'Edit') continue;
229
+ if (isFailedToolCall(e)) continue;
209
230
  const ts = Date.parse(e?.ts ?? '');
210
231
  if (!Number.isFinite(ts) || ts < startMs || ts > endMs) continue;
211
232
  const target = toRepoRelative(e.target, cwd);
@@ -249,6 +270,10 @@ function decide(report, evidence, stale) {
249
270
  ? { verdict: 'report-stale', action: 'refresh-from-evidence' }
250
271
  : { verdict: 'ok', action: 'proceed' };
251
272
  }
273
+ // A PARCIAL report is an agent that stopped short (turn ceiling): the work
274
+ // resumes from its last `wip` commit — completing it DEGRADADO would stamp
275
+ // unfinished work as done.
276
+ if (report.exists && report.partial) return { verdict: 'report-incomplete', action: 'resume' };
252
277
  if (report.exists) return { verdict: 'report-incomplete', action: 'complete-degraded' };
253
278
  if (evidence.outputsExpected === 0) {
254
279
  // No declared outputs means no evidence either way. Re-dispatching costs a
@@ -273,13 +298,36 @@ const ACTION_HINT = {
273
298
  proceed: 'relatório íntegro — siga o fluxo normal.',
274
299
  '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.',
275
300
  '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.',
301
+ resume: 'relatório PARCIAL: o sub-agent parou antes do fim (limite de turnos) e a task NÃO está pronta. Não carimbe DEGRADADO nem pontue — retome o MESMO agente por `SendMessage` (cole no recado `tree.dirty` e `tree.commits` desta saída e o que falta) ou re-despache só o que falta, a partir do último commit `wip`.',
276
302
  '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.',
277
303
  redispatch: 're-despache a task UMA vez com o mesmo prompt. Segunda ausência sem trabalho entregue = falha real: escale ao humano.',
278
304
  };
279
305
 
306
+ function snapshotPath(cwd, feature) {
307
+ return join(cwd, '.morph', 'features', feature, '4-review', 'tree-before-eval.json');
308
+ }
309
+
310
+ /**
311
+ * The tree block, compared with the snapshot taken before the evaluator was
312
+ * dispatched when there is one. `beforeEval` takes a new snapshot after the
313
+ * comparison, so the output still shows what changed since the previous one.
314
+ */
315
+ function treeFor(cwd, feature, beforeEval) {
316
+ const path = snapshotPath(cwd, feature);
317
+ let snapshot = null;
318
+ try { if (existsSync(path)) snapshot = JSON.parse(readFileSync(path, 'utf8')); } catch { snapshot = null; }
319
+ const tree = treeState(cwd, { snapshot });
320
+ if (!beforeEval) return { tree };
321
+ const next = snapshotTree(cwd);
322
+ if (!next) return { tree, beforeEval: null };
323
+ atomicWriteJson(path, next);
324
+ return { tree, beforeEval: { at: next.at, files: Object.keys(next.files).length } };
325
+ }
326
+
280
327
  /**
281
328
  * @param {string} feature
282
- * @param {string|undefined} taskId
329
+ * @param {string|string[]|undefined} taskId - several ids audit a parallel
330
+ * batch whose tasks are not `done` yet, which the whole-feature mode skips
283
331
  * @param {{ json?: boolean, cwd?: string }} [options]
284
332
  */
285
333
  export function reportCheckCommand(feature, taskId, options = {}) {
@@ -303,18 +351,26 @@ export function reportCheckCommand(feature, taskId, options = {}) {
303
351
  emit({ error: `2-plan/tasks.json de "${feature}" está corrompido`, feature }, 1);
304
352
  }
305
353
 
354
+ if (Array.isArray(taskId)) {
355
+ const unknown = taskId.filter((id) => !tasks.some((t) => t.id === id));
356
+ if (unknown.length) emit({ error: `task(s) ${unknown.join(', ')} não existe(m) em tasks.json`, feature }, 1);
357
+ const results = taskId.map((id) => checkTask(cwd, feature, tasks.find((t) => t.id === id)));
358
+ const clean = results.every((r) => r.verdict === 'ok');
359
+ emit({ feature, results, clean, ...treeFor(cwd, feature, options.beforeEval) }, clean ? 0 : 1);
360
+ }
361
+
306
362
  if (taskId) {
307
363
  const task = tasks.find((t) => t.id === taskId);
308
364
  if (!task) emit({ error: `task "${taskId}" não existe em tasks.json`, feature }, 1);
309
365
  const result = checkTask(cwd, feature, task);
310
- emit({ feature, ...result, hint: ACTION_HINT[result.action] }, result.verdict === 'ok' ? 0 : 1);
366
+ emit({ feature, ...result, hint: ACTION_HINT[result.action], ...treeFor(cwd, feature, options.beforeEval) }, result.verdict === 'ok' ? 0 : 1);
311
367
  }
312
368
 
313
369
  // Whole-feature audit: every DONE task owes a report; a pending one does not.
314
370
  const results = tasks.filter((t) => t.status === 'done').map((t) => checkTask(cwd, feature, t));
315
371
  const evaluator = inspectReport(evaluatorPath(cwd, feature));
316
372
  const clean = results.every((r) => r.verdict === 'ok');
317
- emit({ feature, results, evaluator, clean }, clean ? 0 : 1);
373
+ emit({ feature, results, evaluator, clean, ...treeFor(cwd, feature, options.beforeEval) }, clean ? 0 : 1);
318
374
  }
319
375
 
320
376
  function printHuman(obj) {
@@ -339,6 +395,7 @@ function printHuman(obj) {
339
395
  if (obj.task) {
340
396
  console.log(chalk.bold(`\n📄 report-check ${obj.feature} ${obj.task}`));
341
397
  line(obj);
398
+ printTree(obj.tree);
342
399
  console.log('');
343
400
  return;
344
401
  }
@@ -346,9 +403,33 @@ function printHuman(obj) {
346
403
  console.log(chalk.bold(`\n📄 report-check ${obj.feature}`));
347
404
  if (!obj.results.length) console.log(chalk.gray(' nenhuma task done — nada a auditar'));
348
405
  for (const r of obj.results) line(r);
349
- console.log(
350
- ` ${obj.evaluator.exists ? chalk.green('✓') : chalk.yellow('⚠')} ${'Gate 3'.padEnd(6)} `
351
- + `evaluator-report.md ${obj.evaluator.exists ? (obj.evaluator.complete ? 'íntegro' : 'incompleto') : 'ausente'}`,
352
- );
406
+ if (obj.evaluator) {
407
+ console.log(
408
+ ` ${obj.evaluator.exists ? chalk.green('✓') : chalk.yellow('⚠')} ${'Gate 3'.padEnd(6)} `
409
+ + `evaluator-report.md ${obj.evaluator.exists ? (obj.evaluator.complete ? 'íntegro' : obj.evaluator.partial ? 'PARCIAL' : 'incompleto') : 'ausente'}`,
410
+ );
411
+ }
412
+ printTree(obj.tree);
353
413
  console.log('');
354
414
  }
415
+
416
+ function printTree(tree) {
417
+ if (!tree) {
418
+ console.log(chalk.gray(' árvore: fora de um repositório git — não conferida'));
419
+ return;
420
+ }
421
+ const stat = tree.diffstat ? ` (+${tree.diffstat.insertions} −${tree.diffstat.deletions})` : '';
422
+ console.log(` árvore ${tree.branch ?? '-'}@${tree.head ?? '-'}: ${tree.dirtyCount} arquivo(s) não commitado(s) fora de .morph/${stat}`);
423
+ for (const d of tree.dirty.slice(0, 10)) console.log(chalk.gray(` ${d.status.padEnd(2)} ${d.path}`));
424
+ if (tree.dirtyCount > 10) console.log(chalk.gray(` … e mais ${tree.dirtyCount - 10}`));
425
+ if (tree.markers.length) {
426
+ console.log(chalk.yellow(` ⚠ marcador de mutação esquecido: ${tree.markers.map((m) => `${m.file}:${m.line}`).join(', ')}`));
427
+ }
428
+ if (tree.commits?.length) console.log(chalk.gray(` commits sobre a default: ${tree.commits.join(' · ')}`));
429
+ const since = tree.sinceBeforeEval;
430
+ if (since) {
431
+ console.log(since.changedCount
432
+ ? chalk.yellow(` ⚠ desde a foto de ${since.at}: ${since.changedCount} arquivo(s) mudaram — ${since.changed.join(', ')}`)
433
+ : chalk.green(` ✓ nada mudou fora de .morph/ desde a foto de ${since.at}`));
434
+ }
435
+ }
@@ -11,6 +11,8 @@
11
11
  * how `morph-plan` gets that protection; omitting it degrades to "reserved,
12
12
  * no owner recorded" — still collision-free, just unable to name the
13
13
  * conflicting feature if one ever surfaces.
14
+ *
15
+ * `morph-spec usecase check <feature>` — see `usecaseCheckCommand` below.
14
16
  */
15
17
 
16
18
  import chalk from 'chalk';
@@ -18,6 +20,11 @@ import { existsSync, readdirSync } from 'fs';
18
20
  import { join } from 'path';
19
21
  import { resolveGitCommonDir } from '../../../framework/hooks/shared/git-paths.js';
20
22
  import { reserveUseCases } from '../../lib/specs/usecase-registry.js';
23
+ import { checkUseCases } from '../../lib/specs/usecase-check.js';
24
+ import { checkLifecycle } from '../../lib/specs/acervo-lifecycle.js';
25
+ import { parseTasksJsonLenient } from '../../lib/tasks/tasks-json-parser.js';
26
+ import { isValidFeatureName } from '../project/worktree.js';
27
+ import { printLifecycleWarnings } from '../tasks/dag.js';
21
28
 
22
29
  /**
23
30
  * Best-effort feature-name inference when `--feature` is omitted: only when
@@ -75,3 +82,62 @@ export function usecaseReserveCommand(options = {}) {
75
82
  else console.log('');
76
83
  process.exit(0);
77
84
  }
85
+
86
+ /**
87
+ * `morph-spec usecase check <feature> [--json]` — the use-case half of `dag`,
88
+ * runnable before `tasks.json` exists: the feature's use cases and the
89
+ * revocations its `spec.md` declares, against the acervo's lifecycle. With a
90
+ * `tasks.json`, also the task → use case plumbing `dag` checks.
91
+ *
92
+ * Exit 1 only for what `dag` also refuses (a task pointing at a use case that
93
+ * does not exist, a tasks.json that does not validate); lifecycle findings are
94
+ * warnings.
95
+ *
96
+ * @param {string} feature
97
+ * @param {{ json?: boolean, cwd?: string }} [options]
98
+ */
99
+ export async function usecaseCheckCommand(feature, options = {}) {
100
+ const cwd = options.cwd || process.cwd();
101
+ const fail = (error) => {
102
+ if (options.json) console.log(JSON.stringify({ feature, error }));
103
+ else console.error(chalk.red(`\n❌ ${error}\n`));
104
+ process.exit(1);
105
+ };
106
+
107
+ if (!isValidFeatureName(feature)) fail(`nome de feature inválido: "${feature}"`);
108
+ const featureDir = join(cwd, '.morph', 'features', feature);
109
+ if (!existsSync(featureDir)) fail(`feature não encontrada: .morph/features/${feature}`);
110
+
111
+ const tasksPath = join(featureDir, '2-plan', 'tasks.json');
112
+ let tasksData = null;
113
+ if (existsSync(tasksPath)) {
114
+ try {
115
+ ({ data: tasksData } = await parseTasksJsonLenient(tasksPath));
116
+ } catch (error) {
117
+ fail(`tasks.json inválido: ${error.message}`);
118
+ }
119
+ }
120
+
121
+ const useCases = tasksData ? checkUseCases(tasksData, join(featureDir, '2-plan', 'usecases')) : null;
122
+ const lifecycle = checkLifecycle({ cwd, featureDir, tasksData });
123
+ const broken = useCases?.errors.length > 0;
124
+
125
+ if (options.json) {
126
+ console.log(JSON.stringify({ feature, useCases, lifecycle }, null, 2));
127
+ process.exit(broken ? 1 : 0);
128
+ }
129
+
130
+ if (broken) {
131
+ console.error(chalk.red(`\n❌ Use cases — ${useCases.errors.length} erro(s)\n`));
132
+ for (const e of useCases.errors) console.error(chalk.red(` ${e}`));
133
+ }
134
+ if (useCases?.warnings.length) {
135
+ console.error(chalk.yellow(`\n⚠ Use cases — ${useCases.warnings.length} aviso(s)`));
136
+ for (const w of useCases.warnings) console.error(chalk.yellow(` ${w}`));
137
+ }
138
+ printLifecycleWarnings(lifecycle);
139
+ if (!broken && !useCases?.warnings.length && !lifecycle.warnings.length) {
140
+ console.log(chalk.green(`\n✅ ${feature}: use cases e acervo sem achados\n`));
141
+ }
142
+ process.exit(broken ? 1 : 0);
143
+ }
@@ -6,6 +6,15 @@ import { planDispatch } from '../../lib/tasks/dispatch-planner.js';
6
6
  import { renderMermaidFlowchart } from '../../lib/tasks/mermaid-renderer.js';
7
7
  import { checkUseCases } from '../../lib/specs/usecase-check.js';
8
8
  import { checkArtifactCriteria } from '../../lib/specs/done-criteria-check.js';
9
+ import { checkLifecycle } from '../../lib/specs/acervo-lifecycle.js';
10
+
11
+ /** Shared by `dag` and `usecase check` — the two surfaces of the same finding. */
12
+ export function printLifecycleWarnings(lifecycle) {
13
+ if (!lifecycle?.warnings?.length) return;
14
+ const n = lifecycle.warnings.length;
15
+ console.error(chalk.yellow(`\n⚠ Ciclo de vida do acervo — ${n} ${n === 1 ? 'aviso' : 'avisos'}`));
16
+ for (const w of lifecycle.warnings) console.error(chalk.yellow(` ${w}`));
17
+ }
9
18
 
10
19
  /**
11
20
  * `morph-spec dag <feature>` — analyze the tasks.json DAG and print the
@@ -19,7 +28,8 @@ import { checkArtifactCriteria } from '../../lib/specs/done-criteria-check.js';
19
28
  */
20
29
  export async function dagCommand(featureName, options = {}) {
21
30
  try {
22
- const planDir = join(process.cwd(), '.morph', 'features', featureName, '2-plan');
31
+ const featureDir = join(process.cwd(), '.morph', 'features', featureName);
32
+ const planDir = join(featureDir, '2-plan');
23
33
  const tasksPath = join(planDir, 'tasks.json');
24
34
  if (!existsSync(tasksPath)) {
25
35
  console.error(chalk.red(`\n❌ tasks.json not found for feature "${featureName}"`));
@@ -58,10 +68,14 @@ export async function dagCommand(featureName, options = {}) {
58
68
  // own doneCriteria can describe an artifact with no use case in sight.
59
69
  const artifactCriteria = checkArtifactCriteria(data, join(planDir, 'usecases'));
60
70
 
71
+ // Acervo lifecycle: a task citing a superseded use case, and the spec.md
72
+ // revocation rows the finish would fail to apply. Warnings only.
73
+ const lifecycle = checkLifecycle({ cwd: process.cwd(), featureDir, tasksData: data });
74
+
61
75
  const plan = planDispatch(data);
62
76
 
63
77
  if (options.json) {
64
- console.log(JSON.stringify({ ...plan, useCases: ucCheck, artifactCriteria }, null, 2));
78
+ console.log(JSON.stringify({ ...plan, useCases: ucCheck, artifactCriteria, lifecycle }, null, 2));
65
79
  if (ucCheck.errors.length) process.exit(1);
66
80
  return;
67
81
  }
@@ -83,6 +97,7 @@ export async function dagCommand(featureName, options = {}) {
83
97
  for (const w of artifactCriteria.warnings) console.error(chalk.yellow(` ${w}`));
84
98
  console.error(chalk.gray(' Teste que lê texto de arquivo não prova comportamento (morph-plan §2, "Por que artefato").'));
85
99
  }
100
+ printLifecycleWarnings(lifecycle);
86
101
 
87
102
  console.log(chalk.bold(`\n📊 DAG — ${featureName}`));
88
103
  console.log('━'.repeat(60));
@@ -36,7 +36,7 @@ import { validateTasksJson } from '../../lib/tasks/tasks-json-parser.js';
36
36
  */
37
37
  const OUTPUT_SECTIONS = {
38
38
  // morph-brainstorming emits: Problema, Escopo, Usuários e fluxos críticos,
39
- // Stack, Riscos e dependências, Compliance de stack.
39
+ // Stack, Riscos e dependências, Compliance de stack, Conflitos com o acervo.
40
40
  //
41
41
  // "Critérios de aceite" is now OPTIONAL, not required: the behaviour layer
42
42
  // moved to the use cases (2-plan/usecases/), whose post-conditions must be
@@ -44,6 +44,10 @@ const OUTPUT_SECTIONS = {
44
44
  // `optional` — not `recommended` — precisely because the skill never emits it
45
45
  // anymore: a warning on every new proposal would be systematic noise. Legacy
46
46
  // proposals that DO carry the section keep validating exactly as before.
47
+ //
48
+ // "Conflitos com o acervo" is recommended: morph-brainstorming always emits
49
+ // it ("nenhum" included), so its absence means the acervo check never ran.
50
+ // Not required — proposals written before the section existed stay valid.
47
51
  'proposal.md': {
48
52
  required: [
49
53
  ['Problema', 'Problem'],
@@ -51,6 +55,7 @@ const OUTPUT_SECTIONS = {
51
55
  ],
52
56
  recommended: [
53
57
  ['Riscos', 'Risks'],
58
+ ['Conflitos', 'Conflicts'],
54
59
  ],
55
60
  optional: [
56
61
  ['Critérios de aceite', 'Critério', 'Aceite', 'Acceptance Criteria', 'Acceptance', 'Success Criteria', 'Success Metrics'],
@@ -6,6 +6,8 @@ import { runVerify, readTask, VerifyUsageError } from '../../lib/validators/veri
6
6
  import { resolvePrimaryRoot } from '../project/worktree.js';
7
7
  import { getHeadSha } from '../../lib/git/default-branch.js';
8
8
  import { EVAL_RUNNER } from '../../lib/tasks/eval-node.js';
9
+ import { buildVerifyEvent, recordVerifyEvent, findRepeatedVerify } from '../../lib/telemetry/verify-event.js';
10
+ import { treeFingerprint } from '../../lib/git/tree-fingerprint.js';
9
11
 
10
12
  /** ✓ pass · ✗ fail · – skip */
11
13
  function statusIcon(status) {
@@ -237,6 +239,9 @@ export async function verifyCommand(featureName, taskId, options = {}) {
237
239
  }
238
240
  }
239
241
 
242
+ // Taken before the run: it is the tree the run is about to read.
243
+ const fingerprint = treeFingerprint(process.cwd());
244
+
240
245
  let result;
241
246
  try {
242
247
  result = await runVerify(process.cwd(), featureName, taskId || null, {
@@ -335,6 +340,16 @@ export async function verifyCommand(featureName, taskId, options = {}) {
335
340
  await updateFeature(featureName, 'verification', stamp);
336
341
  }
337
342
 
343
+ // A feature-scope run over the exact tree of the previous one proves nothing
344
+ // new. Advisory only: the stamp and the exit code are the same either way.
345
+ let repeatOf = null;
346
+ try {
347
+ const cwd = process.cwd();
348
+ if (!taskId && persist) repeatOf = findRepeatedVerify(cwd, { feature: featureName, task: null, scope: result.scope, fingerprint });
349
+ const tree = resolve(cwd).replace(/\\/g, '/') === resolvePrimaryRoot(cwd) ? 'root' : 'worktree';
350
+ recordVerifyEvent(cwd, buildVerifyEvent(result, { sha: getHeadSha(cwd), tree, persisted: persist, reason, fingerprint }));
351
+ } catch { /* telemetry never changes the verdict */ }
352
+
338
353
  // The refusal always goes to stderr, so it survives --json (whose stdout has
339
354
  // to stay parseable) and is never something the reader has to go looking for.
340
355
  if (result.baseline && !result.baseline.recorded) {
@@ -344,10 +359,14 @@ export async function verifyCommand(featureName, taskId, options = {}) {
344
359
  }
345
360
 
346
361
  if (options.json) {
347
- const output = { ...result, persisted: persist, ...(persist ? {} : { reason }) };
362
+ const output = { ...result, persisted: persist, ...(persist ? {} : { reason }), repeatOf };
348
363
  console.log(JSON.stringify(output, null, 2));
349
364
  } else {
350
365
  console.log(formatVerifyResult(result));
366
+ if (repeatOf) {
367
+ console.log(chalk.yellow(`⚠️ Mesma árvore do verify de feature de ${repeatOf.ts} (${repeatOf.status}): nada mudou fora de .morph/ desde então. `
368
+ + 'Esta rodada não prova nada novo — reuse o resultado anterior, a menos que o ambiente (Docker, banco, serviço externo) tenha mudado.\n'));
369
+ }
351
370
  if (result.baseline?.recorded) {
352
371
  for (const u of result.baseline.updates) {
353
372
  console.log(chalk.green(` baseline ${u.id}: ${u.from ?? '—'} → ${u.to}`));
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, existsSync } from 'fs';
2
2
  import { join } from 'path';
3
+ import { personaStandardIds } from '../../utils/agents-installer.js';
3
4
 
4
5
  /**
5
6
  * Scan agents.json (v5) — flat domain personas, no tiers.
@@ -18,7 +19,10 @@ export function scanAgents(frameworkDir) {
18
19
  color: agent.color || null,
19
20
  domains: agent.domains || [],
20
21
  keywords: agent.keywords || [],
21
- standards: agent.standards || [],
22
+ // Every standard the persona can cite, surfaces included — the same
23
+ // reading the installer and the validator use.
24
+ standards: personaStandardIds(agent),
25
+ surfaces: agent.surfaces || null,
22
26
  stack: agent.stack || ['*'],
23
27
  persona: agent.persona || '',
24
28
  }));