mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
fix(onboarding): show native proposal confirmation recipes (#18025)
## Thinking Path > - Paperclip lets people manage agent work through tasks. > - First-task onboarding asks a new agent to propose work before it starts. > - The user must accept a saved proposal before the agent can create its child task. > - A native agent can ask for approval in a question card, which does not record proposal acceptance. > - The skill names confirmation cards but does not show their native tool arguments. > - This change adds executable native proposal recipes and checks their real approval path. > - The task admission rules and Product E2E assertions stay in place. ## Linked Issues or Issue Description **What happened?** A fresh onboarding agent asked the user to approve a child task through an Approve/Revise question. The question looked like a decision, but its answer cannot authorize child creation. The first-task skill teaches native question calls for interviews and names proposal confirmation cards without showing the native mapping. **Expected behavior** The agent should create a confirmation card for a single-task proposal, or a checkbox card bound to the saved plan revision. Only the saved human acceptance should admit the child task. **Steps to reproduce** 1. Start first-task onboarding with a native agent. 2. Choose one task and ask the agent to propose a child before it starts work. 3. Have the agent use a question card with Approve/Revise options. 4. Answer Approve. Child creation remains blocked because no proposal confirmation was accepted. Related public context: #17940, #13517. ## What Changed - Show the native `request_human_input` arguments for confirmation and plan-bound checkbox proposals. - Explain that question answers supply scope and cannot grant proposal authority. - Resume the agent after either acceptance or rejection, and use new proposal keys for revisions. - Parse the shipped recipes and execute them through the real native tool authority and interaction service. - Verify that pending proposals, agent-only acceptance, and an answered Approve/Revise question cannot admit child work. - Use the existing isolated PostgreSQL test helper when a local test database is supplied. - Fix the required clean Grok npm-install check to prefix synthetic revision identifiers with `g`. The former all-numeric, leading-zero identifier was invalid SemVer and caused a reproduced `ETARGET` failure. Customer release versions are unchanged. ## Verification - Red: both new executable recipe checks fail against the preceding skill. - Green: 44 focused tests pass with zero skips against a task-owned PostgreSQL server. The actual tool authority and approval service are exercised. - Human acceptance of either recipe admits the child. Agent-only acceptance does not. Answering the question leaves the issue set unchanged. - Red/green real HTTP checks prove rejection saves the assignee continuation, starts no extra model run, and still denies child creation. - `git diff --check` passes. - Full repository `pnpm -r typecheck` and `pnpm build` passed on the final approval-guidance revision. - The first full local test attempt used an unmigrated external fixture and was stopped after the cause was confirmed. A new migrated fixture resolved that setup error; the second full attempt reached its explicit 10-minute limit before finishing. Both attempts are preserved and are not claimed as passing. The temporary owned PostgreSQL container was removed. The complete Linux CI workflow passed on the final PR head. - The exact synthetic-version failure was reproduced with real npm and an empty local packed registry. The new maintained regression keeps the invalid-version negative control and installs corrected numeric-hash and normal-hash controls. All 27 existing Grok helper tests and three offline registry-boundary checks pass. - Final head `52d0b92a00dcb0653b6fb48415cfaea979b8f21f`: [CI 40706933035, attempt 1](https://github.com/paperclipai/paperclip/actions/runs/40706933035) passed; 55 successful checks, two skipped and one neutral. Greptile is 5/5 for this exact head with no unresolved threads. The original failed build remains preserved. - Live fresh-workspace acceptance has not passed yet. It follows the reviewed testing rollout; this source test does not prove model compliance. ## Risks The change guides model tool selection. An agent can still ignore the skill. Product admission remains the authority, and the live journey must still verify human approval, native child execution, downloaded artifact bytes, context reuse, and cleanup. There is no schema or API change. The CI helper changes only temporary verifier versions and preserves the existing registry integrity, install, lifecycle, and isolation checks. ## Model Used OpenAI Codex `gpt-6.1-sol`, with reasoning, source inspection, tool use, and code execution. The host does not expose its configured context-window size. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
1 parent
8049fbf51d
commit
b25f3e2af8
5 files changed
+167
-8
No files matched your search
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
import { grokPublicInstallVersion } from '../grok-public-install-sandbox.mjs';
|
||||
import { startPackedRegistry, writePackedRegistryCatalog } from '../public-install-registry.mjs';
|
||||
|
||||
const script = fileURLToPath(new URL('../public-install-registry.mjs', import.meta.url));
|
||||
@@ -15,8 +16,8 @@ const scratch = async body => {
|
||||
};
|
||||
const environment = root => ({ ...process.env, HOME: root, npm_config_cache: join(root, 'cache'),
|
||||
npm_config_userconfig: join(root, 'no-npmrc'), npm_config_audit: 'false', npm_config_fund: 'false', npm_config_update_notifier: 'false', npm_config_fetch_retries: '0', npm_config_fetch_timeout: '10000' });
|
||||
const run = (command, args, cwd) => new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, { cwd, env: environment(cwd), stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const run = (command, args, cwd, env = environment(cwd)) => new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let output = '';
|
||||
for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => { output += chunk; });
|
||||
child.once('error', reject);
|
||||
@@ -59,6 +60,37 @@ const installedFiles = directory => {
|
||||
return entries.sort(([a], [b]) => a.localeCompare(b));
|
||||
};
|
||||
|
||||
test('synthetic Grok versions install from packed bytes even when the hash starts with leading-zero digits', { timeout: 30_000 }, async () => scratch(async root => {
|
||||
const sourceRevision = '096091501644cf20f2e35d1ab02b28c35579b973';
|
||||
const cases = [
|
||||
{ version: `0.0.0-grok-verify.${sourceRevision.slice(0, 12)}`, rejected: true },
|
||||
{ version: grokPublicInstallVersion(sourceRevision) },
|
||||
{ version: grokPublicInstallVersion('8fd48a8d52038fd0d8b286f3742ea13ce40a7ab9') },
|
||||
];
|
||||
for (const [index, { version, rejected }] of cases.entries()) {
|
||||
const cwd = join(root, String(index)), source = join(cwd, 'source'), assets = join(cwd, 'assets'), consumer = join(cwd, 'consumer');
|
||||
for (const directory of [join(source, 'package'), assets, consumer]) mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(join(source, 'package/package.json'), JSON.stringify({ name: '@ci/grok-version', version }));
|
||||
execFileSync('tar', ['-czf', join(assets, 'package.tgz'), '-C', source, 'package']);
|
||||
await writePackedRegistryCatalog(assets);
|
||||
writeFileSync(join(consumer, 'package.json'), '{"private":true}');
|
||||
const userconfig = join(cwd, 'user.npmrc'), globalconfig = join(cwd, 'global.npmrc');
|
||||
writeFileSync(userconfig, ''); writeFileSync(globalconfig, '');
|
||||
const env = { PATH: process.env.PATH, HOME: cwd, npm_config_cache: join(cwd, 'cache'),
|
||||
npm_config_userconfig: userconfig, npm_config_globalconfig: globalconfig,
|
||||
npm_config_audit: 'false', npm_config_fund: 'false', npm_config_update_notifier: 'false', npm_config_fetch_retries: '0' };
|
||||
const registry = await startPackedRegistry(assets);
|
||||
try {
|
||||
const install = run('npm', ['install', '--registry', registry.url, '--replace-registry-host=never', '--ignore-scripts', '--omit=dev', ...registry.specs], consumer, env);
|
||||
if (rejected) await assert.rejects(install, /ETARGET/);
|
||||
else {
|
||||
await install;
|
||||
assert.equal(JSON.parse(readFileSync(join(consumer, 'node_modules/@ci/grok-version/package.json'))).version, version);
|
||||
}
|
||||
} finally { await registry.close(); }
|
||||
}
|
||||
}));
|
||||
|
||||
test('real npm installs identical packed bytes, bundles, optional dependencies and bins without running scripts early', { timeout: 90_000 }, async () => scratch(async root => {
|
||||
const assets = fixture(root, { publicDependency: true }), catalog = await writePackedRegistryCatalog(assets);
|
||||
assert.ok(catalog.every(entry => entry.manifest.version === '1.0.0'), 'metadata must come from packed bytes, not changed source');
|
||||
|
||||
@@ -11,6 +11,12 @@ export const GROK_PUBLIC_INSTALL_LIFECYCLE = [
|
||||
'npm', 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts',
|
||||
];
|
||||
|
||||
export function grokPublicInstallVersion(sourceRevision) {
|
||||
assert.match(sourceRevision, /^[a-f0-9]{40}$/);
|
||||
// An all-numeric hash with a leading zero is not a valid numeric prerelease.
|
||||
return `0.0.0-grok-verify.g${sourceRevision.slice(0, 12)}`;
|
||||
}
|
||||
|
||||
export function runMacInitialInstallWithDiagnostics(install, { sourceRevision, platform, architecture, cacheDirectory, receiptPath }) {
|
||||
const started = performance.now();
|
||||
try {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { packConcurrency, packStagedPackages, packTimings } from './pack-staged-packages.mjs';
|
||||
import { materializePublishManifest } from './prepare-bundled-package.mjs';
|
||||
import { publicInstallClosure } from './public-install-closure.mjs';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedClaudeProbeSource, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource, runMacInitialInstallWithDiagnostics } from './grok-public-install-sandbox.mjs';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, grokPublicInstallVersion, installedClaudeProbeSource, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource, runMacInitialInstallWithDiagnostics } from './grok-public-install-sandbox.mjs';
|
||||
import { startPrefetch, stopPrefetches } from './prefetch-child.mjs';
|
||||
import { retainRunnerQualificationPackages } from './retain-runner-qualification-packages.mjs';
|
||||
import { bundledInstallTimings, startBundledInstalls } from './stage-bundled-packages.mjs';
|
||||
@@ -204,7 +204,7 @@ mkdirSync(cache, { recursive: true });
|
||||
const env = { ...process.env, NODE_PATH: '', ...(process.env.PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE ? { npm_config_cache: cache } : {}), PAPERCLIP_RELEASE_REUSE_UI_DIST: '1', npm_config_ignore_scripts: 'false', npm_config_audit: 'false', npm_config_fund: 'false' };
|
||||
const run = (cmd, args, cwd = root) => execFileSync(cmd, args, { cwd, env, stdio: 'pipe', maxBuffer: 32 * 1024 * 1024 });
|
||||
const sourceRevision = run('git', ['rev-parse', 'HEAD'], repo).toString().trim();
|
||||
const releaseVersion = `0.0.0-grok-verify.${sourceRevision.slice(0, 12)}`;
|
||||
const releaseVersion = grokPublicInstallVersion(sourceRevision);
|
||||
// The two network downloads the probes need do not depend on anything packed
|
||||
// below, so they overlap the CPU-bound staging instead of running after it.
|
||||
// The consumer image is pinned by digest and the Grok binary is checked against
|
||||
|
||||
@@ -1,23 +1,41 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { authUsers, companyMemberships, heartbeatRuns, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
|
||||
import { agentWakeupRequests, authUsers, companyMemberships, heartbeatRuns, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
|
||||
import { createIssueThreadInteractionSchema } from "@paperclipai/shared";
|
||||
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
|
||||
import { documentService } from "../services/documents.js";
|
||||
import { resolveConfirmationFromComment } from "../services/confirmation-comment-resolution.js";
|
||||
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
|
||||
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js";
|
||||
import { recoveryPostgresSupport as support } from "./helpers/recovery-postgres.js";
|
||||
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
async function nativeProposalRecipe(kind: "confirmation" | "checkbox", planRevisionId = randomUUID()) {
|
||||
const skill = await readFile(new URL("../onboarding-assets/first-task/skills/first-task/SKILL.md", import.meta.url), "utf8");
|
||||
const recipes = [...skill.matchAll(/^ {0,3}```json\n([\s\S]*?)^ {0,3}```/gm)].map(match => JSON.parse(match[1]!) as Record<string, unknown>);
|
||||
const matches = recipes.filter(recipe => recipe.interactionKind === kind);
|
||||
expect(matches, `one executable native ${kind} recipe must ship with the first-task skill`).toHaveLength(1);
|
||||
return JSON.parse(JSON.stringify(matches[0]).replaceAll("<child-title>", "Approved child")
|
||||
.replaceAll("<plan-revision-id>", planRevisionId)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
it.each(["confirmation", "checkbox"] as const)("ships a native %s recipe accepted by the production interaction schema", async kind => {
|
||||
const recipe = await nativeProposalRecipe(kind);
|
||||
const parsed = createIssueThreadInteractionSchema.parse({ kind: kind === "confirmation" ? "request_confirmation" : "request_checkbox_confirmation",
|
||||
idempotencyKey: recipe.idempotencyKey, title: recipe.title, continuationPolicy: recipe.continuationPolicy,
|
||||
payload: { ...(recipe.payload as Record<string, unknown>), version: 1, prompt: recipe.prompt } });
|
||||
expect(parsed.kind).toBe(kind === "confirmation" ? "request_confirmation" : "request_checkbox_confirmation");
|
||||
});
|
||||
|
||||
if (!support.supported) console.warn(`Skipping first-task admission database tests: ${support.reason}`);
|
||||
describe.skipIf(!support.supported)("onboarding first-task execution admission", () => {
|
||||
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
|
||||
const originalSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
beforeAll(async () => {
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID();
|
||||
server = await startRunnerApiTestServer();
|
||||
server = await startRunnerApiTestServer({ humanSessions: true });
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await server?.close();
|
||||
@@ -86,6 +104,72 @@ describe.skipIf(!support.supported)("onboarding first-task execution admission",
|
||||
await p.svc.acceptInteraction(p.issue, p.card.id, p.plan ? { selectedOptionIds: ["execute"] } : {}, { userId: f.responsibleUserId! });
|
||||
};
|
||||
|
||||
it("an explicit Approve/Revise question remains input rather than proposal authority after the human answers", async () => {
|
||||
const f = await fixture();
|
||||
const issueIds = (await f.snapshot()).issues.map(issue => issue.id).sort();
|
||||
const { interaction } = await f.authority.execute({ tool: "request_human_input", callId: randomUUID(), arguments: {
|
||||
idempotencyKey: "first-task-proposal-1", interactionKind: "questions", title: "Approve child task",
|
||||
prompt: "I propose exactly one child task and will wait for approval.", continuationPolicy: "wake_assignee",
|
||||
payload: { questionSet: { schema: "paperclip.question_set.v1", questions: [{ id: "approve_child_task",
|
||||
prompt: 'Approve exactly one child task named "Approved child"?', required: true, answerMode: "single_select",
|
||||
options: [{ id: "approve", label: "Approve" }, { id: "revise", label: "Revise" }] }] } },
|
||||
} }) as { interaction: { id: string; kind: string } };
|
||||
expect(interaction.kind).toBe("ask_user_questions");
|
||||
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
|
||||
const answered = await issueThreadInteractionService(server.db).answerQuestions({ id: f.issueId, companyId: f.companyId }, interaction.id,
|
||||
{ answers: [{ questionId: "approve_child_task", optionIds: ["approve"] }] }, { userId: f.responsibleUserId! });
|
||||
expect(answered).toMatchObject({ status: "answered", resolvedByUserId: f.responsibleUserId,
|
||||
result: { answers: [{ questionId: "approve_child_task", optionIds: ["approve"] }] } });
|
||||
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
|
||||
expect((await f.snapshot()).issues.map(issue => issue.id).sort()).toEqual(issueIds);
|
||||
});
|
||||
|
||||
it.each(["confirmation", "checkbox"] as const)("the shipped native %s recipe requires a human decision before it admits child work", async kind => {
|
||||
for (const actor of ["human", "agent"] as const) {
|
||||
const f = await fixture();
|
||||
const plan = kind === "checkbox" ? (await documentService(server.db).upsertIssueDocument({ issueId: f.issueId, key: "plan",
|
||||
title: "Approved child plan", format: "markdown", body: "Create one child for the proposed deliverable.",
|
||||
createdByAgentId: f.agentId, createdByRunId: f.runId })).document : null;
|
||||
const args = await nativeProposalRecipe(kind, plan?.latestRevisionId ?? undefined);
|
||||
const { interaction } = await f.authority.execute({ tool: "request_human_input", callId: randomUUID(), arguments: args }) as { interaction: { id: string; kind: string } };
|
||||
expect(interaction.kind).toBe(kind === "confirmation" ? "request_confirmation" : "request_checkbox_confirmation");
|
||||
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
|
||||
const selectedOptionIds = kind === "checkbox" ? ((args.payload as { options: { id: string }[] }).options.map(option => option.id)) : undefined;
|
||||
await issueThreadInteractionService(server.db).acceptInteraction({ id: f.issueId, companyId: f.companyId }, interaction.id,
|
||||
selectedOptionIds ? { selectedOptionIds } : {}, actor === "human" ? { userId: f.responsibleUserId! } : { agentId: f.agentId, runId: f.runId });
|
||||
if (actor === "human") await expect(create(f)).resolves.toMatchObject({ task: { parentId: f.issueId } });
|
||||
else await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["confirmation", "checkbox"] as const)("rejecting the shipped native %s recipe saves an assignee continuation without authorizing child work", async kind => {
|
||||
const f = await fixture();
|
||||
const issueIds = (await f.snapshot()).issues.map(issue => issue.id).sort();
|
||||
const plan = kind === "checkbox" ? (await documentService(server.db).upsertIssueDocument({ issueId: f.issueId, key: "plan",
|
||||
title: "Proposed child plan", format: "markdown", body: "Create one child for the proposed deliverable.",
|
||||
createdByAgentId: f.agentId, createdByRunId: f.runId })).document : null;
|
||||
const args = await nativeProposalRecipe(kind, plan?.latestRevisionId ?? undefined);
|
||||
const { interaction } = await f.authority.execute({ tool: "request_human_input", callId: randomUUID(), arguments: args }) as { interaction: { id: string } };
|
||||
const response = await fetch(`${server.apiUrl}/api/issues/${f.issueId}/interactions/${interaction.id}/reject`, {
|
||||
method: "POST", headers: { cookie: await server.humanSession(f.responsibleUserId!), "Content-Type": "application/json", origin: server.apiUrl },
|
||||
body: JSON.stringify({ reason: "Please revise the proposed scope." }),
|
||||
});
|
||||
const rejected = await response.json();
|
||||
expect(rejected, JSON.stringify(rejected)).toMatchObject({ status: "rejected", continuationPolicy: "wake_assignee",
|
||||
resolvedByUserId: f.responsibleUserId, result: { outcome: "rejected", reason: "Please revise the proposed scope." } });
|
||||
expect(response.status).toBe(200);
|
||||
const wakes = (await server.db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId)))
|
||||
.filter(wake => wake.payload?.interactionId === interaction.id);
|
||||
expect(wakes).toHaveLength(1);
|
||||
expect(wakes[0]).toMatchObject({ agentId: f.agentId, requestedByActorType: "user", requestedByActorId: f.responsibleUserId,
|
||||
payload: { issueId: f.issueId, interactionId: interaction.id, interactionStatus: "rejected",
|
||||
paperclipAgentMessage: { text: "Please revise the proposed scope.", source: "interaction_rejection" } } });
|
||||
// The fixture disables execution: observe the real durable wake, not a model call.
|
||||
expect((await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).map(run => run.id)).toEqual([f.runId]);
|
||||
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
|
||||
expect((await f.snapshot()).issues.map(issue => issue.id).sort()).toEqual(issueIds);
|
||||
});
|
||||
|
||||
it.each([false, true])("admits persisted human proposal acceptance (checkbox=%s)", async checkbox => {
|
||||
const f = await fixture();
|
||||
const p = await proposal(f, checkbox);
|
||||
|
||||
@@ -35,8 +35,45 @@ Work in this order.
|
||||
- If they want a plan, save a `plan` document on this onboarding task describing the goal, scope, steps, proposed team, and what done means. Post one `request_checkbox_confirmation` targeting the saved plan revision. A card or thread message alone is not a saved plan. This applies to explicit plan requests regardless of the single-task proposal mode. Proposing a team does not authorize hiring it.
|
||||
- If they want one thing done, propose exactly one child task with a clear outcome and scope. Ask them to accept it before creating the child. Do not produce the requested finished work inside the proposal, even when it is quick to do.
|
||||
- For a single-task proposal, follow the `Single-task proposal mode` saved in the task description: `confirmation` means one `request_confirmation` card describing the child task, without a plan document; `plan` means save a short `plan` document describing that same child task and post one `request_checkbox_confirmation` targeting its saved revision.
|
||||
- With native tools, create those proposal cards using `request_human_input`: `interactionKind: "confirmation"` creates `request_confirmation`; `interactionKind: "checkbox"` creates `request_checkbox_confirmation`. Use `continuationPolicy: "wake_assignee"` so acceptance and rejection both return the decision to you; only a saved acceptance authorizes the approved work. Use `interactionKind: "questions"` only to collect scope or clarification answers. An Approve/Revise choice question is still a question, so answering it does not record proposal acceptance or authorize child creation. Present the proposal through the matching confirmation kind instead.
|
||||
- Keep this task `in_review` while waiting. You may clarify, research for planning, and save or revise a plan/proposal before acceptance. Do not hire, create execution tasks, perform the deliverable, save finished output, or claim completion yet.
|
||||
|
||||
For a native single-task confirmation, call `request_human_input` with arguments like these. Replace `<child-title>` with the proposed title and describe its actual scope in the prompt. Use a new idempotency key for each changed or revised proposal. Reuse a key only for the exact same proposal when reconciling an interrupted call; the example key below is for the first proposal.
|
||||
|
||||
```json
|
||||
{
|
||||
"idempotencyKey": "first-task-proposal-confirmation",
|
||||
"interactionKind": "confirmation",
|
||||
"title": "Approve child task",
|
||||
"prompt": "Approve creating exactly one child task named \"<child-title>\" for the scope described above?",
|
||||
"continuationPolicy": "wake_assignee",
|
||||
"payload": {
|
||||
"acceptLabel": "Approve",
|
||||
"rejectLabel": "Revise",
|
||||
"rejectRequiresReason": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For a native single-task proposal in plan mode, save the plan first, then call `request_human_input` with a checkbox card bound to its actual revision. Replace `<child-title>` and `<plan-revision-id>`; make the options describe the scope the user is approving. For a plan-only request, the option approves the plan alone and does not authorize implementation or hiring.
|
||||
|
||||
```json
|
||||
{
|
||||
"idempotencyKey": "first-task-proposal-plan:<plan-revision-id>",
|
||||
"interactionKind": "checkbox",
|
||||
"title": "Approve child task plan",
|
||||
"prompt": "Approve the proposed scope in this saved plan?",
|
||||
"continuationPolicy": "wake_assignee",
|
||||
"payload": {
|
||||
"options": [{ "id": "scope", "label": "Approve the proposed scope for <child-title>" }],
|
||||
"defaultSelectedOptionIds": ["scope"],
|
||||
"minSelected": 1,
|
||||
"maxSelected": 1,
|
||||
"target": { "type": "issue_document", "key": "plan", "revisionId": "<plan-revision-id>" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Interpret the next reply against the latest proposal.
|
||||
|
||||
- Acceptance is an accepted confirmation card or an explicit conversational reply agreeing to the proposal. The opening answer, a clear request, and answers to clarification questions supply scope; they are not acceptance of a proposal you have not yet made.
|
||||
|
||||
Reference in new issue
Block a user