@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
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `morph-spec cost [feature] [--by task|phase] [--all-trees] [--json]`
2
+ * `morph-spec cost [feature] [--by task|phase] [--all-trees] [--metrics [--top n]] [--json]`
3
3
  *
4
4
  * A cost-facing view over the same harness event stream `telemetry` reads
5
5
  * (.morph/logs/events.jsonl): how much wall-clock time was spent inside
@@ -16,11 +16,14 @@
16
16
  * `sessionUsage` (when present) is the exception: real tokens, observed
17
17
  * directly, not a proxy — see aggregateCost's docstring for the contract.
18
18
  *
19
- * `--all-trees` (Furo 3) widens the source from "just the current tree" to
20
- * every git worktree that has its own events.jsonl — a fleet running N
21
- * features in N worktrees otherwise only ever sees whichever tree `cost` was
22
- * run from. Reuses the same worktree enumeration `doctor --worktrees`/`fleet`
23
- * use (see loadTelemetryAllTrees in telemetry-reader.js).
19
+ * `--all-trees` widens the source from "just the current tree" to every git
20
+ * worktree that has its own events.jsonl plus the logs `finish` saved before
21
+ * removing a worktree, deduplicated by eventId.
22
+ *
23
+ * `--metrics` measures the loop instead: turns, tool calls by orchestrator and
24
+ * sub-agent, time per command class, identical reruns and why, rereads, how
25
+ * many feature-scope verifies each feature ran, git calls per task and whether
26
+ * the reuse validator ran in root and worktree (see lib/telemetry/metrics.js).
24
27
  *
25
28
  * Read-only, always exits 0 on a normal (even empty) run — fail-open, an
26
29
  * empty stream is an answer, not an error. Only `--by` with an unknown value
@@ -28,7 +31,8 @@
28
31
  */
29
32
 
30
33
  import chalk from 'chalk';
31
- import { loadTelemetry, loadTelemetryAllTrees, aggregateCost } from '../../lib/telemetry/telemetry-reader.js';
34
+ import { loadTelemetry, loadTelemetryAllTrees, aggregateCost, dedupeEvents } from '../../lib/telemetry/telemetry-reader.js';
35
+ import { computeMetrics } from '../../lib/telemetry/metrics.js';
32
36
 
33
37
  const BYTES_CAVEAT =
34
38
  'Bytes re-ingeridos são um PROXY determinístico de custo (o que as ferramentas leram do disco) — NÃO são tokens reais. Hooks não enxergam o uso real de tokens do modelo.';
@@ -87,17 +91,30 @@ function printPhases(phases) {
87
91
  }
88
92
  }
89
93
 
94
+ function tokenLine(u) {
95
+ return ` input=${u.inputTokens} output=${u.outputTokens} `
96
+ + `cacheRead=${u.cacheReadTokens} cacheCreation=${u.cacheCreationTokens}`;
97
+ }
98
+
90
99
  /**
91
- * Real (hook-observed) token counts, when the stream has `SessionUsage`
92
- * events — the non-proxy sibling of the bytes-re-ingested numbers above.
93
- * No-op when absent (aggregateCost returns `null`, never a zeroed object).
100
+ * Real (hook-observed) token counts — the non-proxy sibling of the bytes
101
+ * above: the main sessions and, separately, the sub-agents.
94
102
  */
