@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,57 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { parseStacks, shouldInstall } from '../stack-filter.js';
|
|
4
|
+
import { realEffects } from './effects.js';
|
|
5
|
+
|
|
6
|
+
export const MAF_TAG = 'maf';
|
|
7
|
+
export const MAF_RULE = 'ai-agents-standards.md';
|
|
8
|
+
const MAF_PATHS = ['**/*.cs'];
|
|
9
|
+
|
|
10
|
+
/** The text a rule is installed with: in a MAF project the agents rule covers every `.cs`, not only `Agents/` folders. */
|
|
11
|
+
export function ruleContentFor(file, content, projectTags) {
|
|
12
|
+
if (file !== MAF_RULE || !projectTags.includes(MAF_TAG)) return content;
|
|
13
|
+
const fm = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
14
|
+
if (!fm) return content;
|
|
15
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
16
|
+
const list = MAF_PATHS.map((p) => ` - "${p}"`).join(eol);
|
|
17
|
+
const rewritten = fm[1].replace(
|
|
18
|
+
/^paths:[ \t]*(?:\r?\n[ \t]+-[^\r\n]*)*(\r?\n)?/m,
|
|
19
|
+
(_, tail) => `paths:${eol}${list}${tail ? eol : ''}`,
|
|
20
|
+
);
|
|
21
|
+
const start = fm[0].indexOf(fm[1]);
|
|
22
|
+
return content.slice(0, start) + rewritten + content.slice(start + fm[1].length);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function installRuleFile(rulesSrc, rulesDest, file, projectTags, fx = realEffects()) {
|
|
26
|
+
const from = join(rulesSrc, file);
|
|
27
|
+
const to = join(rulesDest, file);
|
|
28
|
+
const content = readFileSync(from, 'utf-8');
|
|
29
|
+
const desired = ruleContentFor(file, content, projectTags);
|
|
30
|
+
if (desired === content) fx.copyFile(from, to);
|
|
31
|
+
else fx.writeFile(to, desired);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Also removes orphan rules in rulesDest that are no longer in the install set. */
|
|
35
|
+
export function installRulesForStack(rulesSrc, rulesDest, projectTags, fx = realEffects()) {
|
|
36
|
+
const sourceFiles = readdirSync(rulesSrc).filter(f => f.endsWith('.md'));
|
|
37
|
+
const installed = new Set();
|
|
38
|
+
|
|
39
|
+
for (const file of sourceFiles) {
|
|
40
|
+
const content = readFileSync(join(rulesSrc, file), 'utf-8');
|
|
41
|
+
if (shouldInstall(parseStacks(content), projectTags)) {
|
|
42
|
+
installRuleFile(rulesSrc, rulesDest, file, projectTags, fx);
|
|
43
|
+
installed.add(file);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Calado em `dryRun`: `cleanFrameworkDirs()` já apagou `.claude/rules/` inteiro antes da execução real.
|
|
48
|
+
if (!fx.dryRun) {
|
|
49
|
+
for (const file of readdirSync(rulesDest).filter(f => f.endsWith('.md'))) {
|
|
50
|
+
if (!installed.has(file)) {
|
|
51
|
+
fx.remove(join(rulesDest, file));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { installed: installed.size };
|
|
57
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { installRulesForStack } from './rules-installer.js';
|
|
3
|
+
import { installCommandsForStack } from '../../scripts/setup-infra.js';
|
|
4
|
+
import { installSkills } from '../../utils/skills-installer.js';
|
|
5
|
+
import { installAgents } from '../../utils/agents-installer.js';
|
|
6
|
+
|
|
7
|
+
const FRAMEWORK_SRC = join(import.meta.dirname, '..', '..', '..', 'framework');
|
|
8
|
+
|
|
9
|
+
/** Everything filtered by `stacks:` — rules, commands, skills, agents — for the project's tags. The one path init and stack-set share. */
|
|
10
|
+
export async function installStackAssets(targetPath, stackTags, { frameworkSrc = FRAMEWORK_SRC } = {}) {
|
|
11
|
+
if (stackTags.length === 0) return false;
|
|
12
|
+
installRulesForStack(join(frameworkSrc, 'rules'), join(targetPath, '.claude', 'rules'), stackTags);
|
|
13
|
+
installCommandsForStack(join(frameworkSrc, 'commands'), join(targetPath, '.claude', 'commands'), stackTags);
|
|
14
|
+
await installSkills(targetPath, { projectTags: stackTags });
|
|
15
|
+
await installAgents(targetPath, frameworkSrc, { projectTags: stackTags });
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { detectProject } from '../detectors/index.js';
|
|
4
|
+
import { getProjectTags, parseStacks, shouldInstall } from '../stack-filter.js';
|
|
5
|
+
import { WORKTREE_DIR_NAMES } from '../worktree/dirs.js';
|
|
6
|
+
import { resolveComposeFile } from '../../../framework/hooks/shared/e2e-check.js';
|
|
7
|
+
import { createEffects } from './effects.js';
|
|
8
|
+
import { MAF_RULE, MAF_TAG, installRuleFile, ruleContentFor } from './rules-installer.js';
|
|
9
|
+
|
|
10
|
+
const RULES_SRC = join(import.meta.dirname, '..', '..', '..', 'framework', 'rules');
|
|
11
|
+
const STACK_TAGS = new Set(['nextjs', 'dotnet']);
|
|
12
|
+
const SKIP_DIRS = new Set(['node_modules', 'bin', 'obj', ...WORKTREE_DIR_NAMES]);
|
|
13
|
+
const MAX_DEPTH = 5;
|
|
14
|
+
const MAX_ENTRIES = 20000;
|
|
15
|
+
const NEXT_CONFIG = /^next\.config\.(js|mjs|cjs|ts|mts)$/;
|
|
16
|
+
const DOCKER_FILE = /^(Dockerfile.*|docker-compose\..*|compose\.ya?ml)$/;
|
|
17
|
+
const MAF_PACKAGE =/Include="Microsoft\.Agents\.AI(\.[^"]*)?"/;
|
|
18
|
+
|
|
19
|
+
function ruleFiles(rulesSrc) {
|
|
20
|
+
return readdirSync(rulesSrc).filter((f) => f.endsWith('.md'))
|
|
21
|
+
.map((file) => ({ file, stacks: parseStacks(readFileSync(join(rulesSrc, file), 'utf-8')) }));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The tags some rule's `stacks:` names: the only detected tags worth persisting. */
|
|
25
|
+
export function ruleTags(rulesSrc = RULES_SRC) {
|
|
26
|
+
return new Set(ruleFiles(rulesSrc).flatMap(({ stacks }) => stacks).filter((s) => s !== '*'));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function walk(root, visit) {
|
|
30
|
+
let seen = 0;
|
|
31
|
+
const stack = [[root, 0]];
|
|
32
|
+
while (stack.length) {
|
|
33
|
+
const [dir, depth] = stack.pop();
|
|
34
|
+
let entries;
|
|
35
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
36
|
+
for (const e of entries) {
|
|
37
|
+
if (++seen > MAX_ENTRIES) return;
|
|
38
|
+
if (e.isDirectory()) {
|
|
39
|
+
if (depth < MAX_DEPTH && !e.name.startsWith('.') && !SKIP_DIRS.has(e.name)) stack.push([join(dir, e.name), depth + 1]);
|
|
40
|
+
} else if (e.isFile()) {
|
|
41
|
+
visit(e.name, join(dir, e.name));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `tags`: what the files show, for picking rules. `persist`: the subset safe to write into config.json —
|
|
49
|
+
* `docker` only with a compose file, since a declared `docker` without one turns the e2e node red.
|
|
50
|
+
*/
|
|
51
|
+
export function detectStackTags(projectDir, { config = null, rulesSrc = RULES_SRC } = {}) {
|
|
52
|
+
const found = new Set();
|
|
53
|
+
const { structure } = detectProject(projectDir);
|
|
54
|
+
for (const t of String(structure.stack).split('+')) found.add(t);
|
|
55
|
+
for (const t of structure.integrations) found.add(t);
|
|
56
|
+
|
|
57
|
+
walk(projectDir, (name, path) => {
|
|
58
|
+
if (name.endsWith('.csproj') || name.endsWith('.sln') || name.endsWith('.slnx')) found.add('dotnet');
|
|
59
|
+
if (NEXT_CONFIG.test(name)) found.add('nextjs');
|
|
60
|
+
if (DOCKER_FILE.test(name)) found.add('docker');
|
|
61
|
+
if (name.endsWith('.csproj') || name === 'Directory.Packages.props') {
|
|
62
|
+
try { if (MAF_PACKAGE.test(readFileSync(path, 'utf-8'))) found.add(MAF_TAG); } catch { /* unreadable: not MAF */ }
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const wanted = ruleTags(rulesSrc);
|
|
67
|
+
wanted.add(MAF_TAG);
|
|
68
|
+
const tags = [...found].filter((t) => wanted.has(t));
|
|
69
|
+
const hasCompose = Boolean(resolveComposeFile(projectDir, config ?? {}));
|
|
70
|
+
const persist = tags.filter((t) => t !== 'docker' || hasCompose);
|
|
71
|
+
return { tags, persist };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Adds the tags the config lacks; never removes one. `unknown` is a placeholder, not a tag. */
|
|
75
|
+
export function mergeDetectedTags(config, tags) {
|
|
76
|
+
const have = new Set(getProjectTags(config));
|
|
77
|
+
const added = tags.filter((t) => !have.has(t));
|
|
78
|
+
if (added.length === 0) return { config, added, changed: false };
|
|
79
|
+
|
|
80
|
+
const project = { ...(config.project ?? {}) };
|
|
81
|
+
const stackParts = typeof project.stack === 'string'
|
|
82
|
+
? project.stack.split('+').filter((s) => s && s !== 'unknown')
|
|
83
|
+
: [];
|
|
84
|
+
const integrations = Array.isArray(project.integrations) ? [...project.integrations] : [];
|
|
85
|
+
for (const t of added) (STACK_TAGS.has(t) ? stackParts : integrations).push(t);
|
|
86
|
+
if (stackParts.length) project.stack = stackParts.join('+');
|
|
87
|
+
if (integrations.length) project.integrations = integrations;
|
|
88
|
+
return { config: { ...config, project }, added, changed: true };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const configPathOf = (projectDir) => join(projectDir, '.morph', 'config', 'config.json');
|
|
92
|
+
|
|
93
|
+
function readConfig(path) {
|
|
94
|
+
return JSON.parse(readFileSync(path, 'utf-8').replace(/^/, ''));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function missingRules(projectDir, tags, rulesSrc) {
|
|
98
|
+
const rulesDest = join(projectDir, '.claude', 'rules');
|
|
99
|
+
const missing = [];
|
|
100
|
+
const stale = [];
|
|
101
|
+
for (const { file, stacks } of ruleFiles(rulesSrc)) {
|
|
102
|
+
if (stacks.includes('*') || !shouldInstall(stacks, tags)) continue;
|
|
103
|
+
const dest = join(rulesDest, file);
|
|
104
|
+
if (!existsSync(dest)) { missing.push(file); continue; }
|
|
105
|
+
if (file !== MAF_RULE || !tags.includes(MAF_TAG)) continue;
|
|
106
|
+
const desired = ruleContentFor(file, readFileSync(join(rulesSrc, file), 'utf-8'), tags);
|
|
107
|
+
if (readFileSync(dest, 'utf-8') !== desired) stale.push(file);
|
|
108
|
+
}
|
|
109
|
+
return { missing, stale };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let warned = false;
|
|
113
|
+
function warnOnce(log, message) {
|
|
114
|
+
if (warned) return;
|
|
115
|
+
warned = true;
|
|
116
|
+
log(`⚠ stack-sync: ${message}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const NOOP = Object.freeze({ added: [], updated: [], tags: [], configChanged: false });
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Installs every stack rule (a `stacks:` other than `*`) the project needs and lacks, after adding to
|
|
123
|
+
* config.json the stack tags its files show. Only an installed project (with config.json) is touched. Never throws.
|
|
124
|
+
* `persistTags: false` leaves config.json alone: a check (`verify`) may write gitignored harness files, never a tracked one.
|
|
125
|
+
*/
|
|
126
|
+
export function syncStackRules(projectDir, {
|
|
127
|
+
dryRun = false, fx = createEffects({ dryRun }), log = (line) => console.log(line), rulesSrc = RULES_SRC,
|
|
128
|
+
persistTags = true,
|
|
129
|
+
} = {}) {
|
|
130
|
+
try {
|
|
131
|
+
const configPath = configPathOf(projectDir);
|
|
132
|
+
if (!existsSync(configPath)) return { ...NOOP };
|
|
133
|
+
let config;
|
|
134
|
+
try { config = readConfig(configPath); } catch {
|
|
135
|
+
warnOnce(log, `${configPath} ilegível — regras de stack não sincronizadas`);
|
|
136
|
+
return { ...NOOP };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const detected = detectStackTags(projectDir, { config, rulesSrc });
|
|
140
|
+
const merged = persistTags
|
|
141
|
+
? mergeDetectedTags(config, detected.persist)
|
|
142
|
+
: { config, added: [], changed: false };
|
|
143
|
+
if (merged.changed) {
|
|
144
|
+
fx.writeJson(configPath, merged.config);
|
|
145
|
+
log(` + config.json: ${merged.added.join(', ')} (detectado nos arquivos)`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const tags = [...new Set([...getProjectTags(merged.config), ...detected.tags])];
|
|
149
|
+
const { missing, stale } = missingRules(projectDir, tags, rulesSrc);
|
|
150
|
+
const rulesDest = join(projectDir, '.claude', 'rules');
|
|
151
|
+
for (const file of [...missing, ...stale]) {
|
|
152
|
+
installRuleFile(rulesSrc, rulesDest, file, tags, fx);
|
|
153
|
+
log(` + .claude/rules/${file} (stack: ${tags.join('+')})`);
|
|
154
|
+
}
|
|
155
|
+
return { added: missing, updated: stale, tags, configChanged: merged.changed };
|
|
156
|
+
} catch (err) {
|
|
157
|
+
warnOnce(log, err?.message ?? String(err));
|
|
158
|
+
return { ...NOOP };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** What `stack-set` accepts: the stack tags, and the integrations some rule (or the MAF rewrite) reacts to. */
|
|
163
|
+
export function knownStackTags(rulesSrc = RULES_SRC) {
|
|
164
|
+
const integrations = [...ruleTags(rulesSrc), MAF_TAG].filter((t) => !STACK_TAGS.has(t));
|
|
165
|
+
return { stacks: [...STACK_TAGS].sort(), integrations: [...new Set(integrations)].sort() };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The user's choice of stack, written into config.json (`project.stack` replaced, `integrations` added to),
|
|
170
|
+
* then the rules it needs installed. `{ error }` names what was refused; nothing is written then.
|
|
171
|
+
*/
|
|
172
|
+
export function setProjectStack(projectDir, stack, {
|
|
173
|
+
integrations = [], dryRun = false, fx = createEffects({ dryRun }), log = (line) => console.log(line), rulesSrc = RULES_SRC,
|
|
174
|
+
} = {}) {
|
|
175
|
+
const configPath = configPathOf(projectDir);
|
|
176
|
+
if (!existsSync(configPath)) return { error: `${configPath} ausente — rode morph-spec init antes` };
|
|
177
|
+
const known = knownStackTags(rulesSrc);
|
|
178
|
+
const stackTags = String(stack ?? '').split('+').map((t) => t.trim()).filter(Boolean);
|
|
179
|
+
const unknown = [
|
|
180
|
+
...stackTags.filter((t) => !known.stacks.includes(t)),
|
|
181
|
+
...integrations.filter((t) => !known.integrations.includes(t)),
|
|
182
|
+
];
|
|
183
|
+
if (stackTags.length === 0 || unknown.length > 0) {
|
|
184
|
+
return { error: `tag desconhecida: ${unknown.join(', ') || '(stack vazia)'}. Stack: ${known.stacks.join(' | ')} (unidas por +); --with: ${known.integrations.join(', ')}` };
|
|
185
|
+
}
|
|
186
|
+
let config;
|
|
187
|
+
try { config = readConfig(configPath); } catch { return { error: `${configPath} ilegível` }; }
|
|
188
|
+
if (integrations.includes('docker') && !resolveComposeFile(projectDir, config)) {
|
|
189
|
+
return { error: 'docker sem compose deixa o nó e2e vermelho: crie o compose primeiro (o plan-check e o update declaram docker quando ele existir)' };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const project = { ...(config.project ?? {}), stack: stackTags.join('+') };
|
|
193
|
+
const merged = [...new Set([...(Array.isArray(project.integrations) ? project.integrations : []), ...integrations])];
|
|
194
|
+
if (merged.length) project.integrations = merged;
|
|
195
|
+
fx.writeJson(configPath, { ...config, project });
|
|
196
|
+
log(` + config.json: project.stack = ${project.stack}${merged.length ? `, integrations = ${merged.join(', ')}` : ''}`);
|
|
197
|
+
return { stack: project.stack, integrations: merged, sync: syncStackRules(projectDir, { dryRun, fx, log, rulesSrc }) };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Read-only, for `doctor`: what `syncStackRules` would add. `null` when the config is absent or unreadable. */
|
|
201
|
+
export function stackRuleGaps(projectDir, { rulesSrc = RULES_SRC } = {}) {
|
|
202
|
+
try {
|
|
203
|
+
const configPath = configPathOf(projectDir);
|
|
204
|
+
if (!existsSync(configPath)) return null;
|
|
205
|
+
const config = readConfig(configPath);
|
|
206
|
+
const stack = config?.project?.stack;
|
|
207
|
+
const detected = detectStackTags(projectDir, { config, rulesSrc });
|
|
208
|
+
const tags = [...new Set([...getProjectTags(config), ...detected.tags])];
|
|
209
|
+
const { missing, stale } = missingRules(projectDir, tags, rulesSrc);
|
|
210
|
+
return {
|
|
211
|
+
stackMissing: !stack || stack === 'unknown',
|
|
212
|
+
undeclared: detected.persist.filter((t) => !getProjectTags(config).includes(t)),
|
|
213
|
+
missing: [...missing, ...stale],
|
|
214
|
+
tags,
|
|
215
|
+
};
|
|
216
|
+
} catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
package/src/lib/plan/checks.js
CHANGED
|
@@ -2,88 +2,18 @@ import { createHash } from 'crypto';
|
|
|
2
2
|
import { existsSync, readFileSync } from 'fs';
|
|
3
3
|
import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
|
|
4
4
|
import { frozenBody } from '../../../framework/hooks/shared/errata.js';
|
|
5
|
-
import { readAgentsJson } from '../specs/screen-checklist.js';
|
|
6
5
|
import { getFeature } from '../../../framework/hooks/shared/state-reader.js';
|
|
7
6
|
import { findArtifactTerms } from './artifact-vocabulary.js';
|
|
8
|
-
import {
|
|
7
|
+
import { mediaCitationError } from './media.js';
|
|
8
|
+
import { PROOF_KINDS, RISK_KINDS, REWORK_RUNGS, REUSE_VERDICTS, ADR_SCOPES, SECTIONS, CONFLICTS_HEADING } from './plan-parser.js';
|
|
9
9
|
|
|
10
10
|
export const CHECKS_VERSION = 1;
|
|
11
|
-
export const MAX_SLICE_FILES = 8;
|
|
12
|
-
export const MAX_SLICES = 12;
|
|
13
|
-
export const MAX_PARALLEL = 4;
|
|
14
11
|
|
|
15
12
|
/** sha256 of the plan without its errata: appending a correction never invalidates the extracted checks. */
|
|
16
13
|
export function planHash(text) {
|
|
17
14
|
return createHash('sha256').update(frozenBody(text), 'utf8').digest('hex');
|
|
18
15
|
}
|
|
19
16
|
|
|
20
|
-
/** The installed agents.json, else the package's; null when unreadable (personas then go unchecked). */
|
|
21
|
-
export function knownPersonas(cwd) {
|
|
22
|
-
const agents = readAgentsJson(cwd)?.agents;
|
|
23
|
-
return agents && typeof agents === 'object' ? Object.keys(agents) : null;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** The ids on a dependency cycle, or null. */
|
|
27
|
-
export function dependencyCycle(slices) {
|
|
28
|
-
const deps = new Map(slices.map((s) => [s.id, s.depends ?? []]));
|
|
29
|
-
const state = new Map();
|
|
30
|
-
const stack = [];
|
|
31
|
-
const visit = (id) => {
|
|
32
|
-
if (state.get(id) === 'done' || !deps.has(id)) return null;
|
|
33
|
-
if (state.get(id) === 'open') return stack.slice(stack.indexOf(id)).concat(id);
|
|
34
|
-
state.set(id, 'open');
|
|
35
|
-
stack.push(id);
|
|
36
|
-
for (const dep of deps.get(id)) {
|
|
37
|
-
const cycle = visit(dep);
|
|
38
|
-
if (cycle) return cycle;
|
|
39
|
-
}
|
|
40
|
-
stack.pop();
|
|
41
|
-
state.set(id, 'done');
|
|
42
|
-
return null;
|
|
43
|
-
};
|
|
44
|
-
for (const id of deps.keys()) {
|
|
45
|
-
const cycle = visit(id);
|
|
46
|
-
if (cycle) return cycle;
|
|
47
|
-
}
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const fileKey = (f) => f.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase();
|
|
52
|
-
|
|
53
|
-
// A slice joins a wave only after its dependencies ran, never with one touching its files, and at most
|
|
54
|
-
// MAX_PARALLEL per wave. Empty on a dependency cycle.
|
|
55
|
-
export function parallelGroups(slices) {
|
|
56
|
-
if (dependencyCycle(slices)) return [];
|
|
57
|
-
const ids = new Set(slices.map((s) => s.id));
|
|
58
|
-
const depth = new Map();
|
|
59
|
-
const depthOf = (slice) => {
|
|
60
|
-
if (depth.has(slice.id)) return depth.get(slice.id);
|
|
61
|
-
const parents = (slice.depends ?? []).filter((d) => ids.has(d)).map((d) => slices.find((s) => s.id === d));
|
|
62
|
-
const value = parents.length === 0 ? 0 : 1 + Math.max(...parents.map(depthOf));
|
|
63
|
-
depth.set(slice.id, value);
|
|
64
|
-
return value;
|
|
65
|
-
};
|
|
66
|
-
const layers = [];
|
|
67
|
-
for (const slice of slices) (layers[depthOf(slice)] ??= []).push(slice);
|
|
68
|
-
|
|
69
|
-
const groups = [];
|
|
70
|
-
for (const layer of layers.filter(Boolean)) {
|
|
71
|
-
const open = [];
|
|
72
|
-
for (const slice of layer) {
|
|
73
|
-
const files = new Set((slice.files ?? []).map(fileKey));
|
|
74
|
-
const group = open.find((g) => g.ids.length < MAX_PARALLEL && ![...files].some((f) => g.files.has(f)));
|
|
75
|
-
if (group) {
|
|
76
|
-
group.ids.push(slice.id);
|
|
77
|
-
files.forEach((f) => group.files.add(f));
|
|
78
|
-
} else {
|
|
79
|
-
open.push({ ids: [slice.id], files });
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
groups.push(...open.map((g) => g.ids));
|
|
83
|
-
}
|
|
84
|
-
return groups;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
17
|
// A frozen body already extracted under older rules cannot gain a `risco:` line, so it is not demanded.
|
|
88
18
|
export function planRuleOptions(cwd, featureName) {
|
|
89
19
|
return {
|
|
@@ -94,14 +24,53 @@ export function planRuleOptions(cwd, featureName) {
|
|
|
94
24
|
|
|
95
25
|
const foldRisk = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').replace(/`/g, '').toLowerCase().trim();
|
|
96
26
|
const RISK_BY_FOLD = new Map(RISK_KINDS.map((r) => [foldRisk(r), r]));
|
|
27
|
+
const RUNG_BY_FOLD = new Map(REWORK_RUNGS.map((r) => [foldRisk(r), r]));
|
|
28
|
+
const canonicalRung = (rung) => (rung === null ? null : RUNG_BY_FOLD.get(foldRisk(rung)) ?? false);
|
|
97
29
|
const REGRESSION_WORK_TYPES = ['bug', 'hotfix'];
|
|
30
|
+
const POLICY_PLAN_WORK_TYPES = ['chore', 'hotfix'];
|
|
31
|
+
// Vetar por número só vale para o que custa pouco errar: o resto é pergunta. Aviso, não erro: o vocabulário
|
|
32
|
+
// é comum em plano por outros motivos, e um erro só ensinaria a reescrever a linha.
|
|
33
|
+
const NEVER_DEFAULT_RE = /\b(autentica\w*|senha|password|credencia\w*|segredo|secret|api key|chave de api|permiss\w*|pagamento|cobranc\w*|billing|drop (table|column)|(apaga\w*|delet\w*|exclu\w*) (o |os |a |as )?(dado|registro|lead|conta|cliente)s?)\b/;
|
|
34
|
+
|
|
35
|
+
function defaultsWarnings(plan, at) {
|
|
36
|
+
return plan.defaults.items.flatMap((d) => {
|
|
37
|
+
const hit = NEVER_DEFAULT_RE.exec(foldRisk(d.text));
|
|
38
|
+
return hit ? [`padrão ${d.n} toca "${hit[1]}"${at(d.line)} — credencial, permissão, pagamento e apagar dado viram pergunta, nunca padrão`] : [];
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Optional: absent is fine; present, every line is a transition the plan view can draw.
|
|
43
|
+
function statesErrors(plan, at) {
|
|
44
|
+
if (!plan.present.states) return [];
|
|
45
|
+
const { transitions, invalid } = plan.states;
|
|
46
|
+
const errors = invalid.map((l) => `## ${SECTIONS.states}: "${l.raw}" não é transição — use "- origem → destino: gatilho"${at(l.line)}`);
|
|
47
|
+
if (transitions.length === 0 && invalid.length === 0) errors.push(`## ${SECTIONS.states} vazia — liste as transições ou apague a seção`);
|
|
48
|
+
return errors;
|
|
49
|
+
}
|
|
98
50
|
|
|
99
|
-
|
|
51
|
+
function defaultsErrors(plan, { workType, riskRules }, at) {
|
|
52
|
+
const { items, none } = plan.defaults;
|
|
53
|
+
if (!plan.present.defaults) {
|
|
54
|
+
return riskRules && !POLICY_PLAN_WORK_TYPES.includes(workType)
|
|
55
|
+
? [`seção obrigatória ausente: ## ${SECTIONS.defaults} — numere o que você decidiu sem perguntar, ou escreva "nenhum"`]
|
|
56
|
+
: [];
|
|
57
|
+
}
|
|
58
|
+
if (items.length === 0) {
|
|
59
|
+
return none ? [] : [`## ${SECTIONS.defaults} sem item numerado ("1. …") nem "nenhum"`];
|
|
60
|
+
}
|
|
61
|
+
return items.some((d, i) => d.n !== i + 1)
|
|
62
|
+
? [`## ${SECTIONS.defaults} numerada fora de 1..${items.length} — o usuário veta pelo número`]
|
|
63
|
+
: [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function validatePlan(plan, { workType = null, riskRules = true, text = null } = {}) {
|
|
100
67
|
const errors = [];
|
|
101
68
|
const warnings = [];
|
|
69
|
+
const media = riskRules ? mediaCitationError(text) : null;
|
|
70
|
+
if (media) errors.push(media);
|
|
102
71
|
const at = (line) => (line ? ` (linha ${line})` : '');
|
|
103
72
|
|
|
104
|
-
for (const key of ['problem', 'scenarios', 'decisions'
|
|
73
|
+
for (const key of ['problem', 'scenarios', 'decisions']) {
|
|
105
74
|
if (!plan.present[key]) errors.push(`seção obrigatória ausente: ## ${SECTIONS[key]}`);
|
|
106
75
|
}
|
|
107
76
|
if (plan.present.decisions && !plan.conflicts) {
|
|
@@ -114,7 +83,7 @@ export function validatePlan(plan, { personas = null, surfaces = null, workType
|
|
|
114
83
|
}
|
|
115
84
|
|
|
116
85
|
const duplicated = (items) => items.map((i) => i.id).filter((id, i, all) => all.indexOf(id) !== i);
|
|
117
|
-
for (const [label, items] of [['cenário', plan.scenarios], ['
|
|
86
|
+
for (const [label, items] of [['cenário', plan.scenarios], ['ADR', plan.adrs]]) {
|
|
118
87
|
for (const id of new Set(duplicated(items))) errors.push(`${label} duplicado: ${id}`);
|
|
119
88
|
}
|
|
120
89
|
|
|
@@ -136,6 +105,9 @@ export function validatePlan(plan, { personas = null, surfaces = null, workType
|
|
|
136
105
|
} else if (risk && proof === 'leitura') {
|
|
137
106
|
warnings.push(`${c.id}: risco "${risk}" provado só por leitura${at(c.line)}`);
|
|
138
107
|
}
|
|
108
|
+
if (canonicalRung(c.rung) === false) {
|
|
109
|
+
errors.push(`${c.id}: degrau "${c.rung}" fora de ${REWORK_RUNGS.join(' | ')}${at(c.line)}`);
|
|
110
|
+
}
|
|
139
111
|
const terms = findArtifactTerms(c.text);
|
|
140
112
|
if (terms.length > 0 && !c.artifactWhy) {
|
|
141
113
|
errors.push(`${c.id} descreve artefato ("${terms.join(', ')}") sem "> Por que artefato:" — reescreva como efeito observável ou justifique${at(c.line)}`);
|
|
@@ -147,37 +119,13 @@ export function validatePlan(plan, { personas = null, surfaces = null, workType
|
|
|
147
119
|
errors.push(`${workType} sem cenário de regressão — o sintoma do bug real vira um Cn com "prova: teste" e "risco: regressão"`);
|
|
148
120
|
}
|
|
149
121
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
} else if (personas && !personas.includes(s.persona)) {
|
|
157
|
-
errors.push(`${s.id}: persona "${s.persona}" não existe em agents.json (${personas.join(', ')})${at(s.line)}`);
|
|
158
|
-
}
|
|
159
|
-
if (s.files.length > MAX_SLICE_FILES) {
|
|
160
|
-
errors.push(`${s.id} toca ${s.files.length} arquivos — o teto é ${MAX_SLICE_FILES}; divida a fatia${at(s.line)}`);
|
|
161
|
-
}
|
|
162
|
-
for (const c of s.covers) {
|
|
163
|
-
if (scenarioIds.has(c)) covered.add(c);
|
|
164
|
-
else errors.push(`${s.id} cobre ${c}, que não existe em ## ${SECTIONS.scenarios}${at(s.line)}`);
|
|
165
|
-
}
|
|
166
|
-
for (const d of s.depends) {
|
|
167
|
-
if (!sliceIds.has(d)) errors.push(`${s.id} depende de ${d}, que não existe${at(s.line)}`);
|
|
168
|
-
}
|
|
169
|
-
if (s.covers.length === 0) warnings.push(`${s.id} não cobre nenhum cenário${at(s.line)}`);
|
|
170
|
-
if (s.unknownKeys.length > 0) warnings.push(`${s.id}: chave desconhecida ${s.unknownKeys.join(', ')}${at(s.line)}`);
|
|
171
|
-
if (s.surface && surfaces && !surfaces.includes(s.surface)) {
|
|
172
|
-
warnings.push(`${s.id}: superfície "${s.surface}" não é uma de ${surfaces.join(', ')}${at(s.line)}`);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
for (const c of plan.scenarios) {
|
|
176
|
-
if (!covered.has(c.id)) errors.push(`${c.id} não é coberto por nenhuma fatia ("- cobre: ${c.id}")`);
|
|
122
|
+
errors.push(...defaultsErrors(plan, { workType, riskRules }, at));
|
|
123
|
+
errors.push(...statesErrors(plan, at));
|
|
124
|
+
warnings.push(...defaultsWarnings(plan, at));
|
|
125
|
+
|
|
126
|
+
if (plan.sections.some((s) => !s.key && /^fatias$/i.test(s.name.trim()))) {
|
|
127
|
+
warnings.push('## Fatias não é lida desde a 10.0: o plano inteiro se implementa numa sessão, e os cenários definem o pronto');
|
|
177
128
|
}
|
|
178
|
-
const cycle = dependencyCycle(plan.slices);
|
|
179
|
-
if (cycle) errors.push(`ciclo em "depende": ${cycle.join(' → ')}`);
|
|
180
|
-
if (plan.slices.length > MAX_SLICES) warnings.push(`${plan.slices.length} fatias — acima de ${MAX_SLICES}, considere dividir a feature`);
|
|
181
129
|
|
|
182
130
|
for (const r of plan.reuse) {
|
|
183
131
|
if (!REUSE_VERDICTS.includes(r.verdict)) {
|
|
@@ -200,10 +148,7 @@ export function buildChecks(feature, text, plan) {
|
|
|
200
148
|
planHash: planHash(text),
|
|
201
149
|
checks: plan.scenarios.map((c) => ({
|
|
202
150
|
id: c.id, text: c.text, proof: c.proof ? c.proof.toLowerCase() : null, artifactWhy: c.artifactWhy || null,
|
|
203
|
-
|
|
204
|
-
slices: plan.slices.map((s) => ({
|
|
205
|
-
id: s.id, title: s.title, persona: s.persona, surface: s.surface,
|
|
206
|
-
standards: s.standards, files: s.files, depends: s.depends, covers: s.covers,
|
|
151
|
+
rung: canonicalRung(c.rung) || null,
|
|
207
152
|
})),
|
|
208
153
|
reuse: plan.reuse.map(({ concept, verdict, target, why }) => ({ concept, verdict, target, why })),
|
|
209
154
|
adrs: plan.adrs.map((a) => ({ id: a.id, title: a.title, scope: ADR_SCOPES.includes(a.scope) ? a.scope : null })),
|
|
@@ -222,7 +167,7 @@ export function readPlanChecks(cwd, featureName) {
|
|
|
222
167
|
} catch {
|
|
223
168
|
return { status: 'unreadable', checks: null };
|
|
224
169
|
}
|
|
225
|
-
if (checks?.version !== CHECKS_VERSION || !Array.isArray(checks.
|
|
170
|
+
if (checks?.version !== CHECKS_VERSION || !Array.isArray(checks.checks)) {
|
|
226
171
|
return { status: 'unreadable', checks: null };
|
|
227
172
|
}
|
|
228
173
|
let plan;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
|
|
3
|
+
|
|
4
|
+
export const CRITIQUE_EXEMPT_WORK_TYPES = ['chore', 'hotfix'];
|
|
5
|
+
|
|
6
|
+
const PLAN_HASH_RE = /^\*\*Plan-Hash:\*\*\s*`?([0-9a-f]{64})`?\s*$/m;
|
|
7
|
+
const APPLIED_RE = /^\*\*Aplicado-em:\*\*\s*`?([0-9a-f]{64})`?\s*$/gm;
|
|
8
|
+
|
|
9
|
+
// A critique counts for the plan it read or for the plan the session wrote after applying it:
|
|
10
|
+
// otherwise every fix it asks for would demand a new critique.
|
|
11
|
+
export function critiqueStatus(text, currentHash) {
|
|
12
|
+
if (typeof text !== 'string') return 'missing';
|
|
13
|
+
const read = PLAN_HASH_RE.exec(text)?.[1];
|
|
14
|
+
if (!read) return 'no-hash';
|
|
15
|
+
const applied = [...text.matchAll(APPLIED_RE)].map((m) => m[1]);
|
|
16
|
+
const valid = applied.length ? applied[applied.length - 1] : read;
|
|
17
|
+
return currentHash === read || currentHash === valid ? 'fresh' : 'stale';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function readPlanCritique(cwd, featureName, { workType, planChecks }) {
|
|
21
|
+
if (CRITIQUE_EXEMPT_WORK_TYPES.includes(workType)) return 'exempt';
|
|
22
|
+
const currentHash = planChecks?.status === 'fresh' ? planChecks.checks?.planHash : null;
|
|
23
|
+
if (!currentHash) return null;
|
|
24
|
+
const path = getAbsoluteOutputPath(cwd, featureName, 'planCritique');
|
|
25
|
+
let text = null;
|
|
26
|
+
try { text = existsSync(path) ? readFileSync(path, 'utf8') : null; } catch { text = null; }
|
|
27
|
+
return critiqueStatus(text, currentHash);
|
|
28
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const MEDIA_STANDARD_IDS = [
|
|
2
|
+
'ai-agents-modalities-image-gen',
|
|
3
|
+
'ai-agents-media-video',
|
|
4
|
+
'ai-agents-media-pipeline',
|
|
5
|
+
];
|
|
6
|
+
|
|
7
|
+
// O plano escreve o standard pelo id ou pelo caminho (com ou sem `framework/`/`standards/`).
|
|
8
|
+
export function standardIdOf(value) {
|
|
9
|
+
return String(value ?? '').trim().replace(/\\/g, '/')
|
|
10
|
+
.replace(/^(\.morph\/)?(framework\/)?(standards\/)?/, '')
|
|
11
|
+
.replace(/\.md$/, '')
|
|
12
|
+
.replace(/\//g, '-');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function featureGeneratesMedia(planText) {
|
|
16
|
+
if (typeof planText !== 'string') return false;
|
|
17
|
+
const tokens = new Set(planText.split(/[\s`'",;:()[\]|]+/).map((t) => standardIdOf(t.replace(/[.!?]+$/, ''))));
|
|
18
|
+
return MEDIA_STANDARD_IDS.some((id) => tokens.has(id));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const fold = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
|
|
22
|
+
|
|
23
|
+
// Vocabulário estreito de propósito: "imagem" sozinha (upload, avatar) não é mídia gerada.
|
|
24
|
+
const GENERATED_MEDIA_RE = new RegExp([
|
|
25
|
+
String.raw`\b(gera[rd]?[ao]?s?|geracao|generat\w*)\b[^.\n]{0,40}\b(imagem|imagens|images?|videos?|ilustrac\w*|thumbnails?)\b`,
|
|
26
|
+
String.raw`\b(imagem|imagens|videos?|ilustrac\w*|thumbnails?)\b[^.\n]{0,20}\bgerad[ao]s?\b`,
|
|
27
|
+
String.raw`\b(text|texto)[- ]?(to|para)[- ]?(image|imagem|video)\b`,
|
|
28
|
+
String.raw`\b(img2vid|image-gen|gpt-image|dall-?e|seedance|midjourney|stable diffusion|nano banana|sora|veo ?\d)\b`,
|
|
29
|
+
].join('|'));
|
|
30
|
+
|
|
31
|
+
const NO_MEDIA_RE = /^\s*[-*]?\s*\**midia gerada:?\**:?\s*nao\b/m;
|
|
32
|
+
|
|
33
|
+
/** Plano que fala em gerar imagem ou vídeo e não cita o standard deixaria o guardrail desarmado em silêncio. */
|
|
34
|
+
export function mediaCitationError(planText) {
|
|
35
|
+
if (typeof planText !== 'string' || featureGeneratesMedia(planText)) return null;
|
|
36
|
+
const text = fold(planText);
|
|
37
|
+
if (NO_MEDIA_RE.test(text)) return null;
|
|
38
|
+
const hit = GENERATED_MEDIA_RE.exec(text);
|
|
39
|
+
if (!hit) return null;
|
|
40
|
+
return `o plano fala em mídia gerada ("${hit[0].trim()}") sem citar o standard: cite ${MEDIA_STANDARD_IDS.map((id) => `\`${id}\``).join(', ')} `
|
|
41
|
+
+ 'conforme o caso (é o que arma as provas de mídia do Gate 3), ou escreva a linha "Mídia gerada: não" se nada é gerado por IA';
|
|
42
|
+
}
|