cli-aimlock 7.0.35 → 7.0.37

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 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 / 15 分钟读取预算;Probe、Swarm 另有限制 30K / 100K 估算 token
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。读取预算使用进程间原子锁,耗尽后只允许执行、输出方案或明确阻塞;追加预算必须携带 Confirm Protocol 的低风险确认回执。无凭证写入仅豁免 `.aimlock/logs/` 与 `.aimlock/tmp/`。
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
 
@@ -65,3 +65,9 @@ Source: https://github.com/88208555/aimlock-clitax.git
65
65
  Brain Client 服务端在同一次 runtime 请求的事务中绑定真实响应、生成并持久化权威评分与评语,再返回已提交回执。broker 只验证 `feedbackReceiptId`、`feedbackInvocationId` 和权威摘要,不发起第二次评价写入,也不生成分数或评语。`not-reported`、验证不完整、P0/P1 findings、`blocked` 或 `failed` 都不得生成好评;缺凭证、缺回执、摘要不匹配、响应非法或 HTTP 失败都会显式失败。
66
66
 
67
67
  本地 CLI 不提供手工评分或评语提交命令,人类不能选择技能分数或填写技能评价。日常聊天不属于评价协议。
68
+
69
+ ## 网络中断与原回执恢复
70
+
71
+ 仅在 TLS 握手前确定尚未发送 HTTP 请求时,broker 才允许最多 3 次连接尝试,并受总超时约束。请求发出后发生断线或响应中断,只用 GET 查询原 requestId 的服务端回执,禁止重发 POST;未取得有效回执时保留不确定状态,不得假定成功或继续依赖步骤。
72
+
73
+ `npx cli-aimlock@latest recover <operation> <requestId>` 可重新查询原调用,不会重做操作或重复计费。链恢复不会跳过人工确认,也不会自动重跑结果不确定的本地命令。代理连接需 Node.js 22.21+ 或 24.5+;不支持的运行时会明确报错。
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'
2
2
  import { resolve } from 'node:path'
3
3
  import { executeCoordinatorOperation } from 'cli-swarm/coordinator'
4
4
  import { loadOfficialSkillContext } from './installer.mjs'
5
- import { invokeOfficialSkill } from './broker.mjs'
5
+ import { invokeOfficialSkill, recoverOfficialSkill } from './broker.mjs'
6
6
  import { fail, sha256 } from './aimlock-local-fs.mjs'
7
7
  import { errorRecord } from './aimlock-chain-model.mjs'
8
8
  import { saveExecution } from './aimlock-chain-store.mjs'
@@ -52,10 +52,12 @@ export async function callSkill(session, skillId, operation, input) {
52
52
  const invocation = await invokeOfficialSkill(context, operation, input, {
53
53
  environment: dependencies.environment, credentialAccess: dependencies.credentialAccess,
54
54
  request: async (url, options) => {
55
- const request = JSON.parse(options.body).input
56
- call.requestId = request.requestId
57
- call.status = 'dispatched'
58
- await saveExecution(session.file, session.state)
55
+ if (options.method === 'POST') {
56
+ const request = JSON.parse(options.body).input
57
+ call.requestId = request.requestId
58
+ call.status = 'dispatched'
59
+ await saveExecution(session.file, session.state)
60
+ }
59
61
  return dependencies.request(url, options)
60
62
  },
61
63
  })
@@ -103,3 +105,20 @@ export async function callCommand(session, input) {
103
105
  throw error
104
106
  }
105
107
  }
108
+
109
+ export async function recoverSkillCall(session, skillId) {
110
+ const call = session.record.calls.at(-1)
111
+ if (!call || call.kind !== 'skill' || !call.requestId || !['uncertain', 'dispatched', 'recorded'].includes(call.status)) {
112
+ fail('AIMLOCK_CHAIN_RECOVERY_INVALID', 'Only an uncertain skill request can be recovered by receipt query')
113
+ }
114
+ call.status = 'uncertain'
115
+ await saveExecution(session.file, session.state)
116
+ const invocation = await recoverOfficialSkill(skillContext(session.state, skillId),
117
+ call.operation, call.requestId, session.dependencies)
118
+ call.receipt = invocation
119
+ call.status = 'recorded'
120
+ call.error = null
121
+ call.completedAt = new Date().toISOString()
122
+ await saveExecution(session.file, session.state)
123
+ return invocation.response.output
124
+ }
@@ -1,6 +1,7 @@
1
1
  import { stdin, stdout } from 'node:process'
2
2
  import { answerExecution, executionStatus, initializeExecution, resumeExecution } from './aimlock-chain-executor.mjs'
3
3
  import { errorRecord } from './aimlock-chain-model.mjs'
4
+ import { createBrokerTransport } from './broker-transport.mjs'
4
5
  import { fail } from './aimlock-local-fs.mjs'
5
6
 
6
7
  const PLAN_MAX_BYTES = 1_048_576
