dsh-plan-completion 0.0.0-stage → 0.1.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.
package/host/plan.js ADDED
@@ -0,0 +1,613 @@
1
+ /**
2
+ * host/plan.js — Plan 数据模型、校验与版本规则(纯函数,零依赖)。
3
+ *
4
+ * 设计依据:v1.2 §3.4(数据模型)、§3.5.1/§3.5.2(版本与一致性)、§3.8(用户编辑优先)。
5
+ *
6
+ * 约定:
7
+ * - 所有公共函数「永不 throw」,失败返回 `{ok:false, code, error}`;
8
+ * - 输入永不被修改,返回的是新对象;
9
+ * - 版本规则:rewrite → `gen+1, rev=0`;patch → `gen.rev+1`;首个版本 `0.0`;
10
+ * - 用户编辑 → `gen+1, rev=0`、`editedByUser=true`、改动的步骤 `source='user'`;
11
+ * - 只保留最近 N 版由存储层(store.js)负责,本模块只打版本号。
12
+ */
13
+
14
+ import { DEFAULTS } from './config.js';
15
+ import { clampChars, oneLine } from './text.js';
16
+
17
+ /** 计划状态枚举。 */
18
+ export const PLAN_STATUSES = Object.freeze(['active', 'completed', 'archived']);
19
+ /** 步骤状态枚举。 */
20
+ export const STEP_STATUSES = Object.freeze(['todo', 'doing', 'done', 'dropped']);
21
+ /** 步骤来源枚举。 */
22
+ export const STEP_SOURCES = Object.freeze(['model', 'user']);
23
+
24
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
25
+ const isStr = (v) => typeof v === 'string';
26
+ const isNonEmptyStr = (v) => isStr(v) && v.trim().length > 0;
27
+ const isNonNegInt = (v) => Number.isInteger(v) && v >= 0;
28
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
29
+
30
+ const fail = (code, error) => ({ ok: false, code, error });
31
+
32
+ function errMsg(err) {
33
+ if (err && typeof err.message === 'string') return err.message;
34
+ return String(err);
35
+ }
36
+
37
+ function clone(value) {
38
+ if (value == null) return value;
39
+ try {
40
+ if (typeof structuredClone === 'function') return structuredClone(value);
41
+ } catch {
42
+ /* fall through */
43
+ }
44
+ try {
45
+ return JSON.parse(JSON.stringify(value));
46
+ } catch {
47
+ return value;
48
+ }
49
+ }
50
+
51
+ function metaNow(meta) {
52
+ return isObj(meta) && isNum(meta.now) ? meta.now : Date.now();
53
+ }
54
+
55
+ function clampReason(value) {
56
+ return clampChars(oneLine(value), DEFAULTS.maxReasonChars);
57
+ }
58
+
59
+ function clampTitle(value) {
60
+ return clampChars(oneLine(value), DEFAULTS.maxTitleChars);
61
+ }
62
+
63
+ function clampIntent(value) {
64
+ return clampChars(oneLine(value), DEFAULTS.maxIntentChars);
65
+ }
66
+
67
+ /**
68
+ * 校验并规范化版本号。
69
+ * @param {unknown} value
70
+ * @returns {{gen:number, rev:number}|null}
71
+ */
72
+ export function validVersion(value) {
73
+ if (!isObj(value)) return null;
74
+ if (!isNonNegInt(value.gen) || !isNonNegInt(value.rev)) return null;
75
+ return { gen: value.gen, rev: value.rev };
76
+ }
77
+
78
+ /**
79
+ * 校验并规范化 watermark。
80
+ * @param {unknown} value
81
+ * @returns {{turn:number, seq:number}|null}
82
+ */
83
+ export function validWatermark(value) {
84
+ if (!isObj(value)) return null;
85
+ if (!isNonNegInt(value.turn) || !isNonNegInt(value.seq)) return null;
86
+ return { turn: value.turn, seq: value.seq };
87
+ }
88
+
89
+ /**
90
+ * 规范化证据数组(字符串数组,去空、截断、限量)。
91
+ * @param {unknown} value
92
+ * @returns {string[]}
93
+ */
94
+ export function normalizeEvidence(value) {
95
+ if (value == null) return [];
96
+ const list = Array.isArray(value) ? value : [value];
97
+ const out = [];
98
+ for (const item of list) {
99
+ if (typeof item !== 'string') continue;
100
+ const text = clampChars(oneLine(item), DEFAULTS.maxEvidenceChars);
101
+ if (text) out.push(text);
102
+ if (out.length >= DEFAULTS.maxEvidencePerStep) break;
103
+ }
104
+ return out;
105
+ }
106
+
107
+ function mergeEvidence(prev, next) {
108
+ const merged = [...(Array.isArray(prev) ? prev : []), ...(Array.isArray(next) ? next : [])];
109
+ const uniq = [];
110
+ for (const item of merged) if (!uniq.includes(item)) uniq.push(item);
111
+ return uniq.slice(-DEFAULTS.maxEvidencePerStep);
112
+ }
113
+
114
+ /**
115
+ * 规范化步骤数组:3~7 条、id 唯一、title 非空、status/source 枚举。
116
+ *
117
+ * @param {unknown} input
118
+ * @returns {{ok:true, steps:Array}|{ok:false, code:string, error:string}}
119
+ */
120
+ export function normalizeSteps(input) {
121
+ if (!Array.isArray(input)) return fail('bad-steps', 'steps 必须是数组');
122
+ if (input.length < DEFAULTS.minSteps || input.length > DEFAULTS.maxSteps) {
123
+ return fail(
124
+ 'bad-step-count',
125
+ `步骤数必须为 ${DEFAULTS.minSteps}~${DEFAULTS.maxSteps} 条(当前 ${input.length} 条)`,
126
+ );
127
+ }
128
+ const used = new Set();
129
+ const steps = [];
130
+ for (let i = 0; i < input.length; i += 1) {
131
+ const raw = input[i];
132
+ if (!isObj(raw)) return fail('bad-step', `第 ${i + 1} 步必须是对象`);
133
+ if (typeof raw.id !== 'string' || !raw.id.trim()) {
134
+ return fail('bad-step-id', `第 ${i + 1} 步缺少非空 id`);
135
+ }
136
+ const id = clampChars(raw.id.trim(), 64);
137
+ if (used.has(id)) return fail('duplicate-step-id', `步骤 id 重复:${id}`);
138
+ used.add(id);
139
+ if (!isNonEmptyStr(raw.title)) return fail('bad-step-title', `步骤 ${id} 的 title 不能为空`);
140
+ const status = raw.status == null ? 'todo' : raw.status;
141
+ if (!STEP_STATUSES.includes(status)) {
142
+ return fail('bad-step-status', `步骤 ${id} 的 status 非法:${String(status)}`);
143
+ }
144
+ const source = raw.source == null ? 'model' : raw.source;
145
+ if (!STEP_SOURCES.includes(source)) {
146
+ return fail('bad-step-source', `步骤 ${id} 的 source 非法:${String(source)}`);
147
+ }
148
+ const step = { id, title: clampTitle(raw.title), status, source };
149
+ const evidence = normalizeEvidence(raw.evidence);
150
+ if (evidence.length) step.evidence = evidence;
151
+ steps.push(step);
152
+ }
153
+ return { ok: true, steps };
154
+ }
155
+
156
+ /**
157
+ * 计划状态推导:全部 done/dropped → completed,否则 active。
158
+ * @param {Array<{status?:string}>} steps
159
+ * @returns {'active'|'completed'}
160
+ */
161
+ export function statusFor(steps) {
162
+ if (!Array.isArray(steps) || steps.length === 0) return 'active';
163
+ for (const step of steps) {
164
+ if (!step || (step.status !== 'done' && step.status !== 'dropped')) return 'active';
165
+ }
166
+ return 'completed';
167
+ }
168
+
169
+ /**
170
+ * 计算下一个版本号。
171
+ * @param {{gen:number,rev:number}|null|undefined} prev
172
+ * @param {'patch'|'rewrite'|'user-edit'} [kind]
173
+ * @returns {{gen:number, rev:number}}
174
+ */
175
+ export function nextVersion(prev, kind = 'patch') {
176
+ const base = validVersion(prev);
177
+ if (!base) return { gen: 0, rev: 0 };
178
+ if (kind === 'patch') return { gen: base.gen, rev: base.rev + 1 };
179
+ return { gen: base.gen + 1, rev: 0 };
180
+ }
181
+
182
+ /**
183
+ * 版本是否相等。
184
+ * @param {unknown} a
185
+ * @param {unknown} b
186
+ * @returns {boolean}
187
+ */
188
+ export function versionEquals(a, b) {
189
+ const va = validVersion(a);
190
+ const vb = validVersion(b);
191
+ if (!va || !vb) return false;
192
+ return va.gen === vb.gen && va.rev === vb.rev;
193
+ }
194
+
195
+ /**
196
+ * 版本比较:a>b 返回 1,a<b 返回 -1,相等返回 0。
197
+ * @param {unknown} a
198
+ * @param {unknown} b
199
+ * @returns {number}
200
+ */
201
+ export function compareVersions(a, b) {
202
+ const va = validVersion(a);
203
+ const vb = validVersion(b);
204
+ if (!va && !vb) return 0;
205
+ if (!va) return -1;
206
+ if (!vb) return 1;
207
+ if (va.gen !== vb.gen) return va.gen > vb.gen ? 1 : -1;
208
+ if (va.rev !== vb.rev) return va.rev > vb.rev ? 1 : -1;
209
+ return 0;
210
+ }
211
+
212
+ /**
213
+ * next 是否是 prev 的严格下一版本(patch 的 rev+1 或 rewrite 的 gen+1.rev=0)。
214
+ * @param {unknown} prev
215
+ * @param {unknown} next
216
+ * @returns {boolean}
217
+ */
218
+ export function isNextVersion(prev, next) {
219
+ const vb = validVersion(next);
220
+ if (!vb) return false;
221
+ const va = validVersion(prev);
222
+ if (!va) return vb.gen === 0 && vb.rev === 0;
223
+ return (vb.gen === va.gen && vb.rev === va.rev + 1) || (vb.gen === va.gen + 1 && vb.rev === 0);
224
+ }
225
+
226
+ function stepsEqual(a, b) {
227
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
228
+ }
229
+
230
+ /**
231
+ * 校验并规范化 Plan(设计 §3.4 字段一个不少;未知字段丢弃)。
232
+ *
233
+ * @param {unknown} input
234
+ * @param {{now?: (() => number), requireReason?: boolean}} [options]
235
+ * @returns {{ok:true, plan:object}|{ok:false, code:string, error:string}}
236
+ */
237
+ export function normalizePlan(input, options = {}) {
238
+ try {
239
+ if (!isObj(input)) return fail('not-object', '计划必须是 JSON 对象');
240
+ const requireReason = options.requireReason !== false;
241
+ const now = typeof options.now === 'function' ? options.now : () => Date.now();
242
+
243
+ if (!isNonEmptyStr(input.sessionId)) return fail('bad-session', 'sessionId 不能为空');
244
+ const sessionId = clampChars(input.sessionId.trim(), 128);
245
+
246
+ const stepsRes = normalizeSteps(input.steps);
247
+ if (!stepsRes.ok) return stepsRes;
248
+
249
+ const status = input.status == null ? 'active' : input.status;
250
+ if (!PLAN_STATUSES.includes(status)) return fail('bad-status', `status 非法:${String(status)}`);
251
+
252
+ let intent = '';
253
+ if (input.intent != null) {
254
+ if (!isStr(input.intent)) return fail('bad-intent', 'intent 必须是字符串');
255
+ intent = clampIntent(input.intent);
256
+ }
257
+
258
+ let version = { gen: 0, rev: 0 };
259
+ if (input.version != null) {
260
+ const v = validVersion(input.version);
261
+ if (!v) return fail('bad-version', 'version 必须是 {gen, rev} 非负整数');
262
+ version = v;
263
+ }
264
+
265
+ let revisionOf;
266
+ if (input.revisionOf != null) {
267
+ const v = validVersion(input.revisionOf);
268
+ if (!v) return fail('bad-revision-of', 'revisionOf 必须是 {gen, rev} 非负整数');
269
+ revisionOf = v;
270
+ }
271
+
272
+ const updatedAt = isNum(input.updatedAt) ? input.updatedAt : now();
273
+
274
+ let reason = '';
275
+ if (requireReason) {
276
+ if (!isNonEmptyStr(input.reason)) return fail('missing-reason', 'reason 必填(每次版本变更都要带原因)');
277
+ reason = clampReason(input.reason);
278
+ } else if (isNonEmptyStr(input.reason)) {
279
+ reason = clampReason(input.reason);
280
+ }
281
+
282
+ let watermark = { turn: 0, seq: 0 };
283
+ if (input.watermark != null) {
284
+ const w = validWatermark(input.watermark);
285
+ if (!w) return fail('bad-watermark', 'watermark 必须是 {turn, seq} 非负整数');
286
+ watermark = w;
287
+ }
288
+
289
+ let editedByUser = false;
290
+ if (input.editedByUser != null) {
291
+ if (typeof input.editedByUser !== 'boolean') return fail('bad-edited-by-user', 'editedByUser 必须是布尔值');
292
+ editedByUser = input.editedByUser;
293
+ }
294
+
295
+ const plan = { sessionId, version, status, intent, steps: stepsRes.steps, updatedAt, reason, watermark, editedByUser };
296
+ if (revisionOf) plan.revisionOf = revisionOf;
297
+
298
+ if (input.stats != null) {
299
+ if (!isObj(input.stats)) return fail('bad-stats', 'stats 必须是对象');
300
+ const stats = {};
301
+ for (const key of ['calls', 'tokensIn', 'tokensOut']) {
302
+ if (isNum(input.stats[key])) stats[key] = input.stats[key];
303
+ }
304
+ if (Object.keys(stats).length) plan.stats = stats;
305
+ }
306
+
307
+ return { ok: true, plan };
308
+ } catch (err) {
309
+ return fail('internal', `计划校验异常:${errMsg(err)}`);
310
+ }
311
+ }
312
+
313
+ /**
314
+ * 版本规则:模型只能 patch 的情况返回 false。
315
+ * 用户编辑过的计划(editedByUser)模型不得自动 rewrite 覆盖;/replan 用 `{force:true}` 放行。
316
+ *
317
+ * @param {object|null|undefined} plan
318
+ * @param {{force?: boolean}} [options]
319
+ * @returns {boolean}
320
+ */
321
+ export function mayRewrite(plan, options = {}) {
322
+ if (options && options.force === true) return true;
323
+ if (plan == null) return true;
324
+ if (!isObj(plan)) return false;
325
+ if (plan.status === 'archived') return false;
326
+ return plan.editedByUser !== true;
327
+ }
328
+
329
+ function loadPlan(plan, code = 'bad-plan') {
330
+ if (!isObj(plan)) return fail(code, '现有计划必须是对象');
331
+ const res = normalizePlan(plan, { requireReason: false });
332
+ if (!res.ok) return fail(code, `现有计划非法:${res.error}`);
333
+ return res;
334
+ }
335
+
336
+ /**
337
+ * 执行一次 patch:只改指定步骤的字段,版本 `gen.rev+1`。
338
+ *
339
+ * @param {object} plan 当前计划
340
+ * @param {Array<object>} changes `[{id, status?, title?, evidence?, source?}]`
341
+ * @param {{reason?: string, watermark?: {turn:number,seq:number}, now?: number}} meta
342
+ * @returns {{ok:true, plan:object, changed:boolean, previous:object}|{ok:false, code:string, error:string}}
343
+ */
344
+ export function patchPlan(plan, changes, meta = {}) {
345
+ try {
346
+ const loaded = loadPlan(plan);
347
+ if (!loaded.ok) return loaded;
348
+ const cur = loaded.plan;
349
+ if (cur.status === 'archived') return fail('archived', '归档计划不可 patch');
350
+
351
+ if (!Array.isArray(changes) || changes.length === 0) {
352
+ return fail('empty-changes', 'changes 不能为空');
353
+ }
354
+
355
+ const steps = cur.steps.map((step) => {
356
+ const copy = { ...step };
357
+ if (step.evidence) copy.evidence = [...step.evidence];
358
+ return copy;
359
+ });
360
+ const byId = new Map(steps.map((step) => [step.id, step]));
361
+
362
+ for (let i = 0; i < changes.length; i += 1) {
363
+ const change = changes[i];
364
+ if (!isObj(change)) return fail('bad-change', `第 ${i + 1} 条变更必须是对象`);
365
+ if (typeof change.id !== 'string' || !change.id.trim()) {
366
+ return fail('bad-change-id', `第 ${i + 1} 条变更缺少非空 id`);
367
+ }
368
+ const id = change.id.trim();
369
+ const step = byId.get(id);
370
+ if (!step) return fail('unknown-step', `changes 引用了不存在的步骤 id:${id}`);
371
+ let applied = false;
372
+
373
+ if (change.status !== undefined) {
374
+ if (!STEP_STATUSES.includes(change.status)) {
375
+ return fail('bad-change-status', `步骤 ${id} 的 status 非法:${String(change.status)}`);
376
+ }
377
+ step.status = change.status;
378
+ applied = true;
379
+ }
380
+ if (change.title !== undefined) {
381
+ if (!isNonEmptyStr(change.title)) return fail('bad-change-title', `步骤 ${id} 的 title 不能为空`);
382
+ step.title = clampTitle(change.title);
383
+ applied = true;
384
+ }
385
+ if (change.source !== undefined) {
386
+ if (!STEP_SOURCES.includes(change.source)) {
387
+ return fail('bad-change-source', `步骤 ${id} 的 source 非法:${String(change.source)}`);
388
+ }
389
+ step.source = change.source;
390
+ applied = true;
391
+ }
392
+ if (change.evidence !== undefined) {
393
+ if (!Array.isArray(change.evidence)) {
394
+ return fail('bad-change-evidence', `步骤 ${id} 的 evidence 必须是字符串数组`);
395
+ }
396
+ const merged = mergeEvidence(step.evidence, normalizeEvidence(change.evidence));
397
+ if (merged.length) step.evidence = merged;
398
+ else delete step.evidence;
399
+ applied = true;
400
+ }
401
+ if (!applied) return fail('empty-change', `第 ${i + 1} 条变更没有任何可应用字段`);
402
+ }
403
+
404
+ if (!isNonEmptyStr(meta.reason)) return fail('missing-reason', 'patch 的 reason 必填');
405
+ const updatedAt = metaNow(meta);
406
+ const watermark = validWatermark(meta.watermark) ?? cur.watermark;
407
+ const status = statusFor(steps);
408
+ const candidate = {
409
+ sessionId: cur.sessionId,
410
+ version: nextVersion(cur.version, 'patch'),
411
+ status,
412
+ intent: cur.intent,
413
+ steps,
414
+ updatedAt,
415
+ reason: clampReason(meta.reason),
416
+ revisionOf: cur.version,
417
+ watermark,
418
+ editedByUser: cur.editedByUser,
419
+ };
420
+ if (cur.stats) candidate.stats = cur.stats;
421
+ const norm = normalizePlan(candidate, { requireReason: true, now: () => updatedAt });
422
+ if (!norm.ok) return fail('bad-next-plan', `patch 后计划非法:${norm.error}`);
423
+
424
+ const changed = !stepsEqual(cur.steps, norm.plan.steps) || cur.status !== norm.plan.status;
425
+ return { ok: true, plan: norm.plan, changed, previous: clone(cur) };
426
+ } catch (err) {
427
+ return fail('internal', `patch 异常:${errMsg(err)}`);
428
+ }
429
+ }
430
+
431
+ /**
432
+ * 执行一次 rewrite:整体替换步骤,版本 `gen+1, rev=0`。
433
+ * 用户编辑过的计划默认拒绝(`meta.force` 为 true 时放行,对应 /replan)。
434
+ *
435
+ * @param {object|null} plan 当前计划(冷启动传 null)
436
+ * @param {{steps:Array, intent?:string, reason?:string}} next
437
+ * @param {{reason?:string, watermark?:object, now?:number, force?:boolean, sessionId?:string}} meta
438
+ * @returns {{ok:true, plan:object, changed:boolean, previous:object|null}|{ok:false, code:string, error:string}}
439
+ */
440
+ export function rewritePlan(plan, next, meta = {}) {
441
+ try {
442
+ const curRes = plan == null ? { ok: true, plan: null } : loadPlan(plan);
443
+ if (!curRes.ok) return curRes;
444
+ const cur = curRes.plan;
445
+ const force = meta.force === true;
446
+ if (cur && !mayRewrite(cur, { force })) {
447
+ return fail('locked-by-user', '用户已编辑计划,模型不能自动 rewrite 覆盖(需用户 /replan 强制)');
448
+ }
449
+ if (!isObj(next)) return fail('bad-next', 'rewrite 内容必须是对象');
450
+ const stepsRes = normalizeSteps(next.steps);
451
+ if (!stepsRes.ok) return stepsRes;
452
+ const steps = stepsRes.steps.map((step) => ({ ...step, source: 'model' }));
453
+
454
+ let reason = '';
455
+ if (isNonEmptyStr(meta.reason)) reason = clampReason(meta.reason);
456
+ else if (isNonEmptyStr(next.reason)) reason = clampReason(next.reason);
457
+ if (!reason) return fail('missing-reason', 'rewrite 的 reason 必填');
458
+
459
+ let sessionId = cur?.sessionId ?? '';
460
+ if (!sessionId && isNonEmptyStr(meta.sessionId)) sessionId = clampChars(meta.sessionId.trim(), 128);
461
+ if (!sessionId) return fail('bad-session', 'rewrite 缺少 sessionId');
462
+
463
+ let intent = cur?.intent ?? '';
464
+ if (next.intent != null) {
465
+ if (!isStr(next.intent)) return fail('bad-intent', 'intent 必须是字符串');
466
+ intent = clampIntent(next.intent);
467
+ }
468
+
469
+ const updatedAt = metaNow(meta);
470
+ const watermark = validWatermark(meta.watermark) ?? cur?.watermark ?? { turn: 0, seq: 0 };
471
+ const candidate = {
472
+ sessionId,
473
+ version: cur ? nextVersion(cur.version, 'rewrite') : { gen: 0, rev: 0 },
474
+ status: statusFor(steps),
475
+ intent,
476
+ steps,
477
+ updatedAt,
478
+ reason,
479
+ watermark,
480
+ // /replan 强制重写意味着用户主动要求模型重建计划:覆盖用户编辑标记,后续可再自动 rewrite。
481
+ editedByUser: force ? false : cur?.editedByUser ?? false,
482
+ };
483
+ if (cur) candidate.revisionOf = cur.version;
484
+ if (cur?.stats) candidate.stats = cur.stats;
485
+ const norm = normalizePlan(candidate, { requireReason: true, now: () => updatedAt });
486
+ if (!norm.ok) return fail('bad-next-plan', `rewrite 后计划非法:${norm.error}`);
487
+
488
+ const changed = !cur
489
+ ? true
490
+ : !stepsEqual(cur.steps, norm.plan.steps) || cur.intent !== norm.plan.intent || cur.status !== norm.plan.status;
491
+ return { ok: true, plan: norm.plan, changed, previous: cur ? clone(cur) : null };
492
+ } catch (err) {
493
+ return fail('internal', `rewrite 异常:${errMsg(err)}`);
494
+ }
495
+ }
496
+
497
+ /**
498
+ * 用户手动编辑计划:版本 `gen+1, rev=0`、reason 默认 'user edit'、
499
+ * 改动/新增步骤 `source='user'`、`editedByUser=true`(设计 §3.8)。
500
+ *
501
+ * @param {object} plan
502
+ * @param {{steps:Array, intent?:string}} next
503
+ * @param {{reason?:string, watermark?:object, now?:number}} meta
504
+ * @returns {{ok:true, plan:object, changed:boolean, previous:object}|{ok:false, code:string, error:string}}
505
+ */
506
+ export function applyUserEdit(plan, next, meta = {}) {
507
+ try {
508
+ const loaded = loadPlan(plan);
509
+ if (!loaded.ok) return loaded;
510
+ const cur = loaded.plan;
511
+ if (cur.status === 'archived') return fail('archived', '归档计划不可编辑');
512
+ if (!isObj(next)) return fail('bad-next', '用户编辑内容必须是对象');
513
+ const stepsRes = normalizeSteps(next.steps);
514
+ if (!stepsRes.ok) return stepsRes;
515
+
516
+ const oldById = new Map(cur.steps.map((step) => [step.id, step]));
517
+ const steps = stepsRes.steps.map((step) => {
518
+ const old = oldById.get(step.id);
519
+ // 「未改动」只看用户可编辑字段(标题/状态);evidence 是模型追加的证据链,不算用户编辑。
520
+ const unchanged = old && old.title === step.title && old.status === step.status;
521
+ return { ...step, source: unchanged ? old.source : 'user' };
522
+ });
523
+
524
+ let intent = cur.intent;
525
+ if (next.intent != null) {
526
+ if (!isStr(next.intent)) return fail('bad-intent', 'intent 必须是字符串');
527
+ intent = clampIntent(next.intent);
528
+ }
529
+
530
+ const updatedAt = metaNow(meta);
531
+ const candidate = {
532
+ sessionId: cur.sessionId,
533
+ version: { gen: cur.version.gen + 1, rev: 0 },
534
+ status: statusFor(steps),
535
+ intent,
536
+ steps,
537
+ updatedAt,
538
+ reason: isNonEmptyStr(meta.reason) ? clampReason(meta.reason) : 'user edit',
539
+ revisionOf: cur.version,
540
+ watermark: validWatermark(meta.watermark) ?? cur.watermark,
541
+ editedByUser: true,
542
+ };
543
+ if (cur.stats) candidate.stats = cur.stats;
544
+ const norm = normalizePlan(candidate, { requireReason: true, now: () => updatedAt });
545
+ if (!norm.ok) return fail('bad-next-plan', `用户编辑后计划非法:${norm.error}`);
546
+ return { ok: true, plan: norm.plan, changed: true, previous: clone(cur) };
547
+ } catch (err) {
548
+ return fail('internal', `用户编辑异常:${errMsg(err)}`);
549
+ }
550
+ }
551
+
552
+ /**
553
+ * 归档当前计划(议题剧变 / 全完成后开新计划)。
554
+ *
555
+ * @param {object} plan
556
+ * @param {{reason?:string, watermark?:object, now?:number}} meta
557
+ * @returns {{ok:true, plan:object, changed:boolean, previous:object}|{ok:false, code:string, error:string}}
558
+ */
559
+ export function archivePlan(plan, meta = {}) {
560
+ try {
561
+ const loaded = loadPlan(plan);
562
+ if (!loaded.ok) return loaded;
563
+ const cur = loaded.plan;
564
+ const updatedAt = metaNow(meta);
565
+ const candidate = {
566
+ ...cur,
567
+ version: nextVersion(cur.version, 'rewrite'),
568
+ status: 'archived',
569
+ updatedAt,
570
+ reason: isNonEmptyStr(meta.reason) ? clampReason(meta.reason) : 'topic-drift',
571
+ revisionOf: cur.version,
572
+ watermark: validWatermark(meta.watermark) ?? cur.watermark,
573
+ };
574
+ const norm = normalizePlan(candidate, { requireReason: true, now: () => updatedAt });
575
+ if (!norm.ok) return fail('bad-next-plan', `归档后计划非法:${norm.error}`);
576
+ return { ok: true, plan: norm.plan, changed: true, previous: clone(cur) };
577
+ } catch (err) {
578
+ return fail('internal', `归档异常:${errMsg(err)}`);
579
+ }
580
+ }
581
+
582
+ /**
583
+ * 冷启动 / 降级时生成种子计划(首版 `0.0`)。
584
+ *
585
+ * @param {{sessionId: string, intent?: string, steps: Array, watermark?: object, reason?: string}} input
586
+ * @param {{now?: number}} [meta]
587
+ * @returns {{ok:true, plan:object}|{ok:false, code:string, error:string}}
588
+ */
589
+ export function createSeedPlan(input, meta = {}) {
590
+ try {
591
+ if (!isObj(input)) return fail('bad-input', '种子计划参数必须是对象');
592
+ if (!isNonEmptyStr(input.sessionId)) return fail('bad-session', 'sessionId 不能为空');
593
+ const stepsRes = normalizeSteps(input.steps);
594
+ if (!stepsRes.ok) return stepsRes;
595
+ const updatedAt = metaNow(meta);
596
+ const candidate = {
597
+ sessionId: clampChars(input.sessionId.trim(), 128),
598
+ version: { gen: 0, rev: 0 },
599
+ status: statusFor(stepsRes.steps),
600
+ intent: isStr(input.intent) ? clampIntent(input.intent) : '',
601
+ steps: stepsRes.steps,
602
+ updatedAt,
603
+ reason: isNonEmptyStr(input.reason) ? clampReason(input.reason) : 'seed',
604
+ watermark: validWatermark(input.watermark ?? meta.watermark) ?? { turn: 0, seq: 0 },
605
+ editedByUser: false,
606
+ };
607
+ const norm = normalizePlan(candidate, { requireReason: true, now: () => updatedAt });
608
+ if (!norm.ok) return fail('bad-next-plan', `种子计划非法:${norm.error}`);
609
+ return { ok: true, plan: norm.plan };
610
+ } catch (err) {
611
+ return fail('internal', `种子计划异常:${errMsg(err)}`);
612
+ }
613
+ }