feat(hooks): replace lifecycle pressure system with seed excerpt approach

Removes the hook lifecycle/pressure scoring system (5-level timing,
pressure formulas, pressure map tables) and replaces it with:

1. Simple stalest-first sorting for mustAdvance (same as iter-001)
2. Hook seed excerpts in composer — for each hookAgenda target, finds
   the original chapter where it was planted and the latest advancement
   chapter, extracts the hookActivity text, and injects it as an
   evidence block the writer can directly build on.

The writer now sees concrete narrative material ("original seed (ch2):
萧炎右手碰到戒面时,有一丝温热渗出") instead of abstract metadata
("pressure: high, movement: partial-payoff, reason: stale-promise").

This addresses the hook execution problem: writer knew WHICH hooks to
advance but not HOW. Seed excerpts provide the "how" material.
This commit is contained in:
Ma
2026-04-02 17:20:13 +08:00
parent f4ee25b1fe
commit 66bb890e5c
11 changed files with 106 additions and 656 deletions
+2 -4
View File
@@ -563,10 +563,8 @@ describe("ComposerAgent", () => {
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("建议动作: 局部兑现");
expect(hookDebtEntry?.excerpt).toContain("当前压力: 高");
expect(hookDebtEntry?.excerpt).toContain("注意: 暂缓同类开坑");
expect(hookDebtEntry?.excerpt).not.toContain("抑制同类开坑: 是");
expect(hookDebtEntry?.excerpt).toContain("主要旧债");
expect(hookDebtEntry?.excerpt).toContain("读者承诺");
expect(hookDebtEntry?.excerpt).toContain("River Camp");
expect(hookDebtEntry?.excerpt).toContain("Trial Echo");
});
@@ -83,11 +83,11 @@ describe("governed-working-set", () => {
expect(filtered).not.toContain("future-pr-machine");
});
it("keeps slow-burn hooks in the governed working set when they are still within lifecycle visibility", () => {
it("keeps recently-advanced hooks in the governed working set while filtering far-future hooks", () => {
const hooks = [
"| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
"| river-oath | 8 | relationship | progressing | 13 | Reveal why the river oath was broken | slow-burn | Long debt should stay visible through the middle game |",
"| river-oath | 8 | relationship | progressing | 16 | Reveal why the river oath was broken | slow-burn | Long debt should stay visible through the middle game |",
"| future-pr-machine | 45 | system | open | 0 | Future hook should stay hidden | endgame | Future hook should stay hidden |",
].join("\n");
@@ -1,9 +1,8 @@
import { describe, expect, it } from "vitest";
import {
buildPlannerHookAgenda,
isHookWithinLifecycleWindow,
isHookWithinChapterWindow,
} from "../utils/hook-agenda.js";
import { describeHookLifecycle } from "../utils/hook-lifecycle.js";
import type { StoredHook } from "../state/memory-db.js";
function createHook(overrides: Partial<StoredHook> = {}): StoredHook {
@@ -20,7 +19,7 @@ function createHook(overrides: Partial<StoredHook> = {}): StoredHook {
}
describe("hook-agenda", () => {
it("keeps lifecycle-aware windowing and pressure agenda behavior after extraction", () => {
it("builds agenda with stalest-first sorting and chapter-window filtering", () => {
const staleSlowBurn = createHook({
hookId: "mentor-oath",
startChapter: 4,
@@ -45,23 +44,8 @@ describe("hook-agenda", () => {
});
expect(agenda.mustAdvance).toContain("mentor-oath");
expect(agenda.pressureMap).toEqual(expect.arrayContaining([
expect.objectContaining({
hookId: "mentor-oath",
}),
]));
const lifecycle = describeHookLifecycle({
payoffTiming: staleSlowBurn.payoffTiming,
expectedPayoff: staleSlowBurn.expectedPayoff,
notes: staleSlowBurn.notes,
startChapter: staleSlowBurn.startChapter,
lastAdvancedChapter: staleSlowBurn.lastAdvancedChapter,
status: staleSlowBurn.status,
chapterNumber: 12,
targetChapters: 24,
});
expect(isHookWithinLifecycleWindow(staleSlowBurn, 12, lifecycle)).toBe(true);
expect(isHookWithinChapterWindow(staleSlowBurn, 12, 5)).toBe(true);
expect(isHookWithinChapterWindow(readyMystery, 12, 5)).toBe(true);
});
});
@@ -1211,7 +1211,7 @@ describe("parsePendingHooksMarkdown", () => {
]);
});
it("keeps slow-burn hooks out of early resolve slots while still advancing them", () => {
it("sorts must-advance by stalest-first and resolve by earliest-started", () => {
const agenda = memoryRetrieval.buildPlannerHookAgenda({
chapterNumber: 18,
hooks: [
@@ -1242,29 +1242,13 @@ describe("parsePendingHooksMarkdown", () => {
} as never);
expect(agenda.mustAdvance).toContain("slow-oath");
expect(agenda.mustAdvance).toContain("ready-packet");
expect(agenda.eligibleResolve).toContain("slow-oath");
expect(agenda.eligibleResolve).toContain("ready-packet");
expect(agenda.eligibleResolve).not.toContain("slow-oath");
expect(agenda.pressureMap).toEqual(expect.arrayContaining([
expect.objectContaining({
hookId: "slow-oath",
movement: "advance",
pressure: "medium",
type: "relationship",
payoffTiming: "slow-burn",
reason: "building-debt",
}),
expect.objectContaining({
hookId: "ready-packet",
movement: "full-payoff",
pressure: "high",
type: "mystery",
payoffTiming: "near-term",
reason: "ripe-payoff",
}),
]));
expect(agenda.pressureMap).toEqual([]);
});
it("expands default resolve coverage when several short-payoff hooks mature together", () => {
it("limits eligible resolve to default max of 1 when not overridden", () => {
const agenda = memoryRetrieval.buildPlannerHookAgenda({
chapterNumber: 8,
targetChapters: 12,
@@ -1302,17 +1286,11 @@ describe("parsePendingHooksMarkdown", () => {
] as never,
} as never);
expect(agenda.eligibleResolve.length).toBeGreaterThan(1);
expect(agenda.eligibleResolve).toEqual(expect.arrayContaining([
"packet-drop",
"seal-crack",
]));
expect(
agenda.pressureMap.filter((entry) => entry.movement === "full-payoff").length,
).toBeGreaterThan(1);
expect(agenda.eligibleResolve.length).toBe(1);
expect(agenda.pressureMap).toEqual([]);
});
it("spreads default must-advance coverage across pressured hook families", () => {
it("picks stalest hooks for must-advance regardless of type family", () => {
const agenda = memoryRetrieval.buildPlannerHookAgenda({
chapterNumber: 15,
targetChapters: 30,
@@ -1360,7 +1338,7 @@ describe("parsePendingHooksMarkdown", () => {
] as never,
} as never);
expect(agenda.mustAdvance).toContain("kiln-key");
expect(agenda.mustAdvance).toEqual(["mentor-oath-a", "mentor-oath-b"]);
expect(agenda.mustAdvance).toEqual(expect.arrayContaining([
expect.stringMatching(/^mentor-oath-/),
]));
+2 -32
View File
@@ -1207,27 +1207,10 @@ describe("PlannerAgent", () => {
expect(result.intent.hookAgenda.eligibleResolve).toEqual(["ready-payoff"]);
expect(result.intent.hookAgenda.staleDebt).toEqual(["stale-debt"]);
expect(result.intent.hookAgenda.avoidNewHookFamilies).toContain("relationship");
expect(result.intent.hookAgenda.pressureMap).toEqual(expect.arrayContaining([
expect.objectContaining({
hookId: "ready-payoff",
movement: "full-payoff",
pressure: "critical",
type: "mystery",
reason: "overdue-payoff",
}),
expect.objectContaining({
hookId: "stale-debt",
movement: "advance",
pressure: "critical",
type: "relationship",
reason: "stale-promise",
}),
]));
expect(result.intent.hookAgenda.pressureMap).toEqual([]);
const intentMarkdown = await readFile(result.runtimePath, "utf-8");
expect(intentMarkdown).toContain("## Hook Agenda");
expect(intentMarkdown).toContain("### Pressure Map");
expect(intentMarkdown).toContain("recent-route");
expect(intentMarkdown).toContain("ready-payoff");
expect(intentMarkdown).toContain("stale-debt");
});
@@ -1344,19 +1327,6 @@ describe("PlannerAgent", () => {
"relationship",
"mystery",
]));
expect(result.intent.hookAgenda.pressureMap).toEqual(expect.arrayContaining([
expect.objectContaining({
hookId: "stale-omega",
movement: "advance",
pressure: "critical",
reason: "stale-promise",
}),
expect.objectContaining({
hookId: "stale-sable",
movement: "advance",
pressure: "critical",
reason: "stale-promise",
}),
]));
expect(result.intent.hookAgenda.pressureMap).toEqual([]);
});
});
+17 -88
View File
@@ -16,10 +16,6 @@ import {
parseChapterSummariesMarkdown,
retrieveMemorySelection,
} from "../utils/memory-retrieval.js";
import {
localizeHookPayoffTiming,
resolveHookPayoffTiming,
} from "../utils/hook-lifecycle.js";
export interface ComposeChapterInput {
readonly book: BookConfig;
@@ -289,36 +285,34 @@ export class ComposerAgent extends BaseAgent {
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 guidance = this.findHookPressure(plan, hook.hookId);
const role = this.describeHookAgendaRole(plan, hook.hookId, language);
const movement = guidance
? this.describeHookMovement(guidance.movement, language)
: role;
const pressure = guidance
? this.describeHookPressure(guidance.pressure, language)
: (language === "en" ? "medium" : "中");
const promise = hook.expectedPayoff || (language === "en" ? "(unspecified)" : "(未写明)");
const seedBeat = seedSummary
? this.renderHookDebtBeat(seedSummary)
: (hook.notes || promise);
const latestBeat = latestSummary
const latestBeat = latestSummary && latestSummary !== seedSummary
? this.renderHookDebtBeat(latestSummary)
: (hook.notes || promise);
const reason = guidance
? this.describeHookReason(guidance.reason, language)
: (language === "en"
? "Keep the original promise legible and materially change the on-page situation."
: "保持原始承诺清晰可见,并让页上局势发生实质变化。");
: undefined;
const age = Math.max(0, plan.intent.chapter - Math.max(1, hook.startChapter));
return [{
source: `runtime/hook_debt#${hook.hookId}`,
reason: language === "en"
? "Narrative debt brief for an explicit hook agenda target."
: "显式 hook agenda 目标的叙事债务简报。",
? "Narrative debt brief with original seed text for this hook agenda target."
: "含原始种子文本的叙事债务简报。",
excerpt: language === "en"
? `${hook.hookId} | narrative debt: ${role} (${cadence}) | current pressure: ${pressure} (${reason}) | preferred move: ${movement} | reader promise: ${promise} | original seed: ${seedBeat} | latest turn: ${latestBeat}${guidance?.blockSiblingHooks ? " | caution: avoid opening sibling hooks" : ""}`
: `${hook.hookId} | 叙事债务: ${role}${cadence} | 当前压力: ${pressure}${reason} | 建议动作: ${movement} | 读者承诺: ${promise} | 最初种子: ${seedBeat} | 最近推进: ${latestBeat}${guidance?.blockSiblingHooks ? " | 注意: 暂缓同类开坑" : ""}`,
? [
`${hook.hookId} (${hook.type}, ${role}, open ${age} chapters)`,
`reader promise: ${promise}`,
`original seed (ch${hook.startChapter}): ${seedBeat}`,
latestBeat ? `latest turn (ch${hook.lastAdvancedChapter}): ${latestBeat}` : undefined,
].filter(Boolean).join(" | ")
: [
`${hook.hookId}${hook.type}${role},已开${age}章)`,
`读者承诺:${promise}`,
`种于第${hook.startChapter}章:${seedBeat}`,
latestBeat ? `推进于第${hook.lastAdvancedChapter}章:${latestBeat}` : undefined,
].filter(Boolean).join(" | "),
}];
});
}
@@ -382,71 +376,6 @@ export class ComposerAgent extends BaseAgent {
return language === "en" ? "mainline debt" : "主要旧债";
}
private findHookPressure(
plan: PlanChapterOutput,
hookId: string,
): PlanChapterOutput["intent"]["hookAgenda"]["pressureMap"][number] | undefined {
return plan.intent.hookAgenda.pressureMap.find((entry) => entry.hookId === hookId);
}
private describeHookMovement(
movement: PlanChapterOutput["intent"]["hookAgenda"]["pressureMap"][number]["movement"],
language: "zh" | "en",
): string {
if (language === "en") {
return movement.replace(/-/g, " ");
}
return {
"quiet-hold": "轻压保温",
refresh: "重新点亮",
advance: "推进",
"partial-payoff": "局部兑现",
"full-payoff": "完整兑现",
}[movement];
}
private describeHookPressure(
pressure: PlanChapterOutput["intent"]["hookAgenda"]["pressureMap"][number]["pressure"],
language: "zh" | "en",
): string {
if (language === "en") {
return pressure;
}
return {
low: "低",
medium: "中",
high: "高",
critical: "极高",
}[pressure];
}
private describeHookReason(
reason: PlanChapterOutput["intent"]["hookAgenda"]["pressureMap"][number]["reason"],
language: "zh" | "en",
): string {
if (language === "en") {
return {
"fresh-promise": "fresh promise",
"building-debt": "building debt",
"stale-promise": "stale promise",
"ripe-payoff": "ripe payoff",
"overdue-payoff": "overdue payoff",
"long-arc-hold": "long arc hold",
}[reason];
}
return {
"fresh-promise": "新近承诺,先保持清晰存在",
"building-debt": "债务正在累积,需要继续加码",
"stale-promise": "旧承诺已停滞,需要重新推动或缩圈",
"ripe-payoff": "已经进入可兑现窗口",
"overdue-payoff": "已经拖过理想兑现窗口",
"long-arc-hold": "长线承诺仍应保温,不宜提前兑付",
}[reason];
}
private findHookSummary(
summaries: ReadonlyArray<ReturnType<typeof parseChapterSummariesMarkdown>[number]>,
hookId: string,
-21
View File
@@ -648,28 +648,7 @@ export class PlannerAgent extends BaseAgent {
intent.moodDirective ? `- mood: ${intent.moodDirective}` : undefined,
intent.titleDirective ? `- title: ${intent.titleDirective}` : undefined,
].filter(Boolean).join("\n") || "- none";
const pressureMap = intent.hookAgenda.pressureMap.length > 0
? [
"| hook_id | type | payoff_timing | phase | pressure | movement | reason | sibling_guard |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
...intent.hookAgenda.pressureMap
.map((item) => [
item.hookId,
item.type,
item.payoffTiming ?? "unspecified",
item.phase,
item.pressure,
item.movement,
item.reason,
item.blockSiblingHooks ? "yes" : "no",
].join(" | "))
.map((row) => `| ${row} |`),
].join("\n")
: "- none";
const hookAgenda = [
"### Pressure Map",
pressureMap,
"",
"### Must Advance",
intent.hookAgenda.mustAdvance.length > 0
? intent.hookAgenda.mustAdvance.map((item) => `- ${item}`).join("\n")
+4 -8
View File
@@ -105,11 +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.
- If the explicit hook agenda includes a pressure map, follow the requested move for each target: full-payoff means concrete payoff, partial-payoff means a meaningful intermediate reveal, advance/refresh mean material movement, and quiet-hold means keep the promise visible without cashing it out early.
- When the explicit hook agenda names an eligible resolve target, land a concrete payoff beat instead of merely mentioning the old thread.
- If Hook Debt Briefs are provided, they contain the ORIGINAL SEED TEXT from the chapter where each hook was planted. Use this text to write a continuation or payoff that feels connected to what the reader already saw — not a vague mention, but a scene that builds on the specific promise.
- When the explicit hook agenda names an eligible resolve target, land a concrete payoff beat that answers the reader's original question from the seed chapter.
- When stale debt is present, do not open sibling hooks casually; clear pressure from old promises before minting fresh debt.
- When a hook brief says to suppress sibling hooks, do not fake progress by opening a parallel hook of the same family.
- In multi-character scenes, include at least one resistance-bearing exchange instead of reducing the beat to summary or explanation.`;
}
@@ -120,11 +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 里带有 pressure map,逐条执行其中要求的动作:full-payoff 就是具体兑现,partial-payoff 就是给出中间层级的揭示或缩圈,advance / refresh 就是有分量的推进,quiet-hold 就是让承诺继续可见但不要过早消耗
- 如果显式 hook agenda 里出现了可回收目标,本章必须写出具体兑现片段,不能只是重新提一句旧线索。
- 如果提供了 Hook Debt 简报,里面包含每个伏笔种下时的**原始文本片段**。用这些原文来写延续或兑现场景——不是模糊地提一嘴,而是接着读者已经看到的具体承诺来写
- 如果显式 hook agenda 里出现了可回收目标,本章必须写出具体兑现片段,回答种子章节中读者的原始疑问
- 如果存在 stale debt,先消化旧承诺的压力,再决定是否开新坑;同类 sibling hook 不得随手再开。
- 如果某条 hook 简报明确要求 suppress sibling hooks,就不能用再开一个同类平行坑来假装推进。
- 多角色场景里,至少给出一轮带阻力的直接交锋,不要把人物关系写成纯解释或纯总结。`;
}
@@ -5,9 +5,7 @@ import {
} from "./memory-retrieval.js";
import {
isHookWithinChapterWindow,
isHookWithinLifecycleWindow,
} from "./hook-agenda.js";
import { describeHookLifecycle } from "./hook-lifecycle.js";
export function buildGovernedHookWorkingSet(params: {
readonly hooksMarkdown: string;
@@ -37,23 +35,11 @@ export function buildGovernedHookWorkingSet(params: {
const workingSet = hooks.filter((hook) =>
selectedIds.has(hook.hookId)
|| agendaIds.has(hook.hookId)
|| (
params.keepRecent !== undefined
? isHookWithinChapterWindow(hook, params.chapterNumber, params.keepRecent)
: isHookWithinLifecycleWindow(
hook,
params.chapterNumber,
describeHookLifecycle({
payoffTiming: hook.payoffTiming,
expectedPayoff: hook.expectedPayoff,
notes: hook.notes,
startChapter: Math.max(0, hook.startChapter),
lastAdvancedChapter: Math.max(0, hook.lastAdvancedChapter),
status: hook.status,
chapterNumber: params.chapterNumber,
}),
)
),
|| isHookWithinChapterWindow(
hook,
params.chapterNumber,
params.keepRecent ?? 5,
),
);
if (workingSet.length === 0 || workingSet.length >= hooks.length) {
+42 -391
View File
@@ -1,35 +1,15 @@
import type { HookAgenda, HookPressure } from "../models/input-governance.js";
import type { HookAgenda } from "../models/input-governance.js";
import type { HookRecord, HookStatus } from "../models/runtime-state.js";
import type { StoredHook } from "../state/memory-db.js";
import { describeHookLifecycle, resolveHookPayoffTiming } from "./hook-lifecycle.js";
import {
HOOK_ACTIVITY_THRESHOLDS,
HOOK_AGENDA_LIMITS,
HOOK_AGENDA_LOAD_THRESHOLDS,
HOOK_PRESSURE_WEIGHTS,
HOOK_RELEVANT_SELECTION_DEFAULTS,
resolveHookVisibilityWindow,
type HookAgendaLoad,
} from "./hook-policy.js";
type HookLifecycle = ReturnType<typeof describeHookLifecycle>;
type NormalizedStoredHook = HookRecord;
interface HookAgendaEntry {
readonly hook: NormalizedStoredHook;
readonly lifecycle: HookLifecycle;
}
interface HookSelectionEntry {
readonly hook: {
readonly hookId: string;
readonly type: string;
};
readonly lifecycle: HookLifecycle;
}
import { resolveHookPayoffTiming } from "./hook-lifecycle.js";
export const DEFAULT_HOOK_LOOKAHEAD_CHAPTERS = 3;
/**
* Build the hook agenda using simple stalest-first sorting.
* No lifecycle pressure formulas — just pick the hooks that have been
* dormant the longest and the ones that are ripe for resolution.
*/
export function buildPlannerHookAgenda(params: {
readonly hooks: ReadonlyArray<StoredHook>;
readonly chapterNumber: number;
@@ -43,91 +23,50 @@ export function buildPlannerHookAgenda(params: {
.map(normalizeStoredHook)
.filter((hook) => !isFuturePlannedHook(hook, params.chapterNumber, 0))
.filter((hook) => hook.status !== "resolved" && hook.status !== "deferred");
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 agendaLoad = resolveHookAgendaLoad(lifecycleEntries);
const staleDebtCandidates = 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)
));
const staleDebtHooks = selectAgendaHooksWithTypeSpread({
entries: staleDebtCandidates,
limit: resolveAgendaLimit({
explicitLimit: params.maxStaleDebt,
candidateCount: staleDebtCandidates.length,
fallbackLimit: HOOK_AGENDA_LIMITS[agendaLoad].staleDebt,
}),
forceInclude: (entry) => entry.lifecycle.overdue,
}).map((entry) => entry.hook);
const mustAdvancePool = lifecycleEntries.filter((entry) => isMustAdvanceCandidate(entry.lifecycle));
const mustAdvanceCandidates = (mustAdvancePool.length > 0 ? mustAdvancePool : lifecycleEntries)
// mustAdvance: stalest first (lowest lastAdvancedChapter)
const mustAdvanceHooks = agendaHooks
.slice()
.sort((left, right) => (
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)
));
const mustAdvanceHooks = selectAgendaHooksWithTypeSpread({
entries: mustAdvanceCandidates,
limit: resolveAgendaLimit({
explicitLimit: params.maxMustAdvance,
candidateCount: mustAdvanceCandidates.length,
fallbackLimit: HOOK_AGENDA_LIMITS[agendaLoad].mustAdvance,
}),
forceInclude: (entry) => entry.lifecycle.overdue,
}).map((entry) => entry.hook);
const eligibleResolveCandidates = lifecycleEntries
.filter((entry) => entry.lifecycle.readyToResolve)
left.lastAdvancedChapter - right.lastAdvancedChapter
|| left.startChapter - right.startChapter
|| left.hookId.localeCompare(right.hookId)
))
.slice(0, params.maxMustAdvance ?? 2);
// staleDebt: hooks not advanced for 10+ chapters
const staleThreshold = params.chapterNumber - 10;
const staleDebtHooks = agendaHooks
.filter((hook) => {
const lastTouch = Math.max(hook.startChapter, hook.lastAdvancedChapter);
return lastTouch > 0 && lastTouch <= staleThreshold;
})
.sort((left, right) => (
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)
));
const eligibleResolveHooks = selectAgendaHooksWithTypeSpread({
entries: eligibleResolveCandidates,
limit: resolveAgendaLimit({
explicitLimit: params.maxEligibleResolve,
candidateCount: eligibleResolveCandidates.length,
fallbackLimit: HOOK_AGENDA_LIMITS[agendaLoad].eligibleResolve,
}),
forceInclude: (entry) => (
entry.lifecycle.overdue
|| entry.lifecycle.resolvePressure >= HOOK_PRESSURE_WEIGHTS.criticalResolvePressure
),
}).map((entry) => entry.hook);
left.lastAdvancedChapter - right.lastAdvancedChapter
|| left.startChapter - right.startChapter
|| left.hookId.localeCompare(right.hookId)
))
.slice(0, params.maxStaleDebt ?? 2);
// eligibleResolve: started 3+ chapters ago AND recently advanced
const eligibleResolveHooks = agendaHooks
.filter((hook) => hook.startChapter <= params.chapterNumber - 3)
.filter((hook) => hook.lastAdvancedChapter >= params.chapterNumber - 2)
.sort((left, right) => (
left.startChapter - right.startChapter
|| right.lastAdvancedChapter - left.lastAdvancedChapter
|| left.hookId.localeCompare(right.hookId)
))
.slice(0, params.maxEligibleResolve ?? 1);
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, HOOK_AGENDA_LIMITS[agendaLoad].avoidFamilies);
const pressureMap = buildHookPressureMap({
lifecycleEntries,
mustAdvanceHooks,
eligibleResolveHooks,
staleDebtHooks,
});
])].slice(0, 3);
return {
pressureMap,
pressureMap: [],
mustAdvance: mustAdvanceHooks.map((hook) => hook.hookId),
eligibleResolve: eligibleResolveHooks.map((hook) => hook.hookId),
staleDebt: staleDebtHooks.map((hook) => hook.hookId),
@@ -135,294 +74,6 @@ export function buildPlannerHookAgenda(params: {
};
}
function resolveHookAgendaLoad(entries: ReadonlyArray<HookAgendaEntry>): HookAgendaLoad {
const pressuredEntries = entries.filter((entry) =>
entry.lifecycle.readyToResolve
|| entry.lifecycle.stale
|| entry.lifecycle.overdue,
);
const staleCount = pressuredEntries.filter((entry) => entry.lifecycle.stale).length;
const readyCount = pressuredEntries.filter((entry) => entry.lifecycle.readyToResolve).length;
const criticalCount = pressuredEntries.filter((entry) =>
entry.lifecycle.overdue
|| entry.lifecycle.resolvePressure >= HOOK_PRESSURE_WEIGHTS.criticalResolvePressure,
).length;
const pressuredFamilies = new Set(
pressuredEntries.map((entry) => normalizeHookType(entry.hook.type)),
).size;
if (
readyCount >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyReadyCount
|| staleCount >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyStaleCount
|| criticalCount >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyCriticalCount
|| pressuredEntries.length >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyPressuredCount
) {
return "heavy";
}
if (
readyCount >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumReadyCount
|| staleCount >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumStaleCount
|| criticalCount >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumCriticalCount
|| pressuredFamilies >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumPressuredFamilies
) {
return "medium";
}
return "light";
}
function resolveAgendaLimit(params: {
readonly explicitLimit?: number;
readonly candidateCount: number;
readonly fallbackLimit: number;
}): number {
if (params.candidateCount <= 0) {
return 0;
}
const limit = params.explicitLimit ?? params.fallbackLimit;
return Math.max(1, Math.min(limit, params.candidateCount));
}
export function selectAgendaHooksWithTypeSpread<T extends HookSelectionEntry>(params: {
readonly entries: ReadonlyArray<T>;
readonly limit: number;
readonly forceInclude?: (entry: T) => boolean;
}): T[] {
if (params.limit <= 0 || params.entries.length === 0) {
return [];
}
const selected: T[] = [];
const selectedIds = new Set<string>();
const selectedTypes = new Set<string>();
const forcedEntries = params.entries.filter((entry) => params.forceInclude?.(entry) ?? false);
const addEntry = (entry: T): void => {
if (selectedIds.has(entry.hook.hookId) || selected.length >= params.limit) {
return;
}
selected.push(entry);
selectedIds.add(entry.hook.hookId);
selectedTypes.add(normalizeHookType(entry.hook.type));
};
for (const entry of forcedEntries) {
if (selected.length >= params.limit) {
break;
}
const normalizedType = normalizeHookType(entry.hook.type);
if (!selectedTypes.has(normalizedType)) {
addEntry(entry);
}
}
for (const entry of forcedEntries) {
addEntry(entry);
}
for (const entry of params.entries) {
if (selected.length >= params.limit) {
break;
}
if (selectedIds.has(entry.hook.hookId)) {
continue;
}
const normalizedType = normalizeHookType(entry.hook.type);
if (!selectedTypes.has(normalizedType)) {
addEntry(entry);
}
}
for (const entry of params.entries) {
if (selected.length >= params.limit) {
break;
}
addEntry(entry);
}
return selected;
}
function normalizeHookType(type: string): string {
return type.trim().toLowerCase() || "hook";
}
export function resolveRelevantHookPrimaryLimit(entries: ReadonlyArray<HookSelectionEntry>): number {
const pressuredCount = entries.filter((entry) =>
entry.lifecycle.readyToResolve
|| entry.lifecycle.stale
|| entry.lifecycle.overdue,
).length;
return pressuredCount >= HOOK_RELEVANT_SELECTION_DEFAULTS.primary.pressuredThreshold
? HOOK_RELEVANT_SELECTION_DEFAULTS.primary.pressuredExpansionLimit
: HOOK_RELEVANT_SELECTION_DEFAULTS.primary.baseLimit;
}
export function resolveRelevantHookStaleLimit(
entries: ReadonlyArray<HookSelectionEntry>,
selectedIds: ReadonlySet<string>,
): number {
const staleCandidates = entries.filter((entry) =>
!selectedIds.has(entry.hook.hookId)
&& (entry.lifecycle.stale || entry.lifecycle.overdue),
);
if (staleCandidates.length === 0) {
return 0;
}
const staleFamilies = new Set(
staleCandidates.map((entry) => normalizeHookType(entry.hook.type)),
).size;
const overdueCount = staleCandidates.filter((entry) => entry.lifecycle.overdue).length;
if (
overdueCount >= HOOK_RELEVANT_SELECTION_DEFAULTS.stale.overdueThreshold
|| staleFamilies >= HOOK_RELEVANT_SELECTION_DEFAULTS.stale.familySpreadThreshold
) {
return Math.min(HOOK_RELEVANT_SELECTION_DEFAULTS.stale.expandedLimit, staleCandidates.length);
}
return HOOK_RELEVANT_SELECTION_DEFAULTS.stale.defaultLimit;
}
export function isHookWithinLifecycleWindow(
hook: StoredHook,
chapterNumber: number,
lifecycle: HookLifecycle,
): boolean {
return isHookWithinChapterWindow(
hook,
chapterNumber,
resolveHookVisibilityWindow(lifecycle.timing),
);
}
function isMustAdvanceCandidate(lifecycle: HookLifecycle): boolean {
return lifecycle.stale
|| lifecycle.readyToResolve
|| lifecycle.overdue
|| lifecycle.advancePressure >= HOOK_PRESSURE_WEIGHTS.mustAdvancePressureFloor;
}
function buildHookPressureMap(params: {
readonly lifecycleEntries: ReadonlyArray<HookAgendaEntry>;
readonly mustAdvanceHooks: ReadonlyArray<NormalizedStoredHook>;
readonly eligibleResolveHooks: ReadonlyArray<NormalizedStoredHook>;
readonly staleDebtHooks: ReadonlyArray<NormalizedStoredHook>;
}): HookPressure[] {
const eligibleResolveIds = new Set(params.eligibleResolveHooks.map((hook) => hook.hookId));
const staleDebtIds = new Set(params.staleDebtHooks.map((hook) => hook.hookId));
const lifecycleById = new Map(
params.lifecycleEntries.map((entry) => [entry.hook.hookId, entry.lifecycle] as const),
);
const orderedIds = [...new Set([
...params.eligibleResolveHooks.map((hook) => hook.hookId),
...params.staleDebtHooks.map((hook) => hook.hookId),
...params.mustAdvanceHooks.map((hook) => hook.hookId),
])];
return orderedIds.flatMap((hookId) => {
const hook = params.lifecycleEntries.find((entry) => entry.hook.hookId === hookId)?.hook;
const lifecycle = lifecycleById.get(hookId);
if (!hook || !lifecycle) {
return [];
}
const movement = resolveHookMovement({
lifecycle,
eligibleResolve: eligibleResolveIds.has(hookId),
staleDebt: staleDebtIds.has(hookId),
});
const pressure = resolveHookPressureLevel({ lifecycle, movement });
const reason = resolveHookPressureReason({ lifecycle, movement });
return [{
hookId,
type: hook.type.trim() || "hook",
movement,
pressure,
payoffTiming: lifecycle.timing,
phase: lifecycle.phase,
reason,
blockSiblingHooks: staleDebtIds.has(hookId) || movement === "partial-payoff" || movement === "full-payoff",
}];
});
}
function resolveHookMovement(params: {
readonly lifecycle: HookLifecycle;
readonly eligibleResolve: boolean;
readonly staleDebt: boolean;
}): HookPressure["movement"] {
if (params.eligibleResolve) {
return "full-payoff";
}
const timing = params.lifecycle.timing;
const longArc = timing === "slow-burn" || timing === "endgame";
if (params.staleDebt && longArc) {
return "partial-payoff";
}
if (params.staleDebt) {
return "advance";
}
if (
longArc
&& params.lifecycle.age <= HOOK_ACTIVITY_THRESHOLDS.longArcQuietHoldMaxAge
&& params.lifecycle.dormancy <= HOOK_ACTIVITY_THRESHOLDS.longArcQuietHoldMaxDormancy
) {
return "quiet-hold";
}
if (params.lifecycle.dormancy >= HOOK_ACTIVITY_THRESHOLDS.refreshDormancy) {
return "refresh";
}
return "advance";
}
function resolveHookPressureLevel(params: {
readonly lifecycle: HookLifecycle;
readonly movement: HookPressure["movement"];
}): HookPressure["pressure"] {
if (params.lifecycle.overdue || params.movement === "full-payoff") {
return params.lifecycle.overdue ? "critical" : "high";
}
if (params.lifecycle.stale || params.movement === "partial-payoff") {
return "high";
}
if (params.movement === "advance" || params.movement === "refresh") {
return "medium";
}
return "low";
}
function resolveHookPressureReason(params: {
readonly lifecycle: HookLifecycle;
readonly movement: HookPressure["movement"];
}): HookPressure["reason"] {
if (params.lifecycle.overdue && params.movement === "full-payoff") {
return "overdue-payoff";
}
if (params.movement === "full-payoff") {
return "ripe-payoff";
}
if (params.movement === "partial-payoff" || params.lifecycle.stale) {
return "stale-promise";
}
if (params.movement === "quiet-hold") {
return params.lifecycle.timing === "slow-burn" || params.lifecycle.timing === "endgame"
? "long-arc-hold"
: "fresh-promise";
}
if (params.lifecycle.age <= HOOK_ACTIVITY_THRESHOLDS.freshPromiseAge) {
return "fresh-promise";
}
return "building-debt";
}
function normalizeStoredHook(hook: StoredHook): HookRecord {
return {
hookId: hook.hookId,
+19 -40
View File
@@ -7,16 +7,11 @@ import {
} from "../models/runtime-state.js";
import { MemoryDB, type Fact, type StoredHook, type StoredSummary } from "../state/memory-db.js";
import { bootstrapStructuredStateFromMarkdown } from "../state/state-bootstrap.js";
import { describeHookLifecycle } from "./hook-lifecycle.js";
import {
buildPlannerHookAgenda,
filterActiveHooks,
isFuturePlannedHook,
isHookWithinChapterWindow,
isHookWithinLifecycleWindow,
resolveRelevantHookPrimaryLimit,
resolveRelevantHookStaleLimit,
selectAgendaHooksWithTypeSpread,
} from "./hook-agenda.js";
import {
parseChapterSummariesMarkdown,
@@ -29,7 +24,6 @@ export {
buildPlannerHookAgenda,
isFuturePlannedHook,
isHookWithinChapterWindow,
isHookWithinLifecycleWindow,
} from "./hook-agenda.js";
export {
parseChapterSummariesMarkdown,
@@ -342,49 +336,34 @@ function selectRelevantHooks(
const ranked = hooks
.map((hook) => ({
hook,
lifecycle: describeHookLifecycle({
payoffTiming: hook.payoffTiming,
expectedPayoff: hook.expectedPayoff,
notes: hook.notes,
startChapter: Math.max(0, hook.startChapter),
lastAdvancedChapter: Math.max(0, hook.lastAdvancedChapter),
status: hook.status,
chapterNumber,
}),
score: scoreHook(hook, queryTerms, chapterNumber),
matched: matchesAny(
[hook.hookId, hook.type, hook.expectedPayoff, hook.payoffTiming ?? "", hook.notes].join(" "),
queryTerms,
),
}))
.filter((entry) => entry.matched || isUnresolvedHook(entry.hook.status));
.filter((entry: { hook: StoredHook; score: number; matched: boolean }) =>
entry.matched || isUnresolvedHook(entry.hook.status),
);
const primary = selectAgendaHooksWithTypeSpread({
entries: ranked
.filter((entry) => (
entry.matched
|| isHookWithinLifecycleWindow(entry.hook, chapterNumber, entry.lifecycle)
))
.sort((left, right) => right.score - left.score || right.hook.lastAdvancedChapter - left.hook.lastAdvancedChapter),
limit: resolveRelevantHookPrimaryLimit(ranked),
forceInclude: (entry) => entry.matched && entry.lifecycle.overdue,
});
const primary = ranked
.filter((entry: { hook: StoredHook; score: number; matched: boolean }) =>
entry.matched || isHookWithinChapterWindow(entry.hook, chapterNumber, 5),
)
.sort((left, right) => right.score - left.score || right.hook.lastAdvancedChapter - left.hook.lastAdvancedChapter)
.slice(0, 6);
const selectedIds = new Set(primary.map((entry) => entry.hook.hookId));
const stale = selectAgendaHooksWithTypeSpread({
entries: ranked
.filter((entry) => (
!selectedIds.has(entry.hook.hookId)
&& !isFuturePlannedHook(entry.hook, chapterNumber)
&& (entry.lifecycle.stale || entry.lifecycle.overdue)
&& isUnresolvedHook(entry.hook.status)
))
.sort((left, right) => left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter || right.score - left.score),
limit: resolveRelevantHookStaleLimit(ranked, selectedIds),
forceInclude: (entry) => entry.lifecycle.overdue,
});
const selectedIds = new Set(primary.map((entry: { hook: StoredHook; score: number; matched: boolean }) => entry.hook.hookId));
const stale = ranked
.filter((entry: { hook: StoredHook; score: number; matched: boolean }) =>
!selectedIds.has(entry.hook.hookId)
&& !isFuturePlannedHook(entry.hook, chapterNumber)
&& isUnresolvedHook(entry.hook.status),
)
.sort((left, right) => left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter || right.score - left.score)
.slice(0, 2);
return [...primary, ...stale].map((entry) => entry.hook);
return [...primary, ...stale].map((entry: { hook: StoredHook; score: number; matched: boolean }) => entry.hook);
}
function selectRelevantFacts(