|
@@ -1,10 +1,12 @@
|
|
|
import type { AuditIssue } from "../agents/continuity.js";
|
|
import type { AuditIssue } from "../agents/continuity.js";
|
|
|
import type { HookRecord, RuntimeStateDelta } from "../models/runtime-state.js";
|
|
import type { HookRecord, RuntimeStateDelta } from "../models/runtime-state.js";
|
|
|
import { classifyHookDisposition, collectStaleHookDebt } from "./hook-governance.js";
|
|
import { classifyHookDisposition, collectStaleHookDebt } from "./hook-governance.js";
|
|
|
|
|
+import { describeHookLifecycle, localizeHookPayoffTiming } from "./hook-lifecycle.js";
|
|
|
|
|
|
|
|
export function analyzeHookHealth(params: {
|
|
export function analyzeHookHealth(params: {
|
|
|
readonly language: "zh" | "en";
|
|
readonly language: "zh" | "en";
|
|
|
readonly chapterNumber: number;
|
|
readonly chapterNumber: number;
|
|
|
|
|
+ readonly targetChapters?: number;
|
|
|
readonly hooks: ReadonlyArray<HookRecord>;
|
|
readonly hooks: ReadonlyArray<HookRecord>;
|
|
|
readonly delta?: Pick<RuntimeStateDelta, "chapter" | "hookOps">;
|
|
readonly delta?: Pick<RuntimeStateDelta, "chapter" | "hookOps">;
|
|
|
readonly existingHookIds?: ReadonlyArray<string>;
|
|
readonly existingHookIds?: ReadonlyArray<string>;
|
|
@@ -20,6 +22,19 @@ export function analyzeHookHealth(params: {
|
|
|
const issues: AuditIssue[] = [];
|
|
const issues: AuditIssue[] = [];
|
|
|
|
|
|
|
|
const activeHooks = params.hooks.filter((hook) => hook.status !== "resolved");
|
|
const activeHooks = params.hooks.filter((hook) => hook.status !== "resolved");
|
|
|
|
|
+ const lifecycleEntries = activeHooks.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,
|
|
|
|
|
+ }),
|
|
|
|
|
+ }));
|
|
|
|
|
|
|
|
if (activeHooks.length > maxActiveHooks) {
|
|
if (activeHooks.length > maxActiveHooks) {
|
|
|
issues.push(warning(
|
|
issues.push(warning(
|
|
@@ -33,45 +48,58 @@ export function analyzeHookHealth(params: {
|
|
|
));
|
|
));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const latestRealAdvance = activeHooks.reduce(
|
|
|
|
|
- (max, hook) => Math.max(max, hook.lastAdvancedChapter),
|
|
|
|
|
- 0,
|
|
|
|
|
|
|
+ const staleHookIds = new Set(collectStaleHookDebt({
|
|
|
|
|
+ hooks: activeHooks,
|
|
|
|
|
+ chapterNumber: params.chapterNumber,
|
|
|
|
|
+ targetChapters: params.targetChapters,
|
|
|
|
|
+ staleAfterChapters,
|
|
|
|
|
+ }).map((hook) => hook.hookId));
|
|
|
|
|
+ const pressuredHooks = lifecycleEntries.filter(({ hook, lifecycle }) =>
|
|
|
|
|
+ staleHookIds.has(hook.hookId)
|
|
|
|
|
+ || lifecycle.readyToResolve
|
|
|
|
|
+ || lifecycle.overdue,
|
|
|
);
|
|
);
|
|
|
- if (activeHooks.length > 0 && params.chapterNumber - latestRealAdvance >= noAdvanceWindow) {
|
|
|
|
|
|
|
+ const unresolvedPressure = pressuredHooks.filter(({ hook }) => {
|
|
|
|
|
+ if (!params.delta) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const disposition = classifyHookDisposition({
|
|
|
|
|
+ hookId: hook.hookId,
|
|
|
|
|
+ delta: params.delta,
|
|
|
|
|
+ });
|
|
|
|
|
+ return disposition === "none" || disposition === "mention";
|
|
|
|
|
+ });
|
|
|
|
|
+ if (unresolvedPressure.length > 0) {
|
|
|
issues.push(warning(
|
|
issues.push(warning(
|
|
|
params.language,
|
|
params.language,
|
|
|
|
|
+ buildPressureDescription({
|
|
|
|
|
+ language: params.language,
|
|
|
|
|
+ entries: unresolvedPressure,
|
|
|
|
|
+ mentionsCurrentChapter: Boolean(params.delta),
|
|
|
|
|
+ }),
|
|
|
params.language === "en"
|
|
params.language === "en"
|
|
|
- ? `No real hook advancement has landed for ${params.chapterNumber - latestRealAdvance} chapters.`
|
|
|
|
|
- : `已经连续 ${params.chapterNumber - latestRealAdvance} 章没有真实伏笔推进。`,
|
|
|
|
|
- params.language === "en"
|
|
|
|
|
- ? "Schedule one old hook for real movement instead of opening parallel restatements."
|
|
|
|
|
- : "下一章优先让一个旧伏笔发生真实推进,而不是继续平行重述。",
|
|
|
|
|
|
|
+ ? "Move one pressured hook with a real payoff, escalation, or explicit defer before opening adjacent debt."
|
|
|
|
|
+ : "先让一个已进入压力区的伏笔发生真实推进、回收或明确延后,再继续扩展同类债务。",
|
|
|
));
|
|
));
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const staleHooks = collectStaleHookDebt({
|
|
|
|
|
- hooks: activeHooks,
|
|
|
|
|
- chapterNumber: params.chapterNumber,
|
|
|
|
|
- staleAfterChapters,
|
|
|
|
|
- });
|
|
|
|
|
- if (params.delta && staleHooks.length > 0) {
|
|
|
|
|
- const untouchedStale = staleHooks.filter((hook) => {
|
|
|
|
|
- const disposition = classifyHookDisposition({
|
|
|
|
|
- hookId: hook.hookId,
|
|
|
|
|
- delta: params.delta!,
|
|
|
|
|
- });
|
|
|
|
|
- return disposition === "none" || disposition === "mention";
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- if (untouchedStale.length > 0) {
|
|
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const latestRealAdvance = activeHooks.reduce(
|
|
|
|
|
+ (max, hook) => Math.max(max, hook.lastAdvancedChapter),
|
|
|
|
|
+ 0,
|
|
|
|
|
+ );
|
|
|
|
|
+ if (
|
|
|
|
|
+ params.noAdvanceWindow !== undefined
|
|
|
|
|
+ && activeHooks.length > 0
|
|
|
|
|
+ && params.chapterNumber - latestRealAdvance >= noAdvanceWindow
|
|
|
|
|
+ ) {
|
|
|
issues.push(warning(
|
|
issues.push(warning(
|
|
|
params.language,
|
|
params.language,
|
|
|
params.language === "en"
|
|
params.language === "en"
|
|
|
- ? `Stale hooks received no real disposition this chapter: ${untouchedStale.map((hook) => hook.hookId).join(", ")}.`
|
|
|
|
|
- : `本章没有真正处理这些陈旧伏笔:${untouchedStale.map((hook) => hook.hookId).join("、")}。`,
|
|
|
|
|
|
|
+ ? `No real hook advancement has landed for ${params.chapterNumber - latestRealAdvance} chapters.`
|
|
|
|
|
+ : `已经连续 ${params.chapterNumber - latestRealAdvance} 章没有真实伏笔推进。`,
|
|
|
params.language === "en"
|
|
params.language === "en"
|
|
|
- ? "Advance, resolve, or explicitly defer at least one stale hook."
|
|
|
|
|
- : "至少推进、回收或明确延后一个陈旧伏笔。",
|
|
|
|
|
|
|
+ ? "Schedule one old hook for real movement instead of opening parallel restatements."
|
|
|
|
|
+ : "下一章优先让一个旧伏笔发生真实推进,而不是继续平行重述。",
|
|
|
));
|
|
));
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -99,6 +127,53 @@ export function analyzeHookHealth(params: {
|
|
|
return issues;
|
|
return issues;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function buildPressureDescription(params: {
|
|
|
|
|
+ readonly language: "zh" | "en";
|
|
|
|
|
+ readonly entries: ReadonlyArray<{
|
|
|
|
|
+ readonly hook: HookRecord;
|
|
|
|
|
+ readonly lifecycle: ReturnType<typeof describeHookLifecycle>;
|
|
|
|
|
+ }>;
|
|
|
|
|
+ readonly mentionsCurrentChapter: boolean;
|
|
|
|
|
+}): string {
|
|
|
|
|
+ const summarized = params.entries
|
|
|
|
|
+ .slice(0, 3)
|
|
|
|
|
+ .map(({ hook, lifecycle }) => {
|
|
|
|
|
+ const timing = localizeHookPayoffTiming(lifecycle.timing, params.language);
|
|
|
|
|
+ const pressure = localizePressureLabel(lifecycle, params.language);
|
|
|
|
|
+ return params.language === "en"
|
|
|
|
|
+ ? `${hook.hookId} (${timing}, ${pressure})`
|
|
|
|
|
+ : `${hook.hookId}(${timing},${pressure})`;
|
|
|
|
|
+ });
|
|
|
|
|
+ const suffix = params.entries.length > summarized.length
|
|
|
|
|
+ ? params.language === "en"
|
|
|
|
|
+ ? `, +${params.entries.length - summarized.length} more`
|
|
|
|
|
+ : `,另有 ${params.entries.length - summarized.length} 条`
|
|
|
|
|
+ : "";
|
|
|
|
|
+
|
|
|
|
|
+ if (params.language === "en") {
|
|
|
|
|
+ return params.mentionsCurrentChapter
|
|
|
|
|
+ ? `Hooks are already under payoff pressure but this chapter left them untouched: ${summarized.join(", ")}${suffix}.`
|
|
|
|
|
+ : `Hooks are already under payoff pressure without recent movement: ${summarized.join(", ")}${suffix}.`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return params.mentionsCurrentChapter
|
|
|
|
|
+ ? `这些伏笔已经进入回收/推进压力,但本章没有真正处理:${summarized.join("、")}${suffix}。`
|
|
|
|
|
+ : `这些伏笔已经进入回收/推进压力,但近期没有真实推进:${summarized.join("、")}${suffix}。`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function localizePressureLabel(
|
|
|
|
|
+ lifecycle: ReturnType<typeof describeHookLifecycle>,
|
|
|
|
|
+ language: "zh" | "en",
|
|
|
|
|
+): string {
|
|
|
|
|
+ if (lifecycle.overdue) {
|
|
|
|
|
+ return language === "en" ? "overdue" : "已逾期";
|
|
|
|
|
+ }
|
|
|
|
|
+ if (lifecycle.readyToResolve) {
|
|
|
|
|
+ return language === "en" ? "ready to pay off" : "可回收";
|
|
|
|
|
+ }
|
|
|
|
|
+ return language === "en" ? "stale" : "陈旧";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function warning(
|
|
function warning(
|
|
|
language: "zh" | "en",
|
|
language: "zh" | "en",
|
|
|
description: string,
|
|
description: string,
|