@polymorphism-tech/morph-spec 9.8.0 → 10.1.1

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 (202) hide show
  1. package/CLAUDE.md +12 -10
  2. package/README.md +4 -4
  3. package/bin/morph-spec.js +40 -43
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +28 -36
  6. package/docs/QUICKSTART.md +31 -191
  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-preflight.md +3 -3
  16. package/framework/commands/morph-review.md +15 -0
  17. package/framework/commands/morph-status.md +1 -1
  18. package/framework/docs/CLI.md +84 -95
  19. package/framework/evals/review-checklist.md +45 -53
  20. package/framework/hooks/README.md +4 -4
  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/next-step.js +63 -24
  38. package/framework/hooks/shared/phase-utils.js +6 -9
  39. package/framework/hooks/shared/report-progress.js +86 -0
  40. package/framework/hooks/shared/state-reader.js +3 -2
  41. package/framework/hooks/statusline.js +332 -0
  42. package/framework/mcp/registry.json +4 -4
  43. package/framework/phases.json +32 -10
  44. package/framework/rules/ai-agents-standards.md +10 -2
  45. package/framework/rules/csharp-standards.md +4 -0
  46. package/framework/rules/data-standards.md +1 -1
  47. package/framework/rules/ef-standards.md +20 -0
  48. package/framework/rules/frontend-standards.md +1 -1
  49. package/framework/rules/hangfire-standards.md +1 -1
  50. package/framework/rules/implement-report.md +25 -0
  51. package/framework/rules/infrastructure-standards.md +2 -0
  52. package/framework/rules/neon-auth-standards.md +1 -1
  53. package/framework/rules/neon-standards.md +3 -3
  54. package/framework/rules/nextjs-standards.md +7 -0
  55. package/framework/rules/plan-grammar.md +53 -0
  56. package/framework/rules/ui-design.md +27 -0
  57. package/framework/schemas/config.schema.json +11 -18
  58. package/framework/schemas/feature.schema.json +7 -1
  59. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  60. package/framework/skills/core/create-umbraco-lp/references/umbraco-lp-patterns.md +1 -1
  61. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +2 -2
  62. package/framework/skills/core/morph-apply/SKILL.md +39 -166
  63. package/framework/skills/core/morph-apply/references/debugging.md +8 -0
  64. package/framework/skills/core/morph-apply/references/hotfix.md +42 -0
  65. package/framework/skills/core/morph-apply/references/machine.md +29 -0
  66. package/framework/skills/core/{morph-media/SKILL.md → morph-apply/references/media.md} +11 -74
  67. package/framework/skills/core/morph-apply/references/review.md +65 -0
  68. package/framework/skills/core/morph-plan/SKILL.md +49 -109
  69. package/framework/skills/core/morph-plan/references/critique.md +43 -0
  70. package/framework/skills/core/morph-plan/references/interview.md +37 -0
  71. package/framework/skills/core/morph-uiux/SKILL.md +47 -64
  72. package/framework/skills/core/morph-uiux/surfaces.json +32 -0
  73. package/framework/standards/STANDARDS.json +51 -28
  74. package/framework/standards/STANDARDS.meta.json +16 -19
  75. package/framework/standards/ai-agents/agent-session.md +2 -3
  76. package/framework/standards/ai-agents/context-providers.md +2 -2
  77. package/framework/standards/ai-agents/durable-workflows-hitl.md +2 -2
  78. package/framework/standards/ai-agents/providers/model-registry.md +2 -2
  79. package/framework/standards/ai-agents/rag-custom-pgvector.md +7 -6
  80. package/framework/standards/ai-agents/setup.md +1 -1
  81. package/framework/standards/ai-agents/tool-adapters.md +2 -2
  82. package/framework/standards/architecture/stack-baseline.md +70 -0
  83. package/framework/standards/backend/database/postgresql/database.md +22 -1
  84. package/framework/standards/backend/dotnet/vsa-handler-patterns.md +33 -1
  85. package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +2 -1
  86. package/framework/standards/backend/integrations/neon-auth/neon-auth.md +12 -13
  87. package/framework/standards/frontend/design-system/ai-image-generation.md +51 -12
  88. package/framework/standards/frontend/design-system/ai-video-generation.md +50 -4
  89. package/framework/standards/frontend/design-system/art-direction.md +2 -2
  90. package/framework/standards/frontend/scroll-driven/frame-scrub.md +12 -2
  91. package/framework/standards/frontend/scroll-driven/scroll-components.md +76 -4
  92. package/framework/standards/infrastructure/docker/local-compose-isolation.md +2 -2
  93. package/framework/standards/infrastructure/neon/neon-pgvector.md +62 -20
  94. package/framework/standards/infrastructure/neon/neon-platform.md +61 -0
  95. package/framework/standards/infrastructure/neon/neon-setup.md +14 -13
  96. package/framework/standards/integration/mcp/mcp-tools.md +20 -20
  97. package/framework/templates/REGISTRY.json +1 -2
  98. package/framework/templates/context/ARCHITECTURE.md +1 -1
  99. package/framework/templates/docs/plan.md +15 -12
  100. package/framework/templates/docs/ui-mockups.md +17 -105
  101. package/framework/templates/infrastructure/docker/docker-compose.template.yml +1 -1
  102. package/package.json +1 -1
  103. package/src/commands/e2e/e2e.js +88 -23
  104. package/src/commands/graph/diff.js +74 -0
  105. package/src/commands/graph/status.js +1 -1
  106. package/src/commands/observability/cost.js +2 -4
  107. package/src/commands/observability/telemetry.js +1 -1
  108. package/src/commands/project/doctor.js +23 -39
  109. package/src/commands/project/fleet.js +5 -6
  110. package/src/commands/project/init.js +63 -36
  111. package/src/commands/project/stack-scan.js +3 -42
  112. package/src/commands/project/stack-set.js +26 -0
  113. package/src/commands/project/status.js +12 -13
  114. package/src/commands/project/update.js +39 -3
  115. package/src/commands/project/worktree.js +3 -14
  116. package/src/commands/state/advance.js +17 -17
  117. package/src/commands/state/approve.js +0 -6
  118. package/src/commands/state/archive.js +27 -1
  119. package/src/commands/state/auto-approval.js +2 -2
  120. package/src/commands/state/create.js +1 -6
  121. package/src/commands/state/gate-check.js +38 -31
  122. package/src/commands/state/gate-decision.js +3 -2
  123. package/src/commands/state/next.js +1 -0
  124. package/src/commands/state/recap.js +67 -0
  125. package/src/commands/state/report-check.js +36 -178
  126. package/src/commands/validation/plan-check.js +16 -11
  127. package/src/commands/validation/validate-feature.js +3 -3
  128. package/src/commands/validation/verify.js +3 -6
  129. package/src/core/paths/output-schema.js +3 -3
  130. package/src/core/state/state-manager.js +2 -4
  131. package/src/dashboard/app/components/CommandPalette.jsx +2 -5
  132. package/src/dashboard/app/components/GatePauses.jsx +116 -0
  133. package/src/dashboard/app/components/SessionMap.jsx +77 -0
  134. package/src/dashboard/app/pages/Activity.jsx +4 -0
  135. package/src/dashboard/app/pages/AgentMap.jsx +1 -2
  136. package/src/dashboard/app/pages/FeatureDetail.jsx +129 -186
  137. package/src/dashboard/app/pages/Features.jsx +7 -7
  138. package/src/dashboard/app/pages/PhasePipeline.jsx +1 -1
  139. package/src/dashboard/app/pages/Portfolio.jsx +40 -5
  140. package/src/dashboard/scanner/activity-scanner.js +29 -0
  141. package/src/dashboard/scanner/agents-scanner.js +11 -14
  142. package/src/dashboard/scanner/features-scanner.js +45 -24
  143. package/src/dashboard/scanner/project-api.js +11 -2
  144. package/src/dashboard/scanner/skills-scanner.js +11 -1
  145. package/src/lib/detectors/index.js +1 -1
  146. package/src/lib/e2e/applicability.js +3 -1
  147. package/src/lib/git/tree-state.js +1 -16
  148. package/src/lib/graph/diff-tree.js +127 -0
  149. package/src/lib/graph/graphify-runtime.js +12 -5
  150. package/src/lib/installers/memory-installer.js +3 -3
  151. package/src/lib/installers/rules-installer.js +57 -0
  152. package/src/lib/installers/stack-assets.js +17 -0
  153. package/src/lib/installers/stack-sync.js +219 -0
  154. package/src/lib/plan/checks.js +61 -109
  155. package/src/lib/plan/critique.js +28 -0
  156. package/src/lib/plan/media.js +42 -0
  157. package/src/lib/plan/plan-html.js +125 -0
  158. package/src/lib/plan/plan-parser.js +34 -45
  159. package/src/lib/recap/piper.js +36 -0
  160. package/src/lib/recap/recap.js +232 -0
  161. package/src/lib/review-reports.js +28 -4
  162. package/src/lib/risk-detector.js +90 -11
  163. package/src/lib/specs/screen-checklist.js +33 -48
  164. package/src/lib/stack/dotnet-build-ignores.js +8 -9
  165. package/src/lib/telemetry/metrics.js +27 -29
  166. package/src/lib/validators/reuse/reuse-validator.js +1 -9
  167. package/src/lib/validators/verify-runner.js +0 -7
  168. package/src/lib/worktree/fleet-scanner.js +7 -20
  169. package/src/lib/worktree/worktree-audit.js +0 -37
  170. package/src/scripts/setup-infra.js +15 -50
  171. package/src/utils/agents-installer.js +27 -201
  172. package/src/utils/hooks-installer.js +11 -95
  173. package/framework/hooks/claude-code/core/warn-bash-source-write.js +0 -23
  174. package/framework/hooks/claude-code/statusline.py +0 -699
  175. package/framework/hooks/claude-code/statusline.sh +0 -11
  176. package/framework/hooks/shared/agent-reap.js +0 -74
  177. package/framework/hooks/shared/slice-progress.js +0 -110
  178. package/framework/skills/core/morph-hotfix/SKILL.md +0 -133
  179. package/framework/skills/core/morph-machine/SKILL.md +0 -46
  180. package/framework/skills/core/morph-review/SKILL.md +0 -129
  181. package/framework/skills/core/morph-standards/SKILL.md +0 -52
  182. package/framework/standards/PROVENANCE.json +0 -119
  183. package/framework/standards/infrastructure/neon/neon-pgrag.md +0 -243
  184. package/src/commands/project/worktree-reap.js +0 -34
  185. package/src/commands/state/handoff.js +0 -65
  186. package/src/commands/validation/mutate.js +0 -263
  187. package/src/commands/validation/scout-check.js +0 -28
  188. package/src/lib/generators/handoff-generator.js +0 -172
  189. package/src/lib/mutation/adapters.js +0 -79
  190. package/src/lib/mutation/commands.js +0 -61
  191. package/src/lib/mutation/dotnet-introspection.js +0 -281
  192. package/src/lib/mutation/manifest.js +0 -144
  193. package/src/lib/mutation/plan.js +0 -44
  194. package/src/lib/mutation/report.js +0 -68
  195. package/src/lib/mutation/runner.js +0 -301
  196. package/src/lib/mutation/schemata.js +0 -60
  197. package/src/lib/mutation/snapshot.js +0 -65
  198. package/src/lib/mutation/subset.js +0 -109
  199. package/src/lib/mutation/trx.js +0 -50
  200. package/src/lib/scout/scout-check.js +0 -293
  201. package/src/lib/validators/traceability-check.js +0 -103
  202. 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,20 @@ 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
