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.
- package/dist/cli/commands/dashboard.js +51 -4
- package/dist/cli/commands/explain.js +3 -2
- package/dist/cli/commands/help.js +0 -1
- package/dist/cli/commands/run.js +51 -4
- package/dist/cli/commands/verify.js +2 -1
- package/dist/cli/i18n/en.js +5 -0
- package/dist/cli/i18n/es.js +5 -0
- package/dist/cli/i18n/fr.js +5 -0
- package/dist/cli/i18n/hi.js +5 -0
- package/dist/cli/i18n/ko.js +5 -0
- package/dist/cli/i18n/zh.js +5 -0
- package/dist/cli/lib/cli-output.js +1 -1
- package/dist/cli/lib/dashboard-html/client-script.js +9 -0
- package/dist/cli/lib/dashboard-html/styles.js +48 -1
- package/dist/cli/lib/doc-review-ledger.js +1 -1
- package/dist/cli/lib/git-changes.js +2 -0
- package/dist/cli/lib/local-index/index.js +324 -298
- package/dist/cli/lib/repo-map.js +19 -5
- package/dist/cli/lib/run-plan.js +20 -7
- package/dist/cli/lib/run-root-trust.js +33 -2
- package/dist/cli/lib/validation/index.js +6 -2
- package/dist/cli/lib/validation/test-selection.js +11 -1
- package/dist/core/active-run-locks.js +36 -8
- package/dist/core/atomic-state-write.js +5 -20
- package/dist/core/change-verification.js +18 -2
- package/dist/core/command-intent-eligibility.js +7 -0
- package/dist/core/contract-lint.js +3 -3
- package/dist/core/line-endings.js +2 -0
- package/dist/core/repeated-failure.js +1 -1
- package/dist/core/run-write-drift.js +42 -26
- package/dist/core/safe-filesystem.js +54 -5
- package/dist/core/skill-route-explanation.js +2 -1
- package/dist/core/source-anchors.js +7 -3
- package/dist/core/test-selection.js +13 -2
- package/dist/core/test-target-paths.js +17 -0
- package/dist/core/validation-ratchet.js +62 -17
- package/dist/core/verification-decision-graph.js +8 -1
- package/package.json +1 -1
- package/templates/default/i18n.toml +141 -3
- package/templates/default/locales/en/.mustflow/skills/INDEX.md +24 -1
- package/templates/default/locales/en/.mustflow/skills/api-contract-change/SKILL.md +212 -0
- package/templates/default/locales/en/.mustflow/skills/astro-code-change/SKILL.md +184 -0
- package/templates/default/locales/en/.mustflow/skills/auth-permission-change/SKILL.md +194 -0
- package/templates/default/locales/en/.mustflow/skills/config-env-change/SKILL.md +189 -0
- package/templates/default/locales/en/.mustflow/skills/css-code-change/SKILL.md +199 -0
- package/templates/default/locales/en/.mustflow/skills/dart-code-change/SKILL.md +179 -0
- package/templates/default/locales/en/.mustflow/skills/database-migration-change/SKILL.md +178 -0
- package/templates/default/locales/en/.mustflow/skills/dependency-upgrade-review/SKILL.md +151 -0
- package/templates/default/locales/en/.mustflow/skills/elysia-code-change/SKILL.md +115 -0
- package/templates/default/locales/en/.mustflow/skills/file-path-cross-platform-change/SKILL.md +147 -0
- package/templates/default/locales/en/.mustflow/skills/flutter-code-change/SKILL.md +116 -0
- package/templates/default/locales/en/.mustflow/skills/go-code-change/SKILL.md +156 -0
- package/templates/default/locales/en/.mustflow/skills/hono-code-change/SKILL.md +117 -0
- package/templates/default/locales/en/.mustflow/skills/html-code-change/SKILL.md +173 -0
- package/templates/default/locales/en/.mustflow/skills/javascript-code-change/SKILL.md +149 -0
- package/templates/default/locales/en/.mustflow/skills/python-code-change/SKILL.md +154 -0
- package/templates/default/locales/en/.mustflow/skills/release-publish-change/SKILL.md +172 -0
- package/templates/default/locales/en/.mustflow/skills/routes.toml +138 -0
- package/templates/default/locales/en/.mustflow/skills/rust-code-change/SKILL.md +154 -0
- package/templates/default/locales/en/.mustflow/skills/security-privacy-review/SKILL.md +22 -7
- package/templates/default/locales/en/.mustflow/skills/security-regression-tests/SKILL.md +31 -20
- package/templates/default/locales/en/.mustflow/skills/svelte-code-change/SKILL.md +186 -0
- package/templates/default/locales/en/.mustflow/skills/tailwind-code-change/SKILL.md +164 -0
- package/templates/default/locales/en/.mustflow/skills/tauri-code-change/SKILL.md +185 -0
- package/templates/default/locales/en/.mustflow/skills/typescript-code-change/SKILL.md +184 -0
- package/templates/default/locales/en/.mustflow/skills/unocss-code-change/SKILL.md +186 -0
- package/templates/default/manifest.toml +158 -1
package/dist/cli/lib/repo-map.js
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
521
|
+
if (repositories.length >= workspaceConfig.maxRepositories || depth >= workspaceConfig.maxDepth) {
|
|
508
522
|
return;
|
|
509
523
|
}
|
|
510
524
|
if (seenDirectoryPaths.has(directoryTarget.realPath)) {
|
package/dist/cli/lib/run-plan.js
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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) =>
|
|
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 =
|
|
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(
|
|
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:
|
|
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
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
return `git:${status}:
|
|
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(
|
|
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 =
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
}
|