Skip to content

Commit 7f336b7

Browse files
committed
feat: wire playbook execution engine to create and advance runs through phases
1 parent 88e82ae commit 7f336b7

20 files changed

+893
-1
lines changed

packages/contracts/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,7 @@ export const playbookPhaseSchema = z.object({
505505
name: z.string().min(1),
506506
description: z.string().min(1),
507507
expectedArtifacts: z.array(z.string()).optional(),
508+
specialistId: z.string().optional(),
508509
});
509510

510511
export const playbookManifestSchema = z.object({
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { ArtifactRecord } from "../../artifacts/domain/artifact.js";
2+
3+
export interface ArtifactMatchResult {
4+
matched: Map<string, ArtifactRecord>;
5+
missing: string[];
6+
}
7+
8+
/**
9+
* Tokenize a string into lowercase alphanumeric words.
10+
*/
11+
function tokenize(text: string): string[] {
12+
return text
13+
.toLowerCase()
14+
.replace(/[^a-z0-9\s]/g, " ")
15+
.split(/\s+/)
16+
.filter((t) => t.length > 0);
17+
}
18+
19+
/**
20+
* Matches actual artifacts against expected artifact names using fuzzy token overlap.
21+
* A match requires at least 50% of the expected name's tokens to appear in the artifact title.
22+
* Returns a map of matched expected names → artifact records, and a list of unmatched names.
23+
*/
24+
export function matchArtifactsToExpected(
25+
artifacts: ArtifactRecord[],
26+
expectedNames: string[],
27+
): ArtifactMatchResult {
28+
const matched = new Map<string, ArtifactRecord>();
29+
const missing: string[] = [];
30+
31+
// Track which artifacts have already been claimed to avoid double-matching
32+
const claimed = new Set<string>();
33+
34+
for (const expectedName of expectedNames) {
35+
const expectedTokens = tokenize(expectedName);
36+
if (expectedTokens.length === 0) {
37+
matched.set(expectedName, undefined as unknown as ArtifactRecord);
38+
continue;
39+
}
40+
41+
let bestArtifact: ArtifactRecord | null = null;
42+
let bestScore = 0;
43+
44+
for (const artifact of artifacts) {
45+
if (claimed.has(artifact.artifactId)) continue;
46+
47+
const titleTokens = new Set(tokenize(artifact.title));
48+
const overlap = expectedTokens.filter((t) => titleTokens.has(t)).length;
49+
const score = overlap / expectedTokens.length;
50+
51+
if (score >= 0.5 && score > bestScore) {
52+
bestScore = score;
53+
bestArtifact = artifact;
54+
}
55+
}
56+
57+
if (bestArtifact) {
58+
matched.set(expectedName, bestArtifact);
59+
claimed.add(bestArtifact.artifactId);
60+
} else {
61+
missing.push(expectedName);
62+
}
63+
}
64+
65+
return { matched, missing };
66+
}
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import type { OpenGoatPaths } from "../../domain/opengoat-paths.js";
2+
import type { ArtifactService } from "../../artifacts/application/artifact.service.js";
3+
import type { RunService } from "../../runs/application/run.service.js";
4+
import type { RunRecord } from "../../runs/domain/run.js";
5+
import type { PlaybookRegistryService } from "./playbook-registry.service.js";
6+
import { matchArtifactsToExpected } from "./artifact-phase-matcher.js";
7+
8+
export interface StartPlaybookOptions {
9+
playbookId: string;
10+
projectId: string;
11+
objectiveId: string;
12+
}
13+
14+
export interface PhaseProgressResult {
15+
advanced: boolean;
16+
completed: boolean;
17+
run: RunRecord;
18+
}
19+
20+
export interface PhaseProgress {
21+
name: string;
22+
description: string;
23+
status: "completed" | "current" | "upcoming";
24+
specialistId?: string;
25+
expectedArtifacts: string[];
26+
matchedArtifacts: string[];
27+
missingArtifacts: string[];
28+
}
29+
30+
export interface PlaybookProgress {
31+
runId: string;
32+
playbookId: string;
33+
playbookTitle: string;
34+
currentPhase: string;
35+
runStatus: string;
36+
phases: PhaseProgress[];
37+
}
38+
39+
interface PlaybookExecutionServiceDeps {
40+
runService: RunService;
41+
artifactService: ArtifactService;
42+
playbookRegistryService: PlaybookRegistryService;
43+
}
44+
45+
export class PlaybookExecutionService {
46+
private readonly runService: RunService;
47+
private readonly artifactService: ArtifactService;
48+
private readonly playbookRegistryService: PlaybookRegistryService;
49+
50+
constructor(deps: PlaybookExecutionServiceDeps) {
51+
this.runService = deps.runService;
52+
this.artifactService = deps.artifactService;
53+
this.playbookRegistryService = deps.playbookRegistryService;
54+
}
55+
56+
/**
57+
* Start a playbook: creates a run, sets it to running, and initializes the first phase.
58+
*/
59+
async startPlaybook(
60+
paths: OpenGoatPaths,
61+
options: StartPlaybookOptions,
62+
): Promise<RunRecord> {
63+
const manifest = this.playbookRegistryService.getPlaybook(options.playbookId);
64+
const firstPhase = manifest.defaultPhases[0];
65+
66+
if (!firstPhase) {
67+
throw new Error(`Playbook "${options.playbookId}" has no phases`);
68+
}
69+
70+
const run = await this.runService.createRun(paths, {
71+
projectId: options.projectId,
72+
objectiveId: options.objectiveId,
73+
playbookId: options.playbookId,
74+
title: manifest.title,
75+
startedFrom: "action",
76+
phase: firstPhase.name,
77+
phaseSummary: firstPhase.description,
78+
});
79+
80+
// Transition from draft → running
81+
return this.runService.updateRunStatus(paths, run.runId, "running");
82+
}
83+
84+
/**
85+
* Check if the current phase's expected artifacts have been produced.
86+
* If so, advance to the next phase or complete the run.
87+
*/
88+
async checkPhaseProgress(
89+
paths: OpenGoatPaths,
90+
runId: string,
91+
): Promise<PhaseProgressResult> {
92+
const run = await this.runService.getRun(paths, runId);
93+
94+
if (!run.playbookId) {
95+
throw new Error("Run is not associated with a playbook");
96+
}
97+
98+
const manifest = this.playbookRegistryService.getPlaybook(run.playbookId);
99+
const currentPhaseIndex = manifest.defaultPhases.findIndex(
100+
(p) => p.name === run.phase,
101+
);
102+
103+
if (currentPhaseIndex === -1) {
104+
return { advanced: false, completed: false, run };
105+
}
106+
107+
const currentPhase = manifest.defaultPhases[currentPhaseIndex]!;
108+
const expectedArtifacts = currentPhase.expectedArtifacts ?? [];
109+
110+
// If no expected artifacts, phase is automatically satisfied
111+
if (expectedArtifacts.length === 0) {
112+
return this.advanceOrComplete(paths, run, manifest, currentPhaseIndex);
113+
}
114+
115+
// Get artifacts for this run
116+
const artifactPage = await this.artifactService.listArtifacts(paths, {
117+
runId: run.runId,
118+
});
119+
120+
const { missing } = matchArtifactsToExpected(
121+
artifactPage.items,
122+
expectedArtifacts,
123+
);
124+
125+
if (missing.length > 0) {
126+
return { advanced: false, completed: false, run };
127+
}
128+
129+
return this.advanceOrComplete(paths, run, manifest, currentPhaseIndex);
130+
}
131+
132+
/**
133+
* Get detailed progress for a playbook run.
134+
*/
135+
async getRunProgress(
136+
paths: OpenGoatPaths,
137+
runId: string,
138+
): Promise<PlaybookProgress> {
139+
const run = await this.runService.getRun(paths, runId);
140+
141+
if (!run.playbookId) {
142+
throw new Error("Run is not associated with a playbook");
143+
}
144+
145+
const manifest = this.playbookRegistryService.getPlaybook(run.playbookId);
146+
const currentPhaseIndex = manifest.defaultPhases.findIndex(
147+
(p) => p.name === run.phase,
148+
);
149+
150+
// Get all artifacts for the run
151+
const artifactPage = await this.artifactService.listArtifacts(paths, {
152+
runId: run.runId,
153+
});
154+
155+
const isCompleted = run.status === "completed";
156+
157+
const phases: PhaseProgress[] = manifest.defaultPhases.map((phase, index) => {
158+
const expectedArtifacts = phase.expectedArtifacts ?? [];
159+
const { matched, missing } = matchArtifactsToExpected(
160+
artifactPage.items,
161+
expectedArtifacts,
162+
);
163+
164+
let status: PhaseProgress["status"];
165+
if (isCompleted || index < currentPhaseIndex) {
166+
status = "completed";
167+
} else if (index === currentPhaseIndex) {
168+
status = isCompleted ? "completed" : "current";
169+
} else {
170+
status = "upcoming";
171+
}
172+
173+
return {
174+
name: phase.name,
175+
description: phase.description,
176+
status,
177+
specialistId: phase.specialistId,
178+
expectedArtifacts,
179+
matchedArtifacts: Array.from(matched.keys()),
180+
missingArtifacts: missing,
181+
};
182+
});
183+
184+
return {
185+
runId: run.runId,
186+
playbookId: run.playbookId,
187+
playbookTitle: manifest.title,
188+
currentPhase: run.phase,
189+
runStatus: run.status,
190+
phases,
191+
};
192+
}
193+
194+
private async advanceOrComplete(
195+
paths: OpenGoatPaths,
196+
run: RunRecord,
197+
manifest: { defaultPhases: Array<{ name: string; description: string }> },
198+
currentPhaseIndex: number,
199+
): Promise<PhaseProgressResult> {
200+
const isLastPhase = currentPhaseIndex === manifest.defaultPhases.length - 1;
201+
202+
if (isLastPhase) {
203+
const completedRun = await this.runService.completeRun(paths, run.runId);
204+
return { advanced: false, completed: true, run: completedRun };
205+
}
206+
207+
const nextPhase = manifest.defaultPhases[currentPhaseIndex + 1]!;
208+
const advancedRun = await this.runService.advancePhase(paths, run.runId, {
209+
phase: nextPhase.name,
210+
phaseSummary: nextPhase.description,
211+
});
212+
213+
return { advanced: true, completed: false, run: advancedRun };
214+
}
215+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
export { PlaybookRegistryService } from "./application/playbook-registry.service.js";
2+
export { PlaybookExecutionService } from "./application/playbook-execution.service.js";
3+
export type { StartPlaybookOptions, PhaseProgressResult, PhaseProgress, PlaybookProgress } from "./application/playbook-execution.service.js";
4+
export { matchArtifactsToExpected } from "./application/artifact-phase-matcher.js";
5+
export type { ArtifactMatchResult } from "./application/artifact-phase-matcher.js";
26
export type { PlaybookManifest, PlaybookSource, PlaybookPhase, GoalType } from "./domain/playbook.js";
37
export { BUILTIN_PLAYBOOKS } from "./manifests/index.js";

packages/core/src/core/playbooks/manifests/comparison-page.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,24 +25,28 @@ export const comparisonPagePlaybook: PlaybookManifest = {
2525
description:
2626
"Analyze competitor positioning, messaging themes, strengths, and weaknesses.",
2727
expectedArtifacts: ["competitor matrix"],
28+
specialistId: "market-intel",
2829
},
2930
{
3031
name: "Analyze",
3132
description:
3233
"Identify messaging gaps, unoccupied positions, and counter-positioning opportunities.",
3334
expectedArtifacts: ["messaging gaps"],
35+
specialistId: "positioning",
3436
},
3537
{
3638
name: "Draft",
3739
description:
3840
"Create comparison page outline with sections, copy angles, and proof points.",
3941
expectedArtifacts: ["comparison page outline"],
42+
specialistId: "seo-aeo",
4043
},
4144
{
4245
name: "Prioritize",
4346
description:
4447
"Rank comparison targets by search volume, win rate impact, and content effort.",
4548
expectedArtifacts: ["priority comparison targets"],
49+
specialistId: "seo-aeo",
4650
},
4751
],
4852
artifactTypes: [

packages/core/src/core/playbooks/manifests/content-sprint.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,24 +26,28 @@ export const contentSprintPlaybook: PlaybookManifest = {
2626
description:
2727
"Define content pillars and research audience pain points, trending topics, and competitor gaps.",
2828
expectedArtifacts: ["content pillars"],
29+
specialistId: "content",
2930
},
3031
{
3132
name: "Ideation",
3233
description:
3334
"Generate 10 ranked content ideas with channel, format, and impact reasoning.",
3435
expectedArtifacts: ["10 content ideas"],
36+
specialistId: "content",
3537
},
3638
{
3739
name: "Draft",
3840
description:
3941
"Write 3 content briefs and 1 full draft for the highest-impact idea.",
4042
expectedArtifacts: ["3 content briefs", "1 full draft"],
43+
specialistId: "content",
4144
},
4245
{
4346
name: "Repurpose",
4447
description:
4548
"Create a repurposing set showing how the draft can be adapted across channels.",
4649
expectedArtifacts: ["repurposing set"],
50+
specialistId: "content",
4751
},
4852
],
4953
artifactTypes: [

packages/core/src/core/playbooks/manifests/homepage-conversion.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const homepageConversionPlaybook: PlaybookManifest = {
2525
description:
2626
"Analyze homepage across 7 CRO dimensions: value prop clarity, headline effectiveness, CTA placement, visual hierarchy, trust signals, objection handling, friction points.",
2727
expectedArtifacts: ["CRO audit report"],
28+
specialistId: "website-conversion",
2829
},
2930
{
3031
name: "Draft",
@@ -36,17 +37,20 @@ export const homepageConversionPlaybook: PlaybookManifest = {
3637
"trust section rewrite",
3738
"objection handling copy",
3839
],
40+
specialistId: "website-conversion",
3941
},
4042
{
4143
name: "Review",
4244
description:
4345
"Present options with before/after framing; collect founder preferences.",
46+
specialistId: "cmo",
4447
},
4548
{
4649
name: "Refine",
4750
description:
4851
"Deliver final copy and a prioritized page recommendations backlog.",
4952
expectedArtifacts: ["page recommendations backlog"],
53+
specialistId: "website-conversion",
5054
},
5155
],
5256
artifactTypes: [

0 commit comments

Comments
 (0)