mustflow 2.22.16 → 2.22.46

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 (67) hide show
  1. package/dist/cli/commands/dashboard.js +51 -4
  2. package/dist/cli/commands/explain.js +3 -2
  3. package/dist/cli/commands/help.js +0 -1
  4. package/dist/cli/commands/run.js +51 -4
  5. package/dist/cli/commands/verify.js +2 -1
  6. package/dist/cli/i18n/en.js +5 -0
  7. package/dist/cli/i18n/es.js +5 -0
  8. package/dist/cli/i18n/fr.js +5 -0
  9. package/dist/cli/i18n/hi.js +5 -0
  10. package/dist/cli/i18n/ko.js +5 -0
  11. package/dist/cli/i18n/zh.js +5 -0
  12. package/dist/cli/lib/cli-output.js +1 -1
  13. package/dist/cli/lib/dashboard-html/client-script.js +9 -0
  14. package/dist/cli/lib/dashboard-html/styles.js +48 -1
  15. package/dist/cli/lib/doc-review-ledger.js +1 -1
  16. package/dist/cli/lib/git-changes.js +2 -0
  17. package/dist/cli/lib/local-index/index.js +324 -298
  18. package/dist/cli/lib/repo-map.js +19 -5
  19. package/dist/cli/lib/run-plan.js +20 -7
  20. package/dist/cli/lib/run-root-trust.js +33 -2
  21. package/dist/cli/lib/validation/index.js +6 -2
  22. package/dist/cli/lib/validation/test-selection.js +11 -1
  23. package/dist/core/active-run-locks.js +36 -8
  24. package/dist/core/atomic-state-write.js +5 -20
  25. package/dist/core/change-verification.js +18 -2
  26. package/dist/core/command-intent-eligibility.js +7 -0
  27. package/dist/core/contract-lint.js +3 -3
  28. package/dist/core/line-endings.js +2 -0
  29. package/dist/core/repeated-failure.js +1 -1
  30. package/dist/core/run-write-drift.js +42 -26
  31. package/dist/core/safe-filesystem.js +54 -5
  32. package/dist/core/skill-route-explanation.js +2 -1
  33. package/dist/core/source-anchors.js +7 -3
  34. package/dist/core/test-selection.js +13 -2
  35. package/dist/core/test-target-paths.js +17 -0
  36. package/dist/core/validation-ratchet.js +62 -17
  37. package/dist/core/verification-decision-graph.js +8 -1
  38. package/package.json +1 -1
  39. package/templates/default/i18n.toml +141 -3
  40. package/templates/default/locales/en/.mustflow/skills/INDEX.md +24 -1
  41. package/templates/default/locales/en/.mustflow/skills/api-contract-change/SKILL.md +212 -0
  42. package/templates/default/locales/en/.mustflow/skills/astro-code-change/SKILL.md +184 -0
  43. package/templates/default/locales/en/.mustflow/skills/auth-permission-change/SKILL.md +194 -0
  44. package/templates/default/locales/en/.mustflow/skills/config-env-change/SKILL.md +189 -0
  45. package/templates/default/locales/en/.mustflow/skills/css-code-change/SKILL.md +199 -0
  46. package/templates/default/locales/en/.mustflow/skills/dart-code-change/SKILL.md +179 -0
  47. package/templates/default/locales/en/.mustflow/skills/database-migration-change/SKILL.md +178 -0
  48. package/templates/default/locales/en/.mustflow/skills/dependency-upgrade-review/SKILL.md +151 -0
  49. package/templates/default/locales/en/.mustflow/skills/elysia-code-change/SKILL.md +115 -0
  50. package/templates/default/locales/en/.mustflow/skills/file-path-cross-platform-change/SKILL.md +147 -0
  51. package/templates/default/locales/en/.mustflow/skills/flutter-code-change/SKILL.md +116 -0
  52. package/templates/default/locales/en/.mustflow/skills/go-code-change/SKILL.md +156 -0
  53. package/templates/default/locales/en/.mustflow/skills/hono-code-change/SKILL.md +117 -0
  54. package/templates/default/locales/en/.mustflow/skills/html-code-change/SKILL.md +173 -0
  55. package/templates/default/locales/en/.mustflow/skills/javascript-code-change/SKILL.md +149 -0
  56. package/templates/default/locales/en/.mustflow/skills/python-code-change/SKILL.md +154 -0
  57. package/templates/default/locales/en/.mustflow/skills/release-publish-change/SKILL.md +172 -0
  58. package/templates/default/locales/en/.mustflow/skills/routes.toml +138 -0
  59. package/templates/default/locales/en/.mustflow/skills/rust-code-change/SKILL.md +154 -0
  60. package/templates/default/locales/en/.mustflow/skills/security-privacy-review/SKILL.md +22 -7
  61. package/templates/default/locales/en/.mustflow/skills/security-regression-tests/SKILL.md +31 -20
  62. package/templates/default/locales/en/.mustflow/skills/svelte-code-change/SKILL.md +186 -0
  63. package/templates/default/locales/en/.mustflow/skills/tailwind-code-change/SKILL.md +164 -0
  64. package/templates/default/locales/en/.mustflow/skills/tauri-code-change/SKILL.md +185 -0
  65. package/templates/default/locales/en/.mustflow/skills/typescript-code-change/SKILL.md +184 -0
  66. package/templates/default/locales/en/.mustflow/skills/unocss-code-change/SKILL.md +186 -0
  67. package/templates/default/manifest.toml +158 -1
