mustflow 2.18.3 → 2.18.20

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 (45) hide show
  1. package/README.md +6 -0
  2. package/dist/cli/commands/dashboard.js +68 -12
  3. package/dist/cli/commands/init.js +20 -20
  4. package/dist/cli/commands/run/executor.js +57 -20
  5. package/dist/cli/commands/run/process-tree.js +2 -2
  6. package/dist/cli/commands/run.js +8 -11
  7. package/dist/cli/commands/update.js +6 -11
  8. package/dist/cli/i18n/en.js +1 -0
  9. package/dist/cli/i18n/es.js +1 -0
  10. package/dist/cli/i18n/fr.js +1 -0
  11. package/dist/cli/i18n/hi.js +1 -0
  12. package/dist/cli/i18n/ko.js +1 -0
  13. package/dist/cli/i18n/zh.js +1 -0
  14. package/dist/cli/lib/dashboard-export.js +2 -1
  15. package/dist/cli/lib/dashboard-html/locale-bootstrap.js +3 -2
  16. package/dist/cli/lib/dashboard-html/template.js +5 -4
  17. package/dist/cli/lib/dashboard-preferences.js +8 -6
  18. package/dist/cli/lib/filesystem.js +11 -1
  19. package/dist/cli/lib/html-json.js +11 -0
  20. package/dist/cli/lib/local-index/index.js +190 -17
  21. package/dist/cli/lib/manifest-lock.js +38 -12
  22. package/dist/cli/lib/run-plan.js +6 -0
  23. package/dist/core/check-issues.js +1 -0
  24. package/dist/core/command-classification.js +0 -16
  25. package/dist/core/command-contract-rules.js +17 -6
  26. package/dist/core/command-contract-validation.js +42 -4
  27. package/dist/core/command-intent-eligibility.js +4 -4
  28. package/dist/core/contract-lint.js +3 -3
  29. package/package.json +1 -1
  30. package/templates/default/i18n.toml +42 -6
  31. package/templates/default/locales/en/.mustflow/skills/INDEX.md +11 -5
  32. package/templates/default/locales/en/.mustflow/skills/cli-output-contract-review/SKILL.md +146 -0
  33. package/templates/default/locales/en/.mustflow/skills/command-contract-authoring/SKILL.md +121 -0
  34. package/templates/default/locales/en/.mustflow/skills/cross-platform-filesystem-safety/SKILL.md +137 -0
  35. package/templates/default/locales/en/.mustflow/skills/dependency-reality-check/SKILL.md +19 -6
  36. package/templates/default/locales/en/.mustflow/skills/external-prompt-injection-defense/SKILL.md +26 -10
  37. package/templates/default/locales/en/.mustflow/skills/llm-service-ux-review/SKILL.md +139 -0
  38. package/templates/default/locales/en/.mustflow/skills/process-execution-safety/SKILL.md +120 -0
  39. package/templates/default/locales/en/.mustflow/skills/routes.toml +38 -2
  40. package/templates/default/locales/en/.mustflow/skills/search-ad-content-authoring/SKILL.md +148 -0
  41. package/templates/default/locales/en/.mustflow/skills/security-privacy-review/SKILL.md +46 -12
  42. package/templates/default/locales/en/.mustflow/skills/security-regression-tests/SKILL.md +43 -12
  43. package/templates/default/locales/en/.mustflow/skills/ui-quality-gate/SKILL.md +34 -14
  44. package/templates/default/manifest.toml +23 -1
  45. package/dist/cli/commands/run/builtin-dispatch.js +0 -92
@@ -1,10 +1,11 @@
1
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { isRecord } from './command-contract.js';
4
+ import { readUtf8FileInsideWithoutSymlinks, writeUtf8FileInsideWithoutSymlinks } from './filesystem.js';
4
5
  import { isLocaleTag } from './locale-tags.js';
5
- import { markManifestLockFileCustomized } from './manifest-lock.js';
6
+ import { ensureManifestLockTargetSafe, markManifestLockFileCustomized } from './manifest-lock.js';
6
7
  import { COMMIT_MESSAGE_STYLES, TEST_AUTHORING_POLICIES } from './preferences-options.js';
7
- import { readTomlFile } from './toml.js';
8
+ import { parseTomlText } from './toml.js';
8
9
  const PREFERENCES_RELATIVE_PATH = '.mustflow/config/preferences.toml';
