Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions src/application/kapi-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { scanAutoresearchAntiGaming, type AntiGamingFlag } from "./autoresearch-
import { validateExperimentContract } from "./autoresearch-contract.js";
import type { Clock, CompleteWorkflowInput, DispatchWorkerTaskInput, EvidenceInput, FailWorkflowInput, KapiStore, PlanWorkerInput, PlannedWorkerStrategy, PrepareWorkerInput, ProjectContextProvider, ReadArtifactInput, RefreshWorkerStatusInput, StartWorkflowInput, UpdateWorkflowInput, WorkerCapability, WorkerCapabilityProvider, WorkerKind, WorkerMonitor, WorkerPrepareResult, WorkerStatusSnapshot, WorkerTaskDispatcher, WorkerTaskDispatchResult, WorkerWorkspaceCloser, WorkerWorkspacePreparer, WriteArtifactInput, WriteArtifactsInput } from "./ports.js";
import { EmptyProjectContextProvider, EmptyWorkerCapabilityProvider, SystemClock } from "./service-defaults.js";
import { addEvidence, assertCompletionEvidence, compactGeneratedSlug, createWorkflowState, getWorkflowDefinition, isTerminalStatus, planWorkerStrategy, slugify, summarizeWorkflow, transitionWorkflow, validateWorkflowState, WORKFLOW_DEFINITIONS, type WorkflowDefinition, type WorkflowId, type WorkflowState, type WorkflowTransitionRequest, type WorkflowValidationReport } from "./service-domain.js";
import { addEvidence, assertCompletionEvidence, compactGeneratedSlug, createWorkflowState, getWorkflowDefinition, isTerminalStatus, planWorkerStrategy, renderRunContractPrompt, slugify, transitionWorkflow, validateWorkflowState, WORKFLOW_DEFINITIONS, type WorkflowDefinition, type WorkflowId, type WorkflowState, type WorkflowTransitionRequest, type WorkflowValidationReport } from "./service-domain.js";
import { buildDeepInterviewSnapshot } from "./deep-interview/snapshot.js";
import { decideAutoresearchStatus, nextBestAutoresearchMetric, type AutoresearchDirection } from "../domain/autoresearch-policy.js";
import type { DeepInterviewReviewer } from "./deep-interview/reviewer.js";
Expand Down Expand Up @@ -442,14 +442,24 @@ export class KapiService {
const workerSummary = capabilities.length
? capabilities.map((capability) => `${capability.kind}:${capability.available ? "available" : "missing"}${capability.reason ? ` (${capability.reason})` : ""}`).join(", ")
: "not inspected";

const artifactGuidance = definition.artifactGuidance.length ? `\n- ${definition.artifactGuidance.join("\n- ")}` : "";
const reviewGuidance = state.workflowId === "kapi-ralph"
? "\n- /kapi-ralph completion must be backed by a pi-subagents reviewer subagent verdict; parent self-review is not sufficient."
: "";
const resumeGuidance = resumeNotes.length ? `\n\nResume notes:\n- ${resumeNotes.join("\n- ")}` : "";
return `[KAPI ${mode.toUpperCase()}] ${definition.command} — ${definition.title}\n\nTask:\n${state.task}\n\nWorkflow contract:\n${definition.promptContract}\n\nArtifact cadence:\n- ${definition.artifactCadence}\n- Artifacts are durable checkpoints, not conversational scratchpads; do not write them after every turn unless the workflow cadence explicitly requires it.${artifactGuidance}\n\nShared lifecycle:\n- Current status: ${state.status}\n- Current phase: ${state.phase}\n- Active artifact root: ${state.artifactRoot}\n- Completion gate: ${definition.completionGate.join(" ")}\n\nProject context snapshot:\n${projectFacts}\n\nWorker capability snapshot:\n${workerSummary}\n\nKapi rules:\n- Keep ordinary Pi behavior lightweight; only this explicit workflow is active.\n- Use the listed artifacts only when they help this workflow stay resumable or verifiable.\n- Update evidence before claiming completion.\n- If this workflow is too heavy or too light for the task, recommend the next /kapi-* workflow instead of silently changing the workflow.
- When inspecting a terminal workflow, preserve any unrelated active workflow; terminal resume is inspection, not ownership transfer.${reviewGuidance}${resumeGuidance}\n\n${summarizeWorkflow(state)}`;
? ["/kapi-ralph completion must be backed by a pi-subagents reviewer subagent verdict; parent self-review is not sufficient."]
: [];

return renderRunContractPrompt({
mode,
layout: "workflow",
definition,
task: state.task,
status: state.status,
phase: state.phase,
artifactRoot: state.artifactRoot,
projectContextSnapshot: projectFacts,
workerCapabilitySnapshot: workerSummary,
additionalRuleLines: reviewGuidance,
resumeNotes,
summaryState: state,
});
}


