Просмотр исходного кода

feat(pipeline): add structured hook pressure map

Ma 7 месяцев назад
Родитель
Сommit
6b12293dd5

+ 14 - 0
packages/core/src/__tests__/composer.test.ts

@@ -73,6 +73,7 @@ describe("ComposerAgent", () => {
           },
         ],
         hookAgenda: {
+          pressureMap: [],
           mustAdvance: [],
           eligibleResolve: [],
           staleDebt: [],
@@ -506,6 +507,16 @@ describe("ComposerAgent", () => {
           chapter: 10,
           goal: "Bring the focus back to the mentor oath conflict.",
           hookAgenda: {
+            pressureMap: [{
+              hookId: "mentor-oath",
+              type: "relationship",
+              payoffTiming: "slow-burn",
+              phase: "middle",
+              pressure: "high",
+              movement: "partial-payoff",
+              reason: "stale-promise",
+              blockSiblingHooks: true,
+            }],
             mustAdvance: ["mentor-oath"],
             eligibleResolve: [],
             staleDebt: [],
@@ -518,6 +529,9 @@ describe("ComposerAgent", () => {
     const hookDebtEntry = result.contextPackage.selectedContext.find((entry) => entry.source === "runtime/hook_debt#mentor-oath");
     expect(hookDebtEntry).toBeDefined();
     expect(hookDebtEntry?.excerpt).toContain("mentor-oath");
+    expect(hookDebtEntry?.excerpt).toContain("动作: 局部兑现");
+    expect(hookDebtEntry?.excerpt).toContain("压力: 高");
+    expect(hookDebtEntry?.excerpt).toContain("抑制同类开坑: 是");
     expect(hookDebtEntry?.excerpt).toContain("River Camp");
     expect(hookDebtEntry?.excerpt).toContain("Trial Echo");
   });

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

@@ -1142,5 +1142,23 @@ describe("parsePendingHooksMarkdown", () => {
     expect(agenda.mustAdvance).toContain("slow-oath");
     expect(agenda.eligibleResolve).toContain("ready-packet");
     expect(agenda.eligibleResolve).not.toContain("slow-oath");
+    expect(agenda.pressureMap).toEqual(expect.arrayContaining([
+      expect.objectContaining({
+        hookId: "slow-oath",
+        movement: "advance",
+        pressure: "medium",
+        type: "relationship",
+        payoffTiming: "slow-burn",
+        reason: "building-debt",
+      }),
+      expect.objectContaining({
+        hookId: "ready-packet",
+        movement: "full-payoff",
+        pressure: "high",
+        type: "mystery",
+        payoffTiming: "near-term",
+        reason: "ripe-payoff",
+      }),
+    ]));
   });
 });

+ 24 - 0
packages/core/src/__tests__/models.test.ts

@@ -511,6 +511,18 @@ describe("ChapterIntentSchema", () => {
         },
       ],
       hookAgenda: {
+        pressureMap: [
+          {
+            hookId: "H019",
+            type: "relationship",
+            payoffTiming: "slow-burn",
+            phase: "middle",
+            pressure: "high",
+            movement: "partial-payoff",
+            reason: "stale-promise",
+            blockSiblingHooks: true,
+          },
+        ],
         mustAdvance: ["H019"],
         eligibleResolve: ["H045"],
         staleDebt: ["H023", "H027"],
@@ -528,6 +540,17 @@ describe("ChapterIntentSchema", () => {
     expect(result.moodDirective).toContain("Release pressure");
     expect(result.titleDirective).toContain("ledger title");
     expect(result.conflicts).toHaveLength(1);
+    expect(result.hookAgenda.pressureMap).toEqual([
+      expect.objectContaining({
+        hookId: "H019",
+        type: "relationship",
+        phase: "middle",
+        movement: "partial-payoff",
+        pressure: "high",
+        payoffTiming: "slow-burn",
+        reason: "stale-promise",
+      }),
+    ]);
     expect(result.hookAgenda.mustAdvance).toEqual(["H019"]);
     expect(result.hookAgenda.eligibleResolve).toEqual(["H045"]);
     expect(result.hookAgenda.staleDebt).toEqual(["H023", "H027"]);
@@ -551,6 +574,7 @@ describe("ChapterIntentSchema", () => {
     expect(result.mustAvoid).toEqual([]);
     expect(result.styleEmphasis).toEqual([]);
     expect(result.conflicts).toEqual([]);
+    expect(result.hookAgenda.pressureMap).toEqual([]);
     expect(result.hookAgenda.mustAdvance).toEqual([]);
     expect(result.hookAgenda.eligibleResolve).toEqual([]);
     expect(result.hookAgenda.staleDebt).toEqual([]);

+ 31 - 0
packages/core/src/__tests__/planner.test.ts

@@ -1207,9 +1207,26 @@ describe("PlannerAgent", () => {
     expect(result.intent.hookAgenda.eligibleResolve).toEqual(["ready-payoff"]);
     expect(result.intent.hookAgenda.staleDebt).toEqual(["stale-debt"]);
     expect(result.intent.hookAgenda.avoidNewHookFamilies).toContain("relationship");
+    expect(result.intent.hookAgenda.pressureMap).toEqual(expect.arrayContaining([
+      expect.objectContaining({
+        hookId: "ready-payoff",
+        movement: "full-payoff",
+        pressure: "critical",
+        type: "mystery",
+        reason: "overdue-payoff",
+      }),
+      expect.objectContaining({
+        hookId: "stale-debt",
+        movement: "advance",
+        pressure: "critical",
+        type: "relationship",
+        reason: "stale-promise",
+      }),
+    ]));
 
     const intentMarkdown = await readFile(result.runtimePath, "utf-8");
     expect(intentMarkdown).toContain("## Hook Agenda");
+    expect(intentMarkdown).toContain("### Pressure Map");
     expect(intentMarkdown).toContain("recent-route");
     expect(intentMarkdown).toContain("ready-payoff");
     expect(intentMarkdown).toContain("stale-debt");
@@ -1327,5 +1344,19 @@ describe("PlannerAgent", () => {
       "relationship",
       "mystery",
     ]));
+    expect(result.intent.hookAgenda.pressureMap).toEqual(expect.arrayContaining([
+      expect.objectContaining({
+        hookId: "stale-omega",
+        movement: "advance",
+        pressure: "critical",
+        reason: "stale-promise",
+      }),
+      expect.objectContaining({
+        hookId: "stale-sable",
+        movement: "advance",
+        pressure: "critical",
+        reason: "stale-promise",
+      }),
+    ]));
   });
 });

+ 91 - 8
packages/core/src/agents/composer.ts

@@ -260,11 +260,14 @@ export class ComposerAgent extends BaseAgent {
     }>,
     language: "zh" | "en",
   ): Promise<ContextPackage["selectedContext"]> {
-    const targetHookIds = [...new Set([
-      ...plan.intent.hookAgenda.eligibleResolve,
-      ...plan.intent.hookAgenda.mustAdvance,
-      ...plan.intent.hookAgenda.staleDebt,
-    ])];
+    const targetHookIds = [
+      ...new Set([
+        ...plan.intent.hookAgenda.pressureMap.map((entry) => entry.hookId),
+        ...plan.intent.hookAgenda.eligibleResolve,
+        ...plan.intent.hookAgenda.mustAdvance,
+        ...plan.intent.hookAgenda.staleDebt,
+      ]),
+    ];
     if (targetHookIds.length === 0) {
       return [];
     }
@@ -282,7 +285,14 @@ export class ComposerAgent extends BaseAgent {
       const seedSummary = this.findHookSummary(summaries, hook.hookId, hook.startChapter, "seed");
       const latestSummary = this.findHookSummary(summaries, hook.hookId, hook.lastAdvancedChapter, "latest");
       const cadence = localizeHookPayoffTiming(resolveHookPayoffTiming(hook), language);
+      const guidance = this.findHookPressure(plan, hook.hookId);
       const role = this.describeHookAgendaRole(plan, hook.hookId, language);
+      const movement = guidance
+        ? this.describeHookMovement(guidance.movement, language)
+        : role;
+      const pressure = guidance
+        ? this.describeHookPressure(guidance.pressure, language)
+        : (language === "en" ? "medium" : "中");
       const promise = hook.expectedPayoff || (language === "en" ? "(unspecified)" : "(未写明)");
       const seedBeat = seedSummary
         ? this.renderHookDebtBeat(seedSummary)
@@ -290,6 +300,14 @@ export class ComposerAgent extends BaseAgent {
       const latestBeat = latestSummary
         ? this.renderHookDebtBeat(latestSummary)
         : (hook.notes || promise);
+      const reason = guidance
+        ? this.describeHookReason(guidance.reason, language)
+        : (language === "en"
+            ? "Keep the original promise legible and materially change the on-page situation."
+            : "保持原始承诺清晰可见,并让页上局势发生实质变化。");
+      const suppressSiblingHooks = guidance?.blockSiblingHooks
+        ? (language === "en" ? "yes" : "是")
+        : (language === "en" ? "no" : "否");
 
       return [{
         source: `runtime/hook_debt#${hook.hookId}`,
@@ -297,8 +315,8 @@ export class ComposerAgent extends BaseAgent {
           ? "Narrative debt brief for an explicit hook agenda target."
           : "显式 hook agenda 目标的叙事债务简报。",
         excerpt: language === "en"
-          ? `${hook.hookId} | role: ${role} | cadence: ${cadence} | promise: ${promise} | seed: ${seedBeat} | latest: ${latestBeat}`
-          : `${hook.hookId} | 角色: ${role} | 节奏: ${cadence} | 承诺: ${promise} | 种子: ${seedBeat} | 最近推进: ${latestBeat}`,
+          ? `${hook.hookId} | role: ${role} | movement: ${movement} | pressure: ${pressure} | cadence: ${cadence} | suppress siblings: ${suppressSiblingHooks} | reason: ${reason} | promise: ${promise} | seed: ${seedBeat} | latest: ${latestBeat}`
+          : `${hook.hookId} | 角色: ${role} | 动作: ${movement} | 压力: ${pressure} | 节奏: ${cadence} | 抑制同类开坑: ${suppressSiblingHooks} | 原因: ${reason} | 承诺: ${promise} | 种子: ${seedBeat} | 最近推进: ${latestBeat}`,
       }];
     });
   }
@@ -354,7 +372,7 @@ export class ComposerAgent extends BaseAgent {
     language: "zh" | "en",
   ): string {
     if (plan.intent.hookAgenda.eligibleResolve.includes(hookId)) {
-      return language === "en" ? "payoff candidate" : "本章优先兑现";
+      return language === "en" ? "payoff candidate" : "优先兑现";
     }
     if (plan.intent.hookAgenda.staleDebt.includes(hookId)) {
       return language === "en" ? "stale debt" : "高压旧债";
@@ -362,6 +380,71 @@ export class ComposerAgent extends BaseAgent {
     return language === "en" ? "must advance" : "本章必须推进";
   }
 
+  private findHookPressure(
+    plan: PlanChapterOutput,
+    hookId: string,
+  ): PlanChapterOutput["intent"]["hookAgenda"]["pressureMap"][number] | undefined {
+    return plan.intent.hookAgenda.pressureMap.find((entry) => entry.hookId === hookId);
+  }
+
+  private describeHookMovement(
+    movement: PlanChapterOutput["intent"]["hookAgenda"]["pressureMap"][number]["movement"],
+    language: "zh" | "en",
+  ): string {
+    if (language === "en") {
+      return movement.replace(/-/g, " ");
+    }
+
+    return {
+      "quiet-hold": "轻压保温",
+      refresh: "重新点亮",
+      advance: "推进",
+      "partial-payoff": "局部兑现",
+      "full-payoff": "完整兑现",
+    }[movement];
+  }
+
+  private describeHookPressure(
+    pressure: PlanChapterOutput["intent"]["hookAgenda"]["pressureMap"][number]["pressure"],
+    language: "zh" | "en",
+  ): string {
+    if (language === "en") {
+      return pressure;
+    }
+
+    return {
+      low: "低",
+      medium: "中",
+      high: "高",
+      critical: "极高",
+    }[pressure];
+  }
+
+  private describeHookReason(
+    reason: PlanChapterOutput["intent"]["hookAgenda"]["pressureMap"][number]["reason"],
+    language: "zh" | "en",
+  ): string {
+    if (language === "en") {
+      return {
+        "fresh-promise": "fresh promise",
+        "building-debt": "building debt",
+        "stale-promise": "stale promise",
+        "ripe-payoff": "ripe payoff",
+        "overdue-payoff": "overdue payoff",
+        "long-arc-hold": "long arc hold",
+      }[reason];
+    }
+
+    return {
+      "fresh-promise": "新近承诺,先保持清晰存在",
+      "building-debt": "债务正在累积,需要继续加码",
+      "stale-promise": "旧承诺已停滞,需要重新推动或缩圈",
+      "ripe-payoff": "已经进入可兑现窗口",
+      "overdue-payoff": "已经拖过理想兑现窗口",
+      "long-arc-hold": "长线承诺仍应保温,不宜提前兑付",
+    }[reason];
+  }
+
   private findHookSummary(
     summaries: ReadonlyArray<ReturnType<typeof parseChapterSummariesMarkdown>[number]>,
     hookId: string,

+ 24 - 0
packages/core/src/agents/planner.ts

@@ -84,6 +84,7 @@ export class PlannerAgent extends BaseAgent {
       hooks: memorySelection.activeHooks,
       chapterNumber: input.chapterNumber,
       targetChapters: input.book.targetChapters,
+      language: input.book.language ?? "zh",
     });
     const directives = this.buildStructuredDirectives({
       chapterNumber: input.chapterNumber,
@@ -109,6 +110,7 @@ export class PlannerAgent extends BaseAgent {
     const runtimePath = join(runtimeDir, `chapter-${String(input.chapterNumber).padStart(4, "0")}.intent.md`);
     const intentMarkdown = this.renderIntentMarkdown(
       intent,
+      input.book.language ?? "zh",
       renderHookSnapshot(memorySelection.hooks, input.book.language ?? "zh"),
       renderSummarySnapshot(memorySelection.summaries, input.book.language ?? "zh"),
     );
@@ -658,6 +660,7 @@ export class PlannerAgent extends BaseAgent {
 
   private renderIntentMarkdown(
     intent: ChapterIntent,
+    language: "zh" | "en",
     pendingHooks: string,
     chapterSummaries: string,
   ): string {
@@ -682,7 +685,28 @@ export class PlannerAgent extends BaseAgent {
       intent.moodDirective ? `- mood: ${intent.moodDirective}` : undefined,
       intent.titleDirective ? `- title: ${intent.titleDirective}` : undefined,
     ].filter(Boolean).join("\n") || "- none";
+    const pressureMap = intent.hookAgenda.pressureMap.length > 0
+      ? [
+          "| hook_id | type | payoff_timing | phase | pressure | movement | reason | sibling_guard |",
+          "| --- | --- | --- | --- | --- | --- | --- | --- |",
+          ...intent.hookAgenda.pressureMap
+            .map((item) => [
+              item.hookId,
+              item.type,
+              item.payoffTiming ?? "unspecified",
+              item.phase,
+              item.pressure,
+              item.movement,
+              item.reason,
+              item.blockSiblingHooks ? "yes" : "no",
+            ].join(" | "))
+            .map((row) => `| ${row} |`),
+        ].join("\n")
+      : "- none";
     const hookAgenda = [
+      "### Pressure Map",
+      pressureMap,
+      "",
       "### Must Advance",
       intent.hookAgenda.mustAdvance.length > 0
         ? intent.hookAgenda.mustAdvance.map((item) => `- ${item}`).join("\n")

+ 4 - 0
packages/core/src/agents/writer-prompts.ts

@@ -106,8 +106,10 @@ function buildGovernedInputContract(language: "zh" | "en", governed: boolean): s
 - Keep hard guardrails compact: canon, continuity facts, and explicit prohibitions still win.
 - If an English Variance Brief is provided, obey it: avoid the listed phrase/opening/ending patterns and satisfy the scene obligation.
 - If Hook Debt Briefs are provided, treat them as the active memory of what the reader is still owed: preserve the original promise and change the on-page situation.
+- If the explicit hook agenda includes a pressure map, follow the requested move for each target: full-payoff means concrete payoff, partial-payoff means a meaningful intermediate reveal, advance/refresh mean material movement, and quiet-hold means keep the promise visible without cashing it out early.
 - When the explicit hook agenda names an eligible resolve target, land a concrete payoff beat instead of merely mentioning the old thread.
 - When stale debt is present, do not open sibling hooks casually; clear pressure from old promises before minting fresh debt.
+- When a hook brief says to suppress sibling hooks, do not fake progress by opening a parallel hook of the same family.
 - In multi-character scenes, include at least one resistance-bearing exchange instead of reducing the beat to summary or explanation.`;
   }
 
@@ -119,8 +121,10 @@ function buildGovernedInputContract(language: "zh" | "en", governed: boolean): s
 - 真正不能突破的只有硬护栏:世界设定、连续性事实、显式禁令。
 - 如果提供了 English Variance Brief,必须主动避开其中列出的高频短语、重复开头和重复结尾模式,并完成 scene obligation。
 - 如果提供了 Hook Debt 简报,把它当成读者仍在等待兑现的承诺记忆:保留原始 promise,并让本章在页上发生真实变化。
+- 如果显式 hook agenda 里带有 pressure map,逐条执行其中要求的动作:full-payoff 就是具体兑现,partial-payoff 就是给出中间层级的揭示或缩圈,advance / refresh 就是有分量的推进,quiet-hold 就是让承诺继续可见但不要过早消耗。
 - 如果显式 hook agenda 里出现了可回收目标,本章必须写出具体兑现片段,不能只是重新提一句旧线索。
 - 如果存在 stale debt,先消化旧承诺的压力,再决定是否开新坑;同类 sibling hook 不得随手再开。
+- 如果某条 hook 简报明确要求 suppress sibling hooks,就不能用再开一个同类平行坑来假装推进。
 - 多角色场景里,至少给出一轮带阻力的直接交锋,不要把人物关系写成纯解释或纯总结。`;
 }
 

+ 6 - 0
packages/core/src/index.ts

@@ -38,6 +38,9 @@ export {
 } from "./models/runtime-state.js";
 export {
   type ChapterConflict,
+  type HookMovement,
+  type HookPressureLevel,
+  type HookPressure,
   type ChapterIntent,
   type ContextSource,
   type ContextPackage,
@@ -49,6 +52,9 @@ export {
   type RuleStack,
   type ChapterTrace,
   ChapterConflictSchema,
+  HookMovementSchema,
+  HookPressureLevelSchema,
+  HookPressureSchema,
   ChapterIntentSchema,
   ContextSourceSchema,
   ContextPackageSchema,

+ 41 - 0
packages/core/src/models/input-governance.ts

@@ -1,4 +1,5 @@
 import { z } from "zod";
+import { HookPayoffTimingSchema } from "./runtime-state.js";
 
 export const ChapterConflictSchema = z.object({
   type: z.string().min(1),
@@ -8,7 +9,46 @@ export const ChapterConflictSchema = z.object({
 
 export type ChapterConflict = z.infer<typeof ChapterConflictSchema>;
 
+export const HookPressurePhaseSchema = z.enum(["opening", "middle", "late"]);
+export type HookPressurePhase = z.infer<typeof HookPressurePhaseSchema>;
+
+export const HookMovementSchema = z.enum([
+  "quiet-hold",
+  "refresh",
+  "advance",
+  "partial-payoff",
+  "full-payoff",
+]);
+export type HookMovement = z.infer<typeof HookMovementSchema>;
+
+export const HookPressureLevelSchema = z.enum(["low", "medium", "high", "critical"]);
+export type HookPressureLevel = z.infer<typeof HookPressureLevelSchema>;
+
+export const HookPressureReasonSchema = z.enum([
+  "fresh-promise",
+  "building-debt",
+  "stale-promise",
+  "ripe-payoff",
+  "overdue-payoff",
+  "long-arc-hold",
+]);
+export type HookPressureReason = z.infer<typeof HookPressureReasonSchema>;
+
+export const HookPressureSchema = z.object({
+  hookId: z.string().min(1),
+  type: z.string().min(1),
+  movement: HookMovementSchema,
+  pressure: HookPressureLevelSchema,
+  payoffTiming: HookPayoffTimingSchema.optional(),
+  phase: HookPressurePhaseSchema,
+  reason: HookPressureReasonSchema,
+  blockSiblingHooks: z.boolean().default(false),
+});
+
+export type HookPressure = z.infer<typeof HookPressureSchema>;
+
 export const HookAgendaSchema = z.object({
+  pressureMap: z.array(HookPressureSchema).default([]),
   mustAdvance: z.array(z.string().min(1)).default([]),
   eligibleResolve: z.array(z.string().min(1)).default([]),
   staleDebt: z.array(z.string().min(1)).default([]),
@@ -30,6 +70,7 @@ export const ChapterIntentSchema = z.object({
   styleEmphasis: z.array(z.string()).default([]),
   conflicts: z.array(ChapterConflictSchema).default([]),
   hookAgenda: HookAgendaSchema.default({
+    pressureMap: [],
     mustAdvance: [],
     eligibleResolve: [],
     staleDebt: [],

+ 131 - 1
packages/core/src/utils/memory-retrieval.ts

@@ -1,6 +1,6 @@
 import { readFile } from "node:fs/promises";
 import { join } from "node:path";
-import type { HookAgenda } from "../models/input-governance.js";
+import type { HookAgenda, HookPressure } from "../models/input-governance.js";
 import {
   ChapterSummariesStateSchema,
   CurrentStateStateSchema,
@@ -223,6 +223,7 @@ 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;
@@ -281,8 +282,15 @@ export function buildPlannerHookAgenda(params: {
     ...mustAdvanceHooks.map((hook) => hook.type.trim()).filter(Boolean),
     ...eligibleResolveHooks.map((hook) => hook.type.trim()).filter(Boolean),
   ])].slice(0, 3);
+  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),
@@ -290,6 +298,128 @@ export function buildPlannerHookAgenda(params: {
   };
 }
 
+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 <= 2 && params.lifecycle.dormancy <= 1) {
+    return "quiet-hold";
+  }
+
+  if (params.lifecycle.dormancy >= 2) {
+    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 <= 1) {
+    return "fresh-promise";
+  }
+  return "building-debt";
+}
+
 function openMemoryDB(bookDir: string): MemoryDB | null {
   try {
     return new MemoryDB(bookDir);