Jelajahi Sumber

fix(v13): compute blocked distance for reviewer threshold evaluation [phase 7 hotfix 3]

The reviewer's hook-debt prompt threshold is '5/6+ chapters blocked →
warning', but hook-stale-detection only emitted the upstream id list
('受阻于 H01'), not the distance. The reviewer had to guess whether the
threshold was met.

Extend HookDiagnostics with blockedDistance. Algorithm:
- For each unresolved upstream, pick a reference chapter:
  * upstream planted & unresolved → upstream.startChapter
  * upstream missing or still a seed → hook.startChapter (blocked since
    this hook's own planting)
- blockedDistance = currentChapter - min(reference chapters)

Marker format becomes '受阻于 H01 (已阻 7 章)' / 'blocked on H01
(blocked 7 chapters)'. Reviewer prompt (already updated in hotfix 2 to
reference this literal token) can now read the count directly.

Pre-existing marker tests updated to include blockedDistance in the
diagnostic literal and cover the new token format.
Ma 6 bulan lalu
induk
melakukan
d52c982a3d

+ 6 - 2
packages/core/src/__tests__/hook-stale-detection.test.ts

@@ -103,18 +103,21 @@ describe("computeHookDiagnostics — Phase 7 stale / blocked detection", () => {
   });
   });
 
 
   it("renderHookDiagnosticMarker formats zh / en markers correctly", () => {
   it("renderHookDiagnosticMarker formats zh / en markers correctly", () => {
+    // Hotfix 3: marker now embeds blocked distance (已阻 N 章 / blocked N chapters)
+    // so the reviewer can apply the 5/6-chapter threshold directly.
     const diag = {
     const diag = {
       stale: true,
       stale: true,
       blocked: true,
       blocked: true,
       missingUpstream: ["H-up"],
       missingUpstream: ["H-up"],
       distance: 20,
       distance: 20,
       halfLife: 10,
       halfLife: 10,
+      blockedDistance: 7,
     } as const;
     } as const;
     expect(renderHookDiagnosticMarker(diag, "zh")).toBe(
     expect(renderHookDiagnosticMarker(diag, "zh")).toBe(
-      "过期 (距=20/半衰=10); 受阻于 H-up",
+      "过期 (距=20/半衰=10); 受阻于 H-up (已阻 7 章)",
     );
     );
     expect(renderHookDiagnosticMarker(diag, "en")).toBe(
     expect(renderHookDiagnosticMarker(diag, "en")).toBe(
-      "stale (d=20/half=10); blocked on H-up",
+      "stale (d=20/half=10); blocked on H-up (blocked 7 chapters)",
     );
     );
   });
   });
 
 
@@ -125,6 +128,7 @@ describe("computeHookDiagnostics — Phase 7 stale / blocked detection", () => {
       missingUpstream: [],
       missingUpstream: [],
       distance: 1,
       distance: 1,
       halfLife: 10,
       halfLife: 10,
+      blockedDistance: 0,
     } as const;
     } as const;
     expect(renderHookDiagnosticMarker(diag, "zh")).toBe("");
     expect(renderHookDiagnosticMarker(diag, "zh")).toBe("");
     expect(renderHookDiagnosticMarker(diag, "en")).toBe("");
     expect(renderHookDiagnosticMarker(diag, "en")).toBe("");

+ 46 - 2
packages/core/src/utils/hook-stale-detection.ts

@@ -25,6 +25,20 @@ export interface HookDiagnostics {
   readonly missingUpstream: ReadonlyArray<string>;
   readonly missingUpstream: ReadonlyArray<string>;
   readonly distance: number;
   readonly distance: number;
   readonly halfLife: number;
   readonly halfLife: number;
+  /**
+   * Phase 7 hotfix 3 — number of chapters this hook has been blocked.
+   *
+   * When blocked=false this is 0. When blocked=true it is computed as:
+   *   max over unresolved upstream of (currentChapter - referenceChapter)
+   * where referenceChapter is:
+   *   - upstream.startChapter, if upstream is planted (startChapter > 0)
+   *   - hook.startChapter, if upstream is missing or not yet planted
+   *     (blocked since this hook's own planting)
+   *
+   * Reviewer reads this as the "blocked Y chapters" token for threshold
+   * evaluation (5/6+ chapters → warning).
+   */
+  readonly blockedDistance: number;
 }
 }
 
 
 type HookLike = StoredHook | HookRecord;
 type HookLike = StoredHook | HookRecord;