- }
122
+ errors.push(...defaultsErrors(plan, { workType, riskRules }, at));
123
+ errors.push(...statesErrors(plan, at));
124
+ warnings.push(...defaultsWarnings(plan, at));
125
+
126
+ const isSlices = (s) => /^fatias$/i.test(s.name.trim());
127
+ if (plan.sections.some((s) => !s.key && isSlices(s))) {
128
+ 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');
174
129
  }
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}")`);
130
+ const known = new Set(Object.values(SECTIONS).map(foldRisk));
131
+ for (const s of plan.sections.filter((section) => !section.key && !isSlices(section))) {
132
+ warnings.push(known.has(foldRisk(s.name))
133
+ ? `## ${s.name} repetida${at(s.line)} — só a primeira é lida, o conteúdo desta se perde`
134
+ : `## ${s.name} não é seção do plano${at(s.line)} — o plan-check não a lê`);
177
135
  }
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
136
 
182
137
  for (const r of plan.reuse) {
183
138
  if (!REUSE_VERDICTS.includes(r.verdict)) {
@@ -200,10 +155,7 @@ export function buildChecks(feature, text, plan) {
200
155
  planHash: planHash(text),
201
156
  checks: plan.scenarios.map((c) => ({
202
157
  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,
158
+ rung: canonicalRung(c.rung) || null,
207
159
  })),
208
160
  reuse: plan.reuse.map(({ concept, verdict, target, why }) => ({ concept, verdict, target, why })),
209
161
  adrs: plan.adrs.map((a) => ({ id: a.id, title: a.title, scope: ADR_SCOPES.includes(a.scope) ? a.scope : null })),
@@ -222,7 +174,7 @@ export function readPlanChecks(cwd, featureName) {
222
174
  } catch {
223
175
  return { status: 'unreadable', checks: null };
224
176
  }
225
- if (checks?.version !== CHECKS_VERSION || !Array.isArray(checks.slices) || !Array.isArray(checks.checks)) {
177
+ if (checks?.version !== CHECKS_VERSION || !Array.isArray(checks.checks)) {
226
178
  return { status: 'unreadable', checks: null };
227
179
  }
228
180
  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
+ }