Bläddra i källkod

feat(pipeline): make hook agenda pressure-aware

Ma 7 månader sedan
förälder
incheckning
198cc94859

+ 102 - 0
packages/core/src/__tests__/memory-retrieval.test.ts

@@ -1161,4 +1161,106 @@ describe("parsePendingHooksMarkdown", () => {
       }),
     ]));
   });
+
+  it("expands default resolve coverage when several short-payoff hooks mature together", () => {
+    const agenda = memoryRetrieval.buildPlannerHookAgenda({
+      chapterNumber: 8,
+      targetChapters: 12,
+      hooks: [
+        {
+          hookId: "packet-drop",
+          startChapter: 5,
+          type: "mystery",
+          status: "progressing",
+          lastAdvancedChapter: 7,
+          expectedPayoff: "Open the dropped packet and expose who planted it",
+          payoffTiming: "near-term",
+          notes: "The packet has been foregrounded for two chapters already.",
+        },
+        {
+          hookId: "seal-crack",
+          startChapter: 4,
+          type: "artifact",
+          status: "progressing",
+          lastAdvancedChapter: 7,
+          expectedPayoff: "Reveal what the cracked seal is hiding",
+          payoffTiming: "immediate",
+          notes: "The cracked seal should pay off in the current local sequence.",
+        },
+        {
+          hookId: "witness-turn",
+          startChapter: 5,
+          type: "relationship",
+          status: "progressing",
+          lastAdvancedChapter: 7,
+          expectedPayoff: "Force the silent witness to choose a side",
+          payoffTiming: "near-term",
+          notes: "The witness line is ready for a concrete turn now.",
+        },
+      ] 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);
+  });
+
+  it("spreads default must-advance coverage across pressured hook families", () => {
+    const agenda = memoryRetrieval.buildPlannerHookAgenda({
+      chapterNumber: 15,
+      targetChapters: 30,
+      hooks: [
+        {
+          hookId: "mentor-oath-a",
+          startChapter: 1,
+          type: "relationship",
+          status: "open",
+          lastAdvancedChapter: 2,
+          expectedPayoff: "Explain the first layer of the mentor oath debt",
+          payoffTiming: "mid-arc",
+          notes: "Old relationship debt keeps surfacing without movement.",
+        },
+        {
+          hookId: "mentor-oath-b",
+          startChapter: 2,
+          type: "relationship",
+          status: "open",
+          lastAdvancedChapter: 3,
+          expectedPayoff: "Show what the second oath witness is hiding",
+          payoffTiming: "mid-arc",
+          notes: "Another branch of the same relationship family is also stale.",
+        },
+        {
+          hookId: "mentor-oath-c",
+          startChapter: 3,
+          type: "relationship",
+          status: "open",
+          lastAdvancedChapter: 4,
+          expectedPayoff: "Reveal why the oath cannot be spoken aloud",
+          payoffTiming: "mid-arc",
+          notes: "The third relationship branch is still hanging.",
+        },
+        {
+          hookId: "kiln-key",
+          startChapter: 4,
+          type: "artifact",
+          status: "open",
+          lastAdvancedChapter: 5,
+          expectedPayoff: "Show what the kiln key unlocks",
+          payoffTiming: "mid-arc",
+          notes: "Artifact debt is also stale and should not vanish behind relationship debt.",
+        },
+      ] as never,
+    } as never);
+
+    expect(agenda.mustAdvance).toContain("kiln-key");
+    expect(agenda.mustAdvance).toEqual(expect.arrayContaining([
+      expect.stringMatching(/^mentor-oath-/),
+    ]));
+  });
 });

+ 179 - 13
packages/core/src/utils/memory-retrieval.ts

@@ -245,7 +245,8 @@ export function buildPlannerHookAgenda(params: {
       targetChapters: params.targetChapters,
     }),
   }));
