@polymorphism-tech/morph-spec 9.7.0 → 10.0.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 (211) hide show
  1. package/CLAUDE.md +11 -11
  2. package/README.md +4 -4
  3. package/bin/morph-spec.js +41 -44
  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 +24 -30
  8. package/framework/MORPH.md +47 -123
  9. package/framework/agents.json +6 -374
  10. package/framework/commands/morph-apply.md +1 -1
  11. package/framework/commands/morph-archive.md +4 -3
  12. package/framework/commands/morph-hotfix.md +8 -30
  13. package/framework/commands/morph-infra.md +2 -2
  14. package/framework/commands/morph-media.md +10 -9
  15. package/framework/commands/morph-review.md +15 -0
  16. package/framework/commands/morph-status.md +1 -1
  17. package/framework/docs/CLI.md +100 -102
  18. package/framework/evals/review-checklist.md +46 -54
  19. package/framework/hooks/README.md +4 -4
  20. package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
  21. package/framework/hooks/claude-code/core/README.md +1 -1
  22. package/framework/hooks/claude-code/core/denylist.json +4 -4
  23. package/framework/hooks/claude-code/core/gate-guard.js +9 -0
  24. package/framework/hooks/claude-code/core/masked-exit.js +49 -0
  25. package/framework/hooks/claude-code/core/state-sync.js +4 -1
  26. package/framework/hooks/claude-code/core/telemetry-log.js +1 -2
  27. package/framework/hooks/claude-code/core/test-budget.js +10 -70
  28. package/framework/hooks/claude-code/core/validate-completion.js +11 -24
  29. package/framework/hooks/claude-code/core/worktree-context.js +5 -22
  30. package/framework/hooks/claude-code/core/worktree-create.js +6 -45
  31. package/framework/hooks/claude-code/core/worktree-env-inject.js +2 -10
  32. package/framework/hooks/shared/e2e-check.js +0 -0
  33. package/framework/hooks/shared/gate-event.js +2 -2
  34. package/framework/hooks/shared/graph-refresh.js +20 -3
  35. package/framework/hooks/shared/heavy-command.js +32 -1
  36. package/framework/hooks/shared/heavy-config.js +1 -1
  37. package/framework/hooks/shared/heavy-slots.js +41 -4
  38. package/framework/hooks/shared/next-step.js +63 -24
  39. package/framework/hooks/shared/phase-utils.js +6 -9
  40. package/framework/hooks/shared/report-progress.js +86 -0
  41. package/framework/hooks/shared/state-reader.js +3 -2
  42. package/framework/hooks/statusline.js +332 -0
  43. package/framework/phases.json +32 -10
  44. package/framework/rules/ai-agents-standards.md +9 -1
  45. package/framework/rules/csharp-standards.md +4 -0
  46. package/framework/rules/ef-standards.md +20 -0
  47. package/framework/rules/frontend-standards.md +1 -1
  48. package/framework/rules/implement-report.md +25 -0
  49. package/framework/rules/infrastructure-standards.md +2 -0
  50. package/framework/rules/nextjs-standards.md +7 -0
  51. package/framework/rules/plan-grammar.md +53 -0
  52. package/framework/rules/ui-design.md +27 -0
  53. package/framework/schemas/config.schema.json +12 -19
  54. package/framework/schemas/feature.schema.json +7 -1
  55. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  56. package/framework/skills/core/create-umbraco-lp/references/umbraco-lp-patterns.md +1 -1
  57. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +2 -2
  58. package/framework/skills/core/morph-apply/SKILL.md +38 -166
  59. package/framework/skills/core/morph-apply/references/debugging.md +8 -0
  60. package/framework/skills/core/morph-apply/references/hotfix.md +42 -0
  61. package/framework/skills/core/morph-apply/references/machine.md +29 -0
  62. package/framework/skills/core/{morph-media/SKILL.md → morph-apply/references/media.md} +11 -74
  63. package/framework/skills/core/morph-apply/references/review.md +65 -0
  64. package/framework/skills/core/morph-plan/SKILL.md +42 -108
  65. package/framework/skills/core/morph-plan/references/critique.md +40 -0
  66. package/framework/skills/core/morph-plan/references/interview.md +25 -0
  67. package/framework/skills/core/morph-uiux/SKILL.md +47 -64
  68. package/framework/skills/core/morph-uiux/surfaces.json +32 -0
  69. package/framework/standards/STANDARDS.json +32 -5
  70. package/framework/standards/STANDARDS.meta.json +5 -1
  71. package/framework/standards/ai-agents/agent-session.md +2 -3
  72. package/framework/standards/ai-agents/context-providers.md +2 -2
  73. package/framework/standards/ai-agents/durable-workflows-hitl.md +2 -2
  74. package/framework/standards/ai-agents/providers/model-registry.md +2 -2
  75. package/framework/standards/ai-agents/rag-custom-pgvector.md +6 -6
  76. package/framework/standards/ai-agents/setup.md +1 -1
  77. package/framework/standards/ai-agents/tool-adapters.md +2 -2
  78. package/framework/standards/architecture/stack-baseline.md +47 -0
  79. package/framework/standards/backend/integrations/neon-auth/neon-auth.md +2 -4
  80. package/framework/standards/frontend/design-system/ai-image-generation.md +51 -12
  81. package/framework/standards/frontend/design-system/ai-video-generation.md +50 -4
  82. package/framework/standards/frontend/design-system/art-direction.md +2 -2
  83. package/framework/standards/frontend/scroll-driven/frame-scrub.md +12 -2
  84. package/framework/standards/frontend/scroll-driven/scroll-components.md +76 -4
  85. package/framework/templates/.idea/morph-templates.xml +92 -0
  86. package/framework/templates/.vscode/morph-templates.code-snippets +186 -0
  87. package/framework/templates/IDE-SNIPPETS.md +262 -262
  88. package/framework/templates/REGISTRY.json +1 -2
  89. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  90. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  91. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  92. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  93. package/framework/templates/context/ARCHITECTURE.md +1 -1
  94. package/framework/templates/docs/plan.md +12 -12
  95. package/framework/templates/docs/ui-mockups.md +17 -105
  96. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  97. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  98. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  99. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  100. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  101. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  102. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  103. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  104. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  105. package/framework/templates/state.template.json +17 -17
  106. package/framework/templates/ui/design-system.css +226 -226
  107. package/package.json +1 -1
  108. package/src/commands/e2e/e2e.js +88 -23
  109. package/src/commands/graph/diff.js +74 -0
  110. package/src/commands/graph/status.js +1 -1
  111. package/src/commands/observability/cost.js +2 -4
  112. package/src/commands/observability/telemetry.js +1 -1
  113. package/src/commands/project/doctor.js +23 -39
  114. package/src/commands/project/fleet.js +5 -6
  115. package/src/commands/project/init.js +63 -36
  116. package/src/commands/project/stack-scan.js +3 -42
  117. package/src/commands/project/stack-set.js +26 -0
  118. package/src/commands/project/status.js +12 -13
  119. package/src/commands/project/update.js +39 -3
  120. package/src/commands/project/worktree.js +27 -16
  121. package/src/commands/state/advance.js +17 -17
  122. package/src/commands/state/approve.js +0 -6
  123. package/src/commands/state/archive.js +27 -1
  124. package/src/commands/state/auto-approval.js +1 -1
  125. package/src/commands/state/create.js +0 -5
  126. package/src/commands/state/finish.js +43 -1
  127. package/src/commands/state/gate-check.js +37 -30
  128. package/src/commands/state/gate-decision.js +3 -2
  129. package/src/commands/state/next.js +1 -0
  130. package/src/commands/state/recap.js +67 -0
  131. package/src/commands/state/report-check.js +39 -146
  132. package/src/commands/validation/plan-check.js +16 -11
  133. package/src/commands/validation/validate-feature.js +3 -3
  134. package/src/commands/validation/verify.js +6 -6
  135. package/src/core/paths/output-schema.js +3 -3
  136. package/src/core/state/state-manager.js +2 -4
  137. package/src/dashboard/app/components/CommandPalette.jsx +2 -5
  138. package/src/dashboard/app/components/GatePauses.jsx +116 -0
  139. package/src/dashboard/app/components/SessionMap.jsx +77 -0
  140. package/src/dashboard/app/main.jsx +13 -13
  141. package/src/dashboard/app/pages/Activity.jsx +4 -0
  142. package/src/dashboard/app/pages/AgentMap.jsx +1 -2
  143. package/src/dashboard/app/pages/FeatureDetail.jsx +129 -186
  144. package/src/dashboard/app/pages/Features.jsx +7 -7
  145. package/src/dashboard/app/pages/PhasePipeline.jsx +1 -1
  146. package/src/dashboard/app/pages/Portfolio.jsx +40 -5
  147. package/src/dashboard/scanner/activity-scanner.js +29 -0
  148. package/src/dashboard/scanner/agents-scanner.js +11 -14
  149. package/src/dashboard/scanner/features-scanner.js +45 -24
  150. package/src/dashboard/scanner/project-api.js +11 -2
  151. package/src/dashboard/scanner/skills-scanner.js +11 -1
  152. package/src/dashboard/vite.config.js +24 -24
  153. package/src/lib/e2e/applicability.js +3 -1
  154. package/src/lib/git/tree-state.js +1 -16
  155. package/src/lib/graph/diff-tree.js +127 -0
  156. package/src/lib/graph/graphify-runtime.js +12 -5
  157. package/src/lib/heavy/gate.js +22 -1
  158. package/src/lib/installers/memory-installer.js +2 -2
  159. package/src/lib/installers/rules-installer.js +57 -0
  160. package/src/lib/installers/stack-assets.js +17 -0
  161. package/src/lib/installers/stack-sync.js +219 -0
  162. package/src/lib/plan/checks.js +55 -110
  163. package/src/lib/plan/critique.js +28 -0
  164. package/src/lib/plan/media.js +42 -0
  165. package/src/lib/plan/plan-html.js +125 -0
  166. package/src/lib/plan/plan-parser.js +34 -45
  167. package/src/lib/recap/piper.js +36 -0
  168. package/src/lib/recap/recap.js +232 -0
  169. package/src/lib/review-reports.js +33 -7
  170. package/src/lib/risk-detector.js +90 -11
  171. package/src/lib/specs/screen-checklist.js +33 -48
  172. package/src/lib/stack/dotnet-build-ignores.js +8 -9
  173. package/src/lib/tasks/test-nodes.js +24 -6
  174. package/src/lib/tasks/test-runner.js +40 -1
  175. package/src/lib/telemetry/metrics.js +27 -29
  176. package/src/lib/validators/reuse/reuse-validator.js +1 -9
  177. package/src/lib/validators/verify-runner.js +50 -19
  178. package/src/lib/worktree/fleet-scanner.js +7 -20
  179. package/src/lib/worktree/worktree-audit.js +0 -37
  180. package/src/scripts/setup-infra.js +15 -50
  181. package/src/utils/agents-installer.js +27 -201
  182. package/src/utils/hooks-installer.js +11 -95
  183. package/framework/hooks/claude-code/core/warn-bash-source-write.js +0 -23
  184. package/framework/hooks/claude-code/statusline.py +0 -699
  185. package/framework/hooks/claude-code/statusline.sh +0 -11
  186. package/framework/hooks/shared/agent-reap.js +0 -74
  187. package/framework/hooks/shared/slice-progress.js +0 -110
  188. package/framework/skills/core/morph-hotfix/SKILL.md +0 -133
  189. package/framework/skills/core/morph-machine/SKILL.md +0 -46
  190. package/framework/skills/core/morph-review/SKILL.md +0 -129
  191. package/framework/skills/core/morph-standards/SKILL.md +0 -52
  192. package/framework/standards/PROVENANCE.json +0 -119
  193. package/src/commands/project/worktree-reap.js +0 -34
  194. package/src/commands/state/handoff.js +0 -65
  195. package/src/commands/validation/mutate.js +0 -263
  196. package/src/commands/validation/scout-check.js +0 -28
  197. package/src/lib/generators/handoff-generator.js +0 -172
  198. package/src/lib/mutation/adapters.js +0 -79
  199. package/src/lib/mutation/commands.js +0 -61
  200. package/src/lib/mutation/dotnet-introspection.js +0 -281
  201. package/src/lib/mutation/manifest.js +0 -144
  202. package/src/lib/mutation/plan.js +0 -44
  203. package/src/lib/mutation/report.js +0 -68
  204. package/src/lib/mutation/runner.js +0 -301
  205. package/src/lib/mutation/schemata.js +0 -60
  206. package/src/lib/mutation/snapshot.js +0 -65
  207. package/src/lib/mutation/subset.js +0 -109
  208. package/src/lib/mutation/trx.js +0 -50
  209. package/src/lib/scout/scout-check.js +0 -293
  210. package/src/lib/validators/traceability-check.js +0 -103
  211. package/src/lib/worktree/agent-reaper.js +0 -247
