Преглед на файлове

feat(v13): stale and blocked hook detection based on distance and deps [phase 7/8 part 4]

New pure util `packages/core/src/utils/hook-stale-detection.ts`:

  computeHookDiagnostics({ hooks, currentChapter }) ->
    Map<hookId, { stale, blocked, missingUpstream, distance, halfLife }>

Rules:
- stale   = !resolved AND planted AND (currentChapter - startChapter) > halfLife
- blocked = !resolved AND any depends_on target is unplanted or unresolved
- halfLife derives from hook.halfLifeChapters, falling back to the payoff_timing
  defaults defined in hook-promotion (immediate/near-term = 10, mid-arc = 30,
  slow-burn/endgame = 80).

renderHooksProjection now takes an optional { currentChapter } — when given,
stale / blocked markers are appended to the status cell, e.g.:
  open (过期 (距=22/半衰=10); 受阻于 H003)

buildRuntimeStateArtifacts passes resolvedDelta.chapter through, so every
post-chapter pending_hooks.md write carries the diagnostic tags that the
reviewer dimension consumes in part 5.
Ma преди 6 месеца
родител
ревизия
e528518efc
променени са 3 файла, в които са добавени 162 реда и са изтрити 16 реда
  1. 4 1
      packages/core/src/state/runtime-state-store.ts
  2. 34 15
      packages/core/src/state/state-projections.ts
  3. 124 0
      packages/core/src/utils/hook-stale-detection.ts

+ 4 - 1
packages/core/src/state/runtime-state-store.ts

@@ -75,7 +75,10 @@ export async function buildRuntimeStateArtifacts(params: {
     snapshot: next,
     resolvedDelta,
     currentStateMarkdown: renderCurrentStateProjection(next.currentState, params.language),
-    hooksMarkdown: renderHooksProjection(next.hooks, params.language),
+    // Pass the chapter number so the projection can tag stale / blocked hooks.
+    hooksMarkdown: renderHooksProjection(next.hooks, params.language, {
+      currentChapter: resolvedDelta.chapter,
+    }),
     chapterSummariesMarkdown: renderChapterSummariesProjection(next.chapterSummaries, params.language),
   };
 }

+ 34 - 15
packages/core/src/state/state-projections.ts

@@ -7,14 +7,21 @@ import {
   localizeHookPayoffTiming,
   resolveHookPayoffTiming,
 } from "../utils/hook-lifecycle.js";
