diff --git a/.changeset/goal-on-lane-lanes-view.md b/.changeset/goal-on-lane-lanes-view.md new file mode 100644 index 00000000..5725ab90 --- /dev/null +++ b/.changeset/goal-on-lane-lanes-view.md @@ -0,0 +1,16 @@ +--- +'@colony/storage': minor +'@colony/core': minor +'@colony/mcp-server': minor +'colonyq': minor +--- + +feat(coordination): goal-on-lane + lanes view + +File claims now carry an optional `goal` and `check` (gx /goal style), persisted on the +claim row and surfaced wherever the claim's owner is shown — `active_claims`, +`attention_inbox` recent claims, `bridge_status`, `hivemind_context` local claims, and a +contended claim's `contention_detail.owner_goal`/`owner_check`. A new `colony lane list` +CLI (backed by a reusable `buildLanesSummary` core builder) summarizes one lane per branch +with its agent, stated goal, held files, and activity line. A goal-less re-claim (e.g. the +hook auto-claim path) preserves an already-set goal via a COALESCE upsert. diff --git a/apps/cli/src/commands/lane.ts b/apps/cli/src/commands/lane.ts index fe004883..374a34f8 100644 --- a/apps/cli/src/commands/lane.ts +++ b/apps/cli/src/commands/lane.ts @@ -2,7 +2,7 @@ import { userInfo } from 'node:os'; import { resolve } from 'node:path'; import { loadSettings } from '@colony/config'; import type { LiveFileContentionGroup, MemoryStore } from '@colony/core'; -import { inferIdeFromSessionId, listLiveFileContentions } from '@colony/core'; +import { buildLanesSummary, inferIdeFromSessionId, listLiveFileContentions } from '@colony/core'; import type { LaneRunState } from '@colony/storage'; import { type Command, InvalidArgumentError } from 'commander'; import kleur from 'kleur'; @@ -121,6 +121,47 @@ export function registerLaneCommand(program: Command): void { } }); }); + + group + .command('list') + .description('List active lanes: who is on what branch, why (their goal), and what they hold') + .option('--repo-root ', 'limit to a specific repo root (defaults to process.cwd())') + .option('--include-stale', 'include lanes whose heartbeat has gone stale') + .option('--json', 'emit JSON') + .action(async (opts: { repoRoot?: string; includeStale?: boolean; json?: boolean }) => { + const repoRoot = resolve(opts.repoRoot ?? process.cwd()); + const settings = loadSettings(); + await withStore(settings, (store) => { + const summary = buildLanesSummary(store, { + repo_root: repoRoot, + ...(opts.includeStale ? { includeStale: true } : {}), + }); + if (opts.json) { + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + return; + } + if (summary.lanes.length === 0) { + process.stdout.write(`${kleur.dim('no active lanes in scope')}\n`); + return; + } + process.stdout.write(`${kleur.bold(`${summary.lane_count} lane(s)`)}\n`); + for (const lane of summary.lanes) { + process.stdout.write( + `\n ${kleur.bold(lane.branch)} ${kleur.dim(`(${lane.agent}, ${lane.activity})`)}\n`, + ); + if (lane.goal) { + const check = lane.check ? kleur.dim(` [check: ${lane.check}]`) : ''; + process.stdout.write(` goal: ${lane.goal}${check}\n`); + } + if (lane.now_line) { + process.stdout.write(` ${kleur.dim(lane.now_line)}\n`); + } + if (lane.held_files.length > 0) { + process.stdout.write(` holds: ${lane.held_files.join(', ')}\n`); + } + } + }); + }); } function formatTakeoverHints(group: LiveFileContentionGroup): string { diff --git a/apps/mcp-server/src/tools/bridge.ts b/apps/mcp-server/src/tools/bridge.ts index b1393195..ad6ace36 100644 --- a/apps/mcp-server/src/tools/bridge.ts +++ b/apps/mcp-server/src/tools/bridge.ts @@ -93,6 +93,8 @@ export interface BridgeStatus { by_session_id: string; claimed_at: number; yours: boolean; + goal: string | null; + check: string | null; }>; claimed_files: Array<{ task_id: number; @@ -100,6 +102,8 @@ export interface BridgeStatus { by_session_id: string; claimed_at: number; yours: boolean; + goal: string | null; + check: string | null; }>; latest_working_note: { id: number; @@ -365,6 +369,8 @@ function taskClaimSummary( by_session_id: claim.session_id, claimed_at: claim.claimed_at, yours: claim.session_id === sessionId, + goal: claim.goal, + check: claim.goal_check, })), }; } diff --git a/apps/mcp-server/src/tools/shared.ts b/apps/mcp-server/src/tools/shared.ts index f67eadf8..dd4450ec 100644 --- a/apps/mcp-server/src/tools/shared.ts +++ b/apps/mcp-server/src/tools/shared.ts @@ -139,6 +139,8 @@ export interface HivemindLocalClaim { age_class: ClaimAgeClass; ownership_strength: ClaimOwnershipStrength; yours: boolean; + goal: string | null; + check: string | null; } export interface HivemindLocalPheromoneTrail { @@ -617,6 +619,8 @@ function localClaims( age_class: age.age_class, ownership_strength: age.ownership_strength, yours: claim.session_id === input.sessionId, + goal: claim.goal, + check: claim.goal_check, })), truncated: sorted.length > input.limit, }; diff --git a/apps/mcp-server/src/tools/task.ts b/apps/mcp-server/src/tools/task.ts index 69578ae5..fe53faf1 100644 --- a/apps/mcp-server/src/tools/task.ts +++ b/apps/mcp-server/src/tools/task.ts @@ -374,96 +374,111 @@ export function register(server: McpServer, ctx: ToolContext): void { agent: z.string().min(1).optional(), file_path: z.string().min(1), note: z.string().optional(), + goal: z + .string() + .optional() + .describe('Why this lane is locked — the outcome you are pursuing (gx /goal style).'), + check: z + .string() + .optional() + .describe('Runnable criterion that proves the goal (a command, test, or metric).'), }, - wrapHandler('task_claim_file', async ({ task_id, session_id, agent, file_path, note }) => { - try { - enforceScoutNoClaim(store, { + wrapHandler( + 'task_claim_file', + async ({ task_id, session_id, agent, file_path, note, goal, check }) => { + try { + enforceScoutNoClaim(store, { + session_id, + ...(agent !== undefined ? { agent } : {}), + }); + } catch (err) { + if (err instanceof ClaimsHandlerError) { + return mcpErrorResponse(err.code, err.message); + } + throw err; + } + const normalizedFilePath = store.storage.normalizeTaskFilePath(task_id, file_path); + if (normalizedFilePath === null) { + const reason = store.storage.classifyTaskFilePathRejection(task_id, file_path); + const task = store.storage.getTask(task_id); + return mcpErrorResponse( + 'INVALID_CLAIM_PATH', + claimPathRejectionMessage(reason, file_path, { repo_root: task?.repo_root }), + ); + } + const previous = store.storage.getClaim(task_id, normalizedFilePath); + const guarded = guardedClaimFile(store, { + task_id, session_id, - ...(agent !== undefined ? { agent } : {}), + file_path: normalizedFilePath, + ...(goal !== undefined ? { goal } : {}), + ...(check !== undefined ? { check } : {}), }); - } catch (err) { - if (err instanceof ClaimsHandlerError) { - return mcpErrorResponse(err.code, err.message); + const contended = + guarded.status === 'takeover_recommended' || guarded.status === 'blocked_active_owner'; + if (contended && settings.coordinationMode === 'guarded') { + if (guarded.status === 'takeover_recommended') { + return mcpErrorResponse( + 'CLAIM_TAKEOVER_RECOMMENDED', + guarded.recommendation ?? 'release or take over inactive claim before claiming', + { ...guarded }, + ); + } + return mcpErrorResponse( + 'CLAIM_HELD_BY_ACTIVE_OWNER', + guarded.recommendation ?? 'request handoff or explicit takeover before claiming', + { ...guarded }, + ); } - throw err; - } - const normalizedFilePath = store.storage.normalizeTaskFilePath(task_id, file_path); - if (normalizedFilePath === null) { - const reason = store.storage.classifyTaskFilePathRejection(task_id, file_path); - const task = store.storage.getTask(task_id); - return mcpErrorResponse( - 'INVALID_CLAIM_PATH', - claimPathRejectionMessage(reason, file_path, { repo_root: task?.repo_root }), - ); - } - const previous = store.storage.getClaim(task_id, normalizedFilePath); - const guarded = guardedClaimFile(store, { - task_id, - session_id, - file_path: normalizedFilePath, - }); - const contended = - guarded.status === 'takeover_recommended' || guarded.status === 'blocked_active_owner'; - if (contended && settings.coordinationMode === 'guarded') { - if (guarded.status === 'takeover_recommended') { + if (guarded.status === 'task_not_found') { + return mcpErrorResponse('TASK_NOT_FOUND', `task ${task_id} not found`); + } + if (guarded.status === 'protected_branch_rejected') { return mcpErrorResponse( - 'CLAIM_TAKEOVER_RECOMMENDED', - guarded.recommendation ?? 'release or take over inactive claim before claiming', + 'PROTECTED_BRANCH_CLAIM_REJECTED', + guarded.recommendation ?? + `task ${task_id} is on protected branch ${guarded.protected_branch?.branch}; start a sandbox worktree first`, { ...guarded }, ); } - return mcpErrorResponse( - 'CLAIM_HELD_BY_ACTIVE_OWNER', - guarded.recommendation ?? 'request handoff or explicit takeover before claiming', - { ...guarded }, - ); - } - if (guarded.status === 'task_not_found') { - return mcpErrorResponse('TASK_NOT_FOUND', `task ${task_id} not found`); - } - if (guarded.status === 'protected_branch_rejected') { - return mcpErrorResponse( - 'PROTECTED_BRANCH_CLAIM_REJECTED', - guarded.recommendation ?? - `task ${task_id} is on protected branch ${guarded.protected_branch?.branch}; start a sandbox worktree first`, - { ...guarded }, - ); - } - new TaskThread(store, task_id).join(session_id, agentForTaskClaim(session_id)); - const id = store.addObservation({ - session_id, - kind: 'claim', - content: note ? `claim ${normalizedFilePath} — ${note}` : `claim ${normalizedFilePath}`, - task_id, - metadata: { + new TaskThread(store, task_id).join(session_id, agentForTaskClaim(session_id)); + const id = store.addObservation({ + session_id, kind: 'claim', + content: note ? `claim ${normalizedFilePath} — ${note}` : `claim ${normalizedFilePath}`, + task_id, + metadata: { + kind: 'claim', + file_path: normalizedFilePath, + guarded_claim_status: guarded.status, + ...(goal !== undefined ? { goal } : {}), + ...(check !== undefined ? { goal_check: check } : {}), + }, + }); + store.storage.touchTask(task_id); + const previousClaim = previous + ? compactPreviousClaim(previous, session_id, settings.claimStaleMinutes) + : null; + // Open mode lets contended claims through: the claim succeeds, but the + // response carries the contention loudly so the agent coordinates + // instead of silently clobbering a live owner. + return jsonReply({ + observation_id: id, file_path: normalizedFilePath, - guarded_claim_status: guarded.status, - }, - }); - store.storage.touchTask(task_id); - const previousClaim = previous - ? compactPreviousClaim(previous, session_id, settings.claimStaleMinutes) - : null; - // Open mode lets contended claims through: the claim succeeds, but the - // response carries the contention loudly so the agent coordinates - // instead of silently clobbering a live owner. - return jsonReply({ - observation_id: id, - file_path: normalizedFilePath, - claim_status: guarded.status, - claim_task_id: guarded.claim_task_id ?? task_id, - contention: contended, - contention_detail: contended ? { ...guarded } : null, - warning: contended - ? (guarded.recommendation ?? - 'another live session holds this file; coordinate via task_message before editing') - : null, - live_file_contentions: [], - overlap: previousClaim?.overlap ?? 'none', - previous_claim: previousClaim, - }); - }), + claim_status: guarded.status, + claim_task_id: guarded.claim_task_id ?? task_id, + contention: contended, + contention_detail: contended ? { ...guarded } : null, + warning: contended + ? (guarded.recommendation ?? + 'another live session holds this file; coordinate via task_message before editing') + : null, + live_file_contentions: [], + overlap: previousClaim?.overlap ?? 'none', + previous_claim: previousClaim, + }); + }, + ), ); server.tool( diff --git a/apps/mcp-server/test/goal-on-lane.test.ts b/apps/mcp-server/test/goal-on-lane.test.ts new file mode 100644 index 00000000..23db6c6a --- /dev/null +++ b/apps/mcp-server/test/goal-on-lane.test.ts @@ -0,0 +1,107 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { defaultSettings } from '@colony/config'; +import { MemoryStore, TaskThread } from '@colony/core'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { buildServer } from '../src/server.js'; + +let dir: string; +let repoRoot: string; +let store: MemoryStore; +let client: Client; + +const settings = { + ...defaultSettings, + rejectProtectedBranchClaims: false, + coordinationMode: 'open' as const, +}; + +async function call(name: string, args: Record): Promise { + const res = await client.callTool({ name, arguments: args }); + const text = (res.content as Array<{ type: string; text: string }>)[0]?.text ?? '{}'; + return JSON.parse(text) as T; +} + +beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'colony-goal-on-lane-')); + repoRoot = mkdtempSync(join(dir, 'repo-')); + store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings }); + const server = buildServer(store, settings); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + client = new Client({ name: 'test', version: '0.0.0' }); + await Promise.all([server.connect(serverT), client.connect(clientT)]); +}); + +afterEach(() => { + store.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('goal-on-lane', () => { + it('persists goal and check from an MCP task_claim_file onto the claim row', async () => { + store.startSession({ id: 'A', ide: 'claude-code', cwd: repoRoot }); + const thread = TaskThread.open(store, { + repo_root: repoRoot, + branch: 'feat/x', + session_id: 'A', + }); + thread.join('A', 'claude'); + + await call('task_claim_file', { + task_id: thread.task_id, + session_id: 'A', + file_path: 'src/x.ts', + goal: 'ship the x filter', + check: 'pnpm test', + }); + + expect(store.storage.getClaim(thread.task_id, 'src/x.ts')).toMatchObject({ + goal: 'ship the x filter', + goal_check: 'pnpm test', + }); + }); + + it("surfaces the owner's goal when a second session hits contention", async () => { + store.startSession({ id: 'active-owner', ide: 'claude-code', cwd: repoRoot }); + store.startSession({ id: 'requester', ide: 'codex', cwd: repoRoot }); + const first = TaskThread.open(store, { + repo_root: repoRoot, + branch: 'main', + session_id: 'active-owner', + }); + first.join('active-owner', 'claude'); + first.claimFile({ + session_id: 'active-owner', + file_path: 'src/shared.ts', + goal: 'refactor shared to async', + check: 'pnpm typecheck', + }); + const second = TaskThread.open(store, { + repo_root: repoRoot, + branch: 'main', + session_id: 'requester', + }); + second.join('requester', 'codex'); + + const payload = await call<{ + contention: boolean; + contention_detail: { + owner_session_id?: string; + owner_goal?: string; + owner_check?: string; + } | null; + }>('task_claim_file', { + task_id: second.task_id, + session_id: 'requester', + file_path: 'src/shared.ts', + }); + + expect(payload.contention).toBe(true); + expect(payload.contention_detail?.owner_session_id).toBe('active-owner'); + expect(payload.contention_detail?.owner_goal).toBe('refactor shared to async'); + expect(payload.contention_detail?.owner_check).toBe('pnpm typecheck'); + }); +}); diff --git a/docs/mcp.md b/docs/mcp.md index dcf1f763..9c725431 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1119,6 +1119,8 @@ Claim a file before editing so other agents see ownership and overlap warnings. Claims are warnings, not locks. They never block writes. They arm the conflict preface for the next turn. +Optional `goal` and `check` record **why** the lane is locked — the outcome you are pursuing (gx /goal style) and a runnable criterion that proves it. They are persisted on the claim row (not just the observation) and surface wherever the claim's owner does: `attention_inbox` recent claims, `bridge_status`, `hivemind_context` local claims, and a contended claim's `contention_detail.owner_goal`/`owner_check`. The same per-lane view is on the CLI as `colony lane list`. A re-claim that omits `goal` preserves a goal already set, so the hook auto-claim path never erases stated intent. + Coordination gates follow `settings.coordinationMode` (default `open`): - **open (default)** — contended claims succeed. When another live session holds the file, the response carries `contention: true`, `claim_status` (`blocked_active_owner` | `takeover_recommended`), a `warning`, and `contention_detail`; the claim observation is recorded but table ownership stays with the live owner — coordinate via `task_message` before editing. Roles are advisory: scouts can claim. @@ -1136,7 +1138,9 @@ Existing claims are age-classified before they are treated as ownership. Fresh c "session_id": "sess_abc", "agent": "codex", "file_path": "packages/storage/src/storage.ts", - "note": "extending searchFts with a filter arg" + "note": "extending searchFts with a filter arg", + "goal": "searchFts accepts a language filter; existing callers unaffected", + "check": "pnpm --filter @colony/storage test" } } ``` diff --git a/openspec/changes/agent-claude-goal-on-lane-lanes-view-2026-06-18-11-07/.openspec.yaml b/openspec/changes/agent-claude-goal-on-lane-lanes-view-2026-06-18-11-07/.openspec.yaml new file mode 100644 index 00000000..95ae5a2c --- /dev/null +++ b/openspec/changes/agent-claude-goal-on-lane-lanes-view-2026-06-18-11-07/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-18 diff --git a/openspec/changes/agent-claude-goal-on-lane-lanes-view-2026-06-18-11-07/proposal.md b/openspec/changes/agent-claude-goal-on-lane-lanes-view-2026-06-18-11-07/proposal.md new file mode 100644 index 00000000..04fc4dc8 --- /dev/null +++ b/openspec/changes/agent-claude-goal-on-lane-lanes-view-2026-06-18-11-07/proposal.md @@ -0,0 +1,24 @@ +## Why + +Colony's `biological-coordination` spec says coordination is stigmergic and advisory: the Queen "publishes structure; never commands," and a claim is a local mark other agents read. Open mode (#591) already turned role walls and claim conflicts into loud, advisory signals. But one signal is still missing: **a file claim records *who* holds a lane, never *why***. `task_claim_file`'s `note` is optional free-text that lands in an observation and is never surfaced back; the structured claim row other agents read is just `{ file_path, held_by }`. + +Agents therefore read each other as ownership walls ("X holds this file") instead of shared intent ("X is holding this to make `GET /search` p95 < 200ms"). This change makes the **goal behind a lock first-class** — the `gx /goal` model of an outcome plus a runnable check, attached to the lane — and adds a **lanes view** that answers "who is on what branch, why, and what have they locked." + +## What Changes + +- `task_claim_file` gains optional `goal` and `check` fields, persisted on the `task_claims` row (not just an observation). +- A contended claim's response carries the **current owner's** `owner_goal` / `owner_check`, so contention explains what the lane is held for. +- Every claim-row-backed read surface (`active_claims`, `attention_inbox` recent claims, `bridge_status` preview, `hivemind_context` local-mode claims) surfaces the goal alongside the owner. +- New `colony lane list` CLI, backed by a reusable `buildLanesSummary` core builder: a per-lane summary keyed on `{ repo_root, branch }` showing `{ agent, goal, check, held_files, now_line }`. +- A goal-less re-claim (e.g. the hook auto-claim path) MUST NOT erase a goal already set on the lane. + +Deferred to a follow-up (kept out of scope to keep this a coherent slice and avoid colliding with an in-flight lane that owns the MCP registration surface): +- The `hivemind_lanes` MCP tool (a thin wrapper over `buildLanesSummary`). Its registration changes the canonical tool list asserted in `apps/mcp-server/test/server.test.ts`, which is currently lock-held by an active `mcp-count-schema-tokens` lane. The goal already reaches agents through `contention_detail.owner_goal`, `hivemind_context` local claims, `attention_inbox`, and `bridge_status`, so the dedicated tool is additive, not load-bearing. +- `hivemind_context` global-mode claims (derived from a flat `locked_file_preview` string list, not claim rows) and the PostToolUse contention-awareness push. + +## Impact + +- **Surfaces:** `task_claim_file` response shape (additive), `hivemind_context` local claims (additive), `attention_inbox` recent claims (additive), `bridge_status` claim preview (additive), new `colony lane list` CLI. Docs: `docs/mcp.md`. +- **Storage:** two additive nullable columns on `task_claims` via the idempotent `COLUMN_MIGRATIONS` mechanism (the same path that added `state`/`expires_at`/`handoff_observation_id` to this table). Forward-only, no backfill. +- **Risk:** low. All fields optional; no hard gate added or removed. The one correctness risk — a goal-less re-claim nulling a set goal — is handled by a `COALESCE` upsert and covered by a regression test. +- **Compat:** existing callers and tests that omit `goal`/`check` keep working unchanged. diff --git a/openspec/changes/agent-claude-goal-on-lane-lanes-view-2026-06-18-11-07/specs/goal-on-lane-lanes-view/spec.md b/openspec/changes/agent-claude-goal-on-lane-lanes-view-2026-06-18-11-07/specs/goal-on-lane-lanes-view/spec.md new file mode 100644 index 00000000..01ea624a --- /dev/null +++ b/openspec/changes/agent-claude-goal-on-lane-lanes-view-2026-06-18-11-07/specs/goal-on-lane-lanes-view/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: File Claims Carry An Optional Goal And Check + +`task_claim_file` SHALL accept optional `goal` and `check` strings and persist them on the claim row, so the intent behind a lane lock is a durable, structured field rather than free-text in an observation. + +#### Scenario: agent claims a file with a goal + +- **WHEN** an agent calls `task_claim_file` with `goal` and `check` +- **THEN** the values are stored on the `task_claims` row for that `(task_id, file_path)` +- **AND** they are returned by the structured claim reader (`active_claims`) alongside `held_by` + +#### Scenario: claim without a goal stays valid + +- **WHEN** an agent (or the hook auto-claim path) claims a file with no `goal` +- **THEN** the claim succeeds with `goal` and `check` null +- **AND** no surface emits placeholder or noise text for the absent goal + +### Requirement: Contention Reveals The Owner's Goal + +When a claim is contended in open mode, the response SHALL include the current owner's goal so the contender learns what the lane is held for, not only who holds it. + +#### Scenario: contended claim surfaces owner goal + +- **WHEN** a second session claims a file already held by an active owner whose claim has a `goal` +- **THEN** the `contention_detail` payload includes `owner_goal` (and `owner_check` when present) drawn from the owner's claim row +- **AND** the existing `owner_session_id` / `owner_agent` fields are unchanged + +### Requirement: A Goal Survives A Goal-less Re-claim + +A re-claim of an already-claimed file that omits `goal` SHALL preserve the goal already recorded on the lane, so automatic re-claims do not erase an agent's stated intent. + +#### Scenario: hook re-claim preserves the goal + +- **WHEN** a file already has a stored `goal` and is re-claimed by a call that omits `goal` +- **THEN** the stored `goal` and `check` are retained on the row +- **AND** the claim's ownership and freshness are still updated to the latest claimer + +### Requirement: Lanes View Summarizes Goals Per Branch + +Colony SHALL expose a compact lanes view, as a CLI command backed by a reusable core builder, that lists active lanes keyed on `{ repo_root, branch }` with the agent, its stated goal, the files it has locked, and its current activity line. + +#### Scenario: lanes view lists a working lane + +- **WHEN** an agent is live on a branch and holds claims with a goal +- **THEN** `colony lane list` (via the `buildLanesSummary` core builder) returns one lane for that branch +- **AND** the lane includes `agent`, `goal`, `held_files`, and a `now_line` derived from the live working note or activity summary + +#### Scenario: lanes view is read-only + +- **WHEN** the lanes view is queried +- **THEN** it only reads coordination state and never mutates claims, tasks, or sessions diff --git a/openspec/changes/agent-claude-goal-on-lane-lanes-view-2026-06-18-11-07/tasks.md b/openspec/changes/agent-claude-goal-on-lane-lanes-view-2026-06-18-11-07/tasks.md new file mode 100644 index 00000000..825bf164 --- /dev/null +++ b/openspec/changes/agent-claude-goal-on-lane-lanes-view-2026-06-18-11-07/tasks.md @@ -0,0 +1,44 @@ +## 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 5 explaining the blocker and **STOP**. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria. +- [x] 1.2 Define normative requirements in `specs/goal-on-lane-lanes-view/spec.md`. + +## 2. Storage + write path (Phase A/B) + +- [ ] 2.1 Append `goal` + `goal_check` entries to `COLUMN_MIGRATIONS` (`packages/storage/src/schema.ts`); add fields to `TaskClaimRow` (`packages/storage/src/types.ts`). +- [ ] 2.2 `storage.claimFile`: accept `goal`/`check`, switch `INSERT OR REPLACE` to an `ON CONFLICT … DO UPDATE` upsert that `COALESCE`s `goal`/`goal_check` (preserve on goal-less re-claim) while resetting `state`/`expires_at`/`handoff_observation_id`. +- [ ] 2.3 `guardedClaimFile` (`packages/core/src/scoped-claim.ts`): thread `goal`/`check` to `storage.claimFile`; add `owner_goal`/`owner_check` to `GuardedClaimResult` from the blocking owner's claim row. +- [ ] 2.4 `TaskThread.claimFile` (`packages/core/src/task-thread.ts`): add `goal`/`check` params, thread to storage + observation metadata. +- [ ] 2.5 `task_claim_file` MCP schema (`apps/mcp-server/src/tools/task.ts`): add optional `goal`/`check`, thread to `guardedClaimFile`. + +## 3. Read surfaces (Phase C) + Lanes view (Phase D) + +- [ ] 3.1 Add `goal`/`check` to `active_claims` (`task-thread.ts`), `InboxRecentClaim` (`attention-inbox.ts`), `taskClaimSummary` (`bridge.ts`), `localClaims`/`HivemindLocalClaim` (`shared.ts`). +- [ ] 3.2 New `packages/core/src/lanes.ts` `buildLanesSummary(store, { repo_root })`; export it. +- [x] 3.3 `hivemind_lanes` MCP wrapper DEFERRED to a follow-up — registering it changes the tool list asserted in `apps/mcp-server/test/server.test.ts`, currently lock-held by an active lane. The goal already reaches agents via `contention_detail.owner_goal`, `hivemind_context`, `attention_inbox`, and `bridge_status`. +- [ ] 3.4 Add `colony lane list` CLI sub-command (`apps/cli/src/commands/lane.ts`). +- [ ] 3.5 Document `goal`/`check` in `docs/mcp.md`. + +## 4. Verification + +- [ ] 4.1 Core unit tests: goal persists; goal-less re-claim coalesces; `buildLanesSummary` projects per-branch lanes. +- [ ] 4.2 MCP contract tests: `task_claim_file` goal round-trip; contended claim sees `owner_goal`. Core test: `buildLanesSummary` projects per-branch lanes. +- [ ] 4.3 `pnpm changeset`. +- [ ] 4.4 Gates: `pnpm typecheck`, `pnpm lint`, `pnpm test`, `pnpm build`. +- [ ] 4.5 `bash scripts/e2e-publish.sh` (MCP-server tool surface changed). +- [ ] 4.6 `openspec validate agent-claude-goal-on-lane-lanes-view-2026-06-18-11-07 --type change --strict` and `openspec validate --specs`. + +## 5. Cleanup (mandatory; run before claiming completion) + +- [ ] 5.1 Independent `code-reviewer` pass; fix CRITICAL/HIGH. +- [ ] 5.2 `gx branch finish --branch agent/claude/goal-on-lane-lanes-view-2026-06-18-11-07 --base main --via-pr --wait-for-merge --cleanup`. +- [ ] 5.3 Record PR URL + final `MERGED` state in the completion handoff. +- [ ] 5.4 Confirm the sandbox worktree is pruned. diff --git a/packages/core/src/attention-inbox.ts b/packages/core/src/attention-inbox.ts index 09f9cd22..efa65869 100644 --- a/packages/core/src/attention-inbox.ts +++ b/packages/core/src/attention-inbox.ts @@ -136,6 +136,8 @@ export interface InboxRecentClaim { age_minutes: number; age_class: ClaimAgeClass; ownership_strength: ClaimOwnershipStrength; + goal: string | null; + check: string | null; } export interface InboxFileHeat { @@ -1096,6 +1098,8 @@ function compactClaim( age_minutes: classification.age_minutes, age_class: classification.age_class, ownership_strength: classification.ownership_strength, + goal: row.goal, + check: row.goal_check, }; } diff --git a/packages/core/src/coordination-sweep.ts b/packages/core/src/coordination-sweep.ts index 241d0996..eef4a96e 100644 --- a/packages/core/src/coordination-sweep.ts +++ b/packages/core/src/coordination-sweep.ts @@ -1765,6 +1765,8 @@ function fallbackClaimRows( state: 'active', expires_at: null, handoff_observation_id: null, + goal: null, + goal_check: null, })); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c62160b8..02ebb67b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -48,6 +48,12 @@ export { type HivemindSession, type HivemindSnapshot, } from './hivemind.js'; +export { + buildLanesSummary, + type BuildLanesOptions, + type LaneSummary, + type LanesSummary, +} from './lanes.js'; export { readWorktreeContentionReport, resolveManagedRepoRoot, diff --git a/packages/core/src/lanes.ts b/packages/core/src/lanes.ts new file mode 100644 index 00000000..29eda0a9 Binary files /dev/null and b/packages/core/src/lanes.ts differ diff --git a/packages/core/src/scoped-claim.ts b/packages/core/src/scoped-claim.ts index 52ea98b6..08761421 100644 --- a/packages/core/src/scoped-claim.ts +++ b/packages/core/src/scoped-claim.ts @@ -28,6 +28,10 @@ export interface GuardedClaimResult { owner_agent?: string | undefined; owner_active?: boolean; owner_dirty?: boolean; + /** The blocking owner's stated goal — why the lane is held, not just who holds it. */ + owner_goal?: string | undefined; + /** The blocking owner's runnable check, when stated. */ + owner_check?: string | undefined; recommendation?: string; /** * Set when the task's branch is one of the repo-wide protected base @@ -51,6 +55,8 @@ export function guardedClaimFile( file_path: string; session_id: string; agent?: string; + goal?: string; + check?: string; worktreeContention?: WorktreeContentionReport | null; dryRun?: boolean; }, @@ -133,6 +139,8 @@ export function guardedClaimFile( owner_agent: blockingActive.owner.agent, owner_active: true, owner_dirty: blockingActive.owner.dirty, + owner_goal: blockingActive.claim.goal ?? undefined, + owner_check: blockingActive.claim.goal_check ?? undefined, recommendation: `request handoff or explicit takeover from active owner ${blockingActive.claim.session_id} before claiming ${filePath}`, }); } @@ -150,6 +158,8 @@ export function guardedClaimFile( owner_agent: dirtyOwner.owner.agent, owner_active: dirtyOwner.owner.active, owner_dirty: true, + owner_goal: dirtyOwner.claim.goal ?? undefined, + owner_check: dirtyOwner.claim.goal_check ?? undefined, recommendation: `dirty worktree still has ${filePath}; require handoff or rescue from ${dirtyOwner.claim.session_id} before claiming`, }); } @@ -228,13 +238,22 @@ export function guardedClaimFile( function claimFileUnlessDryRun( store: MemoryStore, - args: { task_id: number; file_path: string; session_id: string; dryRun?: boolean }, + args: { + task_id: number; + file_path: string; + session_id: string; + goal?: string; + check?: string; + dryRun?: boolean; + }, ): void { if (args.dryRun === true) return; store.storage.claimFile({ task_id: args.task_id, file_path: args.file_path, session_id: args.session_id, + ...(args.goal !== undefined ? { goal: args.goal } : {}), + ...(args.check !== undefined ? { check: args.check } : {}), }); } diff --git a/packages/core/src/task-thread.ts b/packages/core/src/task-thread.ts index 60cb9d3d..5f6a2e3f 100644 --- a/packages/core/src/task-thread.ts +++ b/packages/core/src/task-thread.ts @@ -386,7 +386,12 @@ export interface RelayMetadata { */ resumable_state: { last_files_edited: Array<{ file_path: string; ts: number; session_id: string }>; - active_claims: Array<{ file_path: string; held_by: string }>; + active_claims: Array<{ + file_path: string; + held_by: string; + goal: string | null; + check: string | null; + }>; /** Last handoff summary or relay one_line, whichever is more recent. */ last_handoff_summary: string | null; recent_decisions: Array<{ id: number; content: string; ts: number }>; @@ -621,6 +626,8 @@ export class TaskThread { session_id: string; file_path: string; note?: string; + goal?: string; + check?: string; metadata?: Record; }): number { const filePath = this.store.storage.normalizeTaskFilePath(this.task_id, p.file_path); @@ -637,13 +644,21 @@ export class TaskThread { task_id: this.task_id, file_path: filePath, session_id: p.session_id, + ...(p.goal !== undefined ? { goal: p.goal } : {}), + ...(p.check !== undefined ? { check: p.check } : {}), }); return this.store.addObservation({ session_id: p.session_id, kind: 'claim', content: p.note ? `claim ${filePath} — ${p.note}` : `claim ${filePath}`, task_id: this.task_id, - metadata: { kind: 'claim', file_path: filePath, ...(p.metadata ?? {}) }, + metadata: { + kind: 'claim', + file_path: filePath, + ...(p.goal !== undefined ? { goal: p.goal } : {}), + ...(p.check !== undefined ? { goal_check: p.check } : {}), + ...(p.metadata ?? {}), + }, }); }); } @@ -2086,6 +2101,8 @@ export class TaskThread { .map((c) => ({ file_path: c.file_path, held_by: c.session_id, + goal: c.goal, + check: c.goal_check, })); // Most recent prior baton-pass — handoff or relay, whichever ran last — diff --git a/packages/core/test/lanes.test.ts b/packages/core/test/lanes.test.ts new file mode 100644 index 00000000..232136e1 --- /dev/null +++ b/packages/core/test/lanes.test.ts @@ -0,0 +1,94 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { defaultSettings } from '@colony/config'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { buildLanesSummary } from '../src/lanes.js'; +import { MemoryStore } from '../src/memory-store.js'; +import { TaskThread } from '../src/task-thread.js'; + +let dir: string; +let repoRoot: string; +let store: MemoryStore; +let nowMs: number; + +const BRANCH = 'agent/claude/goal'; + +function seedHeartbeat(): void { + const activeSessionDir = join(repoRoot, '.omx', 'state', 'active-sessions'); + mkdirSync(activeSessionDir, { recursive: true }); + const nowIso = new Date(nowMs).toISOString(); + writeFileSync( + join(activeSessionDir, 'agent__claude__goal.json'), + `${JSON.stringify({ + schemaVersion: 1, + repoRoot, + branch: BRANCH, + taskName: 'goal-on-lane', + agentName: 'claude', + cliName: 'claude-code', + sessionKey: 'agent__claude__goal', + worktreePath: repoRoot, + startedAt: nowIso, + lastHeartbeatAt: nowIso, + state: 'working', + })}\n`, + 'utf8', + ); +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'colony-lanes-')); + repoRoot = join(dir, 'repo'); + mkdirSync(repoRoot, { recursive: true }); + nowMs = Date.parse('2026-06-18T10:00:00.000Z'); + store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings }); +}); + +afterEach(() => { + store.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('buildLanesSummary', () => { + it('projects one lane per branch with its goal, held files, and now line', () => { + seedHeartbeat(); + store.startSession({ id: 'A', ide: 'claude-code', cwd: repoRoot }); + const thread = TaskThread.open(store, { repo_root: repoRoot, branch: BRANCH, session_id: 'A' }); + thread.join('A', 'claude'); + thread.claimFile({ + session_id: 'A', + file_path: 'src/a.ts', + goal: 'make GET /search p95 < 200ms', + check: 'pnpm bench search', + }); + // A later, goal-less claim must not hide the stated goal on the lane. + thread.claimFile({ session_id: 'A', file_path: 'src/b.ts' }); + + const summary = buildLanesSummary(store, { repo_root: repoRoot, now: nowMs }); + + expect(summary.lane_count).toBe(1); + const lane = summary.lanes[0]; + expect(lane?.branch).toBe(BRANCH); + expect(lane?.agent).toBe('claude'); + expect(lane?.goal).toBe('make GET /search p95 < 200ms'); + expect(lane?.check).toBe('pnpm bench search'); + expect([...(lane?.held_files ?? [])].sort()).toEqual(['src/a.ts', 'src/b.ts']); + expect(lane?.now_line).toBeTruthy(); + }); + + it('reports a null goal for a lane that holds files without one', () => { + seedHeartbeat(); + store.startSession({ id: 'A', ide: 'claude-code', cwd: repoRoot }); + const thread = TaskThread.open(store, { repo_root: repoRoot, branch: BRANCH, session_id: 'A' }); + thread.join('A', 'claude'); + thread.claimFile({ session_id: 'A', file_path: 'src/a.ts' }); + + const summary = buildLanesSummary(store, { repo_root: repoRoot, now: nowMs }); + + expect(summary.lane_count).toBe(1); + expect(summary.lanes[0]?.goal).toBeNull(); + expect(summary.lanes[0]?.check).toBeNull(); + expect(summary.lanes[0]?.held_files).toEqual(['src/a.ts']); + }); +}); diff --git a/packages/core/test/task-thread.test.ts b/packages/core/test/task-thread.test.ts index ef062393..fc56ee20 100644 --- a/packages/core/test/task-thread.test.ts +++ b/packages/core/test/task-thread.test.ts @@ -766,11 +766,18 @@ describe('TaskThread', () => { const row = store.storage.getObservation(relayId); const meta = JSON.parse(row?.metadata ?? '{}') as { - resumable_state: { active_claims: Array<{ file_path: string; held_by: string }> }; + resumable_state: { + active_claims: Array<{ + file_path: string; + held_by: string; + goal: string | null; + check: string | null; + }>; + }; worktree_recipe: { inherit_claims: string[] }; }; expect(meta.resumable_state.active_claims).toEqual([ - { file_path: 'src/fresh.ts', held_by: 'claude' }, + { file_path: 'src/fresh.ts', held_by: 'claude', goal: null, check: null }, ]); expect(meta.worktree_recipe.inherit_claims).toEqual(['src/fresh.ts']); diff --git a/packages/storage/src/schema.ts b/packages/storage/src/schema.ts index 91a34c2d..e15db71d 100644 --- a/packages/storage/src/schema.ts +++ b/packages/storage/src/schema.ts @@ -549,6 +549,19 @@ export const COLUMN_MIGRATIONS: ReadonlyArray<{ table: string; column: string; s column: 'weight', sql: 'ALTER TABLE observations ADD COLUMN weight REAL NOT NULL DEFAULT 1.0', }, + // Goal-on-lane — the "why" behind a file claim. Both nullable; a claim + // without a stated goal stays valid. Column is `goal_check`, not `check`, + // because CHECK is a SQLite reserved word. + { + table: 'task_claims', + column: 'goal', + sql: 'ALTER TABLE task_claims ADD COLUMN goal TEXT', + }, + { + table: 'task_claims', + column: 'goal_check', + sql: 'ALTER TABLE task_claims ADD COLUMN goal_check TEXT', + }, ]; export const POST_MIGRATION_SQL = ` diff --git a/packages/storage/src/storage.ts b/packages/storage/src/storage.ts index e3582b99..d68cf519 100644 --- a/packages/storage/src/storage.ts +++ b/packages/storage/src/storage.ts @@ -2243,19 +2243,37 @@ export class Storage { return row?.id; } - claimFile(c: { task_id: number; file_path: string; session_id: string }): void { + claimFile(c: { + task_id: number; + file_path: string; + session_id: string; + goal?: string; + check?: string; + }): void { const filePath = this.normalizeTaskFilePath(c.task_id, c.file_path); if (filePath === null) return; - // REPLACE semantics: the latest claimer wins. Handoffs atomically swap - // ownership, so the invariant "at most one owner per (task, file)" is - // preserved by the transaction, not by the primary key alone. + // Upsert, not REPLACE: the latest claimer still wins on ownership and + // freshness (and the state/expiry/handoff reset that a fresh claim + // implies), but a stated goal is preserved across a goal-less re-claim + // via COALESCE. The hook auto-claim path re-claims edited files with no + // goal; without COALESCE it would erase the goal the agent set. The + // "at most one owner per (task, file)" invariant is held by the primary + // key plus the surrounding handoff transaction. this.db .prepare( - `INSERT OR REPLACE INTO task_claims( - task_id, file_path, session_id, claimed_at, state, expires_at, handoff_observation_id - ) VALUES (?, ?, ?, ?, 'active', NULL, NULL)`, + `INSERT INTO task_claims( + task_id, file_path, session_id, claimed_at, state, expires_at, handoff_observation_id, goal, goal_check + ) VALUES (?, ?, ?, ?, 'active', NULL, NULL, ?, ?) + ON CONFLICT(task_id, file_path) DO UPDATE SET + session_id = excluded.session_id, + claimed_at = excluded.claimed_at, + state = 'active', + expires_at = NULL, + handoff_observation_id = NULL, + goal = COALESCE(excluded.goal, task_claims.goal), + goal_check = COALESCE(excluded.goal_check, task_claims.goal_check)`, ) - .run(c.task_id, filePath, c.session_id, Date.now()); + .run(c.task_id, filePath, c.session_id, Date.now(), c.goal ?? null, c.check ?? null); } markClaimHandoffPending(c: { @@ -2405,6 +2423,8 @@ export class Storage { : 'active', expires_at: row.expires_at ?? null, handoff_observation_id: row.handoff_observation_id ?? null, + goal: row.goal ?? null, + goal_check: row.goal_check ?? null, }; } diff --git a/packages/storage/src/types.ts b/packages/storage/src/types.ts index 39d343d9..659c6725 100644 --- a/packages/storage/src/types.ts +++ b/packages/storage/src/types.ts @@ -136,6 +136,10 @@ export interface TaskClaimRow { state: TaskClaimState; expires_at: number | null; handoff_observation_id: number | null; + /** Stated outcome the lane is held for (gx /goal style). Null when unstated. */ + goal: string | null; + /** Runnable criterion that proves the goal. Null when unstated. */ + goal_check: string | null; } export type AccountClaimState = 'active' | 'released'; diff --git a/packages/storage/test/claim-goal.test.ts b/packages/storage/test/claim-goal.test.ts new file mode 100644 index 00000000..cc671a5b --- /dev/null +++ b/packages/storage/test/claim-goal.test.ts @@ -0,0 +1,90 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Storage } from '../src/index.js'; + +let dir: string; +let storage: Storage; +let taskId: number; + +const FILE = 'packages/storage/src/storage.ts'; + +function session(id: string): void { + storage.createSession({ id, ide: 'codex', cwd: '/repo', started_at: 1, metadata: null }); +} + +function claim() { + return storage.listClaims(taskId).find((c) => c.file_path === FILE); +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'colony-claim-goal-')); + storage = new Storage(join(dir, 'test.db')); + const task = storage.findOrCreateTask({ + title: 'goal-on-lane', + repo_root: '/repo', + branch: 'agent/codex/goal', + created_by: 's1', + }); + taskId = task.id; + session('s1'); + session('s2'); +}); + +afterEach(() => { + storage.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('task_claims goal/check', () => { + it('persists goal and check on the claim row', () => { + storage.claimFile({ + task_id: taskId, + file_path: FILE, + session_id: 's1', + goal: 'ship the search filter', + check: 'pnpm --filter @colony/storage test', + }); + const c = claim(); + expect(c?.goal).toBe('ship the search filter'); + expect(c?.goal_check).toBe('pnpm --filter @colony/storage test'); + }); + + it('defaults goal and check to null when unstated', () => { + storage.claimFile({ task_id: taskId, file_path: FILE, session_id: 's1' }); + const c = claim(); + expect(c?.goal).toBeNull(); + expect(c?.goal_check).toBeNull(); + }); + + it('preserves a stated goal across a goal-less re-claim', () => { + storage.claimFile({ + task_id: taskId, + file_path: FILE, + session_id: 's1', + goal: 'ship the search filter', + check: 'pnpm test', + }); + // The hook auto-claim path re-claims edited files with no goal. + storage.claimFile({ task_id: taskId, file_path: FILE, session_id: 's2' }); + const c = claim(); + expect(c?.session_id).toBe('s2'); // ownership + freshness still move to the latest claimer + expect(c?.goal).toBe('ship the search filter'); // intent is preserved, not erased + expect(c?.goal_check).toBe('pnpm test'); + }); + + it('overwrites the goal when a re-claim states a new one, keeping unstated fields', () => { + storage.claimFile({ + task_id: taskId, + file_path: FILE, + session_id: 's1', + goal: 'old goal', + check: 'old check', + }); + storage.claimFile({ task_id: taskId, file_path: FILE, session_id: 's2', goal: 'new goal' }); + const c = claim(); + expect(c?.goal).toBe('new goal'); // restated -> replaced + expect(c?.goal_check).toBe('old check'); // not restated -> preserved + }); +});