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

refactor(state): share story markdown parsers

Ma 7 месяцев назад
Родитель
Сommit
06070ca4be

+ 17 - 118
packages/core/src/state/state-bootstrap.ts

@@ -10,8 +10,24 @@ import {
   type HookStatus,
   type StateManifest,
 } from "../models/runtime-state.js";
-import type { Fact, StoredHook, StoredSummary } from "./memory-db.js";
+import type { Fact, StoredHook } from "./memory-db.js";
 import { normalizeHookPayoffTiming } from "../utils/hook-lifecycle.js";
+import {
+  inferFactSubject,
+  isCurrentChapterLabel,
+  isStateTableHeaderRow,
+  normalizeHookId,
+  parseChapterSummariesMarkdown,
+  parseInteger,
+  parseMarkdownTableRows,
+} from "../utils/story-markdown.js";
+
+export {
+  normalizeHookId,
+  parseChapterSummariesMarkdown,
+  parseCurrentStateFacts,
+  parsePendingHooksMarkdown,
+} from "../utils/story-markdown.js";
 
 export interface BootstrapStructuredStateResult {
   readonly createdFiles: ReadonlyArray<string>;
@@ -164,69 +180,6 @@ export async function rewriteStructuredStateFromMarkdown(params: {
   };
 }
 
-export function parseChapterSummariesMarkdown(markdown: string): StoredSummary[] {
-  const rows = parseMarkdownTableRows(markdown)
-    .filter((row) => /^\d+$/.test(row[0] ?? ""));
-
-  return rows.map((row) => ({
-    chapter: parseInt(row[0]!, 10),
-    title: row[1] ?? "",
-    characters: row[2] ?? "",
-    events: row[3] ?? "",
-    stateChanges: row[4] ?? "",
-    hookActivity: row[5] ?? "",
-    mood: row[6] ?? "",
-    chapterType: row[7] ?? "",
-  }));
-}
-
-export function parsePendingHooksMarkdown(markdown: string): StoredHook[] {
-  const tableRows = parseMarkdownTableRows(markdown)
-    .filter((row) => (row[0] ?? "").toLowerCase() !== "hook_id");
-
-  if (tableRows.length > 0) {
-    return tableRows
-      .filter((row) => normalizeHookId(row[0]).length > 0)
-      .map((row) => {
-        const legacyShape = row.length < 8;
-        return {
-          hookId: normalizeHookId(row[0]),
-          startChapter: parseInteger(row[1]),
-          type: row[2] ?? "",
-          status: row[3] ?? "open",
-          lastAdvancedChapter: parseInteger(row[4]),
-          expectedPayoff: row[5] ?? "",
-          payoffTiming: legacyShape ? undefined : normalizeHookPayoffTiming(row[6]),
-          notes: legacyShape ? (row[6] ?? "") : (row[7] ?? ""),
-        };
-      });
-  }
-
-  return markdown
-    .split("\n")
-    .map((line) => line.trim())
-    .filter((line) => line.startsWith("-"))
-    .map((line) => line.replace(/^-\s*/, ""))
-    .filter(Boolean)
-    .map((line, index) => ({
-      hookId: `hook-${index + 1}`,
-      startChapter: 0,
-      type: "unspecified",
-      status: "open",
-      lastAdvancedChapter: 0,
-      expectedPayoff: "",
-      payoffTiming: undefined,
-      notes: line,
-    }));
-}
-
-export function parseCurrentStateFacts(
-  markdown: string,
-  fallbackChapter: number,
-): Fact[] {
-  return parseCurrentStateStateMarkdown(markdown, fallbackChapter, []).facts;
-}
-
 async function loadOrBootstrapCurrentState(params: {
   readonly storyDir: string;
   readonly statePath: string;
@@ -590,24 +543,6 @@ function maxHookChapter(hooks: ReadonlyArray<StoredHook>): number {
   );
 }
 
-export function normalizeHookId(value: string | undefined): string {
-  let normalized = (value ?? "").trim();
-  let previous = "";
-  while (normalized && normalized !== previous) {
-    previous = normalized;
-    normalized = normalized
-      .replace(/^\[(.+?)\]\([^)]+\)$/u, "$1")
-      .replace(/^\*\*(.+)\*\*$/u, "$1")
-      .replace(/^__(.+)__$/u, "$1")
-      .replace(/^\*(.+)\*$/u, "$1")
-      .replace(/^_(.+)_$/u, "$1")
-      .replace(/^`(.+)`$/u, "$1")
-      .replace(/^~~(.+)~~$/u, "$1")
-      .trim();
-  }
-  return normalized;
-}
-
 function normalizeHookStatus(value: string | undefined, warnings: string[], hookId: string): HookStatus {
   const normalized = (value ?? "").trim().toLowerCase();
   if (!normalized) return "open";
@@ -644,42 +579,6 @@ function parseIntegerWithFallback(
   return parseInt(match[0], 10);
 }
 
-function parseMarkdownTableRows(markdown: string): string[][] {
-  return markdown
-    .split("\n")
-    .map((line) => line.trim())
-    .filter((line) => line.startsWith("|"))
-    .filter((line) => !line.includes("---"))
-    .map((line) => line.split("|").slice(1, -1).map((cell) => cell.trim()))
-    .filter((cells) => cells.some(Boolean));
-}
-
-function isStateTableHeaderRow(row: ReadonlyArray<string>): boolean {
-  const first = (row[0] ?? "").trim().toLowerCase();
-  const second = (row[1] ?? "").trim().toLowerCase();
-  return (first === "字段" && second === "值") || (first === "field" && second === "value");
-}
-
-function isCurrentChapterLabel(label: string): boolean {
-  return /^(当前章节|current chapter)$/i.test(label.trim());
-}
-
-function inferFactSubject(label: string): string {
-  if (/^(当前位置|current location)$/i.test(label)) return "protagonist";
-  if (/^(主角状态|protagonist state)$/i.test(label)) return "protagonist";
-  if (/^(当前目标|current goal)$/i.test(label)) return "protagonist";
-  if (/^(当前限制|current constraint)$/i.test(label)) return "protagonist";
-  if (/^(当前敌我|current alliances|current relationships)$/i.test(label)) return "protagonist";
-  if (/^(当前冲突|current conflict)$/i.test(label)) return "protagonist";
-  return "current_state";
-}
-
-function parseInteger(value: string | undefined): number {
-  if (!value) return 0;
-  const match = value.match(/\d+/);
-  return match ? parseInt(match[0], 10) : 0;
-}
-
 function appendWarning(warnings: string[], warning: string): void {
   if (!warnings.includes(warning)) {
     warnings.push(warning);

+ 16 - 218
packages/core/src/utils/memory-retrieval.ts

@@ -6,13 +6,8 @@ import {
   HooksStateSchema,
 } from "../models/runtime-state.js";
 import { MemoryDB, type Fact, type StoredHook, type StoredSummary } from "../state/memory-db.js";
-import { bootstrapStructuredStateFromMarkdown, normalizeHookId } from "../state/state-bootstrap.js";
-import {
-  describeHookLifecycle,
-  localizeHookPayoffTiming,
-  resolveHookPayoffTiming,
-  normalizeHookPayoffTiming,
-} from "./hook-lifecycle.js";
+import { bootstrapStructuredStateFromMarkdown } from "../state/state-bootstrap.js";
+import { describeHookLifecycle } from "./hook-lifecycle.js";
 import {
   buildPlannerHookAgenda,
   filterActiveHooks,
@@ -23,12 +18,26 @@ import {
   resolveRelevantHookStaleLimit,
   selectAgendaHooksWithTypeSpread,
 } from "./hook-agenda.js";
+import {
+  parseChapterSummariesMarkdown,
+  parseCurrentStateFacts,
+  parsePendingHooksMarkdown,
+  renderHookSnapshot,
+  renderSummarySnapshot,
+} from "./story-markdown.js";
 export {
   buildPlannerHookAgenda,
   isFuturePlannedHook,
   isHookWithinChapterWindow,
   isHookWithinLifecycleWindow,
 } from "./hook-agenda.js";
+export {
+  parseChapterSummariesMarkdown,
+  parseCurrentStateFacts,
+  parsePendingHooksMarkdown,
+  renderHookSnapshot,
+  renderSummarySnapshot,
+} from "./story-markdown.js";
 
 export interface MemorySelection {
   readonly summaries: ReadonlyArray<StoredSummary>;
@@ -168,68 +177,6 @@ export function extractQueryTerms(goal: string, outlineNode: string | undefined,
   ]).slice(0, 12);
 }
 
-export function renderSummarySnapshot(
-  summaries: ReadonlyArray<StoredSummary>,
-  language: "zh" | "en" = "zh",
-): string {
-  if (summaries.length === 0) return "- none";
-
-  const headers = language === "en"
-    ? [
-      "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
-      "| --- | --- | --- | --- | --- | --- | --- | --- |",
-    ]
-    : [
-      "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
-      "| --- | --- | --- | --- | --- | --- | --- | --- |",
-    ];
-
-  return [
-    ...headers,
-    ...summaries.map((summary) => [
-      summary.chapter,
-      summary.title,
-      summary.characters,
-      summary.events,
-      summary.stateChanges,
-      summary.hookActivity,
-      summary.mood,
-      summary.chapterType,
-    ].map(escapeTableCell).join(" | ")).map((row) => `| ${row} |`),
-  ].join("\n");
-}
-
-export function renderHookSnapshot(
-  hooks: ReadonlyArray<StoredHook>,
-  language: "zh" | "en" = "zh",
-): string {
-  if (hooks.length === 0) return "- none";
-
-  const headers = language === "en"
-    ? [
-      "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |",
-      "| --- | --- | --- | --- | --- | --- | --- | --- |",
-    ]
-    : [
-      "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
-      "| --- | --- | --- | --- | --- | --- | --- | --- |",
-    ];
-
-  return [
-    ...headers,
-    ...hooks.map((hook) => [
-      hook.hookId,
-      hook.startChapter,
-      hook.type,
-      hook.status,
-      hook.lastAdvancedChapter,
-      hook.expectedPayoff,
-      localizeHookPayoffTiming(resolveHookPayoffTiming(hook), language),
-      hook.notes,
-    ].map((cell) => escapeTableCell(String(cell))).join(" | ")).map((row) => `| ${row} |`),
-  ].join("\n");
-}
-
 function openMemoryDB(bookDir: string): MemoryDB | null {
   try {
     return new MemoryDB(bookDir);
@@ -336,125 +283,6 @@ function uniqueTerms(terms: ReadonlyArray<string>): string[] {
   return result;
 }
 
-export function parseChapterSummariesMarkdown(markdown: string): StoredSummary[] {
-  const rows = parseMarkdownTableRows(markdown)
-    .filter((row) => /^\d+$/.test(row[0] ?? ""));
-
-  return rows.map((row) => ({
-    chapter: parseInt(row[0]!, 10),
-    title: row[1] ?? "",
-    characters: row[2] ?? "",
-    events: row[3] ?? "",
-    stateChanges: row[4] ?? "",
-    hookActivity: row[5] ?? "",
-    mood: row[6] ?? "",
-    chapterType: row[7] ?? "",
-  }));
-}
-
-export function parsePendingHooksMarkdown(markdown: string): StoredHook[] {
-  const tableRows = parseMarkdownTableRows(markdown)
-    .filter((row) => (row[0] ?? "").toLowerCase() !== "hook_id");
-
-  if (tableRows.length > 0) {
-    return tableRows
-      .filter((row) => normalizeHookId(row[0]).length > 0)
-      .map((row) => parsePendingHookRow(row));
-  }
-
-  return markdown
-    .split("\n")
-    .map((line) => line.trim())
-    .filter((line) => line.startsWith("-"))
-    .map((line) => line.replace(/^-\s*/, ""))
-    .filter(Boolean)
-    .map((line, index) => ({
-      hookId: `hook-${index + 1}`,
-      startChapter: 0,
-      type: "unspecified",
-      status: "open",
-      lastAdvancedChapter: 0,
-      expectedPayoff: "",
-      payoffTiming: undefined,
-      notes: line,
-    }));
-}
-
-function parsePendingHookRow(row: ReadonlyArray<string | undefined>): StoredHook {
-  const legacyShape = row.length < 8;
-  const payoffTiming = legacyShape ? undefined : normalizeHookPayoffTiming(row[6]);
-  const notes = legacyShape ? (row[6] ?? "") : (row[7] ?? "");
-
-  return {
-    hookId: normalizeHookId(row[0]),
-    startChapter: parseInteger(row[1]),
-    type: row[2] ?? "",
-    status: row[3] ?? "open",
-    lastAdvancedChapter: parseInteger(row[4]),
-    expectedPayoff: row[5] ?? "",
-    payoffTiming,
-    notes,
-  };
-}
-
-export function parseCurrentStateFacts(
-  markdown: string,
-  fallbackChapter: number,
-): Fact[] {
-  const tableRows = parseMarkdownTableRows(markdown);
-  const fieldValueRows = tableRows
-    .filter((row) => row.length >= 2)
-    .filter((row) => !isStateTableHeaderRow(row));
-
-  if (fieldValueRows.length > 0) {
-    const chapterFromTable = fieldValueRows.find((row) => isCurrentChapterLabel(row[0] ?? ""));
-    const stateChapter = parseInteger(chapterFromTable?.[1]) || fallbackChapter;
-
-    return fieldValueRows
-      .filter((row) => !isCurrentChapterLabel(row[0] ?? ""))
-      .flatMap((row): Fact[] => {
-        const label = (row[0] ?? "").trim();
-        const value = (row[1] ?? "").trim();
-        if (!label || !value) return [];
-
-        return [{
-          subject: inferFactSubject(label),
-          predicate: label,
-          object: value,
-          validFromChapter: stateChapter,
-          validUntilChapter: null,
-          sourceChapter: stateChapter,
-        }];
-      });
-  }
-
-  const bulletFacts = markdown
-    .split("\n")
-    .map((line) => line.trim())
-    .filter((line) => line.startsWith("-"))
-    .map((line) => line.replace(/^-\s*/, ""))
-    .filter(Boolean);
-
-  return bulletFacts.map((line, index) => ({
-    subject: "current_state",
-    predicate: `note_${index + 1}`,
-    object: line,
-    validFromChapter: fallbackChapter,
-    validUntilChapter: null,
-    sourceChapter: fallbackChapter,
-  }));
-}
-
-function parseMarkdownTableRows(markdown: string): string[][] {
-  return markdown
-    .split("\n")
-    .map((line) => line.trim())
-    .filter((line) => line.startsWith("|"))
-    .filter((line) => !line.includes("---"))
-    .map((line) => line.split("|").slice(1, -1).map((cell) => cell.trim()))
-    .filter((cells) => cells.some(Boolean));
-}
-
 function parseVolumeSummariesMarkdown(markdown: string): VolumeSummarySelection[] {
   if (!markdown.trim()) return [];
 
@@ -476,26 +304,6 @@ function parseVolumeSummariesMarkdown(markdown: string): VolumeSummarySelection[
   }).filter((section) => section.heading.length > 0 && section.content.length > 0);
 }
 
-function isStateTableHeaderRow(row: ReadonlyArray<string>): boolean {
-  const first = (row[0] ?? "").trim().toLowerCase();
-  const second = (row[1] ?? "").trim().toLowerCase();
-  return (first === "字段" && second === "值") || (first === "field" && second === "value");
-}
-
-function isCurrentChapterLabel(label: string): boolean {
-  return /^(当前章节|current chapter)$/i.test(label.trim());
-}
-
-function inferFactSubject(label: string): string {
-  if (/^(当前位置|current location)$/i.test(label)) return "protagonist";
-  if (/^(主角状态|protagonist state)$/i.test(label)) return "protagonist";
-  if (/^(当前目标|current goal)$/i.test(label)) return "protagonist";
-  if (/^(当前限制|current constraint)$/i.test(label)) return "protagonist";
-  if (/^(当前敌我|current alliances|current relationships)$/i.test(label)) return "protagonist";
-  if (/^(当前冲突|current conflict)$/i.test(label)) return "protagonist";
-  return "current_state";
-}
-
 function isUnresolvedHook(status: string): boolean {
   return status.trim().length === 0 || /open|待定|推进|active|progressing/i.test(status);
 }
@@ -678,16 +486,6 @@ function includesTerm(text: string, term: string): boolean {
   return text.toLowerCase().includes(term.toLowerCase());
 }
 
-function parseInteger(value: string | undefined): number {
-  if (!value) return 0;
-  const match = value.match(/\d+/);
-  return match ? parseInt(match[0], 10) : 0;
-}
-
-function escapeTableCell(value: string | number): string {
-  return String(value).replace(/\|/g, "\\|").trim();
-}
-
 function slugifyAnchor(value: string): string {
   return value
     .trim()

+ 235 - 0
packages/core/src/utils/story-markdown.ts

@@ -0,0 +1,235 @@
+import type { Fact, StoredHook, StoredSummary } from "../state/memory-db.js";
+import {
+  localizeHookPayoffTiming,
+  normalizeHookPayoffTiming,
+  resolveHookPayoffTiming,
+} from "./hook-lifecycle.js";
+
+export function renderSummarySnapshot(
+  summaries: ReadonlyArray<StoredSummary>,
+  language: "zh" | "en" = "zh",
+): string {
+  if (summaries.length === 0) return "- none";
+
+  const headers = language === "en"
+    ? [
+      "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+    ]
+    : [
+      "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+    ];
+
+  return [
+    ...headers,
+    ...summaries.map((summary) => [
+      summary.chapter,
+      summary.title,
+      summary.characters,
+      summary.events,
+      summary.stateChanges,
+      summary.hookActivity,
+      summary.mood,
+      summary.chapterType,
+    ].map(escapeTableCell).join(" | ")).map((row) => `| ${row} |`),
+  ].join("\n");
+}
+
+export function renderHookSnapshot(
+  hooks: ReadonlyArray<StoredHook>,
+  language: "zh" | "en" = "zh",
+): string {
+  if (hooks.length === 0) return "- none";
+
+  const headers = language === "en"
+    ? [
+      "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+    ]
+    : [
+      "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+    ];
+
+  return [
+    ...headers,
+    ...hooks.map((hook) => [
+      hook.hookId,
+      hook.startChapter,
+      hook.type,
+      hook.status,
+      hook.lastAdvancedChapter,
+      hook.expectedPayoff,
+      localizeHookPayoffTiming(resolveHookPayoffTiming(hook), language),
+      hook.notes,
+    ].map((cell) => escapeTableCell(String(cell))).join(" | ")).map((row) => `| ${row} |`),
+  ].join("\n");
+}
+
+export function parseChapterSummariesMarkdown(markdown: string): StoredSummary[] {
+  const rows = parseMarkdownTableRows(markdown)
+    .filter((row) => /^\d+$/.test(row[0] ?? ""));
+
+  return rows.map((row) => ({
+    chapter: parseInt(row[0]!, 10),
+    title: row[1] ?? "",
+    characters: row[2] ?? "",
+    events: row[3] ?? "",
+    stateChanges: row[4] ?? "",
+    hookActivity: row[5] ?? "",
+    mood: row[6] ?? "",
+    chapterType: row[7] ?? "",
+  }));
+}
+
+export function parsePendingHooksMarkdown(markdown: string): StoredHook[] {
+  const tableRows = parseMarkdownTableRows(markdown)
+    .filter((row) => (row[0] ?? "").toLowerCase() !== "hook_id");
+
+  if (tableRows.length > 0) {
+    return tableRows
+      .filter((row) => normalizeHookId(row[0]).length > 0)
+      .map((row) => parsePendingHookRow(row));
+  }
+
+  return markdown
+    .split("\n")
+    .map((line) => line.trim())
+    .filter((line) => line.startsWith("-"))
+    .map((line) => line.replace(/^-\s*/, ""))
+    .filter(Boolean)
+    .map((line, index) => ({
+      hookId: `hook-${index + 1}`,
+      startChapter: 0,
+      type: "unspecified",
+      status: "open",
+      lastAdvancedChapter: 0,
+      expectedPayoff: "",
+      payoffTiming: undefined,
+      notes: line,
+    }));
+}
+
+export function parseCurrentStateFacts(
+  markdown: string,
+  fallbackChapter: number,
+): Fact[] {
+  const tableRows = parseMarkdownTableRows(markdown);
+  const fieldValueRows = tableRows
+    .filter((row) => row.length >= 2)
+    .filter((row) => !isStateTableHeaderRow(row));
+
+  if (fieldValueRows.length > 0) {
+    const chapterFromTable = fieldValueRows.find((row) => isCurrentChapterLabel(row[0] ?? ""));
+    const stateChapter = parseInteger(chapterFromTable?.[1]) || fallbackChapter;
+
+    return fieldValueRows
+      .filter((row) => !isCurrentChapterLabel(row[0] ?? ""))
+      .flatMap((row): Fact[] => {
+        const label = (row[0] ?? "").trim();
+        const value = (row[1] ?? "").trim();
+        if (!label || !value) return [];
+
+        return [{
+          subject: inferFactSubject(label),
+          predicate: label,
+          object: value,
+          validFromChapter: stateChapter,
+          validUntilChapter: null,
+          sourceChapter: stateChapter,
+        }];
+      });
+  }
+
+  const bulletFacts = markdown
+    .split("\n")
+    .map((line) => line.trim())
+    .filter((line) => line.startsWith("-"))
+    .map((line) => line.replace(/^-\s*/, ""))
+    .filter(Boolean);
+
+  return bulletFacts.map((line, index) => ({
+    subject: "current_state",
+    predicate: `note_${index + 1}`,
+    object: line,
+    validFromChapter: fallbackChapter,
+    validUntilChapter: null,
+    sourceChapter: fallbackChapter,
+  }));
+}
+
+export function parseMarkdownTableRows(markdown: string): string[][] {
+  return markdown
+    .split("\n")
+    .map((line) => line.trim())
+    .filter((line) => line.startsWith("|"))
+    .filter((line) => !line.includes("---"))
+    .map((line) => line.split("|").slice(1, -1).map((cell) => cell.trim()))
+    .filter((cells) => cells.some(Boolean));
+}
+
+export function isStateTableHeaderRow(row: ReadonlyArray<string>): boolean {
+  const first = (row[0] ?? "").trim().toLowerCase();
+  const second = (row[1] ?? "").trim().toLowerCase();
+  return (first === "字段" && second === "值") || (first === "field" && second === "value");
+}
+
+export function isCurrentChapterLabel(label: string): boolean {
+  return /^(当前章节|current chapter)$/i.test(label.trim());
+}
+
+export function inferFactSubject(label: string): string {
+  if (/^(当前位置|current location)$/i.test(label)) return "protagonist";
+  if (/^(主角状态|protagonist state)$/i.test(label)) return "protagonist";
+  if (/^(当前目标|current goal)$/i.test(label)) return "protagonist";
+  if (/^(当前限制|current constraint)$/i.test(label)) return "protagonist";
+  if (/^(当前敌我|current alliances|current relationships)$/i.test(label)) return "protagonist";
+  if (/^(当前冲突|current conflict)$/i.test(label)) return "protagonist";
+  return "current_state";
+}
+
+export function parseInteger(value: string | undefined): number {
+  if (!value) return 0;
+  const match = value.match(/\d+/);
+  return match ? parseInt(match[0], 10) : 0;
+}
+
+export function normalizeHookId(value: string | undefined): string {
+  let normalized = (value ?? "").trim();
+  let previous = "";
+  while (normalized && normalized !== previous) {
+    previous = normalized;
+    normalized = normalized
+      .replace(/^\[(.+?)\]\([^)]+\)$/u, "$1")
+      .replace(/^\*\*(.+)\*\*$/u, "$1")
+      .replace(/^__(.+)__$/u, "$1")
+      .replace(/^\*(.+)\*$/u, "$1")
+      .replace(/^_(.+)_$/u, "$1")
+      .replace(/^`(.+)`$/u, "$1")
+      .replace(/^~~(.+)~~$/u, "$1")
+      .trim();
+  }
+  return normalized;
+}
+
+function parsePendingHookRow(row: ReadonlyArray<string | undefined>): StoredHook {
+  const legacyShape = row.length < 8;
+  const payoffTiming = legacyShape ? undefined : normalizeHookPayoffTiming(row[6]);
+  const notes = legacyShape ? (row[6] ?? "") : (row[7] ?? "");
+
+  return {
+    hookId: normalizeHookId(row[0]),
+    startChapter: parseInteger(row[1]),
+    type: row[2] ?? "",
+    status: row[3] ?? "open",
+    lastAdvancedChapter: parseInteger(row[4]),
+    expectedPayoff: row[5] ?? "",
+    payoffTiming,
+    notes,
+  };
+}
+
+function escapeTableCell(value: string | number): string {
+  return String(value).replace(/\|/g, "\\|").trim();
+}