@polymorphism-tech/morph-spec 10.5.4 → 10.7.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 (37) hide show
  1. package/CLAUDE.md +1 -0
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +9 -0
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/docs/CLI.md +7 -2
  8. package/framework/hooks/shared/phase-utils.js +0 -2
  9. package/framework/rules/plan-grammar.md +6 -2
  10. package/framework/schemas/config.schema.json +10 -0
  11. package/framework/schemas/feature.schema.json +12 -2
  12. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  13. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  14. package/framework/skills/core/morph-apply/SKILL.md +2 -2
  15. package/framework/skills/core/morph-apply/references/review.md +1 -1
  16. package/framework/skills/core/morph-plan/SKILL.md +5 -4
  17. package/framework/skills/core/morph-uiux/SKILL.md +2 -2
  18. package/framework/templates/docs/plan.md +3 -3
  19. package/package.json +1 -1
  20. package/src/commands/state/finish.js +99 -11
  21. package/src/commands/state/recap.js +6 -5
  22. package/src/commands/validation/plan-check.js +4 -7
  23. package/src/commands/validation/verify.js +13 -4
  24. package/src/core/paths/output-schema.js +3 -1
  25. package/src/lib/ci/ci-verify.js +16 -0
  26. package/src/lib/ci/pr-checks.js +76 -0
  27. package/src/lib/plan/checks.js +6 -6
  28. package/src/lib/plan/plan-parser.js +3 -0
  29. package/src/lib/recap/recap.js +6 -5
  30. package/src/lib/validators/verify-runner.js +6 -3
  31. package/src/lib/view/feature-html.js +307 -0
  32. package/src/lib/view/feature-page.client.js +116 -0
  33. package/src/lib/view/feature-page.css +169 -0
  34. package/src/lib/view/feature-view.js +104 -0
  35. package/src/lib/view/flow-svg.js +63 -0
  36. package/src/lib/view/tokens.css +18 -0
  37. package/src/lib/plan/plan-html.js +0 -125
@@ -13,7 +13,7 @@ import { HeavyBusyError } from '../../lib/heavy/gate.js';
13
13
  import { afterHeavyRun } from '../../lib/heavy/cleanup.js';
14
14
  import { memoryBusyHint } from '../../lib/heavy/live-stacks.js';
15
15
  import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
16
- import { runCiVerify, CiUsageError, reusableLocalStamp } from '../../lib/ci/ci-verify.js';
16
+ import { runCiVerify, CiUsageError, reusableLocalStamp, resolvePrProof } from '../../lib/ci/ci-verify.js';
17
17
  import { syncStackRules } from '../../lib/installers/stack-sync.js';
