فهرست منبع

fix(v13): buildGovernedRuleStack derives sections+overrides from the plan [phase hotfix 6]

The function ignored both `plan` and `chapterNumber` arguments and emitted a
hardcoded shell with old Phase 4 source names (story_bible, volume_outline)
and an always-empty activeOverrides list. Writer (writer.ts:845),
continuity (continuity.ts:724), and reviser (reviser.ts:618) all render
ruleStack.sections + activeOverrides directly into the model prompt — so
"compiled control input" was telling the model both the wrong source paths
and the wrong overrides for years.

Picked Option A (make dynamic) over Option B (delete) because the rule
stack has three downstream prompt consumers; deletion would require a much
larger prompt-rewiring refactor than fixing the lie at source.

Fix:
- sections.hard now uses Phase 5 names: story_frame, current_state,
  book_rules, roles. sections.soft: author_intent, current_focus, volume_map.
- activeOverrides derived from plan.intent.mustAvoid and
  plan.intent.styleEmphasis. Each override targets
  chapter:<N>/(mustAvoid|styleEmphasis) so the writer/reviser knows it
  applies to the current chapter only — matching the L4→L3 edge declared
  in overrideEdges.
- Override reasons are collapsed-and-truncated to 80 chars to keep the
  prompt block compact.
- composer.test.ts updated to assert the new section names + non-empty
  activeOverrides reflecting plan.intent (one mustAvoid + two styleEmphasis
  in the test fixture).
Ma 6 ماه پیش
والد
کامیت
8afc6f91df
2فایلهای تغییر یافته به همراه68 افزوده شده و 8 حذف شده
  1. 14 3
      packages/core/src/__tests__/composer.test.ts
  2. 54 5
      packages/core/src/utils/context-assembly.ts

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

@@ -133,11 +133,22 @@ describe("ComposerAgent", () => {
       plan,
       plan,
     });
     });
 
 
-    expect(result.ruleStack.sections.hard).toContain("story_bible");
+    // Phase hotfix 6: section names track Phase 5 authoritative paths.
+    expect(result.ruleStack.sections.hard).toContain("story_frame");
+    expect(result.ruleStack.sections.hard).toContain("roles");
     expect(result.ruleStack.sections.soft).toContain("author_intent");
     expect(result.ruleStack.sections.soft).toContain("author_intent");
+    expect(result.ruleStack.sections.soft).toContain("volume_map");
     expect(result.ruleStack.sections.diagnostic).toContain("anti_ai_checks");
     expect(result.ruleStack.sections.diagnostic).toContain("anti_ai_checks");
