@ai-agent-forge/plugin-alignment 0.88.1
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/README.md +38 -0
- package/agent-forge.json +19 -0
- package/dist/baseline.d.ts +12 -0
- package/dist/baseline.d.ts.map +1 -0
- package/dist/baseline.js +68 -0
- package/dist/baseline.js.map +1 -0
- package/dist/engine.d.ts +93 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +634 -0
- package/dist/engine.js.map +1 -0
- package/dist/entry.d.ts +14 -0
- package/dist/entry.d.ts.map +1 -0
- package/dist/entry.js +23 -0
- package/dist/entry.js.map +1 -0
- package/dist/export.d.ts +15 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +120 -0
- package/dist/export.js.map +1 -0
- package/dist/http-server.d.ts +33 -0
- package/dist/http-server.d.ts.map +1 -0
- package/dist/http-server.js +119 -0
- package/dist/http-server.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +665 -0
- package/dist/index.js.map +1 -0
- package/dist/page.d.ts +15 -0
- package/dist/page.d.ts.map +1 -0
- package/dist/page.js +831 -0
- package/dist/page.js.map +1 -0
- package/dist/render.d.ts +42 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +158 -0
- package/dist/render.js.map +1 -0
- package/dist/types.d.ts +217 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +30 -0
- package/dist/types.js.map +1 -0
- package/package.json +53 -0
- package/plugin.json +12 -0
package/dist/engine.js
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure alignment state machine (设计 §5) — no host API access, no I/O.
|
|
3
|
+
*
|
|
4
|
+
* Every function takes JSON-safe state and returns the next state plus the
|
|
5
|
+
* audit transitions to persist; index.ts owns persistence (entries + events)
|
|
6
|
+
* and http-server.ts owns transport. Deterministic by construction: same
|
|
7
|
+
* inputs, same outputs, no clocks, no randomness.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { ALIGNMENT_LIMITS, AlignmentError, STEP_ORDER, } from "./types.js";
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Hashing & validation
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
15
|
+
export function validAlignmentId(id) {
|
|
16
|
+
// 入参可能来自未校验的工具输入(空对象 → undefined):test() 会把实参
|
|
17
|
+
// 字符串化为 "undefined" 而"通过"模式匹配,必须先做类型守卫。
|
|
18
|
+
return typeof id === "string" && ID_PATTERN.test(id) && id.length <= 128;
|
|
19
|
+
}
|
|
20
|
+
/** Fingerprint of the fields whose change must re-ask for approval (总则 3). */
|
|
21
|
+
export function blockFingerprint(kind, content, rationale, impact) {
|
|
22
|
+
const canonical = JSON.stringify([kind, content, rationale ?? null, impact ?? null]);
|
|
23
|
+
return createHash("sha256").update(canonical, "utf8").digest("hex");
|
|
24
|
+
}
|
|
25
|
+
function nonEmptyString(value, name, max) {
|
|
26
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
27
|
+
throw new AlignmentError("invalid-input", `${name} must be a non-empty string`);
|
|
28
|
+
}
|
|
29
|
+
if (value.length > max) {
|
|
30
|
+
throw new AlignmentError("invalid-input", `${name} exceeds ${max} characters`);
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
function validateBlockInput(input) {
|
|
35
|
+
if (typeof input.id !== "string" || input.id.trim() === "" || input.id.length > 128) {
|
|
36
|
+
throw new AlignmentError("invalid-block", `block id must be a non-empty string (≤128 chars): ${String(input.id)}`);
|
|
37
|
+
}
|
|
38
|
+
if (!["fact", "assumption", "decision", "missing"].includes(input.kind)) {
|
|
39
|
+
throw new AlignmentError("invalid-block", `invalid block kind: ${String(input.kind)}`);
|
|
40
|
+
}
|
|
41
|
+
nonEmptyString(input.content, `block ${input.id} content`, ALIGNMENT_LIMITS.contentMaxLength);
|
|
42
|
+
if (input.rationale !== undefined && input.rationale.length > ALIGNMENT_LIMITS.rationaleMaxLength) {
|
|
43
|
+
throw new AlignmentError("invalid-block", `block ${input.id} rationale exceeds ${ALIGNMENT_LIMITS.rationaleMaxLength} characters`);
|
|
44
|
+
}
|
|
45
|
+
if (input.impact?.some((item) => typeof item !== "string" || item.trim() === "")) {
|
|
46
|
+
throw new AlignmentError("invalid-block", `block ${input.id} impact must be a non-empty string array`);
|
|
47
|
+
}
|
|
48
|
+
if ((input.comments?.length ?? 0) > ALIGNMENT_LIMITS.notesPerBlockMax) {
|
|
49
|
+
throw new AlignmentError("invalid-block", `block ${input.id} carries more than ${ALIGNMENT_LIMITS.notesPerBlockMax} comments`);
|
|
50
|
+
}
|
|
51
|
+
for (const comment of input.comments ?? []) {
|
|
52
|
+
if (typeof comment.id !== "string" || comment.id.trim() === "") {
|
|
53
|
+
throw new AlignmentError("invalid-comment", `comment id on block ${input.id} must be a non-empty string`);
|
|
54
|
+
}
|
|
55
|
+
nonEmptyString(comment.note, `comment ${comment.id} note`, ALIGNMENT_LIMITS.noteMaxLength);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// State helpers
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
export function findRecord(state, id) {
|
|
62
|
+
return state.alignments.find((record) => record.id === id);
|
|
63
|
+
}
|
|
64
|
+
export function activeStep(record) {
|
|
65
|
+
const found = record.steps.find((candidate) => candidate.step === record.activeStep);
|
|
66
|
+
if (!found)
|
|
67
|
+
throw new AlignmentError("invalid-input", `active step missing: ${record.activeStep}`);
|
|
68
|
+
return found;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Gate facts (总则 2): the three server-checkable conditions. A missing block
|
|
72
|
+
* without a stored user answer counts as `missing-empty`; once answered it is
|
|
73
|
+
* just an unapproved block waiting for the model to restructure it.
|
|
74
|
+
*/
|
|
75
|
+
export function gate(record) {
|
|
76
|
+
const step = activeStep(record);
|
|
77
|
+
const pending = [];
|
|
78
|
+
let unapproved = 0;
|
|
79
|
+
for (const block of step.blocks) {
|
|
80
|
+
if (block.kind === "missing" && (block.draftAnswer ?? "").trim() === "") {
|
|
81
|
+
pending.push({ blockId: block.id, reason: "missing-empty" });
|
|
82
|
+
unapproved += 1;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (block.status !== "approved") {
|
|
86
|
+
pending.push({ blockId: block.id, reason: "unapproved" });
|
|
87
|
+
unapproved += 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
let openComments = 0;
|
|
91
|
+
for (const block of step.blocks) {
|
|
92
|
+
for (const comment of block.comments) {
|
|
93
|
+
if (comment.status !== "open")
|
|
94
|
+
continue;
|
|
95
|
+
openComments += 1;
|
|
96
|
+
if (comment.author === "assistant") {
|
|
97
|
+
pending.push({ blockId: block.id, reason: "assistant-question-open" });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const pendingAdditions = record.pendingFeedback?.additions.length ?? 0;
|
|
102
|
+
return {
|
|
103
|
+
unapproved,
|
|
104
|
+
openComments,
|
|
105
|
+
pendingAdditions,
|
|
106
|
+
allGreen: unapproved === 0 && openComments === 0 && pendingAdditions === 0,
|
|
107
|
+
pending,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function nextStep(step) {
|
|
111
|
+
const index = STEP_ORDER.indexOf(step);
|
|
112
|
+
return index >= 0 && index < STEP_ORDER.length - 1 ? STEP_ORDER[index + 1] : undefined;
|
|
113
|
+
}
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// alignment_set: create / update (总则 3 hash inheritance, 总则 5 comment merge)
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
function materializeBlock(input, previous, currentRound) {
|
|
118
|
+
const fingerprint = blockFingerprint(input.kind, input.content, input.rationale, input.impact);
|
|
119
|
+
const unchanged = previous !== undefined && previous.contentHash === fingerprint;
|
|
120
|
+
const block = {
|
|
121
|
+
id: input.id,
|
|
122
|
+
kind: input.kind,
|
|
123
|
+
content: input.content,
|
|
124
|
+
rationale: input.rationale,
|
|
125
|
+
impact: input.impact,
|
|
126
|
+
// Hash-identical resent blocks keep their approval (防确认疲劳); changed
|
|
127
|
+
// content resets to pending and flags the round it changed in.
|
|
128
|
+
status: unchanged ? previous.status : "pending",
|
|
129
|
+
contentHash: fingerprint,
|
|
130
|
+
...(unchanged ? {} : { modifiedInRound: currentRound + 1 }),
|
|
131
|
+
...(previous?.draftAnswer !== undefined ? { draftAnswer: previous.draftAnswer } : {}),
|
|
132
|
+
comments: [],
|
|
133
|
+
};
|
|
134
|
+
block.comments = mergeComments(previous?.comments ?? [], input, currentRound);
|
|
135
|
+
return block;
|
|
136
|
+
}
|
|
137
|
+
function mergeComments(existing, input, round) {
|
|
138
|
+
const merged = existing.map((comment) => ({ ...comment }));
|
|
139
|
+
const resolutions = new Map((input.commentResolutions ?? []).map((item) => [item.commentId, item]));
|
|
140
|
+
// Existing comments are append-only (总则 5): user text survives resends
|
|
141
|
+
// verbatim, open assistant questions cannot silently disappear; only an
|
|
142
|
+
// explicit resolution closes them.
|
|
143
|
+
for (const comment of merged) {
|
|
144
|
+
const resolution = resolutions.get(comment.id);
|
|
145
|
+
if (!resolution)
|
|
146
|
+
continue;
|
|
147
|
+
resolutions.delete(comment.id);
|
|
148
|
+
if (comment.status === "resolved") {
|
|
149
|
+
throw new AlignmentError("comment-ownership", `comment ${comment.id} is already resolved`);
|
|
150
|
+
}
|
|
151
|
+
comment.status = "resolved";
|
|
152
|
+
comment.resolution = { kind: resolution.kind, text: resolution.text, round };
|
|
153
|
+
}
|
|
154
|
+
if (resolutions.size > 0) {
|
|
155
|
+
const ids = [...resolutions.keys()].join(", ");
|
|
156
|
+
throw new AlignmentError("comment-ownership", `resolutions reference unknown comments: ${ids}`);
|
|
157
|
+
}
|
|
158
|
+
// New comments are assistant-only and deduped by note so a resent open
|
|
159
|
+
// question never duplicates itself.
|
|
160
|
+
for (const fresh of input.comments ?? []) {
|
|
161
|
+
if (merged.some((comment) => comment.author === "assistant" && comment.note === fresh.note))
|
|
162
|
+
continue;
|
|
163
|
+
merged.push({
|
|
164
|
+
id: fresh.id,
|
|
165
|
+
author: "assistant",
|
|
166
|
+
...(fresh.quote !== undefined ? { quote: fresh.quote } : {}),
|
|
167
|
+
note: fresh.note,
|
|
168
|
+
status: "open",
|
|
169
|
+
createdAtRound: round,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return merged;
|
|
173
|
+
}
|
|
174
|
+
export function applyAlignmentSet(state, input) {
|
|
175
|
+
if (!validAlignmentId(input.id)) {
|
|
176
|
+
throw new AlignmentError("invalid-id", `alignment id must match [A-Za-z0-9][A-Za-z0-9._-]* (no leading dot): ${input.id}`);
|
|
177
|
+
}
|
|
178
|
+
if (!Array.isArray(input.blocks) || input.blocks.length === 0) {
|
|
179
|
+
throw new AlignmentError("invalid-input", "blocks must be a non-empty array");
|
|
180
|
+
}
|
|
181
|
+
if (input.blocks.length > ALIGNMENT_LIMITS.blocksPerStepMax) {
|
|
182
|
+
throw new AlignmentError("invalid-input", `a step may carry at most ${ALIGNMENT_LIMITS.blocksPerStepMax} blocks`);
|
|
183
|
+
}
|
|
184
|
+
const seen = new Set();
|
|
185
|
+
for (const block of input.blocks) {
|
|
186
|
+
validateBlockInput(block);
|
|
187
|
+
if (seen.has(block.id)) {
|
|
188
|
+
throw new AlignmentError("invalid-block", `duplicate block id within the step: ${block.id}`);
|
|
189
|
+
}
|
|
190
|
+
seen.add(block.id);
|
|
191
|
+
}
|
|
192
|
+
const existing = findRecord(state, input.id);
|
|
193
|
+
if (!existing) {
|
|
194
|
+
// Create: created-as-aligning (no draft state) and single-active (总则 9).
|
|
195
|
+
const active = state.alignments.find((record) => record.status === "aligning" || record.status === "finalizing");
|
|
196
|
+
if (active) {
|
|
197
|
+
throw new AlignmentError("active-alignment-exists", `an alignment is already in progress: ${active.id}; complete or cancel it first`);
|
|
198
|
+
}
|
|
199
|
+
const record = {
|
|
200
|
+
id: input.id,
|
|
201
|
+
title: input.title ?? input.id,
|
|
202
|
+
...(input.goalId !== undefined ? { goalId: input.goalId } : {}),
|
|
203
|
+
status: "aligning",
|
|
204
|
+
revision: 0,
|
|
205
|
+
activeStep: "objective",
|
|
206
|
+
steps: [
|
|
207
|
+
{
|
|
208
|
+
step: "objective",
|
|
209
|
+
status: "active",
|
|
210
|
+
blocks: input.blocks.map((block) => materializeBlock(block, undefined, 0)),
|
|
211
|
+
},
|
|
212
|
+
{ step: "detail", status: "pending", blocks: [] },
|
|
213
|
+
{ step: "design", status: "pending", blocks: [] },
|
|
214
|
+
],
|
|
215
|
+
roundCount: 0,
|
|
216
|
+
};
|
|
217
|
+
return { state: { alignments: [...state.alignments, record] }, transitions: [] };
|
|
218
|
+
}
|
|
219
|
+
if (existing.status !== "aligning") {
|
|
220
|
+
throw new AlignmentError("terminal-state", `alignment is terminal and cannot be updated: ${input.id}`);
|
|
221
|
+
}
|
|
222
|
+
const revision = input.revision ?? 0;
|
|
223
|
+
if (revision <= existing.revision) {
|
|
224
|
+
throw new AlignmentError("stale-revision", `stale alignment revision: ${input.id} expects > ${existing.revision}, got ${revision}`);
|
|
225
|
+
}
|
|
226
|
+
const step = activeStep(existing);
|
|
227
|
+
if (step.status !== "active" && step.status !== "reset") {
|
|
228
|
+
throw new AlignmentError("step-not-writable", `step ${existing.activeStep} is ${step.status}; only the active step accepts blocks`);
|
|
229
|
+
}
|
|
230
|
+
// Deleting a block that still carries open comments is rejected (总则 3):
|
|
231
|
+
// deletion must not become a way to dodge unresolved objections.
|
|
232
|
+
for (const previous of step.blocks) {
|
|
233
|
+
if (input.blocks.some((block) => block.id === previous.id))
|
|
234
|
+
continue;
|
|
235
|
+
if (previous.comments.some((comment) => comment.status === "open")) {
|
|
236
|
+
throw new AlignmentError("block-has-open-comments", `block ${previous.id} carries open comments; resolve them before deleting it (edit the content instead)`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const blocks = input.blocks.map((blockInput) => {
|
|
240
|
+
const previous = step.blocks.find((candidate) => candidate.id === blockInput.id);
|
|
241
|
+
return materializeBlock(blockInput, previous, existing.roundCount);
|
|
242
|
+
});
|
|
243
|
+
// Version snapshot (走查 2026-10-03): archive the outgoing step block set
|
|
244
|
+
// before it is replaced. Append-only, bounded rolling; the page views
|
|
245
|
+
// historical versions read-only — only the latest accepts interaction.
|
|
246
|
+
// An empty outgoing set has nothing to roll back to — skip it, otherwise
|
|
247
|
+
// the version bar fills with chips that render a blank view.
|
|
248
|
+
const archive = JSON.stringify(step.blocks);
|
|
249
|
+
const snapshot = {
|
|
250
|
+
revision: existing.revision,
|
|
251
|
+
round: existing.roundCount,
|
|
252
|
+
step: existing.activeStep,
|
|
253
|
+
blocks: archive.length <= ALIGNMENT_LIMITS.versionsSnapshotMaxBytes ? structuredClone(step.blocks) : [],
|
|
254
|
+
...(archive.length > ALIGNMENT_LIMITS.versionsSnapshotMaxBytes ? { truncated: true } : {}),
|
|
255
|
+
};
|
|
256
|
+
const priorVersions = (step.blocks.length === 0 ? (existing.versions ?? []) : [...(existing.versions ?? []), snapshot]).slice(-ALIGNMENT_LIMITS.versionsMax);
|
|
257
|
+
const updated = {
|
|
258
|
+
...existing,
|
|
259
|
+
title: input.title ?? existing.title,
|
|
260
|
+
goalId: input.goalId ?? existing.goalId,
|
|
261
|
+
revision,
|
|
262
|
+
steps: existing.steps.map((candidate) => candidate.step === existing.activeStep ? { ...candidate, blocks } : candidate),
|
|
263
|
+
pendingFeedback: undefined,
|
|
264
|
+
versions: priorVersions,
|
|
265
|
+
};
|
|
266
|
+
return {
|
|
267
|
+
state: { alignments: state.alignments.map((record) => (record.id === updated.id ? updated : record)) },
|
|
268
|
+
transitions: [],
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// POST /round: the user feedback round (§5 round flow, v3.2 audits)
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
export function applyRoundPost(record0, post) {
|
|
275
|
+
if (record0.status !== "aligning" && record0.status !== "finalizing") {
|
|
276
|
+
throw new AlignmentError("terminal-state", `alignment is terminal: ${record0.id}`);
|
|
277
|
+
}
|
|
278
|
+
// Anti-staleness gate (terminal audit D6): the page must confirm against
|
|
279
|
+
// the exact state it rendered.
|
|
280
|
+
if (post.baseRevision !== record0.revision) {
|
|
281
|
+
throw new AlignmentError("stale-state", `page snapshot is stale (base ${post.baseRevision}, current ${record0.revision}); reload and resubmit`);
|
|
282
|
+
}
|
|
283
|
+
const hasNewFeedback = post.comments.length > 0 || post.additions.length > 0 || post.answers.length > 0;
|
|
284
|
+
if (post.stepConfirmed && hasNewFeedback) {
|
|
285
|
+
throw new AlignmentError("confirmed-with-feedback", "step confirmation cannot carry new comments/additions/answers; submit the feedback first");
|
|
286
|
+
}
|
|
287
|
+
// 终审阶段唯一合法提交是终审确认(走查 2026-10-03)。此时所有步已锁定,
|
|
288
|
+
// 反馈字段没有落点——尤其 approvals 镜像会把已锁定的 design 块整体重置为
|
|
289
|
+
// pending,再被无门禁的终审分支带进 completed。显式拒绝,不做静默重置。
|
|
290
|
+
if (record0.status === "finalizing" &&
|
|
291
|
+
(hasNewFeedback || post.commentReplies.length > 0 || post.approvals.length > 0 || !post.stepConfirmed)) {
|
|
292
|
+
throw new AlignmentError("invalid-input", "finalizing accepts only the final confirm (stepConfirmed without feedback fields)");
|
|
293
|
+
}
|
|
294
|
+
const record = structuredClone(record0);
|
|
295
|
+
const step = record.steps.find((candidate) => candidate.step === record.activeStep);
|
|
296
|
+
if (!step)
|
|
297
|
+
throw new AlignmentError("invalid-input", "active step missing");
|
|
298
|
+
const blockById = new Map(step.blocks.map((block) => [block.id, block]));
|
|
299
|
+
const round = record.roundCount + 1;
|
|
300
|
+
// New user comments: the quote must match the block's current content.
|
|
301
|
+
for (const comment of post.comments) {
|
|
302
|
+
const block = blockById.get(comment.blockId);
|
|
303
|
+
if (!block) {
|
|
304
|
+
throw new AlignmentError("invalid-input", `comment targets unknown block: ${comment.blockId}`);
|
|
305
|
+
}
|
|
306
|
+
const note = nonEmptyString(comment.note, "comment note", ALIGNMENT_LIMITS.noteMaxLength);
|
|
307
|
+
if (typeof comment.quote !== "string" || !block.content.includes(comment.quote)) {
|
|
308
|
+
throw new AlignmentError("comment-quote-mismatch", `comment quote does not match block ${comment.blockId} content`);
|
|
309
|
+
}
|
|
310
|
+
block.comments.push({
|
|
311
|
+
id: `c${round}-${block.comments.length + 1}`,
|
|
312
|
+
author: "user",
|
|
313
|
+
quote: comment.quote,
|
|
314
|
+
note,
|
|
315
|
+
...(comment.escalate === true ? { escalate: true } : {}),
|
|
316
|
+
status: "open",
|
|
317
|
+
createdAtRound: round,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
// Replies to assistant comments (answer/dismiss are mutually exclusive).
|
|
321
|
+
for (const reply of post.commentReplies) {
|
|
322
|
+
const block = blockById.get(reply.blockId);
|
|
323
|
+
const comment = block?.comments.find((candidate) => candidate.id === reply.commentId);
|
|
324
|
+
if (!comment || comment.author !== "assistant") {
|
|
325
|
+
throw new AlignmentError("invalid-input", `reply targets unknown or non-assistant comment: ${reply.commentId}`);
|
|
326
|
+
}
|
|
327
|
+
if (comment.status !== "open")
|
|
328
|
+
continue; // idempotent resubmission
|
|
329
|
+
if ((reply.answer !== undefined) === (reply.dismiss === true)) {
|
|
330
|
+
throw new AlignmentError("invalid-input", `reply must carry exactly one of answer or dismiss: ${reply.commentId}`);
|
|
331
|
+
}
|
|
332
|
+
comment.status = "resolved";
|
|
333
|
+
comment.resolution = reply.dismiss
|
|
334
|
+
? { kind: "dismissed", text: "user marked unimportant", round }
|
|
335
|
+
: {
|
|
336
|
+
kind: "answered",
|
|
337
|
+
text: nonEmptyString(reply.answer ?? "", "reply answer", ALIGNMENT_LIMITS.answerMaxLength),
|
|
338
|
+
round,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
// Fill-in answers for missing blocks: stored verbatim as the user's words;
|
|
342
|
+
// the model restructures them into the block on its next alignment_set.
|
|
343
|
+
for (const answer of post.answers) {
|
|
344
|
+
const block = blockById.get(answer.blockId);
|
|
345
|
+
if (!block || block.kind !== "missing") {
|
|
346
|
+
throw new AlignmentError("invalid-input", `answer targets unknown or non-missing block: ${answer.blockId}`);
|
|
347
|
+
}
|
|
348
|
+
if ((block.draftAnswer ?? "").trim() !== "")
|
|
349
|
+
continue; // idempotent resubmission
|
|
350
|
+
block.draftAnswer = nonEmptyString(answer.value, "answer", ALIGNMENT_LIMITS.answerMaxLength);
|
|
351
|
+
}
|
|
352
|
+
// Approvals mirror the page's full approved set for the active step.
|
|
353
|
+
// Skipped in finalizing (走查 2026-10-03): the step is locked and the
|
|
354
|
+
// final confirm carries approvals=[] — mirroring would reopen its blocks.
|
|
355
|
+
if (record0.status !== "finalizing") {
|
|
356
|
+
const approvalSet = new Set(post.approvals);
|
|
357
|
+
for (const id of approvalSet) {
|
|
358
|
+
if (!blockById.has(id)) {
|
|
359
|
+
throw new AlignmentError("invalid-input", `approval references unknown block: ${id}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
for (const block of step.blocks) {
|
|
363
|
+
block.status = approvalSet.has(block.id) ? "approved" : "pending";
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// Additions queue as pendingFeedback — the server-side truth of gate
|
|
367
|
+
// condition three (terminal audit D7); cleared by the next alignment_set.
|
|
368
|
+
const additions = [...(record.pendingFeedback?.additions ?? [])];
|
|
369
|
+
for (const addition of post.additions) {
|
|
370
|
+
additions.push(nonEmptyString(addition, "addition", ALIGNMENT_LIMITS.additionMaxLength));
|
|
371
|
+
}
|
|
372
|
+
record.pendingFeedback = additions.length > 0 ? { round, additions } : undefined;
|
|
373
|
+
const transitions = [
|
|
374
|
+
{
|
|
375
|
+
alignmentId: record.id,
|
|
376
|
+
step: record.activeStep,
|
|
377
|
+
event: "round.submitted",
|
|
378
|
+
round,
|
|
379
|
+
reason: {
|
|
380
|
+
code: "round-submitted",
|
|
381
|
+
detail: `approvals=${post.approvals.length} comments=${post.comments.length} replies=${post.commentReplies.length} answers=${post.answers.length} additions=${post.additions.length}`,
|
|
382
|
+
},
|
|
383
|
+
revision: record.revision,
|
|
384
|
+
},
|
|
385
|
+
];
|
|
386
|
+
record.roundCount = round;
|
|
387
|
+
let stepConfirmedNow = false;
|
|
388
|
+
if (post.stepConfirmed && record0.status === "finalizing") {
|
|
389
|
+
// 终审总览确认(走查 2026-10-03): the user reviewed the full plan and
|
|
390
|
+
// confirmed it — the only path to `completed` + baseline write.
|
|
391
|
+
record.status = "completed";
|
|
392
|
+
record.revision += 1;
|
|
393
|
+
for (const locked of record.steps)
|
|
394
|
+
locked.baselineConfirmedAtRound ??= record.roundCount;
|
|
395
|
+
transitions.push({
|
|
396
|
+
alignmentId: record.id,
|
|
397
|
+
event: "alignment.completed",
|
|
398
|
+
reason: { code: "final-review-approved", detail: "final review confirmed the complete plan" },
|
|
399
|
+
revision: record.revision,
|
|
400
|
+
});
|
|
401
|
+
const result = {
|
|
402
|
+
alignmentId: record.id,
|
|
403
|
+
round: record.roundCount,
|
|
404
|
+
step: record.activeStep,
|
|
405
|
+
revision: record.revision,
|
|
406
|
+
approvals: post.approvals,
|
|
407
|
+
comments: post.comments,
|
|
408
|
+
commentReplies: post.commentReplies,
|
|
409
|
+
answers: post.answers,
|
|
410
|
+
additions: post.additions,
|
|
411
|
+
stepConfirmed: true,
|
|
412
|
+
};
|
|
413
|
+
return { record, transitions, result };
|
|
414
|
+
}
|
|
415
|
+
if (post.stepConfirmed) {
|
|
416
|
+
const snapshot = gate(record);
|
|
417
|
+
if (!snapshot.allGreen) {
|
|
418
|
+
throw new AlignmentError("gate-not-satisfied", `gate not satisfied: ${snapshot.unapproved} unapproved blocks, ${snapshot.openComments} open comments, ${snapshot.pendingAdditions} pending additions`);
|
|
419
|
+
}
|
|
420
|
+
step.status = "locked";
|
|
421
|
+
step.baselineConfirmedAtRound = round;
|
|
422
|
+
record.revision += 1;
|
|
423
|
+
transitions.push({
|
|
424
|
+
alignmentId: record.id,
|
|
425
|
+
step: record.activeStep,
|
|
426
|
+
event: "step.confirmed",
|
|
427
|
+
round,
|
|
428
|
+
reason: { code: "step-baseline-locked", detail: `${step.blocks.length} blocks frozen` },
|
|
429
|
+
revision: record.revision,
|
|
430
|
+
});
|
|
431
|
+
const upcoming = nextStep(record.activeStep);
|
|
432
|
+
if (upcoming) {
|
|
433
|
+
record.activeStep = upcoming;
|
|
434
|
+
const upcomingStep = record.steps.find((candidate) => candidate.step === upcoming);
|
|
435
|
+
if (upcomingStep)
|
|
436
|
+
upcomingStep.status = "active";
|
|
437
|
+
record.revision += 1;
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
// Design locked (方案确认): enter the final-review overview instead
|
|
441
|
+
// of completing (走查 2026-10-03) — the baseline is written only
|
|
442
|
+
// after the user confirms the full plan on the 终审 tab.
|
|
443
|
+
record.status = "finalizing";
|
|
444
|
+
record.revision += 1;
|
|
445
|
+
transitions.push({
|
|
446
|
+
alignmentId: record.id,
|
|
447
|
+
event: "design.locked",
|
|
448
|
+
round,
|
|
449
|
+
reason: {
|
|
450
|
+
code: "scheme-locked-awaiting-final-review",
|
|
451
|
+
detail: "all three baselines locked; awaiting final review",
|
|
452
|
+
},
|
|
453
|
+
revision: record.revision,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
stepConfirmedNow = true;
|
|
457
|
+
}
|
|
458
|
+
const result = {
|
|
459
|
+
alignmentId: record.id,
|
|
460
|
+
round,
|
|
461
|
+
step: record0.activeStep,
|
|
462
|
+
revision: record.revision,
|
|
463
|
+
approvals: post.approvals,
|
|
464
|
+
comments: post.comments,
|
|
465
|
+
commentReplies: post.commentReplies,
|
|
466
|
+
answers: post.answers,
|
|
467
|
+
additions: post.additions,
|
|
468
|
+
stepConfirmed: stepConfirmedNow,
|
|
469
|
+
};
|
|
470
|
+
return { record, transitions, result };
|
|
471
|
+
}
|
|
472
|
+
/** Injection succeeded — the delivered transition closes the round (terminal audit D1). */
|
|
473
|
+
export function markDelivered(record, round) {
|
|
474
|
+
return [
|
|
475
|
+
{
|
|
476
|
+
alignmentId: record.id,
|
|
477
|
+
event: "round.delivered",
|
|
478
|
+
round,
|
|
479
|
+
reason: { code: "round-delivered", detail: "feedback injected into the session" },
|
|
480
|
+
revision: record.revision,
|
|
481
|
+
},
|
|
482
|
+
];
|
|
483
|
+
}
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
// alignment_advance: inspect / retreat / cancel (总则 6)
|
|
486
|
+
// ---------------------------------------------------------------------------
|
|
487
|
+
export function applyAlignmentAdvance(state, input) {
|
|
488
|
+
const record = findRecord(state, input.alignmentId);
|
|
489
|
+
if (!record) {
|
|
490
|
+
throw new AlignmentError("not-found", `unknown alignment: ${input.alignmentId}`);
|
|
491
|
+
}
|
|
492
|
+
if (input.action === "inspect")
|
|
493
|
+
return { state, transitions: [] };
|
|
494
|
+
if (record.status === "completed" || record.status === "cancelled") {
|
|
495
|
+
throw new AlignmentError("terminal-state", `alignment is terminal: ${record.id}`);
|
|
496
|
+
}
|
|
497
|
+
if (input.action === "cancel") {
|
|
498
|
+
const cancelled = { ...record, status: "cancelled" };
|
|
499
|
+
return {
|
|
500
|
+
state: { alignments: state.alignments.map((item) => (item.id === record.id ? cancelled : item)) },
|
|
501
|
+
transitions: [
|
|
502
|
+
{
|
|
503
|
+
alignmentId: record.id,
|
|
504
|
+
event: "alignment.cancelled",
|
|
505
|
+
reason: { code: "alignment-cancelled" },
|
|
506
|
+
revision: record.revision,
|
|
507
|
+
},
|
|
508
|
+
],
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
// retreat: comments survive the reset append-only (terminal audit D5); the
|
|
512
|
+
// escalate comments that triggered it are resolved here with the note.
|
|
513
|
+
const note = nonEmptyString(input.note ?? "", "retreat note", ALIGNMENT_LIMITS.noteMaxLength);
|
|
514
|
+
const target = input.step;
|
|
515
|
+
if (!target || STEP_ORDER.indexOf(target) >= STEP_ORDER.indexOf(record.activeStep)) {
|
|
516
|
+
throw new AlignmentError("retreat-target-invalid", `retreat target must be an earlier step than ${record.activeStep}`);
|
|
517
|
+
}
|
|
518
|
+
const affected = [];
|
|
519
|
+
const retreated = { ...record, steps: record.steps.map((step) => ({ ...step })) };
|
|
520
|
+
for (const step of retreated.steps) {
|
|
521
|
+
if (STEP_ORDER.indexOf(step.step) < STEP_ORDER.indexOf(target))
|
|
522
|
+
continue;
|
|
523
|
+
affected.push(step.step);
|
|
524
|
+
step.status = "reset";
|
|
525
|
+
step.baselineConfirmedAtRound = undefined;
|
|
526
|
+
for (const block of step.blocks) {
|
|
527
|
+
block.status = "pending";
|
|
528
|
+
for (const comment of block.comments) {
|
|
529
|
+
if (comment.status === "open" && comment.author === "user" && comment.escalate === true) {
|
|
530
|
+
comment.status = "resolved";
|
|
531
|
+
comment.resolution = { kind: "incorporated", text: note, round: record.roundCount };
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
retreated.activeStep = target;
|
|
537
|
+
// Finalizing → retreated: the final-review state is gone with the
|
|
538
|
+
// downstream resets; the task is writable at the target step again.
|
|
539
|
+
if (retreated.status === "finalizing")
|
|
540
|
+
retreated.status = "aligning";
|
|
541
|
+
retreated.revision += 1;
|
|
542
|
+
return {
|
|
543
|
+
state: { alignments: state.alignments.map((item) => (item.id === record.id ? retreated : item)) },
|
|
544
|
+
transitions: [
|
|
545
|
+
{
|
|
546
|
+
alignmentId: record.id,
|
|
547
|
+
event: "step.retreated",
|
|
548
|
+
reason: {
|
|
549
|
+
code: "step-retreated",
|
|
550
|
+
detail: `target=${target}; reset=${affected.join(",")}; note=${note}`,
|
|
551
|
+
},
|
|
552
|
+
revision: retreated.revision,
|
|
553
|
+
},
|
|
554
|
+
],
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Step-scoped version rollback (走查 2026-10-03): drop the current unconfirmed
|
|
559
|
+
* version (not archived), promote the target snapshot to live, engine-bump the
|
|
560
|
+
* revision (D4). Comments survive append-only — restored blocks carry the union
|
|
561
|
+
* of live and archived comments by comment id, live status winning (a
|
|
562
|
+
* resolution is irreversible). Only the active (unlocked) step accepts it;
|
|
563
|
+
* locked steps go through retreat. Other steps are never touched.
|
|
564
|
+
*/
|
|
565
|
+
export function applyVersionRollback(state, input) {
|
|
566
|
+
const record = findRecord(state, input.alignmentId);
|
|
567
|
+
if (!record) {
|
|
568
|
+
throw new AlignmentError("not-found", `unknown alignment: ${input.alignmentId}`);
|
|
569
|
+
}
|
|
570
|
+
if (record.status !== "aligning") {
|
|
571
|
+
throw new AlignmentError("terminal-state", `version rollback needs an aligning task: ${record.id}`);
|
|
572
|
+
}
|
|
573
|
+
const step = activeStep(record);
|
|
574
|
+
if (step.status !== "active" && step.status !== "reset") {
|
|
575
|
+
throw new AlignmentError("step-not-writable", `step ${record.activeStep} is ${step.status}; locked steps go through retreat`);
|
|
576
|
+
}
|
|
577
|
+
const versions = [...(record.versions ?? [])];
|
|
578
|
+
const index = versions.findIndex((snapshot) => snapshot.revision === input.targetRevision && snapshot.step === record.activeStep);
|
|
579
|
+
if (index < 0) {
|
|
580
|
+
throw new AlignmentError("invalid-input", `no snapshot of step ${record.activeStep} at revision ${input.targetRevision}`);
|
|
581
|
+
}
|
|
582
|
+
const snapshot = versions[index];
|
|
583
|
+
if (snapshot.truncated) {
|
|
584
|
+
throw new AlignmentError("invalid-input", `snapshot r${input.targetRevision} exceeded the size budget and has no blocks`);
|
|
585
|
+
}
|
|
586
|
+
const liveById = new Map(step.blocks.map((block) => [block.id, block]));
|
|
587
|
+
const restored = snapshot.blocks.map((archived) => {
|
|
588
|
+
const live = liveById.get(archived.id);
|
|
589
|
+
if (!live)
|
|
590
|
+
return structuredClone(archived);
|
|
591
|
+
const merged = new Map(archived.comments.map((comment) => [comment.id, comment]));
|
|
592
|
+
for (const comment of live.comments)
|
|
593
|
+
merged.set(comment.id, comment); // live status wins
|
|
594
|
+
return { ...structuredClone(archived), comments: [...merged.values()] };
|
|
595
|
+
});
|
|
596
|
+
// The current version is dropped, the target is promoted: remove it from
|
|
597
|
+
// the archive rather than re-archiving the live set.
|
|
598
|
+
versions.splice(index, 1);
|
|
599
|
+
const rolled = {
|
|
600
|
+
...record,
|
|
601
|
+
revision: record.revision + 1,
|
|
602
|
+
steps: record.steps.map((candidate) => candidate.step === record.activeStep
|
|
603
|
+
? { ...candidate, blocks: restored, status: candidate.status === "locked" ? "reset" : candidate.status }
|
|
604
|
+
: candidate),
|
|
605
|
+
versions,
|
|
606
|
+
};
|
|
607
|
+
return {
|
|
608
|
+
state: { alignments: state.alignments.map((item) => (item.id === rolled.id ? rolled : item)) },
|
|
609
|
+
transitions: [
|
|
610
|
+
{
|
|
611
|
+
alignmentId: rolled.id,
|
|
612
|
+
step: rolled.activeStep,
|
|
613
|
+
event: "version.rolled-back",
|
|
614
|
+
reason: { code: "version-rolled-back", detail: `target=r${input.targetRevision}` },
|
|
615
|
+
revision: rolled.revision,
|
|
616
|
+
},
|
|
617
|
+
],
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
/** Compact final baseline listing (§5 round flow 4): one line per block, answers first. */
|
|
621
|
+
export function finalBaselineLines(record) {
|
|
622
|
+
if (record.status !== "completed")
|
|
623
|
+
return undefined;
|
|
624
|
+
const lines = [];
|
|
625
|
+
for (const step of record.steps) {
|
|
626
|
+
for (const block of step.blocks) {
|
|
627
|
+
const confirmed = block.content.split("\n").find((line) => line.includes("→ 已确认"));
|
|
628
|
+
const text = (confirmed ? confirmed.replace(/^\s*→\s*/, "") : block.content.split("\n")[0]).slice(0, 120);
|
|
629
|
+
lines.push({ step: step.step, kind: block.kind, id: block.id, line: text });
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return lines;
|
|
633
|
+
}
|
|
634
|
+
//# sourceMappingURL=engine.js.map
|