@@ -0,0 +1,219 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { detectProject } from '../detectors/index.js';
4
+ import { getProjectTags, parseStacks, shouldInstall } from '../stack-filter.js';
5
+ import { WORKTREE_DIR_NAMES } from '../worktree/dirs.js';
6
+ import { resolveComposeFile } from '../../../framework/hooks/shared/e2e-check.js';
7
+ import { createEffects } from './effects.js';
8
+ import { MAF_RULE, MAF_TAG, installRuleFile, ruleContentFor } from './rules-installer.js';
9
+
10
+ const RULES_SRC = join(import.meta.dirname, '..', '..', '..', 'framework', 'rules');
11
+ const STACK_TAGS = new Set(['nextjs', 'dotnet']);
12
+ const SKIP_DIRS = new Set(['node_modules', 'bin', 'obj', ...WORKTREE_DIR_NAMES]);
13
+ const MAX_DEPTH = 5;
14
+ const MAX_ENTRIES = 20000;
15
+ const NEXT_CONFIG = /^next\.config\.(js|mjs|cjs|ts|mts)$/;
16
+ const DOCKER_FILE = /^(Dockerfile.*|docker-compose\..*|compose\.ya?ml)$/;
17
+ const MAF_PACKAGE =/Include="Microsoft\.Agents\.AI(\.[^"]*)?"/;
18
+
19
+ function ruleFiles(rulesSrc) {
20
+ return readdirSync(rulesSrc).filter((f) => f.endsWith('.md'))
21
+ .map((file) => ({ file, stacks: parseStacks(readFileSync(join(rulesSrc, file), 'utf-8')) }));
22
+ }
23
+
24
+ /** The tags some rule's `stacks:` names: the only detected tags worth persisting. */
25
+ export function ruleTags(rulesSrc = RULES_SRC) {
26
+ return new Set(ruleFiles(rulesSrc).flatMap(({ stacks }) => stacks).filter((s) => s !== '*'));
27
+ }
28
+
29
+ function walk(root, visit) {
30
+ let seen = 0;
31
+ const stack = [[root, 0]];
32
+ while (stack.length) {
33
+ const [dir, depth] = stack.pop();
34
+ let entries;
35
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
36
+ for (const e of entries) {
37
+ if (++seen > MAX_ENTRIES) return;
38
+ if (e.isDirectory()) {
39
+ if (depth < MAX_DEPTH && !e.name.startsWith('.') && !SKIP_DIRS.has(e.name)) stack.push([join(dir, e.name), depth + 1]);
40
+ } else if (e.isFile()) {
41
+ visit(e.name, join(dir, e.name));
42
+ }
43
+ }
44
+ }
45
+ }
46
+
47
+ /**
48
+ * `tags`: what the files show, for picking rules. `persist`: the subset safe to write into config.json —
49
+ * `docker` only with a compose file, since a declared `docker` without one turns the e2e node red.
50
+ */
51
+ export function detectStackTags(projectDir, { config = null, rulesSrc = RULES_SRC } = {}) {
52
+ const found = new Set();
53
+ const { structure } = detectProject(projectDir);
54
+ for (const t of String(structure.stack).split('+')) found.add(t);
55
+ for (const t of structure.integrations) found.add(t);
56
+
57
+ walk(projectDir, (name, path) => {
58
+ if (name.endsWith('.csproj') || name.endsWith('.sln') || name.endsWith('.slnx')) found.add('dotnet');
59
+ if (NEXT_CONFIG.test(name)) found.add('nextjs');
60
+ if (DOCKER_FILE.test(name)) found.add('docker');
61
+ if (name.endsWith('.csproj') || name === 'Directory.Packages.props') {
62
+ try { if (MAF_PACKAGE.test(readFileSync(path, 'utf-8'))) found.add(MAF_TAG); } catch { /* unreadable: not MAF */ }
63
+ }
64
+ });
65
+
66
+ const wanted = ruleTags(rulesSrc);
67
+ wanted.add(MAF_TAG);
68
+ const tags = [...found].filter((t) => wanted.has(t));
69
+ const hasCompose = Boolean(resolveComposeFile(projectDir, config ?? {}));
70
+ const persist = tags.filter((t) => t !== 'docker' || hasCompose);
71
+ return { tags, persist };
72
+ }
73
+
74
+ /** Adds the tags the config lacks; never removes one. `unknown` is a placeholder, not a tag. */
75
+ export function mergeDetectedTags(config, tags) {
76
+ const have = new Set(getProjectTags(config));
77
+ const added = tags.filter((t) => !have.has(t));
78
+ if (added.length === 0) return { config, added, changed: false };
79
+
80
+ const project = { ...(config.project ?? {}) };
81
+ const stackParts = typeof project.stack === 'string'
82
+ ? project.stack.split('+').filter((s) => s && s !== 'unknown')
83
+ : [];
84
+ const integrations = Array.isArray(project.integrations) ? [...project.integrations] : [];
85
+ for (const t of added) (STACK_TAGS.has(t) ? stackParts : integrations).push(t);
86
+ if (stackParts.length) project.stack = stackParts.join('+');
87
+ if (integrations.length) project.integrations = integrations;
88
+ return { config: { ...config, project }, added, changed: true };
89
+ }
90
+
91
+ const configPathOf = (projectDir) => join(projectDir, '.morph', 'config', 'config.json');
92
+
93
+ function readConfig(path) {
94
+ return JSON.parse(readFileSync(path, 'utf-8').replace(/^/, ''));
95
+ }
96
+
97
+ function missingRules(projectDir, tags, rulesSrc) {
98
+ const rulesDest = join(projectDir, '.claude', 'rules');
99
+ const missing = [];
100
+ const stale = [];
101
+ for (const { file, stacks } of ruleFiles(rulesSrc)) {
102
+ if (stacks.includes('*') || !shouldInstall(stacks, tags)) continue;
103
+ const dest = join(rulesDest, file);
104
+ if (!existsSync(dest)) { missing.push(file); continue; }
105
+ if (file !== MAF_RULE || !tags.includes(MAF_TAG)) continue;
106
+ const desired = ruleContentFor(file, readFileSync(join(rulesSrc, file), 'utf-8'), tags);
107
+ if (readFileSync(dest, 'utf-8') !== desired) stale.push(file);
108
+ }
109
+ return { missing, stale };
110
+ }
111
+
112
+ let warned = false;
113
+ function warnOnce(log, message) {
114
+ if (warned) return;
115
+ warned = true;
116
+ log(`⚠ stack-sync: ${message}`);
117
+ }
118
+
119
+ const NOOP = Object.freeze({ added: [], updated: [], tags: [], configChanged: false });
120
+
121
+ /**
122
+ * Installs every stack rule (a `stacks:` other than `*`) the project needs and lacks, after adding to
123
+ * config.json the stack tags its files show. Only an installed project (with config.json) is touched. Never throws.
124
+ * `persistTags: false` leaves config.json alone: a check (`verify`) may write gitignored harness files, never a tracked one.
125
+ */
126
+ export function syncStackRules(projectDir, {
127
+ dryRun = false, fx = createEffects({ dryRun }), log = (line) => console.log(line), rulesSrc = RULES_SRC,
128
+ persistTags = true,
129
+ } = {}) {
130
+ try {
131
+ const configPath = configPathOf(projectDir);
132
+ if (!existsSync(configPath)) return { ...NOOP };
133
+ let config;
134
+ try { config = readConfig(configPath); } catch {
135
+ warnOnce(log, `${configPath} ilegível — regras de stack não sincronizadas`);
136
+ return { ...NOOP };
137
+ }
138
+
139
+ const detected = detectStackTags(projectDir, { config, rulesSrc });
140
+ const merged = persistTags
141
+ ? mergeDetectedTags(config, detected.persist)
142
+ : { config, added: [], changed: false };
143
+ if (merged.changed) {
144
+ fx.writeJson(configPath, merged.config);
145
+ log(` + config.json: ${merged.added.join(', ')} (detectado nos arquivos)`);
146
+ }
147
+
148
+ const tags = [...new Set([...getProjectTags(merged.config), ...detected.tags])];
149
+ const { missing, stale } = missingRules(projectDir, tags, rulesSrc);
150
+ const rulesDest = join(projectDir, '.claude', 'rules');
151
+ for (const file of [...missing, ...stale]) {
152
+ installRuleFile(rulesSrc, rulesDest, file, tags, fx);
153
+ log(` + .claude/rules/${file} (stack: ${tags.join('+')})`);
154
+ }
155
+ return { added: missing, updated: stale, tags, configChanged: merged.changed };
156
+ } catch (err) {
157
+ warnOnce(log, err?.message ?? String(err));
158
+ return { ...NOOP };
159
+ }
160
+ }
161
+
162
+ /** What `stack-set` accepts: the stack tags, and the integrations some rule (or the MAF rewrite) reacts to. */
163
+ export function knownStackTags(rulesSrc = RULES_SRC) {
164
+ const integrations = [...ruleTags(rulesSrc), MAF_TAG].filter((t) => !STACK_TAGS.has(t));
165
+ return { stacks: [...STACK_TAGS].sort(), integrations: [...new Set(integrations)].sort() };
166
+ }
167
+
168
+ /**
169
+ * The user's choice of stack, written into config.json (`project.stack` replaced, `integrations` added to),
170
+ * then the rules it needs installed. `{ error }` names what was refused; nothing is written then.
171
+ */
172
+ export function setProjectStack(projectDir, stack, {
173
+ integrations = [], dryRun = false, fx = createEffects({ dryRun }), log = (line) => console.log(line), rulesSrc = RULES_SRC,
174
+ } = {}) {
175
+ const configPath = configPathOf(projectDir);
176
+ if (!existsSync(configPath)) return { error: `${configPath} ausente — rode morph-spec init antes` };
177
+ const known = knownStackTags(rulesSrc);
178
+ const stackTags = String(stack ?? '').split('+').map((t) => t.trim()).filter(Boolean);
179
+ const unknown = [
180
+ ...stackTags.filter((t) => !known.stacks.includes(t)),
181
+ ...integrations.filter((t) => !known.integrations.includes(t)),
182
+ ];
183
+ if (stackTags.length === 0 || unknown.length > 0) {
184
+ return { error: `tag desconhecida: ${unknown.join(', ') || '(stack vazia)'}. Stack: ${known.stacks.join(' | ')} (unidas por +); --with: ${known.integrations.join(', ')}` };
185
+ }
186
+ let config;
187
+ try { config = readConfig(configPath); } catch { return { error: `${configPath} ilegível` }; }
188
+ if (integrations.includes('docker') && !resolveComposeFile(projectDir, config)) {
189
+ return { error: 'docker sem compose deixa o nó e2e vermelho: crie o compose primeiro (o plan-check e o update declaram docker quando ele existir)' };
190
+ }
191
+
192
+ const project = { ...(config.project ?? {}), stack: stackTags.join('+') };
193
+ const merged = [...new Set([...(Array.isArray(project.integrations) ? project.integrations : []), ...integrations])];
194
+ if (merged.length) project.integrations = merged;
195
+ fx.writeJson(configPath, { ...config, project });
196
+ log(` + config.json: project.stack = ${project.stack}${merged.length ? `, integrations = ${merged.join(', ')}` : ''}`);
197
+ return { stack: project.stack, integrations: merged, sync: syncStackRules(projectDir, { dryRun, fx, log, rulesSrc }) };
198
+ }
199
+
200
+ /** Read-only, for `doctor`: what `syncStackRules` would add. `null` when the config is absent or unreadable. */
201
+ export function stackRuleGaps(projectDir, { rulesSrc = RULES_SRC } = {}) {
202
+ try {
203
+ const configPath = configPathOf(projectDir);
204
+ if (!existsSync(configPath)) return null;
205
+ const config = readConfig(configPath);
206
+ const stack = config?.project?.stack;
207
+ const detected = detectStackTags(projectDir, { config, rulesSrc });
208
+ const tags = [...new Set([...getProjectTags(config), ...detected.tags])];
209
+ const { missing, stale } = missingRules(projectDir, tags, rulesSrc);
210
+ return {
211
+ stackMissing: !stack || stack === 'unknown',
212
+ undeclared: detected.persist.filter((t) => !getProjectTags(config).includes(t)),
213
+ missing: [...missing, ...stale],
214
+ tags,
215
+ };
216
+ } catch {
217
+ return null;
218
+ }
219
+ }
@@ -2,88 +2,18 @@ import { createHash } from 'crypto';
2
2
  import { existsSync, readFileSync } from 'fs';
