@whaletalk/dsh-codex-collab 0.1.7 → 0.1.10

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 (32) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +197 -26
  3. package/codex-plugin/plugins/dsh/.codex-plugin/plugin.json +1 -1
  4. package/codex-plugin/plugins/dsh/skills/dsh-collab/SKILL.md +102 -6
  5. package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/review.mjs +91 -6
  6. package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/task.mjs +571 -44
  7. package/docs/CODEX_DSH_APPROVAL_DELEGATION_PLAN.md +130 -0
  8. package/docs/CODEX_DSH_APPROVAL_M0_EVIDENCE.md +285 -0
  9. package/docs/CODEX_DSH_DELIVERY_CONTROL_PLAN.md +81 -0
  10. package/docs/CODEX_DSH_MODEL_ROUTING_PLAN.md +75 -0
  11. package/docs/CODEX_DSH_TASK_REPLY_PLAN.md +206 -0
  12. package/docs/DSH_APPROVAL_DELEGATION.md +142 -0
  13. package/docs/DSH_DELIVERY_M0_EVIDENCE.md +69 -0
  14. package/docs/DSH_MODEL_ROUTING_M0_EVIDENCE.md +166 -0
  15. package/docs/DSH_TO_CODEX_INBOX.md +167 -0
  16. package/docs/DSH_TO_CODEX_MESSAGING_PLAN.md +140 -0
  17. package/docs/codex-desktop-attachment-evidence.md +116 -0
  18. package/harness/audit-tail.mjs +32 -0
  19. package/harness/codex-approvals.mjs +1078 -0
  20. package/harness/codex-associations.mjs +297 -0
  21. package/harness/codex-outbox.mjs +432 -0
  22. package/harness/dsh-bridge.mjs +2429 -167
  23. package/harness/fs-lock.mjs +208 -0
  24. package/harness/model-route-host.mjs +530 -0
  25. package/harness/model-routing.mjs +500 -0
  26. package/harness/session-observer.mjs +217 -0
  27. package/harness/session-target.mjs +291 -2
  28. package/harness/task-table.mjs +34 -0
  29. package/mcp/codex-desktop-probe.mjs +407 -0
  30. package/mcp/codex-inbox.mjs +271 -0
  31. package/mcp/dsh-mcp.mjs +616 -24
  32. package/package.json +4 -2
