mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Add planning mode for issue work (#5353)
## Thinking Path > - Paperclip is a control plane for autonomous AI companies. > - Issues are the core unit of work, and issue comments are how board users and agents coordinate execution. > - Some issue conversations need to produce plans and approvals instead of immediate implementation work. > - The existing issue contract did not distinguish standard execution comments from planning-oriented issue work. > - This pull request adds an issue work-mode contract and board UI affordances for standard vs planning mode. > - The benefit is that planning-mode issues can be created, displayed, discussed, and carried through agent heartbeat context without losing the normal issue workflow. ## What Changed - Added `standard` / `planning` issue work-mode contracts across DB, shared validators/types, server issue flows, plugin protocol, and adapter heartbeat payloads. - Added an idempotent `0081_optimal_dormammu` migration for `issues.work_mode`, ordered after current `public-gh/master` migrations. - Updated heartbeat/context summaries and issue-thread interaction behavior so planning work mode is preserved when creating suggested follow-up issues. - Added UI support for planning-mode issue creation, issue rows, detail composer styling, and composer work-mode toggles. - Added focused server/shared/UI tests plus a Playwright visual verification spec for planning-mode surfaces. - Rebased the branch onto current `public-gh/master` and added durable planning-mode screenshots under `doc/assets/pap-3368/`. ## Verification - `pnpm --filter @paperclipai/db run check:migrations` - `pnpm exec vitest run --project @paperclipai/shared packages/shared/src/validators/issue.test.ts` - `pnpm exec vitest run --project @paperclipai/server server/src/__tests__/heartbeat-context-summary.test.ts server/src/__tests__/issue-thread-interactions-service.test.ts server/src/__tests__/issues-goal-context-routes.test.ts --pool=forks --poolOptions.forks.isolate=true` - `pnpm exec vitest run --project @paperclipai/ui ui/src/components/IssueChatThread.test.tsx ui/src/components/NewIssueDialog.test.tsx ui/src/components/IssueRow.test.tsx ui/src/pages/IssueDetail.test.tsx` - `pnpm exec vitest run --project @paperclipai/adapter-utils packages/adapter-utils/src/server-utils.test.ts` - `PAPERCLIP_E2E_SKIP_LLM=true npx playwright test --config tests/e2e/playwright.config.ts tests/e2e/planning-mode-visual-verification.spec.ts` ## Screenshots Desktop planning detail:  Desktop planning row:  Desktop staged standard toggle:  Mobile planning detail:  Mobile planning row:  ## Risks - Medium migration risk: this adds a non-null issue column. The migration uses `ADD COLUMN IF NOT EXISTS` so installations that applied an older branch-local migration number can still apply the final numbered migration safely. - Medium contract risk: issue payloads, plugin payloads, and adapter heartbeat payloads now include work mode; compatibility is handled by defaulting missing values to `standard`. - UI risk is moderate because composer controls changed; focused component tests and visual e2e coverage exercise standard vs planning display and toggle behavior. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent in a local Paperclip worktree, with shell/tool use. Exact context-window size is not exposed in this runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
320fd5d23b
commit
a1b30c9f35
65 changed files with 1539 additions and 214 deletions
|
|
@ -1,9 +1,133 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildPaperclipTaskMarkdown,
|
||||
mergeCoalescedContextSnapshot,
|
||||
summarizeHeartbeatRunContextSnapshot,
|
||||
summarizeHeartbeatRunListResultJson,
|
||||
} from "../services/heartbeat.js";
|
||||
|
||||
describe("buildPaperclipTaskMarkdown", () => {
|
||||
it("adds planning directives for assignment and comment task context", () => {
|
||||
const assignment = buildPaperclipTaskMarkdown({
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-3404",
|
||||
title: "Plan first",
|
||||
workMode: "planning",
|
||||
description: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(assignment).toContain("- Work mode: \"planning\"");
|
||||
expect(assignment).toContain("Make the plan only. Do not write code or perform implementation work.");
|
||||
|
||||
const commentWake = buildPaperclipTaskMarkdown({
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-3404",
|
||||
title: "Plan first",
|
||||
workMode: "planning",
|
||||
description: null,
|
||||
},
|
||||
wakeComment: {
|
||||
id: "comment-1",
|
||||
body: "Please revise the plan.",
|
||||
},
|
||||
});
|
||||
|
||||
expect(commentWake).toContain("Update the plan only. Do not write code or perform implementation work.");
|
||||
|
||||
const acceptedConfirmation = buildPaperclipTaskMarkdown({
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-3404",
|
||||
title: "Plan first",
|
||||
workMode: "planning",
|
||||
description: null,
|
||||
},
|
||||
interaction: {
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
},
|
||||
});
|
||||
|
||||
expect(acceptedConfirmation).toContain("Create child issues from the approved plan only");
|
||||
expect(acceptedConfirmation).not.toContain("Make the plan only.");
|
||||
});
|
||||
|
||||
it("prefers ordinary comment planning guidance over stale accepted confirmation state", () => {
|
||||
const commentWake = buildPaperclipTaskMarkdown({
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-3404",
|
||||
title: "Plan first",
|
||||
workMode: "planning",
|
||||
description: null,
|
||||
},
|
||||
wakeComment: {
|
||||
id: "comment-1",
|
||||
body: "Please revise the plan.",
|
||||
},
|
||||
interaction: {
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
},
|
||||
});
|
||||
|
||||
expect(commentWake).toContain("Update the plan only. Do not write code or perform implementation work.");
|
||||
expect(commentWake).not.toContain("Create child issues from the approved plan only");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeCoalescedContextSnapshot", () => {
|
||||
it("clears stale accepted-plan interaction state when merging a later ordinary comment wake", () => {
|
||||
const merged = mergeCoalescedContextSnapshot(
|
||||
{
|
||||
issueId: "issue-1",
|
||||
interactionId: "interaction-1",
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "accepted",
|
||||
continuationPolicy: "wake_assignee_on_accept",
|
||||
wakeReason: "issue_commented",
|
||||
},
|
||||
{
|
||||
issueId: "issue-1",
|
||||
commentId: "comment-1",
|
||||
wakeCommentId: "comment-1",
|
||||
wakeReason: "issue_commented",
|
||||
},
|
||||
);
|
||||
|
||||
expect(merged.interactionId).toBeUndefined();
|
||||
expect(merged.interactionKind).toBeUndefined();
|
||||
expect(merged.interactionStatus).toBeUndefined();
|
||||
expect(merged.continuationPolicy).toBeUndefined();
|
||||
expect(merged.commentId).toBe("comment-1");
|
||||
expect(merged.wakeCommentId).toBe("comment-1");
|
||||
});
|
||||
|
||||
it("preserves accepted-plan interaction state for the interaction wake itself", () => {
|
||||
const merged = mergeCoalescedContextSnapshot(
|
||||
{
|
||||
issueId: "issue-1",
|
||||
},
|
||||
{
|
||||
issueId: "issue-1",
|
||||
interactionId: "interaction-1",
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "accepted",
|
||||
continuationPolicy: "wake_assignee_on_accept",
|
||||
wakeReason: "issue_commented",
|
||||
},
|
||||
);
|
||||
|
||||
expect(merged.interactionId).toBe("interaction-1");
|
||||
expect(merged.interactionKind).toBe("request_confirmation");
|
||||
expect(merged.interactionStatus).toBe("accepted");
|
||||
expect(merged.continuationPolicy).toBe("wake_assignee_on_accept");
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeHeartbeatRunContextSnapshot", () => {
|
||||
it("keeps only the small retry/linking fields needed by the client", () => {
|
||||
const summarized = summarizeHeartbeatRunContextSnapshot({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
|
|
@ -110,6 +111,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
{
|
||||
clientKey: "root",
|
||||
title: "Create the root follow-up",
|
||||
workMode: "planning",
|
||||
assigneeAgentId,
|
||||
},
|
||||
{
|
||||
|
|
@ -153,6 +155,19 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
|||
status: "todo",
|
||||
}),
|
||||
]);
|
||||
const createdIssueRows = await db
|
||||
.select({
|
||||
title: issues.title,
|
||||
workMode: issues.workMode,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.companyId, companyId));
|
||||
expect(createdIssueRows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ title: "Create the root follow-up", workMode: "planning" }),
|
||||
expect.objectContaining({ title: "Create the nested follow-up", workMode: "standard" }),
|
||||
]),
|
||||
);
|
||||
|
||||
const children = await issuesSvc.list(companyId, { parentId: issueId });
|
||||
expect(children).toHaveLength(1);
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ const legacyProjectLinkedIssue = {
|
|||
title: "Legacy onboarding task",
|
||||
description: "Seed the first CEO task",
|
||||
status: "todo",
|
||||
workMode: "planning",
|
||||
priority: "medium",
|
||||
projectId: "22222222-2222-4222-8222-222222222222",
|
||||
goalId: null,
|
||||
|
|
@ -264,6 +265,7 @@ describe.sequential("issue goal context routes", () => {
|
|||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.issue.goalId).toBe(projectGoal.id);
|
||||
expect(res.body.issue.workMode).toBe("planning");
|
||||
expect(res.body.goal).toEqual(
|
||||
expect.objectContaining({
|
||||
id: projectGoal.id,
|
||||
|
|
|
|||
|
|
@ -1304,6 +1304,7 @@ export function issueRoutes(
|
|||
title: issue.title,
|
||||
description: issue.description,
|
||||
status: issue.status,
|
||||
workMode: issue.workMode,
|
||||
...(blockerAttention ? { blockerAttention } : {}),
|
||||
productivityReview,
|
||||
priority: issue.priority,
|
||||
|
|
|
|||
|
|
@ -1782,6 +1782,7 @@ function enrichWakeContextSnapshot(input: {
|
|||
contextSnapshot.wakeTriggerDetail = triggerDetail;
|
||||
}
|
||||
normalizeModelProfileWakeContext({ contextSnapshot, payload });
|
||||
normalizeInteractionContinuationWakeContext(contextSnapshot, payload);
|
||||
|
||||
return {
|
||||
contextSnapshot,
|
||||
|
|
@ -1792,6 +1793,35 @@ function enrichWakeContextSnapshot(input: {
|
|||
};
|
||||
}
|
||||
|
||||
const INTERACTION_CONTINUATION_CONTEXT_KEYS = [
|
||||
"interactionId",
|
||||
"interactionKind",
|
||||
"interactionStatus",
|
||||
"continuationPolicy",
|
||||
] as const;
|
||||
|
||||
function isInteractionResolutionWakePayload(payload: Record<string, unknown> | null | undefined) {
|
||||
return readNonEmptyString(payload?.mutation) === "interaction";
|
||||
}
|
||||
|
||||
function clearInteractionContinuationWakeContext(contextSnapshot: Record<string, unknown>) {
|
||||
for (const key of INTERACTION_CONTINUATION_CONTEXT_KEYS) {
|
||||
delete contextSnapshot[key];
|
||||
}
|
||||
}
|
||||
|
||||
function hasInteractionContinuationWakeContext(contextSnapshot: Record<string, unknown>) {
|
||||
return INTERACTION_CONTINUATION_CONTEXT_KEYS.some((key) => readNonEmptyString(contextSnapshot[key]));
|
||||
}
|
||||
|
||||
function normalizeInteractionContinuationWakeContext(
|
||||
contextSnapshot: Record<string, unknown>,
|
||||
payload: Record<string, unknown> | null | undefined,
|
||||
) {
|
||||
if (isInteractionResolutionWakePayload(payload)) return;
|
||||
clearInteractionContinuationWakeContext(contextSnapshot);
|
||||
}
|
||||
|
||||
export function mergeCoalescedContextSnapshot(
|
||||
existingRaw: unknown,
|
||||
incoming: Record<string, unknown>,
|
||||
|
|
@ -1811,6 +1841,9 @@ export function mergeCoalescedContextSnapshot(
|
|||
// regenerate any structured payload from those ids.
|
||||
delete merged[PAPERCLIP_WAKE_PAYLOAD_KEY];
|
||||
}
|
||||
if (!hasInteractionContinuationWakeContext(incoming)) {
|
||||
clearInteractionContinuationWakeContext(merged);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
|
|
@ -1833,6 +1866,7 @@ async function buildPaperclipWakePayload(input: {
|
|||
title: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
workMode: string;
|
||||
}
|
||||
| null;
|
||||
}) {
|
||||
|
|
@ -1850,6 +1884,7 @@ async function buildPaperclipWakePayload(input: {
|
|||
title: issues.title,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
workMode: issues.workMode,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId)))
|
||||
|
|
@ -1936,6 +1971,7 @@ async function buildPaperclipWakePayload(input: {
|
|||
title: issueSummary.title,
|
||||
status: issueSummary.status,
|
||||
priority: issueSummary.priority,
|
||||
workMode: issueSummary.workMode,
|
||||
}
|
||||
: null,
|
||||
childIssueSummaries: Array.isArray(input.contextSnapshot.childIssueSummaries)
|
||||
|
|
@ -1955,6 +1991,8 @@ async function buildPaperclipWakePayload(input: {
|
|||
instruction: readNonEmptyString(input.contextSnapshot.livenessContinuationInstruction),
|
||||
}
|
||||
: null,
|
||||
interactionKind: readNonEmptyString(input.contextSnapshot.interactionKind),
|
||||
interactionStatus: readNonEmptyString(input.contextSnapshot.interactionStatus),
|
||||
checkedOutByHarness: input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true,
|
||||
dependencyBlockedInteraction: input.contextSnapshot.dependencyBlockedInteraction === true,
|
||||
treeHoldInteraction: input.contextSnapshot.treeHoldInteraction === true,
|
||||
|
|
@ -2016,12 +2054,17 @@ export function buildPaperclipTaskMarkdown(input: {
|
|||
id: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
workMode?: string | null;
|
||||
description?: string | null;
|
||||
} | null;
|
||||
wakeComment?: {
|
||||
id: string;
|
||||
body: string;
|
||||
} | null;
|
||||
interaction?: {
|
||||
kind?: string | null;
|
||||
status?: string | null;
|
||||
} | null;
|
||||
}) {
|
||||
const quoteTaskScalar = (value: string) => JSON.stringify(value);
|
||||
const fenceTaskText = (value: string) => {
|
||||
|
|
@ -2034,6 +2077,10 @@ export function buildPaperclipTaskMarkdown(input: {
|
|||
};
|
||||
const issue = input.issue;
|
||||
const wakeComment = input.wakeComment ?? null;
|
||||
const acceptedPlanContinuation =
|
||||
!wakeComment &&
|
||||
input.interaction?.kind === "request_confirmation" &&
|
||||
input.interaction.status === "accepted";
|
||||
if (!issue && !wakeComment) return null;
|
||||
|
||||
const lines = [
|
||||
|
|
@ -2045,6 +2092,21 @@ export function buildPaperclipTaskMarkdown(input: {
|
|||
`- Issue: ${quoteTaskScalar(issue.identifier || issue.id)}`,
|
||||
`- Title: ${quoteTaskScalar(issue.title)}`,
|
||||
);
|
||||
if (issue.workMode === "planning") {
|
||||
let directive = "Make the plan only. Do not write code or perform implementation work.";
|
||||
if (wakeComment) {
|
||||
directive = "Update the plan only. Do not write code or perform implementation work.";
|
||||
}
|
||||
if (acceptedPlanContinuation) {
|
||||
directive = "Create child issues from the approved plan only. Do not write code or perform implementation work on the planning issue.";
|
||||
}
|
||||
lines.push(
|
||||
`- Work mode: ${quoteTaskScalar("planning")}`,
|
||||
"",
|
||||
"Planning mode directive:",
|
||||
directive,
|
||||
);
|
||||
}
|
||||
const description = issue.description?.trim();
|
||||
if (description) {
|
||||
lines.push("", "Issue description:", fenceTaskText(description));
|
||||
|
|
@ -2328,6 +2390,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
title: issues.title,
|
||||
description: issues.description,
|
||||
status: issues.status,
|
||||
workMode: issues.workMode,
|
||||
priority: issues.priority,
|
||||
projectId: issues.projectId,
|
||||
projectWorkspaceId: issues.projectWorkspaceId,
|
||||
|
|
@ -6564,6 +6627,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
title: issueContext.title,
|
||||
status: issueContext.status,
|
||||
priority: issueContext.priority,
|
||||
workMode: issueContext.workMode,
|
||||
description: issueContext.description,
|
||||
projectId: issueContext.projectId,
|
||||
projectWorkspaceId: issueContext.projectWorkspaceId,
|
||||
|
|
@ -6596,6 +6660,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
title: issueRef.title,
|
||||
status: issueRef.status,
|
||||
priority: issueRef.priority,
|
||||
workMode: issueRef.workMode,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
|
@ -6610,10 +6675,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
id: issueRef.id,
|
||||
identifier: issueRef.identifier,
|
||||
title: issueRef.title,
|
||||
workMode: issueRef.workMode,
|
||||
description: issueRef.description,
|
||||
}
|
||||
: null,
|
||||
wakeComment: wakeCommentContext,
|
||||
interaction: {
|
||||
kind: readNonEmptyString(context.interactionKind),
|
||||
status: readNonEmptyString(context.interactionStatus),
|
||||
},
|
||||
});
|
||||
if (issueRef) {
|
||||
context.paperclipIssue = {
|
||||
|
|
@ -6621,6 +6691,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
identifier: issueRef.identifier,
|
||||
title: issueRef.title,
|
||||
description: issueRef.description,
|
||||
workMode: issueRef.workMode,
|
||||
};
|
||||
} else {
|
||||
delete context.paperclipIssue;
|
||||
|
|
@ -8764,7 +8835,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
if (coalescedTargetRun) {
|
||||
const mergedContextSnapshot = mergeCoalescedContextSnapshot(
|
||||
coalescedTargetRun.contextSnapshot,
|
||||
contextSnapshot,
|
||||
enrichedContextSnapshot,
|
||||
);
|
||||
const mergedRun = await db
|
||||
.update(heartbeatRuns)
|
||||
|
|
|
|||
|
|
@ -839,6 +839,7 @@ export function issueThreadInteractionService(db: Db) {
|
|||
title: task.title,
|
||||
description: task.description ?? null,
|
||||
status: "todo",
|
||||
workMode: task.workMode ?? "standard",
|
||||
priority: task.priority ?? "medium",
|
||||
assigneeAgentId: task.assigneeAgentId ?? null,
|
||||
assigneeUserId: task.assigneeUserId ?? null,
|
||||
|
|
|
|||
|
|
@ -1430,6 +1430,7 @@ const issueListSelect = {
|
|||
END
|
||||
`,
|
||||
status: issues.status,
|
||||
workMode: issues.workMode,
|
||||
priority: issues.priority,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export interface ExecutionWorkspaceIssueRef {
|
|||
id: string;
|
||||
identifier: string | null;
|
||||
title: string | null;
|
||||
workMode?: string | null;
|
||||
}
|
||||
|
||||
export interface ExecutionWorkspaceAgentRef {
|
||||
|
|
@ -712,6 +713,7 @@ function buildWorkspaceCommandEnv(input: {
|
|||
env.PAPERCLIP_ISSUE_ID = input.issue?.id ?? "";
|
||||
env.PAPERCLIP_ISSUE_IDENTIFIER = input.issue?.identifier ?? "";
|
||||
env.PAPERCLIP_ISSUE_TITLE = input.issue?.title ?? "";
|
||||
env.PAPERCLIP_ISSUE_WORK_MODE = input.issue?.workMode ?? "";
|
||||
return env;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue