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

fix(v13): wire shouldPromoteHook into architect seed + consolidator rerun + reviewer severity [phase 7 hotfix 2]

Before this commit, shouldPromoteHook was dead code: the only callers in
the repo were tests. Phase 7's four promotion rules never filtered or
tagged hooks at runtime, so the reviewer's hook-debt escalation had no
reliable signal for 'which hooks actually carry load'.

Wire the function into two real production sites:

1. Architect seed time (architect.ts normalizePendingHooksSection): for
   every hook seed emitted by the LLM, run the three structural rules
   (core_hook / depends_on / cross_volume — advanced_count is always 0
   at seed time). Tag the seed with promoted: true|false. Non-promoted
   seeds still land in pending_hooks.md; they are just marked for lower
   reviewer severity.

2. Consolidator time (consolidator.ts): pre-archive step reads the
   ledger, flips promoted=false → true for any hook whose advancedCount
   has since crossed >=2. Non-destructive: existing promoted=true hooks
   stay promoted (structural rules are sticky).

3. Reviewer prompt (continuity.ts case 6): critical severity now gated
   on promoted=true. Non-promoted stale hooks log as info only.

Schema: HookRecord + StoredHook gain optional promoted?: boolean. The
pending_hooks.md table gains a 13th column (升级 / promoted). Parser
accepts 11/12/13-column legacy states gracefully; unknown cells fall
back to undefined.
Ma 6 месяцев назад
Родитель
Сommit
bcfdbf828e

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

@@ -137,11 +137,14 @@ describe("ArchitectAgent — Phase 7 extended hook frontmatter", () => {
     // (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卷末 | 否 |  | 对妹妹的承诺 |");
+    // Hotfix 2 adds a 13th `升级` (promoted) column — architect computes it
+    // from core_hook / depends_on / cross_volume at seed time. H01 (core=是)
+    // and H02 (depends_on=[H01]) both get promoted=是; H03 has no rule firing.
+    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. No rule fires so 升级=否.
+    expect(result.pendingHooks).toContain("| H03 | 7 | 小承诺 | 未开启 | 0 | 15章 | 近期 | 无 | 第1卷末 | 否 |  | 否 | 对妹妹的承诺 |");
   });
 
   it("pending_hooks.md on disk carries the Phase 7 columns", async () => {

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

@@ -339,9 +339,11 @@ describe("ArchitectAgent", () => {
 
     const result = await agent.generateFoundation(book);
 
-    // 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章 | 中程 | 无 |  | 否 |  | 主线核心钩子 |");
+    // Phase 7 + hotfixes 1/2: ledger renders extended columns — depends_on,
+    // pays_off_in_arc, core_hook, half_life (empty when not specified), and
+    // promoted (computed at architect time). This hook has no promotion rule
+    // firing (core=否, no depends_on, in-volume payoff) so 升级=否.
+    expect(result.pendingHooks).toContain("| H01 | 1 | 主线 | 未开启 | 0 | 10章 | 中程 | 无 |  | 否 |  | 否 | 主线核心钩子 |");
     expect(result.pendingHooks).not.toContain("如果你愿意");
     expect(result.pendingHooks).not.toContain("前10章逐章细纲");
   });
@@ -403,7 +405,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 () => {
@@ -467,7 +469,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 | 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. |",
+      "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | depends_on | pays_off_in_arc | core_hook | half_life | promoted | 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");

+ 61 - 3
packages/core/src/agents/architect.ts

@@ -5,6 +5,12 @@ import { readGenreProfile } from "./rules-reader.js";
 import { writeFile, mkdir } from "node:fs/promises";
 import { join } from "node:path";
 import { renderHookSnapshot } from "../utils/memory-retrieval.js";
+import {
+  shouldPromoteHook,
+  type PromotionContext,
+  type VolumeBoundary,
+} from "../utils/hook-promotion.js";
+import type { StoredHook } from "../state/memory-db.js";
 
 // ---------------------------------------------------------------------------
 // Phase 5 (v13) — Static 骨架 layer collapse
@@ -576,6 +582,7 @@ Rules:
     const roles = this.parseRoles(rolesRaw);
     const pendingHooks = this.normalizePendingHooksSection(
       this.stripTrailingAssistantCoda(pendingHooksRaw),
+      effectiveVolumeMap,
     );
 
     // Synthesize legacy-facing content from new prose (so back-compat callers
@@ -964,7 +971,7 @@ ${trimmed}\n`;
     return lines.slice(0, cutoff).join("\n").trimEnd();
   }
 
-  private normalizePendingHooksSection(section: string): string {
+  private normalizePendingHooksSection(section: string, volumeMapRaw: string): string {
     const rows = section
       .split("\n")
       .map((line) => line.trim())
@@ -1014,10 +1021,61 @@ ${trimmed}\n`;
         if (halfLife !== undefined) base.halfLifeChapters = halfLife;
       }
 
-      return base as unknown as Parameters<typeof renderHookSnapshot>[0][number];
+      return base as unknown as StoredHook;
     });
 
-    return renderHookSnapshot(normalizedHooks, language);
+    // Phase 7 hotfix 2: pre-promote seeds based on the three structural rules
+    // that don't need runtime advanced_count (core_hook / depends_on /
+    // cross_volume). advanced_count-based promotion is applied later by the
+    // consolidator at volume boundaries.
+    const volumeBoundaries = this.parseVolumeBoundariesForPromotion(volumeMapRaw);
+    const allSeedStartChapters = new Map<string, number>(
+      normalizedHooks.map((hook) => [hook.hookId, hook.startChapter]),
+    );
+    const promotionContext: PromotionContext = {
+      volumeBoundaries,
+      currentChapter: 0,
+      advancedCounts: new Map(),
+      allSeedStartChapters,
+    };
+    const promotedHooks = normalizedHooks.map((hook) => {
+      const decision = shouldPromoteHook(hook, promotionContext);
+      return { ...hook, promoted: decision.promote };
+    });
+
+    return renderHookSnapshot(
+      promotedHooks as unknown as Parameters<typeof renderHookSnapshot>[0],
+      language,
+    );
+  }
+
+  /**
+   * Parse `第N卷 (A-B章)` / `Volume N (chapters A-B)` headers from the
+   * architect's volume_map prose. Best-effort: missing / unparseable blocks
+   * return an empty list and cross-volume promotion simply never fires.
+   */
+  private parseVolumeBoundariesForPromotion(raw: string): ReadonlyArray<VolumeBoundary> {
+    if (!raw) return [];
+    const lines = raw.split("\n");
+    const volumeHeader = /^(第[一二三四五六七八九十百千万零〇\d]+卷|Volume\s+\d+)/i;
+    const rangePattern = /[((]\s*(?:第|[Cc]hapters?\s+)?(\d+)\s*[-–~~—]\s*(\d+)\s*(?:章)?\s*[))]|(?:第|[Cc]hapters?\s+)(\d+)\s*[-–~~—]\s*(\d+)\s*(?:章)?/i;
+
+    const volumes: VolumeBoundary[] = [];
+    for (const rawLine of lines) {
+      const line = rawLine.replace(/^#+\s*/, "").trim();
+      if (!volumeHeader.test(line)) continue;
+      const rangeMatch = line.match(rangePattern);
+      if (!rangeMatch) continue;
+      const startCh = parseInt(rangeMatch[1] ?? rangeMatch[3] ?? "0", 10);
+      const endCh = parseInt(rangeMatch[2] ?? rangeMatch[4] ?? "0", 10);
+      if (startCh <= 0 || endCh <= 0) continue;
+      const rangeIndex = rangeMatch.index ?? line.length;
+      const name = line.slice(0, rangeIndex).replace(/[((]\s*$/, "").trim();
+      if (name.length > 0) {
+        volumes.push({ name, startCh, endCh });
+      }
+    }
+    return volumes;
   }
 
   private parseHookChapterNumber(value: string | undefined): number {

+ 67 - 4
packages/core/src/agents/consolidator.ts

@@ -2,11 +2,22 @@ import { BaseAgent } from "./base.js";
 import { readFile, writeFile, mkdir } from "node:fs/promises";
 import { join } from "node:path";
 import { readVolumeMap } from "../utils/outline-paths.js";
+import {
+  parsePendingHooksMarkdown,
+  renderHookSnapshot,
+} from "../utils/story-markdown.js";
+import type { StoredHook } from "../state/memory-db.js";
 
 export interface ConsolidationResult {
   readonly volumeSummaries: string;
   readonly archivedVolumes: number;
   readonly retainedChapters: number;
+  /**
+   * Phase 7 hotfix 2: number of ledger hooks whose `promoted` flag flipped
+   * from false to true during this consolidation run (advanced_count rule).
+   * 0 when pending_hooks.md is absent or no hook crossed the threshold.
+   */
+  readonly promotedHookCount: number;
 }
 
 /**
@@ -35,20 +46,26 @@ export class ConsolidatorAgent extends BaseAgent {
       readVolumeMap(bookDir, ""),
     ]);
 
+    // Phase 7 hotfix 2: pre-archive re-promotion pass. Runs independently of
+    // summary consolidation so a new book (no completed volumes yet) still
+    // flips the `promoted` flag whenever a seed's advanced_count crosses the
+    // threshold.
+    const promotedHookCount = await this.rerunAdvancedCountPromotion(storyDir);
+
     if (!summariesRaw || !outlineRaw) {
-      return { volumeSummaries: "", archivedVolumes: 0, retainedChapters: 0 };
+      return { volumeSummaries: "", archivedVolumes: 0, retainedChapters: 0, promotedHookCount };
     }
 
     // Parse volume boundaries from outline
     const volumeBoundaries = this.parseVolumeBoundaries(outlineRaw);
     if (volumeBoundaries.length === 0) {
-      return { volumeSummaries: "", archivedVolumes: 0, retainedChapters: 0 };
+      return { volumeSummaries: "", archivedVolumes: 0, retainedChapters: 0, promotedHookCount };
     }
 
     // Parse chapter summaries into rows
     const { header, rows } = this.parseSummaryTable(summariesRaw);
     if (rows.length === 0) {
-      return { volumeSummaries: "", archivedVolumes: 0, retainedChapters: 0 };
+      return { volumeSummaries: "", archivedVolumes: 0, retainedChapters: 0, promotedHookCount };
     }
 
     const maxChapter = Math.max(...rows.map((r) => r.chapter));
@@ -78,7 +95,12 @@ export class ConsolidatorAgent extends BaseAgent {
     }
 
     if (completedVolumes.length === 0) {
-      return { volumeSummaries: "", archivedVolumes: 0, retainedChapters: currentVolumeRows.length };
+      return {
+        volumeSummaries: "",
+        archivedVolumes: 0,
+        retainedChapters: currentVolumeRows.length,
+        promotedHookCount,
+      };
     }
 
     // LLM consolidation for each completed volume
@@ -123,9 +145,50 @@ export class ConsolidatorAgent extends BaseAgent {
       volumeSummaries: newSummaries.join("\n"),
       archivedVolumes: completedVolumes.length,
       retainedChapters: currentVolumeRows.length,
+      promotedHookCount,
     };
   }
 
+  /**
+   * Phase 7 hotfix 2 — re-run promotion for seeds whose advancedCount has
+   * crossed the 2-chapter threshold since architect seed time. Structural
+   * rules (core_hook / depends_on / cross_volume) are already applied at
+   * architect time and stay sticky (once promoted, always promoted).
+   *
+   * Returns the number of hooks that flipped from promoted=false (or
+   * undefined) to promoted=true this run.
+   */
+  private async rerunAdvancedCountPromotion(storyDir: string): Promise<number> {
+    const ledgerPath = join(storyDir, "pending_hooks.md");
+    const raw = await readFile(ledgerPath, "utf-8").catch(() => "");
+    if (!raw.trim()) return 0;
+
+    const hooks = parsePendingHooksMarkdown(raw);
+    if (hooks.length === 0) return 0;
+
+    const language: "zh" | "en" = /[\u4e00-\u9fff]/.test(raw) ? "zh" : "en";
+
+    let flipped = 0;
+    const nextHooks: StoredHook[] = hooks.map((hook) => {
+      if (hook.promoted === true) return hook;
+      const advanced = hook.advancedCount ?? 0;
+      if (advanced >= 2) {
+        flipped += 1;
+        return { ...hook, promoted: true };
+      }
+      // Preserve existing `promoted` (possibly undefined for legacy
+      // 11/12-column ledgers) when the threshold is not met. We deliberately
+      // do NOT override architect-set promoted=false here — the structural
+      // rules ran at architect time; rewriting them would fight that.
+      return hook;
+    });
+
+    if (flipped === 0) return 0;
+
+    await writeFile(ledgerPath, renderHookSnapshot(nextHooks, language), "utf-8");
+    return flipped;
+  }
+
   private parseVolumeBoundaries(outline: string): Array<{ name: string; startCh: number; endCh: number }> {
     const volumes: Array<{ name: string; startCh: number; endCh: number }> = [];
     const lines = outline.split("\n");

+ 12 - 10
packages/core/src/agents/continuity.ts

@@ -196,20 +196,22 @@ function buildDimensionNote(
       // and "受阻于 …" markers emitted by the stale/blocked detector; this
       // dimension tells the reviewer how to escalate them.
       return language === "en"
-        ? `Hook-debt escalation (Phase 7). Read the pending_hooks.md ledger and escalate based on the stale / blocked / core_hook / depends_on columns, NOT only on "undelivered hook present":
+        ? `Hook-debt escalation (Phase 7 + hotfixes 2/3). Read the pending_hooks.md ledger and escalate based on the stale / blocked / core_hook / depends_on / promoted columns, NOT only on "undelivered hook present":
 
-• A core_hook=true hook that has been stale for over 10 chapters → escalate from warning to critical. The book has only 3-7 core hooks; letting one drift that long is the lead symptom of narrative rot (cf. new.txt L1569).
-• A blocked hook (depends_on references upstream still unplanted or unresolved) that has been blocked for a run of 6+ chapters → warning. Call out the specific upstream hook id so the planner can route the resolution.
-• At volume end (final chapter of any volume per volume_map) a core_hook that is still open or stale without explicit "carried over to volume N+1" planning → critical.
-• A non-core stale hook → info-level log; do not fail the chapter on it, but note it so the planner can schedule cleanup.
+• Critical severity only applies to hooks with promoted=true in the ledger. A stale/blocked non-promoted hook stays at info — the promotion flag is the gate that keeps reviewer noise down, because architect-seed emits many non-load-bearing seeds.
+• A promoted core_hook=true hook that has been stale for over 10 chapters → escalate from warning to critical. The book has only 3-7 core hooks; letting one drift that long is the lead symptom of narrative rot (cf. new.txt L1569).
+• A promoted hook whose status cell contains "blocked on X (blocked Y chapters)" with Y >= 6 → warning. The literal "blocked Y chapters" token comes straight from the ledger — read it, don't guess. Call out the upstream hook id so the planner can route the resolution.
+• At volume end (final chapter of any volume per volume_map) a promoted core_hook that is still open or stale without explicit "carried over to volume N+1" planning → critical.
+• Any non-promoted stale hook → info-level log; do not fail the chapter on it, but note it so the planner can schedule cleanup.
 
 Quote the exact hook_id in description and include the stale / blocked marker text verbatim. Structure check only — do not judge hook prose quality.`
-        : `Phase 7 hook-debt 升级规则。阅读 pending_hooks.md 伏笔池时不要只看"有没有悬而未决的伏笔",要读状态列中的 stale / blocked 标记、core_hook 列和 depends_on 列:
+        : `Phase 7 hook-debt 升级规则(含 hotfix 2/3)。阅读 pending_hooks.md 伏笔池时不要只看"有没有悬而未决的伏笔",要读状态列中的 stale / blocked 标记、core_hook 列、depends_on 列、以及升级列:
 
-• 主线伏笔(core_hook=是)过期超过 10 章未回收 → warning 升级为 critical。全书只有 3-7 条核心伏笔,任何一条漂移这么久都是烂尾前兆(对应 new.txt L1569"严禁烂尾逻辑")。
-• 受阻伏笔(depends_on 指向的上游仍未种下或未解决)已被阻塞超过 5 章 → warning。在描述中写出具体的上游 hook_id,让 planner 能安排落地路径。
-• 卷尾(volume_map 中任一卷的末章)仍有主线伏笔处于 open 或 stale 且没有显式"延至下一卷"规划 → critical。
-• 次要伏笔 stale → info 级记录,不判本章失败,但保留以便 planner 安排清理。
+• critical 级别仅适用于升级=是(promoted=true)的伏笔。非升级的 stale/blocked 伏笔一律保持 info——升级标志是降噪的开关,因为架构师阶段会产出大量非承重的伏笔种子。
+• 升级=是且 core_hook=是 的伏笔过期超过 10 章未回收 → warning 升级为 critical。全书只有 3-7 条核心伏笔,任何一条漂移这么久都是烂尾前兆(对应 new.txt L1569"严禁烂尾逻辑")。
+• 升级=是的受阻伏笔,状态列中"受阻于 X (已阻 Y 章)"且 Y ≥ 6 → warning。"已阻 Y 章"这个字面 token 直接读自账本,不要猜。描述中要写出具体的上游 hook_id,让 planner 能安排落地路径。
+• 卷尾(volume_map 中任一卷的末章)仍有升级=是的主线伏笔处于 open 或 stale 且没有显式"延至下一卷"规划 → critical。
+• 升级=否的 stale 伏笔 → info 级记录,不判本章失败,但保留以便 planner 安排清理。
 
 description 中要明确引用 hook_id,并把状态列中 stale / blocked 的原文标记字面抄进去。本维度只审结构,不评价伏笔文笔。`;
     case 19:

+ 4 - 0
packages/core/src/models/runtime-state.ts

@@ -42,6 +42,10 @@ export const HookRecordSchema = z.object({
   coreHook: z.boolean().optional(),
   halfLifeChapters: z.number().int().positive().optional(),
   advancedCount: z.number().int().min(0).optional(),
+  // Phase 7 hotfix 2 — promotion flag. Undefined on legacy 11/12-column
+  // ledgers; architect-seed and consolidator-rerun both populate it going
+  // forward. Reviewer uses it to gate critical severity for stale hooks.
+  promoted: z.boolean().optional(),
 });
 
 export type HookRecord = z.infer<typeof HookRecordSchema>;

+ 4 - 0
packages/core/src/state/memory-db.ts

@@ -60,6 +60,10 @@ export interface StoredHook {
   readonly coreHook?: boolean;
   readonly halfLifeChapters?: number;
   readonly advancedCount?: number;
+  // Phase 7 hotfix 2 — whether the seed has been promoted into the live ledger
+  // (architect-time structural rules + consolidator-time advanced_count rule).
+  // Reviewer uses this to gate critical-severity escalation.
+  readonly promoted?: boolean;
 }
 
 export class MemoryDB {

+ 15 - 7
packages/core/src/state/state-projections.ts

@@ -18,17 +18,18 @@ export function renderHooksProjection(
   options?: { readonly currentChapter?: number },
 ): string {
   const title = language === "en" ? "# Pending Hooks" : "# 伏笔池";
-  // 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.
+  // Phase 7 + hotfixes 1 & 2: depends_on / pays_off_in_arc / core_hook / half_life / promoted
+  // are visible columns, so writer and reviewer both see the causal chain, planned payoff arc,
+  // stale threshold, and promotion flag. 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 | half_life | notes |",
-      "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | depends_on | pays_off_in_arc | core_hook | half_life | promoted | notes |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
     ]
     : [
-      "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 上游依赖 | 回收卷 | 核心 | 半衰期 | 备注 |",
-      "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 上游依赖 | 回收卷 | 核心 | 半衰期 | 升级 | 备注 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
     ];
 
   const currentChapter = options?.currentChapter;
@@ -61,6 +62,7 @@ export function renderHooksProjection(
           hook.paysOffInArc ?? "",
           renderCoreHookCell(hook.coreHook === true, language),
           renderHalfLifeCell(hook.halfLifeChapters),
+          renderPromotedCell(hook.promoted, language),
           hook.notes,
         ].map(escapeTableCell).join(" | ")
       } |`;
@@ -84,6 +86,12 @@ function renderHalfLifeCell(value: number | undefined): string {
   return String(Math.trunc(value));
 }
 
+function renderPromotedCell(value: boolean | undefined, language: "zh" | "en"): string {
+  if (value === undefined) return "";
+  if (language === "en") return value ? "true" : "false";
+  return value ? "是" : "否";
+}
+
 export function renderChapterSummariesProjection(
   state: ChapterSummariesState,
   language: "zh" | "en" = "zh",

+ 36 - 15
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 | half_life | notes |",
-      "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | depends_on | pays_off_in_arc | core_hook | half_life | promoted | notes |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
     ]
     : [
-      "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 上游依赖 | 回收卷 | 核心 | 半衰期 | 备注 |",
-      "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 上游依赖 | 回收卷 | 核心 | 半衰期 | 升级 | 备注 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
     ];
 
   return [
@@ -66,6 +66,7 @@ export function renderHookSnapshot(
       hook.paysOffInArc ?? "",
       renderCoreHookCell(hook.coreHook === true, language),
       renderHalfLifeCell(hook.halfLifeChapters),
+      renderPromotedCell(hook.promoted, language),
       hook.notes,
     ].map((cell) => escapeTableCell(String(cell))).join(" | ")).map((row) => `| ${row} |`),
   ].join("\n");
@@ -76,6 +77,12 @@ function renderHalfLifeCell(value: number | undefined): string {
   return String(Math.trunc(value));
 }
 
+function renderPromotedCell(value: boolean | undefined, language: "zh" | "en"): string {
+  if (value === undefined) return "";
+  if (language === "en") return value ? "true" : "false";
+  return value ? "是" : "否";
+}
+
 function renderDependsOnCell(ids: ReadonlyArray<string>, language: "zh" | "en"): string {
   if (ids.length === 0) return language === "en" ? "none" : "无";
   return `[${ids.join(", ")}]`;
@@ -249,21 +256,25 @@ function parsePendingHookRow(row: ReadonlyArray<string | undefined>): StoredHook
   //   7 (legacy pre-timing): id, ch, type, status, last_adv, expected, notes
   //   8 (Phase 5/6):          id, ch, type, status, last_adv, expected, timing, notes
   //  11 (Phase 7 ledger):     ... + depends_on, pays_off_in_arc, core_hook, notes
-  //  12 (Phase 7 architect):  ... + depends_on, pays_off_in_arc, core_hook, half_life, notes
+  //  12 (Phase 7 hotfix 1):   ... + depends_on, pays_off_in_arc, core_hook, half_life, notes
+  //  13 (Phase 7 hotfix 2):   ... + depends_on, pays_off_in_arc, core_hook, half_life, promoted, notes
   //  additional trailing columns (e.g. stale/blocked diagnostic columns) are
   //  allowed — the parser skips past them to the notes column.
-  const phase7Full = row.length >= 12;
+  const phase7Promoted = row.length >= 13;
+  const phase7HalfLife = row.length === 12;
   const phase7Compact = row.length === 11;
-  const phase7 = phase7Full || phase7Compact;
+  const phase7 = phase7Promoted || phase7HalfLife || phase7Compact;
   const legacyShape = row.length < 8;
   const payoffTiming = legacyShape ? undefined : normalizeHookPayoffTiming(row[6]);
-  const notes = phase7Full
-    ? (row[11] ?? "")
-    : phase7Compact
-      ? (row[10] ?? "")
-      : legacyShape
-        ? (row[6] ?? "")
-        : (row[7] ?? "");
+  const notes = phase7Promoted
+    ? (row[12] ?? "")
+    : phase7HalfLife
+      ? (row[11] ?? "")
+      : phase7Compact
+        ? (row[10] ?? "")
+        : legacyShape
+          ? (row[6] ?? "")
+          : (row[7] ?? "");
 
   const base = {
     hookId: normalizeHookId(row[0]),
@@ -283,10 +294,20 @@ function parsePendingHookRow(row: ReadonlyArray<string | undefined>): StoredHook
     dependsOn: parseDependsOn(row[7] ?? ""),
     paysOffInArc: (row[8] ?? "").trim(),
     coreHook: parseBooleanCell(row[9]),
-    halfLifeChapters: phase7Full ? parseOptionalInt(row[10]) : undefined,
+    halfLifeChapters: (phase7HalfLife || phase7Promoted) ? parseOptionalInt(row[10]) : undefined,
+    promoted: phase7Promoted ? parseOptionalBooleanCell(row[11]) : undefined,
   };
 }
 
+function parseOptionalBooleanCell(cell: string | undefined): boolean | undefined {
+  const normalized = (cell ?? "").trim();
+  if (!normalized) return undefined;
+  const lower = normalized.toLowerCase();
+  if (/^(true|yes|y|是|核心|core|1|✓|✔|promoted|已升级)$/.test(lower)) return true;
+  if (/^(false|no|n|否|未升级|seed|0|✗|✘)$/.test(lower)) return false;
+  return undefined;
+}
+
 function parseDependsOn(cell: string): ReadonlyArray<string> {
   const trimmed = cell.trim();
   if (!trimmed) return [];