18
18
  import {
19
19
  DETACH_RUN_ENV, DETACH_STAGE_ENV, DETACH_WORKER_ENV, launchWorker, markWorkerExit, runFilesOf, superviseVerify,
@@ -137,6 +137,7 @@ async function runLocalVerify(featureName, options, fingerprint) {
137
137
  fingerprint,
138
138
  fresh: options.fresh,
139
139
  affected: options.affected,
140
+ excludeNodes: options.excludeNodes,
140
141
  timeout: options.timeout,
141
142
  skipTests: options.skipTests,
142
143
  skipBuild: options.skipBuild,
@@ -231,14 +232,19 @@ export async function verifyCommand(featureName, options = {}) {
231
232
  }
232
233
 
233
234
  let ci = null;
235
+ let prProof = null;
234
236
  if (options.ci) {
235
237
  const fresh = reusableLocalStamp(process.cwd(), feature.verification);
236
238
  if (fresh) exitWithReusedStamp(featureName, fresh, 'stamp verde e fresco — o CI não roda de novo', options);
239
+ prProof = resolvePrProof(process.cwd(), featureName, readMorphConfig(process.cwd()));
240
+ if (prProof?.local) console.error(chalk.yellow(`\n↪ completa local: ${prProof.local}\n`));
237
241
  try {
238
- const outcome = await runCiVerify(process.cwd(), featureName, readMorphConfig(process.cwd()), {
242
+ const outcome = prProof ? null : await runCiVerify(process.cwd(), featureName, readMorphConfig(process.cwd()), {
239
243
  log: (line) => console.error(chalk.gray(` ${line}`)),
240
244
  });
241
- if (outcome.mode === 'ci') {
245
+ if (!outcome) {
246
+ // The PR proof has no run to wait for here: `finish --pr` waits for it.
247
+ } else if (outcome.mode === 'ci') {
242
248
  ci = outcome;
243
249
  } else if (outcome.mode === 'pending') {
244
250
  if (options.json) console.log(JSON.stringify({ status: 'pending', persisted: false, reason: outcome.reason }, null, 2));
@@ -259,7 +265,8 @@ export async function verifyCommand(featureName, options = {}) {
259
265
  // Taken before the run: it is the tree the run is about to read.
260
266
  const fingerprint = treeFingerprint(process.cwd());
261
267
 
262
- const result = ci ? ci.result : await runLocalVerify(featureName, options, fingerprint);
268
+ const pendingPr = prProof?.covers ? prProof : null;
269
+ const result = ci ? ci.result : await runLocalVerify(featureName, { ...options, excludeNodes: pendingPr?.covers }, fingerprint);
263
270
 
264
271
  const cleanup = ci ? { status: 'skipped', reason: 'ci' } : afterHeavyRun({
265
272
  projectPath: process.cwd(),
@@ -305,6 +312,7 @@ export async function verifyCommand(featureName, options = {}) {
305
312
  ts: now,
306
313
  ...(result.flaky?.length ? { flaky: result.flaky } : {}),
307
314
  ...(ci ? { source: 'ci', ciRun: ci.run.url } : {}),
315
+ ...(pendingPr && result.status === 'pass' ? { pendingPr } : {}),
308
316
  };
309
317
  if (!scopedRun) await updateFeature(featureName, 'verification', stamp);
310
318
  }
@@ -334,6 +342,7 @@ export async function verifyCommand(featureName, options = {}) {
334
342
  Array.isArray(node.runs) ? { ...withoutOutput(node), runs: node.runs.map(withoutOutput) } : withoutOutput(node)]));
335
343
  const output = {
336
344
  ...result, nodes, persisted: persist, ...(persist ? {} : { reason }), repeatOf, cleanup,
345
+ ...(pendingPr ? { pendingPr } : {}),
337
346
  ...(ci ? { source: 'ci', ciRun: { id: ci.run.databaseId, url: ci.run.url, sha: ci.run.headSha }, workflowCreated: ci.created } : {}),
338
347
  };
339
348
  console.log(JSON.stringify(output, null, 2));
@@ -1,10 +1,12 @@
1
1
  import { join } from 'path';
2
2
 
3
+ /** The one page of a feature, at its root: it belongs to no phase, and only the CLI writes it. */
4
+ export const FEATURE_VIEW_FILE = 'feature.html';
5
+
3
6
  export const OUTPUT_SCHEMA = {
4
7
  plan: { filename: 'plan.md', phaseDir: '2-plan', phase: 'plan', protected: true, approvalGate: 'plan' },
5
8
  checks: { filename: 'checks.json', phaseDir: '2-plan', phase: 'plan', protected: true, approvalGate: 'plan' },
6
9
  planCritique: { filename: 'critica.md', phaseDir: '2-plan', phase: 'plan', protected: false },
7
- planView: { filename: 'plan.html', phaseDir: '2-plan', phase: 'plan', protected: false },
8
10
 
9
11
  uiMockups: { filename: 'mockups.md', phaseDir: '1-design', phase: 'uiux', protected: true, approvalGate: 'uiux' },
10
12
 
@@ -8,7 +8,10 @@ import {
8
8
  CI_WORKFLOW_DIR, CI_WORKFLOW_FILE, ciAvailability, downloadResult, findCiWorkflow, pushBranch,
9
9
  uncommittedCode, waitForRun, workflowPushProblem,
10
10
  } from './github-ci.js';
11
+ import { prProofConfig } from './pr-checks.js';
11
12
  import { renderCiWorkflow } from './workflow-scaffold.js';
13
+ import { resolveTestNodes } from '../tasks/test-nodes.js';
14
+ import { selectAffected } from '../verify/affected.js';
12
15
 
13
16
  export const CI_WAIT_MS = 50 * 60 * 1000;
14
17
  const APPEAR_MS = Number(process.env.MORPH_CI_APPEAR_MS ?? 90 * 1000);
@@ -58,6 +61,19 @@ export function reusableLocalStamp(root, stamp) {
58
61
  return treeMovedSince(root, stamp.sha, head) ? null : stamp;
59
62
  }
60
63
 
64
+ /**
65
+ * `covers` = suites the PR's own checks prove, left out of the local run. `local` = run everything here,
66
+ * with the reason. Null = the project declared no PR proof. What the affected run would escalate stays local.
67
+ */
68
+ export function resolvePrProof(root, featureName, config, { select = selectAffected } = {}) {
69
+ const proof = prProofConfig(config);
70
+ if (!proof) return null;
71
+ const avail = ciAvailability(root, featureName, config);
72
+ if (!avail.ok) return { local: avail.reason };
73
+ const selection = select({ projectPath: root, nodes: resolveTestNodes(root).nodes });
74
+ return selection.scope === 'full' ? { local: selection.reason } : proof;
75
+ }
76
+
61
77
  // Every way CI cannot answer is `mode: 'local'` with the reason: no CI is never a pause. Only the
62
78
  // uploaded `verify --json` becomes a result, judged by the same stamp and rules as a local run.
63
79
  export async function runCiVerify(root, featureName, config, { log = () => {}, waitMs = CI_WAIT_MS, wait = waitForRun } = {}) {
@@ -0,0 +1,76 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { resolveGh } from '../git/gh.js';
3
+
4
+ const FAILED = new Set(['FAILURE', 'TIMED_OUT']);
5
+ const PASSED = new Set(['SUCCESS', 'SKIPPED', 'NEUTRAL']);
6
+
7
+ // A job killed by exhausted Actions quota fails within seconds without running a step.
8
+ const MIN_JUDGED_MS = 30 * 1000;
9
+
10
+ export const PR_WAIT_MS = 50 * 60 * 1000;
11
+
12
+ /** The PR proof a project declared; null when it closes by merge, opted out of CI or declared nothing. */
13
+ export function prProofConfig(config) {
14
+ if (config?.project?.ci === false || config?.finish?.mode === 'merge') return null;
15
+ const { workflows, covers } = config?.project?.prChecks ?? {};
16
+ const filled = (list) => Array.isArray(list) && list.length > 0;
17
+ return filled(workflows) && filled(covers) ? { workflows, covers } : null;
18
+ }
19
+
20
+ function neverRan(check) {
21
+ const elapsed = Date.parse(check.completedAt) - Date.parse(check.startedAt);
22
+ return !Number.isFinite(elapsed) || elapsed < MIN_JUDGED_MS;
23
+ }
24
+
25
+ /** Only a declared workflow counts: a green lint or deploy run says nothing about the suite. */
26
+ export function judgeChecks(rollup, workflows) {
27
+ const checks = (rollup ?? []).filter((check) => workflows.includes(check.workflowName));
28
+ if (checks.length === 0) return { state: 'absent' };
29
+ if (checks.some((check) => check.status !== 'COMPLETED')) return { state: 'running' };
30
+
31
+ const failed = checks.filter((check) => FAILED.has(check.conclusion));
32
+ const real = failed.filter((check) => !neverRan(check));
33
+ if (real.length > 0) return { state: 'fail', failed: real };
34
+ if (failed.length > 0) return { state: 'no-verdict', reason: `${failed[0].workflowName} morreu sem rodar (cota do Actions?)` };
35
+
36
+ const undecided = checks.find((check) => !PASSED.has(check.conclusion));
37
+ if (undecided) return { state: 'no-verdict', reason: `${undecided.workflowName}: ${undecided.conclusion}` };
38
+ if (!checks.some((check) => check.conclusion === 'SUCCESS')) return { state: 'no-verdict', reason: 'nenhum check julgou o código' };
39
+ return { state: 'pass' };
40
+ }
41
+
42
+ function readPr(root, branch) {
43
+ try {
44
+ const { bin, prefix } = resolveGh();
45
+ return JSON.parse(execFileSync(bin, [...prefix, 'pr', 'view', branch, '--json', 'headRefOid,statusCheckRollup'], { cwd: root, stdio: 'pipe', timeout: 15000 }).toString());
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Waits `appearMs` even when the first workflow already settled: the others may not have been created yet.
53
+ * @returns {Promise<{ state: 'pass'|'fail'|'no-verdict'|'pending', failed?: object[], reason?: string }>}
54
+ */
55
+ export async function waitForPrChecks(root, { branch, sha, workflows, timeoutMs = PR_WAIT_MS,
56
+ appearMs = Number(process.env.MORPH_CI_APPEAR_MS ?? 90 * 1000), pollMs = 15000,
57
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now, read = readPr }) {
58
+ const start = now();
59
+ let misses = 0;
60
+ for (;;) {
61
+ const elapsed = now() - start;
62
+ const pr = read(root, branch);
63
+ if (pr === null) {
64
+ if (++misses >= 3) return { state: 'no-verdict', reason: 'gh pr view não respondeu' };
65
+ } else {
66
+ misses = 0;
67
+ const verdict = pr.headRefOid === sha ? judgeChecks(pr.statusCheckRollup, workflows) : { state: 'absent' };
68
+ if (elapsed >= appearMs) {
69
+ if (verdict.state === 'absent') return { state: 'no-verdict', reason: `nenhum check de ${workflows.join(', ')} neste commit` };
70
+ if (verdict.state !== 'running') return verdict;
71
+ }
72
+ }
73
+ if (elapsed >= timeoutMs) return { state: 'pending', reason: `checks não terminaram em ${Math.round(timeoutMs / 60000)} min` };
74
+ await sleep(pollMs);
75
+ }
76
+ }
@@ -40,11 +40,11 @@ function defaultsWarnings(plan, at) {
40
40
  }
41
41
 
42
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`);
43
+ function transitionErrors(plan, key, at) {
44
+ if (!plan.present[key]) return [];
45
+ const { transitions, invalid } = plan[key === 'flow' ? 'flow' : 'states'];
46
+ const errors = invalid.map((l) => `## ${SECTIONS[key]}: "${l.raw}" não é transição — use "- origem → destino: gatilho"${at(l.line)}`);
47
+ if (transitions.length === 0 && invalid.length === 0) errors.push(`## ${SECTIONS[key]} vazia — liste as transições ou apague a seção`);
48
48
  return errors;
49
49
  }
50
50
 
@@ -120,7 +120,7 @@ export function validatePlan(plan, { workType = null, riskRules = true, text = n
120
120
  }
121
121
 
122
122
  errors.push(...defaultsErrors(plan, { workType, riskRules }, at));
123
- errors.push(...statesErrors(plan, at));
123
+ errors.push(...transitionErrors(plan, 'states', at), ...transitionErrors(plan, 'flow', at));
124
124
  warnings.push(...defaultsWarnings(plan, at));
125
125
 
126
126
  const isSlices = (s) => /^fatias$/i.test(s.name.trim());
@@ -14,6 +14,7 @@ export const SECTIONS = {
14
14
  assumptions: 'Suposições não confirmadas',
15
15
  defaults: 'Padrões assumidos',
16
16
  states: 'Estados',
17
+ flow: 'Fluxo',
17
18
  scenarios: 'Cenários',
18
19
  decisions: 'Decisões',
19
20
  architecture: 'Arquitetura',
@@ -191,6 +192,8 @@ export function parsePlan(text) {
191
192
  reuseHeader: reuse.header,
192
193
  defaults: parseDefaults(body('defaults')),
193
194
  states: parseStates(body('states')),
195
+ flow: parseStates(body('flow')),
196
+ text: Object.fromEntries(Object.keys(SECTIONS).map((k) => [k, body(k).map((l) => l.raw).join('\n').trim()])),
194
197
  errata: errata ? errata.lines.map((l) => l.raw).join('\n').trim() : null,
195
198
  sectionsAfterErrata: errata ? sections.filter((s) => s.line > errata.line).map((s) => s.name) : [],
196
199
  };
@@ -2,7 +2,7 @@ import { execFileSync } from 'child_process';
2
2
  import { existsSync, readFileSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { parsePlan } from '../plan/plan-parser.js';
5
- import { stateMachineSvg } from '../plan/plan-html.js';
5
+ import { stateMachineSvg } from '../view/flow-svg.js';
6
6
  import { reviewReportsFromDir } from '../review-reports.js';
7
7
  import { loadGraph, getFreshness } from '../graph/graph-loader.js';
8
8
  import { parseDiff, buildDiffTree, declarationDrift } from '../graph/diff-tree.js';
@@ -19,7 +19,7 @@ const git = (cwd, ...args) => execFileSync('git', args, { cwd, stdio: 'pipe', ma
19
19
  * What changed on the branch: the call tree when the graph is of HEAD and its lines hold, else the
20
20
  * plain file and line counts. Never throws; outside git it is null.
21
21
  */
22
- export function changeSummary(cwd, { base = null } = {}) {
22
+ export function changeSummary(cwd, { base = null, statOnly = false } = {}) {
23
23
  let from = base;
24
24
  try {
25
25
  from ??= git(cwd, 'merge-base', detectDefaultBranch(cwd) || 'main', 'HEAD').trim();
@@ -33,6 +33,7 @@ export function changeSummary(cwd, { base = null } = {}) {
33
33
  } catch {
34
34
  return null;
35
35
  }
36
+ if (statOnly) return { stat, tree: null };
36
37
  const loaded = loadGraph(cwd);
37
38
  if (!loaded.ok || getFreshness(cwd, loaded.graph).state !== 'fresh') return { stat, tree: null };
38
39
  try {
@@ -88,7 +89,7 @@ export function recapScenes(r) {
88
89
  id: 'abertura',
89
90
  title: r.feature,
90
91
  narration: `${r.feature}. ${r.description ?? ''} ${r.days !== null ? `Do início ao fim, ${r.days === 0 ? 'menos de um dia' : `${r.days} dia${r.days > 1 ? 's' : ''}`}.` : ''}`.replace(/\s+/g, ' ').trim(),
91
- body: `<p class="lead">${esc(r.description ?? '')}</p>${r.days !== null ? `<p class="big">${r.days === 0 ? '< 1' : r.days} <span>dia${r.days === 1 ? '' : 's'}</span></p>` : ''}`,
92
+ body: `<p class="lead">${esc(r.description ?? '')}</p>${r.days !== null ? `<p class="big">${r.days === 0 ? '< 1' : r.days} <span>dia${r.days <= 1 ? '' : 's'}</span></p>` : ''}`,
92
93
  });
93
94
  if (r.scenarios.length) {
94
95
  const ok = r.scenarios.filter((c) => c.status === 'PROVADO').length;
@@ -135,6 +136,7 @@ export function recapScenes(r) {
135
136
  }
136
137
 
137
138
  const SCENE_MS = 9000;
139
+ const TOKENS = readFileSync(new URL('../view/tokens.css', import.meta.url), 'utf8');
138
140
 
139
141
  /** `audio`: optional `{ [sceneId]: dataUri }`; without it the page narrates with the system voice. */
140
142
  export function renderRecapHtml(r, { audio = null } = {}) {
@@ -147,8 +149,7 @@ export function renderRecapHtml(r, { audio = null } = {}) {
147
149
  <meta name="viewport" content="width=device-width, initial-scale=1">
148
150
  <title>Recap — ${esc(r.feature)}</title>
149
151
  <style>
150
- :root { --bg:#0f1115; --card:#171a21; --fg:#e8ebf0; --dim:#b3bac6; --faint:#7f8796; --hair:#2a2f3a; --ok:#5cc28a; --warn:#e2a54b; --bad:#f07178; --accent:#8fb0ff; }
151
- @media (prefers-color-scheme: light) { :root { --bg:#f6f7f9; --card:#fff; --fg:#14171c; --dim:#4b5260; --faint:#7a8291; --hair:#dfe3ea; --ok:#2f7d4f; --warn:#a35f00; --bad:#b4232f; --accent:#2d5bd7; } }
152
+ ${TOKENS}:root { --bg:var(--paper); --card:var(--panel); --fg:var(--ink); --hair:var(--rule); --accent:var(--route); }
152
153
  * { box-sizing:border-box; }
153
154
  body { margin:0; min-height:100vh; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; display:flex; flex-direction:column; }
154
155
  .stage { flex:1; position:relative; max-width:960px; width:100%; margin:0 auto; padding:40px 16px 16px; }
@@ -105,7 +105,9 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
105
105
  // Usage errors are judged before the build: paying for a build to discover a typo
106
106
  // is the worst ordering, and a refusal after the cache was touched leaves a half-rewritten tree.
107
107
  const resolvedPlan = resolveTestNodes(projectPath);
108
- const testPlan = options.selection ? { ...resolvedPlan, nodes: options.selection.nodes } : resolvedPlan;
108
+ const excluded = new Set(options.excludeNodes ?? []);
109
+ const scopedNodes = options.selection ? options.selection.nodes : resolvedPlan.nodes;
110
+ const testPlan = { ...resolvedPlan, nodes: scopedNodes.filter((node) => !excluded.has(node.id)) };
109
111
  const filter = parseTestFilter(options.filter);
110
112
  if (filter && !testPlan.nodes.some((node) => nodeMatchesFilter(node, filter))) {
111
113
  const declared = testPlan.nodes.map((node) => node.id).join(', ') || '(none)';
@@ -150,10 +152,11 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
150
152
  const kept = options.selection.nodes.map((n) => n.id).join(', ') || 'nenhuma';
151
153
  feedback.push(`[verify] rodada afetada, suítes: ${kept}; a completa roda uma vez antes da aprovação`);
152
154
  }
155
+ if (excluded.size > 0) feedback.push(`[verify] provadas pelo CI da PR no finish: ${[...excluded].join(', ')}`);
153
156
 
154
157
  // Only a complete run feeds or reads the cache: a partial one proves nothing about the nodes it left out.
155
158
  const cacheDir = fingerprint && !filter && !skipTests && !skipBuild && !skipE2e && !recordBaseline
156
- && !refreshCache && !linux && !freshE2e
159
+ && !refreshCache && !linux && !freshE2e && excluded.size === 0
157
160
  ? resolveGitCommonDir(projectPath)
158
161
  : null;
159
162
  const reuse = planReuse(cacheDir && !fresh ? readVerifyCache(cacheDir, featureName) : null, fingerprint);
@@ -172,7 +175,7 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
172
175
  projectPath,
173
176
  testPlan,
174
177
  filter,
175
- skipTests,
178
+ skipTests: skipTests || (excluded.size > 0 && testPlan.nodes.length === 0),
176
179
  buildNode: nodes.build,
177
180
  timeout: explicitTimeout,
178
181
  refreshCache,
@@ -0,0 +1,307 @@
1
+ import { readFileSync } from 'fs';
2
+ import { PROOF_KINDS, RISK_KINDS, REUSE_VERDICTS } from '../plan/plan-parser.js';
3
+ import { renderRecapHtml } from '../recap/recap.js';
4
+ import { stateMachineSvg } from './flow-svg.js';
5
+
6
+ export const TOKENS_CSS = readFileSync(new URL('./tokens.css', import.meta.url), 'utf8');
7
+ const PAGE_CSS = readFileSync(new URL('./feature-page.css', import.meta.url), 'utf8');
8
+ const CLIENT_JS = readFileSync(new URL('./feature-page.client.js', import.meta.url), 'utf8');
9
+
10
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
11
+ const WORK_LABEL = { feature: 'Feature', bug: 'Correção de bug', chore: 'Manutenção', hotfix: 'Hotfix' };
12
+ const STATUS_LABEL = { pendente: 'Pendente', feito: 'Feito', bloqueado: 'Bloqueado' };
13
+ const RUNG_TONE = { migrar: 'warn', 'irreversível': 'bad', irreversivel: 'bad' };
14
+ const NONE_RE = /^(nenhum|nenhuma)\.?$/i;
15
+
16
+ function linkifier(plan) {
17
+ const known = new Set([...plan.scenarios.map((c) => c.id), ...plan.adrs.map((a) => a.id)]);
18
+ const defaults = new Set(plan.defaults.items.map((d) => d.n));
19
+ const ref = (id, label) => `<a href="#${id}" data-ref="${id}">${label}</a>`;
20
+ return (escaped) => escaped
21
+ .replace(/\b(C\d+|ADR-\d+)\b/g, (m, id) => (known.has(id) ? ref(id, m) : m))
22
+ .replace(/\b([Pp]adrão) (\d+)\b/g, (m, word, n) => (defaults.has(Number(n)) ? ref(`P${n}`, `${word} ${n}`) : m));
23
+ }
24
+
25
+ function inline(text, link) {
26
+ return String(text).split(/(`[^`]+`)/).map((part) => {
27
+ if (/^`[^`]+`$/.test(part)) return `<code>${esc(part.slice(1, -1))}</code>`;
28
+ return link(esc(part)).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
29
+ }).join('');
30
+ }
31
+
32
+ function blocks(markdown, link) {
33
+ const text = String(markdown ?? '').replace(/<!--[\s\S]*?-->/g, '');
34
+ const out = [];
35
+ let para = [];
36
+ let list = null;
37
+ let table = null;
38
+ let fence = null;
39
+ const closePara = () => { if (para.length) out.push(`<p>${inline(para.join(' '), link)}</p>`); para = []; };
40
+ const closeList = () => { if (list) out.push(`<ul>${list.map((i) => `<li>${inline(i, link)}</li>`).join('')}</ul>`); list = null; };
41
+ const closeTable = () => {
42
+ if (!table) return;
43
+ const [head, ...rows] = table;
44
+ const cells = (row, tag) => row.map((c) => `<${tag}>${inline(c, link)}</${tag}>`).join('');
45
+ out.push(`<div class="scroll-x"><table><thead><tr>${cells(head, 'th')}</tr></thead><tbody>${rows.map((r) => `<tr>${cells(r, 'td')}</tr>`).join('')}</tbody></table></div>`);
46
+ table = null;
47
+ };
48
+ const closeAll = () => { closePara(); closeList(); closeTable(); };
49
+ for (const line of text.split(/\r?\n/)) {
50
+ if (fence) {
51
+ if (/^\s*```/.test(line)) { out.push(`<pre><code>${esc(fence.join('\n'))}</code></pre>`); fence = null; } else fence.push(line);
52
+ continue;
53
+ }
54
+ if (/^\s*```/.test(line)) { closeAll(); fence = []; continue; }
55
+ if (!line.trim()) { closeAll(); continue; }
56
+ if (/^\s*\|/.test(line)) {
57
+ closePara(); closeList();
58
+ if (/^\s*\|[\s:|-]+\|?\s*$/.test(line)) continue;
59
+ (table ??= []).push(line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim()));
60
+ continue;
61
+ }
62
+ closeTable();
63
+ const bullet = /^\s*[-*]\s+(.*)$/.exec(line);
64
+ if (bullet) { closePara(); (list ??= []).push(bullet[1]); continue; }
65
+ if (list && /^\s+\S/.test(line)) { list[list.length - 1] += ` ${line.trim()}`; continue; }
66
+ closeList();
67
+ const heading = /^#{1,6}\s+(.*)$/.exec(line);
68
+ if (heading) { closePara(); out.push(`<h3>${inline(heading[1], link)}</h3>`); continue; }
69
+ para.push(line.trim());
70
+ }
71
+ if (fence) out.push(`<pre><code>${esc(fence.join('\n'))}</code></pre>`);
72
+ closeAll();
73
+ return out.join('');
74
+ }
75
+
76
+ export function splitProblem(text) {
77
+ const m = /^\s*\**\s*Resultado buscado\s*:\s*\**\s*(.+)$/im.exec(text ?? '');
78
+ const outcome = m ? m[1].replace(/\*\*/g, '').trim() : null;
79
+ return { outcome, problem: m ? text.replace(m[0], '').trim() : text ?? '' };
80
+ }
81
+
82
+ const bulletsOf = (text) => String(text ?? '').replace(/<!--[\s\S]*?-->/g, '').split(/\r?\n/)
83
+ .map((l) => /^\s*[-*]\s+(.+)$/.exec(l)?.[1]).filter((l) => l && !NONE_RE.test(l.trim()));
84
+
85
+ const conflictsOf = (plan) => {
86
+ const text = plan.conflicts?.text?.replace(/<!--[\s\S]*?-->/g, '').trim();
87
+ return text && !NONE_RE.test(text) ? text : null;
88
+ };
89
+
90
+ export function pendingAsks(view) {
91
+ const { plan } = view;
92
+ const asks = [];
93
+ for (const c of plan.scenarios) {
94
+ if (c.rung && c.rung.toLowerCase() !== 'refazer') asks.push({ id: c.id, verb: 'Revisar', action: 'Pedir ajuste', anchor: `ask-${c.id}`, text: `Não se desfaz com revert (degrau: ${c.rung}). ${c.text}`, tag: 'Degrau' });
95
+ if (c.status === 'bloqueado') asks.push({ id: c.id, verb: 'Destravar', action: 'Decidir', anchor: `ask-${c.id}-b`, text: `Bloqueado na implementação. ${c.evidence || c.text}`, tag: 'Bloqueio' });
96
+ }
97
+ bulletsOf(plan.text.assumptions).forEach((text, i) => asks.push({ id: `suposição ${i + 1}`, verb: 'Corrigir', action: 'Corrigir', anchor: `S${i + 1}`, text, tag: 'Suposição sem prova' }));
98
+ for (const d of plan.defaults.items) asks.push({ id: `padrão ${d.n}`, verb: 'Vetar', action: 'Vetar', anchor: `P${d.n}`, text: d.text, tag: `Padrão ${d.n}` });
99
+ return asks;
100
+ }
101
+
102
+ function askRow(a, link) {
103
+ return `<li id="${esc(a.anchor)}" data-ask="${esc(a.id)}" data-verb="${esc(a.verb)}"><div><p>${inline(a.text, link)}</p><span class="tag">${esc(a.tag)}</span></div><button type="button" class="toggle" aria-pressed="false">${esc(a.action)}</button></li>`;
104
+ }
105
+
106
+ function segmented(title, entries, filterable = true) {
107
+ const used = entries.filter((e) => e.n > 0);
108
+ if (!used.length) return '';
109
+ const total = used.reduce((sum, e) => sum + e.n, 0);
110
+ const attrs = (e) => (filterable ? `data-filter="${esc(e.key)}" data-jump` : 'disabled');
111
+ return `<div class="chart"><h3>${esc(title)}</h3><div class="seg" role="img" aria-label="${esc(used.map((e) => `${e.label}: ${e.n}`).join(', '))}">${used.map((e) => `<button type="button" ${attrs(e)} style="--n:${e.n};--c:var(--s${e.slot})" title="${esc(e.label)}: ${e.n} de ${total}" aria-label="${esc(e.label)}: ${e.n}"></button>`).join('')}</div><ul class="legend">${used.map((e) => `<li><i style="--c:var(--s${e.slot})"></i>${esc(e.label)} ${e.n}</li>`).join('')}</ul></div>`;
112
+ }
113
+
114
+ function riskBars(scenarios) {
115
+ const rows = RISK_KINDS.map((r) => ({ r, n: scenarios.filter((c) => c.risk?.toLowerCase() === r).length })).filter((x) => x.n);
116
+ if (!rows.length) return '';
117
+ const max = Math.max(...rows.map((x) => x.n));
118
+ return `<div class="chart"><h3>Risco que os cenários protegem</h3><ul class="bars">${rows.map((x) => `<li><button type="button" data-filter="${esc(x.r)}" data-jump title="Ver cenários de ${esc(x.r)}"><span>${esc(x.r)}</span><span class="track"><span class="fill" style="--share:${x.n / max}"></span></span><span>${x.n}</span></button></li>`).join('')}</ul></div>`;
119
+ }
120
+
121
+ function panorama(view, asks, link) {
122
+ const { plan } = view;
123
+ const { outcome, problem } = splitProblem(plan.text.problem);
124
+ const rungs = plan.scenarios.filter((c) => c.rung && c.rung.toLowerCase() !== 'refazer').length;
125
+ const proofs = PROOF_KINDS.map((k, i) => ({ key: k, label: k, slot: i + 1, n: plan.scenarios.filter((c) => c.proof?.toLowerCase() === k).length }));
126
+ const reuse = REUSE_VERDICTS.map((v, i) => ({ key: v, label: v.toLowerCase(), slot: i + 1, n: plan.reuse.filter((r) => r.verdict === v).length }));
127
+ const num = (href, n, label, alert = false) => `<li${alert ? ' class="alert"' : ''}><a href="${href}"><b>${n}</b>${esc(label)}</a></li>`;
128
+ return `<section id="panorama">
129
+ ${outcome ? `<p class="outcome">${inline(outcome, link)}</p>` : ''}
130
+ <ul class="numbers">
131
+ ${num('#cenarios', plan.scenarios.length, plan.scenarios.length === 1 ? 'cenário define o pronto' : 'cenários definem o pronto')}
132
+ ${num('#decisoes', plan.adrs.length, plan.adrs.length === 1 ? 'decisão registrada' : 'decisões registradas')}
133
+ ${num('#decidir', asks.length, asks.length === 1 ? 'ponto para você olhar' : 'pontos para você olhar')}
134
+ ${rungs ? num('#decidir', rungs, 'sem volta por revert', true) : ''}
135
+ </ul>
136
+ ${problem ? `<details class="fold" open><summary>O problema hoje</summary><div class="prose">${blocks(problem, link)}</div></details>` : ''}
137
+ ${segmented('Como cada cenário será provado', proofs)}
138
+ ${riskBars(plan.scenarios)}
139
+ ${segmented('Reaproveitar ou criar', reuse, false)}
140
+ </section>`;
141
+ }
142
+
143
+ function gateSign(view) {
144
+ const g = view.gate;
145
+ if (g?.pause && g.resolver !== 'session') {
146
+ return `<div class="sign"><h3>O ${esc(g.label.toLowerCase())} espera sua decisão</h3><p>${inline(g.reason ?? 'Leia os pontos abaixo e responda no terminal.', (s) => s)}</p></div>`;
147
+ }
148
+ const approved = Object.values(view.gates).filter((x) => x === true || x?.approved === true).length;
149
+ return `<div class="sign clear"><h3>${approved ? 'Nenhuma decisão parada' : 'Gate ainda não chamado'}</h3><p>Você pode vetar qualquer padrão abaixo a qualquer momento.</p></div>`;
150
+ }
151
+
152
+ function decide(view, asks, link) {
153
+ const conflicts = conflictsOf(view.plan);
154
+ return `<section id="decidir"><h2>O que precisa de você</h2><p class="sub">Marque o que quer mudar. O que ficar sem marca está aceito. A resposta pronta aparece no rodapé para colar no terminal.</p>
155
+ ${gateSign(view)}
156
+ ${asks.length ? `<ul class="asks">${asks.map((a) => askRow(a, link)).join('')}</ul>` : '<p class="empty">Nenhum ponto em aberto neste plano.</p>'}
157
+ ${conflicts ? `<h3>Conflitos com decisões anteriores</h3><div class="prose">${blocks(conflicts, link)}</div>` : ''}
158
+ ${view.critique ? `<details class="fold"><summary>Ler a crítica independente do plano</summary><div class="prose">${blocks(view.critique, link)}</div></details>` : ''}
159
+ </section>`;
160
+ }
161
+
162
+ function scenarioItem(c, cites, link) {
163
+ const state = c.proved === 'PROVADO' ? 'provado' : c.proved === 'FALHA' ? 'falha' : c.status;
164
+ const label = c.proved ?? STATUS_LABEL[c.status];
165
+ const tone = c.proved === 'PROVADO' || c.status === 'feito' ? 'ok' : c.proved === 'FALHA' || c.status === 'bloqueado' ? 'bad' : '';
166
+ const tags = [c.proof, c.risk, c.status, c.rung].filter(Boolean).map((t) => String(t).toLowerCase());
167
+ const rung = c.rung && c.rung.toLowerCase() !== 'refazer' ? `<span class="${RUNG_TONE[c.rung.toLowerCase()] ?? ''}">Degrau: ${esc(c.rung)}</span>` : '';
168
+ const adrs = cites.length ? `<span>${cites.map((id) => `<a href="#${id}" data-ref="${id}">${id}</a>`).join(', ')}</span>` : '';
169
+ const evidence = c.proof_note || c.evidence;
170
+ return `<li id="${esc(c.id)}" data-state="${state}" data-tags="${esc(tags.join(' '))}"><p><span class="id">${esc(c.id)}</span>${inline(c.text, link)}</p><div class="tags"><span class="${tone}">${esc(label)}</span>${c.proof ? `<span>Prova: ${esc(c.proof)}</span>` : ''}${c.risk ? `<span>Risco: ${esc(c.risk)}</span>` : ''}${rung}${adrs}</div>${evidence ? `<p class="evidence">${inline(evidence, link)}</p>` : ''}</li>`;
171
+ }
172
+
173
+ function scenariosSection(view, citesOf, link) {
174
+ const { scenarios } = view.plan;
175
+ const keys = (list) => [...new Set(list.filter(Boolean).map((k) => k.toLowerCase()))];
176
+ const filters = ['todos', ...keys(scenarios.map((c) => c.proof)), ...keys(scenarios.map((c) => c.risk)), ...keys(scenarios.map((c) => c.status))];
177
+ return `<section id="cenarios"><h2>O que vai estar pronto</h2><p class="sub">Cada ponto da linha é um comportamento que o verificador executa antes da entrega.</p>
178
+ <div class="filters" role="group" aria-label="Filtrar cenários">${filters.map((f, i) => `<button type="button" data-filter="${esc(f)}" aria-pressed="${i === 0}">${esc(f)}</button>`).join('')}</div>
179
+ <ol class="line">${scenarios.map((c) => scenarioItem(c, citesOf(c.id), link)).join('')}</ol></section>`;
180
+ }
181
+
182
+ const adrBody = (a) => a.body.replace(/^\s*\*\*Alcance:\*\*.*$/im, '').trim();
183
+
184
+ function matrix(view, cited) {
185
+ const adrs = view.plan.adrs.filter((a) => cited.get(a.id).length);
186
+ if (!adrs.length) return '';
187
+ const ids = view.plan.scenarios.map((c) => c.id);
188
+ return `<h3>Qual decisão sustenta qual cenário</h3><div class="scroll-x"><table class="matrix"><thead><tr><th></th>${ids.map((id) => `<th><a href="#${id}" data-ref="${id}">${id}</a></th>`).join('')}</tr></thead><tbody>${adrs.map((a) => `<tr><th><a href="#${a.id}" data-ref="${a.id}">${a.id}</a></th>${ids.map((id) => `<td>${cited.get(a.id).includes(id) ? `<a href="#${id}" aria-label="${a.id} sustenta ${id}"></a>` : ''}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
189
+ }
190
+
191
+ function decisions(view, cited, link) {
192
+ const { adrs } = view.plan;
193
+ return `<section id="decisoes"><h2>Decisões</h2><p class="sub">Abra uma decisão para ver o motivo. Os links levam aos cenários que dependem dela.</p>
194
+ ${adrs.length ? adrs.map((a) => `<details class="adr" id="${esc(a.id)}"><summary>${esc(a.id)}: ${esc(a.title)}${a.scope ? `<small>vale para ${a.scope === 'projeto' ? 'o projeto' : 'esta feature'}</small>` : ''}</summary><div class="prose">${blocks(adrBody(a), link)}</div></details>`).join('') : '<p class="empty">Este plano não registrou decisões de arquitetura.</p>'}
195
+ ${matrix(view, cited)}</section>`;
196
+ }
197
+
198
+ function howItWorks(view, link) {
199
+ const { plan } = view;
200
+ const flow = plan.flow?.transitions ?? [];
201
+ const states = plan.states?.transitions ?? [];
202
+ const reuse = plan.reuse.length ? `<h3>O que aproveita e o que nasce</h3><div class="scroll-x"><table><thead><tr><th>Conceito</th><th>Veredito</th><th>Onde</th></tr></thead><tbody>${plan.reuse.map((r) => `<tr><td>${inline(r.concept, link)}</td><td>${esc(r.verdict.toLowerCase())}</td><td>${inline(r.target, link)}${r.why ? `<br><small>${inline(r.why, link)}</small>` : ''}</td></tr>`).join('')}</tbody></table></div>` : '';
203
+ const out = plan.text.outOfScope;
204
+ return `<section id="como"><h2>Como funciona</h2>
205
+ ${flow.length ? `<h3>Fluxo</h3><div class="diagram">${stateMachineSvg(flow, 'Fluxo', 'flow')}</div>` : ''}
206
+ ${states.length ? `<h3>Estados</h3><div class="diagram">${stateMachineSvg(states, 'Máquina de estados', 'states')}</div>` : ''}
207
+ ${plan.text.architecture ? `<h3>Arquitetura</h3><div class="prose">${blocks(plan.text.architecture, link)}</div>` : ''}
208
+ ${reuse}
209
+ ${out ? `<h3>Fora de escopo</h3><div class="prose">${blocks(out, link)}</div>` : ''}
210
+ ${plan.text.contract ? `<details class="fold"><summary>Contrato de implementação</summary><div class="prose">${blocks(plan.text.contract, link)}</div></details>` : ''}
211
+ ${plan.errata ? `<h3>Errata</h3><div class="prose">${blocks(plan.errata, link)}</div>` : ''}
212
+ </section>`;
213
+ }
214
+
215
+ const REVIEW_LABEL = { verifier: 'Verificador', reviewer: 'Revisor', security: 'Segurança' };
216
+
217
+ function execution(view, link) {
218
+ const { progress, verification, reviews, design, changes } = view;
219
+ const started = progress?.done > 0 || progress?.blocked > 0 || verification || Object.values(reviews).some((r) => r.status !== 'missing');
220
+ if (!started && !design.length) return '';
221
+ const share = progress?.total ? progress.done / progress.total : 0;
222
+ const ring = progress?.total ? `<div class="gauge"><svg class="ring" viewBox="0 0 112 112" role="img" aria-label="${progress.done} de ${progress.total} cenários feitos"><circle class="base" cx="56" cy="56" r="45"/><circle class="part" cx="56" cy="56" r="45" style="--share:${share}"/></svg><div><b>${progress.done} de ${progress.total}</b>cenários feitos${progress.blocked ? `, ${progress.blocked} bloqueado${progress.blocked > 1 ? 's' : ''}` : ''}</div></div>` : '';
223
+ const stat = changes?.stat ? `<div class="gauge"><div><b>${changes.stat.files}</b>arquivos alterados, +${changes.stat.added} e −${changes.stat.removed} linhas</div></div>` : '';
224
+ const rows = [];
225
+ if (verification) {
226
+ const ok = verification.status === 'pass';
227
+ rows.push(`<li>Verify: <span class="${ok ? 'ok' : 'bad'}">${ok ? 'passou' : 'falhou'}</span>${verification.sha ? ` em <code>${esc(String(verification.sha).slice(0, 8))}</code>` : ''}</li>`);
228
+ }
229
+ for (const [role, r] of Object.entries(reviews)) {
230
+ if (r.status === 'missing') continue;
231
+ const tone = r.verdict === 'SHIP' ? 'ok' : r.verdict === 'FIX' ? 'bad' : 'wait';
232
+ rows.push(`<li>${REVIEW_LABEL[role]}: <span class="${tone}">${esc(r.verdict ?? 'em andamento')}</span>${r.blockers?.length ? `, ${r.blockers.length} bloqueador${r.blockers.length > 1 ? 'es' : ''}` : ''}</li>`);
233
+ }
234
+ const blockers = Object.values(reviews).flatMap((r) => r.blockers ?? []);
235
+ return `<section id="execucao"><h2>Execução e provas</h2>
236
+ <div class="gauges">${ring}${stat}${rows.length ? `<ul class="status-list">${rows.join('')}</ul>` : ''}</div>
237
+ ${blockers.length ? `<h3>Bloqueadores da revisão</h3><div class="prose"><ul>${blockers.map((b) => `<li>${inline(`${b.id}: ${b.evidence || b.where}`, link)}</li>`).join('')}</ul></div>` : ''}
238
+ ${design.length ? `<h3>Telas</h3><ul class="shots">${design.map((d) => `<li><a href="${esc(d.href)}">${d.shot ? `<img src="${esc(d.shot)}" alt="" loading="lazy">` : ''}${esc(d.name)}${d.kind === 'opcoes' ? ' (opções)' : ''}</a></li>`).join('')}</ul>` : ''}
239
+ </section>`;
240
+ }
241
+
242
+ function recapSection(view) {
243
+ if (!view.recap) return '';
244
+ return `<section id="recap"><h2>A história da feature</h2><p class="sub">Toque para ouvir o que foi feito, cena por cena.</p><iframe class="recap" title="Recap narrado" loading="lazy" srcdoc="${esc(renderRecapHtml(view.recap))}"></iframe></section>`;
245
+ }
246
+
247
+ function refsOf(plan) {
248
+ const refs = {};
249
+ for (const c of plan.scenarios) refs[c.id] = c.text.replace(/`/g, '');
250
+ for (const a of plan.adrs) refs[a.id] = a.title;
251
+ for (const d of plan.defaults.items) refs[`P${d.n}`] = d.text.replace(/`/g, '');
252
+ return JSON.stringify(refs).replace(/</g, '\\u003c');
253
+ }
254
+
255
+ export function renderFeatureHtml(view) {
256
+ const { plan } = view;
257
+ const link = linkifier(plan);
258
+ const asks = pendingAsks(view);
259
+ const ids = plan.scenarios.map((c) => c.id);
260
+ const cited = new Map(plan.adrs.map((a) => [a.id, ids.filter((id) => new RegExp(`\\b${id}\\b`).test(a.body))]));
261
+ const citesOf = (id) => plan.adrs.filter((a) => cited.get(a.id).includes(id)).map((a) => a.id);
262
+ const done = view.stations.filter((s) => s.done).length;
263
+ const exec = execution(view, link);
264
+ const recap = recapSection(view);
265
+ const nav = [
266
+ ['panorama', 'Panorama'], ['decidir', 'Decidir', asks.length], ['cenarios', 'Cenários'], ['decisoes', 'Decisões'], ['como', 'Como funciona'],
267
+ ...(exec ? [['execucao', 'Execução']] : []), ...(recap ? [['recap', 'Recap']] : []),
268
+ ];
269
+ const key = `${view.feature}:${(view.planHash ?? '').slice(0, 12)}`;
270
+ return `<!doctype html>
271
+ <html lang="pt-BR">
272
+ <head>
273
+ <meta charset="utf-8">
274
+ <meta name="viewport" content="width=device-width, initial-scale=1">
275
+ <title>${esc(view.feature)}</title>
276
+ <style>
277
+ ${TOKENS_CSS}${PAGE_CSS}</style>
278
+ </head>
279
+ <body data-key="${esc(key)}" data-feature="${esc(view.feature)}">
280
+ <header class="top">
281
+ <p class="kind">${esc(WORK_LABEL[view.workType] ?? 'Feature')}</p>
282
+ <h1>${esc(view.feature)}</h1>
283
+ ${view.description ? `<p class="ask">${esc(view.description)}</p>` : ''}
284
+ <ol class="route" style="--done:${Math.min(1, done / Math.max(1, view.stations.length - 1)).toFixed(3)}">${view.stations.map((s) => `<li class="stop ${s.state}"${s.state === 'now' ? ' aria-current="step"' : ''}>${esc(s.label)}${s.note ? `<small>${esc(s.note)}</small>` : ''}</li>`).join('')}</ol>
285
+ </header>
286
+ <div class="shell">
287
+ <nav class="side" aria-label="Seções">${nav.map(([id, label, badge]) => `<a href="#${id}">${esc(label)}${badge ? `<b>${badge}</b>` : ''}</a>`).join('')}</nav>
288
+ <main>
289
+ ${panorama(view, asks, link)}
290
+ ${decide(view, asks, link)}
291
+ ${scenariosSection(view, citesOf, link)}
292
+ ${decisions(view, cited, link)}
293
+ ${howItWorks(view, link)}
294
+ ${exec}
295
+ ${recap}
296
+ <p class="source">Gerada pelo morph-spec a partir de plan.md${view.planHash ? ` (${esc(view.planHash.slice(0, 12))})` : ''} e dos relatórios da feature. Atualize a aba para ver o estado mais recente. Para mudar o plano, edite o plan.md.</p>
297
+ </main>
298
+ </div>
299
+ <div id="peek" role="tooltip"></div>
300
+ <div id="tray" role="region" aria-label="Sua resposta"><span class="count"></span><button type="button" class="copy">Copiar resposta</button><button type="button" class="clear">Limpar</button></div>
301
+ <script type="application/json" id="refs">${refsOf(plan)}</script>
302
+ <script>
303
+ ${CLIENT_JS}</script>
304
+ </body>
305
+ </html>
306
+ `;
307
+ }