mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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.
184 lines
6.0 KiB
TypeScript
184 lines
6.0 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
companyGoalsApi,
|
|
type CompanyGoalsUpdate,
|
|
} from "@/lib/api/company-goals";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Target, Save } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
function parseError(e: unknown): string {
|
|
return e instanceof Error ? e.message : "parse error";
|
|
}
|
|
|
|
export function CompanyGoalsCard() {
|
|
const queryClient = useQueryClient();
|
|
// null = "show the server value"; deriving the displayed value avoids syncing
|
|
// query state into local state with an effect (react-hooks/set-state-in-effect).
|
|
const [northStar, setNorthStar] = useState<string | null>(null);
|
|
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"],
|
|
queryFn: companyGoalsApi.get,
|
|
});
|
|
|
|
const northStarVal = northStar ?? goals?.north_star ?? "";
|
|
const constraintsVal = constraints ?? (goals?.constraints ?? []).join("\n");
|
|
const objectivesVal =
|
|
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),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["company-goals"] });
|
|
setNorthStar(null);
|
|
setConstraints(null);
|
|
setObjectives(null);
|
|
setPolicy(null);
|
|
setBrandVoice(null);
|
|
toast.success("Company charter updated");
|
|
},
|
|
onError: (error) => {
|
|
toast.error(`Failed to save: ${parseError(error)}`);
|
|
},
|
|
});
|
|
|
|
const handleSave = () => {
|
|
let parsedObjectives: Record<string, unknown>[];
|
|
let parsedPolicy: Record<string, unknown>;
|
|
try {
|
|
parsedObjectives = JSON.parse(objectivesVal);
|
|
if (!Array.isArray(parsedObjectives)) {
|
|
throw new Error("must be a JSON array");
|
|
}
|
|
} catch (e) {
|
|
toast.error(`Objectives: invalid JSON — ${parseError(e)}`);
|
|
return;
|
|
}
|
|
try {
|
|
parsedPolicy = JSON.parse(policyVal);
|
|
if (
|
|
typeof parsedPolicy !== "object" ||
|
|
parsedPolicy === null ||
|
|
Array.isArray(parsedPolicy)
|
|
) {
|
|
throw new Error("must be a JSON object");
|
|
}
|
|
} catch (e) {
|
|
toast.error(`Operating policy: invalid JSON — ${parseError(e)}`);
|
|
return;
|
|
}
|
|
saveMutation.mutate({
|
|
north_star: northStarVal,
|
|
objectives: parsedObjectives,
|
|
constraints: constraintsVal
|
|
.split("\n")
|
|
.map((c) => c.trim())
|
|
.filter(Boolean),
|
|
operating_policy: parsedPolicy,
|
|
brand_voice: brandVoiceVal,
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Target className="h-5 w-5" />
|
|
Company Charter
|
|
</CardTitle>
|
|
<CardDescription>
|
|
The CEO-owned north star, objectives, constraints, operating policy,
|
|
and brand voice. Injected into every agent's briefing so all
|
|
work stays goal-aware.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="north-star">North star</Label>
|
|
<Textarea
|
|
id="north-star"
|
|
rows={3}
|
|
value={northStarVal}
|
|
disabled={isLoading}
|
|
onChange={(e) => setNorthStar(e.target.value)}
|
|
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
|
|
id="constraints"
|
|
rows={3}
|
|
value={constraintsVal}
|
|
disabled={isLoading}
|
|
onChange={(e) => setConstraints(e.target.value)}
|
|
placeholder={"AGPL only\nNo external data egress"}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="objectives">Objectives (JSON array)</Label>
|
|
<Textarea
|
|
id="objectives"
|
|
rows={6}
|
|
value={objectivesVal}
|
|
disabled={isLoading}
|
|
onChange={(e) => setObjectives(e.target.value)}
|
|
className="font-mono text-xs"
|
|
placeholder='[{"metric": "NPS", "target": 50, "status": "active"}]'
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="policy">Operating policy (JSON object)</Label>
|
|
<Textarea
|
|
id="policy"
|
|
rows={6}
|
|
value={policyVal}
|
|
disabled={isLoading}
|
|
onChange={(e) => setPolicy(e.target.value)}
|
|
className="font-mono text-xs"
|
|
placeholder='{"autonomy_level": "assisted", "monthly_budget_cap": 500}'
|
|
/>
|
|
</div>
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={saveMutation.isPending || isLoading}
|
|
>
|
|
<Save className="h-4 w-4 mr-2" />
|
|
{saveMutation.isPending ? "Saving..." : "Save charter"}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|