mustflow 2.38.0 → 2.58.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.
Files changed (94) hide show
  1. package/README.md +3 -0
  2. package/dist/cli/commands/run/executor.js +16 -0
  3. package/dist/cli/commands/run/process-tree.js +6 -3
  4. package/dist/cli/commands/verify.js +3 -1
  5. package/dist/cli/lib/git-changes.js +11 -1
  6. package/dist/cli/lib/i18n.js +1 -1
  7. package/dist/cli/lib/local-index/index.js +8 -4
  8. package/dist/cli/lib/local-index/populate.js +17 -3
  9. package/dist/cli/lib/local-index/search-read-model.js +9 -7
  10. package/dist/cli/lib/local-index/search-text.js +2 -2
  11. package/dist/cli/lib/local-index/workflow-documents.js +17 -2
  12. package/dist/cli/lib/mustflow-read.js +14 -2
  13. package/dist/cli/lib/repo-map.js +16 -3
  14. package/dist/cli/lib/templates.js +8 -7
  15. package/dist/cli/lib/validation/constants.js +1 -1
  16. package/dist/core/active-run-locks.js +78 -20
  17. package/dist/core/change-classification.js +4 -0
  18. package/dist/core/command-contract-rules.js +1 -1
  19. package/dist/core/command-contract-validation.js +1 -1
  20. package/dist/core/command-cwd.js +13 -2
  21. package/dist/core/command-effects.js +22 -4
  22. package/dist/core/command-env.js +8 -6
  23. package/dist/core/command-preconditions.js +28 -2
  24. package/dist/core/completion-verdict.js +1 -1
  25. package/dist/core/line-endings.js +8 -4
  26. package/dist/core/safe-filesystem.js +9 -1
  27. package/dist/core/source-anchor-validation.js +7 -1
  28. package/dist/core/source-anchors.js +8 -2
  29. package/dist/core/verification-scheduler.js +8 -2
  30. package/package.json +1 -1
  31. package/templates/default/common/.mustflow/config/commands.toml +39 -15
  32. package/templates/default/i18n.toml +330 -1
  33. package/templates/default/locales/en/.mustflow/skills/INDEX.md +302 -5
  34. package/templates/default/locales/en/.mustflow/skills/agent-eval-integrity-review/SKILL.md +160 -0
  35. package/templates/default/locales/en/.mustflow/skills/agent-execution-control-review/SKILL.md +163 -0
  36. package/templates/default/locales/en/.mustflow/skills/ai-generated-code-hardening/SKILL.md +49 -13
  37. package/templates/default/locales/en/.mustflow/skills/api-access-control-review/SKILL.md +298 -0
  38. package/templates/default/locales/en/.mustflow/skills/api-misuse-resistance-review/SKILL.md +297 -0
  39. package/templates/default/locales/en/.mustflow/skills/api-request-performance-review/SKILL.md +189 -0
  40. package/templates/default/locales/en/.mustflow/skills/app-startup-performance-review/SKILL.md +309 -0
  41. package/templates/default/locales/en/.mustflow/skills/backend-log-evidence-review/SKILL.md +213 -0
  42. package/templates/default/locales/en/.mustflow/skills/business-rule-leakage-review/SKILL.md +295 -0
  43. package/templates/default/locales/en/.mustflow/skills/cache-integrity-review/SKILL.md +291 -0
  44. package/templates/default/locales/en/.mustflow/skills/change-blast-radius-review/SKILL.md +297 -0
  45. package/templates/default/locales/en/.mustflow/skills/client-bundle-pruning-review/SKILL.md +160 -0
  46. package/templates/default/locales/en/.mustflow/skills/cloud-cost-guardrail-review/SKILL.md +321 -0
  47. package/templates/default/locales/en/.mustflow/skills/concurrency-invariant-review/SKILL.md +193 -0
  48. package/templates/default/locales/en/.mustflow/skills/core-web-vitals-field-review/SKILL.md +161 -0
  49. package/templates/default/locales/en/.mustflow/skills/credit-ledger-integrity-review/SKILL.md +156 -0
  50. package/templates/default/locales/en/.mustflow/skills/database-json-modeling-review/SKILL.md +171 -0
  51. package/templates/default/locales/en/.mustflow/skills/database-lock-contention-review/SKILL.md +192 -0
  52. package/templates/default/locales/en/.mustflow/skills/database-migration-change/SKILL.md +76 -34
  53. package/templates/default/locales/en/.mustflow/skills/database-query-bottleneck-review/SKILL.md +194 -0
  54. package/templates/default/locales/en/.mustflow/skills/deletion-lifecycle-review/SKILL.md +171 -0
  55. package/templates/default/locales/en/.mustflow/skills/deployment-rollout-safety-review/SKILL.md +321 -0
  56. package/templates/default/locales/en/.mustflow/skills/desktop-auto-update-safety-review/SKILL.md +265 -0
  57. package/templates/default/locales/en/.mustflow/skills/desktop-background-process-stability-review/SKILL.md +318 -0
  58. package/templates/default/locales/en/.mustflow/skills/desktop-memory-footprint-review/SKILL.md +318 -0
  59. package/templates/default/locales/en/.mustflow/skills/error-message-integrity-review/SKILL.md +283 -0
  60. package/templates/default/locales/en/.mustflow/skills/failure-integrity-review/SKILL.md +193 -0
  61. package/templates/default/locales/en/.mustflow/skills/file-upload-security-review/SKILL.md +305 -0
  62. package/templates/default/locales/en/.mustflow/skills/frame-render-performance-review/SKILL.md +159 -0
  63. package/templates/default/locales/en/.mustflow/skills/frontend-accessibility-tree-review/SKILL.md +202 -0
  64. package/templates/default/locales/en/.mustflow/skills/frontend-localization-review/SKILL.md +202 -0
  65. package/templates/default/locales/en/.mustflow/skills/frontend-state-ownership-review/SKILL.md +183 -0
  66. package/templates/default/locales/en/.mustflow/skills/frontend-stress-layout-review/SKILL.md +193 -0
  67. package/templates/default/locales/en/.mustflow/skills/hot-path-performance-review/SKILL.md +159 -0
  68. package/templates/default/locales/en/.mustflow/skills/idempotency-integrity-review/SKILL.md +195 -0
  69. package/templates/default/locales/en/.mustflow/skills/image-delivery-performance-review/SKILL.md +161 -0
  70. package/templates/default/locales/en/.mustflow/skills/incident-triage-review/SKILL.md +185 -0
  71. package/templates/default/locales/en/.mustflow/skills/llm-hallucination-control-review/SKILL.md +155 -0
  72. package/templates/default/locales/en/.mustflow/skills/llm-response-latency-review/SKILL.md +155 -0
  73. package/templates/default/locales/en/.mustflow/skills/llm-service-ux-review/SKILL.md +2 -0
  74. package/templates/default/locales/en/.mustflow/skills/llm-token-cost-control-review/SKILL.md +155 -0
  75. package/templates/default/locales/en/.mustflow/skills/low-end-device-support-review/SKILL.md +340 -0
  76. package/templates/default/locales/en/.mustflow/skills/memory-lifetime-review/SKILL.md +169 -0
  77. package/templates/default/locales/en/.mustflow/skills/mobile-energy-efficiency-review/SKILL.md +329 -0
  78. package/templates/default/locales/en/.mustflow/skills/module-boundary-review/SKILL.md +278 -0
  79. package/templates/default/locales/en/.mustflow/skills/multi-agent-work-coordination/SKILL.md +1 -0
  80. package/templates/default/locales/en/.mustflow/skills/observability-debuggability-review/SKILL.md +208 -0
  81. package/templates/default/locales/en/.mustflow/skills/payment-integrity-review/SKILL.md +155 -0
  82. package/templates/default/locales/en/.mustflow/skills/prompt-contract-quality-review/SKILL.md +158 -0
  83. package/templates/default/locales/en/.mustflow/skills/quadratic-scan-review/SKILL.md +155 -0
  84. package/templates/default/locales/en/.mustflow/skills/queue-processing-integrity-review/SKILL.md +193 -0
  85. package/templates/default/locales/en/.mustflow/skills/race-condition-review/SKILL.md +188 -0
  86. package/templates/default/locales/en/.mustflow/skills/rate-limit-integrity-review/SKILL.md +344 -0
  87. package/templates/default/locales/en/.mustflow/skills/retry-policy-integrity-review/SKILL.md +195 -0
  88. package/templates/default/locales/en/.mustflow/skills/routes.toml +330 -0
  89. package/templates/default/locales/en/.mustflow/skills/security-flow-review/SKILL.md +279 -0
  90. package/templates/default/locales/en/.mustflow/skills/testability-boundary-review/SKILL.md +295 -0
  91. package/templates/default/locales/en/.mustflow/skills/transaction-boundary-integrity-review/SKILL.md +196 -0
  92. package/templates/default/locales/en/.mustflow/skills/type-state-modeling-review/SKILL.md +179 -0
  93. package/templates/default/locales/en/.mustflow/skills/web-render-performance-review/SKILL.md +164 -0
  94. package/templates/default/manifest.toml +388 -2
