cli-aimlock 7.0.19 → 7.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/broker.mjs ADDED
@@ -0,0 +1,301 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { constants } from 'node:fs'
3
+ import { lstat, open } from 'node:fs/promises'
4
+ import { resolve, win32 } from 'node:path'
5
+
6
+ export const LOOKUP_TIMEOUT_MS = 8000
7
+ export const CALL_TIMEOUT_MS = 120_000
8
+ const FEEDBACK_API_PATH = '/api/v1/telemetry/skill-usage'
9
+ const TOKEN_FILE_ENV = 'CLITAX_BRAIN_CLIENT_TOKEN_FILE'
10
+ const TOKEN_FILE_VERSION = 'member-brain.client-token-file/1.0'
11
+ const AUTH_SCHEME = 'BrainClient'
12
+ const TOKEN_FILE_MAX_BYTES = 16_384
13
+ const POSIX_TOKEN_FILE_MODE = 0o600
14
+ const WINDOWS_BROKER_DIRECTORY = ['CLI.Tax', 'broker']
15
+ const FEEDBACK_COMMENT_MAX = 500
16
+ const EVALUATION_DURATION_MAX = 86_400_000
17
+ const SCORE_MIN = 0
18
+ const SCORE_MAX = 100
19
+ const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/
20
+ const INVOCATION_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
21
+ const DIGEST_PATTERN = /^[0-9a-f]{64}$/
22
+ const PROTOCOL_STATUSES = new Set(['succeeded', 'blocked', 'failed'])
23
+ const VALIDATION_STATES = new Set(['passed', 'failed', 'incomplete'])
24
+ const EVALUATION_SCHEMA = 'skill-automatic-evaluation/1.0'
25
+ const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
26
+ const REQUEST_SCHEMA_PATTERN = /^([A-Za-z0-9.-]+\.skill)\.request\/([0-9]+\.[0-9]+)$/
27
+
28
+ function asObject(value, label) {
29
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
30
+ throw new Error(`${label} must be an object`)
31
+ }
32
+ return value
33
+ }
34
+
35
+ function requiredString(value, label) {
36
+ const text = typeof value === 'string' ? value.trim() : ''
37
+ if (!text) throw new Error(`${label} is required`)
38
+ return text
39
+ }
40
+
41
+ function boundedInteger(value, label) {
42
+ if (!Number.isFinite(value) || value < 0 || !Number.isSafeInteger(value)) {
43
+ throw new Error(`${label} must be a non-negative safe integer`)
44
+ }
45
+ return value
46
+ }
47
+
48
+ function insideWindowsDirectory(candidate, directory) {
49
+ const relative = win32.relative(directory, candidate)
50
+ return relative === '' || (!relative.startsWith('..\\') && relative !== '..' && !win32.isAbsolute(relative))
51
+ }
52
+
53
+ export function brainClientTokenPath(environment, platform = process.platform) {
54
+ const configured = requiredString(environment[TOKEN_FILE_ENV], TOKEN_FILE_ENV)
55
+ if (platform !== 'win32') return resolve(configured)
56
+ if (!win32.isAbsolute(configured)) {
57
+ throw new Error('Windows Brain Client token file path must be absolute')
58
+ }
59
+ const localAppData = requiredString(environment.LOCALAPPDATA, 'LOCALAPPDATA')
60
+ const brokerDirectory = win32.resolve(localAppData, ...WINDOWS_BROKER_DIRECTORY)
61
+ const candidate = win32.resolve(configured)
62
+ if (!insideWindowsDirectory(candidate, brokerDirectory)) {
63
+ throw new Error(`Windows Brain Client token file must be inside ${brokerDirectory}`)
64
+ }
65
+ return candidate
66
+ }
67
+
68
+ function assertTokenFileStatus(status, platform, currentUserId) {
69
+ if (!status.isFile() || status.size < 1 || status.size > TOKEN_FILE_MAX_BYTES) {
70
+ throw new Error('Brain Client token file must be a non-empty restricted file')
71
+ }
72
+ if (platform === 'win32') return
73
+ if (!Number.isInteger(currentUserId)) {
74
+ throw new Error('Brain Client token file ownership cannot be verified')
75
+ }
76
+ if (status.uid !== currentUserId || (status.mode & 0o777) !== POSIX_TOKEN_FILE_MODE) {
77
+ throw new Error('Brain Client token file must be owned by the current user with mode 0600')
78
+ }
79
+ }
80
+
81
+ function parseTokenFile(source) {
82
+ let tokenFile
83
+ try {
84
+ tokenFile = asObject(JSON.parse(source), 'Brain Client token file')
85
+ } catch {
86
+ throw new Error('Brain Client token file must contain valid JSON')
87
+ }
88
+ const expectedKeys = ['authorizationScheme', 'endpoint', 'schemaVersion', 'token']
89
+ if (Object.keys(tokenFile).sort().join('\n') !== expectedKeys.join('\n')) {
90
+ throw new Error('Brain Client token file contains unknown or missing fields')
91
+ }
92
+ return tokenFile
93
+ }
94
+
95
+ export async function brainClientAuthorization(context, environment, dependencies = {}) {
96
+ const platform = dependencies.platform ?? process.platform
97
+ const tokenFilePath = brainClientTokenPath(environment, platform)
98
+ const inspectPath = dependencies.lstat ?? lstat
99
+ const openPath = dependencies.open ?? open
100
+ const currentUserId = platform === 'win32'
101
+ ? null
102
+ : (dependencies.getuid ?? process.getuid)?.()
103
+ const linkStatus = await inspectPath(tokenFilePath)
104
+ if (linkStatus.isSymbolicLink()) throw new Error('Brain Client token file cannot be a symlink')
105
+ const noFollow = platform === 'win32' ? 0 : (constants.O_NOFOLLOW ?? 0)
106
+ const handle = await openPath(tokenFilePath, constants.O_RDONLY | noFollow)
107
+ try {
108
+ const status = await handle.stat()
109
+ assertTokenFileStatus(status, platform, currentUserId)
110
+ const tokenFile = parseTokenFile(await handle.readFile('utf8'))
111
+ const endpoint = new URL(requiredString(tokenFile.endpoint, 'Brain Client endpoint'))
112
+ if (tokenFile.schemaVersion !== TOKEN_FILE_VERSION
113
+ || tokenFile.authorizationScheme !== AUTH_SCHEME
114
+ || endpoint.origin !== new URL(context.endpoint).origin
115
+ || endpoint.pathname !== FEEDBACK_API_PATH || endpoint.search || endpoint.hash
116
+ || endpoint.username || endpoint.password
117
+ || !TOKEN_PATTERN.test(tokenFile.token)) {
118
+ throw new Error('Brain Client token file authority is invalid')
119
+ }
120
+ return `${AUTH_SCHEME} ${tokenFile.token}`
121
+ } finally {
122
+ await handle.close()
123
+ }
124
+ }
125
+
126
+ function canonicalJson(value) {
127
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') {
128
+ return JSON.stringify(value)
129
+ }
130
+ if (typeof value === 'number') {
131
+ if (!Number.isFinite(value)) throw new Error('automatic evaluation contains a non-finite number')
132
+ return JSON.stringify(value)
133
+ }
134
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
135
+ const record = asObject(value, 'automatic evaluation')
136
+ return `{${Object.keys(record).sort().map((key) => (
137
+ `${JSON.stringify(key)}:${canonicalJson(record[key])}`
138
+ )).join(',')}}`
139
+ }
140
+
141
+ function expectedResponseSchema(requestSchema) {
142
+ const matched = requiredString(requestSchema, 'skill request schemaVersion')
143
+ .match(REQUEST_SCHEMA_PATTERN)
144
+ if (!matched) throw new Error('skill request schemaVersion is invalid')
145
+ return `${matched[1]}.response/${matched[2]}`
146
+ }
147
+
148
+ function protocolResponse(protocolValue, requestEnvelope) {
149
+ const protocol = asObject(protocolValue, 'skill protocol response')
150
+ const responseSchema = expectedResponseSchema(requestEnvelope.schemaVersion)
151
+ if (protocol.schemaVersion !== responseSchema) {
152
+ throw new Error('skill protocol response schemaVersion does not match the request')
153
+ }
154
+ if (protocol.requestId !== requestEnvelope.requestId) {
155
+ throw new Error('skill protocol response requestId does not match the request')
156
+ }
157
+ const status = requiredString(protocol.status, 'skill protocol status')
158
+ if (!PROTOCOL_STATUSES.has(status)) {
159
+ throw new Error('skill protocol status must be succeeded, blocked, or failed')
160
+ }
161
+ return { protocol, responseSchema, status }
162
+ }
163
+
164
+ export function authoritativeEvaluation(value, expected) {
165
+ const evaluation = asObject(value, 'server automatic evaluation')
166
+ const expectedKeys = [
167
+ 'digest', 'durationMs', 'findingCount', 'operation', 'p0Count', 'p1Count', 'p2Count',
168
+ 'requestId', 'responseSchemaVersion', 'schemaVersion', 'score', 'status', 'userComment',
169
+ 'validation',
170
+ ]
171
+ if (Object.keys(evaluation).sort().join('\n') !== expectedKeys.join('\n')) {
172
+ throw new Error('server automatic evaluation contains unknown or missing fields')
173
+ }
174
+ if (evaluation.schemaVersion !== EVALUATION_SCHEMA
175
+ || evaluation.operation !== expected.operation
176
+ || evaluation.requestId !== expected.requestId
177
+ || evaluation.responseSchemaVersion !== expected.responseSchema
178
+ || evaluation.status !== expected.status
179
+ || !VALIDATION_STATES.has(evaluation.validation)
180
+ || typeof evaluation.userComment !== 'string' || !evaluation.userComment.trim()
181
+ || Buffer.byteLength(evaluation.userComment, 'utf8') > FEEDBACK_COMMENT_MAX
182
+ || typeof evaluation.digest !== 'string' || !DIGEST_PATTERN.test(evaluation.digest)) {
183
+ throw new Error('server automatic evaluation authority is invalid')
184
+ }
185
+ for (const field of ['durationMs', 'findingCount', 'p0Count', 'p1Count', 'p2Count', 'score']) {
186
+ boundedInteger(evaluation[field], `server automatic evaluation ${field}`)
187
+ }
188
+ if (evaluation.score < SCORE_MIN || evaluation.score > SCORE_MAX
189
+ || evaluation.durationMs > EVALUATION_DURATION_MAX
190
+ || evaluation.findingCount < evaluation.p0Count + evaluation.p1Count + evaluation.p2Count) {
191
+ throw new Error('server automatic evaluation bounds are invalid')
192
+ }
193
+ if ((evaluation.status !== 'succeeded' || evaluation.validation !== 'passed'
194
+ || evaluation.p0Count > 0 || evaluation.p1Count > 0) && evaluation.score >= 60) {
195
+ throw new Error('server automatic evaluation cannot report a positive score')
196
+ }
197
+ const { digest, ...core } = evaluation
198
+ const actualDigest = createHash('sha256').update(canonicalJson(core)).digest('hex')
199
+ if (digest !== actualDigest) throw new Error('server automatic evaluation digest is invalid')
200
+ return evaluation
201
+ }
202
+
203
+ async function responsePayload(response, label) {
204
+ try {
205
+ return asObject(await response.json(), label)
206
+ } catch {
207
+ throw new Error(`${label} is not valid JSON (HTTP ${response.status})`)
208
+ }
209
+ }
210
+
211
+ function invocationRequest(context, operation, input) {
212
+ const normalizedOperation = requiredString(operation, 'skill operation')
213
+ if (!IDENTIFIER_PATTERN.test(normalizedOperation)) {
214
+ throw new Error('skill operation is invalid')
215
+ }
216
+ expectedResponseSchema(context.schemaVersion)
217
+ return {
218
+ schemaVersion: context.schemaVersion,
219
+ requestId: `${context.runtimeCode}-${randomUUID()}`,
220
+ operation: normalizedOperation,
221
+ input: asObject(input, 'skill operation input'),
222
+ }
223
+ }
224
+
225
+ export async function invokeOfficialSkill(context, operation, input, dependencies) {
226
+ const environment = asObject(dependencies.environment, 'broker environment')
227
+ if (typeof dependencies.request !== 'function') {
228
+ throw new Error('broker request dependency is required')
229
+ }
230
+ const authorization = await brainClientAuthorization(context, environment, dependencies.credentialAccess)
231
+ const requestEnvelope = invocationRequest(context, operation, input)
232
+ let response
233
+ try {
234
+ response = await dependencies.request(context.endpoint, {
235
+ method: 'POST',
236
+ headers: { 'Content-Type': 'application/json', Authorization: authorization },
237
+ body: JSON.stringify({ input: requestEnvelope }),
238
+ signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
239
+ })
240
+ } catch {
241
+ throw new Error(`${context.displayName} ${operation} invocation failed`)
242
+ }
243
+ const payload = await responsePayload(response, `${context.displayName} ${operation} response`)
244
+ if (!response.ok || payload.ok !== true) {
245
+ throw new Error(`${context.displayName} ${operation} failed: HTTP ${response.status}`)
246
+ }
247
+ const invocationId = payload.feedbackInvocationId
248
+ if (typeof invocationId !== 'string' || !INVOCATION_PATTERN.test(invocationId)) {
249
+ throw new Error(`${context.displayName} ${operation} response is missing a valid feedbackInvocationId`)
250
+ }
251
+ const feedbackReceiptId = payload.feedbackReceiptId
252
+ const feedbackRequestId = payload.feedbackRequestId
253
+ if (typeof feedbackReceiptId !== 'string' || !INVOCATION_PATTERN.test(feedbackReceiptId)
254
+ || feedbackRequestId !== `automatic-${invocationId}`) {
255
+ throw new Error(`${context.displayName} ${operation} response is missing a committed feedback receipt`)
256
+ }
257
+ const protocolAuthority = protocolResponse(payload.output, requestEnvelope)
258
+ const evaluation = authoritativeEvaluation(payload.feedbackEvaluation, {
259
+ operation: requestEnvelope.operation,
260
+ requestId: requestEnvelope.requestId,
261
+ responseSchema: protocolAuthority.responseSchema,
262
+ status: protocolAuthority.status,
263
+ })
264
+ const feedback = {
265
+ id: feedbackReceiptId,
266
+ requestId: feedbackRequestId,
267
+ duplicated: false,
268
+ }
269
+ return { response: payload, invocationId, evaluation, feedback }
270
+ }
271
+
272
+ export async function callOfficialSkill(context, operation, input, dependencies) {
273
+ return (await invokeOfficialSkill(context, operation, input, dependencies)).response
274
+ }
275
+
276
+ export function invokeCommandInput(args) {
277
+ const operation = requiredString(args[1], 'skill operation')
278
+ const source = args.slice(2).join(' ').trim()
279
+ if (!source) return { operation, input: {} }
280
+ try {
281
+ return { operation, input: asObject(JSON.parse(source), 'skill operation input') }
282
+ } catch {
283
+ throw new Error('skill operation input must be a JSON object')
284
+ }
285
+ }
286
+
287
+ export function brokerCommandInput(source) {
288
+ let parsed
289
+ try {
290
+ parsed = asObject(JSON.parse(source), 'broker request')
291
+ } catch {
292
+ throw new Error('broker request must be a JSON object')
293
+ }
294
+ if (Object.keys(parsed).some((key) => !['operation', 'input'].includes(key))) {
295
+ throw new Error('broker request contains unknown fields')
296
+ }
297
+ return {
298
+ operation: requiredString(parsed.operation, 'skill operation'),
299
+ input: asObject(parsed.input, 'skill operation input'),
300
+ }
301
+ }
package/cli.mjs CHANGED
@@ -1,26 +1,203 @@
1
1
  #!/usr/bin/env node
2
- import { dirname } from 'node:path'
2
+ import { dirname, resolve } from 'node:path'
3
+ import { cwd, stdin, stdout } from 'node:process'
4
+ import { createInterface } from 'node:readline/promises'
3
5
  import { fileURLToPath } from 'node:url'
4
- import { dispatchOfficialSkillCli, runIntakeHandshake } from './installer.mjs'
6
+ import { defaultUsage, dispatchOfficialSkillCli, runIntakeHandshake } from './installer.mjs'
7
+ import {
8
+ LOCAL_CAPABILITIES,
9
+ extendReadBudget,
10
+ guardedWriteFile,
11
+ initializeReadBudget,
12
+ issueMutationPass,
13
+ probeRepositoryDemand,
14
+ readBudgetStatus,
15
+ readFileWithinBudget,
16
+ reassessMode,
17
+ verifyMutationPassFile,
18
+ } from './aimlock-local-runner.mjs'
19
+
20
+ const BYPASS_LINE_BUDGET = 500
21
+ const DIFFICULTIES = new Set(['low', 'medium', 'high'])
22
+ const RISKS = new Set(['low', 'medium', 'high'])
23
+ const TRUE_ANSWERS = new Set(['yes', 'y', 'true', '是', '需要', 'да'])
24
+ const FALSE_ANSWERS = new Set(['no', 'n', 'false', '否', '不需要', 'нет'])
25
+ const BYPASS_NOTICE = [
26
+ '需求较小且低风险,不建议使用 Aimlock;请直接处理,或只调用一个匹配的专项技能。',
27
+ 'This request is small and low risk; Aimlock is not recommended. Handle it directly or use one matched specialist skill.',
28
+ 'Запрос небольшой и низкорисковый; Aimlock не рекомендуется. Выполните его напрямую или используйте один профильный навык.',
29
+ ].join('\n')
30
+ const COMMON_RUN_USAGE = " Run this skill's applicability or onboarding flow; only a real HTTP invocation can trigger automatic evaluation."
31
+ const AIMLOCK_RUN_USAGE = ' Collect six applicability facts locally; bypass makes no skill HTTP call, automatic evaluation, or requirements file, while active work runs authenticated intake.'
32
+
33
+ const APPLICABILITY_QUESTIONS = [
34
+ { id: 'goal', prompt: '目标 / Goal / Цель', parse: parseText },
35
+ { id: 'targetHints', prompt: '目标路径(逗号分隔) / Target paths / Целевые пути', parse: parseTargetHints },
36
+ { id: 'explicitAimlockRequested', prompt: '是否明确要求启用 Aimlock / Explicitly require Aimlock / Явно включить Aimlock (yes|no)', parse: parseBoolean },
37
+ ]
5
38
 
6
39
  const INTAKE_QUESTIONS = [
7
- { id: 'goal', required: true, prompt: 'What must be true when this finishes, and what must never change?', example: '只改税率常量一行,不改其它计税逻辑' },
8
- { id: 'targetFiles', required: true, prompt: 'Which file paths are in scope? Use unknown if not located yet.', example: 'apps/web/src/tax.ts' },
9
- { id: 'estimatedChangedLines', required: true, prompt: 'How many lines should change?', example: '1' },
10
- { id: 'crossModule', required: true, prompt: 'Does this cross modules? yes or no.', example: 'no' },
11
- { id: 'needParallel', required: true, prompt: 'Must independent modules run in parallel? yes or no.', example: 'no' },
12
- { id: 'goalKind', required: true, prompt: 'Goal kind: code, calculator, mixed, or docs.', example: 'code' },
13
- { id: 'deliveryDoc', required: true, prompt: 'After success, summarize a local delivery document? yes or no.', example: 'no' },
40
+ { id: 'goal', required: true, prompt: '完成标准与禁止改动 / Goal and forbidden changes / Цель и запрещённые изменения', example: '只改税率常量一行,不改其它计税逻辑' },
41
+ { id: 'difficulty', required: true, prompt: '需求难度 / Difficulty / Сложность: low, medium, or high?', example: 'medium' },
42
+ { id: 'risk', required: true, prompt: '风险等级 / Risk / Риск: low, medium, or high?', example: 'low' },
43
+ { id: 'targetFiles', required: true, prompt: '目标文件 / Target files / Целевые файлы (unknown if not located)', example: 'apps/web/src/tax.ts' },
44
+ { id: 'estimatedChangedLines', required: true, prompt: '预计改动行数 / Estimated changed lines / Оценка строк', example: '1' },
45
+ { id: 'crossModule', required: true, prompt: '是否跨模块 / Cross-module / Межмодульно: yes or no?', example: 'no' },
46
+ { id: 'needParallel', required: true, prompt: '是否必须并行 / Parallel required / Нужна параллельность: yes or no?', example: 'no' },
47
+ { id: 'explicitAimlockRequested', required: true, prompt: '是否明确要求启用 Aimlock / Explicitly require Aimlock / Явно включить Aimlock: yes or no?', example: 'no' },
48
+ { id: 'goalKind', required: true, prompt: '需求类型 / Goal kind / Тип цели: code, calculator, mixed, or docs?', example: 'code' },
49
+ { id: 'deliveryDoc', required: true, prompt: '是否生成交付文档 / Delivery document / Нужен отчёт: yes or no?', example: 'no' },
14
50
  ]
