feat(marketing): HoM feature-spotlight X drafts + brand-voice charter (v0.18.0 B)

The Head of Marketing now markets features, not just releases: a default-off
x_feature_spotlight loop periodically spawns the HoM to investigate what shipped
(CHANGELOG, feature flags, docs/map, KB) and draft ONE held marketing post via
propose_feature_spotlight, reviewed in the X post queue.

- New x_feature source (distinct from x_post, fixing panel mislabeling) + a
  panel Feature-spotlight branch.
- brand_voice column on company_goals (migration 061, single head) as the
  CEO-editable voice source, surfaced in Settings and injected into the HoM
  briefing; a VOICE GUIDE baseline in head-marketing.md.
- propose_feature_spotlight verb (HoM-only), mirroring propose_roadmap.

Gated by x_feature_spotlight_enabled (default off; flag-off dormancy proven).
Also fixed two real bugs found mid-build: company_goals API schemas dropped
brand_voice on GET/PUT; the live charter UI is goals-tab.tsx, not the unmounted
company-goals-card.tsx. Full suite green (2935); migration single-head verified.
This commit is contained in:
Renn F
2026-07-04 07:34:40 +02:00
parent 1b992df3ad
commit da17c49f2d
44 changed files with 1502 additions and 57 deletions
+20 -3
View File
@@ -224,6 +224,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
const queryClient = useQueryClient();
const [northStar, setNorthStar] = useState<string | null>(null);
const [brandVoice, setBrandVoice] = useState<string | null>(null);
const [constraints, setConstraints] = useState<string | null>(null);
const [objectives, setObjectives] = useState<
Record<string, unknown>[] | null
@@ -231,6 +232,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
const [policy, setPolicy] = useState<Record<string, unknown> | null>(null);
const northStarVal = northStar ?? goals.north_star ?? "";
const brandVoiceVal = brandVoice ?? goals.brand_voice ?? "";
const constraintsVal = constraints ?? (goals.constraints ?? []).join("\n");
const objectivesVal = objectives ?? goals.objectives ?? [];
const policyVal = policy ?? goals.operating_policy ?? {};
@@ -240,6 +242,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["company-goals"] });
setNorthStar(null);
setBrandVoice(null);
setConstraints(null);
setObjectives(null);
setPolicy(null);
@@ -253,6 +256,7 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
const handleSave = () => {
saveMutation.mutate({
north_star: northStarVal,
brand_voice: brandVoiceVal,
objectives: objectivesVal,
constraints: constraintsVal
.split("\n")
@@ -269,9 +273,9 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
<CardHeader>
<CardTitle>Company Charter</CardTitle>
<CardDescription>
CEO-owned north star, objectives, constraints, and operating policy.
Injected into every agent&apos;s briefing so all work stays
goal-aware.
CEO-owned north star, brand voice, objectives, constraints, and
operating policy. Injected into every agent&apos;s briefing so all
work stays goal-aware.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
@@ -288,6 +292,19 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
/>
</div>
{/* Brand voice */}
<div className="space-y-2">
<Label htmlFor="brand-voice">Brand voice</Label>
<Textarea
id="brand-voice"
rows={3}
value={brandVoiceVal}
disabled={saving}
onChange={(e) => setBrandVoice(e.target.value)}
placeholder="Sample posts or a style description in your real voice — feeds every X (Twitter) draft and the Head of Marketing's spotlight posts…"
/>
</div>
{/* Constraints */}
<div className="space-y-2">
<Label htmlFor="constraints">Constraints (one per line)</Label>
@@ -31,6 +31,7 @@ export function CompanyGoalsCard() {
const [constraints, setConstraints] = useState<string | null>(null);
const [objectives, setObjectives] = useState<string | null>(null);
const [policy, setPolicy] = useState<string | null>(null);
const [brandVoice, setBrandVoice] = useState<string | null>(null);
const { data: goals, isLoading } = useQuery({
queryKey: ["company-goals"],
@@ -43,6 +44,7 @@ export function CompanyGoalsCard() {
objectives ?? JSON.stringify(goals?.objectives ?? [], null, 2);
const policyVal =
policy ?? JSON.stringify(goals?.operating_policy ?? {}, null, 2);
const brandVoiceVal = brandVoice ?? goals?.brand_voice ?? "";
const saveMutation = useMutation({
mutationFn: (update: CompanyGoalsUpdate) => companyGoalsApi.update(update),
@@ -52,6 +54,7 @@ export function CompanyGoalsCard() {
setConstraints(null);
setObjectives(null);
setPolicy(null);
setBrandVoice(null);
toast.success("Company charter updated");
},
onError: (error) => {
@@ -92,6 +95,7 @@ export function CompanyGoalsCard() {
.map((c) => c.trim())
.filter(Boolean),
operating_policy: parsedPolicy,
brand_voice: brandVoiceVal,
});
};
@@ -103,9 +107,9 @@ export function CompanyGoalsCard() {
Company Charter
</CardTitle>
<CardDescription>
The CEO-owned north star, objectives, constraints, and operating
policy. Injected into every agent&apos;s briefing so all work stays
goal-aware.
The CEO-owned north star, objectives, constraints, operating policy,
and brand voice. Injected into every agent&apos;s briefing so all
work stays goal-aware.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -120,6 +124,17 @@ export function CompanyGoalsCard() {
placeholder="The long-term vision in one or two sentences..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="brand-voice">Brand voice</Label>
<Textarea
id="brand-voice"
rows={3}
value={brandVoiceVal}
disabled={isLoading}
onChange={(e) => setBrandVoice(e.target.value)}
placeholder="Sample posts or a style description for the Head of Marketing's drafts..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="constraints">Constraints (one per line)</Label>
<Textarea
@@ -71,6 +71,26 @@ describe("XPostQueue", () => {
expect(screen.getByDisplayValue("RoboCo v0.17.0 just shipped!")).toBeInTheDocument();
});
it("renders a feature-spotlight draft with its own label and badge", async () => {
listPosts.mockResolvedValueOnce([
{
task_id: "x-3",
source: "x_feature",
title: "X feature spotlight: playbooks",
status: "pending",
body: "Did you know RoboCo curates playbooks from real task runs?",
char_count: 60,
feature: { slug: "playbooks", title: "Playbook curation" },
},
] as XPost[]);
render(withQueryClient(<XPostQueue />));
expect(await screen.findByText("Feature spotlight")).toBeInTheDocument();
expect(screen.queryByText("Mention reply")).not.toBeInTheDocument();
expect(screen.getByText(/Playbook curation/)).toBeInTheDocument();
});
it("disables only the row being approved, not every row's Approve", async () => {
render(withQueryClient(<XPostQueue />));
@@ -23,16 +23,17 @@ import {
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { AtSign, CheckCircle2, Rocket, XCircle } from "lucide-react";
import { AtSign, CheckCircle2, Rocket, Sparkles, XCircle } from "lucide-react";
import { toast } from "sonner";
const MAX_TWEET_CHARS = 280;
const _MIN_REASON_CHARS = 4;
function sourceMeta(source: XPost["source"]) {
return source === "x_post"
? { label: "Release post", icon: Rocket }
: { label: "Mention reply", icon: AtSign };
if (source === "x_post") return { label: "Release post", icon: Rocket };
if (source === "x_feature")
return { label: "Feature spotlight", icon: Sparkles };
return { label: "Mention reply", icon: AtSign };
}
function describeExecuteResult(result: XPostExecuteResult): string {
@@ -78,6 +79,11 @@ function XPostRow({
re: {post.mention.text}
</Badge>
)}
{post.feature && (
<Badge variant="outline" className="max-w-56 truncate">
feature: {post.feature.title}
</Badge>
)}
</div>
<Textarea
@@ -195,8 +201,9 @@ export function XPostQueue({ className }: { className?: string }) {
X Post Queue
</CardTitle>
<CardDescription>
Drafted release announcements (and mention replies, if enabled) land
here for you to edit, approve, or reject. Nothing posts on its own.
Drafted release announcements, feature spotlights, and mention
replies (if enabled) land here for you to edit, approve, or
reject. Nothing posts on its own.
</CardDescription>
</CardHeader>
<CardContent>
@@ -220,8 +227,9 @@ export function XPostQueue({ className }: { className?: string }) {
<Badge variant="secondary">{posts.length}</Badge>
</CardTitle>
<CardDescription>
Drafted release announcements and mention replies edit, approve
(posts to X), or reject. Nothing posts on its own.
Drafted release announcements, feature spotlights, and mention
replies edit, approve (posts to X), or reject. Nothing posts on
its own.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
@@ -62,6 +62,8 @@ const FLAG_DESCRIPTIONS: Record<string, string> = {
"Draft release-announcement posts for the X (Twitter) account — every draft is held in a queue for you to edit, approve, or reject; nothing posts automatically, and it stays inert until you set credentials in the X card below.",
x_replies_enabled:
"Also poll X mentions and draft replies (still held for your approval — nothing auto-replies). Off by default: reading mentions needs a paid X API tier, so leave this off if you only want release posts.",
x_feature_spotlight_enabled:
"Periodically spawn the Head of Marketing to investigate RoboCo's shipped features and draft a spotlight post for an under-publicized capability — held in the same X post queue below for your approval. Needs x_engine_enabled and X credentials.",
roadmap_engine_enabled:
"Weekly: the Product Owner explores the company's projects and proposes a themed cycle of 3-7 roadmap items — you approve or reject each one individually; approved items land in the backlog and nothing auto-starts.",
fable_mode_enabled:
+6 -1
View File
@@ -5,6 +5,7 @@ export interface CompanyGoals {
objectives: Record<string, unknown>[];
constraints: string[];
operating_policy: Record<string, unknown>;
brand_voice: string;
updated_at?: string | null;
updated_by?: string | null;
}
@@ -12,7 +13,11 @@ export interface CompanyGoals {
export type CompanyGoalsUpdate = Partial<
Pick<
CompanyGoals,
"north_star" | "objectives" | "constraints" | "operating_policy"
| "north_star"
| "objectives"
| "constraints"
| "operating_policy"
| "brand_voice"
>
>;
+7 -1
View File
@@ -13,15 +13,21 @@ export interface XMentionRef {
text: string;
}
export interface XFeatureRef {
slug: string;
title: string;
}
export interface XPost {
task_id: string;
source: "x_post" | "x_reply";
source: "x_post" | "x_reply" | "x_feature";
title: string;
status: string;
body: string;
char_count: number;
release_version?: string | null;
mention?: XMentionRef | null;
feature?: XFeatureRef | null;
reject_reason?: string | null;
}