mustflow 2.99.2 → 2.103.10
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/dist/cli/commands/run.js +11 -0
- package/dist/cli/commands/skill.js +76 -2
- package/dist/cli/i18n/en.js +2 -0
- package/dist/cli/i18n/es.js +2 -0
- package/dist/cli/i18n/fr.js +2 -0
- package/dist/cli/i18n/hi.js +2 -0
- package/dist/cli/i18n/ko.js +2 -0
- package/dist/cli/i18n/zh.js +2 -0
- package/dist/cli/lib/external-skill-import.js +455 -0
- package/dist/cli/lib/local-index/index.js +5 -1
- package/dist/cli/lib/local-index/sql.js +9 -1
- package/dist/cli/lib/run-plan.js +37 -0
- package/dist/core/change-impact.js +16 -0
- package/dist/core/code-outline.js +3 -13
- package/dist/core/config-chain.js +3 -13
- package/dist/core/dependency-graph.js +3 -13
- package/dist/core/docs-link-integrity.js +23 -4
- package/dist/core/env-contract.js +3 -13
- package/dist/core/export-diff.js +3 -3
- package/dist/core/ignored-directories.js +40 -0
- package/dist/core/public-json-contracts.js +16 -0
- package/dist/core/reference-drift.js +4 -2
- package/dist/core/related-files.js +3 -13
- package/dist/core/repo-merge-conflict-scan.js +3 -9
- package/dist/core/route-outline.js +3 -13
- package/dist/core/script-pack-suggestions.js +23 -12
- package/dist/core/secret-risk-scan.js +3 -13
- package/dist/core/skill-route-resolution.js +74 -6
- package/package.json +2 -2
- package/schemas/README.md +3 -0
- package/schemas/link-integrity-report.schema.json +1 -0
- package/schemas/reference-drift-report.schema.json +1 -0
- package/schemas/skill-import-report.schema.json +97 -0
- package/templates/default/i18n.toml +52 -10
- package/templates/default/locales/en/.mustflow/skills/INDEX.md +22 -2
- package/templates/default/locales/en/.mustflow/skills/ai-generated-code-hardening/SKILL.md +30 -7
- package/templates/default/locales/en/.mustflow/skills/api-request-performance-review/SKILL.md +12 -6
- package/templates/default/locales/en/.mustflow/skills/c-code-change/SKILL.md +371 -0
- package/templates/default/locales/en/.mustflow/skills/clarifying-question-gate/SKILL.md +53 -14
- package/templates/default/locales/en/.mustflow/skills/completion-evidence-gate/SKILL.md +26 -3
- package/templates/default/locales/en/.mustflow/skills/css-code-change/SKILL.md +74 -24
- package/templates/default/locales/en/.mustflow/skills/docs-prose-review/SKILL.md +36 -10
- package/templates/default/locales/en/.mustflow/skills/github-contribution-quality-gate/SKILL.md +27 -3
- package/templates/default/locales/en/.mustflow/skills/hot-path-performance-review/SKILL.md +20 -15
- package/templates/default/locales/en/.mustflow/skills/html-code-change/SKILL.md +37 -21
- package/templates/default/locales/en/.mustflow/skills/next-action-menu/SKILL.md +22 -7
- package/templates/default/locales/en/.mustflow/skills/quadratic-scan-review/SKILL.md +21 -19
- package/templates/default/locales/en/.mustflow/skills/react-code-change/SKILL.md +324 -0
- package/templates/default/locales/en/.mustflow/skills/routes.toml +24 -0
- package/templates/default/locales/en/.mustflow/skills/shell-code-change/SKILL.md +279 -0
- package/templates/default/locales/en/.mustflow/skills/structured-config-change/SKILL.md +170 -0
- package/templates/default/locales/en/.mustflow/skills/vertical-slice-tdd/SKILL.md +22 -8
- package/templates/default/manifest.toml +29 -1
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { existsSync, renameSync, rmSync } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { ensureFileTargetInsideWithoutSymlinks, writeJsonFileInsideWithoutSymlinks, writeUtf8FileInsideWithoutSymlinks, } from '../../core/safe-filesystem.js';
|
|
5
|
+
const EXTERNAL_SKILL_ROOT = '.mustflow/external-skills';
|
|
6
|
+
const PROVENANCE_FILE = 'mustflow-skill-source.json';
|
|
7
|
+
const DEFAULT_GITHUB_REF = 'HEAD';
|
|
8
|
+
const MAX_IMPORTED_FILES = 40;
|
|
9
|
+
const MAX_TOTAL_BYTES = 512 * 1024;
|
|
10
|
+
const MAX_FILE_BYTES = 256 * 1024;
|
|
11
|
+
const ALLOWED_SUPPORT_DIRECTORIES = new Set(['assets', 'references', 'scripts']);
|
|
12
|
+
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
13
|
+
const GITHUB_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/u;
|
|
14
|
+
function normalizeRef(ref) {
|
|
15
|
+
return ref?.trim() || DEFAULT_GITHUB_REF;
|
|
16
|
+
}
|
|
17
|
+
function toPosixPath(value) {
|
|
18
|
+
return value.replace(/\\/gu, '/').replace(/^\/+/u, '').replace(/\/+$/u, '');
|
|
19
|
+
}
|
|
20
|
+
function encodeGitHubPath(value) {
|
|
21
|
+
return toPosixPath(value)
|
|
22
|
+
.split('/')
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
.map((segment) => encodeURIComponent(segment))
|
|
25
|
+
.join('/');
|
|
26
|
+
}
|
|
27
|
+
function validateGitHubName(value, label) {
|
|
28
|
+
if (!value || !GITHUB_NAME_PATTERN.test(value) || value === '.' || value === '..') {
|
|
29
|
+
throw new Error(`Invalid GitHub ${label}: ${value}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function validateRelativeImportPath(value) {
|
|
33
|
+
const normalized = toPosixPath(value);
|
|
34
|
+
if (!normalized || normalized.includes('\0')) {
|
|
35
|
+
throw new Error(`Invalid external skill path: ${value}`);
|
|
36
|
+
}
|
|
37
|
+
const segments = normalized.split('/');
|
|
38
|
+
if (segments.some((segment) => segment === '.' || segment === '..' || segment.length === 0)) {
|
|
39
|
+
throw new Error(`External skill path must not contain traversal segments: ${value}`);
|
|
40
|
+
}
|
|
41
|
+
return normalized;
|
|
42
|
+
}
|
|
43
|
+
function skillDirectoryPathForBlobPath(blobPath) {
|
|
44
|
+
const normalized = validateRelativeImportPath(blobPath);
|
|
45
|
+
if (!normalized.endsWith('SKILL.md')) {
|
|
46
|
+
throw new Error('Blob and raw GitHub URLs must point to a SKILL.md file.');
|
|
47
|
+
}
|
|
48
|
+
return normalized.split('/').slice(0, -1).join('/');
|
|
49
|
+
}
|
|
50
|
+
function parseGitHubUrl(inputUrl, refOverride) {
|
|
51
|
+
let url;
|
|
52
|
+
try {
|
|
53
|
+
url = new URL(inputUrl);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw new Error(`Unsupported external skill URL: ${inputUrl}`);
|
|
57
|
+
}
|
|
58
|
+
if (url.protocol !== 'https:') {
|
|
59
|
+
throw new Error('External skill imports require an HTTPS GitHub URL.');
|
|
60
|
+
}
|
|
61
|
+
const pathParts = url.pathname.split('/').filter(Boolean);
|
|
62
|
+
if (url.hostname === 'github.com') {
|
|
63
|
+
const [owner, repo, mode, ref, ...rest] = pathParts;
|
|
64
|
+
const ownerName = owner ?? '';
|
|
65
|
+
const repoName = repo ?? '';
|
|
66
|
+
validateGitHubName(ownerName, 'owner');
|
|
67
|
+
validateGitHubName(repoName, 'repo');
|
|
68
|
+
if (!mode) {
|
|
69
|
+
const selectedRef = normalizeRef(refOverride);
|
|
70
|
+
return {
|
|
71
|
+
host: 'github.com',
|
|
72
|
+
owner: ownerName,
|
|
73
|
+
repo: repoName,
|
|
74
|
+
ref: selectedRef,
|
|
75
|
+
skillPath: '',
|
|
76
|
+
sourceUrl: `https://github.com/${ownerName}/${repoName}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if ((mode === 'tree' || mode === 'blob') && ref) {
|
|
80
|
+
const selectedRef = normalizeRef(refOverride ?? ref);
|
|
81
|
+
const sourcePath = rest.join('/');
|
|
82
|
+
const skillPath = mode === 'blob' ? skillDirectoryPathForBlobPath(sourcePath) : validateRelativeImportPath(sourcePath);
|
|
83
|
+
return {
|
|
84
|
+
host: 'github.com',
|
|
85
|
+
owner: ownerName,
|
|
86
|
+
repo: repoName,
|
|
87
|
+
ref: selectedRef,
|
|
88
|
+
skillPath,
|
|
89
|
+
sourceUrl: `https://github.com/${ownerName}/${repoName}/${mode}/${ref}/${sourcePath}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (url.hostname === 'raw.githubusercontent.com') {
|
|
94
|
+
const [owner, repo, ref, ...rest] = pathParts;
|
|
95
|
+
const ownerName = owner ?? '';
|
|
96
|
+
const repoName = repo ?? '';
|
|
97
|
+
validateGitHubName(ownerName, 'owner');
|
|
98
|
+
validateGitHubName(repoName, 'repo');
|
|
99
|
+
if (!ref) {
|
|
100
|
+
throw new Error('Raw GitHub URLs must include a ref.');
|
|
101
|
+
}
|
|
102
|
+
const rawPath = rest.join('/');
|
|
103
|
+
const selectedRef = normalizeRef(refOverride ?? ref);
|
|
104
|
+
return {
|
|
105
|
+
host: 'raw.githubusercontent.com',
|
|
106
|
+
owner: ownerName,
|
|
107
|
+
repo: repoName,
|
|
108
|
+
ref: selectedRef,
|
|
109
|
+
skillPath: skillDirectoryPathForBlobPath(rawPath),
|
|
110
|
+
sourceUrl: `https://raw.githubusercontent.com/${ownerName}/${repoName}/${ref}/${rawPath}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
throw new Error('Only github.com and raw.githubusercontent.com skill URLs are supported.');
|
|
114
|
+
}
|
|
115
|
+
function externalSkillSourceFromParsed(inputUrl, parsed) {
|
|
116
|
+
return {
|
|
117
|
+
input_url: inputUrl,
|
|
118
|
+
host: parsed.host,
|
|
119
|
+
owner: parsed.owner,
|
|
120
|
+
repo: parsed.repo,
|
|
121
|
+
ref: parsed.ref,
|
|
122
|
+
skill_path: parsed.skillPath,
|
|
123
|
+
source_url: parsed.sourceUrl,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function sanitizeSkillName(value) {
|
|
127
|
+
return value
|
|
128
|
+
.toLowerCase()
|
|
129
|
+
.replace(/[^a-z0-9]+/gu, '-')
|
|
130
|
+
.replace(/^-+|-+$/gu, '')
|
|
131
|
+
.replace(/-{2,}/gu, '-');
|
|
132
|
+
}
|
|
133
|
+
function readFrontmatterScalar(content, key) {
|
|
134
|
+
if (!content.startsWith('---')) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
const firstLineEnd = content.indexOf('\n');
|
|
138
|
+
const end = firstLineEnd >= 0 ? content.indexOf('\n---', firstLineEnd + 1) : -1;
|
|
139
|
+
if (firstLineEnd < 0 || end < 0) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
for (const line of content.slice(firstLineEnd + 1, end).split(/\r?\n/u)) {
|
|
143
|
+
const match = /^([a-zA-Z0-9_]+):\s*(.*)$/u.exec(line);
|
|
144
|
+
if (match?.[1] === key) {
|
|
145
|
+
return match[2].trim().replace(/^["']|["']$/gu, '') || null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
function targetNameForSkill(files, parsed, nameOverride) {
|
|
151
|
+
const candidate = nameOverride?.trim()
|
|
152
|
+
?? readFrontmatterScalar(files.find((file) => file.relativePath === 'SKILL.md')?.content ?? '', 'name')
|
|
153
|
+
?? parsed.skillPath.split('/').filter(Boolean).pop()
|
|
154
|
+
?? parsed.repo;
|
|
155
|
+
const sanitized = sanitizeSkillName(candidate);
|
|
156
|
+
if (!SLUG_PATTERN.test(sanitized)) {
|
|
157
|
+
throw new Error(`External skill name cannot be converted to a safe slug: ${candidate}`);
|
|
158
|
+
}
|
|
159
|
+
return sanitized;
|
|
160
|
+
}
|
|
161
|
+
function kindForRelativePath(relativePath) {
|
|
162
|
+
if (relativePath === 'SKILL.md') {
|
|
163
|
+
return 'skill';
|
|
164
|
+
}
|
|
165
|
+
const [topLevel] = relativePath.split('/');
|
|
166
|
+
if (topLevel === 'scripts') {
|
|
167
|
+
return 'script';
|
|
168
|
+
}
|
|
169
|
+
if (topLevel === 'assets') {
|
|
170
|
+
return 'asset';
|
|
171
|
+
}
|
|
172
|
+
return 'reference';
|
|
173
|
+
}
|
|
174
|
+
function validateImportRelativeFilePath(relativePath) {
|
|
175
|
+
const normalized = validateRelativeImportPath(relativePath);
|
|
176
|
+
const [topLevel] = normalized.split('/');
|
|
177
|
+
if (normalized === 'SKILL.md') {
|
|
178
|
+
return normalized;
|
|
179
|
+
}
|
|
180
|
+
if (!ALLOWED_SUPPORT_DIRECTORIES.has(topLevel)) {
|
|
181
|
+
throw new Error(`External skill import rejected unsupported file path: ${relativePath}`);
|
|
182
|
+
}
|
|
183
|
+
return normalized;
|
|
184
|
+
}
|
|
185
|
+
function normalizeTextContent(content) {
|
|
186
|
+
return content.replace(/\r\n?/gu, '\n');
|
|
187
|
+
}
|
|
188
|
+
function readFrontmatterParts(content) {
|
|
189
|
+
if (!content.startsWith('---')) {
|
|
190
|
+
return {
|
|
191
|
+
name: null,
|
|
192
|
+
description: null,
|
|
193
|
+
body: content,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const firstLineEnd = content.indexOf('\n');
|
|
197
|
+
const end = firstLineEnd >= 0 ? content.indexOf('\n---', firstLineEnd + 1) : -1;
|
|
198
|
+
if (firstLineEnd < 0 || end < 0) {
|
|
199
|
+
return {
|
|
200
|
+
name: null,
|
|
201
|
+
description: null,
|
|
202
|
+
body: content,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const frontmatter = content.slice(firstLineEnd + 1, end).split(/\r?\n/u);
|
|
206
|
+
const bodyStart = content.indexOf('\n', end + 1);
|
|
207
|
+
return {
|
|
208
|
+
name: readFrontmatterScalar(content, 'name'),
|
|
209
|
+
description: readFrontmatterScalar(content, 'description'),
|
|
210
|
+
body: bodyStart >= 0 ? content.slice(bodyStart + 1) : '',
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function renderYamlScalar(key, value) {
|
|
214
|
+
return `${key}: ${JSON.stringify(value)}`;
|
|
215
|
+
}
|
|
216
|
+
function sanitizeExternalSkillMarkdown(content) {
|
|
217
|
+
const frontmatter = readFrontmatterParts(content);
|
|
218
|
+
const lines = [
|
|
219
|
+
'---',
|
|
220
|
+
...(frontmatter.name ? [renderYamlScalar('name', frontmatter.name)] : []),
|
|
221
|
+
...(frontmatter.description ? [renderYamlScalar('description', frontmatter.description)] : []),
|
|
222
|
+
'external_authority: untrusted',
|
|
223
|
+
'---',
|
|
224
|
+
frontmatter.body,
|
|
225
|
+
];
|
|
226
|
+
return lines.join('\n');
|
|
227
|
+
}
|
|
228
|
+
function normalizeImportedSkillFiles(files) {
|
|
229
|
+
return files.map((file) => ({
|
|
230
|
+
...file,
|
|
231
|
+
content: file.relativePath === 'SKILL.md' ? sanitizeExternalSkillMarkdown(file.content) : file.content,
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
function hashContent(content) {
|
|
235
|
+
return `sha256:${createHash('sha256').update(content).digest('hex')}`;
|
|
236
|
+
}
|
|
237
|
+
function isGitHubNotFoundError(error) {
|
|
238
|
+
return error instanceof Error && /\(404\)/u.test(error.message);
|
|
239
|
+
}
|
|
240
|
+
async function readResponseText(response, url) {
|
|
241
|
+
if (!response.ok) {
|
|
242
|
+
throw new Error(`GitHub request failed (${response.status}) for ${url}`);
|
|
243
|
+
}
|
|
244
|
+
const text = await response.text();
|
|
245
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_FILE_BYTES) {
|
|
246
|
+
throw new Error(`External skill file exceeds ${MAX_FILE_BYTES} bytes: ${url}`);
|
|
247
|
+
}
|
|
248
|
+
return normalizeTextContent(text);
|
|
249
|
+
}
|
|
250
|
+
async function fetchJson(fetchImpl, url) {
|
|
251
|
+
const response = await fetchImpl(url, {
|
|
252
|
+
headers: {
|
|
253
|
+
accept: 'application/vnd.github+json',
|
|
254
|
+
'user-agent': 'mustflow-external-skill-import',
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
if (!response.ok) {
|
|
258
|
+
throw new Error(`GitHub request failed (${response.status}) for ${url}`);
|
|
259
|
+
}
|
|
260
|
+
return response.json();
|
|
261
|
+
}
|
|
262
|
+
function apiContentsUrl(parsed, relativePath) {
|
|
263
|
+
const fullPath = [parsed.skillPath, relativePath].filter(Boolean).join('/');
|
|
264
|
+
const encodedPath = encodeGitHubPath(fullPath);
|
|
265
|
+
const pathSuffix = encodedPath ? `/${encodedPath}` : '';
|
|
266
|
+
return `https://api.github.com/repos/${parsed.owner}/${parsed.repo}/contents${pathSuffix}?ref=${encodeURIComponent(parsed.ref)}`;
|
|
267
|
+
}
|
|
268
|
+
function decodeGitHubFileContent(entry) {
|
|
269
|
+
if (entry.encoding !== 'base64' || typeof entry.content !== 'string') {
|
|
270
|
+
throw new Error(`GitHub content API response did not include base64 content for ${entry.path}`);
|
|
271
|
+
}
|
|
272
|
+
return normalizeTextContent(Buffer.from(entry.content.replace(/\s+/gu, ''), 'base64').toString('utf8'));
|
|
273
|
+
}
|
|
274
|
+
function assertGitHubContentEntry(value) {
|
|
275
|
+
if (!value || typeof value !== 'object') {
|
|
276
|
+
throw new Error('Unexpected GitHub contents API response.');
|
|
277
|
+
}
|
|
278
|
+
const record = value;
|
|
279
|
+
if (typeof record.type !== 'string' || typeof record.name !== 'string' || typeof record.path !== 'string') {
|
|
280
|
+
throw new Error('Unexpected GitHub contents API entry shape.');
|
|
281
|
+
}
|
|
282
|
+
return record;
|
|
283
|
+
}
|
|
284
|
+
async function loadGitHubFile(fetchImpl, parsed, relativePath) {
|
|
285
|
+
const normalizedRelativePath = validateImportRelativeFilePath(relativePath);
|
|
286
|
+
const apiUrl = apiContentsUrl(parsed, normalizedRelativePath);
|
|
287
|
+
const entry = assertGitHubContentEntry(await fetchJson(fetchImpl, apiUrl));
|
|
288
|
+
if (entry.type !== 'file') {
|
|
289
|
+
throw new Error(`Expected a file in GitHub contents API response: ${normalizedRelativePath}`);
|
|
290
|
+
}
|
|
291
|
+
const content = entry.download_url
|
|
292
|
+
? await readResponseText(await fetchImpl(entry.download_url), entry.download_url)
|
|
293
|
+
: decodeGitHubFileContent(entry);
|
|
294
|
+
return {
|
|
295
|
+
relativePath: normalizedRelativePath,
|
|
296
|
+
kind: kindForRelativePath(normalizedRelativePath),
|
|
297
|
+
content,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
async function listGitHubDirectory(fetchImpl, parsed, relativePath) {
|
|
301
|
+
const apiUrl = apiContentsUrl(parsed, relativePath);
|
|
302
|
+
const value = await fetchJson(fetchImpl, apiUrl);
|
|
303
|
+
if (!Array.isArray(value)) {
|
|
304
|
+
throw new Error(`Expected a directory in GitHub contents API response: ${relativePath || '.'}`);
|
|
305
|
+
}
|
|
306
|
+
return value.map(assertGitHubContentEntry);
|
|
307
|
+
}
|
|
308
|
+
async function loadSupportDirectory(fetchImpl, parsed, directory) {
|
|
309
|
+
let entries;
|
|
310
|
+
try {
|
|
311
|
+
entries = await listGitHubDirectory(fetchImpl, parsed, directory);
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
if (isGitHubNotFoundError(error)) {
|
|
315
|
+
return [];
|
|
316
|
+
}
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
const files = [];
|
|
320
|
+
const pending = entries
|
|
321
|
+
.filter((entry) => entry.type === 'file')
|
|
322
|
+
.map((entry) => `${directory}/${entry.name}`)
|
|
323
|
+
.sort((left, right) => left.localeCompare(right));
|
|
324
|
+
for (const relativePath of pending) {
|
|
325
|
+
files.push(await loadGitHubFile(fetchImpl, parsed, relativePath));
|
|
326
|
+
}
|
|
327
|
+
return files;
|
|
328
|
+
}
|
|
329
|
+
async function loadExternalSkillFiles(fetchImpl, parsed) {
|
|
330
|
+
const files = [
|
|
331
|
+
await loadGitHubFile(fetchImpl, parsed, 'SKILL.md'),
|
|
332
|
+
...(await loadSupportDirectory(fetchImpl, parsed, 'assets')),
|
|
333
|
+
...(await loadSupportDirectory(fetchImpl, parsed, 'references')),
|
|
334
|
+
...(await loadSupportDirectory(fetchImpl, parsed, 'scripts')),
|
|
335
|
+
].sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
336
|
+
if (files.length > MAX_IMPORTED_FILES) {
|
|
337
|
+
throw new Error(`External skill import exceeds ${MAX_IMPORTED_FILES} files.`);
|
|
338
|
+
}
|
|
339
|
+
const totalBytes = files.reduce((total, file) => total + Buffer.byteLength(file.content, 'utf8'), 0);
|
|
340
|
+
if (totalBytes > MAX_TOTAL_BYTES) {
|
|
341
|
+
throw new Error(`External skill import exceeds ${MAX_TOTAL_BYTES} bytes.`);
|
|
342
|
+
}
|
|
343
|
+
return files;
|
|
344
|
+
}
|
|
345
|
+
function fileReports(files) {
|
|
346
|
+
return files.map((file) => ({
|
|
347
|
+
relative_path: file.relativePath,
|
|
348
|
+
kind: file.kind,
|
|
349
|
+
bytes: Buffer.byteLength(file.content, 'utf8'),
|
|
350
|
+
sha256: hashContent(file.content),
|
|
351
|
+
}));
|
|
352
|
+
}
|
|
353
|
+
function createTarget(skillName) {
|
|
354
|
+
const skillDir = `${EXTERNAL_SKILL_ROOT}/${skillName}`;
|
|
355
|
+
return {
|
|
356
|
+
root: EXTERNAL_SKILL_ROOT,
|
|
357
|
+
skill_name: skillName,
|
|
358
|
+
skill_dir: skillDir,
|
|
359
|
+
provenance_path: `${skillDir}/${PROVENANCE_FILE}`,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
function writeImportedSkillFiles(projectRoot, target, source, files, fileReport, warnings) {
|
|
363
|
+
const targetPath = path.join(projectRoot, ...target.skill_dir.split('/'));
|
|
364
|
+
const skillPath = path.join(targetPath, 'SKILL.md');
|
|
365
|
+
if (existsSync(targetPath)) {
|
|
366
|
+
throw new Error(`External skill already exists: ${target.skill_dir}`);
|
|
367
|
+
}
|
|
368
|
+
ensureFileTargetInsideWithoutSymlinks(projectRoot, skillPath, { allowMissingLeaf: true });
|
|
369
|
+
const tempSkillDir = `${EXTERNAL_SKILL_ROOT}/.${target.skill_name}.tmp-${process.pid}-${Date.now()}`;
|
|
370
|
+
const tempTarget = {
|
|
371
|
+
...target,
|
|
372
|
+
skill_dir: tempSkillDir,
|
|
373
|
+
provenance_path: `${tempSkillDir}/${PROVENANCE_FILE}`,
|
|
374
|
+
};
|
|
375
|
+
const tempPath = path.join(projectRoot, ...tempTarget.skill_dir.split('/'));
|
|
376
|
+
const tempSkillPath = path.join(tempPath, 'SKILL.md');
|
|
377
|
+
ensureFileTargetInsideWithoutSymlinks(projectRoot, tempSkillPath, { allowMissingLeaf: true });
|
|
378
|
+
try {
|
|
379
|
+
for (const file of files) {
|
|
380
|
+
writeUtf8FileInsideWithoutSymlinks(projectRoot, path.join(projectRoot, ...tempTarget.skill_dir.split('/'), ...file.relativePath.split('/')), file.content);
|
|
381
|
+
}
|
|
382
|
+
writeJsonFileInsideWithoutSymlinks(projectRoot, path.join(projectRoot, ...tempTarget.provenance_path.split('/')), {
|
|
383
|
+
schema_version: '1',
|
|
384
|
+
kind: 'external_skill_source',
|
|
385
|
+
source,
|
|
386
|
+
files: fileReport,
|
|
387
|
+
warnings,
|
|
388
|
+
});
|
|
389
|
+
renameSync(tempPath, targetPath);
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
rmSync(tempPath, { recursive: true, force: true });
|
|
393
|
+
throw error;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
function rejectionReport(mode, issue) {
|
|
397
|
+
return {
|
|
398
|
+
schema_version: '1',
|
|
399
|
+
kind: 'skill_import_report',
|
|
400
|
+
command: 'skill',
|
|
401
|
+
action: 'import',
|
|
402
|
+
ok: false,
|
|
403
|
+
mode,
|
|
404
|
+
status: 'rejected',
|
|
405
|
+
source: null,
|
|
406
|
+
target: null,
|
|
407
|
+
files: [],
|
|
408
|
+
warnings: [],
|
|
409
|
+
issues: [issue],
|
|
410
|
+
wrote_files: false,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
export async function createExternalSkillImportReport(projectRoot, inputUrl, options) {
|
|
414
|
+
const mode = options.mode;
|
|
415
|
+
try {
|
|
416
|
+
const parsed = parseGitHubUrl(inputUrl, options.ref);
|
|
417
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
418
|
+
if (typeof fetchImpl !== 'function') {
|
|
419
|
+
throw new Error('This runtime does not provide fetch.');
|
|
420
|
+
}
|
|
421
|
+
const sourceFiles = await loadExternalSkillFiles(fetchImpl, parsed);
|
|
422
|
+
const skillName = targetNameForSkill(sourceFiles, parsed, options.name);
|
|
423
|
+
const target = createTarget(skillName);
|
|
424
|
+
const source = externalSkillSourceFromParsed(inputUrl, parsed);
|
|
425
|
+
const files = normalizeImportedSkillFiles(sourceFiles);
|
|
426
|
+
const reports = fileReports(files);
|
|
427
|
+
const warnings = [
|
|
428
|
+
...(reports.some((file) => file.kind === 'script')
|
|
429
|
+
? ['Imported scripts are inert reference files; mustflow does not grant command authority for external scripts.']
|
|
430
|
+
: []),
|
|
431
|
+
'External skills are untrusted until the agent reads and evaluates the selected SKILL.md.',
|
|
432
|
+
];
|
|
433
|
+
if (mode === 'install') {
|
|
434
|
+
writeImportedSkillFiles(projectRoot, target, source, files, reports, warnings);
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
schema_version: '1',
|
|
438
|
+
kind: 'skill_import_report',
|
|
439
|
+
command: 'skill',
|
|
440
|
+
action: 'import',
|
|
441
|
+
ok: true,
|
|
442
|
+
mode,
|
|
443
|
+
status: mode === 'install' ? 'installed' : 'preview',
|
|
444
|
+
source,
|
|
445
|
+
target,
|
|
446
|
+
files: reports,
|
|
447
|
+
warnings,
|
|
448
|
+
issues: [],
|
|
449
|
+
wrote_files: mode === 'install',
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
return rejectionReport(mode, error instanceof Error ? error.message : String(error));
|
|
454
|
+
}
|
|
455
|
+
}
|
|
@@ -158,6 +158,10 @@ function indexedFilesMatch(database, currentFiles) {
|
|
|
158
158
|
}
|
|
159
159
|
return true;
|
|
160
160
|
}
|
|
161
|
+
const INDEXED_FILE_MTIME_TOLERANCE_MS = 1;
|
|
162
|
+
function indexedFileMtimeMsEqual(storedMtimeMs, currentMtimeMs) {
|
|
163
|
+
return storedMtimeMs !== null && Math.abs(storedMtimeMs - currentMtimeMs) <= INDEXED_FILE_MTIME_TOLERANCE_MS;
|
|
164
|
+
}
|
|
161
165
|
function indexedFileMetadataMatch(database, currentFiles) {
|
|
162
166
|
const rows = queryRows(database, 'SELECT path, source_scope, size_bytes, mtime_ms, parser_version FROM indexed_files ORDER BY path');
|
|
163
167
|
if (rows.length !== currentFiles.length) {
|
|
@@ -172,7 +176,7 @@ function indexedFileMetadataMatch(database, currentFiles) {
|
|
|
172
176
|
}
|
|
173
177
|
if (normalizeIndexedFileSourceScope(toSearchString(row.source_scope)) !== current.sourceScope ||
|
|
174
178
|
toNullableNumber(row.size_bytes) !== current.sizeBytes ||
|
|
175
|
-
toNullableNumber(row.mtime_ms)
|
|
179
|
+
!indexedFileMtimeMsEqual(toNullableNumber(row.mtime_ms), current.mtimeMs) ||
|
|
176
180
|
toSearchString(row.parser_version) !== LOCAL_INDEX_PARSER_VERSION) {
|
|
177
181
|
return false;
|
|
178
182
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
|
-
|
|
2
|
+
let sqlJsPromise = null;
|
|
3
|
+
async function initializeSqlJs() {
|
|
3
4
|
const require = createRequire(import.meta.url);
|
|
4
5
|
const wasmPath = require.resolve('sql.js/dist/sql-wasm.wasm');
|
|
5
6
|
const sqlJsModule = (await import('sql.js'));
|
|
@@ -13,3 +14,10 @@ export async function loadSqlJs() {
|
|
|
13
14
|
},
|
|
14
15
|
});
|
|
15
16
|
}
|
|
17
|
+
export async function loadSqlJs() {
|
|
18
|
+
sqlJsPromise ??= initializeSqlJs().catch((error) => {
|
|
19
|
+
sqlJsPromise = null;
|
|
20
|
+
throw error;
|
|
21
|
+
});
|
|
22
|
+
return sqlJsPromise;
|
|
23
|
+
}
|
package/dist/cli/lib/run-plan.js
CHANGED
|
@@ -7,6 +7,7 @@ import { evaluateCommandIntentEligibility, } from '../../core/command-intent-eli
|
|
|
7
7
|
import { inspectActiveRunLocks, } from '../../core/active-run-locks.js';
|
|
8
8
|
import { isRecord, readPositiveInteger, readString, readStringArray, } from '../../core/config-loading.js';
|
|
9
9
|
import { DEFAULT_COMMAND_MAX_OUTPUT_BYTES, COMMAND_OUTPUT_LIMIT_SCOPE, } from '../../core/command-output-limits.js';
|
|
10
|
+
import { checkRepoApprovalGate } from '../../core/repo-approval-gate.js';
|
|
10
11
|
import { normalizeSuccessExitCodes } from '../../core/success-exit-codes.js';
|
|
11
12
|
import { normalizeSafeTestTargetPath, TEST_TARGET_PATH_ERROR } from '../../core/test-target-paths.js';
|
|
12
13
|
import { evaluateCommandPreconditions, } from '../../core/command-preconditions.js';
|
|
@@ -124,6 +125,38 @@ function readRunIntentMetadata(contract, intent) {
|
|
|
124
125
|
relatedOneshotChecks: readStringArray(intent, 'related_oneshot_checks') ?? [],
|
|
125
126
|
};
|
|
126
127
|
}
|
|
128
|
+
function createApprovalBlock(projectRoot, metadata) {
|
|
129
|
+
const actionTypes = [];
|
|
130
|
+
if (metadata.network === true) {
|
|
131
|
+
actionTypes.push('network_access');
|
|
132
|
+
}
|
|
133
|
+
if (metadata.destructive === true) {
|
|
134
|
+
actionTypes.push('destructive_command');
|
|
135
|
+
}
|
|
136
|
+
if (actionTypes.length === 0) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const approvalReport = checkRepoApprovalGate(projectRoot, actionTypes);
|
|
140
|
+
if (approvalReport.issues.length > 0) {
|
|
141
|
+
return {
|
|
142
|
+
reasonCode: 'approval_policy_unreadable',
|
|
143
|
+
detail: `Could not evaluate ${approvalReport.input.policy_path}: ${approvalReport.issues.join(' ')}`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const requiredActions = approvalReport.decisions
|
|
147
|
+
.filter((decision) => decision.approval_required)
|
|
148
|
+
.map((decision) => decision.action_type);
|
|
149
|
+
if (requiredActions.length === 0) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const reasonCode = requiredActions.includes('destructive_command')
|
|
153
|
+
? 'destructive_requires_approval'
|
|
154
|
+
: 'network_requires_approval';
|
|
155
|
+
return {
|
|
156
|
+
reasonCode,
|
|
157
|
+
detail: `Action ${requiredActions.map((action) => JSON.stringify(action)).join(', ')} requires explicit approval before mf run can execute this intent.`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
127
160
|
function createBlockedRunPlan(contract, intentName, intent, eligibility, reasonCode, detail, preconditions = []) {
|
|
128
161
|
const metadata = intent ? readRunIntentMetadata(contract, intent) : null;
|
|
129
162
|
return {
|
|
@@ -177,6 +210,10 @@ export function createRunPlan(projectRoot, contract, intentName, options = {}) {
|
|
|
177
210
|
return createBlockedRunPlan(contract, intentName, rawIntent, eligibility, eligibility.code, eligibility.detail, preconditions);
|
|
178
211
|
}
|
|
179
212
|
const metadata = readRunIntentMetadata(contract, rawIntent);
|
|
213
|
+
const approvalBlock = createApprovalBlock(projectRoot, metadata);
|
|
214
|
+
if (approvalBlock) {
|
|
215
|
+
return createBlockedRunPlan(contract, intentName, rawIntent, eligibility, approvalBlock.reasonCode, approvalBlock.detail, preconditions);
|
|
216
|
+
}
|
|
180
217
|
const maxOutputBytesLimitDetail = getCommandMaxOutputBytesLimitDetail(contract, rawIntent);
|
|
181
218
|
if (maxOutputBytesLimitDetail) {
|
|
182
219
|
return createBlockedRunPlan(contract, intentName, rawIntent, eligibility, 'max_output_bytes_exceeds_limit', maxOutputBytesLimitDetail, preconditions);
|
|
@@ -195,6 +195,13 @@ function addDependencyImpacts(root, changedFiles, impacts, policy, findings, iss
|
|
|
195
195
|
issues.push(`dependency-graph: ${issue}`);
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
|
+
for (const dependencyFinding of dependencyReport.findings) {
|
|
199
|
+
const mappedFinding = mapDependencyGraphTruncationFinding(dependencyFinding);
|
|
200
|
+
if (mappedFinding === null) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
findings.push(mappedFinding);
|
|
204
|
+
}
|
|
198
205
|
const changedPathSet = new Set(sourcePaths);
|
|
199
206
|
for (const edge of dependencyReport.edges) {
|
|
200
207
|
if (!changedPathSet.has(edge.target_path) || changedPathSet.has(edge.source_path)) {
|
|
@@ -209,6 +216,15 @@ function addDependencyImpacts(root, changedFiles, impacts, policy, findings, iss
|
|
|
209
216
|
}, policy, findings, issues);
|
|
210
217
|
}
|
|
211
218
|
}
|
|
219
|
+
function mapDependencyGraphTruncationFinding(finding) {
|
|
220
|
+
if (finding.code === 'dependency_graph_max_files_exceeded') {
|
|
221
|
+
return makeFinding('change_impact_max_files_exceeded', 'high', finding.path, `Dependency graph input was truncated while computing change impact: ${finding.message}`);
|
|
222
|
+
}
|
|
223
|
+
if (finding.code === 'dependency_graph_max_nodes_exceeded' || finding.code === 'dependency_graph_max_edges_exceeded') {
|
|
224
|
+
return makeFinding('change_impact_max_impacts_exceeded', 'high', finding.path, `Dependency graph impact expansion was truncated while computing change impact: ${finding.message}`);
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
212
228
|
function createScriptHints(changedFiles) {
|
|
213
229
|
const sourcePaths = changedFiles.filter((file) => file.surface === 'source').map((file) => file.path);
|
|
214
230
|
const selectorRelevantFiles = changedFiles.filter((file) => ['source', 'test', 'schema', 'config', 'package', 'template', 'workflow', 'unknown'].includes(file.surface));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { existsSync, lstatSync, readdirSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { DEFAULT_IGNORED_DIRECTORIES, isIgnoredDirectoryPath } from './ignored-directories.js';
|
|
4
5
|
import { ensureInside, ensureInsideWithoutSymlinks, readFileInsideWithoutSymlinks } from './safe-filesystem.js';
|
|
5
6
|
import { listSourceAnchorFiles, parseSourceAnchorsInContent, sourceAnchorTextContainsSecretLike, splitSourceAnchorList, } from './source-anchors.js';
|
|
6
7
|
export const CODE_PACK_ID = 'code';
|
|
@@ -19,17 +20,7 @@ const SVELTE_EXTENSIONS = ['.svelte'];
|
|
|
19
20
|
const GO_EXTENSIONS = ['.go'];
|
|
20
21
|
const RUST_EXTENSIONS = ['.rs'];
|
|
21
22
|
const PYTHON_EXTENSIONS = ['.py'];
|
|
22
|
-
const IGNORED_DIRECTORIES =
|
|
23
|
-
'.git',
|
|
24
|
-
'.mustflow/cache',
|
|
25
|
-
'.mustflow/state',
|
|
26
|
-
'node_modules',
|
|
27
|
-
'dist',
|
|
28
|
-
'build',
|
|
29
|
-
'coverage',
|
|
30
|
-
'.next',
|
|
31
|
-
'.turbo',
|
|
32
|
-
];
|
|
23
|
+
const IGNORED_DIRECTORIES = DEFAULT_IGNORED_DIRECTORIES;
|
|
33
24
|
const ERROR_OUTLINE_CODES = new Set([
|
|
34
25
|
'code_outline_path_outside_root',
|
|
35
26
|
'code_outline_unreadable_path',
|
|
@@ -97,8 +88,7 @@ export function languageForPath(filePath) {
|
|
|
97
88
|
return languageAdapterForPath(filePath)?.languageForPath(filePath) ?? null;
|
|
98
89
|
}
|
|
99
90
|
function isIgnoredDirectory(relativePath) {
|
|
100
|
-
|
|
101
|
-
return IGNORED_DIRECTORIES.some((directory) => normalized === directory || normalized.startsWith(`${directory}/`));
|
|
91
|
+
return isIgnoredDirectoryPath(normalizeRelativePath(relativePath), IGNORED_DIRECTORIES);
|
|
102
92
|
}
|
|
103
93
|
function makeOutlineFinding(code, severity, pathValue, message) {
|
|
104
94
|
return { code, severity, path: pathValue, message };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { existsSync, lstatSync, readdirSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { DEFAULT_IGNORED_DIRECTORIES, isIgnoredDirectoryPath } from './ignored-directories.js';
|
|
4
5
|
import { ensureInside, ensureInsideWithoutSymlinks, readFileInsideWithoutSymlinks } from './safe-filesystem.js';
|
|
5
6
|
export const CONFIG_CHAIN_PACK_ID = 'repo';
|
|
6
7
|
export const CONFIG_CHAIN_SCRIPT_ID = 'config-chain';
|
|
@@ -37,17 +38,7 @@ const CONFIG_FILE_NAMES = [
|
|
|
37
38
|
'.mustflow/config/commands.toml',
|
|
38
39
|
'.mustflow/config/mustflow.toml',
|
|
39
40
|
];
|
|
40
|
-
const IGNORED_DIRECTORIES =
|
|
41
|
-
'.git',
|
|
42
|
-
'.mustflow/cache',
|
|
43
|
-
'.mustflow/state',
|
|
44
|
-
'node_modules',
|
|
45
|
-
'dist',
|
|
46
|
-
'build',
|
|
47
|
-
'coverage',
|
|
48
|
-
'.next',
|
|
49
|
-
'.turbo',
|
|
50
|
-
];
|
|
41
|
+
const IGNORED_DIRECTORIES = DEFAULT_IGNORED_DIRECTORIES;
|
|
51
42
|
const ERROR_CODES = new Set([
|
|
52
43
|
'config_chain_path_outside_root',
|
|
53
44
|
'config_chain_unreadable_path',
|
|
@@ -133,8 +124,7 @@ function isConfigFile(relativePath) {
|
|
|
133
124
|
/^tsconfig(?:\..*)?\.json$/u.test(name));
|
|
134
125
|
}
|
|
135
126
|
function isIgnoredDirectory(relativePath) {
|
|
136
|
-
|
|
137
|
-
return IGNORED_DIRECTORIES.some((directory) => normalized === directory || normalized.startsWith(`${directory}/`));
|
|
127
|
+
return isIgnoredDirectoryPath(normalizeRelativePath(relativePath), IGNORED_DIRECTORIES);
|
|
138
128
|
}
|
|
139
129
|
function normalizeTargetPath(projectRoot, targetPath) {
|
|
140
130
|
const absolutePath = path.resolve(process.cwd(), targetPath);
|