9
10
  export const DASHBOARD_PREFERENCE_SETTINGS = [
10
11
  {
@@ -281,7 +282,7 @@ export function readDashboardPreferences(projectRoot) {
281
282
  if (!existsSync(preferencesPath)) {
282
283
  throw new Error('Missing .mustflow/config/preferences.toml. Run mf init first or switch to a mustflow root.');
283
284
  }
284
- const parsed = readTomlFile(preferencesPath);
285
+ const parsed = parseTomlText(readUtf8FileInsideWithoutSymlinks(projectRoot, preferencesPath));
285
286
  if (!isRecord(parsed)) {
286
287
  throw new Error('.mustflow/config/preferences.toml must contain a TOML table.');
287
288
  }
@@ -390,7 +391,7 @@ function coerceUpdateValue(definition, value) {
390
391
  export function updateDashboardPreferences(projectRoot, updates) {
391
392
  const preferencesPath = getPreferencesPath(projectRoot);
392
393
  const definitionsById = new Map(DASHBOARD_PREFERENCE_SETTINGS.map((definition) => [definition.id, definition]));
393
- let content = readFileSync(preferencesPath, 'utf8');
394
+ let content = readUtf8FileInsideWithoutSymlinks(projectRoot, preferencesPath);
394
395
  for (const update of updates) {
395
396
  const definition = definitionsById.get(update.id);
396
397
  if (!definition) {
@@ -399,7 +400,8 @@ export function updateDashboardPreferences(projectRoot, updates) {
399
400
  const value = coerceUpdateValue(definition, update.value);
400
401
  content = setTomlScalar(content, definition.path, value);
401
402
  }
402
- writeFileSync(preferencesPath, content);
403
+ ensureManifestLockTargetSafe(projectRoot);
404
+ writeUtf8FileInsideWithoutSymlinks(projectRoot, preferencesPath, content);
403
405
  markManifestLockFileCustomized(projectRoot, PREFERENCES_RELATIVE_PATH);
404
406
  return readDashboardPreferences(projectRoot);
405
407
  }
@@ -48,11 +48,14 @@ export function ensureInsideWithoutSymlinks(parentPath, childPath, options = {})
48
48
  }
49
49
  }
50
50
  export function readUtf8FileInsideWithoutSymlinks(parentPath, childPath) {
51
+ return readFileInsideWithoutSymlinks(parentPath, childPath).toString('utf8');
52
+ }
53
+ export function readFileInsideWithoutSymlinks(parentPath, childPath) {
51
54
  const absoluteChildPath = path.resolve(childPath);
52
55
  ensureInsideWithoutSymlinks(parentPath, absoluteChildPath);
53
56
  const fileDescriptor = openSync(absoluteChildPath, constants.O_RDONLY | NOFOLLOW_FLAG);
54
57
  try {
55
- return readFileSync(fileDescriptor, 'utf8');
58
+ return readFileSync(fileDescriptor);
56
59
  }
57
60
  finally {
58
61
  closeSync(fileDescriptor);
@@ -79,6 +82,9 @@ export function ensureFileTargetInsideWithoutSymlinks(parentPath, childPath, opt
79
82
  }
80
83
  }
81
84
  export function writeUtf8FileInsideWithoutSymlinks(parentPath, childPath, content) {
85
+ writeFileInsideWithoutSymlinks(parentPath, childPath, content);
86
+ }
87
+ export function writeFileInsideWithoutSymlinks(parentPath, childPath, content) {
82
88
  const absoluteChildPath = path.resolve(childPath);
83
89
  const directoryPath = path.dirname(absoluteChildPath);
84
90
  ensureInsideWithoutSymlinks(parentPath, directoryPath, { allowMissingLeaf: true });
@@ -92,6 +98,10 @@ export function writeUtf8FileInsideWithoutSymlinks(parentPath, childPath, conten
92
98
  closeSync(fileDescriptor);
93
99
  }
94
100
  }
101
+ export function copyFileInsideWithoutSymlinks(sourceParentPath, sourcePath, targetParentPath, targetPath) {
102
+ const content = readFileInsideWithoutSymlinks(sourceParentPath, sourcePath);
103
+ writeFileInsideWithoutSymlinks(targetParentPath, targetPath, content);
104
+ }
95
105
  export function copyFileIfMissing(sourcePath, targetPath, relativePath) {
96
106
  if (existsSync(targetPath)) {
97
107
  return { status: 'skipped', relativePath };
@@ -0,0 +1,11 @@
1
+ const INLINE_SCRIPT_JSON_ESCAPES = {
2
+ '<': '\\u003C',
3
+ '>': '\\u003E',
4
+ '&': '\\u0026',
5
+ '\u2028': '\\u2028',
6
+ '\u2029': '\\u2029',
7
+ };
8
+ export function safeJsonForInlineScript(value) {
9
+ const json = JSON.stringify(value);
10
+ return (json ?? 'null').replace(/[<>&\u2028\u2029]/gu, (character) => INLINE_SCRIPT_JSON_ESCAPES[character]);
11
+ }
@@ -5,6 +5,7 @@ import { isRecord, readCommandContract, readString, readStringArray } from '../c
5
5
  import { listFilesRecursive, toPosixPath } from '../filesystem.js';
6
6
  import { readTomlFile } from '../toml.js';
7
7
  import { collectSourceAnchorIndexRecords, hasHighRiskSourceAnchorRiskTags, } from '../../../core/source-anchor-status.js';
8
+ import { listSourceAnchorFiles } from '../../../core/source-anchors.js';
8
9
  import { normalizeCommandEffects } from '../../../core/command-effects.js';
9
10
  import { listChangeClassificationRuleDescriptors } from '../../../core/change-classification.js';
10
11
  import { DEFAULT_DATABASE_RELATIVE_PATH, DEFAULT_PROMPT_CACHE_STABLE_READ, DEFAULT_PROMPT_CACHE_TASK_SOURCES, DEFAULT_PROMPT_CACHE_VOLATILE_SOURCES, INDEX_CONFIG_RELATIVE_PATH, LOCAL_INDEX_CONTENT_MODE, LOCAL_INDEX_EXCLUDED_RAW_DATA_KINDS, LOCAL_INDEX_PARSER_VERSION, LOCAL_INDEX_SCHEMA_VERSION, LOCAL_INDEX_STORE_FULL_CONTENT, LATEST_RUN_STATE_RELATIVE_PATH, MAX_SEARCH_MATCH_SNIPPET_CHARS, MAX_SNIPPET_BYTES_PER_DOCUMENT, MUSTFLOW_RELATIVE_PATH, SEARCH_BACKEND_FTS5, SEARCH_BACKEND_TABLE_SCAN, SEARCH_MATCH_CONTEXT_AFTER_CHARS, SEARCH_MATCH_CONTEXT_BEFORE_CHARS, SEARCH_MATCH_TRUNCATION_MARKER, SEARCH_NGRAM_MAX_GRAMS_PER_TARGET, SEARCH_NGRAM_MAX_LENGTH, SEARCH_NGRAM_MAX_TOKEN_CHARS, SEARCH_NGRAM_MIN_LENGTH, SOURCE_INDEX_MAX_FILE_BYTES, TEST_DISABLE_FTS5_ENV, } from './constants.js';
@@ -284,7 +285,21 @@ function collectCommandIntents(projectRoot) {
284
285
  }
285
286
  return intents;
286
287
  }
288
+ function normalizeIndexedFileSourceScope(value) {
289
+ const sourceScope = toSearchString(value);
290
+ if (sourceScope === 'source_anchor' || sourceScope === 'state') {
291
+ return sourceScope;
292
+ }
293
+ return 'workflow';
294
+ }
287
295
  function readIndexedFileRecord(projectRoot, relativePath, sourceScope, contentHash = null) {
296
+ const metadata = readIndexedFileMetadataRecord(projectRoot, relativePath, sourceScope);
297
+ return {
298
+ ...metadata,
299
+ contentHash: contentHash ?? sha256Bytes(readFileSync(path.join(projectRoot, ...relativePath.split('/')))),
300
+ };
301
+ }
302
+ function readIndexedFileMetadataRecord(projectRoot, relativePath, sourceScope) {
288
303
  const fullPath = path.join(projectRoot, ...relativePath.split('/'));
289
304
  const stats = statSync(fullPath);
290
305
  return {
@@ -292,19 +307,50 @@ function readIndexedFileRecord(projectRoot, relativePath, sourceScope, contentHa
292
307
  sourceScope,
293
308
  sizeBytes: stats.size,
294
309
  mtimeMs: Math.round(stats.mtimeMs),
295
- contentHash: contentHash ?? sha256Bytes(readFileSync(fullPath)),
296
310
  };
297
311
  }
298
- function collectIndexedFileRecords(projectRoot, documents, sourceAnchors) {
312
+ function collectIndexedFileRecords(projectRoot, documents, sourceAnchors, sourceAnchorCandidatePaths = []) {
299
313
  const records = new Map();
300
314
  for (const document of documents) {
301
315
  records.set(document.path, readIndexedFileRecord(projectRoot, document.path, 'workflow', document.contentHash));
302
316
  }
303
- for (const anchorPath of [...new Set(sourceAnchors.map((anchor) => anchor.path))].sort((left, right) => left.localeCompare(right))) {
317
+ const sourcePaths = new Set([...sourceAnchorCandidatePaths, ...sourceAnchors.map((anchor) => anchor.path)]);
318
+ for (const anchorPath of [...sourcePaths].sort((left, right) => left.localeCompare(right))) {
304
319
  if (!records.has(anchorPath)) {
305
320
  records.set(anchorPath, readIndexedFileRecord(projectRoot, anchorPath, 'source_anchor'));
306
321
  }
307
322
  }
323
+ if (existsSync(path.join(projectRoot, ...LATEST_RUN_STATE_RELATIVE_PATH.split('/')))) {
324
+ records.set(LATEST_RUN_STATE_RELATIVE_PATH, readIndexedFileRecord(projectRoot, LATEST_RUN_STATE_RELATIVE_PATH, 'state'));
325
+ }
326
+ return [...records.values()].sort((left, right) => left.path.localeCompare(right.path));
327
+ }
328
+ function collectSourceAnchorCandidatePaths(projectRoot, sourceConfig) {
329
+ return listSourceAnchorFiles(projectRoot, {
330
+ ...sourceConfig,
331
+ excludeGeneratedOrVendor: true,
332
+ });
333
+ }
334
+ function collectFastPreflightIndexedFileMetadataRecords(projectRoot, includeSource, sourceConfig) {
335
+ const records = new Map();
336
+ for (const relativePath of getExistingIndexablePaths(projectRoot)) {
337
+ records.set(relativePath, readIndexedFileMetadataRecord(projectRoot, relativePath, 'workflow'));
338
+ }
339
+ if (includeSource) {
340
+ try {
341
+ for (const sourcePath of collectSourceAnchorCandidatePaths(projectRoot, sourceConfig)) {
342
+ if (!records.has(sourcePath)) {
343
+ records.set(sourcePath, readIndexedFileMetadataRecord(projectRoot, sourcePath, 'source_anchor'));
344
+ }
345
+ }
346
+ }
347
+ catch {
348
+ return null;
349
+ }
350
+ }
351
+ if (existsSync(path.join(projectRoot, ...LATEST_RUN_STATE_RELATIVE_PATH.split('/')))) {
352
+ records.set(LATEST_RUN_STATE_RELATIVE_PATH, readIndexedFileMetadataRecord(projectRoot, LATEST_RUN_STATE_RELATIVE_PATH, 'state'));
353
+ }
308
354
  return [...records.values()].sort((left, right) => left.path.localeCompare(right.path));
309
355
  }
310
356
  function normalizeSearchText(value) {
@@ -1948,6 +1994,86 @@ function populateDatabase(database, capabilities, documents, skills, skillRoutes
1948
1994
  populatePathSurfaceReadModel(database);
1949
1995
  populateSearchTables(database, capabilities, documents, skills, skillRoutes, commandIntents, sourceAnchors);
1950
1996
  }
1997
+ function readCount(database, tableName) {
1998
+ if (!hasTable(database, tableName)) {
1999
+ return 0;
2000
+ }
2001
+ const [row] = queryRows(database, `SELECT COUNT(*) AS count FROM ${tableName}`);
2002
+ const count = row?.count;
2003
+ return typeof count === 'number' && Number.isFinite(count) ? count : 0;
2004
+ }
2005
+ function readStoredIndexedPaths(database) {
2006
+ if (!hasTable(database, 'documents')) {
2007
+ return [];
2008
+ }
2009
+ return queryRows(database, 'SELECT path FROM documents ORDER BY path')
2010
+ .map((row) => toSearchString(row.path))
2011
+ .filter(Boolean);
2012
+ }
2013
+ function createStoredLocalIndexResult(projectRoot, databasePath, dryRun, indexMode, database, capabilities) {
2014
+ return {
2015
+ schema_version: LOCAL_INDEX_SCHEMA_VERSION,
2016
+ command: 'index',
2017
+ ok: true,
2018
+ mustflow_root: path.resolve(projectRoot),
2019
+ database_path: databasePath,
2020
+ dry_run: dryRun,
2021
+ wrote_files: false,
2022
+ index_mode: indexMode,
2023
+ reused_existing: true,
2024
+ rebuild_reason: null,
2025
+ document_count: readCount(database, 'documents'),
2026
+ skill_count: readCount(database, 'skills'),
2027
+ skill_route_count: readCount(database, 'skill_routes'),
2028
+ command_intent_count: readCount(database, 'command_intents'),
2029
+ command_effect_count: readCount(database, 'command_effects'),
2030
+ verification_evidence_summary_count: readCount(database, 'verification_evidence_summaries'),
2031
+ verification_plan_count: readCount(database, 'verification_plans'),
2032
+ acceptance_criteria_count: readCount(database, 'acceptance_criteria'),
2033
+ criterion_coverage_count: readCount(database, 'criterion_coverage'),
2034
+ verification_receipt_summary_count: readCount(database, 'verification_receipt_summaries'),
2035
+ command_receipt_summary_count: readCount(database, 'command_receipt_summaries'),
2036
+ verification_coverage_state_count: readCount(database, 'verification_coverage_states'),
2037
+ verification_risk_signal_count: readCount(database, 'verification_risk_signals'),
2038
+ validation_ratchet_signal_count: readCount(database, 'validation_ratchet_signals'),
2039
+ completion_verdict_summary_count: readCount(database, 'completion_verdict_summaries'),
2040
+ repro_route_count: readCount(database, 'repro_routes'),
2041
+ repro_observation_count: readCount(database, 'repro_observations'),
2042
+ failure_fingerprint_count: readCount(database, 'verification_failure_fingerprints'),
2043
+ source_index_enabled: readMetadataValue(database, 'source_index_enabled') === 'true',
2044
+ source_anchor_count: readCount(database, 'source_anchors'),
2045
+ source_anchor_risk_signal_count: readCount(database, 'source_anchor_risk_signals'),
2046
+ search_backend: capabilities.backend,
2047
+ search_fts5_available: capabilities.fts5Available,
2048
+ content_mode: LOCAL_INDEX_CONTENT_MODE,
2049
+ store_full_content: LOCAL_INDEX_STORE_FULL_CONTENT,
2050
+ max_snippet_bytes_per_document: MAX_SNIPPET_BYTES_PER_DOCUMENT,
2051
+ excluded_raw_data_kinds: [...LOCAL_INDEX_EXCLUDED_RAW_DATA_KINDS],
2052
+ indexed_file_count: readCount(database, 'indexed_files'),
2053
+ indexed_paths: readStoredIndexedPaths(database),
2054
+ };
2055
+ }
2056
+ function indexedFileMetadataMatch(database, currentFiles) {
2057
+ const rows = queryRows(database, 'SELECT path, source_scope, size_bytes, mtime_ms, parser_version FROM indexed_files ORDER BY path');
2058
+ if (rows.length !== currentFiles.length) {
2059
+ return false;
2060
+ }
2061
+ const currentByPath = new Map(currentFiles.map((file) => [file.path, file]));
2062
+ for (const row of rows) {
2063
+ const storedPath = toSearchString(row.path);
2064
+ const current = currentByPath.get(storedPath);
2065
+ if (!current) {
2066
+ return false;
2067
+ }
2068
+ if (normalizeIndexedFileSourceScope(row.source_scope) !== current.sourceScope ||
2069
+ row.size_bytes !== current.sizeBytes ||
2070
+ row.mtime_ms !== current.mtimeMs ||
2071
+ toSearchString(row.parser_version) !== LOCAL_INDEX_PARSER_VERSION) {
2072
+ return false;
2073
+ }
2074
+ }
2075
+ return true;
2076
+ }
1951
2077
  function indexedFilesMatch(database, currentFiles) {
1952
2078
  const rows = queryRows(database, 'SELECT path, source_scope, content_hash, parser_version FROM indexed_files ORDER BY path');
1953
2079
  if (rows.length !== currentFiles.length) {
@@ -1960,7 +2086,7 @@ function indexedFilesMatch(database, currentFiles) {
1960
2086
  if (!current) {
1961
2087
  return false;
1962
2088
  }
1963
- if (toSearchString(row.source_scope) !== current.sourceScope ||
2089
+ if (normalizeIndexedFileSourceScope(row.source_scope) !== current.sourceScope ||
1964
2090
  toSearchString(row.content_hash) !== current.contentHash ||
1965
2091
  toSearchString(row.parser_version) !== LOCAL_INDEX_PARSER_VERSION) {
1966
2092
  return false;
@@ -1968,6 +2094,44 @@ function indexedFilesMatch(database, currentFiles) {
1968
2094
  }
1969
2095
  return true;
1970
2096
  }
2097
+ async function readIncrementalPreflightReuse(SQL, databasePath, projectRoot, currentFiles, sourceScopeHash, dryRun, indexMode) {
2098
+ if (!currentFiles) {
2099
+ return { result: null, rebuildReason: null };
2100
+ }
2101
+ if (!existsSync(databasePath)) {
2102
+ return { result: null, rebuildReason: 'missing_index' };
2103
+ }
2104
+ let database;
2105
+ try {
2106
+ database = new SQL.Database(readFileSync(databasePath));
2107
+ if (readStoredSchemaVersion(database) !== LOCAL_INDEX_SCHEMA_VERSION) {
2108
+ return { result: null, rebuildReason: 'schema_version_mismatch' };
2109
+ }
2110
+ if (readMetadataValue(database, 'parser_version') !== LOCAL_INDEX_PARSER_VERSION) {
2111
+ return { result: null, rebuildReason: 'parser_version_mismatch' };
2112
+ }
2113
+ if (readMetadataValue(database, 'source_scope_hash') !== sourceScopeHash) {
2114
+ return { result: null, rebuildReason: 'source_scope_mismatch' };
2115
+ }
2116
+ if (!hasTable(database, 'indexed_files')) {
2117
+ return { result: null, rebuildReason: 'indexed_files_missing' };
2118
+ }
2119
+ if (!indexedFileMetadataMatch(database, currentFiles)) {
2120
+ return { result: null, rebuildReason: 'file_fingerprint_mismatch' };
2121
+ }
2122
+ const capabilities = readStoredSearchCapabilities(database);
2123
+ return {
2124
+ result: createStoredLocalIndexResult(projectRoot, databasePath, dryRun, indexMode, database, capabilities),
2125
+ rebuildReason: null,
2126
+ };
2127
+ }
2128
+ catch {
2129
+ return { result: null, rebuildReason: 'unreadable_index' };
2130
+ }
2131
+ finally {
2132
+ database?.close();
2133
+ }
2134
+ }
1971
2135
  async function readIncrementalReuseDecision(SQL, databasePath, currentFiles, sourceScopeHash) {
1972
2136
  if (!existsSync(databasePath)) {
1973
2137
  return { reusable: false, rebuildReason: 'missing_index', capabilities: null };
@@ -2015,16 +2179,32 @@ export async function createLocalIndex(projectRoot, options = {}) {
2015
2179
  const dryRun = options.dryRun === true;
2016
2180
  const incremental = options.incremental === true;
2017
2181
  const indexMode = incremental ? 'incremental' : 'full';
2182
+ const sourceConfig = readLocalIndexSourceConfig(projectRoot);
2183
+ const includeSource = options.includeSource === true || sourceConfig.enabledByDefault;
2184
+ const sourceScopeHash = getSourceScopeHash(includeSource, sourceConfig);
2185
+ let capabilities = searchCapabilities(false);
2186
+ let reusedExisting = false;
2187
+ let rebuildReason = null;
2188
+ const SQL = await loadSqlJs();
2189
+ const capabilityDatabase = new SQL.Database();
2190
+ capabilities = detectLocalSearchCapabilities(capabilityDatabase);
2191
+ capabilityDatabase.close();
2192
+ if (incremental) {
2193
+ const preflightFiles = collectFastPreflightIndexedFileMetadataRecords(projectRoot, includeSource, sourceConfig);
2194
+ const preflightReuse = await readIncrementalPreflightReuse(SQL, databasePath, projectRoot, preflightFiles, sourceScopeHash, dryRun, indexMode);
2195
+ if (preflightReuse.result) {
2196
+ return preflightReuse.result;
2197
+ }
2198
+ rebuildReason = preflightReuse.rebuildReason;
2199
+ }
2018
2200
  const documents = collectDocuments(projectRoot);
2019
2201
  const skills = collectSkills(documents);
2020
2202
  const skillRoutes = collectSkillRoutes(projectRoot);
2021
2203
  const commandIntents = collectCommandIntents(projectRoot);
2022
- const sourceConfig = readLocalIndexSourceConfig(projectRoot);
2023
- const includeSource = options.includeSource === true || sourceConfig.enabledByDefault;
2024
- const sourceScopeHash = getSourceScopeHash(includeSource, sourceConfig);
2025
2204
  const previousSourceAnchors = includeSource
2026
2205
  ? await readPreviousSourceAnchorSnapshots(databasePath).catch(() => [])
2027
2206
  : [];
2207
+ const sourceAnchorCandidatePaths = includeSource ? collectSourceAnchorCandidatePaths(projectRoot, sourceConfig) : [];
2028
2208
  const sourceAnchors = includeSource
2029
2209
  ? collectSourceAnchorIndexRecords(projectRoot, previousSourceAnchors, {
2030
2210
  ...sourceConfig,
@@ -2032,18 +2212,11 @@ export async function createLocalIndex(projectRoot, options = {}) {
2032
2212
  })
2033
2213
  : [];
2034
2214
  const verificationEvidence = createVerificationEvidenceIndex(projectRoot);
2035
- const indexedFiles = collectIndexedFileRecords(projectRoot, documents, sourceAnchors);
2036
- let capabilities = searchCapabilities(false);
2037
- let reusedExisting = false;
2038
- let rebuildReason = null;
2039
- const SQL = await loadSqlJs();
2040
- const capabilityDatabase = new SQL.Database();
2041
- capabilities = detectLocalSearchCapabilities(capabilityDatabase);
2042
- capabilityDatabase.close();
2215
+ const indexedFiles = collectIndexedFileRecords(projectRoot, documents, sourceAnchors, sourceAnchorCandidatePaths);
2043
2216
  if (incremental) {
2044
2217
  const reuseDecision = await readIncrementalReuseDecision(SQL, databasePath, indexedFiles, sourceScopeHash);
2045
2218
  reusedExisting = reuseDecision.reusable;
2046
- rebuildReason = reuseDecision.rebuildReason;
2219
+ rebuildReason = reuseDecision.rebuildReason ?? rebuildReason;
2047
2220
  capabilities = reuseDecision.capabilities ?? capabilities;
2048
2221
  }
2049
2222
  if (!dryRun && !reusedExisting) {
@@ -2110,7 +2283,7 @@ function getStalePaths(projectRoot, database) {
2110
2283
  const indexedPaths = new Set(indexedRows.map((row) => toSearchString(row.path)));
2111
2284
  for (const row of indexedRows) {
2112
2285
  const indexedPath = toSearchString(row.path);
2113
- const sourceScope = toSearchString(row.source_scope) === 'source_anchor' ? 'source_anchor' : 'workflow';
2286
+ const sourceScope = normalizeIndexedFileSourceScope(row.source_scope);
2114
2287
  try {
2115
2288
  const current = readIndexedFileRecord(projectRoot, indexedPath, sourceScope);
2116
2289
  if (current.contentHash !== toSearchString(row.content_hash)) {
@@ -1,8 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync } from 'node:fs';
3
3
  import path from 'node:path';
4
- import { ensureInside } from './filesystem.js';
5
- import { readTomlFile, stringifyToml } from './toml.js';
4
+ import { ensureFileTargetInsideWithoutSymlinks, ensureInside, readFileInsideWithoutSymlinks, readUtf8FileInsideWithoutSymlinks, writeUtf8FileInsideWithoutSymlinks, } from './filesystem.js';
5
+ import { parseTomlText, stringifyToml } from './toml.js';
6
6
  export const MANIFEST_LOCK_RELATIVE_PATH = '.mustflow/config/manifest.lock.toml';
7
7
  function isRecord(value) {
8
8
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -48,20 +48,31 @@ function parseManifestLock(raw) {
48
48
  };
49
49
  }
50
50
  export function sha256File(filePath) {
51
- return `sha256:${createHash('sha256').update(readFileSync(filePath)).digest('hex')}`;
51
+ return `sha256:${createHash('sha256')
52
+ .update(readFileInsideWithoutSymlinks(path.dirname(filePath), filePath))
53
+ .digest('hex')}`;
54
+ }
55
+ function sha256ProjectFile(projectRoot, filePath) {
56
+ return `sha256:${createHash('sha256').update(readFileInsideWithoutSymlinks(projectRoot, filePath)).digest('hex')}`;
57
+ }
58
+ export function ensureManifestLockTargetSafe(projectRoot) {
59
+ const lockPath = path.join(projectRoot, MANIFEST_LOCK_RELATIVE_PATH);
60
+ ensureInside(projectRoot, lockPath);
61
+ ensureFileTargetInsideWithoutSymlinks(projectRoot, lockPath, { allowMissingLeaf: true });
62
+ return existsSync(lockPath);
52
63
  }
53
64
  export function markManifestLockFileCustomized(projectRoot, relativePath) {
54
65
  const lockPath = path.join(projectRoot, MANIFEST_LOCK_RELATIVE_PATH);
55
66
  const filePath = path.join(projectRoot, relativePath);
56
- ensureInside(projectRoot, lockPath);
57
67
  ensureInside(projectRoot, filePath);
58
- if (!existsSync(lockPath)) {
68
+ if (!ensureManifestLockTargetSafe(projectRoot)) {
59
69
  return false;
60
70
  }
71
+ ensureFileTargetInsideWithoutSymlinks(projectRoot, filePath, { allowMissingLeaf: true });
61
72
  if (!existsSync(filePath)) {
62
73
  throw new Error(`Cannot refresh manifest lock for missing file: ${relativePath}`);
63
74
  }
64
- const parsed = readTomlFile(lockPath);
75
+ const parsed = parseTomlText(readUtf8FileInsideWithoutSymlinks(projectRoot, lockPath));
65
76
  if (!isRecord(parsed)) {
66
77
  throw new Error(`Invalid manifest lock: ${MANIFEST_LOCK_RELATIVE_PATH} must contain a TOML table`);
67
78
  }
@@ -71,20 +82,27 @@ export function markManifestLockFileCustomized(projectRoot, relativePath) {
71
82
  filesTable[relativePath] = {
72
83
  source: typeof existingTable.source === 'string' ? existingTable.source : 'template_common',
73
84
  last_action: 'customized',
74
- content_hash: sha256File(filePath),
85
+ content_hash: sha256ProjectFile(projectRoot, filePath),
75
86
  };
76
87
  parsed.files = filesTable;
77
- writeFileSync(lockPath, stringifyToml(parsed));
88
+ writeUtf8FileInsideWithoutSymlinks(projectRoot, lockPath, stringifyToml(parsed));
78
89
  return true;
79
90
  }
80
91
  export function readManifestLock(projectRoot) {
81
92
  const lockPath = path.join(projectRoot, MANIFEST_LOCK_RELATIVE_PATH);
82
- ensureInside(projectRoot, lockPath);
93
+ try {
94
+ ensureInside(projectRoot, lockPath);
95
+ ensureFileTargetInsideWithoutSymlinks(projectRoot, lockPath, { allowMissingLeaf: true });
96
+ }
97
+ catch (error) {
98
+ const message = error instanceof Error ? error.message : String(error);
99
+ return { kind: 'invalid', lockPath, message };
100
+ }
83
101
  if (!existsSync(lockPath)) {
84
102
  return { kind: 'missing', lockPath };
85
103
  }
86
104
  try {
87
- return { kind: 'present', lockPath, lock: parseManifestLock(readTomlFile(lockPath)) };
105
+ return { kind: 'present', lockPath, lock: parseManifestLock(parseTomlText(readUtf8FileInsideWithoutSymlinks(projectRoot, lockPath))) };
88
106
  }
89
107
  catch (error) {
90
108
  const message = error instanceof Error ? error.message : String(error);
@@ -121,7 +139,15 @@ export function inspectManifestLock(projectRoot) {
121
139
  issues.push(`Locked file missing: ${lockedFile.relativePath}`);
122
140
  continue;
123
141
  }
124
- const actualHash = sha256File(filePath);
142
+ let actualHash;
143
+ try {
144
+ actualHash = sha256ProjectFile(projectRoot, filePath);
145
+ }
146
+ catch (error) {
147
+ const message = error instanceof Error ? error.message : String(error);
148
+ issues.push(`Locked file cannot be read safely: ${lockedFile.relativePath}: ${message}`);
149
+ continue;
150
+ }
125
151
  if (actualHash !== lockedFile.contentHash) {
126
152
  changedFiles.push(lockedFile.relativePath);
127
153
  issues.push(`Lock hash mismatch: ${lockedFile.relativePath}`);
@@ -78,6 +78,9 @@ function readEffectiveMaxOutputBytes(contract, intent) {
78
78
  readPositiveInteger(contract.defaults, 'max_output_bytes') ??
79
79
  DEFAULT_COMMAND_MAX_OUTPUT_BYTES;
80
80
  }
81
+ function readEffectiveKillAfterSeconds(contract) {
82
+ return readPositiveInteger(contract.defaults, 'kill_after_seconds') ?? 5;
83
+ }
81
84
  function getMaxOutputBytesLimitDetail(contract, intent) {
82
85
  const intentValue = readPositiveInteger(intent, 'max_output_bytes');
83
86
  if (intentValue !== undefined) {
@@ -103,6 +106,7 @@ function readRunIntentMetadata(contract, intent) {
103
106
  kind: readString(intent, 'kind') ?? null,
104
107
  configuredCwd,
105
108
  timeoutSeconds: readPositiveInteger(intent, 'timeout_seconds') ?? null,
109
+ killAfterSeconds: readEffectiveKillAfterSeconds(contract),
106
110
  maxOutputBytes: readEffectiveMaxOutputBytes(contract, intent),
107
111
  successExitCodes: getSuccessExitCodes(intent),
108
112
  commandArgv,
@@ -138,6 +142,7 @@ function createBlockedRunPlan(contract, intentName, intent, eligibility, reasonC
138
142
  cwd: null,
139
143
  relativeCwd: null,
140
144
  timeoutSeconds: metadata?.timeoutSeconds ?? null,
145
+ killAfterSeconds: metadata?.killAfterSeconds ?? null,
141
146
  maxOutputBytes: metadata?.maxOutputBytes ?? null,
142
147
  successExitCodes: metadata?.successExitCodes ?? null,
143
148
  commandArgv: metadata?.commandArgv,
@@ -199,6 +204,7 @@ export function createRunPlan(projectRoot, contract, intentName, options = {}) {
199
204
  cwd,
200
205
  relativeCwd: getRelativeProjectPath(projectRoot, cwd),
201
206
  timeoutSeconds: metadata.timeoutSeconds,
207
+ killAfterSeconds: metadata.killAfterSeconds,
202
208
  maxOutputBytes: metadata.maxOutputBytes,
203
209
  successExitCodes: metadata.successExitCodes,
204
210
  commandArgv,
@@ -15,6 +15,7 @@ const CHECK_ISSUE_ID_RULES = [
15
15
  ['mustflow.command_contract.effect_path_escape', /^Strict: Command effect path must stay inside the current root:/u],
16
16
  ['mustflow.command_contract.shared_writes_without_effects', /^Strict warning: configured agent-runnable intents .+ share path:.+ through writes without explicit effects or resource locks$/u],
17
17
  ['mustflow.command_contract.broad_env_inheritance', /^Strict warning: configured agent-runnable intent [^\s]+ (?:implicitly inherits the host environment|uses env_policy = "inherit")/u],
18
+ ['mustflow.command_contract.project_local_bin_bare_executable', /^Strict warning: configured agent-runnable intent [^\s]+ uses bare executable "[^"]+" that matches project-local node_modules\/\.bin/u],
18
19
  ['mustflow.prompt_cache.required', /^Strict: \[prompt_cache\] table is required$/u],
19
20
  ['mustflow.prompt_cache.volatile_in_stable', /^Strict: \[prompt_cache\.layers\.stable\]\.read must not include volatile path /u],
20
21
  ['mustflow.refresh.hash_method_required', /^Strict: \[refresh\]\.default_method should be "hash_check" for cache-friendly refresh$/u],
@@ -1,20 +1,4 @@
1
1
  const MUSTFLOW_BIN_NAMES = new Set(['mf', 'mustflow']);
2
- const IN_PROCESS_MUSTFLOW_BUILTIN_COMMANDS = new Set([
3
- 'check',
4
- 'classify',
5
- 'context',
6
- 'doctor',
7
- 'help',
8
- 'impact',
9
- 'line-endings',
10
- 'map',
11
- 'status',
12
- 'update',
13
- 'version-sources',
14
- ]);
15
2
  export function isMustflowBinName(command) {
16
3
  return MUSTFLOW_BIN_NAMES.has(command.toLowerCase());
17
4
  }
18
- export function canRunMustflowBuiltinInProcess(command) {
19
- return command !== undefined && IN_PROCESS_MUSTFLOW_BUILTIN_COMMANDS.has(command);
20
- }
@@ -14,6 +14,7 @@ const INTERPRETER_EVALUATION_FLAGS = new Map([
14
14
  const PACKAGE_SCRIPT_RUNNERS = new Set(['bun', 'npm', 'pnpm', 'yarn']);
15
15
  const LONG_RUNNING_PACKAGE_SCRIPTS = new Set(['dev', 'start', 'serve', 'watch', 'preview']);
16
16
  const LONG_RUNNING_EXECUTABLES = new Set(['nodemon', 'pm2', 'serve', 'http-server', 'live-server', 'webpack-dev-server']);
17
+ const ATTACHED_EVALUATION_FLAGS = new Set(['-command', '-commandwithargs']);
17
18
  export const BACKGROUND_SHELL_PATTERNS = [
18
19
  /(?:^|[^&])&(?!&)\s*$/u,
19
20
  /\bnohup\b/iu,
@@ -45,16 +46,26 @@ export function commandIntentHasCommandSource(intent) {
45
46
  export function shellCommandHasBlockedBackgroundPattern(command) {
46
47
  return BACKGROUND_SHELL_PATTERNS.some((pattern) => pattern.test(command));
47
48
  }
48
- export function commandIntentHasBlockedShellBackgroundPattern(intent) {
49
- return intent.mode === 'shell' && typeof intent.cmd === 'string' && shellCommandHasBlockedBackgroundPattern(intent.cmd);
50
- }
51
49
  function normalizeExecutableName(value) {
52
50
  return path.basename(value).replace(/\.(?:cmd|exe|ps1)$/iu, '').toLowerCase();
53
51
  }
52
+ function flagAllowsAttachedPayload(flag) {
53
+ return (flag.startsWith('-') && !flag.startsWith('--') && flag.length === 2) || flag === '/c' || ATTACHED_EVALUATION_FLAGS.has(flag);
54
+ }
54
55
  function findFlagPayload(argv, flags) {
55
- for (let index = 1; index < argv.length - 1; index += 1) {
56
- if (flags.has(argv[index].toLowerCase())) {
57
- return argv[index + 1];
56
+ for (let index = 1; index < argv.length; index += 1) {
57
+ const argument = argv[index] ?? '';
58
+ const normalizedArgument = argument.toLowerCase();
59
+ if (flags.has(normalizedArgument)) {
60
+ return argv[index + 1] ?? null;
61
+ }
62
+ for (const flag of flags) {
63
+ if (normalizedArgument.startsWith(`${flag}=`)) {
64
+ return argument.slice(flag.length + 1);
65
+ }
66
+ if (flagAllowsAttachedPayload(flag) && normalizedArgument.startsWith(flag) && argument.length > flag.length) {
67
+ return argument.slice(flag.length);
68
+ }
58
69
  }
59
70
  }
60
71
  return null;
@@ -1,7 +1,9 @@
1
- import { COMMAND_LIFECYCLES, COMMAND_RUN_POLICIES, LONG_RUNNING_LIFECYCLES, isRecord, } from './config-loading.js';
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { COMMAND_LIFECYCLES, COMMAND_RUN_POLICIES, LONG_RUNNING_LIFECYCLES, isRecord, readStringArray, } from './config-loading.js';
2
4
  import { COMMAND_ENV_POLICIES, DEFAULT_COMMAND_ENV_POLICY } from './command-env.js';
3
5
  import { COMMAND_EFFECT_CONCURRENCY, COMMAND_EFFECT_MODES, COMMAND_EFFECT_TYPES, validateCommandEffectLockWarnings, validateCommandEffects, } from './command-effects.js';
4
- import { commandIntentBlockedCommandPattern, commandIntentHasBlockedShellBackgroundPattern, commandIntentHasCommandSource, commandIntentNameIsSafe, } from './command-contract-rules.js';
6
+ import { commandIntentBlockedCommandPattern, commandIntentHasCommandSource, commandIntentNameIsSafe, } from './command-contract-rules.js';
5
7
  import { MAX_COMMAND_OUTPUT_BYTES, commandMaxOutputBytesLimitMessage } from './command-output-limits.js';
6
8
  function commandContractIssue(message) {
7
9
  return { message };
@@ -186,10 +188,10 @@ function validateCommandIntent(intentName, intent, issues) {
186
188
  if (!commandIntentHasCommandSource(intent)) {
187
189
  issues.push(commandContractIssue(`Configured intent ${intentName} must define argv or mode = "shell" with cmd`));
188
190
  }
189
- if (commandIntentHasBlockedShellBackgroundPattern(intent)) {
191
+ const blockedCommandPattern = commandIntentBlockedCommandPattern(intent);
192
+ if (blockedCommandPattern?.code === 'shell_background_pattern') {
190
193
  issues.push(commandContractIssue(`Shell intent ${intentName} contains a blocked long-running or background pattern`));
191
194
  }
192
- const blockedCommandPattern = commandIntentBlockedCommandPattern(intent);
193
195
  if (blockedCommandPattern?.code === 'long_running_command_pattern') {
194
196
  issues.push(commandContractIssue(`Intent ${intentName} contains a blocked long-running or background command pattern`));
195
197
  }
@@ -245,6 +247,41 @@ function validateCommandEnvInheritanceWarnings(commandsToml) {
245
247
  }
246
248
  return issues;
247
249
  }
250
+ function projectLocalBinExecutableExists(projectRoot, executable) {
251
+ const localBinPath = path.join(projectRoot, 'node_modules', '.bin');
252
+ const executableName = path.basename(executable).replace(/\.(?:cmd|exe|ps1)$/iu, '');
253
+ const candidates = [
254
+ executableName,
255
+ `${executableName}.cmd`,
256
+ `${executableName}.exe`,
257
+ `${executableName}.ps1`,
258
+ ];
259
+ return candidates.some((candidate) => existsSync(path.join(localBinPath, candidate)));
260
+ }
261
+ function validateProjectLocalBinWarnings(projectRoot, commandsToml) {
262
+ const issues = [];
263
+ if (!isRecord(commandsToml?.intents)) {
264
+ return issues;
265
+ }
266
+ for (const [intentName, intent] of Object.entries(commandsToml.intents)) {
267
+ if (!isRecord(intent)) {
268
+ continue;
269
+ }
270
+ if (intent.status !== 'configured' || intent.lifecycle !== 'oneshot' || intent.run_policy !== 'agent_allowed') {
271
+ continue;
272
+ }
273
+ const argv = readStringArray(intent, 'argv');
274
+ const executable = argv?.[0];
275
+ if (!executable || executable.includes('/') || executable.includes('\\')) {
276
+ continue;
277
+ }
278
+ if (!projectLocalBinExecutableExists(projectRoot, executable)) {
279
+ continue;
280
+ }
281
+ issues.push(commandContractWarning(`configured agent-runnable intent ${intentName} uses bare executable "${executable}" that matches project-local node_modules/.bin; use a package-manager mediated command such as npm exec, pnpm exec, bun x, or yarn exec`));
282
+ }
283
+ return issues;
284
+ }
248
285
  /**
249
286
  * mf:anchor core.command-contract-validation
250
287
  * purpose: Validate command intent declarations that gate agent-executable repository commands.
@@ -287,6 +324,7 @@ export function validateCommandContractStrictDefaults(projectRoot, commandsToml)
287
324
  }
288
325
  }
289
326
  issues.push(...validateCommandEnvInheritanceWarnings(commandsToml));
327
+ issues.push(...validateProjectLocalBinWarnings(projectRoot, commandsToml));
290
328
  issues.push(...validateCommandEffects(projectRoot, commandsToml));
291
329
  issues.push(...validateCommandEffectLockWarnings(commandsToml));
292
330
  return issues;