3
3
  import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
4
4
  import { frozenBody } from '../../../framework/hooks/shared/errata.js';
5
- import { readAgentsJson } from '../specs/screen-checklist.js';
6
5
  import { getFeature } from '../../../framework/hooks/shared/state-reader.js';
7
6
  import { findArtifactTerms } from './artifact-vocabulary.js';
8
- import { PROOF_KINDS, RISK_KINDS, REUSE_VERDICTS, ADR_SCOPES, SECTIONS, CONFLICTS_HEADING } from './plan-parser.js';
7
+ import { mediaCitationError } from './media.js';
8
+ import { PROOF_KINDS, RISK_KINDS, REWORK_RUNGS, REUSE_VERDICTS, ADR_SCOPES, SECTIONS, CONFLICTS_HEADING } from './plan-parser.js';
9
9
 
10
10
  export const CHECKS_VERSION = 1;
11
- export const MAX_SLICE_FILES = 8;
12
- export const MAX_SLICES = 12;
13
- export const MAX_PARALLEL = 4;
14
11
 
15
12
  /** sha256 of the plan without its errata: appending a correction never invalidates the extracted checks. */
16
13
  export function planHash(text) {
17
14
  return createHash('sha256').update(frozenBody(text), 'utf8').digest('hex');
18
15
  }
