@polymorphism-tech/morph-spec 9.8.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.
- package/CLAUDE.md +10 -10
- package/README.md +4 -4
- package/bin/morph-spec.js +40 -43
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +24 -30
- package/framework/MORPH.md +47 -123
- package/framework/agents.json +6 -374
- package/framework/commands/morph-apply.md +1 -1
- package/framework/commands/morph-archive.md +4 -3
- package/framework/commands/morph-hotfix.md +8 -30
- package/framework/commands/morph-infra.md +2 -2
- package/framework/commands/morph-media.md +10 -9
- package/framework/commands/morph-review.md +15 -0
- package/framework/commands/morph-status.md +1 -1
- package/framework/docs/CLI.md +83 -94
- package/framework/evals/review-checklist.md +45 -53
- package/framework/hooks/README.md +4 -4
- package/framework/hooks/claude-code/core/README.md +1 -1
- package/framework/hooks/claude-code/core/denylist.json +4 -4
- package/framework/hooks/claude-code/core/gate-guard.js +9 -0
- package/framework/hooks/claude-code/core/masked-exit.js +49 -0
- package/framework/hooks/claude-code/core/state-sync.js +4 -1
- package/framework/hooks/claude-code/core/telemetry-log.js +1 -2
- package/framework/hooks/claude-code/core/test-budget.js +10 -70
- package/framework/hooks/claude-code/core/validate-completion.js +11 -24
- package/framework/hooks/claude-code/core/worktree-context.js +5 -22
- package/framework/hooks/claude-code/core/worktree-create.js +6 -45
- package/framework/hooks/claude-code/core/worktree-env-inject.js +2 -10
- package/framework/hooks/shared/e2e-check.js +0 -0
- package/framework/hooks/shared/gate-event.js +2 -2
- package/framework/hooks/shared/graph-refresh.js +20 -3
- package/framework/hooks/shared/heavy-command.js +32 -1
- package/framework/hooks/shared/heavy-config.js +1 -1
- package/framework/hooks/shared/next-step.js +63 -24
- package/framework/hooks/shared/phase-utils.js +6 -9
- package/framework/hooks/shared/report-progress.js +86 -0
- package/framework/hooks/shared/state-reader.js +3 -2
- package/framework/hooks/statusline.js +332 -0
- package/framework/phases.json +32 -10
- package/framework/rules/ai-agents-standards.md +9 -1
- package/framework/rules/csharp-standards.md +4 -0
- package/framework/rules/ef-standards.md +20 -0
- package/framework/rules/frontend-standards.md +1 -1
- package/framework/rules/implement-report.md +25 -0
- package/framework/rules/infrastructure-standards.md +2 -0
- package/framework/rules/nextjs-standards.md +7 -0
- package/framework/rules/plan-grammar.md +53 -0
- package/framework/rules/ui-design.md +27 -0
- package/framework/schemas/config.schema.json +11 -18
- package/framework/schemas/feature.schema.json +7 -1
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/create-umbraco-lp/references/umbraco-lp-patterns.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +2 -2
- package/framework/skills/core/morph-apply/SKILL.md +38 -166
- package/framework/skills/core/morph-apply/references/debugging.md +8 -0
- package/framework/skills/core/morph-apply/references/hotfix.md +42 -0
- package/framework/skills/core/morph-apply/references/machine.md +29 -0
- package/framework/skills/core/{morph-media/SKILL.md → morph-apply/references/media.md} +11 -74
- package/framework/skills/core/morph-apply/references/review.md +65 -0
- package/framework/skills/core/morph-plan/SKILL.md +42 -108
- package/framework/skills/core/morph-plan/references/critique.md +40 -0
- package/framework/skills/core/morph-plan/references/interview.md +25 -0
- package/framework/skills/core/morph-uiux/SKILL.md +47 -64
- package/framework/skills/core/morph-uiux/surfaces.json +32 -0
- package/framework/standards/STANDARDS.json +32 -5
- package/framework/standards/STANDARDS.meta.json +5 -1
- package/framework/standards/ai-agents/agent-session.md +2 -3
- package/framework/standards/ai-agents/context-providers.md +2 -2
- package/framework/standards/ai-agents/durable-workflows-hitl.md +2 -2
- package/framework/standards/ai-agents/providers/model-registry.md +2 -2
- package/framework/standards/ai-agents/rag-custom-pgvector.md +6 -6
- package/framework/standards/ai-agents/setup.md +1 -1
- package/framework/standards/ai-agents/tool-adapters.md +2 -2
- package/framework/standards/architecture/stack-baseline.md +47 -0
- package/framework/standards/backend/integrations/neon-auth/neon-auth.md +2 -4
- package/framework/standards/frontend/design-system/ai-image-generation.md +51 -12
- package/framework/standards/frontend/design-system/ai-video-generation.md +50 -4
- package/framework/standards/frontend/design-system/art-direction.md +2 -2
- package/framework/standards/frontend/scroll-driven/frame-scrub.md +12 -2
- package/framework/standards/frontend/scroll-driven/scroll-components.md +76 -4
- package/framework/templates/REGISTRY.json +1 -2
- package/framework/templates/context/ARCHITECTURE.md +1 -1
- package/framework/templates/docs/plan.md +12 -12
- package/framework/templates/docs/ui-mockups.md +17 -105
- package/package.json +1 -1
- package/src/commands/e2e/e2e.js +88 -23
- package/src/commands/graph/diff.js +74 -0
- package/src/commands/graph/status.js +1 -1
- package/src/commands/observability/cost.js +2 -4
- package/src/commands/observability/telemetry.js +1 -1
- package/src/commands/project/doctor.js +23 -39
- package/src/commands/project/fleet.js +5 -6
- package/src/commands/project/init.js +63 -36
- package/src/commands/project/stack-scan.js +3 -42
- package/src/commands/project/stack-set.js +26 -0
- package/src/commands/project/status.js +12 -13
- package/src/commands/project/update.js +39 -3
- package/src/commands/project/worktree.js +3 -14
- package/src/commands/state/advance.js +17 -17
- package/src/commands/state/approve.js +0 -6
- package/src/commands/state/archive.js +27 -1
- package/src/commands/state/auto-approval.js +1 -1
- package/src/commands/state/create.js +0 -5
- package/src/commands/state/gate-check.js +37 -30
- package/src/commands/state/gate-decision.js +3 -2
- package/src/commands/state/next.js +1 -0
- package/src/commands/state/recap.js +67 -0
- package/src/commands/state/report-check.js +36 -178
- package/src/commands/validation/plan-check.js +16 -11
- package/src/commands/validation/validate-feature.js +3 -3
- package/src/commands/validation/verify.js +3 -6
- package/src/core/paths/output-schema.js +3 -3
- package/src/core/state/state-manager.js +2 -4
- package/src/dashboard/app/components/CommandPalette.jsx +2 -5
- package/src/dashboard/app/components/GatePauses.jsx +116 -0
- package/src/dashboard/app/components/SessionMap.jsx +77 -0
- package/src/dashboard/app/pages/Activity.jsx +4 -0
- package/src/dashboard/app/pages/AgentMap.jsx +1 -2
- package/src/dashboard/app/pages/FeatureDetail.jsx +129 -186
- package/src/dashboard/app/pages/Features.jsx +7 -7
- package/src/dashboard/app/pages/PhasePipeline.jsx +1 -1
- package/src/dashboard/app/pages/Portfolio.jsx +40 -5
- package/src/dashboard/scanner/activity-scanner.js +29 -0
- package/src/dashboard/scanner/agents-scanner.js +11 -14
- package/src/dashboard/scanner/features-scanner.js +45 -24
- package/src/dashboard/scanner/project-api.js +11 -2
- package/src/dashboard/scanner/skills-scanner.js +11 -1
- package/src/lib/e2e/applicability.js +3 -1
- package/src/lib/git/tree-state.js +1 -16
- package/src/lib/graph/diff-tree.js +127 -0
- package/src/lib/graph/graphify-runtime.js +12 -5
- package/src/lib/installers/memory-installer.js +2 -2
- package/src/lib/installers/rules-installer.js +57 -0
- package/src/lib/installers/stack-assets.js +17 -0
- package/src/lib/installers/stack-sync.js +219 -0
- package/src/lib/plan/checks.js +55 -110
- package/src/lib/plan/critique.js +28 -0
- package/src/lib/plan/media.js +42 -0
- package/src/lib/plan/plan-html.js +125 -0
- package/src/lib/plan/plan-parser.js +34 -45
- package/src/lib/recap/piper.js +36 -0
- package/src/lib/recap/recap.js +232 -0
- package/src/lib/review-reports.js +28 -4
- package/src/lib/risk-detector.js +90 -11
- package/src/lib/specs/screen-checklist.js +33 -48
- package/src/lib/stack/dotnet-build-ignores.js +8 -9
- package/src/lib/telemetry/metrics.js +27 -29
- package/src/lib/validators/reuse/reuse-validator.js +1 -9
- package/src/lib/validators/verify-runner.js +0 -7
- package/src/lib/worktree/fleet-scanner.js +7 -20
- package/src/lib/worktree/worktree-audit.js +0 -37
- package/src/scripts/setup-infra.js +15 -50
- package/src/utils/agents-installer.js +27 -201
- package/src/utils/hooks-installer.js +11 -95
- package/framework/hooks/claude-code/core/warn-bash-source-write.js +0 -23
- package/framework/hooks/claude-code/statusline.py +0 -699
- package/framework/hooks/claude-code/statusline.sh +0 -11
- package/framework/hooks/shared/agent-reap.js +0 -74
- package/framework/hooks/shared/slice-progress.js +0 -110
- package/framework/skills/core/morph-hotfix/SKILL.md +0 -133
- package/framework/skills/core/morph-machine/SKILL.md +0 -46
- package/framework/skills/core/morph-review/SKILL.md +0 -129
- package/framework/skills/core/morph-standards/SKILL.md +0 -52
- package/framework/standards/PROVENANCE.json +0 -119
- package/src/commands/project/worktree-reap.js +0 -34
- package/src/commands/state/handoff.js +0 -65
- package/src/commands/validation/mutate.js +0 -263
- package/src/commands/validation/scout-check.js +0 -28
- package/src/lib/generators/handoff-generator.js +0 -172
- package/src/lib/mutation/adapters.js +0 -79
- package/src/lib/mutation/commands.js +0 -61
- package/src/lib/mutation/dotnet-introspection.js +0 -281
- package/src/lib/mutation/manifest.js +0 -144
- package/src/lib/mutation/plan.js +0 -44
- package/src/lib/mutation/report.js +0 -68
- package/src/lib/mutation/runner.js +0 -301
- package/src/lib/mutation/schemata.js +0 -60
- package/src/lib/mutation/snapshot.js +0 -65
- package/src/lib/mutation/subset.js +0 -109
- package/src/lib/mutation/trx.js +0 -50
- package/src/lib/scout/scout-check.js +0 -293
- package/src/lib/validators/traceability-check.js +0 -103
- package/src/lib/worktree/agent-reaper.js +0 -247
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
+
}
|
|
@@ -4,6 +4,7 @@ import { markdownLines } from '../../../framework/hooks/shared/errata.js';
|
|
|
4
4
|
|
|
5
5
|
export const PROOF_KINDS = ['teste', 'e2e', 'eval', 'manual', 'leitura'];
|
|
6
6
|
export const RISK_KINDS = ['regra', 'dinheiro', 'contrato', 'concorrência', 'dado', 'regressão'];
|
|
7
|
+
export const REWORK_RUNGS = ['refazer', 'desfazer', 'migrar', 'irreversível'];
|
|
7
8
|
export const REUSE_VERDICTS = ['REUSAR', 'ESTENDER', 'CRIAR'];
|
|
8
9
|
export const ADR_SCOPES = ['feature', 'projeto'];
|
|
9
10
|
|
|
@@ -11,44 +12,29 @@ export const SECTIONS = {
|
|
|
11
12
|
problem: 'Problema',
|
|
12
13
|
outOfScope: 'Fora de escopo',
|
|
13
14
|
assumptions: 'Suposições não confirmadas',
|
|
15
|
+
defaults: 'Padrões assumidos',
|
|
16
|
+
states: 'Estados',
|
|
14
17
|
scenarios: 'Cenários',
|
|
15
18
|
decisions: 'Decisões',
|
|
16
19
|
architecture: 'Arquitetura',
|
|
17
20
|
reuse: 'Reuso',
|
|
18
|
-
slices: 'Fatias',
|
|
19
21
|
contract: 'Contrato de implementação',
|
|
20
22
|
errata: 'Errata',
|
|
21
23
|
};
|
|
22
24
|
|
|
23
25
|
export const CONFLICTS_HEADING = 'Conflitos com o acervo';
|
|
24
26
|
|
|
25
|
-
const SLICE_KEYS = {
|
|
26
|
-
persona: 'persona',
|
|
27
|
-
'superfície': 'surface',
|
|
28
|
-
superficie: 'surface',
|
|
29
|
-
standards: 'standards',
|
|
30
|
-
arquivos: 'files',
|
|
31
|
-
depende: 'depends',
|
|
32
|
-
cobre: 'covers',
|
|
33
|
-
};
|
|
34
|
-
|
|
35
27
|
const DASH = String.raw`\s+[—–-]\s+`;
|
|
36
28
|
const SCENARIO_RE = new RegExp(String.raw`^[-*]\s+\*\*(C\d+)\*\*${DASH}(.+)$`);
|
|
37
29
|
const ADR_RE = new RegExp(String.raw`^###\s+(ADR-\d+)${DASH}(.+?)\s*$`);
|
|
38
|
-
const SLICE_RE = new RegExp(String.raw`^###\s+(F\d+)${DASH}(.+?)\s*$`);
|
|
39
30
|
const PROOF_RE = /^\s*prova\s*:\s*(.*?)\s*$/i;
|
|
40
31
|
const RISK_RE = /^\s*risco\s*:\s*(.*?)\s*$/i;
|
|
32
|
+
const RUNG_RE = /^\s*degrau\s*:\s*(.*?)\s*$/i;
|
|
41
33
|
const WHY_RE = new RegExp(String.raw`^\s*>\s*` + ARTIFACT_MARKER_RE.source + String.raw`\s*(.*?)\s*$`, 'i');
|
|
42
34
|
const SCOPE_RE = /^\s*\*\*Alcance:\*\*\s*(.*?)\s*$/i;
|
|
43
|
-
const SLICE_LINE_RE = /^\s*[-*]\s+([\p{L}]+)\s*:\s*(.*?)\s*$/u;
|
|
44
35
|
|
|
45
36
|
const fold = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().trim();
|
|
46
37
|
|
|
47
|
-
const listOf = (value) => value
|
|
48
|
-
.split(',')
|
|
49
|
-
.map((v) => v.replace(/`/g, '').trim())
|
|
50
|
-
.filter((v) => v && !/^[—–-]$/.test(v) && fold(v) !== 'nenhum' && fold(v) !== 'nenhuma');
|
|
51
|
-
|
|
52
38
|
function splitSections(lines) {
|
|
53
39
|
const sections = [];
|
|
54
40
|
let current = null;
|
|
@@ -71,7 +57,7 @@ function parseScenarios(lines) {
|
|
|
71
57
|
if (line.fenced) { current = null; continue; }
|
|
72
58
|
const m = SCENARIO_RE.exec(line.raw);
|
|
73
59
|
if (m) {
|
|
74
|
-
current = { id: m[1], text: m[2].trim(), proof: null, risk: null, artifactWhy: null, line: line.n };
|
|
60
|
+
current = { id: m[1], text: m[2].trim(), proof: null, risk: null, rung: null, artifactWhy: null, line: line.n };
|
|
75
61
|
scenarios.push(current);
|
|
76
62
|
continue;
|
|
77
63
|
}
|
|
@@ -83,6 +69,8 @@ function parseScenarios(lines) {
|
|
|
83
69
|
if (proof && current.proof === null) { current.proof = proof[1]; continue; }
|
|
84
70
|
const risk = RISK_RE.exec(line.raw);
|
|
85
71
|
if (risk && current.risk === null) { current.risk = risk[1]; continue; }
|
|
72
|
+
const rung = RUNG_RE.exec(line.raw);
|
|
73
|
+
if (rung && current.rung === null) { current.rung = rung[1]; continue; }
|
|
86
74
|
if (/^\s*>/.test(line.raw)) continue;
|
|
87
75
|
current.text = `${current.text} ${line.raw.trim()}`;
|
|
88
76
|
}
|
|
@@ -149,34 +137,34 @@ function parseReuse(lines) {
|
|
|
149
137
|
return { header, rows };
|
|
150
138
|
}
|
|
151
139
|
|
|
152
|
-
function
|
|
153
|
-
const
|
|
154
|
-
let
|
|
140
|
+
function parseDefaults(lines) {
|
|
141
|
+
const items = [];
|
|
142
|
+
let none = false;
|
|
143
|
+
let comment = false;
|
|
155
144
|
for (const line of lines) {
|
|
156
145
|
if (line.fenced) continue;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
146
|
+
if (comment || line.raw.includes('<!--')) { comment = !line.raw.includes('-->'); continue; }
|
|
147
|
+
const m = /^\s*(\d+)[.)]\s+(.+?)\s*$/.exec(line.raw);
|
|
148
|
+
if (m) items.push({ n: Number(m[1]), text: m[2], line: line.n });
|
|
149
|
+
else if (/^\s*nenhum\.?\s*$/i.test(line.raw)) none = true;
|
|
150
|
+
}
|
|
151
|
+
return { items, none };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const TRANSITION_RE = /^\s*[-*]\s+(.+?)\s*(?:→|->)\s*(.+?)\s*:\s*(.+?)\s*$/;
|
|
155
|
+
|
|
156
|
+
function parseStates(lines) {
|
|
157
|
+
const transitions = [];
|
|
158
|
+
const invalid = [];
|
|
159
|
+
let comment = false;
|
|
160
|
+
for (const line of lines) {
|
|
161
|
+
if (line.fenced || !line.raw.trim()) continue;
|
|
162
|
+
if (comment || line.raw.includes('<!--')) { comment = !line.raw.includes('-->'); continue; }
|
|
163
|
+
const m = TRANSITION_RE.exec(line.raw);
|
|
164
|
+
if (m) transitions.push({ from: m[1], to: m[2], trigger: m[3], line: line.n });
|
|
165
|
+
else invalid.push({ line: line.n, raw: line.raw.trim() });
|
|
178
166
|
}
|
|
179
|
-
return
|
|
167
|
+
return { transitions, invalid };
|
|
180
168
|
}
|
|
181
169
|
|
|
182
170
|
/** Pure: no validation, no I/O; `checks.js` judges what this returns. */
|
|
@@ -201,7 +189,8 @@ export function parsePlan(text) {
|
|
|
201
189
|
conflicts: decisions.conflicts,
|
|
202
190
|
reuse: reuse.rows,
|
|
203
191
|
reuseHeader: reuse.header,
|
|
204
|
-
|
|
192
|
+
defaults: parseDefaults(body('defaults')),
|
|
193
|
+
states: parseStates(body('states')),
|
|
205
194
|
errata: errata ? errata.lines.map((l) => l.raw).join('\n').trim() : null,
|
|
206
195
|
sectionsAfterErrata: errata ? sections.filter((s) => s.line > errata.line).map((s) => s.name) : [],
|
|
207
196
|
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs';
|
|
3
|
+
import { homedir, tmpdir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { findFirstOnPath } from '../../../framework/hooks/shared/morph-cli.js';
|
|
6
|
+
|
|
7
|
+
export const PIPER_MODEL_DIR = join(homedir(), '.morph-spec', 'piper');
|
|
8
|
+
|
|
9
|
+
/** The piper binary and a voice model, or why narration falls back to the system voice. */
|
|
10
|
+
export function resolvePiper(env = process.env) {
|
|
11
|
+
const bin = findFirstOnPath(process.platform === 'win32' ? ['piper.exe'] : ['piper'], env);
|
|
12
|
+
if (!bin) return { ok: false, reason: 'piper fora do PATH (pip install piper-tts)' };
|
|
13
|
+
const model = env.MORPH_PIPER_MODEL
|
|
14
|
+
|| [join(PIPER_MODEL_DIR, 'pt_BR-faber-medium.onnx')].find((m) => existsSync(m));
|
|
15
|
+
if (!model || !existsSync(model)) {
|
|
16
|
+
return { ok: false, reason: `sem modelo de voz: baixe pt_BR-faber-medium.onnx (+ .onnx.json) para ${PIPER_MODEL_DIR}, ou aponte MORPH_PIPER_MODEL` };
|
|
17
|
+
}
|
|
18
|
+
return { ok: true, bin, model };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `{ [sceneId]: dataUri }`, or null when piper fails on any scene: half-narrated is worse than the system voice. */
|
|
22
|
+
export function narrateWithPiper(scenes, piper, { run = spawnSync } = {}) {
|
|
23
|
+
const dir = mkdtempSync(join(tmpdir(), 'morph-recap-'));
|
|
24
|
+
try {
|
|
25
|
+
const audio = {};
|
|
26
|
+
for (const s of scenes) {
|
|
27
|
+
const out = join(dir, `${s.id}.wav`);
|
|
28
|
+
const r = run(piper.bin, ['--model', piper.model, '--output_file', out], { input: s.narration, windowsHide: true, stdio: ['pipe', 'ignore', 'pipe'] });
|
|
29
|
+
if (r.status !== 0 || !existsSync(out)) return null;
|
|
30
|
+
audio[s.id] = `data:audio/wav;base64,${readFileSync(out).toString('base64')}`;
|
|
31
|
+
}
|
|
32
|
+
return audio;
|
|
33
|
+
} finally {
|
|
34
|
+
rmSync(dir, { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { parsePlan } from '../plan/plan-parser.js';
|
|
5
|
+
import { stateMachineSvg } from '../plan/plan-html.js';
|
|
6
|
+
import { reviewReportsFromDir } from '../review-reports.js';
|
|
7
|
+
import { loadGraph, getFreshness } from '../graph/graph-loader.js';
|
|
8
|
+
import { parseDiff, buildDiffTree, declarationDrift } from '../graph/diff-tree.js';
|
|
9
|
+
import { detectDefaultBranch } from '../git/default-branch.js';
|
|
10
|
+
|
|
11
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
12
|
+
const GATE_LABEL = { plan: 'Plano', uiux: 'Design', review: 'Gate 3' };
|
|
13
|
+
|
|
14
|
+
const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
|
15
|
+
const readText = (path) => { try { return readFileSync(path, 'utf8'); } catch { return null; } };
|
|
16
|
+
const git = (cwd, ...args) => execFileSync('git', args, { cwd, stdio: 'pipe', maxBuffer: 64 << 20 }).toString();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What changed on the branch: the call tree when the graph is of HEAD and its lines hold, else the
|
|
20
|
+
* plain file and line counts. Never throws; outside git it is null.
|
|
21
|
+
*/
|
|
22
|
+
export function changeSummary(cwd, { base = null } = {}) {
|
|
23
|
+
let from = base;
|
|
24
|
+
try {
|
|
25
|
+
from ??= git(cwd, 'merge-base', detectDefaultBranch(cwd) || 'main', 'HEAD').trim();
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
let stat = null;
|
|
30
|
+
try {
|
|
31
|
+
const m = /(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/.exec(git(cwd, 'diff', '--shortstat', from, 'HEAD'));
|
|
32
|
+
stat = { files: Number(m?.[1] ?? 0), added: Number(m?.[2] ?? 0), removed: Number(m?.[3] ?? 0) };
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const loaded = loadGraph(cwd);
|
|
37
|
+
if (!loaded.ok || getFreshness(cwd, loaded.graph).state !== 'fresh') return { stat, tree: null };
|
|
38
|
+
try {
|
|
39
|
+
const tree = buildDiffTree(loaded.graph, parseDiff(git(cwd, 'diff', '-U0', '--no-renames', '--no-color', from, 'HEAD')));
|
|
40
|
+
const drift = declarationDrift(tree, (f) => { try { return git(cwd, 'show', `HEAD:${f}`).split(/\r?\n/); } catch { return []; } });
|
|
41
|
+
return { stat, tree: drift.length ? null : tree };
|
|
42
|
+
} catch {
|
|
43
|
+
return { stat, tree: null };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildRecap(featureDir, feature, { changes = null, now = new Date() } = {}) {
|
|
48
|
+
const fj = readJson(join(featureDir, 'feature.json')) ?? {};
|
|
49
|
+
const planText = readText(join(featureDir, '2-plan', 'plan.md'));
|
|
50
|
+
const plan = planText ? parsePlan(planText) : null;
|
|
51
|
+
const verifier = reviewReportsFromDir(featureDir).verifier;
|
|
52
|
+
const proved = new Map((verifier?.checks ?? []).map((c) => [c.id, c]));
|
|
53
|
+
const scenarios = (plan?.scenarios ?? []).map((c) => ({ id: c.id, text: c.text, status: proved.get(c.id)?.status ?? null, evidence: proved.get(c.id)?.evidence ?? '' }));
|
|
54
|
+
const gates = Object.entries(fj.approvalGates ?? {})
|
|
55
|
+
.filter(([gate]) => GATE_LABEL[gate])
|
|
56
|
+
.map(([gate, g]) => ({
|
|
57
|
+
gate, approved: g === true || g?.approved === true, approvedBy: g?.approvedBy ?? null,
|
|
58
|
+
paused: fj.gateDecisions?.[gate]?.pause === true, reason: fj.gateDecisions?.[gate]?.reason ?? null,
|
|
59
|
+
}));
|
|
60
|
+
const started = Date.parse(fj.createdAt ?? '');
|
|
61
|
+
return {
|
|
62
|
+
feature,
|
|
63
|
+
description: fj.description ?? null,
|
|
64
|
+
workType: fj.workType ?? null,
|
|
65
|
+
days: Number.isNaN(started) ? null : Math.max(0, Math.round((now - started) / 86400000)),
|
|
66
|
+
scenarios,
|
|
67
|
+
transitions: plan?.states?.transitions ?? [],
|
|
68
|
+
gates,
|
|
69
|
+
changes,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function flatten(tree, limit) {
|
|
74
|
+
const out = [];
|
|
75
|
+
const walk = (n, depth) => {
|
|
76
|
+
if (out.length >= limit) return;
|
|
77
|
+
out.push({ depth, mark: n.mark, label: n.label, file: n.file, again: n.repeat || n.cycle });
|
|
78
|
+
n.children.forEach((c) => walk(c, depth + 1));
|
|
79
|
+
};
|
|
80
|
+
tree.roots.forEach((r) => walk(r, 0));
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One scene per part of the story; `narration` is what is spoken and shown as the caption. */
|
|
85
|
+
export function recapScenes(r) {
|
|
86
|
+
const scenes = [];
|
|
87
|
+
scenes.push({
|
|
88
|
+
id: 'abertura',
|
|
89
|
+
title: r.feature,
|
|
90
|
+
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
|
+
});
|
|
93
|
+
if (r.scenarios.length) {
|
|
94
|
+
const ok = r.scenarios.filter((c) => c.status === 'PROVADO').length;
|
|
95
|
+
scenes.push({
|
|
96
|
+
id: 'cenarios',
|
|
97
|
+
title: 'O que ficou provado',
|
|
98
|
+
narration: `${ok} de ${r.scenarios.length} cenários provados pelo verificador. ${r.scenarios.slice(0, 4).map((c) => `${c.id}: ${c.text}`).join(' ')}`,
|
|
99
|
+
body: `<ul class="checks">${r.scenarios.map((c, i) => `<li style="--i:${i}"><b class="${c.status === 'PROVADO' ? 'ok' : 'warn'}">${c.status === 'PROVADO' ? '✓' : '·'}</b><span><em>${esc(c.id)}</em> ${esc(c.text)}${c.evidence ? `<small>${esc(c.evidence)}</small>` : ''}</span></li>`).join('')}</ul>`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (r.transitions.length) {
|
|
103
|
+
const states = new Set(r.transitions.flatMap((t) => [t.from, t.to]));
|
|
104
|
+
scenes.push({
|
|
105
|
+
id: 'estados',
|
|
106
|
+
title: 'Como os estados andam',
|
|
107
|
+
narration: `${states.size} estados e ${r.transitions.length} transições. ${r.transitions.slice(0, 4).map((t) => `De ${t.from} para ${t.to}, quando ${t.trigger}.`).join(' ')}`,
|
|
108
|
+
body: `<div class="machine-box">${stateMachineSvg(r.transitions)}</div>`,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (r.changes) {
|
|
112
|
+
const { stat, tree } = r.changes;
|
|
113
|
+
const rows = tree ? flatten(tree, 14) : [];
|
|
114
|
+
scenes.push({
|
|
115
|
+
id: 'mudancas',
|
|
116
|
+
title: 'O que mudou no código',
|
|
117
|
+
narration: tree
|
|
118
|
+
? `${tree.counts.added} símbolos novos e ${tree.counts.modified} alterados em ${stat.files} arquivos. ${tree.roots.slice(0, 3).map((n) => n.label.replace(/\(\)$/, '')).join(', ')} são os pontos de entrada.`
|
|
119
|
+
: `${stat.files} arquivos, ${stat.added} linhas a mais e ${stat.removed} a menos.`,
|
|
120
|
+
body: `<p class="stat"><b>${stat.files}</b> arquivos <b class="ok">+${stat.added}</b> <b class="bad">−${stat.removed}</b></p>${rows.length
|
|
121
|
+
? `<ul class="tree">${rows.map((n, i) => `<li style="--i:${i};--d:${n.depth}"><b class="${n.mark === '+' ? 'ok' : 'warn'}">${n.mark}</b> ${esc(n.label)}${n.again ? ' <small>↑</small>' : ''} <small>${esc(n.file)}</small></li>`).join('')}</ul>`
|
|
122
|
+
: ''}`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (r.gates.length) {
|
|
126
|
+
const human = r.gates.filter((g) => g.paused);
|
|
127
|
+
scenes.push({
|
|
128
|
+
id: 'gates',
|
|
129
|
+
title: 'Por onde passou',
|
|
130
|
+
narration: `${r.gates.filter((g) => g.approved).length} gates aprovados. ${human.length ? `Pausou para você em ${human.map((g) => GATE_LABEL[g.gate]).join(' e ')}.` : 'Nenhuma pausa para você.'}`,
|
|
131
|
+
body: `<ol class="gates">${r.gates.map((g, i) => `<li style="--i:${i}"><b class="${g.approved ? 'ok' : 'warn'}">${g.approved ? '✓' : '·'}</b> ${GATE_LABEL[g.gate]}${g.approvedBy ? ` <small>por ${esc(g.approvedBy)}</small>` : ''}${g.paused && g.reason ? `<small class="why">pausou: ${esc(g.reason)}</small>` : ''}</li>`).join('')}</ol>`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return scenes;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const SCENE_MS = 9000;
|
|
138
|
+
|
|
139
|
+
/** `audio`: optional `{ [sceneId]: dataUri }`; without it the page narrates with the system voice. */
|
|
140
|
+
export function renderRecapHtml(r, { audio = null } = {}) {
|
|
141
|
+
const scenes = recapScenes(r);
|
|
142
|
+
const data = JSON.stringify(scenes.map((s) => ({ id: s.id, narration: s.narration, audio: audio?.[s.id] ?? null }))).replace(/</g, '\\u003c');
|
|
143
|
+
return `<!doctype html>
|
|
144
|
+
<html lang="pt-BR">
|
|
145
|
+
<head>
|
|
146
|
+
<meta charset="utf-8">
|
|
147
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
148
|
+
<title>Recap — ${esc(r.feature)}</title>
|
|
149
|
+
<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
|
+
* { box-sizing:border-box; }
|
|
153
|
+
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
|
+
.stage { flex:1; position:relative; max-width:960px; width:100%; margin:0 auto; padding:40px 16px 16px; }
|
|
155
|
+
.scene { position:absolute; inset:40px 16px 16px; opacity:0; transform:translateY(12px); transition:opacity .6s, transform .6s; pointer-events:none; overflow:auto; }
|
|
156
|
+
.scene.on { opacity:1; transform:none; pointer-events:auto; }
|
|
157
|
+
h1 { font-size:clamp(26px, 5vw, 44px); margin:0 0 20px; letter-spacing:-.02em; }
|
|
158
|
+
.lead { font-size:20px; color:var(--dim); max-width:60ch; }
|
|
159
|
+
.big { font-size:72px; font-weight:700; margin:24px 0 0; } .big span { font-size:20px; color:var(--faint); font-weight:400; }
|
|
160
|
+
ul, ol { list-style:none; padding:0; margin:0; }
|
|
161
|
+
.on li { animation:rise .5s both; animation-delay:calc(var(--i) * 120ms + 300ms); }
|
|
162
|
+
@keyframes rise { from { opacity:0; transform:translateY(8px); } }
|
|
163
|
+
@media (prefers-reduced-motion: reduce) { .scene, .on li { transition:none; animation:none; } }
|
|
164
|
+
.checks li, .gates li { display:flex; gap:12px; padding:10px 0; border-bottom:1px solid var(--hair); }
|
|
165
|
+
.checks em { font-style:normal; font-weight:600; color:var(--accent); margin-right:6px; }
|
|
166
|
+
small { display:block; color:var(--faint); font-size:13px; }
|
|
167
|
+
.tree li small, .gates li small { display:inline; margin-left:6px; }
|
|
168
|
+
.gates .why { display:block; margin:4px 0 0; }
|
|
169
|
+
.tree li { padding:4px 0 4px calc(var(--d) * 22px); font:14px/1.5 ui-monospace, monospace; }
|
|
170
|
+
.stat { font-size:22px; } .stat b { margin-right:4px; }
|
|
171
|
+
.ok { color:var(--ok); } .warn { color:var(--warn); } .bad { color:var(--bad); }
|
|
172
|
+
.machine-box { background:var(--card); border:1px solid var(--hair); border-radius:12px; padding:12px; overflow-x:auto; }
|
|
173
|
+
.machine .state rect { fill:var(--bg); stroke:var(--accent); stroke-width:1.5; } .machine .state text { fill:var(--fg); font:600 13px system-ui; }
|
|
174
|
+
.machine .edge { fill:none; stroke:var(--faint); stroke-width:1.5; } .machine .arrow-head { fill:var(--faint); }
|
|
175
|
+
.machine .edge-label { fill:var(--dim); font:12px system-ui; paint-order:stroke; stroke:var(--card); stroke-width:4px; }
|
|
176
|
+
.bar { position:sticky; bottom:0; background:var(--card); border-top:1px solid var(--hair); }
|
|
177
|
+
.caption { max-width:960px; margin:0 auto; padding:12px 16px 4px; color:var(--dim); min-height:3.2em; }
|
|
178
|
+
.controls { max-width:960px; margin:0 auto; padding:4px 16px 12px; display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
|
179
|
+
button { background:transparent; color:var(--fg); border:1px solid var(--hair); border-radius:999px; padding:6px 14px; font:inherit; font-size:14px; cursor:pointer; min-height:36px; }
|
|
180
|
+
button[aria-pressed="true"] { border-color:var(--accent); color:var(--accent); }
|
|
181
|
+
.dots { margin-left:auto; display:flex; gap:6px; } .dots i { width:8px; height:8px; border-radius:50%; background:var(--hair); } .dots i.on { background:var(--accent); }
|
|
182
|
+
</style>
|
|
183
|
+
</head>
|
|
184
|
+
<body>
|
|
185
|
+
<main class="stage">
|
|
186
|
+
${scenes.map((s, i) => `<section class="scene${i === 0 ? ' on' : ''}" data-scene="${i}" aria-hidden="${i !== 0}"><h1>${esc(s.title)}</h1>${s.body}</section>`).join('\n')}
|
|
187
|
+
</main>
|
|
188
|
+
<div class="bar">
|
|
189
|
+
<p class="caption" aria-live="polite"></p>
|
|
190
|
+
<div class="controls">
|
|
191
|
+
<button id="play">▶ Tocar</button><button id="prev">←</button><button id="next">→</button><button id="voice" aria-pressed="true">Narração</button>
|
|
192
|
+
<span class="dots">${scenes.map(() => '<i></i>').join('')}</span>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
<script>
|
|
196
|
+
const SCENES = ${data};
|
|
197
|
+
const els = [...document.querySelectorAll('.scene')], dots = [...document.querySelectorAll('.dots i')];
|
|
198
|
+
const caption = document.querySelector('.caption'), voiceBtn = document.getElementById('voice'), playBtn = document.getElementById('play');
|
|
199
|
+
let at = 0, playing = false, timer = null, clip = null;
|
|
200
|
+
const voiceOn = () => voiceBtn.getAttribute('aria-pressed') === 'true';
|
|
201
|
+
function stopSound() { clearTimeout(timer); if (clip) { clip.pause(); clip = null; } if (window.speechSynthesis) speechSynthesis.cancel(); }
|
|
202
|
+
function advance() { if (playing && at < SCENES.length - 1) show(at + 1); else { playing = false; playBtn.textContent = '▶ Tocar'; } }
|
|
203
|
+
function speak(s) {
|
|
204
|
+
if (!playing) return;
|
|
205
|
+
if (voiceOn() && s.audio) { clip = new Audio(s.audio); clip.onended = advance; clip.play().catch(() => { timer = setTimeout(advance, ${SCENE_MS}); }); return; }
|
|
206
|
+
if (voiceOn() && window.speechSynthesis) {
|
|
207
|
+
const u = new SpeechSynthesisUtterance(s.narration); u.lang = 'pt-BR';
|
|
208
|
+
const v = speechSynthesis.getVoices().find((x) => x.lang && x.lang.toLowerCase().startsWith('pt'));
|
|
209
|
+
if (v) u.voice = v;
|
|
210
|
+
u.onend = advance; u.onerror = () => { timer = setTimeout(advance, ${SCENE_MS}); };
|
|
211
|
+
speechSynthesis.speak(u); return;
|
|
212
|
+
}
|
|
213
|
+
timer = setTimeout(advance, ${SCENE_MS});
|
|
214
|
+
}
|
|
215
|
+
function show(i) {
|
|
216
|
+
stopSound(); at = i;
|
|
217
|
+
els.forEach((e, k) => { e.classList.toggle('on', k === i); e.setAttribute('aria-hidden', String(k !== i)); });
|
|
218
|
+
dots.forEach((d, k) => d.classList.toggle('on', k === i));
|
|
219
|
+
caption.textContent = SCENES[i].narration;
|
|
220
|
+
speak(SCENES[i]);
|
|
221
|
+
}
|
|
222
|
+
playBtn.onclick = () => { playing = !playing; playBtn.textContent = playing ? '❚❚ Pausar' : '▶ Tocar'; if (playing) show(at); else stopSound(); };
|
|
223
|
+
document.getElementById('prev').onclick = () => show(Math.max(0, at - 1));
|
|
224
|
+
document.getElementById('next').onclick = () => show(Math.min(SCENES.length - 1, at + 1));
|
|
225
|
+
voiceBtn.onclick = () => { voiceBtn.setAttribute('aria-pressed', String(!voiceOn())); if (!voiceOn()) stopSound(); };
|
|
226
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'ArrowRight') document.getElementById('next').click(); if (e.key === 'ArrowLeft') document.getElementById('prev').click(); if (e.key === ' ') { e.preventDefault(); playBtn.click(); } });
|
|
227
|
+
caption.textContent = SCENES[0].narration; dots[0].classList.add('on');
|
|
228
|
+
</script>
|
|
229
|
+
</body>
|
|
230
|
+
</html>
|
|
231
|
+
`;
|
|
232
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { OUTPUT_SCHEMA, getAbsoluteOutputPath } from '../core/paths/output-schema.js';
|
|
4
|
-
import { PARTIAL_MARKER } from '../../framework/hooks/shared/
|
|
4
|
+
import { PARTIAL_MARKER } from '../../framework/hooks/shared/report-progress.js';
|
|
5
5
|
|
|
6
6
|
export const VERDICT_TOKEN = '**Verdict:**';
|
|
7
7
|
export const REVIEWED_SHA_TOKEN = '**Reviewed-SHA:**';
|
|
8
8
|
export const VERDICTS = ['SHIP', 'FIX'];
|
|
9
|
-
export const BLOCKER_TYPES = ['check', 'teste', 'regressao', 'seguranca', 'escopo', 'arquitetura', 'performance'];
|
|
9
|
+
export const BLOCKER_TYPES = ['check', 'teste', 'regressao', 'seguranca', 'escopo', 'arquitetura', 'performance', 'irreversivel'];
|
|
10
|
+
// The plan is frozen by then: the session cannot declare the rung itself, only the human can accept it.
|
|
11
|
+
export const HUMAN_BLOCKER_TYPES = ['irreversivel'];
|
|
10
12
|
export const CHECK_STATUSES = ['PROVADO', 'NÃO PROVADO', 'FALHA'];
|
|
11
13
|
|
|
12
14
|
export const REVIEW_ROLES = {
|
|
@@ -101,11 +103,31 @@ function parseChecks(bodies) {
|
|
|
101
103
|
return [...byId.values()];
|
|
102
104
|
}
|
|
103
105
|
|
|
104
|
-
|
|
106
|
+
// A verdict at the start of a line opens a round; one quoted inside a note does not.
|
|
107
|
+
const ROUND_START_RE = /^[ \t>*_]*\*\*Verdict:\*\*/gm;
|
|
108
|
+
|
|
109
|
+
function splitRounds(text) {
|
|
110
|
+
const starts = [...text.matchAll(ROUND_START_RE)].map((m) => m.index);
|
|
111
|
+
if (starts.length < 2) return [text];
|
|
112
|
+
return starts.map((s, i) => text.slice(i === 0 ? 0 : s, starts[i + 1] ?? text.length));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Um relatório de review lido do texto. `checks` é `null` sem a seção `## Checks`.
|
|
117
|
+
* `rounds`: uma entrada por veredito, na ordem do arquivo, com o que aquela rodada escreveu.
|
|
118
|
+
*/
|
|
105
119
|
export function parseReviewReport(text) {
|
|
106
120
|
if (typeof text !== 'string') {
|
|
107
|
-
return { partial: false, verdict: undefined, blockers: [], untyped: [], superseded: [], checks: null, reviewedSha: null };
|
|
121
|
+
return { partial: false, verdict: undefined, blockers: [], untyped: [], superseded: [], checks: null, reviewedSha: null, rounds: [] };
|
|
108
122
|
}
|
|
123
|
+
const parsed = splitRounds(text).map(parseRound);
|
|
124
|
+
const last = parsed[parsed.length - 1];
|
|
125
|
+
const rounds = parsed.map((r) => ({ verdict: r.verdict ?? null, reviewedSha: r.reviewedSha, blockers: r.blockers, checks: r.checks }));
|
|
126
|
+
// Blockers belong to the round that wrote them: a confirmation without `## Bloqueadores` closes the earlier ones.
|
|
127
|
+
return { ...parseRound(text), blockers: last.blockers, untyped: last.untyped, rounds };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseRound(text) {
|
|
109
131
|
const firstLine = text.split(/\r?\n/).find((l) => l.trim() !== '') ?? '';
|
|
110
132
|
const verdicts = [...text.matchAll(VERDICT_RE)].map((m) => m[1].toUpperCase());
|
|
111
133
|
const blockersBody = lastSectionBody(text, BLOCKERS_SECTION_RE);
|
|
@@ -182,8 +204,10 @@ export function reviewSignal(reports, { required, checkIds }) {
|
|
|
182
204
|
verdict: r.verdict ?? null,
|
|
183
205
|
blockers: r.blockers.length,
|
|
184
206
|
blockerIds: r.blockers.map((b) => b.id),
|
|
207
|
+
humanBlockerIds: r.blockers.filter((b) => HUMAN_BLOCKER_TYPES.includes(b.type)).map((b) => b.id),
|
|
185
208
|
untyped: r.untyped.length,
|
|
186
209
|
superseded: r.superseded,
|
|
210
|
+
rounds: r.rounds.length,
|
|
187
211
|
};
|
|
188
212
|
}
|
|
189
213
|
out.checks = !required.includes('verifier')
|