@polymorphism-tech/morph-spec 8.37.1 → 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.
- package/CLAUDE.md +19 -6
- package/README.md +2 -2
- package/bin/morph-spec.js +16 -7
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +3 -3
- package/framework/MORPH.md +25 -22
- package/framework/agents.json +76 -27
- package/framework/docs/CLI.md +54 -17
- package/framework/evals/rubrics/code-quality.md +5 -0
- package/framework/evals/rubrics/condensed.md +6 -2
- package/framework/evals/rubrics/contracts.md +20 -0
- package/framework/evals/rubrics/test-coverage.md +17 -6
- package/framework/hooks/README.md +9 -3
- package/framework/hooks/claude-code/core/graph-refresh-kick.js +52 -0
- package/framework/hooks/claude-code/core/telemetry-log.js +172 -87
- package/framework/hooks/claude-code/core/validate-completion.js +16 -90
- package/framework/hooks/claude-code/core/worktree-context.js +10 -0
- package/framework/hooks/shared/command-tokens.js +96 -0
- package/framework/hooks/shared/git-exclude.js +44 -0
- package/framework/hooks/shared/git-paths.js +52 -0
- package/framework/hooks/shared/graph-freshness.js +380 -0
- package/framework/hooks/shared/graph-refresh.js +416 -0
- package/framework/hooks/shared/morph-cli.js +17 -5
- package/framework/hooks/shared/redact-secrets.js +55 -0
- package/framework/hooks/shared/telemetry-logger.js +25 -2
- package/framework/hooks/shared/transcript-usage.js +80 -0
- package/framework/rubrics/backend.json +2 -2
- package/framework/rubrics/default.json +2 -2
- package/framework/rubrics/frontend.json +2 -2
- package/framework/rules/csharp-standards.md +1 -0
- package/framework/rules/morph-workflow.md +1 -0
- package/framework/rules/nextjs-standards.md +1 -0
- package/framework/schemas/config.schema.json +41 -1
- package/framework/schemas/feature.schema.json +5 -1
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +36 -11
- package/framework/skills/core/morph-brainstorming/SKILL.md +16 -7
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +39 -10
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +72 -15
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +51 -13
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +34 -9
- package/framework/standards/STANDARDS.json +187 -12
- package/framework/standards/ai-agents/testing-ai.md +2 -1
- package/framework/standards/architecture/code-comments.md +64 -0
- package/framework/standards/backend/dotnet/testing.md +22 -0
- package/framework/standards/frontend/admin-app/data-table.md +562 -0
- package/framework/standards/frontend/admin-app/feedback.md +236 -0
- package/framework/standards/frontend/admin-app/forms.md +232 -0
- package/framework/standards/frontend/admin-app/page-structure.md +203 -0
- package/framework/standards/frontend/nextjs/components.md +37 -58
- package/framework/standards/frontend/nextjs/data-fetching.md +1 -0
- package/framework/standards/frontend/nextjs/forms.md +31 -30
- package/framework/standards/frontend/nextjs/state-management.md +2 -1
- package/framework/standards/frontend/nextjs/testing.md +18 -0
- package/framework/templates/context/design-system.md +3 -3
- package/framework/templates/docs/proposal.md +11 -0
- package/framework/templates/docs/spec.md +15 -0
- package/framework/templates/docs/ui-mockups.md +115 -98
- package/framework/templates/feature/decisions.md +124 -122
- package/package.json +1 -1
- package/src/commands/graph/affected.js +4 -3
- package/src/commands/graph/build.js +109 -18
- package/src/commands/graph/explain.js +8 -5
- package/src/commands/graph/status.js +14 -8
- package/src/commands/observability/cost.js +148 -18
- package/src/commands/project/doctor.js +82 -10
- package/src/commands/project/update.js +25 -0
- package/src/commands/project/worktree.js +73 -90
- package/src/commands/state/advance.js +18 -0
- package/src/commands/state/approve.js +15 -0
- package/src/commands/state/archive.js +62 -9
- package/src/commands/state/finish.js +62 -14
- package/src/commands/state/gate-check.js +8 -1
- package/src/commands/state/gate-decision.js +8 -4
- package/src/commands/state/report-check.js +90 -9
- package/src/commands/state/usecase.js +66 -0
- package/src/commands/tasks/dag.js +17 -2
- package/src/commands/validation/validate-feature.js +6 -1
- package/src/commands/validation/verify.js +20 -1
- package/src/dashboard/scanner/agents-scanner.js +5 -1
- package/src/dashboard/scanner/hooks-scanner.js +8 -1
- package/src/dashboard/scanner/standards-scanner.js +2 -1
- package/src/lib/ai/comment-stripper.js +101 -4
- package/src/lib/evaluator-report.js +25 -0
- package/src/lib/generators/handoff-generator.js +4 -18
- package/src/lib/git/default-branch.js +52 -0
- package/src/lib/git/tree-fingerprint.js +58 -0
- package/src/lib/git/tree-state.js +167 -0
- package/src/lib/graph/god-nodes.js +12 -3
- package/src/lib/graph/graph-health.js +48 -2
- package/src/lib/graph/graph-loader.js +99 -25
- package/src/lib/graph/graphifyignore.js +145 -28
- package/src/lib/graph/sanitize.js +117 -11
- package/src/lib/risk-detector.js +90 -0
- package/src/lib/specs/acervo-format.js +70 -0
- package/src/lib/specs/acervo-lifecycle.js +264 -0
- package/src/lib/specs/adr-index.js +169 -0
- package/src/lib/specs/revocations.js +156 -0
- package/src/lib/specs/screen-checklist.js +357 -0
- package/src/lib/specs/usecase-check.js +20 -16
- package/src/lib/specs/usecase-promoter.js +103 -38
- package/src/lib/specs/usecase-registry.js +7 -7
- package/src/lib/tasks/tasks-json-parser.js +2 -3
- package/src/lib/telemetry/metrics.js +386 -0
- package/src/lib/telemetry/telemetry-reader.js +161 -51
- package/src/lib/telemetry/verify-event.js +79 -0
- package/src/lib/validators/code-budget/code-budget-validator.js +172 -0
- package/src/lib/validators/code-budget/comment-scanner.js +75 -0
- package/src/lib/validators/code-budget/index.js +9 -0
- package/src/lib/validators/reuse/reuse-validator.js +4 -2
- package/src/lib/validators/reuse/symbol-scanner.js +27 -22
- package/src/lib/validators/validation-runner.js +21 -5
- package/src/lib/validators/verify-runner.js +11 -0
- package/src/lib/worktree/finish-backup.js +739 -0
- package/src/lib/worktree/ignored-triage.js +15 -8
- package/src/lib/worktree/worktree-audit.js +5 -0
- package/src/scripts/setup-infra.js +29 -18
- package/src/utils/agents-installer.js +281 -12
- 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/`,
|
|
22
|
-
* `
|
|
23
|
-
* Reproducible from source, which is a
|
|
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
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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, {
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
* @
|
|
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(
|
|
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
|
-
|
|
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
|
/**
|