Преглед изворни кода

refactor(pipeline): extract hook agenda from memory retrieval

Ma пре 7 месеци
родитељ
комит
ef7d4ebb6a

+ 67 - 0
packages/core/src/__tests__/hook-agenda.test.ts

@@ -0,0 +1,67 @@
+import { describe, expect, it } from "vitest";
+import {
+  buildPlannerHookAgenda,
+  isHookWithinLifecycleWindow,
+} 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 {
+  return {
+    hookId: overrides.hookId ?? "mentor-oath",
+    startChapter: overrides.startChapter ?? 8,
+    type: overrides.type ?? "relationship",
+    status: overrides.status ?? "open",
+    lastAdvancedChapter: overrides.lastAdvancedChapter ?? 9,
+    expectedPayoff: overrides.expectedPayoff ?? "Reveal why the mentor broke the oath",
+    payoffTiming: overrides.payoffTiming ?? "slow-burn",
+    notes: overrides.notes ?? "Long debt should stay visible",
+  };
+}
+
+describe("hook-agenda", () => {
+  it("keeps lifecycle-aware windowing and pressure agenda behavior after extraction", () => {
+    const staleSlowBurn = createHook({
+      hookId: "mentor-oath",
+      startChapter: 4,
+      lastAdvancedChapter: 7,
+      notes: "Long debt is stalling",
+    });
+    const readyMystery = createHook({
+      hookId: "ledger-fragment",
+      type: "mystery",
+      startChapter: 2,
+      lastAdvancedChapter: 10,
+      payoffTiming: "near-term",
+      expectedPayoff: "Reveal the ledger fragment's origin",
+      notes: "Ready to cash out",
+    });
+
+    const agenda = buildPlannerHookAgenda({
+      hooks: [staleSlowBurn, readyMystery],
+      chapterNumber: 12,
+      targetChapters: 24,
+      language: "en",
+    });
+
+    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);
+  });
+});

+ 1 - 1
packages/core/src/agents/planner.ts

@@ -5,13 +5,13 @@ import type { BookConfig } from "../models/book.js";
 import { parseBookRules } from "../models/book-rules.js";
 import { ChapterIntentSchema, type ChapterConflict, type ChapterIntent } from "../models/input-governance.js";
 import {
-  buildPlannerHookAgenda,
   parseChapterSummariesMarkdown,
   renderHookSnapshot,
   renderSummarySnapshot,
   retrieveMemorySelection,
 } from "../utils/memory-retrieval.js";
 import { analyzeChapterCadence } from "../utils/chapter-cadence.js";
+import { buildPlannerHookAgenda } from "../utils/hook-agenda.js";
 
 export interface PlanChapterInput {
   readonly book: BookConfig;

+ 4 - 2
packages/core/src/utils/governed-working-set.ts

@@ -1,10 +1,12 @@
 import type { ContextPackage } from "../models/input-governance.js";
 import {
-  isHookWithinChapterWindow,
-  isHookWithinLifecycleWindow,
   parsePendingHooksMarkdown,
   renderHookSnapshot,
 } from "./memory-retrieval.js";
+import {
+  isHookWithinChapterWindow,
+  isHookWithinLifecycleWindow,
+} from "./hook-agenda.js";
 import { describeHookLifecycle } from "./hook-lifecycle.js";
 
 export function buildGovernedHookWorkingSet(params: {

+ 483 - 0
packages/core/src/utils/hook-agenda.ts

@@ -0,0 +1,483 @@
+import type { HookAgenda, HookPressure } 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;
+}
+
+export const DEFAULT_HOOK_LOOKAHEAD_CHAPTERS = 3;
+
+export function buildPlannerHookAgenda(params: {
+  readonly hooks: ReadonlyArray<StoredHook>;
+  readonly chapterNumber: number;
+  readonly targetChapters?: number;
+  readonly language?: "zh" | "en";
+  readonly maxMustAdvance?: number;
+  readonly maxEligibleResolve?: number;
+  readonly maxStaleDebt?: number;
+}): HookAgenda {
+  const agendaHooks = params.hooks
+    .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)
+    .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)
+    .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);
+  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,
+  });
+
+  return {
+    pressureMap,
+    mustAdvance: mustAdvanceHooks.map((hook) => hook.hookId),
+    eligibleResolve: eligibleResolveHooks.map((hook) => hook.hookId),
+    staleDebt: staleDebtHooks.map((hook) => hook.hookId),
+    avoidNewHookFamilies,
+  };
+}
+
+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,
+    startChapter: Math.max(0, hook.startChapter),
+    type: hook.type,
+    status: normalizeStoredHookStatus(hook.status),
+    lastAdvancedChapter: Math.max(0, hook.lastAdvancedChapter),
+    expectedPayoff: hook.expectedPayoff,
+    payoffTiming: resolveHookPayoffTiming(hook),
+    notes: hook.notes,
+  };
+}
+
+function normalizeStoredHookStatus(status: string): HookStatus {
+  if (/^(resolved|closed|done|已回收|已解决)$/i.test(status.trim())) return "resolved";
+  if (/^(deferred|paused|hold|延后|延期|搁置|暂缓)$/i.test(status.trim())) return "deferred";
+  if (/^(progressing|advanced|重大推进|持续推进)$/i.test(status.trim())) return "progressing";
+  return "open";
+}
+
+export function filterActiveHooks(hooks: ReadonlyArray<StoredHook>): StoredHook[] {
+  return hooks.filter((hook) => normalizeStoredHookStatus(hook.status) !== "resolved");
+}
+
+export function isFuturePlannedHook(
+  hook: StoredHook,
+  chapterNumber: number,
+  lookahead: number = DEFAULT_HOOK_LOOKAHEAD_CHAPTERS,
+): boolean {
+  return hook.lastAdvancedChapter <= 0 && hook.startChapter > chapterNumber + lookahead;
+}
+
+export function isHookWithinChapterWindow(
+  hook: StoredHook,
+  chapterNumber: number,
+  recentWindow: number = 5,
+  lookahead: number = DEFAULT_HOOK_LOOKAHEAD_CHAPTERS,
+): boolean {
+  const recentCutoff = Math.max(0, chapterNumber - recentWindow);
+
+  if (hook.lastAdvancedChapter > 0 && hook.lastAdvancedChapter >= recentCutoff) {
+    return true;
+  }
+
+  if (hook.lastAdvancedChapter > 0) {
+    return false;
+  }
+
+  if (hook.startChapter <= 0) {
+    return true;
+  }
+
+  if (hook.startChapter >= recentCutoff && hook.startChapter <= chapterNumber) {
+    return true;
+  }
+
+  return hook.startChapter > chapterNumber && hook.startChapter <= chapterNumber + lookahead;
+}