95
- function printSessionUsage(sessionUsage) {
96
- if (!sessionUsage) return;
97
- console.log('\n' + chalk.white.bold('Tokens reais (sessões observadas)')
98
- + chalk.gray(` · ${sessionUsage.sessions} SessionUsage`));
99
- console.log(` input=${sessionUsage.inputTokens} output=${sessionUsage.outputTokens} `
100
- + `cacheRead=${sessionUsage.cacheReadTokens} cacheCreation=${sessionUsage.cacheCreationTokens}`);
103
+ function printSessionUsage(summary) {
104
+ const { sessionUsage, agentUsage, sessionUsageLegacySamples } = summary;
105
+ if (sessionUsage) {
106
+ console.log('\n' + chalk.white.bold('Tokens reais (sessões observadas)')
107
+ + chalk.gray(` · ${sessionUsage.sessions} sessão(ões), ${sessionUsage.samples} amostra(s)`));
108
+ console.log(tokenLine(sessionUsage));
109
+ }
110
+ if (agentUsage) {
111
+ console.log('\n' + chalk.white.bold('Tokens reais (sub-agents)')
112
+ + chalk.gray(` · ${agentUsage.agents} agente(s), ${agentUsage.samples} amostra(s)`));
113
+ console.log(tokenLine(agentUsage));
114
+ }
115
+ if (sessionUsageLegacySamples > 0) {
116
+ console.log(chalk.gray(` ${sessionUsageLegacySamples} amostra(s) SessionUsage antigas (sem delta) fora da soma: eram o total acumulado, repetido a cada Stop.`));
117
+ }
101
118
  }
102
119
 
103
120
  function printSummary(summary) {
@@ -112,7 +129,115 @@ function printSummary(summary) {
112
129
  for (const t of summary.tools) console.log(` ${t.name.padEnd(16)} ${t.count}`);
113
130
  }
114
131
 
115
- printSessionUsage(summary.sessionUsage);
132
+ printSessionUsage(summary);
133
+ }
134
+
135
+ function pct(r) {
136
+ return r === null || r === undefined ? '-' : `${Math.round(r * 1000) / 10}%`;
137
+ }
138
+
139
+ function split(counts) {
140
+ return `${counts.total} (orq ${counts.orchestrator} · sub ${counts.subagent}${counts.unknown ? ` · sem agente ${counts.unknown}` : ''})`;
141
+ }
142
+
143
+ function printMetricsBody(m, { by }) {
144
+ const legacyShare = m.window.events ? m.coverage.legacy / m.window.events : 0;
145
+ console.log(chalk.gray(`janela: ${m.window.from ?? '-'} → ${m.window.to ?? '-'} · ${m.window.events} eventos`
146
+ + ` · ${pct(1 - legacyShare)} no esquema v2`));
147
+ if (m.coverage.legacy) {
148
+ console.log(chalk.gray(' eventos v1 não têm agente, exit code, duração nem janela de Read: entram como "sem agente" e rerun "unknown-*".'));
149
+ }
150
+
151
+ console.log('\n' + chalk.white.bold('Turnos e chamadas'));
152
+ console.log(` turnos: ${m.turns ? split(m.turns) : 'desconhecido (nenhum PostToolBatch na janela)'}`
153
+ + (m.turnsPerTaskMedian !== null ? chalk.gray(` · mediana por task ${m.turnsPerTaskMedian}`) : ''));
154
+ console.log(` chamadas de ferramenta: ${split(m.toolCalls)}`);
155
+ const perTask = m.perTaskByFeature;
156
+ if (perTask.toolCalls !== null) {
157
+ console.log(` por task (total da feature ÷ tasks dela, mediana): ${perTask.toolCalls} chamadas`
158
+ + (perTask.turns !== null ? ` · ${perTask.turns} turnos` : '') + ` · ${perTask.git} git status+diff`
159
+ + chalk.gray(` · as janelas TaskStart→TaskEnd têm ${pct(m.taskWindowCoverage)} das chamadas`));
160
+ }
161
+
162
+ console.log('\n' + chalk.white.bold('Tempo por classe de comando'));
163
+ for (const [klass, t] of Object.entries(m.timeByClass).sort((a, b) => b[1].measuredMs - a[1].measuredMs)) {
164
+ const unmeasured = t.unmeasured ? chalk.gray(` (${t.unmeasured} sem duração)`) : '';
165
+ console.log(` ${klass.padEnd(12)} ${fmtDuration(t.measuredMs / 1000).padStart(8)} ${t.runs} execução(ões)${unmeasured}`);
166
+ }
167
+
168
+ console.log('\n' + chalk.white.bold('Rerun do mesmo comando em ≤10 min (mesma feature; o filtro de saída não conta)'));
169
+ console.log(` total: ${pct(m.reruns.rate)} repetidas (${m.reruns.reruns}/${m.reruns.runs}) · ${pct(m.reruns.burstRate)} em rajada — testes, builds e verifies`);
170
+ for (const [kind, r] of Object.entries(m.rerunsByKind).sort((a, b) => b[1].runs - a[1].runs)) {
171
+ console.log(` ${kind.padEnd(22)} ${pct(r.rate).padStart(6)} repetidas ${pct(r.burstRate).padStart(6)} em rajada (${r.runs} execuções)`);
172
+ }
173
+ const classes = Object.entries(m.rerunClasses);
174
+ if (classes.length) console.log(chalk.gray(` por quê: ${classes.map(([k, n]) => `${k} ${n}`).join(' · ')}`));
175
+
176
+ console.log('\n' + chalk.white.bold('Releitura de arquivo (mesma janela, mesma feature)'));
177
+ console.log(` ${pct(m.rereads.rate)} dos bytes lidos (${fmtBytes(m.rereads.redundantBytes)} de ${fmtBytes(m.rereads.bytes)}, ${m.rereads.reads} leituras)`);
178
+ if (m.rereads.windowed < m.rereads.reads) {
179
+ console.log(chalk.gray(` ${m.rereads.reads - m.rereads.windowed} leitura(s) sem janela gravada (v1) contam o arquivo inteiro: a taxa é um teto`));
180
+ }
181
+ for (const f of m.topRereads) {
182
+ console.log(` ${fmtBytes(f.redundantBytes).padStart(9)} ${f.reads}× ${f.file}${chalk.gray(` [${f.feature ?? '-'}]`)}`);
183
+ }
184
+
185
+ console.log('\n' + chalk.white.bold('verify, git e reuso'));
186
+ const perFeature = Object.entries(m.verify.byFeature).sort((a, b) => b[1].feature - a[1].feature);
187
+ console.log(` verify de feature por feature: mediana ${m.verify.featureScopeMedian ?? '-'}`
188
+ + (perFeature.length ? chalk.gray(` (${perFeature.slice(0, 5).map(([f, v]) => `${f} ${v.feature}`).join(', ')})`) : ''));
189
+ const repeats = Object.entries(m.verify.unchangedRepeats);
190
+ if (repeats.length) {
191
+ console.log(` verify de feature sobre a mesma árvore do anterior: ${repeats.reduce((n, [, v]) => n + v, 0)}`
192
+ + chalk.gray(` (${repeats.map(([f, n]) => `${f} ${n}`).join(', ')})`));
193
+ }
194
+ if (m.dotnetFullSuite.runs) {
195
+ const phases = Object.entries(m.dotnetFullSuite.byPhase).map(([p, v]) => `${p} ${v.withBuild}/${v.runs}`).join(' · ');
196
+ console.log(` suíte .NET inteira que recompila (sem --no-build): ${m.dotnetFullSuite.withBuild}/${m.dotnetFullSuite.runs}`
197
+ + chalk.gray(` (${phases})`));
198
+ }
199
+ console.log(` git status / diff por task: mediana ${m.gitPerTaskMedian.status ?? '-'} / ${m.gitPerTaskMedian.diff ?? '-'}`
200
+ + chalk.gray(` · total ${m.git.status} / ${m.git.diff}`));
201
+ const trees = Object.entries(m.reuse.byTree);
202
+ console.log(` reuse-validator: ${trees.length
203
+ ? trees.map(([tree, s]) => `${tree} rodou ${s.ran}, pulou ${s.skipped}`).join(' · ')
204
+ : 'nenhum evento Verify na janela'}`);
205
+ for (const [reason, n] of Object.entries(m.reuse.skipReasons)) console.log(chalk.gray(` pulou ${n}×: ${reason}`));
206
+
207
+ if (by === 'task') {
208
+ for (const t of m.tasks) {
209
+ const status = t.open ? chalk.yellow('[em aberto]') : (t.status || '-');
210
+ console.log('\n' + chalk.white.bold(`Task ${t.task}`) + chalk.gray(` · ${t.feature ?? '(sem feature)'}`) + ` · ${status}`);
211
+ console.log(` turnos ${t.turns ? split(t.turns) : '-'} · chamadas ${split(t.toolCalls)}`);
212
+ const time = Object.entries(t.timeByClass).map(([k, v]) => `${k} ${fmtDuration(v.measuredMs / 1000)}`).join(' · ');
213
+ if (time) console.log(` tempo: ${time}`);
214
+ console.log(` rerun ${pct(t.reruns.rate)} (${t.reruns.reruns}/${t.reruns.runs}) · releitura ${pct(t.rereads.rate)} · git status/diff ${t.git.status}/${t.git.diff}`);
215
+ }
216
+ }
217
+ }
218
+
219
+ /**
220
+ * `--metrics`: the loop measurements over the current tree (and, with
221
+ * `--all-trees`, every tree plus the finish backups, deduplicated).
222
+ */
223
+ function printMetrics(feature, options, cwd) {
224
+ const events = options.allTrees
225
+ ? dedupeEvents(loadTelemetryAllTrees(cwd).flatMap((t) => t.events))
226
+ : loadTelemetry(cwd);
227
+ const metrics = computeMetrics(events, { feature, top: options.top });
228
+
229
+ if (options.json) {
230
+ console.log(JSON.stringify(metrics));
231
+ return;
232
+ }
233
+ if (metrics.window.events === 0) {
234
+ const scope = feature ? ` para a feature "${feature}"` : '';
235
+ console.log(chalk.gray(`Telemetria vazia${scope} — nada a medir.`));
236
+ return;
237
+ }
238
+ const scope = feature ? chalk.cyan(` · feature ${feature}`) : '';
239
+ console.log(chalk.white.bold(`Medições do loop${options.allTrees ? ' — todas as árvores + finish-backup' : ''}`) + scope);
240
+ printMetricsBody(metrics, options);
116
241
  }
117
242
 
118
243
  /**
@@ -132,7 +257,7 @@ function printAllTrees(feature, options, cwd) {
132
257
  path,
133
258
  ...aggregateCost(events, { feature }),
134
259
  }));
135
- const combined = aggregateCost(treesData.flatMap((t) => t.events), { feature });
260
+ const combined = aggregateCost(dedupeEvents(treesData.flatMap((t) => t.events)), { feature });
136
261
 
137
262
  if (options.json) {
138
263
  console.log(JSON.stringify({ allTrees: true, trees: perTree, combined }));
@@ -183,6 +308,11 @@ export function costCommand(feature, options = {}) {
183
308
 
184
309
  const cwd = options.cwd || process.cwd();
185
310
 
311
+ if (options.metrics) {
312
+ printMetrics(feature, options, cwd);
313
+ return;
314
+ }
315
+
186
316
  if (options.allTrees) {
187
317
  // Same fail-open + Windows-flush discipline as the single-tree path
188
318
  // below — no forced process.exit(), --json included.
@@ -16,7 +16,7 @@ import {
16
16
  getInstalledCLIVersion
17
17
  } from '../../utils/version-checker.js';
18
18
  import { resetMorphSettings } from '../../utils/claude-settings-manager.js';
19
- import { checkMissingAgents, checkAgentsDrift } from '../../utils/agents-installer.js';
19
+ import { checkMissingAgents, checkAgentsDrift, maxTurnsError, surfacesError } from '../../utils/agents-installer.js';
20
20
  import { getProjectTags } from '../../lib/stack-filter.js';
21
21
  import { auditGraph } from '../../lib/graph/graph-health.js';
22
22
  import { auditFeatureLifecycle } from '../../core/state/lifecycle-audit.js';
@@ -191,6 +191,23 @@ export function validateAgentsJson(agentsData) {
191
191
  });
192
192
  }
193
193
 
194
+ // 4: maxTurns (ADR-004) — the installer's own rule, so this cannot pass a
195
+ // value the installer refuses. ERROR, not warn: that persona is not generated.
196
+ const maxTurnsErrors = agentEntries
197
+ .map(([id, data]) => maxTurnsError(id, data?.maxTurns))
198
+ .filter(Boolean);
199
+ checks.push(maxTurnsErrors.length > 0
200
+ ? { name: 'agents.json maxTurns', status: 'error', msg: maxTurnsErrors.join(' | ') }
201
+ : { name: 'agents.json maxTurns', status: 'ok' });
202
+
203
+ // 5: surfaces — same installer rule, same severity: the persona is not generated.
204
+ const surfacesErrors = agentEntries
205
+ .map(([id, data]) => surfacesError(id, data?.surfaces, data?.standards))
206
+ .filter(Boolean);
207
+ checks.push(surfacesErrors.length > 0
208
+ ? { name: 'agents.json surfaces', status: 'error', msg: surfacesErrors.join(' | ') }
209
+ : { name: 'agents.json surfaces', status: 'ok' });
210
+
194
211
  if (missingV5Fields.length > 0 && isV5) {
195
212
  const sample = missingV5Fields.slice(0, 3).join(', ');
196
213
  checks.push({
@@ -348,12 +365,23 @@ async function doctorFullCommand(frameworkRoot) {
348
365
  projectTags = getProjectTags(JSON.parse(rawCfg));
349
366
  } catch { /* no tags */ }