-  const staleDebtHooks = lifecycleEntries
+  const agendaLoad = resolveHookAgendaLoad(lifecycleEntries);
+  const staleDebtCandidates = lifecycleEntries
     .filter((entry) => entry.lifecycle.stale)
     .sort((left, right) => (
       Number(right.lifecycle.overdue) - Number(left.lifecycle.overdue)
@@ -253,10 +254,18 @@ export function buildPlannerHookAgenda(params: {
       || left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter
       || left.hook.startChapter - right.hook.startChapter
       || left.hook.hookId.localeCompare(right.hook.hookId)
-    ))
-    .slice(0, params.maxStaleDebt ?? 2)
-    .map((entry) => entry.hook);
-  const mustAdvanceHooks = lifecycleEntries
+    ));
+  const staleDebtHooks = selectAgendaHooksWithTypeSpread({
+    entries: staleDebtCandidates,
+    limit: resolveAgendaLimit({
+      explicitLimit: params.maxStaleDebt,
+      candidateCount: staleDebtCandidates.length,
+      fallbackLimit: ADAPTIVE_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)
@@ -264,24 +273,38 @@ export function buildPlannerHookAgenda(params: {
       || left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter
       || left.hook.startChapter - right.hook.startChapter
       || left.hook.hookId.localeCompare(right.hook.hookId)
-    ))
-    .slice(0, params.maxMustAdvance ?? 2)
-    .map((entry) => entry.hook);
-  const eligibleResolveHooks = lifecycleEntries
+    ));
+  const mustAdvanceHooks = selectAgendaHooksWithTypeSpread({
+    entries: mustAdvanceCandidates,
+    limit: resolveAgendaLimit({
+      explicitLimit: params.maxMustAdvance,
+      candidateCount: mustAdvanceCandidates.length,
+      fallbackLimit: ADAPTIVE_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)
-    ))
-    .slice(0, params.maxEligibleResolve ?? 1)
-    .map((entry) => entry.hook);
+    ));
+  const eligibleResolveHooks = selectAgendaHooksWithTypeSpread({
+    entries: eligibleResolveCandidates,
+    limit: resolveAgendaLimit({
+      explicitLimit: params.maxEligibleResolve,
+      candidateCount: eligibleResolveCandidates.length,
+      fallbackLimit: ADAPTIVE_HOOK_AGENDA_LIMITS[agendaLoad].eligibleResolve,
+    }),
+    forceInclude: (entry) => entry.lifecycle.overdue || entry.lifecycle.resolvePressure >= 40,
+  }).map((entry) => entry.hook);
   const avoidNewHookFamilies = [...new Set([
     ...staleDebtHooks.map((hook) => hook.type.trim()).filter(Boolean),
     ...mustAdvanceHooks.map((hook) => hook.type.trim()).filter(Boolean),
     ...eligibleResolveHooks.map((hook) => hook.type.trim()).filter(Boolean),
-  ])].slice(0, 3);
+  ])].slice(0, ADAPTIVE_HOOK_AGENDA_LIMITS[agendaLoad].avoidFamilies);
   const pressureMap = buildHookPressureMap({
     lifecycleEntries,
     mustAdvanceHooks,
@@ -298,6 +321,149 @@ export function buildPlannerHookAgenda(params: {
   };
 }
 
+type HookAgendaLoad = "light" | "medium" | "heavy";
+
+const ADAPTIVE_HOOK_AGENDA_LIMITS: Record<HookAgendaLoad, {
+  readonly staleDebt: number;
+  readonly mustAdvance: number;
+  readonly eligibleResolve: number;
+  readonly avoidFamilies: number;
+}> = {
+  light: {
+    staleDebt: 1,
+    mustAdvance: 2,
+    eligibleResolve: 1,
+    avoidFamilies: 2,
+  },
+  medium: {
+    staleDebt: 2,
+    mustAdvance: 2,
+    eligibleResolve: 1,
+    avoidFamilies: 3,
+  },
+  heavy: {
+    staleDebt: 3,
+    mustAdvance: 3,
+    eligibleResolve: 2,
+    avoidFamilies: 4,
+  },
+};
+
+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 >= 40,
+  ).length;
+  const pressuredFamilies = new Set(
+    pressuredEntries.map((entry) => normalizeHookType(entry.hook.type)),
+  ).size;
+
+  if (readyCount >= 3 || staleCount >= 4 || criticalCount >= 3 || pressuredEntries.length >= 6) {
+    return "heavy";
+  }
+  if (readyCount >= 2 || staleCount >= 2 || criticalCount >= 1 || pressuredFamilies >= 3) {
+    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: ReturnType<typeof normalizeStoredHook>;
+  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 isMustAdvanceCandidate(
+  lifecycle: ReturnType<typeof describeHookLifecycle>,
+): boolean {
+  return lifecycle.stale
+    || lifecycle.readyToResolve
+    || lifecycle.overdue
+    || lifecycle.advancePressure >= 8;
+}
+
 function buildHookPressureMap(params: {
   readonly lifecycleEntries: ReadonlyArray<{
     readonly hook: ReturnType<typeof normalizeStoredHook>;