+ 15 - 492
packages/core/src/utils/memory-retrieval.ts

@@ -1,12 +1,9 @@
 import { readFile } from "node:fs/promises";
 import { join } from "node:path";
-import type { HookAgenda, HookPressure } from "../models/input-governance.js";
 import {
   ChapterSummariesStateSchema,
   CurrentStateStateSchema,
   HooksStateSchema,
-  type HookRecord,
-  type HookStatus,
 } 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";
@@ -17,14 +14,21 @@ import {
   normalizeHookPayoffTiming,
 } 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";
+  buildPlannerHookAgenda,
+  filterActiveHooks,
+  isFuturePlannedHook,
+  isHookWithinChapterWindow,
+  isHookWithinLifecycleWindow,
+  resolveRelevantHookPrimaryLimit,
+  resolveRelevantHookStaleLimit,
+  selectAgendaHooksWithTypeSpread,
+} from "./hook-agenda.js";
+export {
+  buildPlannerHookAgenda,
+  isFuturePlannedHook,
+  isHookWithinChapterWindow,
+  isHookWithinLifecycleWindow,
+} from "./hook-agenda.js";
 
 export interface MemorySelection {
   readonly summaries: ReadonlyArray<StoredSummary>;
@@ -41,8 +45,6 @@ export interface VolumeSummarySelection {
   readonly anchor: string;
 }
 
-export const DEFAULT_HOOK_LOOKAHEAD_CHAPTERS = 3;
-
 export async function retrieveMemorySelection(params: {
   readonly bookDir: string;
   readonly chapterNumber: number;
@@ -228,426 +230,6 @@ export function renderHookSnapshot(
   ].join("\n");
 }
 
-export function buildPlannerHookAgenda(params: {
-  readonly hooks: ReadonlyArray<StoredHook>;
-  readonly chapterNumber: number;
-  readonly targetChapters?: number;
-  readonly language?: "zh" | "en";
-  readonly maxMustAdvance?: number;
-  readonly maxEligibleResolve?: number;
-  readonly maxStaleDebt?: number;
-}): HookAgenda {
-  const agendaHooks = params.hooks
-    .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)
-    .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)
-    .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);
-  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,
-  });
-
-  return {
-    pressureMap,
-    mustAdvance: mustAdvanceHooks.map((hook) => hook.hookId),
-    eligibleResolve: eligibleResolveHooks.map((hook) => hook.hookId),
-    staleDebt: staleDebtHooks.map((hook) => hook.hookId),
-    avoidNewHookFamilies,
-  };
-}
-
-function resolveHookAgendaLoad(entries: ReadonlyArray<{
-  readonly hook: ReturnType<typeof normalizeStoredHook>;
-  readonly lifecycle: ReturnType<typeof describeHookLifecycle>;
-}>): 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));
-}
-
-function selectAgendaHooksWithTypeSpread<T extends {
-  readonly hook: {
-    readonly hookId: string;
-    readonly type: string;
-  };
-  readonly lifecycle: ReturnType<typeof describeHookLifecycle>;
-}>(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";
-}
-
-function resolveRelevantHookPrimaryLimit(entries: ReadonlyArray<{
-  readonly hook: {
-    readonly type: string;
-  };
-  readonly lifecycle: ReturnType<typeof describeHookLifecycle>;
-}>): 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;
-}
-
-function resolveRelevantHookStaleLimit(
-  entries: ReadonlyArray<{
-    readonly hook: {
-      readonly hookId: string;
-      readonly type: string;
-    };
-    readonly lifecycle: ReturnType<typeof describeHookLifecycle>;
-  }>,
-  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: ReturnType<typeof describeHookLifecycle>,
-): boolean {
-  return isHookWithinChapterWindow(
-    hook,
-    chapterNumber,
-    resolveHookVisibilityWindow(lifecycle.timing),
-  );
-}
-
-function isMustAdvanceCandidate(
-  lifecycle: ReturnType<typeof describeHookLifecycle>,
-): boolean {
-  return lifecycle.stale
-    || lifecycle.readyToResolve
-    || lifecycle.overdue
-    || lifecycle.advancePressure >= HOOK_PRESSURE_WEIGHTS.mustAdvancePressureFloor;
-}
-
-function buildHookPressureMap(params: {
-  readonly lifecycleEntries: ReadonlyArray<{
-    readonly hook: ReturnType<typeof normalizeStoredHook>;
-    readonly lifecycle: ReturnType<typeof describeHookLifecycle>;
-  }>;
-  readonly mustAdvanceHooks: ReadonlyArray<ReturnType<typeof normalizeStoredHook>>;
-  readonly eligibleResolveHooks: ReadonlyArray<ReturnType<typeof normalizeStoredHook>>;
-  readonly staleDebtHooks: ReadonlyArray<ReturnType<typeof normalizeStoredHook>>;
-}): 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({
-      hook,
-      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 hook: ReturnType<typeof normalizeStoredHook>;
-  readonly lifecycle: ReturnType<typeof describeHookLifecycle>;
-  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: ReturnType<typeof describeHookLifecycle>;
-  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: ReturnType<typeof describeHookLifecycle>;
-  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 openMemoryDB(bookDir: string): MemoryDB | null {
   try {
     return new MemoryDB(bookDir);
@@ -1088,65 +670,6 @@ function scoreHook(
   return termScore + freshness;
 }
 
-function normalizeStoredHook(hook: StoredHook): HookRecord {
-  return {
-    hookId: hook.hookId,
-    startChapter: Math.max(0, hook.startChapter),
-    type: hook.type,
-    status: normalizeStoredHookStatus(hook.status),
-    lastAdvancedChapter: Math.max(0, hook.lastAdvancedChapter),
-    expectedPayoff: hook.expectedPayoff,
-    payoffTiming: resolveHookPayoffTiming(hook),
-    notes: hook.notes,
-  };
-}
-
-function normalizeStoredHookStatus(status: string): HookStatus {
-  if (/^(resolved|closed|done|已回收|已解决)$/i.test(status.trim())) return "resolved";
-  if (/^(deferred|paused|hold|延后|延期|搁置|暂缓)$/i.test(status.trim())) return "deferred";
-  if (/^(progressing|advanced|重大推进|持续推进)$/i.test(status.trim())) return "progressing";
-  return "open";
-}
-
-function filterActiveHooks(hooks: ReadonlyArray<StoredHook>): StoredHook[] {
-  return hooks.filter((hook) => normalizeStoredHookStatus(hook.status) !== "resolved");
-}
-
-export function isFuturePlannedHook(
-  hook: StoredHook,
-  chapterNumber: number,
-  lookahead: number = DEFAULT_HOOK_LOOKAHEAD_CHAPTERS,
-): boolean {
-  return hook.lastAdvancedChapter <= 0 && hook.startChapter > chapterNumber + lookahead;
-}
-
-export function isHookWithinChapterWindow(
-  hook: StoredHook,
-  chapterNumber: number,
-  recentWindow: number = 5,
-  lookahead: number = DEFAULT_HOOK_LOOKAHEAD_CHAPTERS,
-): boolean {
-  const recentCutoff = Math.max(0, chapterNumber - recentWindow);
-
-  if (hook.lastAdvancedChapter > 0 && hook.lastAdvancedChapter >= recentCutoff) {
-    return true;
-  }
-
-  if (hook.lastAdvancedChapter > 0) {
-    return false;
-  }
-
-  if (hook.startChapter <= 0) {
-    return true;
-  }
-
-  if (hook.startChapter >= recentCutoff && hook.startChapter <= chapterNumber) {
-    return true;
-  }
-
-  return hook.startChapter > chapterNumber && hook.startChapter <= chapterNumber + lookahead;
-}
-
 function matchesAny(text: string, queryTerms: ReadonlyArray<string>): boolean {
   return queryTerms.some((term) => includesTerm(text, term));
 }