Expand Down
1 change: 1 addition & 0 deletions src/application/service-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export { addEvidence, assertCompletionEvidence, compactGeneratedSlug, createWork
export type { WorkflowDefinition, WorkflowId, WorkflowState, WorkflowTransitionRequest } from "../domain/types.js";
export { validateWorkflowState, type WorkflowValidationReport } from "../domain/workflow-validation.js";
export { getWorkflowDefinition, WORKFLOW_DEFINITIONS } from "../domain/workflows.js";
export { renderRunContractPrompt } from "../domain/run-contract-prompt-renderer.js";
export { planWorkerStrategy } from "../domain/workers.js";
29 changes: 19 additions & 10 deletions src/cli/kapi-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,9 @@ import { formatGitHubWorkflowRunContractAdapter, mapWorkflowStateToGitHubWorkflo
import type { WorkerPrepareResult, WorkerWorkspacePreparer } from "../application/ports.js";
import { branchPrefixForWorkflow, promptKindForWorkflow, type GitHubIssueContextProbe, type KapiRegistryEntry, type RegistryListWarning, type RuntimePlan, workflowShortName } from "../domain/registry.js";
import { buildQualityProbeMatrix, formatQualityProbeMatrix, type QualityMode, type QualityProbeMatrix } from "../domain/quality-probe.js";
import type { WorkflowId, WorkflowState } from "../domain/types.js";
import { validateWorkflowState, type WorkflowValidationReport } from "../domain/workflow-validation.js";
import { WORKFLOW_DEFINITIONS } from "../domain/workflows.js";
import { renderRunContractPrompt } from "../domain/run-contract-prompt-renderer.js";
import { slugify, validateWorkflowState, WORKFLOW_DEFINITIONS, type WorkflowId, type WorkflowState, type WorkflowValidationReport } from "../application/service-domain.js";
import { fs, isNotFoundError, path } from "../adapters/fs-path.js";
import { slugify } from "../domain/state-machine.js";

const execFileAsync = promisify(execFile);

Expand Down Expand Up @@ -299,12 +297,23 @@ function markRegistryActive(entry: KapiRegistryEntry, worker: WorkerPrepareResul

function buildRuntimePrompt(plan: RuntimePlan, goal: string, entry: KapiRegistryEntry): string {
const workflow = WORKFLOW_DEFINITIONS.find((definition) => definition.id === plan.workflowId);
const command = workflow?.command ?? `/${plan.workflowId}`;
const title = workflow?.title ?? plan.workflowId;
const contract = workflow?.promptContract ?? "Follow the active Kapi workflow contract.";
const completionGate = workflow?.completionGate.join(" ") ?? "Record evidence before completion.";
const artifactGuidance = workflow?.artifactGuidance.length ? `\nArtifact guidance:\n- ${workflow.artifactGuidance.join("\n- ")}` : "";
return `[KAPI START] ${command} — ${title}\n\nTask:\n${goal}\n\nRuntime:\n- Slug: ${plan.slug}\n- Worktree: ${entry.worktreePath ?? plan.worktreePath}\n- Branch: ${entry.branch ?? plan.branch}\n- Tmux: ${entry.tmuxSession ?? plan.tmuxSession}\n- Prompt kind: ${plan.promptKind}\n\nShared lifecycle:\n- Current status: ${entry.status}\n- Current phase: ${entry.phase}\n- Active artifact root:\n${entry.artifactRoot ?? plan.artifactRoot}\n- Completion gate: ${completionGate}\n\nWorkflow contract:\n${contract}\n\nKapi rules:\n- Treat this worktree as the execution truth for source changes and workflow artifacts.\n- Keep the original repository registry as a pointer/control plane only.\n- Do not begin build/run work until the planning approval gate is satisfied.\n- Keep durable artifacts current at phase boundaries and before completion claims.${artifactGuidance}${formatGitHubIssuePromptContext(entry.githubIssueContext)}`;
return `${renderRunContractPrompt({
mode: "start",
layout: "runtime",
definition: workflow,
workflowId: plan.workflowId,
task: goal,
status: entry.status,
phase: entry.phase,
artifactRoot: entry.artifactRoot ?? plan.artifactRoot,
runtime: {
slug: plan.slug,
worktree: entry.worktreePath ?? plan.worktreePath,
branch: entry.branch ?? plan.branch,
tmuxSession: entry.tmuxSession ?? plan.tmuxSession,
promptKind: plan.promptKind,
},
})}${formatGitHubIssuePromptContext(entry.githubIssueContext)}`;
}

function appendPromptContext(prompt: string, append: string): string {
Expand Down
67 changes: 67 additions & 0 deletions src/domain/run-contract-prompt-renderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { summarizeWorkflow } from "./state-machine.js";
import type { WorkflowDefinition, WorkflowState, WorkflowId } from "./types.js";

export interface RuntimePromptContext { slug: string; worktree: string; branch: string; tmuxSession: string; promptKind: string }

export interface RenderRunContractPromptOptions {
mode?: "start" | "resume";
definition?: WorkflowDefinition;
workflowId?: WorkflowId | string;
task: string;
status: string;
phase: string;
artifactRoot: string;
layout?: "workflow" | "runtime";
projectContextSnapshot?: string;
workerCapabilitySnapshot?: string;
runtime?: RuntimePromptContext;
additionalRuleLines?: readonly string[];
resumeNotes?: readonly string[];
summaryState?: WorkflowState;
}

export function renderRunContractPrompt(options: RenderRunContractPromptOptions): string {
const definition = options.definition;
const title = definition?.title ?? String(options.workflowId ?? "workflow");
const command = definition?.command ?? `/${String(options.workflowId ?? "workflow")}`;
const contract = definition?.promptContract ?? "Follow the active Kapi workflow contract.";
const completionGate = definition?.completionGate.join(" ") ?? "Record evidence before completion.";
const artifactGuidance = definition?.artifactGuidance ?? [];
const layout = options.layout ?? "workflow";
const sections = [`[KAPI ${(options.mode ?? "start").toUpperCase()}] ${command} — ${title}`, `Task:\n${options.task}`];
if (layout === "runtime" && options.runtime) sections.push(`Runtime:\n- Slug: ${options.runtime.slug}\n- Worktree: ${options.runtime.worktree}\n- Branch: ${options.runtime.branch}\n- Tmux: ${options.runtime.tmuxSession}\n- Prompt kind: ${options.runtime.promptKind}`);
if (layout === "workflow") {
sections.push(`Workflow contract:\n${contract}`);
sections.push(`Artifact cadence:\n- ${definition?.artifactCadence ?? "Keep durable artifacts current."}\n- Artifacts are durable checkpoints, not conversational scratchpads; do not write them after every turn unless the workflow cadence explicitly requires it.${artifactGuidance.length ? `\n- ${artifactGuidance.join("\n- ")}` : ""}`);
}
const artifactRoot = layout === "runtime" ? `Active artifact root:\n${options.artifactRoot}` : `Active artifact root: ${options.artifactRoot}`;
sections.push(`Shared lifecycle:\n- Current status: ${options.status}\n- Current phase: ${options.phase}\n- ${artifactRoot}\n- Completion gate: ${completionGate}`);
if (layout === "runtime") sections.push(`Workflow contract:\n${contract}`);
if (layout === "workflow" && options.projectContextSnapshot !== undefined) sections.push(`Project context snapshot:\n${options.projectContextSnapshot}`);
if (layout === "workflow" && options.workerCapabilitySnapshot !== undefined) sections.push(`Worker capability snapshot:\n${options.workerCapabilitySnapshot}`);
const rules = layout === "runtime" ? runtimeRuleLines(options.additionalRuleLines) : workflowRuleLines(options.additionalRuleLines);
sections.push(`Kapi rules:\n- ${rules.join("\n- ")}${layout === "runtime" && artifactGuidance.length ? `\nArtifact guidance:\n- ${artifactGuidance.join("\n- ")}` : ""}${options.resumeNotes?.length ? `\n\nResume notes:\n- ${options.resumeNotes.join("\n- ")}` : ""}`);
if (options.summaryState) sections.push(summarizeWorkflow(options.summaryState));
return sections.join("\n\n");
}

function workflowRuleLines(additional: readonly string[] = []): string[] {
return [
"Keep ordinary Pi behavior lightweight; only this explicit workflow is active.",
"Use the listed artifacts only when they help this workflow stay resumable or verifiable.",
"Update evidence before claiming completion.",
"If this workflow is too heavy or too light for the task, recommend the next /kapi-* workflow instead of silently changing the workflow.",
"When inspecting a terminal workflow, preserve any unrelated active workflow; terminal resume is inspection, not ownership transfer.",
...additional,
];
}

function runtimeRuleLines(additional: readonly string[] = []): string[] {
return [
"Treat this worktree as the execution truth for source changes and workflow artifacts.",
"Keep the original repository registry as a pointer/control plane only.",
"Do not begin build/run work until the planning approval gate is satisfied.",
"Keep durable artifacts current at phase boundaries and before completion claims.",
...additional,
];
}
36 changes: 36 additions & 0 deletions test/run-contract-prompt-renderer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { KapiService } from "../src/application/kapi-service.js";
import { renderRunContractPrompt } from "../src/domain/run-contract-prompt-renderer.js";
import { createWorkflowState } from "../src/domain/state-machine.js";
import { WORKFLOW_DEFINITIONS } from "../src/domain/workflows.js";

const ralph = WORKFLOW_DEFINITIONS.find((workflow) => workflow.id === "kapi-ralph");
if (!ralph) throw new Error("missing Ralph workflow definition");

const store = {
async loadActive() { return undefined; }, async saveWorkflow() {}, async loadWorkflow() { return undefined; }, async listWorkflows() { return []; },
async setActive() {}, async clearActive() {}, async ensureArtifacts() {}, async appendArtifact() {}, async writeArtifact() {},
async readArtifact() { return undefined; }, async artifactExists() { return false; },
async artifactMetadata() { return { exists: false, isFile: false, isSymlink: false, executable: false }; },
};

test("service workflow prompt is rendered through the shared RunContract prompt renderer", async () => {
const state = createWorkflowState({ workflowId: "kapi-ralph", task: "Unify prompt rendering", workspace: "/tmp/kapi-shared-service-prompt", slug: "prompt-renderer", now: "2026-05-15T00:00:00.000Z" });
const service = new KapiService(store, undefined, { async describe() { return "README.md: present\nverification scripts: test, check"; } }, { async detect() { return [{ kind: "terminal", available: true }]; } });
const expected = renderRunContractPrompt({ mode: "resume", layout: "workflow", definition: ralph, task: state.task, status: state.status, phase: state.phase, artifactRoot: state.artifactRoot, projectContextSnapshot: "README.md: present\nverification scripts: test, check", workerCapabilitySnapshot: "terminal:available", additionalRuleLines: ["/kapi-ralph completion must be backed by a pi-subagents reviewer subagent verdict; parent self-review is not sufficient."], summaryState: state });

assert.equal(await service.buildWorkflowPrompt(state, "resume"), expected);
assert.match(expected, /Project context snapshot:\nREADME\.md: present/);
assert.match(expected, /Worker capability snapshot:\nterminal:available/);
});

test("runtime prompt layout uses the same renderer without adapter-specific semantics", () => {
const prompt = renderRunContractPrompt({ mode: "start", layout: "runtime", definition: ralph, task: "Implement issue #115", status: "active", phase: "planning", artifactRoot: "/worktree/.kapi/workflows/ralph/prompt-renderer", runtime: { slug: "prompt-renderer", worktree: "/worktree", branch: "feat/prompt-renderer", tmuxSession: "prompt-renderer", promptKind: "ralplan" } });

assert.match(prompt, /\[KAPI START\] \/kapi-ralph — Ralph/);
assert.match(prompt, /Runtime:\n- Slug: prompt-renderer/);
assert.match(prompt, /Workflow contract:\nUse the kapi-ralph skill/);
assert.match(prompt, /Artifact guidance:\n- Persist planning\/build transitions/);
assert.doesNotMatch(prompt, /GitHub|pullRequest|kapiAgent|Ragna|Discord/);
});
Loading