package/README.md CHANGED
@@ -299,6 +299,9 @@ Automation and agents should use `--json` output or `mf api serve --stdio` JSONL
299
299
  ## Command execution policy
300
300
 
301
301
  Runnable work is declared in `.mustflow/config/commands.toml` so agents do not guess commands.
302
+ New projects start with Bun-backed `test`, `test_related`, and `test_fast` intents so agents can run
303
+ basic verification immediately after `mf init`. Replace those defaults with narrower project-specific
304
+ commands when a repository uses another runner or has a faster related-test entrypoint.
302
305
 
303
306
  `mf run` executes only commands that meet all these conditions:
304
307
 
@@ -3,7 +3,23 @@ import { BoundedOutputBuffer } from '../../../core/bounded-output.js';
3
3
  import { createPendingTimeoutTermination, forceTerminateProcessTree, getKillMethod, terminateProcessTree, } from './process-tree.js';
4
4
  import { createOutputLimitError, isOutputLimitExceededError, writeOutputLimitTerminationMarker, writeStreamChunk, writeStreamChunkPrefix, } from './output.js';
5
5
  const TERMINATION_CONFIRMATION_FALLBACK_MS = 1000;
6
+ function createEmptyOutputSnapshot(maxBytes) {
7
+ return new BoundedOutputBuffer(maxBytes).toSnapshot();
8
+ }
9
+ function createInvalidExecutableError() {
10
+ return Object.assign(new Error('Command executable must not be empty'), { code: 'EINVAL' });
11
+ }
6
12
  function runSpawnedCommandStreaming(command, cwd, env, timeoutSeconds, killAfterSeconds, maxOutputBytes, stdoutTailBytes, stderrTailBytes, reporter, streamOutput, enforceOutputLimit) {
13
+ if (command.executable.trim().length === 0) {
14
+ return Promise.resolve({
15
+ status: null,
16
+ signal: null,
17
+ error: createInvalidExecutableError(),
18
+ stdout: createEmptyOutputSnapshot(stdoutTailBytes),
19
+ stderr: createEmptyOutputSnapshot(stderrTailBytes),
20
+ termination: null,
21
+ });
22
+ }
7
23
  return new Promise((resolve) => {
8
24
  const stdout = new BoundedOutputBuffer(stdoutTailBytes);
9
25
  const stderr = new BoundedOutputBuffer(stderrTailBytes);
@@ -1,10 +1,13 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
+ function windowsTaskkillArgs(pid, signal) {
3
+ return signal === 'SIGKILL' ? ['/PID', String(pid), '/T', '/F'] : ['/PID', String(pid), '/T'];
4
+ }
2
5
  function signalProcessTree(pid, signal) {
3
6
  if (!pid || pid <= 0) {
4
7
  return;
5
8
  }
6
9
  if (process.platform === 'win32') {
7
- spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], {
10
+ spawnSync('taskkill', windowsTaskkillArgs(pid, signal), {
8
11
  stdio: 'ignore',
9
12
  windowsHide: true,
10
13
  });
@@ -35,7 +38,7 @@ function signalProcessTreeNonBlocking(pid, signal) {
35
38
  return;
36
39
  }
37
40
  if (process.platform === 'win32') {
38
- const killer = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], {
41
+ const killer = spawn('taskkill', windowsTaskkillArgs(pid, signal), {
39
42
  stdio: 'ignore',
40
43
  windowsHide: true,
41
44
  detached: true,
@@ -76,7 +79,7 @@ export function forceTerminateProcessTreeNonBlocking(pid) {
76
79
  signalProcessTreeNonBlocking(pid, 'SIGKILL');
77
80
  }
78
81
  export function getKillMethod() {
79
- return process.platform === 'win32' ? 'taskkill_process_tree_forced' : 'process_group_sigterm';
82
+ return process.platform === 'win32' ? 'taskkill_process_tree' : 'process_group_sigterm';
80
83
  }
81
84
  export function createPendingTimeoutTermination(method, forcedKillAttempted = false) {
82
85
  return {
@@ -206,10 +206,11 @@ async function runVerificationEntriesInParallelChunks(projectRoot, entries, para
206
206
  const chunk = entries.slice(index, index + parallelism);
207
207
  const batchTracker = startRunWriteBatchTracking(projectRoot, createCommandEnv(projectRoot, { policy: 'minimal', allowlist: [] }));
208
208
  const chunkResults = await Promise.all(chunk.map((entry) => runVerificationIntent(entry.intent, lang, verificationPlanId, correlationId, scheduledTestTargets.get(entry.intent) ?? [])));
209
+ const chunkResultsByIntent = new Map(chunkResults.map((result) => [result.intent, result]));
209
210
  const writeDriftByIntent = finishRunWriteBatchTracking(batchTracker, chunk.map((entry) => ({
210
211
  intentName: entry.intent,
211
212
  declaredPaths: declaredWritePathsForScheduleEntry(entry),
212
- observedPaths: observedWriteDriftPaths(chunkResults.find((result) => result.intent === entry.intent)),
213
+ observedPaths: observedWriteDriftPaths(chunkResultsByIntent.get(entry.intent)),
213
214
  })));
214
215
  results.push(...chunkResults.map((result) => applyParallelChunkWriteDrift(result, writeDriftByIntent)));
215
216
  }
@@ -493,6 +494,7 @@ function createCompletionVerdictForResults(input) {
493
494
  failedIntents: input.summary.failed,
494
495
  skippedIntents: input.summary.skipped,
495
496
  receiptCount: input.results.filter((result) => result.receipt !== null).length,
497
+ gapCount: input.report.gaps.length,
496
498
  sourceAnchorRiskCount: input.sourceAnchorRiskCount,
497
499
  scopeDiffRiskCount: input.scopeDiffRiskCount,
498
500
  repeatedFailureCount: input.repeatedFailureRisks.length,
@@ -33,7 +33,17 @@ export function readGitChangedFiles(projectRoot) {
33
33
  stderr,
34
34
  };
35
35
  }
36
- return { ok: true, files: parseGitStatusOutput(result.stdout) };
36
+ try {
37
+ return { ok: true, files: parseGitStatusOutput(result.stdout) };
38
+ }
39
+ catch (error) {
40
+ return {
41
+ ok: false,
42
+ message: error instanceof Error ? error.message : String(error),
43
+ status: result.status,
44
+ stderr: '',
45
+ };
46
+ }
37
47
  }
38
48
  export function requireGitChangedFiles(projectRoot) {
39
49
  const result = readGitChangedFiles(projectRoot);
@@ -24,7 +24,7 @@ function interpolate(template, params) {
24
24
  });
25
25
  }
26
26
  export function t(lang, key, params = {}) {
27
- return interpolate(MESSAGE_CATALOGS[lang][key] ?? enMessages[key], params);
27
+ return interpolate(MESSAGE_CATALOGS[lang][key] ?? enMessages[key] ?? key, params);
28
28
  }
29
29
  export function localeMessage(locale, key, params = {}) {
30
30
  return t(isCliLang(locale) ? locale : DEFAULT_CLI_LANG, key, params);
@@ -310,10 +310,14 @@ export async function createLocalIndex(projectRoot, options = {}) {
310
310
  }
311
311
  if (!dryRun && !reusedExisting) {
312
312
  const database = new SQL.Database();
313
- createSchema(database, capabilities);
314
- populateDatabase(database, capabilities, documents, skills, skillRoutes, commandIntents, sourceAnchors, indexedFiles, verificationEvidence, indexMode, sourceScopeHash, includeSource, new Date().toISOString());
315
- writeFileInsideWithoutSymlinks(projectRoot, databasePath, database.export());
316
- database.close();
313
+ try {
314
+ createSchema(database, capabilities);
315
+ populateDatabase(database, capabilities, documents, skills, skillRoutes, commandIntents, sourceAnchors, indexedFiles, verificationEvidence, indexMode, sourceScopeHash, includeSource, new Date().toISOString());
316
+ writeFileInsideWithoutSymlinks(projectRoot, databasePath, database.export());
317
+ }
318
+ finally {
319
+ database.close();
320
+ }
317
321
  }
318
322
  return {
319
323
  schema_version: LOCAL_INDEX_SCHEMA_VERSION,
@@ -46,11 +46,25 @@ function populatePathSurfaceReadModel(database) {
46
46
  insertPathSurfaceReasons(database, rule.id, 'drift_check', rule.surface.driftChecks);
47
47
  }
48
48
  }
49
+ function readDocumentTermsByPath(database) {
50
+ const termsByPath = new Map();
51
+ for (const row of queryRows(database, 'SELECT document_path, term FROM document_terms ORDER BY document_path, term')) {
52
+ const documentPath = toSearchString(row.document_path);
53
+ const term = toSearchString(row.term);
54
+ const terms = termsByPath.get(documentPath);
55
+ if (terms) {
56
+ terms.push(term);
57
+ }
58
+ else {
59
+ termsByPath.set(documentPath, [term]);
60
+ }
61
+ }
62
+ return termsByPath;
63
+ }
49
64
  function populateSearchTables(database, capabilities, documents, skills, skillRoutes, commandIntents, sourceAnchors) {
65
+ const documentTermsByPath = readDocumentTermsByPath(database);
50
66
  for (const document of documents) {
51
- const documentTerms = queryRows(database, 'SELECT term FROM document_terms WHERE document_path = ? ORDER BY term', [
52
- document.path,
53
- ]).map((row) => toSearchString(row.term));
67
+ const documentTerms = documentTermsByPath.get(document.path) ?? [];
54
68
  insertSearchNgrams(database, 'document', document.path, [
55
69
  document.path,
56
70
  document.type,
@@ -299,6 +299,7 @@ function collectBoundedDirectSearchDocuments(projectRoot) {
299
299
  }
300
300
  function searchLocalWorkflowFilesDirectly(projectRoot, databasePath, normalizedQuery, limit, scope) {
301
301
  const cacheLayers = readCacheLayerSets(projectRoot);
302
+ const querySnippetNgrams = buildSearchNgrams([normalizedQuery]);
302
303
  const results = [];
303
304
  if (scope === 'workflow' || scope === 'all') {
304
305
  const documents = collectBoundedDirectSearchDocuments(projectRoot);
@@ -322,7 +323,7 @@ function searchLocalWorkflowFilesDirectly(projectRoot, databasePath, normalizedQ
322
323
  title: document.title,
323
324
  document_type: document.type,
324
325
  ...workflowAuthorityForDocument(document.type),
325
- match: getMatchSnippet(fields, normalizedQuery),
326
+ match: getMatchSnippet(fields, normalizedQuery, querySnippetNgrams),
326
327
  score: scoreMatch(primaryFields, secondaryFields, normalizedQuery),
327
328
  }, cacheLayers));
328
329
  }
@@ -337,7 +338,7 @@ function searchLocalWorkflowFilesDirectly(projectRoot, databasePath, normalizedQ
337
338
  path: skill.path,
338
339
  title: skill.title,
339
340
  ...skillAuthority(),
340
- match: getMatchSnippet(fields, normalizedQuery),
341
+ match: getMatchSnippet(fields, normalizedQuery, querySnippetNgrams),
341
342
  score: scoreMatch(fields, [], normalizedQuery),
342
343
  }, cacheLayers));
343
344
  }
@@ -393,6 +394,7 @@ export async function searchLocalIndex(projectRoot, query, options = {}) {
393
394
  const stalePaths = getStalePaths(projectRoot, database);
394
395
  capabilities = readStoredSearchCapabilities(database);
395
396
  const indexedMatches = getIndexedSearchMatches(database, normalizedQuery);
397
+ const querySnippetNgrams = buildSearchNgrams([normalizedQuery]);
396
398
  if (stalePaths.length > 0) {
397
399
  throw new Error(`Local mustflow index is stale: ${stalePaths.join(', ')}. Run \`mf index\` before searching. Refresh command: mf index`);
398
400
  }
@@ -419,7 +421,7 @@ export async function searchLocalIndex(projectRoot, query, options = {}) {
419
421
  title,
420
422
  document_type: typeValue,
421
423
  ...workflowAuthorityForDocument(typeValue),
422
- match: getMatchSnippet(fields, normalizedQuery),
424
+ match: getMatchSnippet(fields, normalizedQuery, querySnippetNgrams),
423
425
  score: scoreIndexedOrTableScan(primaryFields, secondaryFields, normalizedQuery, indexedMatches, indexedMatches.documents, pathValue),
424
426
  }, cacheLayers));
425
427
  }
@@ -437,7 +439,7 @@ export async function searchLocalIndex(projectRoot, query, options = {}) {
437
439
  path: pathValue,
438
440
  title,
439
441
  ...skillAuthority(),
440
- match: getMatchSnippet(fields, normalizedQuery),
442
+ match: getMatchSnippet(fields, normalizedQuery, querySnippetNgrams),
441
443
  score: scoreIndexedOrTableScan([name, pathValue, title], [], normalizedQuery, indexedMatches, indexedMatches.skills, name),
442
444
  }, cacheLayers));
443
445
  }
@@ -468,7 +470,7 @@ export async function searchLocalIndex(projectRoot, query, options = {}) {
468
470
  route_risk: risk,
469
471
  verification_intents: verificationIntents,
470
472
  ...skillAuthority(),
471
- match: getMatchSnippet(fields, normalizedQuery),
473
+ match: getMatchSnippet(fields, normalizedQuery, querySnippetNgrams),
472
474
  score: indexedRouteMatch
473
475
  ? Math.max(scoreMatch(primaryFields, secondaryFields, normalizedQuery), 20)
474
476
  : scoreMatch(primaryFields, secondaryFields, normalizedQuery),
@@ -502,7 +504,7 @@ export async function searchLocalIndex(projectRoot, query, options = {}) {
502
504
  effect_paths: effectPaths,
503
505
  effect_modes: effectModes,
504
506
  ...commandIntentAuthority(),
505
- match: getMatchSnippet(fields, normalizedQuery),
507
+ match: getMatchSnippet(fields, normalizedQuery, querySnippetNgrams),
506
508
  score: scoreIndexedOrTableScan(primaryFields, secondaryFields, normalizedQuery, indexedMatches, indexedMatches.commandIntents, name),
507
509
  }, cacheLayers));
508
510
  }
@@ -532,7 +534,7 @@ export async function searchLocalIndex(projectRoot, query, options = {}) {
532
534
  ...sourceAnchorAuthority(),
533
535
  stale_status: toSearchString(row.status),
534
536
  stale_confidence: Number(row.confidence),
535
- match: getMatchSnippet(fields, normalizedQuery),
537
+ match: getMatchSnippet(fields, normalizedQuery, querySnippetNgrams),
536
538
  score: scoreIndexedOrTableScan(primaryFields, secondaryFields, normalizedQuery, indexedMatches, indexedMatches.sourceAnchors, id),
537
539
  }, cacheLayers));
