feat(pipeline): add semantic hook lifecycle guidance

This commit is contained in:
Ma
2026-04-01 11:52:26 +08:00
parent aeb64b658c
commit d6855e4e60
22 changed files with 694 additions and 77 deletions
@@ -458,4 +458,67 @@ describe("ComposerAgent", () => {
expect(parentCanonEntry?.excerpt).toContain("archive fire");
expect(fanficCanonEntry?.excerpt).toContain("oath debt logic");
});
it("emits hook debt briefs for agenda-targeted hooks", async () => {
await Promise.all([
writeFile(
join(storyDir, "pending_hooks.md"),
[
"# Pending Hooks",
"",
"| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
"| mentor-oath | 8 | relationship | progressing | 9 | 揭开师债为何断裂 | 慢烧 | 师债需要跨更大弧线回收 |",
"| guild-route | 1 | mystery | open | 2 | 查清商会路线背后的买家 | 近期 | 商会路线仍在旁支干扰 |",
"",
].join("\n"),
"utf-8",
),
writeFile(
join(storyDir, "chapter_summaries.md"),
[
"# Chapter Summaries",
"",
"| 7 | Broken Letter | Lin Yue | A torn letter mentions the mentor | Lin Yue reopens the old oath | mentor-oath seeded | uneasy | mystery |",
"| 8 | River Camp | Lin Yue | Mentor debt becomes personal | Lin Yue cannot let go | mentor-oath advanced | raw | confrontation |",
"| 9 | Trial Echo | Lin Yue | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
"",
].join("\n"),
"utf-8",
),
]);
const composer = new ComposerAgent({
client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
model: "test-model",
projectRoot: root,
bookId: book.id,
});
const result = await composer.composeChapter({
book,
bookDir,
chapterNumber: 10,
plan: {
...plan,
intent: {
...plan.intent,
chapter: 10,
goal: "Bring the focus back to the mentor oath conflict.",
hookAgenda: {
mustAdvance: ["mentor-oath"],
eligibleResolve: [],
staleDebt: [],
avoidNewHookFamilies: ["relationship"],
},
},
},
});
const hookDebtEntry = result.contextPackage.selectedContext.find((entry) => entry.source === "runtime/hook_debt#mentor-oath");
expect(hookDebtEntry).toBeDefined();
expect(hookDebtEntry?.excerpt).toContain("mentor-oath");
expect(hookDebtEntry?.excerpt).toContain("River Camp");
expect(hookDebtEntry?.excerpt).toContain("Trial Echo");
});
});
@@ -1085,4 +1085,62 @@ describe("parsePendingHooksMarkdown", () => {
expect(hooks.map((hook) => hook.hookId)).toEqual(["H009", "H010"]);
});
it("parses semantic payoff timing from extended pending hooks tables", () => {
const hooks = memoryRetrieval.parsePendingHooksMarkdown([
"| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
"| oath-debt | 8 | relationship | open | 12 | Reveal why the mentor broke the oath | slow-burn | Long-buried debt stays unresolved |",
"| kiln-key | 15 | mystery | open | 15 | Find out what the kiln key opens next chapter | immediate | Fresh key with a fast local payoff |",
"",
].join("\n"));
expect(hooks).toEqual([
expect.objectContaining({
hookId: "oath-debt",
payoffTiming: "slow-burn",
notes: "Long-buried debt stays unresolved",
}),
expect.objectContaining({
hookId: "kiln-key",
payoffTiming: "immediate",
notes: "Fresh key with a fast local payoff",
}),
]);
});
it("keeps slow-burn hooks out of early resolve slots while still advancing them", () => {
const agenda = memoryRetrieval.buildPlannerHookAgenda({
chapterNumber: 18,
hooks: [
{
hookId: "slow-oath",
startChapter: 10,
type: "relationship",
status: "progressing",
lastAdvancedChapter: 17,
expectedPayoff: "Reveal why the mentor buried the oath debt",
payoffTiming: "slow-burn",
notes: "The debt should simmer across the wider arc.",
},
{
hookId: "ready-packet",
startChapter: 14,
type: "mystery",
status: "progressing",
lastAdvancedChapter: 17,
expectedPayoff: "Open the missing packet and expose the inside hand",
payoffTiming: "near-term",
notes: "The local sequence is ready for a concrete payoff.",
},
] as never,
maxMustAdvance: 2,
maxEligibleResolve: 2,
targetChapters: 40,
} as never);
expect(agenda.mustAdvance).toContain("slow-oath");
expect(agenda.eligibleResolve).toContain("ready-packet");
expect(agenda.eligibleResolve).not.toContain("slow-oath");
});
});
+1 -1
View File
@@ -1041,7 +1041,7 @@ describe("PlannerAgent", () => {
});
const intentMarkdown = await readFile(result.runtimePath, "utf-8");
expect(intentMarkdown).toContain("| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |");
expect(intentMarkdown).toContain("| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |");
expect(intentMarkdown).toContain("| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |");
expect(intentMarkdown).not.toContain("| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |");
expect(intentMarkdown).not.toContain("| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |");
+8 -1
View File
@@ -205,6 +205,11 @@ describe("WriterAgent", () => {
reason: "Carry forward unresolved hook.",
excerpt: "relationship | open | 101 | Mentor oath debt with Lin Yue",
},
{
source: "runtime/hook_debt#mentor-oath",
reason: "Explicit hook debt brief for the agenda target.",
excerpt: "mentor-oath | cadence: slow-burn | seed: ch8 River Camp - Mentor debt becomes personal | latest: ch99 Locked Gate - Lin Yue chooses the mentor line over the guild line | unpaid: reveal why the mentor broke the oath",
},
],
},
ruleStack: {
@@ -224,7 +229,9 @@ describe("WriterAgent", () => {
expect(settlePrompt).toContain("## 本章控制输入");
expect(settlePrompt).toContain("story/chapter_summaries.md#99");
expect(settlePrompt).toContain("| 99 | Locked Gate |");
expect(settlePrompt).toContain("| stale-ledger | 14 | mystery | open | 70 | 120 | Old ledger debt is dormant but unresolved |");
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).not.toContain("| 1 | Guild Trail |");
expect(settlePrompt).not.toContain("old-seal");
expect(settlePrompt).not.toContain("Guildmaster Ren");
+8 -5
View File
@@ -195,18 +195,20 @@ enableFullCastTracking: false
const pendingHooksPrompt = resolvedLanguage === "en"
? `Initial hook pool (Markdown table):
| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |
| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | notes |
Rules for the hook table:
- Column 5 must be a pure chapter number, never natural-language description
- During book creation, all planned hooks are still unapplied, so last_advanced_chapter = 0
- Column 7 must be one of: immediate / near-term / mid-arc / slow-burn / endgame
- If you want to describe the initial clue/signal, put it in notes instead of column 5`
: `初始伏笔池(Markdown表格):
| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |
| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |
伏笔表规则:
- 第5列必须是纯数字章节号,不能写自然语言描述
- 建书阶段所有伏笔都还没正式推进,所以第5列统一填 0
- 第7列必须填写:立即 / 近期 / 中程 / 慢烧 / 终局 之一
- 如果要说明“初始线索/最初信号”,写进备注,不要写进第5列`;
const finalRequirementsPrompt = resolvedLanguage === "en"
@@ -489,9 +491,9 @@ enableFullCastTracking: false
const pendingHooksPrompt = resolvedLanguage === "en"
? `Identify all active hooks from the source text (Markdown table):
| hook_id | start_chapter | type | status | latest_progress | expected_payoff | notes |`
| hook_id | start_chapter | type | status | latest_progress | expected_payoff | payoff_timing | notes |`
: `从正文中识别的所有伏笔(Markdown表格):
| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |`;
| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |`;
const keyPrinciplesPrompt = resolvedLanguage === "en"
? `## Key Principles
@@ -727,7 +729,8 @@ prohibitions:
status: row[3] ?? "open",
lastAdvancedChapter: normalizedProgress,
expectedPayoff: row[5] ?? "",
notes,
payoffTiming: row.length >= 8 ? row[6] ?? "" : "",
notes: row.length >= 8 ? this.mergeHookNotes(row[7] ?? "", seedNote, language) : notes,
};
});
+2 -2
View File
@@ -279,7 +279,7 @@ Updated state card as a Markdown table reflecting the end-of-chapter state:
=== UPDATED_HOOKS ===
Updated hooks pool as a Markdown table with the latest status of every known hook:
| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |
| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | notes |
=== CHAPTER_SUMMARY ===
Single Markdown table row:
@@ -372,7 +372,7 @@ ${bookRulesBody ? `## 本书规则\n\n${bookRulesBody}` : ""}
=== UPDATED_HOOKS ===
更新后的伏笔池(Markdown表格),包含所有已知伏笔的最新状态:
| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |
| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |
=== CHAPTER_SUMMARY ===
本章摘要(Markdown表格行):
+131 -3
View File
@@ -16,6 +16,10 @@ import {
parseChapterSummariesMarkdown,
retrieveMemorySelection,
} from "../utils/memory-retrieval.js";
import {
localizeHookPayoffTiming,
resolveHookPayoffTiming,
} from "../utils/hook-lifecycle.js";
export interface ComposeChapterInput {
readonly book: BookConfig;
@@ -43,7 +47,11 @@ export class ComposerAgent extends BaseAgent {
const runtimeDir = join(storyDir, "runtime");
await mkdir(runtimeDir, { recursive: true });
const selectedContext = await this.collectSelectedContext(storyDir, input.plan);
const selectedContext = await this.collectSelectedContext(
storyDir,
input.plan,
input.book.language ?? "zh",
);
const contextPackage = ContextPackageSchema.parse({
chapter: input.chapterNumber,
selectedContext,
@@ -103,7 +111,11 @@ export class ComposerAgent extends BaseAgent {
};
}
private async collectSelectedContext(storyDir: string, plan: PlanChapterOutput): Promise<ContextPackage["selectedContext"]> {
private async collectSelectedContext(
storyDir: string,
plan: PlanChapterOutput,
language: "zh" | "en",
): Promise<ContextPackage["selectedContext"]> {
const entries = await Promise.all([
this.maybeContextSource(storyDir, "current_focus.md", "Current task focus for this chapter."),
this.maybeContextSource(
@@ -145,6 +157,12 @@ export class ComposerAgent extends BaseAgent {
outlineNode: planningAnchor,
mustKeep: plan.intent.mustKeep,
});
const hookDebtEntries = await this.buildHookDebtEntries(
storyDir,
plan,
memorySelection.activeHooks,
language,
);
const summaryEntries = memorySelection.summaries.map((summary) => ({
source: `story/chapter_summaries.md#${summary.chapter}`,
@@ -161,7 +179,7 @@ export class ComposerAgent extends BaseAgent {
const hookEntries = memorySelection.hooks.map((hook) => ({
source: `story/pending_hooks.md#${hook.hookId}`,
reason: "Carry forward unresolved hooks that match the chapter focus.",
excerpt: [hook.type, hook.status, hook.expectedPayoff, hook.notes]
excerpt: [hook.type, hook.status, hook.expectedPayoff, hook.payoffTiming, hook.notes]
.filter(Boolean)
.join(" | "),
}));
@@ -174,6 +192,7 @@ export class ComposerAgent extends BaseAgent {
return [
...entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null),
...trailEntries,
...hookDebtEntries,
...factEntries,
...summaryEntries,
...volumeSummaryEntries,
@@ -226,6 +245,64 @@ export class ComposerAgent extends BaseAgent {
return entries;
}
private async buildHookDebtEntries(
storyDir: string,
plan: PlanChapterOutput,
activeHooks: ReadonlyArray<{
readonly hookId: string;
readonly startChapter: number;
readonly type: string;
readonly status: string;
readonly lastAdvancedChapter: number;
readonly expectedPayoff: string;
readonly payoffTiming?: string;
readonly notes: string;
}>,
language: "zh" | "en",
): Promise<ContextPackage["selectedContext"]> {
const targetHookIds = [...new Set([
...plan.intent.hookAgenda.eligibleResolve,
...plan.intent.hookAgenda.mustAdvance,
...plan.intent.hookAgenda.staleDebt,
])];
if (targetHookIds.length === 0) {
return [];
}
const summaries = parseChapterSummariesMarkdown(
await this.readFileOrDefault(join(storyDir, "chapter_summaries.md")),
);
return targetHookIds.flatMap((hookId) => {
const hook = activeHooks.find((entry) => entry.hookId === hookId);
if (!hook) {
return [];
}
const seedSummary = this.findHookSummary(summaries, hook.hookId, hook.startChapter, "seed");
const latestSummary = this.findHookSummary(summaries, hook.hookId, hook.lastAdvancedChapter, "latest");
const cadence = localizeHookPayoffTiming(resolveHookPayoffTiming(hook), language);
const role = this.describeHookAgendaRole(plan, hook.hookId, language);
const promise = hook.expectedPayoff || (language === "en" ? "(unspecified)" : "(未写明)");
const seedBeat = seedSummary
? this.renderHookDebtBeat(seedSummary)
: (hook.notes || promise);
const latestBeat = latestSummary
? this.renderHookDebtBeat(latestSummary)
: (hook.notes || promise);
return [{
source: `runtime/hook_debt#${hook.hookId}`,
reason: language === "en"
? "Narrative debt brief for an explicit hook agenda target."
: "显式 hook agenda 目标的叙事债务简报。",
excerpt: language === "en"
? `${hook.hookId} | role: ${role} | cadence: ${cadence} | promise: ${promise} | seed: ${seedBeat} | latest: ${latestBeat}`
: `${hook.hookId} | 角色: ${role} | 节奏: ${cadence} | 承诺: ${promise} | 种子: ${seedBeat} | 最近推进: ${latestBeat}`,
}];
});
}
private async maybeContextSource(
storyDir: string,
fileName: string,
@@ -270,4 +347,55 @@ export class ComposerAgent extends BaseAgent {
return "(文件尚未创建)";
}
}
private describeHookAgendaRole(
plan: PlanChapterOutput,
hookId: string,
language: "zh" | "en",
): string {
if (plan.intent.hookAgenda.eligibleResolve.includes(hookId)) {
return language === "en" ? "payoff candidate" : "本章优先兑现";
}
if (plan.intent.hookAgenda.staleDebt.includes(hookId)) {
return language === "en" ? "stale debt" : "高压旧债";
}
return language === "en" ? "must advance" : "本章必须推进";
}
private findHookSummary(
summaries: ReadonlyArray<ReturnType<typeof parseChapterSummariesMarkdown>[number]>,
hookId: string,
chapter: number,
mode: "seed" | "latest",
) {
const directChapterHit = summaries.find((summary) => summary.chapter === chapter);
const hookMentions = summaries.filter((summary) => this.summaryMentionsHook(summary, hookId));
if (mode === "seed") {
return hookMentions.find((summary) => summary.chapter === chapter)
?? hookMentions.at(0)
?? directChapterHit;
}
return [...hookMentions].reverse().find((summary) => summary.chapter === chapter)
?? hookMentions.at(-1)
?? directChapterHit;
}
private summaryMentionsHook(
summary: ReturnType<typeof parseChapterSummariesMarkdown>[number],
hookId: string,
): boolean {
return [
summary.title,
summary.events,
summary.stateChanges,
summary.hookActivity,
].some((text) => text.includes(hookId));
}
private renderHookDebtBeat(
summary: ReturnType<typeof parseChapterSummariesMarkdown>[number],
): string {
return `ch${summary.chapter} ${summary.title} - ${summary.events || summary.hookActivity || summary.stateChanges || "(none)"}`;
}
}
+1
View File
@@ -83,6 +83,7 @@ export class PlannerAgent extends BaseAgent {
const hookAgenda = buildPlannerHookAgenda({
hooks: memorySelection.activeHooks,
chapterNumber: input.chapterNumber,
targetChapters: input.book.targetChapters,
});
const directives = this.buildStructuredDirectives({
chapterNumber: input.chapterNumber,
+4 -1
View File
@@ -22,8 +22,9 @@ export function buildSettlerSystemPrompt(
- 提及伏笔:已有伏笔在本章被提到,但没有新增信息、没有改变读者或角色对该问题的理解 → 放入 mention 数组,不要更新最近推进
- 推进伏笔:已有伏笔在本章出现了新的事实、证据、关系变化、风险升级或范围收缩 → **必须**更新"最近推进"列为当前章节号,更新状态和备注
- 回收伏笔:伏笔在本章被明确揭示、解决、或不再成立 → 状态改为"已回收",备注回收方式
- 延后伏笔:超过5章未推进 → 标注"延后",备注原因
- 延后伏笔:只有当正文明确显示该线被主动搁置、转入后台、或被剧情压后时,才标注"延后";不要因为“已经过了几章”就机械延后
- brand-new unresolved thread:不要直接发明新的 hookId。把候选放进 newHookCandidates,由系统决定它是映射到旧 hook、变成真正新 hook,还是被拒绝为重述
- payoffTiming 使用语义节奏,不用硬写章节号:只允许 immediate / near-term / mid-arc / slow-burn / endgame
- **铁律**:不要把“再次提到”“换个说法重述”“抽象复盘”当成推进。只有状态真的变了,才更新最近推进。只是出现过的旧 hook,放进 mention 数组。`;
const fullCastBlock = bookRules?.enableFullCastTracking
@@ -114,6 +115,7 @@ function buildSettlerOutputFormat(gp: GenreProfile): string {
"status": "progressing",
"lastAdvancedChapter": 12,
"expectedPayoff": "揭开师债真相",
"payoffTiming": "slow-burn",
"notes": "本章为何推进/延后/回收"
}
],
@@ -125,6 +127,7 @@ function buildSettlerOutputFormat(gp: GenreProfile): string {
{
"type": "mystery",
"expectedPayoff": "新伏笔未来要回收到哪里",
"payoffTiming": "near-term",
"notes": "本章为什么会形成新的未解问题"
}
],
@@ -105,6 +105,9 @@ function buildGovernedInputContract(language: "zh" | "en", governed: boolean): s
- When the runtime rule stack records an active L4 -> L3 override, follow the current task over local planning.
- Keep hard guardrails compact: canon, continuity facts, and explicit prohibitions still win.
- If an English Variance Brief is provided, obey it: avoid the listed phrase/opening/ending patterns and satisfy the scene obligation.
- If Hook Debt Briefs are provided, treat them as the active memory of what the reader is still owed: preserve the original promise and change the on-page situation.
- When the explicit hook agenda names an eligible resolve target, land a concrete payoff beat instead of merely mentioning the old thread.
- When stale debt is present, do not open sibling hooks casually; clear pressure from old promises before minting fresh debt.
- In multi-character scenes, include at least one resistance-bearing exchange instead of reducing the beat to summary or explanation.`;
}
@@ -115,6 +118,9 @@ function buildGovernedInputContract(language: "zh" | "en", governed: boolean): s
- 当 runtime rule stack 明确记录了 L4 -> L3 的 active override 时,优先执行当前任务意图,再局部调整规划层。
- 真正不能突破的只有硬护栏:世界设定、连续性事实、显式禁令。
- 如果提供了 English Variance Brief,必须主动避开其中列出的高频短语、重复开头和重复结尾模式,并完成 scene obligation。
- 如果提供了 Hook Debt 简报,把它当成读者仍在等待兑现的承诺记忆:保留原始 promise,并让本章在页上发生真实变化。
- 如果显式 hook agenda 里出现了可回收目标,本章必须写出具体兑现片段,不能只是重新提一句旧线索。
- 如果存在 stale debt,先消化旧承诺的压力,再决定是否开新坑;同类 sibling hook 不得随手再开。
- 多角色场景里,至少给出一轮带阻力的直接交锋,不要把人物关系写成纯解释或纯总结。`;
}
+1
View File
@@ -910,6 +910,7 @@ ${lengthRequirementBlock}
blocks.titleHistoryBlock,
blocks.moodTrailBlock,
blocks.canonBlock,
blocks.hookDebtBlock,
blocks.hooksBlock,
blocks.summariesBlock,
blocks.volumeSummariesBlock,
+11
View File
@@ -16,6 +16,15 @@ export type StateManifest = z.infer<typeof StateManifestSchema>;
export const HookStatusSchema = z.enum(["open", "progressing", "deferred", "resolved"]);
export type HookStatus = z.infer<typeof HookStatusSchema>;
export const HookPayoffTimingSchema = z.enum([
"immediate",
"near-term",
"mid-arc",
"slow-burn",
"endgame",
]);
export type HookPayoffTiming = z.infer<typeof HookPayoffTimingSchema>;
export const HookRecordSchema = z.object({
hookId: z.string().min(1),
startChapter: z.number().int().min(0),
@@ -23,6 +32,7 @@ export const HookRecordSchema = z.object({
status: HookStatusSchema,
lastAdvancedChapter: z.number().int().min(0),
expectedPayoff: z.string().default(""),
payoffTiming: HookPayoffTimingSchema.optional(),
notes: z.string().default(""),
});
@@ -94,6 +104,7 @@ export type HookOps = z.infer<typeof HookOpsSchema>;
export const NewHookCandidateSchema = z.object({
type: z.string().min(1),
expectedPayoff: z.string().default(""),
payoffTiming: HookPayoffTimingSchema.optional(),
notes: z.string().default(""),
});
+16 -2
View File
@@ -52,6 +52,7 @@ export interface StoredHook {
readonly status: string;
readonly lastAdvancedChapter: number;
readonly expectedPayoff: string;
readonly payoffTiming?: string;
readonly notes: string;
}
@@ -99,6 +100,7 @@ export class MemoryDB {
status TEXT NOT NULL DEFAULT 'open',
last_advanced_chapter INTEGER NOT NULL DEFAULT 0,
expected_payoff TEXT NOT NULL DEFAULT '',
payoff_timing TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT ''
);
@@ -108,6 +110,16 @@ export class MemoryDB {
CREATE INDEX IF NOT EXISTS idx_hooks_status ON hooks(status);
CREATE INDEX IF NOT EXISTS idx_hooks_last_advanced ON hooks(last_advanced_chapter);
`);
this.ensureColumn("hooks", "payoff_timing", "TEXT NOT NULL DEFAULT ''");
}
private ensureColumn(table: string, column: string, definition: string): void {
try {
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
} catch {
// Column already exists on existing databases.
}
}
// ---------------------------------------------------------------------------
@@ -289,8 +301,8 @@ export class MemoryDB {
upsertHook(hook: StoredHook): void {
this.db.prepare(
`INSERT OR REPLACE INTO hooks (hook_id, start_chapter, type, status, last_advanced_chapter, expected_payoff, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
`INSERT OR REPLACE INTO hooks (hook_id, start_chapter, type, status, last_advanced_chapter, expected_payoff, payoff_timing, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
hook.hookId,
hook.startChapter,
@@ -298,6 +310,7 @@ export class MemoryDB {
hook.status,
hook.lastAdvancedChapter,
hook.expectedPayoff,
hook.payoffTiming ?? "",
hook.notes,
);
}
@@ -318,6 +331,7 @@ export class MemoryDB {
status,
last_advanced_chapter AS lastAdvancedChapter,
expected_payoff AS expectedPayoff,
payoff_timing AS payoffTiming,
notes
FROM hooks
WHERE lower(status) NOT IN ('resolved', 'closed', '已回收', '已解决')
+10 -9
View File
@@ -109,15 +109,16 @@ export async function loadNarrativeMemorySeed(bookDir: string): Promise<Narrativ
mood: row.mood,
chapterType: row.chapterType,
})),
hooks: snapshot.hooks.hooks.map((hook) => ({
hookId: hook.hookId,
startChapter: hook.startChapter,
type: hook.type,
status: hook.status,
lastAdvancedChapter: hook.lastAdvancedChapter,
expectedPayoff: hook.expectedPayoff,
notes: hook.notes,
})),
hooks: snapshot.hooks.hooks.map((hook) => ({
hookId: hook.hookId,
startChapter: hook.startChapter,
type: hook.type,
status: hook.status,
lastAdvancedChapter: hook.lastAdvancedChapter,
expectedPayoff: hook.expectedPayoff,
payoffTiming: hook.payoffTiming,
notes: hook.notes,
})),
};
}
+19 -10
View File
@@ -11,6 +11,7 @@ import {
type StateManifest,
} from "../models/runtime-state.js";
import type { Fact, StoredHook, StoredSummary } from "./memory-db.js";
import { normalizeHookPayoffTiming } from "../utils/hook-lifecycle.js";
export interface BootstrapStructuredStateResult {
readonly createdFiles: ReadonlyArray<string>;
@@ -186,15 +187,19 @@ export function parsePendingHooksMarkdown(markdown: string): StoredHook[] {
if (tableRows.length > 0) {
return tableRows
.filter((row) => normalizeHookId(row[0]).length > 0)
.map((row) => ({
hookId: normalizeHookId(row[0]),
startChapter: parseInteger(row[1]),
type: row[2] ?? "",
status: row[3] ?? "open",
lastAdvancedChapter: parseInteger(row[4]),
expectedPayoff: row[5] ?? "",
notes: row[6] ?? "",
}));
.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
@@ -210,6 +215,7 @@ export function parsePendingHooksMarkdown(markdown: string): StoredHook[] {
status: "open",
lastAdvancedChapter: 0,
expectedPayoff: "",
payoffTiming: undefined,
notes: line,
}));
}
@@ -333,6 +339,7 @@ function parsePendingHooksStateMarkdown(markdown: string, warnings: string[]) {
.filter((row) => normalizeHookId(row[0]).length > 0)
.map((row) => {
const hookId = normalizeHookId(row[0]);
const legacyShape = row.length < 8;
return {
hookId,
startChapter: parseIntegerWithWarning(row[1], warnings, `${hookId}:startChapter`),
@@ -340,7 +347,8 @@ function parsePendingHooksStateMarkdown(markdown: string, warnings: string[]) {
status: normalizeHookStatus(row[3], warnings, hookId),
lastAdvancedChapter: parseIntegerWithWarning(row[4], warnings, `${hookId}:lastAdvancedChapter`),
expectedPayoff: row[5] ?? "",
notes: row[6] ?? "",
payoffTiming: legacyShape ? undefined : normalizeHookPayoffTiming(row[6]),
notes: legacyShape ? (row[6] ?? "") : (row[7] ?? ""),
};
}),
});
@@ -360,6 +368,7 @@ function parsePendingHooksStateMarkdown(markdown: string, warnings: string[]) {
status: "open" as HookStatus,
lastAdvancedChapter: 0,
expectedPayoff: "",
payoffTiming: undefined,
notes: line,
})),
});
+9 -4
View File
@@ -3,6 +3,10 @@ import type {
CurrentStateState,
HooksState,
} from "../models/runtime-state.js";
import {
localizeHookPayoffTiming,
resolveHookPayoffTiming,
} from "../utils/hook-lifecycle.js";
export function renderHooksProjection(
state: HooksState,
@@ -11,12 +15,12 @@ export function renderHooksProjection(
const title = language === "en" ? "# Pending Hooks" : "# 伏笔池";
const headers = language === "en"
? [
"| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
"| --- | --- | --- | --- | --- | --- | --- |",
"| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | notes |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
]
: [
"| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
"| --- | --- | --- | --- | --- | --- | --- |",
"| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
];
const rows = [...state.hooks]
@@ -33,6 +37,7 @@ export function renderHooksProjection(
hook.status,
hook.lastAdvancedChapter,
hook.expectedPayoff,
localizeHookPayoffTiming(resolveHookPayoffTiming(hook), language),
hook.notes,
].map(escapeTableCell).join(" | ")
} |`);
+6
View File
@@ -12,6 +12,7 @@ import {
type StateManifest,
} from "../models/runtime-state.js";
import { evaluateHookAdmission } from "../utils/hook-governance.js";
import { resolveHookPayoffTiming } from "../utils/hook-lifecycle.js";
import { validateRuntimeState } from "./state-validator.js";
export interface RuntimeStateSnapshot {
@@ -153,6 +154,11 @@ function mergeDuplicateHookFamily(existing: HookRecord, incoming: HookRecord): H
: existing.status,
lastAdvancedChapter: advanced,
expectedPayoff,
payoffTiming: resolveHookPayoffTiming({
payoffTiming: incoming.payoffTiming ?? existing.payoffTiming,
expectedPayoff,
notes,
}),
notes,
};
}
@@ -4,6 +4,7 @@ export function buildGovernedMemoryEvidenceBlocks(
contextPackage: ContextPackage,
language?: "zh" | "en",
): {
readonly hookDebtBlock?: string;
readonly hooksBlock?: string;
readonly summariesBlock?: string;
readonly volumeSummariesBlock?: string;
@@ -15,6 +16,9 @@ export function buildGovernedMemoryEvidenceBlocks(
const hookEntries = contextPackage.selectedContext.filter((entry) =>
entry.source.startsWith("story/pending_hooks.md#"),
);
const hookDebtEntries = contextPackage.selectedContext.filter((entry) =>
entry.source.startsWith("runtime/hook_debt#"),
);
const summaryEntries = contextPackage.selectedContext.filter((entry) =>
entry.source.startsWith("story/chapter_summaries.md#"),
);
@@ -33,6 +37,12 @@ export function buildGovernedMemoryEvidenceBlocks(
);
return {
hookDebtBlock: hookDebtEntries.length > 0
? renderHookDebtBlock(
resolvedLanguage === "en" ? "Hook Debt Briefs" : "Hook Debt Briefs",
hookDebtEntries,
)
: undefined,
hooksBlock: hookEntries.length > 0
? renderEvidenceBlock(
resolvedLanguage === "en" ? "Selected Hook Evidence" : "已选伏笔证据",
@@ -72,6 +82,13 @@ export function buildGovernedMemoryEvidenceBlocks(
};
}
function renderHookDebtBlock(
heading: string,
entries: ContextPackage["selectedContext"],
): string {
return `\n## ${heading}\n${entries.map((entry) => `- ${entry.excerpt ?? entry.reason}`).join("\n")}\n`;
}
function renderEvidenceBlock(
heading: string,
entries: ContextPackage["selectedContext"],
+7
View File
@@ -5,6 +5,7 @@ import {
type RuntimeStateDelta,
} from "../models/runtime-state.js";
import { evaluateHookAdmission } from "./hook-governance.js";
import { resolveHookPayoffTiming } from "./hook-lifecycle.js";
export interface HookArbiterDecision {
readonly action: "created" | "mapped" | "mentioned" | "rejected";
@@ -154,6 +155,11 @@ function mergeCandidateIntoExistingHook(
status: existing.status === "resolved" ? "resolved" : "progressing",
lastAdvancedChapter: Math.max(existing.lastAdvancedChapter, chapter),
expectedPayoff: preferRicherText(existing.expectedPayoff, candidate.expectedPayoff),
payoffTiming: resolveHookPayoffTiming({
payoffTiming: candidate.payoffTiming ?? existing.payoffTiming,
expectedPayoff: preferRicherText(existing.expectedPayoff, candidate.expectedPayoff),
notes: preferRicherText(existing.notes, candidate.notes),
}),
notes: preferRicherText(existing.notes, candidate.notes),
};
}
@@ -170,6 +176,7 @@ function createCanonicalHook(params: {
status: "open",
lastAdvancedChapter: params.chapter,
expectedPayoff: params.candidate.expectedPayoff.trim(),
payoffTiming: resolveHookPayoffTiming(params.candidate),
notes: params.candidate.notes.trim(),
};
}
+23 -4
View File
@@ -1,10 +1,12 @@
import type { HookRecord, RuntimeStateDelta } from "../models/runtime-state.js";
import { describeHookLifecycle } from "./hook-lifecycle.js";
export type HookDisposition = "none" | "mention" | "advance" | "resolve" | "defer";
export interface HookAdmissionCandidate {
readonly type: string;
readonly expectedPayoff?: string;
readonly payoffTiming?: string;
readonly notes?: string;
}
@@ -17,15 +19,30 @@ export interface HookAdmissionDecision {
export function collectStaleHookDebt(params: {
readonly hooks: ReadonlyArray<HookRecord>;
readonly chapterNumber: number;
readonly targetChapters?: number;
readonly staleAfterChapters?: number;
}): HookRecord[] {
const staleAfterChapters = params.staleAfterChapters ?? 10;
const staleCutoff = params.chapterNumber - staleAfterChapters;
return params.hooks
.filter((hook) => hook.status !== "resolved" && hook.status !== "deferred")
.filter((hook) => hook.startChapter <= params.chapterNumber)
.filter((hook) => hook.lastAdvancedChapter <= staleCutoff)
.filter((hook) => {
const lifecycle = describeHookLifecycle({
payoffTiming: hook.payoffTiming,
expectedPayoff: hook.expectedPayoff,
notes: hook.notes,
startChapter: hook.startChapter,
lastAdvancedChapter: hook.lastAdvancedChapter,
status: hook.status,
chapterNumber: params.chapterNumber,
targetChapters: params.targetChapters,
});
if (params.staleAfterChapters !== undefined) {
return hook.lastAdvancedChapter <= params.chapterNumber - params.staleAfterChapters;
}
return lifecycle.stale || lifecycle.overdue;
})
.sort((left, right) => (
left.lastAdvancedChapter - right.lastAdvancedChapter
|| left.startChapter - right.startChapter
@@ -60,6 +77,7 @@ export function evaluateHookAdmission(params: {
const candidateNormalized = normalizeText([
params.candidate.type,
params.candidate.expectedPayoff ?? "",
params.candidate.payoffTiming ?? "",
params.candidate.notes ?? "",
].join(" "));
const candidateTerms = extractTerms(candidateNormalized);
@@ -69,6 +87,7 @@ export function evaluateHookAdmission(params: {
const activeNormalized = normalizeText([
hook.type,
hook.expectedPayoff,
hook.payoffTiming ?? "",
hook.notes,
].join(" "));
+212
View File
@@ -0,0 +1,212 @@
import type { HookPayoffTiming } from "../models/runtime-state.js";
type HookPhase = "opening" | "middle" | "late";
interface LifecycleProfile {
readonly earliestResolveAge: number;
readonly staleDormancy: number;
readonly overdueAge: number;
readonly minimumPhase: HookPhase;
readonly resolveBias: number;
}
const TIMING_PROFILES: Record<HookPayoffTiming, LifecycleProfile> = {
immediate: {
earliestResolveAge: 1,
staleDormancy: 1,
overdueAge: 3,
minimumPhase: "opening",
resolveBias: 5,
},
"near-term": {
earliestResolveAge: 1,
staleDormancy: 2,
overdueAge: 5,
minimumPhase: "opening",
resolveBias: 4,
},
"mid-arc": {
earliestResolveAge: 2,
staleDormancy: 4,
overdueAge: 8,
minimumPhase: "opening",
resolveBias: 3,
},
"slow-burn": {
earliestResolveAge: 4,
staleDormancy: 5,
overdueAge: 12,
minimumPhase: "middle",
resolveBias: 2,
},
endgame: {
earliestResolveAge: 6,
staleDormancy: 6,
overdueAge: 16,
minimumPhase: "late",
resolveBias: 1,
},
};
const PHASE_WEIGHT: Record<HookPhase, number> = {
opening: 0,
middle: 1,
late: 2,
};
const LABELS: Record<"zh" | "en", Record<HookPayoffTiming, string>> = {
en: {
immediate: "immediate",
"near-term": "near-term",
"mid-arc": "mid-arc",
"slow-burn": "slow-burn",
endgame: "endgame",
},
zh: {
immediate: "立即",
"near-term": "近期",
"mid-arc": "中程",
"slow-burn": "慢烧",
endgame: "终局",
},
};
const TIMING_ALIASES: Array<[HookPayoffTiming, RegExp]> = [
["immediate", /^(?:立即|马上|当章|本章|下一章|immediate|instant|next(?:\s+chapter|\s+beat)?|right\s+away)$/i],
["near-term", /^(?:近期|近几章|短线|soon|short(?:\s+run)?|near(?:\s*-\s*|\s+)term|current\s+sequence)$/i],
["mid-arc", /^(?:中程|中期|卷中|mid(?:\s*-\s*|\s+)arc|mid(?:\s*-\s*|\s+)book|middle)$/i],
["slow-burn", /^(?:慢烧|长线|后续|later|late(?:r)?|long(?:\s*-\s*|\s+)arc|slow(?:\s*-\s*|\s+)burn)$/i],
["endgame", /^(?:终局|终章|大结局|最终|climax|finale|endgame|late\s+book)$/i],
];
const SIGNAL_PATTERNS: Array<[HookPayoffTiming, RegExp]> = [
["endgame", /(终局|终章|大结局|最终揭晓|最终摊牌|climax|finale|endgame|final reveal|last act)/i],
["immediate", /(当章|本章|下一章|马上|立刻|即刻|immediate|next chapter|right away|at once)/i],
["near-term", /(近期|近几章|很快|短线|soon|near-term|short run|current sequence)/i],
["mid-arc", /(中期|卷中|本卷中段|mid-book|mid arc|middle of the arc)/i],
["slow-burn", /(长线|慢烧|后续发酵|慢慢揭开|later|slow burn|long arc|long tail)/i],
];
export function normalizeHookPayoffTiming(value: string | undefined | null): HookPayoffTiming | undefined {
const normalized = value?.trim();
if (!normalized) return undefined;
for (const [timing, pattern] of TIMING_ALIASES) {
if (pattern.test(normalized)) {
return timing;
}
}
return undefined;
}
export function inferHookPayoffTiming(params: {
readonly expectedPayoff?: string;
readonly notes?: string;
}): HookPayoffTiming {
const combined = [params.expectedPayoff, params.notes]
.filter((value): value is string => Boolean(value && value.trim()))
.join(" ")
.trim();
if (!combined) return "mid-arc";
for (const [timing, pattern] of SIGNAL_PATTERNS) {
if (pattern.test(combined)) {
return timing;
}
}
return "mid-arc";
}
export function resolveHookPayoffTiming(params: {
readonly payoffTiming?: string | null;
readonly expectedPayoff?: string;
readonly notes?: string;
}): HookPayoffTiming {
return normalizeHookPayoffTiming(params.payoffTiming)
?? inferHookPayoffTiming({
expectedPayoff: params.expectedPayoff,
notes: params.notes,
});
}
export function localizeHookPayoffTiming(
timing: HookPayoffTiming,
language: "zh" | "en",
): string {
return LABELS[language][timing];
}
export function describeHookLifecycle(params: {
readonly payoffTiming?: string | null;
readonly expectedPayoff?: string;
readonly notes?: string;
readonly startChapter: number;
readonly lastAdvancedChapter: number;
readonly status: string;
readonly chapterNumber: number;
readonly targetChapters?: number;
}): {
readonly timing: HookPayoffTiming;
readonly phase: HookPhase;
readonly age: number;
readonly dormancy: number;
readonly readyToResolve: boolean;
readonly stale: boolean;
readonly overdue: boolean;
readonly advancePressure: number;
readonly resolvePressure: number;
} {
const timing = resolveHookPayoffTiming(params);
const profile = TIMING_PROFILES[timing];
const phase = resolveHookPhase(params.chapterNumber, params.targetChapters);
const age = Math.max(0, params.chapterNumber - Math.max(1, params.startChapter));
const lastTouchChapter = Math.max(params.startChapter, params.lastAdvancedChapter);
const dormancy = Math.max(0, params.chapterNumber - Math.max(1, lastTouchChapter));
const explicitProgressing = /^(progressing|advanced|重大推进|持续推进)$/i.test(params.status.trim());
const phaseReady = PHASE_WEIGHT[phase] >= PHASE_WEIGHT[profile.minimumPhase];
const recentlyTouched = dormancy <= 1;
const overdue = phaseReady && age >= profile.overdueAge;
const cadenceReady = timing === "slow-burn"
? phase === "late" || overdue
: timing === "endgame"
? phase === "late"
: true;
const momentum = explicitProgressing || recentlyTouched;
const stale = phaseReady && (
dormancy >= profile.staleDormancy
|| (overdue && !momentum)
);
const readyToResolve = phaseReady
&& cadenceReady
&& age >= profile.earliestResolveAge
&& (momentum || (overdue && explicitProgressing));
return {
timing,
phase,
age,
dormancy,
readyToResolve,
stale,
overdue,
advancePressure: age + dormancy + (stale ? 8 : 0) + (overdue ? 6 : 0),
resolvePressure: readyToResolve
? profile.resolveBias * 10 + (explicitProgressing ? 5 : 0) + Math.min(12, dormancy * 2) + (overdue ? 10 : 0)
: 0,
};
}
function resolveHookPhase(chapterNumber: number, targetChapters?: number): HookPhase {
if (targetChapters && targetChapters > 0) {
const progress = chapterNumber / targetChapters;
if (progress >= 0.72) return "late";
if (progress >= 0.33) return "middle";
return "opening";
}
if (chapterNumber >= 24) return "late";
if (chapterNumber >= 8) return "middle";
return "opening";
}
+81 -35
View File
@@ -10,7 +10,12 @@ import {
} 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 { collectStaleHookDebt } from "./hook-governance.js";
import {
describeHookLifecycle,
localizeHookPayoffTiming,
resolveHookPayoffTiming,
normalizeHookPayoffTiming,
} from "./hook-lifecycle.js";
export interface MemorySelection {
readonly summaries: ReadonlyArray<StoredSummary>;
@@ -191,12 +196,12 @@ export function renderHookSnapshot(
const headers = language === "en"
? [
"| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
"| --- | --- | --- | --- | --- | --- | --- |",
"| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
]
: [
"| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
"| --- | --- | --- | --- | --- | --- | --- |",
"| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
];
return [
@@ -208,6 +213,7 @@ export function renderHookSnapshot(
hook.status,
hook.lastAdvancedChapter,
hook.expectedPayoff,
localizeHookPayoffTiming(resolveHookPayoffTiming(hook), language),
hook.notes,
].map((cell) => escapeTableCell(String(cell))).join(" | ")).map((row) => `| ${row} |`),
].join("\n");
@@ -216,6 +222,7 @@ export function renderHookSnapshot(
export function buildPlannerHookAgenda(params: {
readonly hooks: ReadonlyArray<StoredHook>;
readonly chapterNumber: number;
readonly targetChapters?: number;
readonly maxMustAdvance?: number;
readonly maxEligibleResolve?: number;
readonly maxStaleDebt?: number;
@@ -224,31 +231,55 @@ export function buildPlannerHookAgenda(params: {
.map(normalizeStoredHook)
.filter((hook) => !isFuturePlannedHook(hook, params.chapterNumber, 0))
.filter((hook) => hook.status !== "resolved" && hook.status !== "deferred");
const mustAdvanceHooks = agendaHooks
const lifecycleEntries = agendaHooks.map((hook) => ({
hook,
lifecycle: describeHookLifecycle({
payoffTiming: hook.payoffTiming,
expectedPayoff: hook.expectedPayoff,
notes: hook.notes,
startChapter: hook.startChapter,
lastAdvancedChapter: hook.lastAdvancedChapter,
status: hook.status,
chapterNumber: params.chapterNumber,
targetChapters: params.targetChapters,
}),
}));
const staleDebtHooks = lifecycleEntries
.filter((entry) => entry.lifecycle.stale)
.sort((left, right) => (
Number(right.lifecycle.overdue) - Number(left.lifecycle.overdue)
|| right.lifecycle.advancePressure - left.lifecycle.advancePressure
|| left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter
|| left.hook.startChapter - right.hook.startChapter
|| left.hook.hookId.localeCompare(right.hook.hookId)
))
.slice(0, params.maxStaleDebt ?? 2)
.map((entry) => entry.hook);
const mustAdvanceHooks = lifecycleEntries
.slice()
.sort((left, right) => (
left.lastAdvancedChapter - right.lastAdvancedChapter
|| left.startChapter - right.startChapter
|| left.hookId.localeCompare(right.hookId)
Number(right.lifecycle.stale) - Number(left.lifecycle.stale)
|| right.lifecycle.advancePressure - left.lifecycle.advancePressure
|| left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter
|| left.hook.startChapter - right.hook.startChapter
|| left.hook.hookId.localeCompare(right.hook.hookId)
))
.slice(0, params.maxMustAdvance ?? 2);
const staleDebtHooks = collectStaleHookDebt({
hooks: agendaHooks,
chapterNumber: params.chapterNumber,
})
.slice(0, params.maxStaleDebt ?? 2);
const eligibleResolveHooks = agendaHooks
.filter((hook) => hook.startChapter <= params.chapterNumber - 3)
.filter((hook) => hook.lastAdvancedChapter >= params.chapterNumber - 2)
.slice(0, params.maxMustAdvance ?? 2)
.map((entry) => entry.hook);
const eligibleResolveHooks = lifecycleEntries
.filter((entry) => entry.lifecycle.readyToResolve)
.sort((left, right) => (
left.startChapter - right.startChapter
|| right.lastAdvancedChapter - left.lastAdvancedChapter
|| left.hookId.localeCompare(right.hookId)
right.lifecycle.resolvePressure - left.lifecycle.resolvePressure
|| Number(right.lifecycle.stale) - Number(left.lifecycle.stale)
|| left.hook.startChapter - right.hook.startChapter
|| left.hook.hookId.localeCompare(right.hook.hookId)
))
.slice(0, params.maxEligibleResolve ?? 1);
.slice(0, params.maxEligibleResolve ?? 1)
.map((entry) => entry.hook);
const avoidNewHookFamilies = [...new Set([
...staleDebtHooks.map((hook) => hook.type.trim()).filter(Boolean),
...mustAdvanceHooks.map((hook) => hook.type.trim()).filter(Boolean),
...eligibleResolveHooks.map((hook) => hook.type.trim()).filter(Boolean),
])].slice(0, 3);
return {
@@ -388,15 +419,7 @@ export function parsePendingHooksMarkdown(markdown: string): StoredHook[] {
if (tableRows.length > 0) {
return tableRows
.filter((row) => normalizeHookId(row[0]).length > 0)
.map((row) => ({
hookId: normalizeHookId(row[0]),
startChapter: parseInteger(row[1]),
type: row[2] ?? "",
status: row[3] ?? "open",
lastAdvancedChapter: parseInteger(row[4]),
expectedPayoff: row[5] ?? "",
notes: row[6] ?? "",
}));
.map((row) => parsePendingHookRow(row));
}
return markdown
@@ -412,10 +435,28 @@ export function parsePendingHooksMarkdown(markdown: string): StoredHook[] {
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,
@@ -553,9 +594,9 @@ function selectRelevantHooks(
const ranked = hooks
.map((hook) => ({
hook,
score: scoreHook(hook, queryTerms),
score: scoreHook(hook, queryTerms, chapterNumber),
matched: matchesAny(
[hook.hookId, hook.type, hook.expectedPayoff, hook.notes].join(" "),
[hook.hookId, hook.type, hook.expectedPayoff, hook.payoffTiming ?? "", hook.notes].join(" "),
queryTerms,
),
}))
@@ -665,8 +706,12 @@ function scoreSummary(summary: StoredSummary, chapterNumber: number, queryTerms:
return recencyScore + termScore;
}
function scoreHook(hook: StoredHook, queryTerms: ReadonlyArray<string>): number {
const text = [hook.hookId, hook.type, hook.expectedPayoff, hook.notes].join(" ");
function scoreHook(
hook: StoredHook,
queryTerms: ReadonlyArray<string>,
_chapterNumber: number,
): number {
const text = [hook.hookId, hook.type, hook.expectedPayoff, hook.payoffTiming ?? "", hook.notes].join(" ");
const freshness = Math.max(0, hook.lastAdvancedChapter);
const termScore = queryTerms.reduce((score, term) => score + (includesTerm(text, term) ? Math.max(8, term.length * 2) : 0), 0);
return termScore + freshness;
@@ -680,6 +725,7 @@ function normalizeStoredHook(hook: StoredHook): HookRecord {
status: normalizeStoredHookStatus(hook.status),
lastAdvancedChapter: Math.max(0, hook.lastAdvancedChapter),
expectedPayoff: hook.expectedPayoff,
payoffTiming: resolveHookPayoffTiming(hook),
notes: hook.notes,
};
}