aura-basetools 0.7.0 → 0.13.0

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/bin/aura.mjs CHANGED
@@ -2,19 +2,23 @@
2
2
  /**
3
3
  * aura CLI(薄壳):参数解析 → `lib/ops.mjs` → 一行 JSON。
4
4
  *
5
- * aura read --file F [--mode …] [--around X] [--lines a,b] [--search S] [--since V] [--max-chars N]
6
- * aura edit --file F (--hunks '<json>' | --hunks-file P) [--dry-run] [--create <文本|@文件>]
7
- * aura read --targets '[...]|@spec.json' 一次读多个目标(与 --file 互斥)
8
- * aura edit --targets '[...]|@spec.json' 一次改多个文件(与 --file 互斥;任一规划失败 ⇒ 零写入)
9
- * aura undo [--id <事务 id>] [--force] 撤销最近一次/指定写事务
10
- * aura transactions 列出当前工作区写事务
11
- * aura mcp
5
+ * aura basetools read --file F [--mode …] [--around X] [--lines a,b] [--search S] [--since V] [--max-chars N]
6
+ * aura basetools edit --file F (--hunks '<json>' | --hunks-file P) [--dry-run] [--create <文本|@文件>]
7
+ * aura basetools read --targets '[...]|@spec.json' 一次读多个目标(与 --file 互斥)
8
+ * aura basetools edit --targets '[...]|@spec.json' 一次改多个文件(与 --file 互斥;任一规划失败 ⇒ 零写入)
9
+ * aura basetools undo [--id <事务 id>] [--force] 撤销最近一次/指定写事务
10
+ * aura basetools transactions 列出当前工作区写事务
11
+ * aura basetools mcp
12
12
  * aura --help | --version
13
13
  *
14
14
  * 退出码:0 成功;2 结构化拒绝(回执带 code/next 一类信息);1 用法错误。
15
15
  */
16
16
  import fs from 'node:fs'
17
+ import os from 'node:os'
18
+ import path from 'node:path'
17
19
  import process from 'node:process'
20
+ import { spawnSync } from 'node:child_process'
21
+ import { fileURLToPath } from 'node:url'
18
22
  import {
19
23
  openProvider,
20
24
  readEnvelope,
@@ -25,9 +29,13 @@ import {
25
29
  editManyEnvelope,
26
30
  undoEnvelope,
27
31
  transactionsEnvelope,
32
+ capabilitiesEnvelope,
33
+ writeEnvelope,
28
34
  assertReadTargets,
29
35
  assertEditTargets,
30
36
  assertKnownArgs,
37
+ MT_READ_ELEMENT_KEYS,
38
+ MT_EDIT_ELEMENT_KEYS,
31
39
  errorEvidence,
32
40
  } from '../lib/ops.mjs'
33
41
  // `flatHunk` 是 `locator-lab/args.mjs` 里那一份容错规则(DSH 壳与 CLI 共用),但**不在这里定位引擎**:
@@ -42,7 +50,17 @@ const VERSION = (() => {
42
50
  return 'dev'
43
51
  }
44
52
  })()
45
- const argv = process.argv.slice(2)
53
+ const rootArgv = process.argv.slice(2)
54
+ /**
55
+ * CLI 命名空间:`aura basetools <verb>`。
56
+ *
57
+ * 顶层 `read` / `edit` / `write` ... 不再保留别名:以后会有多个 aura 产品共用 `aura`
58
+ * 这个入口,顶层动词迟早撞名。命名空间只改**调用写法**,引擎与回执不变。
59
+ */
60
+ let argv = rootArgv
61
+ const namespaced = rootArgv[0] === 'basetools'
62
+ if (namespaced) argv = argv.slice(1)
63
+ let command = argv[0]
46
64
 
47
65
  /**
48
66
  /**
@@ -56,9 +74,72 @@ const argv = process.argv.slice(2)
56
74
  * 有了这份名单,“下一个是已知布尔旗标”才当没带值,**其它以 `--` 开头的东西都当值**。
57
75
  * 代价是名单要维护;收益是那个静默丢值的路径从根上消失,而不是靠叫用户改写法。
58
76
  */
59
- const BOOLEAN_FLAGS = new Set(['pretty', 'dry-run', 'transaction', 'force'])
77
+ const BOOLEAN_FLAGS = new Set(['pretty', 'dry-run', 'plan', 'transaction', 'force', 'no-overwrite', 'replace-all', 'require-observation', 'allow-unobserved', 'json'])
60
78
  const isBooleanFlag = (token) => typeof token === 'string' && BOOLEAN_FLAGS.has(token.replace(/^--/, ''))
61
79
 
80
+ /**
81
+ * 每个命令认识的旗标(唯一一份)。`flag()` 只读取它要找的那个名字;**不在这里的 `--xxx`
82
+ * 以前会被静默忽略**——`--dryrun` 于是变成“没传 dry-run”的一次真写盘。
83
+ *
84
+ * 这里同时承担两件事:未知旗标拒绝(`ARGS_UNKNOWN_KEY`)与扫描时的“这个旗标要不要吃值”。
85
+ * 合法键名与命令实现放在同一张表里,新增旗标不会出现“parser 认、校验不认”的分叉。
86
+ */
87
+ const COMMAND_FLAGS = {
88
+ read: {
89
+ value: ['cwd', 'file', 'targets', 'mode', 'around', 'lines', 'start-line', 'end-line', 'search', 'since', 'context', 'kind', 'budget', 'max-chars', 'offset', 'limit'],
90
+ bool: ['pretty'],
91
+ },
92
+ edit: {
93
+ value: ['cwd', 'file', 'targets', 'hunks', 'hunks-file', 'create', 'target', 'intent', 'lines', 'start-line', 'end-line', 'line-cue', 'anchor', 'old', 'context', 'handle', 'line', 'position', 'replace', 'expect', 'near', 'eol'],
94
+ bool: ['pretty', 'dry-run', 'plan', 'transaction', 'replace-all', 'require-observation'],
95
+ },
96
+ write: {
97
+ value: ['cwd', 'file', 'content', 'eol'],
98
+ bool: ['pretty', 'dry-run', 'plan', 'transaction', 'no-overwrite', 'allow-unobserved'],
99
+ },
100
+ undo: { value: ['cwd', 'id'], bool: ['pretty', 'force'] },
101
+ transactions: { value: ['cwd'], bool: ['pretty'] },
102
+ doctor: { value: ['cwd'], bool: ['pretty', 'json'] },
103
+ capabilities: { value: ['cwd'], bool: ['pretty'] },
104
+ mcp: { value: ['cwd'], bool: ['pretty'] },
105
+ }
106
+
107
+ /**
108
+ * 扫一遍命令后的 argv:未知旗标 / 裸位置参数 / 布尔旗标带值 一律拒绝。
109
+ *
110
+ * **值旗标吃下一个 token**(与 `flag()` 的既有语义一致):`--content "---"` 这种以 `--`
111
+ * 开头的正文不能被误判成旗标。没有值的值旗标不在这一层判(各命令会给出自己的错误)。
112
+ */
113
+ function validateCommandFlags(command, tokens) {
114
+ const spec = COMMAND_FLAGS[command]
115
+ if (spec === undefined) return undefined
116
+ const valueFlags = new Set(spec.value)
117
+ const boolFlags = new Set(spec.bool)
118
+ for (let i = 0; i < tokens.length; i += 1) {
119
+ const token = tokens[i]
120
+ if (typeof token !== 'string' || !token.startsWith('--')) {
121
+ return `unexpected argument ${JSON.stringify(token)} — this command takes flags only`
122
+ }
123
+ const eq = token.indexOf('=')
124
+ const name = token.slice(2, eq === -1 ? undefined : eq)
125
+ if (boolFlags.has(name)) {
126
+ if (eq !== -1) return `--${name} is a boolean flag and takes no value (got ${JSON.stringify(token.slice(eq + 1))})`
127
+ continue
128
+ }
129
+ if (!valueFlags.has(name)) {
130
+ const legal = [...spec.value, ...spec.bool].sort().map((flag) => `--${flag}`).join(', ')
131
+ return `unknown flag --${name}; this command accepts: ${legal}`
132
+ }
133
+ if (eq === -1) {
134
+ const next = tokens[i + 1]
135
+ // 与 `flag()` 同一条:下一个 token 若是本命令的布尔旗标,就当“这个值旗标没带值”,
136
+ // 不去吃它(否则 `--content --dry-run` 会把 dry-run 藏进正文里)。
137
+ if (next !== undefined && !(next.startsWith('--') && boolFlags.has(next.slice(2)))) i += 1
138
+ }
139
+ }
140
+ return undefined
141
+ }
142
+
62
143
  const flag = (name, fallback = undefined) => {
63
144
  const i = argv.indexOf(`--${name}`)
64
145
  if (i >= 0) {
@@ -73,38 +154,51 @@ const emit = (obj, code) => {
73
154
  process.stdout.write(`${has('pretty') ? JSON.stringify(obj, null, 2) : JSON.stringify(obj)}\n`)
74
155
  process.exit(code)
75
156
  }
157
+ /** `@文件` 参数读不到时回一条 JSON 回执,而不是把 Node 栈抛给调用方。 */
158
+ const readArgumentFile = (target, flagName) => {
159
+ try {
160
+ return fs.readFileSync(target, 'utf8')
161
+ } catch (error) {
162
+ emit({ ok: false, error: `${flagName} 读不到 ${target}:${error.message}`, code: 'USAGE' }, 1)
163
+ }
164
+ }
76
165
 
77
166
  const USAGE = [
78
167
  `aura ${VERSION} —— 按意图读写文件(独立版:不依赖 DeepSeek Harness,Windows / macOS / Linux 同形)`,
79
168
  '',
80
- ' aura read --file <路径> [--mode map|digest|full|around|lines|search|changed]',
169
+ ' aura basetools read --file <路径> [--mode map|digest|full|around|lines|search|changed]',
81
170
  ' [--around <符号>] [--lines <a,b>|<a-b>|<n>] [--start-line <n> --end-line <n>]',
82
- ' [--search <子串>] [--since <令牌>] [--max-chars <n>]',
171
+ ' [--search <子串>] [--context <n>] [--kind <类型>] [--budget <字符数>] [--since <令牌>] [--max-chars <n>] [--offset <n> --limit <n>]',
83
172
  // `--file` 指向**目录**时直接给一张仓库地图(模块/目录/依赖方向),不需要额外旗标。
84
173
  ' (--file 传目录 ⇒ 仓库地图:模块清单 + 目录骨架 + 被依赖最多的模块 + 入口点)',
85
- ' aura read --targets <json|@文件> # 一次读多个目标(≤32 项;与 --file 互斥)',
86
- ' aura edit --targets <json|@文件> [--dry-run] # 一次改多个文件(先全量规划,任一失败则零写入)',
87
- " aura edit --file <路径> --create <内容|@文件> # 建文件(不必再给 --hunks)",
88
- " aura edit --file <路径> (--hunks '<json>' | --hunks-file <路径>) [--dry-run]",
89
- ' aura edit --file <路径> --lines <a,b> --replace <文本> [--expect <文本>] [--near <n>]',
90
- " (只改一处时不必包成 JSON:--target / --lines / --start-line+--end-line / --line-cue / --anchor 任选其一)",
174
+ ' aura basetools read --targets <json|@文件> # 一次读多个目标(≤32 项;与 --file 互斥)',
175
+ ' aura basetools edit --targets <json|@文件> [--dry-run|--plan] # 一次改多个文件(先全量规划,任一失败则零写入)',
176
+ " aura basetools edit --file <路径> --create <内容|@文件> # 建文件(不必再给 --hunks)",
177
+ " aura basetools edit --file <路径> (--hunks '<json>' | --hunks-file <路径>) [--dry-run|--plan] [--eol auto|preserve|lf|crlf] [--require-observation]",
178
+ ' aura basetools edit --file <路径> --lines <a,b> --replace <文本> [--expect <文本>] [--near <n>]',
179
+ ' aura basetools edit --file <路径> --old <原文> --replace <文本> --replace-all # 改全部非重叠出现',
180
+ " (只改一处时不必包成 JSON:--target / --intent / --lines / --start-line+--end-line / --line-cue / --anchor 任选其一)",
91
181
  // 句柄(fs-contract-v2 §5):读侧发的柄 + 三种定位写法。下面三行是三种**用法**,不是三种键。
92
- ' aura edit --file <路径> --handle <h…> --replace <文本> # 整段替换:区间由柄定,不靠转述',
93
- ' aura edit --file <路径> --handle <h…> --line <段内行号> --replace <文本> # 只换区间内第 k 行(1-based,从区间首行数起)',
94
- ' aura edit --file <路径> --handle <h…> --position before|after --replace <文本> # 在区间边界插入,区间本身不动',
182
+ ' aura basetools edit --file <路径> --handle <h…> --replace <文本> # 整段替换:区间由柄定,不靠转述',
183
+ ' aura basetools edit --file <路径> --handle <h…> --line <段内行号> --replace <文本> # 只换区间内第 k 行(1-based,从区间首行数起)',
184
+ ' aura basetools edit --file <路径> --handle <h…> --position before|after --replace <文本> # 在区间边界插入,区间本身不动',
95
185
  " (--line / --position 是从属键:只有与 --handle 同时给才成立,单独给不构成一次编辑)",
96
- ' aura mcp 启动 MCP stdio 服务',
186
+ ' aura basetools mcp 启动 MCP stdio 服务',
187
+ ' aura basetools write --file <路径> --content <文本|@文件> [--no-overwrite] [--dry-run|--plan] [--transaction] [--allow-unobserved] [--eol auto|preserve|lf|crlf]',
188
+ ' (覆写默认要求先读过这一版;--allow-unobserved 才允许盲写)',
189
+ ' aura basetools doctor [--json] 自检:Node/版本/MCP 握手/缓存与锁目录可写',
190
+ ' aura basetools capabilities [--pretty] 能力清单:读模式/地址档/语言/限制(JSON)',
97
191
  ' aura --help | --version',
98
192
  '',
99
- 'hunks 形状:[{"target":"符号名"|"lines":[a,b]|"line_cue":"唯一词"|"anchor":"记得的原文",',
193
+ 'hunks 形状:[{"target":"符号名"|"intent":"模糊意图(符号片段/短语/近似原文)"|"lines":[a,b]|"line_cue":"唯一词"|"anchor":"记得的原文",',
100
194
  ' "replace":"替换文本","expect":"该处应是什么","near":行号}]',
101
195
  '',
102
196
  '例:',
103
- " aura edit --file a.mjs --hunks '[{\"lines\":[5,5],\"replace\":\"x\"}]' # 单引号包住整段 JSON",
104
- ' aura edit --file a.mjs --hunks-file hunks.json # 内容走文件,绕开引号',
105
- ' aura edit --file new.md --create @body.md # @文件 ——推荐给长内容',
106
- ' aura edit --file new.md --create=--- # 值以 -- 开头时必须用 = 形式',
107
- ' aura read --file ./src # 目录 ⇒ 仓库地图',
197
+ " aura basetools edit --file a.mjs --hunks '[{\"lines\":[5,5],\"replace\":\"x\"}]' # 单引号包住整段 JSON",
198
+ ' aura basetools edit --file a.mjs --hunks-file hunks.json # 内容走文件,绕开引号',
199
+ ' aura basetools edit --file new.md --create @body.md # @文件 ——推荐给长内容',
200
+ ' aura basetools edit --file new.md --create=--- # 值以 -- 开头时必须用 = 形式',
201
+ ' aura basetools read --file ./src # 目录 ⇒ 仓库地图',
108
202
  '',
109
203
  'PowerShell 两个已踩过的坑:',
110
204
  ' · 反引号 `n 写进 JSON 字符串会变成控制字符(JSON 不允许)⇒ 用 --hunks-file 或单引号 heredoc;',
@@ -126,10 +220,10 @@ const USAGE = [
126
220
  * (于是“两个都给”由那条唯一判据说话)、把引擎抛的参数面错接到 exit 1 那一档。
127
221
  *
128
222
  * @param assertShape - 读侧 `assertReadTargets`、写侧 `assertEditTargets`(同一个判据,两套提示语)
129
- * @param toolName - 回执里点名的工具(`aura_read` / `aura_edit`)
223
+ * @param toolName - 回执里点名的工具(`aura_basetools_read` / `aura_basetools_edit`)
130
224
  * @returns `{ targets, dryRun }`(逐项判决在引擎里,这里只递参数)
131
225
  */
132
- const engineTargets = (assertShape, toolName) => {
226
+ const engineTargets = (assertShape, toolName, elementKeys) => {
133
227
  const raw = flag('targets')
134
228
  const where = typeof raw === 'string' && raw.startsWith('@') ? `--targets @${raw.slice(1)}` : '--targets'
135
229
  if (typeof raw !== 'string') {
@@ -183,7 +277,7 @@ const engineTargets = (assertShape, toolName) => {
183
277
  if (typeof flag('file') === 'string') source.file_path = flag('file')
184
278
  let targets
185
279
  try {
186
- targets = assertShape(source, { toolName })
280
+ targets = assertShape(source, { toolName, elementKeys })
187
281
  } catch (error) {
188
282
  refuseShape(where, error)
189
283
  }
@@ -211,9 +305,28 @@ const refuseShape = (where, error) =>
211
305
  1,
212
306
  )
213
307
 
214
- const command = argv[0]
215
308
  if (command === undefined || command === '--help' || command === '-h') emit({ ok: true, usage: USAGE }, 0)
216
309
  if (command === '--version' || command === '-v') emit({ ok: true, version: VERSION }, 0)
310
+ /**
311
+ * 旧顶层动词只给**迁移提示**,不再作为别名执行——保留别名等于保留未来的撞名。
312
+ * 只提示、不执行:`aura basetools read` 不会偷偷改文件。
313
+ */
314
+ const MOVED_COMMANDS = new Set(['read', 'outline', 'edit', 'write', 'undo', 'transactions', 'mcp', 'doctor', 'capabilities'])
315
+ if (!namespaced && MOVED_COMMANDS.has(command)) {
316
+ emit(
317
+ {
318
+ ok: false,
319
+ error: `command moved: use \`aura basetools ${command}\` (top-level verbs are reserved for product namespaces)`,
320
+ code: 'USAGE',
321
+ shape: 'CLI_NAMESPACE_REQUIRED',
322
+ },
323
+ 1,
324
+ )
325
+ }
326
+ const flagProblem = validateCommandFlags(command, argv.slice(1))
327
+ if (flagProblem !== undefined) {
328
+ emit({ ok: false, error: flagProblem, code: 'USAGE', shape: 'ARGS_UNKNOWN_KEY' }, 1)
329
+ }
217
330
  if (command === 'mcp') {
218
331
  const { serve } = await import('./aura-mcp.mjs')
219
332
  await serve()
@@ -222,6 +335,34 @@ if (command === 'mcp') {
222
335
 
223
336
  const provider = openProvider(typeof flag('cwd') === 'string' ? flag('cwd') : process.cwd())
224
337
 
338
+ if (command === 'capabilities') {
339
+ const env = { ok: true, version: VERSION, ...capabilitiesEnvelope() }
340
+ process.stdout.write(`${JSON.stringify(env, null, has('pretty') ? 2 : 0)}\n`)
341
+ process.exit(0)
342
+ }
343
+
344
+ if (command === 'write') {
345
+ const file = flag('file')
346
+ if (typeof file !== 'string') emit({ ok: false, error: 'write 需要 --file <路径>', code: 'USAGE' }, 1)
347
+ const raw = flag('content')
348
+ if (typeof raw !== 'string') emit({ ok: false, error: 'write 需要 --content <文本|@文件>', code: 'USAGE' }, 1)
349
+ const content = raw.startsWith('@') ? readArgumentFile(raw.slice(1), '--content') : raw
350
+ const eol = flag('eol')
351
+ if (eol !== undefined && typeof eol !== 'string') {
352
+ emit({ ok: false, error: 'write 的 --eol 需要跟一个值:auto|preserve|lf|crlf', code: 'USAGE' }, 1)
353
+ }
354
+ const env = await writeEnvelope(provider, {
355
+ file,
356
+ content,
357
+ dryRun: has('dry-run') || has('plan'),
358
+ noOverwrite: has('no-overwrite'),
359
+ includeTransaction: has('transaction'),
360
+ allowUnobserved: has('allow-unobserved'),
361
+ ...(typeof eol === 'string' ? { eol } : {}),
362
+ })
363
+ emit(env, env.ok ? 0 : 2)
364
+ }
365
+
225
366
  if (command === 'undo') {
226
367
  const env = await undoEnvelope(provider, {
227
368
  id: typeof flag('id') === 'string' ? flag('id') : undefined,
@@ -235,13 +376,77 @@ if (command === 'transactions') {
235
376
  emit(env, 0)
236
377
  }
237
378
 
379
+ if (command === 'doctor') {
380
+ /**
381
+ * `aura basetools doctor`:把“装完能不能用”的机械检查放在 CLI 自己身上。
382
+ * 与安装器的 `--doctor` 不同:这里不假设 DSH profile,只查这个包自己的入口、MCP 握手与缓存目录。
383
+ */
384
+ const checks = []
385
+ const add = (id, ok, detail) => checks.push({ id, ok, detail })
386
+ const nodeMajor = Number.parseInt(process.versions.node.split('.')[0], 10)
387
+ add('node', nodeMajor >= 18, `node ${process.versions.node} (need >=18)`)
388
+ add('version', typeof VERSION === 'string' && VERSION.length > 0, VERSION)
389
+ const mcpPath = fileURLToPath(new URL('./aura-mcp.mjs', import.meta.url))
390
+ add('mcp-bin', fs.existsSync(mcpPath), mcpPath)
391
+ const cacheDir = process.env.AURA_CACHE_DIR ?? path.join(os.homedir(), '.aura')
392
+ if (fs.existsSync(mcpPath)) {
393
+ const frames = [
394
+ JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'aura-doctor', version: '1' } } }),
395
+ JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
396
+ JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }),
397
+ '',
398
+ ].join('\n')
399
+ const res = spawnSync(process.execPath, [mcpPath], {
400
+ input: frames,
401
+ encoding: 'utf8',
402
+ timeout: 30000,
403
+ env: { ...process.env, AURA_CACHE_DIR: cacheDir },
404
+ })
405
+ const tools = String(res.stdout ?? '').split('\n').flatMap((line) => {
406
+ try { return JSON.parse(line).result?.tools?.map((tool) => tool.name) ?? [] } catch { return [] }
407
+ })
408
+ add('mcp-handshake', res.status === 0 && tools.includes('aura_basetools_read') && tools.includes('aura_basetools_edit'), `exit=${res.status} tools=${tools.join(',') || '(none)'}`)
409
+ } else {
410
+ add('mcp-handshake', false, 'skipped: mcp bin missing')
411
+ }
412
+ const probeWritable = (dir, id) => {
413
+ try {
414
+ fs.mkdirSync(dir, { recursive: true })
415
+ const probe = path.join(dir, `.doctor-${process.pid}`)
416
+ fs.writeFileSync(probe, 'ok')
417
+ const text = fs.readFileSync(probe, 'utf8')
418
+ fs.rmSync(probe, { force: true })
419
+ add(id, text === 'ok', dir)
420
+ } catch (error) {
421
+ add(id, false, `${dir}: ${String(error?.message ?? error)}`)
422
+ }
423
+ }
424
+ probeWritable(cacheDir, 'cache-writable')
425
+ probeWritable(path.join(cacheDir, 'locks'), 'lock-writable')
426
+ const ok = checks.every((check) => check.ok)
427
+ const report = {
428
+ ok,
429
+ version: VERSION,
430
+ node: process.versions.node,
431
+ checks,
432
+ ...(process.env.AURA_NO_LOCK === '1' ? { notes: ['AURA_NO_LOCK=1: workspace lock is disabled'] } : {}),
433
+ }
434
+ if (has('json')) {
435
+ process.stdout.write(`${JSON.stringify(report)}\n`)
436
+ process.exit(ok ? 0 : 2)
437
+ }
438
+ process.stdout.write(`aura basetools doctor: ${ok ? 'ok' : 'FAILED'} (${VERSION}, node ${process.versions.node})\n`)
439
+ for (const check of checks) process.stdout.write(` ${check.ok ? '✓' : '✗'} ${check.id}: ${check.detail}\n`)
440
+ process.exit(ok ? 0 : 2)
441
+ }
442
+
238
443
  if (command === 'read' || command === 'outline') {
239
444
  /**
240
445
  * 多目标(fs-contract-v2 §2.1):`--targets` 在场 ⇒ 引擎的批入口。
241
446
  * `--file` 那条路一个字不动(就在下面,含它自己的 `USAGE` 与 `outline ⇒ map`)。
242
447
  */
243
448
  if (has('targets')) {
244
- const { targets } = engineTargets(assertReadTargets, 'aura_read')
449
+ const { targets } = engineTargets(assertReadTargets, 'aura_basetools_read', MT_READ_ELEMENT_KEYS)
245
450
  const env = await readManyWith(provider, targets)
246
451
  emit(env, env.ok ? 0 : 2)
247
452
  }
@@ -262,7 +467,13 @@ if (command === 'read' || command === 'outline') {
262
467
  start_line: flag('start-line'),
263
468
  end_line: flag('end-line'),
264
469
  search: flag('search'),
470
+ context: typeof flag('context') === 'string' ? Number(flag('context')) : undefined,
471
+ kind: typeof flag('kind') === 'string' ? flag('kind') : undefined,
472
+ budget: typeof flag('budget') === 'string' ? Number(flag('budget')) : undefined,
265
473
  since: flag('since'),
474
+ // 0.10.0:内置 Read 的分页形状(offset 0-based;limit 行数;默认 2000)。
475
+ offset: typeof flag('offset') === 'string' ? Number(flag('offset')) : undefined,
476
+ limit: typeof flag('limit') === 'string' ? Number(flag('limit')) : undefined,
266
477
  // 光写 `--max-chars` 不带值时 flag() 回 true,Number(true)=1 会变成"预算 1 字符"这种怪值
267
478
  maxChars: typeof flag('max-chars') === 'string' ? Number(flag('max-chars')) : undefined,
268
479
  })
@@ -277,11 +488,12 @@ if (command === 'edit') {
277
488
  * `dryRun` 两个来源都认:spec 里的 `dryRun`(与 DSH 壳同名)与既有的 `--dry-run` 旗标。
278
489
  */
279
490
  if (has('targets')) {
280
- const spec = engineTargets(assertEditTargets, 'aura_edit')
491
+ const spec = engineTargets(assertEditTargets, 'aura_basetools_edit', MT_EDIT_ELEMENT_KEYS)
281
492
  const env = await editManyEnvelope(provider, {
282
493
  targets: spec.targets,
283
- dryRun: has('dry-run') || spec.dryRun,
494
+ dryRun: has('dry-run') || has('plan') || spec.dryRun,
284
495
  includeTransaction: has('transaction'),
496
+ ...(has('require-observation') ? { requireObservation: true } : {}),
285
497
  })
286
498
  emit(env, env.ok ? 0 : 2)
287
499
  }
@@ -297,6 +509,8 @@ if (command === 'edit') {
297
509
  */
298
510
  const flat = flatHunk({
299
511
  target: flag('target'),
512
+ // 意图地址(T16):`--intent 'config loader'` 一句话定位一处改动,不要求符号全名或原文。
513
+ intent: flag('intent'),
300
514
  lines: flag('lines'),
301
515
  start_line: flag('start-line'),
302
516
  end_line: flag('end-line'),
@@ -309,8 +523,13 @@ if (command === 'edit') {
309
523
  handle: flag('handle'),
310
524
  line: flag('line'),
311
525
  position: flag('position'),
526
+ // 内容地址(0.10.2):平铺 CLI 也能直接给逐字原文;`--replace-all` 用它改全部出现。
527
+ old: flag('old'),
528
+ context: flag('context'),
312
529
  replace: flag('replace'),
313
530
  expect: flag('expect'),
531
+ eol: flag('eol'),
532
+ replace_all: has('replace-all') ? true : undefined,
314
533
  // 旗标值天然是字符串:`--near 42` ⇒ `'42'`。收敛层只认整数,所以在这里转一次
315
534
  near: typeof flag('near') === 'string' && flag('near').trim() !== '' ? Number(flag('near')) : flag('near'),
316
535
  })
@@ -374,7 +593,7 @@ if (command === 'edit') {
374
593
  {
375
594
  ok: false,
376
595
  error:
377
- 'edit 需要 --hunks/--hunks-file,或平铺一组地址旗标(--target / --lines / --start-line+--end-line / --line-cue / --anchor / --handle(配 --line 或 --position),外加 --replace),或 --create <整份内容>',
596
+ 'edit 需要 --hunks/--hunks-file,或平铺一组地址旗标(--target / --intent / --lines / --start-line+--end-line / --line-cue / --anchor / --handle(配 --line 或 --position),外加 --replace),或 --create <整份内容>',
378
597
  code: 'USAGE',
379
598
  },
380
599
  1,
@@ -382,13 +601,14 @@ if (command === 'edit') {
382
601
  }
383
602
  const createRaw = flag('create')
384
603
  // `@文件` 形式:内容完全不经过命令行引号。创建含引号/换行/front matter 的内容时这是最稳的路。
385
- const create = typeof createRaw === 'string' && createRaw.startsWith('@') ? fs.readFileSync(createRaw.slice(1), 'utf8') : createRaw
604
+ const create = typeof createRaw === 'string' && createRaw.startsWith('@') ? readArgumentFile(createRaw.slice(1), '--create') : createRaw
386
605
  const env = await editEnvelope(provider, {
387
606
  file,
388
607
  hunks: hunks ?? [],
389
- dryRun: has('dry-run'),
608
+ dryRun: has('dry-run') || has('plan'),
390
609
  create,
391
610
  includeTransaction: has('transaction'),
611
+ ...(has('require-observation') ? { requireObservation: true } : {}),
392
612
  })
393
613
  emit(env, env.ok ? 0 : 2)
394
614
  }
@@ -147,6 +147,100 @@ export function createDiskStore(file = observationsPath()) {
147
147
  for (const [spelling, target] of aliases) if (!alive.has(target)) aliases.delete(spelling)
148
148
  }
149
149
 
150
+ /**
151
+ * 落盘状态的新鲜度标记。多个 CLI/MCP 进程会同时读写同一个 `observations.json`;
152
+ * 旧实现“启动时读一次、结束时整份写回”会让后写的进程覆盖先写的观测(并发读两个文件,
153
+ * 最后只剩一条)——严格新鲜度随后对刚读过的文件报 `never-read`。
154
+ */
155
+ let lastStamp = null
156
+ const fileStamp = () => {
157
+ try {
158
+ const st = fs.statSync(file)
159
+ return `${st.mtimeMs}:${st.size}`
160
+ } catch {
161
+ return 'missing'
162
+ }
163
+ }
164
+ lastStamp = fileStamp()
165
+
166
+ /** 从盘上重新读一份合法缓存;坏文件/不存在都当空(“没观测过”是合法状态)。 */
167
+ const loadDisk = () => {
168
+ try {
169
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
170
+ if (raw !== null && typeof raw === 'object' && Array.isArray(raw.seen) && typeof raw.latest === 'object') return raw
171
+ } catch {
172
+ /* 落到 null */
173
+ }
174
+ return null
175
+ }
176
+
177
+ /** 把盘上条目并进内存:seen 按 key 去重(本地优先),latest/aliases 本地优先。 */
178
+ const mergeDisk = (disk) => {
179
+ if (disk === null) return
180
+ const seen = new Map()
181
+ for (const entry of disk.seen) seen.set(entry.key, entry)
182
+ for (const entry of data.seen) seen.set(entry.key, entry)
183
+ data.seen = [...seen.values()]
184
+ data.latest = { ...disk.latest, ...data.latest }
185
+ if (typeof disk.aliases === 'object' && disk.aliases !== null) {
186
+ for (const [spelling, target] of Object.entries(disk.aliases)) {
187
+ if (!aliases.has(spelling)) aliases.set(spelling, target)
188
+ }
189
+ }
190
+ }
191
+
192
+ /** 查询前刷新:长进程(MCP)也能看见别的进程刚写下的观测。 */
193
+ const refresh = () => {
194
+ const stamp = fileStamp()
195
+ if (stamp === lastStamp) return
196
+ mergeDisk(loadDisk())
197
+ lastStamp = stamp
198
+ }
199
+
200
+ /**
201
+ * 写锁:同一个缓存文件同时只允许一个进程做 read-merge-write。
202
+ *
203
+ * 锁是**旁路文件**(`observations.json.lock`,`wx` 原子创建),不引入平台分支;
204
+ * 陈旧锁(进程崩溃留下)超过 `LOCK_STALE_MS` 后清掉。拿不到锁时仍做一次尽力而为的
205
+ * 合并写入——缓存损坏/丢一条都不该让编辑失败,但正常情况下不会走到这条。
206
+ */
207
+ const LOCK_STALE_MS = 5000
208
+ const LOCK_WAIT_MS = 2000
209
+ const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
210
+ const acquireLock = () => {
211
+ const lock = `${file}.lock`
212
+ const deadline = Date.now() + LOCK_WAIT_MS
213
+ try {
214
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
215
+ } catch {
216
+ return null
217
+ }
218
+ for (;;) {
219
+ try {
220
+ const fd = fs.openSync(lock, 'wx', 0o600)
221
+ try { fs.writeFileSync(fd, String(process.pid)) } finally { fs.closeSync(fd) }
222
+ return lock
223
+ } catch (error) {
224
+ if (error?.code !== 'EEXIST') return null
225
+ try {
226
+ const st = fs.statSync(lock)
227
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
228
+ fs.unlinkSync(lock)
229
+ continue
230
+ }
231
+ } catch {
232
+ continue
233
+ }
234
+ if (Date.now() >= deadline) return null
235
+ sleep(10)
236
+ }
237
+ }
238
+ }
239
+ const releaseLock = (lock) => {
240
+ if (lock === null) return
241
+ try { fs.unlinkSync(lock) } catch { /* 已被清理:没有需要撤销的状态 */ }
242
+ }
243
+
150
244
  const save = () => {
151
245
  try {
152
246
  const dir = path.dirname(file)
@@ -161,6 +255,7 @@ export function createDiskStore(file = observationsPath()) {
161
255
  try { fs.chmodSync(tmp, 0o600) } catch { /* 同上 */ }
162
256
  }
163
257
  fs.renameSync(tmp, file)
258
+ lastStamp = fileStamp()
164
259
  if (path.sep !== '\\') {
165
260
  try { fs.chmodSync(file, 0o600) } catch { /* 同上 */ }
166
261
  }
@@ -172,21 +267,44 @@ export function createDiskStore(file = observationsPath()) {
172
267
 
173
268
  return {
174
269
  remember(filePath, version, source, byteOrderMark = false) {
175
- if (typeof source !== 'string' || source.length > MAX_SOURCE) return
176
- const accounted = accountKeys(filePath, aliases)
177
- if (accounted.canonical !== null) aliases.set(accounted.canonical, accounted.key)
178
- const key = `${accounted.key}@${version}`
179
- data.seen = data.seen.filter((e) => e.key !== key)
180
- data.seen.push({ key, version, source, byteOrderMark: byteOrderMark === true })
181
- while (data.seen.length > LIMIT) data.seen.shift()
182
- data.latest[accounted.key] = key
183
- // 指针指向已被淘汰的条目时一并清掉:留着它只会让"最近观测"指向不存在的全文
184
- const alive = new Set(data.seen.map((e) => e.key))
185
- for (const [p, k] of Object.entries(data.latest)) if (!alive.has(k)) delete data.latest[p]
186
- dropDanglingAliases()
187
- save()
270
+ if (typeof source !== 'string') return
271
+ /**
272
+ * 超过 `MAX_SOURCE` 的文件**仍然记令牌**,只是不存全文。
273
+ *
274
+ * 旧行为是整条丢掉,于是严格新鲜度(`requireObservedRevision`)对大文件永远返回
275
+ * `never-read`——而它要求的恢复动作(先读一次)根本不可能成功。令牌足够回答
276
+ * “这一版读过没有”;区域级闸门拿不到全文时如实退回 `basis:'none'`(见 `regionGate`)。
277
+ */
278
+ const omitted = source.length > MAX_SOURCE
279
+ const lock = acquireLock()
280
+ try {
281
+ // 先合并盘上条目,再算键与写入:否则两个并发进程会各自用旧快照覆盖对方。
282
+ mergeDisk(loadDisk())
283
+ const accounted = accountKeys(filePath, aliases)
284
+ if (accounted.canonical !== null) aliases.set(accounted.canonical, accounted.key)
285
+ const key = `${accounted.key}@${version}`
286
+ data.seen = data.seen.filter((e) => e.key !== key)
287
+ data.seen.push({
288
+ key,
289
+ version,
290
+ source: omitted ? null : source,
291
+ ...(omitted ? { source_omitted: true } : {}),
292
+ byteOrderMark: byteOrderMark === true,
293
+ at: Date.now(),
294
+ })
295
+ while (data.seen.length > LIMIT) data.seen.shift()
296
+ data.latest[accounted.key] = key
297
+ // 指针指向已被淘汰的条目时一并清掉:留着它只会让"最近观测"指向不存在的全文
298
+ const alive = new Set(data.seen.map((e) => e.key))
299
+ for (const [p, k] of Object.entries(data.latest)) if (!alive.has(k)) delete data.latest[p]
300
+ dropDanglingAliases()
301
+ save()
302
+ } finally {
303
+ releaseLock(lock)
304
+ }
188
305
  },
189
306
  latest(filePath) {
307
+ refresh()
190
308
  /**
191
309
  * 快路:**精确键**命中就直接返回(引擎传进来的通常是它自己登记过的那个键)。
192
310
  * 只有查不到时才用文件系统事实再问一次"这个写法是不是盘上那一份" —— 于是常见形态下
@@ -203,16 +321,27 @@ export function createDiskStore(file = observationsPath()) {
203
321
  if (stored === undefined) return null
204
322
  const entry = data.seen.find((e) => e.key === stored)
205
323
  if (entry === undefined) return null
206
- return { version: entry.version, source: entry.source, byteOrderMark: entry.byteOrderMark === true }
324
+ return {
325
+ version: entry.version,
326
+ source: entry.source,
327
+ byteOrderMark: entry.byteOrderMark === true,
328
+ source_omitted: entry.source_omitted === true,
329
+ }
207
330
  },
208
331
  entry(filePath, version) {
332
+ refresh()
209
333
  const direct = findEntry(filePath, version)
210
- if (direct !== null) return { source: direct.source, byteOrderMark: direct.byteOrderMark === true }
334
+ if (direct !== null) {
335
+ return { source: direct.source, byteOrderMark: direct.byteOrderMark === true, source_omitted: direct.source_omitted === true }
336
+ }
211
337
  const accounted = accountKeys(filePath, aliases)
212
338
  const entry = findEntry(accounted.key, version) ?? (accounted.canonical === null ? null : findEntry(accounted.canonical, version))
213
- return entry === null ? null : { source: entry.source, byteOrderMark: entry.byteOrderMark === true }
339
+ return entry === null
340
+ ? null
341
+ : { source: entry.source, byteOrderMark: entry.byteOrderMark === true, source_omitted: entry.source_omitted === true }
214
342
  },
215
343
  recall(filePath, version) {
344
+ refresh()
216
345
  const direct = findEntry(filePath, version)
217
346
  if (direct !== null) return direct.source
218
347
  const accounted = accountKeys(filePath, aliases)
@@ -227,10 +356,14 @@ export function createDiskStore(file = observationsPath()) {
227
356
  * 模型手上的行号来自更早的一版,且没有任何东西能替它发现这件事。
228
357
  */
229
358
  has(filePath, version) {
359
+ refresh()
230
360
  if (findEntry(filePath, version) !== null) return true
231
361
  const accounted = accountKeys(filePath, aliases)
232
362
  return findEntry(accounted.key, version) !== null || (accounted.canonical !== null && findEntry(accounted.canonical, version) !== null)
233
363
  },
234
- count: () => data.seen.length,
364
+ count: () => {
365
+ refresh()
366
+ return data.seen.length
367
+ },
235
368
  }
236
369
  }