Kaynağa Gözat

feat(hooks): lifecycle-aware agenda scheduling

Rewrote buildPlannerHookAgenda to use describeHookLifecycle instead
of simple stalest-first sorting. The timing profiles defined in
hook-policy.ts now actually drive planner scheduling:

- eligibleResolve: uses lifecycle.readyToResolve (timing-aware:
  immediate hooks ready after 1 chapter, slow-burn waits for middle
  phase, endgame waits for late phase)
- staleDebt: uses lifecycle.stale || lifecycle.overdue (immediate
  goes stale after 1 chapter dormancy, slow-burn after 5)
- mustAdvance: sorted by advancePressure descending (combines age,
  dormancy, stale/overdue bonuses)
- avoidNewHookFamilies: derived from stale/overdue families only
- Agenda load (light/medium/heavy) from hook-policy.ts now controls
  slot limits dynamically

This connects the "long vs short hook" capability that was already
defined in policy/lifecycle layers to the actual planner agenda.

All 516 tests pass.
Ma 7 ay önce
ebeveyn
işleme
7ebd723917

+ 8 - 2
packages/core/src/__tests__/hook-agenda.test.ts

@@ -19,7 +19,7 @@ function createHook(overrides: Partial<StoredHook> = {}): StoredHook {
 }
 
 describe("hook-agenda", () => {
-  it("builds agenda with stalest-first sorting and chapter-window filtering", () => {
+  it("builds agenda with lifecycle-aware scheduling and chapter-window filtering", () => {
     const staleSlowBurn = createHook({
       hookId: "mentor-oath",
       startChapter: 4,
@@ -43,7 +43,13 @@ describe("hook-agenda", () => {
       language: "en",
     });
 
-    expect(agenda.mustAdvance).toContain("mentor-oath");
+    // Both hooks are stale at chapter 12: slow-burn dormancy=5 meets threshold,
+    // near-term dormancy=2 meets threshold and is overdue (age 10 >= 5).
+    // Neither is readyToResolve (slow-burn needs late phase or overdue; near-term
+    // lacks momentum). Both land in staleDebt sorted by advancePressure.
+    expect(agenda.staleDebt).toContain("ledger-fragment");
+    expect(agenda.staleDebt).toContain("mentor-oath");
+    expect(agenda.eligibleResolve).toEqual([]);
 
     expect(isHookWithinChapterWindow(staleSlowBurn, 12, 5)).toBe(true);
     expect(isHookWithinChapterWindow(readyMystery, 12, 5)).toBe(true);

+ 19 - 10
packages/core/src/__tests__/memory-retrieval.test.ts

@@ -1211,7 +1211,7 @@ describe("parsePendingHooksMarkdown", () => {
     ]);
   });
 
-  it("sorts must-advance by stalest-first and resolve by earliest-started", () => {
+  it("places near-term progressing hooks in eligible resolve and slow-burn in must-advance", () => {
     const agenda = memoryRetrieval.buildPlannerHookAgenda({
       chapterNumber: 18,
       hooks: [
@@ -1241,14 +1241,14 @@ describe("parsePendingHooksMarkdown", () => {
       targetChapters: 40,
     } as never);
 
-    expect(agenda.mustAdvance).toContain("slow-oath");
-    expect(agenda.mustAdvance).toContain("ready-packet");
-    expect(agenda.eligibleResolve).toContain("slow-oath");
+    // near-term progressing hook with recent touch is readyToResolve → eligibleResolve
     expect(agenda.eligibleResolve).toContain("ready-packet");
+    // slow-burn in middle phase (not late, not overdue) is not readyToResolve → mustAdvance
+    expect(agenda.mustAdvance).toContain("slow-oath");
     expect(agenda.pressureMap).toEqual([]);
   });
 
-  it("limits eligible resolve to default max of 1 when not overridden", () => {
+  it("scales eligible resolve slots with agenda load level", () => {
     const agenda = memoryRetrieval.buildPlannerHookAgenda({
       chapterNumber: 8,
       targetChapters: 12,
@@ -1286,11 +1286,13 @@ describe("parsePendingHooksMarkdown", () => {
       ] as never,
     } as never);
 
-    expect(agenda.eligibleResolve.length).toBe(1);
+    // All three hooks are readyToResolve (progressing + recentlyTouched),
+    // pushing readyCount to 3 which triggers heavy load (eligibleResolve=2).
+    expect(agenda.eligibleResolve.length).toBe(2);
     expect(agenda.pressureMap).toEqual([]);
   });
 
-  it("picks stalest hooks for must-advance regardless of type family", () => {
+  it("routes all stale/overdue hooks to staleDebt by advancePressure, with remainder in mustAdvance", () => {
     const agenda = memoryRetrieval.buildPlannerHookAgenda({
       chapterNumber: 15,
       targetChapters: 30,
@@ -1338,9 +1340,16 @@ describe("parsePendingHooksMarkdown", () => {
       ] as never,
     } as never);
 
-    expect(agenda.mustAdvance).toEqual(["mentor-oath-a", "mentor-oath-b"]);
-    expect(agenda.mustAdvance).toEqual(expect.arrayContaining([
-      expect.stringMatching(/^mentor-oath-/),
+    // All four are stale+overdue (mid-arc, dormancy >> 4, age >> 8).
+    // Heavy load (staleCount=4): staleDebt limit=3 captures highest advancePressure.
+    // The fourth-highest (kiln-key) lands in mustAdvance.
+    expect(agenda.staleDebt).toEqual(["mentor-oath-a", "mentor-oath-b", "mentor-oath-c"]);
+    expect(agenda.mustAdvance).toEqual(["kiln-key"]);
+    expect(agenda.eligibleResolve).toEqual([]);
+    // avoidNewHookFamilies includes both stale families
+    expect(agenda.avoidNewHookFamilies).toEqual(expect.arrayContaining([
+      "relationship",
+      "artifact",
     ]));
   });
 });

+ 6 - 2
packages/core/src/__tests__/planner.test.ts

@@ -1203,9 +1203,11 @@ describe("PlannerAgent", () => {
       externalContext: "Keep the chapter on the mainline debt conflict.",
     });
 
-    expect(result.intent.hookAgenda.mustAdvance).toEqual(["stale-debt", "ready-payoff"]);
+    // Lifecycle scheduling: ready-payoff (progressing, mid-arc, overdue) → eligibleResolve;
+    // stale-debt (dormant, stale+overdue) → staleDebt; recent-route (remainder) → mustAdvance.
     expect(result.intent.hookAgenda.eligibleResolve).toEqual(["ready-payoff"]);
     expect(result.intent.hookAgenda.staleDebt).toEqual(["stale-debt"]);
+    expect(result.intent.hookAgenda.mustAdvance).toEqual(["recent-route"]);
     expect(result.intent.hookAgenda.avoidNewHookFamilies).toContain("relationship");
     expect(result.intent.hookAgenda.pressureMap).toEqual([]);
 
@@ -1321,8 +1323,10 @@ describe("PlannerAgent", () => {
       externalContext: "Keep the chapter on the route pressure.",
     });
 
-    expect(result.intent.hookAgenda.mustAdvance).toEqual(["stale-omega", "stale-sable"]);
+    // Lifecycle scheduling: stale-omega and stale-sable are stale+overdue → staleDebt;
+    // recent-token and recent-route are not stale/overdue → mustAdvance (by advancePressure).
     expect(result.intent.hookAgenda.staleDebt).toEqual(["stale-omega", "stale-sable"]);
+    expect(result.intent.hookAgenda.mustAdvance).toEqual(["recent-token", "recent-route"]);
     expect(result.intent.hookAgenda.avoidNewHookFamilies).toEqual(expect.arrayContaining([
       "relationship",
       "mystery",

+ 86 - 47
packages/core/src/utils/hook-agenda.ts

@@ -1,14 +1,17 @@
 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 { resolveHookPayoffTiming } from "./hook-lifecycle.js";
+import { describeHookLifecycle, resolveHookPayoffTiming } from "./hook-lifecycle.js";
+import { HOOK_AGENDA_LIMITS, HOOK_AGENDA_LOAD_THRESHOLDS, type HookAgendaLoad } from "./hook-policy.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.
+ * Build the hook agenda using lifecycle-aware scheduling.
+ * Uses payoffTiming profiles to differentiate short/long hooks:
+ * - immediate hooks trigger resolve pressure after 1-3 chapters
+ * - slow-burn hooks stay dormant until middle phase
+ * - endgame hooks only resolve in late phase
  */
 export function buildPlannerHookAgenda(params: {
   readonly hooks: ReadonlyArray<StoredHook>;
@@ -24,56 +27,92 @@ export function buildPlannerHookAgenda(params: {
     .filter((hook) => !isFuturePlannedHook(hook, params.chapterNumber, 0))
     .filter((hook) => hook.status !== "resolved" && hook.status !== "deferred");
 
-  // mustAdvance: stalest first (lowest lastAdvancedChapter)
-  const mustAdvanceHooks = agendaHooks
-    .slice()
-    .sort((left, right) => (
-      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) => (
-      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, 3);
+  // Compute lifecycle for each hook
+  const withLifecycle = 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,
+    }),
+  }));
+
+  // Determine agenda load
+  const load = resolveAgendaLoad(withLifecycle);
+  const limits = HOOK_AGENDA_LIMITS[load];
+
+  // eligibleResolve: lifecycle says readyToResolve, sorted by resolvePressure desc
+  const eligibleResolveHooks = withLifecycle
+    .filter((entry) => entry.lifecycle.readyToResolve)
+    .sort((left, right) => right.lifecycle.resolvePressure - left.lifecycle.resolvePressure)
+    .slice(0, params.maxEligibleResolve ?? limits.eligibleResolve);
+
+  // staleDebt: lifecycle says stale or overdue, sorted by advancePressure desc
+  const resolveIds = new Set(eligibleResolveHooks.map((entry) => entry.hook.hookId));
+  const staleDebtHooks = withLifecycle
+    .filter((entry) => (entry.lifecycle.stale || entry.lifecycle.overdue) && !resolveIds.has(entry.hook.hookId))
+    .sort((left, right) => right.lifecycle.advancePressure - left.lifecycle.advancePressure)
+    .slice(0, params.maxStaleDebt ?? limits.staleDebt);
+
+  // mustAdvance: highest advancePressure, excluding those already in resolve/stale
+  const usedIds = new Set([
+    ...resolveIds,
+    ...staleDebtHooks.map((entry) => entry.hook.hookId),
+  ]);
+  const mustAdvanceHooks = withLifecycle
+    .filter((entry) => !usedIds.has(entry.hook.hookId))
+    .sort((left, right) => right.lifecycle.advancePressure - left.lifecycle.advancePressure)
+    .slice(0, params.maxMustAdvance ?? limits.mustAdvance);
+
+  // avoidNewHookFamilies: families with stale/overdue/pressured hooks
+  const pressuredFamilies = withLifecycle
+    .filter((entry) => entry.lifecycle.stale || entry.lifecycle.overdue)
+    .map((entry) => entry.hook.type.trim())
+    .filter(Boolean);
+  const avoidNewHookFamilies = [...new Set(pressuredFamilies)].slice(0, limits.avoidFamilies);
 
   return {
     pressureMap: [],
-    mustAdvance: mustAdvanceHooks.map((hook) => hook.hookId),
-    eligibleResolve: eligibleResolveHooks.map((hook) => hook.hookId),
-    staleDebt: staleDebtHooks.map((hook) => hook.hookId),
+    mustAdvance: mustAdvanceHooks.map((entry) => entry.hook.hookId),
+    eligibleResolve: eligibleResolveHooks.map((entry) => entry.hook.hookId),
+    staleDebt: staleDebtHooks.map((entry) => entry.hook.hookId),
     avoidNewHookFamilies,
   };
 }
 
+function resolveAgendaLoad(
+  entries: ReadonlyArray<{ lifecycle: ReturnType<typeof describeHookLifecycle> }>,
+): HookAgendaLoad {
+  const readyCount = entries.filter((e) => e.lifecycle.readyToResolve).length;
+  const staleCount = entries.filter((e) => e.lifecycle.stale).length;
+  const overdueCount = entries.filter((e) => e.lifecycle.overdue).length;
+  const pressuredCount = entries.filter((e) => e.lifecycle.stale || e.lifecycle.overdue).length;
+
+  if (
+    readyCount >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyReadyCount
+    || staleCount >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyStaleCount
+    || overdueCount >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyCriticalCount
+    || pressuredCount >= HOOK_AGENDA_LOAD_THRESHOLDS.heavyPressuredCount
+  ) {
+    return "heavy";
+  }
+
+  if (
+    readyCount >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumReadyCount
+    || staleCount >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumStaleCount
+    || overdueCount >= HOOK_AGENDA_LOAD_THRESHOLDS.mediumCriticalCount
+  ) {
+    return "medium";
+  }
+
+  return "light";
+}
+
 function normalizeStoredHook(hook: StoredHook): HookRecord {
   return {
     hookId: hook.hookId,