@@ -69,6 +69,8 @@ const MACHINE_CONTRACT_ANCHOR_FILES = [
69
69
  'schema.graphql',
70
70
  'schema.prisma',
71
71
  ];
72
+ const ROOT_OPTIONAL_MARKDOWN_ANCHOR_FILE_SET = new Set(ROOT_OPTIONAL_MARKDOWN_ANCHOR_FILES);
73
+ const MACHINE_CONTRACT_ANCHOR_FILE_SET = new Set(MACHINE_CONTRACT_ANCHOR_FILES);
72
74
  const DEFAULT_NESTED_ANCHOR_FILES = [
73
75
  'AGENTS.md',
74
76
  'REPO_MAP.md',
@@ -386,7 +388,13 @@ function renderDirectoryAnchors(anchors) {
386
388
  const grouped = new Map();
387
389
  for (const anchor of anchors) {
388
390
  const directory = getDirectoryName(anchor.relativePath);
389
- grouped.set(directory, [...(grouped.get(directory) ?? []), anchor]);
391
+ const group = grouped.get(directory);
392
+ if (group) {
393
+ group.push(anchor);
394
+ }
395
+ else {
396
+ grouped.set(directory, [anchor]);
397
+ }
390
398
  }
391
399
  for (const directory of Array.from(grouped.keys()).sort((left, right) => {
392
400
  if (left === '/') {
@@ -469,13 +477,13 @@ function collectNestedRepository(projectRoot, repositoryPath, anchorFiles) {
469
477
  .filter((anchorFile) => EDITING_POLICY_ANCHORS.has(anchorFile) && existingAnchors.has(anchorFile))
470
478
  .map((anchorFile) => `${relativeRoot}${anchorFile}`);
471
479
  const rootDocuments = anchorFiles
472
- .filter((anchorFile) => ROOT_OPTIONAL_MARKDOWN_ANCHOR_FILES.includes(anchorFile) && existingAnchors.has(anchorFile))
480
+ .filter((anchorFile) => ROOT_OPTIONAL_MARKDOWN_ANCHOR_FILE_SET.has(anchorFile) && existingAnchors.has(anchorFile))
473
481
  .map((anchorFile) => ({
474
482
  label: NESTED_ROOT_DOC_LABELS.get(anchorFile) ?? 'root document',
475
483
  relativePath: `${relativeRoot}${anchorFile}`,
476
484
  }));
477
485
  const machineContracts = anchorFiles
478
- .filter((anchorFile) => MACHINE_CONTRACT_ANCHOR_FILES.includes(anchorFile) && existingAnchors.has(anchorFile))
486
+ .filter((anchorFile) => MACHINE_CONTRACT_ANCHOR_FILE_SET.has(anchorFile) && existingAnchors.has(anchorFile))
479
487
  .map((anchorFile) => `${relativeRoot}${anchorFile}`);
480
488
  const mustflowConfig = resolveAnchor('.mustflow/config/mustflow.toml');
481
489
  const commandContract = resolveAnchor('.mustflow/config/commands.toml');
@@ -502,9 +510,15 @@ function discoverNestedRepositories(projectRoot, mapConfig, workspaceConfig) {
502
510
  const repositories = [];
503
511
  const seenRepositoryPaths = new Set();
504
512
  const seenDirectoryPaths = new Set();
505
- const projectRootRealPath = realpathSync(projectRoot);
513
+ let projectRootRealPath;
514
+ try {
515
+ projectRootRealPath = realpathSync(projectRoot);
516
+ }
517
+ catch {
518
+ return [];
519
+ }
506
520
  function visit(directoryTarget, depth) {
507
- if (repositories.length >= workspaceConfig.maxRepositories || depth > workspaceConfig.maxDepth) {
521
+ if (repositories.length >= workspaceConfig.maxRepositories || depth >= workspaceConfig.maxDepth) {
508
522
  return;
509
523
  }
510
524
  if (seenDirectoryPaths.has(directoryTarget.realPath)) {
@@ -7,6 +7,7 @@ import { inspectActiveRunLocks, } from '../../core/active-run-locks.js';
7
7
  import { isRecord, readPositiveInteger, readString, readStringArray, } from '../../core/config-loading.js';
8
8
  import { DEFAULT_COMMAND_MAX_OUTPUT_BYTES, COMMAND_OUTPUT_LIMIT_SCOPE, MAX_COMMAND_OUTPUT_BYTES, commandMaxOutputBytesLimitMessage, } from '../../core/command-output-limits.js';
9
9
  import { normalizeSuccessExitCodes } from '../../core/success-exit-codes.js';
10
+ import { normalizeSafeTestTargetPath, TEST_TARGET_PATH_ERROR } from '../../core/test-target-paths.js';
10
11
  import { evaluateCommandPreconditions, } from '../../core/command-preconditions.js';
11
12
  import { t } from './i18n.js';
12
13
  function getSuccessExitCodes(intent) {
@@ -28,12 +29,18 @@ function getRelativeProjectPath(projectRoot, targetPath) {
28
29
  return relativePath.length > 0 ? toPosixPath(relativePath) : '.';
29
30
  }
30
31
  function normalizeTestTargets(values) {
31
- return [
32
- ...new Set((values ?? [])
33
- .map((value) => value.trim().replace(/\\/g, '/'))
34
- .filter((value) => value.length > 0 && !path.posix.isAbsolute(value) && !path.win32.isAbsolute(value))
35
- .filter((value) => value.split('/').every((segment) => segment.length > 0 && segment !== '.' && segment !== '..'))),
36
- ].sort((left, right) => left.localeCompare(right));
32
+ const normalizedValues = [];
33
+ for (const value of values ?? []) {
34
+ const normalized = normalizeSafeTestTargetPath(value);
35
+ if (normalized === null) {
36
+ return { ok: false, detail: `Test target ${JSON.stringify(value)} is invalid: ${TEST_TARGET_PATH_ERROR}.` };
37
+ }
38
+ normalizedValues.push(normalized);
39
+ }
40
+ return {
41
+ ok: true,
42
+ values: [...new Set(normalizedValues)].sort((left, right) => left.localeCompare(right)),
43
+ };
37
44
  }
38
45
  function commandAcceptsTestTargets(intent) {
39
46
  return isRecord(intent.selection) && intent.selection.accepts_test_targets === true;
@@ -190,7 +197,13 @@ export function createRunPlan(projectRoot, contract, intentName, options = {}) {
190
197
  catch (error) {
191
198
  return createBlockedRunPlan(contract, intentName, rawIntent, eligibility, 'cwd_outside_project', error instanceof Error ? error.message : String(error), preconditions);
192
199
  }
193
- const testTargets = commandAcceptsTestTargets(rawIntent) ? normalizeTestTargets(options.testTargets) : [];
200
+ const normalizedTestTargets = commandAcceptsTestTargets(rawIntent) ?
201
+ normalizeTestTargets(options.testTargets) :
202
+ { ok: true, values: [] };
203
+ if (!normalizedTestTargets.ok) {
204
+ return createBlockedRunPlan(contract, intentName, rawIntent, eligibility, 'invalid_test_target', normalizedTestTargets.detail, preconditions);
205
+ }
206
+ const testTargets = normalizedTestTargets.values;
194
207
  const commandArgv = metadata.commandArgv && testTargets.length > 0 ? [...metadata.commandArgv, ...testTargets] : metadata.commandArgv;
195
208
  if (!metadata.timeoutSeconds || !metadata.mode) {
196
209
  return createBlockedRunPlan(contract, intentName, rawIntent, eligibility, !metadata.timeoutSeconds ? 'missing_timeout' : 'missing_command_source', !metadata.timeoutSeconds ? 'Intent timeout_seconds is missing or invalid.' : 'Intent does not define argv or shell cmd.', preconditions);
@@ -1,8 +1,39 @@
1
- import { MANIFEST_LOCK_RELATIVE_PATH, readManifestLock } from './manifest-lock.js';
1
+ import { MANIFEST_LOCK_RELATIVE_PATH, inspectManifestLock } from './manifest-lock.js';
2
2
  export const ALLOW_UNTRUSTED_ROOT_OPTION = '--allow-untrusted-root';
3
+ const REQUIRED_RUN_TRUST_LOCK_PATHS = [
4
+ 'AGENTS.md',
5
+ '.mustflow/config/commands.toml',
6
+ ];
3
7
  export function assessRunRootTrust(projectRoot) {
4
- const readResult = readManifestLock(projectRoot);
8
+ const inspection = inspectManifestLock(projectRoot);
9
+ const { readResult } = inspection;
5
10
  if (readResult.kind === 'present') {
11
+ if (readResult.lock.files.length === 0) {
12
+ return {
13
+ trusted: false,
14
+ reason: 'manifest_lock_invalid',
15
+ manifestLockPath: readResult.lockPath,
16
+ detail: 'Manifest lock must track at least one file.',
17
+ };
18
+ }
19
+ const trackedPaths = new Set(readResult.lock.files.map((file) => file.relativePath));
20
+ const missingRequiredPath = REQUIRED_RUN_TRUST_LOCK_PATHS.find((relativePath) => !trackedPaths.has(relativePath));
21
+ if (missingRequiredPath) {
22
+ return {
23
+ trusted: false,
24
+ reason: 'manifest_lock_invalid',
25
+ manifestLockPath: readResult.lockPath,
26
+ detail: `Manifest lock must track ${missingRequiredPath}.`,
27
+ };
28
+ }
29
+ if (inspection.issues.length > 0) {
30
+ return {
31
+ trusted: false,
32
+ reason: 'manifest_lock_invalid',
33
+ manifestLockPath: readResult.lockPath,
34
+ detail: inspection.issues[0] ?? 'Manifest lock does not match the current workflow files.',
35
+ };
36
+ }
6
37
  return {
7
38
  trusted: true,
8
39
  reason: 'manifest_lock_present',
@@ -474,11 +474,15 @@ function validateSkills(projectRoot, issues) {
474
474
  function readSkillSectionIds(content) {
475
475
  return new Set([...content.matchAll(SKILL_SECTION_MARKER_PATTERN)].map((match) => match[1]));
476
476
  }
477
+ function findFrontmatterEnd(content) {
478
+ const match = /\n---(?:\r?\n|$)/u.exec(content.slice(3));
479
+ return match ? 3 + match.index : -1;
480
+ }
477
481
  function parseSimpleFrontmatter(content) {
478
482
  if (!content.startsWith('---')) {
479
483
  return {};
480
484
  }
481
- const end = content.indexOf('\n---', 3);
485
+ const end = findFrontmatterEnd(content);
482
486
  if (end === -1) {
483
487
  return {};
484
488
  }
@@ -510,7 +514,7 @@ function readFrontmatterLines(content) {
510
514
  if (!content.startsWith('---')) {
511
515
  return [];
512
516
  }
513
- const end = content.indexOf('\n---', 3);
517
+ const end = findFrontmatterEnd(content);
514
518
  if (end === -1) {
515
519
  return [];
516
520
  }
@@ -2,9 +2,19 @@ import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { isRecord } from '../command-contract.js';
4
4
  import { readMustflowTomlFile } from '../toml.js';
5
+ import { normalizeSafeTestTargetPath, TEST_TARGET_PATH_ERROR } from '../../../core/test-target-paths.js';
5
6
  import { ALLOWED_TEST_SELECTION_RISKS, FORBIDDEN_TEST_SELECTION_COMMAND_AUTHORITY_FIELDS, TEST_SELECTION_CONFIG_PATH, } from './constants.js';
6
7
  import { isConfiguredCommandIntent, isDeclaredCommandIntent } from './command-intents.js';
7
8
  import { hasOwn, pushStrictIssue, validateAllowedStringField, validateNestedTable, validatePathArrayField, validateRequiredStringField, validateStringArrayField, } from './primitives.js';
9
+ function validateTestTargetPathArrayField(table, key, label, issues) {
10
+ if (!hasOwn(table, key)) {
11
+ return;
12
+ }
13
+ const value = table[key];
14
+ if (!Array.isArray(value) || value.length === 0 || !value.every((entry) => normalizeSafeTestTargetPath(entry) !== null)) {
15
+ issues.push({ message: `${label} ${TEST_TARGET_PATH_ERROR}` });
16
+ }
17
+ }
8
18
  function validateNoTestSelectionCommandAuthorityFields(label, table, issues) {
9
19
  for (const field of FORBIDDEN_TEST_SELECTION_COMMAND_AUTHORITY_FIELDS) {
10
20
  if (hasOwn(table, field)) {
@@ -59,7 +69,7 @@ function validateTestSelectionRule(rule, index, commandsToml, issues) {
59
69
  validateNoTestSelectionCommandAuthorityFields(`${label}.select`, select, issues);
60
70
  validateTestSelectionIntentReference(select.intent, `${label}.select.intent`, commandsToml, issues);
61
71
  validateTestSelectionIntentReference(select.fallback_intent, `${label}.select.fallback_intent`, commandsToml, issues);
62
- validatePathArrayField(select, 'test_targets', `${TEST_SELECTION_CONFIG_PATH} ${label}.select.test_targets`, issues);
72
+ validateTestTargetPathArrayField(select, 'test_targets', `${TEST_SELECTION_CONFIG_PATH} ${label}.select.test_targets`, issues);
63
73
  }
64
74
  }
65
75
  export function validateStrictTestSelectionConfig(projectRoot, commandsToml, issues) {
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { commandEffectsConflict, normalizeCommandEffects, } from './command-effects.js';
5
5
  const ACTIVE_LOCK_SCHEMA_VERSION = '1';
@@ -8,8 +8,9 @@ const LOCK_ROOT_RELATIVE_PATH = '.mustflow/state/locks';
8
8
  const LOCK_MUTEX_STALE_MS = 30_000;
9
9
  const LOCK_MUTEX_WAIT_MS = 1_000;
10
10
  const LOCK_MUTEX_SLEEP_MS = 25;
11
+ const LOCK_MUTEX_SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
11
12
  function sleep(milliseconds) {
12
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
13
+ Atomics.wait(LOCK_MUTEX_SLEEP_BUFFER, 0, 0, milliseconds);
13
14
  }
14
15
  function sha256(value) {
15
16
  return createHash('sha256').update(value).digest('hex');
@@ -194,20 +195,38 @@ function createRecord(projectRoot, intentName, effects, commandHash) {
194
195
  function acquireMutex(projectRoot) {
195
196
  const root = activeLockRoot(projectRoot);
196
197
  const mutex = activeLockMutexDirectory(projectRoot);
198
+ const ownerPath = path.join(mutex, 'owner.json');
199
+ const ownerToken = sha256(`${process.pid}:${Date.now()}:${process.hrtime.bigint()}`);
197
200
  mkdirSync(root, { recursive: true });
198
201
  let startedAt = Date.now();
199
202
  while (true) {
200
203
  try {
201
204
  mkdirSync(mutex);
202
- writeFileSync(path.join(mutex, 'owner.json'), JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }, null, 2));
203
- return () => rmSync(mutex, { recursive: true, force: true });
205
+ const ownerRecord = { pid: process.pid, started_at: new Date().toISOString(), token: ownerToken };
206
+ try {
207
+ writeFileSync(ownerPath, JSON.stringify(ownerRecord, null, 2));
208
+ }
209
+ catch (error) {
210
+ rmSync(mutex, { recursive: true, force: true });
211
+ throw error;
212
+ }
213
+ return () => {
214
+ try {
215
+ const owner = JSON.parse(readFileSync(ownerPath, 'utf8'));
216
+ if (Number(owner.pid) === ownerRecord.pid && owner.token === ownerRecord.token) {
217
+ rmSync(mutex, { recursive: true, force: true });
218
+ }
219
+ }
220
+ catch {
221
+ // A missing or replaced owner file means this process no longer owns the mutex.
222
+ }
223
+ };
204
224
  }
205
225
  catch (error) {
206
226
  if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'EEXIST') {
207
227
  throw error;
208
228
  }
209
229
  if (Date.now() - startedAt > LOCK_MUTEX_WAIT_MS) {
210
- const ownerPath = path.join(mutex, 'owner.json');
211
230
  try {
212
231
  const owner = JSON.parse(readFileSync(ownerPath, 'utf8'));
213
232
  const ownerPid = Number(owner.pid);
@@ -220,9 +239,18 @@ function acquireMutex(projectRoot) {
220
239
  }
221
240
  }
222
241
  catch {
223
- rmSync(mutex, { recursive: true, force: true });
224
- startedAt = Date.now();
225
- continue;
242
+ try {
243
+ const mutexStat = statSync(mutex);
244
+ if (Date.now() - mutexStat.mtimeMs > LOCK_MUTEX_STALE_MS) {
245
+ rmSync(mutex, { recursive: true, force: true });
246
+ startedAt = Date.now();
247
+ continue;
248
+ }
249
+ }
250
+ catch {
251
+ startedAt = Date.now();
252
+ continue;
253
+ }
226
254
  }
227
255
  throw new Error('active_run_lock_mutex_busy');
228
256
  }
@@ -1,30 +1,15 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { mkdirSync } from 'node:fs';
3
3
  import path from 'node:path';
4
- function tempFilePath(targetPath) {
5
- const suffix = `${process.pid}-${Date.now()}-${randomBytes(6).toString('hex')}`;
6
- return path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.${suffix}.tmp`);
7
- }
4
+ import { writeUtf8FileInsideWithoutSymlinks } from './safe-filesystem.js';
8
5
  export function createStateRunId(prefix) {
9
6
  const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-');
10
7
  return `${prefix}-${timestamp}-${process.pid}-${randomBytes(6).toString('hex')}`;
11
8
  }
12
9
  export function atomicWriteTextFile(targetPath, content) {
13
- mkdirSync(path.dirname(targetPath), { recursive: true });
14
- const temporaryPath = tempFilePath(targetPath);
15
- try {
16
- writeFileSync(temporaryPath, content, { encoding: 'utf8', flag: 'wx' });
17
- renameSync(temporaryPath, targetPath);
18
- }
19
- catch (error) {
20
- try {
21
- unlinkSync(temporaryPath);
22
- }
23
- catch {
24
- // Best-effort cleanup for a temporary file that may not have been created.
25
- }
26
- throw error;
27
- }
10
+ const targetDirectory = path.dirname(targetPath);
11
+ mkdirSync(targetDirectory, { recursive: true });
12
+ writeUtf8FileInsideWithoutSymlinks(targetDirectory, targetPath, content);
28
13
  }
29
14
  export function atomicWriteJsonFile(targetPath, value) {
30
15
  atomicWriteTextFile(targetPath, `${JSON.stringify(value, null, 2)}\n`);
@@ -188,11 +188,24 @@ function intentIsExplicitlySubsumed(commandContract, narrowerIntent, broaderInte
188
188
  return (intentExplicitlySubsumedBy(commandContract, narrowerIntent, broaderIntent) ||
189
189
  intentExplicitlySubsumes(commandContract, broaderIntent, narrowerIntent));
190
190
  }
191
+ function minNumber(values) {
192
+ let minimum = null;
193
+ for (const value of values) {
194
+ minimum = minimum === null ? value : Math.min(minimum, value);
195
+ }
196
+ return minimum;
197
+ }
191
198
  function selectVerificationCandidates(commandContract, candidates) {
192
199
  const runnableCandidates = candidates.filter((candidate) => candidate.status === 'runnable' && candidate.intent.length > 0);
193
200
  const selectedIntents = new Set(runnableCandidates.map((candidate) => candidate.intent));
194
201
  for (const candidate of runnableCandidates) {
195
- const isSubsumed = runnableCandidates.some((other) => other.intent !== candidate.intent && intentIsExplicitlySubsumed(commandContract, candidate.intent, other.intent));
202
+ const isSubsumed = runnableCandidates.some((other) => {
203
+ if (other.intent === candidate.intent) {
204
+ return false;
205
+ }
206
+ return (intentIsExplicitlySubsumed(commandContract, candidate.intent, other.intent) &&
207
+ !intentIsExplicitlySubsumed(commandContract, other.intent, candidate.intent));
208
+ });
196
209
  if (isSubsumed) {
197
210
  selectedIntents.delete(candidate.intent);
198
211
  }
@@ -217,7 +230,10 @@ function selectVerificationCandidates(commandContract, candidates) {
217
230
  if (costs.some((cost) => cost === null)) {
218
231
  continue;
219
232
  }
220
- const minCost = Math.min(...costs);
233
+ const minCost = minNumber(costs);
234
+ if (minCost === null) {
235
+ continue;
236
+ }
221
237
  const winners = group.filter((candidate) => readIntentCostExpectedSeconds(commandContract, candidate.intent) === minCost);
222
238
  if (winners.length !== 1) {
223
239
  continue;
@@ -76,6 +76,13 @@ export function evaluateCommandIntentEligibility(intentName, rawIntent) {
76
76
  detail: blockedPattern.detail,
77
77
  };
78
78
  }
79
+ if (rawIntent.mode === 'shell' && rawIntent.allow_shell !== true) {
80
+ return {
81
+ ok: false,
82
+ code: 'agent_shell_requires_allow',
83
+ detail: `Agent-runnable shell intent ${intentName} must set allow_shell = true.`,
84
+ };
85
+ }
79
86
  return {
80
87
  ok: true,
81
88
  code: 'ok',
@@ -280,8 +280,8 @@ function pushCoverageFinding(issues, findings, severity, code, reason, intent, i
280
280
  });
281
281
  pushIssue(issues, severity, code, intent, message);
282
282
  }
283
- function configuredIntentIsRunnable(intent) {
284
- return evaluateCommandIntentEligibility('summary', intent).ok;
283
+ function configuredIntentIsRunnable(intentName, intent) {
284
+ return evaluateCommandIntentEligibility(intentName, intent).ok;
285
285
  }
286
286
  function lintIntent(name, value, issues) {
287
287
  if (!commandIntentNameIsSafe(name)) {
@@ -582,7 +582,7 @@ export function lintCommandContract(contract, options = {}) {
582
582
  summary: {
583
583
  totalIntents: intentEntries.length,
584
584
  configured: validIntents.filter((intent) => readString(intent, 'status') === 'configured').length,
585
- runnable: validIntents.filter(configuredIntentIsRunnable).length,
585
+ runnable: intentTables.filter(([name, intent]) => configuredIntentIsRunnable(name, intent)).length,
586
586
  manualOnly: validIntents.filter((intent) => readString(intent, 'status') === 'manual_only').length,
587
587
  unknown: validIntents.filter((intent) => readString(intent, 'status') === 'unknown').length,
588
588
  errors,
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { createCommandEnv } from './command-env.js';
4
5
  import { readFileInsideWithoutSymlinks, writeFileInsideWithoutSymlinks } from './safe-filesystem.js';
5
6
  const GITATTRIBUTES_PATH = '.gitattributes';
6
7
  function toPosixPath(value) {
@@ -18,6 +19,7 @@ function gitList(projectRoot, args) {
18
19
  const result = spawnSync('git', [...args, '-z'], {
19
20
  cwd: projectRoot,
20
21
  encoding: 'buffer',
22
+ env: createCommandEnv(projectRoot, { policy: 'minimal', allowlist: [] }),
21
23
  stdio: ['ignore', 'pipe', 'pipe'],
22
24
  windowsHide: true,
23
25
  });
@@ -124,7 +124,7 @@ export function updateRepeatedFailureState(input) {
124
124
  requires_new_evidence: UNRESOLVED_VERIFY_STATUSES.has(input.status) && seenCount >= 2,
125
125
  };
126
126
  const nextFingerprints = [summary, ...state.fingerprints.filter((entry) => entry.fingerprint !== summary.fingerprint)]
127
- .sort((left, right) => right.last_seen_at.localeCompare(left.last_seen_at))
127
+ .sort((left, right) => (left.last_seen_at > right.last_seen_at ? -1 : left.last_seen_at < right.last_seen_at ? 1 : 0))
128
128
  .slice(0, REPEATED_FAILURE_STATE_LIMIT);
129
129
  writeRepeatedFailureState(input.projectRoot, {
130
130
  schema_version: '1',
@@ -4,6 +4,7 @@ import { existsSync, lstatSync, readFileSync, readlinkSync, readdirSync } from '
4
4
  import path from 'node:path';
5
5
  import { normalizeCommandEffects } from './command-effects.js';
6
6
  const MAX_SNAPSHOT_FILES = 20_000;
7
+ const MAX_SNAPSHOT_DIRECTORY_DEPTH = 200;
7
8
  const MAX_REPORTED_PATHS = 200;
8
9
  const GIT_STATUS_TIMEOUT_MS = 10_000;
9
10
  const GIT_STATUS_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
@@ -40,33 +41,41 @@ function signatureForPath(fullPath) {
40
41
  }
41
42
  function signatureForGitStatusPath(projectRoot, relativePath, status) {
42
43
  const fullPath = path.join(projectRoot, ...relativePath.split('/'));
43
- if (!existsSync(fullPath)) {
44
- return `git:${status}:missing`;
45
- }
46
- const stat = lstatSync(fullPath);
47
- if (stat.isSymbolicLink()) {
48
- return `git:${status}:symlink:${readlinkSync(fullPath)}`;
49
- }
50
- if (!stat.isFile()) {
51
- return `git:${status}:${stat.isDirectory() ? 'directory' : 'other'}:${stat.size}:${stat.mtimeMs}`;
44
+ try {
45
+ if (!existsSync(fullPath)) {
46
+ return `git:${status}:missing`;
47
+ }
48
+ const stat = lstatSync(fullPath);
49
+ if (stat.isSymbolicLink()) {
50
+ return `git:${status}:symlink:${readlinkSync(fullPath)}`;
51
+ }
52
+ if (!stat.isFile()) {
53
+ return `git:${status}:${stat.isDirectory() ? 'directory' : 'other'}:${stat.size}:${stat.mtimeMs}`;
54
+ }
55
+ if (stat.size > MAX_HASH_BYTES) {
56
+ return `git:${status}:file:${stat.size}:${stat.mtimeMs}:unhashed`;
57
+ }
58
+ const digest = createHash('sha256').update(readFileSync(fullPath)).digest('hex');
59
+ return `git:${status}:file:${stat.size}:${digest}`;
52
60
  }
53
- if (stat.size > MAX_HASH_BYTES) {
54
- return `git:${status}:file:${stat.size}:${stat.mtimeMs}:unhashed`;
61
+ catch {
62
+ return `git:${status}:missing`;
55
63
  }
56
- const digest = createHash('sha256').update(readFileSync(fullPath)).digest('hex');
57
- return `git:${status}:file:${stat.size}:${digest}`;
58
64
  }
59
- function collectSnapshotEntries(projectRoot, currentPath, entries) {
65
+ function collectSnapshotEntries(currentPath, currentRelativePath, depth, entries) {
66
+ if (depth > MAX_SNAPSHOT_DIRECTORY_DEPTH) {
67
+ throw new Error('snapshot_directory_depth_limit_exceeded');
68
+ }
60
69
  const names = readdirSync(currentPath).sort((left, right) => left.localeCompare(right));
61
70
  for (const name of names) {
62
71
  const fullPath = path.join(currentPath, name);
63
- const relativePath = normalizeRelativePath(path.relative(projectRoot, fullPath));
72
+ const relativePath = currentRelativePath === '.' ? name : `${currentRelativePath}/${name}`;
64
73
  const stat = lstatSync(fullPath);
65
74
  if (stat.isDirectory()) {
66
75
  if (isExcludedDirectory(relativePath, name)) {
67
76
  continue;
68
77
  }
69
- collectSnapshotEntries(projectRoot, fullPath, entries);
78
+ collectSnapshotEntries(fullPath, relativePath, depth + 1, entries);
70
79
  continue;
71
80
  }
72
81
  if (entries.size >= MAX_SNAPSHOT_FILES) {
@@ -75,8 +84,8 @@ function collectSnapshotEntries(projectRoot, currentPath, entries) {
75
84
  entries.set(relativePath, signatureForPath(fullPath));
76
85
  }
77
86
  }
78
- function captureSnapshot(projectRoot) {
79
- const gitSnapshot = captureGitStatusSnapshot(projectRoot);
87
+ function captureSnapshot(projectRoot, env) {
88
+ const gitSnapshot = captureGitStatusSnapshot(projectRoot, env);
80
89
  if (gitSnapshot) {
81
90
  return gitSnapshot;
82
91
  }
@@ -90,7 +99,7 @@ function captureSnapshot(projectRoot) {
90
99
  }
91
100
  try {
92
101
  const entries = new Map();
93
- collectSnapshotEntries(projectRoot, projectRoot, entries);
102
+ collectSnapshotEntries(projectRoot, '.', 0, entries);
94
103
  return { status: 'checked', entries, reason: null, source: 'recursive_snapshot' };
95
104
  }
96
105
  catch (error) {
@@ -102,9 +111,10 @@ function captureSnapshot(projectRoot) {
102
111
  };
103
112
  }
104
113
  }
105
- function captureGitStatusSnapshot(projectRoot) {
114
+ function captureGitStatusSnapshot(projectRoot, env) {
106
115
  const result = spawnSync('git', ['-C', projectRoot, 'status', '--porcelain=v1', '-z', `--untracked-files=${GIT_STATUS_UNTRACKED_MODE}`], {
107
116
  encoding: 'utf8',
117
+ env,
108
118
  input: '',
109
119
  maxBuffer: GIT_STATUS_MAX_BUFFER_BYTES,
110
120
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -144,6 +154,10 @@ function captureGitStatusSnapshot(projectRoot) {
144
154
  }
145
155
  entries.set(filePath, signatureForGitStatusPath(projectRoot, filePath, status));
146
156
  if (status.includes('R') || status.includes('C')) {
157
+ const sourcePath = normalizeRelativePath(parts[index + 1] ?? '');
158
+ if (status.includes('R') && sourcePath.length > 0) {
159
+ entries.set(sourcePath, `git:${status}:missing`);
160
+ }
147
161
  index += 1;
148
162
  }
149
163
  }
@@ -206,21 +220,23 @@ function createUnavailableWriteDriftReceipt(declaredPaths, reason) {
206
220
  reason,
207
221
  };
208
222
  }
209
- export function startRunWriteTracking(projectRoot, contract, intentName, options = {}) {
223
+ export function startRunWriteTracking(projectRoot, contract, intentName, options) {
210
224
  const declaredPaths = [
211
225
  ...listDeclaredWritePaths(projectRoot, contract, intentName),
212
226
  ...(options.additionalDeclaredPaths ?? []).map(normalizeRelativePath),
213
227
  ];
214
228
  return {
215
229
  projectRoot,
230
+ env: options.env,
216
231
  declaredPaths: [...new Set(declaredPaths)].sort((left, right) => left.localeCompare(right)),
217
- before: captureSnapshot(projectRoot),
232
+ before: captureSnapshot(projectRoot, options.env),
218
233
  };
219
234
  }
220
- export function startRunWriteBatchTracking(projectRoot) {
235
+ export function startRunWriteBatchTracking(projectRoot, env) {
221
236
  return {
222
237
  projectRoot,
223
- before: captureSnapshot(projectRoot),
238
+ env,
239
+ before: captureSnapshot(projectRoot, env),
224
240
  };
225
241
  }
226
242
  export function finishRunWriteBatchTracking(tracker, intents) {
@@ -232,7 +248,7 @@ export function finishRunWriteBatchTracking(tracker, intents) {
232
248
  if (tracker.before.status === 'unavailable') {
233
249
  return fallbackReceipts;
234
250
  }
235
- const after = captureSnapshot(tracker.projectRoot);
251
+ const after = captureSnapshot(tracker.projectRoot, tracker.env);
236
252
  if (after.status === 'unavailable') {
237
253
  return new Map(intents.map((intent) => [
238
254
  intent.intentName,
@@ -309,7 +325,7 @@ export function finishRunWriteTracking(tracker) {
309
325
  if (tracker.before.status === 'unavailable') {
310
326
  return createUnavailableWriteDriftReceipt(tracker.declaredPaths, tracker.before.reason);
311
327
  }
312
- const after = captureSnapshot(tracker.projectRoot);
328
+ const after = captureSnapshot(tracker.projectRoot, tracker.env);
313
329
  if (after.status === 'unavailable') {
314
330
  return createUnavailableWriteDriftReceipt(tracker.declaredPaths, after.reason);
315
331
  }