cli-aimlock 7.0.34 → 7.0.36
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/README.md +2 -2
- package/aimlock-chain-store.mjs +2 -0
- package/aimlock-local-runner.mjs +9 -173
- package/aimlock-read-budget-renewal.mjs +198 -0
- package/aimlock-read-budget-schemas.mjs +31 -0
- package/aimlock-read-budget-state.mjs +71 -0
- package/aimlock-read-budget.mjs +127 -0
- package/aimlock-runtime.mjs +2 -2
- package/brain-client-files.mjs +88 -0
- package/brain-client.mjs +170 -0
- package/cli.mjs +12 -1
- package/package.json +10 -3
- package/skill/SKILL.md +27 -4
- package/skill/skill.json +1 -1
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ When active, Aimlock uses Lock for one file through 500 changed lines and Probe
|
|
|
23
23
|
- 把需求锁成可执行目标,阻止思考漂移、执行漂移、范围膨胀
|
|
24
24
|
- Bypass / Lock / Probe / Swarm 分档:小改绕过;深度修改才进入门禁
|
|
25
25
|
- 改前文件快照,禁止创建 git 分支
|
|
26
|
-
- Lock / Probe / Swarm 分别强制 3 / 10 / 30 个文件与 2 / 8 /
|
|
26
|
+
- Lock / Probe / Swarm 分别强制 3 / 10 / 30 个文件与 2 / 8 / 60 分钟读取预算;Probe、Swarm 另有限制 30K / 100K 估算 token
|
|
27
27
|
- Ed25519 写入凭证绑定 chainId、快照摘要、路径集合和最长 300 秒有效期
|
|
28
28
|
- `probe.targetSymbols` 可从新鲜的 ContextBase 项目地图解析真实目标文件;缺失、歧义或陈旧条目直接阻断
|
|
29
29
|
- 服务端按当前需求实时发现专项技能,只返回命中项;非计算需求不出现 Calctool
|
|
@@ -43,7 +43,7 @@ cli-aimlock local gate-issue .
|
|
|
43
43
|
cli-aimlock local guarded-write .
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
每个命令从 stdin 读取 JSON
|
|
46
|
+
每个命令从 stdin 读取 JSON。读取预算使用进程间原子锁,耗尽后只允许执行、输出方案或明确阻塞;文件/token 追加预算必须携带 Confirm Protocol 的低风险确认回执。长任务可先用 `budget-auto-renew-request` 生成目标、路径、续期间隔与次数上限,再凭一次真实回执调用 `budget-auto-renew`;同任务后续只自动续时间,累计额度与每次续期保留审计。达到次数/文件/token 上限仍明确阻断。撤销或完成时调用 `budget-auto-renew-stop`;本地执行链成功也会关闭预算。无凭证写入仅豁免 `.aimlock/logs/` 与 `.aimlock/tmp/`。
|
|
47
47
|
|
|
48
48
|
Swarm 模式下,`chain-plan` 会在 `swarm` 前插入 `coordinator.conflict-scan`。`gate-issue` 必须显式声明 `coordinationRequired`;为 true 时凭证绑定 `.coord/leases/` 中的签名文件锁,`guarded-write` 在同一拦截点同时校验门禁与活动租约。存在活动 `dependency-wait` 的 chain 会被 `budget-read` 拒绝。
|
|
49
49
|
|
package/aimlock-chain-store.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { dirname, resolve } from 'node:path'
|
|
|
4
4
|
import { setTimeout as delay } from 'node:timers/promises'
|
|
5
5
|
import { atomicJson, ensureManagedDirectory, fail, identifier, repositoryRoot, resolvedProjectPath } from './aimlock-local-fs.mjs'
|
|
6
6
|
import { STATE_SCHEMA, chainStatus, planDigest, validatePlan } from './aimlock-chain-model.mjs'
|
|
7
|
+
import { completeReadBudgetIfExists } from './aimlock-read-budget-renewal.mjs'
|
|
7
8
|
|
|
8
9
|
const LOCK_WAIT_MS = 2_000
|
|
9
10
|
const LOCK_POLL_MS = 20
|
|
@@ -114,6 +115,7 @@ export async function saveExecution(file, state) {
|
|
|
114
115
|
state.updatedAt = new Date().toISOString()
|
|
115
116
|
state.status = chainStatus(state)
|
|
116
117
|
await atomicJson(file, state)
|
|
118
|
+
if (state.status === 'succeeded') await completeReadBudgetIfExists(resolve(dirname(file), '../../..'), state.chainId)
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
export async function loadExecution(file) {
|
package/aimlock-local-runner.mjs
CHANGED
|
@@ -3,22 +3,15 @@ import {
|
|
|
3
3
|
lstat,
|
|
4
4
|
readFile,
|
|
5
5
|
readdir,
|
|
6
|
-
writeFile,
|
|
7
6
|
} from 'node:fs/promises'
|
|
8
7
|
import { dirname, extname, relative, resolve } from 'node:path'
|
|
9
8
|
import { promisify } from 'node:util'
|
|
10
9
|
import {
|
|
11
10
|
LOCAL_SCHEMA,
|
|
12
11
|
appendAudit,
|
|
13
|
-
atomicJson,
|
|
14
|
-
ensureManagedDirectory,
|
|
15
12
|
fail,
|
|
16
|
-
identifier,
|
|
17
|
-
managedPath,
|
|
18
13
|
repositoryRoot,
|
|
19
14
|
resolvedProjectPath,
|
|
20
|
-
safeRelativePath,
|
|
21
|
-
withFileLock,
|
|
22
15
|
} from './aimlock-local-fs.mjs'
|
|
23
16
|
import {
|
|
24
17
|
PASS_SCHEMA,
|
|
@@ -27,24 +20,19 @@ import {
|
|
|
27
20
|
verifyMutationPassFile,
|
|
28
21
|
} from './aimlock-local-gate.mjs'
|
|
29
22
|
import { resolveContextMapTargets } from './aimlock-context-map.mjs'
|
|
30
|
-
import {
|
|
23
|
+
import { BUDGET_SCHEMA, TOKEN_ESTIMATE_ALGORITHM, READ_BUDGETS, initializeReadBudget } from './aimlock-read-budget-state.mjs'
|
|
24
|
+
import { checkCachedReadAccess, extendReadBudget, readBudgetStatus, readFileWithinBudget } from './aimlock-read-budget.mjs'
|
|
25
|
+
import { authorizeReadBudgetRenewal, requestReadBudgetRenewal, stopReadBudgetRenewal } from './aimlock-read-budget-renewal.mjs'
|
|
26
|
+
import { AUTO_RENEW_OPERATION_SCHEMAS } from './aimlock-read-budget-schemas.mjs'
|
|
31
27
|
|
|
32
28
|
const execFile = promisify(execFileCallback)
|
|
33
|
-
const BUDGET_SCHEMA = 'aimlock.read-budget/1.0'
|
|
34
|
-
const CONFIRMATION_SCHEMA = 'confirm-protocol.skill.response/1.0'
|
|
35
29
|
const MAX_DISCOVERED_FILES = 1_000
|
|
36
30
|
const MAX_SOURCE_BYTES = 1_048_576
|
|
37
|
-
const TOKEN_ESTIMATE_ALGORITHM = 'utf8-bytes-div-4-ceil'
|
|
38
31
|
const SOURCE_EXTENSIONS = new Set(['.cjs', '.js', '.jsx', '.mjs', '.ts', '.tsx'])
|
|
39
32
|
const IGNORED_DIRECTORIES = new Set([
|
|
40
33
|
'.aimlock', '.git', '.runtime', 'coverage', 'dist', 'node_modules',
|
|
41
34
|
])
|
|
42
35
|
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
36
|
const HIGH_RISK_PATTERN = /生产数据|支付|用户隐私|密码|密钥|凭证|线上环境|production/i
|
|
49
37
|
const IMPORT_PATTERN = /(?:import|export)\s+(?:[^'";]+?\s+from\s+)?['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)/g
|
|
50
38
|
const schema = (required, properties) => ({ type: 'object', additionalProperties: false,
|
|
@@ -67,6 +55,7 @@ const BUDGET_CONFIRMATION_SCHEMA = schema(['schemaVersion', 'requestId', 'status
|
|
|
67
55
|
}), nextStep: objectValueSchema,
|
|
68
56
|
})
|
|
69
57
|
const LOCAL_OPERATION_SCHEMAS = Object.freeze({
|
|
58
|
+
...AUTO_RENEW_OPERATION_SCHEMAS,
|
|
70
59
|
capabilities: schema([], {}),
|
|
71
60
|
probe: schema(['goal', 'targetHints'], { goal: stringSchema, targetHints: stringArraySchema,
|
|
72
61
|
targetSymbols: { type: 'array', items: objectValueSchema } }),
|
|
@@ -266,167 +255,11 @@ function reassessMode(input) {
|
|
|
266
255
|
}
|
|
267
256
|
}
|
|
268
257
|
|
|
269
|
-
async function readBudget(root, chainId) {
|
|
270
|
-
const id = identifier(chainId, 'chainId')
|
|
271
|
-
const path = managedPath(root, 'runs', id, 'read-budget.json')
|
|
272
|
-
const state = JSON.parse(await readFile(path, 'utf8'))
|
|
273
|
-
if (state.schemaVersion !== BUDGET_SCHEMA || state.chainId !== id) {
|
|
274
|
-
fail('AIMLOCK_BUDGET_INVALID', 'read budget authority is invalid')
|
|
275
|
-
}
|
|
276
|
-
return { path, state }
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function budgetView(state, now = Date.now()) {
|
|
280
|
-
const elapsedMs = now - Date.parse(state.startedAt)
|
|
281
|
-
const remainingFiles = Math.max(0, state.maxFiles - state.uniqueFiles.length)
|
|
282
|
-
const remainingTokenEstimate = state.maxTokenEstimate === null ? null
|
|
283
|
-
: Math.max(0, state.maxTokenEstimate - state.tokenEstimate)
|
|
284
|
-
const remainingDurationMs = Math.max(0, state.maxDurationMs - elapsedMs)
|
|
285
|
-
const decisionRequired = remainingFiles === 0 || remainingDurationMs === 0
|
|
286
|
-
|| remainingTokenEstimate === 0
|
|
287
|
-
return { ...state, elapsedMs, remainingFiles, remainingTokenEstimate, remainingDurationMs,
|
|
288
|
-
decisionRequired, nextActions: decisionRequired ? ['execute', 'plan', 'blocked'] : [] }
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
async function initializeReadBudget(input) {
|
|
292
|
-
const root = await repositoryRoot(input.repositoryRoot)
|
|
293
|
-
const chainId = identifier(input.chainId, 'chainId')
|
|
294
|
-
const limits = READ_BUDGETS[input.mode]
|
|
295
|
-
if (!limits) fail('AIMLOCK_MODE_INVALID', 'mode must be lock, probe, or swarm')
|
|
296
|
-
const directory = await ensureManagedDirectory(root, 'runs', chainId)
|
|
297
|
-
const state = {
|
|
298
|
-
schemaVersion: BUDGET_SCHEMA,
|
|
299
|
-
chainId,
|
|
300
|
-
mode: input.mode,
|
|
301
|
-
startedAt: new Date().toISOString(),
|
|
302
|
-
...limits,
|
|
303
|
-
uniqueFiles: [],
|
|
304
|
-
readCalls: 0,
|
|
305
|
-
tokenEstimate: 0,
|
|
306
|
-
tokenEstimateAlgorithm: TOKEN_ESTIMATE_ALGORITHM,
|
|
307
|
-
extensions: [],
|
|
308
|
-
}
|
|
309
|
-
await writeFile(resolve(directory, 'read-budget.json'), `${JSON.stringify(state)}\n`, {
|
|
310
|
-
flag: 'wx', mode: 0o600,
|
|
311
|
-
})
|
|
312
|
-
await appendAudit(root, { event: 'read-budget-initialized', chainId, mode: input.mode })
|
|
313
|
-
return budgetView(state)
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
async function readFileWithinBudget(input) {
|
|
317
|
-
const root = await repositoryRoot(input.repositoryRoot)
|
|
318
|
-
const chainId = identifier(input.chainId, 'chainId')
|
|
319
|
-
await assertChainNotSuspended({ repositoryRoot: root, chainId })
|
|
320
|
-
const budgetPath = managedPath(root, 'runs', chainId, 'read-budget.json')
|
|
321
|
-
return withFileLock(budgetPath, async () => {
|
|
322
|
-
const authority = await readBudget(root, chainId)
|
|
323
|
-
const path = safeRelativePath(input.path)
|
|
324
|
-
const state = authority.state
|
|
325
|
-
const before = budgetView(state)
|
|
326
|
-
if (before.remainingDurationMs === 0) fail('AIMLOCK_DECISION_REQUIRED', 'read deadline exhausted')
|
|
327
|
-
const isNew = !state.uniqueFiles.includes(path)
|
|
328
|
-
if (isNew && before.remainingFiles === 0) fail('AIMLOCK_DECISION_REQUIRED', 'read file budget exhausted')
|
|
329
|
-
const projectFile = await resolvedProjectPath(root, path)
|
|
330
|
-
if (!projectFile.status.isFile()) fail('AIMLOCK_READ_NOT_FILE', `${path} is not a file`)
|
|
331
|
-
const tokenEstimate = Math.ceil(projectFile.status.size / 4)
|
|
332
|
-
if (before.remainingTokenEstimate !== null && tokenEstimate > before.remainingTokenEstimate) {
|
|
333
|
-
fail('AIMLOCK_DECISION_REQUIRED', 'read token estimate budget exhausted')
|
|
334
|
-
}
|
|
335
|
-
const content = await readFile(projectFile.target, 'utf8')
|
|
336
|
-
const updated = {
|
|
337
|
-
...state,
|
|
338
|
-
uniqueFiles: isNew ? [...state.uniqueFiles, path] : state.uniqueFiles,
|
|
339
|
-
readCalls: state.readCalls + 1,
|
|
340
|
-
tokenEstimate: state.tokenEstimate + tokenEstimate,
|
|
341
|
-
}
|
|
342
|
-
await atomicJson(authority.path, updated)
|
|
343
|
-
await appendAudit(root, { event: 'read-consumed', chainId, path, tokenEstimate })
|
|
344
|
-
return { schemaVersion: LOCAL_SCHEMA, path, content, budget: budgetView(updated) }
|
|
345
|
-
})
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
async function checkCachedReadAccess(input) {
|
|
349
|
-
const root = await repositoryRoot(input.repositoryRoot)
|
|
350
|
-
const chainId = identifier(input.chainId, 'chainId')
|
|
351
|
-
await assertChainNotSuspended({ repositoryRoot: root, chainId })
|
|
352
|
-
const budgetPath = managedPath(root, 'runs', chainId, 'read-budget.json')
|
|
353
|
-
return withFileLock(budgetPath, async () => {
|
|
354
|
-
const { state } = await readBudget(root, chainId)
|
|
355
|
-
const path = safeRelativePath(input.path)
|
|
356
|
-
const budget = budgetView(state)
|
|
357
|
-
if (budget.remainingDurationMs === 0) fail('AIMLOCK_DECISION_REQUIRED', 'read deadline exhausted')
|
|
358
|
-
if (budget.remainingTokenEstimate === 0) fail('AIMLOCK_DECISION_REQUIRED', 'read token estimate budget exhausted')
|
|
359
|
-
if (!state.uniqueFiles.includes(path)) fail('AIMLOCK_CACHE_UNCHARGED', 'cached source was not read by this chain')
|
|
360
|
-
return { schemaVersion: LOCAL_SCHEMA, path, budget }
|
|
361
|
-
})
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
async function readBudgetStatus(input) {
|
|
365
|
-
const root = await repositoryRoot(input.repositoryRoot)
|
|
366
|
-
return budgetView((await readBudget(root, input.chainId)).state)
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
function confirmedBudgetExtension(input) {
|
|
370
|
-
const confirmation = input.confirmation
|
|
371
|
-
const audit = confirmation?.auditEntry
|
|
372
|
-
const callback = confirmation?.callbackRequest
|
|
373
|
-
const payload = callback?.payload
|
|
374
|
-
const fields = ['files', 'tokenEstimate', 'durationMs']
|
|
375
|
-
if (!confirmation || confirmation.schemaVersion !== CONFIRMATION_SCHEMA || confirmation.status !== 'succeeded'
|
|
376
|
-
|| audit?.schemaVersion !== 'confirm.audit-entry/1.0' || audit.risk !== 'low' || audit.answer !== 'approve'
|
|
377
|
-
|| typeof audit.remembered !== 'boolean' || !Number.isFinite(Date.parse(audit.answeredAt))
|
|
378
|
-
|| callback?.operation !== 'budget-extend' || payload?.answer !== 'approve'
|
|
379
|
-
|| payload.chainId !== input.chainId || payload.requestId !== audit.requestId
|
|
380
|
-
|| !payload.additions || fields.some((key) => payload.additions[key] !== input.additions?.[key])) {
|
|
381
|
-
fail('AIMLOCK_CONFIRMATION_REQUIRED', 'a low-risk Confirm Protocol interaction-answer bound to this chain and exact additions is required')
|
|
382
|
-
}
|
|
383
|
-
identifier(audit.actorId, 'actorId')
|
|
384
|
-
identifier(audit.requestId, 'requestId')
|
|
385
|
-
return identifier(audit.auditId, 'auditId')
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
async function extendReadBudget(input) {
|
|
389
|
-
const root = await repositoryRoot(input.repositoryRoot)
|
|
390
|
-
const confirmationId = confirmedBudgetExtension(input)
|
|
391
|
-
const additions = input.additions
|
|
392
|
-
if (!additions || !Number.isSafeInteger(additions.files) || additions.files < 0
|
|
393
|
-
|| !Number.isSafeInteger(additions.tokenEstimate) || additions.tokenEstimate < 0
|
|
394
|
-
|| !Number.isSafeInteger(additions.durationMs) || additions.durationMs < 0
|
|
395
|
-
|| additions.files + additions.tokenEstimate + additions.durationMs === 0) {
|
|
396
|
-
fail('AIMLOCK_EXTENSION_INVALID', 'budget additions must contain a positive integer increase')
|
|
397
|
-
}
|
|
398
|
-
const chainId = identifier(input.chainId, 'chainId')
|
|
399
|
-
const budgetPath = managedPath(root, 'runs', chainId, 'read-budget.json')
|
|
400
|
-
return withFileLock(budgetPath, async () => {
|
|
401
|
-
const authority = await readBudget(root, chainId)
|
|
402
|
-
const state = authority.state
|
|
403
|
-
if (state.extensions.some((item) => item.confirmationId === confirmationId)) {
|
|
404
|
-
fail('AIMLOCK_CONFIRMATION_REPLAYED', 'this budget confirmation has already been applied')
|
|
405
|
-
}
|
|
406
|
-
const updated = {
|
|
407
|
-
...state,
|
|
408
|
-
maxFiles: state.maxFiles + additions.files,
|
|
409
|
-
maxTokenEstimate: state.maxTokenEstimate === null && additions.tokenEstimate === 0
|
|
410
|
-
? null : (state.maxTokenEstimate ?? 0) + additions.tokenEstimate,
|
|
411
|
-
maxDurationMs: state.maxDurationMs + additions.durationMs,
|
|
412
|
-
extensions: [...state.extensions, {
|
|
413
|
-
confirmationId,
|
|
414
|
-
additions,
|
|
415
|
-
at: new Date().toISOString(),
|
|
416
|
-
}],
|
|
417
|
-
}
|
|
418
|
-
await atomicJson(authority.path, updated)
|
|
419
|
-
await appendAudit(root, { event: 'read-budget-extended', chainId,
|
|
420
|
-
confirmationId, additions })
|
|
421
|
-
return budgetView(updated)
|
|
422
|
-
})
|
|
423
|
-
}
|
|
424
|
-
|
|
425
258
|
const LOCAL_CAPABILITIES = Object.freeze({
|
|
426
259
|
schemaVersion: LOCAL_SCHEMA,
|
|
427
260
|
operations: Object.freeze([
|
|
428
261
|
'capabilities', 'probe', 'reassess', 'budget-init', 'budget-read', 'budget-status',
|
|
429
|
-
'budget-extend', 'gate-issue', 'gate-verify', 'guarded-write',
|
|
262
|
+
'budget-extend', 'budget-auto-renew-request', 'budget-auto-renew', 'budget-auto-renew-stop', 'gate-issue', 'gate-verify', 'guarded-write',
|
|
430
263
|
]),
|
|
431
264
|
operationSchemas: LOCAL_OPERATION_SCHEMAS,
|
|
432
265
|
writeBoundary: 'Only writes routed through guarded-write are physically intercepted. The IDE host must route batch writes through this runner.',
|
|
@@ -442,6 +275,9 @@ export {
|
|
|
442
275
|
LOCAL_SCHEMA,
|
|
443
276
|
PASS_SCHEMA,
|
|
444
277
|
READ_BUDGETS,
|
|
278
|
+
authorizeReadBudgetRenewal,
|
|
279
|
+
requestReadBudgetRenewal,
|
|
280
|
+
stopReadBudgetRenewal,
|
|
445
281
|
checkCachedReadAccess,
|
|
446
282
|
extendReadBudget,
|
|
447
283
|
guardedWriteFile,
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { relative } from 'node:path'
|
|
3
|
+
import { appendAudit, atomicJson, fail, identifier, managedPath, repositoryRoot,
|
|
4
|
+
resolvedProjectPath, safeRelativePath, sha256, withFileLock } from './aimlock-local-fs.mjs'
|
|
5
|
+
import { readBudget, budgetView, requiredRenewalIntervals } from './aimlock-read-budget-state.mjs'
|
|
6
|
+
|
|
7
|
+
const RENEWAL_SCHEMA = 'aimlock.read-budget-auto-renew/1.0'
|
|
8
|
+
const MIN_INTERVAL_MS = 60_000
|
|
9
|
+
const MAX_INTERVAL_MS = 86_400_000
|
|
10
|
+
const MAX_RENEWALS = 1_000
|
|
11
|
+
const RENEWAL_LIMITS = Object.freeze({ minIntervalMs: MIN_INTERVAL_MS,
|
|
12
|
+
maxIntervalMs: MAX_INTERVAL_MS, maxRenewals: MAX_RENEWALS })
|
|
13
|
+
const RENEW_OPERATION = 'budget-auto-renew'
|
|
14
|
+
const RENEWAL_REASON = 'authorized-read-deadline-expired'
|
|
15
|
+
|
|
16
|
+
function renewalTerms(input) {
|
|
17
|
+
const chainId = identifier(input.chainId, 'chainId')
|
|
18
|
+
const scope = input.scope
|
|
19
|
+
const policy = input.policy
|
|
20
|
+
if (!scope || typeof scope.goal !== 'string' || !scope.goal.trim()
|
|
21
|
+
|| !Array.isArray(scope.allowedPaths) || scope.allowedPaths.length === 0) {
|
|
22
|
+
fail('AIMLOCK_RENEWAL_SCOPE_INVALID', 'a goal and explicit repository-relative allowedPaths are required')
|
|
23
|
+
}
|
|
24
|
+
const paths = scope.allowedPaths.map((path) => {
|
|
25
|
+
const value = safeRelativePath(path)
|
|
26
|
+
if (/[*?\[\]{}]/.test(value)) fail('AIMLOCK_RENEWAL_SCOPE_INVALID', 'scope paths must be literal files or directories')
|
|
27
|
+
return value
|
|
28
|
+
})
|
|
29
|
+
if (!policy || !Number.isSafeInteger(policy.intervalMs) || policy.intervalMs < MIN_INTERVAL_MS
|
|
30
|
+
|| policy.intervalMs > MAX_INTERVAL_MS || !Number.isSafeInteger(policy.maxRenewals)
|
|
31
|
+
|| policy.maxRenewals < 1 || policy.maxRenewals > MAX_RENEWALS) {
|
|
32
|
+
fail('AIMLOCK_RENEWAL_POLICY_INVALID', 'intervalMs must be 60000..86400000; maxRenewals must be 1..1000')
|
|
33
|
+
}
|
|
34
|
+
const normalizedScope = { goal: scope.goal.trim(), allowedPaths: [...new Set(paths)].sort() }
|
|
35
|
+
return { chainId, scope: normalizedScope, scopeDigest: sha256(JSON.stringify(normalizedScope)),
|
|
36
|
+
policy: { intervalMs: policy.intervalMs, maxRenewals: policy.maxRenewals } }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function renewalQuestion(terms) {
|
|
40
|
+
return `Allow automatic read-time renewal for chain ${terms.chainId}? Goal: ${terms.scope.goal}. `
|
|
41
|
+
+ `Read scope: ${terms.scope.allowedPaths.join(', ')}. Each renewal: ${terms.policy.intervalMs} ms; `
|
|
42
|
+
+ `maximum: ${terms.policy.maxRenewals}; total additional time: ${terms.policy.intervalMs * terms.policy.maxRenewals} ms. `
|
|
43
|
+
+ 'File, token and write limits remain unchanged. Revocation or task completion stops automatic renewal.'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function requestReadBudgetRenewal(input) {
|
|
47
|
+
const terms = renewalTerms(input)
|
|
48
|
+
const root = await repositoryRoot(input.repositoryRoot)
|
|
49
|
+
const { state } = await readBudget(root, terms.chainId)
|
|
50
|
+
await assertReadBudgetActive(root, state)
|
|
51
|
+
const requestId = identifier(input.requestId, 'requestId')
|
|
52
|
+
return { schemaVersion: 'confirm.interaction/1.0', requestId, type: 'confirm',
|
|
53
|
+
question: renewalQuestion(terms), options: [{ id: 'approve', label: 'Approve' }, { id: 'decline', label: 'Decline' }],
|
|
54
|
+
default: null, timeout: null, timeoutAction: 'wait', risk: 'low', riskDescription: '',
|
|
55
|
+
rememberable: false, memoryKey: '', callback: { operation: RENEW_OPERATION,
|
|
56
|
+
payload: { chainId: terms.chainId, scopeDigest: terms.scopeDigest, policy: terms.policy } } }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function renewalReceipt(input, terms) {
|
|
60
|
+
const response = input.confirmation
|
|
61
|
+
const audit = response?.auditEntry
|
|
62
|
+
const callback = response?.callbackRequest
|
|
63
|
+
const payload = callback?.payload
|
|
64
|
+
if (response?.schemaVersion !== 'confirm-protocol.skill.response/1.0' || response.status !== 'succeeded'
|
|
65
|
+
|| audit?.schemaVersion !== 'confirm.audit-entry/1.0' || audit.risk !== 'low'
|
|
66
|
+
|| audit.answer !== 'approve' || audit.remembered !== false || !Number.isFinite(Date.parse(audit.answeredAt))
|
|
67
|
+
|| audit.question !== renewalQuestion(terms) || callback?.operation !== RENEW_OPERATION
|
|
68
|
+
|| payload?.answer !== 'approve' || payload.chainId !== terms.chainId || payload.requestId !== audit.requestId
|
|
69
|
+
|| payload.scopeDigest !== terms.scopeDigest || payload.policy?.intervalMs !== terms.policy.intervalMs
|
|
70
|
+
|| payload.policy?.maxRenewals !== terms.policy.maxRenewals) {
|
|
71
|
+
fail('AIMLOCK_CONFIRMATION_REQUIRED', 'a one-time Confirm Protocol approval bound to the chain, goal, scope and exact renewal policy is required')
|
|
72
|
+
}
|
|
73
|
+
identifier(response.requestId, 'confirmation.requestId')
|
|
74
|
+
return { confirmationId: identifier(audit.auditId, 'auditId'), actorId: identifier(audit.actorId, 'actorId'),
|
|
75
|
+
requestId: identifier(audit.requestId, 'requestId'), authorizedAt: audit.answeredAt }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function authorizeReadBudgetRenewal(input) {
|
|
79
|
+
const terms = renewalTerms(input)
|
|
80
|
+
const receipt = renewalReceipt(input, terms)
|
|
81
|
+
const root = await repositoryRoot(input.repositoryRoot)
|
|
82
|
+
const budgetPath = managedPath(root, 'runs', terms.chainId, 'read-budget.json')
|
|
83
|
+
return withFileLock(budgetPath, async () => {
|
|
84
|
+
const { state } = await readBudget(root, terms.chainId)
|
|
85
|
+
await assertReadBudgetActive(root, state)
|
|
86
|
+
if (state.autoRenew) {
|
|
87
|
+
const replayed = state.autoRenew.confirmationId === receipt.confirmationId
|
|
88
|
+
fail(replayed ? 'AIMLOCK_CONFIRMATION_REPLAYED' : 'AIMLOCK_RENEWAL_ALREADY_AUTHORIZED',
|
|
89
|
+
'this chain already has an immutable renewal authorization; use the existing policy or an explicit budget extension')
|
|
90
|
+
}
|
|
91
|
+
for (const path of terms.scope.allowedPaths) {
|
|
92
|
+
const target = await resolvedProjectPath(root, path, { allowMissing: true })
|
|
93
|
+
if (relative(root, target.target).split('\\').join('/') !== path) {
|
|
94
|
+
fail('AIMLOCK_RENEWAL_SCOPE_INVALID', 'authorized paths must use their canonical repository location')
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const updated = { ...state, autoRenew: { schemaVersion: RENEWAL_SCHEMA,
|
|
98
|
+
...terms, ...receipt, status: 'active', renewalCount: 0, totalRenewedMs: 0, renewals: [] } }
|
|
99
|
+
await atomicJson(budgetPath, updated)
|
|
100
|
+
await appendAudit(root, { event: 'read-budget-auto-renew-authorized', ...terms, ...receipt })
|
|
101
|
+
return budgetView(updated)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function executionCompleted(root, chainId) {
|
|
106
|
+
let source
|
|
107
|
+
try {
|
|
108
|
+
source = await readFile(managedPath(root, 'executions', chainId, 'state.json'), 'utf8')
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error.code === 'ENOENT') return false
|
|
111
|
+
throw error
|
|
112
|
+
}
|
|
113
|
+
const execution = JSON.parse(source)
|
|
114
|
+
if (execution.chainId !== chainId || typeof execution.status !== 'string') {
|
|
115
|
+
fail('AIMLOCK_CHAIN_STATE_INVALID', 'execution lifecycle does not match the read-budget chain')
|
|
116
|
+
}
|
|
117
|
+
return execution.status === 'succeeded'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function assertReadBudgetActive(root, state) {
|
|
121
|
+
if (state.completedAt || await executionCompleted(root, state.chainId)) {
|
|
122
|
+
fail('AIMLOCK_BUDGET_COMPLETED', 'this task is complete; read access and automatic renewal have stopped')
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function assertRenewalScope(state, path, canonicalPath) {
|
|
127
|
+
if (!state.autoRenew) return
|
|
128
|
+
const paths = state.autoRenew.scope.allowedPaths
|
|
129
|
+
const covered = (target) => paths.some((allowed) => target === allowed || target.startsWith(`${allowed}/`))
|
|
130
|
+
if (!covered(path) || !covered(canonicalPath)) {
|
|
131
|
+
fail('AIMLOCK_RENEWAL_SCOPE_MISMATCH', 'source path is outside this task\'s approved read scope')
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function renewReadBudgetTime(root, authority, path) {
|
|
136
|
+
const state = authority.state
|
|
137
|
+
const now = Date.now()
|
|
138
|
+
const budget = budgetView(state, now)
|
|
139
|
+
if (budget.remainingDurationMs > 0) return state
|
|
140
|
+
const renewal = state.autoRenew
|
|
141
|
+
if (!renewal || renewal.status !== 'active') {
|
|
142
|
+
fail('AIMLOCK_DECISION_REQUIRED', `read deadline exhausted; automatic renewal ${renewal ? 'was stopped' : 'requires explicit approval via budget-auto-renew-request'}`)
|
|
143
|
+
}
|
|
144
|
+
if (budget.remainingFiles === 0 || budget.remainingTokenEstimate === 0) {
|
|
145
|
+
fail('AIMLOCK_DECISION_REQUIRED', 'file or token budget exhausted; time renewal cannot expand these limits')
|
|
146
|
+
}
|
|
147
|
+
const count = requiredRenewalIntervals(state, now)
|
|
148
|
+
if (count > renewal.policy.maxRenewals - renewal.renewalCount) {
|
|
149
|
+
fail('AIMLOCK_DECISION_REQUIRED', `automatic renewal limit exhausted: ${renewal.renewalCount}/${renewal.policy.maxRenewals} used; ${count} additional intervals required`)
|
|
150
|
+
}
|
|
151
|
+
const at = new Date(now).toISOString()
|
|
152
|
+
const records = Array.from({ length: count }, (_, index) => ({
|
|
153
|
+
count: renewal.renewalCount + index + 1, reason: RENEWAL_REASON, at, path,
|
|
154
|
+
durationMs: renewal.policy.intervalMs,
|
|
155
|
+
maxDurationMs: state.maxDurationMs + (index + 1) * renewal.policy.intervalMs,
|
|
156
|
+
}))
|
|
157
|
+
const addedMs = count * renewal.policy.intervalMs
|
|
158
|
+
const updated = { ...state, maxDurationMs: state.maxDurationMs + addedMs,
|
|
159
|
+
autoRenew: { ...renewal, renewalCount: renewal.renewalCount + count,
|
|
160
|
+
totalRenewedMs: renewal.totalRenewedMs + addedMs, renewals: [...renewal.renewals, ...records] } }
|
|
161
|
+
await atomicJson(authority.path, updated)
|
|
162
|
+
for (const record of records) await appendAudit(root, { event: 'read-budget-auto-renewed',
|
|
163
|
+
chainId: state.chainId, confirmationId: renewal.confirmationId, scopeDigest: renewal.scopeDigest, ...record })
|
|
164
|
+
return updated
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function stopReadBudgetRenewal(input) {
|
|
168
|
+
const root = await repositoryRoot(input.repositoryRoot)
|
|
169
|
+
const chainId = identifier(input.chainId, 'chainId')
|
|
170
|
+
if (!['revoked', 'completed'].includes(input.reason)) {
|
|
171
|
+
fail('AIMLOCK_RENEWAL_STOP_INVALID', 'reason must be revoked or completed')
|
|
172
|
+
}
|
|
173
|
+
return withFileLock(managedPath(root, 'runs', chainId, 'read-budget.json'), async () => {
|
|
174
|
+
const { state, path } = await readBudget(root, chainId)
|
|
175
|
+
if (state.completedAt || (input.reason === 'revoked' && state.autoRenew?.status === 'revoked')) return budgetView(state)
|
|
176
|
+
if (input.reason === 'revoked' && !state.autoRenew) {
|
|
177
|
+
fail('AIMLOCK_RENEWAL_NOT_AUTHORIZED', 'this task has no automatic renewal authorization to revoke')
|
|
178
|
+
}
|
|
179
|
+
const at = new Date().toISOString()
|
|
180
|
+
const updated = { ...state }
|
|
181
|
+
if (input.reason === 'completed') updated.completedAt = at
|
|
182
|
+
if (state.autoRenew) updated.autoRenew = { ...state.autoRenew, status: input.reason, stoppedAt: at }
|
|
183
|
+
await atomicJson(path, updated)
|
|
184
|
+
await appendAudit(root, { event: 'read-budget-auto-renew-stopped', chainId, reason: input.reason, at })
|
|
185
|
+
return budgetView(updated)
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function completeReadBudgetIfExists(root, chainId) {
|
|
190
|
+
try { await readBudget(root, chainId) } catch (error) {
|
|
191
|
+
if (error.code === 'ENOENT') return
|
|
192
|
+
throw error
|
|
193
|
+
}
|
|
194
|
+
await stopReadBudgetRenewal({ repositoryRoot: root, chainId, reason: 'completed' })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export { RENEWAL_LIMITS, authorizeReadBudgetRenewal, requestReadBudgetRenewal, stopReadBudgetRenewal,
|
|
198
|
+
assertReadBudgetActive, assertRenewalScope, renewReadBudgetTime, completeReadBudgetIfExists }
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { RENEWAL_LIMITS } from './aimlock-read-budget-renewal.mjs'
|
|
2
|
+
|
|
3
|
+
const stringSchema = { type: 'string', minLength: 1 }
|
|
4
|
+
const objectSchema = (required, properties) => ({ type: 'object', additionalProperties: false, required, properties })
|
|
5
|
+
const scopeSchema = objectSchema(['goal', 'allowedPaths'], { goal: stringSchema,
|
|
6
|
+
allowedPaths: { type: 'array', minItems: 1, items: stringSchema } })
|
|
7
|
+
const policySchema = objectSchema(['intervalMs', 'maxRenewals'], {
|
|
8
|
+
intervalMs: { type: 'integer', minimum: RENEWAL_LIMITS.minIntervalMs, maximum: RENEWAL_LIMITS.maxIntervalMs },
|
|
9
|
+
maxRenewals: { type: 'integer', minimum: 1, maximum: RENEWAL_LIMITS.maxRenewals },
|
|
10
|
+
})
|
|
11
|
+
const confirmationSchema = objectSchema(['schemaVersion', 'requestId', 'status', 'callbackRequest', 'auditEntry', 'nextStep'], {
|
|
12
|
+
schemaVersion: { const: 'confirm-protocol.skill.response/1.0' }, requestId: stringSchema,
|
|
13
|
+
status: { const: 'succeeded' },
|
|
14
|
+
callbackRequest: objectSchema(['operation', 'payload'], { operation: { const: 'budget-auto-renew' },
|
|
15
|
+
payload: objectSchema(['chainId', 'scopeDigest', 'policy', 'requestId', 'answer'], {
|
|
16
|
+
chainId: stringSchema, scopeDigest: stringSchema, policy: policySchema, requestId: stringSchema, answer: { const: 'approve' },
|
|
17
|
+
}) }),
|
|
18
|
+
auditEntry: objectSchema(['schemaVersion', 'auditId', 'requestId', 'actorId', 'question', 'answer', 'remembered', 'risk', 'answeredAt'], {
|
|
19
|
+
schemaVersion: { const: 'confirm.audit-entry/1.0' }, auditId: stringSchema, requestId: stringSchema,
|
|
20
|
+
actorId: stringSchema, question: stringSchema, answer: { const: 'approve' }, remembered: { const: false },
|
|
21
|
+
risk: { const: 'low' }, answeredAt: { type: 'string', format: 'date-time' },
|
|
22
|
+
}), nextStep: { type: 'object' },
|
|
23
|
+
})
|
|
24
|
+
const terms = { chainId: stringSchema, scope: scopeSchema, policy: policySchema }
|
|
25
|
+
const AUTO_RENEW_OPERATION_SCHEMAS = Object.freeze({
|
|
26
|
+
'budget-auto-renew-request': objectSchema(['chainId', 'scope', 'policy', 'requestId'], { ...terms, requestId: stringSchema }),
|
|
27
|
+
'budget-auto-renew': objectSchema(['chainId', 'scope', 'policy', 'confirmation'], { ...terms, confirmation: confirmationSchema }),
|
|
28
|
+
'budget-auto-renew-stop': objectSchema(['chainId', 'reason'], { chainId: stringSchema, reason: { enum: ['revoked', 'completed'] } }),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
export { AUTO_RENEW_OPERATION_SCHEMAS }
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { appendAudit, ensureManagedDirectory, fail, identifier, managedPath, repositoryRoot } from './aimlock-local-fs.mjs'
|
|
4
|
+
|
|
5
|
+
const BUDGET_SCHEMA = 'aimlock.read-budget/1.0'
|
|
6
|
+
const TOKEN_ESTIMATE_ALGORITHM = 'utf8-bytes-div-4-ceil'
|
|
7
|
+
const READ_BUDGETS = Object.freeze({
|
|
8
|
+
lock: Object.freeze({ maxFiles: 3, maxTokenEstimate: null, maxDurationMs: 120_000 }),
|
|
9
|
+
probe: Object.freeze({ maxFiles: 10, maxTokenEstimate: 30_000, maxDurationMs: 480_000 }),
|
|
10
|
+
swarm: Object.freeze({ maxFiles: 30, maxTokenEstimate: 100_000, maxDurationMs: 3_600_000 }),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
async function readBudget(root, chainId) {
|
|
14
|
+
const id = identifier(chainId, 'chainId')
|
|
15
|
+
const path = managedPath(root, 'runs', id, 'read-budget.json')
|
|
16
|
+
const state = JSON.parse(await readFile(path, 'utf8'))
|
|
17
|
+
if (state.schemaVersion !== BUDGET_SCHEMA || state.chainId !== id) {
|
|
18
|
+
fail('AIMLOCK_BUDGET_INVALID', 'read budget authority is invalid')
|
|
19
|
+
}
|
|
20
|
+
return { path, state }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function budgetView(state, now = Date.now()) {
|
|
24
|
+
const elapsedMs = now - Date.parse(state.startedAt)
|
|
25
|
+
const remainingFiles = Math.max(0, state.maxFiles - state.uniqueFiles.length)
|
|
26
|
+
const remainingTokenEstimate = state.maxTokenEstimate === null ? null
|
|
27
|
+
: Math.max(0, state.maxTokenEstimate - state.tokenEstimate)
|
|
28
|
+
const remainingDurationMs = Math.max(0, state.maxDurationMs - elapsedMs)
|
|
29
|
+
const autoRenewEligible = !state.completedAt && remainingDurationMs === 0
|
|
30
|
+
&& remainingFiles > 0 && remainingTokenEstimate !== 0 && state.autoRenew?.status === 'active'
|
|
31
|
+
&& requiredRenewalIntervals(state, now) <= state.autoRenew.policy.maxRenewals - state.autoRenew.renewalCount
|
|
32
|
+
const decisionRequired = Boolean(state.completedAt) || remainingFiles === 0 || (remainingDurationMs === 0 && !autoRenewEligible)
|
|
33
|
+
|| remainingTokenEstimate === 0
|
|
34
|
+
return { ...state, elapsedMs, remainingFiles, remainingTokenEstimate, remainingDurationMs,
|
|
35
|
+
autoRenewEligible, decisionRequired,
|
|
36
|
+
nextActions: decisionRequired ? ['execute', 'plan', 'blocked'] : autoRenewEligible ? ['budget-read'] : [] }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function requiredRenewalIntervals(state, now = Date.now()) {
|
|
40
|
+
if (!state.autoRenew) return null
|
|
41
|
+
return Math.max(0, Math.floor((now - Date.parse(state.startedAt) - state.maxDurationMs)
|
|
42
|
+
/ state.autoRenew.policy.intervalMs) + 1)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function initializeReadBudget(input) {
|
|
46
|
+
const root = await repositoryRoot(input.repositoryRoot)
|
|
47
|
+
const chainId = identifier(input.chainId, 'chainId')
|
|
48
|
+
const limits = READ_BUDGETS[input.mode]
|
|
49
|
+
if (!limits) fail('AIMLOCK_MODE_INVALID', 'mode must be lock, probe, or swarm')
|
|
50
|
+
const directory = await ensureManagedDirectory(root, 'runs', chainId)
|
|
51
|
+
const state = {
|
|
52
|
+
schemaVersion: BUDGET_SCHEMA,
|
|
53
|
+
chainId,
|
|
54
|
+
mode: input.mode,
|
|
55
|
+
startedAt: new Date().toISOString(),
|
|
56
|
+
...limits,
|
|
57
|
+
uniqueFiles: [],
|
|
58
|
+
readCalls: 0,
|
|
59
|
+
tokenEstimate: 0,
|
|
60
|
+
tokenEstimateAlgorithm: TOKEN_ESTIMATE_ALGORITHM,
|
|
61
|
+
extensions: [],
|
|
62
|
+
}
|
|
63
|
+
await writeFile(resolve(directory, 'read-budget.json'), `${JSON.stringify(state)}\n`, {
|
|
64
|
+
flag: 'wx', mode: 0o600,
|
|
65
|
+
})
|
|
66
|
+
await appendAudit(root, { event: 'read-budget-initialized', chainId, mode: input.mode })
|
|
67
|
+
return budgetView(state)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export { BUDGET_SCHEMA, TOKEN_ESTIMATE_ALGORITHM, READ_BUDGETS, readBudget, budgetView,
|
|
71
|
+
requiredRenewalIntervals, initializeReadBudget }
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { relative } from 'node:path'
|
|
3
|
+
import { LOCAL_SCHEMA, appendAudit, atomicJson, fail, identifier, managedPath,
|
|
4
|
+
repositoryRoot, resolvedProjectPath, safeRelativePath, withFileLock } from './aimlock-local-fs.mjs'
|
|
5
|
+
import { assertChainNotSuspended } from './aimlock-coordination.mjs'
|
|
6
|
+
import { readBudget, budgetView } from './aimlock-read-budget-state.mjs'
|
|
7
|
+
import { assertReadBudgetActive, assertRenewalScope, renewReadBudgetTime } from './aimlock-read-budget-renewal.mjs'
|
|
8
|
+
|
|
9
|
+
const CONFIRMATION_SCHEMA = 'confirm-protocol.skill.response/1.0'
|
|
10
|
+
|
|
11
|
+
async function readFileWithinBudget(input) {
|
|
12
|
+
const root = await repositoryRoot(input.repositoryRoot)
|
|
13
|
+
const chainId = identifier(input.chainId, 'chainId')
|
|
14
|
+
await assertChainNotSuspended({ repositoryRoot: root, chainId })
|
|
15
|
+
const budgetPath = managedPath(root, 'runs', chainId, 'read-budget.json')
|
|
16
|
+
return withFileLock(budgetPath, async () => {
|
|
17
|
+
const authority = await readBudget(root, chainId)
|
|
18
|
+
const path = safeRelativePath(input.path)
|
|
19
|
+
let state = authority.state
|
|
20
|
+
await assertReadBudgetActive(root, state)
|
|
21
|
+
const before = budgetView(state)
|
|
22
|
+
const isNew = !state.uniqueFiles.includes(path)
|
|
23
|
+
if (isNew && before.remainingFiles === 0) fail('AIMLOCK_DECISION_REQUIRED', 'read file budget exhausted')
|
|
24
|
+
const projectFile = await resolvedProjectPath(root, path)
|
|
25
|
+
if (!projectFile.status.isFile()) fail('AIMLOCK_READ_NOT_FILE', `${path} is not a file`)
|
|
26
|
+
assertRenewalScope(state, path, relative(root, projectFile.target).split('\\').join('/'))
|
|
27
|
+
const tokenEstimate = Math.ceil(projectFile.status.size / 4)
|
|
28
|
+
if (before.remainingTokenEstimate !== null && tokenEstimate > before.remainingTokenEstimate) {
|
|
29
|
+
fail('AIMLOCK_DECISION_REQUIRED', 'read token estimate budget exhausted')
|
|
30
|
+
}
|
|
31
|
+
state = await renewReadBudgetTime(root, authority, path)
|
|
32
|
+
const content = await readFile(projectFile.target, 'utf8')
|
|
33
|
+
const updated = {
|
|
34
|
+
...state,
|
|
35
|
+
uniqueFiles: isNew ? [...state.uniqueFiles, path] : state.uniqueFiles,
|
|
36
|
+
readCalls: state.readCalls + 1,
|
|
37
|
+
tokenEstimate: state.tokenEstimate + tokenEstimate,
|
|
38
|
+
}
|
|
39
|
+
await atomicJson(authority.path, updated)
|
|
40
|
+
await appendAudit(root, { event: 'read-consumed', chainId, path, tokenEstimate })
|
|
41
|
+
return { schemaVersion: LOCAL_SCHEMA, path, content, budget: budgetView(updated) }
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function checkCachedReadAccess(input) {
|
|
46
|
+
const root = await repositoryRoot(input.repositoryRoot)
|
|
47
|
+
const chainId = identifier(input.chainId, 'chainId')
|
|
48
|
+
await assertChainNotSuspended({ repositoryRoot: root, chainId })
|
|
49
|
+
const budgetPath = managedPath(root, 'runs', chainId, 'read-budget.json')
|
|
50
|
+
return withFileLock(budgetPath, async () => {
|
|
51
|
+
const authority = await readBudget(root, chainId)
|
|
52
|
+
const { state } = authority
|
|
53
|
+
await assertReadBudgetActive(root, state)
|
|
54
|
+
const path = safeRelativePath(input.path)
|
|
55
|
+
const budget = budgetView(state)
|
|
56
|
+
if (budget.remainingTokenEstimate === 0) fail('AIMLOCK_DECISION_REQUIRED', 'read token estimate budget exhausted')
|
|
57
|
+
if (!state.uniqueFiles.includes(path)) fail('AIMLOCK_CACHE_UNCHARGED', 'cached source was not read by this chain')
|
|
58
|
+
const projectFile = await resolvedProjectPath(root, path)
|
|
59
|
+
assertRenewalScope(state, path, relative(root, projectFile.target).split('\\').join('/'))
|
|
60
|
+
return { schemaVersion: LOCAL_SCHEMA, path,
|
|
61
|
+
budget: budgetView(await renewReadBudgetTime(root, authority, path)) }
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readBudgetStatus(input) {
|
|
66
|
+
const root = await repositoryRoot(input.repositoryRoot)
|
|
67
|
+
return budgetView((await readBudget(root, input.chainId)).state)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function confirmedBudgetExtension(input) {
|
|
71
|
+
const confirmation = input.confirmation
|
|
72
|
+
const audit = confirmation?.auditEntry
|
|
73
|
+
const callback = confirmation?.callbackRequest
|
|
74
|
+
const payload = callback?.payload
|
|
75
|
+
const fields = ['files', 'tokenEstimate', 'durationMs']
|
|
76
|
+
if (!confirmation || confirmation.schemaVersion !== CONFIRMATION_SCHEMA || confirmation.status !== 'succeeded'
|
|
77
|
+
|| audit?.schemaVersion !== 'confirm.audit-entry/1.0' || audit.risk !== 'low' || audit.answer !== 'approve'
|
|
78
|
+
|| typeof audit.remembered !== 'boolean' || !Number.isFinite(Date.parse(audit.answeredAt))
|
|
79
|
+
|| callback?.operation !== 'budget-extend' || payload?.answer !== 'approve'
|
|
80
|
+
|| payload.chainId !== input.chainId || payload.requestId !== audit.requestId
|
|
81
|
+
|| !payload.additions || fields.some((key) => payload.additions[key] !== input.additions?.[key])) {
|
|
82
|
+
fail('AIMLOCK_CONFIRMATION_REQUIRED', 'a low-risk Confirm Protocol interaction-answer bound to this chain and exact additions is required')
|
|
83
|
+
}
|
|
84
|
+
identifier(audit.actorId, 'actorId')
|
|
85
|
+
identifier(audit.requestId, 'requestId')
|
|
86
|
+
return identifier(audit.auditId, 'auditId')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function extendReadBudget(input) {
|
|
90
|
+
const root = await repositoryRoot(input.repositoryRoot)
|
|
91
|
+
const confirmationId = confirmedBudgetExtension(input)
|
|
92
|
+
const additions = input.additions
|
|
93
|
+
if (!additions || !Number.isSafeInteger(additions.files) || additions.files < 0
|
|
94
|
+
|| !Number.isSafeInteger(additions.tokenEstimate) || additions.tokenEstimate < 0
|
|
95
|
+
|| !Number.isSafeInteger(additions.durationMs) || additions.durationMs < 0
|
|
96
|
+
|| additions.files + additions.tokenEstimate + additions.durationMs === 0) {
|
|
97
|
+
fail('AIMLOCK_EXTENSION_INVALID', 'budget additions must contain a positive integer increase')
|
|
98
|
+
}
|
|
99
|
+
const chainId = identifier(input.chainId, 'chainId')
|
|
100
|
+
const budgetPath = managedPath(root, 'runs', chainId, 'read-budget.json')
|
|
101
|
+
return withFileLock(budgetPath, async () => {
|
|
102
|
+
const authority = await readBudget(root, chainId)
|
|
103
|
+
const state = authority.state
|
|
104
|
+
await assertReadBudgetActive(root, state)
|
|
105
|
+
if (state.extensions.some((item) => item.confirmationId === confirmationId)) {
|
|
106
|
+
fail('AIMLOCK_CONFIRMATION_REPLAYED', 'this budget confirmation has already been applied')
|
|
107
|
+
}
|
|
108
|
+
const updated = {
|
|
109
|
+
...state,
|
|
110
|
+
maxFiles: state.maxFiles + additions.files,
|
|
111
|
+
maxTokenEstimate: state.maxTokenEstimate === null && additions.tokenEstimate === 0
|
|
112
|
+
? null : (state.maxTokenEstimate ?? 0) + additions.tokenEstimate,
|
|
113
|
+
maxDurationMs: state.maxDurationMs + additions.durationMs,
|
|
114
|
+
extensions: [...state.extensions, {
|
|
115
|
+
confirmationId,
|
|
116
|
+
additions,
|
|
117
|
+
at: new Date().toISOString(),
|
|
118
|
+
}],
|
|
119
|
+
}
|
|
120
|
+
await atomicJson(authority.path, updated)
|
|
121
|
+
await appendAudit(root, { event: 'read-budget-extended', chainId,
|
|
122
|
+
confirmationId, additions })
|
|
123
|
+
return budgetView(updated)
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export { checkCachedReadAccess, extendReadBudget, readBudgetStatus, readFileWithinBudget }
|
package/aimlock-runtime.mjs
CHANGED
|
@@ -209,7 +209,7 @@ const RESPONSE_SCHEMA = "aimlock.skill.response/1.1";
|
|
|
209
209
|
const ERROR_SCHEMA = "aimlock.skill.error/1.0";
|
|
210
210
|
const CONTRACT_SCHEMA = "aimlock.scope-contract/1.0";
|
|
211
211
|
const COMPILER_NAME = "aimlock";
|
|
212
|
-
const COMPILER_VERSION = "v7.0.
|
|
212
|
+
const COMPILER_VERSION = "v7.0.36";
|
|
213
213
|
const KEEP_ALIVE_SECONDS = 90;
|
|
214
214
|
const KEEP_ALIVE_MESSAGE = "智能目标持续执行中,请勿关闭!";
|
|
215
215
|
const BYPASS_LINE_BUDGET = 500;
|
|
@@ -1330,7 +1330,7 @@ async function executeRun(request) {
|
|
|
1330
1330
|
localTrustedExecution: {
|
|
1331
1331
|
requiredFor: ["filesystem-probe", "read-budget", "mutate-pass", "guarded-write", "autocoord-lease"],
|
|
1332
1332
|
operations: ["capabilities", "probe", "reassess", "budget-init", "budget-read", "budget-status",
|
|
1333
|
-
"budget-extend", "gate-issue", "gate-verify", "guarded-write"],
|
|
1333
|
+
"budget-extend", "budget-auto-renew-request", "budget-auto-renew", "budget-auto-renew-stop", "gate-issue", "gate-verify", "guarded-write"],
|
|
1334
1334
|
command: "cli-aimlock local <operation> <repositoryRoot>",
|
|
1335
1335
|
schemaDiscovery: "cli-aimlock local capabilities <repositoryRoot>",
|
|
1336
1336
|
boundary: "Only host writes routed through guarded-write are physically intercepted.",
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import { lstat, open, realpath, mkdir, writeFile, readFile } from 'node:fs/promises'
|
|
4
|
+
import { relative, resolve, isAbsolute } from 'node:path'
|
|
5
|
+
|
|
6
|
+
const MAX_FILE_BYTES = 2_000_000
|
|
7
|
+
const MAX_CONTEXT_CHARACTERS = 6_000
|
|
8
|
+
export function validateBrainPath(path) {
|
|
9
|
+
if (typeof path !== 'string' || !path || path.length > 500 || isAbsolute(path)
|
|
10
|
+
|| /[\\:\u0000-\u001f\u007f]/.test(path) || path.split('/').some((part) => !part || part === '.' || part === '..')
|
|
11
|
+
|| /(?:^|\/)(?:\.env(?:\.[^/]*)?|\.git|\.ssh|node_modules|id_rsa|id_ed25519)(?:\/|$)|\.(?:pem|p12|key)$/i.test(path)) {
|
|
12
|
+
throw new Error('Brain target must be a safe relative project file')
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function checkedAncestors(root, path) {
|
|
17
|
+
const canonicalRoot = await realpath(root)
|
|
18
|
+
let current = canonicalRoot
|
|
19
|
+
for (const part of path.split('/').slice(0, -1)) {
|
|
20
|
+
current = resolve(current, part)
|
|
21
|
+
let status
|
|
22
|
+
try { status = await lstat(current) }
|
|
23
|
+
catch (error) { if (error.code === 'ENOENT') continue; throw error }
|
|
24
|
+
if (!status.isDirectory() || status.isSymbolicLink()) throw new Error('Brain target has an unsafe parent')
|
|
25
|
+
}
|
|
26
|
+
return canonicalRoot
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function inspectBrainTarget(root, path) {
|
|
30
|
+
validateBrainPath(path)
|
|
31
|
+
const canonicalRoot = await checkedAncestors(root, path)
|
|
32
|
+
const target = resolve(canonicalRoot, path)
|
|
33
|
+
let status
|
|
34
|
+
try { status = await lstat(target) }
|
|
35
|
+
catch (error) { if (error.code === 'ENOENT') return { path, sha256: null, context: '' }; throw error }
|
|
36
|
+
if (!status.isFile() || status.isSymbolicLink() || status.size > MAX_FILE_BYTES) throw new Error('Brain target is not a bounded regular file')
|
|
37
|
+
const file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
38
|
+
try {
|
|
39
|
+
const opened = await file.stat()
|
|
40
|
+
if (opened.dev !== status.dev || opened.ino !== status.ino || !opened.isFile()) throw new Error('Brain target changed during inspection')
|
|
41
|
+
const bytes = Buffer.alloc(MAX_FILE_BYTES + 1)
|
|
42
|
+
let length = 0
|
|
43
|
+
while (length < bytes.length) {
|
|
44
|
+
const chunk = await file.read(bytes, length, bytes.length - length, length)
|
|
45
|
+
if (!chunk.bytesRead) break
|
|
46
|
+
length += chunk.bytesRead
|
|
47
|
+
}
|
|
48
|
+
if (length > MAX_FILE_BYTES) throw new Error('Brain target exceeds the file limit')
|
|
49
|
+
const final = await lstat(target)
|
|
50
|
+
const inside = relative(canonicalRoot, await realpath(target))
|
|
51
|
+
if (inside === '..' || inside.startsWith('../') || isAbsolute(inside)
|
|
52
|
+
|| final.dev !== opened.dev || final.ino !== opened.ino || final.mtimeMs !== opened.mtimeMs) {
|
|
53
|
+
throw new Error('Brain target changed during inspection')
|
|
54
|
+
}
|
|
55
|
+
const contents = bytes.subarray(0, length)
|
|
56
|
+
if (contents.includes(0)) throw new Error('Brain context must be a text file')
|
|
57
|
+
return { path, sha256: createHash('sha256').update(contents).digest('hex'),
|
|
58
|
+
context: contents.toString('utf8').slice(0, MAX_CONTEXT_CHARACTERS) }
|
|
59
|
+
} finally { await file.close() }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function brainStateDirectory(root, parts) {
|
|
63
|
+
const canonicalRoot = await realpath(root)
|
|
64
|
+
let directory = canonicalRoot
|
|
65
|
+
for (const part of ['.aimlock', ...parts]) {
|
|
66
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(part) && part !== '.aimlock') throw new Error('Brain state path is invalid')
|
|
67
|
+
directory = resolve(directory, part)
|
|
68
|
+
try { await mkdir(directory, { mode: 0o700 }) }
|
|
69
|
+
catch (error) { if (error.code !== 'EEXIST') throw error }
|
|
70
|
+
const status = await lstat(directory)
|
|
71
|
+
if (!status.isDirectory() || status.isSymbolicLink()) throw new Error('Brain state directory is unsafe')
|
|
72
|
+
}
|
|
73
|
+
return directory
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function saveBrainRequest(root, request, digest) {
|
|
77
|
+
const directory = await brainStateDirectory(root, ['brain-requests'])
|
|
78
|
+
const path = resolve(directory, request.requestId + '.json')
|
|
79
|
+
try { await writeFile(path, JSON.stringify(request), { mode: 0o600, flag: 'wx' }) }
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error.code !== 'EEXIST') throw error
|
|
82
|
+
const status = await lstat(path)
|
|
83
|
+
if (!status.isFile() || status.isSymbolicLink() || status.size > MAX_FILE_BYTES) throw new Error('Saved Brain request is unsafe')
|
|
84
|
+
if (digest(JSON.parse(await readFile(path, 'utf8'))) !== digest(request)) {
|
|
85
|
+
throw new Error('Brain request ID is already bound to different local input')
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
package/brain-client.mjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { inspectBrainTarget, brainStateDirectory, saveBrainRequest } from './brain-client-files.mjs'
|
|
4
|
+
export { inspectBrainTarget } from './brain-client-files.mjs'
|
|
5
|
+
import { resolve } from 'node:path'
|
|
6
|
+
import { brainClientAuthorization, transportFailureCode } from './broker.mjs'
|
|
7
|
+
import { executeCommand } from './aimlock-chain-process.mjs'
|
|
8
|
+
|
|
9
|
+
export const BRAIN_ENDPOINT = 'https://cli.tax/api/v1/brain'
|
|
10
|
+
const REQUEST_SCHEMA = 'brain.planning-request/1.0'
|
|
11
|
+
const RESPONSE_SCHEMA = 'brain.planning-response/1.0'
|
|
12
|
+
const PLAN_SCHEMA = 'brain.execution-plan/1.0'
|
|
13
|
+
const MAX_INPUT_BYTES = 256_000
|
|
14
|
+
const TIMEOUT_MS = 120_000
|
|
15
|
+
const HASH_PATTERN = /^[0-9a-f]{64}$/
|
|
16
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
17
|
+
const OPERATIONS = new Set(['plan', 'status', 'report', 'validate'])
|
|
18
|
+
|
|
19
|
+
export function canonicalBrainJson(value) {
|
|
20
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return JSON.stringify(value)
|
|
21
|
+
if (typeof value === 'number' && Number.isFinite(value)) return JSON.stringify(value)
|
|
22
|
+
if (Array.isArray(value)) return '[' + value.map(canonicalBrainJson).join(',') + ']'
|
|
23
|
+
if (!value || typeof value !== 'object') throw new Error('Brain protocol value must be JSON')
|
|
24
|
+
return '{' + Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
|
|
25
|
+
.map(([key, item]) => JSON.stringify(key) + ':' + canonicalBrainJson(item)).join(',') + '}'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function brainClientDigest(value) {
|
|
29
|
+
return createHash('sha256').update(canonicalBrainJson(value)).digest('hex')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function exact(value, keys, label) {
|
|
33
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
34
|
+
|| Object.keys(value).some((key) => !keys.includes(key))
|
|
35
|
+
|| keys.some((key) => !Object.hasOwn(value, key))) throw new Error(label + ' has invalid fields')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function invokeBrain(operation, input, dependencies = {}) {
|
|
39
|
+
if (!OPERATIONS.has(operation)) throw new Error('Unknown Brain operation')
|
|
40
|
+
const endpoint = dependencies.endpoint ?? BRAIN_ENDPOINT
|
|
41
|
+
const context = { endpoint, displayName: 'Brain planning' }
|
|
42
|
+
const environment = dependencies.environment ?? process.env
|
|
43
|
+
const authorization = await brainClientAuthorization(context, environment, dependencies.credentialAccess)
|
|
44
|
+
const body = JSON.stringify({ operation, input })
|
|
45
|
+
if (Buffer.byteLength(body) > MAX_INPUT_BYTES) throw new Error('Brain request exceeds the size limit')
|
|
46
|
+
let response
|
|
47
|
+
try {
|
|
48
|
+
response = await (dependencies.request ?? fetch)(endpoint, {
|
|
49
|
+
method: 'POST', redirect: 'error', headers: { Authorization: authorization, 'Content-Type': 'application/json' },
|
|
50
|
+
body, signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
51
|
+
})
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw new Error('Brain transport failed (' + transportFailureCode(error) + '); query status before submitting another plan')
|
|
54
|
+
}
|
|
55
|
+
const payload = await readBrainResponse(response)
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
const code = typeof payload?.code === 'string' ? payload.code : 'BRAIN_HTTP_ERROR'
|
|
58
|
+
throw new Error('Brain request failed: HTTP ' + response.status + ' (' + code + ')')
|
|
59
|
+
}
|
|
60
|
+
if (payload?.schemaVersion !== RESPONSE_SCHEMA || !UUID_PATTERN.test(payload.planId)
|
|
61
|
+
|| !UUID_PATTERN.test(payload.requestId)
|
|
62
|
+
|| !['planning', 'ready', 'reported', 'verified', 'failed', 'expired'].includes(payload.status)) {
|
|
63
|
+
throw new Error('Brain response envelope is invalid')
|
|
64
|
+
}
|
|
65
|
+
if ((operation === 'plan' && payload.requestId !== input.requestId)
|
|
66
|
+
|| (operation !== 'plan' && (input.planId ? payload.planId !== input.planId : payload.requestId !== input.requestId))) throw new Error('Brain response identity does not match')
|
|
67
|
+
if (payload.plan !== null && (payload.plan?.schemaVersion !== PLAN_SCHEMA
|
|
68
|
+
|| payload.plan.planId !== payload.planId || !HASH_PATTERN.test(payload.planDigest)
|
|
69
|
+
|| brainClientDigest(payload.plan) !== payload.planDigest)) throw new Error('Brain response plan digest is invalid')
|
|
70
|
+
return payload
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function prepareBrainRequest(root, specification) {
|
|
74
|
+
exact(specification, ['requestId', 'goal', 'maxChangedLines', 'targets', 'checks'], 'Brain specification')
|
|
75
|
+
if (!UUID_PATTERN.test(specification.requestId) || typeof specification.goal !== 'string' || !specification.goal.trim()
|
|
76
|
+
|| !Number.isSafeInteger(specification.maxChangedLines) || specification.maxChangedLines < 1
|
|
77
|
+
|| !Array.isArray(specification.targets) || !specification.targets.length || specification.targets.length > 64
|
|
78
|
+
|| new Set(specification.targets).size !== specification.targets.length) throw new Error('Brain specification is invalid')
|
|
79
|
+
const targets = []
|
|
80
|
+
for (const path of specification.targets) targets.push(await inspectBrainTarget(root, path))
|
|
81
|
+
return { schemaVersion: REQUEST_SCHEMA, ...specification, targets }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function validateBrainHandoff(request, response) {
|
|
85
|
+
const plan = response.plan
|
|
86
|
+
if (response.status !== 'ready' || plan?.schemaVersion !== PLAN_SCHEMA || response.requestId !== request.requestId
|
|
87
|
+
|| response.planId !== plan.planId || brainClientDigest(plan) !== response.planDigest) throw new Error('Brain plan is not ready')
|
|
88
|
+
const targets = request.targets.map(({ path, sha256 }) => ({ path, sha256 }))
|
|
89
|
+
if (brainClientDigest(plan.targets) !== brainClientDigest(targets)
|
|
90
|
+
|| brainClientDigest(plan.checks) !== brainClientDigest(request.checks)
|
|
91
|
+
|| brainClientDigest(plan.contract.allowedPaths) !== brainClientDigest(targets.map((target) => target.path))
|
|
92
|
+
|| plan.contract.maxChangedLines !== request.maxChangedLines || plan.contract.allowDeleteFiles !== false) {
|
|
93
|
+
throw new Error('Brain plan changed the local authorization')
|
|
94
|
+
}
|
|
95
|
+
if (!Array.isArray(plan.nodes) || !plan.nodes.length
|
|
96
|
+
|| plan.nodes.some((node) => !targets.some((target) => target.path === node.path))) throw new Error('Brain plan targets are invalid')
|
|
97
|
+
return plan
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function collectBrainReport(root, handoff, environment = process.env) {
|
|
101
|
+
exact(handoff, ['request', 'response'], 'Brain handoff')
|
|
102
|
+
const plan = validateBrainHandoff(handoff.request, handoff.response)
|
|
103
|
+
if (typeof environment.PATH !== 'string' || !environment.PATH) throw new Error('Execution PATH is required')
|
|
104
|
+
const reportId = randomUUID()
|
|
105
|
+
const directory = await brainStateDirectory(root, ['brain-reports', reportId])
|
|
106
|
+
const evidence = []
|
|
107
|
+
for (const check of plan.checks) {
|
|
108
|
+
exact(check, ['id', 'executable', 'args', 'timeoutMs'], 'Brain check')
|
|
109
|
+
if (typeof check.id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(check.id)
|
|
110
|
+
|| typeof check.executable !== 'string' || !check.executable
|
|
111
|
+
|| !Array.isArray(check.args) || check.args.some((arg) => typeof arg !== 'string')
|
|
112
|
+
|| !Number.isSafeInteger(check.timeoutMs) || check.timeoutMs < 1_000 || check.timeoutMs > 3_600_000) {
|
|
113
|
+
throw new Error('Brain check command is invalid')
|
|
114
|
+
}
|
|
115
|
+
const result = await executeCommand(root, {
|
|
116
|
+
executable: check.executable, args: check.args, timeoutMs: check.timeoutMs,
|
|
117
|
+
environment: { PATH: environment.PATH }, workingDirectory: '.', evidenceKind: 'test',
|
|
118
|
+
}, reportId + ':' + check.id, async () => {})
|
|
119
|
+
const digest = (value) => createHash('sha256').update(value).digest('hex')
|
|
120
|
+
evidence.push({ checkId: check.id, exitCode: result.status !== 'succeeded' && result.exitCode === 0 ? null : result.exitCode, durationMs: result.durationMs,
|
|
121
|
+
stdoutSha256: digest(result.stdout), stderrSha256: digest(result.stderr) })
|
|
122
|
+
await writeFile(resolve(directory, check.id + '.json'), JSON.stringify(result), { mode: 0o600 })
|
|
123
|
+
if (result.status === 'uncertain') throw new Error('Check process cleanup is unconfirmed; execution stopped')
|
|
124
|
+
}
|
|
125
|
+
const files = []
|
|
126
|
+
for (const node of plan.nodes) {
|
|
127
|
+
const actual = await inspectBrainTarget(root, node.path)
|
|
128
|
+
if (actual.sha256 === null) throw new Error('Planned file is missing: ' + node.path)
|
|
129
|
+
files.push({ path: node.path, sha256: actual.sha256 })
|
|
130
|
+
}
|
|
131
|
+
return { planId: plan.planId, planDigest: handoff.response.planDigest, reportId, files, evidence }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function runBrainCli(args) {
|
|
135
|
+
const [operation, root, file] = args
|
|
136
|
+
if (!['plan', 'check', 'status', 'validate'].includes(operation) || !root || !file || args.length !== 3) {
|
|
137
|
+
throw new Error('Usage: cli-aimlock brain <plan|check|status|validate> <repositoryRoot> <jsonFile>')
|
|
138
|
+
}
|
|
139
|
+
const source = await readFile(resolve(file), 'utf8')
|
|
140
|
+
if (Buffer.byteLength(source) > MAX_INPUT_BYTES) throw new Error('Brain input file exceeds the size limit')
|
|
141
|
+
const input = JSON.parse(source)
|
|
142
|
+
if (operation === 'plan') {
|
|
143
|
+
const request = await prepareBrainRequest(root, input)
|
|
144
|
+
await saveBrainRequest(root, request, brainClientDigest)
|
|
145
|
+
const response = await invokeBrain('plan', request)
|
|
146
|
+
if (response.status === 'ready') validateBrainHandoff(request, response)
|
|
147
|
+
process.stdout.write(JSON.stringify({ request, response }) + '\n')
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
const payload = operation === 'check' ? await collectBrainReport(root, input) : input
|
|
151
|
+
const response = await invokeBrain(operation === 'check' ? 'report' : operation, payload)
|
|
152
|
+
process.stdout.write(JSON.stringify(response) + '\n')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function readBrainResponse(response) {
|
|
156
|
+
if (!response.body) throw new Error('Brain response body is missing')
|
|
157
|
+
const reader = response.body.getReader()
|
|
158
|
+
const chunks = []
|
|
159
|
+
let bytes = 0
|
|
160
|
+
try {
|
|
161
|
+
for (;;) {
|
|
162
|
+
const chunk = await reader.read()
|
|
163
|
+
if (chunk.done) break
|
|
164
|
+
bytes += chunk.value.byteLength
|
|
165
|
+
if (bytes > MAX_INPUT_BYTES) { await reader.cancel(); throw new Error('Brain response exceeds the size limit') }
|
|
166
|
+
chunks.push(Buffer.from(chunk.value))
|
|
167
|
+
}
|
|
168
|
+
} finally { reader.releaseLock() }
|
|
169
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
170
|
+
}
|
package/cli.mjs
CHANGED
|
@@ -4,10 +4,14 @@ import { dirname, resolve } from 'node:path'
|
|
|
4
4
|
import { cwd, stdin, stdout } from 'node:process'
|
|
5
5
|
import { createInterface } from 'node:readline/promises'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { runBrainCli } from './brain-client.mjs'
|
|
7
8
|
import { CHAIN_USAGE, runChainCli } from './aimlock-chain-cli.mjs'
|
|
8
9
|
import { defaultUsage, dispatchOfficialSkillCli, runIntakeHandshake } from './installer.mjs'
|
|
9
10
|
import {
|
|
10
11
|
LOCAL_CAPABILITIES,
|
|
12
|
+
authorizeReadBudgetRenewal,
|
|
13
|
+
requestReadBudgetRenewal,
|
|
14
|
+
stopReadBudgetRenewal,
|
|
11
15
|
extendReadBudget,
|
|
12
16
|
guardedWriteFile,
|
|
13
17
|
initializeReadBudget,
|
|
@@ -92,6 +96,8 @@ export function aimlockUsage(context) {
|
|
|
92
96
|
const usage = defaultUsage(context)
|
|
93
97
|
if (!usage.includes(COMMON_RUN_USAGE)) throw new Error('Shared CLI run usage contract changed')
|
|
94
98
|
return usage.replace(COMMON_RUN_USAGE, AIMLOCK_RUN_USAGE) + '\n\n' + CHAIN_USAGE
|
|
99
|
+
+ '\n\nRead-time renewal: local budget-auto-renew-request <repositoryRoot> prepares one Confirm Protocol approval;'
|
|
100
|
+
+ '\nlocal budget-auto-renew activates the approved chain/scope/policy; budget-auto-renew-stop revokes or completes it.'
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
async function collectApplicability(input, output) {
|
|
@@ -168,6 +174,9 @@ async function runLocalOperation(operation, repositoryRoot, input) {
|
|
|
168
174
|
if (operation === 'budget-read') return readFileWithinBudget(scoped)
|
|
169
175
|
if (operation === 'budget-status') return readBudgetStatus(scoped)
|
|
170
176
|
if (operation === 'budget-extend') return extendReadBudget(scoped)
|
|
177
|
+
if (operation === 'budget-auto-renew-request') return requestReadBudgetRenewal(scoped)
|
|
178
|
+
if (operation === 'budget-auto-renew') return authorizeReadBudgetRenewal(scoped)
|
|
179
|
+
if (operation === 'budget-auto-renew-stop') return stopReadBudgetRenewal(scoped)
|
|
171
180
|
if (operation === 'gate-issue') return issueMutationPass(scoped)
|
|
172
181
|
if (operation === 'gate-verify') return verifyMutationPassFile(scoped)
|
|
173
182
|
if (operation === 'guarded-write') return guardedWriteFile(scoped)
|
|
@@ -185,7 +194,9 @@ async function dispatchLocal(args) {
|
|
|
185
194
|
|
|
186
195
|
const cliPath = fileURLToPath(import.meta.url)
|
|
187
196
|
if (process.argv[1] && realpathSync(resolve(process.argv[1])) === cliPath) {
|
|
188
|
-
if (process.argv[2] === '
|
|
197
|
+
if (process.argv[2] === 'brain') {
|
|
198
|
+
await runBrainCli(process.argv.slice(3))
|
|
199
|
+
} else if (process.argv[2] === 'chain') {
|
|
189
200
|
await runChainCli(process.argv.slice(3))
|
|
190
201
|
} else if (process.argv[2] === 'local') {
|
|
191
202
|
try {
|
package/package.json
CHANGED
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
"cli-aimlock": "./cli.mjs"
|
|
4
4
|
},
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"cli-swarm": "7.0.
|
|
6
|
+
"cli-swarm": "7.0.36"
|
|
7
7
|
},
|
|
8
8
|
"description": "Aimlock skill installer for CLI.Tax: lock a user request into an executable aim and route Blueprint, Swarm, and Calctool.",
|
|
9
9
|
"exports": {
|
|
10
10
|
"./coordination": "./aimlock-coordination.mjs",
|
|
11
11
|
"./local-runner": "./aimlock-local-runner.mjs",
|
|
12
12
|
"./runtime": "./aimlock-runtime.mjs",
|
|
13
|
-
"./chain-executor": "./aimlock-chain-executor.mjs"
|
|
13
|
+
"./chain-executor": "./aimlock-chain-executor.mjs",
|
|
14
|
+
"./brain-client": "./brain-client.mjs"
|
|
14
15
|
},
|
|
15
16
|
"files": [
|
|
16
17
|
"cli.mjs",
|
|
@@ -32,7 +33,13 @@
|
|
|
32
33
|
"aimlock-local-fs.mjs",
|
|
33
34
|
"aimlock-local-gate.mjs",
|
|
34
35
|
"aimlock-local-runner.mjs",
|
|
36
|
+
"aimlock-read-budget-state.mjs",
|
|
37
|
+
"aimlock-read-budget.mjs",
|
|
38
|
+
"aimlock-read-budget-renewal.mjs",
|
|
39
|
+
"aimlock-read-budget-schemas.mjs",
|
|
35
40
|
"aimlock-runtime.mjs",
|
|
41
|
+
"brain-client.mjs",
|
|
42
|
+
"brain-client-files.mjs",
|
|
36
43
|
"skill/references/chain-executor.md"
|
|
37
44
|
],
|
|
38
45
|
"license": "UNLICENSED",
|
|
@@ -42,5 +49,5 @@
|
|
|
42
49
|
"url": "https://github.com/88208555/aimlock-clitax.git"
|
|
43
50
|
},
|
|
44
51
|
"type": "module",
|
|
45
|
-
"version": "7.0.
|
|
52
|
+
"version": "7.0.36"
|
|
46
53
|
}
|
package/skill/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: "Aimlock 仅用于大型、深度、跨模块、高风险、需要
|
|
|
5
5
|
|
|
6
6
|
# Aimlock Skill
|
|
7
7
|
|
|
8
|
-
Package version: v7.0.
|
|
8
|
+
Package version: v7.0.36
|
|
9
9
|
|
|
10
10
|
Endpoint: https://cli.tax/R3mQ8kWpXn
|
|
11
11
|
|
|
@@ -64,7 +64,7 @@ Only after the applicability gate activates Aimlock:
|
|
|
64
64
|
1. Call local `capabilities`, then `probe`; use only its filesystem, Git-history, package-boundary, and import-graph facts for the initial mode. Exact `targetSymbols` may resolve through a fresh ContextBase map; missing, ambiguous, or stale map entries block.
|
|
65
65
|
2. Call remote `capabilities`, `intake`, and `classify`. A fallback `bypass` response stops the Aimlock chain.
|
|
66
66
|
3. Call `scope-contract`; empty allowed paths are blocked. Initialize the mode's local read budget before exploration.
|
|
67
|
-
4. Route every source read through local `budget-read`.
|
|
67
|
+
4. Route every source read through local `budget-read`. File/token exhaustion requires `execute`, `plan`, or `blocked`, or an exact Confirm Protocol budget extension. Time may renew automatically only after the one-time task-bound approval described below.
|
|
68
68
|
5. Call `skill-route`. The server queries the current published official directory and injects only matched skills.
|
|
69
69
|
6. For Probe or Swarm, workers inspect read-only and return modification nodes. Lock stays on the current agent.
|
|
70
70
|
7. Call `propose-nodes`, `accept-nodes`, `snapshot-plan`, and `snapshot-verify` in order.
|
|
@@ -104,10 +104,24 @@ Caller-supplied full catalogs and local registry flags are forbidden. `serverRes
|
|
|
104
104
|
- `run-status`, `chain-plan`, `chain-status`
|
|
105
105
|
- `delivery-doc`, `validate-json`, `feedback`
|
|
106
106
|
|
|
107
|
-
Trusted local operations: `capabilities`, `probe`, `reassess`, `budget-init`, `budget-read`, `budget-status`, `budget-extend`, `gate-issue`, `gate-verify`, and `guarded-write`. Invoke them as `cli-aimlock local <operation> <repositoryRoot>` with JSON stdin and call local `capabilities` first for every input Schema.
|
|
107
|
+
Trusted local operations: `capabilities`, `probe`, `reassess`, `budget-init`, `budget-read`, `budget-status`, `budget-extend`, `budget-auto-renew-request`, `budget-auto-renew`, `budget-auto-renew-stop`, `gate-issue`, `gate-verify`, and `guarded-write`. Invoke them as `cli-aimlock local <operation> <repositoryRoot>` with JSON stdin and call local `capabilities` first for every input Schema.
|
|
108
108
|
|
|
109
109
|
`chain-plan` accepts only server-resolved skill IDs. High-risk work is blocked unless both Confirm Protocol and Validator were resolved. Confirm Protocol is forced to the first step; the caller must invoke the returned `confirmProtocolRequest`, then submit its authoritative `interaction-answer` response with the same `confirmationRequestId`. Replayed or mismatched approval remains blocked. When `swarm` is present, the plan inserts the internal `coordinator.conflict-scan` step immediately before it; no unrelated external skill is added.
|
|
110
110
|
|
|
111
|
+
## Long-task read-time renewal
|
|
112
|
+
|
|
113
|
+
Swarm starts with 60 minutes; Lock and Probe remain 2 and 8 minutes. Time is wall-clock elapsed since budget initialization, including waits. Renewal is evaluated on a budgeted source/cache read, never by `budget-status` or a background timer.
|
|
114
|
+
|
|
115
|
+
An expired but still authorized budget reports `autoRenewEligible: true`, `decisionRequired: false`, and `nextActions: ["budget-read"]`; continue through that read operation to apply the permitted time extension. A status query never consumes an interval.
|
|
116
|
+
|
|
117
|
+
1. Call `budget-auto-renew-request` with `chainId`, a unique `requestId`, `scope: {goal, allowedPaths}`, and `policy: {intervalMs, maxRenewals}`. Use literal repository-relative files/directories from the accepted task scope. The returned Confirm interaction states the exact task, scope, interval and total renewal cap.
|
|
118
|
+
2. Render that interaction to the user once and obtain an authoritative Confirm Protocol `interaction-answer` response. Invoke `budget-auto-renew` with the same chain/scope/policy and that `confirmation`. A generic earlier approval, remembered response, altered scope or replay does not authorize renewal.
|
|
119
|
+
3. Expired reads within that scope automatically consume the required fixed intervals up to the approved `maxRenewals`. Each interval records its reason, timestamp, count and cumulative duration. File/token limits and write permissions do not increase; exhausted quotas or renewal caps still explicitly block. Missed wall-clock intervals count toward the cap.
|
|
120
|
+
4. Call `budget-auto-renew-stop` with `reason: "revoked"` when the user revokes renewal, and `reason: "completed"` when the task finishes. Completion prohibits further reads. The local persistent chain executor also closes an existing budget when execution succeeds. Other IDE hosts must send the completion signal; this package cannot observe unrelated IDE completion automatically.
|
|
121
|
+
5. The authorization is immutable for that chain. Revocation never creates a new allowance; a later expansion requires a separate exact `budget-extend` approval, and a new task must use its own chain. Never infer permission from a long-running task or from authorization to implement this feature.
|
|
122
|
+
|
|
123
|
+
Example request input: `{"chainId":"task-42","requestId":"renew-task-42","scope":{"goal":"Complete the approved refactor","allowedPaths":["src/module"]},"policy":{"intervalMs":3600000,"maxRenewals":8}}`.
|
|
124
|
+
|
|
111
125
|
## Interrupt and keep-alive
|
|
112
126
|
|
|
113
127
|
Call `interrupt` before acting on an interruption:
|
|
@@ -132,7 +146,7 @@ Aimlock returns the protocol; it does not start a timer.
|
|
|
132
146
|
| A3 | 官方技能按需路由 | 已实现 | 服务端读取当前已发布官方目录,只注入与需求匹配的技能;不加载完整目录。 |
|
|
133
147
|
| A4 | 快照写入门禁 | 已实现(需宿主路由) | 本地运行器重读真实文件副本并签发 Ed25519 短期凭证;凭证绑定 chainId、快照摘要和路径。只有经过 `guarded-write` 的写入能被物理拦截,IDE 宿主必须关闭旁路批量写入口。 |
|
|
134
148
|
| A5 | 真实分档与逐级升级 | 已实现 | 本地读取真实路径、Git 历史、包边界和 import 图;可从新鲜 ContextBase 地图解析精确目标符号;调用方自报复杂度不能覆盖探测,升级继承现有证据。 |
|
|
135
|
-
| A6 | 读取预算与截止 | 已实现(需宿主路由) | Lock/Probe/Swarm 限制 3/10/30 文件与 2/8/
|
|
149
|
+
| A6 | 读取预算与截止 | 已实现(需宿主路由) | Lock/Probe/Swarm 限制 3/10/30 文件与 2/8/60 分钟;Probe/Swarm 另限 30K/100K 估算 token,并用进程间锁阻止并发超额。 |
|
|
136
150
|
| A7 | AutoCoord 物理联锁 | 已实现(需宿主路由) | `gate-issue` 显式选择是否需要协调;协调凭证绑定 Swarm 签名文件租约,`guarded-write` 在同一临界区校验凭证、活动锁和路径范围。活动依赖等待会阻断预算读取。 |
|
|
137
151
|
| A8 | 高风险确认联锁 | 已实现(需宿主调用) | 高风险需求自动路由 Confirm Protocol;`chain-plan` 在权威 `interaction-answer` 返回前保持阻断,并校验请求 ID、审计与回调绑定。 |
|
|
138
152
|
|
|
@@ -165,3 +179,12 @@ Aimlock returns the protocol; it does not start a timer.
|
|
|
165
179
|
## 宿主持久执行
|
|
166
180
|
|
|
167
181
|
使用 [chain-executor.md](references/chain-executor.md) 的显式 `chain init/resume/status/answer` 协议驱动本地持久步骤。`run` 的需求采集、远端 `nextStep` 与 `completed` 均不等于已执行。只有真实 broker/协调器/命令结果及绑定证据能推进;未答复人工裁决禁止恢复,发送后结果不确定禁止自动重发。CLI 终端不提供 OS 隔离或独立可信 runner。
|
|
182
|
+
|
|
183
|
+
## 服务端沙箱规划与 IDE 执行
|
|
184
|
+
|
|
185
|
+
1. 用户在模型设置中启用自己的模型地址、API Key 和模型名后,规划优先使用该配置;未启用个人模型时使用官方模型并执行有限套餐额度。个人模型失败必须明确报错,禁止自动切换模型或消耗官方额度。
|
|
186
|
+
2. 准备请求 JSON,明确 requestId、目标、允许文件、最大修改行数和批准的检查命令;运行 `npx cli-aimlock@latest brain plan <repositoryRoot> <request.json>`。服务端调用模型规划,再由隔离沙箱编译结构化计划;保留返回的 request/response 交接包。
|
|
187
|
+
3. 审查返回计划的允许范围、基线哈希和检查命令;完成现有 Aimlock 范围、快照和写入门禁后,由 IDE 修改代码。计划本身不授权扩大范围,不替代写入门禁。
|
|
188
|
+
4. 运行 `npx cli-aimlock@latest brain check <repositoryRoot> <handoff.json>` 执行批准的检查并回传产物哈希和结果。普通 IDE 回传属于 client-reported,不能据此声称可信验证通过。
|
|
189
|
+
5. 只有已批准的可信 runner 生成与本次计划和报告绑定的签名收据后,才运行 `npx cli-aimlock@latest brain validate <repositoryRoot> <validation.json>`。没有可信收据时保持已回传状态,不伪造验证。
|
|
190
|
+
6. 请求发送后结果不确定时,先用 `brain status <repositoryRoot> <status.json>` 按 requestId 或 planId 查询;禁止自动重发规划或重复计费。
|
package/skill/skill.json
CHANGED