@polymorphism-tech/morph-spec 8.40.0 → 8.41.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 (45) hide show
  1. package/CLAUDE.md +2 -2
  2. package/README.md +2 -2
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +1 -1
  7. package/framework/MORPH.md +9 -8
  8. package/framework/agents.json +8 -2
  9. package/framework/docs/CLI.md +5 -3
  10. package/framework/hooks/README.md +1 -1
  11. package/framework/hooks/claude-code/core/telemetry-log.js +39 -8
  12. package/framework/hooks/claude-code/core/worktree-create.js +22 -6
  13. package/framework/hooks/shared/git-paths.js +38 -5
  14. package/framework/mcp/registry.json +26 -65
  15. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  16. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  17. package/framework/skills/core/morph-apply/SKILL.md +55 -25
  18. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  19. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  20. package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
  21. package/framework/skills/core/morph-implement/SKILL.md +43 -24
  22. package/framework/skills/core/morph-media/SKILL.md +2 -2
  23. package/framework/skills/core/morph-plan/SKILL.md +7 -1
  24. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  25. package/framework/skills/core/morph-review/SKILL.md +1 -1
  26. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  27. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  28. package/framework/skills/core/morph-uiux/SKILL.md +3 -3
  29. package/framework/standards/PROVENANCE.json +4 -4
  30. package/framework/standards/STANDARDS.json +6 -6
  31. package/framework/standards/backend/dotnet/testing.md +4 -2
  32. package/framework/standards/frontend/design-system/ai-image-generation.md +42 -28
  33. package/framework/standards/frontend/design-system/ai-video-generation.md +24 -9
  34. package/framework/standards/frontend/nextjs/testing.md +4 -2
  35. package/framework/standards/integration/mcp/mcp-tools.md +19 -12
  36. package/package.json +1 -1
  37. package/src/commands/project/doctor.js +9 -1
  38. package/src/commands/project/worktree.js +60 -4
  39. package/src/commands/state/gate-check.js +5 -0
  40. package/src/commands/tasks/dag.js +27 -4
  41. package/src/commands/validation/verify.js +22 -38
  42. package/src/lib/risk-detector.js +13 -0
  43. package/src/lib/tasks/tasks-json-parser.js +21 -0
  44. package/src/lib/worktree/env-allocator.js +9 -3
  45. package/src/utils/agents-installer.js +51 -1
@@ -1,7 +1,7 @@
1
1
  import chalk from 'chalk';
2
- import { existsSync } from 'fs';
2
+ import { existsSync, readFileSync } from 'fs';
3
3
  import { join } from 'path';
4
- import { parseTasksJsonLenient } from '../../lib/tasks/tasks-json-parser.js';
4
+ import { parseTasksJsonLenient, oversizedTasks, MAX_TASK_OUTPUTS } from '../../lib/tasks/tasks-json-parser.js';
5
5
  import { planDispatch } from '../../lib/tasks/dispatch-planner.js';
6
6
  import { renderMermaidFlowchart } from '../../lib/tasks/mermaid-renderer.js';
7
7
  import { checkUseCases } from '../../lib/specs/usecase-check.js';
@@ -16,6 +16,15 @@ export function printLifecycleWarnings(lifecycle) {
16
16
  for (const w of lifecycle.warnings) console.error(chalk.yellow(` ${w}`));
17
17
  }
18
18
 
