cli-aimlock 7.0.18 → 7.0.25

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.
@@ -0,0 +1,407 @@
1
+ import { execFile as execFileCallback } from 'node:child_process'
2
+ import {
3
+ lstat,
4
+ readFile,
5
+ readdir,
6
+ writeFile,
7
+ } from 'node:fs/promises'
8
+ import { dirname, extname, relative, resolve } from 'node:path'
9
+ import { promisify } from 'node:util'
10
+ import {
11
+ LOCAL_SCHEMA,
12
+ appendAudit,
13
+ atomicJson,
14
+ ensureManagedDirectory,
15
+ fail,
16
+ identifier,
17
+ managedPath,
18
+ repositoryRoot,
19
+ resolvedProjectPath,
20
+ safeRelativePath,
21
+ withFileLock,
22
+ } from './aimlock-local-fs.mjs'
23
+ import {
24
+ PASS_SCHEMA,
25
+ guardedWriteFile,
26
+ issueMutationPass,
27
+ verifyMutationPassFile,
28
+ } from './aimlock-local-gate.mjs'
29
+ import { resolveContextMapTargets } from './aimlock-context-map.mjs'
30
+ import { assertChainNotSuspended } from './aimlock-coordination.mjs'
31
+
32
+ const execFile = promisify(execFileCallback)
33
+ const BUDGET_SCHEMA = 'aimlock.read-budget/1.0'
34
+ const CONFIRMATION_SCHEMA = 'confirm-protocol.answer/1.0'
35
+ const MAX_DISCOVERED_FILES = 1_000
36
+ const MAX_SOURCE_BYTES = 1_048_576
37
+ const TOKEN_ESTIMATE_ALGORITHM = 'utf8-bytes-div-4-ceil'
38
+ const SOURCE_EXTENSIONS = new Set(['.cjs', '.js', '.jsx', '.mjs', '.ts', '.tsx'])
39
+ const IGNORED_DIRECTORIES = new Set([
40
+ '.aimlock', '.git', '.runtime', 'coverage', 'dist', 'node_modules',
41
+ ])
42
+ const MODE_ORDER = Object.freeze(['lock', 'probe', 'swarm'])
43
+ const READ_BUDGETS = Object.freeze({
44
+ lock: Object.freeze({ maxFiles: 3, maxTokenEstimate: null, maxDurationMs: 120_000 }),
45
+ probe: Object.freeze({ maxFiles: 10, maxTokenEstimate: 30_000, maxDurationMs: 480_000 }),
46
+ swarm: Object.freeze({ maxFiles: 30, maxTokenEstimate: 100_000, maxDurationMs: 900_000 }),
47
+ })
48
+ const HIGH_RISK_PATTERN = /生产数据|支付|用户隐私|密码|密钥|凭证|线上环境|production/i
49
+ const IMPORT_PATTERN = /(?:import|export)\s+(?:[^'";]+?\s+from\s+)?['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)/g
50
+ const schema = (required, properties) => ({ type: 'object', additionalProperties: false,
51
+ required, properties })
52
+ const stringSchema = { type: 'string', minLength: 1 }
53
+ const stringArraySchema = { type: 'array', items: stringSchema }
54
+ const objectValueSchema = { type: 'object' }
55
+ const LOCAL_OPERATION_SCHEMAS = Object.freeze({
56
+ capabilities: schema([], {}),
57
+ probe: schema(['goal', 'targetHints'], { goal: stringSchema, targetHints: stringArraySchema,
58
+ targetSymbols: { type: 'array', items: objectValueSchema } }),
59
+ reassess: schema(['currentMode', 'actualFileCount', 'actualChangedLines', 'crossModule', 'needParallel', 'inherited'], {
60
+ currentMode: { enum: ['lock', 'probe', 'swarm'] }, actualFileCount: { type: 'integer', minimum: 1 },
61
+ actualChangedLines: { type: 'integer', minimum: 0 }, crossModule: { type: 'boolean' },
62
+ needParallel: { type: 'boolean' }, inherited: objectValueSchema }),
63
+ 'budget-init': schema(['chainId', 'mode'], { chainId: stringSchema, mode: { enum: ['lock', 'probe', 'swarm'] } }),
64
+ 'budget-read': schema(['chainId', 'path'], { chainId: stringSchema, path: stringSchema }),
65
+ 'budget-status': schema(['chainId'], { chainId: stringSchema }),
66
+ 'budget-extend': schema(['chainId', 'confirmation', 'additions'], {
67
+ chainId: stringSchema, confirmation: objectValueSchema, additions: objectValueSchema }),
68
+ 'gate-issue': schema(['chainId', 'snapshotRoot', 'receipt', 'contract', 'nodes', 'coordinationRequired'], {
69
+ chainId: stringSchema, snapshotRoot: stringSchema, receipt: objectValueSchema,
70
+ contract: objectValueSchema, nodes: { type: 'array', items: objectValueSchema },
71
+ coordinationRequired: { type: 'boolean' }, coordinationLeasePath: stringSchema,
72
+ ttlSeconds: { type: 'integer', minimum: 1, maximum: 300 } }),
73
+ 'gate-verify': schema(['chainId', 'gatePassPath', 'targetPath'], {
74
+ chainId: stringSchema, gatePassPath: stringSchema, targetPath: stringSchema }),
75
+ 'guarded-write': schema(['targetPath', 'content'], { chainId: stringSchema,
76
+ gatePassPath: stringSchema, targetPath: stringSchema, content: { type: ['string', 'object'] } }),
77
+ })
78
+
79
+ async function discoverDirectory(root, directory, files) {
80
+ const entries = await readdir(directory, { withFileTypes: true })
81
+ for (const entry of entries) {
82
+ if (entry.isSymbolicLink() || IGNORED_DIRECTORIES.has(entry.name)) continue
83
+ const target = resolve(directory, entry.name)
84
+ if (entry.isDirectory()) await discoverDirectory(root, target, files)
85
+ else if (entry.isFile()) files.add(relative(root, target).split('\\').join('/'))
86
+ if (files.size > MAX_DISCOVERED_FILES) {
87
+ fail('AIMLOCK_DISCOVERY_LIMIT', `target discovery exceeds ${MAX_DISCOVERED_FILES} files`)
88
+ }
89
+ }
90
+ }
91
+
92
+ async function discoverTargets(root, hints) {
93
+ if (!Array.isArray(hints) || hints.length === 0) {
94
+ fail('AIMLOCK_TARGETS_REQUIRED', 'targetHints must be a non-empty array')
95
+ }
96
+ const files = new Set()
97
+ for (const hint of hints) {
98
+ const resolved = await resolvedProjectPath(root, hint, { allowMissing: true })
99
+ if (!resolved.exists || resolved.status.isFile()) files.add(resolved.path)
100
+ else if (resolved.status.isDirectory()) await discoverDirectory(root, resolved.target, files)
101
+ else fail('AIMLOCK_TARGET_INVALID', `${resolved.path} is not a file or directory`)
102
+ }
103
+ if (files.size === 0) fail('AIMLOCK_TARGETS_EMPTY', 'target discovery found no files')
104
+ return [...files].sort()
105
+ }
106
+
107
+ async function nearestPackageRoot(root, file) {
108
+ let current = dirname(resolve(root, file))
109
+ for (;;) {
110
+ try {
111
+ const packageFile = await lstat(resolve(current, 'package.json'))
112
+ if (packageFile.isFile()) return relative(root, current).split('\\').join('/') || '.'
113
+ } catch (error) {
114
+ if (!(error instanceof Error && error.code === 'ENOENT')) throw error
115
+ }
116
+ if (current === root) return '.'
117
+ current = dirname(current)
118
+ }
119
+ }
120
+
121
+ function importSpecifiers(source) {
122
+ const values = []
123
+ for (const match of source.matchAll(IMPORT_PATTERN)) {
124
+ const value = match[1] ?? match[2]
125
+ if (value?.startsWith('.')) values.push(value)
126
+ }
127
+ return values
128
+ }
129
+
130
+ async function dependencyGraph(root, files) {
131
+ const targetSet = new Set(files)
132
+ const graph = new Map(files.map((file) => [file, new Set()]))
133
+ for (const file of files) {
134
+ if (!SOURCE_EXTENSIONS.has(extname(file))) continue
135
+ const resolved = await resolvedProjectPath(root, file, { allowMissing: true })
136
+ if (!resolved.exists || resolved.status.size > MAX_SOURCE_BYTES) continue
137
+ const source = await readFile(resolved.target, 'utf8')
138
+ for (const specifier of importSpecifiers(source)) {
139
+ const base = resolve(dirname(resolved.target), specifier)
140
+ const candidates = [base, ...[...SOURCE_EXTENSIONS].map((suffix) => `${base}${suffix}`)]
141
+ for (const candidate of candidates) {
142
+ const projectPath = relative(root, candidate).split('\\').join('/')
143
+ if (targetSet.has(projectPath)) {
144
+ graph.get(file).add(projectPath)
145
+ graph.get(projectPath).add(file)
146
+ break
147
+ }
148
+ }
149
+ }
150
+ }
151
+ return graph
152
+ }
153
+
154
+ function connectedComponents(graph) {
155
+ const pending = new Set(graph.keys())
156
+ let count = 0
157
+ while (pending.size) {
158
+ count += 1
159
+ const queue = [pending.values().next().value]
160
+ while (queue.length) {
161
+ const file = queue.pop()
162
+ if (!pending.delete(file)) continue
163
+ queue.push(...graph.get(file))
164
+ }
165
+ }
166
+ return count
167
+ }
168
+
169
+ async function historicalEstimate(root, files) {
170
+ const { stdout } = await execFile('git', [
171
+ '-C', root, 'log', '--format=commit:%H', '--numstat', '-n', '20', '--', ...files,
172
+ ], { maxBuffer: 1_048_576 })
173
+ const totals = []
174
+ let current = null
175
+ for (const line of stdout.split('\n')) {
176
+ if (line.startsWith('commit:')) {
177
+ if (current !== null) totals.push(current)
178
+ current = 0
179
+ continue
180
+ }
181
+ const match = /^(\d+)\s+(\d+)\s+/.exec(line)
182
+ if (match && current !== null) current += Number(match[1]) + Number(match[2])
183
+ }
184
+ if (current !== null) totals.push(current)
185
+ const samples = totals.filter((value) => value > 0)
186
+ if (!samples.length) return { lines: Math.max(1, files.length), samples: 0, source: 'minimum-policy' }
187
+ const average = Math.floor(samples.reduce((sum, value) => sum + value, 0) / samples.length)
188
+ return {
189
+ lines: Math.max(files.length, files.length === 1 ? Math.min(500, average) : average),
190
+ samples: samples.length,
191
+ source: 'git-history-average',
192
+ }
193
+ }
194
+
195
+ function modeForFacts(facts) {
196
+ if (facts.fileCount === 1 && facts.estimatedChangedLines <= 500
197
+ && !facts.crossModule && !facts.needParallel) return 'lock'
198
+ if (facts.fileCount <= 3 && facts.estimatedChangedLines <= 500
199
+ && !facts.crossModule && !facts.needParallel) return 'probe'
200
+ return 'swarm'
201
+ }
202
+
203
+ async function probeRepositoryDemand(input) {
204
+ const root = await repositoryRoot(input.repositoryRoot)
205
+ const contextMap = await resolveContextMapTargets(root, input.targetSymbols)
206
+ const files = await discoverTargets(root, [...(input.targetHints ?? []), ...contextMap.targets])
207
+ const graph = await dependencyGraph(root, files)
208
+ const moduleRoots = [...new Set(await Promise.all(files.map((file) => nearestPackageRoot(root, file))))]
209
+ const estimate = await historicalEstimate(root, files)
210
+ const components = connectedComponents(graph)
211
+ const crossModule = moduleRoots.length > 1
212
+ const needParallel = components > 1 && files.length > 3 && estimate.lines > 500
213
+ const risk = HIGH_RISK_PATTERN.test(`${input.goal ?? ''}\n${files.join('\n')}`) ? 'high'
214
+ : crossModule ? 'medium' : 'low'
215
+ const facts = {
216
+ targetFiles: files,
217
+ fileCount: files.length,
218
+ estimatedChangedLines: estimate.lines,
219
+ estimateSource: estimate.source,
220
+ historySamples: estimate.samples,
221
+ crossModule,
222
+ needParallel,
223
+ independentComponents: components,
224
+ moduleRoots,
225
+ risk,
226
+ difficulty: files.length <= 1 && estimate.lines <= 50 ? 'low'
227
+ : files.length <= 3 && estimate.lines <= 500 ? 'medium' : 'high',
228
+ }
229
+ return { schemaVersion: LOCAL_SCHEMA, facts, mode: modeForFacts(facts),
230
+ contextMap: { used: contextMap.used, mapPath: contextMap.mapPath ?? null } }
231
+ }
232
+
233
+ function reassessMode(input) {
234
+ const currentIndex = MODE_ORDER.indexOf(input.currentMode)
235
+ if (currentIndex < 0) fail('AIMLOCK_MODE_INVALID', 'currentMode must be lock, probe, or swarm')
236
+ const requiredMode = modeForFacts({
237
+ fileCount: input.actualFileCount,
238
+ estimatedChangedLines: input.actualChangedLines,
239
+ crossModule: input.crossModule === true,
240
+ needParallel: input.needParallel === true,
241
+ })
242
+ const requiredIndex = MODE_ORDER.indexOf(requiredMode)
243
+ const nextMode = requiredIndex > currentIndex ? MODE_ORDER[currentIndex + 1] : input.currentMode
244
+ return {
245
+ schemaVersion: LOCAL_SCHEMA,
246
+ mode: nextMode,
247
+ requiredMode,
248
+ escalated: nextMode !== input.currentMode,
249
+ inherited: input.inherited,
250
+ notice: nextMode !== input.currentMode
251
+ ? `任务比预估复杂,已升级为 ${nextMode} 模式并继承现有快照与修改。` : null,
252
+ }
253
+ }
254
+
255
+ async function readBudget(root, chainId) {
256
+ const id = identifier(chainId, 'chainId')
257
+ const path = managedPath(root, 'runs', id, 'read-budget.json')
258
+ const state = JSON.parse(await readFile(path, 'utf8'))
259
+ if (state.schemaVersion !== BUDGET_SCHEMA || state.chainId !== id) {
260
+ fail('AIMLOCK_BUDGET_INVALID', 'read budget authority is invalid')
261
+ }
262
+ return { path, state }
263
+ }
264
+
265
+ function budgetView(state, now = Date.now()) {
266
+ const elapsedMs = now - Date.parse(state.startedAt)
267
+ const remainingFiles = Math.max(0, state.maxFiles - state.uniqueFiles.length)
268
+ const remainingTokenEstimate = state.maxTokenEstimate === null ? null
269
+ : Math.max(0, state.maxTokenEstimate - state.tokenEstimate)
270
+ const remainingDurationMs = Math.max(0, state.maxDurationMs - elapsedMs)
271
+ const decisionRequired = remainingFiles === 0 || remainingDurationMs === 0
272
+ || remainingTokenEstimate === 0
273
+ return { ...state, elapsedMs, remainingFiles, remainingTokenEstimate, remainingDurationMs,
274
+ decisionRequired, nextActions: decisionRequired ? ['execute', 'plan', 'blocked'] : [] }
275
+ }
276
+
277
+ async function initializeReadBudget(input) {
278
+ const root = await repositoryRoot(input.repositoryRoot)
279
+ const chainId = identifier(input.chainId, 'chainId')
280
+ const limits = READ_BUDGETS[input.mode]
281
+ if (!limits) fail('AIMLOCK_MODE_INVALID', 'mode must be lock, probe, or swarm')
282
+ const directory = await ensureManagedDirectory(root, 'runs', chainId)
283
+ const state = {
284
+ schemaVersion: BUDGET_SCHEMA,
285
+ chainId,
286
+ mode: input.mode,
287
+ startedAt: new Date().toISOString(),
288
+ ...limits,
289
+ uniqueFiles: [],
290
+ readCalls: 0,
291
+ tokenEstimate: 0,
292
+ tokenEstimateAlgorithm: TOKEN_ESTIMATE_ALGORITHM,
293
+ extensions: [],
294
+ }
295
+ await writeFile(resolve(directory, 'read-budget.json'), `${JSON.stringify(state)}\n`, {
296
+ flag: 'wx', mode: 0o600,
297
+ })
298
+ await appendAudit(root, { event: 'read-budget-initialized', chainId, mode: input.mode })
299
+ return budgetView(state)
300
+ }
301
+
302
+ async function readFileWithinBudget(input) {
303
+ const root = await repositoryRoot(input.repositoryRoot)
304
+ const chainId = identifier(input.chainId, 'chainId')
305
+ await assertChainNotSuspended({ repositoryRoot: root, chainId })
306
+ const budgetPath = managedPath(root, 'runs', chainId, 'read-budget.json')
307
+ return withFileLock(budgetPath, async () => {
308
+ const authority = await readBudget(root, chainId)
309
+ const path = safeRelativePath(input.path)
310
+ const state = authority.state
311
+ const before = budgetView(state)
312
+ if (before.remainingDurationMs === 0) fail('AIMLOCK_DECISION_REQUIRED', 'read deadline exhausted')
313
+ const isNew = !state.uniqueFiles.includes(path)
314
+ if (isNew && before.remainingFiles === 0) fail('AIMLOCK_DECISION_REQUIRED', 'read file budget exhausted')
315
+ const projectFile = await resolvedProjectPath(root, path)
316
+ if (!projectFile.status.isFile()) fail('AIMLOCK_READ_NOT_FILE', `${path} is not a file`)
317
+ const tokenEstimate = Math.ceil(projectFile.status.size / 4)
318
+ if (before.remainingTokenEstimate !== null && tokenEstimate > before.remainingTokenEstimate) {
319
+ fail('AIMLOCK_DECISION_REQUIRED', 'read token estimate budget exhausted')
320
+ }
321
+ const content = await readFile(projectFile.target, 'utf8')
322
+ const updated = {
323
+ ...state,
324
+ uniqueFiles: isNew ? [...state.uniqueFiles, path] : state.uniqueFiles,
325
+ readCalls: state.readCalls + 1,
326
+ tokenEstimate: state.tokenEstimate + tokenEstimate,
327
+ }
328
+ await atomicJson(authority.path, updated)
329
+ await appendAudit(root, { event: 'read-consumed', chainId, path, tokenEstimate })
330
+ return { schemaVersion: LOCAL_SCHEMA, path, content, budget: budgetView(updated) }
331
+ })
332
+ }
333
+
334
+ async function readBudgetStatus(input) {
335
+ const root = await repositoryRoot(input.repositoryRoot)
336
+ return budgetView((await readBudget(root, input.chainId)).state)
337
+ }
338
+
339
+ async function extendReadBudget(input) {
340
+ const root = await repositoryRoot(input.repositoryRoot)
341
+ const confirmation = input.confirmation
342
+ if (!confirmation || confirmation.schemaVersion !== CONFIRMATION_SCHEMA
343
+ || confirmation.confirmed !== true || confirmation.risk !== 'low'
344
+ || !identifier(confirmation.confirmationId, 'confirmationId')) {
345
+ fail('AIMLOCK_CONFIRMATION_REQUIRED', 'a low-risk confirmation receipt is required')
346
+ }
347
+ const additions = input.additions
348
+ if (!additions || !Number.isInteger(additions.files) || additions.files < 0
349
+ || !Number.isInteger(additions.tokenEstimate) || additions.tokenEstimate < 0
350
+ || !Number.isInteger(additions.durationMs) || additions.durationMs < 0
351
+ || additions.files + additions.tokenEstimate + additions.durationMs === 0) {
352
+ fail('AIMLOCK_EXTENSION_INVALID', 'budget additions must contain a positive integer increase')
353
+ }
354
+ const chainId = identifier(input.chainId, 'chainId')
355
+ const budgetPath = managedPath(root, 'runs', chainId, 'read-budget.json')
356
+ return withFileLock(budgetPath, async () => {
357
+ const authority = await readBudget(root, chainId)
358
+ const state = authority.state
359
+ const updated = {
360
+ ...state,
361
+ maxFiles: state.maxFiles + additions.files,
362
+ maxTokenEstimate: state.maxTokenEstimate === null && additions.tokenEstimate === 0
363
+ ? null : (state.maxTokenEstimate ?? 0) + additions.tokenEstimate,
364
+ maxDurationMs: state.maxDurationMs + additions.durationMs,
365
+ extensions: [...state.extensions, {
366
+ confirmationId: confirmation.confirmationId,
367
+ additions,
368
+ at: new Date().toISOString(),
369
+ }],
370
+ }
371
+ await atomicJson(authority.path, updated)
372
+ await appendAudit(root, { event: 'read-budget-extended', chainId,
373
+ confirmationId: confirmation.confirmationId, additions })
374
+ return budgetView(updated)
375
+ })
376
+ }
377
+
378
+ const LOCAL_CAPABILITIES = Object.freeze({
379
+ schemaVersion: LOCAL_SCHEMA,
380
+ operations: Object.freeze([
381
+ 'capabilities', 'probe', 'reassess', 'budget-init', 'budget-read', 'budget-status',
382
+ 'budget-extend', 'gate-issue', 'gate-verify', 'guarded-write',
383
+ ]),
384
+ operationSchemas: LOCAL_OPERATION_SCHEMAS,
385
+ writeBoundary: 'Only writes routed through guarded-write are physically intercepted. The IDE host must route batch writes through this runner.',
386
+ coordinationBoundary: 'Active dependency waits block budgeted reads; coordinated gate passes bind signed .coord file leases.',
387
+ tokenEstimateAlgorithm: TOKEN_ESTIMATE_ALGORITHM,
388
+ budgets: READ_BUDGETS,
389
+ })
390
+
391
+ export {
392
+ BUDGET_SCHEMA,
393
+ LOCAL_CAPABILITIES,
394
+ LOCAL_OPERATION_SCHEMAS,
395
+ LOCAL_SCHEMA,
396
+ PASS_SCHEMA,
397
+ READ_BUDGETS,
398
+ extendReadBudget,
399
+ guardedWriteFile,
400
+ initializeReadBudget,
401
+ issueMutationPass,
402
+ probeRepositoryDemand,
403
+ readBudgetStatus,
404
+ readFileWithinBudget,
405
+ reassessMode,
406
+ verifyMutationPassFile,
407
+ }