@@ -29,7 +30,7 @@ export async function dispatchChain(args) {
29
30
  if (!Object.hasOwn(expected, operation) || args.length !== expected[operation]) {
30
31
  fail('AIMLOCK_CHAIN_USAGE_INVALID', CHAIN_USAGE)
31
32
  }
32
- const dependencies = { environment: process.env, request: fetch }
33
+ const dependencies = { environment: process.env, request: createBrokerTransport({ environment: process.env }) }
33
34
  if (operation === 'init') return initializeExecution(repositoryRoot, await readPlan(stdin))
34
35
  if (operation === 'status') return executionStatus(repositoryRoot, chainId)
35
36
  if (operation === 'resume') return resumeExecution(repositoryRoot, chainId, dependencies)
@@ -4,7 +4,7 @@ import { fail } from './aimlock-local-fs.mjs'
4
4
  import { bindInput, errorRecord, validatePlan } from './aimlock-chain-model.mjs'
5
5
  import { assertNewExecution, executionStatus, initialState, loadExecution, recoverInterrupted,
6
6
  saveExecution, withExecutionLock } from './aimlock-chain-store.mjs'
7
- import { callCommand, callCoordinator, callSkill, resolveContexts, verifyContexts } from './aimlock-chain-calls.mjs'
7
+ import { callCommand, callCoordinator, callSkill, recoverSkillCall, resolveContexts, verifyContexts } from './aimlock-chain-calls.mjs'
8
8
  import { answerPending, prepareHuman } from './aimlock-chain-human.mjs'
9
9
  import { skillOutcome } from './aimlock-chain-outcomes.mjs'
10
10
 
@@ -149,6 +149,20 @@ async function coordinatorStep(session, step, input) {
149
149
  }
150
150
  }
151
151
 
152
+ function finishSkillStep(session, step, output) {
153
+ if (step.skillId === 'confirm-protocol' && step.operation === 'interaction-request' && output.status === 'succeeded') {
154
+ session.record.status = 'waiting'
155
+ session.record.output = output
156
+ session.record.pending = { kind: 'human', interaction: output.interaction, waitInput: null,
157
+ presentation: output.chatFallback, response: output,
158
+ continueWhen: Object.hasOwn(step, 'continueWhen') ? step.continueWhen : null }
159
+ } else {
160
+ const outcome = skillOutcome(step, output)
161
+ finish(session.record, output, outcome.status)
162
+ session.record.error = outcome.error
163
+ }
164
+ }
165
+
152
166
  async function executeStep(session, step) {
153
167
  if (!await readyForWork(session, step)) return
154
168
  const input = bindInput(step, session.state)
@@ -164,17 +178,7 @@ async function executeStep(session, step) {
164
178
  } else if (step.kind === 'coordinator') await coordinatorStep(session, step, input)
165
179
  else {
166
180
  const output = await callSkill(session, step.skillId, step.operation, input)
167
- if (step.skillId === 'confirm-protocol' && step.operation === 'interaction-request' && output.status === 'succeeded') {
168
- session.record.status = 'waiting'
169
- session.record.output = output
170
- session.record.pending = { kind: 'human', interaction: output.interaction, waitInput: null,
171
- presentation: output.chatFallback, response: output,
172
- continueWhen: Object.hasOwn(step, 'continueWhen') ? step.continueWhen : null }
173
- } else {
174
- const outcome = skillOutcome(step, output)
175
- finish(session.record, output, outcome.status)
176
- session.record.error = outcome.error
177
- }
181
+ finishSkillStep(session, step, output)
178
182
  }
179
183
  await saveExecution(session.file, session.state)
180
184
  }
@@ -186,6 +190,18 @@ async function advance(root, file, state, dependencies) {
186
190
  if (record.status === 'blocked' && step.kind === 'coordinator' && step.operation === 'lock-acquire'
187
191
  && record.output?.status === 'queued' && record.input) saveQueuePending(record, record.output, record.input)
188
192
  const session = { root, file, state, record, dependencies }
193
+ if (record.status === 'uncertain' && step.kind === 'skill'
194
+ && record.calls.at(-1)?.operation === step.operation) {
195
+ try {
196
+ finishSkillStep(session, step, await recoverSkillCall(session, step.skillId))
197
+ await saveExecution(file, state)
198
+ } catch (error) {
199
+ await failedStep(session, error)
200
+ return state
201
+ }
202
+ if (record.status === 'succeeded') continue
203
+ return state
204
+ }
189
205
  if (['failed', 'uncertain'].includes(record.status)) return state
190
206
  if (record.status === 'waiting' && record.pending?.kind === 'human') return state
191
207
  if (record.status === 'blocked' && record.pending?.kind !== 'coordination-guard') return state
@@ -179,7 +179,7 @@ export function chainStatus(state) {
179
179
  export function errorRecord(error) {
180
180
  if (!(error instanceof Error)) return { name: 'ThrownValue', message: String(error) }
181
181
  const value = { name: error.name, message: error.message }
182
- for (const key of ['code', 'transportCode', 'operation', 'retryable']) {
182
+ for (const key of ['code', 'transportCode', 'operation', 'requestId', 'stage', 'transport', 'recovery', 'receiptStatus', 'retryable']) {
183
183
  if (Object.hasOwn(error, key)) value[key] = error[key]
184
184
  }
185
185
  if (error.cause instanceof Error) value.cause = errorRecord(error.cause)
@@ -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) {
@@ -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 { assertChainNotSuspended } from './aimlock-coordination.mjs'
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 }