@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.
- package/CLAUDE.md +12 -10
- package/README.md +4 -4
- package/bin/morph-spec.js +40 -43
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +28 -36
- package/docs/QUICKSTART.md +31 -191
- 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-preflight.md +3 -3
- package/framework/commands/morph-review.md +15 -0
- package/framework/commands/morph-status.md +1 -1
- package/framework/docs/CLI.md +84 -95
- 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/mcp/registry.json +4 -4
- package/framework/phases.json +32 -10
- package/framework/rules/ai-agents-standards.md +10 -2
- package/framework/rules/csharp-standards.md +4 -0
- package/framework/rules/data-standards.md +1 -1
- package/framework/rules/ef-standards.md +20 -0
- package/framework/rules/frontend-standards.md +1 -1
- package/framework/rules/hangfire-standards.md +1 -1
- package/framework/rules/implement-report.md +25 -0
- package/framework/rules/infrastructure-standards.md +2 -0
- package/framework/rules/neon-auth-standards.md +1 -1
- package/framework/rules/neon-standards.md +3 -3
- 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 +39 -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 +49 -109
- package/framework/skills/core/morph-plan/references/critique.md +43 -0
- package/framework/skills/core/morph-plan/references/interview.md +37 -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 +51 -28
- package/framework/standards/STANDARDS.meta.json +16 -19
- 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 +7 -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 +70 -0
- package/framework/standards/backend/database/postgresql/database.md +22 -1
- package/framework/standards/backend/dotnet/vsa-handler-patterns.md +33 -1
- package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +2 -1
- package/framework/standards/backend/integrations/neon-auth/neon-auth.md +12 -13
- 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/standards/infrastructure/docker/local-compose-isolation.md +2 -2
- package/framework/standards/infrastructure/neon/neon-pgvector.md +62 -20
- package/framework/standards/infrastructure/neon/neon-platform.md +61 -0
- package/framework/standards/infrastructure/neon/neon-setup.md +14 -13
- package/framework/standards/integration/mcp/mcp-tools.md +20 -20
- package/framework/templates/REGISTRY.json +1 -2
- package/framework/templates/context/ARCHITECTURE.md +1 -1
- package/framework/templates/docs/plan.md +15 -12
- package/framework/templates/docs/ui-mockups.md +17 -105
- package/framework/templates/infrastructure/docker/docker-compose.template.yml +1 -1
- 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 +2 -2
- package/src/commands/state/create.js +1 -6
- package/src/commands/state/gate-check.js +38 -31
- 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/detectors/index.js +1 -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 +3 -3
- 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 +61 -109
- 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/framework/standards/infrastructure/neon/neon-pgrag.md +0 -243
- 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
|
@@ -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')
|
package/src/lib/risk-detector.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { MAX_SLICES } from './plan/checks.js';
|
|
2
1
|
import { BLOCKER_TYPES, reviewFileName } from './review-reports.js';
|
|
2
|
+
import { FIX_ATTEMPTS } from '../../framework/hooks/shared/next-step.js';
|
|
3
3
|
import { isTestPath } from './graph/sanitize.js';
|
|
4
4
|
import { isTestArtifact } from './worktree/ignored-triage.js';
|
|
5
5
|
|
|
@@ -55,7 +55,7 @@ function firstOf(items) {
|
|
|
55
55
|
|
|
56
56
|
// Exportada porque approve/advance `auto` recusam pela mesma regra. Sem design de tela não conta,
|
|
57
57
|
// e o legado chega sem problemas: a auditoria o isenta. Checklist
|
|
58
|
-
// completo ainda deve o protótipo
|
|
58
|
+
// completo ainda deve o protótipo, os prints 1280/390 e os estados (`prototypes`).
|
|
59
59
|
export function uiuxChecklistPause(screenChecklist) {
|
|
60
60
|
if (screenChecklist?.mockups !== true) return null;
|
|
61
61
|
const problems = screenChecklist.problems ?? [];
|
|
@@ -95,6 +95,11 @@ export function reviewPause(signal) {
|
|
|
95
95
|
return `${untyped} bloqueador(es) sem tipo de evidência (${BLOCKER_TYPES.join(' | ')})`
|
|
96
96
|
+ ' — sem evidência tipada não é bloqueador: reclassifique como nota ou complete a evidência';
|
|
97
97
|
}
|
|
98
|
+
for (const [role, r] of present) {
|
|
99
|
+
if ((r.humanBlockerIds?.length ?? 0) > 0) {
|
|
100
|
+
return `${role} achou mudança irreversível que o plano não declara: ${firstOf(r.humanBlockerIds)} — o usuário decide`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
98
103
|
for (const [role, r] of present) {
|
|
99
104
|
if ((r.blockers ?? 0) > 0) return `${role} abriu ${r.blockers} bloqueador(es): ${firstOf(r.blockerIds ?? [])}`;
|
|
100
105
|
}
|
|
@@ -162,7 +167,78 @@ export function planChecksPause(status) {
|
|
|
162
167
|
return `${what} — rode \`morph-spec plan-check {feature}\` até zero erro antes do gate do plano`;
|
|
163
168
|
}
|
|
164
169
|
|
|
170
|
+
export const PAUSING_RUNGS = ['migrar', 'irreversível'];
|
|
171
|
+
const MIGRATION_PATH_RE = /(^|\/)migrations?\//i;
|
|
172
|
+
|
|
173
|
+
export function pausingRungs(checks) {
|
|
174
|
+
const list = Array.isArray(checks?.checks) ? checks.checks : [];
|
|
175
|
+
return list.filter((c) => PAUSING_RUNGS.includes(c.rung)).map((c) => ({ id: c.id, rung: c.rung }));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Só o arquivo que É o contrato. Webhook e endpoint ficam com o revisor (`irreversivel`): o caminho não diz se quebra quem consome.
|
|
179
|
+
const CONTRACT_PATH_RE = /(^|\/)(openapi|swagger)\.(ya?ml|json)$|\.proto$/i;
|
|
180
|
+
|
|
181
|
+
const touchedBy = (re) => (changedFiles = []) =>
|
|
182
|
+
changedFiles.map((f) => f.replaceAll('\\', '/')).filter((f) => re.test(f) && !isTestOnly(f));
|
|
183
|
+
export const migrationsTouched = touchedBy(MIGRATION_PATH_RE);
|
|
184
|
+
export const contractsTouched = touchedBy(CONTRACT_PATH_RE);
|
|
185
|
+
|
|
186
|
+
export const humanApprover = (approvedBy) => !!approvedBy && approvedBy !== 'auto' && !String(approvedBy).startsWith('policy:');
|
|
187
|
+
|
|
188
|
+
// Regra única do gate-check (pausa) e do approve/advance automáticos do plano (recusa).
|
|
189
|
+
export function rungPause(signals) {
|
|
190
|
+
const rungs = signals.rungs ?? [];
|
|
191
|
+
const named = rungs.map((r) => `${r.id} (${r.rung})`).join(', ');
|
|
192
|
+
if (signals.gate === 'plan') {
|
|
193
|
+
return rungs.length ? { signal: 'rung', reason: `${named} não se desfaz com revert — o usuário confirma antes de implementar` } : null;
|
|
194
|
+
}
|
|
195
|
+
if (signals.gate !== 'review') return null;
|
|
196
|
+
const migrations = signals.migrationsTouched ?? [];
|
|
197
|
+
if (migrations.length > 0 && rungs.length === 0) {
|
|
198
|
+
return { signal: 'rung-undeclared', reason: `a feature toca migration (${firstOf(migrations)}) e nenhum cenário declara \`degrau: migrar\` ou \`irreversível\`` };
|
|
199
|
+
}
|
|
200
|
+
const contracts = signals.contractsTouched ?? [];
|
|
201
|
+
if (contracts.length > 0 && !rungs.some((r) => r.rung === 'irreversível')) {
|
|
202
|
+
return { signal: 'rung-undeclared', reason: `a feature toca contrato externo (${firstOf(contracts)}) e nenhum cenário declara \`degrau: irreversível\`` };
|
|
203
|
+
}
|
|
204
|
+
if (rungs.length > 0 && !signals.planApprovedByHuman) return { signal: 'rung', reason: `${named} não passou por aprovação humana no plano` };
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const PLAN_CRITIQUE_REASONS = {
|
|
209
|
+
missing: 'sem `2-plan/critica.md`',
|
|
210
|
+
'no-hash': '`2-plan/critica.md` sem `**Plan-Hash:**`',
|
|
211
|
+
stale: '`2-plan/critica.md` é de outra versão do plano e nenhum `**Aplicado-em:**` cobre a atual',
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// Regra única do gate-check (pausa) e do approve/advance automáticos do plano (recusa).
|
|
215
|
+
export function planCritiquePause(status) {
|
|
216
|
+
const what = PLAN_CRITIQUE_REASONS[status];
|
|
217
|
+
return what ? `${what} — despache a crítica em contexto fresco (morph-plan, references/critique.md) antes do gate do plano` : null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Pauses the session clears by doing the work the reason names; the rest wait for the human.
|
|
221
|
+
const REPORT_SIGNALS = ['review-reports', 'screen-checklist', 'media-claims'];
|
|
222
|
+
export const SESSION_SIGNALS = ['verify-red', 'verify-missing', 'verify-stale', 'e2e-skip', 'plan-checks', 'plan-critique', 'uiux-checklist', ...REPORT_SIGNALS];
|
|
223
|
+
|
|
224
|
+
const reviewRounds = (review) => Math.max(0, ...Object.values(review?.reports ?? {}).map((r) => r.rounds ?? 0));
|
|
225
|
+
const humanBlockers = (review) => Object.values(review?.reports ?? {}).some((r) => (r.humanBlockerIds?.length ?? 0) > 0);
|
|
226
|
+
|
|
227
|
+
export function pauseResolver(signal, signals = {}) {
|
|
228
|
+
if (!SESSION_SIGNALS.includes(signal)) return 'human';
|
|
229
|
+
if (signal === 'verify-red' && (signals.failStreak ?? 0) >= FIX_ATTEMPTS) return 'human';
|
|
230
|
+
if (signal === 'e2e-skip' && classifyE2eSkip(signals.e2eReason) !== 'requested') return 'human';
|
|
231
|
+
if (REPORT_SIGNALS.includes(signal) && reviewRounds(signals.reviewReports) >= FIX_ATTEMPTS) return 'human';
|
|
232
|
+
if (signal === 'review-reports' && humanBlockers(signals.reviewReports)) return 'human';
|
|
233
|
+
return 'session';
|
|
234
|
+
}
|
|
235
|
+
|
|
165
236
|
export function shouldPauseGate(config, signals) {
|
|
237
|
+
const decision = pauseDecision(config, signals);
|
|
238
|
+
return decision.pause ? { ...decision, resolver: pauseResolver(decision.signal, signals) } : decision;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function pauseDecision(config, signals) {
|
|
166
242
|
const trust = config?.trust ?? 'auto';
|
|
167
243
|
|
|
168
244
|
if (trust === 'manual') {
|
|
@@ -301,15 +377,13 @@ export function shouldPauseGate(config, signals) {
|
|
|
301
377
|
if (signals.gate === 'plan') {
|
|
302
378
|
const reason = planChecksPause(signals.planChecks);
|
|
303
379
|
if (reason) return { pause: true, signal: 'plan-checks', reason };
|
|
380
|
+
const critique = planCritiquePause(signals.planCritique);
|
|
381
|
+
if (critique) return { pause: true, signal: 'plan-critique', reason: critique };
|
|
304
382
|
}
|
|
305
383
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
signal: 'slice-count',
|
|
310
|
-
reason: `${signals.sliceCount} fatias, acima de ${MAX_SLICES} — o plano é grande demais para uma feature`,
|
|
311
|
-
};
|
|
312
|
-
}
|
|
384
|
+
// Por último: o usuário só é chamado pelo degrau quando a sessão não tem mais nada a corrigir.
|
|
385
|
+
const rung = rungPause(signals);
|
|
386
|
+
if (rung) return { pause: true, ...rung };
|
|
313
387
|
|
|
314
388
|
return { pause: false, signal: null, reason: '' };
|
|
315
389
|
}
|
|
@@ -346,8 +420,13 @@ export function getRiskSignals(signals) {
|
|
|
346
420
|
if (signals.gate === 'plan' && signals.planChecks && signals.planChecks !== 'fresh') {
|
|
347
421
|
out.push(`checks.json ${signals.planChecks}`);
|
|
348
422
|
}
|
|
349
|
-
if (
|
|
350
|
-
|
|
423
|
+
if (signals.gate === 'plan' && signals.planCritique) out.push(`crítica do plano: ${signals.planCritique}`);
|
|
424
|
+
if ((signals.rungs?.length ?? 0) > 0) out.push(`degrau: ${signals.rungs.map((r) => `${r.id} ${r.rung}`).join(', ')}`);
|
|
425
|
+
if (signals.gate === 'review' && (signals.migrationsTouched?.length ?? 0) > 0) {
|
|
426
|
+
out.push(`migration tocada: ${firstOf(signals.migrationsTouched)}`);
|
|
427
|
+
}
|
|
428
|
+
if (signals.gate === 'review' && (signals.contractsTouched?.length ?? 0) > 0) {
|
|
429
|
+
out.push(`contrato externo tocado: ${firstOf(signals.contractsTouched)}`);
|
|
351
430
|
}
|
|
352
431
|
if (signals.gate === 'review' && signals.mediaClaims?.featureGeneratesMedia === true) {
|
|
353
432
|
out.push(signals.mediaClaims.section === true
|