mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 02:40: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
|
|
@ -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