aura-basetools-dsh 0.7.0 → 0.13.1

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.
@@ -29,8 +29,8 @@ import { locate, directoryHint } from 'aura-basetools/locator-lab/locator-api.mj
29
29
  import { normSpace } from 'aura-basetools/locator-lab/anchor-core.mjs'
30
30
  // 单个 hunk 的“平铺形状”容错**只有一份**(住在 args.mjs,与行区间收敛同一层):
31
31
  // DSH 壳与 CLI 共用它。同一套容错规则写两处,就一定会有一处与另一处不一致。
32
- import { assertEditTargets, assertKnownArgs, assertTargetShape, flatHunk } from 'aura-basetools/locator-lab/args.mjs'
33
- // BOM 的字符形态与字节判据都住在引擎里:读路径(aura_read 的令牌)与写路径(这里)共用一份,
32
+ import { MT_EDIT_ELEMENT_KEYS, assertEditTargets, assertKnownArgs, assertTargetShape, flatHunk } from 'aura-basetools/locator-lab/args.mjs'
33
+ // BOM 的字符形态与字节判据都住在引擎里:读路径(aura_basetools_read 的令牌)与写路径(这里)共用一份,
34
34
  // 否则"令牌说没 BOM、写回却带 BOM"这类不自洽会两处各修一遍。
