@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 ADDED
@@ -0,0 +1,38 @@
1
+ # @agent-forge/plugin-alignment
2
+
3
+ Requirement alignment capability plugin for Agent Forge: a three-step
4
+ alignment funnel (**objective → detail → design**) with block-level
5
+ bidirectional feedback confirmed on a local HTML page, feeding the plan layer
6
+ with a user-approved baseline.
7
+
8
+ - Design contract (authoritative): `docs/design/需求对齐插件设计.md`
9
+ - Position: an upstream quality gate for `plan` — not a replacement. The
10
+ goal/plan/todo layers are untouched; the completed baseline is consumed
11
+ through the shared session-entry protocol.
12
+
13
+ ## Tools
14
+
15
+ | Tool | Purpose |
16
+ | --- | --- |
17
+ | `alignment_set` | Create / update the active step's full block set. Unchanged blocks inherit user approval by content fingerprint; comments are append-only (user text survives resends verbatim). |
18
+ | `alignment_round` | Open the local confirmation page (`127.0.0.1`, random token path) and return its URL plus progress facts. Non-blocking: feedback arrives later as an `alignment.feedback` session message. |
19
+ | `alignment_advance` | `inspect` (status/gate/baseline), `retreat` (roll back to an earlier step, resetting downstream confirmations), `cancel`. |
20
+
21
+ Command: `/alignment` · `/alignment why` · `/alignment log [N]`.
22
+
23
+ ## Persistence protocol
24
+
25
+ Structurally identical to the goal/plan/todo/workflow plugins: append-only
26
+ `alignment.state` full snapshots plus one `alignment.transition` audit entry
27
+ per conversion, announced as `alignment.changed`.
28
+
29
+ ## Notes
30
+
31
+ - Requires a host session that exposes `SessionAPI.sendMessage` (feedback
32
+ injection); without it only `alignment_round` fails — the rest of the
33
+ surface stays usable.
34
+ - On completion the engine writes a read-only baseline snapshot to
35
+ `<workspace>/alignments/<id>.md` (image-generation workspace-product
36
+ precedent).
37
+ - Deterministic tests only (fake CapabilityAPI + real `node:http` on an
38
+ OS-assigned port); no real model calls.
@@ -0,0 +1,19 @@
1
+ {
2
+ "packageFormatVersion": 1,
3
+ "id": "agent-forge.plugin.alignment",
4
+ "pluginVersion": "0.88.1",
5
+ "apiVersion": "1",
6
+ "minHostVersion": "0.84.4",
7
+ "platform": {
8
+ "os": [
9
+ "windows",
10
+ "macos",
11
+ "linux"
12
+ ],
13
+ "runtime": [
14
+ {
15
+ "name": "node"
16
+ }
17
+ ]
18
+ }
19
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Final-baseline persistence (设计 §5 round flow 4, §13.2 收口): when the
3
+ * design step confirms, the engine writes a read-only human-readable snapshot
4
+ * to `<workspace>/alignments/<id>.md`. Workspace product-subdirectory writes
5
+ * follow the image-generation `generated-images/` precedent (flag "wx" so a
6
+ * name collision fails instead of overwriting). The id whitelist in the
7
+ * engine makes the filename traversal-proof.
8
+ */
9
+ import type { AlignmentRecord } from "./types.ts";
10
+ export declare function baselineFilePath(workspaceRoot: string, alignmentId: string): string;
11
+ export declare function writeBaselineFile(record: AlignmentRecord, workspaceRoot: string): Promise<string>;
12
+ //# sourceMappingURL=baseline.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"baseline.d.ts","sourceRoot":"","sources":["../src/baseline.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAelD,wBAAgB,gBAAgB,CAAC,aAAa,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAEnF;AAED,wBAAsB,iBAAiB,CAAC,MAAM,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA2CvG","sourcesContent":["/**\n * Final-baseline persistence (设计 §5 round flow 4, §13.2 收口): when the\n * design step confirms, the engine writes a read-only human-readable snapshot\n * to `<workspace>/alignments/<id>.md`. Workspace product-subdirectory writes\n * follow the image-generation `generated-images/` precedent (flag \"wx\" so a\n * name collision fails instead of overwriting). The id whitelist in the\n * engine makes the filename traversal-proof.\n */\n\nimport { mkdir, writeFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport type { AlignmentRecord } from \"./types.ts\";\n\nconst KIND_CN: Record<string, string> = {\n\tfact: \"取证事实\",\n\tassumption: \"假设\",\n\tdecision: \"决策\",\n\tmissing: \"待补充\",\n};\n\nconst STEP_CN: Record<string, string> = {\n\tobjective: \"目标\",\n\tdetail: \"细节\",\n\tdesign: \"设计\",\n};\n\nexport function baselineFilePath(workspaceRoot: string, alignmentId: string): string {\n\treturn join(workspaceRoot, \"alignments\", `${alignmentId}.md`);\n}\n\nexport async function writeBaselineFile(record: AlignmentRecord, workspaceRoot: string): Promise<string> {\n\tconst frontmatter = [\n\t\t\"---\",\n\t\t`id: ${record.id}`,\n\t\t`title: ${JSON.stringify(record.title)}`,\n\t\trecord.goalId ? `goal: ${record.goalId}` : null,\n\t\t`status: ${record.status}`,\n\t\t`revision: ${record.revision}`,\n\t\t`rounds: ${record.roundCount}`,\n\t\t\"schema: alignment-baseline/1\",\n\t\t\"---\",\n\t]\n\t\t.filter((line): line is string => line !== null)\n\t\t.join(\"\\n\");\n\n\tconst sections = record.steps\n\t\t.map((step) => {\n\t\t\tconst header = `## ${STEP_CN[step.step] ?? step.step}(${step.blocks.length} 块,第 ${step.baselineConfirmedAtRound ?? \"-\"} 轮确认)`;\n\t\t\tconst items = step.blocks\n\t\t\t\t.map((block) => {\n\t\t\t\t\tconst lines = [\n\t\t\t\t\t\t`- [${KIND_CN[block.kind] ?? block.kind}] ${block.id}: ${block.content.replace(/\\n/g, \" \")}`,\n\t\t\t\t\t];\n\t\t\t\t\tif (block.rationale) lines.push(` - 理由:${block.rationale.replace(/\\n/g, \" \")}`);\n\t\t\t\t\treturn lines.join(\"\\n\");\n\t\t\t\t})\n\t\t\t\t.join(\"\\n\");\n\t\t\treturn `${header}\\n\\n${items}`;\n\t\t})\n\t\t.join(\"\\n\\n\");\n\n\tconst body = `${frontmatter}\n\n# 对齐基线:${record.title}\n\n> 只读快照(机器真值在会话 entries 的 alignment.state);实施中改动任一决策,须回对齐插件重新确认该块。\n\n${sections}\n`;\n\tconst path = baselineFilePath(workspaceRoot, record.id);\n\tawait mkdir(join(workspaceRoot, \"alignments\"), { recursive: true });\n\tawait writeFile(path, body, { encoding: \"utf8\", flag: \"wx\" });\n\treturn path;\n}\n"]}
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Final-baseline persistence (设计 §5 round flow 4, §13.2 收口): when the
3
+ * design step confirms, the engine writes a read-only human-readable snapshot
4
+ * to `<workspace>/alignments/<id>.md`. Workspace product-subdirectory writes
5
+ * follow the image-generation `generated-images/` precedent (flag "wx" so a
6
+ * name collision fails instead of overwriting). The id whitelist in the
7
+ * engine makes the filename traversal-proof.
8
+ */
9
+ import { mkdir, writeFile } from "node:fs/promises";
10
+ import { join } from "node:path";
11
+ const KIND_CN = {
12
+ fact: "取证事实",
13
+ assumption: "假设",
14
+ decision: "决策",
15
+ missing: "待补充",
16
+ };
17
+ const STEP_CN = {
18
+ objective: "目标",
19
+ detail: "细节",
20
+ design: "设计",
21
+ };
22
+ export function baselineFilePath(workspaceRoot, alignmentId) {
23
+ return join(workspaceRoot, "alignments", `${alignmentId}.md`);
24
+ }
25
+ export async function writeBaselineFile(record, workspaceRoot) {
26
+ const frontmatter = [
27
+ "---",
28
+ `id: ${record.id}`,
29
+ `title: ${JSON.stringify(record.title)}`,
30
+ record.goalId ? `goal: ${record.goalId}` : null,
31
+ `status: ${record.status}`,
32
+ `revision: ${record.revision}`,
33
+ `rounds: ${record.roundCount}`,
34
+ "schema: alignment-baseline/1",
35
+ "---",
36
+ ]
37
+ .filter((line) => line !== null)
38
+ .join("\n");
39
+ const sections = record.steps
40
+ .map((step) => {
41
+ const header = `## ${STEP_CN[step.step] ?? step.step}(${step.blocks.length} 块,第 ${step.baselineConfirmedAtRound ?? "-"} 轮确认)`;
42
+ const items = step.blocks
43
+ .map((block) => {
44
+ const lines = [
45
+ `- [${KIND_CN[block.kind] ?? block.kind}] ${block.id}: ${block.content.replace(/\n/g, " ")}`,
46
+ ];
47
+ if (block.rationale)
48
+ lines.push(` - 理由:${block.rationale.replace(/\n/g, " ")}`);
49
+ return lines.join("\n");
50
+ })
51
+ .join("\n");
52
+ return `${header}\n\n${items}`;
53
+ })
54
+ .join("\n\n");
55
+ const body = `${frontmatter}
56
+
57
+ # 对齐基线:${record.title}
58
+
59
+ > 只读快照(机器真值在会话 entries 的 alignment.state);实施中改动任一决策,须回对齐插件重新确认该块。
60
+
61
+ ${sections}
62
+ `;
63
+ const path = baselineFilePath(workspaceRoot, record.id);
64
+ await mkdir(join(workspaceRoot, "alignments"), { recursive: true });
65
+ await writeFile(path, body, { encoding: "utf8", flag: "wx" });
66
+ return path;
67
+ }
68
+ //# sourceMappingURL=baseline.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"baseline.js","sourceRoot":"","sources":["../src/baseline.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Final-baseline persistence (设计 §5 round flow 4, §13.2 收口): when the\n * design step confirms, the engine writes a read-only human-readable snapshot\n * to `<workspace>/alignments/<id>.md`. Workspace product-subdirectory writes\n * follow the image-generation `generated-images/` precedent (flag \"wx\" so a\n * name collision fails instead of overwriting). The id whitelist in the\n * engine makes the filename traversal-proof.\n */\n\nimport { mkdir, writeFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport type { AlignmentRecord } from \"./types.ts\";\n\nconst KIND_CN: Record<string, string> = {\n\tfact: \"取证事实\",\n\tassumption: \"假设\",\n\tdecision: \"决策\",\n\tmissing: \"待补充\",\n};\n\nconst STEP_CN: Record<string, string> = {\n\tobjective: \"目标\",\n\tdetail: \"细节\",\n\tdesign: \"设计\",\n};\n\nexport function baselineFilePath(workspaceRoot: string, alignmentId: string): string {\n\treturn join(workspaceRoot, \"alignments\", `${alignmentId}.md`);\n}\n\nexport async function writeBaselineFile(record: AlignmentRecord, workspaceRoot: string): Promise<string> {\n\tconst frontmatter = [\n\t\t\"---\",\n\t\t`id: ${record.id}`,\n\t\t`title: ${JSON.stringify(record.title)}`,\n\t\trecord.goalId ? `goal: ${record.goalId}` : null,\n\t\t`status: ${record.status}`,\n\t\t`revision: ${record.revision}`,\n\t\t`rounds: ${record.roundCount}`,\n\t\t\"schema: alignment-baseline/1\",\n\t\t\"---\",\n\t]\n\t\t.filter((line): line is string => line !== null)\n\t\t.join(\"\\n\");\n\n\tconst sections = record.steps\n\t\t.map((step) => {\n\t\t\tconst header = `## ${STEP_CN[step.step] ?? step.step}(${step.blocks.length} 块,第 ${step.baselineConfirmedAtRound ?? \"-\"} 轮确认)`;\n\t\t\tconst items = step.blocks\n\t\t\t\t.map((block) => {\n\t\t\t\t\tconst lines = [\n\t\t\t\t\t\t`- [${KIND_CN[block.kind] ?? block.kind}] ${block.id}: ${block.content.replace(/\\n/g, \" \")}`,\n\t\t\t\t\t];\n\t\t\t\t\tif (block.rationale) lines.push(` - 理由:${block.rationale.replace(/\\n/g, \" \")}`);\n\t\t\t\t\treturn lines.join(\"\\n\");\n\t\t\t\t})\n\t\t\t\t.join(\"\\n\");\n\t\t\treturn `${header}\\n\\n${items}`;\n\t\t})\n\t\t.join(\"\\n\\n\");\n\n\tconst body = `${frontmatter}\n\n# 对齐基线:${record.title}\n\n> 只读快照(机器真值在会话 entries 的 alignment.state);实施中改动任一决策,须回对齐插件重新确认该块。\n\n${sections}\n`;\n\tconst path = baselineFilePath(workspaceRoot, record.id);\n\tawait mkdir(join(workspaceRoot, \"alignments\"), { recursive: true });\n\tawait writeFile(path, body, { encoding: \"utf8\", flag: \"wx\" });\n\treturn path;\n}\n"]}
@@ -0,0 +1,93 @@
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 { type AlignmentBlockKind, type AlignmentRecord, type AlignmentRoundPost, type AlignmentRoundResult, type AlignmentState, type AlignmentStepId, type AlignmentStepState, type AlignmentTransitionEntry, type GateSnapshot } from "./types.ts";
10
+ export interface EngineRun {
11
+ state: AlignmentState;
12
+ transitions: AlignmentTransitionEntry[];
13
+ }
14
+ /** Model-facing block input (alignment_set); comment ownership is checked against it. */
15
+ export interface BlockInput {
16
+ id: string;
17
+ kind: AlignmentBlockKind;
18
+ content: string;
19
+ rationale?: string;
20
+ impact?: string[];
21
+ /** New assistant comments for this block (author is forced to assistant). */
22
+ comments?: {
23
+ id: string;
24
+ note: string;
25
+ quote?: string;
26
+ }[];
27
+ /** Resolutions the model attaches to existing comments. */
28
+ commentResolutions?: {
29
+ commentId: string;
30
+ kind: "answered" | "dismissed" | "incorporated";
31
+ text?: string;
32
+ }[];
33
+ }
34
+ export interface AlignmentSetInput {
35
+ id: string;
36
+ title?: string;
37
+ goalId?: string;
38
+ revision?: number;
39
+ blocks: BlockInput[];
40
+ }
41
+ export interface AdvanceInput {
42
+ alignmentId: string;
43
+ action: "inspect" | "retreat" | "cancel";
44
+ step?: AlignmentStepId;
45
+ note?: string;
46
+ }
47
+ export interface RoundPostOutcome {
48
+ record: AlignmentRecord;
49
+ transitions: AlignmentTransitionEntry[];
50
+ result: AlignmentRoundResult;
51
+ }
52
+ export interface AdvanceOutcome {
53
+ state: AlignmentState;
54
+ transitions: AlignmentTransitionEntry[];
55
+ }
56
+ export declare function validAlignmentId(id: string): boolean;
57
+ /** Fingerprint of the fields whose change must re-ask for approval (总则 3). */
58
+ export declare function blockFingerprint(kind: AlignmentBlockKind, content: string, rationale: string | undefined, impact: string[] | undefined): string;
59
+ export declare function findRecord(state: AlignmentState, id: string): AlignmentRecord | undefined;
60
+ export declare function activeStep(record: AlignmentRecord): AlignmentStepState;
61
+ /**
62
+ * Gate facts (总则 2): the three server-checkable conditions. A missing block
63
+ * without a stored user answer counts as `missing-empty`; once answered it is
64
+ * just an unapproved block waiting for the model to restructure it.
65
+ */
66
+ export declare function gate(record: AlignmentRecord): GateSnapshot;
67
+ export declare function applyAlignmentSet(state: AlignmentState, input: AlignmentSetInput): EngineRun;
68
+ export declare function applyRoundPost(record0: AlignmentRecord, post: AlignmentRoundPost): RoundPostOutcome;
69
+ /** Injection succeeded — the delivered transition closes the round (terminal audit D1). */
70
+ export declare function markDelivered(record: AlignmentRecord, round: number): AlignmentTransitionEntry[];
71
+ export declare function applyAlignmentAdvance(state: AlignmentState, input: AdvanceInput): AdvanceOutcome;
72
+ export interface VersionRollbackInput {
73
+ alignmentId: string;
74
+ /** Target snapshot revision from `record.versions` (must be the active step's). */
75
+ targetRevision: number;
76
+ }
77
+ /**
78
+ * Step-scoped version rollback (走查 2026-10-03): drop the current unconfirmed
79
+ * version (not archived), promote the target snapshot to live, engine-bump the
80
+ * revision (D4). Comments survive append-only — restored blocks carry the union
81
+ * of live and archived comments by comment id, live status winning (a
82
+ * resolution is irreversible). Only the active (unlocked) step accepts it;
83
+ * locked steps go through retreat. Other steps are never touched.
84
+ */
85
+ export declare function applyVersionRollback(state: AlignmentState, input: VersionRollbackInput): EngineRun;
86
+ /** Compact final baseline listing (§5 round flow 4): one line per block, answers first. */
87
+ export declare function finalBaselineLines(record: AlignmentRecord): {
88
+ step: AlignmentStepId;
89
+ kind: AlignmentBlockKind;
90
+ id: string;
91
+ line: string;
92
+ }[] | undefined;
93
+ //# sourceMappingURL=engine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Pure alignment state machine (设计 §5) — no host API access, no I/O.\n *\n * Every function takes JSON-safe state and returns the next state plus the\n * audit transitions to persist; index.ts owns persistence (entries + events)\n * and http-server.ts owns transport. Deterministic by construction: same\n * inputs, same outputs, no clocks, no randomness.\n */\n\nimport { createHash } from \"node:crypto\";\nimport {\n\tALIGNMENT_LIMITS,\n\ttype AlignmentBlock,\n\ttype AlignmentBlockKind,\n\ttype AlignmentComment,\n\tAlignmentError,\n\ttype AlignmentRecord,\n\ttype AlignmentRoundPost,\n\ttype AlignmentRoundResult,\n\ttype AlignmentState,\n\ttype AlignmentStepId,\n\ttype AlignmentStepState,\n\ttype AlignmentTransitionEntry,\n\ttype AlignmentVersionSnapshot,\n\ttype GateSnapshot,\n\ttype PendingItem,\n\tSTEP_ORDER,\n} from \"./types.ts\";\n\nexport interface EngineRun {\n\tstate: AlignmentState;\n\ttransitions: AlignmentTransitionEntry[];\n}\n\n/** Model-facing block input (alignment_set); comment ownership is checked against it. */\nexport interface BlockInput {\n\tid: string;\n\tkind: AlignmentBlockKind;\n\tcontent: string;\n\trationale?: string;\n\timpact?: string[];\n\t/** New assistant comments for this block (author is forced to assistant). */\n\tcomments?: { id: string; note: string; quote?: string }[];\n\t/** Resolutions the model attaches to existing comments. */\n\tcommentResolutions?: {\n\t\tcommentId: string;\n\t\tkind: \"answered\" | \"dismissed\" | \"incorporated\";\n\t\ttext?: string;\n\t}[];\n}\n\nexport interface AlignmentSetInput {\n\tid: string;\n\ttitle?: string;\n\tgoalId?: string;\n\trevision?: number;\n\tblocks: BlockInput[];\n}\n\nexport interface AdvanceInput {\n\talignmentId: string;\n\taction: \"inspect\" | \"retreat\" | \"cancel\";\n\tstep?: AlignmentStepId;\n\tnote?: string;\n}\n\nexport interface RoundPostOutcome {\n\trecord: AlignmentRecord;\n\ttransitions: AlignmentTransitionEntry[];\n\tresult: AlignmentRoundResult;\n}\n\nexport interface AdvanceOutcome {\n\tstate: AlignmentState;\n\ttransitions: AlignmentTransitionEntry[];\n}\n\n// ---------------------------------------------------------------------------\n// Hashing & validation\n// ---------------------------------------------------------------------------\n\nconst ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;\n\nexport function validAlignmentId(id: string): boolean {\n\t// 入参可能来自未校验的工具输入(空对象 → undefined):test() 会把实参\n\t// 字符串化为 \"undefined\" 而\"通过\"模式匹配,必须先做类型守卫。\n\treturn typeof id === \"string\" && ID_PATTERN.test(id) && id.length <= 128;\n}\n\n/** Fingerprint of the fields whose change must re-ask for approval (总则 3). */\nexport function blockFingerprint(\n\tkind: AlignmentBlockKind,\n\tcontent: string,\n\trationale: string | undefined,\n\timpact: string[] | undefined,\n): string {\n\tconst canonical = JSON.stringify([kind, content, rationale ?? null, impact ?? null]);\n\treturn createHash(\"sha256\").update(canonical, \"utf8\").digest(\"hex\");\n}\n\nfunction nonEmptyString(value: unknown, name: string, max: number): string {\n\tif (typeof value !== \"string\" || value.trim() === \"\") {\n\t\tthrow new AlignmentError(\"invalid-input\", `${name} must be a non-empty string`);\n\t}\n\tif (value.length > max) {\n\t\tthrow new AlignmentError(\"invalid-input\", `${name} exceeds ${max} characters`);\n\t}\n\treturn value;\n}\n\nfunction validateBlockInput(input: BlockInput): void {\n\tif (typeof input.id !== \"string\" || input.id.trim() === \"\" || input.id.length > 128) {\n\t\tthrow new AlignmentError(\n\t\t\t\"invalid-block\",\n\t\t\t`block id must be a non-empty string (≤128 chars): ${String(input.id)}`,\n\t\t);\n\t}\n\tif (![\"fact\", \"assumption\", \"decision\", \"missing\"].includes(input.kind)) {\n\t\tthrow new AlignmentError(\"invalid-block\", `invalid block kind: ${String(input.kind)}`);\n\t}\n\tnonEmptyString(input.content, `block ${input.id} content`, ALIGNMENT_LIMITS.contentMaxLength);\n\tif (input.rationale !== undefined && input.rationale.length > ALIGNMENT_LIMITS.rationaleMaxLength) {\n\t\tthrow new AlignmentError(\n\t\t\t\"invalid-block\",\n\t\t\t`block ${input.id} rationale exceeds ${ALIGNMENT_LIMITS.rationaleMaxLength} characters`,\n\t\t);\n\t}\n\tif (input.impact?.some((item) => typeof item !== \"string\" || item.trim() === \"\")) {\n\t\tthrow new AlignmentError(\"invalid-block\", `block ${input.id} impact must be a non-empty string array`);\n\t}\n\tif ((input.comments?.length ?? 0) > ALIGNMENT_LIMITS.notesPerBlockMax) {\n\t\tthrow new AlignmentError(\n\t\t\t\"invalid-block\",\n\t\t\t`block ${input.id} carries more than ${ALIGNMENT_LIMITS.notesPerBlockMax} comments`,\n\t\t);\n\t}\n\tfor (const comment of input.comments ?? []) {\n\t\tif (typeof comment.id !== \"string\" || comment.id.trim() === \"\") {\n\t\t\tthrow new AlignmentError(\"invalid-comment\", `comment id on block ${input.id} must be a non-empty string`);\n\t\t}\n\t\tnonEmptyString(comment.note, `comment ${comment.id} note`, ALIGNMENT_LIMITS.noteMaxLength);\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// State helpers\n// ---------------------------------------------------------------------------\n\nexport function findRecord(state: AlignmentState, id: string): AlignmentRecord | undefined {\n\treturn state.alignments.find((record) => record.id === id);\n}\n\nexport function activeStep(record: AlignmentRecord): AlignmentStepState {\n\tconst found = record.steps.find((candidate) => candidate.step === record.activeStep);\n\tif (!found) throw new AlignmentError(\"invalid-input\", `active step missing: ${record.activeStep}`);\n\treturn found;\n}\n\n/**\n * Gate facts (总则 2): the three server-checkable conditions. A missing block\n * without a stored user answer counts as `missing-empty`; once answered it is\n * just an unapproved block waiting for the model to restructure it.\n */\nexport function gate(record: AlignmentRecord): GateSnapshot {\n\tconst step = activeStep(record);\n\tconst pending: PendingItem[] = [];\n\tlet unapproved = 0;\n\tfor (const block of step.blocks) {\n\t\tif (block.kind === \"missing\" && (block.draftAnswer ?? \"\").trim() === \"\") {\n\t\t\tpending.push({ blockId: block.id, reason: \"missing-empty\" });\n\t\t\tunapproved += 1;\n\t\t\tcontinue;\n\t\t}\n\t\tif (block.status !== \"approved\") {\n\t\t\tpending.push({ blockId: block.id, reason: \"unapproved\" });\n\t\t\tunapproved += 1;\n\t\t}\n\t}\n\tlet openComments = 0;\n\tfor (const block of step.blocks) {\n\t\tfor (const comment of block.comments) {\n\t\t\tif (comment.status !== \"open\") continue;\n\t\t\topenComments += 1;\n\t\t\tif (comment.author === \"assistant\") {\n\t\t\t\tpending.push({ blockId: block.id, reason: \"assistant-question-open\" });\n\t\t\t}\n\t\t}\n\t}\n\tconst pendingAdditions = record.pendingFeedback?.additions.length ?? 0;\n\treturn {\n\t\tunapproved,\n\t\topenComments,\n\t\tpendingAdditions,\n\t\tallGreen: unapproved === 0 && openComments === 0 && pendingAdditions === 0,\n\t\tpending,\n\t};\n}\n\nfunction nextStep(step: AlignmentStepId): AlignmentStepId | undefined {\n\tconst index = STEP_ORDER.indexOf(step);\n\treturn index >= 0 && index < STEP_ORDER.length - 1 ? STEP_ORDER[index + 1] : undefined;\n}\n\n// ---------------------------------------------------------------------------\n// alignment_set: create / update (总则 3 hash inheritance, 总则 5 comment merge)\n// ---------------------------------------------------------------------------\n\nfunction materializeBlock(\n\tinput: BlockInput,\n\tprevious: AlignmentBlock | undefined,\n\tcurrentRound: number,\n): AlignmentBlock {\n\tconst fingerprint = blockFingerprint(input.kind, input.content, input.rationale, input.impact);\n\tconst unchanged = previous !== undefined && previous.contentHash === fingerprint;\n\tconst block: AlignmentBlock = {\n\t\tid: input.id,\n\t\tkind: input.kind,\n\t\tcontent: input.content,\n\t\trationale: input.rationale,\n\t\timpact: input.impact,\n\t\t// Hash-identical resent blocks keep their approval (防确认疲劳); changed\n\t\t// content resets to pending and flags the round it changed in.\n\t\tstatus: unchanged ? previous.status : \"pending\",\n\t\tcontentHash: fingerprint,\n\t\t...(unchanged ? {} : { modifiedInRound: currentRound + 1 }),\n\t\t...(previous?.draftAnswer !== undefined ? { draftAnswer: previous.draftAnswer } : {}),\n\t\tcomments: [],\n\t};\n\tblock.comments = mergeComments(previous?.comments ?? [], input, currentRound);\n\treturn block;\n}\n\nfunction mergeComments(existing: AlignmentComment[], input: BlockInput, round: number): AlignmentComment[] {\n\tconst merged: AlignmentComment[] = existing.map((comment) => ({ ...comment }));\n\tconst resolutions = new Map((input.commentResolutions ?? []).map((item) => [item.commentId, item]));\n\t// Existing comments are append-only (总则 5): user text survives resends\n\t// verbatim, open assistant questions cannot silently disappear; only an\n\t// explicit resolution closes them.\n\tfor (const comment of merged) {\n\t\tconst resolution = resolutions.get(comment.id);\n\t\tif (!resolution) continue;\n\t\tresolutions.delete(comment.id);\n\t\tif (comment.status === \"resolved\") {\n\t\t\tthrow new AlignmentError(\"comment-ownership\", `comment ${comment.id} is already resolved`);\n\t\t}\n\t\tcomment.status = \"resolved\";\n\t\tcomment.resolution = { kind: resolution.kind, text: resolution.text, round };\n\t}\n\tif (resolutions.size > 0) {\n\t\tconst ids = [...resolutions.keys()].join(\", \");\n\t\tthrow new AlignmentError(\"comment-ownership\", `resolutions reference unknown comments: ${ids}`);\n\t}\n\t// New comments are assistant-only and deduped by note so a resent open\n\t// question never duplicates itself.\n\tfor (const fresh of input.comments ?? []) {\n\t\tif (merged.some((comment) => comment.author === \"assistant\" && comment.note === fresh.note)) continue;\n\t\tmerged.push({\n\t\t\tid: fresh.id,\n\t\t\tauthor: \"assistant\",\n\t\t\t...(fresh.quote !== undefined ? { quote: fresh.quote } : {}),\n\t\t\tnote: fresh.note,\n\t\t\tstatus: \"open\",\n\t\t\tcreatedAtRound: round,\n\t\t});\n\t}\n\treturn merged;\n}\n\nexport function applyAlignmentSet(state: AlignmentState, input: AlignmentSetInput): EngineRun {\n\tif (!validAlignmentId(input.id)) {\n\t\tthrow new AlignmentError(\n\t\t\t\"invalid-id\",\n\t\t\t`alignment id must match [A-Za-z0-9][A-Za-z0-9._-]* (no leading dot): ${input.id}`,\n\t\t);\n\t}\n\tif (!Array.isArray(input.blocks) || input.blocks.length === 0) {\n\t\tthrow new AlignmentError(\"invalid-input\", \"blocks must be a non-empty array\");\n\t}\n\tif (input.blocks.length > ALIGNMENT_LIMITS.blocksPerStepMax) {\n\t\tthrow new AlignmentError(\"invalid-input\", `a step may carry at most ${ALIGNMENT_LIMITS.blocksPerStepMax} blocks`);\n\t}\n\tconst seen = new Set<string>();\n\tfor (const block of input.blocks) {\n\t\tvalidateBlockInput(block);\n\t\tif (seen.has(block.id)) {\n\t\t\tthrow new AlignmentError(\"invalid-block\", `duplicate block id within the step: ${block.id}`);\n\t\t}\n\t\tseen.add(block.id);\n\t}\n\n\tconst existing = findRecord(state, input.id);\n\tif (!existing) {\n\t\t// Create: created-as-aligning (no draft state) and single-active (总则 9).\n\t\tconst active = state.alignments.find((record) => record.status === \"aligning\" || record.status === \"finalizing\");\n\t\tif (active) {\n\t\t\tthrow new AlignmentError(\n\t\t\t\t\"active-alignment-exists\",\n\t\t\t\t`an alignment is already in progress: ${active.id}; complete or cancel it first`,\n\t\t\t);\n\t\t}\n\t\tconst record: AlignmentRecord = {\n\t\t\tid: input.id,\n\t\t\ttitle: input.title ?? input.id,\n\t\t\t...(input.goalId !== undefined ? { goalId: input.goalId } : {}),\n\t\t\tstatus: \"aligning\",\n\t\t\trevision: 0,\n\t\t\tactiveStep: \"objective\",\n\t\t\tsteps: [\n\t\t\t\t{\n\t\t\t\t\tstep: \"objective\",\n\t\t\t\t\tstatus: \"active\",\n\t\t\t\t\tblocks: input.blocks.map((block) => materializeBlock(block, undefined, 0)),\n\t\t\t\t},\n\t\t\t\t{ step: \"detail\", status: \"pending\", blocks: [] },\n\t\t\t\t{ step: \"design\", status: \"pending\", blocks: [] },\n\t\t\t],\n\t\t\troundCount: 0,\n\t\t};\n\t\treturn { state: { alignments: [...state.alignments, record] }, transitions: [] };\n\t}\n\n\tif (existing.status !== \"aligning\") {\n\t\tthrow new AlignmentError(\"terminal-state\", `alignment is terminal and cannot be updated: ${input.id}`);\n\t}\n\tconst revision = input.revision ?? 0;\n\tif (revision <= existing.revision) {\n\t\tthrow new AlignmentError(\n\t\t\t\"stale-revision\",\n\t\t\t`stale alignment revision: ${input.id} expects > ${existing.revision}, got ${revision}`,\n\t\t);\n\t}\n\tconst step = activeStep(existing);\n\tif (step.status !== \"active\" && step.status !== \"reset\") {\n\t\tthrow new AlignmentError(\n\t\t\t\"step-not-writable\",\n\t\t\t`step ${existing.activeStep} is ${step.status}; only the active step accepts blocks`,\n\t\t);\n\t}\n\t// Deleting a block that still carries open comments is rejected (总则 3):\n\t// deletion must not become a way to dodge unresolved objections.\n\tfor (const previous of step.blocks) {\n\t\tif (input.blocks.some((block) => block.id === previous.id)) continue;\n\t\tif (previous.comments.some((comment) => comment.status === \"open\")) {\n\t\t\tthrow new AlignmentError(\n\t\t\t\t\"block-has-open-comments\",\n\t\t\t\t`block ${previous.id} carries open comments; resolve them before deleting it (edit the content instead)`,\n\t\t\t);\n\t\t}\n\t}\n\tconst blocks = input.blocks.map((blockInput) => {\n\t\tconst previous = step.blocks.find((candidate) => candidate.id === blockInput.id);\n\t\treturn materializeBlock(blockInput, previous, existing.roundCount);\n\t});\n\t// Version snapshot (走查 2026-10-03): archive the outgoing step block set\n\t// before it is replaced. Append-only, bounded rolling; the page views\n\t// historical versions read-only — only the latest accepts interaction.\n\t// An empty outgoing set has nothing to roll back to — skip it, otherwise\n\t// the version bar fills with chips that render a blank view.\n\tconst archive = JSON.stringify(step.blocks);\n\tconst snapshot: AlignmentVersionSnapshot = {\n\t\trevision: existing.revision,\n\t\tround: existing.roundCount,\n\t\tstep: existing.activeStep,\n\t\tblocks: archive.length <= ALIGNMENT_LIMITS.versionsSnapshotMaxBytes ? structuredClone(step.blocks) : [],\n\t\t...(archive.length > ALIGNMENT_LIMITS.versionsSnapshotMaxBytes ? { truncated: true as const } : {}),\n\t};\n\tconst priorVersions = (\n\t\tstep.blocks.length === 0 ? (existing.versions ?? []) : [...(existing.versions ?? []), snapshot]\n\t).slice(-ALIGNMENT_LIMITS.versionsMax);\n\tconst updated: AlignmentRecord = {\n\t\t...existing,\n\t\ttitle: input.title ?? existing.title,\n\t\tgoalId: input.goalId ?? existing.goalId,\n\t\trevision,\n\t\tsteps: existing.steps.map((candidate) =>\n\t\t\tcandidate.step === existing.activeStep ? { ...candidate, blocks } : candidate,\n\t\t),\n\t\tpendingFeedback: undefined,\n\t\tversions: priorVersions,\n\t};\n\treturn {\n\t\tstate: { alignments: state.alignments.map((record) => (record.id === updated.id ? updated : record)) },\n\t\ttransitions: [],\n\t};\n}\n\n// ---------------------------------------------------------------------------\n// POST /round: the user feedback round (§5 round flow, v3.2 audits)\n// ---------------------------------------------------------------------------\n\nexport function applyRoundPost(record0: AlignmentRecord, post: AlignmentRoundPost): RoundPostOutcome {\n\tif (record0.status !== \"aligning\" && record0.status !== \"finalizing\") {\n\t\tthrow new AlignmentError(\"terminal-state\", `alignment is terminal: ${record0.id}`);\n\t}\n\t// Anti-staleness gate (terminal audit D6): the page must confirm against\n\t// the exact state it rendered.\n\tif (post.baseRevision !== record0.revision) {\n\t\tthrow new AlignmentError(\n\t\t\t\"stale-state\",\n\t\t\t`page snapshot is stale (base ${post.baseRevision}, current ${record0.revision}); reload and resubmit`,\n\t\t);\n\t}\n\tconst hasNewFeedback = post.comments.length > 0 || post.additions.length > 0 || post.answers.length > 0;\n\tif (post.stepConfirmed && hasNewFeedback) {\n\t\tthrow new AlignmentError(\n\t\t\t\"confirmed-with-feedback\",\n\t\t\t\"step confirmation cannot carry new comments/additions/answers; submit the feedback first\",\n\t\t);\n\t}\n\t// 终审阶段唯一合法提交是终审确认(走查 2026-10-03)。此时所有步已锁定,\n\t// 反馈字段没有落点——尤其 approvals 镜像会把已锁定的 design 块整体重置为\n\t// pending,再被无门禁的终审分支带进 completed。显式拒绝,不做静默重置。\n\tif (\n\t\trecord0.status === \"finalizing\" &&\n\t\t(hasNewFeedback || post.commentReplies.length > 0 || post.approvals.length > 0 || !post.stepConfirmed)\n\t) {\n\t\tthrow new AlignmentError(\n\t\t\t\"invalid-input\",\n\t\t\t\"finalizing accepts only the final confirm (stepConfirmed without feedback fields)\",\n\t\t);\n\t}\n\n\tconst record: AlignmentRecord = structuredClone(record0);\n\tconst step = record.steps.find((candidate) => candidate.step === record.activeStep);\n\tif (!step) throw new AlignmentError(\"invalid-input\", \"active step missing\");\n\tconst blockById = new Map(step.blocks.map((block) => [block.id, block]));\n\tconst round = record.roundCount + 1;\n\n\t// New user comments: the quote must match the block's current content.\n\tfor (const comment of post.comments) {\n\t\tconst block = blockById.get(comment.blockId);\n\t\tif (!block) {\n\t\t\tthrow new AlignmentError(\"invalid-input\", `comment targets unknown block: ${comment.blockId}`);\n\t\t}\n\t\tconst note = nonEmptyString(comment.note, \"comment note\", ALIGNMENT_LIMITS.noteMaxLength);\n\t\tif (typeof comment.quote !== \"string\" || !block.content.includes(comment.quote)) {\n\t\t\tthrow new AlignmentError(\n\t\t\t\t\"comment-quote-mismatch\",\n\t\t\t\t`comment quote does not match block ${comment.blockId} content`,\n\t\t\t);\n\t\t}\n\t\tblock.comments.push({\n\t\t\tid: `c${round}-${block.comments.length + 1}`,\n\t\t\tauthor: \"user\",\n\t\t\tquote: comment.quote,\n\t\t\tnote,\n\t\t\t...(comment.escalate === true ? { escalate: true } : {}),\n\t\t\tstatus: \"open\",\n\t\t\tcreatedAtRound: round,\n\t\t});\n\t}\n\n\t// Replies to assistant comments (answer/dismiss are mutually exclusive).\n\tfor (const reply of post.commentReplies) {\n\t\tconst block = blockById.get(reply.blockId);\n\t\tconst comment = block?.comments.find((candidate) => candidate.id === reply.commentId);\n\t\tif (!comment || comment.author !== \"assistant\") {\n\t\t\tthrow new AlignmentError(\n\t\t\t\t\"invalid-input\",\n\t\t\t\t`reply targets unknown or non-assistant comment: ${reply.commentId}`,\n\t\t\t);\n\t\t}\n\t\tif (comment.status !== \"open\") continue; // idempotent resubmission\n\t\tif ((reply.answer !== undefined) === (reply.dismiss === true)) {\n\t\t\tthrow new AlignmentError(\n\t\t\t\t\"invalid-input\",\n\t\t\t\t`reply must carry exactly one of answer or dismiss: ${reply.commentId}`,\n\t\t\t);\n\t\t}\n\t\tcomment.status = \"resolved\";\n\t\tcomment.resolution = reply.dismiss\n\t\t\t? { kind: \"dismissed\", text: \"user marked unimportant\", round }\n\t\t\t: {\n\t\t\t\t\tkind: \"answered\",\n\t\t\t\t\ttext: nonEmptyString(reply.answer ?? \"\", \"reply answer\", ALIGNMENT_LIMITS.answerMaxLength),\n\t\t\t\t\tround,\n\t\t\t\t};\n\t}\n\n\t// Fill-in answers for missing blocks: stored verbatim as the user's words;\n\t// the model restructures them into the block on its next alignment_set.\n\tfor (const answer of post.answers) {\n\t\tconst block = blockById.get(answer.blockId);\n\t\tif (!block || block.kind !== \"missing\") {\n\t\t\tthrow new AlignmentError(\"invalid-input\", `answer targets unknown or non-missing block: ${answer.blockId}`);\n\t\t}\n\t\tif ((block.draftAnswer ?? \"\").trim() !== \"\") continue; // idempotent resubmission\n\t\tblock.draftAnswer = nonEmptyString(answer.value, \"answer\", ALIGNMENT_LIMITS.answerMaxLength);\n\t}\n\n\t// Approvals mirror the page's full approved set for the active step.\n\t// Skipped in finalizing (走查 2026-10-03): the step is locked and the\n\t// final confirm carries approvals=[] — mirroring would reopen its blocks.\n\tif (record0.status !== \"finalizing\") {\n\t\tconst approvalSet = new Set(post.approvals);\n\t\tfor (const id of approvalSet) {\n\t\t\tif (!blockById.has(id)) {\n\t\t\t\tthrow new AlignmentError(\"invalid-input\", `approval references unknown block: ${id}`);\n\t\t\t}\n\t\t}\n\t\tfor (const block of step.blocks) {\n\t\t\tblock.status = approvalSet.has(block.id) ? \"approved\" : \"pending\";\n\t\t}\n\t}\n\n\t// Additions queue as pendingFeedback — the server-side truth of gate\n\t// condition three (terminal audit D7); cleared by the next alignment_set.\n\tconst additions = [...(record.pendingFeedback?.additions ?? [])];\n\tfor (const addition of post.additions) {\n\t\tadditions.push(nonEmptyString(addition, \"addition\", ALIGNMENT_LIMITS.additionMaxLength));\n\t}\n\trecord.pendingFeedback = additions.length > 0 ? { round, additions } : undefined;\n\n\tconst transitions: AlignmentTransitionEntry[] = [\n\t\t{\n\t\t\talignmentId: record.id,\n\t\t\tstep: record.activeStep,\n\t\t\tevent: \"round.submitted\",\n\t\t\tround,\n\t\t\treason: {\n\t\t\t\tcode: \"round-submitted\",\n\t\t\t\tdetail: `approvals=${post.approvals.length} comments=${post.comments.length} replies=${post.commentReplies.length} answers=${post.answers.length} additions=${post.additions.length}`,\n\t\t\t},\n\t\t\trevision: record.revision,\n\t\t},\n\t];\n\trecord.roundCount = round;\n\n\tlet stepConfirmedNow = false;\n\tif (post.stepConfirmed && record0.status === \"finalizing\") {\n\t\t// 终审总览确认(走查 2026-10-03): the user reviewed the full plan and\n\t\t// confirmed it — the only path to `completed` + baseline write.\n\t\trecord.status = \"completed\";\n\t\trecord.revision += 1;\n\t\tfor (const locked of record.steps) locked.baselineConfirmedAtRound ??= record.roundCount;\n\t\ttransitions.push({\n\t\t\talignmentId: record.id,\n\t\t\tevent: \"alignment.completed\",\n\t\t\treason: { code: \"final-review-approved\", detail: \"final review confirmed the complete plan\" },\n\t\t\trevision: record.revision,\n\t\t});\n\t\tconst result: AlignmentRoundResult = {\n\t\t\talignmentId: record.id,\n\t\t\tround: record.roundCount,\n\t\t\tstep: record.activeStep,\n\t\t\trevision: record.revision,\n\t\t\tapprovals: post.approvals,\n\t\t\tcomments: post.comments,\n\t\t\tcommentReplies: post.commentReplies,\n\t\t\tanswers: post.answers,\n\t\t\tadditions: post.additions,\n\t\t\tstepConfirmed: true,\n\t\t};\n\t\treturn { record, transitions, result };\n\t}\n\tif (post.stepConfirmed) {\n\t\tconst snapshot = gate(record);\n\t\tif (!snapshot.allGreen) {\n\t\t\tthrow new AlignmentError(\n\t\t\t\t\"gate-not-satisfied\",\n\t\t\t\t`gate not satisfied: ${snapshot.unapproved} unapproved blocks, ${snapshot.openComments} open comments, ${snapshot.pendingAdditions} pending additions`,\n\t\t\t);\n\t\t}\n\t\tstep.status = \"locked\";\n\t\tstep.baselineConfirmedAtRound = round;\n\t\trecord.revision += 1;\n\t\ttransitions.push({\n\t\t\talignmentId: record.id,\n\t\t\tstep: record.activeStep,\n\t\t\tevent: \"step.confirmed\",\n\t\t\tround,\n\t\t\treason: { code: \"step-baseline-locked\", detail: `${step.blocks.length} blocks frozen` },\n\t\t\trevision: record.revision,\n\t\t});\n\t\tconst upcoming = nextStep(record.activeStep);\n\t\tif (upcoming) {\n\t\t\trecord.activeStep = upcoming;\n\t\t\tconst upcomingStep = record.steps.find((candidate) => candidate.step === upcoming);\n\t\t\tif (upcomingStep) upcomingStep.status = \"active\";\n\t\t\trecord.revision += 1;\n\t\t} else {\n\t\t\t// Design locked (方案确认): enter the final-review overview instead\n\t\t\t// of completing (走查 2026-10-03) — the baseline is written only\n\t\t\t// after the user confirms the full plan on the 终审 tab.\n\t\t\trecord.status = \"finalizing\";\n\t\t\trecord.revision += 1;\n\t\t\ttransitions.push({\n\t\t\t\talignmentId: record.id,\n\t\t\t\tevent: \"design.locked\",\n\t\t\t\tround,\n\t\t\t\treason: {\n\t\t\t\t\tcode: \"scheme-locked-awaiting-final-review\",\n\t\t\t\t\tdetail: \"all three baselines locked; awaiting final review\",\n\t\t\t\t},\n\t\t\t\trevision: record.revision,\n\t\t\t});\n\t\t}\n\t\tstepConfirmedNow = true;\n\t}\n\n\tconst result: AlignmentRoundResult = {\n\t\talignmentId: record.id,\n\t\tround,\n\t\tstep: record0.activeStep,\n\t\trevision: record.revision,\n\t\tapprovals: post.approvals,\n\t\tcomments: post.comments,\n\t\tcommentReplies: post.commentReplies,\n\t\tanswers: post.answers,\n\t\tadditions: post.additions,\n\t\tstepConfirmed: stepConfirmedNow,\n\t};\n\treturn { record, transitions, result };\n}\n\n/** Injection succeeded — the delivered transition closes the round (terminal audit D1). */\nexport function markDelivered(record: AlignmentRecord, round: number): AlignmentTransitionEntry[] {\n\treturn [\n\t\t{\n\t\t\talignmentId: record.id,\n\t\t\tevent: \"round.delivered\",\n\t\t\tround,\n\t\t\treason: { code: \"round-delivered\", detail: \"feedback injected into the session\" },\n\t\t\trevision: record.revision,\n\t\t},\n\t];\n}\n\n// ---------------------------------------------------------------------------\n// alignment_advance: inspect / retreat / cancel (总则 6)\n// ---------------------------------------------------------------------------\n\nexport function applyAlignmentAdvance(state: AlignmentState, input: AdvanceInput): AdvanceOutcome {\n\tconst record = findRecord(state, input.alignmentId);\n\tif (!record) {\n\t\tthrow new AlignmentError(\"not-found\", `unknown alignment: ${input.alignmentId}`);\n\t}\n\tif (input.action === \"inspect\") return { state, transitions: [] };\n\tif (record.status === \"completed\" || record.status === \"cancelled\") {\n\t\tthrow new AlignmentError(\"terminal-state\", `alignment is terminal: ${record.id}`);\n\t}\n\n\tif (input.action === \"cancel\") {\n\t\tconst cancelled: AlignmentRecord = { ...record, status: \"cancelled\" };\n\t\treturn {\n\t\t\tstate: { alignments: state.alignments.map((item) => (item.id === record.id ? cancelled : item)) },\n\t\t\ttransitions: [\n\t\t\t\t{\n\t\t\t\t\talignmentId: record.id,\n\t\t\t\t\tevent: \"alignment.cancelled\",\n\t\t\t\t\treason: { code: \"alignment-cancelled\" },\n\t\t\t\t\trevision: record.revision,\n\t\t\t\t},\n\t\t\t],\n\t\t};\n\t}\n\n\t// retreat: comments survive the reset append-only (terminal audit D5); the\n\t// escalate comments that triggered it are resolved here with the note.\n\tconst note = nonEmptyString(input.note ?? \"\", \"retreat note\", ALIGNMENT_LIMITS.noteMaxLength);\n\tconst target = input.step;\n\tif (!target || STEP_ORDER.indexOf(target) >= STEP_ORDER.indexOf(record.activeStep)) {\n\t\tthrow new AlignmentError(\n\t\t\t\"retreat-target-invalid\",\n\t\t\t`retreat target must be an earlier step than ${record.activeStep}`,\n\t\t);\n\t}\n\tconst affected: string[] = [];\n\tconst retreated: AlignmentRecord = { ...record, steps: record.steps.map((step) => ({ ...step })) };\n\tfor (const step of retreated.steps) {\n\t\tif (STEP_ORDER.indexOf(step.step) < STEP_ORDER.indexOf(target)) continue;\n\t\taffected.push(step.step);\n\t\tstep.status = \"reset\";\n\t\tstep.baselineConfirmedAtRound = undefined;\n\t\tfor (const block of step.blocks) {\n\t\t\tblock.status = \"pending\";\n\t\t\tfor (const comment of block.comments) {\n\t\t\t\tif (comment.status === \"open\" && comment.author === \"user\" && comment.escalate === true) {\n\t\t\t\t\tcomment.status = \"resolved\";\n\t\t\t\t\tcomment.resolution = { kind: \"incorporated\", text: note, round: record.roundCount };\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tretreated.activeStep = target;\n\t// Finalizing → retreated: the final-review state is gone with the\n\t// downstream resets; the task is writable at the target step again.\n\tif (retreated.status === \"finalizing\") retreated.status = \"aligning\";\n\tretreated.revision += 1;\n\treturn {\n\t\tstate: { alignments: state.alignments.map((item) => (item.id === record.id ? retreated : item)) },\n\t\ttransitions: [\n\t\t\t{\n\t\t\t\talignmentId: record.id,\n\t\t\t\tevent: \"step.retreated\",\n\t\t\t\treason: {\n\t\t\t\t\tcode: \"step-retreated\",\n\t\t\t\t\tdetail: `target=${target}; reset=${affected.join(\",\")}; note=${note}`,\n\t\t\t\t},\n\t\t\t\trevision: retreated.revision,\n\t\t\t},\n\t\t],\n\t};\n}\n\nexport interface VersionRollbackInput {\n\talignmentId: string;\n\t/** Target snapshot revision from `record.versions` (must be the active step's). */\n\ttargetRevision: number;\n}\n\n/**\n * Step-scoped version rollback (走查 2026-10-03): drop the current unconfirmed\n * version (not archived), promote the target snapshot to live, engine-bump the\n * revision (D4). Comments survive append-only — restored blocks carry the union\n * of live and archived comments by comment id, live status winning (a\n * resolution is irreversible). Only the active (unlocked) step accepts it;\n * locked steps go through retreat. Other steps are never touched.\n */\nexport function applyVersionRollback(state: AlignmentState, input: VersionRollbackInput): EngineRun {\n\tconst record = findRecord(state, input.alignmentId);\n\tif (!record) {\n\t\tthrow new AlignmentError(\"not-found\", `unknown alignment: ${input.alignmentId}`);\n\t}\n\tif (record.status !== \"aligning\") {\n\t\tthrow new AlignmentError(\"terminal-state\", `version rollback needs an aligning task: ${record.id}`);\n\t}\n\tconst step = activeStep(record);\n\tif (step.status !== \"active\" && step.status !== \"reset\") {\n\t\tthrow new AlignmentError(\n\t\t\t\"step-not-writable\",\n\t\t\t`step ${record.activeStep} is ${step.status}; locked steps go through retreat`,\n\t\t);\n\t}\n\tconst versions = [...(record.versions ?? [])];\n\tconst index = versions.findIndex(\n\t\t(snapshot) => snapshot.revision === input.targetRevision && snapshot.step === record.activeStep,\n\t);\n\tif (index < 0) {\n\t\tthrow new AlignmentError(\n\t\t\t\"invalid-input\",\n\t\t\t`no snapshot of step ${record.activeStep} at revision ${input.targetRevision}`,\n\t\t);\n\t}\n\tconst snapshot = versions[index];\n\tif (snapshot.truncated) {\n\t\tthrow new AlignmentError(\n\t\t\t\"invalid-input\",\n\t\t\t`snapshot r${input.targetRevision} exceeded the size budget and has no blocks`,\n\t\t);\n\t}\n\tconst liveById = new Map(step.blocks.map((block) => [block.id, block]));\n\tconst restored = snapshot.blocks.map((archived) => {\n\t\tconst live = liveById.get(archived.id);\n\t\tif (!live) return structuredClone(archived);\n\t\tconst merged = new Map(archived.comments.map((comment) => [comment.id, comment]));\n\t\tfor (const comment of live.comments) merged.set(comment.id, comment); // live status wins\n\t\treturn { ...structuredClone(archived), comments: [...merged.values()] };\n\t});\n\t// The current version is dropped, the target is promoted: remove it from\n\t// the archive rather than re-archiving the live set.\n\tversions.splice(index, 1);\n\tconst rolled: AlignmentRecord = {\n\t\t...record,\n\t\trevision: record.revision + 1,\n\t\tsteps: record.steps.map((candidate) =>\n\t\t\tcandidate.step === record.activeStep\n\t\t\t\t? { ...candidate, blocks: restored, status: candidate.status === \"locked\" ? \"reset\" : candidate.status }\n\t\t\t\t: candidate,\n\t\t),\n\t\tversions,\n\t};\n\treturn {\n\t\tstate: { alignments: state.alignments.map((item) => (item.id === rolled.id ? rolled : item)) },\n\t\ttransitions: [\n\t\t\t{\n\t\t\t\talignmentId: rolled.id,\n\t\t\t\tstep: rolled.activeStep,\n\t\t\t\tevent: \"version.rolled-back\",\n\t\t\t\treason: { code: \"version-rolled-back\", detail: `target=r${input.targetRevision}` },\n\t\t\t\trevision: rolled.revision,\n\t\t\t},\n\t\t],\n\t};\n}\n\n/** Compact final baseline listing (§5 round flow 4): one line per block, answers first. */\nexport function finalBaselineLines(\n\trecord: AlignmentRecord,\n): { step: AlignmentStepId; kind: AlignmentBlockKind; id: string; line: string }[] | undefined {\n\tif (record.status !== \"completed\") return undefined;\n\tconst lines: { step: AlignmentStepId; kind: AlignmentBlockKind; id: string; line: string }[] = [];\n\tfor (const step of record.steps) {\n\t\tfor (const block of step.blocks) {\n\t\t\tconst confirmed = block.content.split(\"\\n\").find((line) => line.includes(\"→ 已确认\"));\n\t\t\tconst text = (confirmed ? confirmed.replace(/^\\s*→\\s*/, \"\") : block.content.split(\"\\n\")[0]).slice(0, 120);\n\t\t\tlines.push({ step: step.step, kind: block.kind, id: block.id, line: text });\n\t\t}\n\t}\n\treturn lines;\n}\n"]}