19
16
 
20
- /** The installed agents.json, else the package's; null when unreadable (personas then go unchecked). */
21
- export function knownPersonas(cwd) {
22
- const agents = readAgentsJson(cwd)?.agents;
23
- return agents && typeof agents === 'object' ? Object.keys(agents) : null;
24
- }
25
-
26
- /** The ids on a dependency cycle, or null. */
27
- export function dependencyCycle(slices) {
28
- const deps = new Map(slices.map((s) => [s.id, s.depends ?? []]));
29
- const state = new Map();
30
- const stack = [];
31
- const visit = (id) => {
32
- if (state.get(id) === 'done' || !deps.has(id)) return null;
33
- if (state.get(id) === 'open') return stack.slice(stack.indexOf(id)).concat(id);
34
- state.set(id, 'open');
35
- stack.push(id);
36
- for (const dep of deps.get(id)) {
37
- const cycle = visit(dep);
38
- if (cycle) return cycle;
39
- }
40
- stack.pop();
41
- state.set(id, 'done');
42
- return null;
43
- };
44
- for (const id of deps.keys()) {
45
- const cycle = visit(id);
46
- if (cycle) return cycle;
47
- }
48
- return null;
49
- }
50
-
51
- const fileKey = (f) => f.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase();
52
-
53
- // A slice joins a wave only after its dependencies ran, never with one touching its files, and at most
54
- // MAX_PARALLEL per wave. Empty on a dependency cycle.
55
- export function parallelGroups(slices) {
56
- if (dependencyCycle(slices)) return [];
57
- const ids = new Set(slices.map((s) => s.id));
58
- const depth = new Map();
59
- const depthOf = (slice) => {
60
- if (depth.has(slice.id)) return depth.get(slice.id);
61
- const parents = (slice.depends ?? []).filter((d) => ids.has(d)).map((d) => slices.find((s) => s.id === d));
62
- const value = parents.length === 0 ? 0 : 1 + Math.max(...parents.map(depthOf));
63
- depth.set(slice.id, value);
64
- return value;
65
- };
66
- const layers = [];
67
- for (const slice of slices) (layers[depthOf(slice)] ??= []).push(slice);
68
-
69
- const groups = [];
70
- for (const layer of layers.filter(Boolean)) {
71
- const open = [];
72
- for (const slice of layer) {
73
- const files = new Set((slice.files ?? []).map(fileKey));
74
- const group = open.find((g) => g.ids.length < MAX_PARALLEL && ![...files].some((f) => g.files.has(f)));
75
- if (group) {
76
- group.ids.push(slice.id);
77
- files.forEach((f) => group.files.add(f));
78
- } else {
79
- open.push({ ids: [slice.id], files });
80
- }
81
- }
82
- groups.push(...open.map((g) => g.ids));
83
- }
84
- return groups;
85
- }
86
-
87
17
  // A frozen body already extracted under older rules cannot gain a `risco:` line, so it is not demanded.
