dsh-task-dispatch-table 0.1.1 → 0.1.3

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.
@@ -78,6 +78,11 @@ export interface DispatchAttachment {
78
78
  /** link 型的工作区相对路径原文(基准未知时如实展示)。 */
79
79
  ref: string;
80
80
  path: string | null;
81
+ /**
82
+ * 该项是否为一整个**目录**(用户 2026-10-09:文件夹本身也能当附件)。
83
+ * 派发前 stat 得出,**不落库、不进快照**——服务侧刻意不区分目录与文件;测不出即缺位,按文件处理。
84
+ */
85
+ isDir?: boolean;
81
86
  }
82
87
  /**
83
88
  * 随附文件 → 官方 `file` 内容块(2026-10-03 拍板「附加文件走 A」)。
package/dist/dispatch.js CHANGED
@@ -266,6 +266,10 @@ times) {
266
266
  snapshot.prompt,
267
267
  '',
268
268
  `任务实例:${snapshot.title} · ${logicalDate}(目标工作区:${workspacePath})`,
269
+ '',
270
+ '【执行约束】本任务是定时自动派发、无人值守运行的:执行期间没有人会回复你。'
271
+ + '因此:① 不得向用户提问或等待答复——信息不足就基于现有上下文自行决策并继续,必要时在回执 note 中如实记录;'
272
+ + '② 不得申请、索取或等待任何额外权限或授权——严格在已授予的权限范围内执行,超范围的操作跳过并在回执中说明,不要停下来要许可。',
269
273
  ];
270
274
  // 执行时间两条(用户 2026-10-05 拍板,U4 收口):模型对「现在几点」未必可靠(真机 once8 产出
271
275
  // 文件日期差过一天)⇒ 把**原定时刻**与**实际派发时刻**原样下发(ISO 8601 / UTC,精确到秒),
@@ -301,16 +305,27 @@ times) {
301
305
  lines.push(receiptInstruction(snapshot.validStatuses));
302
306
  return userNotice(lines.join('\n'), `[TASK] ${snapshot.title} · ${logicalDate}`, fileBlocks);
303
307
  }
304
- /** 随附文件段(用户 2026-09-30:必须让模型明确知道文件在哪一层、在什么地方)。 */
308
+ /**
309
+ * 随附文件段(用户 2026-09-30:必须让模型明确知道文件在哪一层、在什么地方)。
310
+ *
311
+ * 段头那句「任务提示词里说的『附件 / 随附文件』就是指下面这些」是**用户称呼 → 绝对路径**的接缝
312
+ * (用户 2026-10-09:要在提示词里写「用附件里的 XX 执行」还能被找到)⇒ 没有它,模型只能靠文件名猜。
313
+ */
305
314
  function attachmentLines(list) {
306
315
  if (list.length === 0)
307
316
  return [];
308
- const lines = ['', '本次随附文件(只读输入,请勿修改;以下均为可直接读取的绝对路径):'];
317
+ const lines = ['', '本次随附文件(只读输入,请勿修改;以下均为可直接读取的绝对路径。任务提示词里说的「附件 / 随附文件」就是指下面这些):'];
309
318
  for (const item of list) {
310
- const tag = item.kind === 'upload' ? '上传' : '工作区';
311
- lines.push(item.path === null
312
- ? ` - ${item.name}(${tag}):${item.ref}(基准工作区未知,为工作区相对路径)`
313
- : ` - ${item.name}(${tag}):${item.path}`);
319
+ const tag = item.isDir === true ? '目录' : (item.kind === 'upload' ? '上传' : '工作区');
320
+ if (item.path === null) {
321
+ lines.push(` - ${item.name}(${tag}):${item.ref}(基准工作区未知,为工作区相对路径)`);
322
+ continue;
323
+ }
324
+ // 目录路径尾补 '/':与普通文件在字面上一眼分开,模型不会拿它当单文件去读。
325
+ lines.push(` - ${item.name}(${tag}):${item.path}${item.isDir === true ? '/' : ''}`);
326
+ }
327
+ if (list.some(item => item.isDir === true)) {
328
+ lines.push('标注「目录」的是一整个文件夹:需要里面某个文件时,按 <该项路径>/<相对路径> 拼出完整路径后再读取。');
314
329
  }
315
330
  return lines;
316
331
  }
package/dist/index.js CHANGED
Binary file
package/dist/receipt.js CHANGED
@@ -38,16 +38,34 @@ function normalizeStatus(raw, statuses) {
38
38
  return statuses.find(status => status.toLowerCase() === wanted);
39
39
  }
