dsh-vscode-mode 0.21.4 → 0.23.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.
Files changed (43) hide show
  1. package/README.md +52 -4
  2. package/lib/client.js +3442 -183
  3. package/lib/client.js.map +2 -2
  4. package/lib/index.js +3510 -222
  5. package/lib/index.js.map +1 -1
  6. package/package.json +1 -1
  7. package/src/client/index.ts +166 -0
  8. package/src/client/quickMenuHost.ts +46 -0
  9. package/src/client/quickMenuStore.ts +260 -0
  10. package/src/client/state/diffSummary.ts +52 -8
  11. package/src/client/styles/quickmenu.css +1049 -0
  12. package/src/client/ui/DiffLauncher.ts +1 -1
  13. package/src/client/ui/EditorView.ts +10 -2
  14. package/src/client/ui/OfficialDiffBar.tsx +1 -12
  15. package/src/client/ui/OfficialReview.ts +13 -3
  16. package/src/client/ui/QuickMenuDialog.tsx +835 -0
  17. package/src/client/ui/QuickMenuLauncher.tsx +792 -0
  18. package/src/client/ui/QuickMenuPickers.ts +577 -0
  19. package/src/client/ui/SidecarDiffBar.tsx +0 -22
  20. package/src/index.ts +9 -0
  21. package/src/missingReview.ts +36 -14
  22. package/src/paths.ts +10 -0
  23. package/src/pending/capture.ts +165 -0
  24. package/src/pending/decide.ts +213 -0
  25. package/src/pending/hunks.ts +241 -0
  26. package/src/pending/kernel.ts +233 -0
  27. package/src/pending/persist.ts +329 -0
  28. package/src/pending/store.ts +472 -0
  29. package/src/pending/vcs.ts +444 -0
  30. package/src/pendingCompat.ts +184 -0
  31. package/src/pendingRpc.ts +210 -0
  32. package/src/quickMenu.ts +269 -0
  33. package/src/reviewDb.ts +3 -0
  34. package/src/reviewRpc.ts +52 -1
  35. package/src/rpc.ts +66 -2
  36. package/src/shared/pathKey.ts +54 -0
  37. package/src/shared/pendingReview.ts +105 -0
  38. package/src/shared/quickMenu.ts +1009 -0
  39. package/src/shared/quickPick.ts +67 -0
  40. package/src/shared/rpc.ts +28 -0
  41. package/src/shared/skillCatalog.ts +60 -0
  42. package/src/skillCatalog.ts +138 -0
  43. package/src/store.ts +19 -0
@@ -3,7 +3,7 @@ import { createHash, randomUUID } from 'node:crypto'
3
3
  import { resolve } from 'node:path'
4
4
  import type { DiffRecord, MissingReview } from './shared/types.js'
5
5
  import type { Ctx, Session } from './store.js'
6
- import { archiveRecords, policyOf, READ_CAP, resolveTargetIn } from './store.js'
6
+ import { archiveRecords, policyOf, READ_CAP, resolveTargetIn, targetPathOf } from './store.js'
7
7
  import { hasOfficial } from './reviewDb.js'
8
8
  import { recordResolved } from './model.js'
9
9
 
@@ -31,13 +31,20 @@ function revisionOf(records: DiffRecord[]): string {
31
31
  return createHash('sha256').update(JSON.stringify(evidence)).digest('hex')
32
32
  }
33
33
 
34
+ /** @private @author ddj 2026年10月30号 @param ctx Host capability. @param target Resolve 结果:宿主路径字符串或不透明 FsTarget。 @returns 平台一致的物理身份;无法还原时返回空串。 */
35
+ function fileKeyOf(ctx: Ctx, target: unknown): string {
36
+ const path = targetPathOf(ctx, target)
37
+ return path ? fileKey(path) : ''
38
+ }
39
+
34
40
  /** @private @author ddj 2026年10月28号 @param ctx Host capability. @param cwd Workspace. @param bucket Records. @param target Resolved file. @returns Records for the same physical path spelling, oldest first. */