@@ -0,0 +1,297 @@
1
+ /**
2
+ * codex-associations.mjs —— 「来源 Codex 对话 → 任务 → 执行方 DSH」的持久关联记录(零依赖)。
3
+ *
4
+ * 目的(R1):Codex 在**某个回合**派活时带上来源对话 ID(originThreadId),此后**任何回合**都能
5
+ * 用 originThreadId + taskId 精确取回这次任务的结果;只给 originThreadId 时能列出该对话登记过的任务,
6
+ * 用来找回丢失的 taskId。
7
+ *
8
+ * 存储:`<root>/<namespace>/bindings.jsonl`,**追加型事件日志**(bind / update 各一行),读取时折叠成记录。
9
+ * - 追加前修记录边界(末字节不是换行就先补一个),残尾不会吞掉后续写入;
10
+ * - 写入在 fs-lock.mjs 的独占写锁内串行化(与收件箱共用同一把锁的算法);
11
+ * - 读不动(权限/IO)抛 store-unreadable;文件有内容但一行都解析不出抛 store-corrupt——
12
+ * **绝不静默降级成空记录**。
13
+ *
14
+ * 隔离:命名空间是**校验过的短标识**(`[A-Za-z0-9._-]{1,64}`),只用来选子目录;origin/taskId
15
+ * 永远不参与路径拼接。多个网关共用同一命名空间也是安全的:日志是追加型、taskId 全局唯一,
16
+ * 不存在"别人覆盖我的记录";需要按 profile 分隔时用 DSH_CODEX_TASK_ASSOC_NAMESPACE 指定。
17
+ *
18
+ * 注意:originThreadId 是**关联元数据**,不是认证/授权凭据。
19
+ */
20
+ import fs from 'node:fs'
21
+ import os from 'node:os'
22
+ import path from 'node:path'
23
+ import { createFileLock } from './fs-lock.mjs'
24
+
25
+ export const ASSOC_REASONS = {
26
+ /** originThreadId 不是合法的非空字符串。 */
27
+ INVALID_ORIGIN: 'invalid-origin',
28
+ /** 需要来源才能精确取值/恢复,但调用方没给。 */
29
+ MISSING_ORIGIN: 'origin-required',
30
+ /** 给了来源,但这条任务的登记来源不是它——**绝不返回别的对话的结果**。 */
31
+ ORIGIN_MISMATCH: 'origin-mismatch',
32
+ /** taskId 没有关联登记。 */
33
+ UNKNOWN_TASK: 'unknown-task',
34
+ /** 同一 taskId 试图改投另一个来源。 */
35
+ ORIGIN_CONFLICT: 'origin-conflict',
36
+ STORE_UNREADABLE: 'store-unreadable',
37
+ STORE_CORRUPT: 'store-corrupt',
38
+ }
39
+
40
+ export const MAX_ORIGIN_LENGTH = 200
41
+ const NAMESPACE_RE = /^[A-Za-z0-9._-]{1,64}$/
42
+
43
+ /** origin 必须是**非空**字符串(去空白后仍非空)、不含控制字符、长度有界。 */
44
+ export const isValidOrigin = (value) => typeof value === 'string'
45
+ && value.trim() !== ''
46
+ && value.trim().length <= MAX_ORIGIN_LENGTH
47
+ && !/[\u0000-\u001f\u007f]/.test(value)
48
+
49
+ /** 命名空间只允许 `[A-Za-z0-9._-]{1,64}`,且显式排除 `.` / `..`(它们会让 path.join 逃出存储根)。 */
50
+ export const isValidNamespace = (value) => typeof value === 'string'
51
+ && NAMESPACE_RE.test(value)
52
+ && value !== '.'
53
+ && value !== '..'
54
+
55
+ /** 存储根:显式参数 > DSH_CODEX_TASK_ASSOC_DIR > 用户数据目录(**绝不默认写进仓库**)。 */
56
+ export const resolveAssocRoot = (explicit) => {
57
+ if (typeof explicit === 'string' && explicit.trim() !== '') return path.resolve(explicit)
58
+ const fromEnv = process.env.DSH_CODEX_TASK_ASSOC_DIR
59
+ if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return path.resolve(fromEnv)
60
+ return path.join(os.homedir(), '.dsh-codex-task-assoc')
61
+ }
62
+
63
+ /** 命名空间:显式参数 > 环境变量 > 'default'。非法命名空间直接抛错(不拼路径)。 */
64
+ export const resolveNamespace = (explicit) => {
65
+ const raw = (typeof explicit === 'string' && explicit.trim() !== '')
66
+ ? explicit.trim()
67
+ : (typeof process.env.DSH_CODEX_TASK_ASSOC_NAMESPACE === 'string' && process.env.DSH_CODEX_TASK_ASSOC_NAMESPACE.trim() !== ''
68
+ ? process.env.DSH_CODEX_TASK_ASSOC_NAMESPACE.trim()
69
+ : 'default')
70
+ if (!isValidNamespace(raw)) {
71
+ throw Object.assign(new Error('invalid association namespace: ' + JSON.stringify(raw) + ' (expected [A-Za-z0-9._-]{1,64})'), { reason: ASSOC_REASONS.INVALID_ORIGIN })
72
+ }
73
+ return raw
74
+ }
75
+
76
+ const fail = (reason, message, extra) => Object.assign(new Error(message), { reason, ...(extra === undefined ? {} : extra) })
77
+
78
+ const readJsonLines = (file) => {
79
+ let text
80
+ try {
81
+ text = fs.readFileSync(file, 'utf8')
82
+ } catch (e) {
83
+ if (e !== null && typeof e === 'object' && e.code === 'ENOENT') return []
84
+ throw fail(ASSOC_REASONS.STORE_UNREADABLE, 'cannot read ' + file + ': ' + e.message)
85
+ }
86
+ if (text.trim() === '') return []
87
+ const events = []
88
+ let bad = 0
89
+ for (const line of text.split('\n')) {
90
+ const trimmed = line.trim()
91
+ if (trimmed === '') continue
92
+ try {
93
+ const value = JSON.parse(trimmed)
94
+ if (value !== null && typeof value === 'object' && typeof value.type === 'string') events.push(value)
95
+ else bad++
96
+ } catch (e) { bad++ }
97
+ }
98
+ if (events.length === 0 && bad > 0) {
99
+ // 有内容却一条都解析不出来:这是损坏,不是"空存储"。
100
+ throw fail(ASSOC_REASONS.STORE_CORRUPT, file + ' has content but no readable records (' + bad + ' bad line(s))')
101
+ }
102
+ return events
103
+ }
104
+
105
+ const foldRecords = (events) => {
106
+ const records = new Map()
107
+ for (const event of events) {
108
+ if (event.type === 'bind') {
109
+ if (typeof event.taskId !== 'string' || typeof event.originThreadId !== 'string') continue
110
+ records.set(event.taskId, {
111
+ originThreadId: event.originThreadId,
112
+ taskId: event.taskId,
113
+ kind: event.kind,
114
+ target: event.target,
115
+ profile: event.profile,
116
+ status: typeof event.status === 'string' ? event.status : 'accepted',
117
+ createdAt: event.at,
118
+ updatedAt: event.at,
119
+ delivered: event.delivered,
120
+ result: undefined,
121
+ error: undefined,
122
+ reason: undefined,
123
+ stopReason: undefined,
124
+ // 投递控制(第四轮):父关系/投递模式是**不可变关系**,在 bind 时就落盘(不能等执行后补)。
125
+ deliver: event.deliver,
126
+ parentTaskId: event.parentTaskId,
127
+ resultScope: event.resultScope,
128
+ requestSeq: event.requestSeq,
129
+ receiptState: event.receiptState,
130
+ route: event.route,
131
+ routePolicy: event.routePolicy,
132
+ })
133
+ continue
134
+ }
135
+ if (event.type === 'update') {
136
+ const record = records.get(event.taskId)
137
+ if (record === undefined) continue
138
+ for (const key of ['status', 'delivered', 'result', 'error', 'reason', 'stopReason', 'requestId', 'dshTurn', 'deliver', 'parentTaskId', 'receiptState', 'observedTurn', 'resultScope', 'requestSeq', 'route', 'routePolicy', 'routeStopReason']) {
139
+ if (event[key] === undefined) continue
140
+ // 显式 null = **清除**(回执绝不允许残留父任务的 result/reason 等旧值)
141
+ if (event[key] === null) { delete record[key]; continue }
142
+ record[key] = event[key]
143
+ }
144
+ record.updatedAt = event.at
145
+ }
146
+ }
147
+ return records
148
+ }
149
+
150
+ /**
151
+ * 打开任务关联存储。方法与收件箱同风格:同步、语义确定。
152
+ * @param {{root?: string, namespace?: string, lockTimeoutMs?: number, profile?: string}} options
153
+ */
154
+ export const openAssociations = (options = {}) => {
155
+ const root = resolveAssocRoot(options.root)
156
+ const namespace = resolveNamespace(options.namespace)
157
+ const dir = path.join(root, namespace)
158
+ const file = path.join(dir, 'bindings.jsonl')
159
+ const lock = createFileLock({ lockFile: path.join(dir, '.write.lock'), timeoutMs: options.lockTimeoutMs })
160
+ const profile = typeof options.profile === 'string' && options.profile !== '' ? options.profile : (process.env.DSH_PROFILE || undefined)
161
+
162
+ const ensureDir = () => { fs.mkdirSync(dir, { recursive: true }) }
163
+
164
+ const load = () => foldRecords(readJsonLines(file))
165
+
166
+ /** 追加一行;先修记录边界(末字节不是换行就先补换行),避免残尾吞掉新记录。 */
167
+ const appendEvent = (event) => {
168
+ ensureDir()
169
+ let size = 0
170
+ try { size = fs.statSync(file).size } catch (e) { size = 0 }
171
+ if (size > 0) {
172
+ let last = 0
173
+ const fd = fs.openSync(file, 'r')
174
+ try {
175
+ const buf = Buffer.alloc(1)
176
+ fs.readSync(fd, buf, 0, 1, size - 1)
177
+ last = buf[0]
178
+ } finally {
179
+ fs.closeSync(fd)
180
+ }
181
+ if (last !== 0x0a) fs.appendFileSync(file, '\n', 'utf8')
182
+ }
183
+ fs.appendFileSync(file, JSON.stringify(event) + '\n', 'utf8')
184
+ }
185
+
186
+ return {
187
+ root,
188
+ namespace,
189
+ dir,
190
+ file,
191
+
192
+ /**
193
+ * 登记一条关联。**必须在执行前调用**:写盘失败就抛错,让调用方放弃启动 DSH 执行
194
+ * (绝不出现"执行了却没绑定")。
195
+ */
196
+ bind({ originThreadId, taskId, kind, target, delivered, deliver, parentTaskId, resultScope, requestSeq, receiptState, route, routePolicy }) {
197
+ if (!isValidOrigin(originThreadId)) {
198
+ throw fail(ASSOC_REASONS.INVALID_ORIGIN, 'originThreadId must be a non-empty string of at most ' + MAX_ORIGIN_LENGTH + ' characters without control characters')
199
+ }
200
+ if (typeof taskId !== 'string' || taskId.trim() === '') throw fail(ASSOC_REASONS.UNKNOWN_TASK, 'taskId is required')
201
+ return lock.withLock(() => {
202
+ const existing = load().get(taskId)
203
+ if (existing !== undefined && existing.originThreadId !== originThreadId.trim()) {
204
+ throw fail(ASSOC_REASONS.ORIGIN_CONFLICT, 'task ' + taskId + ' is already bound to another origin', { taskId })
205
+ }
206
+ if (existing !== undefined) return existing
207
+ const at = Date.now()
208
+ appendEvent({
209
+ type: 'bind',
210
+ version: 1,
211
+ originThreadId: originThreadId.trim(),
212
+ taskId,
213
+ kind,
214
+ target,
215
+ profile,
216
+ status: 'accepted',
217
+ delivered: delivered === true ? true : undefined,
218
+ // 不可变父关系 / 投递模式(第四轮 A):执行前就落盘,重启后仍在
219
+ deliver: deliver === 'steer' || deliver === 'queue' ? deliver : undefined,
220
+ parentTaskId: typeof parentTaskId === 'string' && parentTaskId !== '' ? parentTaskId : undefined,
221
+ resultScope: typeof resultScope === 'string' ? resultScope : undefined,
222
+ requestSeq: typeof requestSeq === 'number' ? requestSeq : undefined,
223
+ receiptState: typeof receiptState === 'string' ? receiptState : undefined,
224
+ // 模型路由(第一版验收修正):派活前就落盘的选择/策略(重启后可按 origin+task 取回)
225
+ route: route !== null && typeof route === 'object' ? route : undefined,
226
+ routePolicy: routePolicy !== null && typeof routePolicy === 'object' ? routePolicy : undefined,
227
+ at,
228
+ })
229
+ return load().get(taskId)
230
+ })
231
+ },
232
+
233
+ /** 更新终态/交付信息。任务未登记 → unknown-task(不静默新建)。 */
234
+ update(taskId, patch = {}) {
235
+ if (typeof taskId !== 'string' || taskId.trim() === '') throw fail(ASSOC_REASONS.UNKNOWN_TASK, 'taskId is required')
236
+ return lock.withLock(() => {
237
+ const record = load().get(taskId)
238
+ if (record === undefined) throw fail(ASSOC_REASONS.UNKNOWN_TASK, 'task ' + taskId + ' has no association record', { taskId })
239
+ appendEvent({
240
+ type: 'update',
241
+ version: 1,
242
+ taskId,
243
+ status: typeof patch.status === 'string' ? patch.status : undefined,
244
+ delivered: patch.delivered === undefined ? undefined : patch.delivered === true,
245
+ result: patch.result === null ? null : (typeof patch.result === 'string' ? patch.result : undefined),
246
+ error: patch.error === null ? null : (typeof patch.error === 'string' ? patch.error : undefined),
247
+ reason: patch.reason === null ? null : (typeof patch.reason === 'string' ? patch.reason : undefined),
248
+ stopReason: patch.stopReason === null ? null : (typeof patch.stopReason === 'string' ? patch.stopReason : undefined),
249
+ requestId: typeof patch.requestId === 'string' ? patch.requestId : undefined,
250
+ dshTurn: typeof patch.dshTurn === 'number' ? patch.dshTurn : undefined,
251
+ // 投递控制(第四轮 A):投递模式与父/回执关系(持久化,重启后仍能查);
252
+ // `null` = 显式清除(回执绝不允许残留父任务的旧 result/reason)。
253
+ deliver: patch.deliver === 'steer' || patch.deliver === 'queue' ? patch.deliver : undefined,
254
+ parentTaskId: patch.parentTaskId === null ? null : (typeof patch.parentTaskId === 'string' ? patch.parentTaskId : undefined),
255
+ receiptState: patch.receiptState === null ? null : (typeof patch.receiptState === 'string' ? patch.receiptState : undefined),
256
+ observedTurn: patch.observedTurn === null ? null : (typeof patch.observedTurn === 'number' ? patch.observedTurn : undefined),
257
+ resultScope: patch.resultScope === null ? null : (typeof patch.resultScope === 'string' ? patch.resultScope : undefined),
258
+ requestSeq: patch.requestSeq === null ? null : (typeof patch.requestSeq === 'number' ? patch.requestSeq : undefined),
259
+ // 模型路由(第一版验收修正):选择/策略/终止原因随任务落盘(重启后按 origin+task 可取回)
260
+ route: patch.route === null ? null : (patch.route !== null && typeof patch.route === 'object' ? patch.route : undefined),
261
+ routePolicy: patch.routePolicy === null ? null : (patch.routePolicy !== null && typeof patch.routePolicy === 'object' ? patch.routePolicy : undefined),
262
+ routeStopReason: patch.routeStopReason === null ? null : (typeof patch.routeStopReason === 'string' ? patch.routeStopReason : undefined),
263
+ at: Date.now(),
264
+ })
265
+ return load().get(taskId)
266
+ })
267
+ },
268
+
269
+ record(taskId) {
270
+ return load().get(taskId)
271
+ },
272
+
273
+ /** 精确取值:必须 origin 匹配;不匹配/缺失一律拒绝,**失败时不返回任何 record**(不泄漏别人的结果)。 */
274
+ fetch({ originThreadId, taskId }) {
275
+ if (typeof taskId !== 'string' || taskId.trim() === '') return { ok: false, reason: ASSOC_REASONS.UNKNOWN_TASK }
276
+ const record = load().get(taskId)
277
+ if (record === undefined) return { ok: false, reason: ASSOC_REASONS.UNKNOWN_TASK }
278
+ if (!isValidOrigin(originThreadId)) return { ok: false, reason: ASSOC_REASONS.MISSING_ORIGIN }
279
+ if (record.originThreadId !== originThreadId.trim()) return { ok: false, reason: ASSOC_REASONS.ORIGIN_MISMATCH }
280
+ return { ok: true, record }
281
+ },
282
+
283
+ /** 列出某个来源登记过的任务(找回丢失的 taskId 用),最新在前。 */
284
+ byOrigin(originThreadId, filter = {}) {
285
+ if (!isValidOrigin(originThreadId)) return { ok: false, reason: ASSOC_REASONS.INVALID_ORIGIN }
286
+ const origin = originThreadId.trim()
287
+ const all = Array.from(load().values()).filter((r) => r.originThreadId === origin)
288
+ all.sort((a, b) => b.createdAt - a.createdAt)
289
+ const limit = typeof filter.limit === 'number' && filter.limit > 0 ? filter.limit : undefined
290
+ return { ok: true, originThreadId: origin, tasks: limit === undefined ? all : all.slice(0, limit) }
291
+ },
292
+
293
+ count() {
294
+ return load().size
295
+ },
296
+ }
297
+ }