350
367
  }
351
- const drift = checkAgentsDrift(frameworkRoot, join(frameworkRoot, '.morph/framework'), projectTags);
368
+ // The MCP registry comes from the package, like the installer's: the
369
+ // installed .morph/framework/ has no mcp/, and rendering without it
370
+ // flagged every persona with an `mcp` grant as stale on a clean install.
371
+ const drift = checkAgentsDrift(frameworkRoot, join(frameworkRoot, '.morph/framework'), projectTags,
372
+ { mcpRegistryDir: join(import.meta.dirname, '..', '..', '..', 'framework') });
373
+ if (drift.invalid.length > 0) {
374
+ check('Personas agents.json valid', false, false,
375
+ `${drift.invalid.map(i => i.reason).join(' | ')} — corrija o agents.json e rode 'morph-spec update'`);
376
+ }
352
377
  check(`Personas installed (${drift.expected - drift.missing.length}/${drift.expected})`,
353
378
  drift.missing.length === 0, false,
354
379
  drift.missing.length > 0 ? `missing: ${drift.missing.join(', ')} — run 'morph-spec setup-infra --reinstall'` : '');
380
+ // Name WHAT differs per file (frontmatter key or body): a hand-edited
381
+ // maxTurns and a CLI upgrade that changed the body read differently.
382
+ const staleList = drift.stale.map(f => `${f} (${(drift.staleFields[f] ?? []).join(', ')})`);
355
383
  check('Personas up-to-date (no drift)', drift.stale.length === 0, true,
356
- drift.stale.length > 0 ? `stale/hand-edited: ${drift.stale.join(', ')} — regenerate via 'morph-spec setup-infra --reinstall'` : '');
384
+ drift.stale.length > 0 ? `stale/hand-edited: ${staleList.join(', ')} — regenerate via 'morph-spec setup-infra --reinstall'` : '');
357
385
  check('No orphan morph-* agents', drift.orphans.length === 0, true,
358
386
  drift.orphans.length > 0 ? `orphans: ${drift.orphans.join(', ')}` : '');
