diff --git a/src/agent/grok-residual.test.ts b/src/agent/grok-residual.test.ts
new file mode 100644
index 000000000..9a50612e6
--- /dev/null
+++ b/src/agent/grok-residual.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from "bun:test";
+import { GROK_PROMPT_RESIDUAL } from "./model-family-policy.js";
+import {
+ buildGrokLeafAntiThrashNote,
+ buildSubAgentSystemPrompt,
+} from "./prompts.js";
+import { shouldApplyGrokAntiThrash } from "../subagent/provider-family.js";
+
+// The three Grok ceremony lines (CL-7768 Design, merged by CL-8296): no git,
+// no pre-plan, verify once.
+const CEREMONY_LINES = [
+ "- Never run git add, git commit, git stash, or any other state-changing git command unless the user asks.",
+ "- Do not narrate a plan before acting on a small task; act, then report.",
+ "- Verify with the test command once at the end, not after every edit.",
+] as const;
+
+function countOccurrences(haystack: string, needle: string): number {
+ return haystack.split(needle).length - 1;
+}
+
+describe("grok ceremony merge (CL-8296)", () => {
+ it("exposes a single grok residual with each ceremony line exactly once", () => {
+ expect(GROK_PROMPT_RESIDUAL).toContain("Finish bias (xAI / Grok worker):");
+ for (const line of CEREMONY_LINES) {
+ expect(countOccurrences(GROK_PROMPT_RESIDUAL, line)).toBe(1);
+ }
+ });
+
+ it("keeps the don't re-read line exactly once — no duplicate", () => {
+ expect(
+ countOccurrences(GROK_PROMPT_RESIDUAL, "re-open paths you already read"),
+ ).toBe(1);
+ });
+
+ it("is grok-only: the finish-bias gate fires for grok leaves alone", () => {
+ expect(
+ shouldApplyGrokAntiThrash({
+ providerName: "xai/default",
+ model: "grok-4.6",
+ orchestrator: false,
+ }),
+ ).toBe(true);
+ for (const input of [
+ { providerName: "anthropic", model: "claude-sonnet-4" },
+ { providerName: "moonshot", model: "kimi-k2" },
+ { providerName: "opencode-go", model: "muse-spark-1.3-contributor" },
+ { providerName: "openai", model: "gpt-4.1" },
+ ] as const) {
+ expect(shouldApplyGrokAntiThrash({ ...input, orchestrator: false })).toBe(
+ false,
+ );
+ }
+ expect(
+ shouldApplyGrokAntiThrash({
+ providerName: "xai/default",
+ model: "grok-4.6",
+ orchestrator: true,
+ }),
+ ).toBe(false);
+ });
+
+ it("buildGrokLeafAntiThrashNote is the same single residual (one source of truth)", () => {
+ expect(buildGrokLeafAntiThrashNote()).toBe(GROK_PROMPT_RESIDUAL);
+ });
+
+ it("the assembled grok worker prompt carries the merged residual exactly once", () => {
+ const prompt = buildSubAgentSystemPrompt(undefined, undefined, undefined, {
+ orchestrator: false,
+ grokAntiThrash: true,
+ });
+ expect(countOccurrences(prompt, "Finish bias (xAI / Grok worker):")).toBe(
+ 1,
+ );
+ for (const line of CEREMONY_LINES) {
+ expect(countOccurrences(prompt, line)).toBe(1);
+ }
+ });
+});
diff --git a/src/agent/model-family-policy.test.ts b/src/agent/model-family-policy.test.ts
index ed2843717..88eb8d938 100644
--- a/src/agent/model-family-policy.test.ts
+++ b/src/agent/model-family-policy.test.ts
@@ -4,8 +4,8 @@ import { resolveModelFamilyPolicy } from "./model-family-policy.js";
describe("resolveModelFamilyPolicy", () => {
test("defaults are permissive for an unrecognized provider", () => {
const policy = resolveModelFamilyPolicy({
- providerName: "anthropic",
- model: "claude-sonnet-4",
+ providerName: "unknown-provider",
+ model: "unknown-model",
});
expect(policy.family).toBe("default");
expect(policy.applyGrokFinishBias).toBe(false);
@@ -55,8 +55,8 @@ describe("resolveModelFamilyPolicy", () => {
test("advertisedToolDeny is empty by default and never contains use_skill", () => {
const leaf = resolveModelFamilyPolicy({
- providerName: "anthropic",
- model: "claude-opus-4-6",
+ providerName: "unknown-provider",
+ model: "unknown-model",
orchestrator: false,
});
expect(leaf.advertisedToolDeny).toEqual([]);
@@ -102,4 +102,101 @@ describe("resolveModelFamilyPolicy", () => {
expect(muse.toolDisciplineRules).toContain("Never re-read a file");
expect(base.toolDisciplineRules).toBeUndefined();
});
+
+ describe("promptResidual (CL-8297)", () => {
+ test("grok leaf carries the generic 4-line tool-budget residual", () => {
+ const leaf = resolveModelFamilyPolicy({
+ providerName: "xai/default",
+ model: "grok-4.6",
+ });
+ expect(leaf.family).toBe("grok");
+ expect(leaf.promptResidual).toBeDefined();
+ if (!leaf.promptResidual)
+ throw new Error("expected promptResidual to be defined");
+ expect(leaf.promptResidual.split("\n")).toHaveLength(4);
+ expect(leaf.promptResidual).toContain("Tool budget:");
+ });
+
+ test("grok orchestrators and default family carry no residual", () => {
+ const orchestrator = resolveModelFamilyPolicy({
+ providerName: "xai/default",
+ model: "grok-4.6",
+ orchestrator: true,
+ });
+ expect(orchestrator.promptResidual).toBeUndefined();
+ // Default-family probe: anthropic/claude-sonnet-4 hits the claude row
+ // and openai/gpt-4.1 hits the gpt row (#1135), so an unrecognized
+ // provider is the probe that still resolves to the default family.
+ const base = resolveModelFamilyPolicy({
+ providerName: "unknown-provider",
+ model: "unknown-model",
+ });
+ expect(base.family).toBe("default");
+ expect(base.promptResidual).toBeUndefined();
+ });
+ });
+
+ test("claude leaves carry the XML task_guidance residual; orchestrators do not", () => {
+ const leaf = resolveModelFamilyPolicy({
+ providerName: "anthropic",
+ model: "claude-sonnet-4",
+ orchestrator: false,
+ });
+ expect(leaf.family).toBe("claude");
+ expect(leaf.promptResidual).toContain("");
+ expect(leaf.promptResidual).toContain("");
+ expect(leaf.advertisedToolDeny).toEqual([]);
+ const orchestrator = resolveModelFamilyPolicy({
+ providerName: "anthropic",
+ model: "claude-sonnet-4",
+ orchestrator: true,
+ });
+ expect(orchestrator.promptResidual).toBeUndefined();
+ });
+
+ // The gpt family row has landed (#1135): openai/gpt-4.1 and codex/gpt-5.1
+ // resolve to the gpt family with the narrate-before-tools residual, leaf
+ // and orchestrator alike (no carve-out). Grok keeps its CL-8297 tool-budget
+ // residual — the "no residual" claim below is default-family-only.
+ test("gpt probes resolve to gpt with the narrate residual; grok keeps its tool budget", () => {
+ for (const input of [
+ { providerName: "openai", model: "gpt-4.1" },
+ { providerName: "codex", model: "gpt-5.1" },
+ ] as const) {
+ for (const orchestrator of [false, true]) {
+ const policy = resolveModelFamilyPolicy({ ...input, orchestrator });
+ expect(policy.family).toBe("gpt");
+ expect(policy.promptResidual).toContain(
+ "Narrate before tools (GPT worker):",
+ );
+ }
+ }
+ const grok = resolveModelFamilyPolicy({
+ providerName: "xai/default",
+ model: "grok-4.6",
+ orchestrator: false,
+ });
+ expect(grok.family).toBe("grok");
+ expect(grok.promptResidual).toContain("Tool budget:");
+ });
+ test("gpt resolves its own family on permissive default thresholds (CL-8310)", () => {
+ const gpt = resolveModelFamilyPolicy({
+ providerName: "codex/default",
+ model: "gpt-5.5",
+ });
+ const base = resolveModelFamilyPolicy({
+ providerName: "anthropic",
+ model: "claude-sonnet-4",
+ });
+ expect(gpt.family).toBe("gpt");
+ // No eval characterization for gpt tool-only stretches yet: ship the
+ // permissive default, no finish-bias, no discipline rules. The
+ // narrate-before-tools residual is prompt-level (see prompts.ts), not a
+ // threshold.
+ expect(gpt.toolOnlyTurnNudgeAt).toBe(base.toolOnlyTurnNudgeAt);
+ expect(gpt.subAgentStallTimeoutMs).toBe(base.subAgentStallTimeoutMs);
+ expect(gpt.applyGrokFinishBias).toBe(false);
+ expect(gpt.toolDisciplineRules).toBeUndefined();
+ expect(gpt.advertisedToolDeny).toEqual([]);
+ });
});
diff --git a/src/agent/model-family-policy.ts b/src/agent/model-family-policy.ts
index e77057594..870f7ec45 100644
--- a/src/agent/model-family-policy.ts
+++ b/src/agent/model-family-policy.ts
@@ -38,6 +38,15 @@ export interface ModelFamilyPolicy {
* at the tail so it cannot disturb the cached prompt prefix.
*/
toolDisciplineRules?: string;
+ /**
+ * Provider-family residual appended once to the assembled leaf system
+ * prompt (CL-8297). Tool-budget text for grok, the XML task_guidance block
+ * for claude, the narrate-before-tools note for gpt (CL-8310, primary and
+ * leaf alike).
+ * Withheld from orchestrators and appended at the tail so it cannot
+ * disturb the cached prompt prefix. Undefined for families that need none.
+ */
+ promptResidual?: string | undefined;
}
const DEFAULT_WRAP_UP_NUDGE_TEXT =
@@ -58,6 +67,7 @@ const GROK_WRAP_UP_NUDGE_TEXT =
// sits comfortably above the observed healthy ceiling; the nudge is a
// check-in, not a stop, so erring high costs nothing. Tightened only for
// families with observed runaway tool-only behavior (see grok below).
+/** Default policy: permissive, no finish bias, no prompt residual. */
const DEFAULT_POLICY: Omit = {
toolOnlyTurnNudgeAt: 25,
wrapUpNudgeText: DEFAULT_WRAP_UP_NUDGE_TEXT,
@@ -66,6 +76,16 @@ const DEFAULT_POLICY: Omit = {
advertisedToolDeny: [],
};
+// Generic 4-line tool-budget residual (CL-8297). Grok leaves get this via
+// promptResidual today; other families leave the seam unfilled until their
+// own lanes land. Deliberately free of ceremony lines and family-specific
+// routing — pure tool-loop budget.
+export const GROK_TOOL_BUDGET_RESIDUAL =
+ "Tool budget:\n" +
+ "- Batch independent tool calls into a single turn.\n" +
+ "- Never re-issue a tool call whose result you already have.\n" +
+ "- When the next call would only repeat prior work, write the report instead.";
+
// A directly observed 14-turn pure-tool-call session for this family
// previously motivated a tightened nudge/pause pair here (6/10). That pair
// was miscalibrated: it fired on a session that was making real progress
@@ -83,6 +103,8 @@ const GROK_POLICY: Omit = {
applyGrokFinishBias: true,
// Leaf value; the resolver clears it for orchestrators below.
advertisedToolDeny: ["skill_search"],
+ // Leaf value; the resolver clears it for orchestrators below.
+ promptResidual: GROK_TOOL_BUDGET_RESIDUAL,
};
// Kimi (Moonshot) detection ships now so callers can branch on family, but
@@ -109,6 +131,72 @@ const MUSE_POLICY: Omit = {
toolDisciplineRules: MUSE_TOOL_DISCIPLINE_RULES,
};
+// Single grok finish-bias + ceremony residual (CL-8296): the finish-bias
+// bullets plus the three ceremony lines from the CL-7768 design (no git, no
+// pre-plan, verify once), merged into one block with no line twice. The don't
+// re-read idea appears exactly once (the "re-open paths" bullet) — it is not
+// repeated. Grok-only: detectModelFamily has no glm family, so per the <30min
+// rule no GLM row ships here. The text lives here once; exported for
+// buildGrokLeafAntiThrashNote (prompts.ts), which returns it verbatim, so the
+// prompt carries exactly one copy. This is a different block from the CL-8297
+// tool-budget hook (GROK_TOOL_BUDGET_RESIDUAL, surfaced via the
+// ModelFamilyPolicy.promptResidual field): grok leaves carry both, each once.
+export const GROK_PROMPT_RESIDUAL = [
+ "Finish bias (xAI / Grok worker):",
+ "- Once you can answer the dispatch brief, prefer the structured report over another speculative tool call.",
+ "- If the next call would only re-open paths you already read, write the report instead.",
+ "- When the dispatch brief's done-definition is met, write the report envelope instead of making one more search or micro-edit.",
+ "- Route file and web work through the dedicated tools, never run_shell — mining showed grok reaching for shell first when a typed tool already covered the job.",
+ "- Never run git add, git commit, git stash, or any other state-changing git command unless the user asks.",
+ "- Do not narrate a plan before acting on a small task; act, then report.",
+ "- Verify with the test command once at the end, not after every edit.",
+].join("\n");
+// Claude (Anthropic) ships one XML residual, not prose: a prose residual did
+// nothing, but a single block cut Sonnet tokens. The block is
+// the whole residual — never a full-prompt XML renderer. The text lives here
+// (policy owns data); buildClaudeTaskGuidanceNote (prompts.ts) returns it
+// verbatim so the prompt carries exactly one copy.
+export const CLAUDE_TASK_GUIDANCE_NOTE = [
+ "",
+ "Autonomous coding turn: finish the work in this turn on your best judgment.",
+ "1. Follow the dispatch brief exactly; its Success criteria are the done-definition.",
+ "2. Batch independent tool calls into a single turn; work from files already read this session.",
+ "3. Finish the task when the done-definition is met: prefer the structured report envelope over another tool call.",
+ "",
+].join("\n");
+
+const CLAUDE_POLICY: Omit = {
+ ...DEFAULT_POLICY,
+ promptResidual: CLAUDE_TASK_GUIDANCE_NOTE,
+};
+
+// Tiny narrate-before-tools residual for GPT workers (CL-8310): GPT-5.5 runs
+// showed 6–13 silent tool-only turns. Shared thrash harness + spawn contracts
+// do the structural work; this is only a narrate-before-tools nudge.
+// Deliberately not manage_tasks ceremony — that is CL-7769, not this text.
+// The text lives here (policy owns data); buildGptNarrateBeforeToolsNote
+// (prompts.ts) returns it verbatim so the prompt carries exactly one copy.
+// Served cells (astra/sol/terra/…) are never named here — CL-8265
+// characterizes them later.
+export const GPT_NARRATE_BEFORE_TOOLS_NOTE = [
+ "Narrate before tools (GPT worker):",
+ "- Before each tool call, write one short line saying what you are doing and why.",
+ "- Never make back-to-back tool calls with no narration between them.",
+ "- When the dispatch brief's done-definition is met, write the report envelope instead of making another tool call.",
+].join("\n");
+
+// GPT (Codex / gpt-*) thresholds are provisional: we have no eval
+// characterization yet for how GPT behaves under tool-only stretches or
+// background-run stalls. Ship the permissive default rather than guessing at
+// a tightened number; the narrate-before-tools residual is prompt-level (see
+// GPT_NARRATE_BEFORE_TOOLS_NOTE above), not a threshold.
+const GPT_POLICY: Omit = {
+ ...DEFAULT_POLICY,
+ // Primary and leaf alike, so unlike the grok finish-bias there is no
+ // orchestrator carve-out: the resolver below returns this as-is.
+ promptResidual: GPT_NARRATE_BEFORE_TOOLS_NOTE,
+};
+
export function resolveModelFamilyPolicy(input: {
providerName: string;
model?: string;
@@ -126,6 +214,7 @@ export function resolveModelFamilyPolicy(input: {
...policy,
applyGrokFinishBias: policy.applyGrokFinishBias && !orchestrator,
advertisedToolDeny: orchestrator ? [] : policy.advertisedToolDeny,
+ promptResidual: orchestrator ? undefined : policy.promptResidual,
};
}
case "kimi":
@@ -136,6 +225,16 @@ export function resolveModelFamilyPolicy(input: {
};
case "muse":
return { family, ...MUSE_POLICY };
+ case "claude":
+ // Like the grok finish-bias residual, the task_guidance block only makes
+ // sense on leaf workers — orchestrators dispatch rather than doing the
+ // work directly, so they resolve to the permissive default (no residual).
+ return orchestrator
+ ? { family, ...DEFAULT_POLICY }
+ : { family, ...CLAUDE_POLICY };
+ case "gpt":
+ // Primary and leaf alike: no orchestrator carve-out.
+ return { family, ...GPT_POLICY };
default:
return { family: "default", ...DEFAULT_POLICY };
}
diff --git a/src/agent/prompt-sizes.test.ts b/src/agent/prompt-sizes.test.ts
index deef84e8e..719e336ef 100644
--- a/src/agent/prompt-sizes.test.ts
+++ b/src/agent/prompt-sizes.test.ts
@@ -332,3 +332,26 @@ describe("skywalker grok prefix (infer envelope vs trimmed director)", () => {
}
});
});
+
+describe("grok tool-budget residual (CL-8297)", () => {
+ const countOccurrences = (haystack: string, needle: string): number =>
+ haystack.split(needle).length - 1;
+
+ test("a grok leaf director prompt contains the tool budget exactly once", () => {
+ const prompt = assembleDirectorPrompt("builder", "grok");
+ expect(countOccurrences(prompt, "Tool budget:")).toBe(1);
+ });
+
+ test("default-family and orchestrator prompts carry no tool budget", () => {
+ const defaultPrompt = assembleDirectorPrompt("builder", "default");
+ expect(defaultPrompt).not.toContain("Tool budget:");
+ // The default probe resolves to the default family, so the default
+ // column carries no family residual — neither the claude task_guidance
+ // block nor the gpt narrate-before-tools nudge.
+ expect(defaultPrompt).not.toContain("");
+ expect(defaultPrompt).not.toContain("Narrate before tools (GPT worker):");
+ expect(assembleDirectorPrompt("skywalker", "grok")).not.toContain(
+ "Tool budget:",
+ );
+ });
+});
diff --git a/src/agent/prompt-sizes.ts b/src/agent/prompt-sizes.ts
index a9721a554..8a2944656 100644
--- a/src/agent/prompt-sizes.ts
+++ b/src/agent/prompt-sizes.ts
@@ -12,6 +12,7 @@ import {
type DirectorPackage,
} from "./directors/types.js";
import { buildChatSystemPrompt, buildSubAgentSystemPrompt } from "./prompts.js";
+import { resolveModelFamilyPolicy } from "./model-family-policy.js";
import {
formatAgentsMdExtension,
MAX_AGENTS_MD_BYTES,
@@ -35,7 +36,10 @@ import { webSearchDefinition } from "../tools/web-search.js";
* Assembles each director prompt exactly as src/subagent/run.ts does:
* extensions=[director systemPromptRole] + environment + tools +
* appendix, with the Grok finish-bias note gated by
- * shouldApplyGrokAntiThrash (leaves on Grok-family providers only).
+ * shouldApplyGrokAntiThrash (leaves on Grok-family providers only) and the
+ * family promptResidual (CL-8297 tool budget for grok leaves, XML
+ * task_guidance block for claude leaves) resolved from the model family
+ * policy.
*
* The env and provider inputs are pinned here so sizes never drift with the
* machine, date, or checkout — only real prompt changes move the numbers.
@@ -53,9 +57,12 @@ export const CANONICAL_PROMPT_ENV: EnvironmentInfo = {
};
const GROK_PROVIDER = { providerName: "xai/default", model: "grok-4.6" };
+// Default-family probe: an unrecognized provider stays on the default
+// family no matter how many family rows land (claude/gpt already ship),
+// so the default column carries no residual.
const DEFAULT_PROVIDER = {
- providerName: "anthropic",
- model: "claude-sonnet-4",
+ providerName: "unknown-provider",
+ model: "unknown-model",
};
/** Families in the size table: default assembly vs Grok (+finish-bias note). */
@@ -156,6 +163,7 @@ export function assembleDirectorPrompt(
const pkg = DIRECTOR_REGISTRY[directorId];
const orchestrator = pkg.spawn.maySpawn;
const provider = family === "grok" ? GROK_PROVIDER : DEFAULT_PROVIDER;
+ const policy = resolveModelFamilyPolicy({ ...provider, orchestrator });
return buildSubAgentSystemPrompt(
[formatDirectorSystemPrompt(pkg)],
CANONICAL_PROMPT_ENV,
@@ -164,6 +172,7 @@ export function assembleDirectorPrompt(
orchestrator,
toolNames: canonicalToolNamesForDirector(pkg, family),
grokAntiThrash: shouldApplyGrokAntiThrash({ ...provider, orchestrator }),
+ promptResidual: policy.promptResidual,
},
);
}
diff --git a/src/agent/prompts.test.ts b/src/agent/prompts.test.ts
index bc36b8d0e..a9061fa80 100644
--- a/src/agent/prompts.test.ts
+++ b/src/agent/prompts.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, it } from "bun:test";
import {
buildChatSystemPrompt,
+ buildClaudeTaskGuidanceNote,
+ buildGptNarrateBeforeToolsNote,
buildGrokLeafAntiThrashNote,
buildGuidelines,
buildPromptDisciplineBlock,
@@ -370,3 +372,170 @@ describe("grok finish-bias residual gating (extends existing provider-family tes
expect(prompt.toLowerCase()).not.toContain("kimi");
});
});
+
+describe("promptResidual assembly (CL-8297)", () => {
+ const TOOL_BUDGET =
+ "Tool budget:\n" +
+ "- Batch independent tool calls into a single turn.\n" +
+ "- Never re-issue a tool call whose result you already have.\n" +
+ "- When the next call would only repeat prior work, write the report instead.";
+
+ it("appends promptResidual exactly once at the tail for a grok leaf", () => {
+ const prompt = buildSubAgentSystemPrompt(undefined, undefined, undefined, {
+ orchestrator: false,
+ grokAntiThrash: true,
+ promptResidual: TOOL_BUDGET,
+ });
+ expect(countOccurrences(prompt, TOOL_BUDGET)).toBe(1);
+ expect(prompt.trimEnd().endsWith(TOOL_BUDGET)).toBe(true);
+ });
+
+ it("omits the tool budget when promptResidual is unset", () => {
+ const prompt = buildSubAgentSystemPrompt(undefined, undefined, undefined, {
+ orchestrator: false,
+ grokAntiThrash: true,
+ });
+ expect(prompt).not.toContain("Tool budget:");
+ });
+});
+
+describe("claude XML task_guidance residual (provider residual, not a prompt fork)", () => {
+ it("appends exactly one balanced block for a claude worker", () => {
+ const prompt = buildSubAgentSystemPrompt(undefined, undefined, undefined, {
+ orchestrator: false,
+ promptResidual: buildClaudeTaskGuidanceNote(),
+ });
+ expect(countOccurrences(prompt, "")).toBe(1);
+ expect(countOccurrences(prompt, "")).toBe(1);
+ });
+
+ it("is absent without promptResidual — grok, gpt, and orchestrator rows untouched", () => {
+ for (const opts of [
+ { orchestrator: false },
+ { orchestrator: false, grokAntiThrash: true },
+ { orchestrator: true },
+ ] as const) {
+ const prompt = buildSubAgentSystemPrompt(
+ undefined,
+ undefined,
+ undefined,
+ opts,
+ );
+ expect(prompt).not.toContain("");
+ expect(prompt).not.toContain("");
+ }
+ });
+
+ it("emits one block with balanced tags, never a full-prompt XML renderer", () => {
+ const note = buildClaudeTaskGuidanceNote();
+ expect(countOccurrences(note, "")).toBe(1);
+ expect(countOccurrences(note, "")).toBe(1);
+ expect(note).not.toMatch(/||/);
+ });
+
+ it("keeps the measured CL-7775 shape: rationale first, numbered approach, named output contract, positively framed", () => {
+ const lines = buildClaudeTaskGuidanceNote().split("\n");
+ // Rationale first: the lead line frames the turn before any directive.
+ expect(lines[1]).toMatch(/^Autonomous coding turn:/);
+ // Numbered approach, not bullets.
+ expect(lines.slice(2, 5).map((l) => l.split(".")[0])).toEqual([
+ "1",
+ "2",
+ "3",
+ ]);
+ // Named output contract.
+ expect(buildClaudeTaskGuidanceNote()).toContain(
+ "structured report envelope",
+ );
+ // Positive framing: no negative imperatives.
+ expect(buildClaudeTaskGuidanceNote()).not.toMatch(
+ /\b(do not|don't|never|stop calling)\b/i,
+ );
+ });
+});
+
+describe("gpt narrate-before-tools residual (CL-8310)", () => {
+ it("is a 3-line narrate-before-tools note, not manage_tasks ceremony", () => {
+ const note = buildGptNarrateBeforeToolsNote();
+ expect(note).toContain("Narrate before tools (GPT worker):");
+ expect(note).toMatch(/before.*tool call.*one short line/is);
+ expect(note).toMatch(/no narration between them/i);
+ expect(note).toContain("write the report envelope");
+ expect(note.toLowerCase()).not.toContain("manage_tasks");
+ });
+
+ it("appears exactly once on a gpt leaf prompt", () => {
+ const prompt = buildSubAgentSystemPrompt(undefined, undefined, undefined, {
+ orchestrator: false,
+ promptResidual: buildGptNarrateBeforeToolsNote(),
+ });
+ const note = buildGptNarrateBeforeToolsNote();
+ expect(countOccurrences(prompt, note)).toBe(1);
+ expect(prompt.trimEnd().endsWith(note)).toBe(true);
+ });
+
+ it("appears exactly once on a gpt primary prompt", () => {
+ const prompt = buildChatSystemPrompt(
+ undefined,
+ undefined,
+ undefined,
+ [],
+ "orchestrator",
+ undefined,
+ undefined,
+ { promptResidual: buildGptNarrateBeforeToolsNote() },
+ );
+ const note = buildGptNarrateBeforeToolsNote();
+ expect(countOccurrences(prompt, note)).toBe(1);
+ });
+
+ it("is absent by default on both primary and leaf", () => {
+ const leaf = buildSubAgentSystemPrompt(undefined, undefined, undefined, {
+ orchestrator: false,
+ grokAntiThrash: false,
+ });
+ const primary = buildChatSystemPrompt(
+ undefined,
+ undefined,
+ undefined,
+ [],
+ "orchestrator",
+ );
+ expect(leaf).not.toContain("Narrate before tools (GPT worker):");
+ expect(primary).not.toContain("Narrate before tools (GPT worker):");
+ });
+
+ it("is absent on grok and claude prompts", () => {
+ const grokLeaf = buildSubAgentSystemPrompt(
+ undefined,
+ undefined,
+ undefined,
+ {
+ orchestrator: false,
+ grokAntiThrash: true,
+ },
+ );
+ const claudeLeaf = buildSubAgentSystemPrompt(
+ undefined,
+ undefined,
+ undefined,
+ {
+ orchestrator: false,
+ grokAntiThrash: false,
+ },
+ );
+ const claudePrimary = buildChatSystemPrompt(
+ undefined,
+ undefined,
+ undefined,
+ [],
+ "orchestrator",
+ );
+ for (const prompt of [grokLeaf, claudeLeaf, claudePrimary]) {
+ expect(prompt).not.toContain("Narrate before tools (GPT worker):");
+ expect(prompt).not.toContain("Narrate before tools (GPT");
+ }
+ // The grok row keeps its own residual, untouched.
+ expect(grokLeaf).toContain("Finish bias (xAI / Grok worker):");
+ });
+});
diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts
index fab10da33..ce81caeff 100644
--- a/src/agent/prompts.ts
+++ b/src/agent/prompts.ts
@@ -11,6 +11,11 @@ import {
buildWorkerContract,
buildWorkerToolNames,
} from "./worker-contract.js";
+import {
+ CLAUDE_TASK_GUIDANCE_NOTE,
+ GPT_NARRATE_BEFORE_TOOLS_NOTE,
+ GROK_PROMPT_RESIDUAL,
+} from "./model-family-policy.js";
// Advertise every gated core tool when the caller has no session-start facts
// (tests, ad-hoc prompt previews) — except wait_agents, which is mount-gated:
@@ -455,6 +460,13 @@ export function buildChatSystemPrompt(
sessionMode: SessionMode = "orchestrator",
toolAvailability: ToolAvailability = DEFAULT_TOOL_AVAILABILITY,
guidelineConfig?: GuidelineConfig,
+ opts: {
+ /**
+ * Family policy residual (CL-8297) appended once at the tail so it
+ * cannot disturb the cached prompt prefix. Unset for families with none.
+ */
+ promptResidual?: string | undefined;
+ } = {},
): string {
const sections = [
baseSection(
@@ -473,6 +485,9 @@ export function buildChatSystemPrompt(
if (extensions !== undefined && extensions.length > 0) {
sections.push(...extensions);
}
+ if (opts.promptResidual !== undefined && opts.promptResidual.length > 0) {
+ sections.push(opts.promptResidual);
+ }
return joinSections(sections);
}
@@ -511,14 +526,36 @@ export function buildSubAgentReportContract(
// Tiny residual for Grok/xAI workers: mining showed higher tools-only thrash
// than Codex on the same harness. Shared thrash harness + spawn contracts do
// the structural work; this is only a finish-bias nudge, not a full rewrite.
+// Single source of truth is the GROK_PROMPT_RESIDUAL block in
+// model-family-policy.ts (CL-8296 merged the three ceremony lines into it);
+// this returns that block verbatim so the prompt carries one grok residual
+// with no line twice.
export function buildGrokLeafAntiThrashNote(): string {
- return [
- "Finish bias (xAI / Grok worker):",
- "- Once you can answer the dispatch brief, prefer the structured report over another speculative tool call.",
- "- If the next call would only re-open paths you already read, write the report instead.",
- "- When the dispatch brief's done-definition is met, write the report envelope instead of making one more search or micro-edit.",
- "- Route file and web work through the dedicated tools, never run_shell — mining showed grok reaching for shell first when a typed tool already covered the job.",
- ].join("\n");
+ return GROK_PROMPT_RESIDUAL;
+}
+
+// Single XML residual for Claude-family workers: a prose residual did
+// nothing, but one block cut Sonnet tokens. One block only —
+// never a full-prompt XML renderer, never applied outside the claude family.
+// Rebuilt end to end from Anthropic's prompting docs (CL-8309): rationale
+// first, numbered approach, named output contract; every line is positively
+// framed and scope-explicit for Sonnet's literal instruction-following.
+// Single source of truth is the CLAUDE_TASK_GUIDANCE_NOTE block in
+// model-family-policy.ts (policy owns data); this returns that block verbatim
+// so the prompt carries one claude residual with no line twice.
+export function buildClaudeTaskGuidanceNote(): string {
+ return CLAUDE_TASK_GUIDANCE_NOTE;
+}
+
+// Tiny residual for GPT workers (CL-8310): GPT-5.5/5.6-luna runs showed 6–13
+// silent tool-only turns. Shared thrash harness + spawn contracts do the
+// structural work; this is only a narrate-before-tools nudge. Deliberately
+// not manage_tasks ceremony — that is CL-7769, not this text.
+// Single source of truth is the GPT_NARRATE_BEFORE_TOOLS_NOTE block in
+// model-family-policy.ts (policy owns data); this returns that block verbatim
+// so the prompt carries one gpt residual with no line twice.
+export function buildGptNarrateBeforeToolsNote(): string {
+ return GPT_NARRATE_BEFORE_TOOLS_NOTE;
}
export function buildSubAgentSystemPrompt(
@@ -530,6 +567,11 @@ export function buildSubAgentSystemPrompt(
toolNames?: readonly string[];
/** When true, append the tiny Grok/xAI finish-bias note (provider residual). */
grokAntiThrash?: boolean;
+ /**
+ * Family policy residual (CL-8297) appended once at the tail so it
+ * cannot disturb the cached prompt prefix. Unset for families with none.
+ */
+ promptResidual?: string | undefined;
} = {},
): string {
const toolListForPrompt =
@@ -556,5 +598,8 @@ export function buildSubAgentSystemPrompt(
if (opts.grokAntiThrash === true) {
sections.push(buildGrokLeafAntiThrashNote());
}
+ if (opts.promptResidual !== undefined && opts.promptResidual.length > 0) {
+ sections.push(opts.promptResidual);
+ }
return joinSections(sections);
}
diff --git a/src/exec/runner.ts b/src/exec/runner.ts
index 1a3dc85e9..c7b67af12 100644
--- a/src/exec/runner.ts
+++ b/src/exec/runner.ts
@@ -704,6 +704,8 @@ export async function runExec(config: Config): Promise {
sessionMode,
toolAvailability,
skills: agentToolset.skills,
+ providerName: config.providerName,
+ model: config.model,
})
).systemPrompt;
diff --git a/src/session/runtime-assembly.ts b/src/session/runtime-assembly.ts
index 21c411eb7..5c340375e 100644
--- a/src/session/runtime-assembly.ts
+++ b/src/session/runtime-assembly.ts
@@ -15,6 +15,7 @@ import type { Compactor } from "@intx/types/runtime";
import { buildChatSystemPrompt } from "../agent/prompts.js";
import type { GuidelineSubBlockId } from "../agent/prompts.js";
+import { resolveModelFamilyPolicy } from "../agent/model-family-policy.js";
import type { ToolAvailability } from "../agent/tool-search.js";
import { gatherEnvironment } from "../agent/environment.js";
import {
@@ -293,6 +294,10 @@ export interface SessionChatPromptArgs {
// Guideline sub-block ids to drop (see GUIDELINE_SUB_BLOCK_IDS).
// Omitted = full guidelines.
promptSectionOmit?: readonly GuidelineSubBlockId[];
+ // Active session provider/model, for family residuals on the primary
+ // prompt (CL-8310: GPT narrate-before-tools). Omitted = no family residual.
+ providerName?: string;
+ model?: string;
// Session-start snapshot from createAgentToolset. When provided, skip
// rediscovery so the prompt listing and skill_search share one catalog.
skills?: readonly SkillSummary[];
@@ -320,6 +325,19 @@ export async function loadSessionChatPrompt(
...(args.systemPromptExtensions ?? []),
...overrides.append,
];
+ // Family residual on the primary prompt (CL-8310): resolved from the model
+ // family policy as an orchestrator — primaries dispatch rather than doing
+ // the work directly, so grok/claude primaries stay untouched (their rows
+ // withhold the residual from orchestrators) while the gpt row carries its
+ // narrate-before-tools note primary and leaf alike.
+ const { promptResidual } =
+ args.providerName !== undefined
+ ? resolveModelFamilyPolicy({
+ providerName: args.providerName,
+ ...(args.model !== undefined ? { model: args.model } : {}),
+ orchestrator: true,
+ })
+ : { promptResidual: undefined };
return {
systemPrompt: buildChatSystemPrompt(
extensions.length > 0 ? extensions : undefined,
@@ -331,6 +349,7 @@ export async function loadSessionChatPrompt(
args.promptSectionOmit !== undefined
? { omit: args.promptSectionOmit }
: undefined,
+ { promptResidual },
),
skills,
};
diff --git a/src/subagent/provider-family.test.ts b/src/subagent/provider-family.test.ts
index 19b7bfc42..f7156de84 100644
--- a/src/subagent/provider-family.test.ts
+++ b/src/subagent/provider-family.test.ts
@@ -1,10 +1,13 @@
import { describe, expect, test } from "bun:test";
import {
detectModelFamily,
+ isClaudeLeafProvider,
+ isGptProvider,
isKimiLeafProvider,
isXaiGrokLeafProvider,
shouldApplyGrokAntiThrash,
} from "./provider-family.js";
+import { CODEX_DEFAULT_MODELS } from "../auth/codex/constants.js";
describe("isXaiGrokLeafProvider", () => {
test("matches xai/ OAuth provider names", () => {
@@ -121,7 +124,7 @@ describe("isKimiLeafProvider", () => {
});
describe("detectModelFamily", () => {
- test("detects grok, kimi, and default", () => {
+ test("detects grok, kimi, claude, gpt, and default", () => {
expect(
detectModelFamily({ providerName: "xai/default", model: "grok-4.5" }),
).toBe("grok");
@@ -136,6 +139,125 @@ describe("detectModelFamily", () => {
providerName: "anthropic",
model: "claude-sonnet-4",
}),
+ ).toBe("claude");
+ expect(
+ detectModelFamily({ providerName: "openai", model: "gpt-4.1" }),
+ ).toBe("gpt");
+ expect(
+ detectModelFamily({
+ providerName: "unknown-provider",
+ model: "unknown-model",
+ }),
).toBe("default");
});
});
+
+describe("isClaudeLeafProvider", () => {
+ test("matches anthropic provider names and claude model ids", () => {
+ expect(isClaudeLeafProvider({ providerName: "anthropic" })).toBe(true);
+ expect(isClaudeLeafProvider({ providerName: "ANTHROPIC" })).toBe(true);
+ expect(
+ isClaudeLeafProvider({
+ providerName: "openai-compat",
+ model: "claude-sonnet-4",
+ }),
+ ).toBe(true);
+ });
+
+ test("rejects grok, gpt, kimi, and muse rows", () => {
+ expect(
+ isClaudeLeafProvider({ providerName: "xai/default", model: "grok-4.5" }),
+ ).toBe(false);
+ expect(
+ isClaudeLeafProvider({ providerName: "openai", model: "gpt-4.1" }),
+ ).toBe(false);
+ expect(
+ isClaudeLeafProvider({ providerName: "codex", model: "gpt-5.1" }),
+ ).toBe(false);
+ expect(
+ isClaudeLeafProvider({ providerName: "moonshot", model: "kimi-k2" }),
+ ).toBe(false);
+ expect(
+ isClaudeLeafProvider({
+ providerName: "opencode-go/abklabs",
+ model: "muse-spark-1.3-contributor",
+ }),
+ ).toBe(false);
+ });
+});
+
+describe("detectModelFamily claude row", () => {
+ test("resolves anthropic/claude to the claude family", () => {
+ expect(
+ detectModelFamily({
+ providerName: "anthropic",
+ model: "claude-sonnet-4",
+ }),
+ ).toBe("claude");
+ expect(
+ detectModelFamily({
+ providerName: "openai-compat",
+ model: "claude-opus-4-6",
+ }),
+ ).toBe("claude");
+ });
+});
+
+describe("isGptProvider (CL-8310)", () => {
+ test("matches codex OAuth provider names", () => {
+ expect(isGptProvider({ providerName: "codex/default" })).toBe(true);
+ expect(isGptProvider({ providerName: "codex/work" })).toBe(true);
+ });
+
+ test("matches codex adapter ids and bare codex names", () => {
+ expect(isGptProvider({ providerName: "codex-responses" })).toBe(true);
+ expect(isGptProvider({ providerName: "codex" })).toBe(true);
+ });
+
+ test("matches gpt-* model ids on any provider", () => {
+ expect(isGptProvider({ providerName: "openai", model: "gpt-5.5" })).toBe(
+ true,
+ );
+ expect(
+ isGptProvider({ providerName: "opencode-go", model: "gpt-5.1" }),
+ ).toBe(true);
+ expect(
+ isGptProvider({ providerName: "openai-compat", model: "gpt-5.6-luna" }),
+ ).toBe(true);
+ });
+
+ test("covers every in-tree codex catalog model without naming cells", () => {
+ // No terra/sol/astra special-casing: every served codex id resolves via
+ // the generic codex-provider / gpt-* match, so future cells ride along.
+ for (const model of CODEX_DEFAULT_MODELS) {
+ expect(isGptProvider({ providerName: "codex/default", model })).toBe(
+ true,
+ );
+ expect(detectModelFamily({ providerName: "codex/default", model })).toBe(
+ "gpt",
+ );
+ }
+ });
+
+ test("rejects grok, kimi, muse, and claude", () => {
+ expect(
+ isGptProvider({ providerName: "xai/default", model: "grok-4.6" }),
+ ).toBe(false);
+ expect(isGptProvider({ providerName: "moonshot", model: "kimi-k2" })).toBe(
+ false,
+ );
+ expect(
+ isGptProvider({
+ providerName: "opencode-go",
+ model: "muse-spark-1.3-contributor",
+ }),
+ ).toBe(false);
+ expect(
+ isGptProvider({
+ providerName: "anthropic",
+ model: "claude-sonnet-4",
+ }),
+ ).toBe(false);
+ expect(isGptProvider({ providerName: "anthropic" })).toBe(false);
+ });
+});
diff --git a/src/subagent/provider-family.ts b/src/subagent/provider-family.ts
index 6165fabf2..c1ac21076 100644
--- a/src/subagent/provider-family.ts
+++ b/src/subagent/provider-family.ts
@@ -1,5 +1,6 @@
import { GROK_RESPONSES_PROVIDER } from "../provider/grok-responses.js";
import { isXaiProviderName } from "../config/xai-providers.js";
+import { isCodexProviderName } from "../config/codex-providers.js";
/**
* True when the leaf inference path is xAI / Grok family.
@@ -37,8 +38,45 @@ export function isMuseSparkLeafProvider(input: {
return input.model !== undefined && /^muse-spark/i.test(input.model.trim());
}
+/** True when the provider/model is Anthropic's Claude family. */
+export function isClaudeLeafProvider(input: {
+ providerName: string;
+ model?: string;
+}): boolean {
+ const name = input.providerName.toLowerCase();
+ if (name.includes("anthropic") || name.includes("claude")) return true;
+ if (input.model !== undefined && /^claude/i.test(input.model.trim()))
+ return true;
+ return false;
+}
+
+/**
+ * True when the inference path is the GPT family: a Codex provider name
+ * (codex/ OAuth profiles, the codex-responses adapter, bare codex) or a
+ * gpt-* model id on any provider. Served codex cells (astra/sol/terra/luna)
+ * all match the generic gpt-* model shape — never name them here; CL-8265
+ * characterizes cells later.
+ */
+export function isGptProvider(input: {
+ providerName: string;
+ model?: string;
+}): boolean {
+ const name = input.providerName.toLowerCase();
+ if (isCodexProviderName(name) || name === "codex" || name.includes("codex"))
+ return true;
+ if (input.model !== undefined && /^gpt-/i.test(input.model.trim()))
+ return true;
+ return false;
+}
+
/** Model families the shared directors branch on via ModelFamilyPolicy. */
-export type ModelFamily = "grok" | "kimi" | "muse" | "default";
+export type ModelFamily =
+ | "grok"
+ | "kimi"
+ | "muse"
+ | "claude"
+ | "gpt"
+ | "default";
/**
* Resolves a provider/model to a ModelFamily. Generalizes
@@ -53,6 +91,8 @@ export function detectModelFamily(input: {
if (isXaiGrokLeafProvider(input)) return "grok";
if (isKimiLeafProvider(input)) return "kimi";
if (isMuseSparkLeafProvider(input)) return "muse";
+ if (isClaudeLeafProvider(input)) return "claude";
+ if (isGptProvider(input)) return "gpt";
return "default";
}
diff --git a/src/subagent/run.ts b/src/subagent/run.ts
index 5373793f2..48db889ff 100644
--- a/src/subagent/run.ts
+++ b/src/subagent/run.ts
@@ -1055,6 +1055,7 @@ async function runSubAgentInner(
model: params.provider.model,
orchestrator: params.orchestrator === true,
}),
+ promptResidual: modelFamilyPolicy.promptResidual,
},
);
diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts
index 488a89c13..c4b72b4d0 100644
--- a/src/tui/runner/session.ts
+++ b/src/tui/runner/session.ts
@@ -447,6 +447,8 @@ export async function assembleTUISession(
sessionMode: liveSessionMode,
toolAvailability,
skills: toolset.skills,
+ providerName: config.providerName,
+ model: config.model,
});
const directorHolder: { instance?: ReturnType } =