@@ -66,11 +80,17 @@ export function computeHookDiagnostics(params: {
       && distance > halfLife;
       && distance > halfLife;
 
 
     // Blocked: depends_on references a hook that is unplanted or unresolved.
     // Blocked: depends_on references a hook that is unplanted or unresolved.
+    // Hotfix 3: also track per-upstream reference chapter so we can compute
+    // how long this hook has been blocked (for reviewer threshold eval).
     const missingUpstream: string[] = [];
     const missingUpstream: string[] = [];
+    const upstreamReferenceChapters: number[] = [];
     for (const upstreamId of hook.dependsOn ?? []) {
     for (const upstreamId of hook.dependsOn ?? []) {
       const upstream = byId.get(upstreamId);
       const upstream = byId.get(upstreamId);
       if (!upstream) {
       if (!upstream) {
         missingUpstream.push(upstreamId);
         missingUpstream.push(upstreamId);
+        // Upstream missing from ledger entirely — we've been blocked since
+        // our own planting (or since chapter 0 if we're a pre-planting seed).
+        upstreamReferenceChapters.push(plantedChapter);
         continue;
         continue;
       }
       }
       // Upstream is "planted but not delivered" if startChapter is non-zero
       // Upstream is "planted but not delivered" if startChapter is non-zero
@@ -84,16 +104,32 @@ export function computeHookDiagnostics(params: {
         // For a pure "must be planted first" relationship, "planted but not
         // For a pure "must be planted first" relationship, "planted but not
         // resolved" still counts as blocking the downstream from firing.
         // resolved" still counts as blocking the downstream from firing.
         missingUpstream.push(upstreamId);
         missingUpstream.push(upstreamId);
+        // Blocked since upstream planting (if planted) or since our own
+        // planting (if upstream still unplanted).
+        const referenceChapter = upstreamPlanted
+          ? upstream.startChapter
+          : plantedChapter;
+        upstreamReferenceChapters.push(referenceChapter);
       }
       }
     }
     }
     const blocked = missingUpstream.length > 0 && !isResolved(hook);
     const blocked = missingUpstream.length > 0 && !isResolved(hook);
 
 
+    // Blocked distance = chapters elapsed since the *earliest* reference
+    // chapter among unresolved upstream. Picking the min reference yields the
+    // max distance — the longest any of these gates has held us up.
+    let blockedDistance = 0;
+    if (blocked && upstreamReferenceChapters.length > 0) {
+      const earliestReference = Math.min(...upstreamReferenceChapters);
+      blockedDistance = Math.max(0, currentChapter - earliestReference);
+    }
+
     result.set(hook.hookId, {
     result.set(hook.hookId, {
       stale,
       stale,
       blocked,
       blocked,
       missingUpstream,
       missingUpstream,
       distance,
       distance,
       halfLife,
       halfLife,
+      blockedDistance,
     });
     });
   }
   }
 
 
@@ -116,9 +152,17 @@ export function renderHookDiagnosticMarker(
   }
   }
   if (diagnostics.blocked) {
   if (diagnostics.blocked) {
     const missing = diagnostics.missingUpstream.join(", ");
     const missing = diagnostics.missingUpstream.join(", ");
+    // Hotfix 3: embed the blocked distance so reviewer can apply its 5/6-chapter
+    // threshold without guessing. Token format is load-bearing — it's read by
+    // the reviewer prompt verbatim.
+    const distanceToken = diagnostics.blockedDistance > 0
+      ? (language === "en"
+        ? ` (blocked ${diagnostics.blockedDistance} chapters)`
+        : ` (已阻 ${diagnostics.blockedDistance} 章)`)
+      : "";
     tokens.push(language === "en"
     tokens.push(language === "en"
-      ? `blocked on ${missing}`
-      : `受阻于 ${missing}`);
+      ? `blocked on ${missing}${distanceToken}`
+      : `受阻于 ${missing}${distanceToken}`);
   }
   }
   return tokens.join("; ");
   return tokens.join("; ");
 }
 }