35
35
  import {
36
36
  BYTE_ORDER_MARK,
@@ -53,6 +53,8 @@ import {
53
53
  regionTextIn,
54
54
  joinLinesWithEndings,
55
55
  planHunks,
56
+ // 0.10.3:严格新鲜度(read-before-write)。判据在引擎里,壳层只把宿主的事实递进去。
57
+ requireObservedRevision,
56
58
  // C49:「已落地」的**判据本体**(`old` 两层都不在 + `replace` 整行命中且全文唯一 + 去空白 ≥8 字符)。
57
59
  // 与 `planHunks` 同源、同住引擎:壳层只负责把它接进 outcome 通道,**不在这里写第二份判据**——
58
60
  // 引擎与壳层各写一份「已经改好了吗」的判断,就是两个判决面(本文件已被这类分叉坑过一次)。
@@ -71,6 +73,7 @@ import {
71
73
  // fs-contract-v2 §2.2/§3:多目标写侧的**唯一入口**。三阶段提交(全量规划 →
72
74
  // 全部可落才提交 → 回读校验)与逐项回执都在引擎里,壳层只把 `ctx.fs` 接成它要的 provider
73
75
  // 并把逐项回执渲染出来——本文件里**不写第二份**提交纪律。
76
+ applyEditToProvider,
74
77
  applyEditsToProvider,
75
78
  } from 'aura-basetools/locator-lab/write-path.mjs'
76
79
 
@@ -101,7 +104,19 @@ const HUNK_PROPERTIES = {
101
104
  description:
102
105
  'Symbol to replace, e.g. `toTodoList`, `function:apply`, `Outer/Inner`, `name#2`. Skips all text matching, so it is immune to misremembered content.',
103
106
  },
104
- // 与 aura_read 同一套宽容声明:模型把区间写成 `4, 6` 时那不是合法 JSON(宿主在参数层就拒),
107
+ /**
108
+ * 意图地址(T16):调用方给一段**模糊**的意图串,引擎确定性地落到一个精确区间。
109
+ *
110
+ * 这是比 `target` 更弱、比 `anchor` 更自由的一层:它先当符号名解析(含 `name#n` 等写法),
111
+ * 再按符号名子串匹配,最后落到文本匹配。落不下来就报歧义并给候选,**不猜**。
112
+ * 它与其余地址字段互斥(`PATCH_INTENT_CONFLICT`):同时给两个地址时谁说了算没有正确答案。
113
+ */
114
+ intent: {
115
+ type: 'string',
116
+ description:
117
+ 'Fuzzy intent address: a symbol-name fragment, a phrase, or approximate text (e.g. `parseConfig`, `config loader`, `// retry the upload`). Resolved deterministically to one exact region; an ambiguous intent is refused with candidates. Use it alone (not with target/lines/line_cue/anchor/old/context/handle); `near` and `expect` are allowed.',
118
+ },
119
+ // 与 aura_basetools_read 同一套宽容声明:模型把区间写成 `4, 6` 时那不是合法 JSON(宿主在参数层就拒),
105
120
  // 写成 `"4,6"` / `4` 则是合法 JSON 但形状不对——声明宽一点,能容的都容下来。
106
121
  lines: {
107
122
  oneOf: [{ type: 'array' }, { type: 'string' }, { type: 'integer' }, { type: 'object', additionalProperties: true }],
@@ -156,6 +171,17 @@ const HUNK_PROPERTIES = {
156
171
  required: true,
157
172
  description: 'Replacement text. Empty string deletes the addressed region.',
158
173
  },
174
+ replace_all: {
175
+ type: 'boolean',
176
+ description:
177
+ 'With `old`: replace every non-overlapping occurrence, not just a unique one. Left-to-right; cannot be combined with target/intent/lines/line_cue/anchor/context/handle/near (those narrow to one place). If the occurrence count changed since your last read, the whole patch is refused.',
178
+ },
179
+ eol: {
180
+ type: 'string',
181
+ enum: ['auto', 'preserve', 'lf', 'crlf'],
182
+ description:
183
+ 'Line-ending style for the replacement. `auto` (default) matches the addressed region (CRLF file => CRLF, no mixed endings); `preserve` keeps the replacement exactly as written; `lf`/`crlf` force one style.',
184
+ },
159
185
  expect: {
160
186
  type: 'string',
161
187
  description:
@@ -179,7 +205,7 @@ const HUNK_PROPERTIES = {
179
205
  handle: {
180
206
  type: 'string',
181
207
  description:
182
- 'Opaque region handle handed out by `aura_read` (e.g. `h9f3ac1b2e4d5:120-160`) — the byte-exact region the tool read, so you never have to retype that text. Combine with `line` or `position` to address inside or beside the region. The handle wins over `old`/coordinates; whatever gets ignored is reported back in the receipt.',
208
+ 'Opaque region handle handed out by `aura_basetools_read` (e.g. `h9f3ac1b2e4d5:120-160`) — the byte-exact region the tool read, so you never have to retype that text. Combine with `line` or `position` to address inside or beside the region. The handle wins over `old`/coordinates; whatever gets ignored is reported back in the receipt.',
183
209
  },
184
210
  /**
185
211
  * 段内**相对**定位:区间内第 k 行(1-based,相对区间起点)——整段替换与"只换其中一行"
@@ -251,11 +277,11 @@ function contentFlatHunk(source) {
251
277
  * 算不算给了"的判断一旦不同,同一个入参在两种形状下会得到不同结果。
252
278
  */
253
279
  const hunk = flatHunk(source) ?? {}
254
- for (const key of ['replace', 'expect', 'near']) {
280
+ for (const key of ['replace', 'expect', 'near', 'eol', 'replace_all']) {
255
281
  if (hunk[key] !== undefined) continue
256
282
  const value = source[key]
257
283
  if (value === undefined || value === null) continue
258
- if (typeof value === 'string' && value.length === 0) continue
284
+ if (typeof value === 'string' && value.length === 0 && key !== 'replace') continue
259
285
  hunk[key] = value
260
286
  }
261
287
  if (hasOld) hunk.old = source.old
@@ -280,7 +306,7 @@ function hunkListOf(args) {
280
306
  * 名单在这里是为了"被忽略的一方"能被逐字点名——不是第二份寻址判据:
281
307
  * 谁真的生效由引擎决定(`write-path.mjs`),这里只报告"调用方还给了这些"。
282
308
  */
283
- const ADDRESS_KEYS_BESIDE_HANDLE = ['old', 'context', 'lines', 'start_line', 'end_line', 'line_cue', 'anchor', 'target']
309
+ const ADDRESS_KEYS_BESIDE_HANDLE = ['old', 'context', 'lines', 'start_line', 'end_line', 'line_cue', 'anchor', 'target', 'intent']
284
310
 
285
311
  /** 这个键这次**真的给了**吗(判据与 `flatHunk` 一致:跳过 undefined / null / 空串 / 空数组)。 */
286
312
  function gaveKey(source, key) {
@@ -342,12 +368,24 @@ export const EDIT_ARG_KEYS = [
342
368
  'hunks',
343
369
  'dry_run',
344
370
  'create',
371
+ 'require_observation',
345
372
  // fs-contract-v2 §2.2:多目标形状。它与 `file_path`/`hunks` **互斥**——
346
373
  // 判据只有一份(引擎与壳共调 `assertEditTargets`),这里只登记名字。
347
374
  'targets',
348
375
  ...Object.keys(HUNK_PROPERTIES),
349
376
  ]
350
377
 
378
+ /**
379
+ * `aura_basetools_write` 的顶层参数名(唯一一份,供未知参数守卫用)。
380
+ *
381
+ * 它与 `aura_basetools_edit` 是两个工具:这里没有 hunks/地址字段,只有“整份内容 + 落盘选项”。
382
+ * 未知键必须拒——拼错的 `content`/`file_path` 被静默忽略时,模型会看到一次成功。
383
+ */
384
+ export const WRITE_ARG_KEYS = ['file_path', 'content', 'dry_run', 'no_overwrite', 'allow_unobserved', 'eol']
385
+
386
+ /** `aura_basetools_write` 的 eol 模式(与 CLI/MCP/引擎同一组)。 */
387
+ const WRITE_EOL_MODES = ['auto', 'preserve', 'lf', 'crlf']
388
+
351
389
  export const Config = z.object({})
352
390
 
353
391
  // ── 与 dsh-tool-fs 一致的观测契约 ──────────────────────────────────────────
@@ -519,10 +557,23 @@ const OUTCOME_BY_CODE = new Map([
519
557
  ['LOCATOR_ASSERT_MISMATCH', 'not_found'],
520
558
  ['LOCATOR_OUT_OF_RANGE', 'not_found'],
521
559
  ['LOCATOR_BAD_LINES', 'not_found'],
560
+ // 共享行 / lone CR:地址面明确拒绝(不是工具故障);共享行给候选,属 needs_choice。
561
+ ['LOCATOR_LINE_SHARED', 'needs_choice'],
562
+ ['LOCATOR_LONE_CR', 'not_found'],
563
+ // 符号层(`resolveTarget`)的两种失败:它们与上面是同一类“地址对不上”,
564
+ // 必须走结果通道并带 next/候选;漏进工具故障会让模型只看到一句 error.message。
565
+ ['STRUCT_NOT_FOUND', 'not_found'],
566
+ ['STRUCT_AMBIGUOUS', 'needs_choice'],
567
+ // 符号跨度不完整 / 文件类型未知:可判定、可恢复,不是工具坏了。
568
+ ['LOCATOR_SPAN_INCOMPLETE', 'not_found'],
569
+ ['LOCATOR_NO_LANGUAGE', 'not_found'],
522
570
  // 需要调用方从候选里选一个
523
571
  ['PATCH_AMBIGUOUS', 'needs_choice'],
524
572
  ['LOCATOR_AMBIGUOUS', 'needs_choice'],
525
573
  ['LOCATOR_CUE_AMBIGUOUS', 'needs_choice'],
574
+ // 意图地址(T16):落不下来时**拒绝并给候选**(不猜),没命中时给可执行的替代地址。
575
+ ['LOCATOR_INTENT_AMBIGUOUS', 'needs_choice'],
576
+ ['LOCATOR_INTENT_NOT_FOUND', 'not_found'],
526
577
  /**
527
578
  * 句柄路的四种**如实拒绝**(契约 §5 解析次序 / §9 的 D6):句柄过期、身份标签不符、
528
579
  * 段内行号越界、`{handle, old}` 而引文不在该区间内。
@@ -621,7 +672,7 @@ const NEXT_BY_OUTCOME = {
621
672
  * 引擎的 `PATCH_NOT_FOUND` 错误对象上确实带一句 `next`,内容是「重读文件、按那里的实际
622
673
  * 内容重新寻址」——那是 `not_found` 的恢复动作。到这一态它已经**不成立**了:没有要改的东西。
623
674
  * 照抄它等于把「这次不用改」重新卖成「去改点什么」,而模型会真去改。
624
- * 实测(本轮探针):`PATCH_NOT_FOUND` 的 `error.next` = "re-read the file (aura_read) and pass
675
+ * 实测(本轮探针):`PATCH_NOT_FOUND` 的 `error.next` = "re-read the file (aura_basetools_read) and pass
625
676
  * the text exactly as it is there — no line of your old text is in this file"。
626
677
  */
627
678
  already_applied:
@@ -629,7 +680,7 @@ const NEXT_BY_OUTCOME = {
629
680
  needs_choice:
630
681
  'more than one place matches — pick one of the candidate lines and re-send that hunk addressed by `start_line` + `end_line`, or add `old`/`context` to say which one you mean',
631
682
  not_found:
632
- 'the addressed text is not in the file as it is now — re-read it (aura_read), then address what is actually there',
683
+ 'the addressed text is not in the file as it is now — re-read it (aura_basetools_read), then address what is actually there',
633
684
  }
634
685
 
635
686
  /**
@@ -863,7 +914,7 @@ function renderResult(value) {
863
914
  const short = one.length <= 64
864
915
  return multi || !short ? `${JSON.stringify(short ? one : `${one.slice(0, 64)}…`)}${multi ? ` +${t.split('\n').length - 1} more line(s)` : ''}` : JSON.stringify(one)
865
916
  }
866
- const after = a.replaced ?? ''
917
+ const after = a.after ?? a.replaced ?? ''
867
918
  // 单行 → 单行的替换,把 before → after 都给出;否则只给改后,避免刷屏
868
919
  const singleLineSwap = !a.actual.includes('\n') && !after.includes('\n')
869
920
  lines.push(
@@ -871,6 +922,16 @@ function renderResult(value) {
871
922
  ? ` line ${a.line} [${flags}] ${clip(a.actual)} → ${clip(after)}`
872
923
  : ` line ${a.line} [${flags}] now: ${clip(after)}`,
873
924
  )
925
+ /**
926
+ * 0.8.1:改后坐标与可复用句柄。`line` 是改前坐标;下一处同一区域的编辑不必回读,
927
+ * 直接拿这里给的句柄(或 afterLine)定位即可。
928
+ */
929
+ if (a.afterLine !== undefined) {
930
+ const delta = a.delta === 0 ? 'same line count' : `${a.delta > 0 ? '+' : ''}${a.delta} line(s)`
931
+ const handle = a.handle === undefined ? '' : `, handle ${a.handle}`
932
+ const truncated = a.afterTruncated === true ? ', after truncated' : ''
933
+ lines.push(` now lines ${a.afterLine}-${a.afterEndLine} (${delta})${handle}${truncated}`)
934
+ }
874
935
  }
875
936
  if (value.applied.some((a) => FUZZY_TIERS.has(a.tier))) {
876
937
  lines.push(' (a hunk matched by fallback — confirm the quoted text is the region you meant.)')
@@ -1041,6 +1102,7 @@ async function applyEditTargets(ctx, exec, args, targets, policy) {
1041
1102
  const batch = await applyEditsToProvider(provider, {
1042
1103
  targets,
1043
1104
  ...(args.dry_run === undefined ? {} : { dryRun: args.dry_run }),
1105
+ ...(args.require_observation === true ? { requireObservation: true } : {}),
1044
1106
  ...(signal === undefined ? {} : { signal }),
1045
1107
  })
1046
1108
  const elements = Array.isArray(args.targets) ? args.targets : []
@@ -1070,7 +1132,7 @@ async function applyEditTargets(ctx, exec, args, targets, policy) {
1070
1132
  // ── 插件 ────────────────────────────────────────────────────────────────────
1071
1133
 
1072
1134
  /**
1073
- * 注册 `aura_edit` 工具,以及配套的提示段。
1135
+ * 注册 `aura_basetools_edit` 工具,以及配套的提示段。
1074
1136
  *
1075
1137
  * ## 为什么必须同时注册提示段
1076
1138
  *
@@ -1114,24 +1176,198 @@ export function apply(ctx) {
1114
1176
  function registerTools(ctx, sandbox) {
1115
1177
  // 与 dsh-tool-fs 一致:按 scope 检查工具可见性。`systemPrompt` 已声明进 inject,
1116
1178
  // 因此直接访问——用 `ctx.get` 兜会静默跳过,而"工具注册了却没有指导"正是踩过的坑。
1179
+ /**
1180
+ * `aura_basetools_write`:整份新建/覆写。
1181
+ *
1182
+ * 与 `aura_basetools_edit` 同一个插件、同一套 `ctx.fs` 契约;差别只在入口语义——它没有 hunk/地址,
1183
+ * 只接“整份内容 + 落盘选项”。注册顺序放在 `aura_basetools_edit` **之前**:`load-test` 等套件用
1184
+ * “最后一次 register”取 `aura_basetools_edit`,保持它们不动;`real-framework-smoke` 按名字找。
1185
+ */
1186
+ ctx.systemPrompt.section({
1187
+ name: 'tool:aura-write',
1188
+ order: 1319,
1189
+ text: ({ scope }) =>
1190
+ ctx.tools.get('aura_basetools_write', scope) === undefined
1191
+ ? ''
1192
+ : [
1193
+ 'Use the aura_basetools_write tool to create a new file or replace a whole file in one call — not the built-in write.',
1194
+ 'For any partial change use aura_basetools_edit: it preserves every untouched byte and has addressing / expect guards.',
1195
+ 'Overwriting requires the current revision to have been read (aura_basetools_read) in this session; set `allow_unobserved` only when you deliberately accept a blind overwrite.',
1196
+ '`dry_run` previews without writing, `no_overwrite` refuses an existing file, and `eol` controls line endings (`auto` follows the file, `preserve` writes the content exactly, `lf` / `crlf` force one style).',
1197
+ ].join(' '),
1198
+ })
1199
+
1200
+ ctx.tools.register(
1201
+ defineTool({
1202
+ name: 'aura_basetools_write',
1203
+ description:
1204
+ 'Create or replace a whole UTF-8 text file in one call. It preserves a leading BOM and (with `eol: "auto"`) the file\'s line-ending style, writes through the host filesystem with a version check, and reads the file back to verify. ' +
1205
+ 'For partial changes use `aura_basetools_edit` instead — it preserves every byte you did not address. ' +
1206
+ 'Overwriting an existing file requires the current revision to have been read (aura_basetools_read) in this session; `allow_unobserved: true` is the explicit blind-write escape hatch. ' +
1207
+ '`dry_run` previews without writing; `no_overwrite` refuses an existing file; `eol` is auto | preserve | lf | crlf.',
1208
+ parameters: {
1209
+ file_path: { type: 'string', required: true, description: 'Path to create or replace, resolved by the filesystem backend.' },
1210
+ content: { type: 'string', required: true, description: 'The complete file content. Empty string writes an empty file.' },
1211
+ dry_run: { type: 'boolean', description: 'Plan and validate without touching the file; the receipt says create/write and dryRun=true.' },
1212
+ no_overwrite: { type: 'boolean', description: 'Refuse when the file already exists (WRITE_EXISTS) instead of replacing it.' },
1213
+ allow_unobserved: {
1214
+ type: 'boolean',
1215
+ description: 'Allow overwriting a revision this session has not read (or that changed since it was read). The receipt is marked unverified_region.',
1216
+ },
1217
+ eol: {
1218
+ type: 'string',
1219
+ enum: WRITE_EOL_MODES,
1220
+ description: 'Line-ending policy for an overwrite: auto (default) follows the existing file; preserve writes content exactly; lf/crlf force one style. A new file is always written exactly as given.',
1221
+ },
1222
+ },
1223
+ additionalProperties: false,
1224
+ output: {
1225
+ schema: {
1226
+ type: 'object',
1227
+ additionalProperties: false,
1228
+ properties: {
1229
+ path: { type: 'string', required: true },
1230
+ operation: { type: 'string', enum: ['create', 'write'], required: true },
1231
+ // 与 aura_basetools_edit 共用 OUTCOMES 里的成功态;create/write 的区分在 operation。
1232
+ outcome: { type: 'string', enum: ['applied'], required: true },
1233
+ dryRun: { type: 'boolean', required: true },
1234
+ bytes: { type: 'integer' },
1235
+ unverified_region: { type: 'boolean' },
1236
+ write_verify: {
1237
+ type: 'object',
1238
+ additionalProperties: false,
1239
+ properties: {
1240
+ level: { type: 'string', required: true },
1241
+ bytes: { type: 'integer' },
1242
+ chars: { type: 'integer' },
1243
+ },
1244
+ },
1245
+ },
1246
+ },
1247
+ render: (_args, value) => {
1248
+ const planned = value.dryRun === true ? 'would ' : ''
1249
+ const bytes = typeof value.bytes === 'number' ? `, ${value.bytes} bytes` : ''
1250
+ const warning =
1251
+ value.unverified_region === true
1252
+ ? '\nunverified_region: this revision was not read in this session; the write was allowed only because allow_unobserved was set.'
1253
+ : ''
1254
+ return [{ type: 'text', text: `${value.path} — ${planned}${value.operation}${bytes}${warning}` }]
1255
+ },
1256
+ },
1257
+ async execute(args, exec) {
1258
+ assertKnownArgs(args, WRITE_ARG_KEYS, 'aura_basetools_write')
1259
+ if (typeof args.file_path !== 'string' || args.file_path.trim().length === 0) {
1260
+ throw new Error('file_path must be a non-empty string')
1261
+ }
1262
+ if (typeof args.content !== 'string') throw new Error('content must be a string')
1263
+ const eol = args.eol ?? 'auto'
1264
+ if (!WRITE_EOL_MODES.includes(eol)) {
1265
+ throw new PatchError('write `eol` must be auto, preserve, lf, or crlf — nothing was written', 'WRITE_BAD_ARGS')
1266
+ }
1267
+ const policyService = sandbox()
1268
+ const policy =
1269
+ policyService === undefined
1270
+ ? undefined
1271
+ : policyService.resolve(exec.agent === undefined ? {} : { session: exec.agent.session })
1272
+ const cwd = policy?.workspaceRoot ?? exec.agent?.session?.header?.cwd
1273
+ const target = await ctx.fs.resolve(args.file_path, {
1274
+ ...(cwd !== undefined ? { cwd } : {}),
1275
+ signal: exec.signal,
1276
+ })
1277
+ const info = await ctx.fs.stat(target, exec.signal)
1278
+ if (info !== undefined && args.no_overwrite === true) {
1279
+ throw new PatchError(`cannot write "${target.displayPath}": it already exists (no-overwrite)`, 'WRITE_EXISTS')
1280
+ }
1281
+ if (info === undefined && eol !== 'auto') {
1282
+ throw new PatchError(
1283
+ 'write `eol` applies when overwriting an existing file; a new file is written exactly as given — pass the endings you want in `content`',
1284
+ 'WRITE_BAD_ARGS',
1285
+ )
1286
+ }
1287
+ // 与 aura_basetools_edit 同一条观察契约:写之前先告诉策略我们看见了什么(absent / present + version)。
1288
+ ctx.emit(
1289
+ 'fs/observed',
1290
+ target,
1291
+ info === undefined ? { kind: 'absent' } : { kind: 'present', version: info.version },
1292
+ exec,
1293
+ )
1294
+ let writtenVersion
1295
+ const provider = {
1296
+ stat: async (_displayPath, at) => ctx.fs.stat(target, at ?? exec.signal),
1297
+ readText: async (_displayPath, at) => ctx.fs.readText(target, at ?? exec.signal),
1298
+ readByteRange: async (_displayPath, range, at) =>
1299
+ ctx.fs.readByteRange(target, { offset: range.offset, length: range.length }, at ?? exec.signal),
1300
+ writeText: async (_displayPath, content, expected, at) => {
1301
+ const out = await ctx.fs.writeText(target, content, expected, at ?? exec.signal, policy)
1302
+ writtenVersion = out?.version
1303
+ return out
1304
+ },
1305
+ }
1306
+ let receipt
1307
+ try {
1308
+ receipt = await applyEditToProvider(provider, {
1309
+ displayPath: target.displayPath,
1310
+ hunks: [],
1311
+ ...(info === undefined
1312
+ ? { create: args.content }
1313
+ : {
1314
+ wholeFile: args.content,
1315
+ eol,
1316
+ // 覆写默认要求先读过这一版;allow_unobserved 同时越过严格闸与区域闸。
1317
+ requireObservation: args.allow_unobserved !== true,
1318
+ skipStaleGate: args.allow_unobserved === true,
1319
+ }),
1320
+ dryRun: args.dry_run === true,
1321
+ ...(typeof target.targetKey === 'string' && target.targetKey.length > 0
1322
+ ? { identityKey: target.targetKey }
1323
+ : {}),
1324
+ ...(exec.signal === undefined ? {} : { signal: exec.signal }),
1325
+ })
1326
+ } catch (error) {
1327
+ if (!isNonUtf8TextFailure(error)) throw error
1328
+ throw Object.assign(new Error(nonUtf8RefusalMessage('write', target.displayPath, 'aura_basetools_write')), {
1329
+ code: typeof error.code === 'string' && error.code.length > 0 ? error.code : 'ERR_ENCODING_INVALID_ENCODED_DATA',
1330
+ cause: error,
1331
+ })
1332
+ }
1333
+ if (receipt.dryRun !== true && writtenVersion !== undefined) {
1334
+ ctx.emit('fs/observed', target, { kind: 'present', version: writtenVersion }, exec)
1335
+ }
1336
+ const operation = receipt.operation ?? (info === undefined ? 'create' : 'write')
1337
+ const verify = receipt.write_verify
1338
+ return {
1339
+ path: target.displayPath,
1340
+ operation,
1341
+ outcome: 'applied',
1342
+ dryRun: receipt.dryRun === true,
1343
+ ...(verify?.bytes === undefined ? {} : { bytes: verify.bytes }),
1344
+ ...(receipt.unverified_region === true ? { unverified_region: true } : {}),
1345
+ ...(verify === undefined ? {} : { write_verify: verify }),
1346
+ }
1347
+ },
1348
+ }),
1349
+ )
1350
+
1117
1351
  ctx.systemPrompt.section({
1118
1352
  name: 'tool:aura-edit',
1119
1353
  order: 1320,
1120
1354
  text: ({ scope }) =>
1121
- ctx.tools.get('aura_edit', scope) === undefined
1355
+ ctx.tools.get('aura_basetools_edit', scope) === undefined
1122
1356
  ? ''
1123
1357
  : [
1124
1358
  // 措辞刻意与内置指导**同形**。这一点是量出来的:
1125
1359
  // 内置注入的是命令式——"Use the edit tool for targeted changes to existing UTF-8 text files",
1126
- // 而我先前写的是建议式——"prefer aura_edit over edit"。
1360
+ // 而我先前写的是建议式——"prefer aura_basetools_edit over edit"。
1127
1361
  // 实测同一份建议式指导,新建会话里 4 次选了 Aura、2 次选了内置——**它在跟先验拔河**。
1128
1362
  // 语气不对等,就是在让一条软建议去对抗一条硬指令。
1129
1363
  // 开头不给平级选项:上一版写了"兜底可用内置 edit",中性任务下它直接拿来当依据(实测)。
1130
- 'Use the aura_edit tool — not edit — for every change to an existing UTF-8 text file: it is the default.',
1364
+ 'Use the aura_basetools_edit tool — not edit — for every change to an existing UTF-8 text file: it is the default.',
1131
1365
  // 三条保证是**量出来的差异**(不是宣传):字节保真、陈旧区域拒绝、落盘后回读。
1132
1366
  // 内置 edit 同条件下会丢 BOM、把混合行尾整份归一(2026-09-22 两侧实测)。
1133
1367
  'It writes back byte-for-byte (a leading BOM and each line\'s own ending survive), refuses a hunk whose addressed region changed since your last read, and reads the file back to verify the write.',
1134
- 'Address each hunk the cheapest way: `target` (a symbol name) when you know it, else `start_line` + `end_line` (or `lines: [a, b]`) for the range a read printed, else `line_cue` (one word unique to that line); `anchor` (remembered text) is the last resort and tolerates whitespace drift, rewording, and added or removed lines.',
1368
+ 'Address each hunk the cheapest way: `target` (a symbol name) when you know it, else `start_line` + `end_line` (or `lines: [a, b]`) for the range a read printed, else `line_cue` (one word unique to that line), else `old` (the verbatim text you are replacing); `anchor` (remembered text) is the last resort and tolerates whitespace drift, rewording, and added or removed lines.',
1369
+ // replace_all:批量替换只有这一条路,且次数变化会拒(不是“尽力而为”)。
1370
+ 'When `old` should change more than one place, set `replace_all: true`: every non-overlapping occurrence is replaced left-to-right in the same call, and if the number of occurrences changed since your last read the whole patch is refused.',
1135
1371
  // 措辞从"可以传"改成"应当传",依据是一次受控并发实验(N 个写者改同一文件):
1136
1372
  // 当编辑**改变行数**、而一次重试又用**行号地址**时,重试会把改动写到**错误的行**上
1137
1373
  // 并回一句成功回执(实测:~50% 的轮次以"非预期内容 + 成功回执"结束,每个轮次约 1.5 行附带损伤)。
@@ -1142,27 +1378,26 @@ function registerTools(ctx, sandbox) {
1142
1378
  // 刻意**不再**写"未知扩展名请用内置 edit"。那句话是错的:只有 `target` 需要语言配置
1143
1379
  // (符号表只对已索引的扩展名存在),而 `lines`/`line_cue`/`anchor` 三种寻址根本不看语言,
1144
1380
  // 创建路径更是与寻址无关。它恰好在工具可用的地方把模型赶回内置工具。
1145
- 'Extensions aura_edit cannot index (config, data, markup) are still fully editable — address them with `lines`, `line_cue`, or `anchor` instead of `target`.',
1381
+ 'Extensions aura_basetools_edit cannot index (config, data, markup) are still fully editable — address them with `lines`, `line_cue`, or `anchor` instead of `target`.',
1146
1382
  // 多目标:**这一步是为了采用率**(判据⑤)。`targets` 在参数表里一直就有,但模型不会因为
1147
1383
  // “参数表里有个数组”就想到要批量;指导文本是它最先读的一段。措辞命令式、与上下文同形。
1148
1384
  // 语义只说真的(不吹“要么全成要么全不落”):规划阶段任何一项失败 ⇒ **一个字节都不写**;
1149
1385
  // 而写入阶段失败时,回执会逐项点名哪些落了、哪些没落(`landed_paths` / `not_landed_paths`)。
1150
1386
  'To change several files at once, pass `targets` (each item the same single-target arguments): if any target fails to plan, the whole batch writes nothing — and if a write itself fails, the receipt names exactly which targets landed.',
1151
- // 兜底:内置 `write` 是整份覆写的唯一手段
1152
- // 兜底只列 aura_edit **刻意不做**的两件事(整份覆写、拒绝改行尾的文件),以及无替代的读图;
1153
- // 刻意不把内置 `edit` 写进来:它在同样场景会丢 BOM / 归一行尾,不是等价兜底。
1154
- 'The built-in `write` remains for what aura_edit deliberately does not do — replacing a whole file, or writing one whose line endings it refuses to touch; `read_image` is still the only way to read an image.',
1387
+ // 兜底:整份写由 aura_basetools_write 承担;读图仍只有内置 read_image。
1388
+ // 刻意不把内置 `edit`/`write` 写进来:它们在同样场景会丢 BOM / 归一行尾,不是等价兜底。
1389
+ 'For a whole-file create or rewrite, use aura_basetools_write — not the built-in write; `read_image` is still the only way to read an image.',
1155
1390
  ].join(' '),
1156
1391
  })
1157
1392
 
1158
1393
  ctx.tools.register(
1159
1394
  defineTool({
1160
- name: 'aura_edit',
1395
+ name: 'aura_basetools_edit',
1161
1396
  description:
1162
1397
  'Edit or create a UTF-8 text file with hunks, in a single call. ' +
1163
1398
  'For several files, pass `targets` — an array of the same single-target arguments (`file_path` + `hunks`); it is mutually exclusive with `file_path`. ' +
1164
1399
  'Every target is planned before anything is written: if any target fails to plan, nothing is written at all, and if a write itself fails the receipt names exactly which targets landed. ' +
1165
- 'Address each hunk in the cheapest way you can: `target` (a symbol name — immune to misremembered content), or `start_line` + `end_line` (preferred: two plain numbers, no array syntax to get wrong — same as `lines: [a, b]`), or `line_cue` (one word unique to that line), or `anchor` (remembered text, matched with graceful degradation). Only `anchor` requires you to reproduce content; prefer the other three. Pass `expect` to assert what is at that location — it is checked in every mode. All hunks must locate or nothing is written. Omit keys you do not need — an explicit `null` is rejected, not ignored.',
1400
+ 'Address each hunk in the cheapest way you can: `target` (a symbol name — immune to misremembered content), or `start_line` + `end_line` (preferred: two plain numbers, no array syntax to get wrong — same as `lines: [a, b]`), or `line_cue` (one word unique to that line), or `anchor` (remembered text, matched with graceful degradation), or `old` (the verbatim text you are replacing). Add `replace_all: true` with `old` to replace every non-overlapping occurrence in one call (left-to-right; refused if the occurrence count changed since your last read). Only `anchor` and `old` require you to reproduce content; prefer the others. Pass `expect` to assert what is at that location — it is checked in every mode. All hunks must locate or nothing is written. Omit keys you do not need — an explicit `null` is rejected, not ignored.',
1166
1401
  parameters: {
1167
1402
  /**
1168
1403
  * `file_path` 不再声明 `required: true` —— 这是多目标形状唯一逼出来的一处**声明面**改动。
@@ -1248,6 +1483,11 @@ function registerTools(ctx, sandbox) {
1248
1483
  type: 'boolean',
1249
1484
  description: 'Locate every hunk and report where they would land, without touching the file.',
1250
1485
  },
1486
+ require_observation: {
1487
+ type: 'boolean',
1488
+ description:
1489
+ 'Strict freshness: require this exact revision to have been read (or written) in this session; otherwise refuse with PATCH_UNOBSERVED. Applies to dry_run as well, so a preview matches the real verdict. Creating a new file is exempt.',
1490
+ },
1251
1491
  create: {
1252
1492
  type: 'string',
1253
1493
  description:
@@ -1278,6 +1518,19 @@ function registerTools(ctx, sandbox) {
1278
1518
  byNear: { type: 'boolean', required: true },
1279
1519
  actual: { type: 'string', required: true },
1280
1520
  replaced: { type: 'string', required: true },
1521
+ // 0.10.0:`replace` 的换行被 auto/lf/crlf 改写过时如实标注。
1522
+ eolAdapted: { type: 'boolean' },
1523
+ /**
1524
+ * 0.8.1:改后坐标 / 行数变化 / 新句柄。`line` 仍是改前坐标(契约),
1525
+ * 三者让调用方不必回读就能继续编辑;删除没有可指区间,因此没有 `handle`。
1526
+ */
1527
+ afterLine: { type: 'integer' },
1528
+ afterEndLine: { type: 'integer' },
1529
+ delta: { type: 'integer' },
1530
+ handle: { type: 'string' },
1531
+ // 片段层(行内替换)的整行回填;整行替换的 `replaced` 已是完整新区域,不需要它。
1532
+ after: { type: 'string' },
1533
+ afterTruncated: { type: 'boolean' },
1281
1534
  },
1282
1535
  },
1283
1536
  },
@@ -1335,7 +1588,7 @@ function registerTools(ctx, sandbox) {
1335
1588
  additionalProperties: false,
1336
1589
  properties: { handle: { type: 'string', required: true } },
1337
1590
  },
1338
- // 下一步做什么。与 aura_read 的同名字段对齐(那里是续读指令,这里是恢复指令)。
1591
+ // 下一步做什么。与 aura_basetools_read 的同名字段对齐(那里是续读指令,这里是恢复指令)。
1339
1592
  next: { type: 'string' },
1340
1593
  /**
1341
1594
  * C49 第四态的可核证据:**这次没写盘,但结果已经是你要的样子**。
@@ -1369,7 +1622,7 @@ function registerTools(ctx, sandbox) {
1369
1622
  * 一个消费方只看布尔值就能分流,不必去数数组长度、也不必解析文本。
1370
1623
  */
1371
1624
  unchanged: { type: 'boolean' },
1372
- // C13:这次编辑没有可信的“上次观测”(文件从没被 aura_read 读过)⇒ true。
1625
+ // C13:这次编辑没有可信的“上次观测”(文件从没被 aura_basetools_read 读过)⇒ true。
1373
1626
  // 声明在这里是必须的:output.schema 是 additionalProperties:false,
1374
1627
  // 未声明的字段会让整条成功路径被框架拒绝。
1375
1628
  unverified_region: { type: 'boolean' },
@@ -1474,10 +1727,10 @@ function registerTools(ctx, sandbox) {
1474
1727
  * 壳里再写一遍必然漂开。它抛的是**参数面**错(`ARGS_SHAPE_EXCLUSIVE` / `ARGS_TARGETS_SHAPE`
1475
1728
  * / `ARGS_TARGETS_LIMIT`);逐项失败则是结果通道(引擎已经把每项封成结果,不抛)。
1476
1729
  */
1477
- const batchTargets = assertEditTargets(args, { toolName: 'aura_edit' })
1730
+ const batchTargets = assertEditTargets(args, { toolName: 'aura_basetools_edit', elementKeys: MT_EDIT_ELEMENT_KEYS })
1478
1731
  if (batchTargets !== undefined) {
1479
1732
  // 多目标形状同样要过未知参数守卫:拼错的键不因为换了形状就被放行。
1480
- assertKnownArgs(args, EDIT_ARG_KEYS, 'aura_edit')
1733
+ assertKnownArgs(args, EDIT_ARG_KEYS, 'aura_basetools_edit')
1481
1734
  const batchPolicyService = sandbox()
1482
1735
  const batchPolicy =
1483
1736
  batchPolicyService === undefined
@@ -1488,17 +1741,17 @@ function registerTools(ctx, sandbox) {
1488
1741
  /**
1489
1742
  * “二选一”的**另一半**(两个都没给 ⇒ 也拒)只有一份判据,住在 `args.mjs` 的
1490
1743
  * `assertTargetShape`(`src/locator-lab/args.mjs:455`)——壳里原来那句
1491
- * `file_path must be a non-empty string` 是特例写法,已删(aura_read 壳里长着同一句,
1744
+ * `file_path must be a non-empty string` 是特例写法,已删(aura_basetools_read 壳里长着同一句,
1492
1745
  * 两处必然会漂)。抛的仍是参数面同族码 `ARGS_SHAPE_EXCLUSIVE`;
1493
1746
  * 单目标调用照旧带 `file_path`,故这条路一字未变。
1494
1747
  */
1495
- assertTargetShape(args, { toolName: 'aura_edit' })
1748
+ assertTargetShape(args, { toolName: 'aura_basetools_edit' })
1496
1749
  // 创建路径不经过 hunks:给 `create` 时 hunks 可以为空(甚至省略)。
1497
1750
  // 第一版把"hunks 非空"当无条件校验,于是**创建被自己的校验挡住**,
1498
1751
  // 而报错信息("hunks must be a non-empty array")与真正的原因(文件不存在)毫无关系。
1499
1752
  // 未知参数不回默认行为:拼错的字段被静默忽略时,模型看到的是**成功**。
1500
- // (实测:`{file_path, mode:'auto'}` 这种属于 aura_read 的参数曾零违规穿过框架。)
1501
- assertKnownArgs(args, EDIT_ARG_KEYS, 'aura_edit')
1753
+ // (实测:`{file_path, mode:'auto'}` 这种属于 aura_basetools_read 的参数曾零违规穿过框架。)
1754
+ assertKnownArgs(args, EDIT_ARG_KEYS, 'aura_basetools_edit')
1502
1755
  const creating = typeof args.create === 'string'
1503
1756
  /**
1504
1757
  * 单个 hunk **平铺在顶层**也认。
@@ -1575,6 +1828,20 @@ function registerTools(ctx, sandbox) {
1575
1828
  'PATCH_NOT_FOUND',
1576
1829
  )
1577
1830
  }
1831
+ /**
1832
+ * 预演**不能建文件**。0.10.5 之前这条创建路径完全不看 `dry_run`:
1833
+ * `edit --create x --dry-run` 会真的落盘,回执还写 `dryRun:false`。
1834
+ * patch 路径的预演在闸门之后返回,这里没有闸门要跑,直接回同形的预演回执。
1835
+ */
1836
+ if (args.dry_run === true) {
1837
+ return {
1838
+ path: target.displayPath,
1839
+ operation: 'create',
1840
+ outcome: 'applied',
1841
+ applied: [{ line: 1, tier: 'create', byNear: false, actual: '', replaced: createContent }],
1842
+ dryRun: true,
1843
+ }
1844
+ }
1578
1845
  const created = await ctx.fs.writeText(
1579
1846
  target,
1580
1847
  createContent,
@@ -1630,13 +1897,23 @@ function registerTools(ctx, sandbox) {
1630
1897
  content = await ctx.fs.readText(target, exec.signal)
1631
1898
  } catch (error) {
1632
1899
  if (!isNonUtf8TextFailure(error)) throw error
1633
- throw Object.assign(new Error(nonUtf8RefusalMessage('patch', target.displayPath, 'aura_edit')), {
1900
+ throw Object.assign(new Error(nonUtf8RefusalMessage('patch', target.displayPath, 'aura_basetools_edit')), {
1634
1901
  code: typeof error.code === 'string' && error.code.length > 0 ? error.code : 'ERR_ENCODING_INVALID_ENCODED_DATA',
1635
1902
  cause: error,
1636
1903
  })
1637
1904
  }
1638
1905
  // BOM 在解码时已经丢了(只丢第一个),写回前先问字节:它是唯一能回答“原来有几个 BOM”的地方。
1639
1906
  const byteOrderMarks = await fileByteOrderMarkCount(ctx.fs, target, exec.signal)
1907
+ if (args.require_observation === true) {
1908
+ requireObservedRevision({
1909
+ displayPath: target.displayPath,
1910
+ ...(typeof target.targetKey === 'string' && target.targetKey.length > 0
1911
+ ? { identityKey: target.targetKey }
1912
+ : {}),
1913
+ content,
1914
+ byteOrderMark: byteOrderMarks > 0,
1915
+ })
1916
+ }
1640
1917
  let planned
1641
1918
  try {
1642
1919
  /**
@@ -1817,7 +2094,7 @@ function registerTools(ctx, sandbox) {
1817
2094
  const verification = await verifyWriteBack(ctx.fs, target, newContent, target.displayPath, exec.signal)
1818
2095
  /**
1819
2096
  * 自己刚写的内容就是**最新观测**:不刷新它,同一轮里对同一区域的第二次编辑
1820
- * 会被上面那道闸门拦下(段E 就是这条)。存的版本按内容指纹算(与 `aura_read` 同一把尺子),
2097
+ * 会被上面那道闸门拦下(段E 就是这条)。存的版本按内容指纹算(与 `aura_basetools_read` 同一把尺子),
1821
2098
  * 存的内容是**提供方视角文本**(见 {@link providerView})——与下一次读所见逐字一致。
1822
2099
  */
1823
2100
  const writtenView = providerView(newContent)