40
40
  /**
41
- * outputs 归一:数组取字符串项;字符串按逗号切(模型偶尔传逗号串);其余忽略。
41
+ * 产出归一:数组取字符串项;字符串按逗号切(模型偶尔传逗号串);其余忽略。
42
42
  * 逐项去空白 / 零宽字符、反斜杠统一成 `/`、去掉 `./` 前缀(宿主对账按工作区相对路径 `resolve`)。
43
+ * 同一桶里的重复路径只留第一次(模型偶尔把同一文件报两遍 ⇒ 界面上会出重复卡片)。
43
44
  */
44
45
  function normalizeOutputs(raw) {
45
46
  const list = typeof raw === 'string' ? raw.split(',') : Array.isArray(raw) ? raw : [];
46
- return list
47
- .filter((item) => typeof item === 'string')
48
- .map(item => normalizeText(item).replace(/\\/g, '/').replace(/^\.\//, ''))
49
- .filter(item => item.length > 0);
47
+ const out = [];
48
+ const seen = new Set();
49
+ for (const item of list) {
50
+ if (typeof item !== 'string')
51
+ continue;
52
+ const path = normalizeText(item).replace(/\\/g, '/').replace(/^\.\//, '');
53
+ if (path.length === 0 || seen.has(path))
54
+ continue;
55
+ seen.add(path);
56
+ out.push(path);
57
+ }
58
+ return out;
50
59
  }
60
+ /**
61
+ * 产出清单上限(用户 2026-10-10 拍板):**主文件 / 过程文件各自**最多 10 项。
62
+ *
63
+ * 超限**截断**而非拒绝重报:无人值守链路里一次拒绝就多一轮对话(多烧一轮 token),还可能反复
64
+ * 卡住不收敛;截断最多丢展示项,从不失败。截断后由工具返回文案如实告知。
65
+ * 参数 schema 上的 `maxItems` 只是给模型的提示(宿主不据此硬拦参数,见 normalizeStatus 注释),
66
+ * 真正兜底的是这里。
67
+ */
68
+ const MAX_OUTPUT_ITEMS = 10;
51
69
  /** 合法 status 清单(空数组兜底成 `['ok']`:schema 允许显式传空,但工具的 enum 不能为空)。 */
52
70
  function receiptStatuses(validStatuses) {
53
71
  return validStatuses.length > 0 ? validStatuses : ['ok'];
@@ -67,11 +85,16 @@ function buildDefinition(deps) {
67
85
  // outputs 的填写粒度放这里(属"怎么填",不再占用消息体那段的篇幅)。
68
86
  description: `提交任务「${taskName}」的执行回执。**本轮回复结束前必须调用一次**(准备输出最后一句之前);`
69
87
  + `调度器以它判定本次任务成败,不调用等于失败——任务指令说「不要做其他操作」也不豁免。`
70
- + `参数:status(必填,如实)、outputs(可选,本次真实交付物相对工作区根的路径)、note(可选备注)。`
71
- + `outputs 粒度:装产物的文件夹是为本任务专门建的(如 "web-app/")→ 填文件夹路径;`
72
- + `只是写进既有或按规范建的目录(如按日期的日常目录)→ 逐个列文件路径(如 ["20260928/a.md"]);`
73
- + `**以最后一次提交为准**:可以重复调用,但每一次都会整体覆盖上一次——所以再次提交时,`
74
- + `之前已经报过的产物必须**一并带上**(不回带 = 视为放弃,它们会丢)。没有产出就省略 outputs。`,
88
+ + `参数:status(必填,如实)、outputs(主文件)、processOutputs(过程文件)、note(可选备注);后三者都可省略。`
89
+ + `**产出分两桶报,各桶最多 ${MAX_OUTPUT_ITEMS} 项**(超出只记前 ${MAX_OUTPUT_ITEMS} 项),同一路径只报一次、别重复:`
90
+ + `· outputs = **主文件**:本次任务最终的交付物(任务报告、成果文件、成品目录),可以是一个、多个,文件或文件夹都行;`
91
+ + `只是跑出了一堆过程、没有最终交付物 → 省略 outputs,不要把过程文件塞进来。`
92
+ + `· processOutputs = **过程文件**:执行中产生的日志、中间产物、临时/工作目录、依赖资源(网页项目的 css/js/图片目录等)。`
93
+ + `本身就是目录就直接报整个目录(如 "web-app/"),别把里面每个文件都拆开列——目录里同时含有主要工作和场务/过程内容是正常的,无需为此单独拆分、挪动文件再整理。`
94
+ + `· 判断口径:一个文件夹里只有个别文件是最终交付、其余是过程 ⇒ 那几个文件报 outputs,整个文件夹报 processOutputs(含纳关系合法,不冲突)。`
95
+ + `· 粒度:为本任务专门建的产物目录 → 报目录路径(如 "web-app/");只是写进既有或按日期规范的目录 → 逐个列文件(如 ["20260928/a.md"])。`
96
+ + `**以最后一次提交为准**:可以重复调用,但每一次都会整体覆盖上一次——再次提交时,`
97
+ + `两桶**先前报过的都必须一并带上**(不回带 = 视为放弃,它们会丢)。`,
75
98
  parameters: {
76
99
  type: 'object',
77
100
  additionalProperties: false,
@@ -83,8 +106,15 @@ function buildDefinition(deps) {
83
106
  },
84
107
  outputs: {
85
108
  type: 'array',
109
+ maxItems: MAX_OUTPUT_ITEMS,
110
+ items: { type: 'string' },
111
+ description: `主文件:本次任务最终的交付物,相对工作区根(目录以 "/" 结尾)。可为多个文件或多个文件夹;没有最终交付物就省略。最多 ${MAX_OUTPUT_ITEMS} 项。`,
112
+ },
113
+ processOutputs: {
114
+ type: 'array',
115
+ maxItems: MAX_OUTPUT_ITEMS,
86
116
  items: { type: 'string' },
87
- description: '本次真实交付物,相对工作区根。粒度判断:装产物的文件夹是为本任务专门建的(如网页/项目专属文件夹)→ 填文件夹路径(如 "web-app/");文件只是写进既有或按规范建的目录(如按日期的日常目录)→ 逐个列文件路径。可同时含多个目录与多个文件;没有产出可省略。',
117
+ description: `过程文件:日志、中间产物、临时/工作目录、依赖资源,相对工作区根(目录以 "/" 结尾)。最多 ${MAX_OUTPUT_ITEMS} 项;可与主文件并存(允许「某文件在 outputs、其父目录在 processOutputs」这种含纳关系),但同一路径不要两桶都报(都报了只算主文件)。`,
88
118
  },
89
119
  note: {
90
120
  type: 'string',
@@ -118,7 +148,14 @@ function buildDefinition(deps) {
118
148
  + `大小写与首尾空白会自动归一,其余写法一律不认)。请修正参数后重试一次;这是参数错误,不必等待。`,
119
149
  };
120
150
  }
121
- const outputs = normalizeOutputs(input.outputs);
151
+ // 两桶各自归一 → 各自截断上限(满额即止)→ 跨桶去重(同一路径以主桶为准)。
152
+ // 截断与否要如实回报给模型(见返回文案),否则它会以为全都记上了。
153
+ const mainAll = normalizeOutputs(input.outputs);
154
+ const procAll = normalizeOutputs(input.processOutputs);
155
+ const outputs = mainAll.slice(0, MAX_OUTPUT_ITEMS);
156
+ const mainSet = new Set(outputs);
157
+ const processOutputs = procAll.filter(item => !mainSet.has(item)).slice(0, MAX_OUTPUT_ITEMS);
158
+ const overLimit = mainAll.length > MAX_OUTPUT_ITEMS || procAll.length > MAX_OUTPUT_ITEMS;
122
159
  const rawNote = typeof input.note === 'string' ? normalizeText(input.note) : '';
123
160
  const note = rawNote.length > 0 ? rawNote : undefined;
124
161
  const instance = store.get(instanceId);
@@ -137,10 +174,15 @@ function buildDefinition(deps) {
137
174
  message: `回执未提交:实例当前会话已是 ${instance.session_id ?? 'null'},本会话无权提交。不必重试。`,
138
175
  };
139
176
  }
140
- // 与 submit.ts 写进 receipt 事件的形状完全一致(对账逻辑零改动):status/outputs/note/session_id。
141
- store.appendEvent(instanceId, 'receipt', { status, outputs, note, session_id: sessionId });
142
- logger.info(`回执已记录 ${instanceId}: status=${status}${outputs.length > 0 ? `, outputs=${outputs.length} 项` : ''}`);
177
+ // 与 submit.ts 写进 receipt 事件的形状一致(对账逻辑零改动):status/outputs/processOutputs/note/session_id。
178
+ // processOutputs 是 2026-10-10 新增桶;旧回执没有该字段 ⇒ 消费方按空数组处理(向后兼容)。
179
+ store.appendEvent(instanceId, 'receipt', { status, outputs, processOutputs, note, session_id: sessionId });
180
+ logger.info(`回执已记录 ${instanceId}: status=${status}`
181
+ + `${outputs.length > 0 ? `, 主文件 ${outputs.length} 项` : ''}`
182
+ + `${processOutputs.length > 0 ? `, 过程文件 ${processOutputs.length} 项` : ''}`
183
+ + `${overLimit ? '(超出上限已截断)' : ''}`);
143
184
  // B 路线:插件代写官方交付事件(决策 40 演进:插件作唯一写入方,禁止 LLM 调 present)。
185
+ // ⚠️ 只写**主文件桶**:过程文件若也写进去就变成官方交付卡,与「分主次」矛盾。
144
186
  // 失败只记日志、绝不拖垮回执(外层 try 已兜底,这里再独立 try 防任何意外上抛)。
145
187
  try {
146
188
  const execCtx = exec;
@@ -172,7 +214,10 @@ function buildDefinition(deps) {
172
214
  return {
173
215
  ok: true,
174
216
  message: `回执已记录(${instanceId},status=${status}`
175
- + `${outputs.length > 0 ? `,outputs ${outputs.length} 项` : ''})。任务结束,无需再做任何事。`,
217
+ + `${outputs.length > 0 ? `,主文件 ${outputs.length} 项` : ''}`
218
+ + `${processOutputs.length > 0 ? `,过程文件 ${processOutputs.length} 项` : ''}`
219
+ + `${overLimit ? `。⚠️ 有桶超过 ${MAX_OUTPUT_ITEMS} 项上限,只记录了前 ${MAX_OUTPUT_ITEMS} 项` : ''}`
220
+ + `)。任务结束,无需再做任何事。`,
176
221
  };
177
222
  }
178
223
  catch (error) {
@@ -226,11 +271,13 @@ export function receiptInstruction(validStatuses) {
226
271
  `【回执·唯一权威段】本轮回复结束前,必须调用一次 ${RECEIPT_TOOL_NAME}。`,
227
272
  `⚖️ 冲突裁决:任务提示词里任何「不要做其他操作 / 只做某件事 / 什么都不用做 / 只回复一句话」的说法,`
228
273
  + `对回执**不生效**——它约束的是任务内容,不约束回执。不提交 = 本次任务失败。`,
229
- `- 没有产出文件也要交(省略 outputs)。`,
230
- `- **以最后一次提交为准**:重复提交会整体覆盖,先前报过的产物必须一并带上(不回带 = 放弃)。`,
231
- `${RECEIPT_TOOL_NAME}({ status: "${statuses[0] ?? 'ok'}", outputs: ["<产物,相对工作区根路径>"] })`,
274
+ `- 产出分两桶报:**主文件**填 outputs(最终交付物),**过程文件**填 processOutputs(日志 / 中间产物 / 工作目录);`
275
+ + `各不超过 ${MAX_OUTPUT_ITEMS} 项,没有主文件就只填 processOutputs。`,
276
+ `- 两个桶都可以空,但回执本身必须交(没有产出就省略它们)。`,
277
+ `- **以最后一次提交为准**:重复提交会整体覆盖,两桶先前报过的都必须一并带上(不回带 = 放弃)。`,
278
+ `${RECEIPT_TOOL_NAME}({ status: "${statuses[0] ?? 'ok'}", outputs: ["<主文件,相对工作区根路径>"], processOutputs: ["<过程文件>"] })`,
232
279
  `- status 只能取:${statuses.join(' | ')}(如实填写)。`,
233
280
  `- 调用失败:等 10 秒**原样重试**,最多 3 次;仍失败就**停下**(不要跑命令、不要动状态库、不要绕过沙箱)。`,
234
- `- 不要调用 present:交付卡片由插件按 outputs 统一生成。`,
281
+ `- 不要调用 present:交付卡片由插件按主文件(outputs)统一生成。`,
235
282
  ].join('\n');
236
283
  }
@@ -54,7 +54,7 @@ export interface ReconcilerDeps {
54
54
  }
55
55
  /**
56
56
  * 回执裁决(决策 19,替代旧契约文件三查):
57
- * receipt 事件存在 + status ∈ validStatuses + outputs 里的每个路径**确实存在**。
57
+ * receipt 事件存在 + status ∈ validStatuses + outputs / processOutputs 里的每个路径**确实存在**。
58
58
  *
59
59
  * ⚠️ **已去掉「mtime 新鲜度」闸**(用户 2026-10-03 拍板,原为「防旧产物冒充」):
60
60
  * 那道闸会误伤「复用 / 检查已有文件」类任务 —— 真机案例:任务是判断 `uuid.txt`
package/dist/reconcile.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // 不读文件;跑完信号后无回执 → 宽限 → 追问×2 → 按失败收敛。
7
7
  // 唯一例外(决策 41 兼容口):旧库实例无快照列值时,按 legacyTask 当场合成快照并固化(一次性
8
8
  // 兼容、带 warn)——兜底也不落库 ⇒ 无法发动 / 无法校验,如实按失败收敛,绝不静默。
9
+ import { stat } from 'node:fs/promises';
9
10
  import { existsSync } from 'node:fs';
10
11
  import { resolve } from 'node:path';
11
12
  import { displayNameOf, durationMs, sessionTitleOf } from './tasks.js';
@@ -49,9 +50,11 @@ export function missingSnapshotAttachments(ctx, taskId, snap, assets) {
49
50
  }
50
51
  /** 跑完信号后允许补交回执的追问上限(写死不加配置,事件表可查次数)。 */
51
52
  const NUDGE_LIMIT = 2;
53
+ /** 回执载荷里的路径数组(非数组 / 非字符串项一律剔除;缺字段 ⇒ 空数组)。 */
54
+ const receiptPaths = (raw) => Array.isArray(raw) ? raw.filter((item) => typeof item === 'string') : [];
52
55
  /**
53
56
  * 回执裁决(决策 19,替代旧契约文件三查):
54
- * receipt 事件存在 + status ∈ validStatuses + outputs 里的每个路径**确实存在**。
57
+ * receipt 事件存在 + status ∈ validStatuses + outputs / processOutputs 里的每个路径**确实存在**。
55
58
  *
56
59
  * ⚠️ **已去掉「mtime 新鲜度」闸**(用户 2026-10-03 拍板,原为「防旧产物冒充」):
57
60
  * 那道闸会误伤「复用 / 检查已有文件」类任务 —— 真机案例:任务是判断 `uuid.txt`
@@ -82,11 +85,19 @@ export function checkReceipt(workspacePath, validStatuses, receipt) {
82
85
  detail: { status: payload.status, validStatuses: [...validStatuses] },
83
86
  };
84
87
  }
85
- const outputs = Array.isArray(payload.outputs) ? payload.outputs.filter((item) => typeof item === 'string') : [];
86
- for (const output of outputs) {
87
- const outputPath = resolve(workspacePath, output);
88
- if (!existsSync(outputPath))
89
- return { ok: false, reason: 'output-missing', detail: { output } };
88
+ // 两桶同一道闸(2026-10-10):主文件与过程文件都按「确实存在」校验,防幽灵路径进界面与下游消息。
89
+ // ⚠️ 取舍:过程文件路径写错同样会让整次回执判失败并走重试(与主桶今天的行为一致);
90
+ // 若真机上因此频繁误失败,可降级为「只校验主桶」——改这一处即可。
91
+ const buckets = [
92
+ { bucket: 'main', list: receiptPaths(payload.outputs) },
93
+ { bucket: 'process', list: receiptPaths(payload.processOutputs) },
94
+ ];
95
+ for (const { bucket, list } of buckets) {
96
+ for (const output of list) {
97
+ const outputPath = resolve(workspacePath, output);
98
+ if (!existsSync(outputPath))
99
+ return { ok: false, reason: 'output-missing', detail: { output, bucket } };
100
+ }
90
101
  }
91
102
  return { ok: true, detail: payload };
92
103
  }
@@ -236,7 +247,7 @@ export function createReconciler({ ctx, logger, store, options, runtime, emit })
236
247
  const notifyRow = (instance) => {
237
248
  emit?.({ type: EventType.TASK_RUN_CHANGED, payload: { taskId: instance.task_id, instanceId: instance.id } });
238
249
  };
239
- function finishTerminal(instance, status, reason, detail, outputs) {
250
+ function finishTerminal(instance, status, reason, detail, outputs, processOutputs) {
240
251
  const tk = tokenTotals.get(instance.id);
241
252
  if (tokenTotals.has(instance.id))
242
253
  tokenTotals.delete(instance.id);
@@ -246,8 +257,8 @@ export function createReconciler({ ctx, logger, store, options, runtime, emit })
246
257
  runtime?.markTerminal(instance.task_id, status, instance.scheduled_at, finishedAt);
247
258
  if (detail !== undefined)
248
259
  store.appendEvent(instance.id, 'receipt_check', { reason, detail });
249
- // 决策 32 修订:完成瞬间写回产出与 token 三拆列到总表(冗余,task_events 仍为真源)
250
- store.recordCompletion(instance.id, outputs ?? null, tk?.in ?? null, tk?.out ?? null, tk?.cache ?? null);
260
+ // 决策 32 修订:完成瞬间写回两桶产出与 token 三拆列到总表(冗余,task_events 仍为真源)
261
+ store.recordCompletion(instance.id, outputs ?? null, processOutputs ?? null, tk?.in ?? null, tk?.out ?? null, tk?.cache ?? null);
251
262
  forgetHandle(instance.session_id);
252
263
  // 会话已结束(turn/end / disposed 触发的收敛)→ 归档;租约误判的回收不归档。
253
264
  if (instance.session_id !== null)
@@ -299,7 +310,8 @@ export function createReconciler({ ctx, logger, store, options, runtime, emit })
299
310
  if (verdict.ok) {
300
311
  const payload = verdict.detail;
301
312
  const outputsField = Array.isArray(payload?.outputs) ? JSON.stringify(payload.outputs) : null;
302
- finishTerminal(instance, 'succeeded', 'receipt-pass', verdict.detail, outputsField);
313
+ const processField = Array.isArray(payload?.processOutputs) ? JSON.stringify(payload.processOutputs) : null;
314
+ finishTerminal(instance, 'succeeded', 'receipt-pass', verdict.detail, outputsField, processField);
303
315
  }
304
316
  else {
305
317
  store.appendEvent(instance.id, 'receipt_check', { reason: verdict.reason, detail: verdict.detail });
@@ -382,7 +394,11 @@ export function createReconciler({ ctx, logger, store, options, runtime, emit })
382
394
  // 随附文件段(2026-09-30):把快照里的附件 ref 解析成**绝对路径**随派发消息注入
383
395
  // (用户要求:必须让模型明确知道文件在哪一层、在什么地方)。与上面存在性校验同款口径——
384
396
  // link 按附件来源工作区、upload 按任务目录;解析不出 ⇒ path=null(如实标注,绝不猜)。
385
- const attachments = (snap.attachments ?? []).map(item => {
397
+ // 同上口径 + **是否目录**(用户 2026-10-09:文件夹本身也能当附件)。
398
+ // ref 指的是目录还是文件,只有这一刻 stat 才知道——服务侧刻意不区分两者,schema / 快照零改动。
399
+ // stat 失败 ⇒ 按文件处理:缺失项已被上面的 missingSnapshotAttachments 拦掉,这里不该失败,
400
+ // 标错也只退化成旧文案,绝不阻断派发。
401
+ const attachments = await Promise.all((snap.attachments ?? []).map(async (item) => {
386
402
  let path = null;
387
403
  if (item.kind === 'upload') {
388
404
  path = assets === null ? null : attachmentAbsPath(assets, instance.task_id, item.ref);
@@ -398,8 +414,9 @@ export function createReconciler({ ctx, logger, store, options, runtime, emit })
398
414
  }
399
415
  if (path !== null && path.includes('..'))
400
416
  path = null;
401
- return { name: item.name, kind: item.kind, ref: item.ref, path };
402
- });
417
+ const isDir = path === null ? undefined : await stat(path).then(info => info.isDirectory()).catch(() => undefined);
418
+ return { name: item.name, kind: item.kind, ref: item.ref, path, isDir };
419
+ }));
403
420
  const { sessionId, handle } = await dispatchTask({
404
421
  ctx, logger, store,
405
422
  instanceId: instance.id,
@@ -56,6 +56,7 @@ export interface TaskOverviewRow {
56
56
  kind: 'link' | 'upload';
57
57
  path?: string;
58
58
  anchorSessionId?: string;
59
+ isDir?: boolean;
59
60
  }>;
60
61
  depends: Array<{
61
62
  id: string;
package/dist/scheduler.js CHANGED
@@ -59,6 +59,9 @@ function stripJsoncComments(text) {
59
59
  * 上游实例 → 已解析依赖(决策 43):固化 id / 计划时刻 / 会话 / 上游工作区 / 产出。
60
60
  * 产出取上游实例的 `outputs` 列(回执声明并经 checkReceipt 校验的 JSON 数组,决策 32 修订);
61
61
  * 旧行 / 坏 JSON / 未声明 ⇒ 空数组(消息里如实写「未声明产出」)。
62
+ *
63
+ * ⚠️ 2026-10-10:`outputs` 的语义已收窄为**主文件**(过程文件另存 `process_outputs` 列、**不下传下游**)
64
+ * ⇒ 「下游只收主文件」在这里天然成立,本函数无需改动。
62
65
  */
63
66
  function resolvedOf(dep, upstream) {
64
67
  let outputs = [];
package/dist/store.d.ts CHANGED
@@ -45,7 +45,13 @@ export interface TaskInstance {
45
45
  lease_until: string | null;
46
46
  dispatched_at: string | null;
47
47
  finished_at: string | null;
48
+ /**
49
+ * 回执声明的产出 JSON 数组,**主文件桶**(2026-10-10 语义收窄:核心交付物)。
50
+ * 旧行只有这一列 ⇒ 天然等价于「只有主文件」。
51
+ */
48
52
  outputs: string | null;
53
+ /** 回执声明的**过程文件桶**(2026-10-10 新增):日志 / 中间产物 / 工作目录的 JSON 数组;旧行为 null。 */
54
+ process_outputs: string | null;
49
55
  token_in: number | null;
50
56
  token_out: number | null;
51
57
  token_in_cache: number | null;
@@ -159,8 +165,9 @@ export interface InstancePage {
159
165
  *
160
166
  * 为什么剔除:snapshot 是派发快照(提示词 / 附件 / 依赖的 JSON,单行 1.5–4 KB),
161
167
  * 日历要一次取满整月(几百到上千行),带着它就是数 MB 的白给开销;日历只要状态 / 时刻 / 产出 / 原因。
168
+ * 2026-10-10 起同时剔除 `process_outputs`(过程文件只有记录页 / 查看档 / 会话弹窗展示,日历不展示它)。
162
169
  */
163
- export type LiteTaskInstance = Omit<TaskInstance, 'snapshot'>;
170
+ export type LiteTaskInstance = Omit<TaskInstance, 'snapshot' | 'process_outputs'>;
164
171
  export interface LiteInstancePage {
165
172
  rows: LiteTaskInstance[];
166
173
  /** 触及上限被截断(调用方必须提示用户收窄过滤,**不静默丢**)。 */
@@ -226,7 +233,7 @@ export declare class TaskStore {
226
233
  * - 已迁移过的库:仅补决策 32 冗余字段(outputs / tokens 列),不重复去重。
227
234
  */
228
235
  private migrate;
229
- /** 决策 32(修订):兼容旧库(无 outputs / token 三拆列)。 */
236
+ /** 决策 32(修订)+ 2026-10-10:兼容旧库(无 outputs / process_outputs / token 三拆列)。 */
230
237
  private ensureInstanceColumns;
231
238
  close(): void;
232
239
  /**
@@ -364,8 +371,8 @@ export declare class TaskStore {
364
371
  * ⚠️ **执行记录永久保留,没有清除方法**(用户 2026-10-08 拍板):历史是用来查的、不该被清,
365
372
  * 原 `purgeHistory` 与 `historyRetentionDays` 配置一并删除。
366
373
  */
367
- /** 完成瞬间写回产出与 token 三拆列(决策 32 修订:总表冗余,task_events 仍为真源)。 */
368
- recordCompletion(id: string, outputs: string | null, tokenIn: number | null, tokenOut: number | null, tokenInCache: number | null): void;
374
+ /** 完成瞬间写回两桶产出与 token 三拆列(决策 32 修订:总表冗余,task_events 仍为真源)。 */
375
+ recordCompletion(id: string, outputs: string | null, processOutputs: string | null, tokenIn: number | null, tokenOut: number | null, tokenInCache: number | null): void;
369
376
  /** 按「任务 + 刻度」查实例(手动排查 / 备用回执通道用,不依赖 id 形态)。 */
370
377
  findBySlot(taskId: string, scheduledAt: string): TaskInstance | undefined;
371
378
  get(id: string): TaskInstance | undefined;
package/dist/store.js CHANGED
@@ -90,7 +90,9 @@ CREATE TABLE IF NOT EXISTS task_instances (
90
90
  lease_until TEXT,
91
91
  dispatched_at TEXT,
92
92
  finished_at TEXT,
93
+ -- 2026-10-10:outputs = 主文件(核心交付物);process_outputs = 过程文件。旧库补列(ensureInstanceColumns)。
93
94
  outputs TEXT,
95
+ process_outputs TEXT,
94
96
  token_in INTEGER,
95
97
  token_out INTEGER,
96
98
  token_in_cache INTEGER,
@@ -212,11 +214,14 @@ export class TaskStore {
212
214
  this.db.exec('CREATE UNIQUE INDEX idx_instances_slot ON task_instances(task_id, scheduled_at)');
213
215
  this.ensureInstanceColumns();
214
216
  }
215
- /** 决策 32(修订):兼容旧库(无 outputs / token 三拆列)。 */
217
+ /** 决策 32(修订)+ 2026-10-10:兼容旧库(无 outputs / process_outputs / token 三拆列)。 */
216
218
  ensureInstanceColumns() {
217
219
  const cols = new Set(this.db.prepare('PRAGMA table_info(task_instances)').all().map(c => c.name));
218
220
  if (!cols.has('outputs'))
219
221
  this.db.exec('ALTER TABLE task_instances ADD COLUMN outputs TEXT');
222
+ // 过程文件桶(2026-10-10):旧库补列后为 NULL = 无过程文件 ⇒ 界面照旧只显示主文件。
223
+ if (!cols.has('process_outputs'))
224
+ this.db.exec('ALTER TABLE task_instances ADD COLUMN process_outputs TEXT');
220
225
  // 旧版决策 32 曾用单个 tokens 列;拆成三列后卸下旧列(不支持 DROP COLUMN 的旧 SQLite 静默跳过)。
221
226
  if (cols.has('tokens')) {
222
227
  try {
@@ -482,11 +487,11 @@ export class TaskStore {
482
487
  * ⚠️ **执行记录永久保留,没有清除方法**(用户 2026-10-08 拍板):历史是用来查的、不该被清,
483
488
  * 原 `purgeHistory` 与 `historyRetentionDays` 配置一并删除。
484
489
  */
485
- /** 完成瞬间写回产出与 token 三拆列(决策 32 修订:总表冗余,task_events 仍为真源)。 */
486
- recordCompletion(id, outputs, tokenIn, tokenOut, tokenInCache) {
490
+ /** 完成瞬间写回两桶产出与 token 三拆列(决策 32 修订:总表冗余,task_events 仍为真源)。 */
491
+ recordCompletion(id, outputs, processOutputs, tokenIn, tokenOut, tokenInCache) {
487
492
  this.db
488
- .prepare('UPDATE task_instances SET outputs = ?, token_in = ?, token_out = ?, token_in_cache = ?, updated_at = ? WHERE id = ?')
489
- .run(outputs, tokenIn, tokenOut, tokenInCache, nowIso(), id);
493
+ .prepare('UPDATE task_instances SET outputs = ?, process_outputs = ?, token_in = ?, token_out = ?, token_in_cache = ?, updated_at = ? WHERE id = ?')
494
+ .run(outputs, processOutputs, tokenIn, tokenOut, tokenInCache, nowIso(), id);
490
495
  }
491
496
  /** 按「任务 + 刻度」查实例(手动排查 / 备用回执通道用,不依赖 id 形态)。 */
492
497
  findBySlot(taskId, scheduledAt) {
@@ -73,6 +73,8 @@ export interface AttachmentRef {
73
73
  ref: string;
74
74
  /** link 型:来源工作区 title(同一路径在不同工作区指向不同文件)。 */
75
75
  workspace?: string;
76
+ /** 是否为目录(2026-10-09 起允许把整个文件夹当附件)。 */
77
+ isDir?: boolean;
76
78
  }
77
79
  export interface ReconcileResult {
78
80
  /** 落定后的附件清单(upload 型的 ref 已改写成「相对任务目录」的路径)。 */
package/dist/tasks.d.ts CHANGED
@@ -62,6 +62,7 @@ export declare const taskDefinitionSchema: z.ZodObject<{
62
62
  }>;
63
63
  ref: z.ZodString;
64
64
  workspace: z.ZodOptional<z.ZodString>;
65
+ isDir: z.ZodOptional<z.ZodBoolean>;
65
66
  }, z.core.$strip>>>;
66
67
  }, z.core.$strip>;
67
68
  /** 用户书写形态:`id` 可缺省。 */
package/dist/tasks.js CHANGED
@@ -90,6 +90,8 @@ export const taskDefinitionSchema = z.object({
90
90
  ref: z.string().min(1),
91
91
  /** link 型必带:来源工作区 title(同一路径在不同工作区指向不同文件)。 */
92
92
  workspace: z.string().optional(),
93
+ /** 是否为目录(2026-10-09 起允许把整个文件夹当附件)。 */
94
+ isDir: z.boolean().optional(),
93
95
  })
94
96
  .refine(
95
97
  // 唯一实现 = attachment-allowlist.isSafeAttachmentRef(客户端保存前校验 / 真删 / 上传定位同源)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-task-dispatch-table",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Scheduled task dispatcher: a dsh host plugin that drives recurring agent tasks from a single task table, dispatching each run as an independent dsh session (scheduling itself uses zero model calls)",
5
5
  "icon": "./assets/icon-scheduler.svg",
6
6
  "keywords": [