diff --git a/apps/mcp-server/src/tools/ready-queue.ts b/apps/mcp-server/src/tools/ready-queue.ts index c107a55..623a37c 100644 --- a/apps/mcp-server/src/tools/ready-queue.ts +++ b/apps/mcp-server/src/tools/ready-queue.ts @@ -157,6 +157,19 @@ export interface SpecRootSetupIssue extends SpecRootMissingDetails { message: string; } +export interface ReadyPlanClaimHint { + kind: 'plan_claim_subtask'; + message: string; + plan_slug: string; + subtask_index: number; + title: string; + blocked_by_count: number; + blocked_by: number[]; + next_tool: 'task_plan_claim_subtask'; + claim_args: TaskPlanClaimArgs; + codex_mcp_call: string; +} + export interface ReadyForAgentResult { ready: ReadyQueueEntry[]; total_available: number; @@ -183,6 +196,7 @@ export interface ReadyForAgentResult { codex_mcp_call?: string; next_action_reason?: string; empty_state?: string; + hint?: ReadyPlanClaimHint; setup_issue?: SpecRootSetupIssue; /** * Populated when the caller passed `auto_claim: true` and the server @@ -538,6 +552,7 @@ export async function buildReadyForAgent( plans.length > 0, setupIssue, available.length === 0 && quotaRelays.length === 0 ? staleBlockerRescueCandidate(plans) : null, + planClaimHintForEmptyState(plans, liveSubtaskBranches, args), ); } @@ -555,6 +570,7 @@ function buildReadyResult( hasPlans: boolean, setupIssue: SpecRootSetupIssue | null, rescueCandidate: StaleBlockerRescueCandidate | null, + emptyStateHint: ReadyPlanClaimHint | null, ): ReadyForAgentResult { if (claimable === null) { if (base.ready.length > 0) { @@ -586,6 +602,7 @@ function buildReadyResult( ...base, next_action: hasPlans ? NO_READY_SUBTASKS_NEXT_ACTION : NO_PLAN_NEXT_ACTION, empty_state: NO_CLAIMABLE_PLAN_SUBTASKS_EMPTY_STATE, + ...(emptyStateHint ? { hint: emptyStateHint } : {}), }; } @@ -652,6 +669,53 @@ function subtaskBranchKey(repoRoot: string, branch: string): string { return `${repoRoot}\0${branch}`; } +function planClaimHintForEmptyState( + plans: PlanInfo[], + liveSubtaskBranches: Set, + args: { session_id: string; agent: string }, +): ReadyPlanClaimHint | null { + const candidates = plans + .flatMap((plan) => + plan.subtasks + .filter((subtask) => subtask.status === 'available') + .filter((subtask) => hasLiveSubtaskBranch(liveSubtaskBranches, plan, subtask)) + .map((subtask) => ({ plan, subtask })), + ) + .sort( + (left, right) => + left.subtask.blocked_by_count - right.subtask.blocked_by_count || + left.plan.created_at - right.plan.created_at || + left.plan.plan_slug.localeCompare(right.plan.plan_slug) || + left.subtask.subtask_index - right.subtask.subtask_index, + ); + const candidate = candidates[0]; + if (!candidate) return null; + const claim_args = { + repo_root: candidate.plan.repo_root, + plan_slug: candidate.plan.plan_slug, + subtask_index: candidate.subtask.subtask_index, + session_id: args.session_id, + agent: args.agent, + file_scope: candidate.subtask.file_scope, + }; + const blocked = + candidate.subtask.blocked_by_count > 0 + ? `; blocked by sub-task(s) ${candidate.subtask.blocked_by.join(', ')}` + : ''; + return { + kind: 'plan_claim_subtask', + message: `Unclaimed plan work exists for ${candidate.plan.plan_slug}/sub-${candidate.subtask.subtask_index}${blocked}.`, + plan_slug: candidate.plan.plan_slug, + subtask_index: candidate.subtask.subtask_index, + title: candidate.subtask.title, + blocked_by_count: candidate.subtask.blocked_by_count, + blocked_by: candidate.subtask.blocked_by, + next_tool: 'task_plan_claim_subtask', + claim_args, + codex_mcp_call: codexMcpCall(claim_args), + }; +} + function codexMcpCall(args: TaskPlanClaimArgs): string { return `mcp__colony__task_plan_claim_subtask({ agent: ${JSON.stringify(args.agent)}, session_id: ${JSON.stringify(args.session_id)}, repo_root: ${JSON.stringify(args.repo_root)}, plan_slug: ${JSON.stringify(args.plan_slug)}, subtask_index: ${args.subtask_index}, file_scope: ${JSON.stringify(args.file_scope)} })`; } diff --git a/apps/mcp-server/test/ready-queue.test.ts b/apps/mcp-server/test/ready-queue.test.ts index 7e6320e..f7d70e2 100644 --- a/apps/mcp-server/test/ready-queue.test.ts +++ b/apps/mcp-server/test/ready-queue.test.ts @@ -190,6 +190,25 @@ interface ReadyResult { codex_mcp_call?: string; next_action_reason?: string; empty_state?: string; + hint?: { + kind: 'plan_claim_subtask'; + message: string; + plan_slug: string; + subtask_index: number; + title: string; + blocked_by_count: number; + blocked_by: number[]; + next_tool: 'task_plan_claim_subtask'; + claim_args: { + repo_root: string; + plan_slug: string; + subtask_index: number; + session_id: string; + agent: string; + file_scope: string[]; + }; + codex_mcp_call: string; + }; setup_issue?: { code: 'SPEC_ROOT_NOT_FOUND'; repo_root: string; @@ -877,6 +896,61 @@ describe('task_ready_for_agent', () => { ); }); + it('adds a plan-claim hint when unclaimed plan work exists behind blockers', async () => { + await call('task_plan_publish', { + ...publishArgs( + [ + { + title: 'Blocked API dependency', + description: 'This dependency cannot progress yet.', + file_scope: ['apps/api/blocked-hint-dependency.ts'], + capability_hint: 'api_work', + }, + { + title: 'Hidden follow-up API', + description: 'This is unclaimed plan work, but it is not ready yet.', + file_scope: ['apps/api/blocked-hint-followup.ts'], + depends_on: [0], + capability_hint: 'api_work', + }, + ], + { slug: 'blocked-hint-plan' }, + ), + }); + const claim = await claimSubtask('blocked-hint-plan', 0); + blockSubtask('blocked-hint-plan', 0, claim.task_id); + + const result = await call('task_ready_for_agent', { + session_id: 'agent-session', + agent: 'codex', + repo_root: repoRoot, + auto_claim: false, + }); + + expect(result.ready).toEqual([]); + expect(result.empty_state).toBe(EMPTY_READY_STATE); + expect(result.hint).toMatchObject({ + kind: 'plan_claim_subtask', + plan_slug: 'blocked-hint-plan', + subtask_index: 1, + title: 'Hidden follow-up API', + blocked_by_count: 1, + blocked_by: [0], + next_tool: 'task_plan_claim_subtask', + claim_args: { + repo_root: repoRoot, + plan_slug: 'blocked-hint-plan', + subtask_index: 1, + session_id: 'agent-session', + agent: 'codex', + file_scope: ['apps/api/blocked-hint-followup.ts'], + }, + }); + expect(result.hint?.codex_mcp_call).toBe( + `mcp__colony__task_plan_claim_subtask({ agent: "codex", session_id: "agent-session", repo_root: ${JSON.stringify(repoRoot)}, plan_slug: "blocked-hint-plan", subtask_index: 1, file_scope: ["apps/api/blocked-hint-followup.ts"] })`, + ); + }); + it('makes a stale blocked wave claimable again after rescue release', async () => { const t0 = Date.parse('2026-04-28T12:00:00.000Z'); vi.useFakeTimers({ toFake: ['Date'] }); diff --git a/openspec/changes/agent-codex-plan-claim-nudge-ready-empty-state-2026-05-15-15-41/.openspec.yaml b/openspec/changes/agent-codex-plan-claim-nudge-ready-empty-state-2026-05-15-15-41/.openspec.yaml new file mode 100644 index 0000000..9f70866 --- /dev/null +++ b/openspec/changes/agent-codex-plan-claim-nudge-ready-empty-state-2026-05-15-15-41/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-15 diff --git a/openspec/changes/agent-codex-plan-claim-nudge-ready-empty-state-2026-05-15-15-41/proposal.md b/openspec/changes/agent-codex-plan-claim-nudge-ready-empty-state-2026-05-15-15-41/proposal.md new file mode 100644 index 0000000..358b087 --- /dev/null +++ b/openspec/changes/agent-codex-plan-claim-nudge-ready-empty-state-2026-05-15-15-41/proposal.md @@ -0,0 +1,13 @@ +## Why + +`task_ready_for_agent` can return an empty ready queue while published plan work still exists behind blockers. The current empty state tells agents there is no claimable work, but it does not preserve the exact follow-up claim call for the next unclaimed sub-task. + +## What Changes + +- Add an optional `hint` field to empty `task_ready_for_agent` responses when an unclaimed published-plan sub-task exists. +- Include the plan slug, sub-task index, title, blocker metadata, exact `task_plan_claim_subtask` args, and Codex MCP call string. +- Keep existing `ready`, `empty_state`, `next_action`, and claimable-ready behavior unchanged. + +## Impact + +This is additive response metadata for the MCP ready queue. Existing callers that ignore unknown fields continue to work, while agents that render empty states can now surface plan-claim recovery context. diff --git a/openspec/changes/agent-codex-plan-claim-nudge-ready-empty-state-2026-05-15-15-41/specs/plan-claim-nudge-ready-empty-state/spec.md b/openspec/changes/agent-codex-plan-claim-nudge-ready-empty-state-2026-05-15-15-41/specs/plan-claim-nudge-ready-empty-state/spec.md new file mode 100644 index 0000000..2e178bc --- /dev/null +++ b/openspec/changes/agent-codex-plan-claim-nudge-ready-empty-state-2026-05-15-15-41/specs/plan-claim-nudge-ready-empty-state/spec.md @@ -0,0 +1,12 @@ +## ADDED Requirements + +### Requirement: Ready Queue Empty-State Plan Hint +When `task_ready_for_agent` returns an empty ready queue but a published plan still contains an unclaimed sub-task, the response SHALL include an optional `hint` object for that sub-task without changing the existing empty-state fields. + +#### Scenario: Blocked plan work remains discoverable +- **GIVEN** a published plan has no ready claimable rows because the remaining unclaimed sub-task is blocked by an upstream dependency +- **WHEN** an executor calls `task_ready_for_agent` +- **THEN** the response still has `ready: []` and the existing `empty_state` +- **AND** the response includes `hint.plan_slug`, `hint.subtask_index`, `hint.blocked_by_count`, and `hint.blocked_by` +- **AND** `hint.claim_args` contains the exact `task_plan_claim_subtask` arguments for the unclaimed sub-task +- **AND** `hint.codex_mcp_call` contains the matching Codex MCP call string. diff --git a/openspec/changes/agent-codex-plan-claim-nudge-ready-empty-state-2026-05-15-15-41/tasks.md b/openspec/changes/agent-codex-plan-claim-nudge-ready-empty-state-2026-05-15-15-41/tasks.md new file mode 100644 index 0000000..0720ba0 --- /dev/null +++ b/openspec/changes/agent-codex-plan-claim-nudge-ready-empty-state-2026-05-15-15-41/tasks.md @@ -0,0 +1,34 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline. + +## Handoff + +- Handoff: change=`agent-codex-plan-claim-nudge-ready-empty-state-2026-05-15-15-41`; branch=`agent/codex/plan-claim-nudge-ready-empty-state-2026-05-15-15-41`; scope=`apps/mcp-server/src/tools/ready-queue.ts`, `apps/mcp-server/test/ready-queue.test.ts`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`. +- Copy prompt: Continue `agent-codex-plan-claim-nudge-ready-empty-state-2026-05-15-15-41` on branch `agent/codex/plan-claim-nudge-ready-empty-state-2026-05-15-15-41`. Work inside the existing sandbox, review `openspec/changes/agent-codex-plan-claim-nudge-ready-empty-state-2026-05-15-15-41/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/plan-claim-nudge-ready-empty-state-2026-05-15-15-41 --base main --via-pr --wait-for-merge --cleanup`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-plan-claim-nudge-ready-empty-state-2026-05-15-15-41`. +- [x] 1.2 Define normative requirements in `specs/plan-claim-nudge-ready-empty-state/spec.md`. + +## 2. Implementation + +- [x] 2.1 Implement scoped behavior changes. +- [x] 2.2 Add/update focused regression coverage. + +## 3. Verification + +- [x] 3.1 Run targeted project verification commands. +- [x] 3.2 Run `openspec validate agent-codex-plan-claim-nudge-ready-empty-state-2026-05-15-15-41 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/codex/plan-claim-nudge-ready-empty-state-2026-05-15-15-41 --base main --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch).