@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
@@ -18,10 +18,10 @@
18
18
  * REPLICABLE — the primary root has the same relative path with identical
19
19
  * content. Deleting the worktree's copy loses nothing; the
20
20
  * original stays where it always was.
21
- * DISPOSABLE — a well-known build artifact (`bin/`, `obj/`, `node_modules/`,
22
- * `__pycache__/`, `test-results/`, `.auth/`, `*.tsbuildinfo`).
23
- * Reproducible from source, which is a STRONGER guarantee than
24
- * backed up.
21
+ * DISPOSABLE — a well-known build artifact or cache (`bin/`, `obj/`,
22
+ * `node_modules/`, `.next/`, `TestResults/`, `graphify-out/`…,
23
+ * see DISPOSABLE_DIR_NAMES). Reproducible from source, which is a
24
+ * STRONGER guarantee than backed up.
25
25
  * EXCLUSIVE — everything else: no counterpart in the root, or a counterpart
26
26
  * whose content differs. These are the only bytes at stake, and
27
27
  * the caller copies them to `finish-backup` before proceeding.
@@ -37,10 +37,16 @@ import { existsSync, lstatSync, readdirSync, readFileSync } from 'fs';
37
37
  import { join } from 'path';
38
38
 
39
39
  /**
40
- * Well-known, regenerable build artifacts. Deliberately narrow — do NOT add
41
- * speculative patterns (`dist/`, `.next/`) that a project might legitimately
42
- * hand-edit; anything unlisted still reaches the EXCLUSIVE bucket, where it is
43
- * backed up rather than assumed disposable.
40
+ * Well-known, regenerable build artifacts and caches. Deliberately narrow — do
41
+ * NOT add names a project might use for hand-written output (`dist/`,
42
+ * `build/`, `coverage/`); anything unlisted still reaches the EXCLUSIVE bucket,
43
+ * where it is backed up rather than assumed disposable. Each name here is a
44
+ * directory its own tool rewrites from scratch: `.next/` (next build/dev),
45
+ * `.turbo/`, `.pytest_cache/`, `TestResults/` (VSTest), `graphify-out/`
46
+ * (`morph-spec graph build`). `.next/` is also the one that makes a backup
47
+ * weigh hundreds of megabytes; `graphify-out/` is seeded into every worktree
48
+ * and is past the replica size cap (18 MB in a real project), so without its
49
+ * entry every `finish` would back it up.
44
50
  *
45
51
  * `.auth/` (Playwright storage state) belongs here for a reason worth stating:
46
52
  * it holds a captured session, so it LOOKS like precious local state — but it
@@ -49,6 +55,7 @@ import { join } from 'path';
49
55
  */
50
56
  const DISPOSABLE_DIR_NAMES = new Set([
51
57
  'bin', 'obj', 'node_modules', '__pycache__', 'test-results', '.auth',
58
+ '.next', '.turbo', '.pytest_cache', 'TestResults', 'graphify-out',
52
59
  ]);
53
60
  const DISPOSABLE_FILE_RE = /\.tsbuildinfo$/;
54
61
 
@@ -37,6 +37,7 @@ import { detectDefaultBranch } from '../git/default-branch.js';
37
37
  import { buildPrIndex } from '../git/pr-index.js';
38
38
  import { classifyBranch, commitsAhead, resolveBaseRef } from '../git/branch-classifier.js';
39
39
  import { auditDockerStacks } from './docker-audit.js';
40
+ import { auditFinishBackups } from './finish-backup.js';
40
41
 
41
42
  const norm = (p) => resolve(p).replace(/\\/g, '/');
42
43
 
@@ -423,6 +424,10 @@ export function auditWorktrees(cwd = process.cwd(), { exec } = {}) {
423
424
  }
424
425
  issues.push(...auditDockerStacks(root, { exec, features: treeFeatures }));
425
426
 
427
+ // What a worktree removal leaves behind in `.git/morph-spec/finish-backup/`
428
+ // past its retention — `warn`, pruned by `--prune` and by the next finish.
429
+ issues.push(...auditFinishBackups(root));
430
+
426
431
  return issues;
427
432
  }
428
433
 
@@ -174,16 +174,23 @@ export async function updateClaudeMdAgentsBlock(claudeMdPath, agents = [], optio
174
174
  * @param {string} targetPath - Absolute path to the target project directory
175
175
  * @param {object} options
176
176
  * @param {Function} options._exec - exec function (for testing)
177
+ * @param {Function} [options._detectGraphifyVersion] - graphify probe (for testing: the
178
+ * step-18b branches differ with graphify installed or not, and CI has no Python)
179
+ * @param {Function} [options._log] - log sink (for testing: replaces stdout, which the test
180
+ * runner itself reports through — stubbing it swallows test results)
177
181
  * @param {boolean} options.reinstall - If true, only re-install stack-aware assets (rules, skills, agents)
178
182
  * @param {boolean} options.force - Bypass the secondary-worktree guard (see below). Callers
179
183
  * (e.g. `initCommand`) that already performed their own equivalent check pass this so the
180
184
  * check isn't redundantly re-applied here.
181
185
  */
182
- export async function setupInfra(targetPath, { _exec = execSync, reinstall = false, force = false } = {}) {
186
+ export async function setupInfra(targetPath, {
187
+ _exec = execSync, _detectGraphifyVersion = null, _log = null, reinstall = false, force = false,
188
+ } = {}) {
183
189
  const quiet = process.env.MORPH_QUIET === '1';
184
190
 
185
191
  function log(msg) {
186
- if (!quiet) process.stdout.write(msg + '\n');
192
+ if (_log) _log(msg);
193
+ else if (!quiet) process.stdout.write(msg + '\n');
187
194
  }
188
195
 
189
196
  // Guard: refuse a FULL install from inside a secondary (linked) worktree.
@@ -441,24 +448,26 @@ export async function setupInfra(targetPath, { _exec = execSync, reinstall = fal
441
448
  // (setup-infra-cmd.js), então nada aqui pode escapar do try.
442
449
  log('Step 18b: Codebase graph (graphify)...');
443
450
  try {
444
- const { writeGraphifyignore } = await import('../lib/graph/graphifyignore.js');
451
+ const { writeGraphifyignore, describeOutdatedIgnore } = await import('../lib/graph/graphifyignore.js');
445
452
  const {
446
453
  detectInstaller, installArgs, detectGraphifyVersion,
447
- GRAPHIFY_VERSION, GRAPHIFY_BIN, GRAPHIFY_PACKAGE,
454
+ GRAPHIFY_VERSION, GRAPHIFY_PACKAGE,
448
455
  } = await import('../lib/graph/graphify-runtime.js');
449
456
  const { ensureClaudeignore } = await import('../utils/file-copier.js');
450
457
  const { scanDatabaseSchema } = await import('../lib/generators/architecture-scanner.js');
451
458
 
452
459
  const ignore = writeGraphifyignore(targetPath, projectTags ?? []);
453
- log(ignore.written
454
- ? ' ✓ .graphifyignore (stack-aware)'
455
- : ' ✓ .graphifyignore já existia — preservado');
460
+ if (ignore.migrated) log(' ✓ .graphifyignore atualizado (dublês, fixtures e helpers de teste no grafo)');
461
+ else if (ignore.written) log(' ✓ .graphifyignore (stack-aware)');
462
+ else log(' ✓ .graphifyignore já existia — preservado');
463
+ if (ignore.outdated) log(` ⚠ ${describeOutdatedIgnore(ignore.outdated)}`);
456
464
 
457
465
  // Um graph.json de 12 MB reescrito a cada commit invalida o prompt cache.
458
466
  await ensureClaudeignore(targetPath, ['graphify-out/']);
459
467
  log(' ✓ graphify-out/ no .claudeignore');
460
468
 
461
- const installed = detectGraphifyVersion();
469
+ const probe = _detectGraphifyVersion ?? detectGraphifyVersion;
470
+ const installed = probe();
462
471
  if (installed === GRAPHIFY_VERSION) {
463
472
  log(` ✓ graphify ${installed} já instalado`);
464
473
  } else {
@@ -479,16 +488,18 @@ export async function setupInfra(targetPath, { _exec = execSync, reinstall = fal
479
488
  }
480
489
  }
481
490
 
482
- if (detectGraphifyVersion()) {
483
- try {
484
- // Só os git hooks (post-commit/post-checkout/merge driver). NUNCA
485
- // `graphify claude install`: ele escreveria uma seção no CLAUDE.md e um
486
- // PreToolUse hook próprio, colidindo com os hooks do morph-spec.
487
- _exec(`${GRAPHIFY_BIN} hook install`, { cwd: targetPath, stdio: 'ignore' });
488
- log(' ✓ git hooks do graphify (rebuild assíncrono no post-commit)');
489
- } catch {
490
- log(' ⚠ graphify hook install falhou — rode manualmente se quiser rebuild automático');
491
- }
491
+ // Nenhum hook do graphify: o refresh mora nos hooks do Claude (step 16 —
492
+ // graph-refresh-kick + SessionStart), que o `update` reinstala. O
493
+ // `graphify hook install` ficou de fora por três defeitos medidos no 0.9.50:
494
+ // sai sem fazer nada em worktree vinculada, não tem post-merge (merge e pull
495
+ // nunca rebuildam) e, num commit depois de um fast-forward, carimba o HEAD
496
+ // num grafo sem os arquivos do merge. E nunca `graphify claude install`:
497
+ // escreveria no CLAUDE.md e um PreToolUse próprio.
498
+ //
499
+ // A linha só sai com o graphify presente, e diz o que dispara de fato: um
500
+ // commit feito no terminal não dispara nada até o próximo desses.
501
+ if (probe()) {
502
+ log(' ✓ refresh automático do grafo da raiz: depois dos comandos git/gh/morph-spec do agente e no início de cada sessão');
492
503
  }
493
504
  } catch (err) {
494
505
  log(` ⚠ Step do grafo pulado: ${err.message}`);
@@ -29,6 +29,172 @@ import { realEffects } from '../lib/installers/effects.js';
29
29
  /** Default tool scope for domain personas (override per agent via agents.json `tools`). */
30
30
  export const DEFAULT_AGENT_TOOLS = 'Read, Write, Edit, Bash, Glob, Grep';
31
31
 
32
+ /**
33
+ * A persona that can change files: its effective tool scope grants Write or Edit.
34
+ * Derived from the tools, so a persona made read-only stops being one.
35
+ *
36
+ * @param {object} agent - agents.json entry
37
+ * @returns {boolean}
38
+ */
39
+ export function isImplementer(agent) {
40
+ const tools = String(agent?.tools ?? DEFAULT_AGENT_TOOLS).split(',').map((t) => t.trim());
41
+ return tools.includes('Write') || tools.includes('Edit');
42
+ }
43
+
44
+ /**
45
+ * Turn ceiling for a persona that omits agents.json `maxTurns` (ADR-004).
46
+ *
47
+ * 20 is the value the generator hardcoded before the field existed — the v4
48
+ * Tier-2 default (commit 93aad05f) that survived the v5 flattening with no
49
+ * recorded rationale. Keeping it as the default is what lets an old
50
+ * agents.json render byte-for-byte the same .md as before.
51
+ */
52
+ export const DEFAULT_MAX_TURNS = 20;
53
+
54
+ /** Share of the ceiling (percent) at which a sub-agent checkpoints (partial report). */
55
+ const CHECKPOINT_PERCENT = 70;
56
+
57
+ /**
58
+ * The ONE rule for a valid `maxTurns`. Four readers — installAgents,
59
+ * checkAgentsDrift, doctor's validateAgentsJson and validate-framework.js
60
+ * Check 6 — so the same agents.json can never pass in one and fail in another.
61
+ *
62
+ * Absent (`undefined`) is valid: it means "use the default". Anything present
63
+ * must be an integer >= 1. `null` is NOT absent — whoever wants the default
64
+ * removes the field.
65
+ *
66
+ * @param {unknown} value - the raw agents.json value
67
+ * @returns {string|null} what was expected, or null when valid
68
+ */
69
+ export function maxTurnsProblem(value) {
70
+ if (value === undefined) return null;
71
+ return Number.isInteger(value) && value >= 1 ? null : 'esperado inteiro >= 1';
72
+ }
73
+
74
+ /**
75
+ * The full message for an invalid `maxTurns` — persona, value received, what
76
+ * is expected and how to get the default back. Every reader prints this one.
77
+ *
78
+ * @param {string} id - persona id (agents.json key)
79
+ * @param {unknown} value - the raw agents.json value
80
+ * @returns {string|null} null when valid
81
+ */
82
+ export function maxTurnsError(id, value) {
83
+ const problem = maxTurnsProblem(value);
84
+ if (!problem) return null;
85
+ const shown = typeof value === 'string' || (value !== null && typeof value === 'object')
86
+ ? JSON.stringify(value)
87
+ : String(value);
88
+ return `agents.json: persona "${id}" tem maxTurns inválido (${shown}) — ${problem}. ` +
89
+ `Remova o campo para usar o default ${DEFAULT_MAX_TURNS}.`;
90
+ }
91
+
92
+ /**
93
+ * Resolve the ceiling a persona is generated with: its `maxTurns`, or the
94
+ * default when absent. Throws `maxTurnsError()` on an invalid value.
95
+ *
96
+ * @param {object} agent - agents.json entry with `id` merged in
97
+ * @returns {number}
98
+ */
99
+ export function resolveMaxTurns(agent) {
100
+ const error = maxTurnsError(agent.id, agent.maxTurns);
101
+ if (error) throw new Error(error);
102
+ return agent.maxTurns ?? DEFAULT_MAX_TURNS;
103
+ }
104
+
105
+ /** A surface id is the key the mockup checklist names (`Superfície: admin-app`). */
106
+ const SURFACE_ID_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
107
+
108
+ const SURFACE_KEYS = new Set(['when', 'standards']);
109
+
110
+ /**
111
+ * The ONE rule for a valid `surfaces` — the optional map that splits a
112
+ * persona's standards by the kind of screen it works on
113
+ * (`{ "admin-app": { when, standards: [ids] } }`). Same four readers as
114
+ * `maxTurnsProblem`, for the same reason.
115
+ *
116
+ * Absent (`undefined`) is valid: the persona reads only `standards`. A standard
117
+ * listed both in `standards` (read on every surface) and in a surface is
118
+ * refused: that is exactly how a "separated" persona keeps loading marketing
119
+ * standards on a CRUD screen without anyone noticing.
120
+ *
121
+ * @param {unknown} surfaces - the raw agents.json value
122
+ * @param {unknown} [standards] - the persona's universal `standards`
123
+ * @returns {string|null} what is wrong, or null when valid
124
+ */
125
+ export function surfacesProblem(surfaces, standards = []) {
126
+ if (surfaces === undefined) return null;
127
+ if (surfaces === null || typeof surfaces !== 'object' || Array.isArray(surfaces)) {
128
+ return 'esperado objeto { "<superfície>": { when, standards } }';
129
+ }
130
+ const entries = Object.entries(surfaces);
131
+ if (entries.length === 0) return 'esperado ao menos uma superfície';
132
+
133
+ const universal = new Set(Array.isArray(standards) ? standards : []);
134
+ for (const [name, def] of entries) {
135
+ if (!SURFACE_ID_RE.test(name)) return `superfície "${name}" — o id é kebab-case (ex.: "admin-app")`;
136
+ if (def === null || typeof def !== 'object' || Array.isArray(def)) {
137
+ return `superfície "${name}" — esperado { when, standards }`;
138
+ }
139
+ const unknown = Object.keys(def).filter((k) => !SURFACE_KEYS.has(k));
140
+ if (unknown.length > 0) return `superfície "${name}" — campo desconhecido: ${unknown.join(', ')}`;
141
+ if (typeof def.when !== 'string' || def.when.trim() === '') {
142
+ return `superfície "${name}" — "when" precisa dizer que telas são dessa superfície`;
143
+ }
144
+ const ids = def.standards;
145
+ if (!Array.isArray(ids) || ids.length === 0 || ids.some((s) => typeof s !== 'string' || s.trim() === '')) {
146
+ return `superfície "${name}" — "standards" precisa ser uma lista não vazia de ids`;
147
+ }
148
+ const repeated = ids.filter((s, i) => ids.indexOf(s) !== i);
149
+ if (repeated.length > 0) return `superfície "${name}" repete ${[...new Set(repeated)].join(', ')}`;
150
+ const overlap = ids.filter((s) => universal.has(s));
151
+ if (overlap.length > 0) {
152
+ return `superfície "${name}" repete standard da lista universal (${overlap.join(', ')}) — ` +
153
+ 'um standard é universal OU de uma superfície';
154
+ }
155
+ }
156
+ return null;
157
+ }
158
+
159
+ /**
160
+ * The full message for an invalid `surfaces`. Every reader prints this one.
161
+ *
162
+ * @param {string} id - persona id (agents.json key)
163
+ * @param {unknown} surfaces
164
+ * @param {unknown} [standards]
165
+ * @returns {string|null} null when valid
166
+ */
167
+ export function surfacesError(id, surfaces, standards) {
168
+ const problem = surfacesProblem(surfaces, standards);
169
+ if (!problem) return null;
170
+ return `agents.json: persona "${id}" tem surfaces inválido — ${problem}. ` +
171
+ 'Remova o campo para a persona ler só `standards`.';
172
+ }
173
+
174
+ /**
175
+ * Every standard id a persona can cite: the universal list plus each
176
+ * surface's. Reference checks read this, so a surface id that does not exist
177
+ * fails like any other.
178
+ *
179
+ * @param {object} persona - agents.json entry
180
+ * @returns {string[]} unique ids, universal first
181
+ */
182
+ export function personaStandardIds(persona) {
183
+ const ids = [...(Array.isArray(persona?.standards) ? persona.standards : [])];
184
+ const surfaces = persona?.surfaces;
185
+ if (surfaces && typeof surfaces === 'object' && !Array.isArray(surfaces)) {
186
+ for (const def of Object.values(surfaces)) {
187
+ if (Array.isArray(def?.standards)) ids.push(...def.standards);
188
+ }
189
+ }
190
+ return [...new Set(ids)];
191
+ }
192
+
193
+ /** The first validity error of a persona entry, or null — what drift reports as `invalid`. */
194
+ function personaError(agent) {
195
+ return maxTurnsError(agent.id, agent.maxTurns) ?? surfacesError(agent.id, agent.surfaces, agent.standards);
196
+ }
197
+
32
198
  /**
33
199
  * Load the ids of MCP servers known to the framework registry
34
200
  * (framework/mcp/registry.json). Used to validate agents.json `mcp` entries:
@@ -95,6 +261,13 @@ export async function installAgents(projectDir, frameworkDir = 'framework', opti
95
261
  .filter(([key]) => !key.startsWith('_comment'))
96
262
  .map(([id, data]) => ({ id, ...data }));
97
263
 
264
+ // Validate EVERY persona before the first write: refusing halfway through
265
+ // would leave some personas regenerated and others stale.
266
+ for (const agent of agents) {
267
+ const error = personaError(agent);
268
+ if (error) throw new Error(error);
269
+ }
270
+
98
271
  const targetDir = join(projectDir, '.claude', 'agents');
99
272
  fx.ensureDir(targetDir);
100
273
 
@@ -154,8 +327,13 @@ export async function installAgents(projectDir, frameworkDir = 'framework', opti
154
327
  */
155
328
  export function renderAgentMd(agent, knownMcpIds = null) {
156
329
  const description = buildDescription(agent);
157
- const frontmatter = buildFrontmatter(agent, description, knownMcpIds);
158
- const body = buildBody(agent);
330
+ // Resolved ONCE: the frontmatter ceiling and the budget the body announces
331
+ // must be the same number — the sub-agent never sees its own frontmatter.
332
+ const maxTurns = resolveMaxTurns(agent);
333
+ const surfaces = surfacesError(agent.id, agent.surfaces, agent.standards);
334
+ if (surfaces) throw new Error(surfaces);
335
+ const frontmatter = buildFrontmatter(agent, description, knownMcpIds, maxTurns);
336
+ const body = buildBody(agent, maxTurns);
159
337
  return `---\n${frontmatter}---\n\n${MANAGED_HEADER}\n\n${body}\n`;
160
338
  }
161
339
 
@@ -170,7 +348,7 @@ function buildToolsLine(agent, knownMcpIds) {
170
348
  return [base, ...mcpGrants].join(', ');
171
349
  }
172
350
 
173
- function buildFrontmatter(agent, description, knownMcpIds) {
351
+ function buildFrontmatter(agent, description, knownMcpIds, maxTurns) {
174
352
  const lines = [
175
353
  // Canonical 1:1 naming: frontmatter name === filename === subagent_type.
176
354
  `name: morph-${agent.id}`,
@@ -181,7 +359,10 @@ function buildFrontmatter(agent, description, knownMcpIds) {
181
359
  // personas that omit the field.
182
360
  `model: ${agent.model ?? 'inherit'}`,
183
361
  `tools: ${buildToolsLine(agent, knownMcpIds)}`,
184
- `maxTurns: 20`,
362
+ // Per persona in agents.json (ADR-004); absent → DEFAULT_MAX_TURNS. The
363
+ // line keeps its position so an agents.json without the field renders
364
+ // exactly what the hardcoded `maxTurns: 20` rendered.
365
+ `maxTurns: ${maxTurns}`,
185
366
  ];
186
367
  if (agent.color) lines.push(`color: ${agent.color}`);
187
368
  lines.push(`memory: project`, '');
@@ -195,7 +376,7 @@ function buildDescription(agent) {
195
376
  return desc.length > 200 ? desc.slice(0, 197) + '...' : desc;
196
377
  }
197
378
 
198
- function buildBody(agent) {
379
+ function buildBody(agent, maxTurns) {
199
380
  const lines = [
200
381
  `# ${agent.title ?? agent.id}`,
201
382
  '',
@@ -213,15 +394,51 @@ function buildBody(agent) {
213
394
  lines.push('');
214
395
  }
215
396
 
397
+ if (agent.surfaces) {
398
+ const read = agent.standards?.length ? 'Read the list above plus only' : 'Read only';
399
+ lines.push(
400
+ '## Standards by surface',
401
+ '',
402
+ 'A screen is ONE surface — the mockup checklist names it (`Superfície`). ' +
403
+ `${read} the list of that screen's surface; never another surface's standards for that screen.`,
404
+ '',
405
+ );
406
+ for (const [name, def] of Object.entries(agent.surfaces)) {
407
+ lines.push(`### ${name}`, def.when, ...def.standards.map((s) => `- ${s}`), '');
408
+ }
409
+ }
410
+
216
411
  lines.push(
217
412
  '## How you operate',
218
413
  '',
219
414
  'You are a domain persona dispatched per task. Read the spec/mandate/tasks/standards passed to you, do the work, and return a concise summary plus an auto-score 0-10. Refer to MORPH.md section 6 for the dispatch contract.',
415
+ '',
416
+ // Generic on purpose: this body reaches every dispatch (implement, apply,
417
+ // uiux, hotfix, review). The scout has no canonical file and no Write; the
418
+ // evaluator's file is evaluator-report.md. `PARCIAL` on line 1 is what
419
+ // report-check reads (verdict report-incomplete, action resume); "no
420
+ // score" keeps an evaluator partial from carrying a Composite-Score the
421
+ // gate would read as final.
422
+ '## Turn budget',
423
+ '',
424
+ // Pasted into EVERY dispatch of every persona — kept to the minimum that
425
+ // still carries the contract (test/framework/maf-expert-persona.test.js
426
+ // pins the rendered size).
427
+ `You have ${maxTurns} turns (\`maxTurns\`); at the ceiling you are cut off mid-work, without warning. ` +
428
+ 'One response is one turn, however many parallel tool calls it makes. ' +
429
+ `By turn ${checkpointTurn(maxTurns)}, if your dispatch names a report file, save a partial there — ` +
430
+ '`PARCIAL` on line 1, done, remaining, files, no score (final report only) — then continue or stop, your call; the final report replaces it.',
220
431
  );
221
432
 
222
433
  return lines.join('\n');
223
434
  }
224
435
 
436
+ /** The turn at which a sub-agent with `maxTurns` saves its partial report. */
437
+ function checkpointTurn(maxTurns) {
438
+ // Integer arithmetic: 0.7 × N in floating point is not always exact.
439
+ return Math.max(1, Math.floor((maxTurns * CHECKPOINT_PERCENT) / 100));
440
+ }
441
+
225
442
  /**
226
443
  * Compatibility shim for older callers that expected installDomainAgents.
227
444
  * In v5 there is no separate "domain" install step — installAgents handles
@@ -279,19 +496,30 @@ export function checkMissingAgents(projectDir, frameworkDir, projectTags = []) {
279
496
  *
280
497
  * - missing: eligible persona with no .md installed → run setup-infra --reinstall
281
498
  * - stale: .md exists but differs from renderAgentMd() output (hand-edited
282
- * or generated by an older CLI) → regenerate
499
+ * or generated by an older CLI) → regenerate. `staleFields` names
500
+ * what differs per file: the frontmatter keys, and `body`.
501
+ * - invalid: persona whose agents.json entry cannot be rendered (an invalid
502
+ * `maxTurns` or `surfaces`) — reported with the reason instead of throwing, so
503
+ * the doctor prints the cause and not "failed to compute"
283
504
  * - orphans: morph-*.md files with no matching agents.json entry
284
505
  *
285
506
  * @param {string} projectDir
286
507
  * @param {string} frameworkDir - directory containing agents.json
287
508
  * @param {string[]} projectTags
288
- * @returns {{ missing: string[], stale: string[], orphans: string[], expected: number }}
509
+ * @param {object} [options]
510
+ * @param {string} [options.mcpRegistryDir=frameworkDir] - where `mcp/registry.json`
511
+ * lives. It must be the directory the INSTALLER read: an installed project's
512
+ * `.morph/framework/` carries agents.json but no `mcp/`, so rendering against
513
+ * it drops every `mcp__*` grant and flags a pristine install as stale.
514
+ * @returns {{ missing: string[], stale: string[], staleFields: Object<string, string[]>,
515
+ * invalid: Array<{ file: string, reason: string }>, orphans: string[], expected: number }}
289
516
  */
290
- export function checkAgentsDrift(projectDir, frameworkDir, projectTags = []) {
517
+ export function checkAgentsDrift(projectDir, frameworkDir, projectTags = [], options = {}) {
518
+ const { mcpRegistryDir = frameworkDir } = options;
291
519
  const targetDir = join(projectDir, '.claude', 'agents');
292
520
  const agentsJsonPath = join(frameworkDir, 'agents.json');
293
521
  if (!existsSync(agentsJsonPath)) {
294
- return { missing: [], stale: [], orphans: [], expected: 0 };
522
+ return { missing: [], stale: [], staleFields: {}, invalid: [], orphans: [], expected: 0 };
295
523
  }
296
524
 
297
525
  const agentsJson = JSON.parse(readFileSync(agentsJsonPath, 'utf-8'));
@@ -299,24 +527,33 @@ export function checkAgentsDrift(projectDir, frameworkDir, projectTags = []) {
299
527
  .filter(([key]) => !key.startsWith('_comment'))
300
528
  .map(([id, data]) => ({ id, ...data }));
301
529
 
302
- const knownMcpIds = loadKnownMcpIds(frameworkDir);
530
+ const knownMcpIds = loadKnownMcpIds(mcpRegistryDir);
303
531
  const knownFilenames = new Set(agents.map(a => `morph-${a.id}.md`));
304
532
  const missing = [];
305
533
  const stale = [];
534
+ const staleFields = {};
535
+ const invalid = [];
306
536
  let expected = 0;
307
537
 
308
538
  for (const agent of agents) {
309
539
  if (!agentEligible(agent, projectTags)) continue;
310
540
  expected++;
311
541
  const filename = `morph-${agent.id}.md`;
542
+ const error = personaError(agent);
543
+ if (error) {
544
+ invalid.push({ file: filename, reason: error });
545
+ continue;
546
+ }
312
547
  const filePath = join(targetDir, filename);
313
548
  if (!existsSync(filePath)) {
314
549
  missing.push(filename);
315
550
  continue;
316
551
  }
317
552
  const onDisk = readFileSync(filePath, 'utf-8').replace(/\r\n/g, '\n');
318
- if (onDisk !== renderAgentMd(agent, knownMcpIds)) {
553
+ const rendered = renderAgentMd(agent, knownMcpIds);
554
+ if (onDisk !== rendered) {
319
555
  stale.push(filename);
556
+ staleFields[filename] = diffAgentMd(onDisk, rendered);
320
557
  }
321
558
  }
322
559
 
@@ -329,7 +566,39 @@ export function checkAgentsDrift(projectDir, frameworkDir, projectTags = []) {
329
566
  }
330
567
  }
331
568
 
332
- return { missing, stale, orphans, expected };
569
+ return { missing, stale, staleFields, invalid, orphans, expected };
570
+ }
571
+
572
+ /**
573
+ * Split a generated .md into its frontmatter map and the rest.
574
+ * @returns {{ fields: Map<string, string>, body: string }}
575
+ */
576
+ function splitAgentMd(content) {
577
+ const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
578
+ if (!match) return { fields: new Map(), body: content };
579
+ const fields = new Map();
580
+ for (const line of match[1].split('\n')) {
581
+ const kv = line.match(/^([A-Za-z][\w-]*):\s?(.*)$/);
582
+ if (kv) fields.set(kv[1], kv[2]);
583
+ }
584
+ return { fields, body: match[2] };
585
+ }
586
+
587
+ /**
588
+ * Name what differs between a .md on disk and its render: the frontmatter
589
+ * keys whose value (or presence) differs, in render order, then `body`.
590
+ * A file with no parseable frontmatter differs in everything → `frontmatter`.
591
+ */
592
+ function diffAgentMd(onDisk, rendered) {
593
+ const disk = splitAgentMd(onDisk);
594
+ const want = splitAgentMd(rendered);
595
+ if (disk.fields.size === 0) return disk.body === want.body ? ['frontmatter'] : ['frontmatter', 'body'];
596
+ const keys = [...new Set([...want.fields.keys(), ...disk.fields.keys()])];
597
+ const fields = keys.filter(k => disk.fields.get(k) !== want.fields.get(k));
598
+ if (disk.body !== want.body) fields.push('body');
599
+ // Bytes differ but no key/value does (e.g. a stray blank line between the
600
+ // fences): still drift, and it lives in the frontmatter.
601
+ return fields.length > 0 ? fields : ['frontmatter'];
333
602
  }
334
603
 
335
604
  /**