15
51
 
16
- await dispatchOfficialSkillCli({
17
- packageRoot: dirname(fileURLToPath(import.meta.url)),
18
- runCommand: (context) => runIntakeHandshake(context, {
52
+ function parseBoolean(source) {
53
+ const value = source.trim().toLowerCase()
54
+ if (TRUE_ANSWERS.has(value)) return true
55
+ if (FALSE_ANSWERS.has(value)) return false
56
+ throw new Error('expected yes or no')
57
+ }
58
+
59
+ function parseText(source) {
60
+ const value = source.trim()
61
+ if (!value) throw new Error('a non-empty value is required')
62
+ return value
63
+ }
64
+
65
+ function parseTargetHints(source) {
66
+ const values = source.split(',').map((value) => value.trim()).filter(Boolean)
67
+ if (!values.length) throw new Error('at least one target path is required')
68
+ return values
69
+ }
70
+
71
+ export function localAimlockApplicability(facts) {
72
+ const valid = DIFFICULTIES.has(facts.difficulty)
73
+ && RISKS.has(facts.risk)
74
+ && Number.isSafeInteger(facts.estimatedChangedLines)
75
+ && facts.estimatedChangedLines >= 0
76
+ && typeof facts.crossModule === 'boolean'
77
+ && typeof facts.needParallel === 'boolean'
78
+ && typeof facts.explicitAimlockRequested === 'boolean'
79
+ if (!valid) throw new Error('Aimlock applicability facts are incomplete or invalid')
80
+ const bypass = facts.difficulty === 'low'
81
+ && facts.estimatedChangedLines <= BYPASS_LINE_BUDGET
82
+ && facts.crossModule === false
83
+ && facts.risk !== 'high'
84
+ && facts.needParallel === false
85
+ && facts.explicitAimlockRequested === false
86
+ return { mode: bypass ? 'bypass' : 'active', useAimlock: !bypass }
87
+ }
88
+
89
+ export function aimlockUsage(context) {
90
+ const usage = defaultUsage(context)
91
+ if (!usage.includes(COMMON_RUN_USAGE)) throw new Error('Shared CLI run usage contract changed')
92
+ return usage.replace(COMMON_RUN_USAGE, AIMLOCK_RUN_USAGE)
93
+ }
94
+
95
+ async function collectApplicability(input, output) {
96
+ const readline = createInterface({ input, output })
97
+ const answers = {}
98
+ try {
99
+ for (const question of APPLICABILITY_QUESTIONS) {
100
+ for (;;) {
101
+ const source = await readline.question(`${question.prompt}\n> `)
102
+ try {
103
+ answers[question.id] = question.parse(source)
104
+ break
105
+ } catch (error) {
106
+ output.write(`${error instanceof Error ? error.message : String(error)}\n`)
107
+ }
108
+ }
109
+ }
110
+ } finally {
111
+ readline.close()
112
+ }
113
+ return answers
114
+ }
115
+
116
+ export async function runAimlockWith(context, dependencies) {
117
+ const facts = await dependencies.collectApplicability()
118
+ const decision = localAimlockApplicability(facts)
119
+ if (!decision.useAimlock) {
120
+ dependencies.writeNotice(BYPASS_NOTICE)
121
+ return decision
122
+ }
123
+ await dependencies.runHandshake(context, {
19
124
  questions: INTAKE_QUESTIONS,
20
125
  outputFile: 'AIMLOCK-REQUIREMENTS.json',
21
126
  afterCapabilities(output) {
22
127
  const notice = output.firstUseNotice?.zh
23
128
  if (typeof notice === 'string' && notice.trim()) console.log(notice)
24
129
  },
25
- }),
26
- })
130
+ })
131
+ return decision
132
+ }
133
+
134
+ async function runAimlock(context) {
135
+ return runAimlockWith(context, {
136
+ collectApplicability: async () => {
137
+ const answers = await collectApplicability(stdin, stdout)
138
+ const probe = await probeRepositoryDemand({
139
+ repositoryRoot: cwd(), goal: answers.goal, targetHints: answers.targetHints,
140
+ })
141
+ console.log(JSON.stringify({ mode: probe.mode, facts: probe.facts }))
142
+ return { ...probe.facts, explicitAimlockRequested: answers.explicitAimlockRequested }
143
+ },
144
+ writeNotice: (message) => console.log(message),
145
+ runHandshake: runIntakeHandshake,
146
+ })
147
+ }
148
+
149
+ async function readJsonInput(input) {
150
+ let source = ''
151
+ for await (const chunk of input) source += chunk
152
+ if (!source.trim()) return {}
153
+ const value = JSON.parse(source)
154
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
155
+ throw new Error('local operation input must be a JSON object')
156
+ }
157
+ return value
158
+ }
159
+
160
+ async function runLocalOperation(operation, repositoryRoot, input) {
161
+ const scoped = { ...input, repositoryRoot }
162
+ if (operation === 'capabilities') return LOCAL_CAPABILITIES
163
+ if (operation === 'probe') return probeRepositoryDemand(scoped)
164
+ if (operation === 'reassess') return reassessMode(input)
165
+ if (operation === 'budget-init') return initializeReadBudget(scoped)
166
+ if (operation === 'budget-read') return readFileWithinBudget(scoped)
167
+ if (operation === 'budget-status') return readBudgetStatus(scoped)
168
+ if (operation === 'budget-extend') return extendReadBudget(scoped)
169
+ if (operation === 'gate-issue') return issueMutationPass(scoped)
170
+ if (operation === 'gate-verify') return verifyMutationPassFile(scoped)
171
+ if (operation === 'guarded-write') return guardedWriteFile(scoped)
172
+ throw new Error(`unsupported local operation: ${operation}`)
173
+ }
174
+
175
+ async function dispatchLocal(args) {
176
+ const operation = args[0]?.trim()
177
+ const repositoryRoot = args[1]?.trim()
178
+ if (!operation || !repositoryRoot) {
179
+ throw new Error('usage: cli-aimlock local <operation> <repositoryRoot>')
180
+ }
181
+ return runLocalOperation(operation, repositoryRoot, await readJsonInput(stdin))
182
+ }
183
+
184
+ const cliPath = fileURLToPath(import.meta.url)
185
+ if (process.argv[1] && resolve(process.argv[1]) === cliPath) {
186
+ if (process.argv[2] === 'local') {
187
+ try {
188
+ console.log(JSON.stringify(await dispatchLocal(process.argv.slice(3))))
189
+ } catch (error) {
190
+ console.error(JSON.stringify({ status: 'failed', code: error?.code ?? 'AIMLOCK_LOCAL_FAILED',
191
+ message: error instanceof Error ? error.message : String(error) }))
192
+ process.exitCode = error?.code === 'AIMLOCK_DECISION_REQUIRED' ? 2 : 1
193
+ }
194
+ } else {
195
+ await dispatchOfficialSkillCli({
196
+ packageRoot: dirname(cliPath),
197
+ runCommand: runAimlock,
198
+ usage: aimlockUsage,
199
+ })
200
+ }
201
+ }
202
+
203
+ export { runLocalOperation }