refactor(pipeline): extract hook agenda from memory retrieval

This commit is contained in:
Ma
2026-04-01 16:14:32 +08:00
parent f3044007d4
commit ef7d4ebb6a
5 changed files with 570 additions and 495 deletions
@@ -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
View File
@@ -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;
@@ -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
View File
@@ -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
View File
@@ -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));
}