aura-basetools 0.16.0 → 0.18.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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # aura-basetools 0.16.0
1
+ # aura-basetools 0.18.0
2
2
 
3
3
  Byte-exact file tools for coding agents: read by intent, edit by address, and write whole files without corrupting bytes. One engine shared by a standalone CLI and an MCP server.
4
4
 
@@ -21,7 +21,7 @@ The engine is intentionally not a general file manager: it reads and edits UTF-8
21
21
  ### Install
22
22
 
23
23
  ```bash
24
- npm i -g aura-basetools@0.16.0
24
+ npm i -g aura-basetools@0.18.0
25
25
  aura --version
26
26
  ```
27
27
 
@@ -91,7 +91,7 @@ Compared with the DSH built-in `dsh-tool-fs` tools, the differences are delibera
91
91
  - **Edits refuse stale bases.** A region-level gate rejects an edit when the addressed region changed after the read that authorized it, and points at the lines to re-read; `expect` makes the intended text explicit.
92
92
  - **Writes are verified, not assumed.** Every write goes through a temp file, flush, rename, and a byte-for-byte read-back; multi-target edits plan first and write nothing if any target fails.
93
93
  - **Changes can prove themselves.** `edit` / `write` accept an explicit `verify` command; a non-zero exit or timeout rolls the transaction back and returns the command output plus the rollback state. No auto-discovery of repo scripts.
94
- - **Changes can span calls.** `tx begin` opens a transaction; `edit` / `write` with `--tx <id>` append to it; `tx commit` checks files were not changed externally (`TX_STALE`) and can run `verify`; `tx rollback` restores all of them.
94
+ - **Changes can span calls, and concurrent changes merge.** `tx begin` opens a transaction; `edit` / `write` with `--tx <id>` append to it; `tx commit` three-way-merges non-overlapping external edits, refuses overlapping ones (`TX_CONFLICT`) without writing, and can run `verify`; `tx rollback` restores all of them. MCP exposes the same lifecycle as `aura_basetools_tx` plus the `transaction` field on edit/write.
95
95
  - **Addressing is structural.** `target` / `lines` / `old` / `expect` / handles replace "copy the literal text exactly"; failure receipts include ready-to-use retry addresses.
96
96
  - **The core is DSH-free.** `aura-basetools` has no runtime dependencies and no DSH imports; the DSH adapter is a separate optional package.
97
97
 
@@ -136,7 +136,7 @@ MIT.
136
136
  ### 安装
137
137
 
138
138
  ```bash
139
- npm i -g aura-basetools@0.16.0
139
+ npm i -g aura-basetools@0.18.0
140
140
  aura --version
141
141
  ```
142
142
 
@@ -206,7 +206,7 @@ npx -y --package aura-basetools aura basetools mcp
206
206
  - **依据过期就拒绝。** 区域级陈旧闸门在“授权读取之后该区域被改过”时拒绝,并指出重读哪几行;`expect` 让目标原文显式化。
207
207
  - **写完要验,不靠相信。** 每次写盘走临时文件、flush、rename,再做逐字节回读;多目标先全量规划,任一失败则一个字节都不写。
208
208
  - **变更可以自证。** `edit` / `write` 接受显式 `verify` 命令;非零退出或超时会把整个事务回滚,回执带命令输出与回滚状态。不自动发现仓库脚本。
209
- - **变更可以跨调用。** `tx begin` 开事务;`edit` / `write --tx <id>` 追加;`tx commit` 先检查文件没被外部改过(`TX_STALE`),可带 `verify`;`tx rollback` 恢复全部。
209
+ - **变更可以跨调用,也可以并发合并。** `tx begin` 开事务;`edit` / `write --tx <id>` 追加;`tx commit` 对外部非重叠改动做三方合并,重叠则 `TX_CONFLICT` 且零写入,可带 `verify`;`tx rollback` 恢复全部。MCP 用同一个生命周期:`aura_basetools_tx` + edit/write 的 `transaction` 字段。
210
210
  - **寻址是结构化的。** `target` / `lines` / `old` / `expect` / 句柄替代“把原文一字不差抄对”;失败回执带可直接用的重试地址。
211
211
  - **核心不依赖 DSH。** `aura-basetools` 零运行时依赖、无 DSH import;DSH 适配器是独立可选包。
212
212
 
package/bin/aura-mcp.mjs CHANGED
@@ -30,6 +30,10 @@ import {
30
30
  // 多目标(fs-contract-v2)的引擎绑定:与单目标同一处(`lib/ops.mjs`),bin 里不另找引擎。
31
31
  readManyWith,
32
32
  editManyEnvelope,
33
+ beginTransactionEnvelope,
34
+ commitTransactionEnvelope,
35
+ rollbackTransactionEnvelope,
36
+ statusTransactionEnvelope,
33
37
  assertReadTargets,
34
38
  assertEditTargets,
35
39
  assertTargetShape,
@@ -204,6 +208,7 @@ const TOOLS = [
204
208
  type: 'string',
205
209
  description: '写后验证命令(显式给出才执行;在 workspace 根下、受超时/输出上限约束)。非零退出 ⇒ 自动回滚并返回 PATCH_VERIFY_FAILED。不自动发现/执行仓库脚本。',
206
210
  },
211
+ transaction: { type: 'string', description: '跨调用事务 id(tx begin 返回);本次修改追加到该事务,不立即 commit' },
207
212
  create: { type: 'string', description: '文件不存在时用它创建(整份内容)' },
208
213
  targets: TARGETS_SCHEMA,
209
214
  },
@@ -245,10 +250,25 @@ const TOOLS = [
245
250
  type: 'string',
246
251
  description: '写后验证命令(显式给出才执行;非零退出 ⇒ 自动回滚并返回 PATCH_VERIFY_FAILED)。',
247
252
  },
253
+ transaction: { type: 'string', description: '跨调用事务 id(tx begin 返回);本次修改追加到该事务,不立即 commit' },
248
254
  },
249
255
  required: ['file_path', 'content'],
250
256
  },
251
257
  },
258
+ {
259
+ name: 'aura_basetools_tx',
260
+ description:
261
+ 'Begin, commit, rollback, or inspect a cross-call transaction. Begin returns an id; pass it to edit/write `transaction` to append changes; commit checks each written file was not changed externally and can run `verify`; rollback restores all files.',
262
+ inputSchema: {
263
+ type: 'object',
264
+ properties: {
265
+ action: { type: 'string', enum: ['begin', 'commit', 'rollback', 'status'] },
266
+ transaction: { type: 'string', description: 'tx begin 返回的事务 id(begin 时不需要)' },
267
+ verify: { type: 'string', description: 'commit 时的写后验证命令;失败 ⇒ 整个事务回滚' },
268
+ },
269
+ required: ['action'],
270
+ },
271
+ },
252
272
  ]
253
273
 
254
274
  /**
@@ -333,6 +353,7 @@ async function editTargetsCall(provider, args) {
333
353
  dryRun: args.dry_run === true,
334
354
  ...(args.require_observation === true ? { requireObservation: true } : {}),
335
355
  ...(typeof args.verify === 'string' ? { verify: args.verify } : {}),
356
+ ...(typeof args.transaction === 'string' ? { transaction: args.transaction } : {}),
336
357
  })
337
358
  }
338
359
 
@@ -383,6 +404,14 @@ async function callTool(provider, name, args = {}) {
383
404
  }
384
405
  }
385
406
  }
407
+ if (name === 'aura_basetools_tx') {
408
+ const action = args.action
409
+ if (action === 'begin') return beginTransactionEnvelope(provider)
410
+ if (action === 'commit') return await commitTransactionEnvelope(provider, { id: args.transaction, verify: args.verify })
411
+ if (action === 'rollback') return await rollbackTransactionEnvelope(provider, { id: args.transaction })
412
+ if (action === 'status') return statusTransactionEnvelope(provider, { id: args.transaction })
413
+ return { ok: false, error: 'aura_basetools_tx needs action = begin | commit | rollback | status', code: 'BAD_ARGS' }
414
+ }
386
415
  if (name === 'aura_basetools_read') {
387
416
  if (multi) return await readTargetsCall(provider, args)
388
417
  const noShape = noShapeRefusal(args, 'aura_basetools_read')
@@ -418,6 +447,7 @@ async function callTool(provider, name, args = {}) {
418
447
  create: creating ? args.create : undefined,
419
448
  ...(args.require_observation === true ? { requireObservation: true } : {}),
420
449
  ...(typeof args.verify === 'string' ? { verify: args.verify } : {}),
450
+ ...(typeof args.transaction === 'string' ? { transaction: args.transaction } : {}),
421
451
  })
422
452
  }
423
453
  if (name === 'aura_basetools_write') {
@@ -429,6 +459,7 @@ async function callTool(provider, name, args = {}) {
429
459
  allowUnobserved: args.allow_unobserved === true,
430
460
  ...(typeof args.eol === 'string' ? { eol: args.eol } : {}),
431
461
  ...(typeof args.verify === 'string' ? { verify: args.verify } : {}),
462
+ ...(typeof args.transaction === 'string' ? { transaction: args.transaction } : {}),
432
463
  })
433
464
  }
434
465
  return { ok: false, error: `unknown tool ${JSON.stringify(name)}`, code: 'UNKNOWN_TOOL' }
package/lib/ops.mjs CHANGED
@@ -99,6 +99,11 @@ export function capabilitiesEnvelope() {
99
99
  dsh: ['stale-region-gate', 'byte-verify'],
100
100
  },
101
101
  },
102
+ {
103
+ name: 'aura_basetools_tx',
104
+ actions: ['begin', 'commit', 'rollback', 'status'],
105
+ options: ['verify'],
106
+ },
102
107
  {
103
108
  name: 'aura_basetools_write',
104
109
  operations: ['create', 'overwrite'],
@@ -723,13 +728,13 @@ export async function commitTransactionEnvelope(provider, { id, verify } = {}) {
723
728
  try {
724
729
  return await withWorkspaceLock(provider.root, async () => {
725
730
  const tx = openTransaction(provider.root, id)
726
- const conflicts = tx.assertFresh(provider)
727
- if (conflicts.length > 0) {
731
+ const merge = tx.mergeOnCommit(provider)
732
+ if (merge.conflicts.length > 0) {
728
733
  return {
729
734
  ok: false,
730
- code: 'TX_STALE',
731
- error: `transaction ${id} has ${conflicts.length} file(s) changed since the transaction wrote them`,
732
- conflicts,
735
+ code: 'TX_CONFLICT',
736
+ error: `transaction ${id} has ${merge.conflicts.length} file(s) that cannot be merged; nothing was written`,
737
+ conflicts: merge.conflicts,
733
738
  transaction: tx.summary(),
734
739
  }
735
740
  }
@@ -749,7 +754,7 @@ export async function commitTransactionEnvelope(provider, { id, verify } = {}) {
749
754
  }
750
755
  }
751
756
  tx.commit()
752
- return { ok: true, transaction: tx.summary(), ...(verification === undefined ? {} : { verification }) }
757
+ return { ok: true, transaction: tx.summary(), merges: merge.merged, ...(verification === undefined ? {} : { verification }) }
753
758
  })
754
759
  } catch (error) {
755
760
  return transactionError(error)
@@ -14,6 +14,7 @@
14
14
  * 这不是安全边界;它只保证遵守本协议的进程不会静默覆盖并发写。
15
15
  */
16
16
  import fs from 'node:fs'
17
+ import { spawnSync } from 'node:child_process'
17
18
  import os from 'node:os'
18
19
  import path from 'node:path'
19
20
  import crypto from 'node:crypto'
@@ -108,6 +109,8 @@ function fileSummary(entry) {
108
109
  written: entry.written,
109
110
  afterVersion: entry.afterVersion ?? null,
110
111
  backup: entry.backup ?? null,
112
+ writtenBackup: entry.writtenBackup ?? null,
113
+ rollbackBackup: entry.rollbackBackup ?? null,
111
114
  }
112
115
  }
113
116
 
@@ -117,6 +120,53 @@ function fileSummary(entry) {
117
120
  * @param root - 工作区根
118
121
  * @param tool - 产生它的工具名(仅记录)
119
122
  */
123
+ function utf8Text(buf) {
124
+ const text = buf.toString('utf8')
125
+ if (!Buffer.from(text, 'utf8').equals(buf)) return null
126
+ if (text.includes('\u0000')) return null
127
+ return text
128
+ }
129
+
130
+ /**
131
+ * 2-way fast path + `git merge-file` 的三方合并。
132
+ *
133
+ * 文字层合并,不碰二进制;一方没变时直接取另一方,避免为无关文件启动 git。
134
+ * Git 不可用或冲突时返回 conflict,调用方拒绝提交,不写任何文件。
135
+ */
136
+ function mergeText(baseBuf, oursBuf, theirsBuf, tmpDir) {
137
+ const base = utf8Text(baseBuf)
138
+ const ours = utf8Text(oursBuf)
139
+ const theirs = utf8Text(theirsBuf)
140
+ if (base === null || ours === null || theirs === null) return { ok: false, reason: 'not-utf8' }
141
+ if (ours === base) return { ok: true, bytes: theirsBuf }
142
+ if (theirs === base) return { ok: true, bytes: oursBuf }
143
+ ensureDir(tmpDir)
144
+ const stem = `${process.pid}-${crypto.randomBytes(4).toString('hex')}`
145
+ const baseFile = path.join(tmpDir, `${stem}.base`)
146
+ const oursFile = path.join(tmpDir, `${stem}.ours`)
147
+ const theirsFile = path.join(tmpDir, `${stem}.theirs`)
148
+ try {
149
+ fs.writeFileSync(baseFile, baseBuf)
150
+ fs.writeFileSync(oursFile, oursBuf)
151
+ fs.writeFileSync(theirsFile, theirsBuf)
152
+ const res = spawnSync('git', ['merge-file', '-p', '--diff3', oursFile, baseFile, theirsFile], {
153
+ encoding: null,
154
+ maxBuffer: 16 * 1024 * 1024,
155
+ windowsHide: true,
156
+ })
157
+ if (res.error?.code === 'ENOENT') return { ok: false, reason: 'git-unavailable' }
158
+ if (res.status === 0) return { ok: true, bytes: Buffer.from(res.stdout) }
159
+ if (res.status === 1) {
160
+ return { ok: false, reason: 'overlap', conflictText: Buffer.from(res.stdout).toString('utf8').slice(0, 4000) }
161
+ }
162
+ return { ok: false, reason: 'git-error', exitCode: res.status, stderr: String(res.stderr ?? '').slice(0, 500) }
163
+ } finally {
164
+ for (const file of [baseFile, oursFile, theirsFile]) {
165
+ try { fs.rmSync(file, { force: true }) } catch { /* 临时文件清不掉不影响判定 */ }
166
+ }
167
+ }
168
+ }
169
+
120
170
  function makeTransaction(root, meta, dir) {
121
171
  const entries = new Map()
122
172
  for (let i = 0; i < meta.files.length; i += 1) {
@@ -177,16 +227,77 @@ function makeTransaction(root, meta, dir) {
177
227
  const result = await provider.writeText(target, content, expected)
178
228
  entry.written = true
179
229
  entry.afterVersion = result?.version ?? null
230
+ const writtenPath = path.join('written', `${entry.metaIndex}.bin`)
231
+ writeBytesAtomic(path.join(dir, writtenPath), fs.readFileSync(entry.realPath), 0o600)
232
+ entry.writtenBackup = writtenPath
180
233
  const summary = meta.files[entry.metaIndex]
181
234
  if (summary !== undefined) {
182
235
  summary.written = true
183
236
  summary.afterVersion = entry.afterVersion
237
+ summary.writtenBackup = entry.writtenBackup
184
238
  }
185
239
  saveMeta(meta)
186
240
  return result
187
241
  }
188
242
  return proxy
189
243
  },
244
+ /**
245
+ * 提交前的并发合并:事务写过的文件若被外部改过,尝试把我们的 diff 三方合并到当前内容。
246
+ * **先规划所有文件,全部可合并才写;任一冲突 ⇒ 零写入**,返回 conflicts 供调用方拒绝。
247
+ */
248
+ mergeOnCommit(provider) {
249
+ const plans = []
250
+ const conflicts = []
251
+ for (const entry of entries.values()) {
252
+ if (!entry.written) continue
253
+ let current
254
+ try { current = provider.stat(entry.path) } catch { current = undefined }
255
+ if (entry.afterVersion !== null && current?.version === entry.afterVersion) continue
256
+ if (!entry.writtenBackup || !entry.backup) {
257
+ conflicts.push({ path: entry.path, reason: 'no-merge-base' })
258
+ continue
259
+ }
260
+ let baseBytes
261
+ let oursBytes
262
+ let theirsBytes
263
+ try {
264
+ baseBytes = fs.readFileSync(path.join(dir, entry.backup))
265
+ oursBytes = fs.readFileSync(path.join(dir, entry.writtenBackup))
266
+ theirsBytes = fs.readFileSync(entry.realPath)
267
+ } catch (error) {
268
+ conflicts.push({ path: entry.path, reason: String(error?.message ?? error) })
269
+ continue
270
+ }
271
+ const merged = mergeText(baseBytes, oursBytes, theirsBytes, path.join(dir, 'merge-tmp'))
272
+ if (!merged.ok) {
273
+ conflicts.push({ path: entry.path, reason: merged.reason, ...(merged.conflictText === undefined ? {} : { conflictText: merged.conflictText }), ...(merged.exitCode === undefined ? {} : { exitCode: merged.exitCode }) })
274
+ continue
275
+ }
276
+ plans.push({ entry, theirsBytes, bytes: merged.bytes })
277
+ }
278
+ if (conflicts.length > 0) return { merged: [], conflicts }
279
+ const merged = []
280
+ for (const plan of plans) {
281
+ const { entry } = plan
282
+ const rollbackBackup = path.join('rollback', `${entry.metaIndex}.bin`)
283
+ writeBytesAtomic(path.join(dir, rollbackBackup), plan.theirsBytes, 0o600)
284
+ entry.rollbackBackup = rollbackBackup
285
+ writeBytesAtomic(entry.realPath, plan.bytes, entry.mode ?? 0o600)
286
+ const writtenBackup = path.join('written', `${entry.metaIndex}.bin`)
287
+ writeBytesAtomic(path.join(dir, writtenBackup), plan.bytes, 0o600)
288
+ entry.writtenBackup = writtenBackup
289
+ entry.afterVersion = provider.stat(entry.path)?.version ?? null
290
+ const summary = meta.files[entry.metaIndex]
291
+ if (summary !== undefined) {
292
+ summary.afterVersion = entry.afterVersion
293
+ summary.writtenBackup = entry.writtenBackup
294
+ summary.rollbackBackup = entry.rollbackBackup
295
+ }
296
+ merged.push({ path: entry.path, merged: true })
297
+ }
298
+ if (merged.length > 0) saveMeta(meta)
299
+ return { merged, conflicts: [] }
300
+ },
190
301
  /** 交叉调用提交前的乐观校验:事务写过的文件必须仍停在 afterVersion。 */
191
302
  assertFresh(provider) {
192
303
  const conflicts = []
@@ -227,7 +338,8 @@ function makeTransaction(root, meta, dir) {
227
338
  }
228
339
  try {
229
340
  if (entry.existed) {
230
- writeBytesAtomic(entry.realPath, fs.readFileSync(path.join(dir, entry.backup)), entry.mode ?? 0o600)
341
+ const backup = entry.rollbackBackup ?? entry.backup
342
+ writeBytesAtomic(entry.realPath, fs.readFileSync(path.join(dir, backup)), entry.mode ?? 0o600)
231
343
  } else {
232
344
  fs.rmSync(entry.realPath, { force: true })
233
345
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aura-basetools",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Byte-exact read, edit and write tools for coding agents. Standalone CLI and MCP server; no DSH required. 面向 coding agent 的逐字节读写工具;独立 CLI / MCP,可选 DSH 适配器。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -385,7 +385,7 @@ say('F MCP:initialize / tools/list / tools/call')
385
385
  })()
386
386
  if (manifest !== null) check(byId.get(1)?.result?.serverInfo?.version === manifest.version, `serverInfo.version 跟包一致(${manifest.version})`, String(byId.get(1)?.result?.serverInfo?.version))
387
387
  const tools = byId.get(2)?.result?.tools ?? []
388
- check(tools.length === 3 && tools.some((t) => t.name === 'aura_basetools_read') && tools.some((t) => t.name === 'aura_basetools_edit') && tools.some((t) => t.name === 'aura_basetools_write'), `tools/list 三个工具(${tools.map((t) => t.name).join(',')}`)
388
+ check(tools.length === 4 && tools.some((t) => t.name === 'aura_basetools_read') && tools.some((t) => t.name === 'aura_basetools_edit') && tools.some((t) => t.name === 'aura_basetools_write') && tools.some((t) => t.name === 'aura_basetools_tx'), `tools/list 四个工具(${tools.map((t) => t.name).join(',')}`)
389
389
  const readEnv = JSON.parse(byId.get(3)?.result?.content?.[0]?.text ?? '{}')
390
390
  check(readEnv.ok === true && Array.isArray(readEnv.symbols ?? []), 'aura_basetools_read 经 MCP 可用', JSON.stringify(readEnv).slice(0, 120))
391
391
  const editEnv = JSON.parse(byId.get(4)?.result?.content?.[0]?.text ?? '{}')
@@ -210,7 +210,7 @@ say('C MCP:aura_basetools_write 声明面与调用面')
210
210
 
211
211
  const tools = byId.get(2)?.result?.tools ?? []
212
212
  const writeTool = tools.find((tool) => tool.name === 'aura_basetools_write')
213
- check(tools.length === 3 && writeTool !== undefined, `C1 tools/list 三个工具且含 aura_basetools_write(${tools.map((tool) => tool.name).join(',')})`)
213
+ check(tools.length === 4 && writeTool !== undefined, `C1 tools/list 四个工具且含 aura_basetools_write(${tools.map((tool) => tool.name).join(',')})`)
214
214
  check(JSON.stringify(writeTool?.inputSchema?.required) === JSON.stringify(['file_path', 'content']), 'C2 aura_basetools_write schema 必填 file_path + content', JSON.stringify(writeTool?.inputSchema?.required))
215
215
  check(envOf(3).ok === true && envOf(3).result?.operation === 'create', 'C3 经 MCP 新建成功', JSON.stringify(envOf(3)).slice(0, 160))
216
216
  check(fs.readFileSync(file('mcp-created.txt'), 'utf8') === 'mcp\ncreate\n', 'C4 MCP 新建的内容真的落盘')