19
+ function planGateApproved(featureDir) {
20
+ try {
21
+ const feature = JSON.parse(readFileSync(join(featureDir, 'feature.json'), 'utf8'));
22
+ return feature?.approvalGates?.plan?.approved === true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
19
28
  /**
20
29
  * `morph-spec dag <feature>` — analyze the tasks.json DAG and print the
21
30
  * sequential vs parallel dispatch decision, weighted by effort.
@@ -72,11 +81,17 @@ export async function dagCommand(featureName, options = {}) {
72
81
  // revocation rows the finish would fail to apply. Warnings only.
73
82
  const lifecycle = checkLifecycle({ cwd: process.cwd(), featureDir, tasksData: data });
74
83
 
84
+ // Task acima do teto não cabe num dispatch. Antes do Gate 2 é erro (o plano
85
+ // ainda fatia); depois é aviso, para não travar feature em voo.
86
+ const oversized = oversizedTasks(data);
87
+ const sizeBlocks = oversized.length > 0 && !planGateApproved(featureDir);
88
+ const taskSize = { max: MAX_TASK_OUTPUTS, oversized, blocking: sizeBlocks };
89
+
75
90
  const plan = planDispatch(data);
76
91
 
77
92
  if (options.json) {
78
- console.log(JSON.stringify({ ...plan, useCases: ucCheck, artifactCriteria, lifecycle }, null, 2));
79
- if (ucCheck.errors.length) process.exit(1);
93
+ console.log(JSON.stringify({ ...plan, useCases: ucCheck, artifactCriteria, lifecycle, taskSize }, null, 2));
94
+ if (ucCheck.errors.length || sizeBlocks) process.exit(1);
80
95
  return;
81
96
  }
82
97
 
@@ -88,6 +103,14 @@ export async function dagCommand(featureName, options = {}) {
88
103
  console.error(chalk.gray('\n Corrija a referência no tasks.json ou restaure o use case.\n'));
89
104
  process.exit(1);
90
105
  }
106
+ if (oversized.length) {
107
+ const tone = sizeBlocks ? chalk.red : chalk.yellow;
108
+ const head = sizeBlocks ? '❌' : '⚠';
109
+ console.error(tone(`\n${head} Tamanho de task — ${plural(oversized.length, 'task', 'tasks')} acima de ${MAX_TASK_OUTPUTS} outputs`));
110
+ for (const t of oversized) console.error(tone(` ${t.id}: ${t.outputs} outputs`));
111
+ console.error(chalk.gray(' Fatie por pós-condição: uma task cabe num dispatch (morph-plan, "Tamanho da task").\n'));
112
+ if (sizeBlocks) process.exit(1);
113
+ }
91
114
  if (ucCheck.warnings.length) {
92
115
  console.error(chalk.yellow(`\n⚠ Use cases — ${plural(ucCheck.warnings.length, 'aviso', 'avisos')}`));
93
116
  for (const w of ucCheck.warnings) console.error(chalk.yellow(` ${w}`));
@@ -1,9 +1,10 @@
1
1
  import chalk from 'chalk';
2
- import { readFileSync } from 'fs';
3
- import { resolve, join } from 'path';
2
+ import { resolve } from 'path';
4
3
  import { getFeature, updateFeature } from '../../core/state/state-manager.js';
5
4
  import { runVerify, readTask, VerifyUsageError } from '../../lib/validators/verify-runner.js';
6
5
  import { resolvePrimaryRoot } from '../project/worktree.js';
6
+ import { resolveFeatureTree } from '../../lib/worktree/feature-truth.js';
7
+ import { canonicalPath } from '../../lib/tasks/test-runner.js';
7
8
  import { getHeadSha } from '../../lib/git/default-branch.js';
8
9
  import { EVAL_RUNNER } from '../../lib/tasks/eval-node.js';
9
10
  import { buildVerifyEvent, recordVerifyEvent, findRepeatedVerify } from '../../lib/telemetry/verify-event.js';
@@ -93,25 +94,25 @@ function formatVerifyResult(result) {
93
94
  return lines.join('\n');
94
95
  }
95
96
 
97
+ const samePath = (a, b) => canonicalPath(a).replace(/\\/g, '/') === canonicalPath(b).replace(/\\/g, '/');
98
+
96
99
  /**
97
- * Decide whether it's safe to persist a verify result to feature.json.
100
+ * Persist only in the tree that OWNS the feature — the same predicate
101
+ * `list`/`handoff`/the mutator guard read (`resolveFeatureTree`), asked from
102
+ * the PRIMARY ROOT so every tree gets the same answer. Any other tree (a
103
+ * sub-agent's `morph-task/*` tree branched from `morph/{f}`, a scratch
104
+ * checkout) must never write feature.json: its copy is not the one the gates
105
+ * read, and the write would merge-conflict with the owner's.
98
106
  *
99
- * Safe contexts: the primary git root, or the feature's own REGISTERED
100
- * worktree (`worktree.path` — where feature.json is authoritative and
101
- * travels with the branch). Any other worktree (e.g. a harness subagent's
102
- * temporary scratch worktree) must never write feature.json — that would
103
- * risk a merge conflict / violate the single-writer clause.
107
+ * The old rule read only the root's `worktree.path` pointer, which a feature
108
+ * created INSIDE its worktree never has: a `worktrees/homolog-v2` on
109
+ * `morph/chat-demo-simulacao` measured `unregistered-worktree` on 11/11 runs,
110
+ * and `repeatOf` (computed only when persisting) never fired. Git's worktree
111
+ * registry names that tree as the owner.
104
112
  *
105
- * Worktree registration is ROOT-OWNED data: `runWorktreeSetup` (see
106
- * src/commands/project/worktree.js) writes `.worktree` ONLY into the PRIMARY
107
- * ROOT's copy of feature.json (via saveState after chdir'ing back to root) —
108
- * it never touches the worktree's own branch-local copy. So this must read
109
- * the ROOT's feature.json to find the registration, never the cwd-local one
110
- * (`getFeature(featureName)` resolves relative to `process.cwd()`, which
111
- * inside a worktree is the branch-local copy and will never carry
112
- * `.worktree` — reading it here would make this branch permanently dead code
113
- * for the exact case it exists to handle: verify running from inside a
114
- * registered worktree).
113
+ * The root is not special: with a live worktree owning the feature, the
114
+ * root's copy is the birth-instant skeleton and a stamp there is drift —
115
+ * `owner-tree-elsewhere`, the verify side of `assertOwnerTree`.
115
116
  *
116
117
  * @param {{ cwd: string, featureName: string, noPersist: boolean }} params
117
118
  * @returns {{ persist: boolean, reason: string|null }}
@@ -120,26 +121,9 @@ function decidePersistence({ cwd, featureName, noPersist }) {
120
121
  if (noPersist) return { persist: false, reason: 'no-persist-flag' };
121
122
 
122
123
  const root = resolvePrimaryRoot(cwd);
123
- const normalizedCwd = resolve(cwd).replace(/\\/g, '/');
124
-
125
- if (normalizedCwd === root) return { persist: true, reason: null };
126
-
127
- // cwd is (or looks like) a worktree — consult the ROOT's feature.json for
128
- // the registration. Fail-open: an unreadable/missing root feature.json
129
- // means we can't confirm registration, so treat as unregistered.
130
- let registeredWt = null;
131
- try {
132
- const rootFeatureJsonPath = join(root, '.morph', 'features', featureName, 'feature.json');
133
- const rootFeature = JSON.parse(readFileSync(rootFeatureJsonPath, 'utf8'));
134
- registeredWt = rootFeature.worktree?.path
135
- ? String(rootFeature.worktree.path).replace(/\\/g, '/')
136
- : null;
137
- } catch {
138
- registeredWt = null;
139
- }
140
-
141
- if (registeredWt && normalizedCwd === registeredWt) return { persist: true, reason: null };
142
- return { persist: false, reason: 'unregistered-worktree' };
124
+ const owner = resolveFeatureTree(featureName, root);
125
+ if (owner.tree && samePath(owner.tree, cwd)) return { persist: true, reason: null };
126
+ return { persist: false, reason: samePath(root, cwd) ? 'owner-tree-elsewhere' : 'unregistered-worktree' };
143
127
  }
144
128
 
145
129
  /**
@@ -27,6 +27,8 @@
27
27
  * section?, uncovered? } derivado do 1-design/mockups.md. No uiux, checklist
28
28
  * de tela incompleto pausa; no ship, item decidido sem a conferência do
29
29
  * avaliador pausa. Mockup legado é isento nos dois, com a razão listada
30
+ * - oversizedTasks + gate: array — tasks acima de MAX_TASK_OUTPUTS outputs;
31
+ * pausa SÓ no plano (gate 'plan'): a task tem de caber num dispatch
30
32
  * - evalScore: number — morph-eval score (pause if < 9)
31
33
  * - taskCount: number — total tasks (pause if > 20)
32
34
  */
@@ -425,6 +427,14 @@ export function shouldPauseGate(config, signals) {
425
427
  };
426
428
  }
427
429
 
430
+ if (signals.gate === 'plan' && Array.isArray(signals.oversizedTasks) && signals.oversizedTasks.length > 0) {
431
+ const list = signals.oversizedTasks.map((t) => `${t.id} (${t.outputs})`).join(', ');
432
+ return {
433
+ pause: true,
434
+ reason: `task grande demais para um dispatch: ${list} outputs — fatie por pós-condição`,
435
+ };
436
+ }
437
+
428
438
  if (typeof signals.evalScore === 'number' && signals.evalScore < MIN_EVAL_SCORE) {
429
439
  return {
430
440
  pause: true,
@@ -498,6 +508,9 @@ export function getRiskSignals(signals) {
498
508
  out.push(`prompt/modelo tocado (${touched.length}) com eval ${signals.evalsStatus ?? 'não declarado'}`);
499
509
  }
500
510
  }
511
+ if (signals.gate === 'plan' && Array.isArray(signals.oversizedTasks) && signals.oversizedTasks.length > 0) {
512
+ out.push(`tasks grandes demais: ${signals.oversizedTasks.map((t) => t.id).join(', ')}`);
513
+ }
501
514
  if (typeof signals.evalScore === 'number' && signals.evalScore < MIN_EVAL_SCORE) {
502
515
  out.push(`eval score ${signals.evalScore}/10`);
503
516
  }
@@ -193,6 +193,27 @@ export function parseTasksJsonSync(filePath) {
193
193
  return data;
194
194
  }
195
195
 
196
+ /**
197
+ * Teto de `outputs` por task: a task inteira tem de caber num dispatch. Medido
198
+ * em campo (chat-demo-simulacao): tasks de 12 a 30 outputs levaram 6 de 10
199
+ * sub-agents ao teto de turnos, e cada retomada custou um contexto frio.
200
+ */
201
+ export const MAX_TASK_OUTPUTS = 8;
202
+
203
+ /**
204
+ * A régua única de "task grande demais", lida pelo `dag` e pelo `gate-check`.
205
+ * Fora de `validateTasksJson` de propósito: `outputs` segue mutável depois do
206
+ * Gate 2, e o teto num write de status travaria feature em voo.
207
+ *
208
+ * @returns {{ id: string, outputs: number }[]}
209
+ */
210
+ export function oversizedTasks(data) {
211
+ const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
212
+ return tasks
213
+ .filter(t => Array.isArray(t?.outputs) && t.outputs.length > MAX_TASK_OUTPUTS)
214
+ .map(t => ({ id: t.id, outputs: t.outputs.length }));
215
+ }
216
+
196
217
  /**
197
218
  * @returns {object[]} tasks with status === 'pending'
198
219
  */
@@ -191,16 +191,22 @@ export function portLayout(block, { stack = '', feature = '' } = {}) {
191
191
  * feature, and exactly the `portLayout()` pairs — nothing else, so the file
192
192
  * cannot drift from what compose receives.
193
193
  *
194
- * @param {string} feature
194
+ * `morphFeature` is for a task tree born from `morph/<f>`: `MORPH_FEATURE`
195
+ * names the feature it works for, while the block and `COMPOSE_PROJECT_NAME`
196
+ * stay keyed by `feature` (`task/<n>`) — keying them by `<f>` would hand the
197
+ * sub-agent the feature's own ports and compose project.
198
+ *
199
+ * @param {string} feature - the env-registry key: `<feature>` or `task/<name>`
195
200
  * @param {{ base: number, range: [number, number] }} block
196
201
  * @param {string} [stack] - config.json `project.stack` (e.g. "nextjs+dotnet")
202
+ * @param {{ morphFeature?: string|null }} [opts]
197
203
  * @returns {string}
198
204
  */
199
- export function buildEnvFileContent(feature, block, stack = '') {
205
+ export function buildEnvFileContent(feature, block, stack = '', { morphFeature = null } = {}) {
200
206
  const lines = [
201
207
  `# Managed by morph-spec — ambiente dedicado desta worktree (feature: ${feature}).`,
202
208
  '# Derivado, git-invisível, regenerado por `worktree setup|link`; nunca editar nem commitar.',
203
- `MORPH_FEATURE=${feature}`,
209
+ `MORPH_FEATURE=${morphFeature || feature}`,
204
210
  ...Object.entries(portLayout(block, { stack, feature })).map(([k, v]) => `${k}=${v}`),
205
211
  ];
206
212
  return `${lines.join('\n')}\n`;
@@ -102,6 +102,35 @@ export function resolveMaxTurns(agent) {
102
102
  return agent.maxTurns ?? DEFAULT_MAX_TURNS;
103
103
  }
104
104
 
105
+ /** Values Claude Code accepts in a subagent's `effort` frontmatter. */
106
+ export const VALID_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
107
+
108
+ /**
109
+ * The ONE rule for a valid `effort` — same four readers as `maxTurnsProblem`.
110
+ * Absent means the frontmatter carries no `effort:` line and the sub-agent
111
+ * inherits the session's effort, byte-for-byte what the generator emitted
112
+ * before the field existed.
113
+ *
114
+ * @param {unknown} value - the raw agents.json value
115
+ * @returns {string|null} what was expected, or null when valid
116
+ */
117
+ export function effortProblem(value) {
118
+ if (value === undefined) return null;
119
+ return VALID_EFFORTS.includes(value) ? null : `esperado um de ${VALID_EFFORTS.join('|')}`;
120
+ }
121
+
122
+ /**
123
+ * @param {string} id - persona id (agents.json key)
124
+ * @param {unknown} value - the raw agents.json value
125
+ * @returns {string|null} null when valid
126
+ */
127
+ export function effortError(id, value) {
128
+ const problem = effortProblem(value);
129
+ if (!problem) return null;
130
+ return `agents.json: persona "${id}" tem effort inválido (${JSON.stringify(value)}) — ${problem}. ` +
131
+ 'Remova o campo para herdar o effort da sessão.';
132
+ }
133
+
105
134
  /** A surface id is the key the mockup checklist names (`Superfície: admin-app`). */
106
135
  const SURFACE_ID_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
107
136
 
@@ -192,7 +221,9 @@ export function personaStandardIds(persona) {
192
221
 
193
222
  /** The first validity error of a persona entry, or null — what drift reports as `invalid`. */
194
223
  function personaError(agent) {
195
- return maxTurnsError(agent.id, agent.maxTurns) ?? surfacesError(agent.id, agent.surfaces, agent.standards);
224
+ return maxTurnsError(agent.id, agent.maxTurns)
225
+ ?? surfacesError(agent.id, agent.surfaces, agent.standards)
226
+ ?? effortError(agent.id, agent.effort);
196
227
  }
197
228
 
198
229
  /**
@@ -376,6 +407,8 @@ export function renderAgentMd(agent, knownMcpIds = null) {
376
407
  const maxTurns = resolveMaxTurns(agent);
377
408
  const surfaces = surfacesError(agent.id, agent.surfaces, agent.standards);
378
409
  if (surfaces) throw new Error(surfaces);
410
+ const effort = effortError(agent.id, agent.effort);
411
+ if (effort) throw new Error(effort);
379
412
  const frontmatter = buildFrontmatter(agent, description, knownMcpIds, maxTurns);
380
413
  const body = buildBody(agent, maxTurns);
381
414
  return `---\n${frontmatter}---\n\n${MANAGED_HEADER}\n\n${body}\n`;
@@ -409,6 +442,7 @@ function buildFrontmatter(agent, description, knownMcpIds, maxTurns) {
409
442
  // exactly what the hardcoded `maxTurns: 20` rendered.
410
443
  `maxTurns: ${maxTurns}`,
411
444
  ];
445
+ if (agent.effort) lines.push(`effort: ${agent.effort}`);
412
446
  if (agent.color) lines.push(`color: ${agent.color}`);
413
447
  lines.push(`memory: project`, '');
414
448
  return lines.join('\n');
@@ -475,6 +509,22 @@ function buildBody(agent, maxTurns) {
475
509
  '`PARCIAL` on line 1, done, remaining, files, no score (final report only) — then continue or stop, your call; the final report replaces it.',
476
510
  );
477
511
 
512
+ // The dispatch template is paraphrased by the orchestrator in the field; this
513
+ // body is the only text every implementer dispatch is guaranteed to carry.
514
+ // Gated on `effort` so a persona without it renders byte-for-byte as before.
515
+ if (agent.effort && isImplementer(agent)) {
516
+ lines.push(
517
+ '',
518
+ '## Working fast',
519
+ '',
520
+ 'Independent tool calls go in the SAME response: read every listed file at once, fire the Greps together. ' +
521
+ 'Use Read/Grep/Glob, never grep/sed/cat/find via Bash. Check a library API in the `context7` MCP before guessing ' +
522
+ '(load a deferred `mcp__` tool with ToolSearch). Write the tests the budget asks for, then run only your own ' +
523
+ 'filter ONCE before the report — no red→green cycles, no `morph-spec verify` (the orchestrator proves it). ' +
524
+ 'Never alter, revert or move production code to watch a test fail: mutation proof belongs to the Gate 3 evaluator.',
525
+ );
526
+ }
527
+
478
528
  return lines.join('\n');
479
529
  }
480
530