@polymorphism-tech/morph-spec 8.29.0 → 8.32.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 +11 -1
- package/README.md +13 -2
- package/bin/morph-spec.js +67 -1
- package/bin/validate.js +379 -379
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +4 -1
- package/framework/MORPH.md +4 -3
- package/framework/agents.json +6 -6
- package/framework/commands/morph-status.md +1 -1
- package/framework/docs/CLI.md +68 -45
- package/framework/evals/rubrics/code-quality.md +17 -0
- package/framework/evals/rubrics/condensed.md +3 -1
- package/framework/hooks/claude-code/statusline.sh +11 -11
- package/framework/hooks/git/commit-msg/conventional-commits.sh +33 -33
- package/framework/hooks/git/pre-commit/agents.sh +25 -25
- package/framework/hooks/git/pre-commit/orchestrator.sh +64 -64
- package/framework/hooks/git/pre-commit/specs.sh +50 -50
- package/framework/hooks/git/pre-push/run-tests.sh +44 -44
- package/framework/hooks/shared/phase-utils.js +2 -0
- package/framework/hooks/shared/stdin-reader.js +26 -26
- package/framework/mcp/registry.json +39 -4
- package/framework/rubrics/backend.json +2 -2
- package/framework/rubrics/default.json +2 -2
- package/framework/rubrics/frontend.json +2 -2
- package/framework/schemas/tasks.schema.json +25 -0
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +14 -4
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
- package/framework/skills/core/morph-implement/SKILL.md +12 -2
- package/framework/skills/core/morph-plan/SKILL.md +45 -3
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +12 -4
- package/framework/skills/core/morph-stack-scan/SKILL.md +13 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/ai-agents/agent-session.md +160 -160
- package/framework/standards/ai-agents/batching.md +193 -193
- package/framework/standards/ai-agents/context-providers.md +188 -188
- package/framework/standards/ai-agents/mcp-server.md +250 -250
- package/framework/standards/ai-agents/mcp-tools.md +197 -197
- package/framework/standards/ai-agents/middleware-patterns.md +207 -207
- package/framework/standards/ai-agents/modalities-image-gen.md +173 -173
- package/framework/standards/ai-agents/modalities-speech-to-text.md +171 -171
- package/framework/standards/ai-agents/modalities-text-to-speech.md +181 -181
- package/framework/standards/ai-agents/modalities-vision.md +201 -201
- package/framework/standards/ai-agents/multi-agent-patterns.md +155 -155
- package/framework/standards/ai-agents/production.md +231 -231
- package/framework/standards/ai-agents/prompts-in-db.md +214 -214
- package/framework/standards/ai-agents/providers/model-registry.md +158 -158
- package/framework/standards/ai-agents/service-tier-flex.md +135 -135
- package/framework/standards/ai-agents/structured-output.md +249 -249
- package/framework/standards/ai-agents/sweet-spot.md +109 -109
- package/framework/templates/.idea/morph-templates.xml +92 -0
- package/framework/templates/.vscode/morph-templates.code-snippets +186 -0
- package/framework/templates/IDE-SNIPPETS.md +262 -262
- package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
- package/framework/templates/code/dotnet/ai-agents/AgentBootstrap.cs.template +39 -39
- package/framework/templates/code/dotnet/ai-agents/EmbeddingService.cs.template +68 -68
- package/framework/templates/code/dotnet/ai-agents/ModelRegistry.cs.template +117 -117
- package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +95 -95
- package/framework/templates/code/dotnet/ai-agents/ResilientToolMiddleware.cs.template +61 -61
- package/framework/templates/code/dotnet/ai-agents/VectorSearchTool.cs.template +107 -107
- package/framework/templates/code/dotnet/ai-agents/model-registry.json.template +58 -58
- package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +281 -281
- package/framework/templates/code/dotnet/database/migration.cs +83 -83
- package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +21 -21
- package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
- package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
- package/framework/templates/code/dotnet/feature/validator.cs.hbs +15 -15
- package/framework/templates/code/sql/ai-agents/agent-prompts-migration.sql.template +58 -58
- package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
- package/framework/templates/code/typescript/contracts.ts +167 -167
- package/framework/templates/context/ARCHITECTURE.md +20 -0
- package/framework/templates/docs/clarifications.md +253 -253
- package/framework/templates/docs/schema-analysis.md +119 -119
- package/framework/templates/docs/ui-components.md +52 -52
- package/framework/templates/docs/ui-design-system.md +76 -76
- package/framework/templates/docs/ui-flows.md +167 -167
- package/framework/templates/docs/ui-mockups.md +98 -98
- package/framework/templates/feature/decisions.md +122 -122
- package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +43 -43
- package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
- package/framework/templates/frontend/nextjs/env.mjs.hbs +32 -32
- package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +56 -56
- package/framework/templates/frontend/nextjs/page.tsx.hbs +22 -22
- package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
- package/framework/templates/frontend/nextjs/use-feature.ts.hbs +54 -54
- package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
- package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
- package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
- package/framework/templates/infrastructure/github/README.md +56 -56
- package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
- package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
- package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
- package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
- package/framework/templates/integrations/asaas-client.cs +387 -387
- package/framework/templates/integrations/asaas-webhook.cs +351 -351
- package/framework/templates/meta-prompts/fusion/fusion-agent.md +76 -76
- package/framework/templates/meta-prompts/fusion/fusion-aggregator.md +100 -100
- package/framework/templates/meta-prompts/validators/pre-commit-validator.md +95 -95
- package/framework/templates/project/validate.js +120 -120
- package/framework/templates/state.template.json +17 -17
- package/framework/templates/ui/design-system.css +226 -226
- package/package.json +1 -1
- package/src/commands/dashboard/dashboard.js +53 -53
- package/src/commands/graph/affected.js +89 -0
- package/src/commands/graph/build.js +94 -0
- package/src/commands/graph/explain.js +96 -0
- package/src/commands/graph/status.js +93 -0
- package/src/commands/index.js +13 -13
- package/src/commands/project/branch.js +170 -0
- package/src/commands/project/checkpoint.js +209 -209
- package/src/commands/project/doctor.js +148 -9
- package/src/commands/project/fleet.js +10 -3
- package/src/commands/project/worktree.js +73 -50
- package/src/commands/state/finish.js +41 -26
- package/src/commands/state/list.js +79 -6
- package/src/core/paths/output-schema.js +7 -0
- package/src/core/state/lifecycle-audit.js +127 -0
- package/src/core/state/phase-state-machine.js +197 -197
- package/src/core/templates/template-renderer.js +477 -477
- package/src/dashboard/app/components/DetailPanel.jsx +11 -11
- package/src/dashboard/app/components/FlowGraph.jsx +17 -17
- package/src/dashboard/app/components/SearchFilter.jsx +22 -22
- package/src/dashboard/app/hooks/useDetailPanel.js +15 -15
- package/src/dashboard/app/main.jsx +13 -13
- package/src/dashboard/app/pages/AgentMap.jsx +370 -370
- package/src/dashboard/app/pages/HookInspector.jsx +418 -418
- package/src/dashboard/app/pages/PhasePipeline.jsx +408 -408
- package/src/dashboard/app/pages/RulesMap.jsx +273 -273
- package/src/dashboard/app/pages/SkillsLibrary.jsx +366 -366
- package/src/dashboard/app/pages/Standards.jsx +332 -332
- package/src/dashboard/app/styles/index.css +47 -47
- package/src/dashboard/index.html +12 -12
- package/src/dashboard/package.json +24 -24
- package/src/dashboard/scanner/phases-scanner.js +15 -15
- package/src/dashboard/scanner/rubrics-scanner.js +12 -12
- package/src/dashboard/scanner/rules-scanner.js +37 -37
- package/src/dashboard/scanner/skills-scanner.js +43 -43
- package/src/dashboard/scanner/squads-scanner.js +13 -13
- package/src/dashboard/vite-plugin-morph-data.js +17 -17
- package/src/dashboard/vite.config.js +24 -24
- package/src/lib/detectors/design-system-detector.js +188 -188
- package/src/lib/detectors/index.js +217 -217
- package/src/lib/generators/architecture-scanner.js +45 -4
- package/src/lib/generators/design-system-generator.js +508 -508
- package/src/lib/generators/settings-generator.js +209 -209
- package/src/lib/git/branch-classifier.js +177 -0
- package/src/lib/git/gh.js +30 -0
- package/src/lib/git/pending-prs.js +89 -0
- package/src/lib/git/pr-index.js +70 -0
- package/src/lib/graph/god-nodes.js +75 -0
- package/src/lib/graph/graph-health.js +61 -0
- package/src/lib/graph/graph-loader.js +86 -0
- package/src/lib/graph/graphify-runtime.js +100 -0
- package/src/lib/graph/graphifyignore.js +86 -0
- package/src/lib/graph/sanitize.js +280 -0
- package/src/lib/hooks/hook-executor.js +167 -167
- package/src/lib/installers/mcp-installer.js +719 -719
- package/src/lib/tasks/task-classifier.js +30 -30
- package/src/lib/tasks/task-parser.js +209 -209
- package/src/lib/tasks/tasks-json-parser.js +26 -0
- package/src/lib/validators/checkpoint-persistence.js +39 -39
- package/src/lib/validators/css/css-validator.js +340 -340
- package/src/lib/validators/reuse/index.js +10 -0
- package/src/lib/validators/reuse/reuse-validator.js +324 -0
- package/src/lib/validators/reuse/symbol-scanner.js +144 -0
- package/src/lib/validators/shared/emit-validator-dispatch.js +14 -14
- package/src/lib/validators/shared/issue-counter.js +14 -14
- package/src/lib/validators/validation-runner.js +40 -1
- package/src/lib/worktree/fleet-scanner.js +51 -21
- package/src/lib/worktree/ignored-triage.js +197 -0
- package/src/lib/worktree/worktree-audit.js +200 -35
- package/src/scripts/setup-infra.js +60 -1
- package/src/utils/agent-id-mapper.js +11 -11
- package/src/utils/claude-settings-manager.js +300 -300
- package/src/utils/file-copier.js +40 -0
- package/src/utils/skills-installer.js +187 -187
|
@@ -1,209 +1,209 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Settings Generator
|
|
3
|
-
*
|
|
4
|
-
* Generates .claude/settings.local.json for projects based on detected
|
|
5
|
-
* stack, MCPs, and morph-spec configuration. Uses _morph_managed marker
|
|
6
|
-
* to track managed entries for safe reset.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { join } from 'path';
|
|
10
|
-
import { existsSync } from 'fs';
|
|
11
|
-
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
12
|
-
|
|
13
|
-
const MORPH_MANAGED_KEY = '_morph_managed';
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Generate and merge settings for a project
|
|
17
|
-
* @param {string} targetPath - Project root directory
|
|
18
|
-
* @param {Object} detectedConfig - Output from detectClaudeConfig()
|
|
19
|
-
* @param {Object} morphConfig - morph-spec config (from .morph/config/config.json)
|
|
20
|
-
* @returns {Promise<Object>} Generated settings summary
|
|
21
|
-
*/
|
|
22
|
-
export async function generateSettings(targetPath, detectedConfig, morphConfig = {}) {
|
|
23
|
-
const claudeDir = join(targetPath, '.claude');
|
|
24
|
-
const settingsPath = join(claudeDir, 'settings.local.json');
|
|
25
|
-
|
|
26
|
-
// Read existing settings
|
|
27
|
-
const existing = await readSettingsSafe(settingsPath);
|
|
28
|
-
|
|
29
|
-
// Build stack-based permissions
|
|
30
|
-
const stack = morphConfig?.project?.architecture || 'unknown';
|
|
31
|
-
const permissions = buildPermissions(stack, detectedConfig);
|
|
32
|
-
|
|
33
|
-
// Build generated settings
|
|
34
|
-
const generated = {};
|
|
35
|
-
|
|
36
|
-
if (permissions.length > 0) {
|
|
37
|
-
generated.permissions = permissions;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Track what we manage
|
|
41
|
-
const managed = {
|
|
42
|
-
permissions_added: permissions,
|
|
43
|
-
mcps_detected: detectedConfig.mcpServers.map(s => s.name),
|
|
44
|
-
plugins_detected: detectedConfig.plugins.map(p => p.name),
|
|
45
|
-
generated_at: new Date().toISOString()
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
// Merge without overwriting user config
|
|
49
|
-
const merged = mergeSettings(existing, generated);
|
|
50
|
-
merged[MORPH_MANAGED_KEY] = managed;
|
|
51
|
-
|
|
52
|
-
// Ensure directory exists
|
|
53
|
-
if (!existsSync(claudeDir)) {
|
|
54
|
-
await mkdir(claudeDir, { recursive: true });
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
|
|
58
|
-
|
|
59
|
-
return {
|
|
60
|
-
settingsPath,
|
|
61
|
-
permissionsAdded: permissions.length,
|
|
62
|
-
managed
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Build permission rules based on detected stack
|
|
68
|
-
* @param {string} stack - Detected project stack
|
|
69
|
-
* @param {Object} detectedConfig - Detected Claude config
|
|
70
|
-
* @returns {Array<string>} Permission patterns
|
|
71
|
-
*/
|
|
72
|
-
export function buildPermissions(stack, detectedConfig = {}) {
|
|
73
|
-
const permissions = [];
|
|
74
|
-
|
|
75
|
-
// Stack-based permissions
|
|
76
|
-
const stackPermissions = {
|
|
77
|
-
nodejs: ['Bash(node:*)', 'Bash(npm:*)', 'Bash(npx:*)'],
|
|
78
|
-
typescript: ['Bash(node:*)', 'Bash(npm:*)', 'Bash(npx:*)', 'Bash(tsc:*)'],
|
|
79
|
-
nextjs: ['Bash(node:*)', 'Bash(npm:*)', 'Bash(npx:*)'],
|
|
80
|
-
react: ['Bash(node:*)', 'Bash(npm:*)', 'Bash(npx:*)'],
|
|
81
|
-
vue: ['Bash(node:*)', 'Bash(npm:*)', 'Bash(npx:*)'],
|
|
82
|
-
dotnet: ['Bash(dotnet:*)', 'Bash(nuget:*)'],
|
|
83
|
-
python: ['Bash(python:*)', 'Bash(pip:*)', 'Bash(pytest:*)'],
|
|
84
|
-
go: ['Bash(go:*)']
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
if (stackPermissions[stack]) {
|
|
88
|
-
permissions.push(...stackPermissions[stack]);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// morph-spec CLI always needs node/npx
|
|
92
|
-
if (!permissions.includes('Bash(npx:*)')) {
|
|
93
|
-
permissions.push('Bash(npx:*)');
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return permissions;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Deep merge settings without overwriting user entries
|
|
101
|
-
* @param {Object} existing - Current settings.local.json
|
|
102
|
-
* @param {Object} generated - Generated settings to merge
|
|
103
|
-
* @returns {Object} Merged settings
|
|
104
|
-
*/
|
|
105
|
-
export function mergeSettings(existing, generated) {
|
|
106
|
-
const result = { ...existing };
|
|
107
|
-
|
|
108
|
-
for (const [key, value] of Object.entries(generated)) {
|
|
109
|
-
if (key === MORPH_MANAGED_KEY) continue;
|
|
110
|
-
|
|
111
|
-
if (Array.isArray(value) && Array.isArray(result[key])) {
|
|
112
|
-
// Merge arrays without duplicates
|
|
113
|
-
const set = new Set(result[key]);
|
|
114
|
-
for (const item of value) {
|
|
115
|
-
set.add(item);
|
|
116
|
-
}
|
|
117
|
-
result[key] = [...set];
|
|
118
|
-
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
119
|
-
// Recursively merge objects
|
|
120
|
-
result[key] = mergeSettings(result[key] || {}, value);
|
|
121
|
-
} else if (!(key in result)) {
|
|
122
|
-
// Only add new keys, never overwrite existing
|
|
123
|
-
result[key] = value;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return result;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Remove only morph-spec managed entries from settings
|
|
132
|
-
* @param {string} settingsPath - Path to settings.local.json
|
|
133
|
-
* @returns {Promise<Object>} Summary of what was removed
|
|
134
|
-
*/
|
|
135
|
-
export async function resetMorphSettings(settingsPath) {
|
|
136
|
-
if (!existsSync(settingsPath)) {
|
|
137
|
-
return { removed: false, reason: 'file not found' };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const settings = await readSettingsSafe(settingsPath);
|
|
141
|
-
const managed = settings[MORPH_MANAGED_KEY];
|
|
142
|
-
|
|
143
|
-
if (!managed) {
|
|
144
|
-
return { removed: false, reason: 'no _morph_managed section found' };
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Remove managed permissions
|
|
148
|
-
if (managed.permissions_added && Array.isArray(settings.permissions)) {
|
|
149
|
-
settings.permissions = settings.permissions.filter(
|
|
150
|
-
p => !managed.permissions_added.includes(p)
|
|
151
|
-
);
|
|
152
|
-
if (settings.permissions.length === 0) {
|
|
153
|
-
delete settings.permissions;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Remove the managed marker itself
|
|
158
|
-
delete settings[MORPH_MANAGED_KEY];
|
|
159
|
-
|
|
160
|
-
// Write back
|
|
161
|
-
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
162
|
-
|
|
163
|
-
return {
|
|
164
|
-
removed: true,
|
|
165
|
-
permissionsRemoved: managed.permissions_added || [],
|
|
166
|
-
previouslyGenerated: managed.generated_at
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Save integrations.json with detected config
|
|
172
|
-
* @param {string} targetPath - Project root directory
|
|
173
|
-
* @param {Object} detectedConfig - Output from detectClaudeConfig()
|
|
174
|
-
*/
|
|
175
|
-
export async function saveIntegrations(targetPath, detectedConfig) {
|
|
176
|
-
const integrationsPath = join(targetPath, '.morph', 'config', 'integrations.json');
|
|
177
|
-
const integrationsDir = join(targetPath, '.morph', 'config');
|
|
178
|
-
|
|
179
|
-
if (!existsSync(integrationsDir)) {
|
|
180
|
-
await mkdir(integrationsDir, { recursive: true });
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const integrations = {
|
|
184
|
-
version: '1.0.0',
|
|
185
|
-
detectedAt: new Date().toISOString(),
|
|
186
|
-
plugins: detectedConfig.plugins,
|
|
187
|
-
mcpServers: detectedConfig.mcpServers,
|
|
188
|
-
superpowers: detectedConfig.superpowers,
|
|
189
|
-
customSkills: detectedConfig.customSkills.length,
|
|
190
|
-
customCommands: detectedConfig.customCommands.length,
|
|
191
|
-
hooks: detectedConfig.hooks.length
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
await writeFile(integrationsPath, JSON.stringify(integrations, null, 2) + '\n', 'utf-8');
|
|
195
|
-
return integrationsPath;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// ============================================================================
|
|
199
|
-
// Helpers
|
|
200
|
-
// ============================================================================
|
|
201
|
-
|
|
202
|
-
async function readSettingsSafe(settingsPath) {
|
|
203
|
-
try {
|
|
204
|
-
if (!existsSync(settingsPath)) return {};
|
|
205
|
-
return JSON.parse(await readFile(settingsPath, 'utf-8'));
|
|
206
|
-
} catch {
|
|
207
|
-
return {};
|
|
208
|
-
}
|
|
209
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Settings Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates .claude/settings.local.json for projects based on detected
|
|
5
|
+
* stack, MCPs, and morph-spec configuration. Uses _morph_managed marker
|
|
6
|
+
* to track managed entries for safe reset.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { existsSync } from 'fs';
|
|
11
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
12
|
+
|
|
13
|
+
const MORPH_MANAGED_KEY = '_morph_managed';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Generate and merge settings for a project
|
|
17
|
+
* @param {string} targetPath - Project root directory
|
|
18
|
+
* @param {Object} detectedConfig - Output from detectClaudeConfig()
|
|
19
|
+
* @param {Object} morphConfig - morph-spec config (from .morph/config/config.json)
|
|
20
|
+
* @returns {Promise<Object>} Generated settings summary
|
|
21
|
+
*/
|
|
22
|
+
export async function generateSettings(targetPath, detectedConfig, morphConfig = {}) {
|
|
23
|
+
const claudeDir = join(targetPath, '.claude');
|
|
24
|
+
const settingsPath = join(claudeDir, 'settings.local.json');
|
|
25
|
+
|
|
26
|
+
// Read existing settings
|
|
27
|
+
const existing = await readSettingsSafe(settingsPath);
|
|
28
|
+
|
|
29
|
+
// Build stack-based permissions
|
|
30
|
+
const stack = morphConfig?.project?.architecture || 'unknown';
|
|
31
|
+
const permissions = buildPermissions(stack, detectedConfig);
|
|
32
|
+
|
|
33
|
+
// Build generated settings
|
|
34
|
+
const generated = {};
|
|
35
|
+
|
|
36
|
+
if (permissions.length > 0) {
|
|
37
|
+
generated.permissions = permissions;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Track what we manage
|
|
41
|
+
const managed = {
|
|
42
|
+
permissions_added: permissions,
|
|
43
|
+
mcps_detected: detectedConfig.mcpServers.map(s => s.name),
|
|
44
|
+
plugins_detected: detectedConfig.plugins.map(p => p.name),
|
|
45
|
+
generated_at: new Date().toISOString()
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Merge without overwriting user config
|
|
49
|
+
const merged = mergeSettings(existing, generated);
|
|
50
|
+
merged[MORPH_MANAGED_KEY] = managed;
|
|
51
|
+
|
|
52
|
+
// Ensure directory exists
|
|
53
|
+
if (!existsSync(claudeDir)) {
|
|
54
|
+
await mkdir(claudeDir, { recursive: true });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
settingsPath,
|
|
61
|
+
permissionsAdded: permissions.length,
|
|
62
|
+
managed
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build permission rules based on detected stack
|
|
68
|
+
* @param {string} stack - Detected project stack
|
|
69
|
+
* @param {Object} detectedConfig - Detected Claude config
|
|
70
|
+
* @returns {Array<string>} Permission patterns
|
|
71
|
+
*/
|
|
72
|
+
export function buildPermissions(stack, detectedConfig = {}) {
|
|
73
|
+
const permissions = [];
|
|
74
|
+
|
|
75
|
+
// Stack-based permissions
|
|
76
|
+
const stackPermissions = {
|
|
77
|
+
nodejs: ['Bash(node:*)', 'Bash(npm:*)', 'Bash(npx:*)'],
|
|
78
|
+
typescript: ['Bash(node:*)', 'Bash(npm:*)', 'Bash(npx:*)', 'Bash(tsc:*)'],
|
|
79
|
+
nextjs: ['Bash(node:*)', 'Bash(npm:*)', 'Bash(npx:*)'],
|
|
80
|
+
react: ['Bash(node:*)', 'Bash(npm:*)', 'Bash(npx:*)'],
|
|
81
|
+
vue: ['Bash(node:*)', 'Bash(npm:*)', 'Bash(npx:*)'],
|
|
82
|
+
dotnet: ['Bash(dotnet:*)', 'Bash(nuget:*)'],
|
|
83
|
+
python: ['Bash(python:*)', 'Bash(pip:*)', 'Bash(pytest:*)'],
|
|
84
|
+
go: ['Bash(go:*)']
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
if (stackPermissions[stack]) {
|
|
88
|
+
permissions.push(...stackPermissions[stack]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// morph-spec CLI always needs node/npx
|
|
92
|
+
if (!permissions.includes('Bash(npx:*)')) {
|
|
93
|
+
permissions.push('Bash(npx:*)');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return permissions;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Deep merge settings without overwriting user entries
|
|
101
|
+
* @param {Object} existing - Current settings.local.json
|
|
102
|
+
* @param {Object} generated - Generated settings to merge
|
|
103
|
+
* @returns {Object} Merged settings
|
|
104
|
+
*/
|
|
105
|
+
export function mergeSettings(existing, generated) {
|
|
106
|
+
const result = { ...existing };
|
|
107
|
+
|
|
108
|
+
for (const [key, value] of Object.entries(generated)) {
|
|
109
|
+
if (key === MORPH_MANAGED_KEY) continue;
|
|
110
|
+
|
|
111
|
+
if (Array.isArray(value) && Array.isArray(result[key])) {
|
|
112
|
+
// Merge arrays without duplicates
|
|
113
|
+
const set = new Set(result[key]);
|
|
114
|
+
for (const item of value) {
|
|
115
|
+
set.add(item);
|
|
116
|
+
}
|
|
117
|
+
result[key] = [...set];
|
|
118
|
+
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
119
|
+
// Recursively merge objects
|
|
120
|
+
result[key] = mergeSettings(result[key] || {}, value);
|
|
121
|
+
} else if (!(key in result)) {
|
|
122
|
+
// Only add new keys, never overwrite existing
|
|
123
|
+
result[key] = value;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Remove only morph-spec managed entries from settings
|
|
132
|
+
* @param {string} settingsPath - Path to settings.local.json
|
|
133
|
+
* @returns {Promise<Object>} Summary of what was removed
|
|
134
|
+
*/
|
|
135
|
+
export async function resetMorphSettings(settingsPath) {
|
|
136
|
+
if (!existsSync(settingsPath)) {
|
|
137
|
+
return { removed: false, reason: 'file not found' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const settings = await readSettingsSafe(settingsPath);
|
|
141
|
+
const managed = settings[MORPH_MANAGED_KEY];
|
|
142
|
+
|
|
143
|
+
if (!managed) {
|
|
144
|
+
return { removed: false, reason: 'no _morph_managed section found' };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Remove managed permissions
|
|
148
|
+
if (managed.permissions_added && Array.isArray(settings.permissions)) {
|
|
149
|
+
settings.permissions = settings.permissions.filter(
|
|
150
|
+
p => !managed.permissions_added.includes(p)
|
|
151
|
+
);
|
|
152
|
+
if (settings.permissions.length === 0) {
|
|
153
|
+
delete settings.permissions;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Remove the managed marker itself
|
|
158
|
+
delete settings[MORPH_MANAGED_KEY];
|
|
159
|
+
|
|
160
|
+
// Write back
|
|
161
|
+
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
removed: true,
|
|
165
|
+
permissionsRemoved: managed.permissions_added || [],
|
|
166
|
+
previouslyGenerated: managed.generated_at
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Save integrations.json with detected config
|
|
172
|
+
* @param {string} targetPath - Project root directory
|
|
173
|
+
* @param {Object} detectedConfig - Output from detectClaudeConfig()
|
|
174
|
+
*/
|
|
175
|
+
export async function saveIntegrations(targetPath, detectedConfig) {
|
|
176
|
+
const integrationsPath = join(targetPath, '.morph', 'config', 'integrations.json');
|
|
177
|
+
const integrationsDir = join(targetPath, '.morph', 'config');
|
|
178
|
+
|
|
179
|
+
if (!existsSync(integrationsDir)) {
|
|
180
|
+
await mkdir(integrationsDir, { recursive: true });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const integrations = {
|
|
184
|
+
version: '1.0.0',
|
|
185
|
+
detectedAt: new Date().toISOString(),
|
|
186
|
+
plugins: detectedConfig.plugins,
|
|
187
|
+
mcpServers: detectedConfig.mcpServers,
|
|
188
|
+
superpowers: detectedConfig.superpowers,
|
|
189
|
+
customSkills: detectedConfig.customSkills.length,
|
|
190
|
+
customCommands: detectedConfig.customCommands.length,
|
|
191
|
+
hooks: detectedConfig.hooks.length
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
await writeFile(integrationsPath, JSON.stringify(integrations, null, 2) + '\n', 'utf-8');
|
|
195
|
+
return integrationsPath;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ============================================================================
|
|
199
|
+
// Helpers
|
|
200
|
+
// ============================================================================
|
|
201
|
+
|
|
202
|
+
async function readSettingsSafe(settingsPath) {
|
|
203
|
+
try {
|
|
204
|
+
if (!existsSync(settingsPath)) return {};
|
|
205
|
+
return JSON.parse(await readFile(settingsPath, 'utf-8'));
|
|
206
|
+
} catch {
|
|
207
|
+
return {};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* branch-classifier.js — answer "is this branch's work already in the default
|
|
3
|
+
* branch?" for a repository that squash-merges.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS: a squash-merge puts the WORK in `origin/master` while
|
|
6
|
+
* leaving none of the branch's own commits reachable from it. So
|
|
7
|
+
* `git branch --merged origin/master` does not list the branch, and
|
|
8
|
+
* `git branch -d` refuses it as unmerged. The only remaining command is
|
|
9
|
+
* `git branch -D`, which is byte-identical to the command that discards real,
|
|
10
|
+
* never-integrated work — the tool gives the user no way to tell the two apart,
|
|
11
|
+
* and after enough of these the habit becomes "just -D it", which is how work
|
|
12
|
+
* gets lost.
|
|
13
|
+
*
|
|
14
|
+
* Every branch lands in exactly one class, and only the two MERGED ones are
|
|
15
|
+
* ever deletable:
|
|
16
|
+
*
|
|
17
|
+
* merged-ancestral — git itself proves it: the branch tip is an ancestor of
|
|
18
|
+
* the default. A plain merge, or a fast-forward.
|
|
19
|
+
* merged-squash — git cannot see it, but the evidence is positive: either
|
|
20
|
+
* GitHub reports the PR as MERGED, or the branch's tree is
|
|
21
|
+
* IDENTICAL to the default's (every change it carries is
|
|
22
|
+
* already there under a different commit).
|
|
23
|
+
* open — a PR is OPEN for it. Never deleted: the PR is the whole
|
|
24
|
+
* reason the branch must survive.
|
|
25
|
+
* in-worktree — checked out by a live worktree. Never deleted.
|
|
26
|
+
* unmerged — none of the above could be PROVEN. This is the fail-safe
|
|
27
|
+
* default and covers "no `gh`", "no origin", and "genuinely
|
|
28
|
+
* unintegrated work" alike, because from here they are the
|
|
29
|
+
* same thing: not proven integrated.
|
|
30
|
+
*
|
|
31
|
+
* Nothing here mutates. `pruneBranches` in commands/project/branch.js does the
|
|
32
|
+
* deleting, and it deletes ONLY what this file classified as merged.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { execFileSync } from 'child_process';
|
|
36
|
+
|
|
37
|
+
function git(args, cwd) {
|
|
38
|
+
return execFileSync('git', args, { cwd, stdio: 'pipe' }).toString();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Local branches with their tips. Excludes nothing — the caller decides.
|
|
43
|
+
*
|
|
44
|
+
* @returns {Array<{ name: string, sha: string, current: boolean, worktreePath: string|null }>}
|
|
45
|
+
*/
|
|
46
|
+
export function listLocalBranches(cwd) {
|
|
47
|
+
try {
|
|
48
|
+
const out = git(['for-each-ref', '--format=%(refname:short)%09%(objectname)%09%(HEAD)%09%(worktreepath)', 'refs/heads/'], cwd);
|
|
49
|
+
return out.split('\n').filter(Boolean).map((line) => {
|
|
50
|
+
const [name, sha, head, worktreePath] = line.split('\t');
|
|
51
|
+
return { name, sha, current: head === '*', worktreePath: worktreePath || null };
|
|
52
|
+
});
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Is `ancestor` reachable from `descendant`? False on any git error. */
|
|
59
|
+
function isAncestor(ancestor, descendant, cwd) {
|
|
60
|
+
try {
|
|
61
|
+
git(['merge-base', '--is-ancestor', ancestor, descendant], cwd);
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Does the branch introduce NOTHING the base does not already have?
|
|
70
|
+
*
|
|
71
|
+
* `git diff --quiet <base> <branch>` compares the two TREES: an empty diff
|
|
72
|
+
* means every file the branch touched already reads exactly as the branch left
|
|
73
|
+
* it in the base. That is precisely the state a squash-merge produces, and it
|
|
74
|
+
* is a positive proof rather than an inference from history shape.
|
|
75
|
+
*
|
|
76
|
+
* Deliberately NOT `base...branch` (the three-dot form diffs against the merge
|
|
77
|
+
* base, which for a squash-merged branch still shows the branch's own changes).
|
|
78
|
+
*
|
|
79
|
+
* Fail-CLOSED: any git error reads as "introduces something", never as merged.
|
|
80
|
+
*/
|
|
81
|
+
function treeIsIdentical(base, branch, cwd) {
|
|
82
|
+
try {
|
|
83
|
+
execFileSync('git', ['diff', '--quiet', base, branch], { cwd, stdio: 'pipe' });
|
|
84
|
+
return true;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* How many commits the branch carries that the base does not. Used only for
|
|
92
|
+
* REPORTING — the classification never turns on it, because a squash-merged
|
|
93
|
+
* branch legitimately has commits ahead while carrying no unintegrated work.
|
|
94
|
+
*
|
|
95
|
+
* @returns {number|null} null when it cannot be computed
|
|
96
|
+
*/
|
|
97
|
+
export function commitsAhead(base, branch, cwd) {
|
|
98
|
+
try {
|
|
99
|
+
const out = git(['rev-list', '--count', `${base}..${branch}`], cwd).trim();
|
|
100
|
+
const n = Number(out);
|
|
101
|
+
return Number.isFinite(n) ? n : null;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve the ref to compare against: `origin/<default>` when the remote-tracking
|
|
109
|
+
* ref exists (the authority on what has actually landed), else the local branch.
|
|
110
|
+
*
|
|
111
|
+
* @returns {string|null} null when neither resolves — the caller must then
|
|
112
|
+
* classify everything as `unmerged`, because there is no baseline to prove
|
|
113
|
+
* integration against.
|
|
114
|
+
*/
|
|
115
|
+
export function resolveBaseRef(defaultBranch, cwd) {
|
|
116
|
+
if (!defaultBranch) return null;
|
|
117
|
+
for (const ref of [`refs/remotes/origin/${defaultBranch}`, `refs/heads/${defaultBranch}`]) {
|
|
118
|
+
try {
|
|
119
|
+
git(['show-ref', '--verify', '--quiet', ref], cwd);
|
|
120
|
+
return ref.replace(/^refs\/(remotes|heads)\//, '');
|
|
121
|
+
} catch { /* try the next */ }
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Classify one branch. Pure decision logic over already-gathered facts, so the
|
|
128
|
+
* table above is testable without a GitHub account.
|
|
129
|
+
*
|
|
130
|
+
* ORDER IS THE POLICY, and the two protective classes come FIRST: a branch that
|
|
131
|
+
* is checked out, or that carries an OPEN PR, is never deleted even if its tree
|
|
132
|
+
* happens to match the base — a PR being open means a human still has business
|
|
133
|
+
* with it.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} b - from listLocalBranches
|
|
136
|
+
* @param {{ baseRef: string|null, cwd: string, pr: {number:number,state:string}|null|undefined }} ctx
|
|
137
|
+
* @returns {{ name: string, kind: string, reason: string, pr: number|null, ahead: number|null, deletable: boolean }}
|
|
138
|
+
*/
|
|
139
|
+
export function classifyBranch(b, { baseRef, cwd, pr }) {
|
|
140
|
+
const prNum = pr?.number ?? null;
|
|
141
|
+
const mk = (kind, reason, deletable) => ({
|
|
142
|
+
name: b.name, kind, reason, pr: prNum, ahead: baseRef ? commitsAhead(baseRef, b.name, cwd) : null, deletable,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (b.current || b.worktreePath) {
|
|
146
|
+
return mk('in-worktree', b.current ? 'é a branch atual (HEAD)' : `está em uso pelo worktree ${b.worktreePath}`, false);
|
|
147
|
+
}
|
|
148
|
+
if (pr?.state === 'OPEN') {
|
|
149
|
+
return mk('open', `PR #${prNum} está OPEN — a branch é o que o PR aponta`, false);
|
|
150
|
+
}
|
|
151
|
+
if (!baseRef) {
|
|
152
|
+
return mk('unmerged', 'não há branch default resolvível (origin/HEAD, main ou master) para comparar', false);
|
|
153
|
+
}
|
|
154
|
+
if (isAncestor(b.name, baseRef, cwd)) {
|
|
155
|
+
return mk('merged-ancestral', `já é ancestral de ${baseRef}`, true);
|
|
156
|
+
}
|
|
157
|
+
if (pr?.state === 'MERGED') {
|
|
158
|
+
return mk('merged-squash', `PR #${prNum} está MERGED (squash-merge: os commits não entram em ${baseRef}, o trabalho sim)`, true);
|
|
159
|
+
}
|
|
160
|
+
if (treeIsIdentical(baseRef, b.name, cwd)) {
|
|
161
|
+
return mk('merged-squash', `a árvore é idêntica à de ${baseRef} — tudo que a branch muda já está lá`, true);
|
|
162
|
+
}
|
|
163
|
+
return mk('unmerged', `carrega mudanças que não estão em ${baseRef} e nenhum PR MERGED comprova o contrário`, false);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Classify every local branch except the default itself.
|
|
168
|
+
*
|
|
169
|
+
* @param {{ cwd: string, defaultBranch: string|null, prIndex: Map|null }} ctx
|
|
170
|
+
* @returns {Array} one entry per branch, in `for-each-ref` order
|
|
171
|
+
*/
|
|
172
|
+
export function classifyBranches({ cwd, defaultBranch, prIndex }) {
|
|
173
|
+
const baseRef = resolveBaseRef(defaultBranch, cwd);
|
|
174
|
+
return listLocalBranches(cwd)
|
|
175
|
+
.filter((b) => b.name !== defaultBranch)
|
|
176
|
+
.map((b) => classifyBranch(b, { baseRef, cwd, pr: prIndex?.get(b.name) ?? null }));
|
|
177
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gh.js — como o `gh` é invocado, num lugar só.
|
|
3
|
+
*
|
|
4
|
+
* Extraído de `finish.js`, onde nasceu, porque o `pr-index.js` chamava `'gh'`
|
|
5
|
+
* cru: o caminho de SUCESSO do índice era intestável, já que não havia por
|
|
6
|
+
* onde injetar um stub. Duas formas de chamar o mesmo binário também são duas
|
|
7
|
+
* formas de discordar sobre qual binário é.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve a invocação do `gh`. Por padrão, o `gh` do PATH. Sobrescrevível via
|
|
12
|
+
* MORPH_GH_BIN: uma string simples nomeia outro binário (um wrapper corporativo,
|
|
13
|
+
* `hub`, …); um array JSON é um prefixo de argv COMPLETO — por exemplo
|
|
14
|
+
* `["/path/to/node","/path/to/gh-shim.js"]` — que é como os testes injetam um
|
|
15
|
+
* stub cross-platform que o execFileSync consegue spawnar (Node ≥20 recusa
|
|
16
|
+
* rodar shims `.cmd`/`.bat` sem `shell:true`, e `shell:true` reabriria o buraco
|
|
17
|
+
* de injeção de argumento que estes caminhos evitam).
|
|
18
|
+
*
|
|
19
|
+
* @returns {{ bin: string, prefix: string[] }}
|
|
20
|
+
*/
|
|
21
|
+
export function resolveGh() {
|
|
22
|
+
const raw = process.env.MORPH_GH_BIN;
|
|
23
|
+
if (raw && raw.trim().startsWith('[')) {
|
|
24
|
+
try {
|
|
25
|
+
const arr = JSON.parse(raw);
|
|
26
|
+
if (Array.isArray(arr) && arr.length) return { bin: String(arr[0]), prefix: arr.slice(1).map(String) };
|
|
27
|
+
} catch { /* não é JSON — trata como nome de binário abaixo */ }
|
|
28
|
+
}
|
|
29
|
+
return { bin: raw && raw.trim() ? raw.trim() : 'gh', prefix: [] };
|
|
30
|
+
}
|