+import {
+  computeHookDiagnostics,
+  renderHookDiagnosticMarker,
+} from "../utils/hook-stale-detection.js";
 
 export function renderHooksProjection(
   state: HooksState,
   language: "zh" | "en" = "zh",
+  options?: { readonly currentChapter?: number },
 ): string {
   const title = language === "en" ? "# Pending Hooks" : "# 伏笔池";
   // Phase 7: depends_on / pays_off_in_arc / core_hook are visible columns,
   // so writer and reviewer both see the causal chain and planned payoff arc.
+  // stale / blocked diagnostic flags are appended to the status cell, so the
+  // table width stays fixed at 11 columns.
   const headers = language === "en"
     ? [
       "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | depends_on | pays_off_in_arc | core_hook | notes |",
@@ -25,27 +32,39 @@ export function renderHooksProjection(
       "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
     ];
 
+  const currentChapter = options?.currentChapter;
+  const diagnostics = typeof currentChapter === "number"
+    ? computeHookDiagnostics({ hooks: state.hooks, currentChapter })
+    : null;
+
   const rows = [...state.hooks]
     .sort((left, right) => (
       left.startChapter - right.startChapter
       || left.lastAdvancedChapter - right.lastAdvancedChapter
       || left.hookId.localeCompare(right.hookId)
     ))
-    .map((hook) => `| ${
-      [
-        hook.hookId,
-        hook.startChapter,
-        hook.type,
-        hook.status,
-        hook.lastAdvancedChapter,
-        hook.expectedPayoff,
-        localizeHookPayoffTiming(resolveHookPayoffTiming(hook), language),
-        renderDependsOnCell(hook.dependsOn ?? [], language),
-        hook.paysOffInArc ?? "",
-        renderCoreHookCell(hook.coreHook === true, language),
-        hook.notes,
-      ].map(escapeTableCell).join(" | ")
-    } |`);
+    .map((hook) => {
+      const diag = diagnostics?.get(hook.hookId);
+      const marker = diag ? renderHookDiagnosticMarker(diag, language) : "";
+      const statusCell = marker
+        ? `${hook.status} (${marker})`
+        : hook.status;
+      return `| ${
+        [
+          hook.hookId,
+          hook.startChapter,
+          hook.type,
+          statusCell,
+          hook.lastAdvancedChapter,
+          hook.expectedPayoff,
+          localizeHookPayoffTiming(resolveHookPayoffTiming(hook), language),
+          renderDependsOnCell(hook.dependsOn ?? [], language),
+          hook.paysOffInArc ?? "",
+          renderCoreHookCell(hook.coreHook === true, language),
+          hook.notes,
+        ].map(escapeTableCell).join(" | ")
+      } |`;
+    });
 
   return [title, "", ...headers, ...rows, ""].join("\n");
 }

+ 124 - 0
packages/core/src/utils/hook-stale-detection.ts

@@ -0,0 +1,124 @@
+/**
+ * Phase 7 — stale / blocked hook detection.
+ *
+ * Runs per-chapter (called from the writer's runtime-state projection
+ * pipeline when it emits pending_hooks.md). Tags hooks the reviewer and
+ * writer-planner should pay attention to:
+ *
+ *   stale   — planted long ago (distance > half_life) and still not
+ *             marked paid-off / resolved.
+ *   blocked — depends_on references upstream hooks that are still
+ *             unplanted or unresolved.
+ *
+ * The detection is pure: given the current hook list + current chapter
+ * number, it returns a map of hook id -> diagnostic flags. Nothing is
+ * persisted outside the rendered markdown.
+ */
+
+import type { StoredHook } from "../state/memory-db.js";
+import type { HookRecord } from "../models/runtime-state.js";
+import { resolveHalfLifeChapters } from "./hook-promotion.js";
+
+export interface HookDiagnostics {
+  readonly stale: boolean;
+  readonly blocked: boolean;
+  readonly missingUpstream: ReadonlyArray<string>;
+  readonly distance: number;
+  readonly halfLife: number;
+}
+
+type HookLike = StoredHook | HookRecord;
+
+const RESOLVED_STATUSES: ReadonlyArray<RegExp> = [
+  /^resolved$/i,
+  /^closed$/i,
+  /^done$/i,
+  /^已回收$/,
+  /^已解决$/,
+];
+
+function isResolved(hook: HookLike): boolean {
+  const status = (hook.status ?? "").trim();
+  return RESOLVED_STATUSES.some((pattern) => pattern.test(status));
+}
+
+export function computeHookDiagnostics(params: {
+  readonly hooks: ReadonlyArray<HookLike>;
+  readonly currentChapter: number;
+}): Map<string, HookDiagnostics> {
+  const { hooks, currentChapter } = params;
+  const byId = new Map<string, HookLike>();
+  for (const hook of hooks) {
+    byId.set(hook.hookId, hook);
+  }
+
+  const result = new Map<string, HookDiagnostics>();
+
+  for (const hook of hooks) {
+    const halfLife = resolveHalfLifeChapters(hook as StoredHook);
+    const plantedChapter = Math.max(0, hook.startChapter);
+    const distance = Math.max(0, currentChapter - plantedChapter);
+
+    // Stale: past half-life AND not resolved AND actually planted
+    // (startChapter > 0 — a seed with startChapter 0 is a pre-planting seed).
+    const stale = !isResolved(hook)
+      && plantedChapter > 0
+      && distance > halfLife;
+
+    // Blocked: depends_on references a hook that is unplanted or unresolved.
+    const missingUpstream: string[] = [];
+    for (const upstreamId of hook.dependsOn ?? []) {
+      const upstream = byId.get(upstreamId);
+      if (!upstream) {
+        missingUpstream.push(upstreamId);
+        continue;
+      }
+      // Upstream is "planted but not delivered" if startChapter is non-zero
+      // AND it's not resolved. An upstream that is still a seed (startChapter
+      // 0) counts as unplanted.
+      const upstreamResolved = isResolved(upstream);
+      const upstreamPlanted = upstream.startChapter > 0
+        && upstream.startChapter <= currentChapter;
+      if (!upstreamPlanted || !upstreamResolved) {
+        // We only block when the upstream genuinely has not cleared its gate.
+        // For a pure "must be planted first" relationship, "planted but not
+        // resolved" still counts as blocking the downstream from firing.
+        missingUpstream.push(upstreamId);
+      }
+    }
+    const blocked = missingUpstream.length > 0 && !isResolved(hook);
+
+    result.set(hook.hookId, {
+      stale,
+      blocked,
+      missingUpstream,
+      distance,
+      halfLife,
+    });
+  }
+
+  return result;
+}
+
+/**
+ * Render the diagnostic flags as a compact marker string suitable for
+ * appending to an existing table cell. Empty when nothing is flagged.
+ */
+export function renderHookDiagnosticMarker(
+  diagnostics: HookDiagnostics,
+  language: "zh" | "en",
+): string {
+  const tokens: string[] = [];
+  if (diagnostics.stale) {
+    tokens.push(language === "en"
+      ? `stale (d=${diagnostics.distance}/half=${diagnostics.halfLife})`
+      : `过期 (距=${diagnostics.distance}/半衰=${diagnostics.halfLife})`);
+  }
+  if (diagnostics.blocked) {
+    const missing = diagnostics.missingUpstream.join(", ");
+    tokens.push(language === "en"
+      ? `blocked on ${missing}`
+      : `受阻于 ${missing}`);
+  }
+  return tokens.join("; ");
+}