538
540
  }
@@ -28,13 +28,13 @@ export function buildSearchNgrams(values) {
28
28
  }
29
29
  return [...grams].sort((left, right) => left.localeCompare(right));
30
30
  }
31
- export function getMatchSnippet(fields, query) {
31
+ export function getMatchSnippet(fields, query, queryNgrams = buildSearchNgrams([query])) {
32
32
  const normalized = normalizeSearchText(fields.join(' '));
33
33
  const lower = normalized.toLowerCase();
34
34
  let start = lower.indexOf(query.toLowerCase());
35
35
  let matchLength = query.length;
36
36
  if (start === -1) {
37
- const [firstGram] = buildSearchNgrams([query]).filter((gram) => lower.includes(gram));
37
+ const firstGram = queryNgrams.find((gram) => lower.includes(gram));
38
38
  if (!firstGram) {
39
39
  return truncateSearchMatchSnippet(normalized);
40
40
  }
@@ -65,12 +65,27 @@ function parseFrontmatter(content) {
65
65
  if (!content.startsWith('---')) {
66
66
  return {};
67
67
  }
68
- const end = content.indexOf('\n---', 3);
68
+ const firstLineEnd = content.indexOf('\n');
69
+ if (firstLineEnd === -1) {
70
+ return {};
71
+ }
72
+ let end = -1;
73
+ let lineStart = firstLineEnd + 1;
74
+ while (lineStart < content.length) {
75
+ const lineEnd = content.indexOf('\n', lineStart);
76
+ const nextLineStart = lineEnd === -1 ? content.length : lineEnd + 1;
77
+ const line = content.slice(lineStart, lineEnd === -1 ? content.length : lineEnd).replace(/\r$/u, '');
78
+ if (line.trim() === '---') {
79
+ end = lineStart;
80
+ break;
81
+ }
82
+ lineStart = nextLineStart;
83
+ }
69
84
  if (end === -1) {
70
85
  return {};
71
86
  }
72
87
  const result = {};
73
- const rawFrontmatter = content.slice(3, end);
88
+ const rawFrontmatter = content.slice(firstLineEnd + 1, end);
74
89
  for (const line of rawFrontmatter.split(/\r?\n/)) {
75
90
  const separatorIndex = line.indexOf(':');
76
91
  if (separatorIndex === -1) {
@@ -5,7 +5,13 @@ export const MUSTFLOW_TEXT_MAX_BYTES = 1024 * 1024;
5
5
  export const MUSTFLOW_TOML_MAX_BYTES = 256 * 1024;
6
6
  export const MUSTFLOW_JSON_MAX_BYTES = 1024 * 1024;
7
7
  export function mustflowProjectPath(projectRoot, relativePath) {
8
- return path.join(projectRoot, ...relativePath.split('/'));
8
+ const normalizedRoot = path.resolve(projectRoot);
9
+ const resolvedPath = path.resolve(normalizedRoot, ...relativePath.split('/'));
10
+ const relative = path.relative(normalizedRoot, resolvedPath);
11
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
12
+ throw new Error(`Path escapes mustflow project root: ${relativePath}`);
13
+ }
14
+ return resolvedPath;
9
15
  }
10
16
  function missingPath(error) {
11
17
  return error instanceof Error && 'code' in error && error.code === 'ENOENT';
@@ -14,7 +20,13 @@ export function readMustflowTextFile(projectRoot, relativePath, options = {}) {
14
20
  return readUtf8FileInsideWithoutSymlinks(projectRoot, mustflowProjectPath(projectRoot, relativePath), { maxBytes: options.maxBytes ?? MUSTFLOW_TEXT_MAX_BYTES });
15
21
  }
16
22
  export function readMustflowTextFileResult(projectRoot, relativePath, options = {}) {
17
- const filePath = mustflowProjectPath(projectRoot, relativePath);
23
+ let filePath;
24
+ try {
25
+ filePath = mustflowProjectPath(projectRoot, relativePath);
26
+ }
27
+ catch (error) {
28
+ return { ok: false, exists: false, error: error instanceof Error ? error.message : String(error) };
29
+ }
18
30
  if (!existsSync(filePath)) {
19
31
  return { ok: false, exists: false, error: null };
20
32
  }
@@ -193,6 +193,14 @@ const EXACT_ANCHOR_DESCRIPTIONS = new Map([
193
193
  ['.mustflow/docs/agent-workflow.md', 'Shared workflow policy for agent work.'],
194
194
  ['.mustflow/skills/INDEX.md', 'Index of available procedural skills.'],
195
195
  ]);
196
+ function spawnGitLsFiles(command, args, options) {
197
+ const result = spawnSync(command, [...args], options);
198
+ return {
199
+ status: result.status,
200
+ error: result.error,
201
+ stdout: result.stdout,
202
+ };
203
+ }
196
204
  function getStringArray(value) {
197
205
  return Array.isArray(value) ? value.filter((entry) => typeof entry === 'string') : [];
198
206
  }
@@ -250,8 +258,7 @@ function classifyGitLsFilesFailure(result) {
250
258
  return 'error';
251
259
  }
252
260
  export function discoverGitFilesForRepoMap(projectRoot, options = {}) {
253
- const spawnGit = options.spawnGit ??
254
- ((command, args, spawnOptions) => spawnSync(command, [...args], spawnOptions));
261
+ const spawnGit = options.spawnGit ?? spawnGitLsFiles;
255
262
  const result = spawnGit('git', ['ls-files', '-z'], {
256
263
  cwd: projectRoot,
257
264
  encoding: 'utf8',
@@ -413,7 +420,13 @@ function renderDirectoryAnchors(anchors) {
413
420
  return lines;
414
421
  }
415
422
  function hasGitMarker(directoryPath) {
416
- return existsSync(path.join(directoryPath, '.git'));
423
+ try {
424
+ const marker = lstatSync(path.join(directoryPath, '.git'));
425
+ return marker.isDirectory() || marker.isFile();
426
+ }
427
+ catch {
428
+ return false;
429
+ }
417
430
  }
418
431
  function isRealPathInside(parentRealPath, childRealPath) {
419
432
  const relative = path.relative(parentRealPath, childRealPath);
@@ -304,11 +304,12 @@ export function getTemplateFiles(template, locale = template.manifest.defaultLoc
304
304
  const commonPath = path.join(commonRoot, ...relativePath.split('/'));
305
305
  const localizedPath = localePath && existsSync(localePath) ? localePath : undefined;
306
306
  const fallbackLocalePath = sourceLocalePath && existsSync(sourceLocalePath) ? sourceLocalePath : undefined;
307
- const indexSourcePath = localizedPath ?? fallbackLocalePath ?? commonPath;
308
- const content = relativePath === '.mustflow/skills/INDEX.md'
309
- ? filterSkillIndexContent(readFileSync(indexSourcePath, 'utf8'), selectedSkills)
310
- : relativePath === '.mustflow/skills/routes.toml'
311
- ? filterSkillRouteMetadataContent(readFileSync(indexSourcePath, 'utf8'), selectedSkills)
307
+ const commonSourcePath = existsSync(commonPath) ? commonPath : undefined;
308
+ const selectedSourcePath = localizedPath ?? fallbackLocalePath ?? commonSourcePath;
309
+ const content = selectedSourcePath && relativePath === '.mustflow/skills/INDEX.md'
310
+ ? filterSkillIndexContent(readFileSync(selectedSourcePath, 'utf8'), selectedSkills)
311
+ : selectedSourcePath && relativePath === '.mustflow/skills/routes.toml'
312
+ ? filterSkillRouteMetadataContent(readFileSync(selectedSourcePath, 'utf8'), selectedSkills)
312
313
  : undefined;
313
314
  if (localizedPath) {
314
315
  return {
@@ -326,10 +327,10 @@ export function getTemplateFiles(template, locale = template.manifest.defaultLoc
326
327
  content,
327
328
  };
328
329
  }
329
- if (existsSync(commonPath)) {
330
+ if (commonSourcePath) {
330
331
  return {
331
332
  relativePath,
332
- sourcePath: commonPath,
333
+ sourcePath: commonSourcePath,
333
334
  sourceKind: template.manifest.localesRoot ? 'common' : 'legacy',
334
335
  content,
335
336
  };
@@ -172,7 +172,7 @@ export const ALLOWED_TRANSLATION_POLICIES = new Set([
172
172
  'update_source_mark_targets_stale',
173
173
  'machine_translate_requires_review',
174
174
  ]);
175
- export const RAW_COMMAND_FENCE_PATTERN = /```(?:sh|bash|shell|zsh|powershell|ps1|cmd)\s+[\s\S]*?```/giu;
175
+ export const RAW_COMMAND_FENCE_PATTERN = /```(?:sh|bash|shell|zsh|powershell|ps1|cmd)\s+[\s\S]*?```/iu;
176
176
  export const SKILL_COMMAND_PERMISSION_CLAIM_PATTERNS = [
177
177
  /\b(?:this\s+skill|skill\s+documents?|skills?)\s+(?:authorizes?|grants?|allows?|permits?)\s+(?:agents?\s+)?(?:to\s+)?(?:run|execute)\b/iu,
178
178
  /\b(?:agents?\s+)?(?:may|can|are\s+allowed\s+to|is\s+allowed\s+to|is\s+permitted\s+to|have\s+permission\s+to)\s+(?:run|execute)\s+(?:raw\s+)?(?:shell\s+)?commands?\b/iu,
@@ -1,7 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
+ import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { commandEffectsConflict, normalizeCommandEffects, } from './command-effects.js';
5
+ import { readUtf8FileInsideWithoutSymlinks, writeJsonFileInsideWithoutSymlinks, } from './safe-filesystem.js';
5
6
  const ACTIVE_LOCK_SCHEMA_VERSION = '1';
6
7
  const ACTIVE_LOCK_KIND = 'active_run_lock';
7
8
  const LOCK_ROOT_RELATIVE_PATH = '.mustflow/state/locks';
@@ -9,9 +10,19 @@ const LOCK_MUTEX_STALE_MS = 30_000;
9
10
  const LOCK_MUTEX_WAIT_MS = 1_000;
10
11
  const LOCK_MUTEX_SLEEP_MS = 25;
11
12
  const LOCK_MUTEX_SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
12
- const LOCK_MUTEX_RECOVERY_DIRECTORY = 'recovery';
13
+ const LOCK_MUTEX_RECOVERY_DIRECTORY = 'mutex.recovery';
14
+ const ACTIVE_LOCK_RECORD_MAX_BYTES = 256 * 1024;
15
+ const ACTIVE_LOCK_OWNER_MAX_BYTES = 16 * 1024;
13
16
  function sleep(milliseconds) {
14
- Atomics.wait(LOCK_MUTEX_SLEEP_BUFFER, 0, 0, milliseconds);
17
+ try {
18
+ Atomics.wait(LOCK_MUTEX_SLEEP_BUFFER, 0, 0, milliseconds);
19
+ }
20
+ catch {
21
+ const end = Date.now() + milliseconds;
22
+ while (Date.now() < end) {
23
+ // Fall back only for short mutex retry delays when Atomics.wait is unavailable on this runtime.
24
+ }
25
+ }
15
26
  }
16
27
  function sha256(value) {
17
28
  return createHash('sha256').update(value).digest('hex');
@@ -26,7 +37,7 @@ function activeLockMutexDirectory(projectRoot) {
26
37
  return path.join(activeLockRoot(projectRoot), 'mutex');
27
38
  }
28
39
  function activeLockMutexRecoveryDirectory(mutex) {
29
- return path.join(mutex, LOCK_MUTEX_RECOVERY_DIRECTORY);
40
+ return path.join(path.dirname(mutex), LOCK_MUTEX_RECOVERY_DIRECTORY);
30
41
  }
31
42
  function normalizeEffect(effect) {
32
43
  return {
@@ -121,9 +132,16 @@ function readActiveRecords(projectRoot) {
121
132
  return [];
122
133
  }
123
134
  const records = [];
124
- for (const name of readdirSync(directory).filter((entry) => entry.endsWith('.json')).sort()) {
135
+ const entries = readdirSync(directory, { withFileTypes: true })
136
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
137
+ .map((entry) => entry.name)
138
+ .sort();
139
+ for (const name of entries) {
125
140
  try {
126
- const record = parseRecord(JSON.parse(readFileSync(path.join(directory, name), 'utf8')));
141
+ const recordPath = path.join(directory, name);
142
+ const record = parseRecord(JSON.parse(readUtf8FileInsideWithoutSymlinks(directory, recordPath, {
143
+ maxBytes: ACTIVE_LOCK_RECORD_MAX_BYTES,
144
+ })));
127
145
  if (record) {
128
146
  records.push(record);
129
147
  }
@@ -153,9 +171,19 @@ function conflictDetail(current, active) {
153
171
  }
154
172
  function findConflicts(intentName, effects, records) {
155
173
  const conflicts = [];
174
+ const effectsByLock = new Map();
175
+ for (const effect of effects) {
176
+ const existing = effectsByLock.get(effect.lock);
177
+ if (existing) {
178
+ existing.push(effect);
179
+ }
180
+ else {
181
+ effectsByLock.set(effect.lock, [effect]);
182
+ }
183
+ }
156
184
  for (const record of records) {
157
185
  for (const activeEffect of commandEffectsFromRecord(record)) {
158
- for (const effect of effects) {
186
+ for (const effect of effectsByLock.get(activeEffect.lock) ?? []) {
159
187
  if (!commandEffectsConflict(effect, activeEffect)) {
160
188
  continue;
161
189
  }
@@ -198,7 +226,9 @@ function createRecord(projectRoot, intentName, effects, commandHash) {
198
226
  }
199
227
  function readMutexOwner(ownerPath) {
200
228
  try {
201
- const owner = JSON.parse(readFileSync(ownerPath, 'utf8'));
229
+ const owner = JSON.parse(readUtf8FileInsideWithoutSymlinks(path.dirname(ownerPath), ownerPath, {
230
+ maxBytes: ACTIVE_LOCK_OWNER_MAX_BYTES,
231
+ }));
202
232
  if (typeof owner.started_at !== 'string' || typeof owner.token !== 'string') {
203
233
  return null;
204
234
  }
@@ -249,6 +279,24 @@ function beginMutexRecovery(mutex) {
249
279
  rmSync(recoveryPath, { recursive: true, force: true });
250
280
  };
251
281
  }
282
+ function moveMutexAsideForRecovery(mutex, owner) {
283
+ const tokenSource = owner ? `${owner.pid}:${owner.startedAt}:${owner.token}` : `${process.pid}:${Date.now()}:${process.hrtime.bigint()}`;
284
+ const stalePath = path.join(path.dirname(mutex), `mutex.stale-${sha256(tokenSource)}`);
285
+ try {
286
+ rmSync(stalePath, { recursive: true, force: true });
287
+ renameSync(mutex, stalePath);
288
+ return stalePath;
289
+ }
290
+ catch {
291
+ try {
292
+ rmSync(stalePath, { recursive: true, force: true });
293
+ }
294
+ catch {
295
+ // Best-effort cleanup for a failed stale mutex move.
296
+ }
297
+ return null;
298
+ }
299
+ }
252
300
  function recoverStaleMutexWithOwner(mutex, ownerPath, staleOwner) {
253
301
  const releaseRecovery = beginMutexRecovery(mutex);
254
302
  if (!releaseRecovery) {
@@ -258,7 +306,11 @@ function recoverStaleMutexWithOwner(mutex, ownerPath, staleOwner) {
258
306
  if (!sameMutexOwner(staleOwner, readMutexOwner(ownerPath)) || !mutexOwnerIsStale(staleOwner)) {
259
307
  return false;
260
308
  }
261
- rmSync(mutex, { recursive: true, force: true });
309
+ const stalePath = moveMutexAsideForRecovery(mutex, staleOwner);
310
+ if (!stalePath) {
311
+ return false;
312
+ }
313
+ rmSync(stalePath, { recursive: true, force: true });
262
314
  return true;
263
315
  }
264
316
  finally {
@@ -275,7 +327,11 @@ function recoverStaleMutexWithoutOwner(mutex) {
275
327
  if (readMutexOwner(ownerPath) !== null) {
276
328
  return false;
277
329
  }
278
- rmSync(mutex, { recursive: true, force: true });
330
+ const stalePath = moveMutexAsideForRecovery(mutex, null);
331
+ if (!stalePath) {
332
+ return false;
333
+ }
334
+ rmSync(stalePath, { recursive: true, force: true });
279
335
  return true;
280
336
  }
281
337
  catch {
@@ -291,13 +347,13 @@ function acquireMutex(projectRoot) {
291
347
  const ownerPath = path.join(mutex, 'owner.json');
292
348
  const ownerToken = sha256(`${process.pid}:${Date.now()}:${process.hrtime.bigint()}`);
293
349
  mkdirSync(root, { recursive: true });
294
- let startedAt = Date.now();
350
+ const startedAt = Date.now();
295
351
  while (true) {
296
352
  try {
297
353
  mkdirSync(mutex);
298
354
  const ownerRecord = { pid: process.pid, started_at: new Date().toISOString(), token: ownerToken };
299
355
  try {
300
- writeFileSync(ownerPath, JSON.stringify(ownerRecord, null, 2));
356
+ writeJsonFileInsideWithoutSymlinks(root, ownerPath, ownerRecord);
301
357
  }
302
358
  catch (error) {
303
359
  rmSync(mutex, { recursive: true, force: true });
@@ -305,7 +361,9 @@ function acquireMutex(projectRoot) {
305
361
  }
306
362
  return () => {
307
363
  try {
308
- const owner = JSON.parse(readFileSync(ownerPath, 'utf8'));
364
+ const owner = JSON.parse(readUtf8FileInsideWithoutSymlinks(path.dirname(ownerPath), ownerPath, {
365
+ maxBytes: ACTIVE_LOCK_OWNER_MAX_BYTES,
366
+ }));
309
367
  if (Number(owner.pid) === ownerRecord.pid && owner.token === ownerRecord.token) {
310
368
  rmSync(mutex, { recursive: true, force: true });
311
369
  }
@@ -323,7 +381,6 @@ function acquireMutex(projectRoot) {
323
381
  const owner = readMutexOwner(ownerPath);
324
382
  if (owner) {
325
383
  if (mutexOwnerIsStale(owner) && recoverStaleMutexWithOwner(mutex, ownerPath, owner)) {
326
- startedAt = Date.now();
327
384
  continue;
328
385
  }
329
386
  }
@@ -343,12 +400,10 @@ function acquireMutex(projectRoot) {
343
400
  try {
344
401
  const mutexStat = statSync(mutex);
345
402
  if (Date.now() - mutexStat.mtimeMs > LOCK_MUTEX_STALE_MS && recoverStaleMutexWithoutOwner(mutex)) {
346
- startedAt = Date.now();
347
403
  continue;
348
404
  }
349
405
  }
350
406
  catch {
351
- startedAt = Date.now();
352
407
  continue;
353
408
  }
354
409
  }
@@ -362,7 +417,8 @@ export function inspectActiveRunLocks(projectRoot, contract, intentName) {
362
417
  const effects = normalizeCommandEffects(projectRoot, contract, intentName);
363
418
  const records = readActiveRecords(projectRoot);
364
419
  const staleRecords = records.map(staleRecordFor).filter((record) => record !== null);
365
- const liveRecords = records.filter((record) => !staleRecords.some((stale) => stale.runId === record.run_id));
420
+ const staleRecordIds = new Set(staleRecords.map((stale) => stale.runId));
421
+ const liveRecords = records.filter((record) => !staleRecordIds.has(record.run_id));
366
422
  return {
367
423
  conflicts: findConflicts(intentName, effects, liveRecords),
368
424
  staleRecords,
@@ -371,7 +427,8 @@ export function inspectActiveRunLocks(projectRoot, contract, intentName) {
371
427
  export function listActiveRunLocks(projectRoot) {
372
428
  const records = readActiveRecords(projectRoot);
373
429
  const staleRecords = records.map(staleRecordFor).filter((record) => record !== null);
374
- const activeRecords = records.filter((record) => !staleRecords.some((stale) => stale.runId === record.run_id));
430
+ const staleRecordIds = new Set(staleRecords.map((stale) => stale.runId));
431
+ const activeRecords = records.filter((record) => !staleRecordIds.has(record.run_id));
375
432
  return {
376
433
  records,
377
434
  activeRecords,
@@ -404,14 +461,15 @@ export function acquireActiveRunLock(projectRoot, contract, intentName, options
404
461
  removeRecord(projectRoot, staleRecord);
405
462
  }
406
463
  }
407
- const liveRecords = records.filter((record) => !staleRecords.some((stale) => stale.runId === record.run_id));
464
+ const staleRecordIds = new Set(staleRecords.map((stale) => stale.runId));
465
+ const liveRecords = records.filter((record) => !staleRecordIds.has(record.run_id));
408
466
  const conflicts = findConflicts(intentName, effects, liveRecords);
409
467
  if (conflicts.length > 0) {
410
468
  return { ok: false, conflicts, recoveredStaleRecords: staleRecords };
411
469
  }
412
470
  const record = createRecord(projectRoot, intentName, effects, options.commandHash ?? null);
413
471
  const recordPath = activeLockRecordPath(projectRoot, record.run_id);
414
- writeFileSync(recordPath, JSON.stringify(record, null, 2));
472
+ writeJsonFileInsideWithoutSymlinks(activeLockDirectory(projectRoot), recordPath, record);
415
473
  let released = false;
416
474
  return {
417
475
  ok: true,
@@ -41,6 +41,10 @@ function parseGitPorcelainStatusOutput(output) {
41
41
  paths.push(filePath);
42
42
  }
43
43
  if (status.includes('R') || status.includes('C')) {
44
+ const targetPath = normalizePorcelainStatusPath(parts[index + 1] ?? '');
45
+ if (targetPath.length > 0) {
46
+ paths.push(targetPath);
47
+ }
44
48
  index += 1;
45
49
  }
46
50
  }