dsh-plugin-deck-studio 0.2.6 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cordis.example.yml +85 -7
- package/cordis.patch.yml +78 -7
- package/index.js +176 -25
- package/lib/capabilities.js +234 -0
- package/lib/citation-check.js +93 -0
- package/lib/config.js +27 -0
- package/lib/deck-brief.js +35 -1
- package/lib/deck-docs.js +45 -7
- package/lib/deck-templates.js +8 -1
- package/lib/deck-workspace.js +27 -1
- package/lib/designs.js +15 -0
- package/lib/fact-labels.js +81 -0
- package/lib/first-draft.js +119 -0
- package/lib/image-review.js +523 -0
- package/lib/page-contract.js +112 -0
- package/lib/proposal-decision.js +249 -75
- package/lib/review-agent.js +1 -1
- package/lib/review-personas.js +49 -2
- package/lib/review-protocol.js +411 -0
- package/lib/review-readers.js +300 -0
- package/lib/review-score.js +198 -0
- package/lib/title-chain.js +122 -0
- package/lib/tools.js +971 -77
- package/package.json +2 -1
- package/seeds/docs/assets/screenshots/f186-01-agent-style-create.png +0 -0
- package/seeds/docs/assets/screenshots/f186-02-designs-card.png +0 -0
- package/seeds/docs/assets/screenshots/f187-01-review-readers.png +0 -0
- package/seeds/docs/assets/screenshots/f187-02-gap-report.png +0 -0
- package/seeds/docs/assets/screenshots/i14-01-create.png +0 -0
- package/seeds/docs/assets/screenshots/i14-02-first-draft.png +0 -0
- package/seeds/docs/assets/screenshots/i14-03-rendered.png +0 -0
- package/seeds/docs/assets/screenshots/i14-04-image-review.png +0 -0
- package/seeds/docs/assets/screenshots/i14-05-p01-page.png +0 -0
- package/seeds/docs/install.en.md +13 -3
- package/seeds/docs/install.md +13 -4
- package/seeds/docs/sample-deck.en.md +1 -1
- package/seeds/docs/sample-deck.md +1 -1
- package/seeds/docs/styles.en.md +2 -1
- package/seeds/docs/styles.md +14 -4
- package/seeds/samples/DSA-01.jpg +0 -0
- package/seeds/samples/DSA-02.jpg +0 -0
- package/seeds/samples/DSA-03.jpg +0 -0
- package/seeds/styles/DSA.json +22 -0
- package/ui/workbench.css +5 -0
- package/ui/workbench.html +98 -6
package/lib/proposal-decision.js
CHANGED
|
@@ -14,6 +14,7 @@ import { join } from "node:path";
|
|
|
14
14
|
|
|
15
15
|
import { SCRIPT_FIELD_CELL, proposalNow, scriptSectionRange } from "./deck-workspace.js";
|
|
16
16
|
import { commitFiles } from "./git-commit.js";
|
|
17
|
+
import { isFirstDraft, writeAutoland } from "./first-draft.js";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* 本机新建 deck:确认后的脚本内容回写源 script.md + git 留痕(F-100 / ADR-0022)。
|
|
@@ -33,8 +34,221 @@ export function createScriptToSourceSync({ adapters = [], isLocalDeck = null } =
|
|
|
33
34
|
};
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* 单条 script 提案 → 新文本(纯函数:不写盘、不碰 manifest)。
|
|
39
|
+
* 行级替换 + 改标题时同步分节标题行(F-120)。批量首稿落版与单条落版共用,避免两套替换规则。
|
|
40
|
+
*/
|
|
41
|
+
export function applyScriptProposalToText(current, proposal) {
|
|
42
|
+
const lines = String(current ?? "").split("\n");
|
|
43
|
+
const range = scriptSectionRange(String(current ?? ""), proposal.page);
|
|
44
|
+
const start = range ? range.start + 1 : 0;
|
|
45
|
+
const end = range ? range.end : lines.length;
|
|
46
|
+
// F-181/F-182:契约 / 证据这类「这页原本没有的字段」提案,before 为空串;
|
|
47
|
+
// 以前靠「找一行空行」插入,页面里没有空行就会误判为已过期(真机 500)。
|
|
48
|
+
// 现在直接插在页标题行之后,语义就是「新增字段」。
|
|
49
|
+
if (!String(proposal.before ?? "").length && range) {
|
|
50
|
+
lines.splice(start, 0, String(proposal.after ?? ""));
|
|
51
|
+
return lines.join("\n");
|
|
52
|
+
}
|
|
53
|
+
let index = -1;
|
|
54
|
+
for (let i = start; i < end; i += 1) {
|
|
55
|
+
if (lines[i] === proposal.before) {
|
|
56
|
+
index = i;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (index < 0) {
|
|
61
|
+
throw new Error(`提案已过期:工作副本里找不到提案 ${proposal.proposalId} 的 before 行(已被其他 revision 改动)。请驳回这条提案,让 Agent 基于当前脚本重新提案。`);
|
|
62
|
+
}
|
|
63
|
+
lines[index] = proposal.after;
|
|
64
|
+
if (proposal.field === "title") {
|
|
65
|
+
const newTitle = String(proposal.value ?? proposal.after ?? "").replace(/^\s*\*\*[^*]+\*\*\s*[::]\s*/, "").trim();
|
|
66
|
+
if (newTitle && range) {
|
|
67
|
+
for (let i = range.start; i < Math.min(range.start + 3, lines.length); i += 1) {
|
|
68
|
+
if (/^#{2,3}\s*P0*\d+\s*[·.\-–—:]/.test(lines[i].trim())) {
|
|
69
|
+
lines[i] = lines[i].replace(/(^#{2,3}\s*P0*\d+\s*[·.\-–—:]\s*).*$/, `$1${newTitle}`);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return lines.join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 把 script 提案的结果同步进 manifest,保持 deck.json 与 script 一致。 */
|
|
79
|
+
export function syncScriptManifest(workspace, proposal) {
|
|
80
|
+
const deck = workspace.manifest(proposal.deckId);
|
|
81
|
+
const slide = deck?.slides?.find((candidate) => candidate.id === proposal.pageId);
|
|
82
|
+
if (!deck || !slide) return;
|
|
83
|
+
if (String(proposal.after ?? "").startsWith("|") && SCRIPT_FIELD_CELL[proposal.field] != null) {
|
|
84
|
+
const cell = proposal.after.split("|")[SCRIPT_FIELD_CELL[proposal.field] + 1];
|
|
85
|
+
if (typeof cell === "string" && cell.trim()) slide[proposal.field] = cell.trim();
|
|
86
|
+
workspace.saveManifest(deck);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (proposal.field === "title" || proposal.field === "question" || proposal.field === "claim" || proposal.field === "body") {
|
|
90
|
+
const value = proposal.value != null ? String(proposal.value).trim() : String(proposal.after ?? "").replace(/^\s*\*\*[^*]+\*\*\s*[::]\s*/, "").trim();
|
|
91
|
+
if (value) slide[proposal.field] = value;
|
|
92
|
+
workspace.saveManifest(deck);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 追加页提案 → 新文本;页同步进 manifest(已存在同页号时抛错)。 */
|
|
97
|
+
export function syncAddPageManifest(workspace, proposal, appliedPages) {
|
|
98
|
+
const deck = workspace.manifest(proposal.deckId);
|
|
99
|
+
if (!deck) throw new Error(`manifest 缺失:${proposal.deckId}`);
|
|
100
|
+
deck.slides = Array.isArray(deck.slides) ? deck.slides : [];
|
|
101
|
+
const existingIds = new Set(deck.slides.map((slide) => slide.id));
|
|
102
|
+
for (const spec of appliedPages) {
|
|
103
|
+
const pageId = `p${String(spec.page).padStart(2, "0")}`;
|
|
104
|
+
if (existingIds.has(pageId)) throw new Error(`manifest 已有 ${pageId},拒绝重复追加(可能已确认过;请重新提案)`);
|
|
105
|
+
deck.slides.push({
|
|
106
|
+
id: pageId,
|
|
107
|
+
order: spec.page,
|
|
108
|
+
type: "story",
|
|
109
|
+
title: spec.title,
|
|
110
|
+
question: spec.question,
|
|
111
|
+
claim: spec.claim,
|
|
112
|
+
source: "script.md",
|
|
113
|
+
asset: null,
|
|
114
|
+
visualBrief: spec.visualBrief,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
deck.slides.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
118
|
+
workspace.saveManifest(deck);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 批量落版(F-176 / 智力升级 I-18):把一批 script 提案合成**一条** revision。
|
|
123
|
+
* 首稿自动落版(F-173)与用户「接受并更新」勾选批次共用这一段,保证两条路径的写入语义一致。
|
|
124
|
+
*/
|
|
125
|
+
function landProposalBatch({ store, workspace, proposals, decidedBy, quote = "", outcome, sessionId = null, syncScriptToSource = null, revisionNote }) {
|
|
126
|
+
const list = Array.isArray(proposals) ? proposals.filter(Boolean) : [];
|
|
127
|
+
if (!list.length) throw new Error("批量落版没有可应用的提案");
|
|
128
|
+
const deckId = list[0].deckId;
|
|
129
|
+
for (const proposal of list) {
|
|
130
|
+
if (proposal.deckId !== deckId) throw new Error("批量落版只处理同一个 deck 的提案");
|
|
131
|
+
if (proposal.status !== "waiting-user") throw new Error(`提案 ${proposal.proposalId} 状态为 ${proposal.status},只有 waiting-user 可落版`);
|
|
132
|
+
if (proposal.kind !== "script" && proposal.kind !== "script-add-page" && proposal.kind !== "manifest") {
|
|
133
|
+
throw new Error(`批量落版不支持 ${proposal.kind} 类提案(只支持 script 正文 / 追加页 / visualBrief)`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const current = workspace.scriptCurrent(deckId);
|
|
137
|
+
if (current == null) throw new Error(`script 工作副本缺失:${deckId}`);
|
|
138
|
+
let text = current;
|
|
139
|
+
let manifestTouched = false;
|
|
140
|
+
for (const proposal of list) {
|
|
141
|
+
if (proposal.kind === "manifest") {
|
|
142
|
+
// visualBrief 只动 manifest,不产生 script 文本变化;和 script 提案合成同一条 revision,
|
|
143
|
+
// 否则首稿「正文 + 画面要求」一起写会被拆成两条,用户勾选一次却落两次版。
|
|
144
|
+
const deck = workspace.manifest(deckId);
|
|
145
|
+
const slide = deck?.slides?.find((candidate) => candidate.id === proposal.pageId);
|
|
146
|
+
if (!deck || !slide) throw new Error(`manifest 里没有 ${proposal.pageId}`);
|
|
147
|
+
slide.visualBrief = String(proposal.value ?? proposal.after ?? "").trim();
|
|
148
|
+
slide.visualBriefDesign = proposal.writtenUnderDesign ?? null;
|
|
149
|
+
workspace.saveManifest(deck);
|
|
150
|
+
manifestTouched = true;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (proposal.kind === "script-add-page") {
|
|
154
|
+
const applied = workspace.appendScriptPages(text, proposal.pages ?? []);
|
|
155
|
+
if (!applied.pages.length) throw new Error(`提案 ${proposal.proposalId} 没有可追加的页`);
|
|
156
|
+
text = applied.text;
|
|
157
|
+
syncAddPageManifest(workspace, proposal, applied.pages);
|
|
158
|
+
proposal.pageCountAfter = applied.pageCount;
|
|
159
|
+
} else {
|
|
160
|
+
text = applyScriptProposalToText(text, proposal);
|
|
161
|
+
syncScriptManifest(workspace, proposal);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const saved = workspace.saveScriptRevision(deckId, text, revisionNote);
|
|
165
|
+
for (const proposal of list) {
|
|
166
|
+
workspace.updateProposal(proposal, {
|
|
167
|
+
status: "applied",
|
|
168
|
+
decidedAt: proposalNow(),
|
|
169
|
+
decidedBy,
|
|
170
|
+
confirmationQuote: String(quote ?? ""),
|
|
171
|
+
quoteMissing: decidedBy === "agent" ? false : !String(quote ?? "").trim(),
|
|
172
|
+
revision: saved.revision,
|
|
173
|
+
...(outcome === "autoland" ? { autoland: true } : {}),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const sourceSync = typeof syncScriptToSource === "function" ? syncScriptToSource(deckId, text, list[0]) : null;
|
|
177
|
+
const reviewRefs = [...new Set(list.map((proposal) => proposal.reviewRef).filter(Boolean))];
|
|
178
|
+
store.recordEvent({
|
|
179
|
+
type: "deck.edit",
|
|
180
|
+
sessionId,
|
|
181
|
+
taskId: null,
|
|
182
|
+
deckId,
|
|
183
|
+
slideIds: [...new Set(list.map((proposal) => proposal.pageId))],
|
|
184
|
+
actor: decidedBy === "user" ? "user" : "agent",
|
|
185
|
+
status: "succeeded",
|
|
186
|
+
payload: {
|
|
187
|
+
proposalIds: list.map((proposal) => proposal.proposalId),
|
|
188
|
+
outcome,
|
|
189
|
+
revision: saved.revision,
|
|
190
|
+
...(manifestTouched ? { manifestTouched: true } : {}),
|
|
191
|
+
...(reviewRefs.length ? { reviewRefs } : {}),
|
|
192
|
+
...(sourceSync ? { sourceSync } : {}),
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
return { deckId, revision: saved.revision, proposalIds: list.map((proposal) => proposal.proposalId), sourceSync, reviewRefs, beforeText: current };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 首稿批量落版(F-173):把一批 script 提案合成**一条** revision 直接落库,不逐条等确认。
|
|
200
|
+
* 只在「一次都没落过版」时可用;判定与元数据写盘都在这里,Agent 说错话也落不了有历史版本的稿子。
|
|
201
|
+
*/
|
|
202
|
+
export function applyFirstDraftBatch({ store, workspace, proposals, autoland = null, sessionId = null, syncScriptToSource = null }) {
|
|
203
|
+
const list = Array.isArray(proposals) ? proposals.filter(Boolean) : [];
|
|
204
|
+
if (!list.length) throw new Error("首稿自动落版没有可应用的提案");
|
|
205
|
+
const deckId = list[0].deckId;
|
|
206
|
+
if (!isFirstDraft(workspace, deckId)) {
|
|
207
|
+
throw new Error(`首稿自动落版被拒:${deckId} 已有已落版 revision。自动落版只用于第一次落库,这次请等用户确认后再落。`);
|
|
208
|
+
}
|
|
209
|
+
const landed = landProposalBatch({
|
|
210
|
+
store,
|
|
211
|
+
workspace,
|
|
212
|
+
proposals: list,
|
|
213
|
+
decidedBy: "agent",
|
|
214
|
+
outcome: "autoland",
|
|
215
|
+
sessionId,
|
|
216
|
+
syncScriptToSource,
|
|
217
|
+
revisionNote: `first draft autoland: ${list.length} edit(s) ${list.map((proposal) => proposal.proposalId).join(" ")}`,
|
|
218
|
+
});
|
|
219
|
+
const record = writeAutoland(workspace, deckId, {
|
|
220
|
+
revision: landed.revision,
|
|
221
|
+
proposal: list[0],
|
|
222
|
+
selfReview: autoland?.selfReview ?? {},
|
|
223
|
+
draftRound: autoland?.draftRound ?? 1,
|
|
224
|
+
beforeText: landed.beforeText ?? "",
|
|
225
|
+
});
|
|
226
|
+
return { deckId, revision: landed.revision, proposalIds: landed.proposalIds, autoland: record, ...(landed.sourceSync ? { sourceSync: landed.sourceSync } : {}) };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 用户「接受并更新」:把勾选的一批评审修改合成一条 revision(F-176 / I-18)。
|
|
231
|
+
* 与首稿自动落版的区别只在「谁决定」——判定、写入、元数据、回滚语义全部共用一套。
|
|
232
|
+
*/
|
|
233
|
+
export function applyConfirmedBatch({ store, workspace, proposals, quote = "", sessionId = null, syncScriptToSource = null }) {
|
|
234
|
+
const list = Array.isArray(proposals) ? proposals.filter(Boolean) : [];
|
|
235
|
+
if (!list.length) throw new Error("接受并更新:没有勾选任何修改");
|
|
236
|
+
const landed = landProposalBatch({
|
|
237
|
+
store,
|
|
238
|
+
workspace,
|
|
239
|
+
proposals: list,
|
|
240
|
+
decidedBy: "user",
|
|
241
|
+
quote,
|
|
242
|
+
outcome: "accepted-batch",
|
|
243
|
+
sessionId,
|
|
244
|
+
syncScriptToSource,
|
|
245
|
+
revisionNote: `accept review batch: ${list.length} edit(s) ${list.map((proposal) => proposal.reviewRef || proposal.proposalId).join(" ")}`,
|
|
246
|
+
});
|
|
247
|
+
return { deckId: landed.deckId, revision: landed.revision, proposalIds: landed.proposalIds, reviewRefs: landed.reviewRefs, ...(landed.sourceSync ? { sourceSync: landed.sourceSync } : {}) };
|
|
248
|
+
}
|
|
249
|
+
|
|
36
250
|
/** 把一条 waiting-user 提案落成 revision(或记为用户驳回)。 */
|
|
37
|
-
export function decideProposal({ store, workspace, proposal, confirmed, quote = "", sessionId = null, syncScriptToSource = null }) {
|
|
251
|
+
export function decideProposal({ store, workspace, proposal, confirmed, quote = "", sessionId = null, syncScriptToSource = null, autoland = null }) {
|
|
38
252
|
if (!proposal) throw new Error("提案不存在");
|
|
39
253
|
if (proposal.status !== "waiting-user") throw new Error(`提案 ${proposal.proposalId} 状态为 ${proposal.status},只有 waiting-user 可裁决`);
|
|
40
254
|
|
|
@@ -56,6 +270,19 @@ export function decideProposal({ store, workspace, proposal, confirmed, quote =
|
|
|
56
270
|
return { proposalId: proposal.proposalId, status: "rejected", deckId: proposal.deckId, text: `提案 ${proposal.proposalId} 已按用户意思驳回并留痕。` };
|
|
57
271
|
}
|
|
58
272
|
|
|
273
|
+
// F-173 首稿自动落版:只有「一次都没落过 revision」的稿子允许零确认落版。
|
|
274
|
+
// 判定放在这里(而不是 Agent 侧):Agent 说错话也落不了有历史版本的稿子。
|
|
275
|
+
const wantAutoland = autoland != null;
|
|
276
|
+
if (wantAutoland) {
|
|
277
|
+
if (proposal.kind !== "script" && proposal.kind !== "script-add-page") {
|
|
278
|
+
throw new Error("自动落版只用于 script 内容(正文 / 追加页);visualBrief 这类仍走提案确认。");
|
|
279
|
+
}
|
|
280
|
+
if (!isFirstDraft(workspace, proposal.deckId)) {
|
|
281
|
+
throw new Error(`首稿自动落版被拒:${proposal.deckId} 已有已落版 revision。自动落版只用于第一次落库,这次请等用户确认后再落。`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const beforeText = wantAutoland ? workspace.scriptCurrent(proposal.deckId) ?? "" : null;
|
|
285
|
+
|
|
59
286
|
// F-137:确认不再强制「必须引用原话」。用户在 Chat 说「就这样 / 可以」、或在工作台点「确认落版」都算确认;
|
|
60
287
|
// 拿不到原话时仍落版,但事件里记 quoteMissing=true 留痕(审计可查),并把警告回给 Agent。
|
|
61
288
|
const confirmationQuote = String(quote ?? "").trim();
|
|
@@ -67,52 +294,9 @@ export function decideProposal({ store, workspace, proposal, confirmed, quote =
|
|
|
67
294
|
if (proposal.kind === "script") {
|
|
68
295
|
const current = workspace.scriptCurrent(proposal.deckId);
|
|
69
296
|
if (current == null) throw new Error(`script 工作副本缺失:${proposal.deckId}`);
|
|
70
|
-
|
|
71
|
-
const lines = current.split("\n");
|
|
72
|
-
// before 行必须落在「这一页」的分节里;否则同页对面(或别的页)的相同占位行会被误改。
|
|
73
|
-
const range = scriptSectionRange(current, proposal.page);
|
|
74
|
-
const start = range ? range.start + 1 : 0;
|
|
75
|
-
const end = range ? range.end : lines.length;
|
|
76
|
-
let index = -1;
|
|
77
|
-
for (let i = start; i < end; i += 1) {
|
|
78
|
-
if (lines[i] === proposal.before) {
|
|
79
|
-
index = i;
|
|
80
|
-
break;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
if (index < 0) throw new Error(`提案已过期:工作副本里找不到提案 ${proposal.proposalId} 的 before 行(已被其他 revision 改动)。请驳回这条提案,让 Agent 基于当前脚本重新提案。`);
|
|
84
|
-
lines[index] = proposal.after;
|
|
85
|
-
// F-120:改标题时把分节标题行 `### PNN · 旧标题` 一起改掉。历史 bug:只改 `**标题**` 行,
|
|
86
|
-
// 标题行一直留着旧标题,错位后没人看得出来,工作副本与 manifest 越走越远。
|
|
87
|
-
if (proposal.field === "title") {
|
|
88
|
-
const newTitle = String(proposal.value ?? proposal.after ?? "").replace(/^\s*\*\*[^*]+\*\*\s*[::]\s*/, "").trim();
|
|
89
|
-
if (newTitle && range) {
|
|
90
|
-
for (let i = range.start; i < Math.min(range.start + 3, lines.length); i += 1) {
|
|
91
|
-
if (/^#{2,3}\s*P0*\d+\s*[·.\-–—:]/.test(lines[i].trim())) {
|
|
92
|
-
lines[i] = lines[i].replace(/(^#{2,3}\s*P0*\d+\s*[·.\-–—:]\s*).*$/, `$1${newTitle}`);
|
|
93
|
-
break;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
appliedScriptText = lines.join("\n");
|
|
297
|
+
appliedScriptText = applyScriptProposalToText(current, proposal);
|
|
99
298
|
revisionInfo = workspace.saveScriptRevision(proposal.deckId, appliedScriptText, `apply ${proposal.proposalId} (${proposal.pageId}.${proposal.field}) by user confirmation`);
|
|
100
|
-
|
|
101
|
-
const deck = workspace.manifest(proposal.deckId);
|
|
102
|
-
const slide = deck?.slides?.find((candidate) => candidate.id === proposal.pageId);
|
|
103
|
-
if (deck && slide) {
|
|
104
|
-
if (String(proposal.after ?? "").startsWith("|") && SCRIPT_FIELD_CELL[proposal.field] != null) {
|
|
105
|
-
// 表格惯例:after 是完整表格行,第 0 个 split 段是行首空串。
|
|
106
|
-
const cell = proposal.after.split("|")[SCRIPT_FIELD_CELL[proposal.field] + 1];
|
|
107
|
-
if (typeof cell === "string" && cell.trim()) slide[proposal.field] = cell.trim();
|
|
108
|
-
workspace.saveManifest(deck);
|
|
109
|
-
} else if (proposal.field === "title" || proposal.field === "question" || proposal.field === "claim" || proposal.field === "body") {
|
|
110
|
-
// 分节惯例(F-75 / F-109):直接取提案里的纯值;老提案没有 value 就从 `**字段**:值` 里剥。
|
|
111
|
-
const value = proposal.value != null ? String(proposal.value).trim() : String(proposal.after ?? "").replace(/^\s*\*\*[^*]+\*\*\s*[::]\s*/, "").trim();
|
|
112
|
-
if (value) slide[proposal.field] = value;
|
|
113
|
-
workspace.saveManifest(deck);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
299
|
+
syncScriptManifest(workspace, proposal);
|
|
116
300
|
} else if (proposal.kind === "script-add-page") {
|
|
117
301
|
// F-99:追加页的写回分支。不复用上面的行级替换(before="" 会命中第一处空行),
|
|
118
302
|
// 而是基于**当前**工作副本重算追加结果,避免提案后又被别的 revision 改过而覆盖。
|
|
@@ -125,30 +309,7 @@ export function decideProposal({ store, workspace, proposal, confirmed, quote =
|
|
|
125
309
|
applied.text,
|
|
126
310
|
`append ${applied.pages.length} page(s) ${applied.pages.map((spec) => `P${String(spec.page).padStart(2, "0")}`).join(" ")} by user confirmation (${proposal.proposalId})`,
|
|
127
311
|
);
|
|
128
|
-
|
|
129
|
-
if (!deck) throw new Error(`manifest 缺失:${proposal.deckId}`);
|
|
130
|
-
deck.slides = Array.isArray(deck.slides) ? deck.slides : [];
|
|
131
|
-
const existingIds = new Set(deck.slides.map((slide) => slide.id));
|
|
132
|
-
for (const spec of applied.pages) {
|
|
133
|
-
const pageId = `p${String(spec.page).padStart(2, "0")}`;
|
|
134
|
-
if (existingIds.has(pageId)) {
|
|
135
|
-
throw new Error(`manifest 已有 ${pageId},拒绝重复追加(可能已确认过;请重新提案)`);
|
|
136
|
-
}
|
|
137
|
-
deck.slides.push({
|
|
138
|
-
id: pageId,
|
|
139
|
-
order: spec.page,
|
|
140
|
-
type: "story",
|
|
141
|
-
title: spec.title,
|
|
142
|
-
question: spec.question,
|
|
143
|
-
claim: spec.claim,
|
|
144
|
-
source: "script.md",
|
|
145
|
-
asset: null,
|
|
146
|
-
// 追加页默认没有画面要求;出图前先走 deck_script_propose_edit 补 visualBrief。
|
|
147
|
-
visualBrief: spec.visualBrief,
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
deck.slides.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
151
|
-
workspace.saveManifest(deck);
|
|
312
|
+
syncAddPageManifest(workspace, proposal, applied.pages);
|
|
152
313
|
proposal.pageCountAfter = applied.pageCount;
|
|
153
314
|
appliedScriptText = applied.text;
|
|
154
315
|
} else {
|
|
@@ -161,6 +322,17 @@ export function decideProposal({ store, workspace, proposal, confirmed, quote =
|
|
|
161
322
|
workspace.saveManifest(deck);
|
|
162
323
|
}
|
|
163
324
|
|
|
325
|
+
// F-173:自动落版留痕(自改摘要 + 落版前快照),回滚要用。
|
|
326
|
+
const autolandRecord = wantAutoland && revisionInfo
|
|
327
|
+
? writeAutoland(workspace, proposal.deckId, {
|
|
328
|
+
revision: revisionInfo.revision,
|
|
329
|
+
proposal,
|
|
330
|
+
selfReview: autoland?.selfReview ?? {},
|
|
331
|
+
draftRound: autoland?.draftRound ?? 1,
|
|
332
|
+
beforeText,
|
|
333
|
+
})
|
|
334
|
+
: null;
|
|
335
|
+
|
|
164
336
|
// F-100 / ADR-0022:本机新建 deck 的脚本改动回写源 script.md;受控客户源保持只读。
|
|
165
337
|
const sourceSync = appliedScriptText != null && typeof syncScriptToSource === "function"
|
|
166
338
|
? syncScriptToSource(proposal.deckId, appliedScriptText, proposal)
|
|
@@ -169,11 +341,11 @@ export function decideProposal({ store, workspace, proposal, confirmed, quote =
|
|
|
169
341
|
if (quoteMissing) {
|
|
170
342
|
store.recordEvent({ type: "deck.edit", sessionId, taskId: task?.taskId ?? null, deckId: proposal.deckId, slideIds: affectedSlideIds, actor: "system", status: "succeeded", payload: { proposalId: proposal.proposalId, outcome: "applied", quoteMissing: true } });
|
|
171
343
|
}
|
|
172
|
-
workspace.updateProposal(proposal, { status: "applied", decidedAt: proposalNow(), decidedBy: "user", confirmationQuote, quoteMissing, revision: revisionInfo?.revision ?? null });
|
|
344
|
+
workspace.updateProposal(proposal, { status: "applied", decidedAt: proposalNow(), decidedBy: autolandRecord ? "agent" : "user", confirmationQuote, quoteMissing, revision: revisionInfo?.revision ?? null, ...(autolandRecord ? { autoland: true } : {}) });
|
|
173
345
|
if (task) {
|
|
174
346
|
store.taskUpdate(task.taskId, {
|
|
175
347
|
status: "succeeded",
|
|
176
|
-
actor: "user",
|
|
348
|
+
actor: autolandRecord ? "agent" : "user",
|
|
177
349
|
outputRefs: [...(revisionInfo ? [`revision:${proposal.deckId}/script/${revisionInfo.revision}`] : []), `proposal:${proposal.proposalId}`],
|
|
178
350
|
});
|
|
179
351
|
}
|
|
@@ -183,9 +355,9 @@ export function decideProposal({ store, workspace, proposal, confirmed, quote =
|
|
|
183
355
|
taskId: task?.taskId ?? null,
|
|
184
356
|
deckId: proposal.deckId,
|
|
185
357
|
slideIds: affectedSlideIds,
|
|
186
|
-
actor: "user",
|
|
358
|
+
actor: autolandRecord ? "agent" : "user",
|
|
187
359
|
status: "succeeded",
|
|
188
|
-
payload: { proposalId: proposal.proposalId, outcome: "applied", confirmationQuote, quoteMissing, revision: revisionInfo?.revision ?? null, revisionPath: revisionInfo?.path ?? null, ...(sourceSync ? { sourceSync } : {}), ...(proposal.kind === "script-add-page" ? { field: "add-page", pages: proposal.pageIds, pageCountAfter: proposal.pageCountAfter ?? null } : {}) },
|
|
360
|
+
payload: { proposalId: proposal.proposalId, outcome: "applied", confirmationQuote, quoteMissing, revision: revisionInfo?.revision ?? null, revisionPath: revisionInfo?.path ?? null, ...(autolandRecord ? { autoland: true, selfReview: autolandRecord.selfReview, draftRound: autolandRecord.draftRound } : {}), ...(sourceSync ? { sourceSync } : {}), ...(proposal.kind === "script-add-page" ? { field: "add-page", pages: proposal.pageIds, pageCountAfter: proposal.pageCountAfter ?? null } : {}) },
|
|
189
361
|
});
|
|
190
362
|
|
|
191
363
|
return {
|
|
@@ -196,9 +368,11 @@ export function decideProposal({ store, workspace, proposal, confirmed, quote =
|
|
|
196
368
|
deckId: proposal.deckId,
|
|
197
369
|
...(sourceSync ? { sourceSync } : {}),
|
|
198
370
|
...(quoteMissing ? { quoteMissing: true } : {}),
|
|
371
|
+
...(autolandRecord ? { autoland: autolandRecord } : {}),
|
|
199
372
|
text:
|
|
200
373
|
`提案 ${proposal.proposalId} 已应用${revisionInfo ? `,script revision ${revisionInfo.revision} 已写入(旧版保留)` : ""}` +
|
|
201
374
|
`。${sourceSync ? (sourceSync.ok ? `源 script.md 已回写并提交(${sourceSync.commit})。` : `源 script.md 已回写,git 未提交:${sourceSync.error}。`) : ""}` +
|
|
202
|
-
`${
|
|
375
|
+
`${autolandRecord ? `\n这是首稿(未经确认的自动落版):自改 ${autolandRecord.selfReview.rounds} 轮${autolandRecord.selfReview.summary ? `,主要改了「${autolandRecord.selfReview.summary}」` : ""}。不满意可以调 deck_draft_rollback 一键回到落版前。` : ""}` +
|
|
376
|
+
`${quoteMissing && !autolandRecord ? "\n(这次没带用户原话,已按确认动作落版并标 quoteMissing 留痕;下次能引用原话更好。)" : ""}`,
|
|
203
377
|
};
|
|
204
378
|
}
|
package/lib/review-agent.js
CHANGED
|
@@ -87,7 +87,7 @@ export function registerReviewAgentProvider(ctx, dependencies = {}) {
|
|
|
87
87
|
request.agentOptions?.reasoningEffort === undefined) delete options.reasoningEffort;
|
|
88
88
|
// Only model controls cross the boundary: no authoring/preset/runtime options.
|
|
89
89
|
const agentOptions = { provider: options.provider, model: options.model,
|
|
90
|
-
maxTokens: request.agentOptions?.maxTokens ??
|
|
90
|
+
maxTokens: request.agentOptions?.maxTokens ?? 8192,
|
|
91
91
|
...(options.reasoningEffort === undefined ? {} : { reasoningEffort: options.reasoningEffort }) };
|
|
92
92
|
if (!agentOptions.provider || !agentOptions.model) throw new Error("Internal review requires an explicit provider/model route");
|
|
93
93
|
if (!Number.isSafeInteger(agentOptions.maxTokens) || agentOptions.maxTokens <= 0) throw new Error("Invalid review output token limit");
|
package/lib/review-personas.js
CHANGED
|
@@ -47,16 +47,63 @@ export function splitAudience(audience) {
|
|
|
47
47
|
if (!raw) return [];
|
|
48
48
|
const colon = raw.search(/[::]/);
|
|
49
49
|
const head = colon >= 0 ? raw.slice(0, colon).trim() : raw;
|
|
50
|
-
return head
|
|
51
|
-
.split(/[、,,;;\/|\n]+/)
|
|
50
|
+
return mergeContinuations(splitTopLevel(head))
|
|
52
51
|
.map((part) => part.trim())
|
|
53
52
|
.filter((part) => part.length > 0)
|
|
54
53
|
// 「技术负责人和技术团队」这种句子里再拆一层
|
|
55
54
|
.flatMap((part) => part.split(/\s*(?:以及|还有|和|与|及)\s*/))
|
|
56
55
|
.map((part) => part.replace(/^[·\-*\s]+|[。;;,,.\s]+$/g, "").trim())
|
|
56
|
+
// 听众常写成「角色 + 一串说明」:取角色那一段(第一个逗号前),不要整句当标签
|
|
57
|
+
.map((part) => (part.length > 40 ? part.split(/[,,]/)[0].trim() : part))
|
|
57
58
|
.filter((part) => part.length > 0 && part.length <= 40);
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/**
|
|
62
|
+
* 只按顶层分隔符切分:`研发工程师(前端 / 后端 / 算法)` 是一个听众,不是三个。
|
|
63
|
+
* 括号(含中英文 / 【】/ 直角引号)里的分隔符属于描述,不参与切分。
|
|
64
|
+
*/
|
|
65
|
+
function splitTopLevel(text) {
|
|
66
|
+
const open = new Set(["(", "(", "【", "[", "「", "『"]);
|
|
67
|
+
const close = new Set([")", ")", "】", "]", "」", "』"]);
|
|
68
|
+
const separators = new Set([..."、,,;;/|\n"]);
|
|
69
|
+
const parts = [];
|
|
70
|
+
let depth = 0;
|
|
71
|
+
let buffer = "";
|
|
72
|
+
for (const char of String(text ?? "")) {
|
|
73
|
+
if (open.has(char)) depth += 1;
|
|
74
|
+
else if (close.has(char)) depth = Math.max(0, depth - 1);
|
|
75
|
+
if (depth === 0 && separators.has(char)) {
|
|
76
|
+
parts.push(buffer);
|
|
77
|
+
buffer = "";
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
buffer += char;
|
|
81
|
+
}
|
|
82
|
+
parts.push(buffer);
|
|
83
|
+
return parts;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 说明性片段并回上一个听众:真实 intent 常写「角色(范围),日常…,但…,没有…」,
|
|
88
|
+
* 逗号后面是处境说明,不是第二个听众。以连接词 / 转折词开头或过长的片段一律并回去。
|
|
89
|
+
*/
|
|
90
|
+
function mergeContinuations(parts) {
|
|
91
|
+
const merged = [];
|
|
92
|
+
let sticky = false; // 一旦进入「处境说明」,后面都算同一句说明,直到用尽
|
|
93
|
+
for (const raw of parts) {
|
|
94
|
+
const part = String(raw ?? "").trim();
|
|
95
|
+
if (!part) continue;
|
|
96
|
+
const looksLikeExplanation = /^(日常|平时|现在|目前|大多|一般|主要|同时|另外|但|而|也|还|会|被|没有|其实|就是|比如|例如|系统|并未)/.test(part) || part.length > 30;
|
|
97
|
+
if (merged.length && (sticky || looksLikeExplanation)) {
|
|
98
|
+
merged[merged.length - 1] = `${merged[merged.length - 1]},${part}`;
|
|
99
|
+
sticky = true;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
merged.push(part);
|
|
103
|
+
}
|
|
104
|
+
return merged;
|
|
105
|
+
}
|
|
106
|
+
|
|
60
107
|
/** 同一个听众去重(大小写 / 空格不敏感)。 */
|
|
61
108
|
function uniqueBy(list, keyOf) {
|
|
62
109
|
const seen = new Set();
|