-    // activeOverrides dropped with ChapterConflict removal (Phase 1 transitional)
-    expect(result.ruleStack.activeOverrides).toEqual([]);
+    // activeOverrides now derived from the plan: 1 mustAvoid + 2 styleEmphasis.
+    expect(result.ruleStack.activeOverrides.length).toBeGreaterThan(0);
+    const reasons = result.ruleStack.activeOverrides.map((o) => o.reason);
+    expect(reasons).toContain("Do not reveal the mastermind.");
+    expect(reasons).toContain("character conflict");
+    expect(reasons).toContain("tight POV");
+    // All overrides target the current chapter.
+    for (const override of result.ruleStack.activeOverrides) {
+      expect(override.target).toContain("chapter:4");
+    }
   });
   });
 
 
   it("writes trace output describing planner inputs and selected sources", async () => {
   it("writes trace output describing planner inputs and selected sources", async () => {

+ 54 - 5
packages/core/src/utils/context-assembly.ts

@@ -1,4 +1,5 @@
 import type {
 import type {
+  ActiveOverride,
   ChapterTrace,
   ChapterTrace,
   ContextPackage,
   ContextPackage,
   RuleStack,
   RuleStack,
@@ -9,9 +10,56 @@ import {
 } from "../models/input-governance.js";
 } from "../models/input-governance.js";
 import type { PlanChapterOutput } from "../agents/planner.js";
 import type { PlanChapterOutput } from "../agents/planner.js";
 
 
+const MAX_OVERRIDE_REASON_CHARS = 80;
+
+function truncateForOverrideReason(value: string): string {
+  const collapsed = value.replace(/\s+/g, " ").trim();
+  return collapsed.length > MAX_OVERRIDE_REASON_CHARS
+    ? `${collapsed.slice(0, MAX_OVERRIDE_REASON_CHARS - 1)}…`
+    : collapsed;
+}
+
+/**
+ * Compose the per-chapter rule stack used by writer / continuity / reviser
+ * prompts. Source names follow the Phase 5 layout (story_frame, volume_map,
+ * roles/) and activeOverrides are derived from the planner's intent so the
+ * "Governed Control Stack" block surfaces the actual gating in effect for
+ * the current chapter — it used to be a static stub that ignored both
+ * `plan` and `chapterNumber`.
+ *
+ * Phase hotfix 6 (Option A): make this honestly dynamic instead of deleting
+ * it, because writer.ts (~L820/L900), continuity.ts (~L590), and
+ * reviser.ts (~L600) all render ruleStack.sections / activeOverrides into
+ * the model prompt. Removing the function would require a much larger
+ * prompt refactor; making it real fixes the lie at the source.
+ */
 export function buildGovernedRuleStack(plan: PlanChapterOutput, chapterNumber: number): RuleStack {
 export function buildGovernedRuleStack(plan: PlanChapterOutput, chapterNumber: number): RuleStack {
-  void plan;
-  void chapterNumber;
+  const activeOverrides: ActiveOverride[] = [];
+
+  // L4 → L3: per-chapter prohibitions narrow the planning layer for this
+  // chapter only. mustAvoid items come from rules-reader prohibitions +
+  // current_focus avoid section (planner.collectMustAvoid).
+  for (const item of plan.intent.mustAvoid) {
+    activeOverrides.push({
+      from: "L4",
+      to: "L3",
+      target: `chapter:${chapterNumber}/mustAvoid`,
+      reason: truncateForOverrideReason(item),
+    });
+  }
+
+  // L4 → L3: planner-issued style emphasis is also a per-chapter override
+  // on the planning layer. Style emphasis surfaces things like POV tightness
+  // or character-conflict focus that the writer must honor this chapter.
+  for (const item of plan.intent.styleEmphasis) {
+    activeOverrides.push({
+      from: "L4",
+      to: "L3",
+      target: `chapter:${chapterNumber}/styleEmphasis`,
+      reason: truncateForOverrideReason(item),
+    });
+  }
+
   return RuleStackSchema.parse({
   return RuleStackSchema.parse({
     layers: [
     layers: [
       { id: "L1", name: "hard_facts", precedence: 100, scope: "global" },
       { id: "L1", name: "hard_facts", precedence: 100, scope: "global" },
@@ -20,8 +68,9 @@ export function buildGovernedRuleStack(plan: PlanChapterOutput, chapterNumber: n
       { id: "L4", name: "current_task", precedence: 70, scope: "local" },
       { id: "L4", name: "current_task", precedence: 70, scope: "local" },
     ],
     ],
     sections: {
     sections: {
-      hard: ["story_bible", "current_state", "book_rules"],
-      soft: ["author_intent", "current_focus", "volume_outline"],
+      // Phase 5 authoritative source names (was: story_bible, volume_outline).
+      hard: ["story_frame", "current_state", "book_rules", "roles"],
+      soft: ["author_intent", "current_focus", "volume_map"],
       diagnostic: ["anti_ai_checks", "continuity_audit", "style_regression_checks"],
       diagnostic: ["anti_ai_checks", "continuity_audit", "style_regression_checks"],
     },
     },
     overrideEdges: [
     overrideEdges: [
@@ -29,7 +78,7 @@ export function buildGovernedRuleStack(plan: PlanChapterOutput, chapterNumber: n
       { from: "L4", to: "L2", allowed: false, scope: "current_chapter" },
       { from: "L4", to: "L2", allowed: false, scope: "current_chapter" },
       { from: "L4", to: "L1", allowed: false, scope: "current_chapter" },
       { from: "L4", to: "L1", allowed: false, scope: "current_chapter" },
     ],
     ],
-    activeOverrides: [],
+    activeOverrides,
   });
   });
 }
 }