359
387
  } catch {
@@ -653,23 +681,54 @@ async function doctorMcpCommand(targetPath) {
653
681
  * Deleting a directory that still HOLDS something is reported with the exact
654
682
  * command, never performed — same PROVE-or-DECLINE discipline as the finish
655
683
  * teardown.
684
+ *
685
+ * `--prune` also applies the retention of the finish backups — the same policy
686
+ * every `finish` applies on its own, and the ONLY path that also applies it to
687
+ * the backups written before the retention existed (no manifest), counting
688
+ * them against the budget. Under `--json` its result rides in the JSON.
656
689
  */
657
690
  export async function doctorWorktreesCommand(targetPath, options = {}) {
658
691
  const { auditWorktrees, pruneWorktrees } = await import('../../lib/worktree/worktree-audit.js');
659
692
 
693
+ let backupRetention = null;
694
+ let prune = null;
660
695
  if (options.prune) {
661
696
  const beforeIssues = auditWorktrees(targetPath);
662
697
  const beforeMissing = beforeIssues.filter((i) => i.type === 'registered-missing').length;
663
698
  const beforeDangling = beforeIssues.filter((i) => i.type === 'dangling-registration').length;
664
699
  const beforeEmptyDirs = beforeIssues.filter((i) => i.type === 'unregistered-dir' && i.empty).length;
665
700
  const result = await pruneWorktrees(targetPath);
666
- if (result.pruned) logger.success(`git worktree prune: ${beforeMissing} registro(s) órfão(s) removido(s).`);
667
- else logger.warn(`prune falhou: ${result.error}`);
668
- if (result.removedEmptyDirs.length) {
669
- logger.success(`${result.removedEmptyDirs.length}/${beforeEmptyDirs} diretório(s) vazio(s) removido(s): ${result.removedEmptyDirs.join(', ')}`);
701
+ // Under --json the only thing on stdout is the JSON: a status line in front
702
+ // of it breaks every caller that parses the output.
703
+ prune = {
704
+ pruned: Boolean(result.pruned),
705
+ ...(result.pruned ? { orphanRegistrations: beforeMissing } : { error: result.error }),
706
+ removedEmptyDirs: result.removedEmptyDirs,
707
+ clearedRegistrations: result.clearedRegistrations.map((r) => r.feature),
708
+ };
709
+ if (!options.json) {
710
+ if (result.pruned) logger.success(`git worktree prune: ${beforeMissing} registro(s) órfão(s) removido(s).`);
711
+ else logger.warn(`prune falhou: ${result.error}`);
712
+ if (result.removedEmptyDirs.length) {
713
+ logger.success(`${result.removedEmptyDirs.length}/${beforeEmptyDirs} diretório(s) vazio(s) removido(s): ${result.removedEmptyDirs.join(', ')}`);
714
+ }
715
+ if (result.clearedRegistrations.length) {
716
+ logger.success(`registro de worktree limpo em ${result.clearedRegistrations.length}/${beforeDangling} feature(s): ${result.clearedRegistrations.map((r) => r.feature).join(', ')}`);
717
+ }
670
718
  }
671
- if (result.clearedRegistrations.length) {
672
- logger.success(`registro de worktree limpo em ${result.clearedRegistrations.length}/${beforeDangling} feature(s): ${result.clearedRegistrations.map((r) => r.feature).join(', ')}`);
719
+
720
+ const { resolvePrimaryRoot } = await import('./worktree.js');
721
+ const { applyBackupRetention, summarizeRetention } = await import('../../lib/worktree/finish-backup.js');
722
+ // The explicit prune is the one that reaches the backups written before the
723
+ // retention existed (no manifest): the automatic one never touches them.
724
+ backupRetention = summarizeRetention(applyBackupRetention(resolvePrimaryRoot(targetPath), { includeLegacy: true }));
725
+ if (backupRetention?.skipped && !options.json) {
726
+ logger.warn(`retenção dos backups do finish PULADA: ${backupRetention.skipped}`);
727
+ } else if (backupRetention && !options.json) {
728
+ const stripped = backupRetention.secretsStripped ? `, ${backupRetention.secretsStripped} arquivo(s) .env* apagado(s)` : '';
729
+ const adopted = backupRetention.adopted ? `, ${backupRetention.adopted} backup(s) antigo(s) mantido(s) ganharam manifesto` : '';
730
+ logger.success(`retenção dos backups do finish: ${backupRetention.removed ?? 0} pasta(s) removida(s)${stripped}${adopted}`);
731
+ for (const d of backupRetention.declined || []) logger.warn(`backup mantido: ${d.backup} (${d.reason})`);
673
732
  }
674
733
  }
675
734
 
@@ -684,7 +743,7 @@ export async function doctorWorktreesCommand(targetPath, options = {}) {
684
743
  const isProblem = (i) => !['info', 'warn'].includes(i.severity);
685
744
 
686
745
  if (options.json) {
687
- console.log(JSON.stringify({ issues }, null, 2));
746
+ console.log(JSON.stringify({ issues, ...(prune ? { prune } : {}), ...(backupRetention ? { backupRetention } : {}) }, null, 2));
688
747
  return !issues.some(isProblem);
689
748
  }
690
749
 
@@ -1390,6 +1449,19 @@ export async function doctorCommand(options = {}) {
1390
1449
  checks.push({ name: 'codebase graph', status: 'ok', msg: graphAudit.msg });
1391
1450
  }
1392
1451
 
1452
+ // O que o morph-spec guarda dentro do `.git` (backups do finish, registros de
1453
+ // porta e de claim) cresce sem aparecer em `git status`. Informação — `ok` ou
1454
+ // `warn`, nunca falha —, e nenhuma linha quando não há nada guardado.
1455
+ try {
1456
+ const { resolvePrimaryRoot } = await import('./worktree.js');
1457
+ const { morphStoreCheck } = await import('../../lib/worktree/finish-backup.js');
1458
+ const storeCheck = morphStoreCheck(resolvePrimaryRoot(targetPath));
1459
+ if (storeCheck) {
1460
+ checks.push(storeCheck);
1461
+ if (storeCheck.status === 'warn') hasWarnings = true;
1462
+ }
1463
+ } catch { /* fail-open */ }
1464
+
1393
1465
  // Check key templates exist (in framework package, not project copy)
1394
1466
  const frameworkTemplatesRoot = join(import.meta.dirname, '..', '..', '..', 'framework', 'templates');
1395
1467
  const keyTemplates = [
@@ -33,6 +33,9 @@ import { getProjectTags } from '../../lib/stack-filter.js';
33
33
  import { updateClaudeMdAgentsBlock, installRulesForStack } from '../../scripts/setup-infra.js';
34
34
  import { reconcileLegacyTemplatesCopy } from '../../lib/installers/templates-reconcile.js';
35
35
  import { createEffects } from '../../lib/installers/effects.js';
36
+ import {
37
+ writeGraphifyignore, describeOutdatedIgnore, graphifyignoreTracked,
38
+ } from '../../lib/graph/graphifyignore.js';
36
39
 
37
40
  /**
38
41
  * Backup user's config.json before cleaning
@@ -725,6 +728,24 @@ export async function updateCommand(options = {}) {
725
728
  await updateGitignore(targetPath, fx);
726
729
  await ensureGitattributes(targetPath, fx);
727
730
 
731
+ // Migration: a .graphifyignore the previous version generated and nobody
732
+ // edited is swapped for the current one when its RULES change (test doubles
733
+ // and fixtures back in the graph — a header-only difference is left alone);
734
+ // an edited one is left alone, with the change to make. Only an existing
735
+ // file — the graph is opt-in, and `graph build` writes it. A tracked file
736
+ // reaches worktrees and teammates only once committed: each checkout reads
737
+ // its own.
738
+ let graphIgnore = null;
739
+ if (existsSync(join(targetPath, '.graphifyignore'))) {
740
+ updateSpinner.text = 'Checking .graphifyignore...';
741
+ graphIgnore = writeGraphifyignore(targetPath, projectTags, { fx });
742
+ if (graphIgnore.outdated) notices.push(describeOutdatedIgnore(graphIgnore.outdated));
743
+ if (graphIgnore.migrated && graphifyignoreTracked(targetPath)) {
744
+ notices.push('.graphifyignore é versionado: commite a migração (git add .graphifyignore) — '
745
+ + 'as worktrees e o resto do time leem o arquivo do próprio checkout e só recebem as regras novas depois do commit');
746
+ }
747
+ }
748
+
728
749
  // Migration: untrack a pre-v5 committed state.json so it stops being swept
729
750
  // into feature commits. Gitignore alone can't do this for an already-tracked file.
730
751
  const untracked = untrackCommittedStateJson(targetPath, fx);
@@ -769,6 +790,10 @@ export async function updateCommand(options = {}) {
769
790
  logger.dim(` ✓ .claude/agents/ (${agentsResult?.agents?.length ?? '?'} personas — stack: ${tagsLabel})`);
770
791
  logger.dim(` ✓ .claude/rules/ (${rulesResult?.installed ?? '?'} stack-filtered rules)`);
771
792
  if (commandsCopied) logger.dim(' ✓ .claude/commands/');
793
+ if (graphIgnore?.migrated) {
794
+ logger.dim(' ✓ .graphifyignore (dublês, fixtures e helpers de teste voltam ao grafo da raiz no próximo refresh');
795
+ logger.dim(' em background — início de sessão ou comando git do agente; numa worktree: morph-spec graph refresh)');
796
+ }
772
797
  if (untracked) {
773
798
  logger.dim(' ✓ .morph/state.json untracked (git rm --cached) — commit to finalize');
774
799
  logger.warn('Migration: .morph/state.json was removed from git tracking. The');