Просмотр исходного кода

fix(v13): persist half_life_chapters through pending_hooks.md roundtrip [phase 7 hotfix 1]

The architect-supplied half_life column was silently dropped by the
projection renderer in both story-markdown.renderHookSnapshot and
state-projections.renderHooksProjection (both emitted only 11 columns).
As a result, hook-stale-detection always fell back to the timing-derived
default (10/30/80), never using the architect-supplied value.

Add a 12th column in the rendered pending_hooks.md table (半衰期 in zh,
half_life in en). Parser already accepted 12 columns; renderer now emits
them. Empty cell still falls back to timing default at detection time.
Backward compat: old 11-column files still parse.
Ma 6 месяцев назад
Родитель
Сommit
df72365e51

+ 16 - 5
packages/core/src/__tests__/architect-phase7.test.ts

@@ -133,11 +133,15 @@ describe("ArchitectAgent — Phase 7 extended hook frontmatter", () => {
 
     const result = await agent.generateFoundation(baseBook());
 
-    // The rendered ledger drops half_life (architect-only input) but keeps
-    // depends_on / pays_off_in_arc / core_hook as visible columns.
-    expect(result.pendingHooks).toContain("| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 上游依赖 | 回收卷 | 核心 | 备注 |");
-    expect(result.pendingHooks).toContain("| H01 | 1 | 主线 | 未开启 | 0 | 终章揭晓 | 终局 | 无 | 第3卷终章前 | 是 | 父亲笔记本 |");
-    expect(result.pendingHooks).toContain("| H02 | 3 | 谜团 | 未开启 | 0 | 第2卷揭开 | 中程 | [H01] | 第2卷中段 | 否 | 码头名单碎片 |");
+    // Phase 7 hotfix 1: rendered ledger now includes a 12th column `半衰期`
+    // (half_life) so architect-supplied values persist through the projection
+    // roundtrip and are read by hook-stale-detection. Hooks without an explicit
+    // half_life render an empty cell (parser falls back to timing default).
+    expect(result.pendingHooks).toContain("| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 上游依赖 | 回收卷 | 核心 | 半衰期 | 备注 |");
+    expect(result.pendingHooks).toContain("| H01 | 1 | 主线 | 未开启 | 0 | 终章揭晓 | 终局 | 无 | 第3卷终章前 | 是 | 80 | 父亲笔记本 |");
+    expect(result.pendingHooks).toContain("| H02 | 3 | 谜团 | 未开启 | 0 | 第2卷揭开 | 中程 | [H01] | 第2卷中段 | 否 | 30 | 码头名单碎片 |");
+    // H03 omits half_life; cell renders empty.
+    expect(result.pendingHooks).toContain("| H03 | 7 | 小承诺 | 未开启 | 0 | 15章 | 近期 | 无 | 第1卷末 | 否 |  | 对妹妹的承诺 |");
   });
 
   it("pending_hooks.md on disk carries the Phase 7 columns", async () => {
@@ -175,6 +179,13 @@ describe("ArchitectAgent — Phase 7 extended hook frontmatter", () => {
     expect(h02.coreHook).toBe(false);
     expect(h02.dependsOn).toEqual(["H01"]);
     expect(h02.paysOffInArc).toBe("第2卷中段");
+
+    // Phase 7 hotfix 1: half_life survives the roundtrip.
+    expect(h01.halfLifeChapters).toBe(80);
+    expect(h02.halfLifeChapters).toBe(30);
+    // H03 omitted half_life — falls back to undefined, not a default.
+    const h03 = hooks.find((h) => h.hookId === "H03")!;
+    expect(h03.halfLifeChapters).toBeUndefined();
   });
 
   it("legacy 8-column pending_hooks tables still parse without new fields (backward compat)", () => {

+ 5 - 4
packages/core/src/__tests__/architect.test.ts

@@ -339,8 +339,9 @@ describe("ArchitectAgent", () => {
 
     const result = await agent.generateFoundation(book);
 
-    // Phase 7: ledger renders extended columns — depends_on, pays_off_in_arc, core_hook.
-    expect(result.pendingHooks).toContain("| H01 | 1 | 主线 | 未开启 | 0 | 10章 | 中程 | 无 |  | 否 | 主线核心钩子 |");
+    // Phase 7 + hotfix 1: ledger renders extended columns — depends_on,
+    // pays_off_in_arc, core_hook, half_life (empty when not specified).
+    expect(result.pendingHooks).toContain("| H01 | 1 | 主线 | 未开启 | 0 | 10章 | 中程 | 无 |  | 否 |  | 主线核心钩子 |");
     expect(result.pendingHooks).not.toContain("如果你愿意");
     expect(result.pendingHooks).not.toContain("前10章逐章细纲");
   });
@@ -402,7 +403,7 @@ describe("ArchitectAgent", () => {
 
     const result = await agent.generateFoundation(book);
 
-    expect(result.pendingHooks).toContain("| H13 | 22 | 舆情操盘 | 待推进 | 0 | 51-60章 | 中程 | 无 |  | 否 | 庄蔓出场后逐步揭露(初始线索:一家自媒体公司在多个旧案节点同步接单) |");
+    expect(result.pendingHooks).toContain("| H13 | 22 | 舆情操盘 | 待推进 | 0 | 51-60章 | 中程 | 无 |  | 否 |  | 庄蔓出场后逐步揭露(初始线索:一家自媒体公司在多个旧案节点同步接单) |");
   });
 
   it("accepts section labels with spacing and punctuation drift from non-strict models", async () => {
@@ -466,7 +467,7 @@ describe("ArchitectAgent", () => {
     expect(result.volumeOutline).toBe("# 卷纲");
     expect(result.bookRules).toContain("version: \"1.0\"");
     expect(result.currentState).toBe("# 当前状态");
-    expect(result.pendingHooks).toContain("| H01 | 1 | mystery | open | 0 | 10章 | 中程 | 无 |  | 否 | 初始钩子 |");
+    expect(result.pendingHooks).toContain("| H01 | 1 | mystery | open | 0 | 10章 | 中程 | 无 |  | 否 |  | 初始钩子 |");
   });
 
   it("throws when a required foundation section is missing", async () => {

+ 4 - 4
packages/core/src/__tests__/state-projections.test.ts

@@ -33,10 +33,10 @@ describe("state projections", () => {
     expect(markdown).toBe([
       "# Pending Hooks",
       "",
-      "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | depends_on | pays_off_in_arc | core_hook | notes |",
-      "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
-      "| a-debt | 4 | relationship | progressing | 11 | Reveal the debt. | mid-arc | none |  | false | Old oath token resurfaces. |",
-      "| b-courier | 12 | mystery | open | 13 | Identify the courier. | mid-arc | none |  | false | The seal is still broken. |",
+      "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | depends_on | pays_off_in_arc | core_hook | half_life | notes |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| a-debt | 4 | relationship | progressing | 11 | Reveal the debt. | mid-arc | none |  | false |  | Old oath token resurfaces. |",
+      "| b-courier | 12 | mystery | open | 13 | Identify the courier. | mid-arc | none |  | false |  | The seal is still broken. |",
       "",
     ].join("\n"));
   });

+ 1 - 1
packages/core/src/__tests__/writer.test.ts

@@ -231,7 +231,7 @@ describe("WriterAgent", () => {
       expect(settlePrompt).toContain("| 99 | Locked Gate |");
       expect(settlePrompt).toContain("## Hook Debt Briefs");
       expect(settlePrompt).toContain("mentor-oath | cadence: slow-burn");
-      expect(settlePrompt).toContain("| stale-ledger | 14 | mystery | open | 70 | 120 | 中程 | 无 |  | 否 | Old ledger debt is dormant but unresolved |");
+      expect(settlePrompt).toContain("| stale-ledger | 14 | mystery | open | 70 | 120 | 中程 | 无 |  | 否 |  | Old ledger debt is dormant but unresolved |");
       expect(settlePrompt).not.toContain("| 1 | Guild Trail |");
       expect(settlePrompt).not.toContain("old-seal");
       expect(settlePrompt).not.toContain("Guildmaster Ren");

+ 13 - 8
packages/core/src/state/state-projections.ts

@@ -18,18 +18,17 @@ export function renderHooksProjection(
   options?: { readonly currentChapter?: number },
 ): string {
   const title = language === "en" ? "# Pending Hooks" : "# 伏笔池";
-  // Phase 7: depends_on / pays_off_in_arc / core_hook are visible columns,
-  // so writer and reviewer both see the causal chain and planned payoff arc.
-  // stale / blocked diagnostic flags are appended to the status cell, so the
-  // table width stays fixed at 11 columns.
+  // Phase 7 + hotfix 1: depends_on / pays_off_in_arc / core_hook / half_life are visible columns,
+  // so writer and reviewer both see the causal chain, planned payoff arc, and stale threshold.
+  // stale / blocked diagnostic flags are appended to the status cell.
   const headers = language === "en"
     ? [
-      "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | depends_on | pays_off_in_arc | core_hook | notes |",
-      "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | depends_on | pays_off_in_arc | core_hook | half_life | notes |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
     ]
     : [
-      "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 上游依赖 | 回收卷 | 核心 | 备注 |",
-      "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 上游依赖 | 回收卷 | 核心 | 半衰期 | 备注 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
     ];
 
   const currentChapter = options?.currentChapter;
@@ -61,6 +60,7 @@ export function renderHooksProjection(
           renderDependsOnCell(hook.dependsOn ?? [], language),
           hook.paysOffInArc ?? "",
           renderCoreHookCell(hook.coreHook === true, language),
+          renderHalfLifeCell(hook.halfLifeChapters),
           hook.notes,
         ].map(escapeTableCell).join(" | ")
       } |`;
@@ -79,6 +79,11 @@ function renderCoreHookCell(isCore: boolean, language: "zh" | "en"): string {
   return isCore ? "是" : "否";
 }
 
+function renderHalfLifeCell(value: number | undefined): string {
+  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return "";
+  return String(Math.trunc(value));
+}
+
 export function renderChapterSummariesProjection(
   state: ChapterSummariesState,
   language: "zh" | "en" = "zh",

+ 10 - 4
packages/core/src/utils/story-markdown.ts

@@ -44,12 +44,12 @@ export function renderHookSnapshot(
 
   const headers = language === "en"
     ? [
-      "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | depends_on | pays_off_in_arc | core_hook | notes |",
-      "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | depends_on | pays_off_in_arc | core_hook | half_life | notes |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
     ]
     : [
-      "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 上游依赖 | 回收卷 | 核心 | 备注 |",
-      "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 上游依赖 | 回收卷 | 核心 | 半衰期 | 备注 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
     ];
 
   return [
@@ -65,11 +65,17 @@ export function renderHookSnapshot(
       renderDependsOnCell(hook.dependsOn ?? [], language),
       hook.paysOffInArc ?? "",
       renderCoreHookCell(hook.coreHook === true, language),
+      renderHalfLifeCell(hook.halfLifeChapters),
       hook.notes,
     ].map((cell) => escapeTableCell(String(cell))).join(" | ")).map((row) => `| ${row} |`),
   ].join("\n");
 }
 
+function renderHalfLifeCell(value: number | undefined): string {
+  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return "";
+  return String(Math.trunc(value));
+}
+
 function renderDependsOnCell(ids: ReadonlyArray<string>, language: "zh" | "en"): string {
   if (ids.length === 0) return language === "en" ? "none" : "无";
   return `[${ids.join(", ")}]`;