88
18
  export function planRuleOptions(cwd, featureName) {
89
19
  return {
@@ -94,14 +24,53 @@ export function planRuleOptions(cwd, featureName) {
94
24
 
95
25
  const foldRisk = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').replace(/`/g, '').toLowerCase().trim();
96
26
  const RISK_BY_FOLD = new Map(RISK_KINDS.map((r) => [foldRisk(r), r]));
27
+ const RUNG_BY_FOLD = new Map(REWORK_RUNGS.map((r) => [foldRisk(r), r]));
28
+ const canonicalRung = (rung) => (rung === null ? null : RUNG_BY_FOLD.get(foldRisk(rung)) ?? false);
97
29
  const REGRESSION_WORK_TYPES = ['bug', 'hotfix'];
30
+ const POLICY_PLAN_WORK_TYPES = ['chore', 'hotfix'];
31
+ // Vetar por número só vale para o que custa pouco errar: o resto é pergunta. Aviso, não erro: o vocabulário
32
+ // é comum em plano por outros motivos, e um erro só ensinaria a reescrever a linha.
33
+ const NEVER_DEFAULT_RE = /\b(autentica\w*|senha|password|credencia\w*|segredo|secret|api key|chave de api|permiss\w*|pagamento|cobranc\w*|billing|drop (table|column)|(apaga\w*|delet\w*|exclu\w*) (o |os |a |as )?(dado|registro|lead|conta|cliente)s?)\b/;
34
+
35
+ function defaultsWarnings(plan, at) {
36
+ return plan.defaults.items.flatMap((d) => {
37
+ const hit = NEVER_DEFAULT_RE.exec(foldRisk(d.text));
38
+ return hit ? [`padrão ${d.n} toca "${hit[1]}"${at(d.line)} — credencial, permissão, pagamento e apagar dado viram pergunta, nunca padrão`] : [];
39
+ });
40
+ }
41
+
42
+ // Optional: absent is fine; present, every line is a transition the plan view can draw.
43
+ function statesErrors(plan, at) {
44
+ if (!plan.present.states) return [];
45
+ const { transitions, invalid } = plan.states;
46
+ const errors = invalid.map((l) => `## ${SECTIONS.states}: "${l.raw}" não é transição — use "- origem → destino: gatilho"${at(l.line)}`);
47
+ if (transitions.length === 0 && invalid.length === 0) errors.push(`## ${SECTIONS.states} vazia — liste as transições ou apague a seção`);
48
+ return errors;
49
+ }
98
50
 
99
- export function validatePlan(plan, { personas = null, surfaces = null, workType = null, riskRules = true } = {}) {
51
+ function defaultsErrors(plan, { workType, riskRules }, at) {
52
+ const { items, none } = plan.defaults;
53
+ if (!plan.present.defaults) {
54
+ return riskRules && !POLICY_PLAN_WORK_TYPES.includes(workType)
55
+ ? [`seção obrigatória ausente: ## ${SECTIONS.defaults} — numere o que você decidiu sem perguntar, ou escreva "nenhum"`]
56
+ : [];
57
+ }
58
+ if (items.length === 0) {
59
+ return none ? [] : [`## ${SECTIONS.defaults} sem item numerado ("1. …") nem "nenhum"`];
60
+ }
61
+ return items.some((d, i) => d.n !== i + 1)
62
+ ? [`## ${SECTIONS.defaults} numerada fora de 1..${items.length} — o usuário veta pelo número`]
63
+ : [];
64
+ }
65
+
66
+ export function validatePlan(plan, { workType = null, riskRules = true, text = null } = {}) {
100
67
  const errors = [];
101
68
  const warnings = [];
69
+ const media = riskRules ? mediaCitationError(text) : null;
70
+ if (media) errors.push(media);
102
71
  const at = (line) => (line ? ` (linha ${line})` : '');
103
72
 
104
- for (const key of ['problem', 'scenarios', 'decisions', 'slices']) {
73
+ for (const key of ['problem', 'scenarios', 'decisions']) {
105
74
  if (!plan.present[key]) errors.push(`seção obrigatória ausente: ## ${SECTIONS[key]}`);
106
75
  }
107
76
  if (plan.present.decisions && !plan.conflicts) {
@@ -114,7 +83,7 @@ export function validatePlan(plan, { personas = null, surfaces = null, workType
114
83
  }
115
84
 
116
85
  const duplicated = (items) => items.map((i) => i.id).filter((id, i, all) => all.indexOf(id) !== i);
117
- for (const [label, items] of [['cenário', plan.scenarios], ['fatia', plan.slices], ['ADR', plan.adrs]]) {
86
+ for (const [label, items] of [['cenário', plan.scenarios], ['ADR', plan.adrs]]) {
118
87
  for (const id of new Set(duplicated(items))) errors.push(`${label} duplicado: ${id}`);
119
88
  }
120
89
 
@@ -136,6 +105,9 @@ export function validatePlan(plan, { personas = null, surfaces = null, workType
136
105
  } else if (risk && proof === 'leitura') {
137
106
  warnings.push(`${c.id}: risco "${risk}" provado só por leitura${at(c.line)}`);
138
107
  }
108
+ if (canonicalRung(c.rung) === false) {
109
+ errors.push(`${c.id}: degrau "${c.rung}" fora de ${REWORK_RUNGS.join(' | ')}${at(c.line)}`);
110
+ }
139
111
  const terms = findArtifactTerms(c.text);
140
112
  if (terms.length > 0 && !c.artifactWhy) {
141
113
  errors.push(`${c.id} descreve artefato ("${terms.join(', ')}") sem "> Por que artefato:" — reescreva como efeito observável ou justifique${at(c.line)}`);
@@ -147,37 +119,13 @@ export function validatePlan(plan, { personas = null, surfaces = null, workType
147
119
  errors.push(`${workType} sem cenário de regressão — o sintoma do bug real vira um Cn com "prova: teste" e "risco: regressão"`);
148
120
  }
149
121
 
150
- const scenarioIds = new Set(plan.scenarios.map((c) => c.id));
151
- const sliceIds = new Set(plan.slices.map((s) => s.id));
152
- const covered = new Set();
153
- for (const s of plan.slices) {
154
- if (!s.persona) {
155
- errors.push(`${s.id} sem "- persona:"${at(s.line)}`);
156
- } else if (personas && !personas.includes(s.persona)) {
157
- errors.push(`${s.id}: persona "${s.persona}" não existe em agents.json (${personas.join(', ')})${at(s.line)}`);
158
- }
159
- if (s.files.length > MAX_SLICE_FILES) {
160
- errors.push(`${s.id} toca ${s.files.length} arquivos — o teto é ${MAX_SLICE_FILES}; divida a fatia${at(s.line)}`);
161
- }
162
- for (const c of s.covers) {
163
- if (scenarioIds.has(c)) covered.add(c);
164
- else errors.push(`${s.id} cobre ${c}, que não existe em ## ${SECTIONS.scenarios}${at(s.line)}`);
165
- }
166
- for (const d of s.depends) {
167
- if (!sliceIds.has(d)) errors.push(`${s.id} depende de ${d}, que não existe${at(s.line)}`);
168
- }
169
- if (s.covers.length === 0) warnings.push(`${s.id} não cobre nenhum cenário${at(s.line)}`);
170
- if (s.unknownKeys.length > 0) warnings.push(`${s.id}: chave desconhecida ${s.unknownKeys.join(', ')}${at(s.line)}`);
171
- if (s.surface && surfaces && !surfaces.includes(s.surface)) {
172
- warnings.push(`${s.id}: superfície "${s.surface}" não é uma de ${surfaces.join(', ')}${at(s.line)}`);
173
- }
174
- }
175
- for (const c of plan.scenarios) {
176
- if (!covered.has(c.id)) errors.push(`${c.id} não é coberto por nenhuma fatia ("- cobre: ${c.id}")`);
122
+ errors.push(...defaultsErrors(plan, { workType, riskRules }, at));
123
+ errors.push(...statesErrors(plan, at));
124
+ warnings.push(...defaultsWarnings(plan, at));
125
+
126
+ if (plan.sections.some((s) => !s.key && /^fatias$/i.test(s.name.trim()))) {
127
+ warnings.push('## Fatias não é lida desde a 10.0: o plano inteiro se implementa numa sessão, e os cenários definem o pronto');
177
128
  }
178
- const cycle = dependencyCycle(plan.slices);
179
- if (cycle) errors.push(`ciclo em "depende": ${cycle.join(' → ')}`);
180
- if (plan.slices.length > MAX_SLICES) warnings.push(`${plan.slices.length} fatias — acima de ${MAX_SLICES}, considere dividir a feature`);
181
129
 
182
130
  for (const r of plan.reuse) {
183
131
  if (!REUSE_VERDICTS.includes(r.verdict)) {
@@ -200,10 +148,7 @@ export function buildChecks(feature, text, plan) {
200
148
  planHash: planHash(text),
201
149
  checks: plan.scenarios.map((c) => ({
202
150
  id: c.id, text: c.text, proof: c.proof ? c.proof.toLowerCase() : null, artifactWhy: c.artifactWhy || null,
203
- })),
204
- slices: plan.slices.map((s) => ({
205
- id: s.id, title: s.title, persona: s.persona, surface: s.surface,
206
- standards: s.standards, files: s.files, depends: s.depends, covers: s.covers,
151
+ rung: canonicalRung(c.rung) || null,
207
152
  })),
208
153
  reuse: plan.reuse.map(({ concept, verdict, target, why }) => ({ concept, verdict, target, why })),
209
154
  adrs: plan.adrs.map((a) => ({ id: a.id, title: a.title, scope: ADR_SCOPES.includes(a.scope) ? a.scope : null })),
@@ -222,7 +167,7 @@ export function readPlanChecks(cwd, featureName) {
222
167
  } catch {
223
168
  return { status: 'unreadable', checks: null };
224
169
  }
225
- if (checks?.version !== CHECKS_VERSION || !Array.isArray(checks.slices) || !Array.isArray(checks.checks)) {
170
+ if (checks?.version !== CHECKS_VERSION || !Array.isArray(checks.checks)) {
226
171
  return { status: 'unreadable', checks: null };
227
172
  }
228
173
  let plan;
@@ -0,0 +1,28 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
3
+
4
+ export const CRITIQUE_EXEMPT_WORK_TYPES = ['chore', 'hotfix'];
5
+
6
+ const PLAN_HASH_RE = /^\*\*Plan-Hash:\*\*\s*`?([0-9a-f]{64})`?\s*$/m;
7
+ const APPLIED_RE = /^\*\*Aplicado-em:\*\*\s*`?([0-9a-f]{64})`?\s*$/gm;
8
+
9
+ // A critique counts for the plan it read or for the plan the session wrote after applying it:
10
+ // otherwise every fix it asks for would demand a new critique.
11
+ export function critiqueStatus(text, currentHash) {
12
+ if (typeof text !== 'string') return 'missing';
13
+ const read = PLAN_HASH_RE.exec(text)?.[1];
14
+ if (!read) return 'no-hash';
15
+ const applied = [...text.matchAll(APPLIED_RE)].map((m) => m[1]);
16
+ const valid = applied.length ? applied[applied.length - 1] : read;
17
+ return currentHash === read || currentHash === valid ? 'fresh' : 'stale';
18
+ }
19
+
20
+ export function readPlanCritique(cwd, featureName, { workType, planChecks }) {
21
+ if (CRITIQUE_EXEMPT_WORK_TYPES.includes(workType)) return 'exempt';
22
+ const currentHash = planChecks?.status === 'fresh' ? planChecks.checks?.planHash : null;
23
+ if (!currentHash) return null;
24
+ const path = getAbsoluteOutputPath(cwd, featureName, 'planCritique');
25
+ let text = null;
26
+ try { text = existsSync(path) ? readFileSync(path, 'utf8') : null; } catch { text = null; }
27
+ return critiqueStatus(text, currentHash);
28
+ }
@@ -0,0 +1,42 @@
1
+ export const MEDIA_STANDARD_IDS = [
2
+ 'ai-agents-modalities-image-gen',
3
+ 'ai-agents-media-video',
4
+ 'ai-agents-media-pipeline',
5
+ ];
6
+
7
+ // O plano escreve o standard pelo id ou pelo caminho (com ou sem `framework/`/`standards/`).
8
+ export function standardIdOf(value) {
9
+ return String(value ?? '').trim().replace(/\\/g, '/')
10
+ .replace(/^(\.morph\/)?(framework\/)?(standards\/)?/, '')
11
+ .replace(/\.md$/, '')
12
+ .replace(/\//g, '-');
13
+ }
14
+
15
+ export function featureGeneratesMedia(planText) {
16
+ if (typeof planText !== 'string') return false;
17
+ const tokens = new Set(planText.split(/[\s`'",;:()[\]|]+/).map((t) => standardIdOf(t.replace(/[.!?]+$/, ''))));
18
+ return MEDIA_STANDARD_IDS.some((id) => tokens.has(id));
19
+ }
20
+
21
+ const fold = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
22
+
23
+ // Vocabulário estreito de propósito: "imagem" sozinha (upload, avatar) não é mídia gerada.
24
+ const GENERATED_MEDIA_RE = new RegExp([
25
+ String.raw`\b(gera[rd]?[ao]?s?|geracao|generat\w*)\b[^.\n]{0,40}\b(imagem|imagens|images?|videos?|ilustrac\w*|thumbnails?)\b`,
26
+ String.raw`\b(imagem|imagens|videos?|ilustrac\w*|thumbnails?)\b[^.\n]{0,20}\bgerad[ao]s?\b`,
27
+ String.raw`\b(text|texto)[- ]?(to|para)[- ]?(image|imagem|video)\b`,
28
+ String.raw`\b(img2vid|image-gen|gpt-image|dall-?e|seedance|midjourney|stable diffusion|nano banana|sora|veo ?\d)\b`,
29
+ ].join('|'));
30
+
31
+ const NO_MEDIA_RE = /^\s*[-*]?\s*\**midia gerada:?\**:?\s*nao\b/m;
32
+
33
+ /** Plano que fala em gerar imagem ou vídeo e não cita o standard deixaria o guardrail desarmado em silêncio. */
34
+ export function mediaCitationError(planText) {
35
+ if (typeof planText !== 'string' || featureGeneratesMedia(planText)) return null;
36
+ const text = fold(planText);
37
+ if (NO_MEDIA_RE.test(text)) return null;
38
+ const hit = GENERATED_MEDIA_RE.exec(text);
39
+ if (!hit) return null;
40
+ return `o plano fala em mídia gerada ("${hit[0].trim()}") sem citar o standard: cite ${MEDIA_STANDARD_IDS.map((id) => `\`${id}\``).join(', ')} `
41
+ + 'conforme o caso (é o que arma as provas de mídia do Gate 3), ou escreva a linha "Mídia gerada: não" se nada é gerado por IA';
42
+ }
@@ -0,0 +1,125 @@
1
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
2
+
3
+ const NODE_W = 132;
4
+ const NODE_H = 40;
5
+ const GAP = 84;
6
+ const LANE = 38;
7
+
8
+ /** States in the order a reader meets them: breadth-first from the first transition's origin. */
9
+ export function stateOrder(transitions) {
10
+ const order = [];
11
+ const seen = new Set();
12
+ const queue = transitions.length ? [transitions[0].from] : [];
13
+ const visit = (s) => { if (!seen.has(s)) { seen.add(s); queue.push(s); } };
14
+ if (queue.length) seen.add(queue[0]);
15
+ while (queue.length) {
16
+ const s = queue.shift();
17
+ order.push(s);
18
+ for (const t of transitions) if (t.from === s) visit(t.to);
19
+ }
20
+ for (const t of transitions) for (const s of [t.from, t.to]) if (!seen.has(s)) { seen.add(s); order.push(s); }
21
+ return order;
22
+ }
23
+
24
+ // One row of states; a forward transition arcs above, a backward one below, each in its own lane so
25
+ // labels never stack; a self-transition loops over its node.
26
+ export function stateMachineSvg(transitions) {
27
+ if (!transitions.length) return '';
28
+ const order = stateOrder(transitions);
29
+ const x = (s) => 24 + order.indexOf(s) * (NODE_W + GAP);
30
+ const span = (t) => Math.abs(order.indexOf(t.to) - order.indexOf(t.from));
31
+ const forward = transitions.filter((t) => order.indexOf(t.to) > order.indexOf(t.from)).sort((a, b) => span(a) - span(b));
32
+ const backward = transitions.filter((t) => order.indexOf(t.to) < order.indexOf(t.from)).sort((a, b) => span(a) - span(b));
33
+ const loops = transitions.filter((t) => t.to === t.from);
34
+ const top = 24 + (forward.length + (loops.length ? 1 : 0)) * LANE;
35
+ const height = top + NODE_H + 24 + backward.length * LANE;
36
+ const width = 48 + order.length * NODE_W + (order.length - 1) * GAP;
37
+ const mid = (s) => x(s) + NODE_W / 2;
38
+
39
+ const edge = (t, i, above) => {
40
+ const x1 = mid(t.from) + (above ? 18 : -18);
41
+ const x2 = mid(t.to) + (above ? -18 : 18);
42
+ const y0 = above ? top : top + NODE_H;
43
+ const y = above ? top - (i + 1) * LANE : top + NODE_H + (i + 1) * LANE;
44
+ const lx = (x1 + x2) / 2;
45
+ return `<path class="edge" d="M${x1} ${y0} C${x1} ${y} ${x2} ${y} ${x2} ${y0}" marker-end="url(#arrow)"/>`
46
+ + `<text class="edge-label" x="${lx}" y="${y + (above ? 0 : 14)}" text-anchor="middle"><title>${esc(t.trigger)}</title>${esc(t.trigger)}</text>`;
47
+ };
48
+ const loop = (t) => {
49
+ const cx = mid(t.from);
50
+ return `<path class="edge" d="M${cx - 14} ${top} C${cx - 30} ${top - LANE} ${cx + 30} ${top - LANE} ${cx + 14} ${top}" marker-end="url(#arrow)"/>`
51
+ + `<text class="edge-label" x="${cx}" y="${top - LANE + 4}" text-anchor="middle">${esc(t.trigger)}</text>`;
52
+ };
53
+
54
+ return `<svg class="machine" viewBox="0 0 ${width} ${height}" style="width:100%;min-width:${Math.round(width * 0.6)}px" role="img" aria-label="Máquina de estados: ${esc(order.join(', '))}">
55
+ <defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" class="arrow-head"/></marker></defs>
56
+ ${forward.map((t, i) => edge(t, i, true)).join('\n')}
57
+ ${backward.map((t, i) => edge(t, i, false)).join('\n')}
58
+ ${loops.map(loop).join('\n')}
59
+ ${order.map((s) => `<g class="state"><rect x="${x(s)}" y="${top}" width="${NODE_W}" height="${NODE_H}" rx="10"/><text x="${mid(s)}" y="${top + NODE_H / 2 + 5}" text-anchor="middle">${esc(s)}</text></g>`).join('\n')}
60
+ </svg>`;
61
+ }
62
+
63
+ const RUNG_TONE = { 'migrar': 'warn', 'irreversível': 'bad', 'irreversivel': 'bad' };
64
+
65
+ function scenarioCard(c) {
66
+ const chips = [
67
+ c.proof && `<span class="chip">prova: ${esc(c.proof)}</span>`,
68
+ c.risk && `<span class="chip">risco: ${esc(c.risk)}</span>`,
69
+ c.rung && `<span class="chip ${RUNG_TONE[c.rung.toLowerCase()] ?? ''}">degrau: ${esc(c.rung)}</span>`,
70
+ ].filter(Boolean).join('');
71
+ return `<li class="card"><div class="cid">${esc(c.id)}</div><div><p>${esc(c.text)}</p>${chips ? `<div class="chips">${chips}</div>` : ''}</div></li>`;
72
+ }
73
+
74
+ export function renderPlanHtml(feature, plan, { planHash = null } = {}) {
75
+ const defaults = plan.defaults?.items ?? [];
76
+ const transitions = plan.states?.transitions ?? [];
77
+ const section = (title, body) => `<section><h2>${esc(title)}</h2>${body}</section>`;
78
+ return `<!doctype html>
79
+ <html lang="pt-BR">
80
+ <head>
81
+ <meta charset="utf-8">
82
+ <meta name="viewport" content="width=device-width, initial-scale=1">
83
+ <title>Plano — ${esc(feature)}</title>
84
+ <style>
85
+ :root { --bg:#f6f7f9; --card:#fff; --fg:#14171c; --dim:#4b5260; --faint:#7a8291; --hair:#dfe3ea; --ok:#2f7d4f; --warn:#a35f00; --bad:#b4232f; --accent:#2d5bd7; }
86
+ @media (prefers-color-scheme: dark) { :root { --bg:#0f1115; --card:#171a21; --fg:#e8ebf0; --dim:#b3bac6; --faint:#7f8796; --hair:#2a2f3a; --ok:#5cc28a; --warn:#e2a54b; --bad:#f07178; --accent:#8fb0ff; } }
87
+ * { box-sizing:border-box; }
88
+ body { margin:0; background:var(--bg); color:var(--fg); font:15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; }
89
+ main { max-width:920px; margin:0 auto; padding:40px 16px 64px; }
90
+ h1 { font-size:28px; margin:0 0 4px; letter-spacing:-.01em; }
91
+ .meta { color:var(--faint); font-size:13px; margin:0 0 32px; }
92
+ h2 { font-size:15px; margin:32px 0 12px; }
93
+ ul.cards { list-style:none; padding:0; margin:0; display:grid; gap:10px; }
94
+ .card { display:grid; grid-template-columns:3rem minmax(0,1fr); gap:12px; background:var(--card); border:1px solid var(--hair); border-radius:12px; padding:14px 16px; }
95
+ .card p { margin:0; }
96
+ .cid { font:600 13px/1.6 ui-monospace, monospace; color:var(--accent); }
97
+ .chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
98
+ .chip { font-size:12px; color:var(--dim); border:1px solid var(--hair); border-radius:999px; padding:1px 9px; }
99
+ .chip.warn { color:var(--warn); border-color:currentColor; }
100
+ .chip.bad { color:var(--bad); border-color:currentColor; }
101
+ ol.defaults { margin:0; padding-left:22px; color:var(--dim); }
102
+ ol.defaults li { margin:4px 0; }
103
+ .scroll { overflow-x:auto; background:var(--card); border:1px solid var(--hair); border-radius:12px; padding:12px; }
104
+ .machine .state rect { fill:var(--bg); stroke:var(--accent); stroke-width:1.5; }
105
+ .machine .state text { fill:var(--fg); font:600 13px system-ui, sans-serif; }
106
+ .machine .edge { fill:none; stroke:var(--faint); stroke-width:1.5; }
107
+ .machine .arrow-head { fill:var(--faint); }
108
+ .machine .edge-label { fill:var(--dim); font:12px system-ui, sans-serif; paint-order:stroke; stroke:var(--card); stroke-width:4px; }
109
+ .none { color:var(--faint); }
110
+ </style>
111
+ </head>
112
+ <body>
113
+ <main>
114
+ <h1>${esc(feature)}</h1>
115
+ <p class="meta">Visão do plano, só leitura — gerada pelo <code>morph-spec plan-check</code> a partir de 2-plan/plan.md${planHash ? ` (${esc(planHash.slice(0, 12))})` : ''}. Para mudar, edite o plan.md.</p>
116
+ ${section(`Cenários (${plan.scenarios.length})`, `<ul class="cards">${plan.scenarios.map(scenarioCard).join('')}</ul>`)}
117
+ ${transitions.length ? section('Estados', `<div class="scroll">${stateMachineSvg(transitions)}</div>`) : ''}
118
+ ${section('Padrões assumidos', defaults.length
119
+ ? `<ol class="defaults">${defaults.map((d) => `<li value="${d.n}">${esc(d.text)}</li>`).join('')}</ol><p class="meta">Para vetar um, responda "veta N" no gate.</p>`
120
+ : '<p class="none">Nenhum.</p>')}
121
+ </main>
122
+ </body>
123
+ </html>
124
+ `;
125
+ }