cli-aimlock 7.0.33 → 7.0.35

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.
@@ -31,7 +31,7 @@ import { assertChainNotSuspended } from './aimlock-coordination.mjs'
31
31
 
32
32
  const execFile = promisify(execFileCallback)
33
33
  const BUDGET_SCHEMA = 'aimlock.read-budget/1.0'
34
- const CONFIRMATION_SCHEMA = 'confirm-protocol.answer/1.0'
34
+ const CONFIRMATION_SCHEMA = 'confirm-protocol.skill.response/1.0'
35
35
  const MAX_DISCOVERED_FILES = 1_000
36
36
  const MAX_SOURCE_BYTES = 1_048_576
37
37
  const TOKEN_ESTIMATE_ALGORITHM = 'utf8-bytes-div-4-ceil'
@@ -52,6 +52,20 @@ const schema = (required, properties) => ({ type: 'object', additionalProperties
52
52
  const stringSchema = { type: 'string', minLength: 1 }
53
53
  const stringArraySchema = { type: 'array', items: stringSchema }
54
54
  const objectValueSchema = { type: 'object' }
55
+ const BUDGET_ADDITIONS_SCHEMA = schema(['files', 'tokenEstimate', 'durationMs'], {
56
+ files: { type: 'integer', minimum: 0 }, tokenEstimate: { type: 'integer', minimum: 0 }, durationMs: { type: 'integer', minimum: 0 },
57
+ })
58
+ const BUDGET_CONFIRMATION_SCHEMA = schema(['schemaVersion', 'requestId', 'status', 'callbackRequest', 'auditEntry', 'nextStep'], {
59
+ schemaVersion: { const: 'confirm-protocol.skill.response/1.0' }, requestId: stringSchema, status: { const: 'succeeded' },
60
+ callbackRequest: schema(['operation', 'payload'], { operation: { const: 'budget-extend' },
61
+ payload: schema(['chainId', 'additions', 'requestId', 'answer'], { chainId: stringSchema,
62
+ additions: BUDGET_ADDITIONS_SCHEMA, requestId: stringSchema, answer: { const: 'approve' } }) }),
63
+ auditEntry: schema(['schemaVersion', 'auditId', 'requestId', 'actorId', 'question', 'answer', 'remembered', 'risk', 'answeredAt'], {
64
+ schemaVersion: { const: 'confirm.audit-entry/1.0' }, auditId: stringSchema, requestId: stringSchema, actorId: stringSchema,
65
+ question: stringSchema, answer: { const: 'approve' }, remembered: { type: 'boolean' }, risk: { const: 'low' },
66
+ answeredAt: { type: 'string', format: 'date-time' },
67
+ }), nextStep: objectValueSchema,
68
+ })
55
69
  const LOCAL_OPERATION_SCHEMAS = Object.freeze({
56
70
  capabilities: schema([], {}),
57
71
  probe: schema(['goal', 'targetHints'], { goal: stringSchema, targetHints: stringArraySchema,
@@ -64,7 +78,7 @@ const LOCAL_OPERATION_SCHEMAS = Object.freeze({
64
78
  'budget-read': schema(['chainId', 'path'], { chainId: stringSchema, path: stringSchema }),
65
79
  'budget-status': schema(['chainId'], { chainId: stringSchema }),
66
80
  'budget-extend': schema(['chainId', 'confirmation', 'additions'], {
67
- chainId: stringSchema, confirmation: objectValueSchema, additions: objectValueSchema }),
81
+ chainId: stringSchema, confirmation: BUDGET_CONFIRMATION_SCHEMA, additions: BUDGET_ADDITIONS_SCHEMA }),
68
82
  'gate-issue': schema(['chainId', 'snapshotRoot', 'receipt', 'contract', 'nodes', 'coordinationRequired'], {
69
83
  chainId: stringSchema, snapshotRoot: stringSchema, receipt: objectValueSchema,
70
84
  contract: objectValueSchema, nodes: { type: 'array', items: objectValueSchema },
@@ -331,23 +345,53 @@ async function readFileWithinBudget(input) {
331
345
  })
332
346
  }
333
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
+
334
364
  async function readBudgetStatus(input) {
335
365
  const root = await repositoryRoot(input.repositoryRoot)
336
366
  return budgetView((await readBudget(root, input.chainId)).state)
337
367
  }
338
368
 
339
- async function extendReadBudget(input) {
340
- const root = await repositoryRoot(input.repositoryRoot)
369
+ function confirmedBudgetExtension(input) {
341
370
  const confirmation = input.confirmation
342
- if (!confirmation || confirmation.schemaVersion !== CONFIRMATION_SCHEMA
343
- || confirmation.confirmed !== true || confirmation.risk !== 'low'
344
- || !identifier(confirmation.confirmationId, 'confirmationId')) {
345
- fail('AIMLOCK_CONFIRMATION_REQUIRED', 'a low-risk confirmation receipt is required')
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')
346
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)
347
391
  const additions = input.additions
348
- if (!additions || !Number.isInteger(additions.files) || additions.files < 0
349
- || !Number.isInteger(additions.tokenEstimate) || additions.tokenEstimate < 0
350
- || !Number.isInteger(additions.durationMs) || additions.durationMs < 0
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
351
395
  || additions.files + additions.tokenEstimate + additions.durationMs === 0) {
352
396
  fail('AIMLOCK_EXTENSION_INVALID', 'budget additions must contain a positive integer increase')
353
397
  }
@@ -356,6 +400,9 @@ async function extendReadBudget(input) {
356
400
  return withFileLock(budgetPath, async () => {
357
401
  const authority = await readBudget(root, chainId)
358
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
+ }
359
406
  const updated = {
360
407
  ...state,
361
408
  maxFiles: state.maxFiles + additions.files,
@@ -363,14 +410,14 @@ async function extendReadBudget(input) {
363
410
  ? null : (state.maxTokenEstimate ?? 0) + additions.tokenEstimate,
364
411
  maxDurationMs: state.maxDurationMs + additions.durationMs,
365
412
  extensions: [...state.extensions, {
366
- confirmationId: confirmation.confirmationId,
413
+ confirmationId,
367
414
  additions,
368
415
  at: new Date().toISOString(),
369
416
  }],
370
417
  }
371
418
  await atomicJson(authority.path, updated)
372
419
  await appendAudit(root, { event: 'read-budget-extended', chainId,
373
- confirmationId: confirmation.confirmationId, additions })
420
+ confirmationId, additions })
374
421
  return budgetView(updated)
375
422
  })
376
423
  }
@@ -395,6 +442,7 @@ export {
395
442
  LOCAL_SCHEMA,
396
443
  PASS_SCHEMA,
397
444
  READ_BUDGETS,
445
+ checkCachedReadAccess,
398
446
  extendReadBudget,
399
447
  guardedWriteFile,
400
448
  initializeReadBudget,
@@ -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.33";
212
+ const COMPILER_VERSION = "v7.0.35";
213
213
  const KEEP_ALIVE_SECONDS = 90;
214
214
  const KEEP_ALIVE_MESSAGE = "智能目标持续执行中,请勿关闭!";
215
215
  const BYPASS_LINE_BUDGET = 500;
@@ -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
+ }
@@ -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,6 +4,8 @@ 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'
8
+ import { CHAIN_USAGE, runChainCli } from './aimlock-chain-cli.mjs'
7
9
  import { defaultUsage, dispatchOfficialSkillCli, runIntakeHandshake } from './installer.mjs'
8
10
  import {
9
11
  LOCAL_CAPABILITIES,
@@ -90,7 +92,7 @@ export function localAimlockApplicability(facts) {
90
92
  export function aimlockUsage(context) {
91
93
  const usage = defaultUsage(context)
92
94
  if (!usage.includes(COMMON_RUN_USAGE)) throw new Error('Shared CLI run usage contract changed')
93
- return usage.replace(COMMON_RUN_USAGE, AIMLOCK_RUN_USAGE)
95
+ return usage.replace(COMMON_RUN_USAGE, AIMLOCK_RUN_USAGE) + '\n\n' + CHAIN_USAGE
94
96
  }
95
97
 
96
98
  async function collectApplicability(input, output) {
@@ -184,7 +186,11 @@ async function dispatchLocal(args) {
184
186
 
185
187
  const cliPath = fileURLToPath(import.meta.url)
186
188
  if (process.argv[1] && realpathSync(resolve(process.argv[1])) === cliPath) {
187
- if (process.argv[2] === 'local') {
189
+ if (process.argv[2] === 'brain') {
190
+ await runBrainCli(process.argv.slice(3))
191
+ } else if (process.argv[2] === 'chain') {
192
+ await runChainCli(process.argv.slice(3))
193
+ } else if (process.argv[2] === 'local') {
188
194
  try {
189
195
  console.log(JSON.stringify(await dispatchLocal(process.argv.slice(3))))
190
196
  } catch (error) {
package/package.json CHANGED
@@ -3,13 +3,15 @@
3
3
  "cli-aimlock": "./cli.mjs"
4
4
  },
5
5
  "dependencies": {
6
- "cli-swarm": "7.0.33"
6
+ "cli-swarm": "7.0.35"
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
- "./runtime": "./aimlock-runtime.mjs"
12
+ "./runtime": "./aimlock-runtime.mjs",
13
+ "./chain-executor": "./aimlock-chain-executor.mjs",
14
+ "./brain-client": "./brain-client.mjs"
13
15
  },
14
16
  "files": [
15
17
  "cli.mjs",
@@ -18,12 +20,23 @@
18
20
  "README.md",
19
21
  "skill/SKILL.md",
20
22
  "skill/skill.json",
23
+ "aimlock-chain-model.mjs",
24
+ "aimlock-chain-store.mjs",
25
+ "aimlock-chain-process.mjs",
26
+ "aimlock-chain-calls.mjs",
27
+ "aimlock-chain-human.mjs",
28
+ "aimlock-chain-executor.mjs",
29
+ "aimlock-chain-cli.mjs",
30
+ "aimlock-chain-outcomes.mjs",
21
31
  "aimlock-context-map.mjs",
22
32
  "aimlock-coordination.mjs",
23
33
  "aimlock-local-fs.mjs",
24
34
  "aimlock-local-gate.mjs",
25
35
  "aimlock-local-runner.mjs",
26
- "aimlock-runtime.mjs"
36
+ "aimlock-runtime.mjs",
37
+ "brain-client.mjs",
38
+ "brain-client-files.mjs",
39
+ "skill/references/chain-executor.md"
27
40
  ],
28
41
  "license": "UNLICENSED",
29
42
  "name": "cli-aimlock",
@@ -32,5 +45,5 @@
32
45
  "url": "https://github.com/88208555/aimlock-clitax.git"
33
46
  },
34
47
  "type": "module",
35
- "version": "7.0.33"
48
+ "version": "7.0.35"
36
49
  }
package/skill/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: "Aimlock 仅用于大型、深度、跨模块、高风险、需要
5
5
 
6
6
  # Aimlock Skill
7
7
 
8
- Package version: v7.0.33
8
+ Package version: v7.0.35
9
9
 
10
10
  Endpoint: https://cli.tax/R3mQ8kWpXn
11
11
 
@@ -161,3 +161,16 @@ Aimlock returns the protocol; it does not start a timer.
161
161
  - 本地 CLI 不提供手工评分或评语提交命令,人类不得选择技能分数或填写技能评价;日常聊天不属于评价协议。
162
162
 
163
163
  调用示例:`npx cli-aimlock@latest invoke <operation> '<JSON对象>'`。IDE 集成可向 `npx cli-aimlock@latest broker` 的 stdin 发送 `{"operation":"capabilities","input":{}}`。
164
+
165
+ ## 宿主持久执行
166
+
167
+ 使用 [chain-executor.md](references/chain-executor.md) 的显式 `chain init/resume/status/answer` 协议驱动本地持久步骤。`run` 的需求采集、远端 `nextStep` 与 `completed` 均不等于已执行。只有真实 broker/协调器/命令结果及绑定证据能推进;未答复人工裁决禁止恢复,发送后结果不确定禁止自动重发。CLI 终端不提供 OS 隔离或独立可信 runner。
168
+
169
+ ## 服务端沙箱规划与 IDE 执行
170
+
171
+ 1. 用户在模型设置中启用自己的模型地址、API Key 和模型名后,规划优先使用该配置;未启用个人模型时使用官方模型并执行有限套餐额度。个人模型失败必须明确报错,禁止自动切换模型或消耗官方额度。
172
+ 2. 准备请求 JSON,明确 requestId、目标、允许文件、最大修改行数和批准的检查命令;运行 `npx cli-aimlock@latest brain plan <repositoryRoot> <request.json>`。服务端调用模型规划,再由隔离沙箱编译结构化计划;保留返回的 request/response 交接包。
173
+ 3. 审查返回计划的允许范围、基线哈希和检查命令;完成现有 Aimlock 范围、快照和写入门禁后,由 IDE 修改代码。计划本身不授权扩大范围,不替代写入门禁。
174
+ 4. 运行 `npx cli-aimlock@latest brain check <repositoryRoot> <handoff.json>` 执行批准的检查并回传产物哈希和结果。普通 IDE 回传属于 client-reported,不能据此声称可信验证通过。
175
+ 5. 只有已批准的可信 runner 生成与本次计划和报告绑定的签名收据后,才运行 `npx cli-aimlock@latest brain validate <repositoryRoot> <validation.json>`。没有可信收据时保持已回传状态,不伪造验证。
176
+ 6. 请求发送后结果不确定时,先用 `brain status <repositoryRoot> <status.json>` 按 requestId 或 planId 查询;禁止自动重发规划或重复计费。
@@ -0,0 +1,90 @@
1
+ # 持久 CLI 链执行器
2
+
3
+ 服务端技能仍是无状态协议;宿主通过显式计划执行真实步骤,将状态写入仓库的 `.aimlock/executions/<chainId>/state.json`。旧 `run` 仍负责适用性判断与需求采集,生成需求文件不等于执行技能链。
4
+
5
+ ## 命令
6
+
7
+ ```sh
8
+ cli-aimlock chain init /absolute/repository < execution-plan.json
9
+ cli-aimlock chain resume /absolute/repository my-chain
10
+ cli-aimlock chain status /absolute/repository my-chain
11
+ cli-aimlock chain answer /absolute/repository my-chain human-actor
12
+ ```
13
+
14
+ `init` 只验证并保存不可变计划与已安装技能元数据,不触发 HTTP。
15
+ `resume` 按依赖顺序执行尚未完成的步骤;已成功步骤不会重复调用。
16
+ `status` 只读取本地状态。等待时退出码为 2,失败/结果不确定为 1。
17
+ `answer` 必须在真实交互终端使用,读取展示后的选项 ID;拒绝重定向答案。actorId 是本地审计标签,不代表服务端已验证真人身份。
18
+
19
+ ## 显式计划
20
+
21
+ ```json
22
+ {
23
+ "schemaVersion": "aimlock.execution-plan/1.0",
24
+ "chainId": "my-chain",
25
+ "skills": [],
26
+ "steps": [
27
+ {
28
+ "stepId": "test",
29
+ "kind": "command",
30
+ "skillId": null,
31
+ "operation": "exec",
32
+ "input": {
33
+ "executable": "/usr/local/bin/node",
34
+ "args": ["--test"],
35
+ "workingDirectory": ".",
36
+ "timeoutMs": 600000,
37
+ "evidenceKind": "test",
38
+ "environment": {}
39
+ },
40
+ "dependsOn": [],
41
+ "bindings": []
42
+ }
43
+ ]
44
+ }
45
+ ```
46
+
47
+ 每步必须提供全部七个字段;Confirm interaction-request 可额外声明 continueWhen。其它额外字段均拒绝。支持三种 kind:
48
+
49
+ - `skill`:skillId 必须在 skills 中声明 `{skillId, packageRoot}`;packageRoot 指向已安装 npm 技能包。复用受限 broker 的真实 HTTP、requestId 校验、自动评价和提交回执;不读取或输出令牌。初始化后的包元数据变化会阻止恢复。
50
+ - `coordinator`:skillId=null,operation 使用 Swarm 本地接口。`dependency-wait` 会实际登记并驱动 `wait-for-event`/tick,保存唤醒包;不是仅返回 nextStep。`resolve-human` 禁止从计划注入。
51
+ - `command`:skillId=null、operation=exec,按显式 executable/args 执行无 shell 的真实进程;workingDirectory 限于仓库,timeoutMs 为 1..3600000,输出上限 1 MiB,超限/超时/非零退出显式失败。命令输入不得动态绑定;environment 必须显式提供字符串字典,spawn 不继承宿主环境,拒绝令牌、私钥、授权等保留变量。使用非绝对 executable 时应显式声明 PATH。示例路径需改成实际 Node 安装位置。evidenceKind 为 test/build/lint/security/benchmark。
52
+
53
+ 步骤必须按拓扑顺序排列;依赖只能引用前面已声明的步骤。运行时仅从已成功步骤的实际输出取值,忽略服务端 nextStep/completed 字段,不据此调用隐含步骤。
54
+
55
+ 绑定使用 RFC 6901 JSON Pointer:
56
+ ```json
57
+ {
58
+ "stepId": "dispatch",
59
+ "kind": "skill",
60
+ "skillId": "swarm",
61
+ "operation": "dispatch",
62
+ "input": { "tasks": null },
63
+ "dependsOn": ["compile"],
64
+ "bindings": [
65
+ { "stepId": "compile", "source": "/machineTasks/tasks", "target": "/tasks" }
66
+ ]
67
+ }
68
+ ```
69
+
70
+ 这里 compile 必须是计划中真实调用 Blueprint compile-inline 的步骤,并在 skills 声明两个安装包。source 指向协议 output 本身,不含 broker 外层包装。目标键必须事先在 input 中声明;缺失值、非法指针、循环依赖均报错。Blueprint 的 blueprintSha256 与 criterionId 原样绑定;不要重算或改变前缀。
71
+
72
+ ## 等待、恢复与证据
73
+
74
+ - 持久记录 pending/running/waiting/blocked/failed/uncertain/succeeded、调用 ID、实际 requestId、输入摘要、结果、反馈回执和错误。网络发送前先保存 requestId。
75
+ - 事件到达才记为依赖满足。死亡、放弃和超时不冒充事件成功;人工选中恢复时保留 `resolved-by-human`、原唤醒原因与 Confirm 审计。
76
+ - 遇到 Swarm 高风险裁决,必须在 skills 声明 confirm-protocol 包。宿主真实调用 interaction-request,保存待答状态。resume 不会代答;answer 读取终端输入、真实调用 interaction-answer,校验问题/答案/回调绑定后才调用 resolve-human。
77
+ - 普通 Confirm 步骤还必须声明结构化继续条件,例如步骤字段 `continueWhen: {"answer":"yes"}`。协议没有通用“同意”的 option ID;不依据 label 推测授权。真人答案未匹配或未声明条件时,下游保持 blocked;choice/input/multi 同样须明确条件。
78
+ - Validator 的协议 succeeded 不代表验收通过:verdict=incomplete/blocked、空或失败执行证据、sandbox-run 等 pending-execution 描述均阻塞链。原协议结果与回执仍保留,不能冒充已执行测试。
79
+ - 普通技能 blocked/failed 保持阻塞/失败,不自动重试。发送后断线、无法校验回执、执行中断等不确定结果禁止自动重发;应先人工核查外部效果,再制定新计划。已登记的事件等待可恢复轮询,不重新声明等待。
80
+ - 本地命令提供 `cli.tax.test-evidence/1.0`:真实 exitCode、durationMs、stdout/stderr 摘要;runner=local、producer=local-cli-process、independentRunnerVerified=false。可把 output.evidence 绑定到 Blueprint acceptance-report 的逐项 results;报告对账不等于独立可信执行。
81
+ - 同链正常 completed 任务不再封锁活跃同伴;全部终态仍封锁。失败/回收任务必须显式注册 supersedesTaskId,保留原任务历史、范围与约束,并先处理人工裁决;任意新任务不构成失败豁免。
82
+ - 人工选中 active 任务后,同链未选中的 human-decision 停放任务可继续保持 blocked;旧 lease 不能写入,因为写入联锁仍要求对应 taskId 为 active。未决裁决继续阻止读取与写入。
83
+
84
+ ## 边界
85
+
86
+ 共享 chainId 的只读预算不是智能体身份隔离。命令步骤是用户已声明的本地进程调用,不是操作系统沙箱,不拦截任意外部进程或其文件读取,也不保证其他命令自动遵守 Aimlock 写入门禁。修改源码的受控调用仍必须走 guarded-write 和有效快照/租约。没有隐式 ContextBase 调用;不要省略其适用的预算链参数。没有独立桌面壳或 OS 弹窗承诺;当前 Confirm 宿主是 CLI 终端。
87
+
88
+ 排队锁恢复:允许排队的请求必须显式给出正整数 `queueTimeoutMs`,与授锁后 `ttlSeconds` 独立;`lock-acquire` 返回 queued 后保存 queueId 并挂起本步骤;`resume` 运行一次协调扫描后只查询 `lock-queue-status`,不会重放申请。只有同一任务、链、资源、路径及当前未过期租约全部匹配的真实 grant 才继续。仍在排队时返回包含 deadlineAt 的等待快照;到期后 tick 落账并返回 need-human/Confirm 请求,原队列阻断。本执行器不会把队列超时的人工恢复解释为已授锁;宿主需处理返回的确认,再提交新的显式申请。终止、过期、失配及旧版缺少 queue→grant 关联的授锁明确阻断,不推测归属。
89
+
90
+ 命令结束与进程回收分开记录:超时或输出超限后最多再等待 1 秒关闭输出管道。仍无法确认回收时返回 `uncertain` 和空执行证据,释放执行锁并禁止自动重放;这不证明脱离进程已停止。已启动子进程后落账失败同样按结果不确定记录。
package/skill/skill.json CHANGED
@@ -6,5 +6,5 @@
6
6
  "name": "aimlock",
7
7
  "schemaVersion": "aimlock.skill.request/1.1",
8
8
  "type": "Skill",
9
- "version": "v7.0.33"
9
+ "version": "v7.0.35"
10
10
  }