Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/goal-on-lane-lanes-view.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 42 additions & 1 deletion apps/cli/src/commands/lane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 <path>', '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 {
Expand Down
6 changes: 6 additions & 0 deletions apps/mcp-server/src/tools/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,17 @@ export interface BridgeStatus {
by_session_id: string;
claimed_at: number;
yours: boolean;
goal: string | null;
check: string | null;
}>;
claimed_files: Array<{
task_id: number;
file_path: string;
by_session_id: string;
claimed_at: number;
yours: boolean;
goal: string | null;
check: string | null;
}>;
latest_working_note: {
id: number;
Expand Down Expand Up @@ -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,
})),
};
}
Expand Down
4 changes: 4 additions & 0 deletions apps/mcp-server/src/tools/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ export interface HivemindLocalClaim {
age_class: ClaimAgeClass;
ownership_strength: ClaimOwnershipStrength;
yours: boolean;
goal: string | null;
check: string | null;
}

export interface HivemindLocalPheromoneTrail {
Expand Down Expand Up @@ -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,
};
Expand Down
175 changes: 95 additions & 80 deletions apps/mcp-server/src/tools/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading