dsh-math-modeling-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +127 -0
  3. package/cordis.patch.yml +8 -0
  4. package/package.json +34 -0
  5. package/skills/math-modeling-agent/SKILL.md +50 -0
  6. package/skills/math-modeling-agent/examples/minimal-run/README.md +14 -0
  7. package/skills/math-modeling-agent/examples/resumed-run/README.md +13 -0
  8. package/skills/math-modeling-agent/references/claims-evidence.md +29 -0
  9. package/skills/math-modeling-agent/references/data-subproblems.md +17 -0
  10. package/skills/math-modeling-agent/references/math-grill.md +33 -0
  11. package/skills/math-modeling-agent/references/modeling-methodology.md +38 -0
  12. package/skills/math-modeling-agent/references/problem-types.md +20 -0
  13. package/skills/math-modeling-agent/references/report-contract.md +29 -0
  14. package/skills/math-modeling-agent/references/research-breakthrough.md +27 -0
  15. package/skills/math-modeling-agent/references/state-recovery.md +18 -0
  16. package/skills/math-modeling-agent/references/tool-policy.md +21 -0
  17. package/skills/math-modeling-agent/references/workflow.md +39 -0
  18. package/skills/math-modeling-agent/schemas/attempt.schema.json +70 -0
  19. package/skills/math-modeling-agent/schemas/ledger.schema.json +47 -0
  20. package/skills/math-modeling-agent/schemas/run.schema.json +102 -0
  21. package/skills/math-modeling-agent/scripts/capability-probe.mjs +105 -0
  22. package/skills/math-modeling-agent/scripts/python-environment.mjs +408 -0
  23. package/skills/math-modeling-agent/scripts/run-state.mjs +547 -0
  24. package/skills/math-modeling-audit/SKILL.md +41 -0
  25. package/skills/math-modeling-audit/examples/audit-report.md +13 -0
  26. package/skills/math-modeling-audit/examples/mcm-final-review.md +86 -0
  27. package/skills/math-modeling-audit/references/data-citation-audit.md +15 -0
  28. package/skills/math-modeling-audit/references/evidence-levels.md +10 -0
  29. package/skills/math-modeling-audit/references/mcm-icm-final-judge.md +331 -0
  30. package/skills/math-modeling-audit/references/verification-protocol.md +27 -0
  31. package/skills/math-modeling-audit/scripts/mcm-score.mjs +218 -0
@@ -0,0 +1,47 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/yohanchen1/MathModelingAgent/schemas/ledger.schema.json",
4
+ "title": "Math Modeling Ledger",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "schemaVersion",
9
+ "taskId",
10
+ "scope",
11
+ "assumptions",
12
+ "claims",
13
+ "obligations",
14
+ "subproblems",
15
+ "candidates",
16
+ "issues"
17
+ ],
18
+ "properties": {
19
+ "schemaVersion": {
20
+ "const": 1
21
+ },
22
+ "taskId": {
23
+ "type": "string"
24
+ },
25
+ "scope": {
26
+ "type": "object"
27
+ },
28
+ "assumptions": {
29
+ "type": "array"
30
+ },
31
+ "claims": {
32
+ "type": "array"
33
+ },
34
+ "obligations": {
35
+ "type": "array"
36
+ },
37
+ "subproblems": {
38
+ "type": "array"
39
+ },
40
+ "candidates": {
41
+ "type": "array"
42
+ },
43
+ "issues": {
44
+ "type": "array"
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,102 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/yohanchen1/MathModelingAgent/schemas/run.schema.json",
4
+ "title": "Math Modeling Run",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "schemaVersion",
9
+ "taskId",
10
+ "mode",
11
+ "status",
12
+ "eventSequence",
13
+ "currentAttempt",
14
+ "bestCandidateId",
15
+ "budget",
16
+ "createdAt",
17
+ "updatedAt"
18
+ ],
19
+ "properties": {
20
+ "schemaVersion": {
21
+ "const": 1
22
+ },
23
+ "taskId": {
24
+ "type": "string",
25
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
26
+ },
27
+ "mode": {
28
+ "enum": [
29
+ "fast",
30
+ "standard",
31
+ "high-assurance"
32
+ ]
33
+ },
34
+ "status": {
35
+ "enum": [
36
+ "TRIAGE",
37
+ "SCOPE_FROZEN",
38
+ "INPUT_PROFILED",
39
+ "CLAIMS_REGISTERED",
40
+ "CANDIDATES_READY",
41
+ "ATTEMPT",
42
+ "EXECUTE",
43
+ "VERIFY",
44
+ "REVISE",
45
+ "RESEARCH",
46
+ "FORK",
47
+ "SOLVED",
48
+ "PARTIAL",
49
+ "CONDITIONAL",
50
+ "INCONCLUSIVE",
51
+ "REFUTED",
52
+ "INFEASIBLE",
53
+ "UNIDENTIFIABLE",
54
+ "BLOCKED",
55
+ "CANCELLED"
56
+ ]
57
+ },
58
+ "eventSequence": {
59
+ "type": "integer",
60
+ "minimum": 0
61
+ },
62
+ "currentAttempt": {
63
+ "type": "integer",
64
+ "minimum": 0
65
+ },
66
+ "bestCandidateId": {
67
+ "type": [
68
+ "string",
69
+ "null"
70
+ ]
71
+ },
72
+ "budget": {
73
+ "type": "object",
74
+ "additionalProperties": false,
75
+ "required": [
76
+ "attempts",
77
+ "researchQueries",
78
+ "computeSeconds"
79
+ ],
80
+ "properties": {
81
+ "attempts": {
82
+ "type": "integer",
83
+ "minimum": 1
84
+ },
85
+ "researchQueries": {
86
+ "type": "integer",
87
+ "minimum": 0
88
+ },
89
+ "computeSeconds": {
90
+ "type": "integer",
91
+ "minimum": 0
92
+ }
93
+ }
94
+ },
95
+ "createdAt": {
96
+ "type": "string"
97
+ },
98
+ "updatedAt": {
99
+ "type": "string"
100
+ }
101
+ }
102
+ }
@@ -0,0 +1,105 @@
1
+ import { accessSync, constants, existsSync, statSync } from 'node:fs'
2
+ import { delimiter, extname, join } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+
5
+ const REASON = 'Found on PATH; execute through the DSH shell to verify version and usability.'
6
+
7
+ const TOOL_CANDIDATES = {
8
+ uv: ['uv'],
9
+ python: ['python3', 'python', 'py'],
10
+ lean: ['lean'],
11
+ lake: ['lake'],
12
+ wolfram: ['wolframscript', 'WolframScript'],
13
+ }
14
+
15
+ function executableExtensions(env, platform) {
16
+ if (platform !== 'win32') return ['']
17
+ const raw = env.PATHEXT || '.COM;.EXE;.BAT;.CMD'
18
+ return raw
19
+ .split(';')
20
+ .map((value) => value.trim())
21
+ .filter(Boolean)
22
+ .map((value) => value.toLowerCase())
23
+ }
24
+
25
+ function candidateNames(name, extensions, platform) {
26
+ if (platform !== 'win32' || extname(name)) return [name]
27
+ return extensions.map((extension) => `${name}${extension}`)
28
+ }
29
+
30
+ function isExecutable(path, platform) {
31
+ if (!existsSync(path)) return false
32
+ try {
33
+ if (!statSync(path).isFile()) return false
34
+ accessSync(path, platform === 'win32' ? constants.F_OK : constants.X_OK)
35
+ return true
36
+ } catch {
37
+ return false
38
+ }
39
+ }
40
+
41
+ export function findExecutable(names, options = {}) {
42
+ const env = options.env ?? process.env
43
+ const platform = options.platform ?? process.platform
44
+ const directories = (env.PATH ?? '').split(delimiter).filter(Boolean)
45
+ const extensions = executableExtensions(env, platform)
46
+
47
+ for (const directory of directories) {
48
+ for (const name of names) {
49
+ for (const candidate of candidateNames(name, extensions, platform)) {
50
+ const path = join(directory, candidate)
51
+ if (isExecutable(path, platform)) return path
52
+ }
53
+ }
54
+ }
55
+ return undefined
56
+ }
57
+
58
+ function toolRecord(key, names, options) {
59
+ const executable = findExecutable(names, options)
60
+ if (!executable) {
61
+ return {
62
+ status: 'unavailable',
63
+ executable: null,
64
+ kind: key,
65
+ reason: 'No candidate executable was found on PATH.',
66
+ }
67
+ }
68
+ return {
69
+ status: 'candidate',
70
+ executable,
71
+ kind: key,
72
+ reason: REASON,
73
+ }
74
+ }
75
+
76
+ export function probeCapabilities(options = {}) {
77
+ const env = options.env ?? process.env
78
+ const platform = options.platform ?? process.platform
79
+ return {
80
+ schemaVersion: 1,
81
+ platform,
82
+ node: {
83
+ status: 'available',
84
+ executable: process.execPath,
85
+ version: process.version,
86
+ },
87
+ tools: Object.fromEntries(
88
+ Object.entries(TOOL_CANDIDATES).map(([key, names]) => [
89
+ key,
90
+ toolRecord(key, names, { env, platform }),
91
+ ]),
92
+ ),
93
+ }
94
+ }
95
+
96
+ function main() {
97
+ const pretty = process.argv.includes('--pretty')
98
+ process.stdout.write(
99
+ `${JSON.stringify(probeCapabilities(), null, pretty ? 2 : 0)}
100
+ `,
101
+ )
102
+ }
103
+
104
+ const invokedPath = process.argv[1]
105
+ if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) main()
@@ -0,0 +1,408 @@
1
+ import { closeSync, openSync } from 'node:fs'
2
+ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
3
+ import { randomUUID } from 'node:crypto'
4
+ import { dirname, isAbsolute, posix, resolve, win32 } from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+ import { pathToFileURL } from 'node:url'
7
+
8
+ const PACKAGE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*(?:==[A-Za-z0-9][A-Za-z0-9._+-]*)?$/
9
+ const REPAIR_GUIDANCE = 'Remove the inconsistent Python environment artifacts and retry.'
10
+
11
+ export function validatePackageSpec(value) {
12
+ if (!PACKAGE_PATTERN.test(value)) throw new TypeError(`unsafe package spec: ${value}`)
13
+ return value
14
+ }
15
+
16
+ function environmentPython(environmentDir, platform, resolvePath) {
17
+ return platform === 'win32'
18
+ ? resolvePath(environmentDir, 'Scripts', 'python.exe')
19
+ : resolvePath(environmentDir, 'bin', 'python')
20
+ }
21
+
22
+ function temporaryPath(path) {
23
+ return `${path}.${process.pid}.${randomUUID()}.tmp`
24
+ }
25
+
26
+ async function atomicWriteJson(path, value, { renamePath = rename, removePath = rm } = {}) {
27
+ await mkdir(dirname(path), { recursive: true })
28
+ const temporary = temporaryPath(path)
29
+ try {
30
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`)
31
+ await renamePath(temporary, path)
32
+ } catch (error) {
33
+ await removePath(temporary, { force: true }).catch(() => {})
34
+ throw error
35
+ }
36
+ }
37
+
38
+ export function defaultSpawnOptions({ cwd, stdoutDescriptor } = {}) {
39
+ return {
40
+ cwd,
41
+ shell: false,
42
+ stdio: ['ignore', stdoutDescriptor ?? 2, 2],
43
+ }
44
+ }
45
+
46
+ async function defaultRun(command, args, options = {}) {
47
+ let descriptor
48
+ try {
49
+ descriptor = options.stdoutFile ? openSync(options.stdoutFile, 'w') : undefined
50
+ const result = spawnSync(command, args, defaultSpawnOptions({
51
+ cwd: options.cwd,
52
+ stdoutDescriptor: descriptor,
53
+ }))
54
+ return result.status ?? 1
55
+ } finally {
56
+ if (descriptor !== undefined) closeSync(descriptor)
57
+ }
58
+ }
59
+
60
+ function assertSuccess(status, label) {
61
+ if (status !== 0) throw new Error(`${label} failed with exit code ${status}`)
62
+ }
63
+
64
+ function pathApi(platform) {
65
+ return platform === 'win32' ? win32 : posix
66
+ }
67
+
68
+ // Candidates come from the trusted PATH probe; PATH is intentionally user-controlled, and this CLI runs with that user's privileges.
69
+ async function validateExecutable(record, kind, platform, fileStat) {
70
+ if (!record || record.status !== 'candidate' || !record.executable) return null
71
+ const api = pathApi(platform)
72
+ const executable = record.executable
73
+ const absolute = platform === process.platform ? isAbsolute(executable) : api.isAbsolute(executable)
74
+ if (!absolute) throw new Error(`${kind} candidate executable must be an absolute path`)
75
+ const basename = api.basename(executable).toLowerCase()
76
+ const allowed = kind === 'uv'
77
+ ? /^(uv)(?:\.exe)?$/
78
+ : /^(python|python3|py)(?:\.exe)?$/
79
+ if (!allowed.test(basename)) throw new Error(`${kind} candidate executable has an untrusted basename`)
80
+ let info
81
+ try {
82
+ info = await fileStat(executable)
83
+ } catch {
84
+ throw new Error(`${kind} candidate executable is not a regular file`)
85
+ }
86
+ if (!info.isFile()) throw new Error(`${kind} candidate executable is not a regular file`)
87
+ return { ...record, executable }
88
+ }
89
+
90
+ function pythonVenvArgs(record, environmentDir, platform) {
91
+ const name = pathApi(platform).basename(record.executable).toLowerCase()
92
+ if (name === 'py' || name === 'py.exe') return ['-3', '-m', 'venv', environmentDir]
93
+ return ['-m', 'venv', environmentDir]
94
+ }
95
+
96
+ async function exists(path, fileStat) {
97
+ try {
98
+ await fileStat(path)
99
+ return true
100
+ } catch (error) {
101
+ if (error?.code === 'ENOENT') return false
102
+ throw error
103
+ }
104
+ }
105
+
106
+ function samePackages(left, right) {
107
+ return Array.isArray(left)
108
+ && left.length === right.length
109
+ && left.every((value, index) => value === right[index])
110
+ }
111
+
112
+ async function readManifest(manifestPath) {
113
+ try {
114
+ return JSON.parse(await readFile(manifestPath, 'utf8'))
115
+ } catch {
116
+ throw new Error(`Python environment manifest is invalid. ${REPAIR_GUIDANCE}`)
117
+ }
118
+ }
119
+
120
+ function isBlockedManifest(manifest, environmentDir) {
121
+ return manifest?.status === 'blocked'
122
+ && manifest.adapter === null
123
+ && manifest.environmentDir === environmentDir
124
+ && manifest.python === null
125
+ && manifest.lockPath === null
126
+ && Array.isArray(manifest.requestedPackages)
127
+ }
128
+
129
+ function isReadyManifest(manifest, environmentDir, lockPath) {
130
+ return manifest?.status === 'ready'
131
+ && manifest.environmentDir === environmentDir
132
+ && manifest.lockPath === lockPath
133
+ && Array.isArray(manifest.requestedPackages)
134
+ }
135
+
136
+ async function inspectExisting({ environmentDir, manifestPath, lockPath, packages, fileStat }) {
137
+ const [hasEnvironment, hasManifest, hasLock] = await Promise.all([
138
+ exists(environmentDir, fileStat), exists(manifestPath, fileStat), exists(lockPath, fileStat),
139
+ ])
140
+ if (!hasEnvironment && !hasManifest && !hasLock) return null
141
+ if (hasManifest) {
142
+ const manifest = await readManifest(manifestPath)
143
+ if (isBlockedManifest(manifest, environmentDir)) {
144
+ if (hasEnvironment || hasLock) {
145
+ throw new Error(`Python environment is orphaned or inconsistent. ${REPAIR_GUIDANCE}`)
146
+ }
147
+ if (!samePackages(manifest.requestedPackages, packages)) {
148
+ throw new Error('Existing blocked Python environment uses a different package configuration; refusing to mutate it.')
149
+ }
150
+ return manifest
151
+ }
152
+ if (isReadyManifest(manifest, environmentDir, lockPath) && hasEnvironment && hasLock) {
153
+ if (!samePackages(manifest.requestedPackages, packages)) {
154
+ throw new Error('Existing ready Python environment uses a different package configuration; refusing to mutate it.')
155
+ }
156
+ return manifest
157
+ }
158
+ }
159
+ throw new Error(`Python environment is orphaned or inconsistent. ${REPAIR_GUIDANCE}`)
160
+ }
161
+
162
+ function defaultProcessAlive(pid) {
163
+ try {
164
+ process.kill(pid, 0)
165
+ return true
166
+ } catch (error) {
167
+ return error?.code === 'EPERM'
168
+ }
169
+ }
170
+
171
+ async function acquireLock({ lockDir, manifestPath, environmentDir, lockPath, packages, fileStat, now, processAlive, retryDelay, retryDelayMs, attempts, staleMs, renamePath, removePath }) {
172
+ const owner = { pid: process.pid, time: now(), id: randomUUID() }
173
+ let reclaimedDeadOwner = false
174
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
175
+ let created = false
176
+ try {
177
+ await mkdir(lockDir)
178
+ created = true
179
+ await atomicWriteJson(resolve(lockDir, 'owner.json'), owner, { renamePath, removePath })
180
+ return { owner, reclaimedDeadOwner }
181
+ } catch (error) {
182
+ if (!created && error?.code !== 'EEXIST') throw error
183
+ if (created && error?.code !== 'ENOENT' && error?.code !== 'EEXIST') throw error
184
+ }
185
+ let incumbent
186
+ try {
187
+ incumbent = JSON.parse(await readFile(resolve(lockDir, 'owner.json'), 'utf8'))
188
+ } catch {
189
+ incumbent = null
190
+ }
191
+ let lockAge = 0
192
+ try {
193
+ lockAge = now() - (await fileStat(lockDir)).mtimeMs
194
+ } catch (error) {
195
+ if (error?.code === 'ENOENT') continue
196
+ throw error
197
+ }
198
+ const validOwner = incumbent && Number.isInteger(incumbent.pid) && Number.isFinite(incumbent.time)
199
+ const staleDeadOwner = validOwner && now() - incumbent.time >= staleMs && !processAlive(incumbent.pid)
200
+ const stalePartialOwner = !validOwner && lockAge >= staleMs
201
+ if (staleDeadOwner || stalePartialOwner) {
202
+ let mayReclaim = !await exists(manifestPath, fileStat)
203
+ if (!mayReclaim) {
204
+ const hasLock = await exists(lockDir, fileStat)
205
+ if (!hasLock) {
206
+ const existing = await inspectExisting({ environmentDir, manifestPath, lockPath, packages, fileStat })
207
+ mayReclaim = existing?.status === 'blocked'
208
+ } else {
209
+ const manifest = await readManifest(manifestPath)
210
+ mayReclaim = isBlockedManifest(manifest, environmentDir)
211
+ }
212
+ }
213
+ if (mayReclaim) {
214
+ const reclaimed = `${lockDir}.reclaimed.${owner.id}`
215
+ try {
216
+ await renamePath(lockDir, reclaimed)
217
+ await removePath(reclaimed, { recursive: true, force: true })
218
+ reclaimedDeadOwner = true
219
+ continue
220
+ } catch (error) {
221
+ if (error?.code !== 'ENOENT') throw error
222
+ }
223
+ }
224
+ }
225
+ await retryDelay(retryDelayMs)
226
+ }
227
+ throw new Error('Timed out waiting for the Python environment lock.')
228
+ }
229
+
230
+ async function releaseLock(lockDir, owner, removePath) {
231
+ try {
232
+ const incumbent = JSON.parse(await readFile(resolve(lockDir, 'owner.json'), 'utf8'))
233
+ if (incumbent.id === owner.id) await removePath(lockDir, { recursive: true, force: true })
234
+ } catch (error) {
235
+ if (error?.code !== 'ENOENT') throw error
236
+ }
237
+ }
238
+
239
+ function ensureContained(runDir, path, platform) {
240
+ const api = win32.isAbsolute(runDir) ? win32 : pathApi(platform)
241
+ const relative = api.relative(runDir, path)
242
+ if (relative === '' || (!relative.startsWith('..') && !api.isAbsolute(relative))) return path
243
+ throw new Error('Python environment path escapes the run directory.')
244
+ }
245
+
246
+ async function freezeRequirements(runCommand, pythonPath, executionPythonPath, runDir, stagedLockPath, finalLockPath, commands) {
247
+ const freezeArgs = ['-m', 'pip', 'freeze']
248
+ commands.push({ command: pythonPath, args: freezeArgs, stdoutFile: finalLockPath })
249
+ assertSuccess(
250
+ await runCommand(executionPythonPath, freezeArgs, { cwd: runDir, stdoutFile: stagedLockPath }),
251
+ 'pip freeze',
252
+ )
253
+ }
254
+
255
+ export async function createPythonEnvironment(input) {
256
+ const platform = input.platform ?? process.platform
257
+ const resolvePath = input.resolvePath ?? resolve
258
+ const runDir = resolvePath(input.runDir)
259
+ const environmentDir = ensureContained(runDir, resolvePath(runDir, '.python-env'), platform)
260
+ const lockPath = ensureContained(runDir, resolvePath(runDir, 'requirements.lock'), platform)
261
+ const manifestPath = ensureContained(runDir, resolvePath(runDir, 'python-environment.json'), platform)
262
+ const transactionLockDir = ensureContained(runDir, resolvePath(runDir, '.python-environment.lock'), platform)
263
+ const packages = input.packages.map(validatePackageSpec)
264
+ const runCommand = input.runCommand ?? defaultRun
265
+ const fileStat = input.fileStat ?? stat
266
+ const renamePath = input.renamePath ?? rename
267
+ const removePath = input.removePath ?? rm
268
+ const retryDelay = input.retryDelay ?? ((milliseconds) => new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)))
269
+ const ownerOptions = {
270
+ lockDir: transactionLockDir,
271
+ manifestPath,
272
+ environmentDir,
273
+ lockPath,
274
+ packages,
275
+ fileStat,
276
+ now: input.now ?? Date.now,
277
+ processAlive: input.processAlive ?? defaultProcessAlive,
278
+ retryDelay,
279
+ retryDelayMs: input.lockRetryDelayMs ?? 50,
280
+ attempts: input.lockAttempts ?? 100,
281
+ staleMs: input.lockStaleMs ?? 30_000,
282
+ renamePath,
283
+ removePath,
284
+ }
285
+
286
+ await mkdir(runDir, { recursive: true })
287
+ const hasRuntime = Boolean(input.capabilities.tools?.uv?.status === 'candidate' || input.capabilities.tools?.python?.status === 'candidate')
288
+ if (await exists(manifestPath, fileStat)) {
289
+ const existing = await inspectExisting({ environmentDir, manifestPath, lockPath, packages, fileStat })
290
+ if (existing?.status === 'ready' || !hasRuntime) return existing
291
+ }
292
+ const { owner, reclaimedDeadOwner } = await acquireLock(ownerOptions)
293
+ let committed = false
294
+ try {
295
+ if (reclaimedDeadOwner && await exists(environmentDir, fileStat) && !await exists(manifestPath, fileStat)) {
296
+ await removePath(environmentDir, { recursive: true, force: true })
297
+ await removePath(lockPath, { force: true }).catch(() => {})
298
+ }
299
+ const existing = await inspectExisting({ environmentDir, manifestPath, lockPath, packages, fileStat })
300
+ if (existing?.status === 'ready') return existing
301
+
302
+ const uv = await validateExecutable(input.capabilities.tools?.uv, 'uv', platform, fileStat)
303
+ const python = await validateExecutable(input.capabilities.tools?.python, 'python', platform, fileStat)
304
+ if (existing?.status === 'blocked') {
305
+ if (!uv && !python) return existing
306
+ await removePath(manifestPath, { force: true })
307
+ }
308
+ const commands = []
309
+ if (!uv && !python) {
310
+ const manifest = {
311
+ schemaVersion: 1, status: 'blocked', adapter: null, environmentDir, python: null,
312
+ requestedPackages: packages, lockPath: null, commands,
313
+ reason: 'No uv or Python runtime candidate is available.',
314
+ }
315
+ await atomicWriteJson(manifestPath, manifest)
316
+ return manifest
317
+ }
318
+
319
+ const attemptId = randomUUID()
320
+ const stagedLockPath = ensureContained(runDir, resolvePath(runDir, `requirements.lock.${attemptId}.tmp`), platform)
321
+ const stagedManifestPath = ensureContained(runDir, resolvePath(runDir, `python-environment.json.${attemptId}.tmp`), platform)
322
+ const finalPythonPath = environmentPython(environmentDir, platform, resolvePath)
323
+ try {
324
+ let adapter
325
+ if (uv) {
326
+ adapter = 'uv'
327
+ const createArgs = ['venv', '--seed', environmentDir]
328
+ commands.push({ command: uv.executable, args: createArgs })
329
+ assertSuccess(await runCommand(uv.executable, createArgs, { cwd: runDir }), 'uv venv')
330
+ if (packages.length) {
331
+ const installArgs = ['pip', 'install', '--python', finalPythonPath, ...packages]
332
+ commands.push({ command: uv.executable, args: installArgs })
333
+ assertSuccess(await runCommand(uv.executable, installArgs, { cwd: runDir }), 'uv pip install')
334
+ }
335
+ } else {
336
+ adapter = 'venv'
337
+ const createArgs = pythonVenvArgs(python, environmentDir, platform)
338
+ commands.push({ command: python.executable, args: createArgs })
339
+ assertSuccess(await runCommand(python.executable, createArgs, { cwd: runDir }), 'python venv')
340
+ if (packages.length) {
341
+ const installArgs = ['-m', 'pip', 'install', ...packages]
342
+ commands.push({ command: finalPythonPath, args: installArgs })
343
+ assertSuccess(await runCommand(finalPythonPath, installArgs, { cwd: runDir }), 'pip install')
344
+ }
345
+ }
346
+ await mkdir(environmentDir, { recursive: true })
347
+ await freezeRequirements(runCommand, finalPythonPath, finalPythonPath, runDir, stagedLockPath, lockPath, commands)
348
+ const manifest = {
349
+ schemaVersion: 1, status: 'ready', adapter, environmentDir, python: finalPythonPath,
350
+ requestedPackages: packages, lockPath, commands,
351
+ }
352
+ await writeFile(stagedManifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
353
+ await renamePath(stagedLockPath, lockPath)
354
+ await renamePath(stagedManifestPath, manifestPath)
355
+ committed = true
356
+ return manifest
357
+ } catch (error) {
358
+ committed = await exists(manifestPath, fileStat)
359
+ if (!committed) {
360
+ await removePath(stagedLockPath, { force: true }).catch(() => {})
361
+ await removePath(stagedManifestPath, { force: true }).catch(() => {})
362
+ await removePath(lockPath, { force: true }).catch(() => {})
363
+ await removePath(environmentDir, { recursive: true, force: true }).catch(() => {})
364
+ }
365
+ throw error
366
+ }
367
+ } finally {
368
+ const release = releaseLock(transactionLockDir, owner, removePath)
369
+ if (committed) await release.catch(() => {})
370
+ else await release
371
+ }
372
+ }
373
+
374
+ export function parseArguments(argv) {
375
+ const result = { packages: [] }
376
+ const nextValue = (index, option) => {
377
+ const value = argv[index + 1]
378
+ if (value === undefined || value.startsWith('--')) throw new Error(`missing value for ${option}`)
379
+ return value
380
+ }
381
+ for (let index = 0; index < argv.length; index += 1) {
382
+ const value = argv[index]
383
+ if (value === '--run-dir') result.runDir = nextValue(index++, value)
384
+ else if (value === '--capabilities') result.capabilitiesPath = nextValue(index++, value)
385
+ else if (value === '--package') result.packages.push(nextValue(index++, value))
386
+ else throw new Error(`unknown argument: ${value}`)
387
+ }
388
+ if (!result.runDir || !result.capabilitiesPath) {
389
+ throw new Error('usage: python-environment.mjs --run-dir <path> --capabilities <json> [--package <spec>]')
390
+ }
391
+ return result
392
+ }
393
+
394
+ async function main() {
395
+ const args = parseArguments(process.argv.slice(2))
396
+ const capabilities = JSON.parse(await readFile(args.capabilitiesPath, 'utf8'))
397
+ const manifest = await createPythonEnvironment({ runDir: args.runDir, packages: args.packages, capabilities })
398
+ process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`)
399
+ if (manifest.status === 'blocked') process.exitCode = 2
400
+ }
401
+
402
+ const invokedPath = process.argv[1]
403
+ if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) {
404
+ main().catch((error) => {
405
+ process.stderr.write(`${error.message}\n`)
406
+ process.exitCode = 1
407
+ })
408
+ }