35
41
  async function fileRecords(ctx: Ctx, cwd: string, bucket: Map<string, DiffRecord>, target: string): Promise<DiffRecord[]> {
36
42
  const matches: DiffRecord[] = []
37
- const identity = fileKey(target)
43
+ // 解析结果可能是不透明对象,比较前必须先统一还原成宿主路径。
44
+ const identity = fileKeyOf(ctx, target)
38
45
  for (const record of bucket.values()) {
39
46
  if (record.superseded) continue
40
- if (fileKey(await resolveTargetIn(ctx, cwd, record.path)) === identity) matches.push(record)
47
+ if (fileKeyOf(ctx, await resolveTargetIn(ctx, cwd, record.path)) === identity) matches.push(record)
41
48
  }
42
49
  return matches.sort((left, right) => left.at.localeCompare(right.at))
43
50
  }
@@ -57,9 +64,12 @@ export async function fileAbsent(ctx: Ctx, cwd: string, path: string): Promise<b
57
64
  /** @public @author ddj 2026年10月28号 @param ctx Host. @param session Live session. @param cwd Workspace. @param bucket Records. @param path Requested file. @returns Frozen complete snapshot; never constructs text from hunks or writes files. @throws Missing evidence or nonmissing target. */
58
65
  export async function missingReview(ctx: Ctx, session: Session, cwd: string, bucket: Map<string, DiffRecord>, path: string): Promise<MissingReview> {
59
66
  const target = await resolveTargetIn(ctx, cwd, path)
60
- if (hasOfficial(cwd, path) || hasOfficial(cwd, target)) throw new Error('该文件已有官方审查记录,请使用官方审查入口')
61
- if (!await fileAbsent(ctx, cwd, target)) throw new Error('文件已重新出现,请刷新')
62
- const records = await fileRecords(ctx, cwd, bucket, target)
67
+ // 解析结果可能是不透明 FsTarget,凡按路径字符串使用的分支都必须先还原。
68
+ const abs = targetPathOf(ctx, target)
69
+ if (!abs) throw new Error('无法解析目标文件路径')
70
+ if (hasOfficial(cwd, path) || hasOfficial(cwd, abs)) throw new Error('该文件已有官方审查记录,请使用官方审查入口')
71
+ if (!await fileAbsent(ctx, cwd, abs)) throw new Error('文件已重新出现,请刷新')
72
+ const records = await fileRecords(ctx, cwd, bucket, abs)
63
73
  const latest = records.at(-1)
64
74
  if (!records.some((record) => !record.archived && !recordResolved(record))) throw new Error('没有待审历史记录')
65
75
  if (!latest || typeof latest.after !== 'string') throw new Error('缺少可靠的完整历史快照,无法展示或恢复;不会用局部差异拼接文件')
@@ -88,33 +98,45 @@ async function applyDisk(ctx: Ctx, item: FrozenReview, target: string, undo: boo
88
98
  if (await fs.readText(target) !== item.view.content) throw new Error('恢复内容校验失败,未结清记录')
89
99
  }
90
100
 
101
+ /**
102
+ * @author ddj 2026年10月29号
103
+ * @description: 取删除预览对应的文件路径,供服务端判定该路径是否已由待审投影接管。
104
+ * @param token 冻结预览 token
105
+ * @returns 文件路径,token 无效时为 undefined
106
+ */
107
+ export function missingPath(token: string): string | undefined {
108
+ return frozen.get(token)?.view.path
109
+ }
110
+
91
111
  /** @public @author ddj 2026年10月28号 @param ctx Host. @param session Requesting session. @param cwd Workspace. @param bucket Records. @param token Frozen preview token. @param operationId Stable retry ID. @param undo True restores shown snapshot; false keeps deletion. @returns Covered call IDs. @throws Changed evidence, concurrent action or sandbox/IO failure. */
92
112
  export async function missingDecide(ctx: Ctx, session: Session, cwd: string, bucket: Map<string, DiffRecord>, token: string, operationId: string, undo: boolean): Promise<string[]> {
93
113
  const item = frozen.get(token)
94
114
  if (!item || item.expires < Date.now() || item.cwd !== cwd || item.session.id !== session.id) throw new Error('删除预览已过期,请刷新后重试')
95
115
  if (!operationId || operationId.length > 128) throw new Error('缺少有效操作 ID')
96
116
  const target = await resolveTargetIn(ctx, cwd, item.view.path)
97
- const key = JSON.stringify([cwd, fileKey(target)])
117
+ const abs = targetPathOf(ctx, target)
118
+ if (!abs) throw new Error('无法解析目标文件路径')
119
+ const key = JSON.stringify([cwd, fileKey(abs)])
98
120
  if (locks.has(key)) throw new Error('该文件正在处理,请稍后重试')
99
121
  locks.add(key)
100
122
  item.session = session
101
- try { return await commitMissing(ctx, item, bucket, target, operationId, undo) }
123
+ try { return await commitMissing(ctx, item, bucket, target, abs, operationId, undo) }
102
124
  finally { locks.delete(key) }
103
125
  }
104
126
 
105
- /** @private @author ddj 2026年10月28号 @param ctx Host. @param item Frozen preview. @param bucket Live records. @param target Resolved target. @param operationId Retry identity. @param undo Restore action. @returns Archived pending call IDs; disk success survives bookkeeping failure in the same Host lifetime. */
106
- async function commitMissing(ctx: Ctx, item: FrozenReview, bucket: Map<string, DiffRecord>, target: string, operationId: string, undo: boolean): Promise<string[]> {
127
+ /** @private @author ddj 2026年10月28号 @param ctx Host. @param item Frozen preview. @param bucket Live records. @param target Resolved target. @param abs Host path of the target. @param operationId Retry identity. @param undo Restore action. @returns Archived pending call IDs; disk success survives bookkeeping failure in the same Host lifetime. */
128
+ async function commitMissing(ctx: Ctx, item: FrozenReview, bucket: Map<string, DiffRecord>, target: string, abs: string, operationId: string, undo: boolean): Promise<string[]> {
107
129
  const previous = item.operation
108
130
  if (previous && (previous.id !== operationId || previous.undo !== undo)) throw new Error('已有不同操作,请刷新后重试')
109
131
  if (previous?.done) return item.view.callIds
110
- if (hasOfficial(item.cwd, target) || hasOfficial(item.cwd, item.view.path)) throw new Error('该文件已有官方审查记录,请刷新')
111
- const records = await fileRecords(ctx, item.cwd, bucket, target)
132
+ if (hasOfficial(item.cwd, abs) || hasOfficial(item.cwd, item.view.path)) throw new Error('该文件已有官方审查记录,请刷新')
133
+ const records = await fileRecords(ctx, item.cwd, bucket, abs)
112
134
  if (revisionOf(records) !== item.revision) throw new Error('历史记录已变化,请刷新删除预览')
113
135
  item.operation ??= { id: operationId, undo, restored: false, done: false }
114
- await applyDisk(ctx, item, target, undo)
136
+ await applyDisk(ctx, item, abs, undo)
115
137
 
116
138
  // Recheck after awaited IO: capture/another review must not silently join this decision.
117
- const current = await fileRecords(ctx, item.cwd, bucket, target)
139
+ const current = await fileRecords(ctx, item.cwd, bucket, abs)
118
140
  if (revisionOf(current) !== item.revision) throw new Error('历史记录已变化,未结清记录,请刷新')
119
141
  const pending = current.filter((record) => item.view.callIds.includes(record.callId))
120
142
  const decisions = pending.map((record) => record.decisions)
package/src/paths.ts CHANGED
@@ -204,6 +204,16 @@ export function archiveDbFile(cwd: string, home = dshHome()): string {
204
204
  return join(home, PLUGIN_ID, 'data', 'archive', hashOf(cwd) + '.sqlite')
205
205
  }
206
206
 
207
+ /**
208
+ * 快捷菜单配置文本(用户数据,不受缓存清理预算约束,跨工作区共享一份)。
209
+ * @author ddj 2026年10月30号
210
+ * @param home DSH home(测试可注入)
211
+ * @returns 快捷菜单配置绝对路径
212
+ */
213
+ export function quickMenuFile(home = dshHome()): string {
214
+ return join(home, PLUGIN_ID, 'data', 'quick-menu.json')
215
+ }
216
+
207
217
  /** 插件包 assets 目录(import.meta.url 派生;config.imageDir 可覆盖,见 imageDirOf)。 */
208
218
  export function assetsDirOf(moduleUrl: string): string {
209
219
  return join(dirname(fileURLToPath(moduleUrl)), '..', 'assets')
@@ -0,0 +1,165 @@
1
+ /**
2
+ * 待审差异采集:环绕一次文件写入读取真实前后内容,只有内容确实变化才落库。
3
+ * @author ddj 2026年10月29号
4
+ */
5
+ import { cwdOf } from '../registry.js'
6
+ import { resolveTarget } from '../store.js'
7
+ import type { Ctx } from '../store.js'
8
+ import { log } from '../log.js'
9
+
10
+ /** 可被采信为待审来源的写文件工具。 */
11
+ const WRITE_TOOLS = ['edit', 'write', 'str_replace_editor']
12
+ /** 单文件文本上限:超过即标注 oversized,不静默截断。 */
13
+ const MAX_TEXT = 1024 * 1024
14
+
15
+ /** 一次已确认的写入。 */
16
+ export interface PendingWrite {
17
+ cwd: string
18
+ sessionId: string
19
+ path: string
20
+ identity: string
21
+ before: string | null
22
+ after: string | null
23
+ now: number
24
+ }
25
+
26
+ /** 采集器。 */
27
+ export interface PendingCapture {
28
+ /** 释放监听并丢弃暂存。 */
29
+ dispose(): void
30
+ }
31
+
32
+ /** 采集器的进程内状态:同一目标并发写入时只采信最外层那一次。 */
33
+ interface CaptureState {
34
+ busy: Set<string>
35
+ stopped: boolean
36
+ }
37
+
38
+ /** 工具的写工具参数形状;只取本采集关心的字段。 */
39
+ interface WriteExec {
40
+ name?: string
41
+ arguments?: { file_path?: unknown; path?: unknown }
42
+ agent?: { session?: { id?: string; cwd?: string } }
43
+ }
44
+
45
+ /**
46
+ * @author ddj 2026年10月30号
47
+ * @description: 安装写文件采集;读写失败只影响待审来源,绝不影响用户工具本身。
48
+ * @param ctx Host 生命周期
49
+ * @param sink 已确认写入的接收方
50
+ * @returns 可释放的采集器
51
+ */
52
+ export function installPending(ctx: Ctx, sink: (write: PendingWrite) => void): PendingCapture {
53
+ const state: CaptureState = { busy: new Set<string>(), stopped: false }
54
+ ctx.on('tools/execute', (exec: WriteExec, next: () => Promise<unknown>) => aroundTool(ctx, state, sink, exec, next))
55
+ return {
56
+ dispose(): void {
57
+ state.stopped = true
58
+ state.busy.clear()
59
+ },
60
+ }
61
+ }
62
+
63
+ /**
64
+ * @author ddj 2026年10月30号
65
+ * @description: 判定本次调用是否值得采集;不值得时返回 null,调用方直接透传原始分发。
66
+ * @param ctx Host 生命周期
67
+ * @param state 采集状态
68
+ * @param exec 工具分发上下文
69
+ * @returns 采集计划或 null
70
+ */
71
+ async function planOf(ctx: Ctx, state: CaptureState, exec: WriteExec): Promise<CapturePlan | null> {
72
+ const session = exec.agent?.session
73
+ const requested = exec.arguments?.file_path ?? exec.arguments?.path
74
+ if (state.stopped || !session?.id || typeof requested !== 'string') return null
75
+ if (!WRITE_TOOLS.includes(String(exec.name))) return null
76
+ const cwd = cwdOf(session as never)
77
+ if (!cwd) return null
78
+ const fs = ctx.get('fs')
79
+ const target = await resolveTarget(ctx, session as never, requested)
80
+ const identity = fs.processPath(target)
81
+ if (typeof identity !== 'string' || !identity || state.busy.has(identity)) return null
82
+ const before = await readText(fs, target)
83
+ if (before !== null && before.length > MAX_TEXT) return null
84
+ return { cwd: String(cwd), sessionId: String(session.id), path: requested, identity, before, session: session as never }
85
+ }
86
+
87
+ /** 一次采集所需的固定信息。 */
88
+ interface CapturePlan {
89
+ cwd: string
90
+ sessionId: string
91
+ path: string
92
+ identity: string
93
+ before: string | null
94
+ session: never
95
+ }
96
+
97
+ /**
98
+ * @author ddj 2026年10月30号
99
+ * @description: 写工具执行前后各读一次,内容变化才上报;任何采集异常都不改变工具结果。
100
+ * @param ctx Host 生命周期
101
+ * @param state 采集状态
102
+ * @param sink 已确认写入的接收方
103
+ * @param exec 工具分发上下文
104
+ * @param next 原始分发
105
+ * @returns 原始工具结果
106
+ */
107
+ async function aroundTool(
108
+ ctx: Ctx, state: CaptureState, sink: (write: PendingWrite) => void,
109
+ exec: WriteExec, next: () => Promise<unknown>,
110
+ ): Promise<unknown> {
111
+ let plan: CapturePlan | null = null
112
+ try {
113
+ plan = await planOf(ctx, state, exec)
114
+ } catch (error) {
115
+ log.debug('待审采集跳过:' + String(error))
116
+ }
117
+ if (!plan) return next()
118
+
119
+ state.busy.add(plan.identity)
120
+ try {
121
+ return await next()
122
+ } finally {
123
+ state.busy.delete(plan.identity)
124
+ try {
125
+ reportWrite(ctx, sink, plan)
126
+ } catch (error) {
127
+ log.debug('待审上报失败:' + String(error))
128
+ }
129
+ }
130
+ }
131
+
132
+ /**
133
+ * @author ddj 2026年10月29号
134
+ * @description: 写后读回并比较,内容一致视为无净变化不产生条目。
135
+ * @param ctx Host 生命周期
136
+ * @param sink 已确认写入的接收方
137
+ * @param plan 采集计划
138
+ * @returns 无;读回失败只记日志
139
+ */
140
+ function reportWrite(ctx: Ctx, sink: (write: PendingWrite) => void, plan: CapturePlan): void {
141
+ const fs = ctx.get('fs')
142
+ void resolveTarget(ctx, plan.session, plan.path).then(async (target: unknown) => {
143
+ const after = await readText(fs, target)
144
+ if (after !== null && after.length > MAX_TEXT) return
145
+ if (plan.before === after) return
146
+ sink({ cwd: plan.cwd, sessionId: plan.sessionId, path: plan.path, identity: plan.identity, before: plan.before, after, now: Date.now() })
147
+ }).catch((error: unknown) => log.debug('待审读回失败:' + String(error)))
148
+ }
149
+
150
+ /**
151
+ * @author ddj 2026年10月29号
152
+ * @description: 读取全文;缺失或不可读按"无内容"处理,交由上层决定语义。
153
+ * @param fs 文件系统
154
+ * @param target 目标
155
+ * @returns 文本或 null
156
+ */
157
+ export async function readText(fs: any, target: any): Promise<string | null> {
158
+ try {
159
+ return String(await fs.readText(target))
160
+ } catch (error) {
161
+ const code = (error as { code?: string }).code
162
+ if (code === 'ENOENT' || code === 'FS_NOT_FOUND' || code === 'FS_TOO_LARGE') return null
163
+ throw error
164
+ }
165
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * 待审判定内核:Keep 只推进投影,Revert 才写磁盘;写盘由注入的 writer 完成以便测试零写断言。
3
+ * @author ddj 2026年10月29号
4
+ */
5
+ import type { PendingDecide } from '../shared/pendingReview.js'
6
+ import type { ReviewBlock, ReviewBlockState } from '../shared/reviewRegions.js'
7
+ import { reviewBlocks } from '../shared/reviewRegions.js'
8
+ import type { OfficialDiff } from '../shared/review.js'
9
+ import { keepBlock, revertBlock, textDiff } from './hunks.js'
10
+ import type { PendingEntry } from './kernel.js'
11
+ import { applyUpdate, isSettled } from './kernel.js'
12
+ import type { PendingFlags } from './persist.js'
13
+
14
+ /** 写盘结果:`content` 是读回校验通过后的最终内容,null 表示删除。 */
15
+ export type WriteResult = { ok: true; content: string | null } | { ok: false; reason: string }
16
+
17
+ /** 写盘能力,由 host 侧实现;纯判定层只调用它一次。 */
18
+ export type WriteBack = (content: string | null) => Promise<WriteResult>
19
+
20
+ /** 一次判定的输入。 */
21
+ export interface DecideInput {
22
+ request: PendingDecide
23
+ entry: PendingEntry
24
+ flags: PendingFlags
25
+ blocks: ReviewBlockState[]
26
+ now: number
27
+ write: WriteBack
28
+ }
29
+
30
+ /** 一次判定的输出。 */
31
+ export interface DecideOutcome {
32
+ ok: boolean
33
+ reason: string
34
+ entry: PendingEntry
35
+ flags: PendingFlags
36
+ blocks: ReviewBlockState[]
37
+ /** 是否真正写过磁盘(Keep 恒为 false)。 */
38
+ wrote: boolean
39
+ }
40
+
41
+ /**
42
+ * @author ddj 2026年10月29号
43
+ * @description: 执行一次 Keep/Revert;失败时返回原状态,调用方不得落库。
44
+ * @param input 判定输入
45
+ * @returns 判定结果
46
+ */
47
+ export async function decide(input: DecideInput): Promise<DecideOutcome> {
48
+ const failed = (reason: string): DecideOutcome => ({
49
+ ok: false, reason, entry: input.entry, flags: input.flags, blocks: input.blocks, wrote: false,
50
+ })
51
+ if (input.request.revision !== input.flags.revision) return failed('revision-mismatch')
52
+ if (input.flags.oversized) return failed('oversized')
53
+ if (input.entry.oldText === input.entry.newText) return failed('settled')
54
+
55
+ const diff = textDiff(input.entry.path, input.entry.path, input.entry.oldText, input.entry.newText)
56
+ const targets = pickBlocks(reviewBlocks(diff), input)
57
+ if (targets === null) return failed('unknown-block')
58
+ if (input.request.scope !== 'file' && input.request.scope !== 'all' && targets.length === 0) {
59
+ return failed('no-block')
60
+ }
61
+ const isKeep = input.request.decision === 'accepted'
62
+ const whole = input.request.scope === 'file' || input.request.scope === 'all'
63
+ return isKeep ? keep(input, targets, whole) : revert(input, targets, whole)
64
+ }
65
+
66
+ /**
67
+ * @author ddj 2026年10月29号
68
+ * @description: Keep:只把目标块或整文件的新内容并入基线,绝不写目标文件。
69
+ * @param input 判定输入
70
+ * @param targets 目标块(整文件时为空)
71
+ * @param whole 是否整文件
72
+ * @returns 判定结果
73
+ */
74
+ function keep(input: DecideInput, targets: ReviewBlock[], whole: boolean): DecideOutcome {
75
+ if (whole) {
76
+ const entry = applyUpdate(input.entry, { oldText: input.entry.newText }, input.now)
77
+ return {
78
+ ok: true, reason: '', entry, wrote: false,
79
+ flags: { ...input.flags, revision: input.flags.revision + 1 },
80
+ blocks: markAll(input.blocks, targets, 'accepted'),
81
+ }
82
+ }
83
+ let oldText = input.entry.oldText
84
+ for (const block of targets) {
85
+ const next = keepBlock(oldText, block)
86
+ if (next === null) {
87
+ return { ok: false, reason: 'ambiguous-block', entry: input.entry, flags: input.flags, blocks: input.blocks, wrote: false }
88
+ }
89
+ oldText = next
90
+ }
91
+ const entry = applyUpdate(input.entry, { oldText }, input.now)
92
+ return {
93
+ ok: true, reason: '', entry, wrote: false,
94
+ flags: { ...input.flags, revision: input.flags.revision + 1 },
95
+ blocks: markAll(input.blocks, targets, 'accepted'),
96
+ }
97
+ }
98
+
99
+ /**
100
+ * @author ddj 2026年10月29号
101
+ * @description: Revert:合成回退内容后交给 writer 写盘并读回,写失败则状态不变。
102
+ * @param input 判定输入
103
+ * @param targets 目标块(整文件时为空)
104
+ * @param whole 是否整文件
105
+ * @returns 判定结果
106
+ */
107
+ async function revert(input: DecideInput, targets: ReviewBlock[], whole: boolean): Promise<DecideOutcome> {
108
+ const failed = (reason: string): DecideOutcome => ({
109
+ ok: false, reason, entry: input.entry, flags: input.flags, blocks: input.blocks, wrote: false,
110
+ })
111
+ const next = whole ? wholeRevert(input.entry) : blockRevert(input.entry, targets)
112
+ if (next === undefined) return failed('ambiguous-block')
113
+
114
+ const written = await input.write(next)
115
+ if (!written.ok) return failed(written.reason)
116
+
117
+ const entry = applyUpdate(input.entry, { newText: written.content ?? '' }, input.now)
118
+ return {
119
+ ok: true, reason: '', entry, wrote: true,
120
+ flags: { ...input.flags, revision: input.flags.revision + 1, missing: written.content === null },
121
+ blocks: markAll(input.blocks, targets, 'rejected'),
122
+ }
123
+ }
124
+
125
+ /**
126
+ * @author ddj 2026年10月29号
127
+ * @description: 整文件回退目标:基线为"文件不存在"时删除,否则写回基线全文。
128
+ * @param entry 待审条目
129
+ * @returns 目标内容,null 表示删除
130
+ */
131
+ function wholeRevert(entry: PendingEntry): string | null {
132
+ return entry.earlierVersion === 'none' ? null : entry.oldText
133
+ }
134
+
135
+ /**
136
+ * @author ddj 2026年10月29号
137
+ * @description: 块级回退:在最新内容里依次换回目标块,任一不可定位即整体放弃。
138
+ * @param entry 待审条目
139
+ * @param targets 目标块
140
+ * @returns 新内容或 undefined
141
+ */
142
+ function blockRevert(entry: PendingEntry, targets: ReviewBlock[]): string | null | undefined {
143
+ let newText = entry.newText
144
+ for (const block of targets) {
145
+ const next = revertBlock(newText, block)
146
+ if (next === null) return undefined
147
+ newText = next
148
+ }
149
+ return newText
150
+ }
151
+
152
+ /**
153
+ * @author ddj 2026年10月29号
154
+ * @description: 按请求粒度挑出目标块;块不存在时返回 null 以区分"没有块"。
155
+ * @param blocks 当前差异块
156
+ * @param input 判定输入
157
+ * @returns 目标块,未知块 ID 时为 null
158
+ */
159
+ function pickBlocks(blocks: ReviewBlock[], input: DecideInput): ReviewBlock[] | null {
160
+ const scope = input.request.scope
161
+ if (scope === 'file' || scope === 'all') return []
162
+ if (scope === 'block') {
163
+ const block = blocks.find((item) => item.id === input.request.blockId)
164
+ return block ? [block] : null
165
+ }
166
+ const start = input.request.startLine ?? 0
167
+ const end = input.request.endLine ?? start
168
+ return blocks.filter((block) => block.newStart <= end && block.newStart + Math.max(block.newLines.length, 1) >= start)
169
+ }
170
+
171
+ /**
172
+ * @author ddj 2026年10月29号
173
+ * @description: 把目标块标记为已决定;整文件时把全部块一起标记。
174
+ * @param states 已存块决定
175
+ * @param targets 目标块
176
+ * @param decision 决定
177
+ * @returns 新的块决定列表
178
+ */
179
+ function markAll(states: ReviewBlockState[], targets: ReviewBlock[], decision: 'accepted' | 'rejected'): ReviewBlockState[] {
180
+ const next = states.slice()
181
+ for (const block of targets) {
182
+ const at = next.findIndex((state) => state.id === block.id)
183
+ if (at >= 0) next[at] = { id: block.id, decision }
184
+ else next.push({ id: block.id, decision })
185
+ }
186
+ return next
187
+ }
188
+
189
+ /**
190
+ * @author ddj 2026年10月29号
191
+ * @description: 能力口径:Keep 恒可;Revert 受超大/缺失/已解决限制,拒绝原因要能展示。
192
+ * @param entry 待审条目
193
+ * @param flags 持久标记
194
+ * @returns 能力与原因
195
+ */
196
+ export function capabilityOf(entry: PendingEntry, flags: PendingFlags): { keep: boolean; revert: boolean; reason: string } {
197
+ if (isSettled(entry)) return { keep: false, revert: false, reason: 'settled' }
198
+ if (flags.oversized) return { keep: false, revert: false, reason: 'oversized' }
199
+ if (entry.earlierVersion === 'none' && entry.newText.length === 0) {
200
+ return { keep: true, revert: false, reason: 'empty-create' }
201
+ }
202
+ return { keep: true, revert: true, reason: '' }
203
+ }
204
+
205
+ /**
206
+ * @author ddj 2026年10月29号
207
+ * @description: 供详情接口复用的差异构造。
208
+ * @param entry 待审条目
209
+ * @returns 官方差异
210
+ */
211
+ export function diffOf(entry: PendingEntry): OfficialDiff {
212
+ return textDiff(entry.path, entry.path, entry.oldText, entry.newText)
213
+ }